Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
0efc97e
Harden local and iCloud file access
Bbrizly Aug 31, 2026
84026a1
Retry transient Files provider access without blocking UI
Bbrizly Aug 31, 2026
75c087e
Explain provider-specific widget failures
Bbrizly Aug 31, 2026
8c2126b
Cover local files and iCloud materialization semantics
Bbrizly Aug 31, 2026
6339172
Document local, iCloud and Files provider storage behavior
Bbrizly Aug 31, 2026
efd7178
Define local and iCloud storage contract
Bbrizly Aug 31, 2026
5cffe20
Make local and iCloud source choice explicit
Bbrizly Aug 31, 2026
b2f331b
Persist staged file selections across provider materialization
Bbrizly Aug 31, 2026
b38a002
Stage cloud-backed file selections until canonical read succeeds
Bbrizly Aug 31, 2026
779aafa
Preserve current pending source when replacement validation fails
Bbrizly Aug 31, 2026
d70cf23
Avoid retrying terminal File Provider errors
Bbrizly Aug 31, 2026
e50f754
Make cloud-backed source switching transactional
Bbrizly Aug 31, 2026
936804c
Show a dedicated provider-materialization state
Bbrizly Aug 31, 2026
653d930
Explain cloud materialization without implying reconnect
Bbrizly Aug 31, 2026
e1b382e
Preserve recoverable provider grants without false retries
Bbrizly Aug 31, 2026
02dff03
Stage provider grants that can recover without repicking
Bbrizly Aug 31, 2026
1bf7a82
Preserve staged provider selections through user recovery
Bbrizly Aug 31, 2026
3a779e7
Document staged cloud source promotion and rollback
Bbrizly Aug 31, 2026
091c30e
Define staged source switching in the iOS contract
Bbrizly Aug 31, 2026
fa445d7
Lock provider retry and grant-preservation semantics
Bbrizly Aug 31, 2026
6ffb124
Polish quick capture persistence
Bbrizly Aug 31, 2026
42ebd63
Refine task row state interactions
Bbrizly Aug 31, 2026
e2615a3
Add focused small widget
Bbrizly Aug 31, 2026
f31f3cf
Clarify focus completion semantics
Bbrizly Aug 31, 2026
0060a94
Align completed task affordances
Bbrizly Aug 31, 2026
590f5d0
Tune tactile press response
Bbrizly Aug 31, 2026
0d8c0fb
Make cold-launch capture instant
Bbrizly Aug 31, 2026
b37efd5
Cover next widget ordering
Bbrizly Aug 31, 2026
0491261
Fix capture token lifecycle
Bbrizly Aug 31, 2026
8751313
Define shared Focus Live Activity state
Bbrizly Aug 31, 2026
0b83664
Keep Live Activity state minimal
Bbrizly Aug 31, 2026
ecccf09
Keep Focus alive across iOS
Bbrizly Aug 31, 2026
15123ec
Sync Focus with Live Activity
Bbrizly Aug 31, 2026
ce8520a
Add Focus Live Activity UI
Bbrizly Aug 31, 2026
f19dc10
Bundle Focus Live Activity
Bbrizly Aug 31, 2026
9a6b584
Enable Focus Live Activities
Bbrizly Aug 31, 2026
e9b56fe
Cover Focus Live Activity state
Bbrizly Aug 31, 2026
b147929
Start Live Activity from every Focus entry point
Bbrizly Aug 31, 2026
0953878
Keep Live Activity in sync with Focus state
Bbrizly Aug 31, 2026
a9b6a3a
Harden mobile mutation and status semantics
Bbrizly Aug 31, 2026
9c73c8d
Route importer failures through typed status
Bbrizly Aug 31, 2026
e00db02
Polish home recovery and filtered states
Bbrizly Aug 31, 2026
3935656
Add safe native task editing
Bbrizly Aug 31, 2026
c23e27e
Order Focus completion persistence before reconciliation
Bbrizly Aug 31, 2026
b8e82b7
Keep recovery status calm and deterministic
Bbrizly Aug 31, 2026
c44b6d4
Close edited task surfaces deterministically
Bbrizly Aug 31, 2026
ba152c3
Make quick capture targets accessibility sized
Bbrizly Aug 31, 2026
63a8fc3
Simplify Focus strip and enlarge controls
Bbrizly Aug 31, 2026
e458bbc
Cover atomic mobile task edit semantics
Bbrizly Aug 31, 2026
42b40a0
Declare App Group UserDefaults privacy reason
Bbrizly Aug 31, 2026
2dba0aa
Declare widget App Group privacy reason
Bbrizly Aug 31, 2026
f106ab6
Strengthen iOS release metadata gate
Bbrizly Aug 31, 2026
f7ece3a
Make primary Drawer typography scale with Dynamic Type
Bbrizly Aug 31, 2026
fdb5881
Scale task detail title with Dynamic Type
Bbrizly Aug 31, 2026
9a2e47a
docs: lock iOS release acceptance and App Review path
Bbrizly Aug 31, 2026
7353e9a
fix: invalidate stale focus notification scheduling
Bbrizly Aug 31, 2026
25f3079
fix: clear orphan focus surfaces on launch
Bbrizly Aug 31, 2026
eec8d69
fix: focus alerts
Bbrizly Aug 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 47 additions & 5 deletions .github/workflows/ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ name: iOS
on:
push:
branches:
- main
- ios-companion
- ios-storage-hardening
paths:
- "iOS/**"
- "Sources/DrawerCore/**"
Expand Down Expand Up @@ -66,17 +68,57 @@ jobs:

