diff --git a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift index fe30556..1ce7639 100644 --- a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift @@ -335,6 +335,62 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { try await live.currentUser() } + public func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult { + try await live.createAttachment(request) + } + + public func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws { + try await live.uploadAttachmentFile(result, fileData: fileData) + } + + public func deleteAttachment(id: String) async throws { + try await live.deleteAttachment(id: id) + } + + public func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser { + try await live.updateUserAvatar(request) + } + + public func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser { + try await live.updateUserName(request) + } + + public func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser { + try await live.updateUserLanguage(request) + } + + public func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser { + try await live.updateUserPreferences(request) + } + + public func deleteAccount() async throws { + try await live.deleteAccount() + } + + public func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser { + try await live.subscribeToNotifications(eventType: eventType) + } + + public func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser { + try await live.unsubscribeFromNotifications(eventType: eventType) + } + + public func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] { + try await live.listApiKeys(request) + } + + public func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey { + try await live.createApiKey(request) + } + + public func deleteApiKey(id: String) async throws { + try await live.deleteApiKey(id: id) + } + + public func installationInfo() async throws -> OutlineInstallationInfo { + try await live.installationInfo() + } + // MARK: - Sync management (Settings surface) public func pendingOperations() async -> [PendingOperationSummary] { diff --git a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift index c1b0eac..6ebf225 100644 --- a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift @@ -69,4 +69,40 @@ public protocol OutlineAPIClient: Sendable { func deleteStar(id: String) async throws func currentUser() async throws -> OutlineUser + + /// Two-step presigned upload: this requests where/how to upload, + /// `uploadAttachmentFile` performs the actual multipart POST to that + /// target. See `OutlineAttachment`/`CreateAttachmentResult`. + func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult + func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws + /// Best-effort — matches the shape every other simple `id`-only delete + /// in this API uses (`pins.delete`, `stars.delete`, …), not confirmed + /// against a live server specifically for attachments yet. + func deleteAttachment(id: String) async throws + /// `users.update`, avatar only. See `UpdateUserAvatarRequest`. + func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser + /// `users.update`, name only. See `UpdateUserNameRequest`. + func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser + /// `users.update`, language only. See `UpdateUserLanguageRequest`. + func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser + /// `users.update`, preferences only. See `UpdateUserPreferencesRequest`. + func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser + /// Backed by `users.delete` — self-service account deletion, no + /// confirmation code param confirmed live, matches every other simple + /// no-body delete in this API. + func deleteAccount() async throws + /// `nil` targets every notification event. See `NotificationEventType`. + func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser + func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser + + /// Settings → API & Access. + func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] + /// The returned `OutlineAPIKey.value` is the only time the full + /// plaintext key is ever available — the caller is responsible for + /// displaying it once and then discarding it. + func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey + func deleteApiKey(id: String) async throws + + /// Settings → Installation. Self-hosted server version info. + func installationInfo() async throws -> OutlineInstallationInfo } diff --git a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift index 13ca7e1..5caaad1 100644 --- a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift @@ -229,6 +229,103 @@ public actor LiveOutlineAPIClient: OutlineAPIClient { try await post("users.info", body: EmptyParams()) } + public func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult { + try await post("attachments.create", body: request) + } + + /// No Bearer/CSRF header attached here, deliberately — the presigned + /// `form.sig` field (short-lived, scoped to this exact upload key) is + /// what authorizes this specific request, the same way an S3 presigned + /// POST works. Best-effort against a live server: if it turns out the + /// self-hosted local-storage backend also wants a bearer token here, + /// that's a one-line addition once confirmed, not a design change. + public func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws { + let (body, contentType) = MultipartFormDataBuilder.build( + fields: result.form, + fileFieldName: "file", + fileName: result.attachment.name ?? "avatar", + fileData: fileData, + fileContentType: result.form["Content-Type"] ?? "application/octet-stream" + ) + + // `uploadUrl` is a host-relative path (e.g. "/api/files.create") on + // a self-hosted local-storage backend, not an absolute S3 URL — + // resolving against `baseURL` handles both: `URL(string:relativeTo:)` + // replaces the whole path for a leading-slash relative string per + // RFC 3986, same resolution already used for user/team avatar URLs. + guard let uploadURL = URL(string: result.uploadUrl, relativeTo: baseURL)?.absoluteURL else { + throw OutlineAPIError.transport(URLError(.badURL)) + } + + var request = URLRequest(url: uploadURL) + request.httpMethod = "POST" + request.setValue(contentType, forHTTPHeaderField: "Content-Type") + request.httpBody = body + + let data: Data + let response: HTTPURLResponse + do { + (data, response) = try await httpClient.send(request) + } catch let error as OutlineAPIError { + throw error + } catch { + throw OutlineAPIError.transport(error) + } + + guard (200...299).contains(response.statusCode) else { + let errorEnvelope = try? decoder.decode(OutlineErrorEnvelope.self, from: data) + throw OutlineAPIError.server(status: response.statusCode, message: errorEnvelope?.message ?? errorEnvelope?.error) + } + } + + public func deleteAttachment(id: String) async throws { + try await postForSuccess("attachments.delete", body: StarIDParams(id: id)) + } + + public func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser { + try await post("users.update", body: request) + } + + public func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser { + try await post("users.update", body: request) + } + + public func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser { + try await post("users.update", body: request) + } + + public func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser { + try await post("users.update", body: request) + } + + public func deleteAccount() async throws { + try await postForSuccess("users.delete", body: EmptyParams()) + } + + public func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser { + try await post("users.notificationsSubscribe", body: NotificationSubscriptionRequest(eventType: eventType?.rawValue)) + } + + public func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser { + try await post("users.notificationsUnsubscribe", body: NotificationSubscriptionRequest(eventType: eventType?.rawValue)) + } + + public func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] { + try await post("apiKeys.list", body: request) + } + + public func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey { + try await post("apiKeys.create", body: request) + } + + public func deleteApiKey(id: String) async throws { + try await postForSuccess("apiKeys.delete", body: StarIDParams(id: id)) + } + + public func installationInfo() async throws -> OutlineInstallationInfo { + try await post("installation.info", body: EmptyParams()) + } + private func post(_ path: String, body: Body) async throws -> Response { guard let token = try? tokenStore.token() else { throw OutlineAPIError.tokenUnavailable diff --git a/OutlineKit/Sources/OutlineKit/Models/NotificationEventType.swift b/OutlineKit/Sources/OutlineKit/Models/NotificationEventType.swift new file mode 100644 index 0000000..c226a1a --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/NotificationEventType.swift @@ -0,0 +1,28 @@ +import Foundation + +/// The subset of Outline's notification event types this app exposes a +/// toggle for. Wire values confirmed live (captured `users.update` +/// responses showing the full `notificationSettings` dictionary after +/// toggling each one in Outline's own web app). The server tracks more +/// event types than this app has UI for (`revisions.create`, +/// `emails.onboarding`, `emails.features` were also seen live) — those are +/// left alone since `users.notificationsSubscribe`/`Unsubscribe` are +/// per-event, not a whole-object replace like `preferences`, so there's no +/// clobbering risk in only covering a subset. +public enum NotificationEventType: String, CaseIterable, Sendable { + case documentPublish = "documents.publish" + case documentUpdate = "documents.update" + case commentCreate = "comments.create" + case commentMentioned = "comments.mentioned" + case documentMentioned = "documents.mentioned" + case commentGroupMentioned = "comments.group_mentioned" + case documentGroupMentioned = "documents.group_mentioned" + case commentResolve = "comments.resolve" + case reactionCreate = "reactions.create" + case collectionCreate = "collections.create" + case emailsInviteAccepted = "emails.invite_accepted" + case documentAddUser = "documents.add_user" + case collectionAddUser = "collections.add_user" + case emailsExportCompleted = "emails.export_completed" + case accessRequestCreate = "access_requests.create" +} diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineAPIKey.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineAPIKey.swift new file mode 100644 index 0000000..beaca80 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineAPIKey.swift @@ -0,0 +1,40 @@ +import Foundation + +/// A personal API key (Settings → API & Access). Only the last 4 characters +/// of the actual token are ever returned by the server — there's no way to +/// see a full key again after creation, matching every other API-key UI +/// convention. +public struct OutlineAPIKey: Codable, Identifiable, Hashable, Sendable { + public let id: String + public let name: String + public let last4: String? + public let scope: [String]? + public let createdAt: Date + public let expiresAt: Date? + public let lastActiveAt: Date? + /// The full plaintext key — present *only* in `apiKeys.create`'s + /// response, confirmed live: `apiKeys.list` never includes it, matching + /// "shown once at creation" being enforced server-side, not just a + /// client-side UI convention this app has to uphold on its own. + public let value: String? + + public init( + id: String, + name: String, + last4: String? = nil, + scope: [String]? = nil, + createdAt: Date, + expiresAt: Date? = nil, + lastActiveAt: Date? = nil, + value: String? = nil + ) { + self.id = id + self.name = name + self.last4 = last4 + self.scope = scope + self.createdAt = createdAt + self.expiresAt = expiresAt + self.lastActiveAt = lastActiveAt + self.value = value + } +} diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineAttachment.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineAttachment.swift new file mode 100644 index 0000000..0563c06 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineAttachment.swift @@ -0,0 +1,26 @@ +import Foundation + +/// One uploaded file — created via a two-step presigned upload +/// (`attachments.create` for the upload target, then a direct POST to +/// `uploadUrl`/`form`). Confirmed live against a self-hosted instance's +/// local-storage backend; `size` comes back as a string there, not a +/// number — same "don't trust the vendored spec's implied types" lesson as +/// everywhere else this codebase has hit it. +public struct OutlineAttachment: Decodable, Identifiable, Sendable { + public let id: String + public let documentId: String? + public let contentType: String? + public let name: String? + public let url: String? + public let size: String? +} + +/// `attachments.create`'s response — everything needed to perform the +/// actual upload. `form` fields (Content-Type, key, acl, sig, `_csrf`, …) +/// must all be included as their own multipart parts, in the same request +/// as the file itself, POSTed to `uploadUrl`. +public struct CreateAttachmentResult: Decodable, Sendable { + public let attachment: OutlineAttachment + public let uploadUrl: String + public let form: [String: String] +} diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineInstallationInfo.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineInstallationInfo.swift new file mode 100644 index 0000000..7980e2a --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineInstallationInfo.swift @@ -0,0 +1,11 @@ +import Foundation + +/// `installation.info` — the self-hosted server's own version, confirmed +/// live. `policies` (a separate top-level array alongside `data` in the raw +/// response) isn't modeled here — not used by this app's Installation +/// settings page. +public struct OutlineInstallationInfo: Decodable, Sendable { + public let version: String + public let latestVersion: String + public let versionsBehind: Int +} diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineUser.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineUser.swift index b85a6ca..25466c7 100644 --- a/OutlineKit/Sources/OutlineKit/Models/OutlineUser.swift +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineUser.swift @@ -6,18 +6,30 @@ public struct OutlineUser: Codable, Identifiable, Hashable, Sendable { public let email: String? public let avatarUrl: String? public let role: String? + public let language: String? + public let preferences: OutlineUserPreferences? + /// Keyed by `NotificationEventType`'s raw values, plus event types this + /// app has no UI for — kept as a flexible dictionary rather than a + /// fixed struct for that reason. `true` means subscribed. + public let notificationSettings: [String: Bool]? public init( id: String, name: String, email: String? = nil, avatarUrl: String? = nil, - role: String? = nil + role: String? = nil, + language: String? = nil, + preferences: OutlineUserPreferences? = nil, + notificationSettings: [String: Bool]? = nil ) { self.id = id self.name = name self.email = email self.avatarUrl = avatarUrl self.role = role + self.language = language + self.preferences = preferences + self.notificationSettings = notificationSettings } } diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineUserPreferences.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineUserPreferences.swift new file mode 100644 index 0000000..b5b2c63 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineUserPreferences.swift @@ -0,0 +1,109 @@ +import Foundation + +/// `User.preferences` — a free-form JSON blob on Outline's own `User` row, +/// not a fixed-shape API resource. Wire key names below are confirmed +/// against a live server's own web app traffic (captured toggling every +/// Preferences setting one at a time), not guessed — see the `CodingKeys` +/// mapping for the two that don't match this struct's own property names. +/// +/// The server also validates `preferences` against a known key allowlist +/// and rejects the whole `users.update` call (not just the bad field) if +/// any key it doesn't recognize is present — confirmed live via a +/// `"notificationBadge: Invalid Input"` error when an earlier, wrong value +/// was sent. That's also why `fullWidthDocuments` is kept here even though +/// this app has no UI for it yet: this app always sends the *whole* +/// preferences object back on every save (see +/// `UpdateUserPreferencesRequest`), so silently dropping an unknown key +/// during decode would permanently clear it the next time any other +/// preference here gets saved. +public struct OutlineUserPreferences: Codable, Hashable, Sendable { + public var rememberLastPath: Bool? + /// App-facing polarity: `true` means "separate editing mode is on" — + /// the opposite of the wire's own `seamlessEdit` (seamless editing and + /// separate editing modes are each other's negation), inverted in + /// `init(from:)`/`encode(to:)` so nothing outside this file has to + /// remember that. + public var separateEditing: Bool? + public var useCursorPointer: Bool? + public var codeBlockLineNumbers: Bool? + public var showCommentMarker: Bool? + public var smartText: Bool? + /// One of `NotificationBadgeStyle`'s raw values. + public var notificationBadge: String? + /// No UI in this app yet — preserved purely so saving any other + /// preference here doesn't clobber it. See the type doc comment. + public var fullWidthDocuments: Bool? + + public init( + rememberLastPath: Bool? = nil, + separateEditing: Bool? = nil, + useCursorPointer: Bool? = nil, + codeBlockLineNumbers: Bool? = nil, + showCommentMarker: Bool? = nil, + smartText: Bool? = nil, + notificationBadge: String? = nil, + fullWidthDocuments: Bool? = nil + ) { + self.rememberLastPath = rememberLastPath + self.separateEditing = separateEditing + self.useCursorPointer = useCursorPointer + self.codeBlockLineNumbers = codeBlockLineNumbers + self.showCommentMarker = showCommentMarker + self.smartText = smartText + self.notificationBadge = notificationBadge + self.fullWidthDocuments = fullWidthDocuments + } + + private enum CodingKeys: String, CodingKey { + case rememberLastPath + case seamlessEdit + case useCursorPointer + case codeBlockLineNumbers + case commentsInGutter + case enableSmartText + case notificationBadge + case fullWidthDocuments + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + rememberLastPath = try container.decodeIfPresent(Bool.self, forKey: .rememberLastPath) + separateEditing = try container.decodeIfPresent(Bool.self, forKey: .seamlessEdit).map { !$0 } + useCursorPointer = try container.decodeIfPresent(Bool.self, forKey: .useCursorPointer) + codeBlockLineNumbers = try container.decodeIfPresent(Bool.self, forKey: .codeBlockLineNumbers) + showCommentMarker = try container.decodeIfPresent(Bool.self, forKey: .commentsInGutter) + smartText = try container.decodeIfPresent(Bool.self, forKey: .enableSmartText) + notificationBadge = try container.decodeIfPresent(String.self, forKey: .notificationBadge) + fullWidthDocuments = try container.decodeIfPresent(Bool.self, forKey: .fullWidthDocuments) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(rememberLastPath, forKey: .rememberLastPath) + try container.encodeIfPresent(separateEditing.map { !$0 }, forKey: .seamlessEdit) + try container.encodeIfPresent(useCursorPointer, forKey: .useCursorPointer) + try container.encodeIfPresent(codeBlockLineNumbers, forKey: .codeBlockLineNumbers) + try container.encodeIfPresent(showCommentMarker, forKey: .commentsInGutter) + try container.encodeIfPresent(smartText, forKey: .enableSmartText) + try container.encodeIfPresent(notificationBadge, forKey: .notificationBadge) + try container.encodeIfPresent(fullWidthDocuments, forKey: .fullWidthDocuments) + } +} + +/// App-icon unread indicator style. Wire values confirmed live (captured +/// setting all three from Outline's own web app). +public enum NotificationBadgeStyle: String, CaseIterable, Identifiable, Sendable { + case none = "disabled" + case unreadIndicator = "indicator" + case unreadCount = "count" + + public var id: String { rawValue } + + public var label: String { + switch self { + case .none: return "None" + case .unreadIndicator: return "Unread Indicator" + case .unreadCount: return "Unread Count" + } + } +} diff --git a/OutlineKit/Sources/OutlineKit/Networking/MultipartFormDataBuilder.swift b/OutlineKit/Sources/OutlineKit/Networking/MultipartFormDataBuilder.swift new file mode 100644 index 0000000..f6e7afc --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Networking/MultipartFormDataBuilder.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Builds the multipart/form-data body for Outline's presigned-upload +/// targets (`attachments.create`'s `uploadUrl`/`form`) — an S3-style +/// presigned POST: every `form` field has to ride along as its own part in +/// the same request as the file, not as query params or headers. +enum MultipartFormDataBuilder { + static func build( + fields: [String: String], + fileFieldName: String, + fileName: String, + fileData: Data, + fileContentType: String, + boundary: String = "Boundary-\(UUID().uuidString)" + ) -> (body: Data, contentType: String) { + var body = Data() + + for (key, value) in fields { + body.append("--\(boundary)\r\n".utf8Data) + body.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".utf8Data) + body.append(value.utf8Data) + body.append("\r\n".utf8Data) + } + + body.append("--\(boundary)\r\n".utf8Data) + body.append( + "Content-Disposition: form-data; name=\"\(fileFieldName)\"; filename=\"\(fileName)\"\r\n".utf8Data + ) + body.append("Content-Type: \(fileContentType)\r\n\r\n".utf8Data) + body.append(fileData) + body.append("\r\n".utf8Data) + body.append("--\(boundary)--\r\n".utf8Data) + + return (body, "multipart/form-data; boundary=\(boundary)") + } +} + +private extension String { + var utf8Data: Data { Data(utf8) } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/CreateApiKeyRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/CreateApiKeyRequest.swift new file mode 100644 index 0000000..33fe4b4 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/CreateApiKeyRequest.swift @@ -0,0 +1,21 @@ +import Foundation + +/// `apiKeys.create`. `expiresAt: nil` (the key omitted entirely, not sent as +/// literal `null`) confirmed live to mean no expiration — Swift's +/// synthesized `Encodable` already omits `nil` optionals via +/// `encodeIfPresent`, so no custom `encode(to:)` is needed here the way +/// `UpdateUserAvatarRequest` needed one for the opposite case. +public struct CreateApiKeyRequest: Encodable, Sendable { + public let name: String + public let expiresAt: Date? + /// `nil`/omitted grants full access — confirmed live (every key created + /// without a scope came back with unrestricted access). A specific + /// scope is a list of allowed API paths, e.g. `["/api/documents.info"]`. + public let scope: [String]? + + public init(name: String, expiresAt: Date? = nil, scope: [String]? = nil) { + self.name = name + self.expiresAt = expiresAt + self.scope = scope + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/CreateAttachmentRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/CreateAttachmentRequest.swift new file mode 100644 index 0000000..8189b57 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/CreateAttachmentRequest.swift @@ -0,0 +1,19 @@ +import Foundation + +/// Requests an upload target for a new file — not the upload itself, see +/// `OutlineAPIClient.uploadAttachmentFile`. `documentId: nil` is what a +/// user-avatar upload sends (confirmed live); a real value scopes the +/// attachment to a document instead (e.g. an inline image embed). +public struct CreateAttachmentRequest: Encodable, Sendable { + public let name: String + public let contentType: String + public let size: Int + public let documentId: String? + + public init(name: String, contentType: String, size: Int, documentId: String? = nil) { + self.name = name + self.contentType = contentType + self.size = size + self.documentId = documentId + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/ListApiKeysRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/ListApiKeysRequest.swift new file mode 100644 index 0000000..239fedd --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/ListApiKeysRequest.swift @@ -0,0 +1,14 @@ +import Foundation + +/// `apiKeys.list` — matches every other paginated `.list` endpoint's flat +/// offset/limit convention (e.g. `ListSharesRequest`), not the nested +/// `pagination` object that only appears in list *responses*. +public struct ListApiKeysRequest: Encodable, Sendable { + public let offset: Int + public let limit: Int + + public init(offset: Int = 0, limit: Int = 25) { + self.offset = offset + self.limit = limit + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/NotificationSubscriptionRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/NotificationSubscriptionRequest.swift new file mode 100644 index 0000000..cd1aace --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/NotificationSubscriptionRequest.swift @@ -0,0 +1,13 @@ +import Foundation + +/// `users.notificationsSubscribe` / `users.notificationsUnsubscribe`. A +/// `nil` `eventType` targets every notification event at once — confirmed +/// live via Outline's own "All notifications" master toggle, which sends +/// no `eventType` at all. +public struct NotificationSubscriptionRequest: Encodable, Sendable { + public let eventType: String? + + public init(eventType: String? = nil) { + self.eventType = eventType + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/UpdateUserAvatarRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/UpdateUserAvatarRequest.swift new file mode 100644 index 0000000..f889ec9 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/UpdateUserAvatarRequest.swift @@ -0,0 +1,28 @@ +import Foundation + +/// `users.update`, scoped to just the avatar. Custom `encode(to:)` because +/// Swift's synthesized `Encodable` uses `encodeIfPresent` for `Optional` +/// properties, which *omits* the key entirely when the value is `nil` — +/// removing the avatar needs a literal `"avatarUrl": null` in the request +/// body, not the key missing. `id` is required: confirmed live (the +/// captured request body's length only matches `{"id": "...", "avatarUrl": +/// ...}`, not a shorter shape without it). +public struct UpdateUserAvatarRequest: Encodable, Sendable { + public let id: String + public let avatarUrl: String? + + public init(id: String, avatarUrl: String?) { + self.id = id + self.avatarUrl = avatarUrl + } + + private enum CodingKeys: String, CodingKey { + case id, avatarUrl + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(avatarUrl, forKey: .avatarUrl) + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/UpdateUserLanguageRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/UpdateUserLanguageRequest.swift new file mode 100644 index 0000000..865b677 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/UpdateUserLanguageRequest.swift @@ -0,0 +1,12 @@ +import Foundation + +/// `users.update`, language only. +public struct UpdateUserLanguageRequest: Encodable, Sendable { + public let id: String + public let language: String + + public init(id: String, language: String) { + self.id = id + self.language = language + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/UpdateUserNameRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/UpdateUserNameRequest.swift new file mode 100644 index 0000000..5857409 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/UpdateUserNameRequest.swift @@ -0,0 +1,12 @@ +import Foundation + +/// `users.update`, name only. +public struct UpdateUserNameRequest: Encodable, Sendable { + public let id: String + public let name: String + + public init(id: String, name: String) { + self.id = id + self.name = name + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/UpdateUserPreferencesRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/UpdateUserPreferencesRequest.swift new file mode 100644 index 0000000..d0521c6 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/UpdateUserPreferencesRequest.swift @@ -0,0 +1,14 @@ +import Foundation + +/// `users.update`, preferences only. Sends the *whole* preferences object +/// back (not a single changed key) — avoids needing to know whether the +/// server deep-merges a partial `preferences` body or replaces it outright. +public struct UpdateUserPreferencesRequest: Encodable, Sendable { + public let id: String + public let preferences: OutlineUserPreferences + + public init(id: String, preferences: OutlineUserPreferences) { + self.id = id + self.preferences = preferences + } +} diff --git a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift index ffeb429..e14af0f 100644 --- a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift @@ -89,6 +89,20 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable func listStars(_ request: ListStarsRequest) async throws -> [OutlineStar] { throw NotStubbed() } func deleteStar(id: String) async throws { throw NotStubbed() } func currentUser() async throws -> OutlineUser { throw NotStubbed() } + func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult { throw NotStubbed() } + func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws { throw NotStubbed() } + func deleteAttachment(id: String) async throws { throw NotStubbed() } + func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser { throw NotStubbed() } + func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser { throw NotStubbed() } + func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser { throw NotStubbed() } + func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser { throw NotStubbed() } + func deleteAccount() async throws { throw NotStubbed() } + func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser { throw NotStubbed() } + func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser { throw NotStubbed() } + func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] { throw NotStubbed() } + func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey { throw NotStubbed() } + func deleteApiKey(id: String) async throws { throw NotStubbed() } + func installationInfo() async throws -> OutlineInstallationInfo { throw NotStubbed() } } private struct StubTransportError: Error {} diff --git a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift index 39cb57d..bfee9c3 100644 --- a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift @@ -993,6 +993,490 @@ final class LiveOutlineAPIClientTests: XCTestCase { XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.list") } + func testUpdateUserAvatarSendsExplicitNullWhenRemoving() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": { + "id": "user-1", + "name": "Jane Doe", + "avatarUrl": null + } + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let user = try await client.updateUserAvatar(UpdateUserAvatarRequest(id: "user-1", avatarUrl: nil)) + + XCTAssertNil(user.avatarUrl) + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.update") + let sentBody = try XCTUnwrap(httpClient.lastRequest?.httpBody) + let sentJSON = try XCTUnwrap(String(data: sentBody, encoding: .utf8)) + // The whole point of UpdateUserAvatarRequest's custom encode(to:) — + // Swift's synthesized Encodable would have omitted the key entirely + // for a nil Optional instead of sending a literal null. + XCTAssertTrue(sentJSON.contains("\"avatarUrl\":null"), "expected an explicit null, got: \(sentJSON)") + } + + func testUpdateUserAvatarSendsTheNewURLWhenSet() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": { + "id": "user-1", + "name": "Jane Doe", + "avatarUrl": "/api/attachments.redirect?id=abc" + } + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let user = try await client.updateUserAvatar( + UpdateUserAvatarRequest(id: "user-1", avatarUrl: "/api/attachments.redirect?id=abc") + ) + + XCTAssertEqual(user.avatarUrl, "/api/attachments.redirect?id=abc") + let sentBody = try XCTUnwrap(httpClient.lastRequest?.httpBody) + let sentJSON = try XCTUnwrap(String(data: sentBody, encoding: .utf8)) + XCTAssertTrue(sentJSON.contains("\"id\":\"user-1\"")) + } + + func testUpdateUserNameSendsRequestAndDecodesResult() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": { + "id": "user-1", + "name": "New Name" + } + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let user = try await client.updateUserName(UpdateUserNameRequest(id: "user-1", name: "New Name")) + + XCTAssertEqual(user.name, "New Name") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.update") + } + + func testUpdateUserLanguageSendsRequestAndDecodesResult() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": { + "id": "user-1", + "name": "Jane Doe", + "language": "fr_FR" + } + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let user = try await client.updateUserLanguage(UpdateUserLanguageRequest(id: "user-1", language: "fr_FR")) + + XCTAssertEqual(user.language, "fr_FR") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.update") + } + + func testUpdateUserPreferencesSendsWholeObjectAndDecodesResult() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": { + "id": "user-1", + "name": "Jane Doe", + "preferences": { "rememberLastPath": true, "useCursorPointer": true } + } + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let preferences = OutlineUserPreferences(rememberLastPath: true, useCursorPointer: true) + let user = try await client.updateUserPreferences(UpdateUserPreferencesRequest(id: "user-1", preferences: preferences)) + + XCTAssertEqual(user.preferences?.rememberLastPath, true) + XCTAssertEqual(user.preferences?.useCursorPointer, true) + + let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any] + let sentPreferences = sentBody?["preferences"] as? [String: Any] + XCTAssertEqual(sentPreferences?["rememberLastPath"] as? Bool, true) + XCTAssertEqual(sentPreferences?["useCursorPointer"] as? Bool, true) + } + + /// Locks in the wire mapping confirmed against a live server's own web + /// app traffic: `seamlessEdit`/`commentsInGutter`/`enableSmartText` are + /// the real keys (not `separateEditing`/`showCommentMarker`/`smartText` + /// this struct exposes), and `seamlessEdit` is the *negation* of this + /// app's `separateEditing`. + func testOutlineUserPreferencesDecodesRealWireKeys() throws { + let json = """ + { + "seamlessEdit": false, + "commentsInGutter": true, + "enableSmartText": true, + "rememberLastPath": true, + "useCursorPointer": true, + "codeBlockLineNumbers": false, + "notificationBadge": "indicator", + "fullWidthDocuments": true + } + """.data(using: .utf8)! + + let preferences = try JSONDecoder().decode(OutlineUserPreferences.self, from: json) + + XCTAssertEqual(preferences.separateEditing, true, "seamlessEdit: false means separate editing is ON") + XCTAssertEqual(preferences.showCommentMarker, true) + XCTAssertEqual(preferences.smartText, true) + XCTAssertEqual(preferences.rememberLastPath, true) + XCTAssertEqual(preferences.useCursorPointer, true) + XCTAssertEqual(preferences.codeBlockLineNumbers, false) + XCTAssertEqual(preferences.notificationBadge, "indicator") + XCTAssertEqual(preferences.fullWidthDocuments, true) + } + + func testOutlineUserPreferencesEncodesRealWireKeysAndInvertsSeparateEditing() throws { + var preferences = OutlineUserPreferences() + preferences.separateEditing = true + preferences.showCommentMarker = false + preferences.smartText = true + preferences.fullWidthDocuments = true + + let data = try JSONEncoder().encode(preferences) + let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] + + XCTAssertEqual(object?["seamlessEdit"] as? Bool, false, "separateEditing: true must encode as seamlessEdit: false") + XCTAssertEqual(object?["commentsInGutter"] as? Bool, false) + XCTAssertEqual(object?["enableSmartText"] as? Bool, true) + XCTAssertEqual(object?["fullWidthDocuments"] as? Bool, true) + XCTAssertNil(object?["separateEditing"], "must not leak this app's own field name onto the wire") + XCTAssertNil(object?["showCommentMarker"]) + XCTAssertNil(object?["smartText"]) + } + + func testSubscribeToNotificationsSendsEventTypeAndDecodesResult() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": { + "id": "user-1", + "name": "Jane Doe", + "notificationSettings": { "documents.publish": true } + } + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let user = try await client.subscribeToNotifications(eventType: .documentPublish) + + XCTAssertEqual(user.notificationSettings?["documents.publish"], true) + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.notificationsSubscribe") + let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any] + XCTAssertEqual(sentBody?["eventType"] as? String, "documents.publish") + } + + func testUnsubscribeFromNotificationsWithNilEventTypeTargetsAll() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": { + "id": "user-1", + "name": "Jane Doe", + "notificationSettings": { "documents.publish": false } + } + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + _ = try await client.unsubscribeFromNotifications(eventType: nil) + + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.notificationsUnsubscribe") + let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any] + XCTAssertNil(sentBody?["eventType"]) + } + + func testListApiKeysDecodesResult() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "pagination": { "limit": 25, "offset": 0 }, + "data": [ + { + "id": "c3eec545-6d38-4065-90dc-b6c96a551445", + "name": "Outpost", + "scope": null, + "last4": "dl1h", + "createdAt": "2026-08-12T18:44:32.467Z", + "updatedAt": "2026-08-12T18:44:32.467Z", + "expiresAt": null, + "lastActiveAt": "2026-08-18T01:01:36.553Z" + } + ] + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let keys = try await client.listApiKeys(ListApiKeysRequest()) + + XCTAssertEqual(keys.count, 1) + XCTAssertEqual(keys.first?.name, "Outpost") + XCTAssertEqual(keys.first?.last4, "dl1h") + XCTAssertNil(keys.first?.expiresAt) + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/apiKeys.list") + } + + func testInstallationInfoDecodesResult() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": { "version": "1.9.2", "latestVersion": "1.9.2", "versionsBehind": 0 }, + "policies": [] + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let info = try await client.installationInfo() + + XCTAssertEqual(info.version, "1.9.2") + XCTAssertEqual(info.versionsBehind, 0) + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/installation.info") + } + + func testCreateApiKeyOmitsExpiresAtWhenNilAndDecodesValue() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": { + "id": "5655fb3e-2a8c-4f2c-9cae-bbd910ef8683", + "name": "test", + "scope": null, + "value": "ol_api_Xqx9Jti7xUunb5b8bXh29vHmBngqjJl3Id0DGv", + "last4": "0DGv", + "createdAt": "2026-08-18T15:39:29.872Z", + "expiresAt": null, + "lastActiveAt": null + } + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let key = try await client.createApiKey(CreateApiKeyRequest(name: "test")) + + XCTAssertEqual(key.value, "ol_api_Xqx9Jti7xUunb5b8bXh29vHmBngqjJl3Id0DGv") + XCTAssertNil(key.expiresAt) + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/apiKeys.create") + let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any] + XCTAssertEqual(sentBody?["name"] as? String, "test") + XCTAssertNil(sentBody?["expiresAt"], "omitting expiresAt (not sending null) is what produces a non-expiring key") + XCTAssertNil(sentBody?["scope"]) + } + + func testCreateApiKeySendsExpiresAtWhenProvided() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": { + "id": "04e204fb-51a4-4b54-aed1-2056dfd576d7", + "name": "Test", + "scope": null, + "value": "ol_api_aeYcOts7I2sJXw3zaztjydRM3W3W89TBAtcLts", + "last4": "cLts", + "createdAt": "2026-08-18T15:38:22.928Z", + "expiresAt": "2026-11-16T23:59:59.999Z", + "lastActiveAt": null + } + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let expiresAt = Date(timeIntervalSince1970: 1_795_000_000) + _ = try await client.createApiKey(CreateApiKeyRequest(name: "Test", expiresAt: expiresAt)) + + let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any] + XCTAssertNotNil(sentBody?["expiresAt"]) + } + + func testDeleteApiKeySendsRequest() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { "success": true } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + try await client.deleteApiKey(id: "5655fb3e-2a8c-4f2c-9cae-bbd910ef8683") + + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/apiKeys.delete") + let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any] + XCTAssertEqual(sentBody?["id"] as? String, "5655fb3e-2a8c-4f2c-9cae-bbd910ef8683") + } + + func testDeleteAccountSendsRequest() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { "success": true } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + try await client.deleteAccount() + + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.delete") + } + + func testDeleteAttachmentSendsRequest() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { "success": true } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + try await client.deleteAttachment(id: "attach-1") + + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/attachments.delete") + } + + func testCreateAttachmentDecodesUploadTargetAndFormFields() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": { + "attachment": { + "id": "attach-1", + "documentId": null, + "contentType": "image/jpeg", + "name": "avatar.jpg", + "url": "/api/attachments.redirect?id=attach-1", + "size": "59304" + }, + "uploadUrl": "/api/files.create", + "form": { + "Content-Type": "image/jpeg", + "key": "uploads/user-1/attach-1/avatar.jpg", + "acl": "public-read" + } + } + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let result = try await client.createAttachment( + CreateAttachmentRequest(name: "avatar.jpg", contentType: "image/jpeg", size: 59304) + ) + + XCTAssertEqual(result.attachment.id, "attach-1") + XCTAssertEqual(result.attachment.size, "59304") + XCTAssertEqual(result.uploadUrl, "/api/files.create") + XCTAssertEqual(result.form["acl"], "public-read") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/attachments.create") + } + + func testUploadAttachmentFilePostsToTheResolvedRelativeURL() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { "success": true } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let uploadTarget = CreateAttachmentResult( + attachment: OutlineAttachment( + id: "attach-1", + documentId: nil, + contentType: "image/jpeg", + name: "avatar.jpg", + url: "/api/attachments.redirect?id=attach-1", + size: "3" + ), + uploadUrl: "/api/files.create", + form: ["Content-Type": "image/jpeg", "key": "uploads/attach-1"] + ) + + try await client.uploadAttachmentFile(uploadTarget, fileData: Data("abc".utf8)) + + // Resolved against baseURL's host, not appended onto "/api/". + XCTAssertEqual(httpClient.lastRequest?.url?.absoluteString, "https://outline.example.com/api/files.create") + XCTAssertNil(httpClient.lastRequest?.value(forHTTPHeaderField: "Authorization")) + let contentType = httpClient.lastRequest?.value(forHTTPHeaderField: "Content-Type") + XCTAssertTrue(contentType?.hasPrefix("multipart/form-data; boundary=") ?? false) + } + func testMissingTokenThrowsTokenUnavailable() async throws { let httpClient = MockHTTPClient() let client = LiveOutlineAPIClient( diff --git a/OutlineKit/Tests/OutlineKitTests/MultipartFormDataBuilderTests.swift b/OutlineKit/Tests/OutlineKitTests/MultipartFormDataBuilderTests.swift new file mode 100644 index 0000000..dd5bbc3 --- /dev/null +++ b/OutlineKit/Tests/OutlineKitTests/MultipartFormDataBuilderTests.swift @@ -0,0 +1,48 @@ +import XCTest +@testable import OutlineKit + +final class MultipartFormDataBuilderTests: XCTestCase { + func testBuildIncludesEveryFieldAndTheFile() throws { + let fileData = Data("fake-jpeg-bytes".utf8) + let (body, contentType) = MultipartFormDataBuilder.build( + fields: ["key": "uploads/abc", "acl": "public-read", "Content-Type": "image/jpeg"], + fileFieldName: "file", + fileName: "avatar.jpg", + fileData: fileData, + fileContentType: "image/jpeg", + boundary: "TestBoundary" + ) + + let bodyString = String(decoding: body, as: UTF8.self) + + XCTAssertEqual(contentType, "multipart/form-data; boundary=TestBoundary") + XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"key\"")) + XCTAssertTrue(bodyString.contains("uploads/abc")) + XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"acl\"")) + XCTAssertTrue(bodyString.contains("public-read")) + XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"file\"; filename=\"avatar.jpg\"")) + XCTAssertTrue(bodyString.contains("fake-jpeg-bytes")) + XCTAssertTrue(bodyString.hasPrefix("--TestBoundary\r\n")) + XCTAssertTrue(bodyString.hasSuffix("--TestBoundary--\r\n")) + } + + func testFileFieldComesAfterAllFormFields() throws { + let (body, _) = MultipartFormDataBuilder.build( + fields: ["a": "1", "b": "2"], + fileFieldName: "file", + fileName: "x.jpg", + fileData: Data("bytes".utf8), + fileContentType: "image/jpeg", + boundary: "B" + ) + let bodyString = String(decoding: body, as: UTF8.self) + + let fieldsRange = bodyString.range(of: "name=\"a\"") + let fileRange = bodyString.range(of: "name=\"file\"") + XCTAssertNotNil(fieldsRange) + XCTAssertNotNil(fileRange) + if let fieldsRange, let fileRange { + XCTAssertTrue(fieldsRange.lowerBound < fileRange.lowerBound) + } + } +} diff --git a/Outpost.xcodeproj/project.pbxproj b/Outpost.xcodeproj/project.pbxproj index f4596c8..2b7732c 100644 --- a/Outpost.xcodeproj/project.pbxproj +++ b/Outpost.xcodeproj/project.pbxproj @@ -299,7 +299,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; - DEVELOPMENT_TEAM = B95H74ZDY6; + DEVELOPMENT_TEAM = CW6GQT9SK5; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -361,7 +361,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = B95H74ZDY6; + DEVELOPMENT_TEAM = CW6GQT9SK5; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -385,15 +385,20 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_ENTITLEMENTS = Outpost/Outpost.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = B95H74ZDY6; + DEVELOPMENT_TEAM = CW6GQT9SK5; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = Outpost; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; @@ -408,9 +413,10 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 27.0; - MARKETING_VERSION = 0.0.3; - PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost; + MARKETING_VERSION = 0.0.4; + PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; REGISTER_APP_GROUPS = YES; SDKROOT = auto; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -430,15 +436,20 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_ENTITLEMENTS = Outpost/Outpost.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = B95H74ZDY6; + DEVELOPMENT_TEAM = CW6GQT9SK5; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = Outpost; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; @@ -453,9 +464,10 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 27.0; - MARKETING_VERSION = 0.0.3; - PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost; + MARKETING_VERSION = 0.0.4; + PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; REGISTER_APP_GROUPS = YES; SDKROOT = auto; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -476,7 +488,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = B95H74ZDY6; + DEVELOPMENT_TEAM = CW6GQT9SK5; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 27.0; MACOSX_DEPLOYMENT_TARGET = 27.0; @@ -502,7 +514,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = B95H74ZDY6; + DEVELOPMENT_TEAM = CW6GQT9SK5; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 27.0; MACOSX_DEPLOYMENT_TARGET = 27.0; @@ -527,7 +539,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = B95H74ZDY6; + DEVELOPMENT_TEAM = CW6GQT9SK5; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 27.0; MACOSX_DEPLOYMENT_TARGET = 27.0; @@ -552,7 +564,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = B95H74ZDY6; + DEVELOPMENT_TEAM = CW6GQT9SK5; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 27.0; MACOSX_DEPLOYMENT_TARGET = 27.0; diff --git a/Outpost/Assets.xcassets/AppLogo.imageset/Contents.json b/Outpost/Assets.xcassets/AppLogo.imageset/Contents.json new file mode 100644 index 0000000..69a9f98 --- /dev/null +++ b/Outpost/Assets.xcassets/AppLogo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "outpost-ios-1024.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Outpost/Assets.xcassets/AppLogo.imageset/outpost-ios-1024.png b/Outpost/Assets.xcassets/AppLogo.imageset/outpost-ios-1024.png new file mode 100644 index 0000000..b1fcf6b Binary files /dev/null and b/Outpost/Assets.xcassets/AppLogo.imageset/outpost-ios-1024.png differ diff --git a/Outpost/Features/About/AboutView.swift b/Outpost/Features/About/AboutView.swift index 6f4c53b..93b5afd 100644 --- a/Outpost/Features/About/AboutView.swift +++ b/Outpost/Features/About/AboutView.swift @@ -14,17 +14,7 @@ struct AboutInfoView: View { Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "Outpost" } - /// Bumped alongside `MARKETING_VERSION` in the Xcode project — kept out - /// of the bundle version itself since `CFBundleShortVersionString` is - /// expected to stay a plain dotted-numeric string, not `0.0.1-ALPHA`. - private let releaseStage = "ALPHA" - - var versionString: String { - let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.1" - let buildNumber = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1" - let stageSuffix = releaseStage.isEmpty ? "" : "-\(releaseStage)" - return "Version \(shortVersion)\(stageSuffix) (\(buildNumber))" - } + var versionString: String { OutpostVersion.fullVersionString } private var copyrightYear: String { String(Calendar.current.component(.year, from: Date())) diff --git a/Outpost/Features/Account/AvatarCropperView.swift b/Outpost/Features/Account/AvatarCropperView.swift new file mode 100644 index 0000000..a66e20f --- /dev/null +++ b/Outpost/Features/Account/AvatarCropperView.swift @@ -0,0 +1,114 @@ +#if os(macOS) +import AppKit +import SwiftUI + +/// Crop/rotate/zoom editor shown after picking a photo, before it's +/// uploaded — pan (drag), zoom (pinch or the slider), and 90°-increment +/// rotate, all inside a circular mask matching how the avatar actually +/// renders everywhere else in the app. +/// +/// The on-screen preview and the final exported image are built from the +/// exact same view composition (`avatarContent`), just instantiated once +/// for display and once inside an `ImageRenderer` — that's deliberate: +/// hand-deriving a separate set of crop-math for a higher-resolution +/// render would risk it silently disagreeing with what the user actually +/// saw and confirmed, and there's no way to visually verify that +/// agreement without running the app. Reusing the identical view tree +/// makes the export WYSIWYG by construction instead of by careful math. +struct AvatarCropperView: View { + let sourceImage: NSImage + let onConfirm: (Data) -> Void + let onCancel: () -> Void + + @State private var scale: CGFloat = 1 + @State private var offset: CGSize = .zero + @State private var rotationDegrees: Double = 0 + @GestureState private var dragTranslation: CGSize = .zero + + /// Used for both the live preview and the exported image — see the + /// type-level doc comment for why that's the same size, not two. + private let diameter: CGFloat = 320 + + var body: some View { + VStack(spacing: 20) { + Text("Edit Photo") + .font(.headline) + + ZStack { + avatarContent + .clipShape(Circle()) + Circle() + .strokeBorder(Color.primary.opacity(0.15), lineWidth: 1) + } + .frame(width: diameter, height: diameter) + .contentShape(Circle()) + .gesture( + DragGesture() + .updating($dragTranslation) { value, state, _ in state = value.translation } + .onEnded { value in + offset.width += value.translation.width + offset.height += value.translation.height + } + ) + + HStack(spacing: 16) { + Button { + withAnimation(.easeInOut(duration: 0.2)) { rotationDegrees -= 90 } + } label: { + Image(systemName: "rotate.left") + } + .help("Rotate left") + + Slider(value: $scale, in: 1...4) + .frame(width: 140) + + Button { + withAnimation(.easeInOut(duration: 0.2)) { rotationDegrees += 90 } + } label: { + Image(systemName: "rotate.right") + } + .help("Rotate right") + } + + HStack { + Button("Cancel", role: .cancel, action: onCancel) + Spacer() + Button("Use Photo") { + if let data = renderFinalImage() { + onConfirm(data) + } + } + .buttonStyle(.borderedProminent) + } + } + .padding(24) + .frame(width: 360) + } + + /// Aspect-fills `sourceImage` into a `diameter`×`diameter` square, then + /// applies the user's pan/zoom/rotation on top — identical between the + /// live preview and the final render (see the type-level doc comment). + private var avatarContent: some View { + Image(nsImage: sourceImage) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: diameter, height: diameter) + .scaleEffect(scale) + .rotationEffect(.degrees(rotationDegrees)) + .offset(x: offset.width + dragTranslation.width, y: offset.height + dragTranslation.height) + .frame(width: diameter, height: diameter) + .clipped() + } + + @MainActor + private func renderFinalImage() -> Data? { + let content = avatarContent + .clipShape(Circle()) + .frame(width: diameter, height: diameter) + let renderer = ImageRenderer(content: content) + renderer.scale = 2 // @2x so it isn't a blurry 320px avatar on Retina displays + guard let nsImage = renderer.nsImage else { return nil } + return nsImage.jpegData(compressionQuality: 0.9) + } +} +#endif diff --git a/Outpost/Features/Account/SettingsSidebarList.swift b/Outpost/Features/Account/SettingsSidebarList.swift index bb0f99c..9f86f5d 100644 --- a/Outpost/Features/Account/SettingsSidebarList.swift +++ b/Outpost/Features/Account/SettingsSidebarList.swift @@ -1,15 +1,34 @@ #if os(macOS) import SwiftUI +import OutlineKit /// Swapped into the real sidebar's content slot (search field, collections /// tree, account footer) while Settings is open — same sidebar, different /// content, rather than a separate mini sidebar nested inside a page. "Done" /// clears `AppNavigation.isShowingSettings`, which puts the collections tree /// back. +/// +/// Grouped by `SettingsCategory` — `general` (ours) sits under an "Outpost" +/// header at the top, then Outline's own Account/Workspace groups, matching +/// the settings page structure of the Outline web app. Outline's own server +/// version has no dedicated section (there used to be an Integrations & +/// Installation category for just that) — it's cheap enough to show +/// unconditionally in the footer here instead, alongside Outpost's own +/// version. struct SettingsSidebarList: View { @Binding var selection: SettingsSection? let onDone: () -> Void + @Environment(SessionStore.self) private var session + @AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false + @State private var outlineVersion: String? + + /// Mirrors `SettingsView`'s own check — a real dropped connection or + /// the manual Offline Mode toggle both mean there's no server to ask. + private var isEffectivelyOnline: Bool { + session.networkMonitor.isOnline && !isOfflineModeEnabled + } + var body: some View { VStack(spacing: 0) { HStack { @@ -22,20 +41,75 @@ struct SettingsSidebarList: View { Divider() - List(SettingsSection.allCases, selection: $selection) { section in - Label(section.title, systemImage: section.icon) - .tag(section) + List(selection: $selection) { + ForEach(SettingsCategory.allCases) { category in + let sections = SettingsSection.allCases.filter { $0.category == category } + Section { + ForEach(sections) { section in + Label(section.title, systemImage: section.icon) + .tag(section) + } + } header: { + if let title = category.title { + HStack(spacing: 6) { + Text(title) + // Not hardcoded to `.workspace` specifically — + // stays correct on its own as sections get + // built, only shows while every section in + // the category is still `!isImplemented`. + if sections.allSatisfy({ !$0.isImplemented }) { + Text("Coming Soon") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.secondary.opacity(0.15), in: Capsule()) + } + } + } + } + } } .listStyle(.sidebar) Divider() + versionFooter + + Divider() + Button("Done", action: onDone) .keyboardShortcut(.cancelAction) .buttonStyle(.borderedProminent) .frame(maxWidth: .infinity) .padding(12) } + // Keyed to connectivity, not a one-shot `.task {}` — reconnecting + // (or turning the manual Offline Mode toggle back off) re-fires + // this automatically instead of leaving the footer stuck on + // whatever it last knew, or blank, until Settings is reopened. + .task(id: isEffectivelyOnline) { await refreshOutlineVersion() } + } + + private var versionFooter: some View { + VStack(alignment: .leading, spacing: 2) { + Text("Outpost \(OutpostVersion.displayString)") + if let outlineVersion { + Text("Outline \(outlineVersion)") + } else if !isEffectivelyOnline { + Text("Outline — offline") + } + } + .font(.caption2) + .foregroundStyle(.tertiary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + + private func refreshOutlineVersion() async { + guard isEffectivelyOnline, let apiClient = session.apiClient else { return } + outlineVersion = try? await apiClient.installationInfo().version } } #endif diff --git a/Outpost/Features/Account/SettingsView.swift b/Outpost/Features/Account/SettingsView.swift index d62b4f1..f12443f 100644 --- a/Outpost/Features/Account/SettingsView.swift +++ b/Outpost/Features/Account/SettingsView.swift @@ -1,5 +1,7 @@ #if os(macOS) +import AppKit import SwiftUI +import UniformTypeIdentifiers import OutlineKit /// Settings *detail* content for one section — the section list itself now @@ -24,6 +26,34 @@ struct SettingsView: View { @State private var didClearCache = false @State private var lastFullSyncSummary: FullSyncSummary? @State private var lastFlushSummary: SyncFlushSummary? + @State private var pickedPhoto: PickedPhoto? + @State private var isUploadingAvatar = false + @State private var avatarErrorMessage: String? + @State private var editableName = "" + @State private var isSavingName = false + @State private var nameErrorMessage: String? + @State private var isSavingLanguage = false + @State private var languageErrorMessage: String? + @State private var isSavingPreferences = false + @State private var preferencesErrorMessage: String? + @State private var isShowingDeleteAccountConfirmation = false + @State private var isDeletingAccount = false + @State private var deleteAccountErrorMessage: String? + @State private var isSavingNotifications = false + @State private var notificationsErrorMessage: String? + @State private var apiKeys: [OutlineAPIKey] = [] + @State private var isLoadingApiKeys = false + @State private var apiKeysErrorMessage: String? + @State private var isShowingCreateApiKey = false + @State private var newApiKeyName = "" + @State private var newApiKeyExpiration: ApiKeyExpiration = .noExpiration + @State private var isCreatingApiKey = false + @State private var createApiKeyErrorMessage: String? + @State private var revealedApiKey: RevealedApiKey? + @State private var didCopyRevealedKey = false + @State private var apiKeyPendingDeletion: OutlineAPIKey? + @State private var deletingApiKeyId: String? + @State private var isShowingApiKeyWebOnlyNotice = false /// Full Local Sync and cache-clearing both need a real connection to be /// safe — clearing while offline (or letting Full Local Sync think it @@ -62,13 +92,32 @@ struct SettingsView: View { private var sectionDetail: some View { switch section { case .appearance: appearanceDetail - case .account: accountDetail + case .profile: profileDetail + case .preferences: preferencesDetail + case .notifications: notificationsDetail + case .passkeys: passkeysDetail + case .apiAccess: apiAccessDetail case .offlineSync: offlineSyncDetail case .advanced: advancedDetail case .about: aboutDetail + default: comingSoonDetail } } + /// Everything in Account/Workspace that isn't `.profile` — real content + /// lands section by section; this is just the nav skeleton until then. + private var comingSoonDetail: some View { + VStack(spacing: 16) { + sectionHeader + ContentUnavailableView { + Label("Coming Soon", systemImage: section.icon) + } description: { + Text("\(section.title) settings aren't built yet.") + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + private var sectionHeader: some View { Text(section.title) .font(.title.bold()) @@ -90,26 +139,925 @@ struct SettingsView: View { } } - // MARK: - Account + // MARK: - Profile - private var accountDetail: some View { - VStack(alignment: .leading, spacing: 16) { + private var profileDetail: some View { + VStack(alignment: .leading, spacing: 24) { sectionHeader - VStack(alignment: .leading, spacing: 10) { - labeledRow("Signed in as", session.userName ?? "—") - if let email = session.userEmail { - labeledRow("Email", email) - } - if let teamName = session.teamName { - labeledRow("Workspace", teamName) - } + if !isEffectivelyOnline { + offlineSettingsHint + } + avatarRow + Divider().frame(maxWidth: 420) + nameRow + Divider().frame(maxWidth: 420) + emailRow + if let teamName = session.teamName { + Divider().frame(maxWidth: 420) + labeledRow("Workspace", teamName) + .frame(maxWidth: 420) } - .frame(maxWidth: 420) Button("Log Out…", role: .destructive) { isShowingLogoutConfirmation = true } } + .sheet(item: $pickedPhoto) { picked in + AvatarCropperView( + sourceImage: picked.image, + onConfirm: { data in + pickedPhoto = nil + Task { await uploadAvatar(data: data) } + }, + onCancel: { pickedPhoto = nil } + ) + } + // The session only holds whatever was fetched at sign-in/launch — + // a name (or avatar) changed elsewhere (the Outline web app, say) + // never reaches it on its own. Refetching every time this page + // opens, rather than only once per app launch, is what makes that + // show up without having to quit and relaunch. + .task { + editableName = session.userName ?? "" + await refreshProfile() + } + .onChange(of: session.userName) { _, newValue in + editableName = newValue ?? "" + } + } + + private var avatarRow: some View { + HStack(spacing: 16) { + AvatarBadge(avatarURL: session.userAvatarURL, size: 64, placeholderSystemImage: "person.crop.circle.fill") + + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 10) { + Button(isUploadingAvatar ? "Uploading…" : "Upload Photo…") { pickPhoto() } + .disabled(isUploadingAvatar) + if session.userAvatarURL != nil { + Button("Remove", role: .destructive) { Task { await removeAvatar() } } + .buttonStyle(.plain) + .foregroundStyle(.red) + .disabled(isUploadingAvatar) + } + if isUploadingAvatar { + ProgressView().controlSize(.small) + } + } + .disabled(!isEffectivelyOnline) + if let avatarErrorMessage { + Text(avatarErrorMessage) + .font(.caption) + .foregroundStyle(.red) + } + } + } + } + + private var nameRow: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Name") + .font(.caption) + .foregroundStyle(.secondary) + HStack(spacing: 10) { + TextField("Name", text: $editableName) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 280) + .onSubmit { Task { await saveName() } } + if editableName != (session.userName ?? "") && !editableName.trimmingCharacters(in: .whitespaces).isEmpty { + if isSavingName { + ProgressView().controlSize(.small) + } else { + Button("Save") { Task { await saveName() } } + .controlSize(.small) + } + } + } + .disabled(!isEffectivelyOnline) + if let nameErrorMessage { + Text(nameErrorMessage) + .font(.caption) + .foregroundStyle(.red) + } + } + .frame(maxWidth: 420, alignment: .leading) + } + + private var emailRow: some View { + VStack(alignment: .leading, spacing: 6) { + labeledRow("Email", session.userEmail ?? "—") + HStack(spacing: 4) { + Text("Email is tied to sign-in, so it can't be changed here — use") + if let serverURL = session.serverURL { + Link("Outline on the web", destination: serverURL) + } else { + Text("Outline on the web") + } + Text("instead.") + } + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: 420, alignment: .leading) + } + + // MARK: - Preferences + + private var preferencesDetail: some View { + VStack(alignment: .leading, spacing: 24) { + sectionHeader + Text("Manage settings that affect your personal experience.") + .font(.subheadline) + .foregroundStyle(.secondary) + + if !isEffectivelyOnline { + offlineSettingsHint + } + + preferencesSubsection("Display") { + languageRow + .disabled(!isEffectivelyOnline) + Divider() + appearanceRow + Divider() + preferenceToggleRow( + "Use pointer cursor", + description: "Show a hand cursor when hovering over interactive elements.", + isOn: session.userPreferences?.useCursorPointer ?? false, + onChange: { newValue in Task { await savePreference { $0.useCursorPointer = newValue } } } + ) + .disabled(!isEffectivelyOnline) + Divider() + preferenceToggleRow( + "Show line numbers", + description: "Show line numbers on code blocks in documents.", + isOn: session.userPreferences?.codeBlockLineNumbers ?? false, + onChange: { newValue in Task { await savePreference { $0.codeBlockLineNumbers = newValue } } } + ) + .disabled(!isEffectivelyOnline) + Divider() + preferenceToggleRow( + "Show comment marker", + description: "Display a marker beside lines in the editor that contain comments.", + isOn: session.userPreferences?.showCommentMarker ?? false, + onChange: { newValue in Task { await savePreference { $0.showCommentMarker = newValue } } } + ) + .disabled(!isEffectivelyOnline) + } + + preferencesSubsection("Behavior") { + preferenceToggleRow( + "Separate editing", + description: "When enabled, documents have a separate editing mode. When disabled, documents are always editable when you have permission.", + isOn: session.userPreferences?.separateEditing ?? false, + onChange: { newValue in Task { await savePreference { $0.separateEditing = newValue } } } + ) + Divider() + preferenceToggleRow( + "Remember previous location", + description: "Automatically return to the document you were last viewing when the app is re-opened.", + isOn: session.userPreferences?.rememberLastPath ?? false, + onChange: { newValue in Task { await savePreference { $0.rememberLastPath = newValue } } } + ) + Divider() + preferenceToggleRow( + "Smart text replacements", + description: "Auto-format text by replacing shortcuts with symbols, dashes, smart quotes, and other typographical elements.", + isOn: session.userPreferences?.smartText ?? false, + onChange: { newValue in Task { await savePreference { $0.smartText = newValue } } } + ) + Divider() + notificationBadgeRow + } + .disabled(!isEffectivelyOnline) + + if let preferencesErrorMessage { + Text(preferencesErrorMessage) + .font(.caption) + .foregroundStyle(.red) + } + + preferencesSubsection("Danger") { + deleteAccountRow + } + .disabled(!isEffectivelyOnline) + } + .frame(maxWidth: 480, alignment: .leading) + .task { await refreshProfile() } + .confirmationDialog( + "Delete Account?", + isPresented: $isShowingDeleteAccountConfirmation, + titleVisibility: .visible + ) { + Button("Delete Account", role: .destructive) { Task { await deleteAccount() } } + Button("Cancel", role: .cancel) {} + } message: { + Text("This is unrecoverable — everything associated with your account will be permanently deleted.") + } + } + + private var deleteAccountRow: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Delete account") + Text("You may delete your account at any time, note that this is unrecoverable.") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if isDeletingAccount { + ProgressView().controlSize(.small) + } else { + Button("Delete…", role: .destructive) { isShowingDeleteAccountConfirmation = true } + .buttonStyle(.bordered) + .tint(.red) + } + } + if let deleteAccountErrorMessage { + Text(deleteAccountErrorMessage) + .font(.caption) + .foregroundStyle(.red) + } + } + } + + private func preferencesSubsection( + _ title: String, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 14) { + Text(title) + .font(.headline) + content() + } + } + + private var languageRow: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Language") + Text("Choose the interface language. Community translations are accepted through our translation portal.") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if isSavingLanguage { + ProgressView().controlSize(.small) + } else { + Picker("Language", selection: languageBinding) { + ForEach(displayedLocales) { locale in + Text(locale.label).tag(locale.code) + } + } + .labelsHidden() + .frame(width: 200) + } + } + if let languageErrorMessage { + Text(languageErrorMessage) + .font(.caption) + .foregroundStyle(.red) + } + } + } + + /// Same `@AppStorage("outpost.appearance")` binding the standalone + /// Appearance section already uses — this is a local-only, device-side + /// preference (matches how this app has always handled it), not a + /// server-synced Outline preference, so it doesn't need its own + /// save call or error state. + private var appearanceRow: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Appearance") + Text("Choose your preferred interface colour scheme.") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Picker("Appearance", selection: $appearance) { + ForEach(AppAppearance.allCases) { option in + Text(option.label).tag(option) + } + } + .labelsHidden() + .frame(width: 200) + } + } + + private var notificationBadgeRow: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Notification badge") + Text("Choose how unread notifications are indicated on the app icon.") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Picker("Notification badge", selection: notificationBadgeBinding) { + ForEach(NotificationBadgeStyle.allCases) { style in + Text(style.label).tag(style) + } + } + .labelsHidden() + .frame(width: 160) + } + } + + private var notificationBadgeBinding: Binding { + Binding( + get: { + session.userPreferences?.notificationBadge.flatMap(NotificationBadgeStyle.init(rawValue:)) ?? .unreadCount + }, + set: { newValue in Task { await savePreference { $0.notificationBadge = newValue.rawValue } } } + ) + } + + /// `OutlineLocale.all` is a curated subset, not Outline's full list — + /// a self-hosted server can report a code we don't know about (seen + /// live: "en_GB"). `Picker` needs a `Text` for every possible selection + /// or it logs "invalid tag" and its displayed value goes undefined — + /// appending the current code here (using itself as the label, same + /// fallback `OutlineLocale.label(for:)` already uses) guarantees a + /// match no matter what the server sends. + private var displayedLocales: [OutlineLocale] { + guard let current = session.userLanguage, !OutlineLocale.all.contains(where: { $0.code == current }) else { + return OutlineLocale.all + } + return OutlineLocale.all + [OutlineLocale(code: current, label: OutlineLocale.label(for: current))] + } + + private var languageBinding: Binding { + Binding( + get: { session.userLanguage ?? "en_US" }, + set: { newValue in Task { await saveLanguage(newValue) } } + ) + } + + private func deleteAccount() async { + guard let apiClient = session.apiClient else { return } + isDeletingAccount = true + defer { isDeletingAccount = false } + do { + try await apiClient.deleteAccount() + session.signOut() + } catch { + deleteAccountErrorMessage = outlineErrorMessage(error, fallback: "Couldn't delete your account.") + } + } + + private func saveLanguage(_ code: String) async { + guard code != session.userLanguage, let apiClient = session.apiClient, let userId = session.userId else { return } + isSavingLanguage = true + defer { isSavingLanguage = false } + do { + let updated = try await apiClient.updateUserLanguage(UpdateUserLanguageRequest(id: userId, language: code)) + session.applyUpdatedProfile(updated) + languageErrorMessage = nil + } catch { + languageErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update your language.") + } + } + + /// Every toggle in the Preferences page shares this: read + /// `session.userPreferences` (or an empty object if nothing's been set + /// yet), flip the one field the caller cares about, send the *whole* + /// object back — see `UpdateUserPreferencesRequest`'s own doc comment + /// for why the full object rather than a partial diff. + private func savePreference(_ update: (inout OutlineUserPreferences) -> Void) async { + guard let apiClient = session.apiClient, let userId = session.userId else { return } + var preferences = session.userPreferences ?? OutlineUserPreferences() + update(&preferences) + isSavingPreferences = true + defer { isSavingPreferences = false } + do { + let updated = try await apiClient.updateUserPreferences( + UpdateUserPreferencesRequest(id: userId, preferences: preferences) + ) + session.applyUpdatedProfile(updated) + preferencesErrorMessage = nil + } catch { + preferencesErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update your preferences.") + } + } + + private func preferenceToggleRow( + _ title: String, + description: String, + isOn: Bool, + onChange: @escaping (Bool) -> Void + ) -> some View { + VStack(alignment: .leading, spacing: 2) { + Toggle(isOn: Binding(get: { isOn }, set: onChange)) { + VStack(alignment: .leading, spacing: 2) { + Text(title) + Text(description) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + + // MARK: - Notifications + + private var notificationsDetail: some View { + VStack(alignment: .leading, spacing: 14) { + sectionHeader + Text("Manage when and where you receive email notifications.") + .font(.subheadline) + .foregroundStyle(.secondary) + + if !isEffectivelyOnline { + offlineSettingsHint + } + + allNotificationsRow + Divider() + notificationToggleRow( + "Document published", + description: "Receive a notification whenever a new document is published", + isOn: session.userNotificationSettings?[NotificationEventType.documentPublish.rawValue] ?? false, + onChange: { newValue in Task { await setNotifications([.documentPublish], subscribed: newValue) } } + ) + Divider() + notificationToggleRow( + "Document updated", + description: "Receive a notification when a document you are subscribed to is edited", + isOn: session.userNotificationSettings?[NotificationEventType.documentUpdate.rawValue] ?? false, + onChange: { newValue in Task { await setNotifications([.documentUpdate], subscribed: newValue) } } + ) + Divider() + notificationToggleRow( + "Comment posted", + description: "Receive a notification when a document you are subscribed to or a thread you participated in receives a comment", + isOn: session.userNotificationSettings?[NotificationEventType.commentCreate.rawValue] ?? false, + onChange: { newValue in Task { await setNotifications([.commentCreate], subscribed: newValue) } } + ) + Divider() + notificationToggleRow( + "Mentioned", + description: "Receive a notification when someone mentions you in a document or comment", + isOn: [NotificationEventType.commentMentioned, .documentMentioned].allSatisfy { session.userNotificationSettings?[$0.rawValue] ?? false }, + onChange: { newValue in Task { await setNotifications([.commentMentioned, .documentMentioned], subscribed: newValue) } } + ) + Divider() + notificationToggleRow( + "Group mentions", + description: "Receive a notification when someone mentions a group you are a member of in a document or comment", + isOn: [NotificationEventType.commentGroupMentioned, .documentGroupMentioned].allSatisfy { session.userNotificationSettings?[$0.rawValue] ?? false }, + onChange: { newValue in Task { await setNotifications([.commentGroupMentioned, .documentGroupMentioned], subscribed: newValue) } } + ) + Divider() + notificationToggleRow( + "Resolved", + description: "Receive a notification when a comment thread you were involved in is resolved", + isOn: session.userNotificationSettings?[NotificationEventType.commentResolve.rawValue] ?? false, + onChange: { newValue in Task { await setNotifications([.commentResolve], subscribed: newValue) } } + ) + Divider() + notificationToggleRow( + "Reaction added", + description: "Receive a notification when someone reacts to your comment", + isOn: session.userNotificationSettings?[NotificationEventType.reactionCreate.rawValue] ?? false, + onChange: { newValue in Task { await setNotifications([.reactionCreate], subscribed: newValue) } } + ) + Divider() + notificationToggleRow( + "Collection created", + description: "Receive a notification whenever a new collection is created", + isOn: session.userNotificationSettings?[NotificationEventType.collectionCreate.rawValue] ?? false, + onChange: { newValue in Task { await setNotifications([.collectionCreate], subscribed: newValue) } } + ) + Divider() + notificationToggleRow( + "Invite accepted", + description: "Receive a notification when someone you invited creates an account", + isOn: session.userNotificationSettings?[NotificationEventType.emailsInviteAccepted.rawValue] ?? false, + onChange: { newValue in Task { await setNotifications([.emailsInviteAccepted], subscribed: newValue) } } + ) + Divider() + notificationToggleRow( + "Invited to document", + description: "Receive a notification when a document is shared with you", + isOn: session.userNotificationSettings?[NotificationEventType.documentAddUser.rawValue] ?? false, + onChange: { newValue in Task { await setNotifications([.documentAddUser], subscribed: newValue) } } + ) + Divider() + notificationToggleRow( + "Invited to collection", + description: "Receive a notification when you are given access to a collection", + isOn: session.userNotificationSettings?[NotificationEventType.collectionAddUser.rawValue] ?? false, + onChange: { newValue in Task { await setNotifications([.collectionAddUser], subscribed: newValue) } } + ) + Divider() + notificationToggleRow( + "Export completed", + description: "Receive a notification when an export you requested has been completed", + isOn: session.userNotificationSettings?[NotificationEventType.emailsExportCompleted.rawValue] ?? false, + onChange: { newValue in Task { await setNotifications([.emailsExportCompleted], subscribed: newValue) } } + ) + Divider() + notificationToggleRow( + "Document access requested", + description: "Receive a notification when a user requests access to a document you manage", + isOn: session.userNotificationSettings?[NotificationEventType.accessRequestCreate.rawValue] ?? false, + onChange: { newValue in Task { await setNotifications([.accessRequestCreate], subscribed: newValue) } } + ) + + if let notificationsErrorMessage { + Text(notificationsErrorMessage) + .font(.caption) + .foregroundStyle(.red) + } + } + .disabled(!isEffectivelyOnline) + .frame(maxWidth: 480, alignment: .leading) + .task { await refreshProfile() } + } + + private var allNotificationsRow: some View { + let isOn = NotificationEventType.allCases.allSatisfy { session.userNotificationSettings?[$0.rawValue] ?? false } + return notificationToggleRow( + "All notifications", + description: nil, + isOn: isOn, + onChange: { newValue in Task { await setNotifications(nil, subscribed: newValue) } } + ) + } + + private func notificationToggleRow( + _ title: String, + description: String?, + isOn: Bool, + onChange: @escaping (Bool) -> Void + ) -> some View { + Toggle(isOn: Binding(get: { isOn }, set: onChange)) { + VStack(alignment: .leading, spacing: 2) { + Text(title) + if let description { + Text(description) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + + /// `eventTypes` groups more than one wire event under a single visible + /// toggle (e.g. "Mentioned" covers both `comments.mentioned` and + /// `documents.mentioned`) — confirmed as the right grouping against + /// Outline's own settings copy, applied with one subscribe/unsubscribe + /// call per underlying event type, sequentially. + private func setNotifications(_ eventTypes: [NotificationEventType]?, subscribed: Bool) async { + guard let apiClient = session.apiClient else { return } + isSavingNotifications = true + defer { isSavingNotifications = false } + let targets: [NotificationEventType?] = eventTypes ?? [nil] + do { + for target in targets { + let updated = subscribed + ? try await apiClient.subscribeToNotifications(eventType: target) + : try await apiClient.unsubscribeFromNotifications(eventType: target) + session.applyUpdatedProfile(updated) + } + notificationsErrorMessage = nil + } catch { + notificationsErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update your notification settings.") + } + } + + // MARK: - Passkeys + + /// Read-only on purpose — Outline only exposes passkey management from + /// its own web app (WebAuthn registration needs a browser context this + /// native app doesn't have), so this page is informational, not a + /// placeholder for missing functionality. + private var passkeysDetail: some View { + VStack(alignment: .leading, spacing: 16) { + sectionHeader + Text("Passkeys allow you to sign in safely without a password using your device's biometric authentication (Face ID, Touch ID, Windows Hello) or security key.") + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: 480, alignment: .leading) + + if !isEffectivelyOnline { + offlineSettingsHint + } + + Label("This setting can only be changed from the web version of Outline.", systemImage: "lock.fill") + .font(.callout) + .foregroundStyle(.secondary) + .padding(12) + .frame(maxWidth: 480, alignment: .leading) + .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + } + + // MARK: - API & Access + + private var apiAccessDetail: some View { + VStack(alignment: .leading, spacing: 16) { + sectionHeader + Text("Create personal API keys to authenticate with the API and programmatically control your workspace's data. For more details see the [developer documentation](https://www.getoutline.com/developers).") + .font(.subheadline) + .foregroundStyle(.secondary) + .tint(.accentColor) + .frame(maxWidth: 480, alignment: .leading) + + Divider().frame(maxWidth: 480) + + if !isEffectivelyOnline { + offlineSettingsHint + } + + HStack { + Text("Personal keys") + .font(.headline) + Spacer() + // TODO: apiKeys.create needs Outline's cookie+CSRF web + // session, not this app's Bearer-token auth — confirmed + // this app's requests to it don't work. Swap this back to + // `isShowingCreateApiKey = true` (the real create sheet + // below is fully built and untouched) once there's a + // supported native auth path, or Outline adds Bearer + // support for this endpoint. + Button("New API Key…") { isShowingApiKeyWebOnlyNotice = true } + } + .frame(maxWidth: 480) + + apiKeysList + .disabled(!isEffectivelyOnline) + .opacity(isEffectivelyOnline ? 1 : 0.4) + } + .task { await refreshApiKeys() } + .sheet(isPresented: $isShowingCreateApiKey) { + createApiKeySheet + } + .sheet(item: $revealedApiKey) { revealed in + apiKeyRevealSheet(revealed) + } + .confirmationDialog( + "Delete API Key?", + isPresented: Binding( + get: { apiKeyPendingDeletion != nil }, + set: { if !$0 { apiKeyPendingDeletion = nil } } + ), + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + if let key = apiKeyPendingDeletion { + Task { await deleteApiKey(key) } + } + } + Button("Cancel", role: .cancel) { apiKeyPendingDeletion = nil } + } message: { + if let name = apiKeyPendingDeletion?.name { + Text("Any scripts or integrations using \"\(name)\" will stop working immediately.") + } + } + .alert("Manage API Keys on the Web", isPresented: $isShowingApiKeyWebOnlyNotice) { + Button("OK") {} + } message: { + Text("Creating and deleting personal API keys is only supported on the web version of Outline. This app can display your existing keys, but not create or delete them.") + } + } + + /// Shown exactly once, immediately after creation — Outline never + /// returns the plaintext value again after this response (confirmed + /// live: `apiKeys.list` omits it), so this app enforces the same + /// "copy it now or lose it" rule the web app does, not just for show. + private func apiKeyRevealSheet(_ revealed: RevealedApiKey) -> some View { + VStack(alignment: .leading, spacing: 16) { + Text("API Key Created") + .font(.headline) + Text("Copy this key now — treat it like a password. For security, it will only be shown this once.") + .font(.callout) + .foregroundStyle(.secondary) + + HStack { + Text(revealed.value) + .font(.system(.callout, design: .monospaced)) + .textSelection(.enabled) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Button(didCopyRevealedKey ? "Copied" : "Copy") { + copyToPasteboard(revealed.value) + didCopyRevealedKey = true + } + } + .padding(10) + .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + + Label( + "Be careful when handling your keys, as they allow full access to your data — treat them like passwords.", + systemImage: "exclamationmark.triangle.fill" + ) + .font(.caption) + .foregroundStyle(.orange) + + HStack { + Spacer() + Button(didCopyRevealedKey ? "Done" : "I've Copied It") { + revealedApiKey = nil + } + .buttonStyle(.borderedProminent) + .disabled(!didCopyRevealedKey) + } + } + .padding(24) + .frame(width: 420) + .interactiveDismissDisabled(!didCopyRevealedKey) + // Belt-and-suspenders: however this sheet actually closes, the + // plaintext key is gone from memory the moment it's gone from + // screen — not just visually hidden behind dismissed UI state. + .onDisappear { + revealedApiKey = nil + didCopyRevealedKey = false + } + } + + private func copyToPasteboard(_ string: String) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(string, forType: .string) + } + + private var createApiKeySheet: some View { + VStack(alignment: .leading, spacing: 16) { + Text("New API Key") + .font(.headline) + + VStack(alignment: .leading, spacing: 6) { + Text("Name") + .font(.caption) + .foregroundStyle(.secondary) + TextField("e.g. My Script", text: $newApiKeyName) + .textFieldStyle(.roundedBorder) + .onSubmit { Task { await createApiKey() } } + } + + VStack(alignment: .leading, spacing: 6) { + Text("Expiration") + .font(.caption) + .foregroundStyle(.secondary) + Picker("Expiration", selection: $newApiKeyExpiration) { + ForEach(ApiKeyExpiration.allCases) { option in + Text(option.label).tag(option) + } + } + .labelsHidden() + } + + if let createApiKeyErrorMessage { + Text(createApiKeyErrorMessage) + .font(.caption) + .foregroundStyle(.red) + } + + HStack { + Spacer() + Button("Cancel") { + isShowingCreateApiKey = false + newApiKeyName = "" + newApiKeyExpiration = .noExpiration + createApiKeyErrorMessage = nil + } + if isCreatingApiKey { + ProgressView().controlSize(.small) + } else { + Button("Create") { Task { await createApiKey() } } + .buttonStyle(.borderedProminent) + .disabled(newApiKeyName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !isEffectivelyOnline) + } + } + } + .padding(24) + .frame(width: 360) + } + + @ViewBuilder + private var apiKeysList: some View { + if isLoadingApiKeys && apiKeys.isEmpty { + ProgressView().controlSize(.small) + } else if let apiKeysErrorMessage { + Text(apiKeysErrorMessage) + .font(.caption) + .foregroundStyle(.red) + } else if apiKeys.isEmpty { + Text("No personal API keys yet.") + .font(.callout) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 0) { + ForEach(apiKeys) { key in + apiKeyRow(key) + if key.id != apiKeys.last?.id { + Divider() + } + } + } + .frame(maxWidth: 480, alignment: .leading) + } + } + + private func apiKeyRow(_ key: OutlineAPIKey) -> some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(key.name) + .font(.callout.weight(.medium)) + Spacer() + if let last4 = key.last4 { + Text("••••••••\(last4)") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + Text("Created \(formattedDate(key.createdAt))\(key.lastActiveAt.map { " — last used \(formattedDate($0))" } ?? "")") + .font(.caption) + .foregroundStyle(.secondary) + } + if deletingApiKeyId == key.id { + ProgressView().controlSize(.small) + } else { + // TODO: apiKeys.delete — same web-session-only limitation + // as create above, see that comment. Swap back to + // `apiKeyPendingDeletion = key` (the real confirmation + // dialog + deleteApiKey(_:) below are fully built and + // untouched) once native auth can actually call it. + Button { + isShowingApiKeyWebOnlyNotice = true + } label: { + Image(systemName: "trash") + } + .buttonStyle(.plain) + .foregroundStyle(.red) + } + } + .padding(.vertical, 8) + } + + // TODO: not currently reachable from the UI — apiKeys.create needs + // Outline's cookie+CSRF web session, confirmed this app's Bearer-token + // requests to it don't work. Kept intact (and covered by OutlineKit + // tests) for when native auth can support it; see the "New API Key…" + // button's own TODO for the reconnect point. + private func createApiKey() async { + guard let apiClient = session.apiClient else { return } + let trimmedName = newApiKeyName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedName.isEmpty else { return } + isCreatingApiKey = true + defer { isCreatingApiKey = false } + do { + let created = try await apiClient.createApiKey( + CreateApiKeyRequest(name: trimmedName, expiresAt: newApiKeyExpiration.expiresAtDate) + ) + isShowingCreateApiKey = false + newApiKeyName = "" + newApiKeyExpiration = .noExpiration + createApiKeyErrorMessage = nil + // The plaintext `value` only ever exists on this one response — + // held only in `revealedApiKey`'s short lifetime, never merged + // into the persisted `apiKeys` list (refreshed from the server + // right after, which never returns it). + if let value = created.value { + revealedApiKey = RevealedApiKey(name: created.name, value: value) + } + await refreshApiKeys() + } catch { + createApiKeyErrorMessage = outlineErrorMessage(error, fallback: "Couldn't create this API key.") + } + } + + // TODO: not currently reachable from the UI — same apiKeys.delete + // web-session-only limitation as createApiKey() above. Kept intact + // for the same reason; see the trash button's own TODO. + private func deleteApiKey(_ key: OutlineAPIKey) async { + guard let apiClient = session.apiClient else { return } + apiKeyPendingDeletion = nil + deletingApiKeyId = key.id + defer { deletingApiKeyId = nil } + do { + try await apiClient.deleteApiKey(id: key.id) + await refreshApiKeys() + } catch { + apiKeysErrorMessage = outlineErrorMessage(error, fallback: "Couldn't delete this API key.") + } + } + + private func refreshApiKeys() async { + guard let apiClient = session.apiClient else { return } + isLoadingApiKeys = true + defer { isLoadingApiKeys = false } + do { + apiKeys = try await apiClient.listApiKeys(ListApiKeysRequest()) + apiKeysErrorMessage = nil + } catch { + apiKeysErrorMessage = outlineErrorMessage(error, fallback: "Couldn't load your API keys.") + } } // MARK: - Offline & Sync @@ -361,6 +1309,20 @@ struct SettingsView: View { // MARK: - Helpers + /// Everything backed by a live `users.update`/`users.notifications*` + /// call (Profile's name/avatar, all of Preferences, all of + /// Notifications) shows this and disables its controls while offline — + /// there's nothing to optimistically apply here the way document edits + /// can be, and no offline queue for it (see `TODO.local.md`'s own + /// "things that actually require an internet connection" framing, + /// already applied to Share/Permissions/Search). Local-only settings + /// (Appearance, Offline Mode itself) are deliberately left enabled. + private var offlineSettingsHint: some View { + Text("You're offline — these settings need a connection to change.") + .font(.caption) + .foregroundStyle(.orange) + } + private func labeledRow(_ label: String, _ value: String) -> some View { HStack { Text(label) @@ -371,6 +1333,10 @@ struct SettingsView: View { .font(.callout) } + private func formattedDate(_ date: Date) -> String { + date.formatted(date: .abbreviated, time: .omitted) + } + private func formattedBytes(_ bytes: Int) -> String { ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file) } @@ -416,5 +1382,134 @@ struct SettingsView: View { didClearCache = false } } + + // MARK: - Profile actions + + private func refreshProfile() async { + guard let apiClient = session.apiClient else { return } + guard let fresh = try? await apiClient.currentUser() else { return } + session.applyUpdatedProfile(fresh) + } + + private func pickPhoto() { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.png, .jpeg, .heic, .image] + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.canChooseFiles = true + guard panel.runModal() == .OK, let url = panel.url, let image = NSImage(contentsOf: url) else { return } + pickedPhoto = PickedPhoto(image: image) + } + + private func uploadAvatar(data: Data) async { + guard let apiClient = session.apiClient, let userId = session.userId else { return } + isUploadingAvatar = true + defer { isUploadingAvatar = false } + let previousAttachmentId = attachmentId(from: session.userAvatarURL) + do { + let target = try await apiClient.createAttachment( + CreateAttachmentRequest(name: "avatar.jpg", contentType: "image/jpeg", size: data.count) + ) + try await apiClient.uploadAttachmentFile(target, fileData: data) + let updated = try await apiClient.updateUserAvatar( + UpdateUserAvatarRequest(id: userId, avatarUrl: target.attachment.url) + ) + session.applyUpdatedProfile(updated) + avatarErrorMessage = nil + // Best-effort — the new avatar is already live either way, this + // just stops the old upload from sitting around unreferenced. + if let previousAttachmentId { + try? await apiClient.deleteAttachment(id: previousAttachmentId) + } + } catch { + avatarErrorMessage = outlineErrorMessage(error, fallback: "Couldn't upload this photo.") + } + } + + private func removeAvatar() async { + guard let apiClient = session.apiClient, let userId = session.userId else { return } + isUploadingAvatar = true + defer { isUploadingAvatar = false } + let previousAttachmentId = attachmentId(from: session.userAvatarURL) + do { + let updated = try await apiClient.updateUserAvatar(UpdateUserAvatarRequest(id: userId, avatarUrl: nil)) + session.applyUpdatedProfile(updated) + avatarErrorMessage = nil + if let previousAttachmentId { + try? await apiClient.deleteAttachment(id: previousAttachmentId) + } + } catch { + avatarErrorMessage = outlineErrorMessage(error, fallback: "Couldn't remove this photo.") + } + } + + /// The avatar URL is `/api/attachments.redirect?id=` — + /// pulling the id back out of it is how the previous attachment gets + /// found for cleanup, since nothing else keeps a record of it. + private func attachmentId(from avatarURL: URL?) -> String? { + guard let avatarURL, let components = URLComponents(url: avatarURL, resolvingAgainstBaseURL: false) else { + return nil + } + return components.queryItems?.first(where: { $0.name == "id" })?.value + } + + private func saveName() async { + let trimmed = editableName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, trimmed != (session.userName ?? ""), let apiClient = session.apiClient, let userId = session.userId else { return } + isSavingName = true + defer { isSavingName = false } + do { + let updated = try await apiClient.updateUserName(UpdateUserNameRequest(id: userId, name: trimmed)) + session.applyUpdatedProfile(updated) + editableName = updated.name + nameErrorMessage = nil + } catch { + nameErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update your name.") + } + } +} + +/// Wraps a picked `NSImage` so it can drive `.sheet(item:)` — `NSImage` +/// itself isn't `Identifiable`. +private struct PickedPhoto: Identifiable { + let id = UUID() + let image: NSImage +} + +/// The one-time plaintext value from a just-created API key, plus enough +/// to label the reveal sheet — deliberately not `OutlineAPIKey` itself, so +/// nothing holding a reference to "the created key" for other purposes can +/// accidentally end up holding the secret too. +private struct RevealedApiKey: Identifiable { + let id = UUID() + let name: String + let value: String +} + +private enum ApiKeyExpiration: String, CaseIterable, Identifiable { + case noExpiration, oneMonth, threeMonths, sixMonths, oneYear + + var id: String { rawValue } + + var label: String { + switch self { + case .noExpiration: return "No expiration" + case .oneMonth: return "1 month" + case .threeMonths: return "3 months" + case .sixMonths: return "6 months" + case .oneYear: return "1 year" + } + } + + var expiresAtDate: Date? { + let calendar = Calendar.current + switch self { + case .noExpiration: return nil + case .oneMonth: return calendar.date(byAdding: .month, value: 1, to: Date()) + case .threeMonths: return calendar.date(byAdding: .month, value: 3, to: Date()) + case .sixMonths: return calendar.date(byAdding: .month, value: 6, to: Date()) + case .oneYear: return calendar.date(byAdding: .year, value: 1, to: Date()) + } + } } #endif diff --git a/Outpost/Features/Auth/AuthHeaderView.swift b/Outpost/Features/Auth/AuthHeaderView.swift index efa4249..e57ae36 100644 --- a/Outpost/Features/Auth/AuthHeaderView.swift +++ b/Outpost/Features/Auth/AuthHeaderView.swift @@ -3,21 +3,17 @@ import SwiftUI struct AuthHeaderView: View { var body: some View { VStack(spacing: 12) { - ZStack { - Circle() - .fill( - LinearGradient( - colors: [Color.accentColor, Color.accentColor.opacity(0.6)], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) - .frame(width: 64, height: 64) - Image(systemName: "text.book.closed.fill") - .font(.system(size: 26, weight: .semibold)) - .foregroundStyle(.white) - } - .shadow(color: Color.accentColor.opacity(0.35), radius: 12, y: 6) + // A plain Image Set, not the AppIcon *app icon* asset — App Icon + // sets aren't reliably resolvable through Image(_:)/UIImage + // (named:) at runtime (confirmed live: showed nothing). This is + // the same source artwork (outpost-ios-1024.png) duplicated + // into a normal image set so SwiftUI can actually load it. + Image("AppLogo") + .resizable() + .scaledToFit() + .frame(width: 72, height: 72) + .clipShape(RoundedRectangle(cornerRadius: 72 * 0.2237, style: .continuous)) + .shadow(color: .black.opacity(0.25), radius: 12, y: 6) VStack(spacing: 4) { Text("Welcome to Outpost") diff --git a/Outpost/Root/AppNavigation.swift b/Outpost/Root/AppNavigation.swift index 6804aef..ce30d06 100644 --- a/Outpost/Root/AppNavigation.swift +++ b/Outpost/Root/AppNavigation.swift @@ -1,27 +1,124 @@ import Observation -enum SettingsSection: String, CaseIterable, Identifiable, Hashable { - case appearance, account, offlineSync, advanced, about +/// Top-level groupings shown as section headers in `SettingsSidebarList`. +/// `general` holds everything that's ours, not Outline's own settings +/// categories — labeled "Outpost" so it reads as clearly distinct from the +/// Outline-sourced groups below it. +enum SettingsCategory: String, CaseIterable, Identifiable { + case general + case account + case workspace var id: String { rawValue } + var title: String? { + switch self { + case .general: return "Outpost" + case .account: return "Account" + case .workspace: return "Workspace" + } + } +} + +/// One entry in the Settings sidebar. Mirrors Outline's own settings +/// categories (Account/Workspace) so this app's settings read as a native +/// counterpart to the web app's, plus a `general` group for things that are +/// ours and don't map onto Outline's structure (offline/sync, advanced, +/// about, appearance). Outline's own version info moved to the sidebar +/// footer (`SettingsSidebarList`) instead of a standalone +/// Integrations & Installation section. +/// +/// Most of the Account/Workspace cases are navigation-only for now — +/// `SettingsView` renders a "Coming Soon" placeholder for anything not +/// explicitly built yet. Content lands section by section. +enum SettingsSection: String, CaseIterable, Identifiable, Hashable { + // General (ours) + case appearance, offlineSync, advanced, about + + // Account + case profile, preferences, notifications, passkeys, apiAccess + + // Workspace + case details, authentication, security, ai, members, groups, templates, emojis, applications, shared, links, webhooks, importData, exportData + + var id: String { rawValue } + + var category: SettingsCategory { + switch self { + case .appearance, .offlineSync, .advanced, .about: + return .general + case .profile, .preferences, .notifications, .passkeys, .apiAccess: + return .account + case .details, .authentication, .security, .ai, .members, .groups, .templates, .emojis, .applications, .shared, .links, .webhooks, .importData, .exportData: + return .workspace + } + } + var title: String { switch self { case .appearance: return "Appearance" - case .account: return "Account" case .offlineSync: return "Offline & Sync" case .advanced: return "Advanced" case .about: return "About" + case .profile: return "Profile" + case .preferences: return "Preferences" + case .notifications: return "Notifications" + case .passkeys: return "Passkeys" + case .apiAccess: return "API & Access" + case .details: return "Details" + case .authentication: return "Authentication" + case .security: return "Security" + case .ai: return "AI" + case .members: return "Members" + case .groups: return "Groups" + case .templates: return "Templates" + case .emojis: return "Emojis" + case .applications: return "Applications" + case .shared: return "Shared" + case .links: return "Links" + case .webhooks: return "Webhooks" + case .importData: return "Import" + case .exportData: return "Export" } } var icon: String { switch self { case .appearance: return "paintbrush" - case .account: return "person.crop.circle" case .offlineSync: return "arrow.triangle.2.circlepath" case .advanced: return "wrench.and.screwdriver" case .about: return "info.circle" + case .profile: return "person.crop.circle" + case .preferences: return "gearshape" + case .notifications: return "bell" + case .passkeys: return "key" + case .apiAccess: return "chevron.left.forwardslash.chevron.right" + case .details: return "building.2" + case .authentication: return "lock" + case .security: return "shield" + case .ai: return "sparkles" + case .members: return "person.2" + case .groups: return "person.3" + case .templates: return "doc.on.doc" + case .emojis: return "face.smiling" + case .applications: return "app.badge" + case .shared: return "square.and.arrow.up.on.square" + case .links: return "link" + case .webhooks: return "bolt.horizontal" + case .importData: return "square.and.arrow.down" + case .exportData: return "square.and.arrow.up" + } + } + + /// Everything actually built so far — everything else in Account/ + /// Workspace renders a "Coming Soon" placeholder until its content is + /// specified and built. + var isImplemented: Bool { + switch self { + case .appearance, .offlineSync, .advanced, .about, .profile, .preferences, .notifications, .passkeys, .apiAccess: + return true + default: + return false } } } diff --git a/Outpost/Root/SessionStore.swift b/Outpost/Root/SessionStore.swift index d815378..a22bf08 100644 --- a/Outpost/Root/SessionStore.swift +++ b/Outpost/Root/SessionStore.swift @@ -11,9 +11,13 @@ final class SessionStore { private let defaults: UserDefaults var isSignedIn: Bool + private(set) var userId: String? var userName: String? var userEmail: String? var userAvatarURL: URL? + var userLanguage: String? + var userPreferences: OutlineUserPreferences? + var userNotificationSettings: [String: Bool]? var teamName: String? var teamAvatarURL: URL? private(set) var apiClient: OutlineAPIClient? @@ -35,11 +39,27 @@ final class SessionStore { init(tokenStore: TokenStoring = KeychainTokenStore(), defaults: UserDefaults = .standard) { self.tokenStore = tokenStore self.defaults = defaults - self.isSignedIn = (try? tokenStore.token()) != nil self.cacheStore = (try? OfflineCacheStore.makeContainer()).map(OfflineCacheStore.init(modelContainer:)) - if isSignedIn, let serverURL { - (apiClient, cachingClient) = Self.makeAPIClient(serverURL: serverURL, tokenStore: tokenStore, cache: cacheStore) + let hasToken = (try? tokenStore.token()) != nil + let storedServerURL = defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:)) + + if hasToken, let storedServerURL { + isSignedIn = true + (apiClient, cachingClient) = Self.makeAPIClient(serverURL: storedServerURL, tokenStore: tokenStore, cache: cacheStore) + } else { + // Keychain and the sandboxed UserDefaults container don't + // always survive together — a Keychain item written by an + // older-signed build can outlive a reinstall that wipes the + // container (or vice versa), leaving a token with no server or + // a server with no token. Clear whichever half survived rather + // than showing a broken "signed in" UI with no working + // apiClient — a fresh sign-in rewrites both consistently. + if hasToken { + try? tokenStore.clear() + } + defaults.removeObject(forKey: Self.serverURLDefaultsKey) + isSignedIn = false } } @@ -68,9 +88,13 @@ final class SessionStore { try? tokenStore.clear() defaults.removeObject(forKey: Self.serverURLDefaultsKey) isSignedIn = false + userId = nil userName = nil userEmail = nil userAvatarURL = nil + userLanguage = nil + userPreferences = nil + userNotificationSettings = nil teamName = nil teamAvatarURL = nil apiClient = nil @@ -85,13 +109,30 @@ final class SessionStore { apply(user: auth.user, team: auth.team, serverURL: serverURL) } + /// Settings calls this after a successful name/avatar change so the + /// sidebar's account footer and everywhere else reading these reflect + /// it immediately, without waiting for the next `auth.info` refresh. + func applyUpdatedProfile(_ user: OutlineUser) { + guard let serverURL else { return } + userId = user.id + userName = user.name + userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL } + userLanguage = user.language + userPreferences = user.preferences + userNotificationSettings = user.notificationSettings + } + private func apply(user: OutlineUser, team: OutlineTeam, serverURL: URL) { + userId = user.id userName = user.name userEmail = user.email // Outline can return either an absolute URL or a server-relative path // (e.g. `/api/files.get?key=...`) for avatarUrl — resolve against the // configured server so relative paths don't fail as "unsupported URL". userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL } + userLanguage = user.language + userPreferences = user.preferences + userNotificationSettings = user.notificationSettings teamName = team.name teamAvatarURL = team.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL } } diff --git a/Outpost/Support/AvatarBadge.swift b/Outpost/Support/AvatarBadge.swift index bf35d89..13da9dd 100644 --- a/Outpost/Support/AvatarBadge.swift +++ b/Outpost/Support/AvatarBadge.swift @@ -1,4 +1,5 @@ import SwiftUI +import OutlineKit #if os(macOS) import AppKit @@ -41,11 +42,46 @@ struct AvatarBadge: View { .task(id: avatarURL) { loadedImage = nil guard let avatarURL else { return } - guard let (data, _) = try? await URLSession.shared.data(from: avatarURL) else { return } - loadedImage = PlatformImage(data: data) + loadedImage = await Self.loadImage(from: avatarURL) } } + /// The real fix, confirmed against a live network capture: every other + /// request this app makes attaches `Authorization: Bearer ` — + /// this one never did, sending a bare unauthenticated GET. Outline's + /// browser session authenticates `attachments.redirect` via cookies + /// instead, which a native app doesn't have; the API-token equivalent + /// is the same Bearer header every RPC call already uses. Almost + /// certainly means no avatar image (not just a freshly-uploaded one) + /// has ever actually loaded in this app — a 401 and a "no avatar set" + /// look identical here, both just fall back to the placeholder icon + /// with nothing on screen to flag it as an error. + /// + /// The retry loop is a secondary, independent hardening — cheap + /// insurance against a self-hosted reverse-proxied storage backend not + /// being instantly consistent right after an upload — kept alongside + /// the auth fix rather than instead of it. + private static func loadImage(from url: URL) async -> PlatformImage? { + var request = URLRequest(url: url) + request.cachePolicy = .reloadIgnoringLocalCacheData + if let token = try? KeychainTokenStore().token() { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + for attempt in 0..<3 { + if attempt > 0 { + try? await Task.sleep(for: .milliseconds(400)) + } + if let (data, response) = try? await URLSession.shared.data(for: request), + let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode), + let image = PlatformImage(data: data) { + return image + } + } + return nil + } + private func platformImage(_ image: PlatformImage) -> Image { #if os(macOS) Image(nsImage: image) diff --git a/Outpost/Support/NSImage+JPEGData.swift b/Outpost/Support/NSImage+JPEGData.swift new file mode 100644 index 0000000..9f20459 --- /dev/null +++ b/Outpost/Support/NSImage+JPEGData.swift @@ -0,0 +1,14 @@ +#if os(macOS) +import AppKit + +extension NSImage { + /// `NSImage` has no built-in JPEG encoder (unlike `UIImage`) — routes + /// through a bitmap representation to get one. + func jpegData(compressionQuality: CGFloat) -> Data? { + guard let tiffData = tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiffData) else { + return nil + } + return bitmap.representation(using: .jpeg, properties: [.compressionFactor: compressionQuality]) + } +} +#endif diff --git a/Outpost/Support/OutlineLocale.swift b/Outpost/Support/OutlineLocale.swift new file mode 100644 index 0000000..ab79106 --- /dev/null +++ b/Outpost/Support/OutlineLocale.swift @@ -0,0 +1,35 @@ +import Foundation + +/// Outline's interface-language options. Not exhaustive — Outline accepts +/// community translations via its own translation portal, so the real list +/// on any given server can be longer than this; this covers the common +/// cases and falls back to showing whatever code the server already has +/// set even if it isn't in this list. +struct OutlineLocale: Identifiable, Hashable { + let code: String + let label: String + + var id: String { code } + + static let all: [OutlineLocale] = [ + OutlineLocale(code: "en_US", label: "English (US)"), + OutlineLocale(code: "en_GB", label: "English (UK)"), + OutlineLocale(code: "de_DE", label: "Deutsch"), + OutlineLocale(code: "fr_FR", label: "Français"), + OutlineLocale(code: "es_ES", label: "Español"), + OutlineLocale(code: "pt_PT", label: "Português"), + OutlineLocale(code: "pt_BR", label: "Português (Brasil)"), + OutlineLocale(code: "it_IT", label: "Italiano"), + OutlineLocale(code: "nl_NL", label: "Nederlands"), + OutlineLocale(code: "pl_PL", label: "Polski"), + OutlineLocale(code: "ru_RU", label: "Русский"), + OutlineLocale(code: "ja_JP", label: "日本語"), + OutlineLocale(code: "ko_KR", label: "한국어"), + OutlineLocale(code: "zh_CN", label: "中文 (简体)"), + OutlineLocale(code: "zh_TW", label: "中文 (繁體)"), + ] + + static func label(for code: String) -> String { + all.first(where: { $0.code == code })?.label ?? code + } +} diff --git a/Outpost/Support/OutpostVersion.swift b/Outpost/Support/OutpostVersion.swift new file mode 100644 index 0000000..85aee99 --- /dev/null +++ b/Outpost/Support/OutpostVersion.swift @@ -0,0 +1,31 @@ +import Foundation + +/// Single source of truth for how Outpost's own version is formatted — +/// used by both the About page and the Settings sidebar footer, so they +/// can't drift out of sync the way `AboutInfoView` was already written to +/// avoid for its own two call sites. +enum OutpostVersion { + /// Bumped alongside `MARKETING_VERSION` in the Xcode project — kept out + /// of the bundle version itself since `CFBundleShortVersionString` is + /// expected to stay a plain dotted-numeric string, not `0.0.1-ALPHA`. + static let releaseStage = "ALPHA" + + static var shortVersion: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.1" + } + + static var buildNumber: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1" + } + + /// e.g. `"0.0.3-ALPHA"` — for compact display (sidebar footer). + static var displayString: String { + let stageSuffix = releaseStage.isEmpty ? "" : "-\(releaseStage)" + return "\(shortVersion)\(stageSuffix)" + } + + /// e.g. `"Version 0.0.3-ALPHA (1)"` — for the About page. + static var fullVersionString: String { + "Version \(displayString) (\(buildNumber))" + } +}