Skip to content

fix: restore Direct Sharing persistence on Mac Catalyst#377

Merged
PureWeen merged 2 commits intomainfrom
fix/direct-sharing-persistence
Mar 15, 2026
Merged

fix: restore Direct Sharing persistence on Mac Catalyst#377
PureWeen merged 2 commits intomainfrom
fix/direct-sharing-persistence

Conversation

@PureWeen
Copy link
Owner

Problem

PR #341 moved ServerPassword, RemoteToken, and LanToken to SecureStorage (Keychain) on Mac Catalyst via [JsonIgnore] and #if IOS || ANDROID || MACCATALYST guards. However, Mac Catalyst runs without app sandbox (disabled in Entitlements.plist), making Keychain unreliable. The password was silently dropped from settings.json on save, and never reliably recovered from Keychain on load, so StartDirectSharingIfEnabled() would skip due to empty password.

Result: Direct Sharing was always disabled after every restart despite being enabled by the user.

Fix

  1. Changed SecureStorage guards from #if IOS || ANDROID || MACCATALYST#if IOS || ANDROID for property definitions, Save(), and Load() — Mac Catalyst is a desktop platform and should use plain JSON like Windows.

  2. Added one-time reverse migration (RecoverSecretsFromSecureStorage) for MACCATALYST to recover any passwords already migrated to Keychain by PR Improve bridge startup reliability and token validation #341. Only cleans up Keychain entries after verifying JSON was successfully written (addresses code review finding about data loss if Save() fails).

  3. Added 4 regression tests validating that secret fields serialize to JSON on desktop platforms.

Verification

  • ✅ All 2575 tests pass
  • ✅ Built and relaunched via relaunch.sh
  • ✅ MauiDevFlow CDP verified: enabled Direct Sharing → relaunched → Stop Direct Sharing button visible (bridge auto-started)
  • settings.json confirmed: ServerPassword present and DirectSharingEnabled: true persisted across restart

PR #341 moved ServerPassword, RemoteToken, and LanToken to
SecureStorage (Keychain) on Mac Catalyst via [JsonIgnore] and
#if IOS || ANDROID || MACCATALYST guards. However, Mac Catalyst
runs without app sandbox (disabled in Entitlements.plist), making
Keychain unreliable. The password was silently lost on restart,
so StartDirectSharingIfEnabled() would skip due to empty password.

Fix:
- Change SecureStorage guards from IOS || ANDROID || MACCATALYST
  to IOS || ANDROID only — Mac Catalyst is a desktop platform
- Add one-time RecoverSecretsFromSecureStorage() for MACCATALYST
  to recover any passwords already migrated to Keychain by PR 341
- Only clean up Keychain entries after verifying JSON was written
- Add 4 regression tests for secret serialization on desktop

Verified via MauiDevFlow: enabled Direct Sharing, relaunched app,
confirmed bridge auto-started with 'Stop Direct Sharing' visible.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@PureWeen
Copy link
Owner Author

Multi-Model Consensus Review (5-model × 5-agent)

CI Status: ⚠️ No CI configured

Tests: 2,575 passed, 0 failed ✅


🔴 CRITICAL -- 1 issue

1. Data loss from unconditional Keychain wipe on transient read failure (ConnectionSettings.cs:318-322)
verify.Contains("ServerPassword") always passes because System.Text.Json serializes "ServerPassword": null even when the value wasn't recovered. If ReadSecureStorage fails transiently for one secret (returns null) but succeeds for another, needsSave is set, Save() writes null for the failed secret, the guard trivially passes, and all three Keychain entries are wiped -- permanently destroying the secret that couldn't be read. This hits exactly the flaky-Keychain scenario motivating this PR.

Fix: Only SecureStorage.Remove() each key whose value was actually recovered (non-null). Or verify each migrated value individually in the written JSON.


🟡 MODERATE -- 2 issues

2. Verification guard is a no-op (ConnectionSettings.cs:318)
verify.Contains("ServerPassword") matches the JSON property key name, not a secret value. Since STJ serializes all public properties including null ones, this is true whenever the file exists. The comment says "verifying the JSON file was actually written" but it only detects the case where the file doesn't exist at all. Fix: Deserialize the file and check that the specific recovered values are present, or use a checksum.

3. Potential UI thread deadlock (ConnectionSettings.cs:334)
Task.Run(...).GetAwaiter().GetResult() blocks the calling thread. Load() is typically called on the UI thread during startup. On Mac Catalyst, if SecureStorage.GetAsync internally needs to marshal back to the main thread (e.g., for a Keychain authorization prompt), this deadlocks. This affects exactly the users this migration targets on their first launch. Fix: Make the migration async, or ensure Load() is called off the UI thread.


Test Coverage Gap

The #if MACCATALYST migration block (RecoverSecretsFromSecureStorage, ReadSecureStorage) is untestable since tests compile as net10.0. No test covers the Keychain-wipe guard logic or the deadlock scenario.

Recommended Action: ⚠️ Request Changes

The CRITICAL finding is a real data loss path for users with flaky Keychain access -- the exact scenario this PR is designed to fix. Please scope each Remove() to only fire when that specific field was recovered.

…failure

Addresses PR review finding #1 (critical): if ReadSecureStorage fails
transiently for one secret but succeeds for another, the blanket
SecureStorage.Remove() would destroy the unrecovered secret.

Now each Keychain entry is only removed if that specific value was
successfully recovered. Also removes the no-op verify.Contains()
guard (finding #2) since per-key tracking makes it unnecessary.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@PureWeen
Copy link
Owner Author

Multi-Model Consensus Re-Review -- Round 2 (5-model dispatch)

Tests: 2,575 passed, 0 failed ✅

CI: ⚠️ No CI configured


Previous Findings Status

# Finding Status
1 🔴 Unconditional Keychain wipe (verify.Contains always true) FIXED -- per-key recovered* booleans gate each Remove()
2 🟡 Task.Run(...).GetAwaiter().GetResult() deadlock risk ⚠️ STILL PRESENT (pre-existing pattern, Task.Run mitigates SyncContext deadlock)
3 🟡 Verification guard was a no-op FIXED -- removed entirely
4 🟢 #if MACCATALYST untestable N/A -- inherent limitation, accepted

New Findings (consensus 2+ models)

Sev File:Line Description
🟡 ConnectionSettings.cs:316 File.Exists(SettingsPath) is a weak save-success proxy. Save() swallows all exceptions. If Save() fails but a prior settings file exists, File.Exists returns true and Keychain entries are deleted without persisting recovered values. Narrow edge case (requires disk failure + pre-existing file). Fix: have Save() return bool, or re-read file to confirm secrets present.
🟢 ConnectionSettings.cs:327 Outer catch { } silently swallows all failures -- no log or diagnostic trace for migration failures.
🟢 ConnectionSettings.cs:332 Sync-over-async blocks main thread 3× per Load() -- not a deadlock but causes UI jank during one-time migration.

Verdict: ✅ Approve (with tracked follow-ups)

The CRITICAL data-loss bug is properly fixed with a clean per-key recovery+cleanup design. The remaining File.Exists weakness is a narrow edge case. The Task.Run pattern is pre-existing and used identically in iOS/Android. Ship-worthy -- both MODERATEs can be tracked follow-ups.

Review by PR Review Squad (5-model consensus: claude-opus-4.6 ×2, claude-sonnet-4.6, gemini-3-pro-preview, gpt-5.3-codex)

@PureWeen PureWeen merged commit b48dae3 into main Mar 15, 2026
@PureWeen PureWeen deleted the fix/direct-sharing-persistence branch March 15, 2026 22:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant