Migrate token storage to shared Keychain

Add SharedKeychainManager and migrate token storage from the old iCloud Key-Value (KV) approach to a synchronized Keychain-backed solution. Replace iCloudTokenManager usages across WatchSessionManager, TokenManager, and WatchConnectivityManager, update saveToken API to syncToSharedKeychain, and add KV-store migration logic that moves existing KV entries into the shared Keychain and clears the old KV store. Update entitlements (add keychain-access-groups) for both Runner and the Watch app, add/remove files in the Xcode project, delete iCloudTokenManager.swift. Also include access-group resolution, logging, and compatibility observer methods in the new manager.
This commit is contained in:
Horváth Gergely
2026-02-13 23:29:19 +01:00
committed by 4831c0
parent 71f1412164
commit 86c7641c60
10 changed files with 471 additions and 399 deletions

View File

@@ -6,6 +6,10 @@
<array>
<string>group.app.firka.firkaa</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)app.firka.shared</string>
</array>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)app.firka.firkaa</string>
</dict>

View File

@@ -375,7 +375,7 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate {
try TokenManager.shared.saveToken(
token,
syncToICloud: false,
syncToSharedKeychain: false,
forceAccountSwitch: shouldForceAccountSwitch
)
print("[Watch] Token saved successfully")

View File

@@ -289,7 +289,7 @@ struct ReauthRequiredView: View {
try TokenManager.shared.saveToken(
token,
syncToICloud: false,
syncToSharedKeychain: false,
forceAccountSwitch: shouldForceAccountSwitch
)

View File

@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objectVersion = 70;
objects = {
/* Begin PBXBuildFile section */
@@ -12,13 +12,13 @@
213F8C0F6B5418B02DE14204 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 035E9CCBCC6585D0F5639031 /* Pods_Runner.framework */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
4F27D4D22F3F2F7700749B9A /* SharedKeychainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F27D4D12F3F2F7700749B9A /* SharedKeychainManager.swift */; };
4F27D4D32F3F2F7700749B9A /* SharedKeychainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F27D4D12F3F2F7700749B9A /* SharedKeychainManager.swift */; };
4F30C7592E8FBF26008BB46C /* LiveActivityMethodChannelManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F30C7582E8FBF26008BB46C /* LiveActivityMethodChannelManager.swift */; };
4F30C7672E8FBF9D008BB46C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F30C7662E8FBF9D008BB46C /* WidgetKit.framework */; };
4F30C7692E8FBF9D008BB46C /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F30C7682E8FBF9D008BB46C /* SwiftUI.framework */; };
4F30C7782E8FBF9F008BB46C /* LiveActivityWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 4F30C7652E8FBF9D008BB46C /* LiveActivityWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
4F45A1752F27FA3C0020E6F1 /* HomeWidgetMethodChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F45A1732F27FA3C0020E6F1 /* HomeWidgetMethodChannel.swift */; };
4F5824802F35468E00B92EA7 /* iCloudTokenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F58247F2F35468D00B92EA7 /* iCloudTokenManager.swift */; };
4F5824812F35468E00B92EA7 /* iCloudTokenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F58247F2F35468D00B92EA7 /* iCloudTokenManager.swift */; };
4F5824832F3548B800B92EA7 /* WatchToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F5824822F3548B800B92EA7 /* WatchToken.swift */; };
4F5824842F3548B800B92EA7 /* WatchToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F5824822F3548B800B92EA7 /* WatchToken.swift */; };
4F5965F82F2F0C1600A3DB03 /* WatchSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F5965F72F2F0C1600A3DB03 /* WatchSessionManager.swift */; };
@@ -177,12 +177,12 @@
4836947EC3B04B475B3DA1F8 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
485F3791F25A288C749509B2 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
4F25FCBD2EB1790E0060DAAA /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
4F27D4D12F3F2F7700749B9A /* SharedKeychainManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedKeychainManager.swift; sourceTree = "<group>"; };
4F30C7582E8FBF26008BB46C /* LiveActivityMethodChannelManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityMethodChannelManager.swift; sourceTree = "<group>"; };
4F30C7652E8FBF9D008BB46C /* LiveActivityWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = LiveActivityWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; };
4F30C7662E8FBF9D008BB46C /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
4F30C7682E8FBF9D008BB46C /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
4F45A1732F27FA3C0020E6F1 /* HomeWidgetMethodChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeWidgetMethodChannel.swift; sourceTree = "<group>"; };
4F58247F2F35468D00B92EA7 /* iCloudTokenManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iCloudTokenManager.swift; sourceTree = "<group>"; };
4F5824822F3548B800B92EA7 /* WatchToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchToken.swift; sourceTree = "<group>"; };
4F5965F72F2F0C1600A3DB03 /* WatchSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchSessionManager.swift; sourceTree = "<group>"; };
4F5965FD2F2F0EAF00A3DB03 /* FirkaWatchComplicationsExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FirkaWatchComplicationsExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -222,35 +222,35 @@
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
4F0EA0512F2BD2A2003CC89E /* Exceptions for "HomeWidgetsExtension" folder in "Runner" target */ = {
4F0EA0512F2BD2A2003CC89E /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Controls/AppControls.swift,
);
target = 97C146ED1CF9000F007C117D /* Runner */;
};
4F4E70D02EF565FF00C90AD1 /* Exceptions for "LiveActivityWidget" folder in "Runner" target */ = {
4F4E70D02EF565FF00C90AD1 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
ActivityAttributes.swift,
);
target = 97C146ED1CF9000F007C117D /* Runner */;
};
4F5966082F2F0EB100A3DB03 /* Exceptions for "FirkaWatchComplications" folder in "FirkaWatchComplicationsExtension" target */ = {
4F5966082F2F0EB100A3DB03 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 4F5965FC2F2F0EAF00A3DB03 /* FirkaWatchComplicationsExtension */;
};
4F6C1D3E2ECD3FBD00F819D7 /* Exceptions for "LiveActivityWidget" folder in "LiveActivityWidget" target */ = {
4F6C1D3E2ECD3FBD00F819D7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 4F30C7642E8FBF9D008BB46C /* LiveActivityWidget */;
};
4FE64E472F27B07B006F9205 /* Exceptions for "HomeWidgetsExtension" folder in "HomeWidgetsExtension" target */ = {
4FE64E472F27B07B006F9205 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
@@ -260,55 +260,10 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
4F30C76A2E8FBF9D008BB46C /* LiveActivityWidget */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
4F4E70D02EF565FF00C90AD1 /* Exceptions for "LiveActivityWidget" folder in "Runner" target */,
4F6C1D3E2ECD3FBD00F819D7 /* Exceptions for "LiveActivityWidget" folder in "LiveActivityWidget" target */,
);
explicitFileTypes = {
};
explicitFolders = (
);
path = LiveActivityWidget;
sourceTree = "<group>";
};
4F5966002F2F0EAF00A3DB03 /* FirkaWatchComplications */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
4F5966082F2F0EB100A3DB03 /* Exceptions for "FirkaWatchComplications" folder in "FirkaWatchComplicationsExtension" target */,
);
explicitFileTypes = {
};
explicitFolders = (
);
path = FirkaWatchComplications;
sourceTree = "<group>";
};
4FE64E362F27B07A006F9205 /* HomeWidgetsExtension */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
4F0EA0512F2BD2A2003CC89E /* Exceptions for "HomeWidgetsExtension" folder in "Runner" target */,
4FE64E472F27B07B006F9205 /* Exceptions for "HomeWidgetsExtension" folder in "HomeWidgetsExtension" target */,
);
explicitFileTypes = {
};
explicitFolders = (
);
path = HomeWidgetsExtension;
sourceTree = "<group>";
};
4FF81B7B2F2EB4C100E95BA0 /* FirkaWatch Watch App */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
explicitFileTypes = {
};
explicitFolders = (
);
path = "FirkaWatch Watch App";
sourceTree = "<group>";
};
4F30C76A2E8FBF9D008BB46C /* LiveActivityWidget */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (4F4E70D02EF565FF00C90AD1 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 4F6C1D3E2ECD3FBD00F819D7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = LiveActivityWidget; sourceTree = "<group>"; };
4F5966002F2F0EAF00A3DB03 /* FirkaWatchComplications */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (4F5966082F2F0EB100A3DB03 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = FirkaWatchComplications; sourceTree = "<group>"; };
4FE64E362F27B07A006F9205 /* HomeWidgetsExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (4F0EA0512F2BD2A2003CC89E /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 4FE64E472F27B07B006F9205 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = HomeWidgetsExtension; sourceTree = "<group>"; };
4FF81B7B2F2EB4C100E95BA0 /* FirkaWatch Watch App */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = "FirkaWatch Watch App"; sourceTree = "<group>"; };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -387,10 +342,10 @@
4F7701CD2F2EC1AA00B79171 /* API */ = {
isa = PBXGroup;
children = (
4F27D4D12F3F2F7700749B9A /* SharedKeychainManager.swift */,
4F7701ED2F2EC2F500B79171 /* KretaAPIClient.swift */,
4F7701EE2F2EC2F500B79171 /* TokenManager.swift */,
4FCB030C2F330F3B00418E63 /* KretaAPIModels.swift */,
4F58247F2F35468D00B92EA7 /* iCloudTokenManager.swift */,
);
path = API;
sourceTree = "<group>";
@@ -756,10 +711,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
@@ -848,10 +807,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
@@ -916,12 +879,12 @@
files = (
4F7701E62F2EC1AA00B79171 /* Grade.swift in Sources */,
4F7701E72F2EC1AA00B79171 /* WidgetColors.swift in Sources */,
4F27D4D22F3F2F7700749B9A /* SharedKeychainManager.swift in Sources */,
4F7701E82F2EC1AA00B79171 /* Average.swift in Sources */,
4F5824842F3548B800B92EA7 /* WatchToken.swift in Sources */,
4FCB030D2F330F3B00418E63 /* KretaAPIModels.swift in Sources */,
4F7701E92F2EC1AA00B79171 /* SeasonalIconHelper.swift in Sources */,
4F7701EA2F2EC1AA00B79171 /* Lesson.swift in Sources */,
4F5824802F35468E00B92EA7 /* iCloudTokenManager.swift in Sources */,
4F7701EB2F2EC1AA00B79171 /* Subject.swift in Sources */,
4F7701EC2F2EC1AA00B79171 /* WidgetData.swift in Sources */,
4F7701EF2F2EC2F500B79171 /* TokenManager.swift in Sources */,
@@ -934,12 +897,12 @@
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
4F27D4D32F3F2F7700749B9A /* SharedKeychainManager.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
4F30C7592E8FBF26008BB46C /* LiveActivityMethodChannelManager.swift in Sources */,
4F45A1752F27FA3C0020E6F1 /* HomeWidgetMethodChannel.swift in Sources */,
4F5965F82F2F0C1600A3DB03 /* WatchSessionManager.swift in Sources */,
4F5824832F3548B800B92EA7 /* WatchToken.swift in Sources */,
4F5824812F35468E00B92EA7 /* iCloudTokenManager.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -1067,21 +1030,21 @@
};
name = Profile;
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1068;
CURRENT_PROJECT_VERSION = 1101;
DEVELOPMENT_TEAM = UT7MSP4GWZ;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Firka Testing";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education";
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Firka Testing";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -1108,7 +1071,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1068;
CURRENT_PROJECT_VERSION = 1101;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = app.firka.firka.RunnerTests;
@@ -1127,7 +1090,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1068;
CURRENT_PROJECT_VERSION = 1101;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = app.firka.firka.RunnerTests;
@@ -1144,7 +1107,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1068;
CURRENT_PROJECT_VERSION = 1101;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = app.firka.firka.RunnerTests;
@@ -1171,7 +1134,7 @@
CODE_SIGN_ENTITLEMENTS = LiveActivityWidget.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1068;
CURRENT_PROJECT_VERSION = 1101;
DEVELOPMENT_TEAM = UT7MSP4GWZ;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -1222,7 +1185,7 @@
CODE_SIGN_ENTITLEMENTS = LiveActivityWidget.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1068;
CURRENT_PROJECT_VERSION = 1101;
DEVELOPMENT_TEAM = UT7MSP4GWZ;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -1270,7 +1233,7 @@
CODE_SIGN_ENTITLEMENTS = LiveActivityWidget.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1068;
CURRENT_PROJECT_VERSION = 1101;
DEVELOPMENT_TEAM = UT7MSP4GWZ;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -1317,7 +1280,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = FirkaWatchComplicationsExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1068;
CURRENT_PROJECT_VERSION = 1101;
DEVELOPMENT_TEAM = UT7MSP4GWZ;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -1370,7 +1333,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = FirkaWatchComplicationsExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1068;
CURRENT_PROJECT_VERSION = 1101;
DEVELOPMENT_TEAM = UT7MSP4GWZ;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -1420,7 +1383,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = FirkaWatchComplicationsExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1068;
CURRENT_PROJECT_VERSION = 1101;
DEVELOPMENT_TEAM = UT7MSP4GWZ;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -1471,7 +1434,7 @@
CODE_SIGN_ENTITLEMENTS = HomeWidgetsExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1068;
CURRENT_PROJECT_VERSION = 1101;
DEVELOPMENT_TEAM = UT7MSP4GWZ;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -1522,7 +1485,7 @@
CODE_SIGN_ENTITLEMENTS = HomeWidgetsExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1068;
CURRENT_PROJECT_VERSION = 1101;
DEVELOPMENT_TEAM = UT7MSP4GWZ;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -1569,7 +1532,7 @@
CODE_SIGN_ENTITLEMENTS = HomeWidgetsExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1068;
CURRENT_PROJECT_VERSION = 1101;
DEVELOPMENT_TEAM = UT7MSP4GWZ;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -1600,9 +1563,9 @@
};
name = Profile;
};
4FF81B9C2F2EB4C300E95BA0 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
4FF81B9C2F2EB4C300E95BA0 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ARCHS = "$(ARCHS_STANDARD)";
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
@@ -1620,10 +1583,10 @@
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = FirkaWatch;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = app.firka.firkaa;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = FirkaWatch;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = app.firka.firkaa;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -1652,9 +1615,9 @@
};
name = Debug;
};
4FF81B9D2F2EB4C300E95BA0 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
4FF81B9D2F2EB4C300E95BA0 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ARCHS = "$(ARCHS_STANDARD)";
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
@@ -1672,10 +1635,10 @@
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = FirkaWatch;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = app.firka.firkaa;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = FirkaWatch;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = app.firka.firkaa;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -1701,9 +1664,9 @@
};
name = Release;
};
4FF81B9E2F2EB4C300E95BA0 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
4FF81B9E2F2EB4C300E95BA0 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ARCHS = "$(ARCHS_STANDARD)";
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
@@ -1721,10 +1684,10 @@
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = FirkaWatch;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = app.firka.firkaa;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = FirkaWatch;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = app.firka.firkaa;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -1865,21 +1828,21 @@
};
name = Release;
};
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1068;
CURRENT_PROJECT_VERSION = 1101;
DEVELOPMENT_TEAM = UT7MSP4GWZ;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Firka Testing";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education";
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Firka Testing";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -1901,21 +1864,21 @@
};
name = Debug;
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1068;
CURRENT_PROJECT_VERSION = 1101;
DEVELOPMENT_TEAM = UT7MSP4GWZ;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Firka Testing";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education";
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Firka Testing";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",

View File

@@ -46,7 +46,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>1068</string>
<string>1101</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>

View File

@@ -10,6 +10,10 @@
<array>
<string>group.app.firka.firkaa</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)app.firka.shared</string>
</array>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)app.firka.firkaa</string>
</dict>

View File

@@ -111,12 +111,12 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
token.expiryDate > Date().addingTimeInterval(skewSeconds)
}
private func fallbackTokenFromiCloud() -> [String: Any]? {
guard let token = iCloudTokenManager.shared.loadToken() else {
private func fallbackTokenFromSharedKeychain() -> [String: Any]? {
guard let token = SharedKeychainManager.shared.loadToken() else {
return nil
}
guard isTokenUsable(token, skewSeconds: 0) else {
print("[WatchSessionManager] iCloud fallback token is expired, skipping fallback")
print("[WatchSessionManager] Shared Keychain fallback token is expired, skipping fallback")
return nil
}
return tokenPayload(from: token)
@@ -297,17 +297,17 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
}
private func handleCheckiCloudToken(result: @escaping FlutterResult) {
print("[WatchSessionManager] Checking iCloud for token...")
print("[WatchSessionManager] Checking shared Keychain for token...")
guard let token = iCloudTokenManager.shared.loadToken() else {
print("[WatchSessionManager] No token in iCloud")
guard let token = SharedKeychainManager.shared.loadToken() else {
print("[WatchSessionManager] No token in shared Keychain")
result(["error": "no_token"])
return
}
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
print("[WatchSessionManager] Found iCloud token, expiry: \(formatter.string(from: token.expiryDate))")
print("[WatchSessionManager] Found shared Keychain token, expiry: \(formatter.string(from: token.expiryDate))")
result(tokenPayload(from: token))
}
@@ -345,11 +345,11 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
updatedAtMs: updatedAtMs
)
iCloudTokenManager.shared.saveToken(token, deviceName: "iPhone")
SharedKeychainManager.shared.saveToken(token)
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
print("[WatchSessionManager] Token saved to iCloud, expiry: \(formatter.string(from: expiryDate))")
print("[WatchSessionManager] Token saved to shared Keychain, expiry: \(formatter.string(from: expiryDate))")
result(nil)
}
@@ -366,7 +366,9 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
}
private func handleClearICloudToken(result: @escaping FlutterResult) {
iCloudTokenManager.shared.deleteToken()
SharedKeychainManager.shared.deleteToken()
SharedKeychainManager.shared.clearKVStore()
result(nil)
}
@@ -441,7 +443,7 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
switch action {
case "requestToken":
if !self.isFlutterWatchSyncReady {
if let tokenData = self.fallbackTokenFromiCloud() {
if let tokenData = self.fallbackTokenFromSharedKeychain() {
print("[WatchSessionManager] Flutter not ready, returning iCloud token to Watch")
replyHandler(["auth": tokenData])
} else {
@@ -457,7 +459,7 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
if error == "needsReauth" {
print("[WatchSessionManager] Flutter reported needsReauth, not using iCloud fallback")
replyHandler(["error": error])
} else if let fallbackToken = self.fallbackTokenFromiCloud() {
} else if let fallbackToken = self.fallbackTokenFromSharedKeychain() {
print("[WatchSessionManager] Flutter returned error (\(error)), falling back to iCloud token")
replyHandler(["auth": fallbackToken])
} else {
@@ -474,7 +476,7 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
replyHandler(["auth": tokenData])
}
} else {
if let fallbackToken = self.fallbackTokenFromiCloud() {
if let fallbackToken = self.fallbackTokenFromSharedKeychain() {
print("[WatchSessionManager] No Flutter token available, falling back to iCloud token")
replyHandler(["auth": fallbackToken])
} else {

View File

@@ -0,0 +1,281 @@
import Foundation
import Security
/// Manages the synced Keychain storage for cross-device token sharing via iCloud Keychain.
class SharedKeychainManager {
static let shared = SharedKeychainManager()
private let accessGroupSuffix = "app.firka.shared"
private lazy var accessGroup: String = resolveAccessGroup()
private let service = "app.firka.shared.token"
private let account = "syncedToken"
#if os(iOS)
private let deviceName = "iPhone"
#elseif os(watchOS)
private let deviceName = "Watch"
#endif
private var changeObserver: ((WatchToken) -> Void)?
private init() {}
private func resolveAccessGroup() -> String {
let probeService = "\(service).probe"
let probeAccount = "probe"
let probeValue = Data("probe".utf8)
let cleanupQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: probeService,
kSecAttrAccount as String: probeAccount,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
]
SecItemDelete(cleanupQuery as CFDictionary)
let addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: probeService,
kSecAttrAccount as String: probeAccount,
kSecValueData as String: probeValue,
kSecAttrSynchronizable as String: kCFBooleanTrue!,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
]
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
if addStatus == errSecSuccess || addStatus == errSecDuplicateItem {
let readQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: probeService,
kSecAttrAccount as String: probeAccount,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
kSecReturnAttributes as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let readStatus = SecItemCopyMatching(readQuery as CFDictionary, &result)
SecItemDelete(cleanupQuery as CFDictionary)
if readStatus == errSecSuccess,
let attributes = result as? [String: Any],
let resolvedGroup = attributes[kSecAttrAccessGroup as String] as? String,
!resolvedGroup.isEmpty {
print("[SharedKeychain] Resolved access group: \(resolvedGroup)")
return resolvedGroup
}
}
print("[SharedKeychain] Failed to resolve access group dynamically, using suffix fallback")
return accessGroupSuffix
}
// MARK: - Save Token (Synced)
@discardableResult
func saveToken(_ token: WatchToken, forceAccountSwitch: Bool = false) -> Bool {
if let existingToken = loadToken() {
if existingToken.isSameAccount(as: token) {
if !token.isNewer(than: existingToken) {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
formatter.timeZone = TimeZone.current
print("[SharedKeychain] Ignoring stale token save from \(deviceName), existing expiry: \(formatter.string(from: existingToken.expiryDate)), incoming: \(formatter.string(from: token.expiryDate))")
return false
}
} else {
if !forceAccountSwitch {
let incomingUpdatedAt = token.effectiveUpdatedAtMs ?? 0
let existingUpdatedAt = existingToken.effectiveUpdatedAtMs ?? 0
if incomingUpdatedAt > 0 &&
existingUpdatedAt > 0 &&
incomingUpdatedAt <= existingUpdatedAt {
print("[SharedKeychain] Ignoring cross-account stale token save from \(deviceName)")
return false
}
}
}
}
print("[SharedKeychain] Saving token to synced Keychain from \(deviceName)")
do {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let data = try encoder.encode(token)
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessGroup as String: accessGroup,
kSecAttrSynchronizable as String: kCFBooleanTrue!
]
SecItemDelete(deleteQuery as CFDictionary)
let addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessGroup as String: accessGroup,
kSecAttrSynchronizable as String: kCFBooleanTrue!,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
]
let status = SecItemAdd(addQuery as CFDictionary, nil)
if status == errSecSuccess {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
formatter.timeZone = TimeZone.current
print("[SharedKeychain] Token saved successfully to synced Keychain, expiry: \(formatter.string(from: token.expiryDate))")
return true
} else {
print("[SharedKeychain] Failed to save token to synced Keychain: \(status)")
return false
}
} catch {
print("[SharedKeychain] Failed to encode token: \(error)")
return false
}
}
// MARK: - Load Token (Synced)
func loadToken() -> WatchToken? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessGroup as String: accessGroup,
kSecAttrSynchronizable as String: kCFBooleanTrue!,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
if status != errSecItemNotFound {
print("[SharedKeychain] Failed to load token from synced Keychain: \(status)")
}
return nil
}
do {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let token = try decoder.decode(WatchToken.self, from: data)
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
formatter.timeZone = TimeZone.current
print("[SharedKeychain] Token loaded from synced Keychain, expiry: \(formatter.string(from: token.expiryDate))")
return token
} catch {
print("[SharedKeychain] Failed to decode token from synced Keychain: \(error)")
return nil
}
}
// MARK: - Delete Token (Synced)
func deleteToken() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessGroup as String: accessGroup,
kSecAttrSynchronizable as String: kCFBooleanTrue!
]
let status = SecItemDelete(query as CFDictionary)
if status == errSecSuccess || status == errSecItemNotFound {
print("[SharedKeychain] Token deleted from synced Keychain")
} else {
print("[SharedKeychain] Failed to delete token from synced Keychain: \(status)")
}
}
// MARK: - Observer (for compatibility with old iCloudTokenManager interface)
func observeChanges(_ observer: @escaping (WatchToken) -> Void) {
self.changeObserver = observer
}
func notifyObservers(with token: WatchToken) {
changeObserver?(token)
}
// MARK: - Migration from KV Store
func migrateFromKVStoreAndClear() -> WatchToken? {
let iCloudStore = NSUbiquitousKeyValueStore.default
iCloudStore.synchronize()
guard let accessToken = iCloudStore.string(forKey: "firka_access_token"),
let refreshToken = iCloudStore.string(forKey: "firka_refresh_token"),
let idToken = iCloudStore.string(forKey: "firka_id_token"),
let iss = iCloudStore.string(forKey: "firka_iss"),
let studentId = iCloudStore.string(forKey: "firka_student_id") else {
print("[SharedKeychain] No token found in KV Store to migrate")
clearKVStore()
return nil
}
let studentIdNorm = iCloudStore.longLong(forKey: "firka_student_id_norm")
let expiryTimestamp = iCloudStore.double(forKey: "firka_expiry_date")
let tokenVersionRaw = iCloudStore.longLong(forKey: "firka_token_version")
let updatedAtMsRaw = iCloudStore.longLong(forKey: "firka_updated_at_ms")
guard expiryTimestamp > 0 else {
print("[SharedKeychain] Invalid expiry date in KV Store")
clearKVStore()
return nil
}
let expiryDate = Date(timeIntervalSince1970: expiryTimestamp)
let token = WatchToken(
accessToken: accessToken,
refreshToken: refreshToken,
idToken: idToken,
iss: iss,
studentId: studentId,
studentIdNorm: studentIdNorm,
expiryDate: expiryDate,
tokenVersion: tokenVersionRaw > 0 ? tokenVersionRaw : nil,
updatedAtMs: updatedAtMsRaw > 0 ? updatedAtMsRaw : nil
)
print("[SharedKeychain] Migrated token from KV Store, expiry: \(expiryDate)")
clearKVStore()
return token
}
func clearKVStore() {
let iCloudStore = NSUbiquitousKeyValueStore.default
let keysToRemove = [
"firka_access_token",
"firka_refresh_token",
"firka_id_token",
"firka_iss",
"firka_student_id",
"firka_student_id_norm",
"firka_expiry_date",
"firka_token_version",
"firka_updated_at_ms",
"firka_last_updated_device",
"firka_last_update_timestamp"
]
for key in keysToRemove {
iCloudStore.removeObject(forKey: key)
}
iCloudStore.synchronize()
print("[SharedKeychain] Cleared old KV Store data")
}
}

View File

@@ -126,10 +126,10 @@ class TokenManager {
}
}
private func probeICloudTokenWithTimeout() async -> WatchToken? {
private func probeSharedKeychainTokenWithTimeout() async -> WatchToken? {
await withTaskGroup(of: WatchToken?.self) { group in
group.addTask {
iCloudTokenManager.shared.loadToken()
SharedKeychainManager.shared.loadToken()
}
group.addTask { [iCloudProbeTimeoutNs] in
try? await Task.sleep(nanoseconds: iCloudProbeTimeoutNs)
@@ -143,30 +143,32 @@ class TokenManager {
}
private init() {
iCloudTokenManager.shared.observeChanges { [weak self] iCloudToken in
runKVStoreMigrationIfNeeded()
SharedKeychainManager.shared.observeChanges { [weak self] sharedToken in
guard let self = self else { return }
let preferredStudentIdNorm = self.getActiveStudentIdNorm()
let isValidToken = iCloudToken.expiryDate > Date().addingTimeInterval(60)
let isValidToken = sharedToken.expiryDate > Date().addingTimeInterval(60)
let preferredLocalToken = self.localTokenFromKeychainAndFile(
preferredStudentIdNorm: preferredStudentIdNorm
)
if let preferredStudentIdNorm,
iCloudToken.studentIdNorm != preferredStudentIdNorm,
sharedToken.studentIdNorm != preferredStudentIdNorm,
preferredLocalToken != nil {
print("[TokenManager] Ignoring iCloud token for inactive account (\(iCloudToken.studentIdNorm)), active is \(preferredStudentIdNorm)")
print("[TokenManager] Ignoring shared Keychain token for inactive account (\(sharedToken.studentIdNorm)), active is \(preferredStudentIdNorm)")
return
}
let localToken = preferredLocalToken ?? self.localTokenFromKeychainAndFile()
if let localToken = localToken {
if iCloudToken.isNewer(than: localToken) {
print("[TokenManager] iCloud token is fresher, updating local cache")
try? self.saveTokenToKeychain(iCloudToken)
try? self.saveTokenToFile(iCloudToken)
self.setActiveStudentIdNorm(iCloudToken.studentIdNorm)
if sharedToken.isNewer(than: localToken) {
print("[TokenManager] Shared Keychain token is fresher, updating local cache")
try? self.saveTokenToKeychain(sharedToken)
try? self.saveTokenToFile(sharedToken)
self.setActiveStudentIdNorm(sharedToken.studentIdNorm)
#if os(watchOS)
DataStore.shared.checkTokenState()
@@ -178,13 +180,13 @@ class TokenManager {
}
#endif
} else {
print("[TokenManager] Local token is fresher or equal, ignoring iCloud update")
print("[TokenManager] Local token is fresher or equal, ignoring shared Keychain update")
}
} else {
print("[TokenManager] No local token, using iCloud token")
try? self.saveTokenToKeychain(iCloudToken)
try? self.saveTokenToFile(iCloudToken)
self.setActiveStudentIdNorm(iCloudToken.studentIdNorm)
print("[TokenManager] No local token, using shared Keychain token")
try? self.saveTokenToKeychain(sharedToken)
try? self.saveTokenToFile(sharedToken)
self.setActiveStudentIdNorm(sharedToken.studentIdNorm)
#if os(watchOS)
DataStore.shared.checkTokenState()
@@ -199,6 +201,32 @@ class TokenManager {
}
}
private let kvStoreMigrationKey = "firka_kv_store_migrated_v1"
private func runKVStoreMigrationIfNeeded() {
let alreadyMigrated = UserDefaults.standard.bool(forKey: kvStoreMigrationKey)
if alreadyMigrated {
return
}
print("[TokenManager] Running KV Store migration...")
if let migratedToken = SharedKeychainManager.shared.migrateFromKVStoreAndClear() {
SharedKeychainManager.shared.saveToken(migratedToken)
try? saveTokenToKeychain(migratedToken)
try? saveTokenToFile(migratedToken)
setActiveStudentIdNorm(migratedToken.studentIdNorm)
print("[TokenManager] KV Store migration completed, token migrated")
} else {
SharedKeychainManager.shared.clearKVStore()
print("[TokenManager] KV Store migration completed, no token to migrate")
}
UserDefaults.standard.set(true, forKey: kvStoreMigrationKey)
}
#if os(iOS)
private func notifyiOSTokenRecovered() {
print("[TokenManager] Valid token received from iCloud, notifying Flutter to clear reauth flag")
@@ -221,12 +249,12 @@ class TokenManager {
// MARK: - Load Token (active-account first)
func loadToken() -> WatchToken? {
let iCloudToken = iCloudTokenManager.shared.loadToken()
let sharedKeychainToken = SharedKeychainManager.shared.loadToken()
let keychainToken = loadTokenFromKeychain()
let fileToken = loadTokenFromFile()
var candidates: [(token: WatchToken, source: String)] = []
if let t = iCloudToken { candidates.append((t, "iCloud")) }
if let t = sharedKeychainToken { candidates.append((t, "sharedKeychain")) }
if let t = keychainToken { candidates.append((t, "keychain")) }
if let t = fileToken { candidates.append((t, "file")) }
@@ -299,7 +327,7 @@ class TokenManager {
func deleteToken() {
print("[TokenManager] Deleting token from all storage locations")
deleteTokenFromKeychain()
iCloudTokenManager.shared.deleteToken()
SharedKeychainManager.shared.deleteToken()
UserDefaults.standard.removeObject(forKey: activeStudentIdNormKey)
guard let filePath = getTokenFilePath() else { return }
@@ -309,7 +337,7 @@ class TokenManager {
// MARK: - Save Token
func saveToken(
_ token: WatchToken,
syncToICloud: Bool = false,
syncToSharedKeychain: Bool = false,
forceAccountSwitch: Bool = false
) throws {
if let currentToken = loadToken() {
@@ -326,8 +354,8 @@ class TokenManager {
try saveTokenToKeychain(token)
if syncToICloud {
iCloudTokenManager.shared.saveToken(token, deviceName: deviceName)
if syncToSharedKeychain {
SharedKeychainManager.shared.saveToken(token, forceAccountSwitch: forceAccountSwitch)
}
guard let filePath = getTokenFilePath() else {
@@ -515,26 +543,26 @@ class TokenManager {
print("[TokenManager] Starting central token recovery...")
if let iCloudToken = await probeICloudTokenWithTimeout() {
if let sharedToken = await probeSharedKeychainTokenWithTimeout() {
let now = Date()
if let preferredStudentIdNorm = getActiveStudentIdNorm(),
iCloudToken.studentIdNorm != preferredStudentIdNorm,
sharedToken.studentIdNorm != preferredStudentIdNorm,
localTokenFromKeychainAndFile(preferredStudentIdNorm: preferredStudentIdNorm) != nil {
print("[TokenManager] iCloud probe token belongs to inactive account, skipping direct apply")
} else if iCloudToken.expiryDate > now.addingTimeInterval(60) {
print("[TokenManager] iCloud probe found valid token, applying without recovery")
print("[TokenManager] Shared Keychain probe token belongs to inactive account, skipping direct apply")
} else if sharedToken.expiryDate > now.addingTimeInterval(60) {
print("[TokenManager] Shared Keychain probe found valid token, applying without recovery")
do {
try saveToken(iCloudToken, syncToICloud: false)
try saveToken(sharedToken, syncToSharedKeychain: false)
clearLastRecoveryFailure()
return iCloudToken
return sharedToken
} catch {
print("[TokenManager] Failed to apply iCloud probe token: \(error)")
print("[TokenManager] Failed to apply shared Keychain probe token: \(error)")
}
} else {
print("[TokenManager] iCloud probe token exists but access is expired, continuing with refresh path")
print("[TokenManager] Shared Keychain probe token exists but access is expired, continuing with refresh path")
}
} else {
print("[TokenManager] iCloud probe timed out or no token available, continuing with refresh path")
print("[TokenManager] Shared Keychain probe timed out or no token available, continuing with refresh path")
}
print("[TokenManager] Step 1: Trying local token refresh...")
@@ -567,7 +595,7 @@ class TokenManager {
if let recoveredToken = await tryRecoverFromKeychainAndWatch() {
if recoveredToken.expiryDate > Date().addingTimeInterval(60) {
print("[TokenManager] Step 2 SUCCESS: Keychain/Watch token is already valid")
try? saveToken(recoveredToken, syncToICloud: false)
try? saveToken(recoveredToken, syncToSharedKeychain: false)
clearLastRecoveryFailure()
return recoveredToken
} else {
@@ -591,48 +619,48 @@ class TokenManager {
print("[TokenManager] Step 2 SKIPPED: No token from Keychain/Watch")
}
print("[TokenManager] Step 3: Trying iCloud recovery with retries...")
print("[TokenManager] Step 3: Trying shared Keychain recovery with retries...")
let retryDelays: [TimeInterval] = [0, 5, 10, 5, 10]
var iCloudHasToken = false
var sharedKeychainHasToken = false
for (attempt, delay) in retryDelays.enumerated() {
if delay > 0 {
if !iCloudHasToken && attempt > 0 {
print("[TokenManager] Step 3: Skipping retries - iCloud has no token")
if !sharedKeychainHasToken && attempt > 0 {
print("[TokenManager] Step 3: Skipping retries - shared Keychain has no token")
break
}
print("[TokenManager] Step 3: Waiting \(Int(delay))s before attempt \(attempt + 1)...")
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
}
print("[TokenManager] Step 3: iCloud attempt \(attempt + 1)/\(retryDelays.count)...")
print("[TokenManager] Step 3: Shared Keychain attempt \(attempt + 1)/\(retryDelays.count)...")
if let iCloudToken = iCloudTokenManager.shared.loadToken() {
if let sharedToken = SharedKeychainManager.shared.loadToken() {
if let preferredStudentIdNorm = getActiveStudentIdNorm(),
iCloudToken.studentIdNorm != preferredStudentIdNorm {
sharedToken.studentIdNorm != preferredStudentIdNorm {
if localTokenFromKeychainAndFile(
preferredStudentIdNorm: preferredStudentIdNorm
) != nil {
print("[TokenManager] Step 3: Ignoring iCloud token for inactive account (\(iCloudToken.studentIdNorm)), active is \(preferredStudentIdNorm)")
print("[TokenManager] Step 3: Ignoring shared Keychain token for inactive account (\(sharedToken.studentIdNorm)), active is \(preferredStudentIdNorm)")
continue
}
print("[TokenManager] Step 3: Active account token missing locally, considering different-account iCloud token")
print("[TokenManager] Step 3: Active account token missing locally, considering different-account shared Keychain token")
}
iCloudHasToken = true
if iCloudToken.expiryDate > Date() {
print("[TokenManager] Step 3 SUCCESS: Found valid iCloud token, applying without immediate refresh")
try? saveToken(iCloudToken, syncToICloud: false)
sharedKeychainHasToken = true
if sharedToken.expiryDate > Date() {
print("[TokenManager] Step 3 SUCCESS: Found valid shared Keychain token, applying without immediate refresh")
try? saveToken(sharedToken, syncToSharedKeychain: false)
clearLastRecoveryFailure()
return iCloudToken
return sharedToken
} else {
print("[TokenManager] Step 3: iCloud token is expired, trying refresh anyway...")
print("[TokenManager] Step 3: Shared Keychain token is expired, trying refresh anyway...")
do {
let refreshedToken = try await refreshTokenInternal(iCloudToken)
print("[TokenManager] Step 3 SUCCESS: Expired iCloud token refresh succeeded on attempt \(attempt + 1)")
let refreshedToken = try await refreshTokenInternal(sharedToken)
print("[TokenManager] Step 3 SUCCESS: Expired shared Keychain token refresh succeeded on attempt \(attempt + 1)")
clearLastRecoveryFailure()
return refreshedToken
} catch {
print("[TokenManager] Step 3: Expired iCloud token refresh failed on attempt \(attempt + 1): \(error)")
print("[TokenManager] Step 3: Expired shared Keychain token refresh failed on attempt \(attempt + 1): \(error)")
if let tokenError = error as? TokenError {
lastRecoveryFailure = tokenError
if tokenError == .networkError {
@@ -643,9 +671,9 @@ class TokenManager {
}
}
} else {
print("[TokenManager] Step 3: No token in iCloud on attempt \(attempt + 1)")
print("[TokenManager] Step 3: No token in shared Keychain on attempt \(attempt + 1)")
if attempt == 0 {
iCloudHasToken = false
sharedKeychainHasToken = false
}
}
}
@@ -800,7 +828,7 @@ class TokenManager {
updatedAtMs: nowMs
)
try saveToken(newToken, syncToICloud: true)
try saveToken(newToken, syncToSharedKeychain: true)
#if os(watchOS)
WatchConnectivityManager.shared.sendTokenToiPhoneInBackground()
@@ -834,7 +862,7 @@ class TokenManager {
updatedAtMs: nowMs
)
try saveToken(newToken, syncToICloud: true)
try saveToken(newToken, syncToSharedKeychain: true)
#if os(watchOS)
WatchConnectivityManager.shared.sendTokenToiPhoneInBackground()

View File

@@ -1,210 +0,0 @@
import Foundation
class iCloudTokenManager {
static let shared = iCloudTokenManager()
private let iCloudStore = NSUbiquitousKeyValueStore.default
private let kAccessToken = "firka_access_token"
private let kRefreshToken = "firka_refresh_token"
private let kIdToken = "firka_id_token"
private let kIss = "firka_iss"
private let kStudentId = "firka_student_id"
private let kStudentIdNorm = "firka_student_id_norm"
private let kExpiryDate = "firka_expiry_date"
private let kTokenVersion = "firka_token_version"
private let kUpdatedAtMs = "firka_updated_at_ms"
private let kLastUpdatedDevice = "firka_last_updated_device"
private let kLastUpdateTimestamp = "firka_last_update_timestamp"
private var changeObserver: ((WatchToken) -> Void)?
private var isAvailable = false
private init() {
NotificationCenter.default.addObserver(
self,
selector: #selector(iCloudStoreDidChange(_:)),
name: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
object: iCloudStore
)
isAvailable = iCloudStore.synchronize()
if isAvailable {
print("[iCloud] iCloud KeyValue Store available and synced")
} else {
print("[iCloud] iCloud not available (not signed in or disabled) - using local storage only")
}
}
func saveToken(_ token: WatchToken, deviceName: String) {
guard isAvailable else {
return
}
if let existingToken = loadToken() {
if existingToken.isSameAccount(as: token) {
if !token.isNewer(than: existingToken) {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
formatter.timeZone = TimeZone.current
print("[iCloud] Ignoring stale token save from \(deviceName), existing expiry: \(formatter.string(from: existingToken.expiryDate)), incoming: \(formatter.string(from: token.expiryDate))")
return
}
} else {
let incomingUpdatedAt = token.effectiveUpdatedAtMs ?? 0
let existingUpdatedAt = existingToken.effectiveUpdatedAtMs ?? 0
if incomingUpdatedAt > 0 &&
existingUpdatedAt > 0 &&
incomingUpdatedAt <= existingUpdatedAt {
print("[iCloud] Ignoring cross-account stale token save from \(deviceName)")
return
}
}
}
print("[iCloud] Saving token to iCloud from \(deviceName)")
let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
let updatedAtMs = token.effectiveUpdatedAtMs ?? nowMs
let tokenVersion = token.effectiveTokenVersion
iCloudStore.set(token.accessToken, forKey: kAccessToken)
iCloudStore.set(token.refreshToken, forKey: kRefreshToken)
iCloudStore.set(token.idToken, forKey: kIdToken)
iCloudStore.set(token.iss, forKey: kIss)
iCloudStore.set(token.studentId, forKey: kStudentId)
iCloudStore.set(token.studentIdNorm, forKey: kStudentIdNorm)
iCloudStore.set(token.expiryDate.timeIntervalSince1970, forKey: kExpiryDate)
if let tokenVersion {
iCloudStore.set(tokenVersion, forKey: kTokenVersion)
} else {
iCloudStore.removeObject(forKey: kTokenVersion)
}
iCloudStore.set(updatedAtMs, forKey: kUpdatedAtMs)
iCloudStore.set(deviceName, forKey: kLastUpdatedDevice)
iCloudStore.set(Double(updatedAtMs) / 1000.0, forKey: kLastUpdateTimestamp)
let success = iCloudStore.synchronize()
if success {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
formatter.timeZone = TimeZone.current
print("[iCloud] Token saved successfully, expiry: \(formatter.string(from: token.expiryDate))")
} else {
print("[iCloud] Failed to synchronize token to iCloud")
}
}
func loadToken() -> WatchToken? {
guard isAvailable else {
return nil
}
iCloudStore.synchronize()
guard let accessToken = iCloudStore.string(forKey: kAccessToken),
let refreshToken = iCloudStore.string(forKey: kRefreshToken),
let idToken = iCloudStore.string(forKey: kIdToken),
let iss = iCloudStore.string(forKey: kIss),
let studentId = iCloudStore.string(forKey: kStudentId) else {
print("[iCloud] No token found in iCloud")
return nil
}
let studentIdNorm = iCloudStore.longLong(forKey: kStudentIdNorm)
let expiryTimestamp = iCloudStore.double(forKey: kExpiryDate)
let tokenVersionRaw = iCloudStore.longLong(forKey: kTokenVersion)
let updatedAtMsRaw = iCloudStore.longLong(forKey: kUpdatedAtMs)
let fallbackUpdatedAt = Int64(iCloudStore.double(forKey: kLastUpdateTimestamp) * 1000.0)
let lastDevice = iCloudStore.string(forKey: kLastUpdatedDevice) ?? "unknown"
guard expiryTimestamp > 0 else {
print("[iCloud] Invalid expiry date in iCloud")
return nil
}
let expiryDate = Date(timeIntervalSince1970: expiryTimestamp)
let token = WatchToken(
accessToken: accessToken,
refreshToken: refreshToken,
idToken: idToken,
iss: iss,
studentId: studentId,
studentIdNorm: studentIdNorm,
expiryDate: expiryDate,
tokenVersion: tokenVersionRaw > 0 ? tokenVersionRaw : nil,
updatedAtMs: updatedAtMsRaw > 0 ? updatedAtMsRaw : (fallbackUpdatedAt > 0 ? fallbackUpdatedAt : nil)
)
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
formatter.timeZone = TimeZone.current
print("[iCloud] Token loaded from iCloud (last updated by: \(lastDevice)), expiry: \(formatter.string(from: expiryDate))")
return token
}
func deleteToken() {
guard isAvailable else {
return
}
print("[iCloud] Deleting token from iCloud")
iCloudStore.removeObject(forKey: kAccessToken)
iCloudStore.removeObject(forKey: kRefreshToken)
iCloudStore.removeObject(forKey: kIdToken)
iCloudStore.removeObject(forKey: kIss)
iCloudStore.removeObject(forKey: kStudentId)
iCloudStore.removeObject(forKey: kStudentIdNorm)
iCloudStore.removeObject(forKey: kExpiryDate)
iCloudStore.removeObject(forKey: kTokenVersion)
iCloudStore.removeObject(forKey: kUpdatedAtMs)
iCloudStore.removeObject(forKey: kLastUpdatedDevice)
iCloudStore.removeObject(forKey: kLastUpdateTimestamp)
iCloudStore.synchronize()
}
func observeChanges(_ observer: @escaping (WatchToken) -> Void) {
self.changeObserver = observer
}
@objc private func iCloudStoreDidChange(_ notification: Notification) {
guard let userInfo = notification.userInfo,
let changeReason = userInfo[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int else {
return
}
if changeReason == NSUbiquitousKeyValueStoreServerChange ||
changeReason == NSUbiquitousKeyValueStoreInitialSyncChange {
print("[iCloud] Token changed externally in iCloud")
if let token = loadToken() {
let lastDevice = iCloudStore.string(forKey: kLastUpdatedDevice) ?? "unknown"
print("[iCloud] Received updated token from: \(lastDevice)")
changeObserver?(token)
}
}
}
func getLastUpdatedDevice() -> String? {
guard isAvailable else {
return nil
}
iCloudStore.synchronize()
return iCloudStore.string(forKey: kLastUpdatedDevice)
}
func getLastUpdateTimestamp() -> Date? {
guard isAvailable else {
return nil
}
iCloudStore.synchronize()
let timestamp = iCloudStore.double(forKey: kLastUpdateTimestamp)
guard timestamp > 0 else { return nil }
return Date(timeIntervalSince1970: timestamp)
}
}