ios: notifications, actionable approvals, Live Activity, and the relay push path
Brings the phone's ambient surfaces (UX_IOS §5/§8) to maturity: - Notification pipeline (NotificationRouter): local notifications for approvals and needs-input transitions while backgrounded; low-risk approvals are actionable from the banner (Allow requires device auth, high-risk must open the app's Face ID gate); taps deep-link to the session; app-icon badge = NEEDS YOU count; resolutions withdraw the notification (first-responder-wins). The relay's content-free approval.pending tickle localizes via Localizable.strings. - Live Activity: new NucleicRemoteWidgets extension target (lock screen + Dynamic Island) rendering one aggregate Activity — N running / M waiting + the most urgent session — started/updated/ ended by LiveActivityManager as session state changes. - Out-of-band push path: nucleic-edge gains POST /v1/push/register (admin) so the host can upload tokens for LAN-only pairings; the host's new PushRelayClient (config-gated on NUCLEIC_RELAY_URL + NUCLEIC_RELAY_ADMIN_SECRET) mirrors Hello.pushToken to the relay and wakes non-connected phones when an approval arrives, throttled per device. Everything stays off until the relay is provisioned. Worker tests (23) and Swift suites pass apart from the pre-existing fixture gaps and nvrsion flake. Simulated APNs delivery is blocked in this environment (notification auth can't be granted headlessly). Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
@@ -8,12 +8,38 @@
|
|||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
BF00000000000000000001 /* NucleicProtocol in Frameworks */ = {isa = PBXBuildFile; productRef = PD00000000000000000001 /* NucleicProtocol */; };
|
BF00000000000000000001 /* NucleicProtocol in Frameworks */ = {isa = PBXBuildFile; productRef = PD00000000000000000001 /* NucleicProtocol */; };
|
||||||
|
BF00000000000000000002 /* NucleicRemoteWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = FR00000000000000000002 /* NucleicRemoteWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
E7D43FF12FF0806800BF2407 /* app-logo-file.icon in Resources */ = {isa = PBXBuildFile; fileRef = E7D43FF02FF0806800BF2407 /* app-logo-file.icon */; };
|
E7D43FF12FF0806800BF2407 /* app-logo-file.icon in Resources */ = {isa = PBXBuildFile; fileRef = E7D43FF02FF0806800BF2407 /* app-logo-file.icon */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
CP00000000000000000001 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = PJ00000000000000000001 /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = TG00000000000000000002;
|
||||||
|
remoteInfo = NucleicRemoteWidgets;
|
||||||
|
};
|
||||||
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
|
BP00000000000000000005 /* Embed Foundation Extensions */ = {
|
||||||
|
isa = PBXCopyFilesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
dstPath = "";
|
||||||
|
dstSubfolderSpec = 13;
|
||||||
|
files = (
|
||||||
|
BF00000000000000000002 /* NucleicRemoteWidgets.appex in Embed Foundation Extensions */,
|
||||||
|
);
|
||||||
|
name = "Embed Foundation Extensions";
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
E7D43FF02FF0806800BF2407 /* app-logo-file.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = "app-logo-file.icon"; sourceTree = "<group>"; };
|
E7D43FF02FF0806800BF2407 /* app-logo-file.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = "app-logo-file.icon"; sourceTree = "<group>"; };
|
||||||
FR00000000000000000001 /* NucleicRemote.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = NucleicRemote.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
FR00000000000000000001 /* NucleicRemote.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = NucleicRemote.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
FR00000000000000000002 /* NucleicRemoteWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NucleicRemoteWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
@@ -24,6 +50,13 @@
|
|||||||
);
|
);
|
||||||
target = TG00000000000000000001 /* NucleicRemote */;
|
target = TG00000000000000000001 /* NucleicRemote */;
|
||||||
};
|
};
|
||||||
|
EX00000000000000000002 /* Exceptions for "NucleicRemoteWidgets" folder in "NucleicRemoteWidgets" target */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
|
membershipExceptions = (
|
||||||
|
Info.plist,
|
||||||
|
);
|
||||||
|
target = TG00000000000000000002 /* NucleicRemoteWidgets */;
|
||||||
|
};
|
||||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
@@ -35,6 +68,19 @@
|
|||||||
path = NucleicRemote;
|
path = NucleicRemote;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
SG00000000000000000002 /* NucleicRemoteWidgets */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
EX00000000000000000002 /* Exceptions for "NucleicRemoteWidgets" folder in "NucleicRemoteWidgets" target */,
|
||||||
|
);
|
||||||
|
path = NucleicRemoteWidgets;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
SG00000000000000000003 /* Shared */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
path = Shared;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -46,6 +92,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
BP00000000000000000007 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
@@ -53,6 +106,8 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
SG00000000000000000001 /* NucleicRemote */,
|
SG00000000000000000001 /* NucleicRemote */,
|
||||||
|
SG00000000000000000002 /* NucleicRemoteWidgets */,
|
||||||
|
SG00000000000000000003 /* Shared */,
|
||||||
GR00000000000000000002 /* Products */,
|
GR00000000000000000002 /* Products */,
|
||||||
E7D43FF02FF0806800BF2407 /* app-logo-file.icon */,
|
E7D43FF02FF0806800BF2407 /* app-logo-file.icon */,
|
||||||
);
|
);
|
||||||
@@ -62,6 +117,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
FR00000000000000000001 /* NucleicRemote.app */,
|
FR00000000000000000001 /* NucleicRemote.app */,
|
||||||
|
FR00000000000000000002 /* NucleicRemoteWidgets.appex */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -76,13 +132,16 @@
|
|||||||
BP00000000000000000002 /* Sources */,
|
BP00000000000000000002 /* Sources */,
|
||||||
BP00000000000000000003 /* Frameworks */,
|
BP00000000000000000003 /* Frameworks */,
|
||||||
BP00000000000000000004 /* Resources */,
|
BP00000000000000000004 /* Resources */,
|
||||||
|
BP00000000000000000005 /* Embed Foundation Extensions */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
|
TD00000000000000000001 /* PBXTargetDependency */,
|
||||||
);
|
);
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
SG00000000000000000001 /* NucleicRemote */,
|
SG00000000000000000001 /* NucleicRemote */,
|
||||||
|
SG00000000000000000003 /* Shared */,
|
||||||
);
|
);
|
||||||
name = NucleicRemote;
|
name = NucleicRemote;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
@@ -92,8 +151,37 @@
|
|||||||
productReference = FR00000000000000000001 /* NucleicRemote.app */;
|
productReference = FR00000000000000000001 /* NucleicRemote.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
};
|
};
|
||||||
|
TG00000000000000000002 /* NucleicRemoteWidgets */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = CL00000000000000000003 /* Build configuration list for PBXNativeTarget "NucleicRemoteWidgets" */;
|
||||||
|
buildPhases = (
|
||||||
|
BP00000000000000000006 /* Sources */,
|
||||||
|
BP00000000000000000007 /* Frameworks */,
|
||||||
|
BP00000000000000000008 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
SG00000000000000000002 /* NucleicRemoteWidgets */,
|
||||||
|
SG00000000000000000003 /* Shared */,
|
||||||
|
);
|
||||||
|
name = NucleicRemoteWidgets;
|
||||||
|
productName = NucleicRemoteWidgets;
|
||||||
|
productReference = FR00000000000000000002 /* NucleicRemoteWidgets.appex */;
|
||||||
|
productType = "com.apple.product-type.app-extension";
|
||||||
|
};
|
||||||
/* End PBXNativeTarget section */
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
TD00000000000000000001 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = TG00000000000000000002 /* NucleicRemoteWidgets */;
|
||||||
|
targetProxy = CP00000000000000000001 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
PJ00000000000000000001 /* Project object */ = {
|
PJ00000000000000000001 /* Project object */ = {
|
||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
@@ -105,6 +193,9 @@
|
|||||||
TG00000000000000000001 = {
|
TG00000000000000000001 = {
|
||||||
CreatedOnToolsVersion = 27.0;
|
CreatedOnToolsVersion = 27.0;
|
||||||
};
|
};
|
||||||
|
TG00000000000000000002 = {
|
||||||
|
CreatedOnToolsVersion = 27.0;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
buildConfigurationList = CL00000000000000000001 /* Build configuration list for PBXProject "NucleicRemote" */;
|
buildConfigurationList = CL00000000000000000001 /* Build configuration list for PBXProject "NucleicRemote" */;
|
||||||
@@ -125,6 +216,7 @@
|
|||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
targets = (
|
targets = (
|
||||||
TG00000000000000000001 /* NucleicRemote */,
|
TG00000000000000000001 /* NucleicRemote */,
|
||||||
|
TG00000000000000000002 /* NucleicRemoteWidgets */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@@ -138,6 +230,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
BP00000000000000000008 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
@@ -148,6 +247,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
BP00000000000000000006 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
@@ -256,6 +362,66 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
|
BC00000000000000000005 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = L7UDTQ6F5W;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = NucleicRemoteWidgets/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = NucleicRemoteWidgets;
|
||||||
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 0.1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = "xyz.blakeslee.nucleic-remote.widgets";
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
BC00000000000000000006 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = L7UDTQ6F5W;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = NucleicRemoteWidgets/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = NucleicRemoteWidgets;
|
||||||
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 0.1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = "xyz.blakeslee.nucleic-remote.widgets";
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
/* End XCBuildConfiguration section */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
@@ -277,6 +443,15 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
|
CL00000000000000000003 /* Build configuration list for PBXNativeTarget "NucleicRemoteWidgets" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
BC00000000000000000005 /* Debug */,
|
||||||
|
BC00000000000000000006 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCLocalSwiftPackageReference section */
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
|
|||||||
@@ -8,5 +8,7 @@
|
|||||||
</array>
|
</array>
|
||||||
<key>NucleicChannel</key>
|
<key>NucleicChannel</key>
|
||||||
<string>$(NUCLEIC_CHANNEL)</string>
|
<string>$(NUCLEIC_CHANNEL)</string>
|
||||||
|
<key>NSSupportsLiveActivities</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import ActivityKit
|
||||||
|
import Foundation
|
||||||
|
import NucleicProtocol
|
||||||
|
|
||||||
|
/// Owns the one aggregate session Live Activity (UX_IOS §5.3): started when work exists,
|
||||||
|
/// updated as sessions change, ended when everything is idle or the device unpairs. State
|
||||||
|
/// flows in from `RemoteStore` on every session-list change; the widget extension renders it
|
||||||
|
/// (`SessionLiveActivity`).
|
||||||
|
@MainActor
|
||||||
|
final class LiveActivityManager {
|
||||||
|
static let shared = LiveActivityManager()
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
private var activity: Activity<NucleicSessionAttributes>?
|
||||||
|
|
||||||
|
/// Reconcile the Activity with the current session set.
|
||||||
|
func sync(hostName: String, sessions: [WireSessionSummary]) {
|
||||||
|
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
|
||||||
|
let live = sessions.filter { !$0.archived }
|
||||||
|
let running = live.filter { $0.status == .running || $0.status == .provisioning }
|
||||||
|
let needsYou = live.filter { $0.status.needsYou($0.disposition) }
|
||||||
|
|
||||||
|
guard !running.isEmpty || !needsYou.isEmpty else {
|
||||||
|
end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The most urgent session headlines: the top waiter, else the freshest runner.
|
||||||
|
let top = needsYou.max { $0.updatedAt < $1.updatedAt }
|
||||||
|
?? running.max { $0.updatedAt < $1.updatedAt }
|
||||||
|
let state = NucleicSessionAttributes.ContentState(
|
||||||
|
runningCount: running.count,
|
||||||
|
needsYouCount: needsYou.count,
|
||||||
|
topTitle: top?.title,
|
||||||
|
topNeedsYou: top.map { $0.status.needsYou($0.disposition) } ?? false)
|
||||||
|
|
||||||
|
if let activity {
|
||||||
|
Task { await activity.update(ActivityContent(state: state, staleDate: nil)) }
|
||||||
|
} else {
|
||||||
|
// Recover an Activity that survived an app relaunch before starting a new one.
|
||||||
|
if let existing = Activity<NucleicSessionAttributes>.activities.first {
|
||||||
|
activity = existing
|
||||||
|
Task { await existing.update(ActivityContent(state: state, staleDate: nil)) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
activity = try? Activity.request(
|
||||||
|
attributes: NucleicSessionAttributes(hostName: hostName),
|
||||||
|
content: ActivityContent(state: state, staleDate: nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End the Activity (all idle, or unpaired).
|
||||||
|
func end() {
|
||||||
|
guard let activity else { return }
|
||||||
|
self.activity = nil
|
||||||
|
Task {
|
||||||
|
await activity.end(
|
||||||
|
ActivityContent(state: activity.content.state, staleDate: nil),
|
||||||
|
dismissalPolicy: .immediate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/* The relay's content-free APNS wake (cloud/nucleic-edge apns.ts `approvalPayload`):
|
||||||
|
the push deliberately carries no approval detail (the relay can't read it — E2EE), so
|
||||||
|
this localized alert is all the lock screen shows. The app pulls the real approval
|
||||||
|
over the encrypted channel on open. */
|
||||||
|
"approval.pending" = "A session is waiting for your approval";
|
||||||
@@ -69,6 +69,43 @@ final class RemoteStore: ObservableObject {
|
|||||||
/// doesn't carry the host's flag, so the phone tracks its own view locally).
|
/// doesn't carry the host's flag, so the phone tracks its own view locally).
|
||||||
@Published private(set) var lastOpenedAt: [SessionID: Date] = RemoteStore.loadLastOpened()
|
@Published private(set) var lastOpenedAt: [SessionID: Date] = RemoteStore.loadLastOpened()
|
||||||
|
|
||||||
|
/// A navigation request from outside the view hierarchy (notification tap → this session).
|
||||||
|
/// `RootView` switches to the Sessions tab; `SessionsView` pushes it and clears.
|
||||||
|
@Published var pendingRoute: SessionID?
|
||||||
|
|
||||||
|
/// Whether the app is foreground-active (scene phase), mirrored here so the store can
|
||||||
|
/// decide which transitions deserve a notification.
|
||||||
|
private(set) var isActive = true
|
||||||
|
|
||||||
|
func setScenePhaseActive(_ active: Bool) { isActive = active }
|
||||||
|
|
||||||
|
/// Route to a session from a notification tap (deep link).
|
||||||
|
func route(to sessionID: SessionID) { pendingRoute = sessionID }
|
||||||
|
|
||||||
|
/// An Allow/Deny straight from a notification action (UX_IOS §5.1). Requires a live
|
||||||
|
/// channel; if the socket dropped while backgrounded, reconnect and send once ready —
|
||||||
|
/// but only briefly (a stale queued approval must never fire minutes later; see
|
||||||
|
/// UX_IOS §11.5, and the host dedupes/`alreadyResolved`s a lost race anyway).
|
||||||
|
func respondFromNotification(_ id: ApprovalID, allow: Bool) {
|
||||||
|
let decision: Decision = allow ? .allow(updatedInput: nil) : .deny(reason: nil)
|
||||||
|
if connectivity.isLive {
|
||||||
|
send(.approvalRespond(id, decision))
|
||||||
|
} else {
|
||||||
|
pendingNotificationDecision = (id, decision, Date())
|
||||||
|
reconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// At most one decision waits for reconnect, and it expires after 30s.
|
||||||
|
private var pendingNotificationDecision: (ApprovalID, Decision, Date)?
|
||||||
|
|
||||||
|
private func flushPendingNotificationDecision() {
|
||||||
|
guard let (id, decision, at) = pendingNotificationDecision else { return }
|
||||||
|
pendingNotificationDecision = nil
|
||||||
|
guard Date().timeIntervalSince(at) < 30 else { return } // stale — require the app
|
||||||
|
send(.approvalRespond(id, decision))
|
||||||
|
}
|
||||||
|
|
||||||
private static let lastOpenedKey = "nucleic.lastOpenedAt"
|
private static let lastOpenedKey = "nucleic.lastOpenedAt"
|
||||||
private static func loadLastOpened() -> [SessionID: Date] {
|
private static func loadLastOpened() -> [SessionID: Date] {
|
||||||
guard let raw = UserDefaults.standard.dictionary(forKey: lastOpenedKey) else { return [:] }
|
guard let raw = UserDefaults.standard.dictionary(forKey: lastOpenedKey) else { return [:] }
|
||||||
@@ -205,6 +242,7 @@ final class RemoteStore: ObservableObject {
|
|||||||
]),
|
]),
|
||||||
WireStatusFeed(provider: "xai", providerName: "xAI", incidents: []),
|
WireStatusFeed(provider: "xai", providerName: "xAI", incidents: []),
|
||||||
])
|
])
|
||||||
|
LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Offline diff fixture (NUCLEIC_DEMO) so the full-patch Diff tab renders without a host.
|
/// Offline diff fixture (NUCLEIC_DEMO) so the full-patch Diff tab renders without a host.
|
||||||
@@ -275,6 +313,8 @@ final class RemoteStore: ObservableObject {
|
|||||||
IdentityStore.clearPairedHost()
|
IdentityStore.clearPairedHost()
|
||||||
connectivity = .unpaired
|
connectivity = .unpaired
|
||||||
sessions = []
|
sessions = []
|
||||||
|
LiveActivityManager.shared.end()
|
||||||
|
NotificationRouter.shared.updateBadge(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Intents (UX_IOS §9)
|
// MARK: - Intents (UX_IOS §9)
|
||||||
@@ -450,11 +490,29 @@ final class RemoteStore: ObservableObject {
|
|||||||
send(.listSessions)
|
send(.listSessions)
|
||||||
send(.listDashboard)
|
send(.listDashboard)
|
||||||
if let id = openSessionID { send(.subscribe(Subscribe(sessionID: id, sinceSeq: nil, verbosity: .full))) }
|
if let id = openSessionID { send(.subscribe(Subscribe(sessionID: id, sinceSeq: nil, verbosity: .full))) }
|
||||||
|
flushPendingNotificationDecision()
|
||||||
case .sessionList(let list):
|
case .sessionList(let list):
|
||||||
sessions = list
|
sessions = list
|
||||||
|
NotificationRouter.shared.updateBadge(needsYouCount)
|
||||||
|
LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions)
|
||||||
case .sessionUpdated(let summary):
|
case .sessionUpdated(let summary):
|
||||||
if let i = sessions.firstIndex(where: { $0.sessionID == summary.sessionID }) { sessions[i] = summary }
|
let previous: WireSessionSummary?
|
||||||
else { sessions.append(summary) }
|
if let i = sessions.firstIndex(where: { $0.sessionID == summary.sessionID }) {
|
||||||
|
previous = sessions[i]
|
||||||
|
sessions[i] = summary
|
||||||
|
} else {
|
||||||
|
previous = nil
|
||||||
|
sessions.append(summary)
|
||||||
|
}
|
||||||
|
// Notify on the transition into "waiting on you" / "finished" — only when the
|
||||||
|
// app isn't foreground-active (in-app, the list's washes and badges carry it).
|
||||||
|
let becameWaiting = summary.status == .awaitingInput
|
||||||
|
&& previous?.status != .awaitingInput
|
||||||
|
if becameWaiting, !isActive, !summary.archived {
|
||||||
|
NotificationRouter.shared.postSessionUpdate(summary)
|
||||||
|
}
|
||||||
|
NotificationRouter.shared.updateBadge(needsYouCount)
|
||||||
|
LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions)
|
||||||
case .dashboard(let snapshot):
|
case .dashboard(let snapshot):
|
||||||
dashboard = snapshot
|
dashboard = snapshot
|
||||||
case .snapshot(let snapshot):
|
case .snapshot(let snapshot):
|
||||||
@@ -472,8 +530,13 @@ final class RemoteStore: ObservableObject {
|
|||||||
if req.sessionID == openSessionID, !openApprovals.contains(where: { $0.id == req.id }) {
|
if req.sessionID == openSessionID, !openApprovals.contains(where: { $0.id == req.id }) {
|
||||||
openApprovals.append(req)
|
openApprovals.append(req)
|
||||||
}
|
}
|
||||||
|
// Always post; the router suppresses the banner when the user is already
|
||||||
|
// looking at this session, and resolution (any device) withdraws it.
|
||||||
|
let title = sessions.first { $0.sessionID == req.sessionID }?.title ?? "Approval"
|
||||||
|
NotificationRouter.shared.postApproval(req, sessionTitle: title)
|
||||||
case .approvalResolved(let resolved):
|
case .approvalResolved(let resolved):
|
||||||
openApprovals.removeAll { $0.id == resolved.id }
|
openApprovals.removeAll { $0.id == resolved.id }
|
||||||
|
NotificationRouter.shared.withdrawApproval(resolved.id)
|
||||||
case .sessionDiff(let diff):
|
case .sessionDiff(let diff):
|
||||||
guard diff.sessionID == openSessionID else { break }
|
guard diff.sessionID == openSessionID else { break }
|
||||||
openDiff = diff
|
openDiff = diff
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import Foundation
|
||||||
|
import UserNotifications
|
||||||
|
import UIKit
|
||||||
|
import NucleicProtocol
|
||||||
|
|
||||||
|
/// The phone's notification pipeline (UX_IOS §5.1): local notifications for approvals and
|
||||||
|
/// needs-input transitions while the app is backgrounded, actionable Allow/Deny for low-risk
|
||||||
|
/// approvals, deep-link routing on tap, and the app-icon badge (= NEEDS YOU count).
|
||||||
|
///
|
||||||
|
/// Two arrival paths converge here:
|
||||||
|
/// - **Local** (LAN): the app is backgrounded but its socket is briefly alive; `RemoteStore`
|
||||||
|
/// posts a rich local notification (content composed on-device — nothing rides APNs).
|
||||||
|
/// - **Remote** (relay, M5): a content-free `approval.pending` tickle wakes the phone; the
|
||||||
|
/// alert text comes from Localizable.strings and the app pulls the real approval over the
|
||||||
|
/// encrypted channel on open.
|
||||||
|
@MainActor
|
||||||
|
final class NotificationRouter: NSObject {
|
||||||
|
static let shared = NotificationRouter()
|
||||||
|
|
||||||
|
/// The store notifications act on; set once at app start.
|
||||||
|
weak var store: RemoteStore?
|
||||||
|
|
||||||
|
// Category / action identifiers (also referenced from the notification service side).
|
||||||
|
static let approvalCategory = "NUCLEIC_APPROVAL"
|
||||||
|
static let approvalActionableCategory = "NUCLEIC_APPROVAL_ACTIONABLE"
|
||||||
|
static let inputCategory = "NUCLEIC_INPUT"
|
||||||
|
static let allowAction = "NUCLEIC_ALLOW"
|
||||||
|
static let denyAction = "NUCLEIC_DENY"
|
||||||
|
|
||||||
|
/// Install the delegate + categories. Call once at launch (before any notification can
|
||||||
|
/// arrive, so actions are always registered).
|
||||||
|
func install(store: RemoteStore) {
|
||||||
|
self.store = store
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
center.delegate = self
|
||||||
|
|
||||||
|
// Low/medium-risk approvals resolve straight from the banner (UX_IOS §5.1);
|
||||||
|
// Allow requires device auth so a pocket-tap can't grant. High-risk approvals use
|
||||||
|
// the action-less category — they must open the app (Face ID gate on the card).
|
||||||
|
let allow = UNNotificationAction(
|
||||||
|
identifier: Self.allowAction, title: "Allow",
|
||||||
|
options: [.authenticationRequired])
|
||||||
|
let deny = UNNotificationAction(
|
||||||
|
identifier: Self.denyAction, title: "Deny",
|
||||||
|
options: [.destructive])
|
||||||
|
let actionable = UNNotificationCategory(
|
||||||
|
identifier: Self.approvalActionableCategory,
|
||||||
|
actions: [deny, allow], intentIdentifiers: [])
|
||||||
|
let plain = UNNotificationCategory(
|
||||||
|
identifier: Self.approvalCategory, actions: [], intentIdentifiers: [])
|
||||||
|
let input = UNNotificationCategory(
|
||||||
|
identifier: Self.inputCategory, actions: [], intentIdentifiers: [])
|
||||||
|
center.setNotificationCategories([actionable, plain, input])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Posting (local path, composed on-device)
|
||||||
|
|
||||||
|
/// Post a local notification for a pending approval. Rich because it never leaves the
|
||||||
|
/// device; the remote tickle stays content-free.
|
||||||
|
func postApproval(_ approval: ApprovalRequest, sessionTitle: String) {
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = sessionTitle
|
||||||
|
content.body = "\(approval.toolName) wants: \(approval.title)"
|
||||||
|
content.sound = .default
|
||||||
|
content.categoryIdentifier = approval.risk.isHigh
|
||||||
|
? Self.approvalCategory : Self.approvalActionableCategory
|
||||||
|
content.userInfo = [
|
||||||
|
"sessionID": approval.sessionID.rawValue,
|
||||||
|
"approvalID": approval.id.rawValue,
|
||||||
|
]
|
||||||
|
// One notification per approval; a re-post for the same id replaces, and resolution
|
||||||
|
// (any device) withdraws it.
|
||||||
|
let request = UNNotificationRequest(
|
||||||
|
identifier: "approval-\(approval.id.rawValue)", content: content, trigger: nil)
|
||||||
|
UNUserNotificationCenter.current().add(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Post a "session needs you / finished" transition notification.
|
||||||
|
func postSessionUpdate(_ summary: WireSessionSummary) {
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = summary.title
|
||||||
|
content.body = summary.status == .awaitingInput && summary.disposition == .completed
|
||||||
|
? "Finished its work." : "Waiting for your next prompt."
|
||||||
|
content.sound = .default
|
||||||
|
content.categoryIdentifier = Self.inputCategory
|
||||||
|
content.userInfo = ["sessionID": summary.sessionID.rawValue]
|
||||||
|
let request = UNNotificationRequest(
|
||||||
|
identifier: "input-\(summary.sessionID.rawValue)", content: content, trigger: nil)
|
||||||
|
UNUserNotificationCenter.current().add(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Withdraw an approval's notification once it's resolved (first-responder-wins — the
|
||||||
|
/// Mac may have answered).
|
||||||
|
func withdrawApproval(_ id: ApprovalID) {
|
||||||
|
let identifier = "approval-\(id.rawValue)"
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
center.removeDeliveredNotifications(withIdentifiers: [identifier])
|
||||||
|
center.removePendingNotificationRequests(withIdentifiers: [identifier])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep the app-icon badge equal to the NEEDS YOU count (UX_IOS §8).
|
||||||
|
func updateBadge(_ count: Int) {
|
||||||
|
UNUserNotificationCenter.current().setBadgeCount(count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NotificationRouter: UNUserNotificationCenterDelegate {
|
||||||
|
/// Foreground arrivals: show the banner unless the user is already looking at that
|
||||||
|
/// session (the approval card is louder than a banner).
|
||||||
|
nonisolated func userNotificationCenter(
|
||||||
|
_ center: UNUserNotificationCenter,
|
||||||
|
willPresent notification: UNNotification
|
||||||
|
) async -> UNNotificationPresentationOptions {
|
||||||
|
let sessionID = notification.request.content.userInfo["sessionID"] as? String
|
||||||
|
let suppress = await MainActor.run {
|
||||||
|
sessionID != nil && store?.openSessionID?.rawValue == sessionID
|
||||||
|
}
|
||||||
|
return suppress ? [] : [.banner, .sound, .badge]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Taps and actions. A tap routes to the session; Allow/Deny resolve the approval over
|
||||||
|
/// a live channel (reconnecting first if the socket dropped).
|
||||||
|
nonisolated func userNotificationCenter(
|
||||||
|
_ center: UNUserNotificationCenter,
|
||||||
|
didReceive response: UNNotificationResponse
|
||||||
|
) async {
|
||||||
|
let info = response.notification.request.content.userInfo
|
||||||
|
let sessionID = (info["sessionID"] as? String).map(SessionID.init(rawValue:))
|
||||||
|
let approvalID = (info["approvalID"] as? String).map(ApprovalID.init(rawValue:))
|
||||||
|
let action = response.actionIdentifier
|
||||||
|
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
guard let store = self?.store else { return }
|
||||||
|
switch action {
|
||||||
|
case Self.allowAction:
|
||||||
|
if let approvalID { store.respondFromNotification(approvalID, allow: true) }
|
||||||
|
case Self.denyAction:
|
||||||
|
if let approvalID { store.respondFromNotification(approvalID, allow: false) }
|
||||||
|
default:
|
||||||
|
// Plain tap (or a long-press open): route to the session.
|
||||||
|
if let sessionID { store.route(to: sessionID) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,9 @@ struct NucleicRemoteApp: App {
|
|||||||
.environmentObject(store)
|
.environmentObject(store)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
store.onAppear()
|
store.onAppear()
|
||||||
|
// Notification categories + delegate must be in place before any
|
||||||
|
// notification can arrive, so actions/taps always route.
|
||||||
|
NotificationRouter.shared.install(store: store)
|
||||||
// Surface the notifications prompt + register for APNS. The token rides along
|
// Surface the notifications prompt + register for APNS. The token rides along
|
||||||
// in the sync Hello; the relay uses it to wake the phone for approvals (§3).
|
// in the sync Hello; the relay uses it to wake the phone for approvals (§3).
|
||||||
// Skipped in demo mode so offline UI previews aren't blocked by the system dialog.
|
// Skipped in demo mode so offline UI previews aren't blocked by the system dialog.
|
||||||
@@ -21,8 +24,10 @@ struct NucleicRemoteApp: App {
|
|||||||
// Anonymous, opt-out, once-a-day DAI heartbeat (docs/CLOUD_INFRA.md §4).
|
// Anonymous, opt-out, once-a-day DAI heartbeat (docs/CLOUD_INFRA.md §4).
|
||||||
Task { await HeartbeatReporter.reportIfDue() }
|
Task { await HeartbeatReporter.reportIfDue() }
|
||||||
}
|
}
|
||||||
// Re-check on every foreground so a new active day is counted.
|
// Re-check on every foreground so a new active day is counted; keep the store's
|
||||||
|
// activity flag current so it only notifies for background transitions.
|
||||||
.onChange(of: scenePhase) { _, phase in
|
.onChange(of: scenePhase) { _, phase in
|
||||||
|
store.setScenePhaseActive(phase == .active)
|
||||||
if phase == .active { Task { await HeartbeatReporter.reportIfDue() } }
|
if phase == .active { Task { await HeartbeatReporter.reportIfDue() } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,6 +69,11 @@ struct RootView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.overlay(alignment: .bottom) { ErrorBubble() }
|
.overlay(alignment: .bottom) { ErrorBubble() }
|
||||||
|
// A notification tap routes to its session: jump to the Sessions tab, where
|
||||||
|
// `SessionsView` consumes `pendingRoute` and pushes the detail.
|
||||||
|
.onChange(of: store.pendingRoute) { _, route in
|
||||||
|
if route != nil { tab = 1 }
|
||||||
|
}
|
||||||
.tint(Palette.accent)
|
.tint(Palette.accent)
|
||||||
.preferredColorScheme((AppAppearance(rawValue: appearanceRaw) ?? .system).colorScheme)
|
.preferredColorScheme((AppAppearance(rawValue: appearanceRaw) ?? .system).colorScheme)
|
||||||
.dynamicTypeSize((AppTextSize(rawValue: textSizeRaw) ?? .medium).dynamicTypeSize)
|
.dynamicTypeSize((AppTextSize(rawValue: textSizeRaw) ?? .medium).dynamicTypeSize)
|
||||||
|
|||||||
@@ -74,7 +74,10 @@ struct SessionsView: View {
|
|||||||
!raw.isEmpty, path.isEmpty {
|
!raw.isEmpty, path.isEmpty {
|
||||||
path.append(SessionID(rawValue: raw))
|
path.append(SessionID(rawValue: raw))
|
||||||
}
|
}
|
||||||
|
consumeRoute()
|
||||||
}
|
}
|
||||||
|
// A notification tap while this tab is already up.
|
||||||
|
.onChange(of: store.pendingRoute) { consumeRoute() }
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
Button {
|
Button {
|
||||||
@@ -87,6 +90,13 @@ struct SessionsView: View {
|
|||||||
.safeAreaInset(edge: .bottom) { ConnectionChip().padding(.bottom, 8) }
|
.safeAreaInset(edge: .bottom) { ConnectionChip().padding(.bottom, 8) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Push the session a notification tap asked for, then clear the request.
|
||||||
|
private func consumeRoute() {
|
||||||
|
guard let route = store.pendingRoute else { return }
|
||||||
|
store.pendingRoute = nil
|
||||||
|
path.append(route)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SessionRow: View {
|
struct SessionRow: View {
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.widgetkit-extension</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct NucleicRemoteWidgets: WidgetBundle {
|
||||||
|
var body: some Widget {
|
||||||
|
SessionLiveActivity()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import ActivityKit
|
||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
/// Lock-screen / Dynamic Island rendering of the aggregate session Activity (UX_IOS §5.3):
|
||||||
|
/// glance = running count + needs-you count + the most urgent session; tap opens the app
|
||||||
|
/// (straight to the approval, via the notification-style route). Self-contained colors — the
|
||||||
|
/// extension doesn't link the app's Theme, so the two palette constants it needs live here
|
||||||
|
/// (the same teal/amber/blue as `Palette`).
|
||||||
|
struct SessionLiveActivity: Widget {
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
ActivityConfiguration(for: NucleicSessionAttributes.self) { context in
|
||||||
|
// Lock screen banner.
|
||||||
|
LockScreenView(state: context.state, hostName: context.attributes.hostName)
|
||||||
|
.activityBackgroundTint(Color.black.opacity(0.55))
|
||||||
|
.activitySystemActionForegroundColor(.white)
|
||||||
|
} dynamicIsland: { context in
|
||||||
|
DynamicIsland {
|
||||||
|
DynamicIslandExpandedRegion(.leading) {
|
||||||
|
Label("\(context.state.runningCount)", systemImage: "circle.lefthalf.filled")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(ActivityPalette.active)
|
||||||
|
}
|
||||||
|
DynamicIslandExpandedRegion(.trailing) {
|
||||||
|
if context.state.needsYouCount > 0 {
|
||||||
|
Label("\(context.state.needsYouCount)", systemImage: "exclamationmark.triangle.fill")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(ActivityPalette.attention)
|
||||||
|
} else {
|
||||||
|
Label("0", systemImage: "checkmark.circle")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DynamicIslandExpandedRegion(.bottom) {
|
||||||
|
if let title = context.state.topTitle {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: context.state.topNeedsYou
|
||||||
|
? "exclamationmark.triangle.fill" : "circle.lefthalf.filled")
|
||||||
|
.foregroundStyle(context.state.topNeedsYou
|
||||||
|
? ActivityPalette.attention : ActivityPalette.active)
|
||||||
|
Text(title).font(.subheadline.weight(.medium)).lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
Text(statusLine(context.state))
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(statusLine(context.state))
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} compactLeading: {
|
||||||
|
Image(systemName: "circle.lefthalf.filled")
|
||||||
|
.foregroundStyle(ActivityPalette.active)
|
||||||
|
} compactTrailing: {
|
||||||
|
if context.state.needsYouCount > 0 {
|
||||||
|
Text("\(context.state.needsYouCount)")
|
||||||
|
.font(.caption2.bold())
|
||||||
|
.foregroundStyle(ActivityPalette.attention)
|
||||||
|
} else {
|
||||||
|
Text("\(context.state.runningCount)")
|
||||||
|
.font(.caption2.bold())
|
||||||
|
.foregroundStyle(ActivityPalette.active)
|
||||||
|
}
|
||||||
|
} minimal: {
|
||||||
|
Image(systemName: context.state.needsYouCount > 0
|
||||||
|
? "exclamationmark.triangle.fill" : "circle.lefthalf.filled")
|
||||||
|
.foregroundStyle(context.state.needsYouCount > 0
|
||||||
|
? ActivityPalette.attention : ActivityPalette.active)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusLine(_ state: NucleicSessionAttributes.ContentState) -> String {
|
||||||
|
var parts: [String] = []
|
||||||
|
if state.runningCount > 0 { parts.append("\(state.runningCount) running") }
|
||||||
|
if state.needsYouCount > 0 { parts.append("\(state.needsYouCount) need you") }
|
||||||
|
return parts.isEmpty ? "Idle" : parts.joined(separator: " · ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct LockScreenView: View {
|
||||||
|
let state: NucleicSessionAttributes.ContentState
|
||||||
|
let hostName: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: state.needsYouCount > 0
|
||||||
|
? "exclamationmark.triangle.fill" : "circle.lefthalf.filled")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(state.needsYouCount > 0
|
||||||
|
? ActivityPalette.attention : ActivityPalette.active)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
if let title = state.topTitle {
|
||||||
|
Text(title).font(.headline).lineLimit(1)
|
||||||
|
}
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
if state.runningCount > 0 {
|
||||||
|
Text("\(state.runningCount) running").foregroundStyle(ActivityPalette.active)
|
||||||
|
}
|
||||||
|
if state.runningCount > 0 && state.needsYouCount > 0 { Text("·") }
|
||||||
|
if state.needsYouCount > 0 {
|
||||||
|
Text("\(state.needsYouCount) waiting for you")
|
||||||
|
.foregroundStyle(ActivityPalette.attention)
|
||||||
|
}
|
||||||
|
if state.runningCount == 0 && state.needsYouCount == 0 {
|
||||||
|
Text("Idle").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text(hostName)
|
||||||
|
.font(.caption2).foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The two categorical colors the Activity needs — same constants as the app's `Palette`.
|
||||||
|
private enum ActivityPalette {
|
||||||
|
static let active = Color(red: 0.20, green: 0.55, blue: 0.95) // running blue
|
||||||
|
static let attention = Color(red: 1.00, green: 0.65, blue: 0.15) // needs-you amber
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import ActivityKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The one aggregate Live Activity (UX_IOS §5.3, aggregate per §11.4 — a single Activity
|
||||||
|
/// respects iOS's budget better than one per session): "N running, M waiting" plus the most
|
||||||
|
/// urgent session's title. Shared between the app (which starts/updates it) and the widget
|
||||||
|
/// extension (which renders it).
|
||||||
|
struct NucleicSessionAttributes: ActivityAttributes {
|
||||||
|
struct ContentState: Codable, Hashable {
|
||||||
|
/// Sessions actively working a turn.
|
||||||
|
var runningCount: Int
|
||||||
|
/// Sessions blocked on the user (approval or next prompt) — the loud number.
|
||||||
|
var needsYouCount: Int
|
||||||
|
/// The most urgent session's title: the top waiting one, else the busiest running.
|
||||||
|
var topTitle: String?
|
||||||
|
/// Whether `topTitle` is waiting on the user (amber) vs just running (blue).
|
||||||
|
var topNeedsYou: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The paired Mac's name (static for the Activity's lifetime).
|
||||||
|
var hostName: String
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user