Worktree ios support #3
@@ -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 {
|
||||
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
|
||||
|
||||
@@ -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