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:
co-authored by
Claude Sonnet 5.5
parent
02d9d799a6
commit
4ab6c5d85c
@@ -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).
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user