forked from firka/firka
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:
@@ -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>
|
||||
|
||||
@@ -375,7 +375,7 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate {
|
||||
|
||||
try TokenManager.shared.saveToken(
|
||||
token,
|
||||
syncToICloud: false,
|
||||
syncToSharedKeychain: false,
|
||||
forceAccountSwitch: shouldForceAccountSwitch
|
||||
)
|
||||
print("[Watch] Token saved successfully")
|
||||
|
||||
@@ -289,7 +289,7 @@ struct ReauthRequiredView: View {
|
||||
|
||||
try TokenManager.shared.saveToken(
|
||||
token,
|
||||
syncToICloud: false,
|
||||
syncToSharedKeychain: false,
|
||||
forceAccountSwitch: shouldForceAccountSwitch
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1068</string>
|
||||
<string>1101</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
281
firka/ios/Shared/API/SharedKeychainManager.swift
Normal file
281
firka/ios/Shared/API/SharedKeychainManager.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user