|
|
@@ -38,11 +38,11 @@ final class RemoteStore: ObservableObject {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@Published private(set) var connectivity: Connectivity = .unpaired
|
|
|
|
@Published private(set) var connectivity: Connectivity = .unpaired
|
|
|
|
|
|
|
|
/// A representative host name for the aggregate — the open session's Mac when a transcript is
|
|
|
|
|
|
|
|
/// open, otherwise a live host. Sessions/projects from *every* connected Mac are merged into the
|
|
|
|
|
|
|
|
/// flat state above (mesh P3), so no single "active host" is chosen; per-row provenance comes
|
|
|
|
|
|
|
|
/// from `hostName(forSession:)` instead.
|
|
|
|
@Published private(set) var hostName: String = ""
|
|
|
|
@Published private(set) var hostName: String = ""
|
|
|
|
/// The paired Mac this connection currently reflects (mesh P3), by its `HostID` (fingerprint).
|
|
|
|
|
|
|
|
/// `hostName`/`sessions`/etc. above are that host's projection; switching hosts re-points them
|
|
|
|
|
|
|
|
/// (live: reconnect to a different paired Mac; demo: swap the mock host). `nil` until connected.
|
|
|
|
|
|
|
|
@Published private(set) var activeHostID: String?
|
|
|
|
|
|
|
|
@Published private(set) var sessions: [WireSessionSummary] = []
|
|
|
|
@Published private(set) var sessions: [WireSessionSummary] = []
|
|
|
|
@Published private(set) var capabilities = WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: [])
|
|
|
|
@Published private(set) var capabilities = WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: [])
|
|
|
|
@Published private(set) var grantedScope: DeviceScope = .approve
|
|
|
|
@Published private(set) var grantedScope: DeviceScope = .approve
|
|
|
@@ -60,6 +60,10 @@ final class RemoteStore: ObservableObject {
|
|
|
|
|
|
|
|
|
|
|
|
// Open session projection.
|
|
|
|
// Open session projection.
|
|
|
|
@Published private(set) var openSessionID: SessionID?
|
|
|
|
@Published private(set) var openSessionID: SessionID?
|
|
|
|
|
|
|
|
/// The paired Mac that owns the open session (mesh P3). The open transcript's intents route to
|
|
|
|
|
|
|
|
/// it, and the host-specific projected values (capabilities/scope/catalog/connectivity) follow
|
|
|
|
|
|
|
|
/// it while a session is open. `nil` when nothing is open.
|
|
|
|
|
|
|
|
private var openSessionHostID: String?
|
|
|
|
@Published private(set) var openEvents: [AgentEvent] = []
|
|
|
|
@Published private(set) var openEvents: [AgentEvent] = []
|
|
|
|
@Published private(set) var openApprovals: [ApprovalRequest] = []
|
|
|
|
@Published private(set) var openApprovals: [ApprovalRequest] = []
|
|
|
|
/// The open session's full diff (the Mac Diff tab's patch), fetched on demand when the
|
|
|
|
/// The open session's full diff (the Mac Diff tab's patch), fetched on demand when the
|
|
|
@@ -180,13 +184,57 @@ final class RemoteStore: ObservableObject {
|
|
|
|
/// Proactive network-path awareness (shared across every host connection): flips transports the
|
|
|
|
/// Proactive network-path awareness (shared across every host connection): flips transports the
|
|
|
|
/// instant Wi-Fi drops or returns, instead of waiting for a dead LAN socket to time out.
|
|
|
|
/// instant Wi-Fi drops or returns, instead of waiting for a dead LAN socket to time out.
|
|
|
|
let pathMonitor = NetworkPathMonitor()
|
|
|
|
let pathMonitor = NetworkPathMonitor()
|
|
|
|
/// One live connection per paired Mac (mesh P3 multiplexer). All connected at once; the flat
|
|
|
|
/// One live connection per paired Mac (mesh P3 multiplexer). All connected at once, and the flat
|
|
|
|
/// `sessions`/`connectivity`/`capabilities`/… above mirror the *active* one (`activeHostID`), so
|
|
|
|
/// `sessions`/`dashboard` above are the *merged* projection across every one of them — the phone
|
|
|
|
/// switching hosts is instant (no reconnect). Empty in demo mode (demo seeds state directly).
|
|
|
|
/// shows all your Macs together, with no active-host selector. Intents route to the Mac that owns
|
|
|
|
|
|
|
|
/// the target session/project (see `send`). Empty in demo mode (demo seeds state directly).
|
|
|
|
private var connections: [String: HostConnection] = [:]
|
|
|
|
private var connections: [String: HostConnection] = [:]
|
|
|
|
/// The active connection — the Mac whose projection the flat state reflects + whose transcript
|
|
|
|
|
|
|
|
/// is open.
|
|
|
|
/// The connection whose projection drives the host-specific flat values (capabilities, scope,
|
|
|
|
private var activeConnection: HostConnection? { activeHostID.flatMap { connections[$0] } }
|
|
|
|
/// catalog, connectivity, host name): the open session's Mac while a transcript is open, else a
|
|
|
|
|
|
|
|
/// representative live host (one with a populated catalog, falling back to any live/any host).
|
|
|
|
|
|
|
|
private var contextConnection: HostConnection? {
|
|
|
|
|
|
|
|
if let id = openSessionHostID, let conn = connections[id] { return conn }
|
|
|
|
|
|
|
|
return connections.values.first { $0.connectivity.isLive && !$0.modelCatalog.groups.isEmpty }
|
|
|
|
|
|
|
|
?? connections.values.first { $0.connectivity.isLive }
|
|
|
|
|
|
|
|
?? connections.values.first
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// A live connection to fall back on for host-agnostic intents (e.g. a project-less to-do
|
|
|
|
|
|
|
|
/// capture, which has no owning Mac).
|
|
|
|
|
|
|
|
private var firstLiveConnection: HostConnection? {
|
|
|
|
|
|
|
|
connections.values.first { $0.connectivity.isLive } ?? connections.values.first
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: Intent routing (mesh P3) — resolve the Mac that owns a session / project / to-do.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private func connection(owningSession id: SessionID) -> HostConnection? {
|
|
|
|
|
|
|
|
connections.values.first { $0.sessions.contains { $0.sessionID == id } }
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
private func connection(owningProject id: ProjectID) -> HostConnection? {
|
|
|
|
|
|
|
|
connections.values.first { $0.dashboard.projects.contains { $0.id == id } }
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
private func connection(owningTodo id: TodoID) -> HostConnection? {
|
|
|
|
|
|
|
|
connections.values.first { $0.dashboard.todos.contains { $0.id == id } }
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Send to every live connection — for host-agnostic pulls (list sessions/dashboard) and as the
|
|
|
|
|
|
|
|
/// safety fallback for an approval whose owning Mac isn't known (a notification-driven decision).
|
|
|
|
|
|
|
|
private func broadcastLive(_ msg: ClientMsg) {
|
|
|
|
|
|
|
|
for conn in connections.values where conn.connectivity.isLive { conn.send(msg) }
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// How many Macs are currently connected — the Sessions list shows a per-row host tag only when
|
|
|
|
|
|
|
|
/// more than one, so a single-Mac view stays clean.
|
|
|
|
|
|
|
|
var connectedHostCount: Int {
|
|
|
|
|
|
|
|
if demoMode { return 1 }
|
|
|
|
|
|
|
|
return connections.values.filter { $0.connectivity.isLive }.count
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// The name of the Mac that owns a session, for the per-row host tag. `nil` in demo (no
|
|
|
|
|
|
|
|
/// connections) or when the session isn't found on any connected Mac.
|
|
|
|
|
|
|
|
func hostName(forSession id: SessionID) -> String? {
|
|
|
|
|
|
|
|
connection(owningSession: id)?.hostName
|
|
|
|
|
|
|
|
}
|
|
|
|
/// The phone's embedded Tailscale node state, for Settings (nil = not running). Shared across all
|
|
|
|
/// The phone's embedded Tailscale node state, for Settings (nil = not running). Shared across all
|
|
|
|
/// connections (one embedded node, many dials).
|
|
|
|
/// connections (one embedded node, many dials).
|
|
|
|
@Published private(set) var tailnetStatus: String?
|
|
|
|
@Published private(set) var tailnetStatus: String?
|
|
|
@@ -207,75 +255,6 @@ final class RemoteStore: ObservableObject {
|
|
|
|
/// In-flight simulated-run tasks (canned streaming), cancelled on `exitDemo()`.
|
|
|
|
/// In-flight simulated-run tasks (canned streaming), cancelled on `exitDemo()`.
|
|
|
|
private var demoTasks: [Task<Void, Never>] = []
|
|
|
|
private var demoTasks: [Task<Void, Never>] = []
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - Multi-host switching (mesh P3)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// One selectable Mac for the host switcher — the paired Macs (live) or the mock hosts (demo).
|
|
|
|
|
|
|
|
struct HostChoice: Identifiable, Equatable {
|
|
|
|
|
|
|
|
let id: String // HostID (fingerprint), or a demo id
|
|
|
|
|
|
|
|
let name: String
|
|
|
|
|
|
|
|
let isActive: Bool
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// The Macs this phone can switch between. Live: the paired-host registry. Demo: the mock hosts.
|
|
|
|
|
|
|
|
/// One entry ⇒ the switcher hides itself and the app reads single-host. Today the phone holds one
|
|
|
|
|
|
|
|
/// *live* connection at a time (switching reconnects); simultaneous connections are the deferred
|
|
|
|
|
|
|
|
/// multiplexer step.
|
|
|
|
|
|
|
|
var hostChoices: [HostChoice] {
|
|
|
|
|
|
|
|
if demoMode {
|
|
|
|
|
|
|
|
return demoHosts.map { HostChoice(id: $0.id, name: $0.name, isActive: $0.id == activeHostID) }
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return IdentityStore.pairedHosts().map {
|
|
|
|
|
|
|
|
HostChoice(id: $0.fingerprint, name: $0.hostName, isActive: $0.fingerprint == activeHostID)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Switch which paired Mac is active. Live: every Mac is already connected, so this is instant —
|
|
|
|
|
|
|
|
/// close the current transcript, re-point at the target connection, and mirror its state. Demo:
|
|
|
|
|
|
|
|
/// swap the mock host's projection, preserving in-demo edits.
|
|
|
|
|
|
|
|
func switchHost(to id: String) {
|
|
|
|
|
|
|
|
guard id != activeHostID else { return }
|
|
|
|
|
|
|
|
if demoMode {
|
|
|
|
|
|
|
|
if let i = demoHosts.firstIndex(where: { $0.id == activeHostID }) {
|
|
|
|
|
|
|
|
demoHosts[i].sessions = sessions // keep any in-demo mutations
|
|
|
|
|
|
|
|
demoHosts[i].dashboard = dashboard
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
guard let next = demoHosts.first(where: { $0.id == id }) else { return }
|
|
|
|
|
|
|
|
applyDemoHost(next)
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
// Close the transcript open on the old host.
|
|
|
|
|
|
|
|
activeConnection?.openSessionID = nil
|
|
|
|
|
|
|
|
openSessionID = nil; openEvents = []; openApprovals = []; openDiff = nil; diffLoading = false
|
|
|
|
|
|
|
|
activeHostID = id
|
|
|
|
|
|
|
|
// The target should already be connected; (re)dial only if it isn't.
|
|
|
|
|
|
|
|
if let host = IdentityStore.pairedHost(id: id) {
|
|
|
|
|
|
|
|
let conn = connection(for: host)
|
|
|
|
|
|
|
|
if !conn.connectivity.isLive { conn.reconnect(to: host) }
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
mirrorActive()
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Mock hosts for the multi-host demo. Each carries its own name + sessions + dashboard;
|
|
|
|
|
|
|
|
/// switching swaps the flat active-host projection. Empty outside demo.
|
|
|
|
|
|
|
|
private struct DemoHost {
|
|
|
|
|
|
|
|
let id: String
|
|
|
|
|
|
|
|
let name: String
|
|
|
|
|
|
|
|
var sessions: [WireSessionSummary]
|
|
|
|
|
|
|
|
var dashboard: DashboardSnapshot
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
private var demoHosts: [DemoHost] = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Project a mock host onto the flat active-host state (demo host switch).
|
|
|
|
|
|
|
|
private func applyDemoHost(_ host: DemoHost) {
|
|
|
|
|
|
|
|
activeHostID = host.id
|
|
|
|
|
|
|
|
hostName = host.name
|
|
|
|
|
|
|
|
sessions = host.sessions
|
|
|
|
|
|
|
|
dashboard = host.dashboard
|
|
|
|
|
|
|
|
openSessionID = nil; openEvents = []; openApprovals = []; openDiff = nil
|
|
|
|
|
|
|
|
LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions)
|
|
|
|
|
|
|
|
NotificationRouter.shared.updateBadge(needsYouCount)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
var isPaired: Bool { demoMode || IdentityStore.loadPairedHost() != nil }
|
|
|
|
var isPaired: Bool { demoMode || IdentityStore.loadPairedHost() != nil }
|
|
|
|
var deviceFingerprint: String { identity.fingerprint }
|
|
|
|
var deviceFingerprint: String { identity.fingerprint }
|
|
|
|
|
|
|
|
|
|
|
@@ -411,11 +390,13 @@ final class RemoteStore: ObservableObject {
|
|
|
|
fiveHour: WireUsageWindow(utilization: 18, resetsAt: Date().addingTimeInterval(2 * 3600)),
|
|
|
|
fiveHour: WireUsageWindow(utilization: 18, resetsAt: Date().addingTimeInterval(2 * 3600)),
|
|
|
|
sevenDay: WireUsageWindow(utilization: 40, resetsAt: nil)),
|
|
|
|
sevenDay: WireUsageWindow(utilization: 40, resetsAt: nil)),
|
|
|
|
statusFeeds: [])
|
|
|
|
statusFeeds: [])
|
|
|
|
demoHosts = [
|
|
|
|
// Mesh P3: seed BOTH demo Macs' worlds merged into the flat aggregate, exactly as the app
|
|
|
|
DemoHost(id: "demo-1", name: "Andrew's Mac", sessions: host1Sessions, dashboard: host1Dashboard),
|
|
|
|
// presents real paired Macs — every host's sessions/projects/to-dos shown together, no
|
|
|
|
DemoHost(id: "demo-2", name: "Studio Mac", sessions: host2Sessions, dashboard: host2Dashboard),
|
|
|
|
// switcher. `demoHandle` then mutates this aggregate directly for the interactive demo.
|
|
|
|
]
|
|
|
|
sessions = host1Sessions + host2Sessions
|
|
|
|
applyDemoHost(demoHosts[0])
|
|
|
|
dashboard = DashboardSnapshot.merged([host1Dashboard, host2Dashboard])
|
|
|
|
|
|
|
|
LiveActivityManager.shared.sync(hostName: hostName, sessions: liveSessions)
|
|
|
|
|
|
|
|
NotificationRouter.shared.updateBadge(needsYouCount)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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.
|
|
|
@@ -461,8 +442,7 @@ final class RemoteStore: ObservableObject {
|
|
|
|
connections[id]?.teardown()
|
|
|
|
connections[id]?.teardown()
|
|
|
|
let conn = makeConnection(hostID: id, hostName: payload.hostName)
|
|
|
|
let conn = makeConnection(hostID: id, hostName: payload.hostName)
|
|
|
|
connections[id] = conn
|
|
|
|
connections[id] = conn
|
|
|
|
activeHostID = id
|
|
|
|
rebuildAggregate()
|
|
|
|
mirrorActive()
|
|
|
|
|
|
|
|
conn.pair(with: payload)
|
|
|
|
conn.pair(with: payload)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
@@ -481,13 +461,14 @@ final class RemoteStore: ObservableObject {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The callbacks one `HostConnection` uses to drive shared/aggregate state. `hostID` is captured
|
|
|
|
/// The callbacks one `HostConnection` uses to drive shared/aggregate state. `hostID` is captured
|
|
|
|
/// so a change updates the flat projection only for the active host, while badges, notifications,
|
|
|
|
/// so the open-transcript forwarding fires only for the Mac that owns the open session, while the
|
|
|
|
/// and the single open transcript are handled globally across hosts.
|
|
|
|
/// merged list/dashboard, badges, and notifications are rebuilt across every host on any change.
|
|
|
|
private func callbacks(for hostID: String) -> HostConnection.Callbacks {
|
|
|
|
private func callbacks(for hostID: String) -> HostConnection.Callbacks {
|
|
|
|
var cb = HostConnection.Callbacks()
|
|
|
|
var cb = HostConnection.Callbacks()
|
|
|
|
cb.didUpdate = { [weak self] in
|
|
|
|
cb.didUpdate = { [weak self] in
|
|
|
|
guard let self else { return }
|
|
|
|
guard let self else { return }
|
|
|
|
if hostID == self.activeHostID { self.mirrorActive() }
|
|
|
|
// Any host's change re-merges the aggregate the flat state binds to (mesh P3).
|
|
|
|
|
|
|
|
self.rebuildAggregate()
|
|
|
|
self.refreshAggregate()
|
|
|
|
self.refreshAggregate()
|
|
|
|
self.flushPendingNotificationDecision()
|
|
|
|
self.flushPendingNotificationDecision()
|
|
|
|
// A host that just connected (or reconnected) needs the current Live Activity token
|
|
|
|
// A host that just connected (or reconnected) needs the current Live Activity token
|
|
|
@@ -495,22 +476,22 @@ final class RemoteStore: ObservableObject {
|
|
|
|
self.syncLiveActivityRegistration()
|
|
|
|
self.syncLiveActivityRegistration()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
cb.openSnapshot = { [weak self] snap in
|
|
|
|
cb.openSnapshot = { [weak self] snap in
|
|
|
|
guard let self, hostID == self.activeHostID, snap.summary.sessionID == self.openSessionID else { return }
|
|
|
|
guard let self, hostID == self.openSessionHostID, snap.summary.sessionID == self.openSessionID else { return }
|
|
|
|
self.openEvents = snap.recentEvents
|
|
|
|
self.openEvents = snap.recentEvents
|
|
|
|
self.openApprovals = snap.pendingApprovals
|
|
|
|
self.openApprovals = snap.pendingApprovals
|
|
|
|
}
|
|
|
|
}
|
|
|
|
cb.openEvents = { [weak self] batch in
|
|
|
|
cb.openEvents = { [weak self] batch in
|
|
|
|
guard let self, hostID == self.activeHostID, batch.sessionID == self.openSessionID else { return }
|
|
|
|
guard let self, hostID == self.openSessionHostID, batch.sessionID == self.openSessionID else { return }
|
|
|
|
self.openEvents.append(contentsOf: batch.events)
|
|
|
|
self.openEvents.append(contentsOf: batch.events)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
cb.openDiff = { [weak self] diff in
|
|
|
|
cb.openDiff = { [weak self] diff in
|
|
|
|
guard let self, hostID == self.activeHostID, diff.sessionID == self.openSessionID else { return }
|
|
|
|
guard let self, hostID == self.openSessionHostID, diff.sessionID == self.openSessionID else { return }
|
|
|
|
self.openDiff = diff
|
|
|
|
self.openDiff = diff
|
|
|
|
self.diffLoading = false
|
|
|
|
self.diffLoading = false
|
|
|
|
}
|
|
|
|
}
|
|
|
|
cb.approvalRequested = { [weak self] req, title in
|
|
|
|
cb.approvalRequested = { [weak self] req, title in
|
|
|
|
guard let self else { return }
|
|
|
|
guard let self else { return }
|
|
|
|
if hostID == self.activeHostID, req.sessionID == self.openSessionID,
|
|
|
|
if hostID == self.openSessionHostID, req.sessionID == self.openSessionID,
|
|
|
|
!self.openApprovals.contains(where: { $0.id == req.id }) {
|
|
|
|
!self.openApprovals.contains(where: { $0.id == req.id }) {
|
|
|
|
self.openApprovals.append(req)
|
|
|
|
self.openApprovals.append(req)
|
|
|
|
}
|
|
|
|
}
|
|
|
@@ -532,7 +513,7 @@ final class RemoteStore: ObservableObject {
|
|
|
|
cb.didPair = { [weak self] host in
|
|
|
|
cb.didPair = { [weak self] host in
|
|
|
|
guard let self else { return }
|
|
|
|
guard let self else { return }
|
|
|
|
IdentityStore.savePairedHost(host)
|
|
|
|
IdentityStore.savePairedHost(host)
|
|
|
|
self.activeHostID = host.fingerprint
|
|
|
|
self.rebuildAggregate()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
cb.meshRosterChanged = { [weak self] in
|
|
|
|
cb.meshRosterChanged = { [weak self] in
|
|
|
|
// Mesh "join": a Mac was learned or revoked via gossip. Reconnect to every paired Mac
|
|
|
|
// Mesh "join": a Mac was learned or revoked via gossip. Reconnect to every paired Mac
|
|
|
@@ -546,32 +527,66 @@ final class RemoteStore: ObservableObject {
|
|
|
|
return cb
|
|
|
|
return cb
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Mirror the active connection's projection onto the flat @Published state the UI binds to.
|
|
|
|
/// Re-merge every connected Mac's projection into the flat @Published state the UI binds to
|
|
|
|
private func mirrorActive() {
|
|
|
|
/// (mesh P3): all hosts' sessions and dashboards shown together, with the host-specific values
|
|
|
|
guard let c = activeConnection else { return }
|
|
|
|
/// (host name, capabilities, catalog, connectivity, scope) following the open session's Mac —
|
|
|
|
connectivity = c.connectivity
|
|
|
|
/// or a representative one. No-op in demo, which seeds the aggregate directly.
|
|
|
|
hostName = c.hostName
|
|
|
|
private func rebuildAggregate() {
|
|
|
|
sessions = c.sessions
|
|
|
|
guard !demoMode else { return }
|
|
|
|
capabilities = c.capabilities
|
|
|
|
sessions = aggregatedSessions()
|
|
|
|
grantedScope = c.grantedScope
|
|
|
|
dashboard = DashboardSnapshot.merged(connections.values.map(\.dashboard))
|
|
|
|
modelCatalog = c.modelCatalog
|
|
|
|
meshPeers = connections.values.flatMap(\.meshPeers)
|
|
|
|
dashboard = c.dashboard
|
|
|
|
|
|
|
|
meshPeers = c.meshPeers
|
|
|
|
let ctx = contextConnection
|
|
|
|
|
|
|
|
hostName = ctx?.hostName ?? ""
|
|
|
|
|
|
|
|
capabilities = ctx?.capabilities
|
|
|
|
|
|
|
|
?? WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: [])
|
|
|
|
|
|
|
|
modelCatalog = ctx?.modelCatalog ?? .empty
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if let id = openSessionHostID, let conn = connections[id] {
|
|
|
|
|
|
|
|
// While a transcript is open, the composer/controls act on *that* Mac — its connectivity
|
|
|
|
|
|
|
|
// gates send and its scope drives the control affordances.
|
|
|
|
|
|
|
|
connectivity = conn.connectivity
|
|
|
|
|
|
|
|
grantedScope = conn.grantedScope
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
connectivity = aggregateConnectivity()
|
|
|
|
|
|
|
|
// Optimistic new-chat gating: enabled if *any* Mac grants control (the owning Mac still
|
|
|
|
|
|
|
|
// enforces scope when the intent lands there).
|
|
|
|
|
|
|
|
grantedScope = connections.values
|
|
|
|
|
|
|
|
.filter { $0.connectivity.isLive }.map(\.grantedScope).max() ?? .approve
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Every connected Mac's live (non-archived) sessions — the aggregate the app-icon badge and
|
|
|
|
/// The flat sessions list: every connected Mac's sessions, deduped by id (ids are globally
|
|
|
|
/// Live Activity summarize across hosts.
|
|
|
|
/// unique). Views handle sorting/grouping.
|
|
|
|
private var allLiveSessions: [WireSessionSummary] {
|
|
|
|
private func aggregatedSessions() -> [WireSessionSummary] {
|
|
|
|
if demoMode { return demoHosts.flatMap(\.sessions).filter { !$0.archived } }
|
|
|
|
var seen = Set<SessionID>()
|
|
|
|
return connections.values.flatMap(\.sessions).filter { !$0.archived }
|
|
|
|
var result: [WireSessionSummary] = []
|
|
|
|
|
|
|
|
for conn in connections.values {
|
|
|
|
|
|
|
|
for summary in conn.sessions where seen.insert(summary.sessionID).inserted {
|
|
|
|
|
|
|
|
result.append(summary)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// One connectivity value for the whole app when no transcript is open (the chip + global
|
|
|
|
|
|
|
|
/// gating): connected if any Mac is live, else the most-informative in-progress/offline state.
|
|
|
|
|
|
|
|
private func aggregateConnectivity() -> Connectivity {
|
|
|
|
|
|
|
|
let all = connections.values.map(\.connectivity)
|
|
|
|
|
|
|
|
guard !all.isEmpty else { return .unpaired }
|
|
|
|
|
|
|
|
if let live = all.first(where: { $0.isLive }) { return live }
|
|
|
|
|
|
|
|
if all.contains(where: { if case .connecting = $0 { return true } else { return false } }) { return .connecting }
|
|
|
|
|
|
|
|
if all.contains(where: { if case .reconnecting = $0 { return true } else { return false } }) { return .reconnecting }
|
|
|
|
|
|
|
|
if all.contains(.hostOffline) { return .hostOffline }
|
|
|
|
|
|
|
|
return all.first ?? .unpaired
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Refresh cross-host aggregates: the app-icon badge (needs-you across *all* Macs) + the Live
|
|
|
|
/// Refresh cross-host aggregates: the app-icon badge (needs-you across *all* Macs) + the Live
|
|
|
|
/// Activity summary.
|
|
|
|
/// Activity summary. `sessions`/`liveSessions` are already the merged aggregate.
|
|
|
|
private func refreshAggregate() {
|
|
|
|
private func refreshAggregate() {
|
|
|
|
let needs = allLiveSessions.filter { $0.status.needsYou($0.disposition) }.count
|
|
|
|
NotificationRouter.shared.updateBadge(needsYouCount)
|
|
|
|
NotificationRouter.shared.updateBadge(needs)
|
|
|
|
LiveActivityManager.shared.sync(hostName: hostName, sessions: liveSessions)
|
|
|
|
LiveActivityManager.shared.sync(hostName: hostName, sessions: allLiveSessions)
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Tear down every live connection (leaving the paired registry intact) — for entering demo.
|
|
|
|
/// Tear down every live connection (leaving the paired registry intact) — for entering demo.
|
|
|
@@ -594,33 +609,43 @@ final class RemoteStore: ObservableObject {
|
|
|
|
let conn = connection(for: host)
|
|
|
|
let conn = connection(for: host)
|
|
|
|
if !conn.connectivity.isLive { conn.reconnect(to: host) }
|
|
|
|
if !conn.connectivity.isLive { conn.reconnect(to: host) }
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if activeHostID == nil || connections[activeHostID!] == nil {
|
|
|
|
rebuildAggregate()
|
|
|
|
activeHostID = hosts.last?.fingerprint
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
mirrorActive()
|
|
|
|
|
|
|
|
refreshAggregate()
|
|
|
|
refreshAggregate()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Unpair this device entirely — drop every paired Mac and its connection (the "Unpair this
|
|
|
|
|
|
|
|
/// device" button). Nothing left to show, so the app returns to the pairing intro.
|
|
|
|
func unpair() {
|
|
|
|
func unpair() {
|
|
|
|
// Forget + drop the active Mac's connection specifically (the switcher may have made it not
|
|
|
|
for (id, conn) in connections { conn.teardown(); connections[id] = nil }
|
|
|
|
// the last). Other Macs' connections keep running.
|
|
|
|
for host in IdentityStore.pairedHosts() { IdentityStore.removePairedHost(id: host.fingerprint) }
|
|
|
|
if let id = activeHostID {
|
|
|
|
|
|
|
|
connections[id]?.teardown()
|
|
|
|
|
|
|
|
connections[id] = nil
|
|
|
|
|
|
|
|
IdentityStore.removePairedHost(id: id)
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
IdentityStore.clearPairedHost()
|
|
|
|
IdentityStore.clearPairedHost()
|
|
|
|
|
|
|
|
finishUnpairIfEmpty()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
activeHostID = nil
|
|
|
|
|
|
|
|
// Mesh P3: if other Macs remain paired, switch to one (already connected) rather than unpairing.
|
|
|
|
/// Forget one paired Mac (the Settings ▸ Mesh per-row remove): drop just its connection +
|
|
|
|
if let next = IdentityStore.pairedHosts().last {
|
|
|
|
/// registry record. Other Macs keep running and stay in the merged view.
|
|
|
|
activeHostID = next.fingerprint
|
|
|
|
func unpair(_ hostID: String) {
|
|
|
|
reconnect()
|
|
|
|
connections[hostID]?.teardown()
|
|
|
|
return
|
|
|
|
connections[hostID] = nil
|
|
|
|
|
|
|
|
IdentityStore.removePairedHost(id: hostID)
|
|
|
|
|
|
|
|
if IdentityStore.pairedHosts().isEmpty {
|
|
|
|
|
|
|
|
finishUnpairIfEmpty()
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
rebuildAggregate()
|
|
|
|
|
|
|
|
refreshAggregate()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Wind the app back to the unpaired state once no Macs remain: clear the open transcript + flat
|
|
|
|
|
|
|
|
/// aggregate and spin the embedded Tailscale node down.
|
|
|
|
|
|
|
|
private func finishUnpairIfEmpty() {
|
|
|
|
|
|
|
|
openSessionID = nil
|
|
|
|
|
|
|
|
openSessionHostID = nil
|
|
|
|
|
|
|
|
openEvents = []; openApprovals = []; openDiff = nil; diffLoading = false
|
|
|
|
connectivity = .unpaired
|
|
|
|
connectivity = .unpaired
|
|
|
|
sessions = []
|
|
|
|
sessions = []
|
|
|
|
|
|
|
|
dashboard = .empty
|
|
|
|
// Nothing left to dial — spin the embedded Tailscale node down if it was running.
|
|
|
|
// Nothing left to dial — spin the embedded Tailscale node down if it was running.
|
|
|
|
Task { await TailnetNode.shared.stop() }
|
|
|
|
Task { await TailnetNode.shared.stop() }
|
|
|
|
tailnetStatus = nil
|
|
|
|
tailnetStatus = nil
|
|
|
@@ -645,9 +670,8 @@ final class RemoteStore: ObservableObject {
|
|
|
|
UserDefaults.standard.set(false, forKey: Self.demoModeKey)
|
|
|
|
UserDefaults.standard.set(false, forKey: Self.demoModeKey)
|
|
|
|
demoTasks.forEach { $0.cancel() }
|
|
|
|
demoTasks.forEach { $0.cancel() }
|
|
|
|
demoTasks.removeAll()
|
|
|
|
demoTasks.removeAll()
|
|
|
|
demoHosts = []
|
|
|
|
|
|
|
|
activeHostID = nil
|
|
|
|
|
|
|
|
openSessionID = nil
|
|
|
|
openSessionID = nil
|
|
|
|
|
|
|
|
openSessionHostID = nil
|
|
|
|
openEvents = []
|
|
|
|
openEvents = []
|
|
|
|
openApprovals = []
|
|
|
|
openApprovals = []
|
|
|
|
openDiff = nil
|
|
|
|
openDiff = nil
|
|
|
@@ -667,15 +691,19 @@ final class RemoteStore: ObservableObject {
|
|
|
|
|
|
|
|
|
|
|
|
func open(_ sessionID: SessionID) {
|
|
|
|
func open(_ sessionID: SessionID) {
|
|
|
|
openSessionID = sessionID
|
|
|
|
openSessionID = sessionID
|
|
|
|
|
|
|
|
// Record which Mac owns this session (mesh P3) so its connection forwards the transcript and
|
|
|
|
|
|
|
|
// the host-specific projected values follow it.
|
|
|
|
|
|
|
|
openSessionHostID = connection(owningSession: sessionID)?.hostID
|
|
|
|
openEvents = []
|
|
|
|
openEvents = []
|
|
|
|
openApprovals = []
|
|
|
|
openApprovals = []
|
|
|
|
openDiff = nil
|
|
|
|
openDiff = nil
|
|
|
|
diffLoading = false
|
|
|
|
diffLoading = false
|
|
|
|
markOpened(sessionID)
|
|
|
|
markOpened(sessionID)
|
|
|
|
if demoMode { seedDemoTranscript(sessionID); return }
|
|
|
|
if demoMode { seedDemoTranscript(sessionID); return }
|
|
|
|
// The open transcript belongs to the active host; tell its connection so it forwards the
|
|
|
|
// Tell the owning connection so it forwards the snapshot/events (and dedupes them), re-derive
|
|
|
|
// snapshot/events (and dedupes them), then subscribe.
|
|
|
|
// the context host (capabilities/scope/catalog/connectivity now follow it), then subscribe.
|
|
|
|
activeConnection?.openSessionID = sessionID
|
|
|
|
connection(owningSession: sessionID)?.openSessionID = sessionID
|
|
|
|
|
|
|
|
rebuildAggregate()
|
|
|
|
send(.subscribe(Subscribe(sessionID: sessionID, sinceSeq: nil, verbosity: .full)))
|
|
|
|
send(.subscribe(Subscribe(sessionID: sessionID, sinceSeq: nil, verbosity: .full)))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
@@ -759,16 +787,25 @@ final class RemoteStore: ObservableObject {
|
|
|
|
send(.unsubscribe(target))
|
|
|
|
send(.unsubscribe(target))
|
|
|
|
markOpened(target) // everything up to now has been seen
|
|
|
|
markOpened(target) // everything up to now has been seen
|
|
|
|
guard openSessionID == target else { return }
|
|
|
|
guard openSessionID == target else { return }
|
|
|
|
activeConnection?.openSessionID = nil
|
|
|
|
connection(owningSession: target)?.openSessionID = nil
|
|
|
|
openSessionID = nil
|
|
|
|
openSessionID = nil
|
|
|
|
|
|
|
|
openSessionHostID = nil
|
|
|
|
openEvents = []
|
|
|
|
openEvents = []
|
|
|
|
openApprovals = []
|
|
|
|
openApprovals = []
|
|
|
|
openDiff = nil
|
|
|
|
openDiff = nil
|
|
|
|
diffLoading = false
|
|
|
|
diffLoading = false
|
|
|
|
|
|
|
|
// Context reverts to the aggregate now that no transcript is open.
|
|
|
|
|
|
|
|
if !demoMode { rebuildAggregate() }
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func respond(_ approval: ApprovalRequest, _ decision: Decision) {
|
|
|
|
func respond(_ approval: ApprovalRequest, _ decision: Decision) {
|
|
|
|
send(.approvalRespond(approval.id, decision))
|
|
|
|
// Route to the Mac that owns the approval's session (the approval message carries no
|
|
|
|
|
|
|
|
// sessionID of its own). Demo resolves locally.
|
|
|
|
|
|
|
|
if demoMode {
|
|
|
|
|
|
|
|
demoHandle(.approvalRespond(approval.id, decision))
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
connection(owningSession: approval.sessionID)?.send(.approvalRespond(approval.id, decision))
|
|
|
|
|
|
|
|
}
|
|
|
|
openApprovals.removeAll { $0.id == approval.id } // optimistic dismiss; host confirms
|
|
|
|
openApprovals.removeAll { $0.id == approval.id } // optimistic dismiss; host confirms
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
@@ -830,12 +867,54 @@ final class RemoteStore: ObservableObject {
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - Plumbing
|
|
|
|
// MARK: - Plumbing
|
|
|
|
|
|
|
|
|
|
|
|
/// Route an intent to the active host's connection (mesh P3). Every current intent — subscribe,
|
|
|
|
/// Route an intent to the Mac that owns its target (mesh P3). Sessions/projects/to-dos are shown
|
|
|
|
/// send-input, approvals, favorite/archive/delete, start-chat — targets a session or project on
|
|
|
|
/// merged across every connected Mac, so an intent goes to the connection that owns the session,
|
|
|
|
/// the Mac whose list is on screen, i.e. the active one.
|
|
|
|
/// project, or to-do it names — not to a single "active" host. Host-agnostic pulls (list
|
|
|
|
|
|
|
|
/// sessions/dashboard) broadcast to every live Mac; a project-less to-do capture (no owner) goes
|
|
|
|
|
|
|
|
/// to the first live Mac. Demo has no connections and mutates the seeded aggregate directly.
|
|
|
|
private func send(_ msg: ClientMsg) {
|
|
|
|
private func send(_ msg: ClientMsg) {
|
|
|
|
if demoMode { demoHandle(msg); return }
|
|
|
|
if demoMode { demoHandle(msg); return }
|
|
|
|
activeConnection?.send(msg)
|
|
|
|
switch msg {
|
|
|
|
|
|
|
|
// Session-owning intents → the Mac that has this session.
|
|
|
|
|
|
|
|
case .subscribe(let s):
|
|
|
|
|
|
|
|
connection(owningSession: s.sessionID)?.send(msg)
|
|
|
|
|
|
|
|
case .unsubscribe(let id), .interrupt(let id), .deleteSession(let id), .discard(let id),
|
|
|
|
|
|
|
|
.integrate(let id, _), .renameSession(let id, _), .setFavorite(let id, _),
|
|
|
|
|
|
|
|
.setArchived(let id, _), .setSessionModel(let id, _), .setSessionEffort(let id, _),
|
|
|
|
|
|
|
|
.setSessionAuto(let id, _), .setSessionAutoShip(let id, _), .setSessionShipBranch(let id, _),
|
|
|
|
|
|
|
|
.sendInput(let id, _), .cancelQueuedMessage(let id, _), .fetchDiff(let id):
|
|
|
|
|
|
|
|
connection(owningSession: id)?.send(msg)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Project-owning intents → the Mac that has this project.
|
|
|
|
|
|
|
|
case .startChat(let req):
|
|
|
|
|
|
|
|
connection(owningProject: req.projectID)?.send(msg)
|
|
|
|
|
|
|
|
case .dispatchTodo(_, let projectID):
|
|
|
|
|
|
|
|
connection(owningProject: projectID)?.send(msg)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// To-do-owning intents → the Mac that has this to-do.
|
|
|
|
|
|
|
|
case .setTodoStatus(let id, _), .deleteTodo(let id):
|
|
|
|
|
|
|
|
connection(owningTodo: id)?.send(msg)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// A capture with a project goes to that Mac; a project-less one has no owner → first live.
|
|
|
|
|
|
|
|
case .captureTodo(let req):
|
|
|
|
|
|
|
|
if let pid = req.projectID, let conn = connection(owningProject: pid) { conn.send(msg) }
|
|
|
|
|
|
|
|
else { firstLiveConnection?.send(msg) }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Approvals carry no sessionID — `respond(_:_:)` routes by the approval's session directly;
|
|
|
|
|
|
|
|
// a stray one here (or a notification-driven decision) broadcasts and the owner resolves it.
|
|
|
|
|
|
|
|
case .approvalRespond:
|
|
|
|
|
|
|
|
broadcastLive(msg)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Host-agnostic pulls → every live Mac.
|
|
|
|
|
|
|
|
case .listSessions, .listDashboard:
|
|
|
|
|
|
|
|
broadcastLive(msg)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Never originated from here (connection-internal, or handled by dedicated loops).
|
|
|
|
|
|
|
|
case .hello, .ping, .listPeers, .addressUpdate, .meshRoster,
|
|
|
|
|
|
|
|
.registerLiveActivity, .endLiveActivity,
|
|
|
|
|
|
|
|
.transferOffer, .transferChunk, .transferCommit, .transferCancel, .fetchTranscript:
|
|
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - Live Activity push registration (UX_IOS §5.3)
|
|
|
|
// MARK: - Live Activity push registration (UX_IOS §5.3)
|
|
|
@@ -1109,6 +1188,59 @@ extension Data {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
extension DashboardSnapshot {
|
|
|
|
|
|
|
|
/// Merge every connected Mac's dashboard into one aggregate projection (mesh P3), so Home,
|
|
|
|
|
|
|
|
/// Projects, and To-Dos show all your Macs together: projects and to-dos concatenated, activity
|
|
|
|
|
|
|
|
/// summed per day, counts summed (active-days recomputed from the merged activity), subscription
|
|
|
|
|
|
|
|
/// usage taken from the first Mac that reports it (a user's Macs share one account, so their
|
|
|
|
|
|
|
|
/// windows match — summing would double-count), and provider status feeds unioned by provider
|
|
|
|
|
|
|
|
/// (provider status is global, not per-Mac).
|
|
|
|
|
|
|
|
static func merged(_ snaps: [DashboardSnapshot]) -> DashboardSnapshot {
|
|
|
|
|
|
|
|
guard snaps.count != 1 else { return snaps[0] }
|
|
|
|
|
|
|
|
guard !snaps.isEmpty else { return .empty }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let projects = snaps.flatMap(\.projects)
|
|
|
|
|
|
|
|
let todos = snaps.flatMap(\.todos)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Activity summed per start-of-day.
|
|
|
|
|
|
|
|
var byDay: [Date: (count: Int, tokens: Int)] = [:]
|
|
|
|
|
|
|
|
for snap in snaps {
|
|
|
|
|
|
|
|
for day in snap.activity {
|
|
|
|
|
|
|
|
let key = Calendar.current.startOfDay(for: day.day)
|
|
|
|
|
|
|
|
var entry = byDay[key] ?? (0, 0)
|
|
|
|
|
|
|
|
entry.count += day.count
|
|
|
|
|
|
|
|
entry.tokens += day.tokens
|
|
|
|
|
|
|
|
byDay[key] = entry
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
let activity = byDay
|
|
|
|
|
|
|
|
.map { ActivityDay(day: $0.key, count: $0.value.count, tokens: $0.value.tokens) }
|
|
|
|
|
|
|
|
.sorted { $0.day < $1.day }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let counts = DashboardCounts(
|
|
|
|
|
|
|
|
projects: projects.count,
|
|
|
|
|
|
|
|
chats: snaps.reduce(0) { $0 + $1.counts.chats },
|
|
|
|
|
|
|
|
activeChats: snaps.reduce(0) { $0 + $1.counts.activeChats },
|
|
|
|
|
|
|
|
messages: snaps.reduce(0) { $0 + $1.counts.messages },
|
|
|
|
|
|
|
|
activeDays: activity.filter { $0.count > 0 || $0.tokens > 0 }.count,
|
|
|
|
|
|
|
|
tokens: snaps.reduce(0) { $0 + $1.counts.tokens })
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let usage = snaps.compactMap(\.usage).first
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
var seenProviders = Set<String>()
|
|
|
|
|
|
|
|
var statusFeeds: [WireStatusFeed] = []
|
|
|
|
|
|
|
|
for snap in snaps {
|
|
|
|
|
|
|
|
for feed in snap.statusFeeds where seenProviders.insert(feed.provider).inserted {
|
|
|
|
|
|
|
|
statusFeeds.append(feed)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return DashboardSnapshot(
|
|
|
|
|
|
|
|
counts: counts, activity: activity, projects: projects,
|
|
|
|
|
|
|
|
todos: todos, usage: usage, statusFeeds: statusFeeds)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
extension WireSessionSummary {
|
|
|
|
extension WireSessionSummary {
|
|
|
|
/// A copy with selected fields overridden — the demo simulator's only way to "mutate" a
|
|
|
|
/// A copy with selected fields overridden — the demo simulator's only way to "mutate" a
|
|
|
|
/// summary, whose stored properties are all `let`. Nullable fields use a double optional so a
|
|
|
|
/// summary, whose stored properties are all `let`. Nullable fields use a double optional so a
|
|
|
|