Skip to content

Keychain storage with iCloud sync, watcher race fixes, hardened runtime#2

Open
sandy787 wants to merge 11 commits into
rtCamp:mainfrom
sandy787:fix/race-conditions-and-enhancements
Open

Keychain storage with iCloud sync, watcher race fixes, hardened runtime#2
sandy787 wants to merge 11 commits into
rtCamp:mainfrom
sandy787:fix/race-conditions-and-enhancements

Conversation

@sandy787
Copy link
Copy Markdown
Member

Summary

  • Keychain storage. Move password storage from UserDefaults to Keychain via SecItemAdd, one entry per password (account = SHA-256 of value). Old UserDefaults and legacy single-blob Keychain entries are migrated once via a versioned flag, then cleared.
  • iCloud Keychain sync. Passwords are written with kSecAttrSynchronizable = true and kSecAttrAccessibleAfterFirstUnlock, so they follow the signed-in user across Macs.
  • Watcher hardening. Dedupe FileWatcher events on a 10-second window per path (recentlyProcessed map behind a serial queue). Filter to regular files with a .pdf extension, skip dotfiles. Mark a PDF processed before the atomic replaceItemAt write so the resulting fileCreated event from the replace is reliably deduped. Balance security-scoped resource start/stop in start/stop/setMonitoredFolder/deinit.
  • Bookmark resilience. Refresh stale security-scoped bookmarks when URL(resolvingBookmarkData:...) reports isStale = true. Show an NSAlert when creating a bookmark for a user-picked folder fails (was a silent return).
  • Auto-open unencrypted PDFs. If a watched PDF is already unencrypted, open it directly instead of falling through the password loop.
  • Menu bar polish. Template lock.doc SF Symbol auto-tints to the actual menu bar background (wallpaper-aware, dark/light-aware). Separate colored subview for the monitoring status dot keeps green/red regardless of menu bar appearance.
  • Settings UX. Persistent monitored-folder selection via security-scoped bookmark, single-window settings via NSStatusItem menu, clean folder display with truncation.
  • Hardened Runtime enabled; version bumped to v1.4.
  • didSet noop guard on isMonitoring to avoid redundant UserDefaults writes on init.
  • Bundle identifier kept as com.rahul286.PDF-Unlocker so existing v1.3 prefs live in the same UserDefaults domain and migrate natively.

Notes

  • Empty test targets removed from the project file.
  • No new third-party dependencies.

sandy787 added 11 commits May 7, 2026 16:47
- Dedupe watcher events with 10s sliding window to stop reopen loop
  triggered by FSEvents sticky ItemCreated on xattr touches
- Move AppKit calls and @published writes to main thread
- Atomic write of unlocked PDF via temp + replaceItem; verify pageCount
- Strict .pdf filter, skip dotfiles and partial downloads
- Try empty password before iterating saved list
- Folder picker with security-scoped bookmark (default Downloads)
- Toggle for opening unencrypted PDFs (default on)
- Save confirmation, fixed window size, truncated path with hover
- Drop print() calls
- Migrate bundle id to com.rtcamp.PDFUnlocker, set rtCamp signing team
- Remove empty test targets
Adds PasswordStore wrapper around SecItem APIs. Passwords stored as a
single kSecClassGenericPassword item under service
com.rtcamp.PDFUnlocker, account passwordList, with
kSecAttrAccessibleWhenUnlocked.

One-shot migration on first load: reads legacy plaintext value from
UserDefaults["passwordList"], writes it to Keychain, then removes the
defaults entry. Save path also trims whitespace before dedup.

Local Keychain only; kSecAttrSynchronizable not set. iCloud Keychain
sync will land in a follow-up once Keychain Sharing entitlement is
added (and the blob will need to split into per-password items to fit
the ~4KB CloudKit cap).
Refactors PasswordStore to one Keychain item per password (account =
SHA256(password) hex) so the per-item value stays well under the
~4 KB CloudKit cap that applies to synced Keychain items.

