diff --git a/Scripts/package_app.sh b/Scripts/package_app.sh index 4a8a6de02..9e62c83dc 100755 --- a/Scripts/package_app.sh +++ b/Scripts/package_app.sh @@ -134,9 +134,10 @@ if [[ "$SIGNING_MODE" == "adhoc" ]]; then AUTO_CHECKS=false fi WIDGET_BUNDLE_ID="${BUNDLE_ID}.widget" -APP_GROUP_ID="group.com.steipete.codexbar" +APP_TEAM_ID="${APP_TEAM_ID:-Y5PE65HELJ}" +APP_GROUP_ID="${APP_TEAM_ID}.com.steipete.codexbar" if [[ "$BUNDLE_ID" == *".debug"* ]]; then - APP_GROUP_ID="group.com.steipete.codexbar.debug" + APP_GROUP_ID="${APP_TEAM_ID}.com.steipete.codexbar.debug" fi ENTITLEMENTS_DIR="$ROOT/.build/entitlements" APP_ENTITLEMENTS="${ENTITLEMENTS_DIR}/CodexBar.entitlements" @@ -197,6 +198,7 @@ cat > "$APP/Contents/Info.plist" <SUEnableAutomaticChecks<${AUTO_CHECKS}/> CodexBuildTimestamp${BUILD_TIMESTAMP} CodexGitCommit${GIT_COMMIT} + CodexBarTeamID${APP_TEAM_ID} PLIST @@ -292,6 +294,7 @@ if [[ -n "$(resolve_binary_path "CodexBarWidget" "${ARCH_LIST[0]}")" ]]; then CFBundleShortVersionString${MARKETING_VERSION} CFBundleVersion${BUILD_NUMBER} LSMinimumSystemVersion14.0 + CodexBarTeamID${APP_TEAM_ID} NSExtension NSExtensionPointIdentifiercom.apple.widgetkit-extension diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 68ba707aa..d16439317 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -63,7 +63,7 @@ enum MenuBarMetricPreference: String, CaseIterable, Identifiable { @MainActor @Observable final class SettingsStore { - static let sharedDefaults = UserDefaults(suiteName: "group.com.steipete.codexbar") + static let sharedDefaults = AppGroupSupport.sharedDefaults() static let mergedOverviewProviderLimit = 3 static let isRunningTests: Bool = { let env = ProcessInfo.processInfo.environment @@ -124,6 +124,20 @@ final class SettingsStore { copilotTokenStore: any CopilotTokenStoring = KeychainCopilotTokenStore(), tokenAccountStore: any ProviderTokenAccountStoring = FileTokenAccountStore()) { + let appGroupID = AppGroupSupport.currentGroupID() + let appGroupMigration = AppGroupSupport.migrateLegacyDataIfNeeded(standardDefaults: userDefaults) + let sharedDefaultsAvailable = Self.sharedDefaults != nil + if !Self.isRunningTests { + CodexBarLog.logger(LogCategories.settings).info( + "App group resolved", + metadata: [ + "groupID": appGroupID, + "sharedDefaultsAvailable": sharedDefaultsAvailable ? "1" : "0", + "migrationStatus": appGroupMigration.status.rawValue, + "migratedSnapshot": appGroupMigration.copiedSnapshot ? "1" : "0", + ]) + } + let hasStoredOpenAIWebAccessPreference = userDefaults.object(forKey: "openAIWebAccessEnabled") != nil let hadExistingConfig = (try? configStore.load()) != nil let legacyStores = CodexBarConfigMigrator.LegacyStores( diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index c818e13cc..fd00cf187 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -235,8 +235,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin // Status items for individual providers are now created lazily in updateVisibility() super.init() self.wireBindings() - self.updateIcons() self.updateVisibility() + self.updateIcons() NotificationCenter.default.addObserver( self, selector: #selector(self.handleDebugReplayNotification(_:)), diff --git a/Sources/CodexBarCore/AppGroupSupport.swift b/Sources/CodexBarCore/AppGroupSupport.swift new file mode 100644 index 000000000..fa2237668 --- /dev/null +++ b/Sources/CodexBarCore/AppGroupSupport.swift @@ -0,0 +1,210 @@ +import Foundation +#if os(macOS) +import Security +#endif + +public enum AppGroupSupport { + public static let defaultTeamID = "Y5PE65HELJ" + public static let teamIDInfoKey = "CodexBarTeamID" + public static let legacyReleaseGroupID = "group.com.steipete.codexbar" + public static let legacyDebugGroupID = "group.com.steipete.codexbar.debug" + public static let widgetSnapshotFilename = "widget-snapshot.json" + public static let migrationVersion = 1 + public static let migrationVersionKey = "appGroupMigrationVersion" + + public struct MigrationResult: Sendable { + public enum Status: String, Sendable { + case alreadyCompleted + case targetUnavailable + case noChangesNeeded + case migrated + } + + public let status: Status + public let copiedSnapshot: Bool + + public init(status: Status, copiedSnapshot: Bool = false) { + self.status = status + self.copiedSnapshot = copiedSnapshot + } + } + + public static func currentGroupID(for bundleID: String? = Bundle.main.bundleIdentifier) -> String { + self.currentGroupID(teamID: self.resolvedTeamID(), bundleID: bundleID) + } + + static func currentGroupID(teamID: String, bundleID: String?) -> String { + let base = "\(teamID).com.steipete.codexbar" + return self.isDebugBundleID(bundleID) ? "\(base).debug" : base + } + + public static func resolvedTeamID(bundle: Bundle = .main) -> String { + self.resolvedTeamID( + infoDictionaryOverride: bundle.infoDictionary, + bundleURLOverride: bundle.bundleURL) + } + + static func resolvedTeamID( + infoDictionaryOverride: [String: Any]?, + bundleURLOverride: URL?) -> String + { + if let teamID = self.codeSignatureTeamID(bundleURL: bundleURLOverride) { + return teamID + } + if let teamID = infoDictionaryOverride?[self.teamIDInfoKey] as? String, + !teamID.isEmpty + { + return teamID + } + return self.defaultTeamID + } + + public static func legacyGroupID(for bundleID: String? = Bundle.main.bundleIdentifier) -> String { + self.isDebugBundleID(bundleID) ? self.legacyDebugGroupID : self.legacyReleaseGroupID + } + + public static func sharedDefaults( + bundleID: String? = Bundle.main.bundleIdentifier, + fileManager: FileManager = .default) + -> UserDefaults? + { + guard self.currentContainerURL(bundleID: bundleID, fileManager: fileManager) != nil else { return nil } + return UserDefaults(suiteName: self.currentGroupID(for: bundleID)) + } + + public static func currentContainerURL( + bundleID: String? = Bundle.main.bundleIdentifier, + fileManager: FileManager = .default) + -> URL? + { + #if os(macOS) + fileManager.containerURL(forSecurityApplicationGroupIdentifier: self.currentGroupID(for: bundleID)) + #else + nil + #endif + } + + public static func snapshotURL( + bundleID: String? = Bundle.main.bundleIdentifier, + fileManager: FileManager = .default, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) + -> URL + { + if let container = self.currentContainerURL(bundleID: bundleID, fileManager: fileManager) { + return container.appendingPathComponent(self.widgetSnapshotFilename, isDirectory: false) + } + + let directory = self.localFallbackDirectory(fileManager: fileManager, homeDirectory: homeDirectory) + return directory.appendingPathComponent(self.widgetSnapshotFilename, isDirectory: false) + } + + public static func localFallbackDirectory( + fileManager: FileManager = .default, + homeDirectory _: URL = FileManager.default.homeDirectoryForCurrentUser) + -> URL + { + let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? fileManager.temporaryDirectory + let directory = base.appendingPathComponent("CodexBar", isDirectory: true) + try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + public static func legacyContainerCandidateURL( + bundleID: String? = Bundle.main.bundleIdentifier, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) + -> URL + { + homeDirectory + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Group Containers", isDirectory: true) + .appendingPathComponent(self.legacyGroupID(for: bundleID), isDirectory: true) + } + + public static func migrateLegacyDataIfNeeded( + bundleID: String? = Bundle.main.bundleIdentifier, + standardDefaults: UserDefaults = .standard, + fileManager: FileManager = .default, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser, + currentDefaultsOverride: UserDefaults? = nil, + currentSnapshotURLOverride: URL? = nil, + legacySnapshotURLOverride: URL? = nil) + -> MigrationResult + { + if standardDefaults.integer(forKey: self.migrationVersionKey) >= self.migrationVersion { + return MigrationResult(status: .alreadyCompleted) + } + + guard currentDefaultsOverride ?? self.sharedDefaults(bundleID: bundleID, fileManager: fileManager) != nil else { + return MigrationResult(status: .targetUnavailable) + } + + let currentSnapshotURL = currentSnapshotURLOverride + ?? self.currentContainerURL(bundleID: bundleID, fileManager: fileManager)? + .appendingPathComponent(self.widgetSnapshotFilename, isDirectory: false) + let legacySnapshotURL = legacySnapshotURLOverride + ?? self.legacyContainerCandidateURL(bundleID: bundleID, homeDirectory: homeDirectory) + .appendingPathComponent(self.widgetSnapshotFilename, isDirectory: false) + + let copiedSnapshot = { + guard let currentSnapshotURL else { return false } + guard !fileManager.fileExists(atPath: currentSnapshotURL.path), + fileManager.fileExists(atPath: legacySnapshotURL.path) + else { + return false + } + do { + try fileManager.createDirectory( + at: currentSnapshotURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + try fileManager.copyItem(at: legacySnapshotURL, to: currentSnapshotURL) + return true + } catch { + return false + } + }() + + let result = if copiedSnapshot { + MigrationResult(status: .migrated, copiedSnapshot: true) + } else { + MigrationResult(status: .noChangesNeeded) + } + + standardDefaults.set(self.migrationVersion, forKey: self.migrationVersionKey) + return result + } + + private static func isDebugBundleID(_ bundleID: String?) -> Bool { + guard let bundleID, !bundleID.isEmpty else { return false } + return bundleID.contains(".debug") + } + + private static func codeSignatureTeamID(bundleURL: URL?) -> String? { + #if os(macOS) + guard let bundleURL else { return nil } + + var staticCode: SecStaticCode? + guard SecStaticCodeCreateWithPath(bundleURL as CFURL, SecCSFlags(), &staticCode) == errSecSuccess, + let code = staticCode + else { + return nil + } + + var infoCF: CFDictionary? + guard SecCodeCopySigningInformation( + code, + SecCSFlags(rawValue: kSecCSSigningInformation), + &infoCF) == errSecSuccess, + let info = infoCF as? [String: Any], + let teamID = info[kSecCodeInfoTeamIdentifier as String] as? String, + !teamID.isEmpty + else { + return nil + } + return teamID + #else + _ = bundleURL + return nil + #endif + } +} diff --git a/Sources/CodexBarCore/KeychainAccessGate.swift b/Sources/CodexBarCore/KeychainAccessGate.swift index 548a4add4..86451a75d 100644 --- a/Sources/CodexBarCore/KeychainAccessGate.swift +++ b/Sources/CodexBarCore/KeychainAccessGate.swift @@ -5,7 +5,6 @@ import SweetCookieKit public enum KeychainAccessGate { private static let flagKey = "debugDisableKeychainAccess" - private static let appGroupID = "group.com.steipete.codexbar" @TaskLocal private static var taskOverrideValue: Bool? private nonisolated(unsafe) static var overrideValue: Bool? @@ -19,9 +18,7 @@ public enum KeychainAccessGate { #endif if let overrideValue { return overrideValue } if UserDefaults.standard.bool(forKey: Self.flagKey) { return true } - if let shared = UserDefaults(suiteName: Self.appGroupID), - shared.bool(forKey: Self.flagKey) - { + if let shared = AppGroupSupport.sharedDefaults(), shared.bool(forKey: Self.flagKey) { return true } return false diff --git a/Sources/CodexBarCore/WidgetSnapshot.swift b/Sources/CodexBarCore/WidgetSnapshot.swift index 0dc371f02..d87c4dcca 100644 --- a/Sources/CodexBarCore/WidgetSnapshot.swift +++ b/Sources/CodexBarCore/WidgetSnapshot.swift @@ -114,17 +114,16 @@ public struct WidgetSnapshot: Codable, Sendable { } public enum WidgetSnapshotStore { - public static let appGroupID = "group.com.steipete.codexbar" - private static let filename = "widget-snapshot.json" + private static let filename = AppGroupSupport.widgetSnapshotFilename public static func load(bundleID: String? = Bundle.main.bundleIdentifier) -> WidgetSnapshot? { - guard let url = self.snapshotURL(bundleID: bundleID) else { return nil } + let url = self.snapshotURL(bundleID: bundleID) guard let data = try? Data(contentsOf: url) else { return nil } return try? self.decoder.decode(WidgetSnapshot.self, from: data) } public static func save(_ snapshot: WidgetSnapshot, bundleID: String? = Bundle.main.bundleIdentifier) { - guard let url = self.snapshotURL(bundleID: bundleID) else { return } + let url = self.snapshotURL(bundleID: bundleID) do { let data = try self.encoder.encode(snapshot) try data.write(to: url, options: [.atomic]) @@ -133,32 +132,12 @@ public enum WidgetSnapshotStore { } } - private static func snapshotURL(bundleID: String?) -> URL? { - let fm = FileManager.default - let groupID = self.groupID(for: bundleID) - #if os(macOS) - if let groupID, let container = fm.containerURL(forSecurityApplicationGroupIdentifier: groupID) { - return container.appendingPathComponent(self.filename, isDirectory: false) - } - #endif - - let base = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first - ?? fm.temporaryDirectory - let dir = base.appendingPathComponent("CodexBar", isDirectory: true) - try? fm.createDirectory(at: dir, withIntermediateDirectories: true) - return dir.appendingPathComponent(self.filename, isDirectory: false) + private static func snapshotURL(bundleID: String?) -> URL { + AppGroupSupport.snapshotURL(bundleID: bundleID) } public static func appGroupID(for bundleID: String?) -> String? { - self.groupID(for: bundleID) - } - - private static func groupID(for bundleID: String?) -> String? { - guard let bundleID, !bundleID.isEmpty else { return self.appGroupID } - if bundleID.contains(".debug") { - return "group.com.steipete.codexbar.debug" - } - return self.appGroupID + AppGroupSupport.currentGroupID(for: bundleID) } private static var encoder: JSONEncoder { @@ -178,7 +157,7 @@ public enum WidgetSelectionStore { private static let selectedProviderKey = "widgetSelectedProvider" public static func loadSelectedProvider(bundleID: String? = Bundle.main.bundleIdentifier) -> UsageProvider? { - guard let defaults = self.sharedDefaults(bundleID: bundleID) else { return nil } + let defaults = self.sharedDefaults(bundleID: bundleID) guard let raw = defaults.string(forKey: self.selectedProviderKey) else { return nil } return UsageProvider(rawValue: raw) } @@ -187,12 +166,11 @@ public enum WidgetSelectionStore { _ provider: UsageProvider, bundleID: String? = Bundle.main.bundleIdentifier) { - guard let defaults = self.sharedDefaults(bundleID: bundleID) else { return } + let defaults = self.sharedDefaults(bundleID: bundleID) defaults.set(provider.rawValue, forKey: self.selectedProviderKey) } - private static func sharedDefaults(bundleID: String?) -> UserDefaults? { - guard let groupID = WidgetSnapshotStore.appGroupID(for: bundleID) else { return nil } - return UserDefaults(suiteName: groupID) + private static func sharedDefaults(bundleID: String?) -> UserDefaults { + AppGroupSupport.sharedDefaults(bundleID: bundleID) ?? .standard } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index 7e4a7ddb0..e100f91b9 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -183,7 +183,7 @@ struct CodexBarTimelineProvider: AppIntentTimelineProvider { in context: Context) async -> Timeline { let provider = configuration.provider.provider - let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot() + let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot() let entry = CodexBarWidgetEntry(date: Date(), provider: provider, snapshot: snapshot) let refresh = Date().addingTimeInterval(30 * 60) return Timeline(entries: [entry], policy: .after(refresh)) @@ -212,7 +212,7 @@ struct CodexBarSwitcherTimelineProvider: TimelineProvider { } private func makeEntry() -> CodexBarSwitcherEntry { - let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot() + let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot() let providers = self.availableProviders(from: snapshot) let stored = WidgetSelectionStore.loadSelectedProvider() let selected = providers.first { $0 == stored } ?? providers.first ?? .codex @@ -261,7 +261,7 @@ struct CodexBarCompactTimelineProvider: AppIntentTimelineProvider { in context: Context) async -> Timeline { let provider = configuration.provider.provider - let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot() + let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot() let entry = CodexBarCompactEntry( date: Date(), provider: provider, @@ -273,6 +273,10 @@ struct CodexBarCompactTimelineProvider: AppIntentTimelineProvider { } enum WidgetPreviewData { + static func emptySnapshot() -> WidgetSnapshot { + WidgetSnapshot(entries: [], enabledProviders: [], generatedAt: Date()) + } + static func snapshot() -> WidgetSnapshot { let primary = RateWindow(usedPercent: 35, windowMinutes: nil, resetsAt: nil, resetDescription: "Resets in 4h") let secondary = RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: "Resets in 3d") diff --git a/Tests/CodexBarTests/AppGroupSupportTests.swift b/Tests/CodexBarTests/AppGroupSupportTests.swift new file mode 100644 index 000000000..249f109c2 --- /dev/null +++ b/Tests/CodexBarTests/AppGroupSupportTests.swift @@ -0,0 +1,88 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct AppGroupSupportTests { + @Test + func `app group identifiers use resolved team-prefixed release and debug variants`() { + #expect( + AppGroupSupport.currentGroupID(teamID: "Y5PE65HELJ", bundleID: "com.steipete.codexbar") + == "Y5PE65HELJ.com.steipete.codexbar") + #expect( + AppGroupSupport.currentGroupID(teamID: "ABCDE12345", bundleID: "com.steipete.codexbar.debug") + == "ABCDE12345.com.steipete.codexbar.debug") + #expect( + AppGroupSupport.legacyGroupID(for: "com.steipete.codexbar") + == "group.com.steipete.codexbar") + #expect( + AppGroupSupport.legacyGroupID(for: "com.steipete.codexbar.debug") + == "group.com.steipete.codexbar.debug") + } + + @Test + func `resolved team id falls back to plist and then default`() { + #expect( + AppGroupSupport.resolvedTeamID( + infoDictionaryOverride: [AppGroupSupport.teamIDInfoKey: "ABCDE12345"], + bundleURLOverride: nil) == "ABCDE12345") + #expect( + AppGroupSupport.resolvedTeamID( + infoDictionaryOverride: nil, + bundleURLOverride: nil) == AppGroupSupport.defaultTeamID) + } + + @Test + func `legacy migration copies snapshot once`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let standardSuite = "AppGroupSupportTests-standard-\(UUID().uuidString)" + let currentSuite = "AppGroupSupportTests-current-\(UUID().uuidString)" + let legacySuite = "AppGroupSupportTests-legacy-\(UUID().uuidString)" + + let standardDefaults = try #require(UserDefaults(suiteName: standardSuite)) + let currentDefaults = try #require(UserDefaults(suiteName: currentSuite)) + let legacyDefaults = try #require(UserDefaults(suiteName: legacySuite)) + standardDefaults.removePersistentDomain(forName: standardSuite) + currentDefaults.removePersistentDomain(forName: currentSuite) + legacyDefaults.removePersistentDomain(forName: legacySuite) + + legacyDefaults.set(true, forKey: "debugDisableKeychainAccess") + legacyDefaults.set(UsageProvider.cursor.rawValue, forKey: "widgetSelectedProvider") + + let legacySnapshotURL = root.appendingPathComponent( + "legacy/widget-snapshot.json", + isDirectory: false) + try fileManager.createDirectory( + at: legacySnapshotURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + try Data("legacy-snapshot".utf8).write(to: legacySnapshotURL) + + let currentSnapshotURL = root.appendingPathComponent("current/widget-snapshot.json", isDirectory: false) + let result = AppGroupSupport.migrateLegacyDataIfNeeded( + bundleID: "com.steipete.codexbar", + standardDefaults: standardDefaults, + currentDefaultsOverride: currentDefaults, + currentSnapshotURLOverride: currentSnapshotURL, + legacySnapshotURLOverride: legacySnapshotURL) + + #expect(result.status == .migrated) + #expect(result.copiedSnapshot) + #expect(!currentDefaults.bool(forKey: "debugDisableKeychainAccess")) + #expect(currentDefaults.string(forKey: "widgetSelectedProvider") == nil) + #expect(fileManager.fileExists(atPath: currentSnapshotURL.path)) + #expect( + standardDefaults.integer(forKey: AppGroupSupport.migrationVersionKey) + == AppGroupSupport.migrationVersion) + + let secondResult = AppGroupSupport.migrateLegacyDataIfNeeded( + bundleID: "com.steipete.codexbar", + standardDefaults: standardDefaults, + currentDefaultsOverride: currentDefaults, + currentSnapshotURLOverride: currentSnapshotURL, + legacySnapshotURLOverride: legacySnapshotURL) + #expect(secondResult.status == .alreadyCompleted) + } +} diff --git a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift index 71c6461de..56c376811 100644 --- a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift +++ b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift @@ -16,6 +16,13 @@ struct CodexBarWidgetProviderTests { #expect(ProviderChoice.opencodego.provider == .opencodego) } + @Test + func `supported providers fall back to codex when snapshot is empty`() { + let snapshot = WidgetSnapshot(entries: [], enabledProviders: [], generatedAt: Date()) + + #expect(CodexBarSwitcherTimelineProvider.supportedProviders(from: snapshot) == [.codex]) + } + @Test func `supported providers keep alibaba when it is the only enabled provider`() { let now = Date(timeIntervalSince1970: 1_700_000_000)