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:
@@ -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