Refine store

This commit is contained in:
Kaede Akino committed 2026-09-21 16:48:49 +08:00
1 parent c414693eb9
commit b40c71eec6
17 files changed
+222 -23

No files matched your search

+36
View File
@@ -110,6 +110,42 @@ struct SettingsPortTests {
#expect(defaults.bool(forKey: SettingsKey.settingsSyncEnabled))
}
@Test func newVaultCredentialStorageRequiresBothSwitchesAndProAccess() throws {
let domain = "VaultCredentialStorage.\(UUID())"
let defaults = try #require(UserDefaults(suiteName: domain))
defer { defaults.removePersistentDomain(forName: domain) }
for iCloudEnabled in [false, true] {
for vaultEnabled in [false, true] {
for canUseVaultSync in [false, true] {
defaults.set(iCloudEnabled, forKey: SettingsKey.iCloudSyncEnabled)
defaults.set(vaultEnabled, forKey: SettingsKey.vaultSyncEnabled)
let expectedKey: KeyStorage =
iCloudEnabled && vaultEnabled && canUseVaultSync ? .iCloud : .thisDevice
let expectedPassword: PasswordStorage =
iCloudEnabled && vaultEnabled && canUseVaultSync ? .iCloud : .thisDevice
#expect(
NewVaultCredentialStorage.keyStorage(
defaults: defaults,
canUseVaultSync: canUseVaultSync) == expectedKey)
#expect(
NewVaultCredentialStorage.passwordStorage(
defaults: defaults,
canUseVaultSync: canUseVaultSync) == expectedPassword)
}
}
}
}
@Test func newVaultCredentialStorageUsesEnabledDefaultsWhenPreferencesAreMissing() throws {
let domain = "VaultCredentialStorageDefaults.\(UUID())"
let defaults = try #require(UserDefaults(suiteName: domain))
defer { defaults.removePersistentDomain(forName: domain) }
#expect(NewVaultCredentialStorage.keyStorage(defaults: defaults, canUseVaultSync: true) == .iCloud)
#expect(NewVaultCredentialStorage.passwordStorage(defaults: defaults, canUseVaultSync: true) == .iCloud)
}
@Test func rolesTakePrecedenceOverLastSelectedModelAndDeletedModelsFallBack() {
let provider = ModelProvider(name: "Test", models: [AIModel(id: "role"), AIModel(id: "last")])
let role = AIModelRef(modelProviderID: provider.id, modelID: "role")
@@ -7,7 +7,7 @@ import struct TermindDomain.Host
public final class SSHImportModel {
public var items: [SSHImportItem] = []
public var expanded = Set(SSHImportCategory.allCases)
public var storage: PasswordStorage = .iCloud
public var storage: PasswordStorage
public var importColor: TagColor = .none
public var hostGroupID: HostGroup.ID?
public var hostTags: [String] = []
@@ -36,10 +36,16 @@ public final class SSHImportModel {
var keyID: UUID?
}
public init(library: HostLibraryModel, reader: any SSHImportReading, defaultUsername: String = "") {
public init(
library: HostLibraryModel,
reader: any SSHImportReading,
defaultUsername: String = "",
storage: PasswordStorage = .iCloud
) {
self.defaultUsername = defaultUsername
self.library = library
self.reader = reader
self.storage = storage
}
public func scan(_ url: URL, knownHostsOnly: Bool) async {
@@ -68,3 +68,34 @@ public nonisolated enum SettingsKey {
if !vault && !ai { defaults.set(true, forKey: vaultSyncEnabled) }
}
}
public nonisolated enum NewVaultCredentialStorage {
public static func keyStorage(
defaults: UserDefaults = .standard,
canUseVaultSync: Bool
) -> KeyStorage {
vaultSyncIsAvailable(defaults: defaults, canUseVaultSync: canUseVaultSync)
? .iCloud : .thisDevice
}
public static func passwordStorage(
defaults: UserDefaults = .standard,
canUseVaultSync: Bool
) -> PasswordStorage {
vaultSyncIsAvailable(defaults: defaults, canUseVaultSync: canUseVaultSync)
? .iCloud : .thisDevice
}
private static func vaultSyncIsAvailable(
defaults: UserDefaults,
canUseVaultSync: Bool
) -> Bool {
let iCloudEnabled =
defaults.object(forKey: SettingsKey.iCloudSyncEnabled) as? Bool
?? SettingsKey.defaultICloudSyncEnabled
let vaultEnabled =
defaults.object(forKey: SettingsKey.vaultSyncEnabled) as? Bool
?? SettingsKey.defaultVaultSyncEnabled
return iCloudEnabled && vaultEnabled && canUseVaultSync
}
}
+2 -1
View File
@@ -106,7 +106,8 @@ sibling working tree is not the app dependency. Follow `AGENTS.md` for package r
Keychain metadata without reading secret data; the legacy software-Key JSON envelope is parsed strictly and fails closed.
- Saved iCloud Passwords and software Keys cannot be moved to device-only storage: editors disable that destination and
application writes reject it before touching Keychain. New credentials may start device-only, and device-only credentials
may move to iCloud.
may move to iCloud. New Key and Identity storage pickers default to iCloud only while the master iCloud switch, Vault
selection and Pro access are all enabled; otherwise they default to this device. Existing credentials keep their storage.
- Editors whose saved Password or software Private Key is unavailable on the current device preserve its reference and
disable secret replacement, removal, passphrase and storage changes while still allowing public metadata edits. Key
metadata-only saves may update the SSH certificate after validating it against the saved public key, without reading or
+17 -1
View File
@@ -68,6 +68,7 @@ final class MobileAppModel {
let liveActivities: MobileLiveActivityController
@ObservationIgnored private var workspaceActivitySessions: [WorkspaceActivityAttributes.Session] = []
private let defaults: UserDefaults?
private let settingsDefaults: UserDefaults
private let snippetTimestampFallback: Date
private let background: any MobileBackgroundTaskManaging
private let workspaceConnectionServices: MobileWorkspaceConnectionServices
@@ -139,7 +140,10 @@ final class MobileAppModel {
accessGroup: Bundle.main.object(forInfoDictionaryKey: "TermindKeychainAccessGroup") as? String
?? "F6SUNDBBN9.com.akinokaede.termind")
let settingsDefaults =
memoryOnly ? UserDefaults(suiteName: "termind.tests.\(UUID().uuidString)")! : defaults ?? .standard
memoryOnly
? defaults ?? UserDefaults(suiteName: "termind.tests.\(UUID().uuidString)")!
: defaults ?? .standard
self.settingsDefaults = settingsDefaults
SettingsKey.migrateSyncPreferences(in: settingsDefaults)
let settingsPersistence = SettingsSyncPersistence(context: ModelContext(container), defaults: settingsDefaults)
try settingsPersistence.prepare()
@@ -264,6 +268,18 @@ final class MobileAppModel {
loadRecentWorkspaces()
}
var newVaultKeyStorage: KeyStorage {
NewVaultCredentialStorage.keyStorage(
defaults: settingsDefaults,
canUseVaultSync: pro.canUse(.vaultSync))
}
var newVaultPasswordStorage: PasswordStorage {
NewVaultCredentialStorage.passwordStorage(
defaults: settingsDefaults,
canUseVaultSync: pro.canUse(.vaultSync))
}
func open(
hostID: UUID, kind: ConnectionHistoryEntry.Kind,
initialPath: String? = nil
@@ -39,12 +39,17 @@ final class MobileKeyEditorModel: Identifiable {
private var originalPrivateKey = ""
private var didLoadPrivateKey: Bool
init(mode: Mode, credentials: CredentialLibraryModel, key: SSHKeyRef? = nil) {
init(
mode: Mode,
credentials: CredentialLibraryModel,
key: SSHKeyRef? = nil,
defaultStorage: KeyStorage = .iCloud
) {
self.mode = mode
self.credentials = credentials
original = key
label = key?.label ?? ""
storage = key?.storage ?? .iCloud
storage = key?.storage ?? defaultStorage
certificate = key?.certificate ?? ""
colorTag = key?.colorTag ?? .none
let availability = key.map { credentials.keyAvailability(for: $0) }
@@ -128,13 +128,19 @@ struct MobileHostEditor: View {
switch route {
case .hostIdentity:
MobileIdentityEditor(
app: app, identity: Identity(name: "", username: "")
app: app,
identity: Identity(
name: "", username: "",
passwordStorage: app.newVaultPasswordStorage)
) { identity in
draft = MobileHostEditorPolicy.selectIdentity(identity, current: draft)
}
case .proxyIdentity:
MobileIdentityEditor(
app: app, identity: Identity(name: "", username: ""),
app: app,
identity: Identity(
name: "", username: "",
passwordStorage: app.newVaultPasswordStorage),
configuration: .proxy
) { identity in
draft.proxy = MobileHostEditorPolicy.selectProxyIdentity(identity, current: draft.proxy)
@@ -76,14 +76,20 @@ struct MobileCredentialsView: View {
.toolbar {
Menu {
Button("Generate Key", systemImage: "key") {
keyEditor = MobileKeyEditorModel(mode: .generate, credentials: app.credentials)
keyEditor = MobileKeyEditorModel(
mode: .generate, credentials: app.credentials,
defaultStorage: app.newVaultKeyStorage)
}
Button("Import Key", systemImage: "square.and.arrow.down") {
keyEditor = MobileKeyEditorModel(mode: .importKey, credentials: app.credentials)
keyEditor = MobileKeyEditorModel(
mode: .importKey, credentials: app.credentials,
defaultStorage: app.newVaultKeyStorage)
}
Divider()
Button("New Identity", systemImage: "person.crop.circle.badge.plus") {
editing = Identity(name: "", username: "")
editing = Identity(
name: "", username: "",
passwordStorage: app.newVaultPasswordStorage)
}
} label: {
Label("Add", systemImage: "plus").labelStyle(.iconOnly)
+20
View File
@@ -10,6 +10,26 @@ import Testing
@Suite @MainActor
struct MobileVaultFeatureTests {
@Test func appDefaultsNewCredentialsToThisDeviceWhenVaultSyncIsOff() throws {
let domain = "MobileVaultCredentialDefaults.\(UUID())"
let defaults = try #require(UserDefaults(suiteName: domain))
defer { defaults.removePersistentDomain(forName: domain) }
defaults.set(false, forKey: SettingsKey.iCloudSyncEnabled)
let app = try MobileAppModel(inMemory: true, defaults: defaults)
defer { app.outbox.stop() }
#expect(app.newVaultKeyStorage == .thisDevice)
#expect(app.newVaultPasswordStorage == .thisDevice)
let generator = MobileKeyEditorModel(
mode: .generate,
credentials: app.credentials,
defaultStorage: app.newVaultKeyStorage)
#expect(generator.storage == .thisDevice)
generator.storage = .iCloud
#expect(generator.storage == .iCloud)
}
@Test func hostMenuSeparatesConnectionEditingAndDeletionActions() {
#expect(
MobileHostMenuPolicy.sections == [
+9 -3
View File
@@ -20,6 +20,7 @@ struct AppDependencies {
let metricsPreferences: (any MetricsConfigurationStoring)?
let mcpServerSecrets: any SecretStoring
let logging: any ApplicationLogManaging
let settingsDefaults: UserDefaults
init(
hostRepository: any HostRepository, outboxRunner: OutboxRunner?,
@@ -31,7 +32,8 @@ struct AppDependencies {
metricsSync: MetricsSyncCoordinator? = nil,
metricsPreferences: (any MetricsConfigurationStoring)? = nil,
mcpServerSecrets: any SecretStoring = InMemorySecretStore(),
logging: any ApplicationLogManaging = DisabledApplicationLogManager.shared
logging: any ApplicationLogManaging = DisabledApplicationLogManager.shared,
settingsDefaults: UserDefaults = .standard
) {
self.hostRepository = hostRepository
self.outboxRunner = outboxRunner
@@ -44,14 +46,18 @@ struct AppDependencies {
self.metricsPreferences = metricsPreferences
self.mcpServerSecrets = mcpServerSecrets
self.logging = logging
self.settingsDefaults = settingsDefaults
}
static func inMemory(hosts: [Host], groups: [HostGroup]) -> Self {
Self(
let defaults = UserDefaults(suiteName: "termind.tests.\(UUID().uuidString)")!
SettingsKey.migrateSyncPreferences(in: defaults)
return Self(
hostRepository: InMemoryHostRepository(
snapshot: HostLibrarySnapshot(hosts: hosts, groups: groups)
),
outboxRunner: nil
outboxRunner: nil,
settingsDefaults: defaults
)
}
}
+17 -1
View File
@@ -109,13 +109,28 @@ final class AppModel {
/// and a run ending is not a reason to stop a deploy.
private let agentBackgroundCommands: AgentBackgroundCommands
private let outboxRunner: OutboxRunner?
private let settingsDefaults: UserDefaults
private var windows: [ObjectIdentifier: WeakWindow] = [:]
private var deletedAgentConversationIDs = Set<UUID>()
/// Conversations already handed to the summarizer. Titling is once per
/// conversation, and every run re-arms the hook that would do it.
func makeSSHImportModel() -> SSHImportModel {
SSHImportModel(library: hosts, reader: SSHImportReader())
SSHImportModel(
library: hosts, reader: SSHImportReader(),
storage: newVaultPasswordStorage)
}
var newVaultKeyStorage: KeyStorage {
NewVaultCredentialStorage.keyStorage(
defaults: settingsDefaults,
canUseVaultSync: proAccess.canUse(.vaultSync))
}
var newVaultPasswordStorage: PasswordStorage {
NewVaultCredentialStorage.passwordStorage(
defaults: settingsDefaults,
canUseVaultSync: proAccess.canUse(.vaultSync))
}
init(
@@ -139,6 +154,7 @@ final class AppModel {
) {
self.windowSizeStore = windowSizeStore
let dependencies = dependencies ?? .inMemory(hosts: hosts.hosts, groups: hosts.groups)
settingsDefaults = dependencies.settingsDefaults
logging = dependencies.logging
let externalAuthentication = ExternalAuthCoordinator(
logger: logging.makeLogger(label: "termind.external-mcp"))
+2 -1
View File
@@ -124,7 +124,8 @@ struct TermindApp: App {
vaultSync: vaultSync, settingsSync: settingsSync,
metricsSync: metricsSync, metricsPreferences: metricsPersistence,
mcpServerSecrets: secrets,
logging: logging
logging: logging,
settingsDefaults: .standard
),
proAccess: proAccess,
appReview: .live(),
+9 -2
View File
@@ -233,7 +233,10 @@ struct HostEditorView: View {
private func destination(_ route: Route) -> some View {
switch route {
case .identity:
NewIdentitySheet(back: closeNestedEditor) { identity, passwordUpdate in
NewIdentitySheet(
defaultPasswordStorage: app.newVaultPasswordStorage,
back: closeNestedEditor
) { identity, passwordUpdate in
try app.hostLibrary.upsert(identity, passwordUpdate: passwordUpdate)
draft.identityID = identity.id
}
@@ -243,7 +246,11 @@ struct HostEditorView: View {
draft.groupID = group.id
}
case .proxyIdentity:
NewIdentitySheet(configuration: .proxy, back: closeNestedEditor) {
NewIdentitySheet(
configuration: .proxy,
defaultPasswordStorage: app.newVaultPasswordStorage,
back: closeNestedEditor
) {
identity, passwordUpdate in
try app.hostLibrary.upsert(identity, passwordUpdate: passwordUpdate)
draft.proxy?.identityID = identity.id
+24 -4
View File
@@ -324,7 +324,9 @@ struct KeychainImportDetail: View {
var body: some View {
SoftwareKeyEditor(
title: "Import Key", symbol: "square.and.arrow.down",
close: window.closeVaultWork, save: save
close: window.closeVaultWork,
defaultStorage: app.newVaultKeyStorage,
save: save
)
}
@@ -390,6 +392,7 @@ private struct SoftwareKeyEditor: View {
init(
existing: SSHKeyRef? = nil, title: LocalizedStringKey,
symbol: String, close: @escaping () -> Void,
defaultStorage: KeyStorage = .iCloud,
loadPrivateKey: (() throws -> String)? = nil,
saveMetadata: ((_ name: String, _ colorTag: TagColor, _ certificate: String) throws -> SSHKeyRef)? = nil,
save:
@@ -406,7 +409,7 @@ private struct SoftwareKeyEditor: View {
self.save = save
_name = State(initialValue: existing?.label ?? "")
_colorTag = State(initialValue: existing?.colorTag ?? .none)
_storage = State(initialValue: existing?.storage ?? .iCloud)
_storage = State(initialValue: existing?.storage ?? defaultStorage)
_certificate = State(initialValue: existing?.certificate ?? "")
_isLoading = State(initialValue: loadPrivateKey != nil)
}
@@ -759,7 +762,10 @@ struct KeychainGenerateDetail: View {
@Environment(WindowModel.self) private var window
var body: some View {
GenerateKeySheet(generate: generate, close: window.closeVaultWork)
GenerateKeySheet(
defaultStorage: app.newVaultKeyStorage,
generate: generate,
close: window.closeVaultWork)
}
private func generate(
@@ -828,7 +834,7 @@ private struct GenerateKeySheet: View {
@State private var label = ""
@State private var colorTag = TagColor.none
@State private var storage = KeyStorage.iCloud
@State private var storage: KeyStorage
@State private var kind = Kind.ed25519
@State private var curveBits = 256
@State private var rsaBits = 3072
@@ -846,6 +852,20 @@ private struct GenerateKeySheet: View {
.secureEnclave,
]
init(
defaultStorage: KeyStorage = .iCloud,
generate:
@escaping (
String, TagColor, SSHKeyMaterial.Generation, String?, SSHKeyMaterial.Cipher, Int,
KeyStorage, Bool
) async throws -> SSHKeyRef,
close: @escaping () -> Void
) {
self.generate = generate
self.close = close
_storage = State(initialValue: defaultStorage)
}
private var generation: SSHKeyMaterial.Generation {
if storage == .secureEnclave { return .ecdsa(bits: 256) }
return switch kind {
+2 -1
View File
@@ -75,6 +75,7 @@ struct NewIdentitySheet: View {
init(
identity: Identity? = nil,
configuration: IdentityEditorConfiguration = .standard,
defaultPasswordStorage: PasswordStorage = .iCloud,
back: (() -> Void)? = nil,
create: @escaping (Identity, IdentityPasswordUpdate) throws -> Void,
close: (() -> Void)? = nil
@@ -86,7 +87,7 @@ struct NewIdentitySheet: View {
requestedBack = back
_name = State(initialValue: identity?.name ?? "")
_username = State(initialValue: identity?.username ?? "")
_passwordStorage = State(initialValue: identity?.passwordStorage ?? .iCloud)
_passwordStorage = State(initialValue: identity?.passwordStorage ?? defaultPasswordStorage)
_keyID = State(initialValue: identity?.keyID)
_colorTag = State(initialValue: identity?.colorTag ?? .none)
}
+1
View File
@@ -53,6 +53,7 @@ struct VaultWorkDetailView: View {
case .identityEditor(let id):
NewIdentitySheet(
identity: id.flatMap(app.hostLibrary.identity),
defaultPasswordStorage: app.newVaultPasswordStorage,
create: { identity, password in
try app.hostLibrary.upsert(identity, passwordUpdate: password)
window.selectVaultItem(.identity(identity.id), on: .keychain)
+20
View File
@@ -27,6 +27,26 @@ struct SSHImportTests {
return "|1|\(salt.base64EncodedString())|\(Data(digest).base64EncodedString())"
}
@Test
func appDefaultsNewCredentialsAndSSHImportToThisMacWhenVaultSyncIsOff() throws {
let domain = "SSHImportDefaults.\(UUID())"
let defaults = try #require(UserDefaults(suiteName: domain))
defer { defaults.removePersistentDomain(forName: domain) }
defaults.set(false, forKey: SettingsKey.vaultSyncEnabled)
let hosts = HostLibraryModel()
let app = AppModel(
hosts: hosts,
dependencies: AppDependencies(
hostRepository: InMemoryHostRepository(),
outboxRunner: nil,
settingsDefaults: defaults))
defer { app.shutdownForTermination() }
#expect(app.newVaultKeyStorage == .thisDevice)
#expect(app.newVaultPasswordStorage == .thisDevice)
#expect(app.makeSSHImportModel().storage == .thisDevice)
}
@Test
func hashedKeysMatchPortsAndSurvivePersistenceWithoutHosts() throws {
let entry = KnownHostEntry(hostname: hash("[2001:db8::1]:2222"), fingerprint: "SHA256:key")