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