32 Commits
Author SHA1 Message Date
Puranjay Savar Mattas cd6277f5aa fix(avatar): attach the Bearer token — the real bug, not propagation delay
Your network capture of the working web-app request showed session
cookies (accessToken, authelia_session) on the attachments.redirect
call. This app authenticates every other request with an Authorization:
Bearer header instead — AvatarBadge's fetch never attached one, sending
a bare unauthenticated GET. Almost certainly a 401 the whole time, for
every avatar image, not just a freshly-uploaded one — a failed fetch
and "no avatar set" render identically here (placeholder icon, no
visible error), so there was nothing on screen to reveal it before now.

Uses KeychainTokenStore() directly, same keychain entry SessionStore
already reads, rather than threading a token through every AvatarBadge
call site. Kept the retry loop from the previous attempt too — genuinely
useful insurance against upload-consistency timing, just not the actual
cause here.
2026-08-15 16:44:45 +01:00
Puranjay Savar Mattas bde6be91ca fix(avatar): retry the redirect fetch instead of giving up on one failure
You confirmed the whole upload chain works — the new avatar shows
instantly on Outline's web app — but this app kept showing the old/
placeholder picture in both Settings and the sidebar, which read the
same underlying session.userAvatarURL. Since that state is provably
correct (the same URL that works on web), the bug has to be in how
AvatarBadge loads it, not in the update itself.

Leading theory: a freshly-uploaded attachment's redirect URL can fail
on the very first request right after upload — self-hosted storage
behind a reverse proxy isn't necessarily instantly consistent —  and
AvatarBadge's .task(id:) only ever fires once per URL with no retry,
so a single transient failure right after uploading would leave the
placeholder showing forever even though the exact same URL works fine
moments later (which lines up with it looking fine on a fresh web
load). Now retries twice with a short delay and explicitly checks the
HTTP status before treating the body as image data, instead of
silently accepting whatever came back. Also drops any URLCache
involvement (.reloadIgnoringLocalCacheData) as a second, independent
possible cause, cheap to rule out at the same time.

Couldn't confirm this is the actual root cause without being able to
run the app — worth retesting.
2026-08-15 16:37:44 +01:00
Puranjay Savar Mattas cb8bbd53c4 fix(profile): refresh from the server every time the page opens
SessionStore only ever populated from auth.info once per app launch
— a name (or avatar) changed elsewhere, like Outline's web app, never
reached it until a full quit-and-relaunch. Profile now refetches via
currentUser() every time it's opened and applies the result, same as
any live edit made from this app already does. Fails silently offline,
same as everything else that needs a live read.
2026-08-15 16:31:40 +01:00
Puranjay Savar Mattas 90df5f056d fix: missing UniformTypeIdentifiers import for NSOpenPanel content types 2026-08-15 16:27:39 +01:00
Puranjay Savar Mattas 077042295b feat(profile): avatar upload/remove with crop editor, name editing
Profile now has a real avatar row (AvatarBadge + Upload Photo…/Remove),
wired through last commit's presigned-upload backend: pick a file via
NSOpenPanel, crop/rotate/zoom in a new AvatarCropperView, upload,
point users.update at the result, then best-effort delete whatever
attachment the previous avatar pointed to so replacing/removing a
photo doesn't leak an orphaned blob in Outline's storage every time.

AvatarCropperView builds its on-screen preview and its final exported
image from the exact same SwiftUI view composition (just instantiated
twice — once for display, once through ImageRenderer) rather than a
separately hand-derived set of crop math for a higher resolution —
that's deliberate: there's no way to visually verify a second
independent set of transform math agrees with what the user actually
saw and confirmed without running the app, so making the export
WYSIWYG by construction was the safer choice here.

Name is now editable too (TextField + Save, users.update name-only).
Email is read-only with a note pointing to Outline's web app instead —
per instruction, changing it here isn't supported since email is
tied to sign-in.

SessionStore gained userId (never stored before — needed for every
users.update call) and applyUpdatedProfile(_:), so a successful
change reflects immediately in the account footer and everywhere
else without waiting for the next auth.info refresh.
2026-08-15 16:27:01 +01:00
Puranjay Savar Mattas 052db93d69 feat(profile): name update + attachment cleanup backend
UpdateUserNameRequest (users.update, name only) and deleteAttachment
(attachments.delete, id-only — same shape as every other simple
delete in this API, not yet confirmed live) so the app can clean up
the previous avatar attachment when replacing or removing it instead
of leaking an orphaned blob in Outline's storage every time. 2 new
tests, 65/65 passing.
2026-08-15 16:23:43 +01:00
Puranjay Savar Mattas 373c681c10 feat(profile): avatar upload/remove backend (attachments + users.update)
Adds the presigned-upload plumbing Outline's own web client uses for
any file upload, not just avatars: attachments.create requests an
upload target (server-assigned key, ACL, a short-lived signed form),
then a direct multipart POST to that target (uploadUrl/form) actually
uploads the bytes — confirmed against a live server's own request/
response shapes pulled from network capture. MultipartFormDataBuilder
is a pure, fully-tested function (no network) building that S3-style
presigned-POST body.

uploadAttachmentFile deliberately sends no Bearer/CSRF header — the
presigned form's `sig` field is what authorizes that specific request,
same as an S3 presigned POST; flagged as best-effort pending live
confirmation, cheap to add if the server turns out to also want one.

UpdateUserAvatarRequest (users.update, avatar only) needed a custom
encode(to:) — Swift's synthesized Encodable omits nil Optional keys
via encodeIfPresent, but removing an avatar needs a literal
"avatarUrl": null in the body, not the key missing. Confirmed the
request shape (id + avatarUrl) from the captured request's
Content-Length matching that shape and no shorter alternative.

6 new tests (multipart body structure/field ordering, explicit-null
encoding, create/upload/update round trip) — 63/63 passing.
2026-08-15 16:19:33 +01:00
Puranjay Savar Mattas 2e6de4baf4 fix(settings): label our own settings group "Outpost"
The general category (Appearance, Offline & Sync, Advanced, About)
sat unlabeled at the top of the sidebar, ambiguous next to the named
Outline categories below it. Now has its own "Outpost" header.
2026-08-15 16:06:05 +01:00
Puranjay Savar Mattas 67bef76716 feat(settings): grouped sidebar matching Outline's own settings categories
SettingsSection now carries a SettingsCategory (general/account/
workspace/integrationsInstallation) and SettingsSidebarList renders it
as a grouped, headered list — general (ours: Appearance, Offline &
Sync, Advanced, About) unlabeled at the top, then Account (Profile,
Preferences, Notifications, Passkeys, API & Access), Workspace
(Details, Authentication, Security, AI, Members, Groups, Templates,
Emojis, Applications, Shared, Links, Webhooks, Import, Export), and
Integrations & Installation (Installation).

Nav skeleton only, per instruction — everything except the ones
already built (renamed the old flat "Account" page to "Profile", its
natural new home; content unchanged) shows a "Coming Soon" placeholder
until each section's real content is specified and built one at a
time.
2026-08-15 16:04:03 +01:00
Puranjay Savar Mattas 861f596f5f Merge pull request 'Offline-first support: read cache, write queue, settings redesign' (#6) from feature/offline-cache into main
Reviewed-on: #6
2026-08-15 15:47:48 +01:00
Puranjay Savar Mattas badb206f8a chore: bump version to 0.0.3 2026-08-15 15:45:08 +01:00
Puranjay Savar Mattas bf9eec6a9b fix(offline): retry pending sync with a cooloff instead of one shot
The auto-flush that runs when reconnecting (or turning Offline Mode
back off) only ever tried once — any operation that still failed on
that attempt sat stuck until the user manually hit Retry or
connectivity changed again. Now retries every 5 minutes for as long as
anything's still pending and the signal stays on, stopping on its own
once the queue is empty. Full Local Sync's existing 20-minute loop is
unaffected/separate.
2026-08-15 15:43:00 +01:00
Puranjay Savar Mattas 21e81feed7 fix(settings): stop non-About sections from vertically centering
.leading as an Alignment means horizontally-leading but vertically
centered, not top-left — the minHeight fix for centering About ended
up vertically centering every other section too inside that same tall
box. .topLeading is what was actually meant; About still centers
correctly since its own .frame(maxHeight: .infinity, alignment: .center)
fills and self-centers within whatever space it's given regardless.
2026-08-15 01:49:05 +01:00
Puranjay Savar Mattas 8b4c5852ed feat(offline): support creating documents offline
createDocument now queues and syncs like the other offline-capable
writes, closing the gap deliberately left open earlier this session.
Synthesizes a pending-<uuid> document (same placeholder scheme as
pin/star/subscribe), caches it so it's immediately readable/openable,
and queues the real create. On sync, the server's real document
replaces the placeholder in the cache — no dead orphan entries.

Editing that same still-unsynced document folds the edit into the
pending create's payload instead of queuing a separate update, which
would otherwise target the placeholder id and 404 forever once
flushed. CreateDocumentRequest widened Encodable -> Codable for the
queue round-trip.

Known limitation: a reader still open on that exact document at the
moment it syncs in the background keeps holding the stale placeholder
id until navigated away and back. Narrow, not solved generally here.

3 new OutlineKit tests (57/57). Removed the now-unneeded offline
restriction on the reader's New Document button/menu item.
2026-08-15 01:46:32 +01:00
Puranjay Savar Mattas 284ad07251 fix(offline): false "New changes available" banner, sync stuck on toggle-off
Home's checkForRemoteChanges compared fresh pinned docs against what
was displayed, but the pinned fetch (listPins isn't a cached endpoint)
silently collapsed any failure to []  — every poll while offline
compared "[]" against the real non-empty pinned list, which always
looked like a change and popped the banner every ~45s. Split fetchPinned
into a throwing variant checkForRemoteChanges can bail on (matching the
already-correct pattern in CollectionsViewModel/DocumentsViewModel,
which don't have this bug), keeping the non-throwing version for the
initial load where collapsing to [] is the right behavior.

RootView's auto-flush was keyed only to session.networkMonitor.isOnline
— turning the manual Offline Mode toggle back off while the real
network had been up the whole time never changes that value, so queued
operations sat stuck until the next real network blip. Keyed the flush
(and the Full Local Sync loop's online check) to a combined
isEffectivelyOnline instead, so either signal clearing resumes sync
immediately.
2026-08-15 01:40:07 +01:00
Puranjay Savar Mattas 8d8c8fead8 style(settings): center the About section on the page
AboutInfoView (icon/name/version/links) now centers both
horizontally and vertically within the detail pane instead of sitting
pinned to the top-left like the other sections. Needed a GeometryReader
+ minHeight on the shared ScrollView wrapper — a ScrollView proposes
unbounded height to its content, so maxHeight: .infinity alone doesn't
give short content anything to center within; minHeight pinned to the
real viewport height does. Harmless for the other sections, which stay
top/leading-aligned as before.
2026-08-15 01:35:24 +01:00
Puranjay Savar Mattas e2e4b746c1 fix(reader): pass a real per-document id to NativeTextViewWrapper
Every document was using the library's literal "default" documentId
— never our own viewModel.documentId — meaning undo stacks, content-
divergence snapshots, scroll-offset memory, and pending inline
replacements were all keyed to the same slot across every document
instead of being scoped per-document. That's a real, unambiguous bug
regardless of the click/cursor issue: opening a second document could
replay or discard the wrong document's undo history.

Investigated the click-does-nothing-in-edit-mode report by reading
through the library's (vendored, external — swift-markdown-engine)
own isEditable/isSelectable wiring in both makeNSView and updateNSView
directly; both looked correctly applied on every pass, and a clean-
relaunch test ruled out the settings/NavigationSplitView bug from
earlier in this session as the cause. Couldn't confirm this documentId
fix resolves the click issue without being able to run the app here —
worth retesting either way.
2026-08-15 01:33:54 +01:00
Puranjay Savar Mattas 0d9f7d752d fix(reader): grey out share/permissions and other live-only actions offline
Share, Permissions, Templatize, Duplicate, Unpublish, Archive, Move,
New Document, History, Insights (sheet + Viewer Insights toggle), and
Download all hit the server directly with no offline path — disabled
(with a tooltip on the two toolbar buttons) whenever the app isn't
effectively online, instead of failing confusingly on tap.

Left enabled: Edit, Pin/Unpin, Star/Unstar, Subscribed, Full Width
(all queue via CachingOutlineAPIClient and sync later), Present and
Search in Document (both read documents.info, which is cached), and
Copy/Print (read already-loaded text directly, no network at all).
2026-08-15 01:22:47 +01:00
Puranjay Savar Mattas a9db3e1412 fix(settings): make Settings its own top-level branch, not swapped content
The .id() fix on the detail pane wasn't enough — still reported as
the document staying visible with only the sidebar switching.
NavigationSplitView bridges to NSSplitViewController on macOS, and
apparently doesn't reliably replace already-mounted detail content
(a NavigationStack with real push history) for something unrelated
inside one persisting split view instance, identity hints or not.

Restructured so `navigation.isShowingSettings` picks between two
entirely separate NavigationSplitView instances (settingsContent /
mainContent) at the top of body, instead of branching on content
inside a single one. A different top-level view hierarchy is a
guaranteed full teardown of whatever AppKit was holding onto — no
split-view instance persists across the switch for it to get
confused about reusing.

Also dropped the now-redundant `isShowingSettings` guards scattered
through mainContent's toolbar/leadingToolbarContent — moot once
mainContent only ever renders while Settings isn't showing.
2026-08-15 01:13:28 +01:00
Puranjay Savar Mattas 82ff3b49ff fix(settings): force detail pane teardown when opening/closing Settings
Opening Settings while a document was pushed left the document
visible in the detail pane even though the sidebar correctly switched
to the settings list — NavigationSplitView on macOS doesn't reliably
replace the detail pane's content on an implicit branch change alone
between very different subtrees (a NavigationStack with a real push
history vs. a plain view). An explicit .id() keyed to
isShowingSettings forces a real teardown/remount; documentPath itself
is untouched, so returning via Done still lands back on the same
document.
2026-08-15 01:10:29 +01:00
Puranjay Savar Mattas 924f84525c fix(offline): manual mode never skipped live reads, storage/banner cleanup
The main fix: CachingOutlineAPIClient.cachedFetch (documentInfo,
listCollections, listDocuments, etc.) never actually checked manual
Offline Mode — only the write path did. With a real connection still
up, turning Offline Mode on did nothing for reads: the sidebar kept
fetching live, showing collections/documents beyond what was actually
cached. Reads now skip `live` entirely under manual offline mode, same
as writes already did — the sidebar and document lists are now
genuinely limited to whatever's cached, and only those documents are
openable, once the toggle is on. 1 new regression test (54/54).

Settings: removed the cache-size readout from Offline & Sync (storage
management is Advanced-only now, per the "one place that can touch the
cache" design) and added it next to Advanced's Clear All Cache instead,
where it was missing.

Sidebar: replaced the old passive "Offline — showing cached content"
banner for a real dropped connection with an actionable prompt
(OfflineConnectionPromptBanner, styled like the existing
RemoteChangesBanner) offering to turn Offline Mode on — doesn't change
any behavior on its own, same as RemoteChangesBanner never auto-
refreshes. The informational banner still shows once Offline Mode is
actually on (manually, or via this prompt).
2026-08-15 01:08:03 +01:00
Puranjay Savar Mattas 1ce2aced90 fix(settings): red Clear Cache button, confirm when it's done
role: .destructive alone wasn't rendering red on its own — explicit
.buttonStyle(.borderedProminent).tint(.red), matching how other
destructive actions in the app (DocumentShareSheet's Revoke) had to
be styled explicitly too. Also shows a transient "Cleared" checkmark
for 2s after finishing, same timed-reset pattern as the share sheet's
copy-link feedback, since the storage count updating alone was easy
to miss.
2026-08-15 01:01:19 +01:00
Puranjay Savar Mattas d4d81bb848 feat(settings): Advanced section with safeguards, fully-automatic sync
Full Local Sync and Clear Cache both need a real connection or a real
cache to be safe against — clearing while offline, or letting sync
think it should run with nothing to talk to, can leave the app with
no local copy and no way to rebuild one. Both are now disabled
(Toggle/button, with a tooltip explaining why) whenever the app isn't
effectively online (real network down OR the manual Offline Mode
toggle).

Clear Cache moves out of Offline & Sync entirely into a new Advanced
section (sidebar, above About) — the one deliberate escape hatch that
works even offline, gated behind an off-by-default "Enable Advanced
Options" master toggle that only turns on after a confirmation dialog
warning about data loss. A few not-yet-implemented settings sit under
it as permanently-dimmed "Coming Soon" rows. Nothing destructive is
reachable outside this one screen, so there's no path to breaking
Full Local Sync's cache by accident.

Full Local Sync itself is now fully automatic: RootView's background
task syncs immediately whenever it (re)starts — covers both "just
turned on" and "was already on at a fresh launch" — then every 20
minutes, and again immediately on reconnect. No more needing to press
Sync Now by hand.
2026-08-15 00:58:24 +01:00
Puranjay Savar Mattas ce235d656d fix(offline): pagination-limit bug in full sync, missing manual-mode badge, capture error
- performFullSync was requesting listCollections with limit: 250 —
  Outline caps pagination at 100 and rejects anything over that
  outright, which was the actual "Synced with 1 error" (now visible
  in the UI as of the last fix: "Pagination limit is too large (max
  100)"). Paginate collections in increments of 100 the same way
  documents already were, continuing until a short page signals the
  end — the protocol doesn't expose a total count to ask for up front,
  so this is the only way to know when to stop. 2 new regression tests.
- OfflineBanner only ever reflected NetworkMonitor (a real dropped
  connection) — turning on the manual "Offline Mode" toggle did
  nothing to it, since that's a separate AppStorage flag the banner
  never read. ContentView_macOS's sidebar now shows the banner for
  either condition, with distinct copy for "you turned this on" vs
  "the network's actually down".
- NetworkMonitor: the previous fix (weak self only on the inner Task)
  traded one Swift 6 error for another ("'weak' ownership of capture
  'self' differs from implicitly-captured strong reference in outer
  scope") since the inner closure's capture forced the outer one to
  implicitly capture self too. Standard fix: weak capture on the outer
  closure, guard-let into a strong local immediately, let the inner
  Task closure capture that plain local instead.
2026-08-15 00:45:09 +01:00
Puranjay Savar Mattas ae6cb7dc7d fix: real Xcode compile errors from Swift 6 strict concurrency
- SettingsView: ternary between .secondary (HierarchicalShapeStyle)
  and .red (Color) doesn't unify — both sides now explicit Color.
- CollectionRowView: passing OutlineIconMapping.sfSymbolName as a bare
  function reference to flatMap loses its (inferred default) MainActor
  isolation; wrapping it in a closure keeps the call inside body's
  already-MainActor context.
- NetworkMonitor: [weak self] was captured on NWPathMonitor's
  non-isolated pathUpdateHandler closure, which must cross into the
  inner @MainActor Task — moved the weak capture onto the Task closure
  itself instead, which is where it's actually used.
2026-08-15 00:38:15 +01:00
Puranjay Savar Mattas d1ed52b825 redesign(settings): reuse the real sidebar instead of a mini one
Settings now swaps the actual app sidebar's content (search field,
collections tree, account footer) for a section list, instead of
SettingsView drawing its own nested sidebar inside the detail pane.
Done, wherever it's triggered from, restores the collections tree and
detail content exactly as they were.

AppNavigation gains selectedSettingsSection (and the SettingsSection
enum moves there, shared by the new list and the sidebar host) so the
sidebar's list and the detail pane agree on which section is showing.

Also deferred the profile menu's "Settings…" action by one runloop
tick — setting isShowingSettings synchronously in the same call that
dismisses the popover was very likely the source of the AppKit "CA
commit" transaction warnings in the console.
2026-08-15 00:36:28 +01:00
Puranjay Savar Mattas 45a72f8d10 fix(settings): surface the actual full-sync error, not just a count
"Synced with 1 error" gave no way to tell what failed. Now lists each
FullSyncSummary.errors entry underneath the summary line.
2026-08-15 00:30:55 +01:00
Puranjay Savar Mattas 19eb1bae69 redesign(settings): section list + single-section detail
Replaces the stacked/gridded cards with the same shape as macOS System
Settings: a section list on the left, one section's content in the
detail pane on the right. Sections never render next to each other
anymore, so there's no card-height mismatch to look weird, and the
layout holds up at any window size or aspect ratio without needing a
grid to reflow.
2026-08-15 00:29:51 +01:00
Puranjay Savar Mattas e991f4ed4c style(settings): grid the short cards, keep the tall ones stacked
Appearance and Account are both short — a LazyVGrid lets them sit
side-by-side when the window's wide enough instead of each wasting a
full-width row. Offline & Sync and About stay full-width: pairing
those with anything in the same grid row looked worse (very uneven
row heights) than just stacking them. Also widened the content column
(640 -> 900) so the grid actually has room to go two-up.
2026-08-15 00:23:37 +01:00
Puranjay Savar Mattas 4f423ef971 fix(settings): keep the sidebar visible instead of a full-window overlay
Settings now swaps into the same NavigationSplitView detail pane as
Home/collections (gated on AppNavigation.isShowingSettings) instead of
covering the whole root window — the sidebar, and the ability to just
click something else in it to leave Settings, stays available. Any
sidebar navigation (Home, a collection, a document) exits Settings.
2026-08-15 00:17:51 +01:00
Puranjay Savar Mattas 4769645489 feat(offline): write queue with sync-on-reconnect + settings redesign
Extends CachingOutlineAPIClient with a scoped offline write queue:
updateDocument, updateCollection, pin/unpin, star/unstar, and
subscribe/unsubscribe now apply optimistically and queue via a new
PendingOperation (SwiftData) when they fail (or when a new manual
"Offline Mode" toggle forces it), then replay on reconnect via
flushPendingOperations(). Same-target edits coalesce into one queued
operation; a pin/unpin pair that never syncs cancels out instead of
queuing a delete the server never saw. Actions that would invent new
tree structure (create/move/archive/delete/duplicate) stay live-only —
reconciling a locally-invented id against the server's real one is a
separate, harder problem this pass doesn't take on. Sharing,
permissions, search, and export also stay live-only.

Added a "Full Local Sync" toggle that eagerly walks and caches the
whole workspace instead of only what's been opened, running
immediately on enable and every 20 minutes after while online.

Settings moved from a popup (Settings {} scene / PreferencesView) to
a full-page view rendered inside the root window (AppNavigation),
including the ⌘, shortcut. Folds in offline/sync management (storage
size, clear cache, pending-sync list with per-item retry) and the
About window's content (version, check for updates) so it's all in
one place.

8 new OutlineKit tests covering coalescing, cancel-out, flush
success/failure, and manual offline mode — 51/51 passing.
2026-08-15 00:12:05 +01:00
Puranjay Savar Mattas eee00197bb feat(offline): read-through cache for browse endpoints + offline badge
CachingOutlineAPIClient (OutlineKit) wraps LiveOutlineAPIClient at the
existing protocol boundary: always tries live, falls back to a SwiftData
key/value cache only on failure, and overwrites the cache on every live
success. Applies to the read/browse path only (collections, document
lists, document content) — search, writes, and sharing pass straight
through uncached. Wired in once at SessionStore.makeAPIClient, so no
view model needed to change. 5 new tests via a stub OutlineAPIClient
(43/43 passing).

NetworkMonitor (NWPathMonitor-backed, app target) is unrelated to the
cache's own fallback logic — it only drives a new sidebar OfflineBanner
so the user knows when they might be looking at stale content.
2026-08-14 23:41:00 +01:00
42 changed files with 3166 additions and 80 deletions
@@ -0,0 +1,18 @@
import Foundation
import SwiftData
/// Generic key/value row backing `OfflineCacheStore` one table for every
/// cached response shape instead of a `@Model` per Outline type, so adding a
/// new cached endpoint never needs a schema migration.
@Model
public final class CachedPayload {
@Attribute(.unique) public var key: String
public var payload: Data
public var cachedAt: Date
public init(key: String, payload: Data, cachedAt: Date) {
self.key = key
self.payload = payload
self.cachedAt = cachedAt
}
}
@@ -0,0 +1,685 @@
import Foundation
/// Decorates `LiveOutlineAPIClient` (or any `OutlineAPIClient`) with offline
/// support at the existing protocol boundary, so no view model needs to know
/// the network exists:
///
/// - **Reads** (`documentInfo`, `listDocuments`, `documentsList`,
/// `listViewedDocuments`, `listCollections`, `collectionInfo`): read-through
/// cache. Always tries live first never pre-checks reachability, since
/// "did this specific request just fail" is a more honest signal than a
/// reachability monitor, and the two can disagree (captive portals,
/// flaky Wi-Fi). Falls back to the cache only on failure; a live success
/// always overwrites the cache, so staleness is bounded by "last time this
/// endpoint actually worked."
/// - **A small set of writes** (`updateDocument`, `updateCollection`,
/// pin/star/subscribe create+delete): applied optimistically against the
/// cache and queued in `OfflineCacheStore`'s `PendingOperation` table on
/// failure, then replayed in order by `flushPendingOperations()` once back
/// online. Deliberately doesn't include anything that creates new tree
/// structure (`createDocument`, `moveDocument`, `archiveDocument`,
/// `deleteDocument`, `deleteCollection`, `duplicateDocument`) those need
/// real server-assigned ids to stay consistent with the rest of the tree,
/// and reconciling a locally-invented id with the one the server hands
/// back on sync is a much bigger problem than this pass takes on. Sharing,
/// permissions, search, and export also stay live-only read the request,
/// they need someone else's server session, not just a network.
/// - **Manual offline mode** (`offlineModeDefaultsKey`): when set, skips
/// attempting `live` entirely same fallback/queue paths as a real
/// failure, just chosen on purpose instead of discovered.
public actor CachingOutlineAPIClient: OutlineAPIClient {
public static let offlineModeDefaultsKey = "outpost.offlineModeEnabled"
private let live: OutlineAPIClient
private let cache: OfflineCacheStore
private let defaults: UserDefaults
private let encoder: JSONEncoder
private let decoder: JSONDecoder
private let keyEncoder: JSONEncoder
public init(live: OutlineAPIClient, cache: OfflineCacheStore, defaults: UserDefaults = .standard) {
self.live = live
self.cache = cache
self.defaults = defaults
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
self.encoder = encoder
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
self.decoder = decoder
let keyEncoder = JSONEncoder()
keyEncoder.outputFormatting = .sortedKeys
self.keyEncoder = keyEncoder
}
private var isManualOfflineModeEnabled: Bool {
defaults.bool(forKey: Self.offlineModeDefaultsKey)
}
// MARK: - Cached reads
public func documentInfo(id: String) async throws -> OutlineDocument {
try await cachedFetch(key: "document:\(id)") { try await self.live.documentInfo(id: id) }
}
public func listDocuments(
collectionId: String?,
parentDocumentId: String?,
offset: Int,
limit: Int
) async throws -> [OutlineDocument] {
let key = "documents:\(collectionId ?? "-"):\(parentDocumentId ?? "-"):\(offset):\(limit)"
return try await cachedFetch(key: key) {
try await self.live.listDocuments(
collectionId: collectionId,
parentDocumentId: parentDocumentId,
offset: offset,
limit: limit
)
}
}
public func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] {
try await cachedFetch(key: requestKey("documentsList", request)) { try await self.live.documentsList(request) }
}
public func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] {
try await cachedFetch(key: "documentsViewed:\(offset):\(limit)") {
try await self.live.listViewedDocuments(offset: offset, limit: limit)
}
}
public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] {
try await cachedFetch(key: "collections:\(offset):\(limit)") {
try await self.live.listCollections(offset: offset, limit: limit)
}
}
public func collectionInfo(id: String) async throws -> OutlineCollection {
try await cachedFetch(key: "collection:\(id)") { try await self.live.collectionInfo(id: id) }
}
// MARK: - Queueable writes
/// The one exception to "no new tree structure while offline"
/// synthesizes a document under a `pending-<uuid>` id (same scheme as
/// pin/star/subscribe), caches it so it's immediately readable/editable,
/// and queues the real create. On sync, the server-assigned id replaces
/// the placeholder in the cache; anything still referencing the old id
/// directly (a currently-open reader, most likely) won't follow that
/// rename automatically a known, narrow limitation, not something this
/// pass tries to solve generally.
public func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument {
if !isManualOfflineModeEnabled {
do {
let result = try await live.createDocument(request)
await cacheDocument(result)
return result
} catch {
return await queueDocumentCreate(request)
}
}
return await queueDocumentCreate(request)
}
public func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument {
if !isManualOfflineModeEnabled {
do {
let result = try await live.updateDocument(request)
await cacheDocument(result)
return result
} catch {
return try await queueDocumentUpdate(request, dueTo: error)
}
}
return try await queueDocumentUpdate(request, dueTo: nil)
}
public func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection {
if !isManualOfflineModeEnabled {
do {
let result = try await live.updateCollection(request)
await cacheCollection(result)
return result
} catch {
return try await queueCollectionUpdate(request, dueTo: error)
}
}
return try await queueCollectionUpdate(request, dueTo: nil)
}
public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin {
if !isManualOfflineModeEnabled {
do { return try await live.createPin(request) } catch { return await queuePinCreate(request) }
}
return await queuePinCreate(request)
}
public func deletePin(id: String) async throws {
if await cancelIfNeverSynced(id: id) { return }
if !isManualOfflineModeEnabled {
do {
try await live.deletePin(id: id)
return
} catch {
await enqueue(.deletePin, payload: IDPayload(id: id), id: "delete-pin-\(id)")
return
}
}
await enqueue(.deletePin, payload: IDPayload(id: id), id: "delete-pin-\(id)")
}
public func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription {
if !isManualOfflineModeEnabled {
do { return try await live.createSubscription(request) } catch { return await queueSubscriptionCreate(request) }
}
return await queueSubscriptionCreate(request)
}
public func deleteSubscription(id: String) async throws {
if await cancelIfNeverSynced(id: id) { return }
if !isManualOfflineModeEnabled {
do {
try await live.deleteSubscription(id: id)
return
} catch {
await enqueue(.deleteSubscription, payload: IDPayload(id: id), id: "delete-subscription-\(id)")
return
}
}
await enqueue(.deleteSubscription, payload: IDPayload(id: id), id: "delete-subscription-\(id)")
}
public func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar {
if !isManualOfflineModeEnabled {
do { return try await live.starDocument(request) } catch { return await queueStarDocumentCreate(request) }
}
return await queueStarDocumentCreate(request)
}
public func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar {
if !isManualOfflineModeEnabled {
do { return try await live.starCollection(request) } catch { return await queueStarCollectionCreate(request) }
}
return await queueStarCollectionCreate(request)
}
public func deleteStar(id: String) async throws {
if await cancelIfNeverSynced(id: id) { return }
if !isManualOfflineModeEnabled {
do {
try await live.deleteStar(id: id)
return
} catch {
await enqueue(.deleteStar, payload: IDPayload(id: id), id: "delete-star-\(id)")
return
}
}
await enqueue(.deleteStar, payload: IDPayload(id: id), id: "delete-star-\(id)")
}
// MARK: - Pass-through
public func authInfo() async throws -> OutlineAuthInfo {
try await live.authInfo()
}
public func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] {
try await live.searchDocuments(request)
}
public func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] {
try await live.searchDocumentTitles(request)
}
public func templatizeDocument(_ request: TemplatizeDocumentRequest) async throws -> OutlineTemplate {
try await live.templatizeDocument(request)
}
public func duplicateDocument(_ request: DuplicateDocumentRequest) async throws -> [OutlineDocument] {
try await live.duplicateDocument(request)
}
public func unpublishDocument(_ request: UnpublishDocumentRequest) async throws -> OutlineDocument {
try await live.unpublishDocument(request)
}
public func archiveDocument(id: String) async throws -> OutlineDocument {
try await live.archiveDocument(id: id)
}
public func moveDocument(_ request: MoveDocumentRequest) async throws {
try await live.moveDocument(request)
}
public func deleteDocument(_ request: DeleteDocumentRequest) async throws {
try await live.deleteDocument(request)
}
public func documentInsights(_ request: DocumentInsightsRequest) async throws -> [OutlineDocumentInsight] {
try await live.documentInsights(request)
}
public func listRevisions(_ request: ListRevisionsRequest) async throws -> [OutlineRevision] {
try await live.listRevisions(request)
}
public func exportDocument(id: String) async throws -> String {
try await live.exportDocument(id: id)
}
public func createShare(_ request: CreateShareRequest) async throws -> OutlineShare {
try await live.createShare(request)
}
public func shareInfo(documentId: String) async throws -> OutlineShare? {
try await live.shareInfo(documentId: documentId)
}
public func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare {
try await live.updateShare(request)
}
public func listShares(_ request: ListSharesRequest) async throws -> [OutlineShare] {
try await live.listShares(request)
}
public func revokeShare(id: String) async throws {
try await live.revokeShare(id: id)
}
public func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] {
try await live.listPins(request)
}
public func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] {
try await live.listSubscriptions(request)
}
public func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] {
try await live.listViews(request)
}
public func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership {
try await live.addDocumentUser(request)
}
public func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws {
try await live.removeDocumentUser(request)
}
public func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember] {
try await live.documentUsers(request)
}
public func listUsers(_ request: ListUsersRequest) async throws -> [OutlineUser] {
try await live.listUsers(request)
}
public func deleteCollection(id: String) async throws {
try await live.deleteCollection(id: id)
}
public func exportCollection(_ request: ExportCollectionRequest) async throws -> OutlineFileOperation {
try await live.exportCollection(request)
}
public func listStars(_ request: ListStarsRequest) async throws -> [OutlineStar] {
try await live.listStars(request)
}
public func currentUser() async throws -> OutlineUser {
try await live.currentUser()
}
public func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult {
try await live.createAttachment(request)
}
public func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws {
try await live.uploadAttachmentFile(result, fileData: fileData)
}
public func deleteAttachment(id: String) async throws {
try await live.deleteAttachment(id: id)
}
public func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser {
try await live.updateUserAvatar(request)
}
public func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser {
try await live.updateUserName(request)
}
// MARK: - Sync management (Settings surface)
public func pendingOperations() async -> [PendingOperationSummary] {
await cache.pendingOperations().map {
PendingOperationSummary(id: $0.id, kind: $0.kind, createdAt: $0.createdAt, attemptCount: $0.attemptCount, lastError: $0.lastError)
}
}
public func cacheStorageSummary() async -> CacheStorageSummary {
CacheStorageSummary(itemCount: await cache.itemCount(), totalBytes: await cache.totalBytes())
}
public func clearCache() async {
await cache.clearAll()
}
/// Replays every queued operation against `live`, in the order they were
/// queued. Each is independent one failing doesn't block the rest.
public func flushPendingOperations() async -> SyncFlushSummary {
let operations = await cache.pendingOperations()
var succeeded = 0
var failed = 0
for operation in operations {
do {
try await replay(operation)
await cache.removeOperation(id: operation.id)
succeeded += 1
} catch {
await cache.recordFailure(id: operation.id, error: errorDescription(error))
failed += 1
}
}
return SyncFlushSummary(succeeded: succeeded, failed: failed)
}
/// "Full Local Sync": eagerly walks every collection and caches every
/// document's full content (not just whatever's been opened), so
/// browsing offline works for the whole workspace, not only what was
/// already viewed. Goes through `self`, not `live`, directly the
/// existing cached-read methods already do the caching as a side effect,
/// this just has to drive the walk and separately cache each document by
/// id (`listDocuments`'s cache key is the list, not the individual doc).
public func performFullSync() async -> FullSyncSummary {
var documentsCount = 0
var errors: [String] = []
var collections: [OutlineCollection] = []
var collectionsOffset = 0
let collectionsLimit = 100
// Outline rejects any `limit` over 100 outright page in increments
// of that instead of guessing a total up front (the protocol doesn't
// expose `pagination`'s total count, only the page itself); a page
// shorter than the limit is what signals "that was the last one".
while true {
let page: [OutlineCollection]
do {
page = try await listCollections(offset: collectionsOffset, limit: collectionsLimit)
} catch {
errors.append(errorDescription(error))
break
}
collections.append(contentsOf: page)
guard page.count == collectionsLimit else { break }
collectionsOffset += collectionsLimit
}
for collection in collections {
var offset = 0
let limit = 100
while true {
let documents: [OutlineDocument]
do {
documents = try await listDocuments(collectionId: collection.id, parentDocumentId: nil, offset: offset, limit: limit)
} catch {
errors.append("\(collection.name): \(errorDescription(error))")
break
}
for document in documents {
await cacheDocument(document)
}
documentsCount += documents.count
guard documents.count == limit else { break }
offset += limit
}
}
return FullSyncSummary(collectionsCount: collections.count, documentsCount: documentsCount, errors: errors, finishedAt: Date())
}
// MARK: - Helpers
private func cachedFetch<T: Codable>(key: String, fetch: () async throws -> T) async throws -> T {
// Manual offline mode means "skip the network entirely," not just
// "prefer it" without this check, a read would still hit `live`
// (and succeed, showing content beyond whatever's cached) any time
// the device actually had a connection, defeating the point of
// deliberately testing/working as if offline.
if isManualOfflineModeEnabled {
if let data = await cache.load(forKey: key), let cached = try? decoder.decode(T.self, from: data) {
return cached
}
throw OutlineAPIError.transport(URLError(.notConnectedToInternet))
}
do {
let result = try await fetch()
if let data = try? encoder.encode(result) {
await cache.save(data, forKey: key)
}
return result
} catch {
if let data = await cache.load(forKey: key), let cached = try? decoder.decode(T.self, from: data) {
return cached
}
throw error
}
}
private func requestKey(_ prefix: String, _ request: some Encodable) -> String {
guard let data = try? keyEncoder.encode(request), let json = String(data: data, encoding: .utf8) else {
return prefix
}
return "\(prefix):\(json)"
}
private func cacheDocument(_ document: OutlineDocument) async {
if let data = try? encoder.encode(document) {
await cache.save(data, forKey: "document:\(document.id)")
}
}
private func cacheCollection(_ collection: OutlineCollection) async {
if let data = try? encoder.encode(collection) {
await cache.save(data, forKey: "collection:\(collection.id)")
}
}
private func enqueue(_ kind: PendingOperationKind, payload: some Encodable, id: String) async {
guard let data = try? encoder.encode(payload) else { return }
await cache.enqueueOperation(id: id, kind: kind.rawValue, payload: data)
}
/// A delete targeting a `pending-*` id can only mean "cancel the create
/// still sitting in the queue" the server has never heard of that id,
/// so queuing the delete would just fail once synced. Returns whether it
/// found (and removed) a matching create, meaning the caller is done.
private func cancelIfNeverSynced(id: String) async -> Bool {
guard id.hasPrefix("pending-") else { return false }
return await cache.removeOperation(id: id)
}
private func queueDocumentCreate(_ request: CreateDocumentRequest) async -> OutlineDocument {
let pendingId = "pending-\(UUID().uuidString)"
let now = Date()
let synthesized = OutlineDocument(
id: pendingId,
title: request.title,
text: request.text,
emoji: nil,
collectionId: request.collectionId,
parentDocumentId: request.parentDocumentId,
url: "/doc/\(pendingId)",
revision: nil,
fullWidth: nil,
createdAt: now,
updatedAt: now,
publishedAt: request.publish ? now : nil,
archivedAt: nil,
deletedAt: nil
)
await cacheDocument(synthesized)
await enqueue(.createDocument, payload: request, id: pendingId)
return synthesized
}
private func queueDocumentUpdate(_ request: UpdateDocumentRequest, dueTo error: Error?) async throws -> OutlineDocument {
guard let baseData = await cache.load(forKey: "document:\(request.id)"),
let base = try? decoder.decode(OutlineDocument.self, from: baseData) else {
throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet))
}
let mergedText = request.append == true ? base.text + (request.text ?? "") : (request.text ?? base.text)
let merged = OutlineDocument(
id: base.id,
title: request.title ?? base.title,
text: mergedText,
emoji: base.emoji,
collectionId: base.collectionId,
parentDocumentId: base.parentDocumentId,
url: base.url,
revision: base.revision,
fullWidth: request.fullWidth ?? base.fullWidth,
createdAt: base.createdAt,
updatedAt: Date(),
publishedAt: base.publishedAt,
archivedAt: base.archivedAt,
deletedAt: base.deletedAt
)
await cacheDocument(merged)
// Still-unsynced create for this exact document fold the edit into
// the pending create's payload instead of queuing a separate update.
// A separate update would target `merged.id`, which is still the
// `pending-*` placeholder at replay time; the server has never heard
// of it and the update would just fail every retry.
if merged.id.hasPrefix("pending-"),
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 resolvedCreate = CreateDocumentRequest(
title: merged.title,
text: merged.text,
collectionId: createRequest.collectionId,
parentDocumentId: createRequest.parentDocumentId,
publish: createRequest.publish
)
await enqueue(.createDocument, payload: resolvedCreate, id: merged.id)
return merged
}
// Fully resolved (not the original partial request) so replaying just
// this one queued operation reproduces `merged` exactly that's what
// makes coalescing a second edit onto the same queued id safe.
let resolved = UpdateDocumentRequest(id: merged.id, title: merged.title, text: merged.text, fullWidth: merged.fullWidth)
await enqueue(.updateDocument, payload: resolved, id: "update-document-\(merged.id)")
return merged
}
private func queueCollectionUpdate(_ request: UpdateCollectionRequest, dueTo error: Error?) async throws -> OutlineCollection {
guard let baseData = await cache.load(forKey: "collection:\(request.id)"),
let base = try? decoder.decode(OutlineCollection.self, from: baseData) else {
throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet))
}
let merged = OutlineCollection(
id: base.id,
name: request.name ?? base.name,
description: request.description ?? base.description,
color: base.color,
icon: base.icon,
createdAt: base.createdAt,
updatedAt: Date()
)
await cacheCollection(merged)
let resolved = UpdateCollectionRequest(id: merged.id, name: merged.name, description: merged.description)
await enqueue(.updateCollection, payload: resolved, id: "update-collection-\(merged.id)")
return merged
}
private func queuePinCreate(_ request: CreatePinRequest) async -> OutlinePin {
let pendingId = "pending-\(UUID().uuidString)"
await enqueue(.createPin, payload: request, id: pendingId)
return OutlinePin(id: pendingId, documentId: request.documentId, collectionId: request.collectionId, index: nil)
}
private func queueSubscriptionCreate(_ request: CreateSubscriptionRequest) async -> OutlineSubscription {
let pendingId = "pending-\(UUID().uuidString)"
await enqueue(.createSubscription, payload: request, id: pendingId)
return OutlineSubscription(id: pendingId, documentId: request.documentId, collectionId: nil, event: request.event)
}
private func queueStarDocumentCreate(_ request: StarDocumentRequest) async -> OutlineStar {
let pendingId = "pending-\(UUID().uuidString)"
await enqueue(.starDocument, payload: request, id: pendingId)
return OutlineStar(id: pendingId, index: nil, documentId: request.documentId, collectionId: nil)
}
private func queueStarCollectionCreate(_ request: StarCollectionRequest) async -> OutlineStar {
let pendingId = "pending-\(UUID().uuidString)"
await enqueue(.starCollection, payload: request, id: pendingId)
return OutlineStar(id: pendingId, index: nil, documentId: nil, collectionId: request.collectionId)
}
private func replay(_ operation: PendingOperation) async throws {
guard let kind = PendingOperationKind(rawValue: operation.kind) else {
throw OutlineAPIError.decoding(DecodingError.dataCorrupted(
DecodingError.Context(codingPath: [], debugDescription: "Unknown pending operation kind: \(operation.kind)")
))
}
switch kind {
case .createDocument:
let request = try decoder.decode(CreateDocumentRequest.self, from: operation.payload)
let result = try await live.createDocument(request)
await cacheDocument(result)
// The placeholder id (== operation.id) is now a dead orphan
// nothing server-side will ever answer to it again.
await cache.removeCacheEntry(forKey: "document:\(operation.id)")
case .updateDocument:
let request = try decoder.decode(UpdateDocumentRequest.self, from: operation.payload)
let result = try await live.updateDocument(request)
await cacheDocument(result)
case .updateCollection:
let request = try decoder.decode(UpdateCollectionRequest.self, from: operation.payload)
let result = try await live.updateCollection(request)
await cacheCollection(result)
case .createPin:
let request = try decoder.decode(CreatePinRequest.self, from: operation.payload)
_ = try await live.createPin(request)
case .deletePin:
let request = try decoder.decode(IDPayload.self, from: operation.payload)
try await live.deletePin(id: request.id)
case .createSubscription:
let request = try decoder.decode(CreateSubscriptionRequest.self, from: operation.payload)
_ = try await live.createSubscription(request)
case .deleteSubscription:
let request = try decoder.decode(IDPayload.self, from: operation.payload)
try await live.deleteSubscription(id: request.id)
case .starDocument:
let request = try decoder.decode(StarDocumentRequest.self, from: operation.payload)
_ = try await live.starDocument(request)
case .deleteStar:
let request = try decoder.decode(IDPayload.self, from: operation.payload)
try await live.deleteStar(id: request.id)
case .starCollection:
let request = try decoder.decode(StarCollectionRequest.self, from: operation.payload)
_ = try await live.starCollection(request)
}
}
private func errorDescription(_ error: Error) -> String {
if let apiError = error as? OutlineAPIError {
switch apiError {
case .unauthorized: return "Sign-in expired."
case .notFound: return "Not found on the server."
case .server(let status, let message): return message ?? "Server error (\(status))."
case .decoding: return "Unexpected response shape."
case .transport: return "Couldn't reach the server."
case .tokenUnavailable: return "Couldn't access the saved sign-in."
}
}
return String(describing: error)
}
}
@@ -0,0 +1,104 @@
import Foundation
import SwiftData
/// Read-through cache backing `CachingOutlineAPIClient` stores whatever it
/// last fetched successfully, keyed by request shape, so browsing keeps
/// working (stale) once the network stops. Also owns the offline write
/// queue (`PendingOperation`) same store, same actor, since both need
/// synchronized access to the same on-disk SwiftData container.
@ModelActor
public actor OfflineCacheStore {
public static func makeContainer(inMemory: Bool = false) throws -> ModelContainer {
let configuration = ModelConfiguration(isStoredInMemoryOnly: inMemory)
return try ModelContainer(for: CachedPayload.self, PendingOperation.self, configurations: configuration)
}
// MARK: - Read-through cache
public func save(_ data: Data, forKey key: String) {
let descriptor = FetchDescriptor<CachedPayload>(predicate: #Predicate { $0.key == key })
if let existing = try? modelContext.fetch(descriptor).first {
existing.payload = data
existing.cachedAt = Date()
} else {
modelContext.insert(CachedPayload(key: key, payload: data, cachedAt: Date()))
}
try? modelContext.save()
}
public func load(forKey key: String) -> Data? {
let descriptor = FetchDescriptor<CachedPayload>(predicate: #Predicate { $0.key == key })
return try? modelContext.fetch(descriptor).first?.payload
}
/// Used to drop a temporary `pending-*` document's cache entry once a
/// queued create syncs and the server hands back the real id the
/// placeholder key would otherwise sit around as a dead orphan forever.
public func removeCacheEntry(forKey key: String) {
let descriptor = FetchDescriptor<CachedPayload>(predicate: #Predicate { $0.key == key })
guard let existing = try? modelContext.fetch(descriptor).first else { return }
modelContext.delete(existing)
try? modelContext.save()
}
public func itemCount() -> Int {
(try? modelContext.fetchCount(FetchDescriptor<CachedPayload>())) ?? 0
}
public func totalBytes() -> Int {
let descriptor = FetchDescriptor<CachedPayload>()
let rows = (try? modelContext.fetch(descriptor)) ?? []
return rows.reduce(0) { $0 + $1.payload.count }
}
public func clearAll() {
let descriptor = FetchDescriptor<CachedPayload>()
guard let rows = try? modelContext.fetch(descriptor) else { return }
rows.forEach { modelContext.delete($0) }
try? modelContext.save()
}
// MARK: - Offline write queue
/// Upserts by `id` a second call with the same id (an edit coalescing
/// onto a still-unsynced edit, or a create being cancelled by its own
/// synthesized id) replaces the row in place rather than piling up.
public func enqueueOperation(id: String, kind: String, payload: Data) {
let descriptor = FetchDescriptor<PendingOperation>(predicate: #Predicate { $0.id == id })
if let existing = try? modelContext.fetch(descriptor).first {
existing.kind = kind
existing.payload = payload
existing.lastError = nil
existing.attemptCount = 0
} else {
modelContext.insert(PendingOperation(id: id, kind: kind, payload: payload, createdAt: Date()))
}
try? modelContext.save()
}
public func pendingOperations() -> [PendingOperation] {
let descriptor = FetchDescriptor<PendingOperation>(sortBy: [SortDescriptor(\.createdAt)])
return (try? modelContext.fetch(descriptor)) ?? []
}
/// Returns whether a matching operation was actually found and removed
/// callers use this to detect "this targeted something that never made
/// it to the server in the first place" and skip queuing a delete.
@discardableResult
public func removeOperation(id: String) -> Bool {
let descriptor = FetchDescriptor<PendingOperation>(predicate: #Predicate { $0.id == id })
guard let existing = try? modelContext.fetch(descriptor).first else { return false }
modelContext.delete(existing)
try? modelContext.save()
return true
}
public func recordFailure(id: String, error: String) {
let descriptor = FetchDescriptor<PendingOperation>(predicate: #Predicate { $0.id == id })
guard let existing = try? modelContext.fetch(descriptor).first else { return }
existing.lastAttemptAt = Date()
existing.lastError = error
existing.attemptCount += 1
try? modelContext.save()
}
}
@@ -0,0 +1,40 @@
import Foundation
import SwiftData
/// A queued mutation made while offline, waiting to replay against the live
/// server. `id` is deliberately overloaded: for actions that create a new
/// server-side record (pin, star, subscription), it's also the synthesized
/// placeholder id handed back to the caller immediately so a matching
/// delete queued before that create ever syncs can cancel both out by id
/// instead of hitting a server that's never heard of the placeholder. For
/// actions that edit an existing record (document/collection updates), it's
/// deterministic per target id, so a second edit before the first syncs
/// coalesces into one queued operation instead of piling up.
@Model
public final class PendingOperation {
@Attribute(.unique) public var id: String
public var kind: String
public var payload: Data
public var createdAt: Date
public var lastAttemptAt: Date?
public var lastError: String?
public var attemptCount: Int
public init(
id: String,
kind: String,
payload: Data,
createdAt: Date,
lastAttemptAt: Date? = nil,
lastError: String? = nil,
attemptCount: Int = 0
) {
self.id = id
self.kind = kind
self.payload = payload
self.createdAt = createdAt
self.lastAttemptAt = lastAttemptAt
self.lastError = lastError
self.attemptCount = attemptCount
}
}
@@ -0,0 +1,50 @@
import Foundation
/// UI-facing snapshot of a queued offline mutation deliberately not the
/// `@Model` type itself, so Settings can display it without holding a
/// reference into the SwiftData store.
public struct PendingOperationSummary: Identifiable, Sendable {
public let id: String
public let kind: String
public let createdAt: Date
public let attemptCount: Int
public let lastError: String?
}
public struct CacheStorageSummary: Sendable {
public let itemCount: Int
public let totalBytes: Int
}
public struct SyncFlushSummary: Sendable {
public let succeeded: Int
public let failed: Int
}
public struct FullSyncSummary: Sendable {
public let collectionsCount: Int
public let documentsCount: Int
public let errors: [String]
public let finishedAt: Date
}
/// Every mutation `CachingOutlineAPIClient` knows how to queue offline and
/// replay later. Deliberately a small, explicit set see its doc comment
/// for what's excluded and why.
enum PendingOperationKind: String, Codable, Sendable {
case createDocument
case updateDocument
case updateCollection
case createPin
case deletePin
case createSubscription
case deleteSubscription
case starDocument
case deleteStar
case starCollection
}
/// Shared payload shape for the delete-by-id queueable operations.
struct IDPayload: Codable, Sendable {
let id: String
}
@@ -69,4 +69,18 @@ public protocol OutlineAPIClient: Sendable {
func deleteStar(id: String) async throws
func currentUser() async throws -> OutlineUser
/// Two-step presigned upload: this requests where/how to upload,
/// `uploadAttachmentFile` performs the actual multipart POST to that
/// target. See `OutlineAttachment`/`CreateAttachmentResult`.
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult
func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws
/// Best-effort matches the shape every other simple `id`-only delete
/// in this API uses (`pins.delete`, `stars.delete`, ), not confirmed
/// against a live server specifically for attachments yet.
func deleteAttachment(id: String) async throws
/// `users.update`, avatar only. See `UpdateUserAvatarRequest`.
func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser
/// `users.update`, name only. See `UpdateUserNameRequest`.
func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser
}
@@ -229,6 +229,67 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
try await post("users.info", body: EmptyParams())
}
public func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult {
try await post("attachments.create", body: request)
}
/// No Bearer/CSRF header attached here, deliberately the presigned
/// `form.sig` field (short-lived, scoped to this exact upload key) is
/// what authorizes this specific request, the same way an S3 presigned
/// POST works. Best-effort against a live server: if it turns out the
/// self-hosted local-storage backend also wants a bearer token here,
/// that's a one-line addition once confirmed, not a design change.
public func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws {
let (body, contentType) = MultipartFormDataBuilder.build(
fields: result.form,
fileFieldName: "file",
fileName: result.attachment.name ?? "avatar",
fileData: fileData,
fileContentType: result.form["Content-Type"] ?? "application/octet-stream"
)
// `uploadUrl` is a host-relative path (e.g. "/api/files.create") on
// a self-hosted local-storage backend, not an absolute S3 URL
// resolving against `baseURL` handles both: `URL(string:relativeTo:)`
// replaces the whole path for a leading-slash relative string per
// RFC 3986, same resolution already used for user/team avatar URLs.
guard let uploadURL = URL(string: result.uploadUrl, relativeTo: baseURL)?.absoluteURL else {
throw OutlineAPIError.transport(URLError(.badURL))
}
var request = URLRequest(url: uploadURL)
request.httpMethod = "POST"
request.setValue(contentType, forHTTPHeaderField: "Content-Type")
request.httpBody = body
let data: Data
let response: HTTPURLResponse
do {
(data, response) = try await httpClient.send(request)
} catch let error as OutlineAPIError {
throw error
} catch {
throw OutlineAPIError.transport(error)
}
guard (200...299).contains(response.statusCode) else {
let errorEnvelope = try? decoder.decode(OutlineErrorEnvelope.self, from: data)
throw OutlineAPIError.server(status: response.statusCode, message: errorEnvelope?.message ?? errorEnvelope?.error)
}
}
public func deleteAttachment(id: String) async throws {
try await postForSuccess("attachments.delete", body: StarIDParams(id: id))
}
public func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser {
try await post("users.update", body: request)
}
public func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser {
try await post("users.update", body: request)
}
private func post<Body: Encodable, Response: Decodable>(_ path: String, body: Body) async throws -> Response {
guard let token = try? tokenStore.token() else {
throw OutlineAPIError.tokenUnavailable
@@ -0,0 +1,26 @@
import Foundation
/// One uploaded file created via a two-step presigned upload
/// (`attachments.create` for the upload target, then a direct POST to
/// `uploadUrl`/`form`). Confirmed live against a self-hosted instance's
/// local-storage backend; `size` comes back as a string there, not a
/// number same "don't trust the vendored spec's implied types" lesson as
/// everywhere else this codebase has hit it.
public struct OutlineAttachment: Decodable, Identifiable, Sendable {
public let id: String
public let documentId: String?
public let contentType: String?
public let name: String?
public let url: String?
public let size: String?
}
/// `attachments.create`'s response everything needed to perform the
/// actual upload. `form` fields (Content-Type, key, acl, sig, `_csrf`, )
/// must all be included as their own multipart parts, in the same request
/// as the file itself, POSTed to `uploadUrl`.
public struct CreateAttachmentResult: Decodable, Sendable {
public let attachment: OutlineAttachment
public let uploadUrl: String
public let form: [String: String]
}
@@ -0,0 +1,40 @@
import Foundation
/// Builds the multipart/form-data body for Outline's presigned-upload
/// targets (`attachments.create`'s `uploadUrl`/`form`) an S3-style
/// presigned POST: every `form` field has to ride along as its own part in
/// the same request as the file, not as query params or headers.
enum MultipartFormDataBuilder {
static func build(
fields: [String: String],
fileFieldName: String,
fileName: String,
fileData: Data,
fileContentType: String,
boundary: String = "Boundary-\(UUID().uuidString)"
) -> (body: Data, contentType: String) {
var body = Data()
for (key, value) in fields {
body.append("--\(boundary)\r\n".utf8Data)
body.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".utf8Data)
body.append(value.utf8Data)
body.append("\r\n".utf8Data)
}
body.append("--\(boundary)\r\n".utf8Data)
body.append(
"Content-Disposition: form-data; name=\"\(fileFieldName)\"; filename=\"\(fileName)\"\r\n".utf8Data
)
body.append("Content-Type: \(fileContentType)\r\n\r\n".utf8Data)
body.append(fileData)
body.append("\r\n".utf8Data)
body.append("--\(boundary)--\r\n".utf8Data)
return (body, "multipart/form-data; boundary=\(boundary)")
}
}
private extension String {
var utf8Data: Data { Data(utf8) }
}
@@ -0,0 +1,19 @@
import Foundation
/// Requests an upload target for a new file not the upload itself, see
/// `OutlineAPIClient.uploadAttachmentFile`. `documentId: nil` is what a
/// user-avatar upload sends (confirmed live); a real value scopes the
/// attachment to a document instead (e.g. an inline image embed).
public struct CreateAttachmentRequest: Encodable, Sendable {
public let name: String
public let contentType: String
public let size: Int
public let documentId: String?
public init(name: String, contentType: String, size: Int, documentId: String? = nil) {
self.name = name
self.contentType = contentType
self.size = size
self.documentId = documentId
}
}
@@ -1,6 +1,6 @@
import Foundation
public struct CreateDocumentRequest: Encodable, Sendable {
public struct CreateDocumentRequest: Codable, Sendable {
public let title: String
public let text: String
public let collectionId: String
@@ -2,7 +2,7 @@ import Foundation
/// See `OutlinePin`. `collectionId: nil` = "Pin to Home", non-nil = "Pin to
/// Collection" these are distinct actions on the real server.
public struct CreatePinRequest: Encodable, Sendable {
public struct CreatePinRequest: Codable, Sendable {
public let documentId: String
public let collectionId: String?
@@ -1,7 +1,7 @@
import Foundation
/// See `OutlineSubscription` best-effort shape, not in the vendored spec.
public struct CreateSubscriptionRequest: Encodable, Sendable {
public struct CreateSubscriptionRequest: Codable, Sendable {
public let documentId: String
public let event: String
@@ -1,6 +1,6 @@
import Foundation
public struct StarCollectionRequest: Encodable, Sendable {
public struct StarCollectionRequest: Codable, Sendable {
public let collectionId: String
public init(collectionId: String) {
@@ -1,6 +1,6 @@
import Foundation
public struct StarDocumentRequest: Encodable, Sendable {
public struct StarDocumentRequest: Codable, Sendable {
public let documentId: String
public init(documentId: String) {
@@ -1,6 +1,6 @@
import Foundation
public struct UpdateCollectionRequest: Encodable, Sendable {
public struct UpdateCollectionRequest: Codable, Sendable {
public let id: String
public let name: String?
public let description: String?
@@ -1,6 +1,6 @@
import Foundation
public struct UpdateDocumentRequest: Encodable, Sendable {
public struct UpdateDocumentRequest: Codable, Sendable {
public let id: String
public let title: String?
public let text: String?
@@ -0,0 +1,28 @@
import Foundation
/// `users.update`, scoped to just the avatar. Custom `encode(to:)` because
/// Swift's synthesized `Encodable` uses `encodeIfPresent` for `Optional`
/// properties, which *omits* the key entirely when the value is `nil`
/// removing the avatar needs a literal `"avatarUrl": null` in the request
/// body, not the key missing. `id` is required: confirmed live (the
/// captured request body's length only matches `{"id": "...", "avatarUrl":
/// ...}`, not a shorter shape without it).
public struct UpdateUserAvatarRequest: Encodable, Sendable {
public let id: String
public let avatarUrl: String?
public init(id: String, avatarUrl: String?) {
self.id = id
self.avatarUrl = avatarUrl
}
private enum CodingKeys: String, CodingKey {
case id, avatarUrl
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(avatarUrl, forKey: .avatarUrl)
}
}
@@ -0,0 +1,12 @@
import Foundation
/// `users.update`, name only.
public struct UpdateUserNameRequest: Encodable, Sendable {
public let id: String
public let name: String
public init(id: String, name: String) {
self.id = id
self.name = name
}
}
@@ -0,0 +1,505 @@
import XCTest
@testable import OutlineKit
private struct NotStubbed: Error {}
/// Conforms to the full protocol (so it can stand in for `live`), but only
/// the two methods under test in this file have real behavior everything
/// else throws loudly if a test accidentally exercises it.
private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable {
var documentInfoHandler: (@Sendable (String) async throws -> OutlineDocument)?
var listCollectionsHandler: (@Sendable (Int, Int) async throws -> [OutlineCollection])?
var updateDocumentHandler: (@Sendable (UpdateDocumentRequest) async throws -> OutlineDocument)?
var createPinHandler: (@Sendable (CreatePinRequest) async throws -> OutlinePin)?
var deletePinHandler: (@Sendable (String) async throws -> Void)?
var listDocumentsHandler: (@Sendable (String?, String?, Int, Int) async throws -> [OutlineDocument])?
var createDocumentHandler: (@Sendable (CreateDocumentRequest) async throws -> OutlineDocument)?
func authInfo() async throws -> OutlineAuthInfo { throw NotStubbed() }
func documentInfo(id: String) async throws -> OutlineDocument {
guard let handler = documentInfoHandler else { throw NotStubbed() }
return try await handler(id)
}
func listDocuments(collectionId: String?, parentDocumentId: String?, offset: Int, limit: Int) async throws -> [OutlineDocument] {
guard let handler = listDocumentsHandler else { throw NotStubbed() }
return try await handler(collectionId, parentDocumentId, offset, limit)
}
func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] { throw NotStubbed() }
func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] { throw NotStubbed() }
func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument {
guard let handler = createDocumentHandler else { throw NotStubbed() }
return try await handler(request)
}
func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument {
guard let handler = updateDocumentHandler else { throw NotStubbed() }
return try await handler(request)
}
func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar { throw NotStubbed() }
func templatizeDocument(_ request: TemplatizeDocumentRequest) async throws -> OutlineTemplate { throw NotStubbed() }
func duplicateDocument(_ request: DuplicateDocumentRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
func unpublishDocument(_ request: UnpublishDocumentRequest) async throws -> OutlineDocument { throw NotStubbed() }
func archiveDocument(id: String) async throws -> OutlineDocument { throw NotStubbed() }
func moveDocument(_ request: MoveDocumentRequest) async throws { throw NotStubbed() }
func deleteDocument(_ request: DeleteDocumentRequest) async throws { throw NotStubbed() }
func documentInsights(_ request: DocumentInsightsRequest) async throws -> [OutlineDocumentInsight] { throw NotStubbed() }
func listRevisions(_ request: ListRevisionsRequest) async throws -> [OutlineRevision] { throw NotStubbed() }
func exportDocument(id: String) async throws -> String { throw NotStubbed() }
func createShare(_ request: CreateShareRequest) async throws -> OutlineShare { throw NotStubbed() }
func shareInfo(documentId: String) async throws -> OutlineShare? { throw NotStubbed() }
func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare { throw NotStubbed() }
func listShares(_ request: ListSharesRequest) async throws -> [OutlineShare] { throw NotStubbed() }
func revokeShare(id: String) async throws { throw NotStubbed() }
func createPin(_ request: CreatePinRequest) async throws -> OutlinePin {
guard let handler = createPinHandler else { throw NotStubbed() }
return try await handler(request)
}
func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] { throw NotStubbed() }
func deletePin(id: String) async throws {
guard let handler = deletePinHandler else { throw NotStubbed() }
try await handler(id)
}
func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription { throw NotStubbed() }
func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] { throw NotStubbed() }
func deleteSubscription(id: String) async throws { throw NotStubbed() }
func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] { throw NotStubbed() }
func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership { throw NotStubbed() }
func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws { throw NotStubbed() }
func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember] { throw NotStubbed() }
func listUsers(_ request: ListUsersRequest) async throws -> [OutlineUser] { throw NotStubbed() }
func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] {
guard let handler = listCollectionsHandler else { throw NotStubbed() }
return try await handler(offset, limit)
}
func collectionInfo(id: String) async throws -> OutlineCollection { throw NotStubbed() }
func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection { throw NotStubbed() }
func deleteCollection(id: String) async throws { throw NotStubbed() }
func exportCollection(_ request: ExportCollectionRequest) async throws -> OutlineFileOperation { throw NotStubbed() }
func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar { throw NotStubbed() }
func listStars(_ request: ListStarsRequest) async throws -> [OutlineStar] { throw NotStubbed() }
func deleteStar(id: String) async throws { throw NotStubbed() }
func currentUser() async throws -> OutlineUser { throw NotStubbed() }
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult { throw NotStubbed() }
func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws { throw NotStubbed() }
func deleteAttachment(id: String) async throws { throw NotStubbed() }
func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser { throw NotStubbed() }
func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser { throw NotStubbed() }
}
private struct StubTransportError: Error {}
final class CachingOutlineAPIClientTests: XCTestCase {
private func makeCache() throws -> OfflineCacheStore {
OfflineCacheStore(modelContainer: try OfflineCacheStore.makeContainer(inMemory: true))
}
private func makeDocument(id: String = "doc-1", title: String = "Hello") -> OutlineDocument {
OutlineDocument(
id: id,
title: title,
text: "body",
url: "/doc/\(id)",
createdAt: Date(timeIntervalSince1970: 0),
updatedAt: Date(timeIntervalSince1970: 0)
)
}
private func makeCollection(id: String = "col-1", name: String = "Engineering") -> OutlineCollection {
OutlineCollection(
id: id,
name: name,
createdAt: Date(timeIntervalSince1970: 0),
updatedAt: Date(timeIntervalSince1970: 0)
)
}
func testDocumentInfoWritesThroughToCacheOnSuccess() async throws {
let stub = StubOutlineAPIClient()
let document = makeDocument()
stub.documentInfoHandler = { _ in document }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let result = try await sut.documentInfo(id: "doc-1")
XCTAssertEqual(result, document)
}
func testDocumentInfoFallsBackToCacheWhenLiveFails() async throws {
let stub = StubOutlineAPIClient()
let document = makeDocument()
var callCount = 0
stub.documentInfoHandler = { _ in
callCount += 1
if callCount == 1 { return document }
throw StubTransportError()
}
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
// First call succeeds and populates the cache.
_ = try await sut.documentInfo(id: "doc-1")
// Second call fails live should transparently return the cached value.
let result = try await sut.documentInfo(id: "doc-1")
XCTAssertEqual(result, document)
XCTAssertEqual(callCount, 2)
}
func testDocumentInfoRethrowsWhenLiveFailsAndCacheIsEmpty() async throws {
let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
do {
_ = try await sut.documentInfo(id: "doc-1")
XCTFail("Expected an error")
} catch is StubTransportError {
// expected
}
}
func testListCollectionsFallsBackToCacheWhenLiveFails() async throws {
let stub = StubOutlineAPIClient()
let collections = [makeCollection()]
var callCount = 0
stub.listCollectionsHandler = { _, _ in
callCount += 1
if callCount == 1 { return collections }
throw StubTransportError()
}
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
_ = try await sut.listCollections(offset: 0, limit: 25)
let result = try await sut.listCollections(offset: 0, limit: 25)
XCTAssertEqual(result, collections)
}
func testDifferentDocumentIdsAreCachedSeparately() async throws {
let stub = StubOutlineAPIClient()
let docOne = makeDocument(id: "doc-1", title: "One")
let docTwo = makeDocument(id: "doc-2", title: "Two")
stub.documentInfoHandler = { id in id == "doc-1" ? docOne : docTwo }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
_ = try await sut.documentInfo(id: "doc-1")
_ = try await sut.documentInfo(id: "doc-2")
stub.documentInfoHandler = { _ in throw StubTransportError() }
let cachedOne = try await sut.documentInfo(id: "doc-1")
let cachedTwo = try await sut.documentInfo(id: "doc-2")
XCTAssertEqual(cachedOne, docOne)
XCTAssertEqual(cachedTwo, docTwo)
}
// MARK: - Offline writes
func testUpdateDocumentQueuesAndAppliesOptimisticallyWhenLiveFails() async throws {
let stub = StubOutlineAPIClient()
let original = makeDocument(id: "doc-1", title: "Original")
stub.documentInfoHandler = { _ in original }
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
// Populate the cache with the base document first (as a real open would).
_ = try await sut.documentInfo(id: "doc-1")
let updated = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
XCTAssertEqual(updated.title, "Edited Offline")
// Re-opening the document (still offline) should reflect the optimistic edit.
stub.documentInfoHandler = { _ in throw StubTransportError() }
let reopened = try await sut.documentInfo(id: "doc-1")
XCTAssertEqual(reopened.title, "Edited Offline")
let pending = await sut.pendingOperations()
XCTAssertEqual(pending.count, 1)
XCTAssertEqual(pending.first?.kind, "updateDocument")
}
func testUpdateDocumentRethrowsWhenDocumentWasNeverCached() async throws {
let stub = StubOutlineAPIClient()
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
do {
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "never-seen", title: "x"))
XCTFail("Expected an error")
} catch is StubTransportError {
// expected nothing to optimistically merge onto
}
}
func testSecondOfflineEditCoalescesIntoOneQueuedOperation() async throws {
let stub = StubOutlineAPIClient()
let original = makeDocument(id: "doc-1", title: "Original")
stub.documentInfoHandler = { _ in original }
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
_ = 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: "Second Edit"))
let pending = await sut.pendingOperations()
XCTAssertEqual(pending.count, 1)
}
func testPinThenUnpinBeforeSyncCancelsOutWithoutQueuingADelete() async throws {
let stub = StubOutlineAPIClient()
stub.createPinHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1"))
XCTAssertTrue(pin.id.hasPrefix("pending-"))
let pendingCount1 = await sut.pendingOperations().count
XCTAssertEqual(pendingCount1, 1)
try await sut.deletePin(id: pin.id)
let pendingCount0 = await sut.pendingOperations().count
XCTAssertEqual(pendingCount0, 0)
}
func testFlushPendingOperationsReplaysAndClearsOnSuccess() async throws {
let stub = StubOutlineAPIClient()
let original = makeDocument(id: "doc-1", title: "Original")
stub.documentInfoHandler = { _ in original }
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
_ = try await sut.documentInfo(id: "doc-1")
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
let pendingCount1 = await sut.pendingOperations().count
XCTAssertEqual(pendingCount1, 1)
// Network's back replay should now succeed.
stub.updateDocumentHandler = { request in
self.makeDocument(id: request.id, title: request.title ?? "")
}
let summary = await sut.flushPendingOperations()
XCTAssertEqual(summary.succeeded, 1)
XCTAssertEqual(summary.failed, 0)
let pendingCount0 = await sut.pendingOperations().count
XCTAssertEqual(pendingCount0, 0)
}
func testFlushPendingOperationsRecordsErrorAndKeepsFailedOperation() async throws {
let stub = StubOutlineAPIClient()
let original = makeDocument(id: "doc-1", title: "Original")
stub.documentInfoHandler = { _ in original }
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
_ = try await sut.documentInfo(id: "doc-1")
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
// Still offline replay should fail and keep the operation queued.
let summary = await sut.flushPendingOperations()
XCTAssertEqual(summary.succeeded, 0)
XCTAssertEqual(summary.failed, 1)
let pending = await sut.pendingOperations()
XCTAssertEqual(pending.count, 1)
XCTAssertEqual(pending.first?.attemptCount, 1)
XCTAssertNotNil(pending.first?.lastError)
}
func testManualOfflineModeSkipsLiveEntirely() async throws {
let defaults = UserDefaults(suiteName: "CachingOutlineAPIClientTests.manualOffline")!
defaults.removePersistentDomain(forName: "CachingOutlineAPIClientTests.manualOffline")
defaults.set(true, forKey: CachingOutlineAPIClient.offlineModeDefaultsKey)
let stub = StubOutlineAPIClient()
var liveCallCount = 0
stub.createPinHandler = { _ in
liveCallCount += 1
return OutlinePin(id: "real-id", documentId: "doc-1", collectionId: nil, index: nil)
}
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), defaults: defaults)
let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1"))
XCTAssertEqual(liveCallCount, 0, "Manual offline mode should never attempt the live call")
XCTAssertTrue(pin.id.hasPrefix("pending-"))
}
/// Regression test: manual offline mode used to only gate writes
/// reads (listCollections, documentInfo, etc.) still hit `live` first
/// any time the device actually had a connection, silently defeating
/// "skip the network entirely" for the one thing that mattered most:
/// the sidebar showing more than what was actually cached.
func testManualOfflineModeSkipsLiveForReadsToo() async throws {
let defaults = UserDefaults(suiteName: "CachingOutlineAPIClientTests.manualOfflineReads")!
defaults.removePersistentDomain(forName: "CachingOutlineAPIClientTests.manualOfflineReads")
let stub = StubOutlineAPIClient()
let cachedCollection = makeCollection(id: "col-1")
var liveCallCount = 0
stub.listCollectionsHandler = { _, _ in
liveCallCount += 1
return [cachedCollection]
}
let cache = try makeCache()
let sut = CachingOutlineAPIClient(live: stub, cache: cache, defaults: defaults)
// Online first populates the cache normally.
let firstResult = try await sut.listCollections(offset: 0, limit: 25)
XCTAssertEqual(firstResult, [cachedCollection])
XCTAssertEqual(liveCallCount, 1)
// Flip manual offline mode on, still "connected" (stub would happily
// answer) the live call must not be attempted at all.
defaults.set(true, forKey: CachingOutlineAPIClient.offlineModeDefaultsKey)
let secondResult = try await sut.listCollections(offset: 0, limit: 25)
XCTAssertEqual(secondResult, [cachedCollection])
XCTAssertEqual(liveCallCount, 1, "listCollections should have served entirely from cache")
}
func testCacheStorageSummaryReflectsCachedItems() async throws {
let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in self.makeDocument() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
_ = try await sut.documentInfo(id: "doc-1")
let summary = await sut.cacheStorageSummary()
XCTAssertEqual(summary.itemCount, 1)
XCTAssertGreaterThan(summary.totalBytes, 0)
await sut.clearCache()
let clearedSummary = await sut.cacheStorageSummary()
XCTAssertEqual(clearedSummary.itemCount, 0)
}
// MARK: - Full sync
func testPerformFullSyncNeverRequestsMoreThan100CollectionsPerPage() async throws {
let stub = StubOutlineAPIClient()
var requestedLimits: [Int] = []
// 105 collections across two pages (100 + 5) regression test for a
// real bug: this used to ask for `limit: 250` in one shot, which
// Outline's server rejects outright ("Pagination limit is too large
// (max 100)"), turning the whole sync into a single silent failure.
stub.listCollectionsHandler = { offset, limit in
requestedLimits.append(limit)
let remaining = max(0, 105 - offset)
let count = min(limit, remaining)
return (0..<count).map { self.makeCollection(id: "col-\(offset + $0)") }
}
stub.listDocumentsHandler = { _, _, _, _ in [] }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let summary = await sut.performFullSync()
XCTAssertEqual(summary.collectionsCount, 105)
XCTAssertTrue(summary.errors.isEmpty)
XCTAssertTrue(requestedLimits.allSatisfy { $0 <= 100 }, "requested limits: \(requestedLimits)")
}
func testPerformFullSyncCachesEachDocumentIndividually() async throws {
let stub = StubOutlineAPIClient()
stub.listCollectionsHandler = { offset, _ in offset == 0 ? [self.makeCollection(id: "col-1")] : [] }
stub.listDocumentsHandler = { _, _, offset, _ in
offset == 0 ? [self.makeDocument(id: "doc-1"), self.makeDocument(id: "doc-2")] : []
}
let cache = try makeCache()
let sut = CachingOutlineAPIClient(live: stub, cache: cache)
let summary = await sut.performFullSync()
XCTAssertEqual(summary.documentsCount, 2)
// A plain documentInfo read (no live call available) should now hit
// the cache full sync populated, not just the list-shaped cache key.
stub.documentInfoHandler = { _ in throw StubTransportError() }
let cachedDoc = try await sut.documentInfo(id: "doc-2")
XCTAssertEqual(cachedDoc.id, "doc-2")
}
// MARK: - Offline document creation
func testCreateDocumentQueuesAndReturnsUsableDocumentWhenOffline() async throws {
let stub = StubOutlineAPIClient()
stub.createDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let created = try await sut.createDocument(
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
)
XCTAssertTrue(created.id.hasPrefix("pending-"))
XCTAssertEqual(created.title, "New Doc")
XCTAssertEqual(created.text, "hello")
// It's immediately readable, same as any other cached document.
stub.documentInfoHandler = { _ in throw StubTransportError() }
let reopened = try await sut.documentInfo(id: created.id)
XCTAssertEqual(reopened.title, "New Doc")
let pending = await sut.pendingOperations()
XCTAssertEqual(pending.count, 1)
XCTAssertEqual(pending.first?.kind, "createDocument")
}
func testEditingAnUnsyncedCreatedDocumentFoldsIntoTheCreateInsteadOfQueuingAnUpdate() async throws {
let stub = StubOutlineAPIClient()
stub.createDocumentHandler = { _ in throw StubTransportError() }
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let created = try await sut.createDocument(
CreateDocumentRequest(title: "Untitled", text: "", collectionId: "col-1")
)
_ = try await sut.updateDocument(UpdateDocumentRequest(id: created.id, title: "Real Title", text: "Real body"))
let pending = await sut.pendingOperations()
XCTAssertEqual(pending.count, 1, "the edit should have folded into the pending create, not added a second operation")
XCTAssertEqual(pending.first?.kind, "createDocument")
stub.documentInfoHandler = { _ in throw StubTransportError() }
let reopened = try await sut.documentInfo(id: created.id)
XCTAssertEqual(reopened.title, "Real Title")
XCTAssertEqual(reopened.text, "Real body")
}
func testFlushingAPendingCreateReconcilesThePlaceholderIdToTheRealOne() async throws {
let stub = StubOutlineAPIClient()
stub.createDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let created = try await sut.createDocument(
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
)
let realDocument = makeDocument(id: "real-server-id", title: "New Doc")
stub.createDocumentHandler = { _ in realDocument }
let summary = await sut.flushPendingOperations()
XCTAssertEqual(summary.succeeded, 1)
XCTAssertEqual(summary.failed, 0)
// The real document is now readable under its real id...
stub.documentInfoHandler = { _ in throw StubTransportError() }
let byRealId = try await sut.documentInfo(id: "real-server-id")
XCTAssertEqual(byRealId.id, "real-server-id")
// ...and the placeholder is gone rather than left as a dead orphan.
do {
_ = try await sut.documentInfo(id: created.id)
XCTFail("Expected the placeholder cache entry to have been removed")
} catch {
// expected nothing left under the old id
}
}
}
@@ -993,6 +993,178 @@ final class LiveOutlineAPIClientTests: XCTestCase {
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.list")
}
func testUpdateUserAvatarSendsExplicitNullWhenRemoving() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "user-1",
"name": "Jane Doe",
"avatarUrl": null
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let user = try await client.updateUserAvatar(UpdateUserAvatarRequest(id: "user-1", avatarUrl: nil))
XCTAssertNil(user.avatarUrl)
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.update")
let sentBody = try XCTUnwrap(httpClient.lastRequest?.httpBody)
let sentJSON = try XCTUnwrap(String(data: sentBody, encoding: .utf8))
// The whole point of UpdateUserAvatarRequest's custom encode(to:)
// Swift's synthesized Encodable would have omitted the key entirely
// for a nil Optional instead of sending a literal null.
XCTAssertTrue(sentJSON.contains("\"avatarUrl\":null"), "expected an explicit null, got: \(sentJSON)")
}
func testUpdateUserAvatarSendsTheNewURLWhenSet() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "user-1",
"name": "Jane Doe",
"avatarUrl": "/api/attachments.redirect?id=abc"
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let user = try await client.updateUserAvatar(
UpdateUserAvatarRequest(id: "user-1", avatarUrl: "/api/attachments.redirect?id=abc")
)
XCTAssertEqual(user.avatarUrl, "/api/attachments.redirect?id=abc")
let sentBody = try XCTUnwrap(httpClient.lastRequest?.httpBody)
let sentJSON = try XCTUnwrap(String(data: sentBody, encoding: .utf8))
XCTAssertTrue(sentJSON.contains("\"id\":\"user-1\""))
}
func testUpdateUserNameSendsRequestAndDecodesResult() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "user-1",
"name": "New Name"
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let user = try await client.updateUserName(UpdateUserNameRequest(id: "user-1", name: "New Name"))
XCTAssertEqual(user.name, "New Name")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.update")
}
func testDeleteAttachmentSendsRequest() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "success": true }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
try await client.deleteAttachment(id: "attach-1")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/attachments.delete")
}
func testCreateAttachmentDecodesUploadTargetAndFormFields() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"attachment": {
"id": "attach-1",
"documentId": null,
"contentType": "image/jpeg",
"name": "avatar.jpg",
"url": "/api/attachments.redirect?id=attach-1",
"size": "59304"
},
"uploadUrl": "/api/files.create",
"form": {
"Content-Type": "image/jpeg",
"key": "uploads/user-1/attach-1/avatar.jpg",
"acl": "public-read"
}
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let result = try await client.createAttachment(
CreateAttachmentRequest(name: "avatar.jpg", contentType: "image/jpeg", size: 59304)
)
XCTAssertEqual(result.attachment.id, "attach-1")
XCTAssertEqual(result.attachment.size, "59304")
XCTAssertEqual(result.uploadUrl, "/api/files.create")
XCTAssertEqual(result.form["acl"], "public-read")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/attachments.create")
}
func testUploadAttachmentFilePostsToTheResolvedRelativeURL() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "success": true }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let uploadTarget = CreateAttachmentResult(
attachment: OutlineAttachment(
id: "attach-1",
documentId: nil,
contentType: "image/jpeg",
name: "avatar.jpg",
url: "/api/attachments.redirect?id=attach-1",
size: "3"
),
uploadUrl: "/api/files.create",
form: ["Content-Type": "image/jpeg", "key": "uploads/attach-1"]
)
try await client.uploadAttachmentFile(uploadTarget, fileData: Data("abc".utf8))
// Resolved against baseURL's host, not appended onto "/api/<baseURL-relative-path>".
XCTAssertEqual(httpClient.lastRequest?.url?.absoluteString, "https://outline.example.com/api/files.create")
XCTAssertNil(httpClient.lastRequest?.value(forHTTPHeaderField: "Authorization"))
let contentType = httpClient.lastRequest?.value(forHTTPHeaderField: "Content-Type")
XCTAssertTrue(contentType?.hasPrefix("multipart/form-data; boundary=") ?? false)
}
func testMissingTokenThrowsTokenUnavailable() async throws {
let httpClient = MockHTTPClient()
let client = LiveOutlineAPIClient(
@@ -0,0 +1,48 @@
import XCTest
@testable import OutlineKit
final class MultipartFormDataBuilderTests: XCTestCase {
func testBuildIncludesEveryFieldAndTheFile() throws {
let fileData = Data("fake-jpeg-bytes".utf8)
let (body, contentType) = MultipartFormDataBuilder.build(
fields: ["key": "uploads/abc", "acl": "public-read", "Content-Type": "image/jpeg"],
fileFieldName: "file",
fileName: "avatar.jpg",
fileData: fileData,
fileContentType: "image/jpeg",
boundary: "TestBoundary"
)
let bodyString = String(decoding: body, as: UTF8.self)
XCTAssertEqual(contentType, "multipart/form-data; boundary=TestBoundary")
XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"key\""))
XCTAssertTrue(bodyString.contains("uploads/abc"))
XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"acl\""))
XCTAssertTrue(bodyString.contains("public-read"))
XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"file\"; filename=\"avatar.jpg\""))
XCTAssertTrue(bodyString.contains("fake-jpeg-bytes"))
XCTAssertTrue(bodyString.hasPrefix("--TestBoundary\r\n"))
XCTAssertTrue(bodyString.hasSuffix("--TestBoundary--\r\n"))
}
func testFileFieldComesAfterAllFormFields() throws {
let (body, _) = MultipartFormDataBuilder.build(
fields: ["a": "1", "b": "2"],
fileFieldName: "file",
fileName: "x.jpg",
fileData: Data("bytes".utf8),
fileContentType: "image/jpeg",
boundary: "B"
)
let bodyString = String(decoding: body, as: UTF8.self)
let fieldsRange = bodyString.range(of: "name=\"a\"")
let fileRange = bodyString.range(of: "name=\"file\"")
XCTAssertNotNil(fieldsRange)
XCTAssertNotNil(fileRange)
if let fieldsRange, let fileRange {
XCTAssertTrue(fieldsRange.lowerBound < fileRange.lowerBound)
}
}
}
+2 -2
View File
@@ -408,7 +408,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 27.0;
MARKETING_VERSION = 0.0.2;
MARKETING_VERSION = 0.0.3;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -453,7 +453,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 27.0;
MARKETING_VERSION = 0.0.2;
MARKETING_VERSION = 0.0.3;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
+15 -5
View File
@@ -2,11 +2,15 @@
import AppKit
import SwiftUI
struct AboutView: View {
/// Bare content (icon, name, version, links) with no window chrome reused
/// by both the standalone "About Outpost" window (`AboutView`, the standard
/// macOS app-menu affordance) and the Settings page's own About section, so
/// the two can't drift out of sync.
struct AboutInfoView: View {
private let repositoryURL = URL(string: "https://git.psmattas.com/psmattas/Outpost")!
private let releasesURL = URL(string: "https://git.psmattas.com/psmattas/Outpost/releases")!
private var appName: String {
var appName: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "Outpost"
}
@@ -15,7 +19,7 @@ struct AboutView: View {
/// expected to stay a plain dotted-numeric string, not `0.0.1-ALPHA`.
private let releaseStage = "ALPHA"
private var versionString: String {
var versionString: String {
let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.1"
let buildNumber = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
let stageSuffix = releaseStage.isEmpty ? "" : "-\(releaseStage)"
@@ -66,8 +70,6 @@ struct AboutView: View {
.font(.caption2)
.foregroundStyle(.tertiary)
}
.padding(32)
.frame(width: 320)
}
// No Sparkle-style in-app updater yet this just opens the releases page
@@ -76,4 +78,12 @@ struct AboutView: View {
NSWorkspace.shared.open(releasesURL)
}
}
struct AboutView: View {
var body: some View {
AboutInfoView()
.padding(32)
.frame(width: 320)
}
}
#endif
+13 -2
View File
@@ -1,5 +1,6 @@
#if os(macOS)
import SwiftUI
import OutlineKit
/// Uses a plain `Button` + `.popover` rather than `Menu`. A `Menu` whose label
/// contains the avatar image reliably broke its own sizing on click (even after
@@ -7,10 +8,11 @@ import SwiftUI
/// label rendering, which a `Button` doesn't go through.
struct AccountFooter: View {
@Environment(SessionStore.self) private var session
@Environment(AppNavigation.self) private var navigation
@Environment(\.openURL) private var openURL
@Environment(\.openWindow) private var openWindow
@Environment(\.openSettings) private var openSettings
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
@State private var isMenuPresented = false
@State private var isShowingLogoutConfirmation = false
@State private var isShowingProfile = false
@@ -93,8 +95,17 @@ struct AccountFooter: View {
.padding(.horizontal, 6)
.padding(.vertical, 4)
Toggle("Offline Mode", isOn: $isOfflineModeEnabled)
.padding(.horizontal, 6)
.padding(.vertical, 4)
menuItem("Profile…") { isShowingProfile = true }
menuItem("Preferences…") { openSettings() }
// Deferred a tick: setting this synchronously in the same call
// that dismisses this popover collides two AppKit window/layer
// transactions in the same runloop turn (visible in the console
// as "Invalid attempt to open a new transaction during CA
// commit") letting the popover's dismissal finish first avoids it.
menuItem("Settings…") { Task { @MainActor in navigation.isShowingSettings = true } }
Divider()
@@ -0,0 +1,114 @@
#if os(macOS)
import AppKit
import SwiftUI
/// Crop/rotate/zoom editor shown after picking a photo, before it's
/// uploaded pan (drag), zoom (pinch or the slider), and 90°-increment
/// rotate, all inside a circular mask matching how the avatar actually
/// renders everywhere else in the app.
///
/// The on-screen preview and the final exported image are built from the
/// exact same view composition (`avatarContent`), just instantiated once
/// for display and once inside an `ImageRenderer` that's deliberate:
/// hand-deriving a separate set of crop-math for a higher-resolution
/// render would risk it silently disagreeing with what the user actually
/// saw and confirmed, and there's no way to visually verify that
/// agreement without running the app. Reusing the identical view tree
/// makes the export WYSIWYG by construction instead of by careful math.
struct AvatarCropperView: View {
let sourceImage: NSImage
let onConfirm: (Data) -> Void
let onCancel: () -> Void
@State private var scale: CGFloat = 1
@State private var offset: CGSize = .zero
@State private var rotationDegrees: Double = 0
@GestureState private var dragTranslation: CGSize = .zero
/// Used for both the live preview and the exported image see the
/// type-level doc comment for why that's the same size, not two.
private let diameter: CGFloat = 320
var body: some View {
VStack(spacing: 20) {
Text("Edit Photo")
.font(.headline)
ZStack {
avatarContent
.clipShape(Circle())
Circle()
.strokeBorder(Color.primary.opacity(0.15), lineWidth: 1)
}
.frame(width: diameter, height: diameter)
.contentShape(Circle())
.gesture(
DragGesture()
.updating($dragTranslation) { value, state, _ in state = value.translation }
.onEnded { value in
offset.width += value.translation.width
offset.height += value.translation.height
}
)
HStack(spacing: 16) {
Button {
withAnimation(.easeInOut(duration: 0.2)) { rotationDegrees -= 90 }
} label: {
Image(systemName: "rotate.left")
}
.help("Rotate left")
Slider(value: $scale, in: 1...4)
.frame(width: 140)
Button {
withAnimation(.easeInOut(duration: 0.2)) { rotationDegrees += 90 }
} label: {
Image(systemName: "rotate.right")
}
.help("Rotate right")
}
HStack {
Button("Cancel", role: .cancel, action: onCancel)
Spacer()
Button("Use Photo") {
if let data = renderFinalImage() {
onConfirm(data)
}
}
.buttonStyle(.borderedProminent)
}
}
.padding(24)
.frame(width: 360)
}
/// Aspect-fills `sourceImage` into a `diameter`×`diameter` square, then
/// applies the user's pan/zoom/rotation on top identical between the
/// live preview and the final render (see the type-level doc comment).
private var avatarContent: some View {
Image(nsImage: sourceImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: diameter, height: diameter)
.scaleEffect(scale)
.rotationEffect(.degrees(rotationDegrees))
.offset(x: offset.width + dragTranslation.width, y: offset.height + dragTranslation.height)
.frame(width: diameter, height: diameter)
.clipped()
}
@MainActor
private func renderFinalImage() -> Data? {
let content = avatarContent
.clipShape(Circle())
.frame(width: diameter, height: diameter)
let renderer = ImageRenderer(content: content)
renderer.scale = 2 // @2x so it isn't a blurry 320px avatar on Retina displays
guard let nsImage = renderer.nsImage else { return nil }
return nsImage.jpegData(compressionQuality: 0.9)
}
}
#endif
@@ -1,40 +0,0 @@
#if os(macOS)
import SwiftUI
struct PreferencesView: View {
@Environment(SessionStore.self) private var session
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
@State private var isShowingLogoutConfirmation = false
var body: some View {
Form {
Section("Appearance") {
Picker("Appearance", selection: $appearance) {
ForEach(AppAppearance.allCases) { option in
Text(option.label).tag(option)
}
}
.pickerStyle(.segmented)
.labelsHidden()
}
Section("Account") {
LabeledContent("Signed in as", value: session.userName ?? "")
if let email = session.userEmail {
LabeledContent("Email", value: email)
}
if let teamName = session.teamName {
LabeledContent("Workspace", value: teamName)
}
Button("Log Out…", role: .destructive) {
isShowingLogoutConfirmation = true
}
}
}
.formStyle(.grouped)
.frame(width: 380, height: 300)
.logoutConfirmationDialog(isPresented: $isShowingLogoutConfirmation, session: session)
}
}
#endif
@@ -0,0 +1,57 @@
#if os(macOS)
import SwiftUI
/// Swapped into the real sidebar's content slot (search field, collections
/// tree, account footer) while Settings is open same sidebar, different
/// content, rather than a separate mini sidebar nested inside a page. "Done"
/// clears `AppNavigation.isShowingSettings`, which puts the collections tree
/// back.
///
/// Grouped by `SettingsCategory` `general` (ours) sits under an "Outpost"
/// header at the top, then Outline's own Account/Workspace/Integrations &
/// Installation groups, matching the settings page structure of the
/// Outline web app.
struct SettingsSidebarList: View {
@Binding var selection: SettingsSection?
let onDone: () -> Void
var body: some View {
VStack(spacing: 0) {
HStack {
Text("Settings")
.font(.headline)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
Divider()
List(selection: $selection) {
ForEach(SettingsCategory.allCases) { category in
let sections = SettingsSection.allCases.filter { $0.category == category }
Section {
ForEach(sections) { section in
Label(section.title, systemImage: section.icon)
.tag(section)
}
} header: {
if let title = category.title {
Text(title)
}
}
}
}
.listStyle(.sidebar)
Divider()
Button("Done", action: onDone)
.keyboardShortcut(.cancelAction)
.buttonStyle(.borderedProminent)
.frame(maxWidth: .infinity)
.padding(12)
}
}
}
#endif
+631
View File
@@ -0,0 +1,631 @@
#if os(macOS)
import AppKit
import SwiftUI
import UniformTypeIdentifiers
import OutlineKit
/// Settings *detail* content for one section the section list itself now
/// lives in `ContentView_macOS`'s real sidebar (swapped in over the
/// collections tree while `AppNavigation.isShowingSettings` is set, not a
/// separate mini sidebar of its own), so this view only ever renders
/// whichever section is currently selected.
struct SettingsView: View {
let section: SettingsSection
@Environment(SessionStore.self) private var session
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
@AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false
@AppStorage("outpost.advancedOptionsEnabled") private var isAdvancedOptionsEnabled = false
@State private var isShowingLogoutConfirmation = false
@State private var isShowingAdvancedWarning = false
@State private var storageSummary: CacheStorageSummary?
@State private var pendingOperations: [PendingOperationSummary] = []
@State private var isSyncing = false
@State private var isClearingCache = false
@State private var didClearCache = false
@State private var lastFullSyncSummary: FullSyncSummary?
@State private var lastFlushSummary: SyncFlushSummary?
@State private var pickedPhoto: PickedPhoto?
@State private var isUploadingAvatar = false
@State private var avatarErrorMessage: String?
@State private var editableName = ""
@State private var isSavingName = false
@State private var nameErrorMessage: String?
/// Full Local Sync and cache-clearing both need a real connection to be
/// safe clearing while offline (or letting Full Local Sync think it
/// should be running) can leave the app with nothing local to show and
/// no way to refetch it. "Offline" here means either a real dropped
/// connection or the user's own manual toggle both leave the app with
/// no server to talk to.
private var isEffectivelyOnline: Bool {
session.networkMonitor.isOnline && !isOfflineModeEnabled
}
var body: some View {
// GeometryReader + `minHeight` (not `maxHeight`) is the actual fix for
// "center short content inside a ScrollView" a ScrollView proposes
// effectively unbounded height to its content, so `maxHeight: .infinity`
// alone just resolves to the content's own intrinsic size and does
// nothing; forcing a `minHeight` equal to the real viewport height is
// what gives `aboutDetail`'s own centered alignment somewhere to
// actually center within. Harmless for the other (already
// top/leading-aligned) sections short ones just get blank space
// below, same as before.
GeometryReader { geometry in
ScrollView {
sectionDetail
.padding(28)
.frame(maxWidth: .infinity, minHeight: geometry.size.height, alignment: .topLeading)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.background)
.task { await refreshSyncState() }
.logoutConfirmationDialog(isPresented: $isShowingLogoutConfirmation, session: session)
}
@ViewBuilder
private var sectionDetail: some View {
switch section {
case .appearance: appearanceDetail
case .profile: profileDetail
case .offlineSync: offlineSyncDetail
case .advanced: advancedDetail
case .about: aboutDetail
default: comingSoonDetail
}
}
/// Everything in Account/Workspace/Integrations & Installation that
/// isn't `.profile` real content lands section by section; this is
/// just the nav skeleton until then.
private var comingSoonDetail: some View {
VStack(spacing: 16) {
sectionHeader
ContentUnavailableView {
Label("Coming Soon", systemImage: section.icon)
} description: {
Text("\(section.title) settings aren't built yet.")
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
}
private var sectionHeader: some View {
Text(section.title)
.font(.title.bold())
}
// MARK: - Appearance
private var appearanceDetail: some View {
VStack(alignment: .leading, spacing: 16) {
sectionHeader
Picker("Appearance", selection: $appearance) {
ForEach(AppAppearance.allCases) { option in
Text(option.label).tag(option)
}
}
.pickerStyle(.segmented)
.labelsHidden()
.frame(maxWidth: 320)
}
}
// MARK: - Profile
private var profileDetail: some View {
VStack(alignment: .leading, spacing: 24) {
sectionHeader
avatarRow
Divider().frame(maxWidth: 420)
nameRow
Divider().frame(maxWidth: 420)
emailRow
if let teamName = session.teamName {
Divider().frame(maxWidth: 420)
labeledRow("Workspace", teamName)
.frame(maxWidth: 420)
}
Button("Log Out…", role: .destructive) {
isShowingLogoutConfirmation = true
}
}
.sheet(item: $pickedPhoto) { picked in
AvatarCropperView(
sourceImage: picked.image,
onConfirm: { data in
pickedPhoto = nil
Task { await uploadAvatar(data: data) }
},
onCancel: { pickedPhoto = nil }
)
}
// The session only holds whatever was fetched at sign-in/launch
// a name (or avatar) changed elsewhere (the Outline web app, say)
// never reaches it on its own. Refetching every time this page
// opens, rather than only once per app launch, is what makes that
// show up without having to quit and relaunch.
.task {
editableName = session.userName ?? ""
await refreshProfile()
}
.onChange(of: session.userName) { _, newValue in
editableName = newValue ?? ""
}
}
private var avatarRow: some View {
HStack(spacing: 16) {
AvatarBadge(avatarURL: session.userAvatarURL, size: 64, placeholderSystemImage: "person.crop.circle.fill")
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 10) {
Button(isUploadingAvatar ? "Uploading…" : "Upload Photo…") { pickPhoto() }
.disabled(isUploadingAvatar)
if session.userAvatarURL != nil {
Button("Remove", role: .destructive) { Task { await removeAvatar() } }
.buttonStyle(.plain)
.foregroundStyle(.red)
.disabled(isUploadingAvatar)
}
if isUploadingAvatar {
ProgressView().controlSize(.small)
}
}
if let avatarErrorMessage {
Text(avatarErrorMessage)
.font(.caption)
.foregroundStyle(.red)
}
}
}
}
private var nameRow: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Name")
.font(.caption)
.foregroundStyle(.secondary)
HStack(spacing: 10) {
TextField("Name", text: $editableName)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 280)
.onSubmit { Task { await saveName() } }
if editableName != (session.userName ?? "") && !editableName.trimmingCharacters(in: .whitespaces).isEmpty {
if isSavingName {
ProgressView().controlSize(.small)
} else {
Button("Save") { Task { await saveName() } }
.controlSize(.small)
}
}
}
if let nameErrorMessage {
Text(nameErrorMessage)
.font(.caption)
.foregroundStyle(.red)
}
}
.frame(maxWidth: 420, alignment: .leading)
}
private var emailRow: some View {
VStack(alignment: .leading, spacing: 6) {
labeledRow("Email", session.userEmail ?? "")
HStack(spacing: 4) {
Text("Email is tied to sign-in, so it can't be changed here — use")
if let serverURL = session.serverURL {
Link("Outline on the web", destination: serverURL)
} else {
Text("Outline on the web")
}
Text("instead.")
}
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: 420, alignment: .leading)
}
// MARK: - Offline & Sync
private var offlineSyncDetail: some View {
VStack(alignment: .leading, spacing: 20) {
sectionHeader
VStack(alignment: .leading, spacing: 6) {
Toggle("Offline Mode", isOn: $isOfflineModeEnabled)
Text("Skip the network entirely and work from what's already been cached. Turn this off to reconnect.")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: 480, alignment: .leading)
VStack(alignment: .leading, spacing: 6) {
Toggle("Full Local Sync", isOn: $isFullLocalSyncEnabled)
.disabled(!isEffectivelyOnline)
Text("Keep a complete local copy of every collection and document, not just what's been opened — the whole workspace stays browsable offline. Runs automatically in the background once on; no need to trigger it by hand.")
.font(.caption)
.foregroundStyle(.secondary)
if !isEffectivelyOnline {
Text("Requires an internet connection to turn on or off.")
.font(.caption2)
.foregroundStyle(.orange)
}
}
.frame(maxWidth: 480, alignment: .leading)
.help(isEffectivelyOnline ? "" : "Full Local Sync needs a real connection — it can't safely turn on (or off) while offline.")
if isFullLocalSyncEnabled {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
if isSyncing {
ProgressView().controlSize(.small)
Text("Syncing…")
.font(.caption)
.foregroundStyle(.secondary)
} else {
Button("Sync Now") { Task { await runFullSync() } }
.controlSize(.small)
.disabled(!isEffectivelyOnline)
if let lastFullSyncSummary {
Text(fullSyncSummaryText(lastFullSyncSummary))
.font(.caption)
.foregroundStyle(lastFullSyncSummary.errors.isEmpty ? Color.secondary : Color.red)
}
}
}
if let lastFullSyncSummary, !isSyncing {
ForEach(Array(lastFullSyncSummary.errors.enumerated()), id: \.offset) { _, message in
Text(message)
.font(.caption2)
.foregroundStyle(.red)
}
}
}
}
Divider()
.frame(maxWidth: 480)
pendingOperationsRow
.frame(maxWidth: 480, alignment: .leading)
}
}
private var pendingOperationsRow: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Pending Sync")
.font(.subheadline.weight(.medium))
Spacer()
if isSyncing {
ProgressView().controlSize(.small)
} else {
Button("Retry") { Task { await retrySync() } }
.buttonStyle(.plain)
.font(.caption)
.foregroundStyle(Color.accentColor)
.disabled(pendingOperations.isEmpty)
}
}
if pendingOperations.isEmpty {
Text("Everything's synced.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 6) {
ForEach(pendingOperations) { operation in
pendingOperationRow(operation)
}
}
}
}
}
private func pendingOperationRow(_ operation: PendingOperationSummary) -> some View {
HStack(alignment: .top, spacing: 6) {
Image(systemName: operation.lastError == nil ? "clock" : "exclamationmark.triangle.fill")
.foregroundStyle(operation.lastError == nil ? Color.secondary : Color.orange)
.font(.caption)
VStack(alignment: .leading, spacing: 2) {
Text(operationLabel(operation.kind))
.font(.caption)
if let lastError = operation.lastError {
Text(lastError)
.font(.caption2)
.foregroundStyle(.red)
}
}
}
}
private func operationLabel(_ kind: String) -> String {
switch kind {
case "updateDocument": return "Document edit"
case "updateCollection": return "Collection rename"
case "createPin": return "Pin"
case "deletePin": return "Unpin"
case "createSubscription": return "Subscribe"
case "deleteSubscription": return "Unsubscribe"
case "starDocument", "starCollection": return "Star"
case "deleteStar": return "Unstar"
default: return kind
}
}
// MARK: - Advanced
/// Everything here either does something destructive (clearing the
/// cache Full Local Sync just spent minutes building) or doesn't exist
/// yet gating all of it behind an off-by-default master toggle plus a
/// confirmation to turn that toggle on is the safeguard: nothing here
/// can be reached by accident, and nothing outside this section can
/// touch the cache at all, so there's no path to "messed up Full Local
/// Sync" that doesn't go through this screen on purpose.
private var advancedDetail: some View {
VStack(alignment: .leading, spacing: 20) {
sectionHeader
VStack(alignment: .leading, spacing: 6) {
Toggle("Enable Advanced Options", isOn: advancedOptionsBinding)
Text("Off by default on purpose. Turning this on unlocks things that can cause unintended behavior, including permanently losing your local cache.")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: 480, alignment: .leading)
Divider()
.frame(maxWidth: 480)
VStack(alignment: .leading, spacing: 12) {
comingSoonRow("Export All Data")
comingSoonRow("Developer Diagnostics")
comingSoonRow("Reset Local Database")
}
.frame(maxWidth: 480, alignment: .leading)
Divider()
.frame(maxWidth: 480)
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 10) {
Text("Clear All Cache")
Spacer()
if didClearCache {
Label("Cleared", systemImage: "checkmark")
.font(.caption)
.foregroundStyle(.secondary)
}
Button(role: .destructive) {
Task { await clearCache() }
} label: {
if isClearingCache {
ProgressView().controlSize(.small)
} else {
Text("Clear")
}
}
.buttonStyle(.borderedProminent)
.tint(.red)
}
if let storageSummary {
Text("\(storageSummary.itemCount) items — \(formattedBytes(storageSummary.totalBytes))")
.font(.caption)
.foregroundStyle(.secondary)
}
Text("Deletes every cached collection and document, including anything Full Local Sync built, and anything still waiting to sync. Doesn't touch the server. This is the only place that can — it works even while offline, which is exactly why it's behind this toggle.")
.font(.caption2)
.foregroundStyle(.secondary)
}
.disabled(!isAdvancedOptionsEnabled || isClearingCache)
.opacity(isAdvancedOptionsEnabled ? 1 : 0.4)
.frame(maxWidth: 480, alignment: .leading)
}
.confirmationDialog(
"Enable Advanced Options?",
isPresented: $isShowingAdvancedWarning,
titleVisibility: .visible
) {
Button("Enable", role: .destructive) { isAdvancedOptionsEnabled = true }
Button("Cancel", role: .cancel) {}
} message: {
Text("These settings can cause unintended behavior, including permanently losing your local cache. Only continue if you know what you're doing.")
}
}
/// Never writes `true` directly turning the toggle on only opens the
/// warning dialog; only that dialog's own "Enable" button actually sets
/// it. Turning off doesn't need confirmation.
private var advancedOptionsBinding: Binding<Bool> {
Binding(
get: { isAdvancedOptionsEnabled },
set: { newValue in
if newValue {
isShowingAdvancedWarning = true
} else {
isAdvancedOptionsEnabled = false
}
}
)
}
private func comingSoonRow(_ title: String) -> some View {
HStack {
Text(title)
Spacer()
Text("Coming Soon")
.font(.caption)
.foregroundStyle(.secondary)
}
.disabled(true)
.opacity(0.5)
}
// MARK: - About
private var aboutDetail: some View {
VStack(spacing: 16) {
sectionHeader
AboutInfoView()
.frame(maxWidth: 420)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
}
// MARK: - Helpers
private func labeledRow(_ label: String, _ value: String) -> some View {
HStack {
Text(label)
.foregroundStyle(.secondary)
Spacer()
Text(value)
}
.font(.callout)
}
private func formattedBytes(_ bytes: Int) -> String {
ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file)
}
private func fullSyncSummaryText(_ summary: FullSyncSummary) -> String {
if summary.errors.isEmpty {
return "Synced \(summary.documentsCount) documents across \(summary.collectionsCount) collections."
}
return "Synced with \(summary.errors.count) error\(summary.errors.count == 1 ? "" : "s")."
}
private func refreshSyncState() async {
guard let cachingClient = session.cachingClient else { return }
storageSummary = await cachingClient.cacheStorageSummary()
pendingOperations = await cachingClient.pendingOperations()
}
private func runFullSync() async {
guard let cachingClient = session.cachingClient, !isSyncing else { return }
isSyncing = true
defer { isSyncing = false }
lastFullSyncSummary = await cachingClient.performFullSync()
await refreshSyncState()
}
private func retrySync() async {
guard let cachingClient = session.cachingClient, !isSyncing else { return }
isSyncing = true
defer { isSyncing = false }
lastFlushSummary = await cachingClient.flushPendingOperations()
await refreshSyncState()
}
private func clearCache() async {
guard let cachingClient = session.cachingClient else { return }
isClearingCache = true
defer { isClearingCache = false }
await cachingClient.clearCache()
await refreshSyncState()
didClearCache = true
Task {
try? await Task.sleep(for: .seconds(2))
didClearCache = false
}
}
// MARK: - Profile actions
private func refreshProfile() async {
guard let apiClient = session.apiClient else { return }
guard let fresh = try? await apiClient.currentUser() else { return }
session.applyUpdatedProfile(fresh)
}
private func pickPhoto() {
let panel = NSOpenPanel()
panel.allowedContentTypes = [.png, .jpeg, .heic, .image]
panel.allowsMultipleSelection = false
panel.canChooseDirectories = false
panel.canChooseFiles = true
guard panel.runModal() == .OK, let url = panel.url, let image = NSImage(contentsOf: url) else { return }
pickedPhoto = PickedPhoto(image: image)
}
private func uploadAvatar(data: Data) async {
guard let apiClient = session.apiClient, let userId = session.userId else { return }
isUploadingAvatar = true
defer { isUploadingAvatar = false }
let previousAttachmentId = attachmentId(from: session.userAvatarURL)
do {
let target = try await apiClient.createAttachment(
CreateAttachmentRequest(name: "avatar.jpg", contentType: "image/jpeg", size: data.count)
)
try await apiClient.uploadAttachmentFile(target, fileData: data)
let updated = try await apiClient.updateUserAvatar(
UpdateUserAvatarRequest(id: userId, avatarUrl: target.attachment.url)
)
session.applyUpdatedProfile(updated)
avatarErrorMessage = nil
// Best-effort the new avatar is already live either way, this
// just stops the old upload from sitting around unreferenced.
if let previousAttachmentId {
try? await apiClient.deleteAttachment(id: previousAttachmentId)
}
} catch {
avatarErrorMessage = outlineErrorMessage(error, fallback: "Couldn't upload this photo.")
}
}
private func removeAvatar() async {
guard let apiClient = session.apiClient, let userId = session.userId else { return }
isUploadingAvatar = true
defer { isUploadingAvatar = false }
let previousAttachmentId = attachmentId(from: session.userAvatarURL)
do {
let updated = try await apiClient.updateUserAvatar(UpdateUserAvatarRequest(id: userId, avatarUrl: nil))
session.applyUpdatedProfile(updated)
avatarErrorMessage = nil
if let previousAttachmentId {
try? await apiClient.deleteAttachment(id: previousAttachmentId)
}
} catch {
avatarErrorMessage = outlineErrorMessage(error, fallback: "Couldn't remove this photo.")
}
}
/// The avatar URL is `/api/attachments.redirect?id=<attachment-id>`
/// pulling the id back out of it is how the previous attachment gets
/// found for cleanup, since nothing else keeps a record of it.
private func attachmentId(from avatarURL: URL?) -> String? {
guard let avatarURL, let components = URLComponents(url: avatarURL, resolvingAgainstBaseURL: false) else {
return nil
}
return components.queryItems?.first(where: { $0.name == "id" })?.value
}
private func saveName() async {
let trimmed = editableName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, trimmed != (session.userName ?? ""), let apiClient = session.apiClient, let userId = session.userId else { return }
isSavingName = true
defer { isSavingName = false }
do {
let updated = try await apiClient.updateUserName(UpdateUserNameRequest(id: userId, name: trimmed))
session.applyUpdatedProfile(updated)
editableName = updated.name
nameErrorMessage = nil
} catch {
nameErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update your name.")
}
}
}
/// Wraps a picked `NSImage` so it can drive `.sheet(item:)` `NSImage`
/// itself isn't `Identifiable`.
private struct PickedPhoto: Identifiable {
let id = UUID()
let image: NSImage
}
#endif
@@ -10,7 +10,7 @@ struct CollectionRowView: View {
} icon: {
if let emoji = collection.emojiIcon {
Text(emoji)
} else if let symbolName = collection.icon.flatMap(OutlineIconMapping.sfSymbolName) {
} else if let symbolName = collection.icon.flatMap({ OutlineIconMapping.sfSymbolName(for: $0) }) {
Image(systemName: symbolName)
.foregroundStyle(tintColor)
} else {
@@ -4,6 +4,8 @@ import OutlineKit
struct ContentView_macOS: View {
@Environment(SessionStore.self) private var session
@Environment(AppNavigation.self) private var navigation
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
/// The landing state no collection selected yet is what Home actually
/// means, so this starts `true` rather than auto-selecting the first
/// collection the way this used to work.
@@ -26,11 +28,73 @@ struct ContentView_macOS: View {
globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
}
/// `@Environment(AppNavigation.self)` doesn't hand out `$`-bindings on its
/// own (that's `@Bindable`'s job, and introducing one in `body` would
/// mean restructuring it away from a single implicit-return expression)
/// a manually-built `Binding` over the same reference is simpler here.
private var selectedSettingsSectionBinding: Binding<SettingsSection?> {
Binding(
get: { navigation.selectedSettingsSection },
set: { navigation.selectedSettingsSection = $0 }
)
}
var body: some View {
// A totally separate top-level branch, not content swapped inside
// one persistent `NavigationSplitView` that was tried first (an
// `if/else` inside a single split view, then an explicit `.id()` on
// just the detail pane) and neither reliably replaced the detail
// pane's content on macOS: `NavigationSplitView` bridges to
// `NSSplitViewController`, and swapping a `NavigationStack` with
// real push history for a plain view inside one persisting instance
// doesn't propagate the way plain SwiftUI identity rules would
// suggest. A different `if` branch is a genuinely different view
// hierarchy, so there's no existing split view instance for AppKit
// to get confused about reusing.
if navigation.isShowingSettings {
settingsContent
} else {
mainContent
}
}
private var settingsContent: some View {
NavigationSplitView {
SettingsSidebarList(
selection: selectedSettingsSectionBinding,
onDone: { navigation.isShowingSettings = false }
)
.navigationSplitViewColumnWidth(min: 220, ideal: 260)
} detail: {
SettingsView(section: navigation.selectedSettingsSection ?? .appearance)
}
.navigationTitle("")
.toolbar {
ToolbarItem(placement: .navigation) {
HStack(spacing: 6) {
Image(systemName: "gearshape.fill")
Text("Settings")
}
.font(.headline)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(.fill.tertiary, in: Capsule())
}
}
}
private var mainContent: some View {
NavigationSplitView {
VStack(spacing: 0) {
SidebarSearchField(text: $globalSearchQuery)
Divider()
if isOfflineModeEnabled {
OfflineBanner(isManual: true)
Divider()
} else if !session.networkMonitor.isOnline {
OfflineConnectionPromptBanner(onEnableOfflineMode: { isOfflineModeEnabled = true })
Divider()
}
sidebar
AccountFooter()
}
@@ -92,6 +156,7 @@ struct ContentView_macOS: View {
isContextualSearchExpanded = false
selectedCollection = nil
isShowingHome = true
navigation.isShowingSettings = false
replaceDocumentPath(with: [])
}
@@ -12,6 +12,7 @@ import OutlineKit
struct DocumentReaderView: View {
@Environment(SessionStore.self) private var session
@Environment(StarStore.self) private var starStore
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
@State private var viewModel: DocumentReaderViewModel
let apiClient: OutlineAPIClient
@@ -56,6 +57,15 @@ struct DocumentReaderView: View {
self.onDocumentCreated = onDocumentCreated
}
/// New Document, editing, pin/star/subscribe, and Full Width all queue
/// and sync later (see `CachingOutlineAPIClient`) everything else here
/// (sharing, permissions, move/archive/delete/duplicate/templatize,
/// history, insights, export) hits the server directly with no offline
/// path, so it's disabled rather than left to fail confusingly on tap.
private var isEffectivelyOnline: Bool {
session.networkMonitor.isOnline && !isOfflineModeEnabled
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
@@ -82,6 +92,7 @@ struct DocumentReaderView: View {
NativeTextViewWrapper(
text: $viewModel.text,
configuration: .init(heightBehavior: .fitsContent),
documentId: viewModel.documentId,
isEditable: viewModel.isEditing
)
@@ -110,7 +121,8 @@ struct DocumentReaderView: View {
} label: {
Image(systemName: "square.and.arrow.up")
}
.help("Share")
.help(isEffectivelyOnline ? "Share" : "Sharing needs an internet connection")
.disabled(!isEffectivelyOnline)
.popover(isPresented: $isShowingShareSheet, arrowEdge: .bottom) {
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId)
}
@@ -235,7 +247,8 @@ struct DocumentReaderView: View {
viewModel.isPinned,
viewModel.isInsightsEnabled ?? false,
viewModel.isFullWidth,
viewModel.isEditing
viewModel.isEditing,
isEffectivelyOnline
].map(String.init).joined(separator: "-")
}
@@ -305,27 +318,33 @@ struct DocumentReaderView: View {
Button("Permissions…") {
isShowingShareSheet = true
}
.disabled(!isEffectivelyOnline)
Divider()
Button("Templatize") {
Task { await templatize() }
}
.disabled(!isEffectivelyOnline)
Button("Duplicate") {
Task { await duplicate() }
}
.disabled(!isEffectivelyOnline)
Button("Unpublish") {
isShowingUnpublishConfirmation = true
}
.disabled(!isEffectivelyOnline)
Button("Archive…") {
isShowingArchiveConfirmation = true
}
.disabled(!isEffectivelyOnline)
Divider()
Button("Move") {
isShowingMoveSheet = true
}
.disabled(!isEffectivelyOnline)
// Multipart file upload is its own subsystem deferred rather than
// half-built here.
Button("Import Document…") {}
@@ -342,9 +361,13 @@ struct DocumentReaderView: View {
Button("History") {
isShowingHistorySheet = true
}
.disabled(!isEffectivelyOnline)
Button("Insights") {
isShowingInsightsSheet = true
}
.disabled(!isEffectivelyOnline)
// Present/Search in Document both read `documents.info`, which is
// read-through cached they work offline on whatever's cached.
Button("Present") {
isShowingPresentSheet = true
}
@@ -354,6 +377,7 @@ struct DocumentReaderView: View {
Button("Download") {
Task { await download() }
}
.disabled(!isEffectivelyOnline)
Button("Copy") {
Task { await copyMarkdown() }
}
@@ -370,6 +394,7 @@ struct DocumentReaderView: View {
get: { viewModel.isInsightsEnabled ?? false },
set: { _ in Task { await toggleInsights() } }
))
.disabled(!isEffectivelyOnline)
// Confirmed against a live server: there's no per-document embeds
// field. Only a workspace-level setting exists, and that's not
// reachable via the API either (no `team.update` endpoint in the
@@ -386,6 +411,7 @@ struct DocumentReaderView: View {
Button("Delete…", role: .destructive) {
isShowingDeleteConfirmation = true
}
.disabled(!isEffectivelyOnline)
}
private func star() async {
@@ -0,0 +1,27 @@
#if os(macOS)
import SwiftUI
/// Shown at the top of the sidebar whenever the app is offline either for
/// real (`NetworkMonitor` reports no connection) or because the user turned
/// on the manual "Offline Mode" toggle. Cached collections/documents keep
/// browsing working either way, but the user should know they might be
/// looking at stale data.
struct OfflineBanner: View {
/// Whether this is the user's own "Offline Mode" toggle rather than a
/// real dropped connection same banner slot, different explanation.
var isManual: Bool = false
var body: some View {
HStack(spacing: 8) {
Image(systemName: "wifi.slash")
.foregroundStyle(.orange)
Text(isManual ? "Offline Mode — showing cached content" : "Offline — showing cached content")
.font(.callout)
Spacer(minLength: 0)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.orange.opacity(0.12))
}
}
#endif
@@ -0,0 +1,30 @@
#if os(macOS)
import SwiftUI
/// Shown in the sidebar when the network is actually down and the user
/// hasn't turned on Offline Mode themselves yet. Deliberately doesn't change
/// any fetch behavior on its own reads still try live and fall back to
/// cache the normal way (see `CachingOutlineAPIClient`) until the user
/// actually taps the button here, same as `RemoteChangesBanner` never
/// auto-refreshes on their behalf either.
struct OfflineConnectionPromptBanner: View {
let onEnableOfflineMode: () -> Void
var body: some View {
HStack(spacing: 8) {
Image(systemName: "wifi.slash")
.foregroundStyle(.orange)
Text("No connection")
.font(.callout)
Spacer(minLength: 8)
Button("Turn On Offline Mode", action: onEnableOfflineMode)
.buttonStyle(.borderedProminent)
.tint(.orange)
.controlSize(.small)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.orange.opacity(0.12))
}
}
#endif
+15 -7
View File
@@ -57,25 +57,33 @@ final class HomeViewModel {
/// Fetches fresh pinned docs and the current tab's documents to compare
/// against what's displayed, without replacing either. Bails silently
/// on a fetch failure rather than treating it as "changed" a
/// transient network hiccup shouldn't pop the refresh banner.
/// transient network hiccup (or, offline, `listPins` failing outright
/// it isn't one of the cached endpoints) shouldn't pop the refresh
/// banner. This needs the *throwing* pinned-fetch specifically: the
/// plain `fetchPinned()` used elsewhere collapses any failure to `[]`,
/// which used to read here as "pins changed" against whatever was
/// already displayed and falsely popped the banner on every offline
/// poll.
func checkForRemoteChanges(tab: HomeTab) async {
async let freshPinned = fetchPinned()
async let freshPinnedTask = fetchPinnedThrowing()
guard let freshTab = try? await fetch(tab: tab) else { return }
let pinned = await freshPinned
guard let pinned = try? await freshPinnedTask else { return }
if Self.fingerprint(pinned) != Self.fingerprint(pinnedDocuments)
|| Self.fingerprint(freshTab) != Self.fingerprint(documents(for: tab)) {
hasRemoteChanges = true
}
}
private func fetchPinned() async -> [OutlineDocument] {
(try? await fetchPinnedThrowing()) ?? []
}
/// `pins.list` only returns pin records, not the documents themselves
/// fetches each pinned document individually. Pins are a small curated
/// set (unlike a full collection tree), so the N+1 here is acceptable
/// where it wouldn't be in the sidebar.
private func fetchPinned() async -> [OutlineDocument] {
guard let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: nil)) else {
return []
}
private func fetchPinnedThrowing() async throws -> [OutlineDocument] {
let pins = try await apiClient.listPins(ListPinsRequest(collectionId: nil))
var documents: [OutlineDocument] = []
for pin in pins {
if let document = try? await apiClient.documentInfo(id: pin.documentId) {
+12 -5
View File
@@ -14,6 +14,7 @@ import AppKit
@main
struct OutpostApp: App {
@State private var session = SessionStore()
@State private var navigation = AppNavigation()
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
#if os(macOS)
@@ -25,6 +26,7 @@ struct OutpostApp: App {
WindowGroup {
RootView()
.environment(session)
.environment(navigation)
#if os(iOS)
.preferredColorScheme(appearance.colorScheme)
#endif
@@ -41,6 +43,16 @@ struct OutpostApp: App {
openWindow(id: "about")
}
}
// No `Settings {}` scene anymore Settings renders inside the
// root window (see `AppNavigation`), not a separate popup, so
// , has to be wired up by hand instead of coming for free.
CommandGroup(replacing: .appSettings) {
Button("Settings…") {
navigation.isShowingSettings = true
}
.keyboardShortcut(",")
.disabled(!session.isSignedIn)
}
CommandGroup(after: .appSettings) {
Divider()
Button("Log Out…") {
@@ -63,11 +75,6 @@ struct OutpostApp: App {
.disablesFullScreen()
}
.windowResizability(.contentSize)
Settings {
PreferencesView()
.environment(session)
}
#endif
}
+144
View File
@@ -0,0 +1,144 @@
import Observation
/// Top-level groupings shown as section headers in `SettingsSidebarList`.
/// `general` holds everything that's ours, not Outline's own settings
/// categories labeled "Outpost" so it reads as clearly distinct from the
/// Outline-sourced groups below it.
enum SettingsCategory: String, CaseIterable, Identifiable {
case general
case account
case workspace
case integrationsInstallation
var id: String { rawValue }
var title: String? {
switch self {
case .general: return "Outpost"
case .account: return "Account"
case .workspace: return "Workspace"
case .integrationsInstallation: return "Integrations & Installation"
}
}
}
/// One entry in the Settings sidebar. Mirrors Outline's own settings
/// categories (Account/Workspace/Integrations & Installation) so this app's
/// settings read as a native counterpart to the web app's, plus a `general`
/// group for things that are ours and don't map onto Outline's structure
/// (offline/sync, advanced, about, appearance).
///
/// Most of the Account/Workspace/Integrations cases are navigation-only for
/// now `SettingsView` renders a "Coming Soon" placeholder for anything not
/// explicitly built yet. Content lands section by section.
enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
// General (ours)
case appearance, offlineSync, advanced, about
// Account
case profile, preferences, notifications, passkeys, apiAccess
// Workspace
case details, authentication, security, ai, members, groups, templates, emojis, applications, shared, links, webhooks, importData, exportData
// Integrations & Installation
case installation
var id: String { rawValue }
var category: SettingsCategory {
switch self {
case .appearance, .offlineSync, .advanced, .about:
return .general
case .profile, .preferences, .notifications, .passkeys, .apiAccess:
return .account
case .details, .authentication, .security, .ai, .members, .groups, .templates, .emojis, .applications, .shared, .links, .webhooks, .importData, .exportData:
return .workspace
case .installation:
return .integrationsInstallation
}
}
var title: String {
switch self {
case .appearance: return "Appearance"
case .offlineSync: return "Offline & Sync"
case .advanced: return "Advanced"
case .about: return "About"
case .profile: return "Profile"
case .preferences: return "Preferences"
case .notifications: return "Notifications"
case .passkeys: return "Passkeys"
case .apiAccess: return "API & Access"
case .details: return "Details"
case .authentication: return "Authentication"
case .security: return "Security"
case .ai: return "AI"
case .members: return "Members"
case .groups: return "Groups"
case .templates: return "Templates"
case .emojis: return "Emojis"
case .applications: return "Applications"
case .shared: return "Shared"
case .links: return "Links"
case .webhooks: return "Webhooks"
case .importData: return "Import"
case .exportData: return "Export"
case .installation: return "Installation"
}
}
var icon: String {
switch self {
case .appearance: return "paintbrush"
case .offlineSync: return "arrow.triangle.2.circlepath"
case .advanced: return "wrench.and.screwdriver"
case .about: return "info.circle"
case .profile: return "person.crop.circle"
case .preferences: return "gearshape"
case .notifications: return "bell"
case .passkeys: return "key"
case .apiAccess: return "chevron.left.forwardslash.chevron.right"
case .details: return "building.2"
case .authentication: return "lock"
case .security: return "shield"
case .ai: return "sparkles"
case .members: return "person.2"
case .groups: return "person.3"
case .templates: return "doc.on.doc"
case .emojis: return "face.smiling"
case .applications: return "app.badge"
case .shared: return "square.and.arrow.up.on.square"
case .links: return "link"
case .webhooks: return "bolt.horizontal"
case .importData: return "square.and.arrow.down"
case .exportData: return "square.and.arrow.up"
case .installation: return "shippingbox"
}
}
/// Everything actually built so far everything else in Account/
/// Workspace/Integrations & Installation renders a "Coming Soon"
/// placeholder until its content is specified and built.
var isImplemented: Bool {
switch self {
case .appearance, .offlineSync, .advanced, .about, .profile:
return true
default:
return false
}
}
}
/// Cross-cutting UI state that doesn't belong to any one screen. Lives at
/// `OutpostApp` (so both `RootView`'s content and its `,` command can reach
/// it) and is read wherever something needs to open Settings (the profile
/// menu) or render it as a swap of the *existing* sidebar/detail panes in
/// `ContentView_macOS`, not a separate popup window or an overlay that hides
/// the sidebar.
@Observable
@MainActor
final class AppNavigation {
var isShowingSettings = false
var selectedSettingsSection: SettingsSection? = .appearance
}
+55
View File
@@ -5,6 +5,19 @@ struct RootView: View {
@Environment(SessionStore.self) private var session
@State private var welcomeName: String?
@State private var starStore = StarStore()
@AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
/// Real connectivity is only half of "can talk to the server" the
/// manual Offline Mode toggle is the other half. Syncing needs to
/// resume on either one clearing, not just a real reconnect: turning
/// the toggle off while the network had been up the whole time never
/// changes `networkMonitor.isOnline`, so keying the flush task off that
/// alone left pending operations stuck until the next real network
/// blip.
private var isEffectivelyOnline: Bool {
session.networkMonitor.isOnline && !isOfflineModeEnabled
}
var body: some View {
ZStack {
@@ -32,6 +45,48 @@ struct RootView: View {
starStore.reset()
}
}
// Replay whatever queued up while offline the moment either signal
// clears a real reconnect, or the user turning Offline Mode back
// off no need to wait for the user to open Settings and hit Retry.
// Also catches Full Local Sync back up immediately, rather than
// leaving it to wait out the rest of the periodic loop below.
//
// The flush itself gets a retry loop with a cooloff, not just one
// attempt: a single transient failure (one bad operation, a blip
// mid-flush) used to leave everything else stuck until the user
// manually hit Retry or connectivity changed again. Keeps retrying
// every 5 minutes for as long as *anything* is still pending and
// this signal stays on stops on its own once the queue is empty,
// and `.task(id:)` cancels/restarts it automatically if
// isEffectivelyOnline flips again in the meantime.
.task(id: isEffectivelyOnline) {
guard isEffectivelyOnline, let cachingClient = session.cachingClient else { return }
if isFullLocalSyncEnabled {
_ = await cachingClient.performFullSync()
}
while !Task.isCancelled {
_ = await cachingClient.flushPendingOperations()
let remaining = await cachingClient.pendingOperations()
guard !remaining.isEmpty else { break }
try? await Task.sleep(for: .seconds(300))
}
}
// Fully automatic this is the only place Full Local Sync actually
// runs from (Settings' "Sync Now" is just an on-demand nudge at the
// same call). Syncs immediately whenever this task (re)starts, which
// covers both "just switched on" and "was already on at launch"
// `.task(id:)` restarts on either, an `@AppStorage`-backed toggle
// changing anywhere updates every view reading that key then every
// 20 minutes after, for as long as it stays enabled.
.task(id: isFullLocalSyncEnabled) {
guard isFullLocalSyncEnabled else { return }
while !Task.isCancelled {
if isEffectivelyOnline, let cachingClient = session.cachingClient {
_ = await cachingClient.performFullSync()
}
try? await Task.sleep(for: .seconds(1200))
}
}
}
private func startWelcomeTransition(_ result: AuthViewModel.AuthResult) {
+40 -7
View File
@@ -11,12 +11,23 @@ final class SessionStore {
private let defaults: UserDefaults
var isSignedIn: Bool
private(set) var userId: String?
var userName: String?
var userEmail: String?
var userAvatarURL: URL?
var teamName: String?
var teamAvatarURL: URL?
private(set) var apiClient: OutlineAPIClient?
/// Same object as `apiClient` when the offline cache is available kept
/// as a separately-typed reference so Settings can reach cache/sync-queue
/// specific methods (`flushPendingOperations`, `performFullSync`, etc.)
/// without downcasting the protocol-typed `apiClient` everywhere.
private(set) var cachingClient: CachingOutlineAPIClient?
let networkMonitor = NetworkMonitor()
/// `nil` only if the on-disk SwiftData store failed to open (e.g. disk
/// full) in that case `apiClient` falls back to talking to the server
/// directly with no offline cache, rather than failing sign-in outright.
private let cacheStore: OfflineCacheStore?
var serverURL: URL? {
defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:))
@@ -26,35 +37,46 @@ final class SessionStore {
self.tokenStore = tokenStore
self.defaults = defaults
self.isSignedIn = (try? tokenStore.token()) != nil
self.cacheStore = (try? OfflineCacheStore.makeContainer()).map(OfflineCacheStore.init(modelContainer:))
if isSignedIn, let serverURL {
apiClient = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: serverURL),
tokenStore: tokenStore
)
(apiClient, cachingClient) = Self.makeAPIClient(serverURL: serverURL, tokenStore: tokenStore, cache: cacheStore)
}
}
func signIn(serverURL: URL, user: OutlineUser, team: OutlineTeam) {
defaults.set(serverURL.absoluteString, forKey: Self.serverURLDefaultsKey)
apiClient = LiveOutlineAPIClient(
(apiClient, cachingClient) = Self.makeAPIClient(serverURL: serverURL, tokenStore: tokenStore, cache: cacheStore)
apply(user: user, team: team, serverURL: serverURL)
isSignedIn = true
}
private static func makeAPIClient(
serverURL: URL,
tokenStore: TokenStoring,
cache: OfflineCacheStore?
) -> (OutlineAPIClient, CachingOutlineAPIClient?) {
let live = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: serverURL),
tokenStore: tokenStore
)
apply(user: user, team: team, serverURL: serverURL)
isSignedIn = true
guard let cache else { return (live, nil) }
let caching = CachingOutlineAPIClient(live: live, cache: cache)
return (caching, caching)
}
func signOut() {
try? tokenStore.clear()
defaults.removeObject(forKey: Self.serverURLDefaultsKey)
isSignedIn = false
userId = nil
userName = nil
userEmail = nil
userAvatarURL = nil
teamName = nil
teamAvatarURL = nil
apiClient = nil
cachingClient = nil
}
/// Re-fetches user/workspace name/logo on relaunch, when the token survived but this
@@ -65,7 +87,18 @@ final class SessionStore {
apply(user: auth.user, team: auth.team, serverURL: serverURL)
}
/// Settings calls this after a successful name/avatar change so the
/// sidebar's account footer and everywhere else reading these reflect
/// it immediately, without waiting for the next `auth.info` refresh.
func applyUpdatedProfile(_ user: OutlineUser) {
guard let serverURL else { return }
userId = user.id
userName = user.name
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
}
private func apply(user: OutlineUser, team: OutlineTeam, serverURL: URL) {
userId = user.id
userName = user.name
userEmail = user.email
// Outline can return either an absolute URL or a server-relative path
+38 -2
View File
@@ -1,4 +1,5 @@
import SwiftUI
import OutlineKit
#if os(macOS)
import AppKit
@@ -41,11 +42,46 @@ struct AvatarBadge: View {
.task(id: avatarURL) {
loadedImage = nil
guard let avatarURL else { return }
guard let (data, _) = try? await URLSession.shared.data(from: avatarURL) else { return }
loadedImage = PlatformImage(data: data)
loadedImage = await Self.loadImage(from: avatarURL)
}
}
/// The real fix, confirmed against a live network capture: every other
/// request this app makes attaches `Authorization: Bearer <token>`
/// this one never did, sending a bare unauthenticated GET. Outline's
/// browser session authenticates `attachments.redirect` via cookies
/// instead, which a native app doesn't have; the API-token equivalent
/// is the same Bearer header every RPC call already uses. Almost
/// certainly means no avatar image (not just a freshly-uploaded one)
/// has ever actually loaded in this app a 401 and a "no avatar set"
/// look identical here, both just fall back to the placeholder icon
/// with nothing on screen to flag it as an error.
///
/// The retry loop is a secondary, independent hardening cheap
/// insurance against a self-hosted reverse-proxied storage backend not
/// being instantly consistent right after an upload kept alongside
/// the auth fix rather than instead of it.
private static func loadImage(from url: URL) async -> PlatformImage? {
var request = URLRequest(url: url)
request.cachePolicy = .reloadIgnoringLocalCacheData
if let token = try? KeychainTokenStore().token() {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
for attempt in 0..<3 {
if attempt > 0 {
try? await Task.sleep(for: .milliseconds(400))
}
if let (data, response) = try? await URLSession.shared.data(for: request),
let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode),
let image = PlatformImage(data: data) {
return image
}
}
return nil
}
private func platformImage(_ image: PlatformImage) -> Image {
#if os(macOS)
Image(nsImage: image)
+14
View File
@@ -0,0 +1,14 @@
#if os(macOS)
import AppKit
extension NSImage {
/// `NSImage` has no built-in JPEG encoder (unlike `UIImage`) routes
/// through a bitmap representation to get one.
func jpegData(compressionQuality: CGFloat) -> Data? {
guard let tiffData = tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiffData) else {
return nil
}
return bitmap.representation(using: .jpeg, properties: [.compressionFactor: compressionQuality])
}
}
#endif
+36
View File
@@ -0,0 +1,36 @@
import Foundation
import Network
import Observation
/// Backs the sidebar's offline badge purely a UI signal. Unrelated to
/// `CachingOutlineAPIClient`'s own fallback logic, which reacts to actual
/// request failures rather than pre-checking reachability.
@Observable
@MainActor
final class NetworkMonitor {
private(set) var isOnline = true
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "com.outpost.network-monitor")
init() {
// Weak on the outer closure (it's held by `monitor` for the object's
// whole lifetime, so a strong capture here would be a retain cycle),
// then unwrapped once into a plain strong local the inner `Task`
// closure capturing that local `self` is what a nested closure needs
// to be consistent about capture semantics with its enclosing one.
monitor.pathUpdateHandler = { [weak self] path in
guard let self else { return }
let online = path.status == .satisfied
Task { @MainActor in
self.isOnline = online
}
}
monitor.start(queue: queue)
}
deinit {
monitor.cancel()
}
}