Adds a "Sync passwords via iCloud Keychain" toggle in Settings
(default off). Toggling switches between two scopes:
  - off: kSecAttrSynchronizable=false, AccessibleWhenUnlocked
  - on:  kSecAttrSynchronizable=true,  AccessibleAfterFirstUnlock
Flipping the toggle migrates items between scopes (read from old,
write to new, delete old).

Also handles legacy migrations on load:
  - prior single-blob Keychain item (account "passwordList") -> split
    into per-password items in the local scope
  - any leftover plaintext UserDefaults["passwordList"] -> imported
    then removed

Adds keychain-access-groups entitlement
($(AppIdentifierPrefix)com.rtcamp.PDFUnlocker) so the synced items
land in the team-prefixed access group iCloud Keychain expects.
iCloud Keychain syncs in the background but the SwiftUI @State only
reads on first init. Adds a small circular-arrow button next to
Save Passwords that calls PasswordStore.load() again, so users can
pull in entries saved on another device without quitting the app.
Hardened Runtime is required for Developer ID distribution and
notarization. Notarization upload was failing with "Hardened Runtime
is Not Enabled" until this flag was on. Also bumps marketing version
to 1.4 and CURRENT_PROJECT_VERSION to 2 since this is the first
build with iCloud Keychain sync.
Settings:
- Replace icon refresh button with explicit "Sync Now" text button
- Move Start/Stop PDF Monitoring above the folder picker so the
  primary action sits near the password editor
- Default monitored folder now resolves real ~/Downloads via
  NSHomeDirectoryForUser instead of the sandbox container path that
  homeDirectoryForCurrentUser returns inside an App Sandbox

Menu bar:
- Switch from "lock.open.rotation" SF Symbol to a hand-rendered
  NSImage so the small status dot keeps its color (template tinting
  in MenuBarExtra otherwise flattens it to a single foreground)
- Hollow lock.doc icon at pointSize 18 with a green/red dot in the
  top-right reflecting fileWatcherManager.isMonitoring
Asset catalog manifest left at PDF Unlocker/Contents.json by an
external icon generator. Xcode only reads Contents.json files inside
.xcassets subfolders, so this one was dead weight and duplicated the
real AppIcon.appiconset manifest.
Removes the per-user toggle and "Sync Now" button. Passwords now
always live in the synced Keychain scope; if iCloud Keychain is
disabled in System Settings the writes still succeed locally and
silently start syncing once it is re-enabled.

Migration in runMigrationsIfNeeded():
- existing kSecAttrSynchronizable=false items are read, re-added with
  synchronizable=true, and the originals deleted
- legacy iCloudKeychainSyncEnabled UserDefaults flag is cleared
App init() now calls PasswordStore.load() so legacy migrations and
Keychain reads happen at launch instead of on first Settings click.
SettingsView gains an .onAppear that re-reads the synced Keychain
items into the password list, so reopening Settings picks up
passwords just synced from another device — no hard quit needed.

iCloud Keychain sync itself is lazy in securityd; calling SecItem on
launch and on window appear is the closest the public API gets to a
"sync now" trigger.
- Refresh stale security-scoped bookmarks instead of silently reusing
  them; persisted bookmarks were never rewritten when isStale=true.
- Show an NSAlert if creating a new folder bookmark fails so the user
  knows their pick was rejected (was a silent return).
- Gate the Keychain migration with a versioned UserDefaults flag so
  it runs once instead of on every PasswordStore.load() call.
- Mark a PDF as processed before saving the unlocked copy so the
  watcher event triggered by replaceItemAt is deduped reliably.
- Skip the didSet UserDefaults write when isMonitoring is unchanged.
- Revert to NSStatusItem-based StatusBarController so the menu bar
  lock icon stays template (auto-tints to the actual menu bar
  background, including wallpaper-induced dark bars) while the
  monitoring dot keeps its literal green/red color via a separate
  subview.
- Revert bundle identifier to com.rahul286.PDF-Unlocker so existing
  v1.3 preferences live in the same UserDefaults domain and migrate
  natively.
- Update keychain-access-groups entitlement to match.
- Drop the empty test targets from the project file.
@sandy787 sandy787 marked this pull request as ready for review May 21, 2026 13:42
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