python3 - <<'PY'
import plistlib
expected = ["group.com.bbrizly.drawer"]
paths = [

expected_group = ["group.com.bbrizly.drawer"]
entitlement_paths = [
"iOS/DrawerMobile/Resources/DrawerMobile.entitlements",
"iOS/DrawerWidgets/Resources/DrawerWidgets.entitlements",
]
for path in paths:
for path in entitlement_paths:
with open(path, "rb") as handle:
payload = plistlib.load(handle)
actual = payload.get("com.apple.security.application-groups")
if actual != expected:
raise SystemExit(f"{path}: expected App Group {expected}, found {actual}")
if actual != expected_group:
raise SystemExit(f"{path}: expected App Group {expected_group}, found {actual}")

privacy_paths = [
"iOS/DrawerMobile/Resources/PrivacyInfo.xcprivacy",
"iOS/DrawerWidgets/Resources/PrivacyInfo.xcprivacy",
]
for path in privacy_paths:
with open(path, "rb") as handle:
payload = plistlib.load(handle)
if payload.get("NSPrivacyTracking") is not False:
raise SystemExit(f"{path}: Drawer must declare tracking disabled")
if payload.get("NSPrivacyCollectedDataTypes") != []:
raise SystemExit(f"{path}: unexpected collected-data declaration")
api_types = payload.get("NSPrivacyAccessedAPITypes", [])
user_defaults = next(
(entry for entry in api_types
if entry.get("NSPrivacyAccessedAPIType") == "NSPrivacyAccessedAPICategoryUserDefaults"),
None,
)
reasons = set((user_defaults or {}).get("NSPrivacyAccessedAPITypeReasons", []))
if "1C8F.1" not in reasons:
raise SystemExit(f"{path}: App Group UserDefaults use must declare reason 1C8F.1")

with open("iOS/DrawerMobile/Resources/Info.plist", "rb") as handle:
app_info = plistlib.load(handle)
if app_info.get("NSSupportsLiveActivities") is not True:
raise SystemExit("DrawerMobile Info.plist must enable Live Activities")
schemes = {
scheme
for entry in app_info.get("CFBundleURLTypes", [])
for scheme in entry.get("CFBundleURLSchemes", [])
}
if "drawer" not in schemes:
raise SystemExit("DrawerMobile Info.plist must register the drawer URL scheme")

with open("iOS/DrawerWidgets/Resources/Info.plist", "rb") as handle:
widget_info = plistlib.load(handle)
extension_point = widget_info.get("NSExtension", {}).get("NSExtensionPointIdentifier")
if extension_point != "com.apple.widgetkit-extension":
raise SystemExit(f"Unexpected widget extension point: {extension_point}")
PY

ICON="iOS/DrawerMobile/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png"
Expand Down
38 changes: 32 additions & 6 deletions Docs/IOS.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,39 +117,65 @@ Drawer document access adapter
WidgetKit timeline
```

### Storage and sync contract

Drawer does not own a cloud service and does not assume that Obsidian owns the file path. The user selects one canonical `Drawer.md` through Files and that grant may resolve to:

- an On My iPhone / local Files item
- an Obsidian local vault
- an Obsidian Sync vault's local copy, when that file is exposed through Files
- `iCloud Drive/Obsidian/<Vault>/Drawer.md`
- another third-party Files provider

The access adapter classifies only what iOS can prove. An item that reports `isUbiquitousItem == true` receives iCloud-specific freshness handling; every other document-picker source stays on the generic Files path rather than relying on private path heuristics.

For iCloud, canonical reads and writes are permitted only when the local item is current. Apple's `downloaded` state means a local copy exists but is stale, while `notDownloaded` means no local copy exists. In either state Drawer requests `startDownloadingUbiquitousItem` and waits rather than reading stale bytes or writing over a newer cloud revision. An unresolved iCloud document conflict also blocks canonical mutation until the user resolves it in Files/Obsidian.

A document-picker grant and a usable canonical source are deliberately different states. If a newly chosen source is not safely readable yet, Drawer persists it as a **staged bookmark** in the App Group but does not replace the primary bookmark. The staged source survives process death and is promoted only after a current coordinated read succeeds and the bytes are valid UTF-8 Markdown. If a previous `Drawer.md` exists, that previous source remains the app/widget mutation target throughout staging. A terminal failure discards only the staged replacement and leaves the previous source intact.

Transient iCloud/provider states are retried with suspended `Task` delays only while Drawer is foregrounded, with the interval capped at five seconds; no main-thread sleep is used. Authentication and iCloud-conflict states preserve the staged or active grant but do not busy-poll: Drawer retries when the scene becomes active again after the user fixes the provider state. Permission loss, missing files, invalid content, quota/collision, and unrelated provider failures are not mislabeled as temporary connectivity problems.

For generic third-party Files providers, `NSFileCoordinator` remains the authority. There is no universal client API equivalent to iCloud's materialization API for every provider, so unavailable/authentication states preserve the selected grant and widget cache while all mutation paths fail closed.

### Shared core

Keep `TodoParser`, `TodoWriteback`, `TodoItem`, planning, timer models, and other deterministic behavior in `DrawerCore`.

Add an iOS-compatible document boundary rather than teaching core logic about UIKit or WidgetKit. The mobile adapter owns:

- selected file bookmark
- primary + staged selected-file bookmarks
- transactional promotion / rollback of source changes
- coordinated reads/writes (`NSFileCoordinator`)
- foreground file presentation / external-change notifications (`NSFilePresenter`)
- stale/invalid bookmark recovery
- iCloud freshness/materialization and unresolved-conflict refusal
- transient File Provider recovery without destroying the saved source
- content-CAS retry before every canonical write, preserving Drawer’s existing no-clobber invariant

A replacement file bookmark is committed only after the selected file can actually be read as UTF-8 Markdown, so a bad Change Drawer.md selection cannot discard the last known-good connection.
A replacement file bookmark becomes canonical only after the selected file can actually be read as current UTF-8 Markdown, so a bad, evicted, signed-out, or conflicted Change Drawer.md selection cannot discard the last known-good connection. Staged grants that can recover without another picker visit are retained across relaunch.

Apple’s iOS file model returns externally selected URLs through the document picker, and persistent bookmarks are platform-specific. Relaunch/reboot/iCloud/File Provider behavior remains a physical-device integration gate rather than something inferred from macOS bookmark semantics.

### Mobile application model

`DrawerMobileModel` is MainActor-owned. It:

- resolves the selected document
- resolves the selected canonical document and any staged replacement independently
- reloads + parses via `DrawerCore`
- exposes Today / Carried / Upcoming / Backlog
- performs mutation transforms only against the primary canonical document
- performs mutation transforms against the freshest coordinated bytes
- applies the same fresh-byte recheck to automatic recurrence reconciliation / completed-task normalization before those paths write canonical Markdown
- publishes a widget snapshot only after a successful canonical read/write
- reports auxiliary widget-cache failure separately without treating the canonical save as failed
- retains the last-known-good task UI during transient provider materialization/offline states and retries without blocking the main actor
- resumes a staged replacement after relaunch and promotes it only after validation
- maintains a one-action undo payload for destructive/move actions and clears that payload on source-file changes
- persists/restores absolute Focus state across scene suspension and process relaunch

### Widget snapshot

The App Group stores a tiny, versioned last-known-good snapshot. Widget timeline generation renders safely from that snapshot and opportunistically refreshes it from the selected canonical `Drawer.md` when the extension can resolve the security-scoped bookmark. If the File Provider is unavailableor the external file is temporarily not valid UTF-8the widget preserves the last known-good snapshot instead of inventing an empty state.
The App Group stores a tiny, versioned last-known-good snapshot. Widget timeline generation renders safely from that snapshot and opportunistically refreshes it from the **primary** canonical `Drawer.md` when the extension can resolve the security-scoped bookmark. A staged replacement is intentionally invisible to WidgetKit until promotion. If the File Provider is unavailable, iCloud is still materializing the primary file, an iCloud conflict exists, or the external file is temporarily not valid UTF-8, the widget preserves the last known-good snapshot instead of inventing an empty or stale task state.

```swift
struct WidgetSnapshot: Codable {
Expand All @@ -164,9 +190,9 @@ struct WidgetSnapshot: Codable {
}
```

Interactive intents use the same canonical mutation path. On success they rebuild the snapshot and ask WidgetKit to reload. On failure they leave the snapshot untouched, record a short-lived recovery state, and the widget explicitly says the update failed / opens Drawer for recovery. Mutable widget content is marked invalidatable while WidgetKit reloads. This is critical: no UI-only completion state.
Interactive intents use the same canonical mutation path. On success they rebuild the snapshot and ask WidgetKit to reload. On failure they leave the snapshot untouched, record a short-lived provider-specific recovery state, and the widget explicitly explains whether the file is syncing, the provider is unavailable, or reconnection is required. Mutable widget content is marked invalidatable while WidgetKit reloads. This is critical: no UI-only completion state.

Disconnect removes the shared snapshot and immediately reloads WidgetKit so old task text is not intentionally left on the Home or Lock Screen after the source is disconnected.
Disconnect removes both primary/staged bookmarks plus the shared snapshot and immediately reloads WidgetKit so old task text is not intentionally left on the Home or Lock Screen after the source is disconnected.

External-file bookmark access from an app-extension process remains provider/OS-sensitive. If the extension cannot safely regain access to `Drawer.md`, the interaction fails closed; Drawer never marks the cached task complete without a canonical write.

Expand Down
12 changes: 11 additions & 1 deletion iOS/DrawerMobile/App/DrawerMobileApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,17 @@ struct DrawerMobileApp: App {
var body: some Scene {
WindowGroup {
DrawerRootView(model: model)
.task { model.bootstrap() }
.task {
// ActivityKit and pending local notifications can outlive a
// process. If there is no persisted Focus session after the
// model's restore pass, clear any orphan system surface.
// A legitimate restored session remains untouched because
// it has already repopulated DrawerFocusStore.
if DrawerFocusStore.load() == nil {
FocusNotificationScheduler.cancel()
}
model.bootstrap()
}
.onOpenURL { url in
guard url.scheme == "drawer" else { return }
if url.host == "capture" || url.path == "/capture" {
Expand Down
12 changes: 10 additions & 2 deletions iOS/DrawerMobile/App/DrawerRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,23 @@ struct DrawerRootView: View {
case .loading:
ProgressView()
.controlSize(.large)
.accessibilityLabel("Opening Drawer")
case .connected:
DrawerHomeView(
model: model,
changeFile: { showingImporter = true }
)
case .waitingForProvider:
DrawerConnectionView(
needsPermission: false,
waitingForProvider: true,
message: model.statusMessage,
chooseFile: { showingImporter = true }
)
case .disconnected, .needsPermission:
DrawerConnectionView(
needsPermission: model.connectionState == .needsPermission,
waitingForProvider: false,
message: model.statusMessage,
chooseFile: { showingImporter = true }
)
Expand All @@ -48,8 +57,7 @@ struct DrawerRootView: View {
nsError.code == CocoaError.userCancelled.rawValue {
return
}
model.statusMessage = error.localizedDescription
DrawerHaptics.shared.error()
model.reportError(error)
}
}
.onReceive(NotificationCenter.default.publisher(for: .NSCalendarDayChanged)) { _ in
Expand Down
Loading