diff --git a/apple/aesel/Sources/ContentView.swift b/apple/aesel/Sources/ContentView.swift index 4c9872001b..5bf05b2e69 100644 --- a/apple/aesel/Sources/ContentView.swift +++ b/apple/aesel/Sources/ContentView.swift @@ -244,7 +244,6 @@ struct ContentView: View { .onChange(of: session.showSignIn) { if !session.showSignIn { host.cancelSignIn() } } .onChange(of: session.signedIn) { if session.signedIn { Task { await braincells.load() } } } .task { braincells.start(token: { host.accessToken() }, credited: { host.refreshCredits() }) } - .onChange(of: oskiewar.status) { fileNotice = oskiewar.status } .onDisappear { host.automation.stop(); oskiewar.disconnect() } .onAppear { host.automation.inspect = { automationState } @@ -438,6 +437,17 @@ struct ContentView: View { private var connectionNotices: some View { VStack(alignment: .leading, spacing: 6) { + if !oskiewar.room.isEmpty { + HStack(spacing: 10) { + Image(systemName: "sparkles").accessibilityHidden(true) + Text(oskiewar.status).font(Paint.font(13)) + Button { oskiewar.disconnect() } label: { + Image(systemName: "xmark").frame(width: 24, height: 24) + }.accessibilityLabel("Disconnect Oskiewar") + } + .padding(10).background(paint.bg, in: RoundedRectangle(cornerRadius: 8)) + .overlay { RoundedRectangle(cornerRadius: 8).stroke(paint.ink.opacity(0.3), lineWidth: 1) } + } ForEach(session.notices.keys.sorted(), id: \.self) { scope in if let notice = session.notices[scope] { HStack(spacing: 10) { @@ -630,10 +640,9 @@ struct ContentView: View { } Rectangle().fill(paint.ink.opacity(0.16)).frame(height: 1) if session.signedIn { - Text(session.handle.isEmpty ? "Aesthetic Computer account" : "@\(session.handle)") - .font(.custom("ComicRelief-Bold", size: 26)) - .foregroundStyle(paint.ink) - .shadow(color: paint.accent.opacity(0.55), radius: 0, x: 2, y: 2) + AeselTitle(text: session.handle.isEmpty ? "Aesthetic Computer account" : "@\(session.handle)", + colors: session.handleColors, size: 26, + maximumWidth: max(180, min(520, geometry.size.width - 80)), horizontalInset: 4) .padding(.bottom, 4) HStack(spacing: 16) { accountAction("Log out") { closeSettings { host.signOut() } } diff --git a/apple/aesel/Sources/SessionStore.swift b/apple/aesel/Sources/SessionStore.swift index c461bcba49..0ee17e031c 100644 --- a/apple/aesel/Sources/SessionStore.swift +++ b/apple/aesel/Sources/SessionStore.swift @@ -6,12 +6,14 @@ final class SessionStore { private let url: URL private var values: [String: String] private let sessionKey: String + private let tokenService: String private let backup: URL private var writable = true private(set) var issue: String? - init(directory override: URL? = nil, windowID: String = "main") { + init(directory override: URL? = nil, windowID: String = "main", tokenService: String? = nil) { + self.tokenService = tokenService ?? Self.keychainService sessionKey = windowID == "main" ? "session" : "session.\(windowID)" #if os(macOS) let standard = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] @@ -118,7 +120,7 @@ final class SessionStore { private var tokenQuery: [String: Any] { [kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: Self.keychainService, + kSecAttrService as String: tokenService, kSecAttrAccount as String: "access-token"] } diff --git a/apple/aesel/Tests/TokenPersistenceChecks.swift b/apple/aesel/Tests/TokenPersistenceChecks.swift new file mode 100644 index 0000000000..e5a1a577eb --- /dev/null +++ b/apple/aesel/Tests/TokenPersistenceChecks.swift @@ -0,0 +1,30 @@ +import Foundation +import Security + +@main struct TokenPersistenceChecks { + static func main() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let service = "computer.aesthetic.aesel.test." + UUID().uuidString + let store = SessionStore(directory: root, tokenService: service) + defer { store.clearToken(); try? FileManager.default.removeItem(at: root) } + func reference() -> Data { + let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, kSecAttrAccount as String: "access-token", + kSecReturnPersistentRef as String: true] + var result: CFTypeRef? + precondition(SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess) + return result as! Data + } + try store.write(key: "session", value: #"{"token":"test-token-one"}"#) + let original = reference() + for _ in 0..<3 { try store.write(key: "session", value: #"{"token":"test-token-one"}"#) } + precondition(reference() == original, "Repeated saves must retain the Keychain item and its ACL") + try store.write(key: "session", value: #"{"token":"test-token-two"}"#) + precondition(reference() == original, "Token rotation must retain the Keychain item") + precondition(store.token() == "test-token-two") + let disk = try String(contentsOf: root.appendingPathComponent("session.json"), encoding: .utf8) + precondition(!disk.contains("test-token"), "Tokens must stay out of session files") + store.clearToken(); precondition(store.token() == nil) + print("Keychain token persistence checks passed") + } +}