26 Commits
Author SHA1 Message Date
Puranjay Savar Mattas 6a2d5c9be6 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 2fc279c3ce feat(settings): Notifications invited-to-collection/export-completed toggles 2026-08-17 21:52:58 +01:00
Puranjay Savar Mattas c0e3ae2b88 feat(settings): Notifications invite-accepted/invited-to-document toggles 2026-08-17 21:52:50 +01:00
Puranjay Savar Mattas ed00703d5c feat(settings): Notifications reaction-added/collection-created toggles 2026-08-17 21:52:42 +01:00
Puranjay Savar Mattas 9999ed1392 feat(settings): Notifications group-mentions/resolved toggles 2026-08-17 21:52:33 +01:00
Puranjay Savar Mattas 0ee46536f7 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 73119c483a feat(settings): Notifications document-published/document-updated toggles 2026-08-17 21:52:15 +01:00
Puranjay Savar Mattas a979b5b6e0 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 b421afa417 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 580dde44bc 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 6c1121e750 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 fbbb8b8199 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 1c0cbffe27 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 e6884cda82 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 155037c40b 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 e1ba4218dd feat(settings): Preferences smart-text-replacements toggle 2026-08-15 20:31:11 +01:00
Puranjay Savar Mattas 44487bf26e feat(settings): Preferences remember-previous-location toggle 2026-08-15 20:31:05 +01:00
Puranjay Savar Mattas d443bb2342 feat(settings): Preferences separate-editing toggle
Starts the Behavior subsection.
2026-08-15 20:30:58 +01:00
Puranjay Savar Mattas 5d755247f4 feat(settings): Preferences show-comment-marker toggle
Closes out the Display subsection.
2026-08-15 20:30:51 +01:00
Puranjay Savar Mattas c3a4ccc16b feat(settings): Preferences show-line-numbers toggle 2026-08-15 20:30:44 +01:00
Puranjay Savar Mattas 46b67eac33 feat(settings): Preferences use-pointer-cursor toggle 2026-08-15 20:30:38 +01:00
Puranjay Savar Mattas 49b00d6c80 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 a4c09f28a0 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 ba3ede9ae2 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 8b0b233265 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 5a522ec0e1 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
19 changed files with 66 additions and 842 deletions
@@ -375,22 +375,6 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
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] {
@@ -94,15 +94,4 @@ public protocol OutlineAPIClient: Sendable {
/// `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
}
@@ -310,22 +310,6 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
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
@@ -1,40 +0,0 @@
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
}
}
@@ -1,11 +0,0 @@
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
}
@@ -1,21 +0,0 @@
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
}
}
@@ -1,14 +0,0 @@
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
}
}
@@ -99,10 +99,6 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
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 {}
@@ -1229,146 +1229,6 @@ final class LiveOutlineAPIClientTests: XCTestCase {
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 = """
+12 -24
View File
@@ -299,7 +299,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = CW6GQT9SK5;
DEVELOPMENT_TEAM = B95H74ZDY6;
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 = CW6GQT9SK5;
DEVELOPMENT_TEAM = B95H74ZDY6;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -385,20 +385,15 @@
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 = CW6GQT9SK5;
DEVELOPMENT_TEAM = B95H74ZDY6;
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;
@@ -413,10 +408,9 @@
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.4;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp;
MARKETING_VERSION = 0.0.3;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
REGISTER_APP_GROUPS = YES;
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -436,20 +430,15 @@
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 = CW6GQT9SK5;
DEVELOPMENT_TEAM = B95H74ZDY6;
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;
@@ -464,10 +453,9 @@
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.4;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp;
MARKETING_VERSION = 0.0.3;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
REGISTER_APP_GROUPS = YES;
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -488,7 +476,7 @@
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = CW6GQT9SK5;
DEVELOPMENT_TEAM = B95H74ZDY6;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
MACOSX_DEPLOYMENT_TARGET = 27.0;
@@ -514,7 +502,7 @@
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = CW6GQT9SK5;
DEVELOPMENT_TEAM = B95H74ZDY6;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
MACOSX_DEPLOYMENT_TARGET = 27.0;
@@ -539,7 +527,7 @@
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = CW6GQT9SK5;
DEVELOPMENT_TEAM = B95H74ZDY6;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
MACOSX_DEPLOYMENT_TARGET = 27.0;
@@ -564,7 +552,7 @@
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = CW6GQT9SK5;
DEVELOPMENT_TEAM = B95H74ZDY6;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
MACOSX_DEPLOYMENT_TARGET = 27.0;
-21
View File
@@ -1,21 +0,0 @@
{
"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.

Before

Width:  |  Height:  |  Size: 440 KiB

+11 -1
View File
@@ -14,7 +14,17 @@ struct AboutInfoView: View {
Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "Outpost"
}
var versionString: String { OutpostVersion.fullVersionString }
/// 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))"
}
private var copyrightYear: String {
String(Calendar.current.component(.year, from: Date()))
@@ -1,6 +1,5 @@
#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
@@ -9,26 +8,13 @@ import OutlineKit
/// back.
///
/// Grouped by `SettingsCategory` `general` (ours) sits under an "Outpost"
/// header at the top, then Outline's own Account/Workspace groups, matching
/// the settings page structure of the Outline web app. Outline's own server
/// version has no dedicated section (there used to be an Integrations &
/// Installation category for just that) it's cheap enough to show
/// unconditionally in the footer here instead, alongside Outpost's own
/// version.
/// header at the top, then Outline's own Account/Workspace/Integrations &
/// Installation groups, matching the settings page structure of the
/// Outline web app.
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 {
@@ -51,21 +37,7 @@ struct SettingsSidebarList: View {
}
} header: {
if let title = category.title {
HStack(spacing: 6) {
Text(title)
// Not hardcoded to `.workspace` specifically
// stays correct on its own as sections get
// built, only shows while every section in
// the category is still `!isImplemented`.
if sections.allSatisfy({ !$0.isImplemented }) {
Text("Coming Soon")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(.secondary.opacity(0.15), in: Capsule())
}
}
Text(title)
}
}
}
@@ -74,42 +46,12 @@ 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
+3 -389
View File
@@ -41,19 +41,6 @@ struct SettingsView: View {
@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
@@ -95,8 +82,6 @@ struct SettingsView: View {
case .profile: profileDetail
case .preferences: preferencesDetail
case .notifications: notificationsDetail
case .passkeys: passkeysDetail
case .apiAccess: apiAccessDetail
case .offlineSync: offlineSyncDetail
case .advanced: advancedDetail
case .about: aboutDetail
@@ -104,8 +89,9 @@ struct SettingsView: View {
}
}
/// Everything in Account/Workspace that isn't `.profile` real content
/// lands section by section; this is just the nav skeleton until then.
/// Everything in Account/Workspace/Integrations & Installation 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
@@ -729,337 +715,6 @@ struct SettingsView: View {
}
}
// 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 {
@@ -1333,10 +988,6 @@ 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)
}
@@ -1475,41 +1126,4 @@ 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
+15 -11
View File
@@ -3,17 +3,21 @@ import SwiftUI
struct AuthHeaderView: View {
var body: some View {
VStack(spacing: 12) {
// 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)
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)
VStack(spacing: 4) {
Text("Welcome to Outpost")
+18 -11
View File
@@ -8,6 +8,7 @@ enum SettingsCategory: String, CaseIterable, Identifiable {
case general
case account
case workspace
case integrationsInstallation
var id: String { rawValue }
@@ -16,20 +17,19 @@ 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) 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.
/// 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).
///
/// Most of the Account/Workspace cases are navigation-only for now
/// `SettingsView` renders a "Coming Soon" placeholder for anything not
/// Most of the Account/Workspace/Integrations 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,6 +41,9 @@ 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 {
@@ -51,6 +54,8 @@ 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
}
}
@@ -79,6 +84,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
case .webhooks: return "Webhooks"
case .importData: return "Import"
case .exportData: return "Export"
case .installation: return "Installation"
}
}
@@ -107,15 +113,16 @@ 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 renders a "Coming Soon" placeholder until its content is
/// specified and built.
/// Workspace/Integrations & Installation renders a "Coming Soon"
/// placeholder until its content is specified and built.
var isImplemented: Bool {
switch self {
case .appearance, .offlineSync, .advanced, .about, .profile, .preferences, .notifications, .passkeys, .apiAccess:
case .appearance, .offlineSync, .advanced, .about, .profile, .preferences, .notifications:
return true
default:
return false
+3 -19
View File
@@ -39,27 +39,11 @@ 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:))
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
if isSignedIn, let serverURL {
(apiClient, cachingClient) = Self.makeAPIClient(serverURL: serverURL, tokenStore: tokenStore, cache: cacheStore)
}
}
-31
View File
@@ -1,31 +0,0 @@
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))"
}
}