diff --git a/.claude/context/server.md b/.claude/context/server.md index df64637..a63fa9e 100644 --- a/.claude/context/server.md +++ b/.claude/context/server.md @@ -935,7 +935,15 @@ Reminders/Notes - the destination is picked *inside* the sheet: hidden, with a note. Switching `only` <-> `all`, or back to `hide`, never asks. - **Account switching**: choosing any account other than the app's - active one asks for the same unlock when "lock account switching" is on. One successful unlock covers the rest of that sheet. + active one asks for the same unlock when "lock account switching" is on. + **Every gated action prompts every time**, exactly like the app - no + unlock is remembered for the sheet (an earlier version cached one, so + later account picks / hidden toggles skipped Face ID). The only + merging is within one action: picking another account whose own filter + already shows hidden folders is a single prompt covering both + (`SharedAccounts.unlockNeeds` / `SelectionUnlock`, unit-tested). + Declining the account unlock cancels the pick; declining only the + hidden unlock shows the account with hidden folders hidden. - Opening the sheet itself is not gated - only these two actions are (as in the app, where login lock guards launch and these are separate locks). diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift index f324061..79e2c65 100644 --- a/ios/RunnerTests/RunnerTests.swift +++ b/ios/RunnerTests/RunnerTests.swift @@ -217,6 +217,55 @@ class RunnerTests: XCTestCase { XCTAssertTrue(rules(master: true, switching: true, hidden: true) == (true, true)) } + // Which actions in the share sheet ask to unlock - and how many prompts. + + private func lockedAccounts(switching: Bool, hidden: Bool) -> SharedAccounts { + SharedAccounts( + accounts: [account("alice"), account("bob")], activeId: "alice", + loginLockEnabled: false, lockAccountSwitching: switching, lockHiddenFiles: hidden) + } + + func testChoosingTheActiveAccountWithoutHiddenFoldersNeverPrompts() { + let shared = lockedAccounts(switching: true, hidden: true) + let needs = shared.unlockNeeds(choosing: account("alice"), showing: .hide) + XCTAssertEqual(needs, SelectionUnlock(switchesAccount: false, showsHidden: false)) + XCTAssertFalse(needs.needsPrompt) + } + + func testChoosingAnotherAccountPromptsEveryTime() { + let shared = lockedAccounts(switching: true, hidden: false) + // No unlock is remembered: the same choice asks again on every call. + for _ in 0..<3 { + XCTAssertTrue(shared.unlockNeeds(choosing: account("bob"), showing: .hide).needsPrompt) + } + XCTAssertFalse( + lockedAccounts(switching: false, hidden: false).unlockNeeds(choosing: account("bob"), showing: .hide).needsPrompt) + } + + func testSwitchPlusHiddenFoldersIsOnePromptThatCoversBoth() { + let needs = lockedAccounts(switching: true, hidden: true) + .unlockNeeds(choosing: account("bob"), showing: .include) + XCTAssertEqual(needs, SelectionUnlock(switchesAccount: true, showsHidden: true)) + XCTAssertTrue(needs.needsPrompt) + } + + func testHiddenFoldersAloneOnTheActiveAccountPromptsForHidden() { + let needs = lockedAccounts(switching: true, hidden: true) + .unlockNeeds(choosing: account("alice"), showing: .only) + XCTAssertEqual(needs, SelectionUnlock(switchesAccount: false, showsHidden: true)) + } + + func testOnlyTurningHiddenFoldersOnAsksToUnlock() { + let locked = lockedAccounts(switching: false, hidden: true) + XCTAssertTrue(locked.needsUnlockToChangeHidden(from: .hide, to: .only)) + XCTAssertTrue(locked.needsUnlockToChangeHidden(from: .hide, to: .include)) + XCTAssertFalse(locked.needsUnlockToChangeHidden(from: .include, to: .hide), "hiding again never asks") + XCTAssertFalse(locked.needsUnlockToChangeHidden(from: .only, to: .include), "only <-> all never asks") + XCTAssertFalse( + lockedAccounts(switching: false, hidden: false).needsUnlockToChangeHidden(from: .hide, to: .include), + "no lock set, no prompt") + } + // MARK: - TransferBatchStore func testBatchSummaryFiresOnlyOnTheLastFile() { diff --git a/ios/ShareExtension/SharePickerView.swift b/ios/ShareExtension/SharePickerView.swift index 9b963da..ff9cd1a 100644 --- a/ios/ShareExtension/SharePickerView.swift +++ b/ios/ShareExtension/SharePickerView.swift @@ -35,9 +35,6 @@ final class ShareModel: ObservableObject { let shared: SharedAccounts? - /// One successful unlock covers every gate for the rest of this sheet. - private var unlocked = false - /// Set by the view controller: what each button actually does. var onUpload: () -> Void = {} var onSaveForLater: () -> Void = {} @@ -103,24 +100,30 @@ final class ShareModel: ObservableObject { func select(_ account: SharedAccount) async { guard let shared else { return } notice = nil - if account.id != shared.active?.id, shared.needsUnlockToSwitchAccount { - guard await unlock("Unlock to upload to \(account.displayName)") else { - notice = "Unlock to upload to a different account." - stage = .chooseAccount - return - } - } - NSLog("[ShareExtension] selected %@ (hidden=%@ storage=%@)", account.id, account.hiddenFilter, account.storageScope) - selected = account - externalRoots = [] - storageFilter = StorageFilter(raw: account.storageScope) - hiddenFilter = HiddenFilter(raw: account.hiddenFilter) - if hiddenFilter != .hide, shared.needsUnlockForHidden { - if await !unlock("Unlock to show hidden folders") { - hiddenFilter = .hide + var wantedHidden = HiddenFilter(raw: account.hiddenFilter) + + // Every selection asks again, like the app does for every switch - no + // unlock is remembered. If one action needs both unlocks, one prompt + // covers both. + let needs = shared.unlockNeeds(choosing: account, showing: wantedHidden) + if needs.needsPrompt { + let reason = needs.switchesAccount + ? "Unlock to upload to \(account.displayName)" : "Unlock to show hidden folders" + if await !DeviceAuth.authenticate(reason: reason) { + guard !needs.switchesAccount else { + notice = "Unlock to upload to a different account." + stage = .chooseAccount + return + } + wantedHidden = .hide notice = "Hidden folders are locked, so they're not shown." } } + NSLog("[ShareExtension] selected %@ (hidden=%@ storage=%@)", account.id, wantedHidden.rawValue, account.storageScope) + selected = account + externalRoots = [] + storageFilter = StorageFilter(raw: account.storageScope) + hiddenFilter = wantedHidden stage = .picking await open("/") } @@ -132,8 +135,8 @@ final class ShareModel: ObservableObject { /// between the two revealing modes. func setHiddenFilter(_ filter: HiddenFilter) async { guard filter != hiddenFilter else { return } - if hiddenFilter == .hide, shared?.needsUnlockForHidden == true { - guard await unlock("Unlock to show hidden folders") else { + if shared?.needsUnlockToChangeHidden(from: hiddenFilter, to: filter) == true { + guard await DeviceAuth.authenticate(reason: "Unlock to show hidden folders") else { notice = "Hidden folders are locked, so they're not shown." return } @@ -150,13 +153,6 @@ final class ShareModel: ObservableObject { await open("/") } - private func unlock(_ reason: String) async -> Bool { - if unlocked { return true } - let ok = await DeviceAuth.authenticate(reason: reason) - if ok { unlocked = true } - return ok - } - // MARK: - Browsing /// A toggle can leave you inside a folder it now hides, so changing one diff --git a/ios/Shared/SharedAccount.swift b/ios/Shared/SharedAccount.swift index aa9ff97..031620d 100644 --- a/ios/Shared/SharedAccount.swift +++ b/ios/Shared/SharedAccount.swift @@ -73,6 +73,21 @@ struct SharedAccounts: Codable, Equatable { /// Showing hidden folders needs the same unlock the app asks for when you /// turn hidden files on. var needsUnlockForHidden: Bool { lockHiddenFiles } + + /// What choosing [account] (with [hidden] folders to show) asks the user + /// to unlock. One action, one prompt - when both the account switch and + /// the hidden folders need it, a single authentication covers both. + func unlockNeeds(choosing account: SharedAccount, showing hidden: HiddenFilter) -> SelectionUnlock { + SelectionUnlock( + switchesAccount: account.id != active?.id && needsUnlockToSwitchAccount, + showsHidden: hidden != .hide && needsUnlockForHidden) + } + + /// Turning hidden folders on (from `hide`) is what the app locks; going + /// back to `hide`, or between the two revealing modes, never asks. + func needsUnlockToChangeHidden(from old: HiddenFilter, to new: HiddenFilter) -> Bool { + old == .hide && new != .hide && needsUnlockForHidden + } } /// Keeps the [SharedAccounts] in a Keychain access group both the app and the @@ -136,3 +151,11 @@ enum SharedAccountStore { SecItemDelete(legacy as CFDictionary) } } + +/// What one selection needs unlocked - see `SharedAccounts.unlockNeeds`. +struct SelectionUnlock: Equatable { + let switchesAccount: Bool + let showsHidden: Bool + + var needsPrompt: Bool { switchesAccount || showsHidden } +}