13 Commits
Author SHA1 Message Date
Puranjay Savar Mattas 48377668b0 Merge pull request 'v0.1.0 stability, security, and App Store readiness' (#14) from chore/v0.1.0-stability-audit into main
Reviewed-on: #14
2026-08-21 02:02:23 +01:00
Puranjay Savar Mattas 8db8e985a7 release: drop alpha framing, TestFlight badge -> Mac App Store, bump to 0.1.0
App Store link: https://apps.apple.com/us/app/outpost-for-outline/id6802736230

README's TestFlight badge replaced with Apple's official "Download on
the Mac App Store" badge (docs/assets/mac-app-store-badge.svg, black
lockup, from Apple's official marketing badge kit), linked to the
real App Store listing. "Early alpha" language dropped from README,
CONTRIBUTING.md, SECURITY.md, and both Gitea issue templates -
these are now "0.1.x"/"early" rather than "0.0.x"/"alpha", matching
the actual release.

OutpostVersion.releaseStage is now "" instead of "ALPHA" - About page
and the Settings sidebar footer both read through this single source
of truth, so this alone drops the "-ALPHA" suffix everywhere it was
shown without touching either call site.

MARKETING_VERSION bumped 0.0.4 -> 0.1.0 for the Outpost target
(Debug + Release) - left OutpostTests/OutpostUITests' MARKETING_VERSION
alone, that's just Xcode's unrelated template default for test
bundles, never shown to a user.

Not compiler-verified - Outpost app target has no CLI build path.
2026-08-21 01:58:39 +01:00
Puranjay Savar Mattas b8ce517b60 perf: stop recomputing derived state on every SwiftUI render
An audit for v0.1.0 turned up the same pattern in four places: a
computed property doing real work (filtering/sorting/scoring a
collection), read multiple times per render including from
unrelated state changes (selection, hover, scroll), so the work
reran far more often than the underlying data actually changed.
Converted each to a @State cache recomputed only via onChange of its
real inputs:

- DocumentSearchSheet: matchingLineIndices re-scanned the whole
  document per access, read once per visible row plus twice more in
  the header/step logic - O(n^2) case-insensitive scan per frame on
  a large document. Also split into an ordered array (for
  currentMatchIndex/stepping) plus a parallel Set for the per-row
  highlight check, which was an O(k) linear .contains before.
- CollectionDocumentsOutline: tree rebuilt the whole dictionary-
  grouped, recursively-sorted document tree on every body
  evaluation, not just when documents/sortOption actually changed.
- CommandPaletteView: results re-scored and re-sorted the entire
  index (up to the whole local workspace cache in Full Workspace
  mode) on every render, including ones from selectedIndex moving as
  arrow keys are pressed.
- CollectionOverviewView: sortedDocuments re-sorted on every render;
  same pattern, smaller blast radius (capped at 100 docs).

Also:
- HomeViewModel.fetchPinnedThrowing fetched each pinned document
  serially in a for loop (one round trip at a time) - switched to a
  TaskGroup so latency doesn't scale with pin count, results
  reordered back to pins.list's own order since task completion
  order isn't submission order.
- AvatarCropperView.renderFinalImage ran ImageRenderer + JPEG
  compression synchronously on the main actor from the "Use Photo"
  button tap. ImageRenderer itself has to stay on the main actor (it
  captures live SwiftUI state), but JPEG compression on the already-
  rendered bitmap has no SwiftUI dependency left - hopped that part
  to a detached Task via tiffRepresentation (plain Data, unlike
  NSImage itself isn't Sendable) so it doesn't hitch the UI.

No crash risks or retain cycles found in the same audit (no try!/
as!, force-unwraps essentially absent outside a hardcoded URL
literal, weak self already used where it matters) - this is purely
the perf half of the findings.

Not compiler-verified - Outpost app target has no CLI build path.
2026-08-21 01:48:20 +01:00
Puranjay Savar Mattas 5de445daa5 feat(app): rewire account footer menu - App Store feedback, support link
Center-aligned TipJarView's "Support Outpost" heading and thank-you/
error text to match the rest of AboutInfoView (was VStack(alignment:
.leading), out of place among everything else there being centered).

AccountFooter's Documentation/API Documentation/Changelog links
pointed at Outpost's own repo or the signed-in Outline server's own
/developers page - not actually useful here, removed along with the
now-unused repositoryURL/issuesURL/apiDocumentationURL. Send Us
Feedback and Report a Bug (previously both just opening the Gitea
issues page) collapsed into one "Leave Us Feedback" wired to Apple's
native requestReview() prompt - there's no separate Apple-native
channel for "bug" vs "feedback", so one button covers both.

Added "Support Outpost" near the bottom of the same menu, alongside
Profile/Settings (same visual weight, not pinned to the top) - opens
Settings -> About, same navigation the app-menu's "About Outpost"
command already uses.

Also checking in the shared Xcode scheme (previously untracked/
nonexistent) now that it references Configuration.storekit, so the
StoreKit testing setup travels with the repo instead of being
machine-local.

Not compiler-verified - Outpost app target has no CLI build path.
2026-08-21 01:38:11 +01:00
Puranjay Savar Mattas 8ca5735019 feat(app): tip jar (StoreKit consumables) in Settings -> About
Four consumable IAP tiers - Small (0.99), Medium (2.99), Large (4.99),
Generous (9.99), product ids com.psmattas.OutpostApp.tip.{small,
medium,large,generous}. TipJarStore loads them via
Product.products(for:), purchases via product.purchase(), finishes
the transaction immediately on success - consumables have no
entitlement to persist or restore (a tip doesn't unlock anything), so
there's none of the Transaction.currentEntitlements restore-on-launch
logic a real purchase would need. TipJarView shows one button per
tier (price + name, StoreKit's own localized display strings) with a
"Thank you!" after success or a plain message on failure - no manual
retry button, tapping a tier again just re-attempts.

Wired into AboutInfoView between the source link and the copyright
line.

Added Configuration.storekit (4 products matching the IDs above) for
local testing in Xcode without needing real App Store Connect
products yet - enable it via Edit Scheme -> Run/Preview -> Options ->
StoreKit Configuration. Before actually shipping, the same 4 product
IDs need to exist for real in App Store Connect (Consumable type,
matching reference names) - the local file doesn't create anything
there.

Not compiler-verified - Outpost app target has no CLI build path.
2026-08-21 01:23:48 +01:00
Puranjay Savar Mattas f624ce6c9f feat(app): clear the cache encryption key on sign-out, note it in Settings
SessionStore.signOut() now clears the offline cache's Keychain-stored
encryption key alongside the API token, and wipes the cache/pending-
write storage itself (CachingOutlineAPIClient.clearEverythingForSignOut())
before doing so - so a previous account's cached content isn't sitting
there readable (even in principle, if the on-disk rows survive) by
whoever signs in next on the same machine. signOut() is async now to
do this properly instead of firing a detached Task; both call sites
(the logout confirmation dialog, delete-account) updated.

Settings -> Offline & Sync now states plainly that the local cache is
encrypted at rest and cleared on log out - not compiler-verified
(Outpost app target has no CLI build path), worth a look in Xcode.
2026-08-21 01:15:26 +01:00
Puranjay Savar Mattas 20baab78c0 feat(outlinekit): encrypt the offline cache at rest
Both CachedPayload and PendingOperation only ever stored their
payload as plain JSON on disk (SwiftData/SQLite, no encryption of its
own) - readable by anyone with access to the logged-in session, per
the earlier discussion on where this cache lives. Adds AES-GCM
encryption at the one place raw bytes cross into/out of
OfflineCacheStore (CachingOutlineAPIClient, which already owns
encode/decode) - OfflineCacheStore itself stays a dumb opaque-blob
store, since its key/id/kind columns can't be encrypted without
breaking the #Predicate queries built against them.

Key management (KeychainCacheEncryptionKeyStore, mirrors
KeychainTokenStore exactly): a random 256-bit key, generated once and
Keychain-stored, not derived from anything guessable. It doesn't need
deriving to survive an uninstall/reinstall either - Keychain items
are scoped to the app's code signature, not its on-disk presence, so
a reinstall of the same app regains access to the same key
automatically (same reason a saved API token already survives a
reinstall today). If the on-disk cache also happens to survive
(dragging the .app to the Trash doesn't clean ~/Library/Containers),
a reinstall can still read it.

A row written before this shipped (still plaintext) or encrypted
under a since-cleared key just fails to decrypt and is treated as a
cache miss - same as any other decode failure, so it silently
refetches and re-caches encrypted rather than crashing. No explicit
migration needed.

Also: OfflineCacheStore.clearEverything() wipes both the cache AND
the pending write queue (clearAll(), used by Settings' "Clear All
Cache", still only touches the cache - it shouldn't silently discard
someone's unsynced edits). Exposed as
CachingOutlineAPIClient.clearEverythingForSignOut(), for sign-out to
use alongside clearing the key.

CacheEncryptionKeyStoring is a protocol (like TokenStoring) so tests
never touch the real Keychain - existing CachingOutlineAPIClientTests
now inject an in-memory StaticCacheEncryptionKeyStore. 8 new tests
(retry/failure-log tests from the previous commit plus 3 new ones
here: ciphertext isn't plaintext JSON, a cleared key makes old rows
unreadable, clearEverythingForSignOut wipes both tables). 95/95
passing.
2026-08-21 01:15:15 +01:00
Puranjay Savar Mattas 1580510cfb fix(engine): silence PERF logs by default, fix "modifying state during view update"
PerfTrace was opt-out (MD_PERF=0 to silence) so every Debug build
printed a PERF line per keystroke unconditionally. Flipped to opt-in
(MD_PERF=1 to enable) - still fully available for future perf work,
just quiet by default.

"Modifying state during view update": onCodeBlockSelectionChange,
onSelectedTextChange, and onCommentAnchorRectsChange could all fire
synchronously from inside NativeTextViewWrapper.updateNSView's own
call stack - a programmatic edit (pendingInlineReplacement/
pendingTextInsertion/pendingTextRangeReplacement) re-enters
textViewDidChangeSelection/textDidChange synchronously (AppKit resets
selection on edit), which is still a SwiftUI view update in progress.
Calling straight into the embedder's @State setter there is exactly
what trips the warning. Routed all three through new
fireCodeBlockSelectionChange/fireSelectedTextChange/
fireCommentAnchorRectsChange helpers on the coordinator that defer
one runloop tick via DispatchQueue.main.async - same technique
NativeTextViewWrapper already uses to clear its own pending*
bindings, just centralized instead of ad-hoc per call site.

322/322 tests passing.
2026-08-21 00:56:37 +01:00
Puranjay Savar Mattas 5b876d7085 chore: remove Keyboard Shortcuts menu item and window
No real app-specific shortcuts existed - the panel only listed
Return/⌘,/⌘W/⌘Q (standard macOS conventions everyone already knows,
and it didn't even include the app's actual shortcuts like ⌘K for
Command Palette). Removed the menu item from AccountFooter's bottom
menu, the Window scene that hosted it, and KeyboardShortcutsView
itself plus WindowConfigurator.swift (disablesFullScreen() had no
other caller once this was gone).
2026-08-21 00:50:39 +01:00
Puranjay Savar Mattas 5d5cda9cea fix: RetryPolicy.withRetry closure label, missing Foundation import
RetryPolicy.withRetry's operation param wasn't anonymous (_), so the
14 Outpost call sites that pass the closure in parens - RetryPolicy.
withRetry({ ... }) - rather than as a trailing closure failed to
compile ("Missing argument label 'operation:'" cascading into
nonsense errors about maxAttempts). OutlineKit's own internal call
sites all happened to use trailing-closure syntax, so this only
showed up once the app target actually got compiled. One-line fix at
the declaration (_ operation:) instead of touching every call site -
trailing-closure calls are unaffected either way.

APIFailureCenter.swift used Date/TimeInterval/URL/URLComponents/
URLQueryItem while only importing Observation and OutlineKit -
missing import Foundation. Other new files in the same commit escaped
this because they import SwiftUI, which re-exports Foundation
transitively; this one didn't.

OutlineKit: 92/92 still passing.
2026-08-21 00:48:17 +01:00
Puranjay Savar Mattas 7369b46b87 feat(app): auto-retry every remaining try?-swallowed API call, add repeated-failure banner
Second half of the silent-failure fix - OutlineKit's RetryPolicy and
CachingOutlineAPIClient tracking landed in 512c6d2, this wires the
rest of the app onto it.

Every bare `try? await apiClient.X(...)` that bypasses
CachingOutlineAPIClient's own caching (listPins, listSubscriptions,
listViews, listStars, documentUsers, listUsers, listComments,
currentUser, installationInfo, authInfo, deleteAttachment - the
"pass-through" methods) now goes through RetryPolicy.withRetry first,
so a single transient blip gets absorbed automatically instead of
just returning nil. Calls that were already routed through
CachingOutlineAPIClient's cached-read path (documentInfo,
listDocuments, listCollections, etc.) are left alone - they picked up
retry and repeated-failure tracking for free from the previous commit
and wrapping them again would've just retried twice.

New: APIFailureCenter (Root/) turns CachingOutlineAPIClient's
repeatedFailureSummaries() into a banner - RootView polls it every
30s while signed in (cheap, no network call of its own) and shows
RepeatedFailureBanner for whichever category is currently past the
threshold. No manual "Retry" button - the retries already happened
automatically before the banner ever appears, so the only actions are
Report (opens a prefilled Gitea issue - category, generic error
description, app/OS version, no document content or server URL) and
dismiss, which starts a 15-minute cooldown so a still-flaky operation
doesn't immediately pop the same banner back up.

Not compiler-verified - the Outpost app target has no CLI build path,
only OutlineKit does (92/92 passing as of the previous commit, no
OutlineKit changes here).
2026-08-21 00:45:58 +01:00
Puranjay Savar Mattas 512c6d22bf feat(outlinekit): auto-retry with backoff and repeated-failure tracking
Foundation for turning the app's try?-swallowed API failures (see the
pins bug) into something self-diagnosing instead of silent, without
a manual "Retry" button nagging the user for every blip.

RetryPolicy.withRetry wraps a call with exponential backoff, but only
for OutlineAPIError.transport - a decode/auth/server error will look
identical on a second try, so those fail immediately instead of
burning the cooldown window. CachingOutlineAPIClient now runs every
live call (both the cached-read path and the queueable-write path)
through it, and keeps a per-category sliding-window failure log:
repeatedFailureSummaries() surfaces a category only once it's failed
3+ times in 5 minutes with a structural (non-transport) error -
plain connectivity loss is deliberately excluded since that already
has its own offline UI elsewhere, and logging it here too would just
be a redundant second banner every time Wi-Fi drops.

Pull-based (polled), not push - this actor has no UI dependency of
its own, so the Outpost-side banner reads this periodically instead
of the client taking a callback. Categories are coarse (documents,
collections, pins, subscriptions, stars, drafts, etc.) and the
summaries carry no document content or server URL, only a generic
error description - safe to show a user or attach to a bug report
as-is.

10 new tests (RetryPolicyTests + CachingOutlineAPIClientTests),
92/92 passing overall.
2026-08-21 00:41:26 +01:00
Puranjay Savar Mattas aa02153b5d chore: hide unbuilt Workspace section and Advanced coming-soon rows
App Store review won't accept a settings section that's just "Coming
Soon" placeholders. Workspace (all 14 sub-sections: details,
authentication, security, ai, members, groups, templates, emojis,
applications, shared, links, webhooks, importData, exportData) had
zero built content, so it's filtered out of SettingsSidebarList
entirely rather than shown with a Coming Soon badge - via a new
visibleCategories helper (categories with at least one isImplemented
section), not by touching the SettingsSection enum itself, so nothing
else that switches over it needs to change.

Advanced's three comingSoonRow placeholders (Export All Data,
Developer Diagnostics, Reset Local Database) are commented out the
same way, comingSoonRow() itself kept (unused for now) so re-enabling
either is a one-line job once real content lands. Both marked TODO.
2026-08-21 00:34:53 +01:00
49 changed files with 1458 additions and 250 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
Outpost is early alpha — please check the version in About (or your build's commit) is current before filing, and mention which platform (macOS only, for now) and OS version you're on. Outpost is early — please check the version in About (or your build's commit) is current before filing, and mention which platform (macOS only, for now) and OS version you're on.
- type: input - type: input
id: summary id: summary
attributes: attributes:
+1 -1
View File
@@ -6,7 +6,7 @@ body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
Outpost is early alpha and tracking Outline's own web app for parity (see [`CLAUDE.md`](../../CLAUDE.md) for the phased build order) — a request that's "just do what web Outline does" is easier to act on than a net-new idea. Outpost is early and tracking Outline's own web app for parity (see [`CLAUDE.md`](../../CLAUDE.md) for the phased build order) — a request that's "just do what web Outline does" is easier to act on than a net-new idea.
- type: input - type: input
id: summary id: summary
attributes: attributes:
+1 -1
View File
@@ -2,7 +2,7 @@
Thank you for contributing. Please read this guide before opening issues or PRs. Thank you for contributing. Please read this guide before opening issues or PRs.
Outpost is early alpha (`0.0.x`) — expect the codebase and conventions here to shift as Phase 1 (see [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)) settles. If something in this guide is stale, flag it. Outpost is early (`0.1.x`) — expect the codebase and conventions here to keep evolving as later phases (see [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)) land. If something in this guide is stale, flag it.
--- ---
@@ -0,0 +1,16 @@
import Foundation
import CryptoKit
/// Boundary over wherever the offline cache's symmetric encryption key
/// lives. Mirrors `TokenStoring` same reasoning, different secret.
public protocol CacheEncryptionKeyStoring: Sendable {
/// Returns the existing key, generating and persisting a new random one
/// on first use if none exists yet.
func key() throws -> SymmetricKey
/// Called on sign-out. Anything still encrypted with the cleared key
/// becomes permanently unreadable that's the point, not a bug: the
/// next person signed in on this machine shouldn't be able to read a
/// previous account's cached content just because the on-disk rows
/// happen to still be there.
func clear() throws
}
@@ -0,0 +1,24 @@
import Foundation
import CryptoKit
/// AES-GCM at the boundary where `CachingOutlineAPIClient` writes to/reads
/// from `OfflineCacheStore`. Encryption lives at this layer rather than
/// inside `OfflineCacheStore` itself that stays a dumb opaque-blob store;
/// its `key`/`id`/`kind` columns can't be encrypted without breaking the
/// `#Predicate` queries built directly against them.
enum CachePayloadCryptor {
enum CryptoError: Error {
case sealingFailed
}
static func encrypt(_ data: Data, key: SymmetricKey) throws -> Data {
let sealedBox = try AES.GCM.seal(data, using: key)
guard let combined = sealedBox.combined else { throw CryptoError.sealingFailed }
return combined
}
static func decrypt(_ data: Data, key: SymmetricKey) throws -> Data {
let sealedBox = try AES.GCM.SealedBox(combined: data)
return try AES.GCM.open(sealedBox, using: key)
}
}
@@ -1,4 +1,5 @@
import Foundation import Foundation
import CryptoKit
/// Decorates `LiveOutlineAPIClient` (or any `OutlineAPIClient`) with offline /// Decorates `LiveOutlineAPIClient` (or any `OutlineAPIClient`) with offline
/// support at the existing protocol boundary, so no view model needs to know /// support at the existing protocol boundary, so no view model needs to know
@@ -36,11 +37,33 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
private let encoder: JSONEncoder private let encoder: JSONEncoder
private let decoder: JSONDecoder private let decoder: JSONDecoder
private let keyEncoder: JSONEncoder private let keyEncoder: JSONEncoder
private let encryptionKeyStore: CacheEncryptionKeyStoring
/// Resolved lazily and kept for this instance's lifetime a fresh
/// instance is created on every sign-in/sign-out anyway (see
/// `SessionStore.makeAPIClient`), so there's no staleness risk, just
/// one fewer Keychain round-trip per cache read/write.
private var cachedEncryptionKey: SymmetricKey?
public init(live: OutlineAPIClient, cache: OfflineCacheStore, defaults: UserDefaults = .standard) { /// Recent failure timestamps per category see `recordFailure` /
/// `repeatedFailureSummaries()`. A category only shows up there once it's
/// failed `failureThreshold` times within `failureWindow`; a single
/// transient blip (which `RetryPolicy` already tries to absorb) never
/// reaches this at all.
private var failureLog: [String: [Date]] = [:]
private var lastFailureMessage: [String: String] = [:]
private let failureWindow: TimeInterval = 300
private let failureThreshold: Int = 3
public init(
live: OutlineAPIClient,
cache: OfflineCacheStore,
defaults: UserDefaults = .standard,
encryptionKeyStore: CacheEncryptionKeyStoring = KeychainCacheEncryptionKeyStore()
) {
self.live = live self.live = live
self.cache = cache self.cache = cache
self.defaults = defaults self.defaults = defaults
self.encryptionKeyStore = encryptionKeyStore
let encoder = JSONEncoder() let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601 encoder.dateEncodingStrategy = .iso8601
@@ -62,7 +85,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
// MARK: - Cached reads // MARK: - Cached reads
public func documentInfo(id: String) async throws -> OutlineDocument { public func documentInfo(id: String) async throws -> OutlineDocument {
try await cachedFetch(key: "document:\(id)") { try await self.live.documentInfo(id: id) } try await cachedFetch(key: "document:\(id)", category: "document") { try await self.live.documentInfo(id: id) }
} }
public func listDocuments( public func listDocuments(
@@ -72,7 +95,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
limit: Int limit: Int
) async throws -> [OutlineDocument] { ) async throws -> [OutlineDocument] {
let key = "documents:\(collectionId ?? "-"):\(parentDocumentId ?? "-"):\(offset):\(limit)" let key = "documents:\(collectionId ?? "-"):\(parentDocumentId ?? "-"):\(offset):\(limit)"
return try await cachedFetch(key: key) { return try await cachedFetch(key: key, category: "documents") {
try await self.live.listDocuments( try await self.live.listDocuments(
collectionId: collectionId, collectionId: collectionId,
parentDocumentId: parentDocumentId, parentDocumentId: parentDocumentId,
@@ -83,29 +106,29 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
} }
public func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] { public func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] {
try await cachedFetch(key: requestKey("documentsList", request)) { try await self.live.documentsList(request) } try await cachedFetch(key: requestKey("documentsList", request), category: "documents") { try await self.live.documentsList(request) }
} }
public func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] { public func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] {
try await cachedFetch(key: "documentsViewed:\(offset):\(limit)") { try await cachedFetch(key: "documentsViewed:\(offset):\(limit)", category: "documents-viewed") {
try await self.live.listViewedDocuments(offset: offset, limit: limit) try await self.live.listViewedDocuments(offset: offset, limit: limit)
} }
} }
public func listDrafts(_ request: ListDraftsRequest) async throws -> [OutlineDocument] { public func listDrafts(_ request: ListDraftsRequest) async throws -> [OutlineDocument] {
try await cachedFetch(key: "documentsDrafts:\(request.offset):\(request.limit)") { try await cachedFetch(key: "documentsDrafts:\(request.offset):\(request.limit)", category: "drafts") {
try await self.live.listDrafts(request) try await self.live.listDrafts(request)
} }
} }
public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] { public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] {
try await cachedFetch(key: "collections:\(offset):\(limit)") { try await cachedFetch(key: "collections:\(offset):\(limit)", category: "collections") {
try await self.live.listCollections(offset: offset, limit: limit) try await self.live.listCollections(offset: offset, limit: limit)
} }
} }
public func collectionInfo(id: String) async throws -> OutlineCollection { public func collectionInfo(id: String) async throws -> OutlineCollection {
try await cachedFetch(key: "collection:\(id)") { try await self.live.collectionInfo(id: id) } try await cachedFetch(key: "collection:\(id)", category: "collections") { try await self.live.collectionInfo(id: id) }
} }
// MARK: - Queueable writes // MARK: - Queueable writes
@@ -121,10 +144,12 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
public func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument { public func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument {
if !isManualOfflineModeEnabled { if !isManualOfflineModeEnabled {
do { do {
let result = try await live.createDocument(request) let result = try await RetryPolicy.withRetry { try await self.live.createDocument(request) }
recordSuccess(category: "documents-write")
await cacheDocument(result) await cacheDocument(result)
return result return result
} catch { } catch {
recordWriteFailureIfStructural(category: "documents-write", error)
return await queueDocumentCreate(request) return await queueDocumentCreate(request)
} }
} }
@@ -134,10 +159,12 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
public func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument { public func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument {
if !isManualOfflineModeEnabled { if !isManualOfflineModeEnabled {
do { do {
let result = try await live.updateDocument(request) let result = try await RetryPolicy.withRetry { try await self.live.updateDocument(request) }
recordSuccess(category: "documents-write")
await cacheDocument(result) await cacheDocument(result)
return result return result
} catch { } catch {
recordWriteFailureIfStructural(category: "documents-write", error)
return try await queueDocumentUpdate(request, dueTo: error) return try await queueDocumentUpdate(request, dueTo: error)
} }
} }
@@ -147,10 +174,12 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
public func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection { public func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection {
if !isManualOfflineModeEnabled { if !isManualOfflineModeEnabled {
do { do {
let result = try await live.updateCollection(request) let result = try await RetryPolicy.withRetry { try await self.live.updateCollection(request) }
recordSuccess(category: "collections-write")
await cacheCollection(result) await cacheCollection(result)
return result return result
} catch { } catch {
recordWriteFailureIfStructural(category: "collections-write", error)
return try await queueCollectionUpdate(request, dueTo: error) return try await queueCollectionUpdate(request, dueTo: error)
} }
} }
@@ -159,7 +188,14 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin { public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin {
if !isManualOfflineModeEnabled { if !isManualOfflineModeEnabled {
do { return try await live.createPin(request) } catch { return await queuePinCreate(request) } do {
let result = try await RetryPolicy.withRetry { try await self.live.createPin(request) }
recordSuccess(category: "pins")
return result
} catch {
recordWriteFailureIfStructural(category: "pins", error)
return await queuePinCreate(request)
}
} }
return await queuePinCreate(request) return await queuePinCreate(request)
} }
@@ -168,9 +204,11 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
if await cancelIfNeverSynced(id: id) { return } if await cancelIfNeverSynced(id: id) { return }
if !isManualOfflineModeEnabled { if !isManualOfflineModeEnabled {
do { do {
try await live.deletePin(id: id) try await RetryPolicy.withRetry { try await self.live.deletePin(id: id) }
recordSuccess(category: "pins")
return return
} catch { } catch {
recordWriteFailureIfStructural(category: "pins", error)
await enqueue(.deletePin, payload: IDPayload(id: id), id: "delete-pin-\(id)") await enqueue(.deletePin, payload: IDPayload(id: id), id: "delete-pin-\(id)")
return return
} }
@@ -180,7 +218,14 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
public func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription { public func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription {
if !isManualOfflineModeEnabled { if !isManualOfflineModeEnabled {
do { return try await live.createSubscription(request) } catch { return await queueSubscriptionCreate(request) } do {
let result = try await RetryPolicy.withRetry { try await self.live.createSubscription(request) }
recordSuccess(category: "subscriptions")
return result
} catch {
recordWriteFailureIfStructural(category: "subscriptions", error)
return await queueSubscriptionCreate(request)
}
} }
return await queueSubscriptionCreate(request) return await queueSubscriptionCreate(request)
} }
@@ -189,9 +234,11 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
if await cancelIfNeverSynced(id: id) { return } if await cancelIfNeverSynced(id: id) { return }
if !isManualOfflineModeEnabled { if !isManualOfflineModeEnabled {
do { do {
try await live.deleteSubscription(id: id) try await RetryPolicy.withRetry { try await self.live.deleteSubscription(id: id) }
recordSuccess(category: "subscriptions")
return return
} catch { } catch {
recordWriteFailureIfStructural(category: "subscriptions", error)
await enqueue(.deleteSubscription, payload: IDPayload(id: id), id: "delete-subscription-\(id)") await enqueue(.deleteSubscription, payload: IDPayload(id: id), id: "delete-subscription-\(id)")
return return
} }
@@ -201,14 +248,28 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
public func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar { public func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar {
if !isManualOfflineModeEnabled { if !isManualOfflineModeEnabled {
do { return try await live.starDocument(request) } catch { return await queueStarDocumentCreate(request) } do {
let result = try await RetryPolicy.withRetry { try await self.live.starDocument(request) }
recordSuccess(category: "stars")
return result
} catch {
recordWriteFailureIfStructural(category: "stars", error)
return await queueStarDocumentCreate(request)
}
} }
return await queueStarDocumentCreate(request) return await queueStarDocumentCreate(request)
} }
public func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar { public func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar {
if !isManualOfflineModeEnabled { if !isManualOfflineModeEnabled {
do { return try await live.starCollection(request) } catch { return await queueStarCollectionCreate(request) } do {
let result = try await RetryPolicy.withRetry { try await self.live.starCollection(request) }
recordSuccess(category: "stars")
return result
} catch {
recordWriteFailureIfStructural(category: "stars", error)
return await queueStarCollectionCreate(request)
}
} }
return await queueStarCollectionCreate(request) return await queueStarCollectionCreate(request)
} }
@@ -217,9 +278,11 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
if await cancelIfNeverSynced(id: id) { return } if await cancelIfNeverSynced(id: id) { return }
if !isManualOfflineModeEnabled { if !isManualOfflineModeEnabled {
do { do {
try await live.deleteStar(id: id) try await RetryPolicy.withRetry { try await self.live.deleteStar(id: id) }
recordSuccess(category: "stars")
return return
} catch { } catch {
recordWriteFailureIfStructural(category: "stars", error)
await enqueue(.deleteStar, payload: IDPayload(id: id), id: "delete-star-\(id)") await enqueue(.deleteStar, payload: IDPayload(id: id), id: "delete-star-\(id)")
return return
} }
@@ -445,6 +508,15 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
await cache.clearAll() await cache.clearAll()
} }
/// Sign-out only see `OfflineCacheStore.clearEverything()` and
/// `CacheEncryptionKeyStoring.clear()`. Callers must clear the
/// encryption key too (this actor doesn't own that decision); wiping
/// the storage here without it would leave the key to be reused by
/// whoever signs in next.
public func clearEverythingForSignOut() async {
await cache.clearEverything()
}
/// Replays every queued operation against `live`, in the order they were /// Replays every queued operation against `live`, in the order they were
/// queued. Each is independent one failing doesn't block the rest. /// queued. Each is independent one failing doesn't block the rest.
public func flushPendingOperations() async -> SyncFlushSummary { public func flushPendingOperations() async -> SyncFlushSummary {
@@ -553,43 +625,87 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
/// SwiftData read, no network involved. /// SwiftData read, no network involved.
public func cachedDocumentsIndex() async -> [OutlineDocument] { public func cachedDocumentsIndex() async -> [OutlineDocument] {
let payloads = await cache.loadAll(keyPrefix: "document:") let payloads = await cache.loadAll(keyPrefix: "document:")
return payloads.compactMap { try? decoder.decode(OutlineDocument.self, from: $0) } return payloads.compactMap { decryptedDecode(OutlineDocument.self, from: $0) }
} }
/// Every individually cached collection from the last Full Local Sync. /// Every individually cached collection from the last Full Local Sync.
public func cachedCollectionsIndex() async -> [OutlineCollection] { public func cachedCollectionsIndex() async -> [OutlineCollection] {
let payloads = await cache.loadAll(keyPrefix: "collection:") let payloads = await cache.loadAll(keyPrefix: "collection:")
return payloads.compactMap { try? decoder.decode(OutlineCollection.self, from: $0) } return payloads.compactMap { decryptedDecode(OutlineCollection.self, from: $0) }
} }
// MARK: - Helpers // MARK: - Helpers
private func cachedFetch<T: Codable>(key: String, fetch: () async throws -> T) async throws -> T { private func cachedFetch<T: Codable>(key: String, category: String, fetch: () async throws -> T) async throws -> T {
// Manual offline mode means "skip the network entirely," not just // Manual offline mode means "skip the network entirely," not just
// "prefer it" without this check, a read would still hit `live` // "prefer it" without this check, a read would still hit `live`
// (and succeed, showing content beyond whatever's cached) any time // (and succeed, showing content beyond whatever's cached) any time
// the device actually had a connection, defeating the point of // the device actually had a connection, defeating the point of
// deliberately testing/working as if offline. // deliberately testing/working as if offline.
if isManualOfflineModeEnabled { if isManualOfflineModeEnabled {
if let data = await cache.load(forKey: key), let cached = try? decoder.decode(T.self, from: data) { if let data = await cache.load(forKey: key), let cached = decryptedDecode(T.self, from: data) {
return cached return cached
} }
throw OutlineAPIError.transport(URLError(.notConnectedToInternet)) throw OutlineAPIError.transport(URLError(.notConnectedToInternet))
} }
do { do {
let result = try await fetch() let result = try await RetryPolicy.withRetry { try await fetch() }
if let data = try? encoder.encode(result) { recordSuccess(category: category)
if let data = encryptedEncode(result) {
await cache.save(data, forKey: key) await cache.save(data, forKey: key)
} }
return result return result
} catch { } catch {
if let data = await cache.load(forKey: key), let cached = try? decoder.decode(T.self, from: data) { // Only a structural failure (decode/auth/server the server
// answered, but something's actually wrong) counts toward the
// repeated-failure log. Plain connectivity loss already has its
// own offline UI elsewhere; logging it here too would just be a
// second banner for the same thing every time Wi-Fi drops.
if !RetryPolicy.isRetryable(error) {
recordFailure(category: category, message: errorDescription(error))
}
if let data = await cache.load(forKey: key), let cached = decryptedDecode(T.self, from: data) {
return cached return cached
} }
throw error throw error
} }
} }
private func resolvedEncryptionKey() throws -> SymmetricKey {
if let cachedEncryptionKey { return cachedEncryptionKey }
let key = try encryptionKeyStore.key()
cachedEncryptionKey = key
return key
}
/// Encrypts before it ever reaches SwiftData. `nil` on any failure
/// (matches the shape of the plain `try? encoder.encode(...)` this
/// replaces) a Keychain hiccup here should behave exactly like an
/// encode failure already did: skip caching this one value, not crash.
private func encryptedEncode(_ value: some Encodable) -> Data? {
guard let plain = try? encoder.encode(value), let key = try? resolvedEncryptionKey() else { return nil }
return try? CachePayloadCryptor.encrypt(plain, key: key)
}
/// Decrypts + decodes a value previously written by `encryptedEncode`.
/// A row written before this feature shipped (still plaintext JSON, or
/// anything encrypted under a key that's since been cleared by
/// sign-out) fails to decrypt and returns `nil` here same as any
/// other decode failure, so `cachedFetch` treats it as a cache miss and
/// refetches, not a crash.
private func decryptedDecode<T: Decodable>(_ type: T.Type, from data: Data) -> T? {
guard let key = try? resolvedEncryptionKey(), let plain = try? CachePayloadCryptor.decrypt(data, key: key) else { return nil }
return try? decoder.decode(type, from: plain)
}
/// Throwing counterpart for `replay(_:)`, where a genuine decode
/// failure needs to propagate (so `flushPendingOperations` records it
/// as a failed sync attempt) instead of silently vanishing.
private func decryptedDecodeThrowing<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
let plain = try CachePayloadCryptor.decrypt(data, key: try resolvedEncryptionKey())
return try decoder.decode(type, from: plain)
}
private func requestKey(_ prefix: String, _ request: some Encodable) -> String { private func requestKey(_ prefix: String, _ request: some Encodable) -> String {
guard let data = try? keyEncoder.encode(request), let json = String(data: data, encoding: .utf8) else { guard let data = try? keyEncoder.encode(request), let json = String(data: data, encoding: .utf8) else {
return prefix return prefix
@@ -598,19 +714,19 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
} }
private func cacheDocument(_ document: OutlineDocument) async { private func cacheDocument(_ document: OutlineDocument) async {
if let data = try? encoder.encode(document) { if let data = encryptedEncode(document) {
await cache.save(data, forKey: "document:\(document.id)") await cache.save(data, forKey: "document:\(document.id)")
} }
} }
private func cacheCollection(_ collection: OutlineCollection) async { private func cacheCollection(_ collection: OutlineCollection) async {
if let data = try? encoder.encode(collection) { if let data = encryptedEncode(collection) {
await cache.save(data, forKey: "collection:\(collection.id)") await cache.save(data, forKey: "collection:\(collection.id)")
} }
} }
private func enqueue(_ kind: PendingOperationKind, payload: some Encodable, id: String) async { private func enqueue(_ kind: PendingOperationKind, payload: some Encodable, id: String) async {
guard let data = try? encoder.encode(payload) else { return } guard let data = encryptedEncode(payload) else { return }
await cache.enqueueOperation(id: id, kind: kind.rawValue, payload: data) await cache.enqueueOperation(id: id, kind: kind.rawValue, payload: data)
} }
@@ -649,7 +765,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
private func queueDocumentUpdate(_ request: UpdateDocumentRequest, dueTo error: Error?) async throws -> OutlineDocument { private func queueDocumentUpdate(_ request: UpdateDocumentRequest, dueTo error: Error?) async throws -> OutlineDocument {
guard let baseData = await cache.load(forKey: "document:\(request.id)"), guard let baseData = await cache.load(forKey: "document:\(request.id)"),
let base = try? decoder.decode(OutlineDocument.self, from: baseData) else { let base = decryptedDecode(OutlineDocument.self, from: baseData) else {
throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet)) throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet))
} }
let mergedText = request.append == true ? base.text + (request.text ?? "") : (request.text ?? base.text) let mergedText = request.append == true ? base.text + (request.text ?? "") : (request.text ?? base.text)
@@ -678,7 +794,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
// of it and the update would just fail every retry. // of it and the update would just fail every retry.
if merged.id.hasPrefix("pending-"), if merged.id.hasPrefix("pending-"),
let createOp = await cache.pendingOperations().first(where: { $0.id == merged.id && $0.kind == PendingOperationKind.createDocument.rawValue }), let createOp = await cache.pendingOperations().first(where: { $0.id == merged.id && $0.kind == PendingOperationKind.createDocument.rawValue }),
let createRequest = try? decoder.decode(CreateDocumentRequest.self, from: createOp.payload) { let createRequest = decryptedDecode(CreateDocumentRequest.self, from: createOp.payload) {
let resolvedCreate = CreateDocumentRequest( let resolvedCreate = CreateDocumentRequest(
title: merged.title, title: merged.title,
text: merged.text, text: merged.text,
@@ -700,7 +816,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
private func queueCollectionUpdate(_ request: UpdateCollectionRequest, dueTo error: Error?) async throws -> OutlineCollection { private func queueCollectionUpdate(_ request: UpdateCollectionRequest, dueTo error: Error?) async throws -> OutlineCollection {
guard let baseData = await cache.load(forKey: "collection:\(request.id)"), guard let baseData = await cache.load(forKey: "collection:\(request.id)"),
let base = try? decoder.decode(OutlineCollection.self, from: baseData) else { let base = decryptedDecode(OutlineCollection.self, from: baseData) else {
throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet)) throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet))
} }
let merged = OutlineCollection( let merged = OutlineCollection(
@@ -750,44 +866,84 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
} }
switch kind { switch kind {
case .createDocument: case .createDocument:
let request = try decoder.decode(CreateDocumentRequest.self, from: operation.payload) let request = try decryptedDecodeThrowing(CreateDocumentRequest.self, from: operation.payload)
let result = try await live.createDocument(request) let result = try await live.createDocument(request)
await cacheDocument(result) await cacheDocument(result)
// The placeholder id (== operation.id) is now a dead orphan // The placeholder id (== operation.id) is now a dead orphan
// nothing server-side will ever answer to it again. // nothing server-side will ever answer to it again.
await cache.removeCacheEntry(forKey: "document:\(operation.id)") await cache.removeCacheEntry(forKey: "document:\(operation.id)")
case .updateDocument: case .updateDocument:
let request = try decoder.decode(UpdateDocumentRequest.self, from: operation.payload) let request = try decryptedDecodeThrowing(UpdateDocumentRequest.self, from: operation.payload)
let result = try await live.updateDocument(request) let result = try await live.updateDocument(request)
await cacheDocument(result) await cacheDocument(result)
case .updateCollection: case .updateCollection:
let request = try decoder.decode(UpdateCollectionRequest.self, from: operation.payload) let request = try decryptedDecodeThrowing(UpdateCollectionRequest.self, from: operation.payload)
let result = try await live.updateCollection(request) let result = try await live.updateCollection(request)
await cacheCollection(result) await cacheCollection(result)
case .createPin: case .createPin:
let request = try decoder.decode(CreatePinRequest.self, from: operation.payload) let request = try decryptedDecodeThrowing(CreatePinRequest.self, from: operation.payload)
_ = try await live.createPin(request) _ = try await live.createPin(request)
case .deletePin: case .deletePin:
let request = try decoder.decode(IDPayload.self, from: operation.payload) let request = try decryptedDecodeThrowing(IDPayload.self, from: operation.payload)
try await live.deletePin(id: request.id) try await live.deletePin(id: request.id)
case .createSubscription: case .createSubscription:
let request = try decoder.decode(CreateSubscriptionRequest.self, from: operation.payload) let request = try decryptedDecodeThrowing(CreateSubscriptionRequest.self, from: operation.payload)
_ = try await live.createSubscription(request) _ = try await live.createSubscription(request)
case .deleteSubscription: case .deleteSubscription:
let request = try decoder.decode(IDPayload.self, from: operation.payload) let request = try decryptedDecodeThrowing(IDPayload.self, from: operation.payload)
try await live.deleteSubscription(id: request.id) try await live.deleteSubscription(id: request.id)
case .starDocument: case .starDocument:
let request = try decoder.decode(StarDocumentRequest.self, from: operation.payload) let request = try decryptedDecodeThrowing(StarDocumentRequest.self, from: operation.payload)
_ = try await live.starDocument(request) _ = try await live.starDocument(request)
case .deleteStar: case .deleteStar:
let request = try decoder.decode(IDPayload.self, from: operation.payload) let request = try decryptedDecodeThrowing(IDPayload.self, from: operation.payload)
try await live.deleteStar(id: request.id) try await live.deleteStar(id: request.id)
case .starCollection: case .starCollection:
let request = try decoder.decode(StarCollectionRequest.self, from: operation.payload) let request = try decryptedDecodeThrowing(StarCollectionRequest.self, from: operation.payload)
_ = try await live.starCollection(request) _ = try await live.starCollection(request)
} }
} }
/// Writes always queue on any failure (existing behavior, unchanged)
/// this only decides whether the failure is worth logging. A structural
/// error (decode/auth/server) queuing for later replay will likely just
/// fail the same way again next sync; a transient one might not. Either
/// way the queue doesn't change, only whether it's counted toward
/// `repeatedFailureSummaries()`.
private func recordWriteFailureIfStructural(category: String, _ error: Error) {
guard !RetryPolicy.isRetryable(error) else { return }
recordFailure(category: category, message: errorDescription(error))
}
private func recordFailure(category: String, message: String) {
let now = Date()
var timestamps = (failureLog[category] ?? []).filter { now.timeIntervalSince($0) < failureWindow }
timestamps.append(now)
failureLog[category] = timestamps
lastFailureMessage[category] = message
}
private func recordSuccess(category: String) {
failureLog[category] = nil
lastFailureMessage[category] = nil
}
/// Categories that have failed `failureThreshold`+ times within the last
/// `failureWindow` seconds meant to be polled periodically (see
/// `RootView`), not pushed, since this actor has no UI-facing dependency
/// of its own. A single blip never shows up here: `RetryPolicy` absorbs
/// transient failures before they're ever logged, and only structural
/// ones (decode/auth/server) get logged at all see
/// `recordWriteFailureIfStructural` and `cachedFetch`.
public func repeatedFailureSummaries() async -> [RepeatedFailure] {
let now = Date()
return failureLog.compactMap { category, timestamps in
let recent = timestamps.filter { now.timeIntervalSince($0) < failureWindow }
guard recent.count >= failureThreshold, let message = lastFailureMessage[category] else { return nil }
return RepeatedFailure(category: category, message: message, count: recent.count)
}
}
private func errorDescription(_ error: Error) -> String { private func errorDescription(_ error: Error) -> String {
if let apiError = error as? OutlineAPIError { if let apiError = error as? OutlineAPIError {
switch apiError { switch apiError {
@@ -0,0 +1,76 @@
import Foundation
import CryptoKit
import Security
/// Real Keychain-backed `CacheEncryptionKeyStoring`. The key is a plain
/// random 256-bit value deliberately NOT derived from anything guessable
/// (bundle id, device id, etc.). It doesn't need deriving to survive an
/// uninstall/reinstall of the app either: Keychain items are scoped to the
/// requesting app's code signature (bundle id + team id), not its on-disk
/// presence, so reinstalling the same app regains access to the same
/// Keychain item automatically exactly how `KeychainTokenStore`'s saved
/// token already survives a reinstall today. If the on-disk cache also
/// happens to survive (macOS doesn't clean out `~/Library/Containers` just
/// because the .app bundle was dragged to the Trash), the reinstalled app
/// can still decrypt it; a different app, or the same app after an explicit
/// sign-out (`clear()`), can't.
public final class KeychainCacheEncryptionKeyStore: CacheEncryptionKeyStoring, @unchecked Sendable {
private let service: String
private let account: String
public init(service: String = "com.outpost.outlinekit", account: String = "offline-cache-encryption-key") {
self.service = service
self.account = account
}
public func key() throws -> SymmetricKey {
if let existing = try? readKey() { return existing }
let generated = SymmetricKey(size: .bits256)
try store(generated)
return generated
}
public func clear() throws {
let status = SecItemDelete(baseQuery() as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw TokenStoreError.deleteFailed(status)
}
}
private func readKey() throws -> SymmetricKey {
var query = baseQuery()
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
throw TokenStoreError.notFound
}
return SymmetricKey(data: data)
}
private func store(_ key: SymmetricKey) throws {
let data = key.withUnsafeBytes { Data($0) }
let query = baseQuery()
let existsStatus = SecItemCopyMatching(query as CFDictionary, nil)
if existsStatus == errSecSuccess {
let updateStatus = SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary)
guard updateStatus == errSecSuccess else { throw TokenStoreError.storeFailed(updateStatus) }
} else {
var addQuery = query
addQuery[kSecValueData as String] = data
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
guard addStatus == errSecSuccess else { throw TokenStoreError.storeFailed(addStatus) }
}
}
private func baseQuery() -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
}
}
@@ -67,6 +67,22 @@ public actor OfflineCacheStore {
try? modelContext.save() try? modelContext.save()
} }
/// Wipes both the read-through cache AND the pending write queue
/// used only on sign-out (see `SessionStore.signOut()`), since the
/// encryption key backing every row here is about to be cleared too.
/// Anything left un-wiped would just become permanently undecryptable
/// garbage instead of readable by the next account signed in on this
/// machine deliberately more thorough than `clearAll()` (Settings'
/// "Clear All Cache", which never touches pending writes; that button
/// shouldn't silently discard someone's unsynced edits).
public func clearEverything() {
let cachedDescriptor = FetchDescriptor<CachedPayload>()
(try? modelContext.fetch(cachedDescriptor))?.forEach { modelContext.delete($0) }
let pendingDescriptor = FetchDescriptor<PendingOperation>()
(try? modelContext.fetch(pendingDescriptor))?.forEach { modelContext.delete($0) }
try? modelContext.save()
}
// MARK: - Offline write queue // MARK: - Offline write queue
/// Upserts by `id` a second call with the same id (an edit coalescing /// Upserts by `id` a second call with the same id (an edit coalescing
@@ -0,0 +1,19 @@
import Foundation
/// A category of API call that's failed repeatedly within a short window
/// see `CachingOutlineAPIClient.repeatedFailureSummaries()`. Deliberately
/// carries no request/response payload, document content, or server URL:
/// this is meant to be safe to show a user or attach to a bug report as-is.
public struct RepeatedFailure: Sendable, Identifiable, Equatable {
public let category: String
public let message: String
public let count: Int
public var id: String { category }
public init(category: String, message: String, count: Int) {
self.category = category
self.message = message
self.count = count
}
}
@@ -0,0 +1,38 @@
import Foundation
/// Automatic retry-with-backoff for API calls, so a single transient network
/// blip doesn't turn into a user-visible failure (or a silently swallowed
/// one) the way one `try?` used to.
///
/// Only retries `OutlineAPIError.transport` a dropped connection or
/// timeout might succeed a second later. Everything else (`.decoding`,
/// `.unauthorized`, `.notFound`, `.server`, `.tokenUnavailable`) is retried
/// zero times: a response-shape mismatch or a 404 will look exactly the same
/// on attempt two, so retrying just burns the cooldown window for nothing
/// callers should treat those as immediate failures instead.
public enum RetryPolicy {
public static func withRetry<T: Sendable>(
maxAttempts: Int = 3,
initialDelay: Duration = .seconds(1),
_ operation: () async throws -> T
) async throws -> T {
var attempt = 1
var delay = initialDelay
while true {
do {
return try await operation()
} catch {
guard attempt < maxAttempts, isRetryable(error) else { throw error }
attempt += 1
try? await Task.sleep(for: delay)
delay *= 2
}
}
}
static func isRetryable(_ error: Error) -> Bool {
guard let apiError = error as? OutlineAPIError else { return false }
if case .transport = apiError { return true }
return false
}
}
@@ -1,4 +1,5 @@
import XCTest import XCTest
import CryptoKit
@testable import OutlineKit @testable import OutlineKit
private struct NotStubbed: Error {} private struct NotStubbed: Error {}
@@ -116,6 +117,22 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
private struct StubTransportError: Error {} private struct StubTransportError: Error {}
/// In-memory `CacheEncryptionKeyStoring` tests must never touch the real
/// Keychain (would pollute the developer's machine and can hang/fail in a
/// sandboxed CI runner with no Keychain access).
private final class StaticCacheEncryptionKeyStore: CacheEncryptionKeyStoring, @unchecked Sendable {
private var stored: SymmetricKey?
func key() throws -> SymmetricKey {
if let stored { return stored }
let generated = SymmetricKey(size: .bits256)
stored = generated
return generated
}
func clear() throws { stored = nil }
}
final class CachingOutlineAPIClientTests: XCTestCase { final class CachingOutlineAPIClientTests: XCTestCase {
private func makeCache() throws -> OfflineCacheStore { private func makeCache() throws -> OfflineCacheStore {
OfflineCacheStore(modelContainer: try OfflineCacheStore.makeContainer(inMemory: true)) OfflineCacheStore(modelContainer: try OfflineCacheStore.makeContainer(inMemory: true))
@@ -145,7 +162,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
let stub = StubOutlineAPIClient() let stub = StubOutlineAPIClient()
let document = makeDocument() let document = makeDocument()
stub.documentInfoHandler = { _ in document } stub.documentInfoHandler = { _ in document }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
let result = try await sut.documentInfo(id: "doc-1") let result = try await sut.documentInfo(id: "doc-1")
@@ -161,7 +178,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
if callCount == 1 { return document } if callCount == 1 { return document }
throw StubTransportError() throw StubTransportError()
} }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
// First call succeeds and populates the cache. // First call succeeds and populates the cache.
_ = try await sut.documentInfo(id: "doc-1") _ = try await sut.documentInfo(id: "doc-1")
@@ -175,7 +192,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
func testDocumentInfoRethrowsWhenLiveFailsAndCacheIsEmpty() async throws { func testDocumentInfoRethrowsWhenLiveFailsAndCacheIsEmpty() async throws {
let stub = StubOutlineAPIClient() let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in throw StubTransportError() } stub.documentInfoHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
do { do {
_ = try await sut.documentInfo(id: "doc-1") _ = try await sut.documentInfo(id: "doc-1")
@@ -194,7 +211,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
if callCount == 1 { return collections } if callCount == 1 { return collections }
throw StubTransportError() throw StubTransportError()
} }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try await sut.listCollections(offset: 0, limit: 25) _ = try await sut.listCollections(offset: 0, limit: 25)
let result = try await sut.listCollections(offset: 0, limit: 25) let result = try await sut.listCollections(offset: 0, limit: 25)
@@ -207,7 +224,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
let docOne = makeDocument(id: "doc-1", title: "One") let docOne = makeDocument(id: "doc-1", title: "One")
let docTwo = makeDocument(id: "doc-2", title: "Two") let docTwo = makeDocument(id: "doc-2", title: "Two")
stub.documentInfoHandler = { id in id == "doc-1" ? docOne : docTwo } stub.documentInfoHandler = { id in id == "doc-1" ? docOne : docTwo }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try await sut.documentInfo(id: "doc-1") _ = try await sut.documentInfo(id: "doc-1")
_ = try await sut.documentInfo(id: "doc-2") _ = try await sut.documentInfo(id: "doc-2")
@@ -227,7 +244,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
let original = makeDocument(id: "doc-1", title: "Original") let original = makeDocument(id: "doc-1", title: "Original")
stub.documentInfoHandler = { _ in original } stub.documentInfoHandler = { _ in original }
stub.updateDocumentHandler = { _ in throw StubTransportError() } stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
// Populate the cache with the base document first (as a real open would). // Populate the cache with the base document first (as a real open would).
_ = try await sut.documentInfo(id: "doc-1") _ = try await sut.documentInfo(id: "doc-1")
@@ -248,7 +265,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
func testUpdateDocumentRethrowsWhenDocumentWasNeverCached() async throws { func testUpdateDocumentRethrowsWhenDocumentWasNeverCached() async throws {
let stub = StubOutlineAPIClient() let stub = StubOutlineAPIClient()
stub.updateDocumentHandler = { _ in throw StubTransportError() } stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
do { do {
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "never-seen", title: "x")) _ = try await sut.updateDocument(UpdateDocumentRequest(id: "never-seen", title: "x"))
@@ -263,7 +280,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
let original = makeDocument(id: "doc-1", title: "Original") let original = makeDocument(id: "doc-1", title: "Original")
stub.documentInfoHandler = { _ in original } stub.documentInfoHandler = { _ in original }
stub.updateDocumentHandler = { _ in throw StubTransportError() } stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try await sut.documentInfo(id: "doc-1") _ = try await sut.documentInfo(id: "doc-1")
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "First Edit")) _ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "First Edit"))
@@ -276,7 +293,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
func testPinThenUnpinBeforeSyncCancelsOutWithoutQueuingADelete() async throws { func testPinThenUnpinBeforeSyncCancelsOutWithoutQueuingADelete() async throws {
let stub = StubOutlineAPIClient() let stub = StubOutlineAPIClient()
stub.createPinHandler = { _ in throw StubTransportError() } stub.createPinHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1")) let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1"))
XCTAssertTrue(pin.id.hasPrefix("pending-")) XCTAssertTrue(pin.id.hasPrefix("pending-"))
@@ -294,7 +311,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
let original = makeDocument(id: "doc-1", title: "Original") let original = makeDocument(id: "doc-1", title: "Original")
stub.documentInfoHandler = { _ in original } stub.documentInfoHandler = { _ in original }
stub.updateDocumentHandler = { _ in throw StubTransportError() } stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try await sut.documentInfo(id: "doc-1") _ = try await sut.documentInfo(id: "doc-1")
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline")) _ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
@@ -318,7 +335,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
let original = makeDocument(id: "doc-1", title: "Original") let original = makeDocument(id: "doc-1", title: "Original")
stub.documentInfoHandler = { _ in original } stub.documentInfoHandler = { _ in original }
stub.updateDocumentHandler = { _ in throw StubTransportError() } stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try await sut.documentInfo(id: "doc-1") _ = try await sut.documentInfo(id: "doc-1")
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline")) _ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
@@ -345,7 +362,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
liveCallCount += 1 liveCallCount += 1
return OutlinePin(id: "real-id", documentId: "doc-1", collectionId: nil, index: nil) return OutlinePin(id: "real-id", documentId: "doc-1", collectionId: nil, index: nil)
} }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), defaults: defaults) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), defaults: defaults, encryptionKeyStore: StaticCacheEncryptionKeyStore())
let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1")) let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1"))
@@ -370,7 +387,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
return [cachedCollection] return [cachedCollection]
} }
let cache = try makeCache() let cache = try makeCache()
let sut = CachingOutlineAPIClient(live: stub, cache: cache, defaults: defaults) let sut = CachingOutlineAPIClient(live: stub, cache: cache, defaults: defaults, encryptionKeyStore: StaticCacheEncryptionKeyStore())
// Online first populates the cache normally. // Online first populates the cache normally.
let firstResult = try await sut.listCollections(offset: 0, limit: 25) let firstResult = try await sut.listCollections(offset: 0, limit: 25)
@@ -389,7 +406,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
func testCacheStorageSummaryReflectsCachedItems() async throws { func testCacheStorageSummaryReflectsCachedItems() async throws {
let stub = StubOutlineAPIClient() let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in self.makeDocument() } stub.documentInfoHandler = { _ in self.makeDocument() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try await sut.documentInfo(id: "doc-1") _ = try await sut.documentInfo(id: "doc-1")
let summary = await sut.cacheStorageSummary() let summary = await sut.cacheStorageSummary()
@@ -418,7 +435,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
return (0..<count).map { self.makeCollection(id: "col-\(offset + $0)") } return (0..<count).map { self.makeCollection(id: "col-\(offset + $0)") }
} }
stub.listDocumentsHandler = { _, _, _, _ in [] } stub.listDocumentsHandler = { _, _, _, _ in [] }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
let summary = await sut.performFullSync() let summary = await sut.performFullSync()
@@ -439,7 +456,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
return offset == 0 ? [self.makeDocument(id: "doc-1"), self.makeDocument(id: "doc-2")] : [] return offset == 0 ? [self.makeDocument(id: "doc-1"), self.makeDocument(id: "doc-2")] : []
} }
let cache = try makeCache() let cache = try makeCache()
let sut = CachingOutlineAPIClient(live: stub, cache: cache) let sut = CachingOutlineAPIClient(live: stub, cache: cache, encryptionKeyStore: StaticCacheEncryptionKeyStore())
let summary = await sut.performFullSync() let summary = await sut.performFullSync()
XCTAssertEqual(summary.documentsCount, 2) XCTAssertEqual(summary.documentsCount, 2)
@@ -469,7 +486,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
} }
} }
let cache = try makeCache() let cache = try makeCache()
let sut = CachingOutlineAPIClient(live: stub, cache: cache) let sut = CachingOutlineAPIClient(live: stub, cache: cache, encryptionKeyStore: StaticCacheEncryptionKeyStore())
let summary = await sut.performFullSync() let summary = await sut.performFullSync()
@@ -484,7 +501,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
func testCreateDocumentQueuesAndReturnsUsableDocumentWhenOffline() async throws { func testCreateDocumentQueuesAndReturnsUsableDocumentWhenOffline() async throws {
let stub = StubOutlineAPIClient() let stub = StubOutlineAPIClient()
stub.createDocumentHandler = { _ in throw StubTransportError() } stub.createDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
let created = try await sut.createDocument( let created = try await sut.createDocument(
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1") CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
@@ -508,7 +525,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
let stub = StubOutlineAPIClient() let stub = StubOutlineAPIClient()
stub.createDocumentHandler = { _ in throw StubTransportError() } stub.createDocumentHandler = { _ in throw StubTransportError() }
stub.updateDocumentHandler = { _ in throw StubTransportError() } stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
let created = try await sut.createDocument( let created = try await sut.createDocument(
CreateDocumentRequest(title: "Untitled", text: "", collectionId: "col-1") CreateDocumentRequest(title: "Untitled", text: "", collectionId: "col-1")
@@ -528,7 +545,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
func testFlushingAPendingCreateReconcilesThePlaceholderIdToTheRealOne() async throws { func testFlushingAPendingCreateReconcilesThePlaceholderIdToTheRealOne() async throws {
let stub = StubOutlineAPIClient() let stub = StubOutlineAPIClient()
stub.createDocumentHandler = { _ in throw StubTransportError() } stub.createDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
let created = try await sut.createDocument( let created = try await sut.createDocument(
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1") CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
@@ -553,4 +570,169 @@ final class CachingOutlineAPIClientTests: XCTestCase {
// expected nothing left under the old id // expected nothing left under the old id
} }
} }
// MARK: - Repeated-failure tracking
/// A structural failure (decode/auth/server) isn't retried it fails
/// the same way every time, so `RetryPolicy` gives up after one attempt
/// and it's logged immediately. No cache entry means no fallback either,
/// so every call rethrows.
func testRepeatedDecodingFailuresSurfaceAfterThreshold() async throws {
let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
for _ in 0..<3 {
_ = try? await sut.documentInfo(id: "doc-1")
}
let summaries = await sut.repeatedFailureSummaries()
XCTAssertEqual(summaries.count, 1)
XCTAssertEqual(summaries.first?.category, "document")
XCTAssertEqual(summaries.first?.count, 3)
}
/// Two failures alone shouldn't trip the banner only three or more
/// within the window counts as "repeated."
func testFewerThanThresholdFailuresDoNotSurface() async throws {
let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
for _ in 0..<2 {
_ = try? await sut.documentInfo(id: "doc-1")
}
let summaries = await sut.repeatedFailureSummaries()
XCTAssertTrue(summaries.isEmpty)
}
/// A later success clears the category entirely a transient run of
/// bad luck shouldn't leave a stale banner up after things recover.
func testSuccessAfterRepeatedFailuresClearsTheLog() async throws {
let stub = StubOutlineAPIClient()
let document = makeDocument()
var callCount = 0
stub.documentInfoHandler = { _ in
callCount += 1
if callCount <= 3 { throw OutlineAPIError.decoding(NotStubbed()) }
return document
}
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
for _ in 0..<3 {
_ = try? await sut.documentInfo(id: "doc-1")
}
let beforeRecovery = await sut.repeatedFailureSummaries()
XCTAssertEqual(beforeRecovery.count, 1)
_ = try await sut.documentInfo(id: "doc-1")
let afterRecovery = await sut.repeatedFailureSummaries()
XCTAssertTrue(afterRecovery.isEmpty)
}
/// Plain connectivity loss (`OutlineAPIError.transport`) already has its
/// own offline UI elsewhere it shouldn't also pile up in the repeated-
/// failure log and pop a second, redundant banner.
func testTransportFailuresDoNotCountTowardTheRepeatedFailureLog() async throws {
let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in throw OutlineAPIError.transport(URLError(.notConnectedToInternet)) }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try? await sut.documentInfo(id: "doc-1")
let summaries = await sut.repeatedFailureSummaries()
XCTAssertTrue(summaries.isEmpty)
}
/// Different categories (document reads vs. pin writes) track
/// independently a broken pins endpoint shouldn't mask, or be masked
/// by, unrelated document failures, and one crossing the threshold
/// shouldn't drag an unrelated one along with it.
func testFailuresInDifferentCategoriesDoNotMix() async throws {
let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
stub.createPinHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
// Document reads cross the threshold...
for _ in 0..<3 {
_ = try? await sut.documentInfo(id: "doc-1")
}
// ...pin creates don't.
for _ in 0..<2 {
_ = try? await sut.createPin(CreatePinRequest(documentId: "doc-1", collectionId: nil))
}
let summaries = await sut.repeatedFailureSummaries()
XCTAssertEqual(summaries.map(\.category), ["document"])
}
// MARK: - At-rest encryption
func testCachedPayloadIsNotStoredAsPlaintextJSON() async throws {
let stub = StubOutlineAPIClient()
let document = makeDocument(title: "Secret Title")
stub.documentInfoHandler = { _ in document }
let cache = try makeCache()
let sut = CachingOutlineAPIClient(live: stub, cache: cache, encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try await sut.documentInfo(id: "doc-1")
let raw = await cache.load(forKey: "document:doc-1")
XCTAssertNotNil(raw)
// A plain JSON encode would contain the literal title text in the
// clear - ciphertext shouldn't, and shouldn't even parse as JSON.
XCTAssertNil(String(data: raw!, encoding: .utf8)?.range(of: "Secret Title"))
XCTAssertThrowsError(try JSONDecoder().decode(OutlineDocument.self, from: raw!))
}
/// Simulates sign-out (key cleared) followed by a fresh sign-in (a new
/// `CachingOutlineAPIClient` instance, same underlying on-disk cache,
/// same shape as `SessionStore.makeAPIClient` always creating a new
/// instance) anything still on disk from before is unreadable under
/// the new key, which is the entire point of clearing it on sign-out.
func testCacheIsUnreadableAfterTheEncryptionKeyIsCleared() async throws {
let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in self.makeDocument() }
let cache = try makeCache()
let keyStore = StaticCacheEncryptionKeyStore()
let beforeSignOut = CachingOutlineAPIClient(live: stub, cache: cache, encryptionKeyStore: keyStore)
_ = try await beforeSignOut.documentInfo(id: "doc-1")
try keyStore.clear()
stub.documentInfoHandler = { _ in throw StubTransportError() }
let afterSignIn = CachingOutlineAPIClient(live: stub, cache: cache, encryptionKeyStore: keyStore)
do {
_ = try await afterSignIn.documentInfo(id: "doc-1")
XCTFail("Expected the now-undecryptable cache entry to be unusable")
} catch is StubTransportError {
// expected live fails, and the leftover cache entry can't be
// decrypted under the new key either, so there's no fallback.
}
}
func testClearEverythingForSignOutWipesBothCacheAndPendingQueue() async throws {
let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in self.makeDocument() }
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try await sut.documentInfo(id: "doc-1")
_ = try? await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Offline edit"))
let summaryBefore = await sut.cacheStorageSummary()
let pendingBefore = await sut.pendingOperations()
XCTAssertGreaterThan(summaryBefore.itemCount, 0)
XCTAssertFalse(pendingBefore.isEmpty)
await sut.clearEverythingForSignOut()
let summaryAfter = await sut.cacheStorageSummary()
let pendingAfter = await sut.pendingOperations()
XCTAssertEqual(summaryAfter.itemCount, 0)
XCTAssertTrue(pendingAfter.isEmpty)
}
} }
@@ -0,0 +1,69 @@
import XCTest
@testable import OutlineKit
private struct PlainError: Error {}
final class RetryPolicyTests: XCTestCase {
func testSucceedsOnFirstAttemptWithoutRetrying() async throws {
var callCount = 0
let result = try await RetryPolicy.withRetry(initialDelay: .milliseconds(1)) {
callCount += 1
return "ok"
}
XCTAssertEqual(result, "ok")
XCTAssertEqual(callCount, 1)
}
func testRetriesTransportErrorsAndSucceedsOnceItStopsFailing() async throws {
var callCount = 0
let result = try await RetryPolicy.withRetry(initialDelay: .milliseconds(1)) { () -> String in
callCount += 1
if callCount < 3 { throw OutlineAPIError.transport(URLError(.timedOut)) }
return "ok"
}
XCTAssertEqual(result, "ok")
XCTAssertEqual(callCount, 3)
}
func testGivesUpAfterMaxAttemptsAndRethrowsTheLastError() async throws {
var callCount = 0
do {
_ = try await RetryPolicy.withRetry(maxAttempts: 3, initialDelay: .milliseconds(1)) { () -> String in
callCount += 1
throw OutlineAPIError.transport(URLError(.timedOut))
}
XCTFail("Expected the persistent failure to be rethrown")
} catch {
XCTAssertEqual(callCount, 3)
}
}
/// A decode failure means the response is structurally wrong trying
/// again gets the exact same wrong response, so it isn't worth the
/// cooldown window the way a network blip is.
func testDoesNotRetryNonTransportErrors() async throws {
var callCount = 0
do {
_ = try await RetryPolicy.withRetry(initialDelay: .milliseconds(1)) { () -> String in
callCount += 1
throw OutlineAPIError.decoding(PlainError())
}
XCTFail("Expected the decoding error to be rethrown without retrying")
} catch {
XCTAssertEqual(callCount, 1)
}
}
func testDoesNotRetryErrorsThatAreNotOutlineAPIErrors() async throws {
var callCount = 0
do {
_ = try await RetryPolicy.withRetry(initialDelay: .milliseconds(1)) { () -> String in
callCount += 1
throw PlainError()
}
XCTFail("Expected the error to be rethrown without retrying")
} catch {
XCTAssertEqual(callCount, 1)
}
}
}
+2 -2
View File
@@ -425,7 +425,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 27.0; MACOSX_DEPLOYMENT_TARGET = 27.0;
MARKETING_VERSION = 0.0.4; MARKETING_VERSION = 0.1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp; PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -477,7 +477,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 27.0; MACOSX_DEPLOYMENT_TARGET = 27.0;
MARKETING_VERSION = 0.0.4; MARKETING_VERSION = 0.1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp; PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2700"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FAF99C17302CE96100C9949F"
BuildableName = "Outpost.app"
ReferencedContainer = "container:Outpost.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FAF99C26302CE96200C9949F"
BuildableName = "OutpostTests.xctest"
ReferencedContainer = "container:Outpost.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FAF99C30302CE96200C9949F"
BuildableName = "OutpostUITests.xctest"
ReferencedContainer = "container:Outpost.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
queueDebuggingEnabled = "No">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FAF99C17302CE96100C9949F"
BuildableName = "Outpost.app"
ReferencedContainer = "container:Outpost.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<StoreKitConfigurationFileReference
identifier = "../../Outpost/Configuration.storekit">
</StoreKitConfigurationFileReference>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FAF99C17302CE96100C9949F"
BuildableName = "Outpost.app"
ReferencedContainer = "container:Outpost.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+74
View File
@@ -0,0 +1,74 @@
{
"identifier" : "12E4A6D1-6B8A-4C2E-9F3A-0D1B2C3E4F5A",
"nonRenewingSubscriptions" : [],
"products" : [
{
"displayPrice" : "0.99",
"familyShareable" : false,
"internalID" : "992ABE97-F5C4-4F80-8FB0-382BDF47DAB6",
"localizations" : [
{
"description" : "A small tip to support Outpost's development.",
"displayName" : "Small Tip",
"locale" : "en_US"
}
],
"productID" : "com.psmattas.OutpostApp.tip.small",
"referenceName" : "Small Tip",
"type" : "Consumable"
},
{
"displayPrice" : "2.99",
"familyShareable" : false,
"internalID" : "316DEB73-6BA3-4F6B-BD54-D17A1CE61938",
"localizations" : [
{
"description" : "A medium tip to support Outpost's development.",
"displayName" : "Medium Tip",
"locale" : "en_US"
}
],
"productID" : "com.psmattas.OutpostApp.tip.medium",
"referenceName" : "Medium Tip",
"type" : "Consumable"
},
{
"displayPrice" : "4.99",
"familyShareable" : false,
"internalID" : "37936F94-AC21-4A39-BC85-CAE6609E170D",
"localizations" : [
{
"description" : "A large tip to support Outpost's development.",
"displayName" : "Large Tip",
"locale" : "en_US"
}
],
"productID" : "com.psmattas.OutpostApp.tip.large",
"referenceName" : "Large Tip",
"type" : "Consumable"
},
{
"displayPrice" : "9.99",
"familyShareable" : false,
"internalID" : "E072C1AC-2719-483E-8A54-F4924808B724",
"localizations" : [
{
"description" : "A generous tip to support Outpost's development.",
"displayName" : "Generous Tip",
"locale" : "en_US"
}
],
"productID" : "com.psmattas.OutpostApp.tip.generous",
"referenceName" : "Generous Tip",
"type" : "Consumable"
}
],
"settings" : {
"_askToBuyEnabled" : false
},
"subscriptionGroups" : [],
"version" : {
"major" : 3,
"minor" : 0
}
}
+5
View File
@@ -48,6 +48,11 @@ struct AboutInfoView: View {
} }
.font(.callout) .font(.callout)
Divider()
.frame(maxWidth: 240)
TipJarView()
Text("© \(copyrightYear) Puranjay Savar Mattas") Text("© \(copyrightYear) Puranjay Savar Mattas")
.font(.caption2) .font(.caption2)
.foregroundStyle(.tertiary) .foregroundStyle(.tertiary)
+75
View File
@@ -0,0 +1,75 @@
#if os(macOS)
import SwiftUI
import StoreKit
/// One button per consumable tip tier no "restore purchases" (nothing to
/// restore, consumables aren't entitlements) and no manual retry: a failed
/// load just shows a message, tapping a tier again re-attempts naturally.
struct TipJarView: View {
@State private var store = TipJarStore()
var body: some View {
VStack(spacing: 8) {
Text("Support Outpost")
.font(.callout.weight(.semibold))
if store.isLoading && store.products.isEmpty {
ProgressView()
.controlSize(.small)
} else if !store.products.isEmpty {
HStack(spacing: 8) {
ForEach(store.products) { product in
tipButton(for: product)
}
}
}
switch store.purchaseState {
case .thankYou:
Label("Thank you!", systemImage: "heart.fill")
.font(.caption)
.foregroundStyle(.pink)
case .failed(let message):
Text(message)
.font(.caption)
.foregroundStyle(.secondary)
case .idle, .purchasing:
EmptyView()
}
}
.task { await store.loadProductsIfNeeded() }
}
private func tipButton(for product: Product) -> some View {
Button {
Task { await store.purchase(product) }
} label: {
VStack(spacing: 2) {
if isPurchasing(product) {
ProgressView()
.controlSize(.small)
} else {
Text(product.displayPrice)
.font(.callout.weight(.semibold))
}
Text(product.displayName)
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(minWidth: 64)
.padding(.vertical, 6)
}
.buttonStyle(.bordered)
.disabled(isAnyPurchaseInFlight)
}
private func isPurchasing(_ product: Product) -> Bool {
store.purchaseState == .purchasing(product.id)
}
private var isAnyPurchaseInFlight: Bool {
if case .purchasing = store.purchaseState { return true }
return false
}
}
#endif
+14 -23
View File
@@ -1,5 +1,6 @@
#if os(macOS) #if os(macOS)
import SwiftUI import SwiftUI
import StoreKit
import OutlineKit import OutlineKit
/// Uses a plain `Button` + `.popover` rather than `Menu`. A `Menu` whose label /// Uses a plain `Button` + `.popover` rather than `Menu`. A `Menu` whose label
@@ -9,21 +10,13 @@ import OutlineKit
struct AccountFooter: View { struct AccountFooter: View {
@Environment(SessionStore.self) private var session @Environment(SessionStore.self) private var session
@Environment(AppNavigation.self) private var navigation @Environment(AppNavigation.self) private var navigation
@Environment(\.openURL) private var openURL @Environment(\.requestReview) private var requestReview
@Environment(\.openWindow) private var openWindow
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system @AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false @AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
@State private var isMenuPresented = false @State private var isMenuPresented = false
@State private var isShowingLogoutConfirmation = false @State private var isShowingLogoutConfirmation = false
@State private var isShowingProfile = false @State private var isShowingProfile = false
private let repositoryURL = URL(string: "https://git.psmattas.com/psmattas/Outpost")!
private let issuesURL = URL(string: "https://git.psmattas.com/psmattas/Outpost/issues")!
private var apiDocumentationURL: URL? {
session.serverURL?.appendingPathComponent("developers")
}
var body: some View { var body: some View {
Button { Button {
isMenuPresented = true isMenuPresented = true
@@ -63,20 +56,10 @@ struct AccountFooter: View {
private var menuContent: some View { private var menuContent: some View {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
menuItem("Keyboard Shortcuts…") { openWindow(id: "keyboard-shortcuts") } // Apple's own review/feedback prompt there's no separate
// native channel for "bug" vs. "feedback", so one button covers
Divider() // both.
menuItem("Leave Us Feedback") { requestReview() }
menuItem("Documentation") { openURL(repositoryURL) }
if let apiDocumentationURL {
menuItem("API Documentation") { openURL(apiDocumentationURL) }
}
menuItem("Changelog") { openURL(repositoryURL) }
Divider()
menuItem("Send Us Feedback") { openURL(issuesURL) }
menuItem("Report a Bug") { openURL(issuesURL) }
Divider() Divider()
@@ -106,6 +89,14 @@ struct AccountFooter: View {
// as "Invalid attempt to open a new transaction during CA // as "Invalid attempt to open a new transaction during CA
// commit") letting the popover's dismissal finish first avoids it. // commit") letting the popover's dismissal finish first avoids it.
menuItem("Settings…") { Task { @MainActor in navigation.isShowingSettings = true } } menuItem("Settings…") { Task { @MainActor in navigation.isShowingSettings = true } }
// Same deferred-Task reasoning as Settings above this also
// sets isShowingSettings synchronously.
menuItem("Support Outpost") {
Task { @MainActor in
navigation.selectedSettingsSection = .about
navigation.isShowingSettings = true
}
}
Divider() Divider()
@@ -74,10 +74,12 @@ struct AvatarCropperView: View {
Button("Cancel", role: .cancel, action: onCancel) Button("Cancel", role: .cancel, action: onCancel)
Spacer() Spacer()
Button("Use Photo") { Button("Use Photo") {
if let data = renderFinalImage() { Task {
if let data = await renderFinalImage() {
onConfirm(data) onConfirm(data)
} }
} }
}
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
} }
} }
@@ -100,15 +102,26 @@ struct AvatarCropperView: View {
.clipped() .clipped()
} }
/// `ImageRenderer` itself has to run on the main actor (it captures live
/// SwiftUI view state), but JPEG compression on the bitmap it produces
/// is pure CPU work with no SwiftUI dependency left hopping off for
/// just that part avoids a visible hitch on tapping "Use Photo".
/// `tiffRepresentation` (plain `Data`, unlike `NSImage` itself) is what
/// actually crosses the actor boundary; mirrors `NSImage.jpegData(
/// compressionQuality:)`'s own logic rather than calling it directly, so
/// crossing doesn't require handing a non-Sendable `NSImage` to a
/// detached task.
@MainActor @MainActor
private func renderFinalImage() -> Data? { private func renderFinalImage() async -> Data? {
let content = avatarContent let content = avatarContent
.clipShape(Circle()) .clipShape(Circle())
.frame(width: diameter, height: diameter) .frame(width: diameter, height: diameter)
let renderer = ImageRenderer(content: content) let renderer = ImageRenderer(content: content)
renderer.scale = 2 // @2x so it isn't a blurry 320px avatar on Retina displays renderer.scale = 2 // @2x so it isn't a blurry 320px avatar on Retina displays
guard let nsImage = renderer.nsImage else { return nil } guard let tiffData = renderer.nsImage?.tiffRepresentation else { return nil }
return nsImage.jpegData(compressionQuality: 0.9) return await Task.detached(priority: .userInitiated) {
NSBitmapImageRep(data: tiffData)?.representation(using: .jpeg, properties: [.compressionFactor: 0.9])
}.value
} }
} }
#endif #endif
@@ -1,39 +0,0 @@
#if os(macOS)
import SwiftUI
struct KeyboardShortcutsView: View {
private struct Shortcut: Identifiable {
let id = UUID()
let action: String
let keys: String
}
private let shortcuts: [Shortcut] = [
Shortcut(action: "Sign In", keys: ""),
Shortcut(action: "Preferences", keys: "⌘ ,"),
Shortcut(action: "Close Window", keys: "⌘ W"),
Shortcut(action: "Quit Outpost", keys: "⌘ Q")
]
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Keyboard Shortcuts")
.font(.title3.bold())
VStack(spacing: 10) {
ForEach(shortcuts) { shortcut in
HStack {
Text(shortcut.action)
Spacer()
Text(shortcut.keys)
.foregroundStyle(.secondary)
.monospaced()
}
}
}
}
.padding(24)
.frame(width: 280)
}
}
#endif
@@ -42,7 +42,15 @@ struct SettingsSidebarList: View {
Divider() Divider()
List(selection: $selection) { List(selection: $selection) {
ForEach(SettingsCategory.allCases) { category in // TODO: Workspace is entirely `!isImplemented` placeholders
// right now (details/authentication/security/ai/members/
// groups/templates/emojis/applications/shared/links/
// webhooks/importData/exportData) App Store review won't
// accept a section that's just "Coming Soon" rows, so it's
// filtered out of the sidebar below (via `visibleCategories`)
// until real content lands. Remove the filter once at least
// one Workspace section is built.
ForEach(visibleCategories) { category in
let sections = SettingsSection.allCases.filter { $0.category == category } let sections = SettingsSection.allCases.filter { $0.category == category }
Section { Section {
ForEach(sections) { section in ForEach(sections) { section in
@@ -91,6 +99,14 @@ struct SettingsSidebarList: View {
.task(id: isEffectivelyOnline) { await refreshOutlineVersion() } .task(id: isEffectivelyOnline) { await refreshOutlineVersion() }
} }
/// Categories with at least one built (`isImplemented`) section see the
/// TODO above the `ForEach` that uses this.
private var visibleCategories: [SettingsCategory] {
SettingsCategory.allCases.filter { category in
SettingsSection.allCases.contains { $0.category == category && $0.isImplemented }
}
}
private var versionFooter: some View { private var versionFooter: some View {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("Outpost \(OutpostVersion.displayString)") Text("Outpost \(OutpostVersion.displayString)")
@@ -109,7 +125,7 @@ struct SettingsSidebarList: View {
private func refreshOutlineVersion() async { private func refreshOutlineVersion() async {
guard isEffectivelyOnline, let apiClient = session.apiClient else { return } guard isEffectivelyOnline, let apiClient = session.apiClient else { return }
outlineVersion = try? await apiClient.installationInfo().version outlineVersion = try? await RetryPolicy.withRetry({ try await apiClient.installationInfo().version })
} }
} }
#endif #endif
+20 -7
View File
@@ -618,7 +618,7 @@ struct SettingsView: View {
defer { isDeletingAccount = false } defer { isDeletingAccount = false }
do { do {
try await apiClient.deleteAccount() try await apiClient.deleteAccount()
session.signOut() await session.signOut()
} catch { } catch {
deleteAccountErrorMessage = outlineErrorMessage(error, fallback: "Couldn't delete your account.") deleteAccountErrorMessage = outlineErrorMessage(error, fallback: "Couldn't delete your account.")
} }
@@ -1182,6 +1182,11 @@ struct SettingsView: View {
VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 20) {
sectionHeader sectionHeader
Label("Everything cached here is encrypted at rest with a key stored in Keychain, cleared automatically when you log out.", systemImage: "lock.fill")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: 480, alignment: .leading)
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Toggle("Offline Mode", isOn: $isOfflineModeEnabled) Toggle("Offline Mode", isOn: $isOfflineModeEnabled)
Text("Skip the network entirely and work from what's already been cached. Turn this off to reconnect.") Text("Skip the network entirely and work from what's already been cached. Turn this off to reconnect.")
@@ -1325,9 +1330,10 @@ struct SettingsView: View {
} }
.frame(maxWidth: 480, alignment: .leading) .frame(maxWidth: 480, alignment: .leading)
Divider() // TODO: Export All Data / Developer Diagnostics / Reset Local
.frame(maxWidth: 480) // Database aren't built yet App Store review won't accept
// "Coming Soon" rows, so commented out until real content lands.
/*
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
comingSoonRow("Export All Data") comingSoonRow("Export All Data")
comingSoonRow("Developer Diagnostics") comingSoonRow("Developer Diagnostics")
@@ -1335,6 +1341,10 @@ struct SettingsView: View {
} }
.frame(maxWidth: 480, alignment: .leading) .frame(maxWidth: 480, alignment: .leading)
Divider()
.frame(maxWidth: 480)
*/
Divider() Divider()
.frame(maxWidth: 480) .frame(maxWidth: 480)
@@ -1400,6 +1410,9 @@ struct SettingsView: View {
) )
} }
/// Only referenced from the commented-out block above right now kept
/// (not deleted) so re-enabling those rows is a one-line uncomment once
/// they're actually built.
private func comingSoonRow(_ title: String) -> some View { private func comingSoonRow(_ title: String) -> some View {
HStack { HStack {
Text(title) Text(title)
@@ -1503,7 +1516,7 @@ struct SettingsView: View {
private func refreshProfile() async { private func refreshProfile() async {
guard let apiClient = session.apiClient else { return } guard let apiClient = session.apiClient else { return }
guard let fresh = try? await apiClient.currentUser() else { return } guard let fresh = try? await RetryPolicy.withRetry({ try await apiClient.currentUser() }) else { return }
session.applyUpdatedProfile(fresh) session.applyUpdatedProfile(fresh)
} }
@@ -1535,7 +1548,7 @@ struct SettingsView: View {
// Best-effort the new avatar is already live either way, this // Best-effort the new avatar is already live either way, this
// just stops the old upload from sitting around unreferenced. // just stops the old upload from sitting around unreferenced.
if let previousAttachmentId { if let previousAttachmentId {
try? await apiClient.deleteAttachment(id: previousAttachmentId) try? await RetryPolicy.withRetry({ try await apiClient.deleteAttachment(id: previousAttachmentId) })
} }
} catch { } catch {
avatarErrorMessage = outlineErrorMessage(error, fallback: "Couldn't upload this photo.") avatarErrorMessage = outlineErrorMessage(error, fallback: "Couldn't upload this photo.")
@@ -1552,7 +1565,7 @@ struct SettingsView: View {
session.applyUpdatedProfile(updated) session.applyUpdatedProfile(updated)
avatarErrorMessage = nil avatarErrorMessage = nil
if let previousAttachmentId { if let previousAttachmentId {
try? await apiClient.deleteAttachment(id: previousAttachmentId) try? await RetryPolicy.withRetry({ try await apiClient.deleteAttachment(id: previousAttachmentId) })
} }
} catch { } catch {
avatarErrorMessage = outlineErrorMessage(error, fallback: "Couldn't remove this photo.") avatarErrorMessage = outlineErrorMessage(error, fallback: "Couldn't remove this photo.")
@@ -30,8 +30,15 @@ struct CollectionDocumentsOutline: View {
/// every document in the tree. /// every document in the tree.
@State private var pinsByDocumentID: [String: OutlinePin] = [:] @State private var pinsByDocumentID: [String: OutlinePin] = [:]
private var tree: [DocumentNode] { /// Recomputed only when `viewModel.documents`/`sortOption` actually change
buildDocumentTree(from: viewModel.documents, sortedBy: sortOption) /// (below) instead of being a computed property this rebuilt the whole
/// dictionary-grouped, recursively-sorted tree on every `body` evaluation,
/// including renders triggered by unrelated state (selection, hover,
/// pins) that don't change the tree's shape at all.
@State private var tree: [DocumentNode] = []
private func rebuildTree() {
tree = buildDocumentTree(from: viewModel.documents, sortedBy: sortOption)
} }
init( init(
@@ -86,11 +93,14 @@ struct CollectionDocumentsOutline: View {
.task(id: "\(refreshToken)-\(externalRefreshToken)") { .task(id: "\(refreshToken)-\(externalRefreshToken)") {
await viewModel.load() await viewModel.load()
await loadPins() await loadPins()
rebuildTree()
} }
.onChange(of: viewModel.documents) { rebuildTree() }
.onChange(of: sortOption) { rebuildTree() }
} }
private func loadPins() async { private func loadPins() async {
guard let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: collection.id)) else { return } guard let pins = try? await RetryPolicy.withRetry({ try await apiClient.listPins(ListPinsRequest(collectionId: collection.id)) }) else { return }
pinsByDocumentID = Dictionary(uniqueKeysWithValues: pins.map { ($0.documentId, $0) }) pinsByDocumentID = Dictionary(uniqueKeysWithValues: pins.map { ($0.documentId, $0) })
} }
} }
@@ -34,8 +34,15 @@ struct CollectionOverviewView: View {
self.onOpenDocument = onOpenDocument self.onOpenDocument = onOpenDocument
} }
private var sortedDocuments: [OutlineDocument] { /// Recomputed only when `viewModel.documents`/`selectedTab` actually
selectedTab.sorted(viewModel.documents) /// change (below) instead of being a computed property re-sorted on
/// every render capped at 100 documents per collection page, so lower
/// blast radius than the sidebar/command-palette versions of this same
/// pattern, but the same fix.
@State private var sortedDocuments: [OutlineDocument] = []
private func resortDocuments() {
sortedDocuments = selectedTab.sorted(viewModel.documents)
} }
var body: some View { var body: some View {
@@ -94,6 +101,8 @@ struct CollectionOverviewView: View {
await viewModel.checkForRemoteChanges() await viewModel.checkForRemoteChanges()
} }
} }
.onChange(of: viewModel.documents) { resortDocuments() }
.onChange(of: selectedTab) { resortDocuments() }
} }
// Spans the full window width, centered, directly under the toolbar // Spans the full window width, centered, directly under the toolbar
@@ -41,22 +41,31 @@ struct CommandPaletteView: View {
} }
} }
private var results: [Result] { /// Recomputed only when `query`/`collections`/`documents` actually change
/// (below) instead of being a computed property Full Workspace mode's
/// index can be large (every document in the local cache, sub-documents
/// included), and this was re-scanning + re-sorting the entire thing on
/// every render, including ones triggered by unrelated state like
/// `selectedIndex` changing as arrow keys move the selection.
@State private var results: [Result] = []
private func recomputeResults() {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { guard !trimmed.isEmpty else {
// No query yet: surface collections first, then the most // No query yet: surface collections first, then the most
// recent/full-workspace documents as-is, capped so the panel // recent/full-workspace documents as-is, capped so the panel
// doesn't dump the entire workspace with nothing typed. // doesn't dump the entire workspace with nothing typed.
return (collections.map(Result.collection) + documents.map(Result.document)) results = (collections.map(Result.collection) + documents.map(Result.document))
.prefix(20) .prefix(20)
.map { $0 } .map { $0 }
return
} }
let scored: [(Result, Int)] = collections.compactMap { collection in let scored: [(Result, Int)] = collections.compactMap { collection in
matchScore(collection.name, query: trimmed).map { (Result.collection(collection), $0) } matchScore(collection.name, query: trimmed).map { (Result.collection(collection), $0) }
} + documents.compactMap { document in } + documents.compactMap { document in
matchScore(document.title, query: trimmed).map { (Result.document(document), $0) } matchScore(document.title, query: trimmed).map { (Result.document(document), $0) }
} }
return scored.sorted { $0.1 < $1.1 }.prefix(30).map(\.0) results = scored.sorted { $0.1 < $1.1 }.prefix(30).map(\.0)
} }
/// Lower is better exact match, then prefix match, then earliest /// Lower is better exact match, then prefix match, then earliest
@@ -100,7 +109,10 @@ struct CommandPaletteView: View {
.textFieldStyle(.plain) .textFieldStyle(.plain)
.font(.title3) .font(.title3)
.focused($isSearchFieldFocused) .focused($isSearchFieldFocused)
.onChange(of: query) { selectedIndex = 0 } .onChange(of: query) {
selectedIndex = 0
recomputeResults()
}
.onSubmit { selectCurrent() } .onSubmit { selectCurrent() }
// Attached directly on the field itself, not an // Attached directly on the field itself, not an
// ancestor confirmed live that .onKeyPress on the // ancestor confirmed live that .onKeyPress on the
@@ -169,6 +181,8 @@ struct CommandPaletteView: View {
isSearchFieldFocused = true isSearchFieldFocused = true
await loadResults() await loadResults()
} }
.onChange(of: collections) { recomputeResults() }
.onChange(of: documents) { recomputeResults() }
} }
private func resultRow(_ result: Result, isSelected: Bool) -> some View { private func resultRow(_ result: Result, isSelected: Bool) -> some View {
@@ -123,7 +123,7 @@ struct DocumentCommentsSheet: View {
} }
.frame(width: 480, height: 560) .frame(width: 480, height: 560)
.task { await load() } .task { await load() }
.task { currentUserId = try? await apiClient.currentUser().id } .task { currentUserId = try? await RetryPolicy.withRetry({ try await apiClient.currentUser().id }) }
.task { composingAnchorText = pendingAnchorText } .task { composingAnchorText = pendingAnchorText }
.alert("Couldn't Complete Action", isPresented: .constant(actionErrorMessage != nil)) { .alert("Couldn't Complete Action", isPresented: .constant(actionErrorMessage != nil)) {
Button("OK") { actionErrorMessage = nil } Button("OK") { actionErrorMessage = nil }
@@ -328,9 +328,11 @@ struct DocumentReaderView: View {
await viewModel.loadInsightsEnabledState() await viewModel.loadInsightsEnabledState()
} }
.task { .task {
loadedComments = (try? await apiClient.listComments( loadedComments = (try? await RetryPolicy.withRetry({
try await apiClient.listComments(
ListCommentsRequest(documentId: viewModel.documentId, includeAnchorText: true) ListCommentsRequest(documentId: viewModel.documentId, includeAnchorText: true)
)) ?? [] )
})) ?? []
} }
.task { .task {
while !Task.isCancelled { while !Task.isCancelled {
@@ -387,9 +389,11 @@ struct DocumentReaderView: View {
pendingAnchorText: pendingCommentAnchorText, pendingAnchorText: pendingCommentAnchorText,
onCommentsChanged: { onCommentsChanged: {
Task { Task {
loadedComments = (try? await apiClient.listComments( loadedComments = (try? await RetryPolicy.withRetry({
try await apiClient.listComments(
ListCommentsRequest(documentId: viewModel.documentId, includeAnchorText: true) ListCommentsRequest(documentId: viewModel.documentId, includeAnchorText: true)
)) ?? loadedComments )
})) ?? loadedComments
} }
} }
) )
@@ -111,14 +111,18 @@ final class DocumentReaderViewModel {
} }
func loadViewers() async { func loadViewers() async {
guard let views = try? await apiClient.listViews(ListViewsRequest(documentId: documentId)) else { return } // A single blip here used to just leave `viewers` empty forever with
// no sign anything went wrong retry-with-backoff absorbs that;
// `try?` still covers the "still failing after retries" case, same
// silent-but-harmless fallback as before (an empty viewers list).
guard let views = try? await RetryPolicy.withRetry({ try await apiClient.listViews(ListViewsRequest(documentId: documentId)) }) else { return }
viewers = views.filter { $0.lastViewedAt != nil } viewers = views.filter { $0.lastViewedAt != nil }
} }
func loadPinAndSubscriptionState() async { func loadPinAndSubscriptionState() async {
// `collectionId: nil` = Home pins. This menu's Pin action is "Pin to // `collectionId: nil` = Home pins. This menu's Pin action is "Pin to
// Home", not "Pin to Collection" those are distinct on the server. // Home", not "Pin to Collection" those are distinct on the server.
if let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: nil)), if let pins = try? await RetryPolicy.withRetry({ try await apiClient.listPins(ListPinsRequest(collectionId: nil)) }),
let match = pins.first(where: { $0.documentId == documentId }) { let match = pins.first(where: { $0.documentId == documentId }) {
isPinned = true isPinned = true
pinId = match.id pinId = match.id
@@ -127,7 +131,7 @@ final class DocumentReaderViewModel {
pinId = nil pinId = nil
} }
if let subscriptions = try? await apiClient.listSubscriptions(ListSubscriptionsRequest(documentId: documentId)), if let subscriptions = try? await RetryPolicy.withRetry({ try await apiClient.listSubscriptions(ListSubscriptionsRequest(documentId: documentId)) }),
let match = subscriptions.first { let match = subscriptions.first {
isSubscribed = true isSubscribed = true
subscriptionId = match.id subscriptionId = match.id
@@ -22,9 +22,26 @@ struct DocumentSearchSheet: View {
@State private var currentMatchIndex = 0 @State private var currentMatchIndex = 0
@FocusState private var isSearchFieldFocused: Bool @FocusState private var isSearchFieldFocused: Bool
private var matchingLineIndices: [Int] { /// Recomputed only when `query`/`lines` actually change (`recomputeMatches()`)
guard !query.isEmpty else { return [] } /// instead of being a computed property this used to re-scan the whole
return lines.indices.filter { lines[$0].localizedCaseInsensitiveContains(query) } /// document on every access, and it's read multiple times per row
/// (`isCurrentMatch`, the highlight check) on every SwiftUI re-render, so a
/// large document turned into an O(n²) case-insensitive scan per frame.
@State private var matchingLineIndices: [Int] = []
/// O(1) membership for the per-row highlight check below `matchingLineIndices`
/// stays an ordered array (needed for `currentMatchIndex`/stepping), this is
/// just a parallel lookup so a common search term with many matches doesn't
/// make every row's highlight check an O(k) linear scan.
@State private var matchingLineIndexSet: Set<Int> = []
private func recomputeMatches() {
guard !query.isEmpty else {
matchingLineIndices = []
matchingLineIndexSet = []
return
}
matchingLineIndices = lines.indices.filter { lines[$0].localizedCaseInsensitiveContains(query) }
matchingLineIndexSet = Set(matchingLineIndices)
} }
var body: some View { var body: some View {
@@ -35,7 +52,10 @@ struct DocumentSearchSheet: View {
TextField("Search in \"\(document.title.isEmpty ? "Untitled" : document.title)\"", text: $query) TextField("Search in \"\(document.title.isEmpty ? "Untitled" : document.title)\"", text: $query)
.textFieldStyle(.plain) .textFieldStyle(.plain)
.focused($isSearchFieldFocused) .focused($isSearchFieldFocused)
.onChange(of: query) { currentMatchIndex = 0 } .onChange(of: query) {
currentMatchIndex = 0
recomputeMatches()
}
if !matchingLineIndices.isEmpty { if !matchingLineIndices.isEmpty {
Text("\(currentMatchIndex + 1) of \(matchingLineIndices.count)") Text("\(currentMatchIndex + 1) of \(matchingLineIndices.count)")
@@ -83,7 +103,7 @@ struct DocumentSearchSheet: View {
.padding(.horizontal, 4) .padding(.horizontal, 4)
.background( .background(
isCurrentMatch(index) ? Color.yellow.opacity(0.4) isCurrentMatch(index) ? Color.yellow.opacity(0.4)
: matchingLineIndices.contains(index) ? Color.yellow.opacity(0.15) : matchingLineIndexSet.contains(index) ? Color.yellow.opacity(0.15)
: Color.clear : Color.clear
) )
.id(index) .id(index)
@@ -131,6 +151,7 @@ struct DocumentSearchSheet: View {
do { do {
let text = try await apiClient.documentInfo(id: document.id).text let text = try await apiClient.documentInfo(id: document.id).text
lines = text.components(separatedBy: "\n") lines = text.components(separatedBy: "\n")
recomputeMatches()
} catch { } catch {
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load this document.") errorMessage = outlineErrorMessage(error, fallback: "Couldn't load this document.")
} }
@@ -370,7 +370,7 @@ struct DocumentShareSheet: View {
private func loadMembers() async { private func loadMembers() async {
isLoadingMembers = true isLoadingMembers = true
defer { isLoadingMembers = false } defer { isLoadingMembers = false }
members = (try? await apiClient.documentUsers(ListDocumentUsersRequest(id: documentId))) ?? [] members = (try? await RetryPolicy.withRetry({ try await apiClient.documentUsers(ListDocumentUsersRequest(id: documentId)) })) ?? []
} }
private func searchUsers(_ query: String) async { private func searchUsers(_ query: String) async {
@@ -381,7 +381,7 @@ struct DocumentShareSheet: View {
} }
isSearchingUsers = true isSearchingUsers = true
defer { isSearchingUsers = false } defer { isSearchingUsers = false }
userSearchResults = (try? await apiClient.listUsers(ListUsersRequest(query: trimmed))) ?? [] userSearchResults = (try? await RetryPolicy.withRetry({ try await apiClient.listUsers(ListUsersRequest(query: trimmed)) })) ?? []
} }
private func addUser(_ user: OutlineUser) async { private func addUser(_ user: OutlineUser) async {
+16 -7
View File
@@ -83,16 +83,25 @@ final class HomeViewModel {
/// `pins.list` only returns pin records, not the documents themselves /// `pins.list` only returns pin records, not the documents themselves
/// fetches each pinned document individually. Pins are a small curated /// fetches each pinned document individually. Pins are a small curated
/// set (unlike a full collection tree), so the N+1 here is acceptable /// set (unlike a full collection tree), so the N+1 here is acceptable
/// where it wouldn't be in the sidebar. /// where it wouldn't be in the sidebar but they're fetched concurrently
/// (a `TaskGroup`, not a serial loop) so latency doesn't scale with pin
/// count; `documentInfo` already goes through `CachingOutlineAPIClient`'s
/// own cached-read/retry path either way.
private func fetchPinnedThrowing() async throws -> [OutlineDocument] { private func fetchPinnedThrowing() async throws -> [OutlineDocument] {
let pins = try await apiClient.listPins(ListPinsRequest(collectionId: nil)) let pins = try await RetryPolicy.withRetry { try await apiClient.listPins(ListPinsRequest(collectionId: nil)) }
var documents: [OutlineDocument] = [] let client = apiClient
let documentsByID: [String: OutlineDocument] = await withTaskGroup(of: (String, OutlineDocument?).self) { group in
for pin in pins { for pin in pins {
if let document = try? await apiClient.documentInfo(id: pin.documentId) { group.addTask { (pin.documentId, try? await client.documentInfo(id: pin.documentId)) }
documents.append(document)
} }
var result: [String: OutlineDocument] = [:]
for await (id, document) in group {
if let document { result[id] = document }
} }
return documents return result
}
// Preserve pins.list's own order rather than task-completion order.
return pins.compactMap { documentsByID[$0.documentId] }
} }
private func fetch(tab: HomeTab) async throws -> [OutlineDocument] { private func fetch(tab: HomeTab) async throws -> [OutlineDocument] {
@@ -134,7 +143,7 @@ final class HomeViewModel {
private func resolveCurrentUserID() async throws -> String { private func resolveCurrentUserID() async throws -> String {
if let currentUserID { return currentUserID } if let currentUserID { return currentUserID }
let user = try await apiClient.currentUser() let user = try await RetryPolicy.withRetry { try await apiClient.currentUser() }
currentUserID = user.id currentUserID = user.id
return user.id return user.id
} }
-8
View File
@@ -75,14 +75,6 @@ struct OutpostApp: App {
} }
} }
#endif #endif
#if os(macOS)
Window("Keyboard Shortcuts", id: "keyboard-shortcuts") {
KeyboardShortcutsView()
.disablesFullScreen()
}
.windowResizability(.contentSize)
#endif
} }
#if os(macOS) #if os(macOS)
+67
View File
@@ -0,0 +1,67 @@
import Foundation
import Observation
import OutlineKit
/// Turns `CachingOutlineAPIClient.repeatedFailureSummaries()` into a banner
/// the user can actually see and act on, instead of a silently-swallowed
/// `try?` see the pins bug this whole mechanism exists to catch a repeat
/// of. `RootView` polls the client periodically and feeds results in via
/// `update(with:)`; nothing here talks to the network directly.
@MainActor
@Observable
final class APIFailureCenter {
/// The single most-relevant category to show right now, or nil if
/// nothing's currently past the threshold (or everything past it has
/// been dismissed and is still in its cooldown).
private(set) var activeBanner: RepeatedFailure?
/// Categories the user's already dismissed, and when suppressed from
/// reappearing until `dismissCooldown` passes, so a still-flaky
/// operation doesn't pop the same banner right back up a few seconds
/// after being told to go away.
private var dismissedAt: [String: Date] = [:]
private let dismissCooldown: TimeInterval = 900
/// Called from `RootView`'s poll loop with the latest snapshot from
/// `CachingOutlineAPIClient`. Picks the worst-offending category
/// (highest failure count) that isn't in cooldown; clears the banner
/// entirely once nothing qualifies (e.g. the user went back online and
/// everything recovered).
func update(with summaries: [RepeatedFailure]) {
let now = Date()
dismissedAt = dismissedAt.filter { now.timeIntervalSince($0.value) < dismissCooldown }
let eligible = summaries
.filter { dismissedAt[$0.category] == nil }
.sorted { $0.count > $1.count }
activeBanner = eligible.first
}
/// Dismiss without reporting starts that category's cooldown so it
/// won't immediately reappear on the next poll if it's still failing.
func dismiss() {
guard let category = activeBanner?.category else { return }
dismissedAt[category] = Date()
activeBanner = nil
}
/// Everything folded into the report is safe to paste into a public bug
/// tracker as-is: a category name, a generic error description, and
/// version numbers no document content, no server URL, no token.
func reportURL(appVersion: String, osVersion: String) -> URL? {
guard let banner = activeBanner else { return nil }
var components = URLComponents(string: "https://git.psmattas.com/psmattas/Outpost/issues/new")
let body = """
Outpost kept failing to \(banner.category) (\(banner.count) times in the last few minutes).
Error: \(banner.message)
App version: \(appVersion)
macOS: \(osVersion)
<!-- Anything else you can add about what you were doing when this started would help. -->
"""
components?.queryItems = [URLQueryItem(name: "body", value: body)]
return components?.url
}
}
+38
View File
@@ -0,0 +1,38 @@
#if os(macOS)
import SwiftUI
/// Shown when the same category of API call has failed repeatedly within a
/// few minutes (see `APIFailureCenter`) the self-diagnosing replacement
/// for a `try?` that used to fail silently. No manual "Retry" button: the
/// retries already happened automatically before this ever appears, so all
/// that's left worth offering is reporting it and moving on.
struct RepeatedFailureBanner: View {
let message: String
let onReport: () -> Void
let onDismiss: () -> Void
var body: some View {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle")
.foregroundStyle(.orange)
Text(message)
.font(.callout)
.lineLimit(2)
Spacer(minLength: 8)
Button("Report", action: onReport)
.buttonStyle(.borderedProminent)
.controlSize(.small)
Button {
onDismiss()
} label: {
Image(systemName: "xmark")
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.orange.opacity(0.12))
}
}
#endif
+60
View File
@@ -3,8 +3,10 @@ import OutlineKit
struct RootView: View { struct RootView: View {
@Environment(SessionStore.self) private var session @Environment(SessionStore.self) private var session
@Environment(\.openURL) private var openURL
@State private var welcomeName: String? @State private var welcomeName: String?
@State private var starStore = StarStore() @State private var starStore = StarStore()
@State private var failureCenter = APIFailureCenter()
@AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false @AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false @AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
@@ -34,10 +36,39 @@ struct RootView: View {
} }
} }
.environment(starStore) .environment(starStore)
.environment(failureCenter)
#if os(macOS)
.overlay(alignment: .top) {
if let banner = failureCenter.activeBanner {
RepeatedFailureBanner(
message: bannerMessage(for: banner),
onReport: { reportActiveFailure() },
onDismiss: { failureCenter.dismiss() }
)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
.animation(.easeInOut(duration: 0.2), value: failureCenter.activeBanner)
#endif
.animation(.easeInOut(duration: 0.45), value: welcomeName != nil) .animation(.easeInOut(duration: 0.45), value: welcomeName != nil)
.task { .task {
await session.refreshTeamInfoIfNeeded() await session.refreshTeamInfoIfNeeded()
} }
// Repeated (non-transient) API failures already get an automatic
// retry-with-backoff inside CachingOutlineAPIClient itself this
// just surfaces the ones that kept failing anyway, on a cheap poll
// (the client's own state, no network call of its own) rather than
// a push, since the client is a plain actor with no UI dependency.
.task(id: session.isSignedIn) {
guard session.isSignedIn else { return }
while !Task.isCancelled {
if let cachingClient = session.cachingClient {
let summaries = await cachingClient.repeatedFailureSummaries()
failureCenter.update(with: summaries)
}
try? await Task.sleep(for: .seconds(30))
}
}
.task(id: session.isSignedIn) { .task(id: session.isSignedIn) {
if session.isSignedIn, let apiClient = session.apiClient { if session.isSignedIn, let apiClient = session.apiClient {
await starStore.load(apiClient: apiClient) await starStore.load(apiClient: apiClient)
@@ -89,6 +120,35 @@ struct RootView: View {
} }
} }
#if os(macOS)
/// Category names are internal plumbing (`documents-write`, `pins`,
/// `collections-write`, ...) this is the one place they turn into
/// something a user reads, so a new category added later just needs a
/// case here, not a rewrite of the tracking/polling underneath it.
private func bannerMessage(for failure: RepeatedFailure) -> String {
switch failure.category {
case "documents-write": return "Outpost is having trouble saving your document edits."
case "collections-write": return "Outpost is having trouble saving collection changes."
case "pins": return "Outpost is having trouble updating pins."
case "subscriptions": return "Outpost is having trouble updating subscriptions."
case "stars": return "Outpost is having trouble updating stars."
case "document", "documents": return "Outpost is having trouble loading documents."
case "collections": return "Outpost is having trouble loading collections."
case "drafts": return "Outpost is having trouble loading drafts."
default: return "Outpost is having trouble talking to the server (\(failure.category))."
}
}
private func reportActiveFailure() {
guard let url = failureCenter.reportURL(
appVersion: OutpostVersion.displayString,
osVersion: ProcessInfo.processInfo.operatingSystemVersionString
) else { return }
openURL(url)
failureCenter.dismiss()
}
#endif
private func startWelcomeTransition(_ result: AuthViewModel.AuthResult) { private func startWelcomeTransition(_ result: AuthViewModel.AuthResult) {
welcomeName = result.user.name welcomeName = result.user.name
session.signIn(serverURL: result.serverURL, user: result.user, team: result.team) session.signIn(serverURL: result.serverURL, user: result.user, team: result.team)
+33 -6
View File
@@ -15,6 +15,7 @@ final class SessionStore {
private static let userPreferencesDefaultsKey = "outline.userPreferences" private static let userPreferencesDefaultsKey = "outline.userPreferences"
private let tokenStore: TokenStoring private let tokenStore: TokenStoring
private let cacheEncryptionKeyStore: CacheEncryptionKeyStoring
private let defaults: UserDefaults private let defaults: UserDefaults
var isSignedIn: Bool var isSignedIn: Bool
@@ -43,8 +44,13 @@ final class SessionStore {
defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:)) defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:))
} }
init(tokenStore: TokenStoring = KeychainTokenStore(), defaults: UserDefaults = .standard) { init(
tokenStore: TokenStoring = KeychainTokenStore(),
cacheEncryptionKeyStore: CacheEncryptionKeyStoring = KeychainCacheEncryptionKeyStore(),
defaults: UserDefaults = .standard
) {
self.tokenStore = tokenStore self.tokenStore = tokenStore
self.cacheEncryptionKeyStore = cacheEncryptionKeyStore
self.defaults = defaults self.defaults = defaults
self.cacheStore = (try? OfflineCacheStore.makeContainer()).map(OfflineCacheStore.init(modelContainer:)) self.cacheStore = (try? OfflineCacheStore.makeContainer()).map(OfflineCacheStore.init(modelContainer:))
@@ -53,7 +59,12 @@ final class SessionStore {
if hasToken, let storedServerURL { if hasToken, let storedServerURL {
isSignedIn = true isSignedIn = true
(apiClient, cachingClient) = Self.makeAPIClient(serverURL: storedServerURL, tokenStore: tokenStore, cache: cacheStore) (apiClient, cachingClient) = Self.makeAPIClient(
serverURL: storedServerURL,
tokenStore: tokenStore,
cacheEncryptionKeyStore: cacheEncryptionKeyStore,
cache: cacheStore
)
userPreferences = Self.loadCachedPreferences(defaults: defaults) userPreferences = Self.loadCachedPreferences(defaults: defaults)
} else { } else {
// Keychain and the sandboxed UserDefaults container don't // Keychain and the sandboxed UserDefaults container don't
@@ -73,7 +84,12 @@ final class SessionStore {
func signIn(serverURL: URL, user: OutlineUser, team: OutlineTeam) { func signIn(serverURL: URL, user: OutlineUser, team: OutlineTeam) {
defaults.set(serverURL.absoluteString, forKey: Self.serverURLDefaultsKey) defaults.set(serverURL.absoluteString, forKey: Self.serverURLDefaultsKey)
(apiClient, cachingClient) = Self.makeAPIClient(serverURL: serverURL, tokenStore: tokenStore, cache: cacheStore) (apiClient, cachingClient) = Self.makeAPIClient(
serverURL: serverURL,
tokenStore: tokenStore,
cacheEncryptionKeyStore: cacheEncryptionKeyStore,
cache: cacheStore
)
apply(user: user, team: team, serverURL: serverURL) apply(user: user, team: team, serverURL: serverURL)
isSignedIn = true isSignedIn = true
} }
@@ -99,6 +115,7 @@ final class SessionStore {
private static func makeAPIClient( private static func makeAPIClient(
serverURL: URL, serverURL: URL,
tokenStore: TokenStoring, tokenStore: TokenStoring,
cacheEncryptionKeyStore: CacheEncryptionKeyStoring,
cache: OfflineCacheStore? cache: OfflineCacheStore?
) -> (OutlineAPIClient, CachingOutlineAPIClient?) { ) -> (OutlineAPIClient, CachingOutlineAPIClient?) {
let live = LiveOutlineAPIClient( let live = LiveOutlineAPIClient(
@@ -106,12 +123,22 @@ final class SessionStore {
tokenStore: tokenStore tokenStore: tokenStore
) )
guard let cache else { return (live, nil) } guard let cache else { return (live, nil) }
let caching = CachingOutlineAPIClient(live: live, cache: cache) let caching = CachingOutlineAPIClient(live: live, cache: cache, encryptionKeyStore: cacheEncryptionKeyStore)
return (caching, caching) return (caching, caching)
} }
func signOut() { /// Clears everything scoped to this sign-in: the API token, the offline
/// cache's encryption key, and the cache/pending-write storage itself
/// (in that order wiping storage before the key would leave it
/// readable a moment longer than necessary, and wiping the key without
/// the storage would leave permanently-undecryptable rows sitting
/// around instead of actually freeing anything). Whoever signs in next
/// on this machine gets a clean slate, not a previous account's
/// leftover cached content.
func signOut() async {
try? tokenStore.clear() try? tokenStore.clear()
await cachingClient?.clearEverythingForSignOut()
try? cacheEncryptionKeyStore.clear()
defaults.removeObject(forKey: Self.serverURLDefaultsKey) defaults.removeObject(forKey: Self.serverURLDefaultsKey)
isSignedIn = false isSignedIn = false
userId = nil userId = nil
@@ -136,7 +163,7 @@ final class SessionStore {
/// in-memory state didn't. /// in-memory state didn't.
func refreshTeamInfoIfNeeded() async { func refreshTeamInfoIfNeeded() async {
guard isSignedIn, teamName == nil, let apiClient, let serverURL else { return } guard isSignedIn, teamName == nil, let apiClient, let serverURL else { return }
guard let auth = try? await apiClient.authInfo() else { return } guard let auth = try? await RetryPolicy.withRetry({ try await apiClient.authInfo() }) else { return }
apply(user: auth.user, team: auth.team, serverURL: serverURL) apply(user: auth.user, team: auth.team, serverURL: serverURL)
} }
+7 -5
View File
@@ -7,24 +7,26 @@ import Foundation
enum OutpostVersion { enum OutpostVersion {
/// Bumped alongside `MARKETING_VERSION` in the Xcode project kept out /// Bumped alongside `MARKETING_VERSION` in the Xcode project kept out
/// of the bundle version itself since `CFBundleShortVersionString` is /// of the bundle version itself since `CFBundleShortVersionString` is
/// expected to stay a plain dotted-numeric string, not `0.0.1-ALPHA`. /// expected to stay a plain dotted-numeric string, not `0.1.0-ALPHA`.
static let releaseStage = "ALPHA" /// Empty since the App Store release (no more alpha/beta suffix)
/// `displayString`/`fullVersionString` just show the plain version now.
static let releaseStage = ""
static var shortVersion: String { static var shortVersion: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.1" Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.1.0"
} }
static var buildNumber: String { static var buildNumber: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1" Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
} }
/// e.g. `"0.0.3-ALPHA"` for compact display (sidebar footer). /// e.g. `"0.1.0"` for compact display (sidebar footer).
static var displayString: String { static var displayString: String {
let stageSuffix = releaseStage.isEmpty ? "" : "-\(releaseStage)" let stageSuffix = releaseStage.isEmpty ? "" : "-\(releaseStage)"
return "\(shortVersion)\(stageSuffix)" return "\(shortVersion)\(stageSuffix)"
} }
/// e.g. `"Version 0.0.3-ALPHA (1)"` for the About page. /// e.g. `"Version 0.1.0 (1)"` for the About page.
static var fullVersionString: String { static var fullVersionString: String {
"Version \(displayString) (\(buildNumber))" "Version \(displayString) (\(buildNumber))"
} }
+1 -1
View File
@@ -22,7 +22,7 @@ final class StarStore {
} }
func load(apiClient: OutlineAPIClient) async { func load(apiClient: OutlineAPIClient) async {
guard let stars = try? await apiClient.listStars(ListStarsRequest(offset: 0, limit: 250)) else { return } guard let stars = try? await RetryPolicy.withRetry({ try await apiClient.listStars(ListStarsRequest(offset: 0, limit: 250)) }) else { return }
documentStars = Dictionary(uniqueKeysWithValues: stars.compactMap { star in documentStars = Dictionary(uniqueKeysWithValues: stars.compactMap { star in
star.documentId.map { ($0, star) } star.documentId.map { ($0, star) }
}) })
+71
View File
@@ -0,0 +1,71 @@
import StoreKit
import Observation
/// Backs the tip jar in Settings About. Consumables only a tip doesn't
/// unlock anything, so there's no entitlement to persist or restore, and
/// finishing the transaction immediately (rather than checking
/// `Transaction.currentEntitlements` on launch, the way a real purchase
/// would need to) is correct here.
@MainActor
@Observable
final class TipJarStore {
enum PurchaseState: Equatable {
case idle
case purchasing(String)
case thankYou(String)
case failed(String)
}
/// Must match the consumable In-App Purchase products created in App
/// Store Connect for this app exactly, including the bundle id prefix.
static let productIDs = [
"com.psmattas.OutpostApp.tip.small",
"com.psmattas.OutpostApp.tip.medium",
"com.psmattas.OutpostApp.tip.large",
"com.psmattas.OutpostApp.tip.generous"
]
private(set) var products: [Product] = []
private(set) var isLoading = false
var purchaseState: PurchaseState = .idle
/// Tip options don't change during a session no reason to refetch
/// every time the About screen appears.
func loadProductsIfNeeded() async {
guard products.isEmpty, !isLoading else { return }
isLoading = true
defer { isLoading = false }
do {
let fetched = try await Product.products(for: Self.productIDs)
// Keep the order defined above (small -> generous), not
// whatever order the App Store happens to return them in.
products = Self.productIDs.compactMap { id in fetched.first { $0.id == id } }
if products.isEmpty {
purchaseState = .failed("Tip options aren't available right now.")
}
} catch {
purchaseState = .failed("Couldn't load tip options. Check your connection and try again.")
}
}
func purchase(_ product: Product) async {
purchaseState = .purchasing(product.id)
do {
switch try await product.purchase() {
case .success(let verification):
guard case .verified(let transaction) = verification else {
purchaseState = .failed("Couldn't verify this purchase.")
return
}
await transaction.finish()
purchaseState = .thankYou(product.id)
case .userCancelled, .pending:
purchaseState = .idle
@unknown default:
purchaseState = .idle
}
} catch {
purchaseState = .failed("Something went wrong completing the purchase.")
}
}
}
@@ -8,7 +8,7 @@ extension View {
titleVisibility: .visible titleVisibility: .visible
) { ) {
Button("Log Out", role: .destructive) { Button("Log Out", role: .destructive) {
session.signOut() Task { await session.signOut() }
} }
Button("Cancel", role: .cancel) {} Button("Cancel", role: .cancel) {}
} message: { } message: {
-38
View File
@@ -1,38 +0,0 @@
#if os(macOS)
import SwiftUI
import AppKit
/// Grabs the hosting `NSWindow` once it's attached to a screen, for the
/// handful of things SwiftUI's `Window` scene doesn't expose a modifier for.
private struct WindowConfigurator: NSViewRepresentable {
let configure: (NSWindow) -> Void
func makeNSView(context: Context) -> NSView {
let view = NSView()
DispatchQueue.main.async {
if let window = view.window {
configure(window)
}
}
return view
}
func updateNSView(_ nsView: NSView, context: Context) {}
}
extension View {
/// `Window` scenes default to a full standard titlebar, fullscreen
/// (green) button included not appropriate for fixed-size reference
/// panels like About or Keyboard Shortcuts, which have no reason to
/// support fullscreen at all.
func disablesFullScreen() -> some View {
background(
WindowConfigurator { window in
window.collectionBehavior.remove(.fullScreenPrimary)
window.collectionBehavior.insert(.fullScreenNone)
window.standardWindowButton(.zoomButton)?.isHidden = true
}
)
}
}
#endif
+4 -4
View File
@@ -5,14 +5,14 @@
<h1 align="center">Outpost</h1> <h1 align="center">Outpost</h1>
<p align="center"> <p align="center">
<a href="https://testflight.apple.com/join/y1mYcYAM"> <a href="https://apps.apple.com/us/app/outpost-for-outline/id6802736230">
<img src="https://img.shields.io/badge/Download-TestFlight-0D96F6?style=for-the-badge&logo=apple&logoColor=white" alt="Download on TestFlight"> <img src="docs/assets/mac-app-store-badge.svg" height="40" alt="Download on the Mac App Store">
</a> </a>
</p> </p>
A native Apple ecosystem client for [Outline](https://github.com/outline/outline) — built for iOS, iPadOS, and macOS from a single SwiftUI codebase, aiming for full editing parity with Outline's web app, including realtime collaborative editing. A native Apple ecosystem client for [Outline](https://github.com/outline/outline) — built for iOS, iPadOS, and macOS from a single SwiftUI codebase, aiming for full editing parity with Outline's web app, including realtime collaborative editing.
> **Early alpha — macOS only for now.** Expect missing features and rough edges. iOS/iPadOS support is planned but not in the current build. See the [releases page](https://git.psmattas.com/psmattas/Outpost/releases) for changelogs, and [open an issue](https://git.psmattas.com/psmattas/Outpost/issues) if you hit anything. > **macOS only for now.** Expect missing features and rough edges. iOS/iPadOS support is planned but not in the current build. See the [releases page](https://git.psmattas.com/psmattas/Outpost/releases) for changelogs, and [open an issue](https://git.psmattas.com/psmattas/Outpost/issues) if you hit anything.
## Why ## Why
@@ -21,7 +21,7 @@ Outline's web app is great, but there's no native Apple client with full editing
## Requirements ## Requirements
- Xcode 27+ (currently developed against an Xcode 27 beta — this is a hard minimum, not a suggestion) - Xcode 27+ (currently developed against an Xcode 27 beta — this is a hard minimum, not a suggestion)
- macOS 27+. iOS/iPadOS support is planned but not in the current build (see the alpha note above) — same 27+ minimum will apply once it lands - macOS 27+. iOS/iPadOS support is planned but not in the current build (see the note above) — same 27+ minimum will apply once it lands
- A self-hosted (or hosted) Outline instance with API access - A self-hosted (or hosted) Outline instance with API access
## Setup ## Setup
+3 -3
View File
@@ -18,9 +18,9 @@ within 14 days depending on severity.
## Supported Versions ## Supported Versions
Outpost is in early alpha (`0.0.x`) — there's no stable release line Outpost is early (`0.1.x`) — there's no stable release line yet. Only
yet. Only the most recent tagged release receives fixes; please make the most recent tagged release receives fixes; please make sure
sure you're on the latest alpha before reporting. you're on the latest release before reporting.
| Version | Supported | | Version | Supported |
| :--- | :---: | | :--- | :---: |
@@ -9,7 +9,9 @@
// which costs grow with file size instead of staying constant. The whole point: // which costs grow with file size instead of staying constant. The whole point:
// type in a short file, then a long one, and compare `total` for the same edit. // type in a short file, then a long one, and compare `total` for the same edit.
// //
// Toggle: set the env var MD_PERF=0 in the run scheme to silence. // Toggle: set the env var MD_PERF=1 in the run scheme to enable.
// Off by default even in Debug opt-in, not opt-out, so a normal
// debug run stays quiet.
// Debug-only the whole thing compiles out in Release. // Debug-only the whole thing compiles out in Release.
// Remove before shipping (this file + the `PerfTrace.` call sites). // Remove before shipping (this file + the `PerfTrace.` call sites).
// //
@@ -18,7 +20,7 @@ import Foundation
enum PerfTrace { enum PerfTrace {
#if DEBUG #if DEBUG
static var enabled = ProcessInfo.processInfo.environment["MD_PERF"] != "0" static var enabled = ProcessInfo.processInfo.environment["MD_PERF"] == "1"
/// Opt-in for the sampled full-rebuild verifier asserts (wiki splice, /// Opt-in for the sampled full-rebuild verifier asserts (wiki splice,
/// backtick census, parse buffer). They run 3× O(doc) work synchronously /// backtick census, parse buffer). They run 3× O(doc) work synchronously
/// on every 64th keystroke periodic spikes that pollute the PERF /// on every 64th keystroke periodic spikes that pollute the PERF
@@ -15,7 +15,7 @@ import AppKit
extension NativeTextViewCoordinator { extension NativeTextViewCoordinator {
func updateCodeBlockSelection(textView: NSTextView, parsed: ParsedDocument? = nil) { func updateCodeBlockSelection(textView: NSTextView, parsed: ParsedDocument? = nil) {
guard let textContainer = textView.textContainer else { guard let textContainer = textView.textContainer else {
onCodeBlockSelectionChange?([]) fireCodeBlockSelectionChange([])
return return
} }
@@ -24,7 +24,7 @@ extension NativeTextViewCoordinator {
// no per-call full-token filter. // no per-call full-token filter.
cachedCodeBlockTokens = parsed.codeBlockTokensWithIndices cachedCodeBlockTokens = parsed.codeBlockTokensWithIndices
} else if cachedCodeBlockTokens.isEmpty { } else if cachedCodeBlockTokens.isEmpty {
onCodeBlockSelectionChange?([]) fireCodeBlockSelectionChange([])
return return
} }
@@ -91,6 +91,6 @@ extension NativeTextViewCoordinator {
) )
} }
onCodeBlockSelectionChange?(selections) fireCodeBlockSelectionChange(selections)
} }
} }
@@ -16,7 +16,7 @@ import AppKit
extension NativeTextViewCoordinator { extension NativeTextViewCoordinator {
func updateCommentAnchorRects(textView: NSTextView) { func updateCommentAnchorRects(textView: NSTextView) {
guard !commentAnchorQueries.isEmpty else { guard !commentAnchorQueries.isEmpty else {
onCommentAnchorRectsChange?([]) fireCommentAnchorRectsChange([])
return return
} }
let nsText = textView.string as NSString let nsText = textView.string as NSString
@@ -28,6 +28,6 @@ extension NativeTextViewCoordinator {
let rect = textView.viewRect(forCharacterRange: found, using: layoutBridge) else { continue } let rect = textView.viewRect(forCharacterRange: found, using: layoutBridge) else { continue }
results.append(CommentAnchorRect(id: query.id, rect: rect)) results.append(CommentAnchorRect(id: query.id, rect: rect))
} }
onCommentAnchorRectsChange?(results) fireCommentAnchorRectsChange(results)
} }
} }
@@ -362,7 +362,7 @@ extension NativeTextViewCoordinator {
// selection at all. // selection at all.
if !isRebuildingDocument { if !isRebuildingDocument {
let selRange = tv.selectedRange() let selRange = tv.selectedRange()
onSelectedTextChange?(selRange.length > 0 ? (tv.string as NSString).substring(with: selRange) : nil) fireSelectedTextChange(selRange.length > 0 ? (tv.string as NSString).substring(with: selRange) : nil)
} }
// Raw mode: plain source no reveal, snap-back, or inline previews. // Raw mode: plain source no reveal, snap-back, or inline previews.
if configuration.rawSourceMode { return } if configuration.rawSourceMode { return }
@@ -87,6 +87,26 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate {
var onSelectedTextChange: ((String?) -> Void)? var onSelectedTextChange: ((String?) -> Void)?
var commentAnchorQueries: [CommentAnchorQuery] = [] var commentAnchorQueries: [CommentAnchorQuery] = []
var onCommentAnchorRectsChange: (([CommentAnchorRect]) -> Void)? var onCommentAnchorRectsChange: (([CommentAnchorRect]) -> Void)?
/// These three callbacks can fire from inside `NativeTextViewWrapper.updateNSView`
/// itself (a programmatic edit re-enters `textViewDidChangeSelection`/
/// `textDidChange` synchronously see `isRebuildingDocument` above), which is a
/// SwiftUI view update already in progress on the call stack. Calling straight into
/// an embedder's `@State` setter there trips "Modifying state during view update"
/// deferring one runloop tick is enough to land outside it, same as how
/// `NativeTextViewWrapper` already clears its `pending*` bindings.
func fireCodeBlockSelectionChange(_ selections: [CodeBlockSelection]) {
DispatchQueue.main.async { [onCodeBlockSelectionChange] in onCodeBlockSelectionChange?(selections) }
}
func fireSelectedTextChange(_ text: String?) {
DispatchQueue.main.async { [onSelectedTextChange] in onSelectedTextChange?(text) }
}
func fireCommentAnchorRectsChange(_ rects: [CommentAnchorRect]) {
DispatchQueue.main.async { [onCommentAnchorRectsChange] in onCommentAnchorRectsChange?(rects) }
}
var didInitialFormatting: Bool = false var didInitialFormatting: Bool = false
/// One-shot guard so `updateCodeBlockSelection` only forces a full-document layout once per document. /// One-shot guard so `updateCodeBlockSelection` only forces a full-document layout once per document.
var didEnsureLayoutForCurrentDocument: Bool = false var didEnsureLayoutForCurrentDocument: Bool = false
+51
View File
@@ -0,0 +1,51 @@
<svg id="livetype" xmlns="http://www.w3.org/2000/svg" width="156.10054" height="40" viewBox="0 0 156.10054 40">
<title>Download_on_the_Mac_App_Store_Badge_US-UK_RGB_blk_092917</title>
<g>
<g>
<g>
<path d="M146.57123,0H9.53468c-.3667,0-.729,0-1.09473.002-.30615.002-.60986.00781-.91895.0127A13.21476,13.21476,0,0,0,5.5171.19141a6.66509,6.66509,0,0,0-1.90088.627A6.4378,6.4378,0,0,0,1.99757,1.99707,6.25844,6.25844,0,0,0,.81935,3.61816a6.60119,6.60119,0,0,0-.625,1.90332,12.993,12.993,0,0,0-.1792,2.002C.00587,7.83008.00489,8.1377,0,8.44434V31.5586c.00489.3105.00587.6113.01514.9219a12.99232,12.99232,0,0,0,.1792,2.0019,6.58756,6.58756,0,0,0,.625,1.9043A6.20778,6.20778,0,0,0,1.99757,38.001a6.27446,6.27446,0,0,0,1.61865,1.1787,6.70082,6.70082,0,0,0,1.90088.6308,13.45514,13.45514,0,0,0,2.0039.1768c.30909.0068.6128.0107.91895.0107C8.80567,40,9.168,40,9.53468,40H146.57123c.3594,0,.7246,0,1.084-.002.3047,0,.6172-.0039.9219-.0107a13.279,13.279,0,0,0,2-.1768,6.80432,6.80432,0,0,0,1.9082-.6308,6.27742,6.27742,0,0,0,1.6172-1.1787,6.39482,6.39482,0,0,0,1.1816-1.6143,6.60413,6.60413,0,0,0,.6191-1.9043,13.50643,13.50643,0,0,0,.1856-2.0019c.0039-.3106.0039-.6114.0039-.9219.0078-.3633.0078-.7246.0078-1.0938V9.53613c0-.36621,0-.72949-.0078-1.09179,0-.30664,0-.61426-.0039-.9209a13.5071,13.5071,0,0,0-.1856-2.002,6.6177,6.6177,0,0,0-.6191-1.90332,6.46619,6.46619,0,0,0-2.7988-2.7998,6.76754,6.76754,0,0,0-1.9082-.627,13.04394,13.04394,0,0,0-2-.17676c-.3047-.00488-.6172-.01074-.9219-.01269-.3594-.002-.7246-.002-1.084-.002Z" style="fill: #a6a6a6"/>
<path d="M8.44483,39.125c-.30468,0-.60205-.0039-.90429-.0107a12.68714,12.68714,0,0,1-1.86914-.1631,5.88381,5.88381,0,0,1-1.65674-.5479,5.40573,5.40573,0,0,1-1.397-1.0166,5.32082,5.32082,0,0,1-1.02051-1.3965,5.72184,5.72184,0,0,1-.543-1.6572,12.41339,12.41339,0,0,1-.1665-1.875c-.00634-.2109-.01464-.9131-.01464-.9131V8.44434S.88185,7.75293.8877,7.5498a12.37032,12.37032,0,0,1,.16553-1.87207,5.75552,5.75552,0,0,1,.54346-1.6621A5.3735,5.3735,0,0,1,2.61183,2.61768,5.56543,5.56543,0,0,1,4.01417,1.59521a5.82309,5.82309,0,0,1,1.65332-.54394A12.58589,12.58589,0,0,1,7.543.88721L8.44532.875h139.205l.9131.0127a12.38493,12.38493,0,0,1,1.8584.16259,5.93833,5.93833,0,0,1,1.6709.54785,5.59374,5.59374,0,0,1,2.415,2.41993,5.76267,5.76267,0,0,1,.5352,1.64892,12.995,12.995,0,0,1,.1738,1.88721c.0029.2832.0029.5874.0029.89014.0079.375.0079.73193.0079,1.09179V30.4648c0,.3633,0,.7178-.0079,1.0752,0,.3252,0,.6231-.0039.9297a12.73127,12.73127,0,0,1-.1709,1.8535,5.739,5.739,0,0,1-.54,1.67,5.48029,5.48029,0,0,1-1.0156,1.3857,5.4129,5.4129,0,0,1-1.3994,1.0225,5.86168,5.86168,0,0,1-1.668.5498,12.54218,12.54218,0,0,1-1.8692.1631c-.2929.0068-.5996.0107-.8974.0107l-1.084.002Z"/>
</g>
<g id="_Group_" data-name="&lt;Group&gt;">
<g id="_Group_2" data-name="&lt;Group&gt;">
<g id="_Group_3" data-name="&lt;Group&gt;">
<g id="_Group_4" data-name="&lt;Group&gt;">
<path id="_Path_" data-name="&lt;Path&gt;" d="M24.76888,20.30068a4.94881,4.94881,0,0,1,2.35656-4.15206,5.06566,5.06566,0,0,0-3.99116-2.15768c-1.67924-.17626-3.30719,1.00483-4.1629,1.00483-.87227,0-2.18977-.98733-3.6085-.95814a5.31529,5.31529,0,0,0-4.47292,2.72787c-1.934,3.34842-.49141,8.26947,1.3612,10.97608.9269,1.32535,2.01018,2.8058,3.42763,2.7533,1.38706-.05753,1.9051-.88448,3.5794-.88448,1.65876,0,2.14479.88448,3.591.8511,1.48838-.02416,2.42613-1.33124,3.32051-2.66914a10.962,10.962,0,0,0,1.51842-3.09251A4.78205,4.78205,0,0,1,24.76888,20.30068Z" style="fill: #fff"/>
<path id="_Path_2" data-name="&lt;Path&gt;" d="M22.03725,12.21089a4.87248,4.87248,0,0,0,1.11452-3.49062,4.95746,4.95746,0,0,0-3.20758,1.65961,4.63634,4.63634,0,0,0-1.14371,3.36139A4.09905,4.09905,0,0,0,22.03725,12.21089Z" style="fill: #fff"/>
</g>
</g>
<g>
<path d="M46.14895,30.49609V21.35645H46.0884l-3.74316,9.04492H40.91652l-3.75293-9.04492H37.104v9.13965H35.34816v-12.418h2.22949l4.01855,9.80176h.06836l4.01074-9.80176h2.2373v12.418Z" style="fill: #fff"/>
<path d="M49.396,27.92285c0-1.583,1.21289-2.53906,3.36523-2.668l2.47852-.1377v-.68848c0-1.00684-.66309-1.5752-1.791-1.5752a1.73035,1.73035,0,0,0-1.90137,1.27441H49.8091c.05176-1.63574,1.5752-2.79687,3.69141-2.79687,2.16016,0,3.58887,1.17871,3.58887,2.96v6.20508H55.30813V29.00684h-.043a3.23683,3.23683,0,0,1-2.85742,1.64453A2.74447,2.74447,0,0,1,49.396,27.92285Zm5.84375-.81738V26.4082l-2.22949.1377c-1.11035.06934-1.73828.55078-1.73828,1.3252,0,.792.6543,1.30859,1.65234,1.30859A2.17046,2.17046,0,0,0,55.23977,27.10547Z" style="fill: #fff"/>
<path d="M64.89309,24.55762a1.99909,1.99909,0,0,0-2.13379-1.66895c-1.42871,0-2.375,1.19629-2.375,3.08105,0,1.92773.95508,3.08887,2.3916,3.08887a1.94829,1.94829,0,0,0,2.11719-1.626h1.79A3.61835,3.61835,0,0,1,62.7593,30.6084c-2.582,0-4.26855-1.76465-4.26855-4.63867,0-2.81445,1.68652-4.63867,4.251-4.63867a3.63931,3.63931,0,0,1,3.9248,3.22656Z" style="fill: #fff"/>
<path d="M78.7593,27.13965H74.0259l-1.13672,3.35645H70.8843l4.4834-12.418h2.083l4.4834,12.418H79.895Zm-4.24316-1.54883h3.752l-1.84961-5.44727h-.05176Z" style="fill: #fff"/>
<path d="M91.61672,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438H83.0884V21.44238h1.79883v1.50586h.03418a3.21161,3.21161,0,0,1,2.88281-1.60059C90.10207,21.34766,91.61672,23.16406,91.61672,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C88.7593,29.01563,89.70656,27.81934,89.70656,25.96973Z" style="fill: #fff"/>
<path d="M101.58156,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238h1.79883v1.50586h.03418a3.21162,3.21162,0,0,1,2.88281-1.60059C100.06691,21.34766,101.58156,23.16406,101.58156,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C98.72414,29.01563,99.67141,27.81934,99.67141,25.96973Z" style="fill: #fff"/>
<path d="M108.1675,27.03613c.1377,1.23145,1.334,2.04,2.96875,2.04,1.56641,0,2.69336-.80859,2.69336-1.91895,0-.96387-.67969-1.541-2.28906-1.93652l-1.60937-.3877c-2.28027-.55078-3.33887-1.61719-3.33887-3.34766,0-2.14258,1.86719-3.61426,4.51855-3.61426,2.624,0,4.42285,1.47168,4.4834,3.61426h-1.876c-.1123-1.23926-1.13672-1.9873-2.63379-1.9873s-2.52149.75684-2.52149,1.8584c0,.87793.65431,1.39453,2.25489,1.79l1.36816.33594c2.54785.60254,3.60645,1.626,3.60645,3.44238,0,2.32324-1.85059,3.77832-4.79395,3.77832-2.75391,0-4.61328-1.4209-4.7334-3.667Z" style="fill: #fff"/>
<path d="M119.80324,19.2998v2.14258h1.72168v1.47168h-1.72168v4.99121c0,.77539.34473,1.13672,1.10156,1.13672a5.80752,5.80752,0,0,0,.61133-.043v1.46289a5.10351,5.10351,0,0,1-1.03223.08594c-1.833,0-2.54785-.68848-2.54785-2.44434V22.91406h-1.31641V21.44238h1.31641V19.2998Z" style="fill: #fff"/>
<path d="M122.521,25.96973c0-2.84863,1.67773-4.63867,4.29395-4.63867,2.625,0,4.29492,1.79,4.29492,4.63867,0,2.85645-1.66113,4.63867-4.29492,4.63867C124.18215,30.6084,122.521,28.82617,122.521,25.96973Zm6.69531,0c0-1.9541-.89551-3.10742-2.40137-3.10742s-2.40137,1.16211-2.40137,3.10742c0,1.96191.89551,3.10645,2.40137,3.10645S129.21633,27.93164,129.21633,25.96973Z" style="fill: #fff"/>
<path d="M132.64309,21.44238h1.77246v1.541h.043a2.1594,2.1594,0,0,1,2.17773-1.63574,2.86616,2.86616,0,0,1,.63672.06934v1.73828a2.598,2.598,0,0,0-.835-.1123,1.87264,1.87264,0,0,0-1.93651,2.083v5.37012h-1.8584Z" style="fill: #fff"/>
<path d="M145.84035,27.83691c-.25,1.64355-1.85059,2.77148-3.89844,2.77148-2.63379,0-4.26855-1.76465-4.26855-4.5957,0-2.83984,1.64355-4.68164,4.19043-4.68164,2.50488,0,4.08008,1.7207,4.08008,4.46582v.63672h-6.39453v.1123a2.358,2.358,0,0,0,2.43555,2.56445,2.04834,2.04834,0,0,0,2.09082-1.27344Zm-6.28223-2.70215h4.52637a2.1773,2.1773,0,0,0-2.2207-2.29785A2.292,2.292,0,0,0,139.55813,25.13477Z" style="fill: #fff"/>
</g>
</g>
</g>
</g>
<g id="_Group_5" data-name="&lt;Group&gt;">
<g>
<path d="M37.82619,8.731a2.63964,2.63964,0,0,1,2.80762,2.96484c0,1.90625-1.03027,3.002-2.80762,3.002H35.67092V8.731Zm-1.22852,5.123h1.125a1.87588,1.87588,0,0,0,1.96777-2.146,1.881,1.881,0,0,0-1.96777-2.13379h-1.125Z" style="fill: #fff"/>
<path d="M41.68068,12.44434a2.13323,2.13323,0,1,1,4.24707,0,2.13358,2.13358,0,1,1-4.24707,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C44.57521,13.99463,45.01369,13.42432,45.01369,12.44434Z" style="fill: #fff"/>
<path d="M51.57326,14.69775h-.92187l-.93066-3.31641h-.07031l-.92676,3.31641h-.91309l-1.24121-4.50293h.90137l.80664,3.436h.06641l.92578-3.436h.85254l.92578,3.436h.07031l.80273-3.436h.88867Z" style="fill: #fff"/>
<path d="M53.85354,10.19482H54.709v.71533h.06641a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915h-.88867V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z" style="fill: #fff"/>
<path d="M59.09377,8.437h.88867v6.26074h-.88867Z" style="fill: #fff"/>
<path d="M61.21779,12.44434a2.13323,2.13323,0,1,1,4.24707,0,2.13358,2.13358,0,1,1-4.24707,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C64.11232,13.99463,64.5508,13.42432,64.5508,12.44434Z" style="fill: #fff"/>
<path d="M66.40041,13.42432c0-.81055.60352-1.27783,1.6748-1.34424l1.21973-.07031v-.38867c0-.47559-.31445-.74414-.92187-.74414-.49609,0-.83984.18213-.93848.50049h-.86035c.09082-.77344.81836-1.26953,1.83984-1.26953,1.12891,0,1.76563.562,1.76563,1.51318v3.07666h-.85547v-.63281h-.07031a1.515,1.515,0,0,1-1.35254.707A1.36026,1.36026,0,0,1,66.40041,13.42432Zm2.89453-.38477v-.37646l-1.09961.07031c-.62012.0415-.90137.25244-.90137.64941,0,.40527.35156.64111.835.64111A1.0615,1.0615,0,0,0,69.29494,13.03955Z" style="fill: #fff"/>
<path d="M71.34768,12.44434c0-1.42285.73145-2.32422,1.86914-2.32422a1.484,1.484,0,0,1,1.38086.79h.06641V8.437h.88867v6.26074h-.85156v-.71143h-.07031a1.56284,1.56284,0,0,1-1.41406.78564C72.07131,14.772,71.34768,13.87061,71.34768,12.44434Zm.918,0c0,.95508.4502,1.52979,1.20313,1.52979.749,0,1.21191-.583,1.21191-1.52588,0-.93848-.46777-1.52979-1.21191-1.52979C72.72072,10.91846,72.26564,11.49707,72.26564,12.44434Z" style="fill: #fff"/>
<path d="M79.22951,12.44434a2.13346,2.13346,0,1,1,4.24756,0,2.1338,2.1338,0,1,1-4.24756,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C82.124,13.99463,82.56252,13.42432,82.56252,12.44434Z" style="fill: #fff"/>
<path d="M84.66945,10.19482h.85547v.71533h.06641a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915H87.605V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z" style="fill: #fff"/>
<path d="M93.51516,9.07373v1.1416h.97559v.74854h-.97559V13.2793c0,.47168.19434.67822.63672.67822a2.96657,2.96657,0,0,0,.33887-.02051v.74023a2.9155,2.9155,0,0,1-.4834.04541c-.98828,0-1.38184-.34766-1.38184-1.21582v-2.543h-.71484v-.74854h.71484V9.07373Z" style="fill: #fff"/>
<path d="M95.70461,8.437h.88086v2.48145h.07031a1.3856,1.3856,0,0,1,1.373-.80664,1.48339,1.48339,0,0,1,1.55078,1.67871v2.90723H98.69v-2.688c0-.71924-.335-1.0835-.96289-1.0835a1.05194,1.05194,0,0,0-1.13379,1.1416v2.62988h-.88867Z" style="fill: #fff"/>
<path d="M104.76125,13.48193a1.828,1.828,0,0,1-1.95117,1.30273A2.04531,2.04531,0,0,1,100.73,12.46045a2.07685,2.07685,0,0,1,2.07617-2.35254c1.25293,0,2.00879.856,2.00879,2.27V12.688h-3.17969v.0498a1.1902,1.1902,0,0,0,1.19922,1.29,1.07934,1.07934,0,0,0,1.07129-.5459Zm-3.126-1.45117h2.27441a1.08647,1.08647,0,0,0-1.1084-1.1665A1.15162,1.15162,0,0,0,101.63527,12.03076Z" style="fill: #fff"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB