48 Commits
Author SHA1 Message Date
Puranjay Savar Mattas 710a6617f5 Merge pull request 'Settings parity: Profile, Preferences, Notifications, Passkeys, API & Access' (#7) from feature/settings-parity into main
Reviewed-on: #7
2026-08-18 17:13:52 +01:00
Puranjay Savar Mattas 55c23afaff chore: bump version to 0.0.4 2026-08-18 17:01:17 +01:00
Puranjay Savar Mattas f07c5055fa fix(settings): style Coming Soon as a capsule badge instead of plain text 2026-08-18 16:59:06 +01:00
Puranjay Savar Mattas 8af860cd7e feat(settings): mark Workspace category Coming Soon in the sidebar
Shows a small tertiary-styled 'Coming Soon' next to the category header
whenever every section under it is still !isImplemented — not hardcoded
to Workspace specifically, so it stops on its own once real Workspace
sections start landing instead of needing a manual follow-up removal.
2026-08-18 16:58:14 +01:00
Puranjay Savar Mattas 78e3566a8e fix(settings): show offline hint on Passkeys, clearer API key web-only message
Passkeys was the only implemented section missing the standard offline
banner. Reworded the API key create/delete popup to state plainly it's
only supported on Outline's web version, matching Passkeys' own phrasing
style instead of the more roundabout original wording.
2026-08-18 16:54:45 +01:00
Puranjay Savar Mattas 64e970fc92 fix(settings): API key create/delete need Outline's web session, not this app
Same limitation as Passkeys — apiKeys.create/apiKeys.delete need
Outline's cookie+CSRF web session, not this app's Bearer-token auth.
"New API Key…" and the per-row trash button now show an explanatory
popup instead of attempting a request that doesn't work.

The real create sheet, reveal-once flow, delete confirmation, and their
backing functions are untouched and still fully built/tested at the
OutlineKit layer — only the two trigger points were redirected, each
marked with a TODO pointing back to how to re-enable them (swap the
button action back) once there's a supported native auth path or
Outline adds Bearer support for these two endpoints.
2026-08-18 16:53:46 +01:00
Puranjay Savar Mattas 1e541979e9 fix(settings): sidebar version footer — alpha tag, offline handling, auto-refresh
Outpost's version was missing the -ALPHA suffix About already shows —
extracted OutpostVersion (Support/) as the one shared source for both,
so a third divergent copy can't happen the way AboutInfoView's own doc
comment already warns against for its two call sites.

Outline's version now accounts for the manual Offline Mode toggle too,
not just real connectivity, shows "Outline — offline" instead of just
disappearing when nothing's been fetched yet, and re-fetches
automatically via .task(id: isEffectivelyOnline) whenever connectivity
changes — previously a one-shot fetch on first sidebar mount only.
2026-08-18 16:51:13 +01:00
Puranjay Savar Mattas 26b0d7b118 fix(settings): grey out API & Access while offline
New API Key/delete were already gated on isEffectivelyOnline, but
nothing visually signaled offline state the way Preferences/Profile do
— added the same offline hint banner plus dimmed+disabled the key list
itself, and gated the sheet's own Create button too (in case Offline
Mode gets toggled on while the sheet is already open).
2026-08-18 16:49:45 +01:00
Puranjay Savar Mattas 45c950d474 feat(settings): API key create, reveal-once, and delete
Create: name + expiration picker (No expiration/1/3/6 months/1 year,
computed client-side and sent as expiresAt — omitted entirely for no
expiration, confirmed live that's what produces a non-expiring key).

Reveal: plaintext value only ever shown in a dedicated one-time sheet,
separate from the persisted apiKeys list (which is refreshed from the
server right after creating, so it never carries the value at all).
Requires clicking Copy before the confirm button unlocks, copies to the
system pasteboard, and clears the value from @State the moment the sheet
closes however it closes (explicit confirm, Escape, or otherwise) via
onDisappear — not just hidden behind dismissed UI.

Delete: confirmation dialog naming the key, per-row spinner while in
flight.

Both New API Key and per-row delete disable while offline, consistent
with every other server-synced action in Settings.
2026-08-18 16:48:12 +01:00
Puranjay Savar Mattas 044a706bd4 feat(outlinekit): add apiKeys.create/delete
value is only ever present in the create response (confirmed live —
apiKeys.list never includes it), so the model and call sites treat it
as a one-time reveal, not persisted state. expiresAt omitted (not null)
for a non-expiring key, matches synthesized Encodable's default
encodeIfPresent behavior.
2026-08-18 16:45:18 +01:00
Puranjay Savar Mattas fa22ac616b feat(settings): move Outline version to sidebar footer, drop Installation section
Removed the Integrations & Installation category and its lone
Installation section entirely — Outline's server version now shows in
the settings sidebar footer instead, alongside Outpost's own version
(installation.info, fetched once when the sidebar appears). About stays
where it was, unaffected.
2026-08-18 16:39:19 +01:00
Puranjay Savar Mattas adfa477ecf feat(settings): Installation section
Server version + up-to-date/behind indicator via installation.info.
2026-08-18 14:20:26 +01:00
Puranjay Savar Mattas dc780e420f feat(settings): API & Access section (personal keys, read-only)
Lists existing personal API keys via apiKeys.list (name, masked last4,
created/last-used dates) with a link to Outline's developer docs.
Creation/revocation deferred per instruction — read-only for this pass.
2026-08-18 14:19:50 +01:00
Puranjay Savar Mattas 6eb780d1bd feat(settings): Passkeys section
Informational only, per Outline itself — passkey/WebAuthn registration
needs a browser context, so this stays web-only rather than a
placeholder for missing native functionality.
2026-08-18 14:18:26 +01:00
Puranjay Savar Mattas 78a0e31897 feat(settings): mark Passkeys, API & Access, Installation implemented 2026-08-18 14:17:55 +01:00
Puranjay Savar Mattas e3ad8650c6 feat(outlinekit): add apiKeys.list and installation.info plumbing
Read-only for now, per instruction to defer key creation/revocation.
Both confirmed against live network captures.
2026-08-18 14:17:41 +01:00
Puranjay Savar Mattas 4faa23f79e chore(xcode): sync project signing config after bundle ID rename
Picks up Xcode's own automatic-signing additions from setting up the
Development provisioning profile in the IDE (CODE_SIGN_IDENTITY,
PROVISIONING_PROFILE_SPECIFIER, ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS,
Info.plist display name/category keys), alongside the earlier
com.psmattas.OutpostApp bundle ID and CW6GQT9SK5 team fixes.
2026-08-18 13:52:32 +01:00
Puranjay Savar Mattas 09e1ab8f65 fix(auth): properly clean the login logo edge, previous pass left a fringe
Plain -fuzz -transparent white color-keyed every pixel in the whole
image close enough to white by color distance — including an
antialiased blend inside the artwork itself (where the light beam meets
the window frame), which punched a stray transparent hole there, and
still left a faint opaque fringe ring around the outer edge since the
binary cutoff didn't fully consume the antialiasing ramp.

Redone with a flood fill seeded from a corner pixel instead: only
transforms the background region actually connected to that seed (the
four corners + thin white border, confirmed contiguous), leaving
disconnected internal antialiased pixels untouched. Then eroded the
alpha channel by ~2px to consume the remaining boundary ramp cleanly.
Verified against both dark and light composited backgrounds, and
confirmed the beam/window-frame area is no longer punctured.
2026-08-18 12:39:42 +01:00
Puranjay Savar Mattas bec8135c74 fix(auth): remove white corners around the login logo
The App Store marketing icon source (outpost-ios-1024.png) is a flat
opaque square with the squircle baked in and pure-white corners outside
it — meant to be masked by the OS everywhere it's normally shown as an
app icon, but nothing masks it when used as a plain in-app image.
Color-keyed white to transparent (fuzz 8%, safe here since nothing in
the actual artwork is white/near-white) so the corners disappear instead
of showing a white border.
2026-08-18 12:30:05 +01:00
Puranjay Savar Mattas fcb72ba9d6 fix(auth): login logo was blank, AppIcon app-icon set isn't Image()-loadable
Image("AppIcon") resolved to nothing at runtime (confirmed live) — App
Icon-type asset catalog entries aren't reliably retrievable through
Image(_:)/UIImage(named:) the way a normal Image Set is. Added AppLogo,
a plain Image Set duplicating the same outpost-ios-1024.png artwork, and
pointed AuthHeaderView at that instead.
2026-08-18 12:22:16 +01:00
Puranjay Savar Mattas 2b0924be04 fix(auth): use the real app icon on the login screen, not a placeholder
AuthHeaderView was still a generic gradient-circle + SF Symbol book icon
from before AppIcon.appiconset had real branded artwork — never swapped
out once the actual icon was added. Now renders Image("AppIcon") directly
so the login screen can't drift from whatever the Dock/Home Screen icon
actually is.
2026-08-18 12:09:52 +01:00
Puranjay Savar Mattas 931338c9d3 fix(session): don't show signed-in UI when only half the session survived
SessionStore.init treated a Keychain token alone as "signed in," without
checking the paired serverURL in UserDefaults also existed. UserDefaults
is scoped to the app's sandboxed container (keyed by bundle ID), while
Keychain items can survive a reinstall or bundle ID change independently
of it — hit this live after renaming the bundle ID: Keychain still had
the old token, the new container had no serverURL, so isSignedIn came
back true with apiClient nil. App landed on Home with nothing able to
load instead of the login screen.

Now requires both to be present to consider the session valid, and clears
whichever half survived otherwise so a fresh sign-in rewrites both
consistently.
2026-08-18 12:07:02 +01:00
Puranjay Savar Mattas 2740eaeaaf feat(settings): Notifications document-access-requested toggle
Closes out the Notifications page — all 13 event toggles + the All
notifications master toggle now wired.
2026-08-17 21:53:06 +01:00
Puranjay Savar Mattas 6986601b8b feat(settings): Notifications invited-to-collection/export-completed toggles 2026-08-17 21:52:58 +01:00
Puranjay Savar Mattas ddd6701446 feat(settings): Notifications invite-accepted/invited-to-document toggles 2026-08-17 21:52:50 +01:00
Puranjay Savar Mattas aee09cb60a feat(settings): Notifications reaction-added/collection-created toggles 2026-08-17 21:52:42 +01:00
Puranjay Savar Mattas 944d0bf373 feat(settings): Notifications group-mentions/resolved toggles 2026-08-17 21:52:33 +01:00
Puranjay Savar Mattas a1df1b3d3d feat(settings): Notifications comment-posted/mentioned toggles
"Mentioned" groups comments.mentioned + documents.mentioned under one
visible toggle, per Outline's own settings copy.
2026-08-17 21:52:25 +01:00
Puranjay Savar Mattas 8faa94f3cc feat(settings): Notifications document-published/document-updated toggles 2026-08-17 21:52:15 +01:00
Puranjay Savar Mattas bf1e28c9ac feat(settings): Notifications section skeleton + All-notifications toggle
Marks .notifications implemented, adds the shared setNotifications save
path (per-event subscribe/unsubscribe, sequential calls for toggles that
group more than one wire event type) and the master "All notifications"
row. Individual event toggles land in follow-up commits.
2026-08-17 21:52:07 +01:00
Puranjay Savar Mattas c05b787bb7 feat(session): track user notification settings
Same pattern as language/preferences — Notifications settings page reads
current subscribed state from here.
2026-08-17 21:51:24 +01:00
Puranjay Savar Mattas 960919227b feat(outlinekit): add notification subscription plumbing
NotificationEventType (wire keys confirmed live), OutlineUser.notification
Settings dictionary, subscribeToNotifications/unsubscribeFromNotifications
(users.notificationsSubscribe/Unsubscribe, nil eventType = all — confirmed
against the "All notifications" master toggle live too).
2026-08-17 21:51:07 +01:00
Puranjay Savar Mattas a11be29ba9 feat(settings): grey out server-synced settings while offline
Profile's name/avatar and every Preferences toggle (except Appearance,
which is local-only) now disable + show a hint when isEffectivelyOnline
is false, instead of letting a save silently fail. Matches how Share/
Permissions/Search already behave — these are a "needs a real connection"
category, not queued through the offline write queue (infrequent writes,
not worth a second offline-sync path for).
2026-08-17 21:49:11 +01:00
Puranjay Savar Mattas 564cce0637 fix(outlinekit): correct preferences wire keys to the real server shape
All preference toggles showed as off regardless of server state, and
saving notificationBadge 400'd with "notificationBadge: Invalid Input" —
the guessed wire keys/values from the earlier speculative commit were
wrong. Fixed against a live network capture of Outline's own web app
toggling every one of these settings:

