From c13b1bbd55c8e497eaa8b809c7007cb1bbf21e02 Mon Sep 17 00:00:00 2001 From: Kevin Zhong Date: Sat, 26 Sep 2026 15:50:30 -0500 Subject: [PATCH] Keep the session through updates; login page says e-ink displays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signing in again after every Sparkle update came from TokenStore.save: it deleted the Keychain item and added a new one on every save. On this Mac the item had been created by the first Developer ID install (~/Applications/inklet.app, 2026-09-16) and never changed since, although the backend rotates the refresh token every 72 hours: the delete was refused, the add failed with errSecDuplicateItem, and the fresh tokens lived only in memory. The next launch — an update relaunch — presented the old refresh token, the backend answered 401, and the app signed out. Saves now update the existing item in place and only fall back to delete-and-add; if the Keychain still refuses, the session is kept in the 0600 file until the Keychain accepts it, instead of being dropped. The login page reads "Your second brain, on e-ink displays." --- Sources/InkletMac/Services/TokenStore.swift | 32 +++++++++++++++++---- Sources/InkletMac/Views/LoginView.swift | 2 +- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/Sources/InkletMac/Services/TokenStore.swift b/Sources/InkletMac/Services/TokenStore.swift index aa1c2f2..252c44d 100644 --- a/Sources/InkletMac/Services/TokenStore.swift +++ b/Sources/InkletMac/Services/TokenStore.swift @@ -53,11 +53,20 @@ enum TokenStore { } let status = keychainSave(data) - if status != errSecSuccess { - log.error("Keychain refused the session (OSStatus \(status, privacy: .public)); it is kept in memory for this launch only") + if status == errSecSuccess { + try? FileManager.default.removeItem(at: fileURL) + return } - // Whatever the Keychain said, a release build leaves no plaintext copy. - try? FileManager.default.removeItem(at: fileURL) + // The Keychain said no — typically an item another build of the app + // created, whose access control this build cannot change. Keeping the + // tokens only in memory meant the next launch found a refresh token the + // backend had already rotated away, and the user was signed out by an + // update. The file copy (0600, file protection) is the lesser evil; the + // next successful Keychain save removes it. + log.error("Keychain refused the session (OSStatus \(status, privacy: .public)); keeping it in the session file instead") + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try? data.write(to: fileURL, options: [.atomic, .completeFileProtection]) + try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fileURL.path) } /// Blocking. Never call this from the main thread — see `usesKeychain`. @@ -88,7 +97,7 @@ enum TokenStore { try? FileManager.default.removeItem(at: fileURL) return nil } - log.notice("Moving a session file left by an earlier release into the Keychain") + log.notice("Session came from the file; offering it to the Keychain again") save(tokens) return tokens } @@ -110,8 +119,19 @@ enum TokenStore { ] } + /// Update in place when the item exists, add when it does not. + /// + /// Delete-then-add re-creates the item's access control on every save, and + /// when the delete is refused (the item belongs to an earlier install of + /// the app) the add fails with errSecDuplicateItem — so the fresh tokens + /// were never written and every relaunch after a refresh needed a login. private static func keychainSave(_ data: Data) -> OSStatus { - SecItemDelete(baseQuery() as CFDictionary) + let update = SecItemUpdate(baseQuery() as CFDictionary, [kSecValueData as String: data] as CFDictionary) + if update == errSecSuccess { return update } + if update != errSecItemNotFound { + log.error("Keychain update failed (OSStatus \(update, privacy: .public)); replacing the item") + SecItemDelete(baseQuery() as CFDictionary) + } var query = baseQuery() query[kSecValueData as String] = data query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock diff --git a/Sources/InkletMac/Views/LoginView.swift b/Sources/InkletMac/Views/LoginView.swift index 1d0bd7c..a975464 100644 --- a/Sources/InkletMac/Views/LoginView.swift +++ b/Sources/InkletMac/Views/LoginView.swift @@ -49,7 +49,7 @@ struct LoginView: View { private var wordmark: some View { VStack(spacing: 10) { Wordmark(size: 34) - Text("Your second brain, on paper.") + Text("Your second brain, on e-ink displays.") .font(.system(size: 13)) .foregroundStyle(Ink.muted) }