nvrsion: Add: Align streak card lightning bolt to left side of card, remove streak freeze counter, fix push notification wording to reflect correct session status, adjust push notification logic to recall only the approval request’s notification without affecting live activities, and investigate Live Activity Session Load.
Nucleic-Promote: 1 Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
@@ -161,10 +161,9 @@ struct StreakBadge: View {
|
||||
static let showKey = "nucleic.home.showStreak"
|
||||
let days: Int
|
||||
/// Streak freezes in reserve (one earned per five days), each able to cover a missed day.
|
||||
/// Shown as a small iced-blue snowflake tally beside the count when any are banked.
|
||||
/// Surfaced only in the tooltip (`helpText`), not as an on-card counter.
|
||||
var freezes: Int = 0
|
||||
|
||||
@Environment(\.appPalette) private var palette
|
||||
/// Drives the repeating charge pulse; toggled on appear so the animation runs forever.
|
||||
@State private var charged = false
|
||||
|
||||
@@ -209,6 +208,10 @@ struct StreakBadge: View {
|
||||
.animation(active ? .easeInOut(duration: pulsePeriod).repeatForever(autoreverses: true) : .default,
|
||||
value: pulsing)
|
||||
.onAppear { charged = true }
|
||||
// A fixed leading slot pins the bolt in place: the glyph grows with the streak
|
||||
// but its footprint doesn't, so it stays locked to the left of the count and
|
||||
// never drifts into or overlaps the "day streak" label.
|
||||
.frame(width: 32, alignment: .center)
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Text("\(days)")
|
||||
.font(.system(size: 26, weight: .semibold).monospacedDigit())
|
||||
@@ -216,16 +219,6 @@ struct StreakBadge: View {
|
||||
.font(.system(size: 26, weight: .semibold).monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
// Banked freezes: a small iced-blue snowflake tally, shown only when the streak is
|
||||
// live and at least one freeze is in reserve.
|
||||
if active && freezes > 0 {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: "snowflake")
|
||||
Text("\(freezes)").monospacedDigit()
|
||||
}
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(palette.frozen)
|
||||
}
|
||||
}
|
||||
.opacity(active ? 1 : 0.55)
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
@@ -132,12 +132,17 @@ public actor PushRelayClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the relay to wake a phone (content-free APNS tickle). Throttled per device.
|
||||
public func wake(deviceID: String) async {
|
||||
/// Ask the relay to wake a phone (content-free APNS tickle). Throttled per device. `question`
|
||||
/// tags the wake so the relay's alert reads "waiting for your answer" when the block is an agent
|
||||
/// question (`AskUserQuestion`) rather than a tool approval — the only detail the content-free
|
||||
/// tickle carries is this block *category*, not the question itself.
|
||||
public func wake(deviceID: String, question: Bool = false) async {
|
||||
let now = Date()
|
||||
if let last = lastWake[deviceID], now.timeIntervalSince(last) < Self.wakeInterval { return }
|
||||
lastWake[deviceID] = now
|
||||
_ = await authedPost("/v1/push/notify", body: ["deviceId": deviceID])
|
||||
var body = ["deviceId": deviceID]
|
||||
if question { body["kind"] = "question" }
|
||||
_ = await authedPost("/v1/push/notify", body: body)
|
||||
}
|
||||
|
||||
/// Ask the relay to silently wake a phone so it comes online and registers a push-started Live
|
||||
|
||||
@@ -330,38 +330,28 @@ public actor SyncHost {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pending approvals across every live session — the same aggregate the Live Activity glance
|
||||
/// sums. Zero means no session is blocked on a human, so the wake tickle can be recalled.
|
||||
private func totalPendingApprovals() async -> Int {
|
||||
await bridge.sessionSummaries().reduce(0) { $0 + $1.pendingApprovalCount }
|
||||
}
|
||||
|
||||
private func fanout(_ broadcast: HostBroadcast) async {
|
||||
for handler in handlers { await handler.deliver(broadcast) }
|
||||
// An approval blocks its session on a human — wake paired phones that aren't
|
||||
// connected right now (a connected one already got the broadcast frame). The
|
||||
// push is a content-free tickle (UX_IOS §5.1); the relay throttles per device.
|
||||
if case .approvalRequested = broadcast, let pushRelay {
|
||||
if case .approvalRequested(let req) = broadcast, let pushRelay {
|
||||
// An `AskUserQuestion` block waits for an *answer*, not an approval — tag the wake so the
|
||||
// phone's lock-screen alert reads accurately (the tickle stays content-free otherwise).
|
||||
let question = req.toolName == AskUserQuestion.toolName
|
||||
let connected = await connectedDeviceIDs()
|
||||
for device in await store.all() {
|
||||
guard let token = device.pushToken, !token.isEmpty,
|
||||
!connected.contains(device.deviceID) else { continue }
|
||||
Task { await pushRelay.wake(deviceID: device.deviceID) }
|
||||
}
|
||||
}
|
||||
// The mirror image: once an approval is resolved and nothing else is pending anywhere,
|
||||
// recall the "waiting for your approval" tickle from phones that got it while away.
|
||||
// Gated on the aggregate hitting zero so a still-pending approval's nudge isn't dropped
|
||||
// (its own wake already fired and won't re-fire). A connected phone clears the tickle
|
||||
// itself off the broadcast frame; these are the ones that never saw the resolution.
|
||||
if case .approvalResolved = broadcast, let pushRelay, await totalPendingApprovals() == 0 {
|
||||
let connected = await connectedDeviceIDs()
|
||||
for device in await store.all() {
|
||||
guard let token = device.pushToken, !token.isEmpty,
|
||||
!connected.contains(device.deviceID) else { continue }
|
||||
Task { await pushRelay.clear(deviceID: device.deviceID) }
|
||||
Task { await pushRelay.wake(deviceID: device.deviceID, question: question) }
|
||||
}
|
||||
}
|
||||
// Note: recalling the "waiting for your approval" tickle after a resolution is handled on the
|
||||
// phone, not by a push from here. A connected phone clears the tickle off the `approvalResolved`
|
||||
// broadcast (and reconciles the shared tickle when nothing's left); an away phone clears it on
|
||||
// its next foreground. We deliberately do *not* send a silent background "clear" push: that
|
||||
// relaunches a terminated app, and a background relaunch that reconnects into an empty session
|
||||
// list can tear down a push-started Live Activity before the app re-adopts it.
|
||||
// Any change to a session's status, diff, or approvals shifts the aggregate glance —
|
||||
// refresh the Live Activity for phones registered for push. Off the fanout path (a
|
||||
// network round-trip must not stall broadcast delivery); the drainer coalesces and dedupes.
|
||||
|
||||
@@ -81,11 +81,14 @@ export async function apnsJwt(cfg: ApnsConfig, now = Date.now(), cache: JwtCache
|
||||
* `NotificationRouter.approvalTickleIdentifier` on the phone. */
|
||||
export const APPROVAL_COLLAPSE_ID = "approval-pending";
|
||||
|
||||
/** A wake "tickle": carries no code, transcript, or approval detail. */
|
||||
export function approvalPayload(): Record<string, unknown> {
|
||||
/** A wake "tickle": carries no code, transcript, or approval detail — just the *category* of block
|
||||
* so the phone shows an accurate alert. `question` picks the wording: an agent question
|
||||
* (`AskUserQuestion`) waits for an *answer*, everything else waits for an *approval*. Both ride the
|
||||
* same collapse-id (a session blocked on either is one attention item, recalled by one clear). */
|
||||
export function approvalPayload(question = false): Record<string, unknown> {
|
||||
return {
|
||||
aps: {
|
||||
alert: { "loc-key": "approval.pending" },
|
||||
alert: { "loc-key": question ? "question.pending" : "approval.pending" },
|
||||
"interruption-level": "time-sensitive",
|
||||
sound: "default",
|
||||
},
|
||||
|
||||
@@ -214,7 +214,7 @@ async function pushNotify(request: Request, env: Env): Promise<Response> {
|
||||
const cfg = apnsConfig(env);
|
||||
if (!cfg) return new Response("apns not configured", { status: 503 });
|
||||
|
||||
const body = (await safeJson(request)) as { deviceId?: string } | null;
|
||||
const body = (await safeJson(request)) as { deviceId?: string; kind?: string } | null;
|
||||
if (!body?.deviceId) return new Response("deviceId required", { status: 422 });
|
||||
// Scoped: a host may only wake devices it registered (404 either way, so a probe can't
|
||||
// distinguish "unknown device" from "someone else's device").
|
||||
@@ -225,7 +225,9 @@ async function pushNotify(request: Request, env: Env): Promise<Response> {
|
||||
// runs); the global APNS_ENV / APNS_TOPIC secrets only back records that predate them.
|
||||
const apsEnv = rec.env ?? cfg.env;
|
||||
const topic = rec.topic ?? cfg.topic;
|
||||
const result = await sendApns({ ...cfg, env: apsEnv, topic }, rec.token, approvalPayload());
|
||||
// `kind:"question"` ⇒ the block is an agent question, so the alert reads "waiting for your
|
||||
// answer" rather than "…approval". Absent/any other value keeps the approval wording.
|
||||
const result = await sendApns({ ...cfg, env: apsEnv, topic }, rec.token, approvalPayload(body.kind === "question"));
|
||||
logPush("notify", body.deviceId, topic, apsEnv, result);
|
||||
return json(result, result.status === 200 ? 200 : 502);
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ export class Room implements DurableObject {
|
||||
// MARK: - Control plane (text JSON)
|
||||
|
||||
private async handleControl(ws: WebSocket, raw: string): Promise<void> {
|
||||
let msg: { t?: string; pushToken?: unknown; env?: unknown; topic?: unknown; deviceId?: unknown };
|
||||
let msg: { t?: string; pushToken?: unknown; env?: unknown; topic?: unknown; deviceId?: unknown; kind?: unknown };
|
||||
try {
|
||||
msg = JSON.parse(raw);
|
||||
} catch {
|
||||
@@ -206,7 +206,7 @@ export class Room implements DurableObject {
|
||||
case "wake": {
|
||||
// Only the host may ask the relay to wake a phone, and only when it isn't connected.
|
||||
if (me?.role === "host" && typeof msg.deviceId === "string") {
|
||||
await this.wake(msg.deviceId);
|
||||
await this.wake(msg.deviceId, msg.kind === "question");
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -221,7 +221,7 @@ export class Room implements DurableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private async wake(deviceId: string): Promise<void> {
|
||||
private async wake(deviceId: string, question = false): Promise<void> {
|
||||
const present = this.state.getWebSockets().some((w) => {
|
||||
const m = w.deserializeAttachment() as SocketMeta | null;
|
||||
return m?.deviceId === deviceId;
|
||||
@@ -240,7 +240,7 @@ export class Room implements DurableObject {
|
||||
// The record's environment + topic win (set when the phone/host registered — they know the
|
||||
// build's env and per-channel bundle id); the global APNS_ENV / APNS_TOPIC only backstop
|
||||
// records that predate them. A wrong topic is `BadTopic`-rejected by APNs.
|
||||
await sendApns({ ...cfg, env: env ?? cfg.env, topic: topic ?? cfg.topic }, token, approvalPayload());
|
||||
await sendApns({ ...cfg, env: env ?? cfg.env, topic: topic ?? cfg.topic }, token, approvalPayload(question));
|
||||
}
|
||||
|
||||
/** Recall a phone's approval tickle (silent background push). Unlike `wake` this is worth
|
||||
|
||||
@@ -68,6 +68,17 @@ test("approvalPayload carries no content", () => {
|
||||
assert.equal(/transcript|diff|prompt|secret/.test(JSON.stringify(p)), false);
|
||||
});
|
||||
|
||||
test("approvalPayload picks the loc-key from the block category", () => {
|
||||
const locKey = (p: Record<string, unknown>) =>
|
||||
((p.aps as { alert: { "loc-key": string } }).alert)["loc-key"];
|
||||
// Default (a tool approval) waits for an approval; a question waits for an answer.
|
||||
assert.equal(locKey(approvalPayload()), "approval.pending");
|
||||
assert.equal(locKey(approvalPayload(false)), "approval.pending");
|
||||
assert.equal(locKey(approvalPayload(true)), "question.pending");
|
||||
// Still content-free either way.
|
||||
assert.equal(/transcript|diff|prompt|secret/.test(JSON.stringify(approvalPayload(true))), false);
|
||||
});
|
||||
|
||||
test("sendApns posts to the sandbox host with APNS headers", async () => {
|
||||
const { pem } = await freshKey();
|
||||
const cfg: ApnsConfig = {
|
||||
|
||||
+4
-1
@@ -241,7 +241,10 @@ waiting*; the phone wakes, connects over the encrypted channel, and pulls the re
|
||||
[cloudflare-apns2](https://github.com/FiveSheepCo/cloudflare-apns2).
|
||||
- Content-free payload, e.g.
|
||||
`{"aps":{"alert":{"loc-key":"approval.pending"},"interruption-level":"time-sensitive","sound":"default"},"roomID":"…","nudge":1}`.
|
||||
Optionally pair a `content-available:1` background push so the app can pre-connect silently.
|
||||
The only detail it carries is the block *category*: a `notify` tagged `kind:"question"` swaps the
|
||||
`loc-key` to `question.pending` ("…waiting for your answer") for an `AskUserQuestion` block, vs
|
||||
`approval.pending` ("…waiting for your approval") for a tool approval. Optionally pair a
|
||||
`content-available:1` background push so the app can pre-connect silently.
|
||||
- **Recall (`/v1/push/clear`).** The tickle rides a stable `apns-collapse-id` (`approval-pending`),
|
||||
so repeat wakes coalesce into one lock-screen alert *and* the delivered notification has a known
|
||||
identifier. When an approval is resolved on any device and nothing else is pending, the host asks
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import AppIntents
|
||||
import NucleicProtocol
|
||||
|
||||
// MARK: - Decision option (§6 enums)
|
||||
|
||||
/// The decision an approval intent can carry, mapped from the wire `Decision` (`Approval.swift`).
|
||||
/// The `allowAlways` cases are the safe subset (session / this-tool); pattern scopes and any allow
|
||||
/// on a destructive request are handled by the risk gate, not offered here.
|
||||
enum ApprovalDecisionOption: String, AppEnum {
|
||||
case allow
|
||||
case deny
|
||||
case allowAlwaysSession
|
||||
case allowAlwaysTool
|
||||
|
||||
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Decision")
|
||||
static let caseDisplayRepresentations: [ApprovalDecisionOption: DisplayRepresentation] = [
|
||||
.allow: "Allow",
|
||||
.deny: "Deny",
|
||||
.allowAlwaysSession: "Always Allow (this session)",
|
||||
.allowAlwaysTool: "Always Allow (this tool)",
|
||||
]
|
||||
|
||||
/// Whether this decision grants the request (everything but `deny`) — the half the risk gate
|
||||
/// forbids inline on a high-risk approval.
|
||||
var isAllow: Bool { self != .deny }
|
||||
|
||||
/// The wire `Decision` this option resolves to.
|
||||
func decision() -> Decision {
|
||||
switch self {
|
||||
case .allow: .allow(updatedInput: nil)
|
||||
case .deny: .deny(reason: nil)
|
||||
case .allowAlwaysSession: .allowAlways(.session)
|
||||
case .allowAlwaysTool: .allowAlways(.toolName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Approval entity (§4.6)
|
||||
|
||||
/// A pending approval exposed to Shortcuts/Siri. Backed by `RemoteStore.openApprovals`, which the
|
||||
/// phone holds in full (id, risk, title) only for the *subscribed* session — so today this surfaces
|
||||
/// the approvals of the session you're looking at. (A global pending-approvals feed would need the
|
||||
/// host to carry the top approval's id/risk on the wire summary; see §4.1 / the Live Activity note.)
|
||||
struct ApprovalEntity: AppEntity, Identifiable {
|
||||
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Approval")
|
||||
static let defaultQuery = ApprovalEntityQuery()
|
||||
|
||||
/// `ApprovalID.rawValue`.
|
||||
var id: String
|
||||
/// `SessionID.rawValue` of the owning session — routes the decision to the right Mac.
|
||||
var sessionID: String
|
||||
var toolName: String
|
||||
var requestTitle: String
|
||||
/// `Risk.rawValue`, kept for display; the gate uses `isHighRisk`.
|
||||
var riskLabel: String
|
||||
/// Destructive / network / host-exec — never allowed inline (§3.3).
|
||||
var isHighRisk: Bool
|
||||
|
||||
var displayRepresentation: DisplayRepresentation {
|
||||
DisplayRepresentation(
|
||||
title: "\(toolName): \(requestTitle)",
|
||||
subtitle: "\(riskLabel)")
|
||||
}
|
||||
}
|
||||
|
||||
extension ApprovalEntity {
|
||||
init(_ r: ApprovalRequest) {
|
||||
self.init(
|
||||
id: r.id.rawValue,
|
||||
sessionID: r.sessionID.rawValue,
|
||||
toolName: r.toolName,
|
||||
requestTitle: r.title,
|
||||
riskLabel: r.risk.label,
|
||||
isHighRisk: r.risk.isHigh)
|
||||
}
|
||||
}
|
||||
|
||||
struct ApprovalEntityQuery: EntityQuery {
|
||||
@MainActor
|
||||
func entities(for identifiers: [ApprovalEntity.ID]) async throws -> [ApprovalEntity] {
|
||||
let wanted = Set(identifiers)
|
||||
return RemoteStore.shared.openApprovals
|
||||
.filter { wanted.contains($0.id.rawValue) }
|
||||
.map(ApprovalEntity.init)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func suggestedEntities() async throws -> [ApprovalEntity] {
|
||||
RemoteStore.shared.openApprovals.map(ApprovalEntity.init)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Answer an approval (§4.1, the flagship)
|
||||
|
||||
/// Allow or deny what an agent is asking to do. The risk gate (§3.3) is enforced here so an intent
|
||||
/// can never be a softer approval path than the UI: a high-risk request (destructive / network /
|
||||
/// host-exec) is never granted inline — it routes to the app's guarded card. Deny always goes
|
||||
/// straight through. "Already resolved elsewhere" is a friendly no-op (§3.4), not an error, because
|
||||
/// the host de-dupes a lost first-responder race.
|
||||
struct AnswerApprovalIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Answer Approval"
|
||||
static let description = IntentDescription(
|
||||
"Allow or deny what a Nucleic agent is asking to do.")
|
||||
|
||||
@Parameter(title: "Approval")
|
||||
var approval: ApprovalEntity
|
||||
|
||||
@Parameter(title: "Decision", default: .allow)
|
||||
var decision: ApprovalDecisionOption
|
||||
|
||||
init() {}
|
||||
init(approval: ApprovalEntity, decision: ApprovalDecisionOption) {
|
||||
self.approval = approval
|
||||
self.decision = decision
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult & ProvidesDialog {
|
||||
let store = RemoteStore.shared
|
||||
guard store.isPaired else { throw IntentError.notPaired }
|
||||
let approvalID = ApprovalID(rawValue: approval.id)
|
||||
let sessionID = SessionID(rawValue: approval.sessionID)
|
||||
|
||||
// §3.3 — high-risk can't be granted inline; route to the app's biometric-gated card.
|
||||
if approval.isHighRisk, decision.isAllow {
|
||||
store.route(to: sessionID)
|
||||
throw IntentError.needsAppConfirmation
|
||||
}
|
||||
|
||||
guard await store.awaitLiveConnection() else { throw IntentError.macUnreachable }
|
||||
store.respondToApproval(id: approvalID, sessionID: sessionID, decision: decision.decision())
|
||||
return .result(dialog: decision.isAllow ? "Allowed." : "Denied.")
|
||||
}
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
Summary("\(\.$decision) \(\.$approval)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import AppIntents
|
||||
|
||||
/// Friendly, spoken-safe failures for Nucleic's App Intents. The phone is a thin client with no
|
||||
/// local authority (docs/APP_INTENTS_OPPORTUNITIES §3.1): an intent that can't reach the paired
|
||||
/// Mac must fail *clean* with a dialog, never silently or with a raw error.
|
||||
enum IntentError: Error, CustomLocalizedStringResourceConvertible {
|
||||
/// No live channel to any paired Mac (and none came up within the wait window).
|
||||
case macUnreachable
|
||||
/// Not paired with a Mac yet — nothing to act on.
|
||||
case notPaired
|
||||
/// The action needs `control` scope, which this device hasn't been granted.
|
||||
case controlScopeRequired
|
||||
/// A high-risk approval (destructive/network/host-exec) can't be allowed inline — it must be
|
||||
/// confirmed on the app's guarded card (docs/APP_INTENTS_OPPORTUNITIES §3.3).
|
||||
case needsAppConfirmation
|
||||
|
||||
var localizedStringResource: LocalizedStringResource {
|
||||
switch self {
|
||||
case .macUnreachable:
|
||||
"Your Mac isn't reachable right now. Try again when it's online."
|
||||
case .notPaired:
|
||||
"Pair this iPhone with your Mac in Nucleic first."
|
||||
case .controlScopeRequired:
|
||||
"This device can view and approve, but isn't allowed to control sessions."
|
||||
case .needsAppConfirmation:
|
||||
"This one's high-risk — open Nucleic to confirm it on the approval card."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import AppIntents
|
||||
|
||||
/// Curates the handful of genuinely voice-worthy actions for Siri / Spotlight (§8: keep the spoken
|
||||
/// set to ~4 — approve, unblock, open, "what needs me" — and expose the long tail through Shortcuts
|
||||
/// only). Every phrase must name the app; `\(.applicationName)` resolves to "Nucleic".
|
||||
struct NucleicShortcuts: AppShortcutsProvider {
|
||||
static var appShortcuts: [AppShortcut] {
|
||||
AppShortcut(
|
||||
intent: SessionsNeedingMeIntent(),
|
||||
phrases: [
|
||||
"What needs me in \(.applicationName)",
|
||||
"What's waiting in \(.applicationName)",
|
||||
"Which agents need me in \(.applicationName)",
|
||||
],
|
||||
shortTitle: "What Needs Me",
|
||||
systemImageName: "bell.badge")
|
||||
|
||||
AppShortcut(
|
||||
intent: AnswerApprovalIntent(),
|
||||
phrases: [
|
||||
"Approve in \(.applicationName)",
|
||||
"Answer an approval in \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Answer Approval",
|
||||
systemImageName: "checkmark.shield")
|
||||
|
||||
AppShortcut(
|
||||
intent: SendFollowUpIntent(),
|
||||
phrases: [
|
||||
"Send a follow-up in \(.applicationName)",
|
||||
"Tell an agent in \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Send Follow-Up",
|
||||
systemImageName: "arrowshape.turn.up.right")
|
||||
|
||||
AppShortcut(
|
||||
intent: OpenSessionIntent(),
|
||||
phrases: [
|
||||
"Open a session in \(.applicationName)",
|
||||
"Open \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Open Session",
|
||||
systemImageName: "bubble.left.and.text.bubble.right")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import AppIntents
|
||||
import NucleicProtocol
|
||||
|
||||
/// A Nucleic agent session exposed to Siri / Shortcuts / Spotlight (docs/APP_INTENTS_OPPORTUNITIES
|
||||
/// §4.6). Backed by the host's `SessionSummary` projection the phone already holds; the entity id
|
||||
/// is the wire `SessionID`, so it maps straight onto the deep-link and control APIs.
|
||||
///
|
||||
/// A thin, `Sendable` value snapshot — never the live store row. The `EntityQuery` re-reads
|
||||
/// `RemoteStore` each time so a stale Siri suggestion never acts on outdated state.
|
||||
struct SessionEntity: AppEntity, Identifiable {
|
||||
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Session")
|
||||
static let defaultQuery = SessionEntityQuery()
|
||||
|
||||
/// `SessionID.rawValue`.
|
||||
var id: String
|
||||
var title: String
|
||||
var projectName: String
|
||||
var statusLabel: String
|
||||
/// Whether this session is waiting on the user (approval, or a non-completed input turn).
|
||||
var needsAttention: Bool
|
||||
|
||||
var displayRepresentation: DisplayRepresentation {
|
||||
DisplayRepresentation(
|
||||
title: "\(title)",
|
||||
subtitle: "\(projectName) · \(statusLabel)")
|
||||
}
|
||||
}
|
||||
|
||||
extension SessionEntity {
|
||||
/// Project a wire summary into the entity (falling back to the project name for an untitled
|
||||
/// session, exactly as the session list does).
|
||||
init(_ s: WireSessionSummary) {
|
||||
self.init(
|
||||
id: s.sessionID.rawValue,
|
||||
title: s.title.isEmpty ? s.projectName : s.title,
|
||||
projectName: s.projectName,
|
||||
statusLabel: StatusStyle.label(s.status, disposition: s.disposition),
|
||||
needsAttention: s.status.needsYou(s.disposition))
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves `SessionEntity` values for Shortcuts/Siri from the phone's current (or cached) session
|
||||
/// list. Discovery paths (`suggestedEntities`, string matching) bootstrap the channel first so a
|
||||
/// cold intent process still has data; id resolution stays fast and offline-tolerant.
|
||||
struct SessionEntityQuery: EntityQuery {
|
||||
@MainActor
|
||||
func entities(for identifiers: [SessionEntity.ID]) async throws -> [SessionEntity] {
|
||||
let wanted = Set(identifiers)
|
||||
return RemoteStore.shared.liveSessions
|
||||
.filter { wanted.contains($0.sessionID.rawValue) }
|
||||
.map(SessionEntity.init)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func suggestedEntities() async throws -> [SessionEntity] {
|
||||
let store = RemoteStore.shared
|
||||
store.bootstrapForIntent()
|
||||
return store.liveSessions
|
||||
.sorted(by: StatusStyle.attentionThenRecency)
|
||||
.prefix(12)
|
||||
.map(SessionEntity.init)
|
||||
}
|
||||
}
|
||||
|
||||
/// Lets Siri match a session by spoken name ("open payment-flow") against title or project.
|
||||
extension SessionEntityQuery: EntityStringQuery {
|
||||
@MainActor
|
||||
func entities(matching string: String) async throws -> [SessionEntity] {
|
||||
let store = RemoteStore.shared
|
||||
store.bootstrapForIntent()
|
||||
let needle = string.lowercased()
|
||||
return store.liveSessions
|
||||
.filter {
|
||||
let title = ($0.title.isEmpty ? $0.projectName : $0.title).lowercased()
|
||||
return title.contains(needle) || $0.projectName.lowercased().contains(needle)
|
||||
}
|
||||
.sorted(by: StatusStyle.attentionThenRecency)
|
||||
.map(SessionEntity.init)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import AppIntents
|
||||
import NucleicProtocol
|
||||
|
||||
// MARK: - Open a session (deep-link intent, §4.4)
|
||||
|
||||
/// Open a specific session in the app. Reuses the existing `nucleic://session/<id>` route via
|
||||
/// `RemoteStore.route(to:)` — so a Spotlight result, a Siri phrase, or a Shortcut jumps straight
|
||||
/// into the waiting session. `openAppWhenRun` foregrounds the app (this is a navigation action).
|
||||
struct OpenSessionIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Open Session"
|
||||
static let description = IntentDescription("Open a Nucleic agent session.")
|
||||
static let openAppWhenRun = true
|
||||
|
||||
@Parameter(title: "Session")
|
||||
var session: SessionEntity
|
||||
|
||||
init() {}
|
||||
init(session: SessionEntity) { self.session = session }
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult {
|
||||
RemoteStore.shared.route(to: SessionID(rawValue: session.id))
|
||||
return .result()
|
||||
}
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
Summary("Open \(\.$session)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Unblock with a follow-up (§4.2, approve-scope)
|
||||
|
||||
/// Send a follow-up prompt to a paused agent — the "type the next step to unblock it" flow that
|
||||
/// stays at `approve` scope (docs/UX_IOS §2). Runs in the background over the app's live channel
|
||||
/// (no foregrounding); if the Mac isn't reachable it fails clean with a spoken error (§3.1).
|
||||
struct SendFollowUpIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Send Follow-Up"
|
||||
static let description = IntentDescription(
|
||||
"Send a follow-up prompt to a Nucleic agent to unblock or steer it.")
|
||||
|
||||
@Parameter(title: "Session")
|
||||
var session: SessionEntity
|
||||
|
||||
@Parameter(title: "Message", requestValueDialog: "What should the agent do next?")
|
||||
var text: String
|
||||
|
||||
init() {}
|
||||
init(session: SessionEntity, text: String) {
|
||||
self.session = session
|
||||
self.text = text
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult & ProvidesDialog {
|
||||
let store = RemoteStore.shared
|
||||
guard store.isPaired else { throw IntentError.notPaired }
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return .result(dialog: "That message was empty.") }
|
||||
guard await store.awaitLiveConnection() else { throw IntentError.macUnreachable }
|
||||
store.sendInput(trimmed, to: SessionID(rawValue: session.id))
|
||||
return .result(dialog: "Sent to \(session.title).")
|
||||
}
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
Summary("Tell \(\.$session) to \(\.$text)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - "What needs me?" (§4.3)
|
||||
|
||||
/// List the agents waiting on the user — for approval, or a next prompt — using the same attention
|
||||
/// rule as the badge and NEEDS YOU section (`SessionStatus.needsYou`). Answers Siri's "what needs me
|
||||
/// in Nucleic?" and feeds Shortcuts automations. Runs in the background; returns the list plus a
|
||||
/// spoken summary.
|
||||
struct SessionsNeedingMeIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Sessions Needing Me"
|
||||
static let description = IntentDescription(
|
||||
"List the Nucleic agents waiting on you — for approval or your next prompt.")
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult & ReturnsValue<[SessionEntity]> & ProvidesDialog {
|
||||
let store = RemoteStore.shared
|
||||
guard store.isPaired else { throw IntentError.notPaired }
|
||||
store.bootstrapForIntent()
|
||||
// Give a cold process a brief moment to bring a channel up so the answer is current; fall
|
||||
// back to the persisted list if the Mac stays unreachable.
|
||||
_ = await store.awaitLiveConnection(timeout: 4)
|
||||
let needy = store.liveSessions
|
||||
.filter { $0.status.needsYou($0.disposition) }
|
||||
.sorted(by: StatusStyle.attentionThenRecency)
|
||||
.map(SessionEntity.init)
|
||||
|
||||
let dialog: IntentDialog
|
||||
switch needy.count {
|
||||
case 0: dialog = "Nothing needs you right now."
|
||||
case 1: dialog = "One agent needs you: \(needy[0].title)."
|
||||
default: dialog = "\(needy.count) agents need you."
|
||||
}
|
||||
return .result(value: needy, dialog: dialog)
|
||||
}
|
||||
}
|
||||
@@ -3,3 +3,8 @@
|
||||
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";
|
||||
|
||||
/* The same content-free wake, but the block is an agent question (`AskUserQuestion`) rather than
|
||||
a tool approval — the host tags the wake `kind:"question"` so the lock screen reads accurately.
|
||||
Still content-free: the real question is pulled over the encrypted channel on open. */
|
||||
"question.pending" = "A session is waiting for your answer";
|
||||
|
||||
@@ -1225,6 +1225,64 @@ final class RemoteStore: ObservableObject {
|
||||
func setSessionAutoShip(_ id: SessionID, _ autoShip: Bool) { send(.setSessionAutoShip(id, autoShip)) }
|
||||
func setSessionShipBranch(_ id: SessionID, _ branch: String?) { send(.setSessionShipBranch(id, branch)) }
|
||||
|
||||
// MARK: - App Intents support
|
||||
|
||||
/// Bring networking online and dial the paired host(s) for an App Intent that runs in a *cold*
|
||||
/// background process (Siri / Shortcuts / a widget or Live Activity button), where the SwiftUI
|
||||
/// scene never mounts and `onAppear` never fires. Mirrors the push handler's silent-launch
|
||||
/// bootstrap (`startNetworkingIfNeeded` + `reconnect`). Idempotent; a no-op in demo mode.
|
||||
func bootstrapForIntent() {
|
||||
guard !demoMode else { return }
|
||||
startNetworkingIfNeeded()
|
||||
if isPaired {
|
||||
// Show the persisted session list immediately so a cold intent query (Siri/Spotlight)
|
||||
// has data to answer with before any host connects — same seed as `onAppear`.
|
||||
if sessions.isEmpty { sessions = cachedSummaries }
|
||||
reconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any paired Mac currently has a live channel.
|
||||
var hasLiveConnection: Bool { connections.values.contains { $0.connectivity.isLive } }
|
||||
|
||||
/// Await a live connection to any paired Mac, up to `timeout` seconds — an intent must act over a
|
||||
/// *live* channel or fail clean (UX_IOS §6, §3.1). Brings networking up first if it's cold, then
|
||||
/// polls until a link comes up or the deadline passes. Returns whether a link is live.
|
||||
func awaitLiveConnection(timeout: TimeInterval = 6) async -> Bool {
|
||||
if demoMode || hasLiveConnection { return true }
|
||||
bootstrapForIntent()
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
try? await Task.sleep(for: .milliseconds(200))
|
||||
if hasLiveConnection { return true }
|
||||
}
|
||||
return hasLiveConnection
|
||||
}
|
||||
|
||||
/// Resolve an approval by id from an App Intent with a full `Decision`. Like
|
||||
/// `respondFromNotification`, it prefers the owning Mac, else broadcasts to every live Mac (the
|
||||
/// owner resolves; the rest see an unknown / `alreadyResolved` id and no-op), and queues briefly
|
||||
/// on a dropped link so a decision made just as the socket blips still lands. `sessionID` (when
|
||||
/// the surface knows it) routes directly. Returns whether it went out over a live channel now.
|
||||
@discardableResult
|
||||
func respondToApproval(id: ApprovalID, sessionID: SessionID?, decision: Decision) -> Bool {
|
||||
if demoMode { demoHandle(.approvalRespond(id, decision)); return true }
|
||||
if let sessionID, let conn = connection(owningSession: sessionID), conn.connectivity.isLive {
|
||||
conn.send(.approvalRespond(id, decision))
|
||||
openApprovals.removeAll { $0.id == id }
|
||||
return true
|
||||
}
|
||||
let live = connections.values.filter { $0.connectivity.isLive }
|
||||
if !live.isEmpty {
|
||||
for conn in live { conn.send(.approvalRespond(id, decision)) }
|
||||
openApprovals.removeAll { $0.id == id }
|
||||
return true
|
||||
}
|
||||
pendingNotificationDecision = (id, decision, Date())
|
||||
reconnect()
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Plumbing
|
||||
|
||||
/// Route an intent to the Mac that owns its target (mesh P3). Sessions/projects/to-dos are shown
|
||||
|
||||
@@ -108,21 +108,15 @@ final class NotificationRouter: NSObject {
|
||||
center.removePendingNotificationRequests(withIdentifiers: [identifier])
|
||||
}
|
||||
|
||||
/// Recall the shared "waiting for your approval" attention notification once nothing is pending
|
||||
/// anywhere. This is the remote wake tickle (delivered under `approvalTickleIdentifier`) that a
|
||||
/// prior push left on the lock screen — the counterpart to the per-approval `withdrawApproval`.
|
||||
/// Arrives two ways: the relay's silent `clear` push (phone was away), or the store's own
|
||||
/// reconcile when a live socket sees the last approval resolve. Also sweeps any per-approval
|
||||
/// locals a live socket posted but hasn't individually withdrawn, so the lock screen ends clean.
|
||||
/// Recall the generic remote wake tickle ("A session is waiting for your approval") once no
|
||||
/// approval is pending anywhere. This targets *only* the shared tickle (delivered under
|
||||
/// `approvalTickleIdentifier`, its APNs collapse-id) — the content-free push a phone gets while
|
||||
/// away. Per-approval notifications are each recalled by `withdrawApproval(_:)` as they resolve,
|
||||
/// so a still-pending approval's own banner is never swept away by this.
|
||||
func withdrawApprovalAttention() {
|
||||
let center = UNUserNotificationCenter.current()
|
||||
center.removeDeliveredNotifications(withIdentifiers: [Self.approvalTickleIdentifier])
|
||||
center.removePendingNotificationRequests(withIdentifiers: [Self.approvalTickleIdentifier])
|
||||
center.getDeliveredNotifications { delivered in
|
||||
let stale = delivered.map(\.request.identifier).filter { $0.hasPrefix("approval-") }
|
||||
guard !stale.isEmpty else { return }
|
||||
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: stale)
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the app-icon badge equal to the NEEDS YOU count (UX_IOS §8).
|
||||
|
||||
@@ -131,15 +131,42 @@ private struct LockScreenView: View {
|
||||
private struct SessionRow: View {
|
||||
let line: NucleicSessionAttributes.SessionLine
|
||||
|
||||
/// An approval this row can resolve inline: it's blocked on an approval, carries that approval's
|
||||
/// id, and isn't high-risk (§3.3 — high-risk keeps the plain tap-to-open row and is answered on
|
||||
/// the app's guarded card). `nil` until the producer populates `approvalID` — an older host, or
|
||||
/// today's summary that doesn't yet carry it, so the row stays a deep-link exactly as before.
|
||||
private var inlineApprovalID: String? {
|
||||
guard line.kind == .approval, (line.approvalIsHighRisk ?? false) == false else { return nil }
|
||||
return line.approvalID
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if let url = NucleicDeepLink.session(line.id) {
|
||||
if let approvalID = inlineApprovalID {
|
||||
actionableRow(approvalID: approvalID)
|
||||
} else if let url = NucleicDeepLink.session(line.id) {
|
||||
Link(destination: url) { rowContent }
|
||||
} else {
|
||||
rowContent
|
||||
}
|
||||
}
|
||||
|
||||
private var rowContent: some View {
|
||||
/// An approval row with inline Allow/Deny. The identity still deep-links to the session; the
|
||||
/// buttons fire `ApproveFromActivityIntent`, which the system performs in the app's background
|
||||
/// process — allow/deny without unlocking (§4.1).
|
||||
private func actionableRow(approvalID: String) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
if let url = NucleicDeepLink.session(line.id) {
|
||||
Link(destination: url) { rowIdentity }
|
||||
} else {
|
||||
rowIdentity
|
||||
}
|
||||
Spacer(minLength: 6)
|
||||
ApprovalButtons(sessionID: line.id, approvalID: approvalID)
|
||||
}
|
||||
}
|
||||
|
||||
/// The glyph + title + "project · Backend" — the row's identity, minus the trailing status.
|
||||
private var rowIdentity: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: ActivityPalette.glyph(line.kind))
|
||||
.font(.footnote)
|
||||
@@ -159,6 +186,12 @@ private struct SessionRow: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var rowContent: some View {
|
||||
HStack(spacing: 8) {
|
||||
rowIdentity
|
||||
Spacer(minLength: 6)
|
||||
Text(line.detail)
|
||||
.font(.caption2.weight(.medium))
|
||||
@@ -169,6 +202,33 @@ private struct SessionRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// The compact inline Allow/Deny pair for an actionable approval row. Each button fires
|
||||
/// `ApproveFromActivityIntent` (a low/medium-risk decision — high-risk never reaches here).
|
||||
private struct ApprovalButtons: View {
|
||||
let sessionID: String
|
||||
let approvalID: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Button(intent: intent(allow: false)) {
|
||||
Image(systemName: "xmark").font(.caption2.bold())
|
||||
}
|
||||
.tint(ActivityPalette.danger)
|
||||
Button(intent: intent(allow: true)) {
|
||||
Image(systemName: "checkmark").font(.caption2.bold())
|
||||
}
|
||||
.tint(ActivityPalette.success)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
private func intent(allow: Bool) -> ApproveFromActivityIntent {
|
||||
ApproveFromActivityIntent(
|
||||
approvalID: approvalID, sessionID: sessionID, isHighRisk: false, allow: allow)
|
||||
}
|
||||
}
|
||||
|
||||
/// Headline count chips: running (blue), waiting (teal), approvals (amber). Only nonzero show.
|
||||
private struct CountChips: View {
|
||||
let state: NucleicSessionAttributes.ContentState
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import AppIntents
|
||||
#if canImport(NucleicProtocol)
|
||||
import NucleicProtocol
|
||||
#endif
|
||||
|
||||
/// The interactive approve / deny an aggregate Live Activity (or widget) button fires — the flagship
|
||||
/// "resolve from the lock screen without unlocking" path (docs/APP_INTENTS_OPPORTUNITIES §4.1).
|
||||
///
|
||||
/// It lives in the **Shared** group so both the app and the widget extension can reference it in
|
||||
/// `Button(intent:)`. The widget extension links no `NucleicProtocol`, so this intent carries plain
|
||||
/// `String` ids and compiles its real work only into the app (`#if canImport(NucleicProtocol)`).
|
||||
/// That's sound because iOS runs a widget/Live-Activity button's intent in the **app's background
|
||||
/// process** — where `RemoteStore` owns the live E2EE channel — never in the extension. The
|
||||
/// extension-side copy exists solely to satisfy the `Button(intent:)` type reference.
|
||||
///
|
||||
/// Not discoverable in Shortcuts/Spotlight: it's button-only, driven by ids embedded at render time
|
||||
/// (a human uses `AnswerApprovalIntent` for the spoken/Shortcuts path).
|
||||
struct ApproveFromActivityIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Approve from Live Activity"
|
||||
static let isDiscoverable = false
|
||||
|
||||
@Parameter(title: "Approval ID")
|
||||
var approvalID: String
|
||||
@Parameter(title: "Session ID")
|
||||
var sessionID: String
|
||||
@Parameter(title: "High Risk")
|
||||
var isHighRisk: Bool
|
||||
/// `true` = allow, `false` = deny.
|
||||
@Parameter(title: "Allow")
|
||||
var allow: Bool
|
||||
|
||||
init() {}
|
||||
init(approvalID: String, sessionID: String, isHighRisk: Bool, allow: Bool) {
|
||||
self.approvalID = approvalID
|
||||
self.sessionID = sessionID
|
||||
self.isHighRisk = isHighRisk
|
||||
self.allow = allow
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult {
|
||||
#if canImport(NucleicProtocol)
|
||||
let store = RemoteStore.shared
|
||||
// §3.3 — a high-risk allow is never resolved inline; route to the app's biometric-gated card.
|
||||
// (Surfaces shouldn't render an inline Allow for high-risk in the first place; this is the
|
||||
// backstop so the intent can never be a softer path than the UI.)
|
||||
if isHighRisk, allow {
|
||||
store.route(to: SessionID(rawValue: sessionID))
|
||||
throw IntentError.needsAppConfirmation
|
||||
}
|
||||
// Best-effort bring-up; `respondToApproval` queues briefly on a dropped link (§3.1). A lost
|
||||
// first-responder race is de-duped host-side (§3.4), so we never surface an error for it.
|
||||
_ = await store.awaitLiveConnection()
|
||||
let decision: Decision = allow ? .allow(updatedInput: nil) : .deny(reason: nil)
|
||||
store.respondToApproval(
|
||||
id: ApprovalID(rawValue: approvalID),
|
||||
sessionID: SessionID(rawValue: sessionID),
|
||||
decision: decision)
|
||||
return .result()
|
||||
#else
|
||||
// Widget-extension build: never executed (the system performs this in the app process).
|
||||
return .result()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,30 @@ struct NucleicSessionAttributes: ActivityAttributes {
|
||||
/// A compact right-aligned status: its diff ("3 files +42 −7"), "2 to approve",
|
||||
/// "Waiting on you", "Working…", etc.
|
||||
var detail: String
|
||||
/// The single most-urgent pending approval on this session, when it has one — the id the
|
||||
/// inline Allow/Deny buttons resolve (`ApproveFromActivityIntent`). `nil` when the session
|
||||
/// isn't blocked on an approval, or when the producer doesn't carry it (an older host, or a
|
||||
/// summary that predates the field) — in which case the row falls back to a plain deep-link
|
||||
/// tap, exactly as before. Optional so a host that omits it still decodes.
|
||||
var approvalID: String?
|
||||
/// Whether that approval is high-risk (destructive / network / host-exec). The surface hides
|
||||
/// inline Allow on a high-risk request (§3.3) — only Deny / open-the-app is offered. `nil`
|
||||
/// (treated as unknown → no inline Allow) when the producer doesn't carry it.
|
||||
var approvalIsHighRisk: Bool?
|
||||
|
||||
init(
|
||||
id: String, title: String, project: String, backend: Backend, kind: Kind,
|
||||
detail: String, approvalID: String? = nil, approvalIsHighRisk: Bool? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.project = project
|
||||
self.backend = backend
|
||||
self.kind = kind
|
||||
self.detail = detail
|
||||
self.approvalID = approvalID
|
||||
self.approvalIsHighRisk = approvalIsHighRisk
|
||||
}
|
||||
}
|
||||
|
||||
struct ContentState: Codable, Hashable {
|
||||
|
||||
Reference in New Issue
Block a user