- separateEditing -> seamlessEdit, and inverted (seamlessEdit is
  separate editing's negation, confirmed by toggling it live)
- showCommentMarker -> commentsInGutter
- smartText -> enableSmartText
- notificationBadge values -> "disabled"/"indicator"/"count", not the
  guessed "none"/"unread"/"all"
- rememberLastPath/useCursorPointer/codeBlockLineNumbers were already
  correct

Also preserves fullWidthDocuments (a real preference this app has no UI
for) on round-trip, since the client always sends the whole preferences
object back on save — dropping an unrecognized key during decode would
otherwise silently clear it the next time any toggle here gets saved.
2026-08-17 21:35:17 +01:00
Puranjay Savar Mattas 7e34d58a81 fix(settings): language picker breaks on a server locale not in our list
Live warning: "en_GB" invalid tag, undefined Picker display — the curated
OutlineLocale.all didn't include it. Added en_GB explicitly, and made the
Picker's item list always include whatever code the server actually
reports (falling back to the code itself as the label) so any future
unlisted locale degrades gracefully instead of breaking the control.
2026-08-17 21:26:12 +01:00
Puranjay Savar Mattas 384dfe3cc1 feat(settings): Preferences delete-account action
Danger subsection, confirmation dialog before calling users.delete
(deleteAccount()), signs out locally on success. Closes out the
Preferences page.
2026-08-15 20:31:50 +01:00
Puranjay Savar Mattas cac11f0a22 feat(settings): Preferences notification-badge picker
Closes out the Behavior subsection. Uses NotificationBadgeStyle
(None/Unread Indicator/Unread Count) — wire values are a guess same as
the other preference keys, flagged in OutlineUserPreferences.
2026-08-15 20:31:24 +01:00
Puranjay Savar Mattas 78e1c158aa feat(settings): Preferences smart-text-replacements toggle 2026-08-15 20:31:11 +01:00
Puranjay Savar Mattas eeb6dc2564 feat(settings): Preferences remember-previous-location toggle 2026-08-15 20:31:05 +01:00
Puranjay Savar Mattas 286fe80e58 feat(settings): Preferences separate-editing toggle
Starts the Behavior subsection.
2026-08-15 20:30:58 +01:00
Puranjay Savar Mattas 32600bf081 feat(settings): Preferences show-comment-marker toggle
Closes out the Display subsection.
2026-08-15 20:30:51 +01:00
Puranjay Savar Mattas bdb1b4977f feat(settings): Preferences show-line-numbers toggle 2026-08-15 20:30:44 +01:00
Puranjay Savar Mattas f5cfb53f96 feat(settings): Preferences use-pointer-cursor toggle 2026-08-15 20:30:38 +01:00
Puranjay Savar Mattas 73602d1ade feat(settings): shared save path for preference toggles
savePreference(_:) reads the current OutlineUserPreferences, flips one
field, sends the whole object via updateUserPreferences. Every Behavior/
Display toggle added next reuses this instead of its own copy of the
same read-modify-write.
2026-08-15 20:30:31 +01:00
Puranjay Savar Mattas a3651a9364 feat(settings): Preferences appearance setting
Reuses the existing local Appearance color-scheme picker instead of a
second divergent implementation — this has always been a device-side
preference in this app, not a server-synced one.
2026-08-15 20:30:08 +01:00
Puranjay Savar Mattas cfdae2f9ba feat(settings): Preferences section skeleton + Language setting
Marks .preferences implemented, adds the section header/description and
a Display subsection. Language is real and wired end-to-end (users.update
language field, high confidence per Outline's public API docs) — the rest
of the toggles land in follow-up commits, one setting at a time.
2026-08-15 20:29:54 +01:00
Puranjay Savar Mattas fdb4ba5c06 feat(session): track user language + preferences
Mirrors the existing name/avatar/id fields — Settings' Preferences page
needs somewhere to read current values from and applyUpdatedProfile
already refreshes everything else on save.
2026-08-15 20:29:06 +01:00
Puranjay Savar Mattas eb4e1472b9 feat(outlinekit): add language/preferences plumbing for Preferences settings
OutlineUser gains language + preferences fields, new updateUserLanguage/
updateUserPreferences/deleteAccount client methods. Preference wire keys
are best-effort against Outline's own naming, same speculative treatment
as OutlinePin/OutlineDocumentMember — expect a correction round once
tested against a live server.
2026-08-15 20:28:38 +01:00
26 changed files with 1804 additions and 67 deletions
@@ -355,6 +355,42 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
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] {
@@ -83,4 +83,26 @@ public protocol OutlineAPIClient: Sendable {
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
}
@@ -290,6 +290,42 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
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<Body: Encodable, Response: Decodable>(_ path: String, body: Body) async throws -> Response {
guard let token = try? tokenStore.token() else {
throw OutlineAPIError.tokenUnavailable
@@ -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"
}
@@ -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
}
}
@@ -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
}
@@ -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
}
}
@@ -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"
}
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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
}
}
@@ -94,6 +94,15 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
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 {}
@@ -1074,6 +1074,318 @@ final class LiveOutlineAPIClientTests: XCTestCase {
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 = """
+24 -12
View File
@@ -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;
+21
View File
@@ -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
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

+1 -11
View File
@@ -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()))
@@ -1,5 +1,6 @@
#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
@@ -8,13 +9,26 @@ import SwiftUI
/// back.
///
/// Grouped by `SettingsCategory` `general` (ours) sits under an "Outpost"
/// header at the top, then Outline's own Account/Workspace/Integrations &
/// Installation groups, matching the settings page structure of the
/// Outline web app.
/// 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 {
@@ -37,7 +51,21 @@ struct SettingsSidebarList: View {
}
} header: {
if let title = category.title {
Text(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())
}
}
}
}
}
@@ -46,12 +74,42 @@ struct SettingsSidebarList: View {
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
+887 -3
View File
@@ -32,6 +32,28 @@ struct SettingsView: View {
@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
@@ -71,6 +93,10 @@ struct SettingsView: View {
switch section {
case .appearance: appearanceDetail
case .profile: profileDetail
case .preferences: preferencesDetail
case .notifications: notificationsDetail
case .passkeys: passkeysDetail
case .apiAccess: apiAccessDetail
case .offlineSync: offlineSyncDetail
case .advanced: advancedDetail
case .about: aboutDetail
@@ -78,9 +104,8 @@ struct SettingsView: View {
}
}
/// Everything in Account/Workspace/Integrations & Installation that
/// isn't `.profile` real content lands section by section; this is
/// just the nav skeleton until then.
/// 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
@@ -119,6 +144,9 @@ struct SettingsView: View {
private var profileDetail: some View {
VStack(alignment: .leading, spacing: 24) {
sectionHeader
if !isEffectivelyOnline {
offlineSettingsHint
}
avatarRow
Divider().frame(maxWidth: 420)
nameRow
@@ -176,6 +204,7 @@ struct SettingsView: View {
ProgressView().controlSize(.small)
}
}
.disabled(!isEffectivelyOnline)
if let avatarErrorMessage {
Text(avatarErrorMessage)
.font(.caption)
@@ -204,6 +233,7 @@ struct SettingsView: View {
}
}
}
.disabled(!isEffectivelyOnline)
if let nameErrorMessage {
Text(nameErrorMessage)
.font(.caption)
@@ -231,6 +261,805 @@ struct SettingsView: View {
.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<Content: View>(
_ 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<NotificationBadgeStyle> {
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<String> {
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
private var offlineSyncDetail: some View {
@@ -480,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)
@@ -490,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)
}
@@ -628,4 +1475,41 @@ 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
+11 -15
View File
@@ -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")
+11 -18
View File
@@ -8,7 +8,6 @@ enum SettingsCategory: String, CaseIterable, Identifiable {
case general
case account
case workspace
case integrationsInstallation
var id: String { rawValue }
@@ -17,19 +16,20 @@ enum SettingsCategory: String, CaseIterable, Identifiable {
case .general: return "Outpost"
case .account: return "Account"
case .workspace: return "Workspace"
case .integrationsInstallation: return "Integrations & Installation"
}
}
}
/// One entry in the Settings sidebar. Mirrors Outline's own settings
/// categories (Account/Workspace/Integrations & Installation) 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).
/// 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/Integrations cases are navigation-only for
/// now `SettingsView` renders a "Coming Soon" placeholder for anything not
/// 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)
@@ -41,9 +41,6 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
// Workspace
case details, authentication, security, ai, members, groups, templates, emojis, applications, shared, links, webhooks, importData, exportData
// Integrations & Installation
case installation
var id: String { rawValue }
var category: SettingsCategory {
@@ -54,8 +51,6 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
return .account
case .details, .authentication, .security, .ai, .members, .groups, .templates, .emojis, .applications, .shared, .links, .webhooks, .importData, .exportData:
return .workspace
case .installation:
return .integrationsInstallation
}
}
@@ -84,7 +79,6 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
case .webhooks: return "Webhooks"
case .importData: return "Import"
case .exportData: return "Export"
case .installation: return "Installation"
}
}
@@ -113,16 +107,15 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
case .webhooks: return "bolt.horizontal"
case .importData: return "square.and.arrow.down"
case .exportData: return "square.and.arrow.up"
case .installation: return "shippingbox"
}
}
/// Everything actually built so far everything else in Account/
/// Workspace/Integrations & Installation renders a "Coming Soon"
/// placeholder until its content is specified and built.
/// Workspace renders a "Coming Soon" placeholder until its content is
/// specified and built.
var isImplemented: Bool {
switch self {
case .appearance, .offlineSync, .advanced, .about, .profile:
case .appearance, .offlineSync, .advanced, .about, .profile, .preferences, .notifications, .passkeys, .apiAccess:
return true
default:
return false
+31 -3
View File
@@ -15,6 +15,9 @@ final class SessionStore {
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?
@@ -36,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
}
}
@@ -73,6 +92,9 @@ final class SessionStore {
userName = nil
userEmail = nil
userAvatarURL = nil
userLanguage = nil
userPreferences = nil
userNotificationSettings = nil
teamName = nil
teamAvatarURL = nil
apiClient = nil
@@ -95,6 +117,9 @@ final class SessionStore {
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) {
@@ -105,6 +130,9 @@ final class SessionStore {
// (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 }
}
+35
View File
@@ -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
}
}
+31
View File
@@ -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))"
}
}