Share sheet: ask to unlock on every gated action, like the app

The sheet remembered one unlock for its whole lifetime, so later account picks
and hidden toggles skipped Face ID. Each switch / each turning-on of hidden
folders now prompts again; one action needing both is a single prompt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Ayushya Amitabh
2026-10-06 20:24:20 -04:00
co-authored by Claude Sonnet 5.5
parent 02d9d799a6
commit 4ab6c5d85c
4 changed files with 104 additions and 28 deletions
+9 -1
View File
@@ -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 hidden, with a note. Switching `only` <-> `all`, or back to `hide`, never
asks. asks.
- **Account switching**: choosing any account other than the app's - **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 - 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 (as in the app, where login lock guards launch and these are separate
locks). locks).
+49
View File
@@ -217,6 +217,55 @@ class RunnerTests: XCTestCase {
XCTAssertTrue(rules(master: true, switching: true, hidden: true) == (true, true)) 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 // MARK: - TransferBatchStore
func testBatchSummaryFiresOnlyOnTheLastFile() { func testBatchSummaryFiresOnlyOnTheLastFile() {
+19 -23
View File
@@ -35,9 +35,6 @@ final class ShareModel: ObservableObject {
let shared: SharedAccounts? 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. /// Set by the view controller: what each button actually does.
var onUpload: () -> Void = {} var onUpload: () -> Void = {}
var onSaveForLater: () -> Void = {} var onSaveForLater: () -> Void = {}
@@ -103,24 +100,30 @@ final class ShareModel: ObservableObject {
func select(_ account: SharedAccount) async { func select(_ account: SharedAccount) async {
guard let shared else { return } guard let shared else { return }
notice = nil notice = nil
if account.id != shared.active?.id, shared.needsUnlockToSwitchAccount { var wantedHidden = HiddenFilter(raw: account.hiddenFilter)
guard await unlock("Unlock to upload to \(account.displayName)") else {
// 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." notice = "Unlock to upload to a different account."
stage = .chooseAccount stage = .chooseAccount
return return
} }
} wantedHidden = .hide
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
notice = "Hidden folders are locked, so they're not shown." 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 stage = .picking
await open("/") await open("/")
} }
@@ -132,8 +135,8 @@ final class ShareModel: ObservableObject {
/// between the two revealing modes. /// between the two revealing modes.
func setHiddenFilter(_ filter: HiddenFilter) async { func setHiddenFilter(_ filter: HiddenFilter) async {
guard filter != hiddenFilter else { return } guard filter != hiddenFilter else { return }
if hiddenFilter == .hide, shared?.needsUnlockForHidden == true { if shared?.needsUnlockToChangeHidden(from: hiddenFilter, to: filter) == true {
guard await unlock("Unlock to show hidden folders") else { guard await DeviceAuth.authenticate(reason: "Unlock to show hidden folders") else {
notice = "Hidden folders are locked, so they're not shown." notice = "Hidden folders are locked, so they're not shown."
return return
} }
@@ -150,13 +153,6 @@ final class ShareModel: ObservableObject {
await open("/") 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 // MARK: - Browsing
/// A toggle can leave you inside a folder it now hides, so changing one /// A toggle can leave you inside a folder it now hides, so changing one
+23
View File
@@ -73,6 +73,21 @@ struct SharedAccounts: Codable, Equatable {
/// Showing hidden folders needs the same unlock the app asks for when you /// Showing hidden folders needs the same unlock the app asks for when you
/// turn hidden files on. /// turn hidden files on.
var needsUnlockForHidden: Bool { lockHiddenFiles } 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 /// Keeps the [SharedAccounts] in a Keychain access group both the app and the
@@ -136,3 +151,11 @@ enum SharedAccountStore {
SecItemDelete(legacy as CFDictionary) 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 }
}