From 47d96901a6b83f094e271ab4d76ad1ee1f166d3f Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sat, 4 Jul 2026 17:02:15 -0700 Subject: [PATCH] =?UTF-8?q?Mesh=20P5:=20finish=20transfer=20feature=20set?= =?UTF-8?q?=20=E2=80=94=20moved/arrived=20visibility,=20recovery,=20bulk?= =?UTF-8?q?=20hand-off?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes Phase 5 of the multi-device mesh / session-transfer program (docs/MESH_TRANSFER.md) except the two-Mac memory-carry spike. All additive + capability-gated; SyncProtocol stays v1. - Moved-session visibility: additive SessionSummary.movedTo (MovedDestination), decode-defaulted. A moved session no longer silently vanishes — the source keeps a read-only "Moved to " tombstone under Archived (name resolved live from paired Macs), surfaced on relaunch without rebuilding a runnable controller, and sent on the wire so phones see it too. - Relaunch recovery driven from launch (+ on every peer reconnect, single-flight): AppStore.recoverInterruptedTransfers clears abandoned pre-tombstone locks, discards orphaned inbound staging, and re-drives a tombstoned commit via SessionTransferCoordinator.recoverTombstoned (bounded, idempotent; a dest that lost staging leaves the lock, never revives the source). - Bulk "Hand off active sessions…": transferableSessions + moveSessionsToPeer (sequential, rollup error) behind a "Hand off…" button → HandoffSheet checklist in RemoteAccessView. - Arrived-from provenance (mirror of moved-to): GRDB v24 arrived_from_device_id/arrived_at; the importer stamps them at staging; additive SessionSummary.arrivedFrom (ArrivedFrom); a subtle "Arrived from " marker on the sidebar (live name) + iOS row (host-baked name). - Stranded-arrival "Activate anyway": the importer persists the staged Session to the staging dir at .ready, so a destination that relaunches before commit can recoverableInboundTransfers() and activateRecoveredTransfer()/clearInboundStaging(). AppStore surfaces pendingArrivedTransfers with activate/discard, shown in a new "Interrupted arrivals" section. (A .ready lock with no manifest is now cleared as unrecoverable.) Tests: +6 core, +2 protocol across WireMessageTests, SessionTransferTests, AppStoreTests, AppStoreSyncBridgeTests. Full package builds; Swift suites green. iOS NucleicRemote edits reviewed but not compiled here (separate Xcode target). Co-Authored-By: Claude Opus 4.8 --- .../NucleicRemote/Models/RemoteStore.swift | 3 +- .../NucleicRemote/Views/SessionsView.swift | 34 +++++++++++++------ 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index d908d71..0352e85 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -1210,6 +1210,7 @@ extension WireSessionSummary { auto: auto ?? self.auto, autoShip: autoShip ?? self.autoShip, shipBranch: shipBranch ?? self.shipBranch, contextInputTokens: contextInputTokens, - updatedAt: updatedAt ?? Date()) + updatedAt: updatedAt ?? Date(), + movedTo: movedTo, arrivedFrom: arrivedFrom) } } diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index 277639e..e9c9d37 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -125,18 +125,32 @@ struct SessionRow: View { } Text(summary.title).font(.body.weight(.medium)).lineLimit(1) // The same trailing markers the Mac sidebar row carries: auto-approval - // bolt, Orchestra note, autoship box. - if summary.auto { - Image(systemName: "bolt.fill").font(.caption2).foregroundStyle(Palette.accent) - } - if orchestra { - Image(systemName: "music.note.list").font(.caption2).foregroundStyle(Palette.orchestra) - } - if summary.autoShip { - Image(systemName: "shippingbox.fill").font(.caption2).foregroundStyle(Palette.accent) + // bolt, Orchestra note, autoship box. Suppressed on a moved-away tombstone. + if summary.movedTo == nil { + if summary.auto { + Image(systemName: "bolt.fill").font(.caption2).foregroundStyle(Palette.accent) + } + if orchestra { + Image(systemName: "music.note.list").font(.caption2).foregroundStyle(Palette.orchestra) + } + if summary.autoShip { + Image(systemName: "shippingbox.fill").font(.caption2).foregroundStyle(Palette.accent) + } } } - if showProjectName { + if let moved = summary.movedTo { + // Moved to another Mac (mesh P5): the name is baked by the host, so the phone + // renders it directly without needing to know that Mac itself. + Label("Moved to \(moved.deviceName)", systemImage: "arrow.up.forward") + .font(.caption).foregroundStyle(.secondary).lineLimit(1) + } else if let arrived = summary.arrivedFrom { + // Arrived from another Mac (mesh P5) — subtle provenance, with the project name + // folded in when it would otherwise show. + Label(showProjectName ? "\(summary.projectName) · from \(arrived.deviceName)" + : "from \(arrived.deviceName)", + systemImage: "arrow.down.forward") + .font(.caption).foregroundStyle(.secondary).lineLimit(1) + } else if showProjectName { Text(summary.projectName).font(.caption).foregroundStyle(.secondary) } }