diff --git a/apps/mobile/iosApp/ExportOptions.plist b/apps/mobile/iosApp/ExportOptions.plist new file mode 100644 index 00000000..bacfaad9 --- /dev/null +++ b/apps/mobile/iosApp/ExportOptions.plist @@ -0,0 +1,38 @@ + + + + + + + method + app-store-connect + + signingStyle + automatic + + + uploadSymbols + + + + compileBitcode + + + + stripSwiftSymbols + + + diff --git a/apps/mobile/iosApp/Piro.xcodeproj/project.pbxproj b/apps/mobile/iosApp/Piro.xcodeproj/project.pbxproj index 76935bab..41229480 100644 --- a/apps/mobile/iosApp/Piro.xcodeproj/project.pbxproj +++ b/apps/mobile/iosApp/Piro.xcodeproj/project.pbxproj @@ -8,12 +8,16 @@ /* Begin PBXBuildFile section */ 0FDD73F4D0747D5AC3D3C264 /* ServerStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24B0ABB9E71DA75AF7F2F17B /* ServerStore.swift */; }; + 138DBD75D2EEDFBD77871946 /* PiroNotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8FD8F0363AA4C1939F21DC1B /* PiroNotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 242F1145BEDEBCE61674D46B /* PushKeyStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3A102BB9C35A654FDDE773E /* PushKeyStore.swift */; }; 3E362161024CADEDDF3E71AA /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77E13EC31C62547DD9776B6A /* ProfileView.swift */; }; 412326DD143D9404CB96F75C /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D0DAD9E92B4E5A1358A57C /* RootView.swift */; }; 427D4D34D05F4D9906441FAC /* OnCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82F616189395D2F7306DCAE4 /* OnCallView.swift */; }; + 47912A086EC64E9C1934AC8B /* PushPayloadUnsealer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2220706C4FA925E7A3EF026 /* PushPayloadUnsealer.swift */; }; 50731D9EED4EE54FDAB74899 /* PlaceholderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 763682643898A70C06FE1F5B /* PlaceholderView.swift */; }; 55FDBD0DB8DF78CB09A0E0FA /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06C48FD355AD7B768D82C966 /* LoginView.swift */; }; 572178D60FB2093056FFD8B0 /* ScheduleViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58DB19F63117BF01303A7881 /* ScheduleViewModel.swift */; }; + 5C198978EA01EBDCD8FD2DFB /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B760316313B14C728B1E192F /* NotificationService.swift */; }; 5F88595A22F6441B81F704DA /* DeepLinkRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74A6656F35212FB479CA497D /* DeepLinkRouter.swift */; }; 61921A5CACB0E1D36D62C86C /* PiroApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15DF87ECBA4A4D253AACFC9F /* PiroApp.swift */; }; 658D3AABA133865D1215D9B6 /* SSOAuthenticator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D5026A1EEF3F8AB356A4B35 /* SSOAuthenticator.swift */; }; @@ -22,6 +26,7 @@ 6F676B86C4B60FEE0A5FC4E0 /* SessionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2E44A9B88A30DDFDE4B2192 /* SessionViewModel.swift */; }; 7024C4CBF699AC1839469F68 /* AlertsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5AF99D6A78AE18F845F5057 /* AlertsViewModel.swift */; }; 71DBFFBC524BCB03CB1EBCF0 /* AlertDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CB8AB5C8DB22611C627AF39 /* AlertDetailView.swift */; }; + 738A6D3EC77B52819035E088 /* PushPayloadUnsealer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2220706C4FA925E7A3EF026 /* PushPayloadUnsealer.swift */; }; 74B88B7E663754DA66B3633D /* KotlinError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE467E739C89E65CD524D06 /* KotlinError.swift */; }; 8274B5B5C8E88E4962FDFC8B /* PiroColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE46AD1A5BF6F16583727C51 /* PiroColors.swift */; }; 8A4AE16AF0298BB7695AE315 /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FC895C8FF2205C8C52B6E1C /* ProfileViewModel.swift */; }; @@ -32,16 +37,42 @@ B278AEBD05ADED00B553DB6B /* ServiceLocator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FDE5A79B1261DDC01D51CA3 /* ServiceLocator.swift */; }; B2C9C410F80081EFBFADCE1D /* AlertCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92FB1EFA2485E2081354C60C /* AlertCardView.swift */; }; BA005EDE62354F9727E36DE2 /* PushManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6DEE0D7847DC9CA0E51674A /* PushManager.swift */; }; + C43BA87BADEADCB4C8B8FB63 /* PushKeyStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3A102BB9C35A654FDDE773E /* PushKeyStore.swift */; }; C6D90E297A7FBD24ACACD4D3 /* AvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 056B84BBA5C207D5631A18CE /* AvatarView.swift */; }; D2CE07219C6B92D5CDF5C7CC /* DateFormatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE0D60FE7E2C3DB7CFF1680D /* DateFormatting.swift */; }; DB953CE8A6B3ACBE901C6032 /* AlertDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D36995A74084B63BBC85976 /* AlertDetailViewModel.swift */; }; DBE0A330F49107E1E43E28B0 /* NotificationPreferenceRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CE3F434BD2AC1E27E922FED /* NotificationPreferenceRow.swift */; }; + EC917A2419688CDF21F2CB17 /* SkeletonBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47AF6673E2E8ED02C6A9446B /* SkeletonBar.swift */; }; ECAE739EDFC61B258C0A13FF /* GlassCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3356AE05FEC9631ECFCDD275 /* GlassCard.swift */; }; F27E7489AC6322EF3AFD0252 /* ProfileField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01D8C10B3F353DBDBA5AA516 /* ProfileField.swift */; }; F73642C146DB79A7E7579894 /* PiroFlame.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E5D4206DC27DA9CACECD1B1 /* PiroFlame.swift */; }; FA6A31ED560C1B87C92BDEFD /* AppConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA529AF2D178ED8107E65405 /* AppConfig.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + D6C709D54D517C07EE63208F /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 07279C4D4230F4EF8635CD26 /* Project object */; + proxyType = 1; + remoteGlobalIDString = D1CCDB0F70F42F7329C71BCE; + remoteInfo = PiroNotificationService; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 2D8A1D1FE9DB215944743544 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 138DBD75D2EEDFBD77871946 /* PiroNotificationService.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 01D8C10B3F353DBDBA5AA516 /* ProfileField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileField.swift; sourceTree = ""; }; 056B84BBA5C207D5631A18CE /* AvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarView.swift; sourceTree = ""; }; @@ -55,9 +86,11 @@ 37D56ED11C9EE31FCA26CF5A /* Piro.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Piro.entitlements; sourceTree = ""; }; 3CE3F434BD2AC1E27E922FED /* NotificationPreferenceRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPreferenceRow.swift; sourceTree = ""; }; 3FC895C8FF2205C8C52B6E1C /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = ""; }; + 47AF6673E2E8ED02C6A9446B /* SkeletonBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkeletonBar.swift; sourceTree = ""; }; 58DB19F63117BF01303A7881 /* ScheduleViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleViewModel.swift; sourceTree = ""; }; 5FDE5A79B1261DDC01D51CA3 /* ServiceLocator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceLocator.swift; sourceTree = ""; }; 625F1EE13CA13BAAFBDE58A6 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 662F838D22BDE6260051F574 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 69D0DAD9E92B4E5A1358A57C /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; 6CB8AB5C8DB22611C627AF39 /* AlertDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertDetailView.swift; sourceTree = ""; }; 6D5026A1EEF3F8AB356A4B35 /* SSOAuthenticator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSOAuthenticator.swift; sourceTree = ""; }; @@ -68,20 +101,36 @@ 7E96033D789E6C309FBA079F /* MainTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTab.swift; sourceTree = ""; }; 82F616189395D2F7306DCAE4 /* OnCallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnCallView.swift; sourceTree = ""; }; 8E5D4206DC27DA9CACECD1B1 /* PiroFlame.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiroFlame.swift; sourceTree = ""; }; + 8FD8F0363AA4C1939F21DC1B /* PiroNotificationService.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = PiroNotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 92FB1EFA2485E2081354C60C /* AlertCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertCardView.swift; sourceTree = ""; }; + 9B80C358B2C6C6C74B71688B /* PiroNotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PiroNotificationService.entitlements; sourceTree = ""; }; AE0D60FE7E2C3DB7CFF1680D /* DateFormatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFormatting.swift; sourceTree = ""; }; + B760316313B14C728B1E192F /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; BE46AD1A5BF6F16583727C51 /* PiroColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiroColors.swift; sourceTree = ""; }; C217B54C3293533C5F33C653 /* PiroScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiroScreen.swift; sourceTree = ""; }; C2E44A9B88A30DDFDE4B2192 /* SessionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionViewModel.swift; sourceTree = ""; }; C6DEE0D7847DC9CA0E51674A /* PushManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushManager.swift; sourceTree = ""; }; D9471B6753362DB28C08FD43 /* ScheduleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleView.swift; sourceTree = ""; }; + E2220706C4FA925E7A3EF026 /* PushPayloadUnsealer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushPayloadUnsealer.swift; sourceTree = ""; }; + E5C935F3B7EBC38D35AD93BA /* Piro.Release.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Piro.Release.entitlements; sourceTree = ""; }; E723062807D400E2D6F41ED9 /* AlertsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertsListView.swift; sourceTree = ""; }; EA529AF2D178ED8107E65405 /* AppConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfig.swift; sourceTree = ""; }; + F3A102BB9C35A654FDDE773E /* PushKeyStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushKeyStore.swift; sourceTree = ""; }; F5AF99D6A78AE18F845F5057 /* AlertsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertsViewModel.swift; sourceTree = ""; }; FDE467E739C89E65CD524D06 /* KotlinError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KotlinError.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ + 04780B0768D58B4024257DAC /* PiroNotificationService */ = { + isa = PBXGroup; + children = ( + 662F838D22BDE6260051F574 /* Info.plist */, + B760316313B14C728B1E192F /* NotificationService.swift */, + 9B80C358B2C6C6C74B71688B /* PiroNotificationService.entitlements */, + ); + path = PiroNotificationService; + sourceTree = ""; + }; 10B14CD50B507B20235B0020 /* OnCall */ = { isa = PBXGroup; children = ( @@ -98,6 +147,7 @@ 3356AE05FEC9631ECFCDD275 /* GlassCard.swift */, FDE467E739C89E65CD524D06 /* KotlinError.swift */, C217B54C3293533C5F33C653 /* PiroScreen.swift */, + 47AF6673E2E8ED02C6A9446B /* SkeletonBar.swift */, 10C198668CBBC55B43704A02 /* SkeletonCard.swift */, ); path = Support; @@ -117,6 +167,7 @@ isa = PBXGroup; children = ( BDEFD919FCFD2275A553CD46 /* Piro */, + 04780B0768D58B4024257DAC /* PiroNotificationService */, C3E397762B087AA8627DE779 /* Products */, ); sourceTree = ""; @@ -142,7 +193,9 @@ 3F4F6C8C0DA239B2E3B46EA9 /* Push */ = { isa = PBXGroup; children = ( + F3A102BB9C35A654FDDE773E /* PushKeyStore.swift */, C6DEE0D7847DC9CA0E51674A /* PushManager.swift */, + E2220706C4FA925E7A3EF026 /* PushPayloadUnsealer.swift */, ); path = Push; sourceTree = ""; @@ -171,6 +224,7 @@ 625F1EE13CA13BAAFBDE58A6 /* Assets.xcassets */, 1963E6569447089C328596E3 /* Info.plist */, 37D56ED11C9EE31FCA26CF5A /* Piro.entitlements */, + E5C935F3B7EBC38D35AD93BA /* Piro.Release.entitlements */, 15DF87ECBA4A4D253AACFC9F /* PiroApp.swift */, 24B0ABB9E71DA75AF7F2F17B /* ServerStore.swift */, 5FDE5A79B1261DDC01D51CA3 /* ServiceLocator.swift */, @@ -204,6 +258,7 @@ isa = PBXGroup; children = ( 0FA56F1D81DBE8F40393DB8B /* Piro.app */, + 8FD8F0363AA4C1939F21DC1B /* PiroNotificationService.appex */, ); name = Products; sourceTree = ""; @@ -230,10 +285,12 @@ 8C0BD025CD789B07AD47BB19 /* Build Shared KMP framework (Gradle) */, 1ED0A598E7CF1486F0727298 /* Sources */, 137D0DCBA045AD47285D9886 /* Resources */, + 2D8A1D1FE9DB215944743544 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + A9A62A2E3A8B8A0AA4286179 /* PBXTargetDependency */, ); name = Piro; packageProductDependencies = ( @@ -242,6 +299,23 @@ productReference = 0FA56F1D81DBE8F40393DB8B /* Piro.app */; productType = "com.apple.product-type.application"; }; + D1CCDB0F70F42F7329C71BCE /* PiroNotificationService */ = { + isa = PBXNativeTarget; + buildConfigurationList = B251EBFB0FA9F4ED32F62CCD /* Build configuration list for PBXNativeTarget "PiroNotificationService" */; + buildPhases = ( + 37DD0D3B1E20B85835CE1727 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = PiroNotificationService; + packageProductDependencies = ( + ); + productName = PiroNotificationService; + productReference = 8FD8F0363AA4C1939F21DC1B /* PiroNotificationService.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -252,6 +326,11 @@ LastUpgradeCheck = 1430; TargetAttributes = { AE9E99BD1414C3B4E9B987B4 = { + DevelopmentTeam = GNUVS35QW5; + ProvisioningStyle = Automatic; + }; + D1CCDB0F70F42F7329C71BCE = { + DevelopmentTeam = GNUVS35QW5; ProvisioningStyle = Automatic; }; }; @@ -271,6 +350,7 @@ projectRoot = ""; targets = ( AE9E99BD1414C3B4E9B987B4 /* Piro */, + D1CCDB0F70F42F7329C71BCE /* PiroNotificationService */, ); }; /* End PBXProject section */ @@ -304,7 +384,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "set -e\n# Xcode's build environment has no JAVA_HOME/PATH java; fall back to Android Studio's JBR.\nif [ -z \"$JAVA_HOME\" ] || [ ! -x \"$JAVA_HOME/bin/java\" ]; then\n if [ -x \"/Applications/Android Studio.app/Contents/jbr/Contents/Home/bin/java\" ]; then\n export JAVA_HOME=\"/Applications/Android Studio.app/Contents/jbr/Contents/Home\"\n fi\nfi\ncd \"$SRCROOT/..\"\n./gradlew :shared:embedAndSignAppleFrameworkForXcode\n"; + shellScript = "set -e\n# Xcode's build environment inherits neither JAVA_HOME nor the shell PATH, so a JDK that works\n# from a terminal is invisible here. Probe the usual homes in order and fail with something\n# actionable rather than letting Gradle die with \"Unable to locate a Java Runtime\".\nif [ -z \"$JAVA_HOME\" ] || [ ! -x \"$JAVA_HOME/bin/java\" ]; then\n for candidate in \\\n \"/Applications/Android Studio.app/Contents/jbr/Contents/Home\" \\\n \"/opt/homebrew/opt/openjdk@17\" \\\n \"/opt/homebrew/opt/openjdk\" \\\n \"/usr/local/opt/openjdk@17\" \\\n \"/usr/local/opt/openjdk\"; do\n if [ -x \"$candidate/bin/java\" ]; then export JAVA_HOME=\"$candidate\"; break; fi\n done\nfi\n# Last resort: ask macOS itself, which knows about JDKs installed as system packages.\nif [ -z \"$JAVA_HOME\" ] && [ -x /usr/libexec/java_home ]; then\n JAVA_HOME=\"$(/usr/libexec/java_home 2>/dev/null)\" && export JAVA_HOME\nfi\nif [ -z \"$JAVA_HOME\" ] || [ ! -x \"$JAVA_HOME/bin/java\" ]; then\n echo \"error: no JDK found. Install one (brew install openjdk@17) or set JAVA_HOME.\" >&2\n exit 1\nfi\ncd \"$SRCROOT/..\"\n./gradlew :shared:embedAndSignAppleFrameworkForXcode\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -336,7 +416,9 @@ F27E7489AC6322EF3AFD0252 /* ProfileField.swift in Sources */, 3E362161024CADEDDF3E71AA /* ProfileView.swift in Sources */, 8A4AE16AF0298BB7695AE315 /* ProfileViewModel.swift in Sources */, + 242F1145BEDEBCE61674D46B /* PushKeyStore.swift in Sources */, BA005EDE62354F9727E36DE2 /* PushManager.swift in Sources */, + 47912A086EC64E9C1934AC8B /* PushPayloadUnsealer.swift in Sources */, 412326DD143D9404CB96F75C /* RootView.swift in Sources */, 658D3AABA133865D1215D9B6 /* SSOAuthenticator.swift in Sources */, 9BEDEB3C98E27DFBDA514428 /* ScheduleView.swift in Sources */, @@ -344,13 +426,50 @@ 0FDD73F4D0747D5AC3D3C264 /* ServerStore.swift in Sources */, B278AEBD05ADED00B553DB6B /* ServiceLocator.swift in Sources */, 6F676B86C4B60FEE0A5FC4E0 /* SessionViewModel.swift in Sources */, + EC917A2419688CDF21F2CB17 /* SkeletonBar.swift in Sources */, 9235D3D9EC2B4C02D9E46952 /* SkeletonCard.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; + 37DD0D3B1E20B85835CE1727 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 5C198978EA01EBDCD8FD2DFB /* NotificationService.swift in Sources */, + C43BA87BADEADCB4C8B8FB63 /* PushKeyStore.swift in Sources */, + 738A6D3EC77B52819035E088 /* PushPayloadUnsealer.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + A9A62A2E3A8B8A0AA4286179 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D1CCDB0F70F42F7329C71BCE /* PiroNotificationService */; + targetProxy = D6C709D54D517C07EE63208F /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ + 53DE39F39CE5EBFE6B31EB0C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = PiroNotificationService/PiroNotificationService.entitlements; + INFOPLIST_FILE = PiroNotificationService/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = co.heva.piro.NotificationService; + PRODUCT_NAME = PiroNotificationService; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; 5B639983B5B7817BB5611399 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -388,6 +507,7 @@ COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = GNUVS35QW5; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -448,6 +568,7 @@ COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = GNUVS35QW5; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -505,11 +626,29 @@ }; name = Debug; }; + C4E054C097938016CA4423E3 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = PiroNotificationService/PiroNotificationService.entitlements; + INFOPLIST_FILE = PiroNotificationService/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = co.heva.piro.NotificationService; + PRODUCT_NAME = PiroNotificationService; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; DC591190A7A94F714C204137 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_ENTITLEMENTS = Piro/Piro.entitlements; + CODE_SIGN_ENTITLEMENTS = Piro/Piro.Release.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -553,6 +692,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + B251EBFB0FA9F4ED32F62CCD /* Build configuration list for PBXNativeTarget "PiroNotificationService" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C4E054C097938016CA4423E3 /* Debug */, + 53DE39F39CE5EBFE6B31EB0C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; /* End XCConfigurationList section */ }; rootObject = 07279C4D4230F4EF8635CD26 /* Project object */; diff --git a/apps/mobile/iosApp/Piro.xcodeproj/xcshareddata/xcschemes/Piro (Release).xcscheme b/apps/mobile/iosApp/Piro.xcodeproj/xcshareddata/xcschemes/Piro (Release).xcscheme new file mode 100644 index 00000000..96aa21d9 --- /dev/null +++ b/apps/mobile/iosApp/Piro.xcodeproj/xcshareddata/xcschemes/Piro (Release).xcscheme @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/iosApp/Piro.xcodeproj/xcshareddata/xcschemes/PiroNotificationService.xcscheme b/apps/mobile/iosApp/Piro.xcodeproj/xcshareddata/xcschemes/PiroNotificationService.xcscheme new file mode 100644 index 00000000..cc4e61a0 --- /dev/null +++ b/apps/mobile/iosApp/Piro.xcodeproj/xcshareddata/xcschemes/PiroNotificationService.xcscheme @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/iosApp/Piro/Auth/LoginView.swift b/apps/mobile/iosApp/Piro/Auth/LoginView.swift index 4a769bde..265eb4bf 100644 --- a/apps/mobile/iosApp/Piro/Auth/LoginView.swift +++ b/apps/mobile/iosApp/Piro/Auth/LoginView.swift @@ -56,7 +56,7 @@ struct LoginView: View { private var serverField: some View { VStack(alignment: .leading, spacing: 4) { - TextField("Server URL (https://piro.example.com)", text: $session.serverURL) + TextField("Server URL", text: $session.serverURL) .textContentType(.URL) .keyboardType(.URL) .textInputAutocapitalization(.never) @@ -78,7 +78,10 @@ struct LoginView: View { private var credentialFields: some View { VStack(spacing: 12) { TextField("Email", text: $session.email) - .textContentType(.username) + // .emailAddress rather than .username: it is what makes iOS offer the addresses from + // Contacts and the keyboard's own suggestions, on top of any saved credential. With + // .username the QuickType bar only ever proposes a stored login. + .textContentType(.emailAddress) .keyboardType(.emailAddress) .textInputAutocapitalization(.never) .autocorrectionDisabled() diff --git a/apps/mobile/iosApp/Piro/OnCall/OnCallView.swift b/apps/mobile/iosApp/Piro/OnCall/OnCallView.swift index 8c7941be..9e8cdfde 100644 --- a/apps/mobile/iosApp/Piro/OnCall/OnCallView.swift +++ b/apps/mobile/iosApp/Piro/OnCall/OnCallView.swift @@ -23,6 +23,7 @@ struct OnCallView: View { statusBanner .padding(.top, 28) + .animation(.easeInOut(duration: 0.25), value: readiness) Spacer() Spacer() } @@ -31,14 +32,35 @@ struct OnCallView: View { } } + @ViewBuilder private var statusBanner: some View { - Text(bannerText) - .font(.callout.weight(.medium)) - .multilineTextAlignment(.center) - .foregroundStyle(bannerColor) + if readiness == .registering { + // While registering, the app genuinely does not know whether this device will be paged, so + // it shows a placeholder instead of a claim. The card keeps its shape and position, so the + // layout does not jump when the real answer arrives a moment later. + VStack(spacing: 8) { + SkeletonBar(width: 220) + SkeletonBar(width: 150) + } .padding(16) .frame(maxWidth: .infinity) - .glassCard(cornerRadius: 12, tint: bannerColor.opacity(0.12)) + .glassCard(cornerRadius: 12, tint: PiroColors.muted(scheme).opacity(0.08)) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Arming this device to receive pages") + // VoiceOver should hear progress rather than silence, since the skeleton conveys nothing + // to a screen reader on its own. + .accessibilityAddTraits(.updatesFrequently) + .transition(.opacity) + } else { + Text(bannerText) + .font(.callout.weight(.medium)) + .multilineTextAlignment(.center) + .foregroundStyle(bannerColor) + .padding(16) + .frame(maxWidth: .infinity) + .glassCard(cornerRadius: 12, tint: bannerColor.opacity(0.12)) + .transition(.opacity) + } } /// The banner only promises pages when the device is actually registered with the backend — otherwise @@ -46,6 +68,8 @@ struct OnCallView: View { private var bannerText: String { switch readiness { case .registered: return "This device will receive critical pages, even on silent." + // Not rendered: .registering shows the skeleton above. Kept so the switch stays exhaustive + // and the string exists if the banner is ever reused somewhere without one. case .registering: return "Arming this device to receive pages…" case .needsPermission: return "Enable notifications so pages can reach you." case .failed: return "This device isn't registered for pages yet — you may not be paged." @@ -63,3 +87,21 @@ struct OnCallView: View { } } } + +// Previews for every readiness state, because the real ones are hard to catch: registration completes +// in well under a second, so the skeleton would otherwise only ever be seen by accident. +#Preview("Registering (skeleton)") { + OnCallView(userName: "aespinosa@heva.co", readiness: .registering) +} + +#Preview("Registered") { + OnCallView(userName: "aespinosa@heva.co", readiness: .registered) +} + +#Preview("Needs permission") { + OnCallView(userName: "aespinosa@heva.co", readiness: .needsPermission) +} + +#Preview("Failed") { + OnCallView(userName: "aespinosa@heva.co", readiness: .failed) +} diff --git a/apps/mobile/iosApp/Piro/Piro.Release.entitlements b/apps/mobile/iosApp/Piro/Piro.Release.entitlements new file mode 100644 index 00000000..097ad9af --- /dev/null +++ b/apps/mobile/iosApp/Piro/Piro.Release.entitlements @@ -0,0 +1,21 @@ + + + + + + aps-environment + production + keychain-access-groups + + + $(AppIdentifierPrefix)co.heva.piro + + + diff --git a/apps/mobile/iosApp/Piro/Piro.entitlements b/apps/mobile/iosApp/Piro/Piro.entitlements index a2fb8ad8..28dc9aea 100644 --- a/apps/mobile/iosApp/Piro/Piro.entitlements +++ b/apps/mobile/iosApp/Piro/Piro.entitlements @@ -1,9 +1,22 @@ + - aps-environment development + keychain-access-groups + + + $(AppIdentifierPrefix)co.heva.piro + diff --git a/apps/mobile/iosApp/Piro/Push/PushKeyStore.swift b/apps/mobile/iosApp/Piro/Push/PushKeyStore.swift new file mode 100644 index 00000000..8122ddb3 --- /dev/null +++ b/apps/mobile/iosApp/Piro/Push/PushKeyStore.swift @@ -0,0 +1,145 @@ +import CryptoKit +import Foundation +import Security + +/// Holds this device's push keypair. The private half never leaves the device and is never sent to any +/// backend, which is what makes a payload sealed against the public half unreadable by Piro's operator, +/// by heva's relay, and by APNs (RFC 0017). +/// +/// The curve is NIST P-256 to match the backend: .NET's `ECDiffieHellman` cannot do X25519, so the +/// sealer standardised on P-256 across all three platforms. +/// +/// Stored in the Keychain with `kSecAttrAccessibleAfterFirstUnlock` rather than +/// `WhenUnlockedThisDeviceOnly`: a Notification Service Extension has to decrypt while the phone is +/// locked, which is the entire point of a push. `ThisDeviceOnly` keeps it out of iCloud backups, so a +/// restored backup simply generates a fresh keypair and re-registers rather than resurrecting a key +/// that other devices might hold. +enum PushKeyStore { + + /// Shared with the notification service extension, so both processes see the same key. Without + /// this the extension generates its own keypair and can never decrypt what the backend sealed for + /// the app. + /// + /// This is the app's *default* access group — its application-identifier — rather than a custom + /// one. A custom group has to be registered on the App ID and present in the provisioning profile; + /// declaring it only in the entitlements file is not enough, and the Keychain then denies every + /// call with errSecMissingEntitlement, which surfaces as a device that registers with no push key + /// at all. The default group is authorised by every profile automatically, and both targets list + /// it in their entitlements, so it shares just as well with nothing to configure. + static let accessGroup: String? = "GNUVS35QW5.co.heva.piro" + + private static let service = "co.heva.piro.push" + private static let account = "push-private-p256" + + /// Uncompressed EC point: `0x04 || X(32) || Y(32)` — the exact encoding the backend imports. + private static let publicKeyLength = 65 + + // MARK: - Public API + + /// The device's public key as base64url of an uncompressed EC point, generating and persisting a + /// keypair on first call. Returns nil only if the Keychain refuses to store the key, in which case + /// the device registers unsealed rather than failing to register at all. + static func publicKeyBase64Url() -> String? { + guard let key = ensureKeyPair() else { return nil } + return base64UrlEncode(key.publicKey.x963Representation) + } + + /// The private key, for agreeing with the ephemeral key inside a sealed envelope. + static func privateKey() -> P256.KeyAgreement.PrivateKey? { + ensureKeyPair() + } + + /// True once a keypair exists, i.e. this device can receive sealed pushes. + static var hasKey: Bool { + loadPrivateKey() != nil + } + + // MARK: - Keychain + + private static func ensureKeyPair() -> P256.KeyAgreement.PrivateKey? { + if let existing = loadPrivateKey() { return existing } + + let key = P256.KeyAgreement.PrivateKey() + return store(key) ? key : nil + } + + private static func loadPrivateKey() -> P256.KeyAgreement.PrivateKey? { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + if let accessGroup { query[kSecAttrAccessGroup as String] = accessGroup } + + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data else { return nil } + + // A key that no longer parses is worse than no key: it would fail every decrypt silently. Drop + // it so the next call generates a fresh one and the device re-registers. + guard let key = try? P256.KeyAgreement.PrivateKey(rawRepresentation: data) else { + delete() + return nil + } + return key + } + + @discardableResult + private static func store(_ key: P256.KeyAgreement.PrivateKey) -> Bool { + delete() // SecItemAdd fails on a duplicate rather than replacing + + var attributes: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecValueData as String: key.rawRepresentation, + // AfterFirstUnlock, not WhenUnlocked: the NSE decrypts on a locked phone. + // ThisDeviceOnly keeps it out of backups — a restored device should re-key, not inherit. + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + if let accessGroup { attributes[kSecAttrAccessGroup as String] = accessGroup } + + let status = SecItemAdd(attributes as CFDictionary, nil) + if status != errSecSuccess { + // Logged rather than swallowed: a failure here means the device registers with no push key + // and silently receives unsealed pushes, which looks identical to success from the UI. + // errSecMissingEntitlement (-34018) is the one to expect — it means the access group is not + // in the provisioning profile, not merely absent from the entitlements file. + let reason = SecCopyErrorMessageString(status, nil) as String? ?? "unknown" + NSLog("[PushKeyStore] could not store the push key: OSStatus \(status) — \(reason)") + } + return status == errSecSuccess + } + + private static func delete() { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + if let accessGroup { query[kSecAttrAccessGroup as String] = accessGroup } + SecItemDelete(query as CFDictionary) + } + + // MARK: - base64url + + /// base64url without padding, matching what the backend's `Base64Url` emits and expects. + static func base64UrlEncode(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + static func base64UrlDecode(_ string: String) -> Data? { + var s = string + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + // Restore the padding base64url drops; Foundation's decoder requires it. + let remainder = s.count % 4 + if remainder > 0 { s += String(repeating: "=", count: 4 - remainder) } + return Data(base64Encoded: s) + } +} diff --git a/apps/mobile/iosApp/Piro/Push/PushManager.swift b/apps/mobile/iosApp/Piro/Push/PushManager.swift index c229d760..bd93273c 100644 --- a/apps/mobile/iosApp/Piro/Push/PushManager.swift +++ b/apps/mobile/iosApp/Piro/Push/PushManager.swift @@ -139,7 +139,15 @@ final class PushManager: NSObject, ObservableObject { armingDeadlineTask?.cancel() // a token arrived — the timeout is moot Task { do { - _ = try await api.registerDevice(platform: "Ios", token: token, deviceName: name) + // Publishing the public half is what makes the backend seal for this device (RFC 0017); + // the private half stays in the Keychain. Nil only if the Keychain refused to store a + // key, in which case the server sends unsealed rather than not sending at all — a + // readable page beats a silent one. + _ = try await api.registerDevice( + platform: "iOS", + token: token, + deviceName: name, + pushPublicKey: PushKeyStore.publicKeyBase64Url()) readiness = .registered } catch { readiness = .failed diff --git a/apps/mobile/iosApp/Piro/Push/PushPayloadUnsealer.swift b/apps/mobile/iosApp/Piro/Push/PushPayloadUnsealer.swift new file mode 100644 index 00000000..02c9d0dd --- /dev/null +++ b/apps/mobile/iosApp/Piro/Push/PushPayloadUnsealer.swift @@ -0,0 +1,120 @@ +import CryptoKit +import Foundation + +/// What the backend sealed for this device. +struct PushPayload: Equatable { + let title: String + let body: String + let eventKey: String + let alertId: Int + let url: String? +} + +enum PushUnsealError: Error, Equatable { + case notBase64Url + case malformedEnvelope + case unsupportedVersion(Int) + case badNonceLength(Int) + case ciphertextTooShort + case noPrivateKey + case decryptionFailed +} + +/// Opens an envelope sealed by the backend's `PushPayloadSealer` (RFC 0017). +/// +/// The scheme, which must stay byte-for-byte in step with the server and the Android client: +/// - ECDH P-256 between this device's private key and the ephemeral public key in the envelope +/// - HKDF-SHA256 over the shared secret, salted with that ephemeral public key, info `piro-push-v1` +/// - AES-256-GCM, 12-byte nonce, 16-byte tag appended to the ciphertext, AAD `piro-push-v1` +/// +/// The ephemeral public key travels in the clear on purpose: it is not a secret, and without this +/// device's private key it yields nothing. That is what lets a relay forward a payload it cannot read. +/// +/// Verified against an envelope produced by the real server sealer — the interop that matters here is +/// with `PushPayloadSealer`, not with a Swift-side round trip, since a self-consistent bug would pass +/// the latter. There is no iOS test target yet to keep that check running; this vector reproduces it: +/// +/// private key (base64url raw): 6Z--VmFjwVhHk-aUJz1zi-CN8eOC2IWv4tznQuzR_3U +/// decrypts to: title "Critical alert", body "heva-api is down", +/// eventKey "alert.raised", alertId 4242, url "piro://alert/4242" +enum PushPayloadUnsealer { + + private static let version = 1 + private static let associatedData = Data("piro-push-v1".utf8) + private static let hkdfInfo = Data("piro-push-v1".utf8) + private static let nonceLength = 12 + private static let tagLength = 16 + + /// Decrypts an envelope using the key held in the Keychain. + static func unseal(_ envelopeBase64Url: String) throws -> PushPayload { + guard let key = PushKeyStore.privateKey() else { throw PushUnsealError.noPrivateKey } + return try unseal(envelopeBase64Url, with: key) + } + + /// Testable overload taking the key directly. + static func unseal( + _ envelopeBase64Url: String, + with privateKey: P256.KeyAgreement.PrivateKey + ) throws -> PushPayload { + guard let envelopeData = PushKeyStore.base64UrlDecode(envelopeBase64Url) else { + throw PushUnsealError.notBase64Url + } + guard let envelope = try? JSONSerialization.jsonObject(with: envelopeData) as? [String: Any] else { + throw PushUnsealError.malformedEnvelope + } + + // The version is checked before anything else and is also bound into the AAD, so a downgrade + // fails to decrypt rather than being silently reinterpreted. + guard let v = envelope["V"] as? Int else { throw PushUnsealError.malformedEnvelope } + guard v == version else { throw PushUnsealError.unsupportedVersion(v) } + + guard let epk = envelope["Epk"] as? String, + let n = envelope["N"] as? String, + let ct = envelope["Ct"] as? String, + let ephemeralRaw = PushKeyStore.base64UrlDecode(epk), + let nonceData = PushKeyStore.base64UrlDecode(n), + let sealedData = PushKeyStore.base64UrlDecode(ct) + else { throw PushUnsealError.malformedEnvelope } + + guard nonceData.count == nonceLength else { + throw PushUnsealError.badNonceLength(nonceData.count) + } + guard sealedData.count > tagLength else { throw PushUnsealError.ciphertextTooShort } + + guard let ephemeralPublic = try? P256.KeyAgreement.PublicKey(x963Representation: ephemeralRaw) else { + throw PushUnsealError.malformedEnvelope + } + + guard let shared = try? privateKey.sharedSecretFromKeyAgreement(with: ephemeralPublic) else { + throw PushUnsealError.decryptionFailed + } + + // Salted with the ephemeral public key so two pushes to the same device never derive the same + // content key. CryptoKit zeroes the derived material itself when it goes out of scope. + let key = shared.hkdfDerivedSymmetricKey( + using: SHA256.self, + salt: ephemeralRaw, + sharedInfo: hkdfInfo, + outputByteCount: 32) + + // The server appends the tag to the ciphertext, which is exactly what `combined` expects once + // the nonce is prefixed. + guard let nonce = try? AES.GCM.Nonce(data: nonceData), + let box = try? AES.GCM.SealedBox( + combined: Data(nonce) + sealedData), + let plaintext = try? AES.GCM.open(box, using: key, authenticating: associatedData) + else { throw PushUnsealError.decryptionFailed } + + guard let json = try? JSONSerialization.jsonObject(with: plaintext) as? [String: Any] else { + throw PushUnsealError.malformedEnvelope + } + + return PushPayload( + title: json["title"] as? String ?? "", + body: json["body"] as? String ?? "", + eventKey: json["eventKey"] as? String ?? "", + alertId: json["alertId"] as? Int ?? 0, + // A JSON null decodes to NSNull, which would render as "" if passed through. + url: (json["url"] as? String).flatMap { $0.isEmpty ? nil : $0 }) + } +} diff --git a/apps/mobile/iosApp/Piro/Support/SkeletonBar.swift b/apps/mobile/iosApp/Piro/Support/SkeletonBar.swift new file mode 100644 index 00000000..1a7ad587 --- /dev/null +++ b/apps/mobile/iosApp/Piro/Support/SkeletonBar.swift @@ -0,0 +1,29 @@ +import SwiftUI + +/// A placeholder bar that pulses while real content is being determined. +/// +/// Used instead of showing a verdict the app does not have yet: the On-call banner states whether this +/// device will be paged, and asserting that before registration completes would be a promise the app +/// cannot keep. A pulsing bar says "still working" without claiming anything. +struct SkeletonBar: View { + var width: CGFloat? = nil + var height: CGFloat = 14 + + @Environment(\.colorScheme) private var scheme + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var pulsing = false + + var body: some View { + RoundedRectangle(cornerRadius: height / 2, style: .continuous) + .fill(PiroColors.muted(scheme).opacity(pulsing ? 0.28 : 0.14)) + .frame(width: width, height: height) + .onAppear { + // Reduce Motion turns the animation off but keeps the shape: the placeholder still + // reads as "not content yet", which is the part that matters. + guard !reduceMotion else { return } + withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) { + pulsing = true + } + } + } +} diff --git a/apps/mobile/iosApp/PiroNotificationService/Info.plist b/apps/mobile/iosApp/PiroNotificationService/Info.plist new file mode 100644 index 00000000..6a63f62b --- /dev/null +++ b/apps/mobile/iosApp/PiroNotificationService/Info.plist @@ -0,0 +1,34 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + PiroNotificationService + CFBundleExecutable + $(EXECUTABLE_NAME) + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + + diff --git a/apps/mobile/iosApp/PiroNotificationService/NotificationService.swift b/apps/mobile/iosApp/PiroNotificationService/NotificationService.swift new file mode 100644 index 00000000..1c11a11b --- /dev/null +++ b/apps/mobile/iosApp/PiroNotificationService/NotificationService.swift @@ -0,0 +1,70 @@ +import UserNotifications + +/// Decrypts a sealed push before iOS shows it (RFC 0017). +/// +/// The extension exists because the payload arrives encrypted: the app process is not running when a +/// notification lands, so nothing else gets a chance to touch it. APNs sets `mutable-content: 1` and +/// hands the notification here first; whatever this returns is what the user sees. +/// +/// The private key comes from the shared Keychain access group, which is why the app and this target +/// must sit in the same App Group. Without that this process generates its own keypair and can never +/// decrypt anything the backend sealed for the app. +final class NotificationService: UNNotificationServiceExtension { + + private var contentHandler: ((UNNotificationContent) -> Void)? + private var bestAttempt: UNMutableNotificationContent? + + override func didReceive( + _ request: UNNotificationRequest, + withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void + ) { + self.contentHandler = contentHandler + let content = request.content.mutableCopy() as? UNMutableNotificationContent + bestAttempt = content + + guard let content else { + contentHandler(request.content) + return + } + + // No ciphertext means a legacy cleartext push (a device registered before it published a key): + // the title and body are already correct, so pass it through untouched. + guard let envelope = content.userInfo["ciphertext"] as? String else { + contentHandler(content) + return + } + + do { + let payload = try PushPayloadUnsealer.unseal(envelope) + + content.title = payload.title + content.body = payload.body + + // Rebuild the userInfo the app expects on tap. The deep-link router reads `url`, so it has + // to survive decryption or tapping the notification stops opening the right alert. + var info = content.userInfo + info["eventKey"] = payload.eventKey + info["alertId"] = payload.alertId + if let url = payload.url { info["url"] = url } + info.removeValue(forKey: "ciphertext") + content.userInfo = info + + contentHandler(content) + } catch { + // Showing the placeholder beats showing nothing: a page the user cannot read still tells + // them to open the app, whereas swallowing it loses the page entirely. The reason is worth + // logging, since a decrypt failure here means the device's key and the server's copy have + // diverged — usually a reinstall that re-keyed without re-registering. + content.body = "Open Piro to view this alert." + contentHandler(content) + } + } + + /// iOS gives the extension a few seconds. If it runs out, this fires and whatever has been built so + /// far is delivered — hence keeping `bestAttempt` up to date rather than only mutating at the end. + override func serviceExtensionTimeWillExpire() { + if let contentHandler, let bestAttempt { + contentHandler(bestAttempt) + } + } +} diff --git a/apps/mobile/iosApp/PiroNotificationService/PiroNotificationService.entitlements b/apps/mobile/iosApp/PiroNotificationService/PiroNotificationService.entitlements new file mode 100644 index 00000000..96598c53 --- /dev/null +++ b/apps/mobile/iosApp/PiroNotificationService/PiroNotificationService.entitlements @@ -0,0 +1,15 @@ + + + + + + keychain-access-groups + + $(AppIdentifierPrefix)co.heva.piro + + + diff --git a/apps/mobile/iosApp/README.md b/apps/mobile/iosApp/README.md index 82b3ee9e..50fe843e 100644 --- a/apps/mobile/iosApp/README.md +++ b/apps/mobile/iosApp/README.md @@ -66,6 +66,70 @@ release builds start empty. On the iOS Simulator `localhost` resolves to the hos Piro API is reachable directly — but it must actually be running (a connection-refused error means nothing is listening on that port, not a networking problem). +## Releasing to TestFlight + +TestFlight and the App Store are the same channel: you upload once, then promote the build in App +Store Connect. `ExportOptions.plist` in this directory is already set up for it, and +`DEVELOPMENT_TEAM` is pinned in `project.yml`, so an archive signs against the team that owns the +`co.heva.piro` App ID regardless of which Mac builds it. + +### One-time setup + +1. **Register the App ID.** With automatic signing, selecting the team in Xcode's *Signing & + Capabilities* creates `co.heva.piro` for you. Confirm **Push Notifications** appears there — the app + declares it in `Piro.entitlements`, and signing fails if the capability is missing from the App ID. +2. **Create the app in App Store Connect.** Xcode does not do this: *Apps → + → New App*, platform + iOS, bundle ID `co.heva.piro`, any SKU. App names are globally unique, so if "Piro" is taken pick a + different display name — the bundle ID does not have to change. +3. **APNs key.** Generate a `.p8` in the developer portal and configure it server-side. Without it the + app works but never receives a page. + +### Uploading a build + +Bump `CURRENT_PROJECT_VERSION` in `project.yml` first, run `xcodegen generate`, and commit. Every +upload needs a build number higher than the last one App Store Connect saw; a duplicate is rejected, +and it is the most common reason a first upload fails. + +From Xcode: destination **Any iOS Device (arm64)**, then *Product → Archive*, and in the Organizer +*Distribute App → TestFlight & App Store*. + +From the command line: + +```bash +cd apps/mobile/iosApp + +xcodebuild -project Piro.xcodeproj -scheme Piro -configuration Release \ + -destination 'generic/platform=iOS' \ + -archivePath build/Piro.xcarchive \ + -allowProvisioningUpdates archive + +xcodebuild -exportArchive \ + -archivePath build/Piro.xcarchive \ + -exportOptionsPlist ExportOptions.plist \ + -exportPath build/export + +# App Store Connect API key: Users and Access → Integrations → App Store Connect API. +# The .p8 goes in ~/.appstoreconnect/private_keys/ (or ~/.private_keys/) or altool will not find it. +xcrun altool --upload-app -f build/export/Piro.ipa -t ios \ + --apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID" +``` + +`-allowProvisioningUpdates` is what lets automatic signing fetch or create the distribution profile +without opening Xcode. + +### Push notifications on TestFlight + +A TestFlight build is a Release build, so it registers against the **production** APNs environment, not +the sandbox one the simulator and debug builds use. The `.p8` configured server-side has to be valid +for production, and a device token obtained from a TestFlight build will not work against the sandbox. +This is the usual reason pushes arrive in a debug build and then stop working in TestFlight. + +### Processing and testers + +Uploads take a few minutes to process, and the first build of an app also needs the *Export Compliance* +question answered before it can be distributed. Internal testers (up to 100 people on your team) get +builds immediately; external testers require a review that usually takes a day. + ## Liquid Glass Liquid Glass (`.glassEffect(...)`) requires the **iOS 26 SDK (Xcode 26)**. This project builds against diff --git a/apps/mobile/iosApp/project.yml b/apps/mobile/iosApp/project.yml index dd53f5e3..2975df19 100644 --- a/apps/mobile/iosApp/project.yml +++ b/apps/mobile/iosApp/project.yml @@ -19,12 +19,35 @@ settings: base: SWIFT_VERSION: "5.9" CODE_SIGN_STYLE: Automatic + # heva's Apple Developer team. Committed rather than left to whoever opens Xcode, so an archive + # signs against the team that owns the co.heva.piro App ID no matter which machine builds it. + # A contributor building for the simulator does not need it to match — ad-hoc signing ignores it. + DEVELOPMENT_TEAM: GNUVS35QW5 MARKETING_VERSION: "0.1.0" CURRENT_PROJECT_VERSION: "1" # Gradle needs to shell out during the build phase; keep the script sandbox off. ENABLE_USER_SCRIPT_SANDBOXING: NO targets: + PiroNotificationService: + type: app-extension + platform: iOS + sources: + - path: PiroNotificationService + # The extension decrypts on its own, in its own process, so it compiles the crypto directly + # rather than depending on the app target — an extension cannot import its host app. + - path: Piro/Push/PushKeyStore.swift + - path: Piro/Push/PushPayloadUnsealer.swift + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: co.heva.piro.NotificationService + PRODUCT_NAME: PiroNotificationService + INFOPLIST_FILE: PiroNotificationService/Info.plist + CODE_SIGN_ENTITLEMENTS: PiroNotificationService/PiroNotificationService.entitlements + # Extensions are memory- and time-limited; skipping the KMP framework keeps this one small, + # which is also why it does not reuse the app's Shared.framework. + SKIP_INSTALL: YES + Piro: type: application platform: iOS @@ -35,7 +58,6 @@ targets: PRODUCT_BUNDLE_IDENTIFIER: co.heva.piro PRODUCT_NAME: Piro INFOPLIST_FILE: Piro/Info.plist - CODE_SIGN_ENTITLEMENTS: Piro/Piro.entitlements ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon # Where Gradle drops the built Shared.framework for the current CONFIGURATION/SDK. FRAMEWORK_SEARCH_PATHS: @@ -45,16 +67,74 @@ targets: - $(inherited) - -framework - Shared + configs: + # aps-environment is a fixed string in a plist, so the two APNs environments need two files. + # A Release build — which is what TestFlight distributes — must ship `production`, or the app + # registers a sandbox token that production APNs refuses to deliver to: pushes then work in + # development and stop the moment the build comes from TestFlight. + Debug: + CODE_SIGN_ENTITLEMENTS: Piro/Piro.entitlements + Release: + CODE_SIGN_ENTITLEMENTS: Piro/Piro.Release.entitlements + dependencies: + # Embeds the extension inside the app bundle; without this it is built but never shipped. + - target: PiroNotificationService + preBuildScripts: - name: "Build Shared KMP framework (Gradle)" basedOnDependencyAnalysis: false script: | set -e - # Xcode's build environment has no JAVA_HOME/PATH java; fall back to Android Studio's JBR. + # Xcode's build environment inherits neither JAVA_HOME nor the shell PATH, so a JDK that works + # from a terminal is invisible here. Probe the usual homes in order and fail with something + # actionable rather than letting Gradle die with "Unable to locate a Java Runtime". if [ -z "$JAVA_HOME" ] || [ ! -x "$JAVA_HOME/bin/java" ]; then - if [ -x "/Applications/Android Studio.app/Contents/jbr/Contents/Home/bin/java" ]; then - export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" - fi + for candidate in \ + "/Applications/Android Studio.app/Contents/jbr/Contents/Home" \ + "/opt/homebrew/opt/openjdk@17" \ + "/opt/homebrew/opt/openjdk" \ + "/usr/local/opt/openjdk@17" \ + "/usr/local/opt/openjdk"; do + if [ -x "$candidate/bin/java" ]; then export JAVA_HOME="$candidate"; break; fi + done + fi + # Last resort: ask macOS itself, which knows about JDKs installed as system packages. + if [ -z "$JAVA_HOME" ] && [ -x /usr/libexec/java_home ]; then + JAVA_HOME="$(/usr/libexec/java_home 2>/dev/null)" && export JAVA_HOME + fi + if [ -z "$JAVA_HOME" ] || [ ! -x "$JAVA_HOME/bin/java" ]; then + echo "error: no JDK found. Install one (brew install openjdk@17) or set JAVA_HOME." >&2 + exit 1 fi cd "$SRCROOT/.." ./gradlew :shared:embedAndSignAppleFrameworkForXcode + +schemes: + # Runs the app in Release. Debug builds get aps-environment: development, so they register against + # the APNs sandbox and never receive pushes from a production Piro server — running Release is what + # makes push testable on a device, and breakpoints still work. + Piro (Release): + build: + targets: + Piro: [run, test, profile, analyze, archive] + run: + config: Release + # Without this, launching from Xcode and then locking the phone suspends the process before a + # push arrives. Waiting for the launch means the debugger attaches when the notification does. + debugEnabled: true + profile: + config: Release + archive: + config: Release + + # Attaches to the notification service extension so a breakpoint in didReceive fires when a push + # lands. Xcode asks which app to run; pick Piro. + PiroNotificationService: + build: + targets: + PiroNotificationService: [run, profile, analyze] + run: + config: Release + debugEnabled: true + profile: + config: Release