Files
nucleic/Sources/NucleicApp/RemoteAccessView.swift
T

1028 lines
48 KiB
Swift

import SwiftUI
import CoreImage.CIFilterBuiltins
import NucleicCore
import NucleicProtocol
import NucleicTailnet
/// SF Symbol for a peer, matching the Covalence Mesh Viewer's per-kind icon
/// (`MeshHostRow.kindSymbol`) so the Peers section and the viewer stay visually consistent.
extension PeerKind {
var meshSymbol: String {
switch self {
case .iphone: "iphone"
case .cloud: "cloud"
default: "desktopcomputer" // .mac and any unknown kind
}
}
}
/// Settings ▸ Covalence ▸ "Peers" section (host side, UX_IOS §7): pairs devices while the
/// Covalence-wide switch owns the required meshing-service lifecycle. "Add device…" shows the
/// pairing QR a phone scans or a Mac copies; the meshed sibling Macs (join/presence/leave) are
/// folded in via `MeshMacsView`. This is the one section now — "Remote access" and "Mesh" were
/// merged.
struct RemoteAccessSection: View {
@Environment(AppStore.self) private var store
@State private var showPairing = false
/// Paired phones (Macs get their own section below).
private var pairedPhones: [PairedDevice] {
store.pairedDevices.filter { $0.kind != .mac }
}
var body: some View {
Section("Peers") {
// Shown while remote access is on *or* mid-(re)start — a connection-method
// change bounces the server, and gating on `syncRunning` alone made the whole
// pairing UI blink out during the restart. `beginPairing` needs the live host,
// so the button stays put but disables until the restart commits.
if store.syncRunning || store.syncStartInFlight {
if store.syncStartInFlight && !store.syncRunning {
Text("Reconnecting…")
.font(.caption).foregroundStyle(.secondary)
}
if pairedPhones.isEmpty {
Text("No paired iPhones yet.")
.font(.caption).foregroundStyle(.secondary)
} else {
ForEach(pairedPhones, id: \.deviceID) { device in
// A live inbound connection means this phone is reachable right now
// (mirrors the Paired Macs dot) — otherwise it's paired but offline.
let online = store.inboundConnectedDeviceIDs.contains(device.deviceID)
HStack {
Label(device.label, systemImage: device.kind.meshSymbol)
Circle()
.fill(online ? Color.green : Color.secondary.opacity(0.4))
.frame(width: 7, height: 7)
Spacer()
Button("Revoke") { Task { await store.revokeDevice(device.deviceID) } }
.buttonStyle(.borderless)
.foregroundStyle(.red)
}
.font(.callout)
}
}
// The sibling Macs meshed with this one (mesh P4): join, live presence,
// and leaving — folded into this section now that "Remote access" and
// "Mesh" are one. Only meaningful while the meshing service runs.
MeshMacsView(showPairing: $showPairing)
}
}
.sheet(isPresented: $showPairing, onDismiss: { Task { await store.endPairing() } }) {
PairingQRSheet()
}
// A chat stranded mid-transfer by a relaunch — surfaced regardless of remote-access state so
// the user can always recover their work.
if !store.pendingArrivedTransfers.isEmpty {
InterruptedArrivalsSection()
}
ConnectionTransportSection()
CovalentEdgeSection()
RunnerSection()
}
}
/// Settings ▸ Covalence ▸ "Covalent Edge": the two features that ride Nucleic's edge software —
/// push notifications (the relay that wakes the phone) and lock-screen approvals (resolving an
/// iPhone Live Activity's inline Allow/Deny through the relay). Both depend on the edge software
/// to function, so they share one section.
///
/// **Push notifications** (`nucleic.relay.enabled`): read when the sync server starts, so the
/// toggle bounces remote access to take effect immediately. Dev builds expose relay-URL / APNS
/// overrides.
///
/// **Lock Screen approvals** (`SyncedSettings.resolveApprovalsViaCloud`): the account-level opt-in
/// for resolving a lock-screen decision over the Nucleic Edge relay instead of only the local
/// network — the fix for a decision never reaching this Mac when the phone is backgrounded/off
/// Wi-Fi. Off by default because the path rides Nucleic cloud services. Synced across the mesh:
/// the host is the source of truth (`AppStore.updateSyncedSettings` persists, forces the Relay
/// method on, and broadcasts `HostMsg.settings`); every device's toggle reflects the same value.
private struct CovalentEdgeSection: View {
@Environment(AppStore.self) private var store
@AppStorage("nucleic.relay.enabled") private var relayEnabled = false
@AppStorage(SyncedSettings.resolveApprovalsViaCloudKey) private var resolveViaCloud = false
// Dev-build-only overrides (env vars of the same names win, for scripted runs).
@AppStorage("NUCLEIC_RELAY_URL") private var relayURL = ""
@AppStorage("NUCLEIC_APNS_ENV") private var apnsEnv = "production"
private var isDevBuild: Bool { BuildInfo.current.channel == .local }
var body: some View {
Section("Covalent Edge") {
Toggle("Push notifications", isOn: $relayEnabled)
.onChange(of: relayEnabled) { restartIfRunning() }
if isDevBuild {
TextField("Relay URL (dev)", text: $relayURL,
prompt: Text(PushRelayConfig.defaultBaseURL.absoluteString))
.autocorrectionDisabled()
.onSubmit { restartIfRunning() }
Picker("APNS environment (dev)", selection: $apnsEnv) {
Text("Production (TestFlight / App Store)").tag("production")
Text("Sandbox (Xcode installs)").tag("sandbox")
}
.onChange(of: apnsEnv) { restartIfRunning() }
}
Toggle("Lock Screen approvals", isOn: Binding(
get: { resolveViaCloud },
set: { on in
// The host is the authority: persist, enforce (force the Relay method on so
// this Mac stays reachable in the relay room), and broadcast to every device.
Task { await store.updateSyncedSettings(SyncedSettings(resolveApprovalsViaCloud: on)) }
}))
Text("Let your iPhone's Live Activity Approve/Deny reach this Mac through the Covalence "
+ "Relay, so a decision lands even when the phone is locked or off your Wi-Fi. "
+ "Turning this on keeps the Covalence connection method on. When off, "
+ "the lock-screen buttons open Nucleic to approve locally instead.")
.font(.caption).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
/// Settings are read when the sync server starts; make the push toggle take effect
/// immediately by bouncing remote access when it's on.
private func restartIfRunning() {
guard store.syncRunning else { return }
Task {
await store.stopSyncServer()
await store.startSyncServer()
}
}
}
/// Settings ▸ Covalence ▸ Covalence Cloud (docs/COVALENCE_RUNNER.md §7): a Covalence Cloud runner
/// is a headless cloud (or self-hosted) host you dispatch agents onto through the same mesh as a
/// sibling Mac. This section configures the runner *fleet* — the **max runners** cap — which the
/// app pushes to the runner control plane (and which the pool re-clamps server-side). The cap
/// mirrors the macOS-VM ceiling pattern (`nucleic.macvm.maxConcurrent`): the pool scales the
/// number of independent runner peers up to it on demand and reaps idle ones back to zero.
private struct RunnerSection: View {
@Environment(AppStore.self) private var store
@AppStorage(RunnerSettings.serviceEnabledKey) private var runnerEnabled = false
@AppStorage(RunnerSettings.maxRunnersKey) private var maxRunners = RunnerSettings
.defaultMaxRunners
@AppStorage(RunnerSettings.intelligenceModeKey) private var intelligenceMode =
RunnerIntelligenceMode.mesh.rawValue
// Dev/self-host override; empty ⇒ the production control plane.
@AppStorage(RunnerSettings.controlURLKey) private var controlURL = ""
// Live pool state (COVALENCE_RUNNER §0.2 item 4): fetched on demand from the control
// plane through `AppStore.runnerPoolClient()`; knob changes push debounced PATCHes.
@State private var poolStatus: RunnerPoolStatus?
@State private var poolError: String?
@State private var poolBusy = false
/// Progress/outcome of the provision → pair choreography (booting, joined, fallback hints),
/// mapped from `store.runnerProvisionPhase`.
@State private var pairingNote: String?
@State private var settingsPush: Task<Void, Never>?
/// Which credential kinds this Mac can hand a runner (COVALENCE_RUNNER §6, item 5) —
/// computed off `onAppear`/refresh (it touches the Keychain), not per frame.
@State private var sharedCredentials: [CredentialKind] = []
private var isDevBuild: Bool { BuildInfo.current.channel == .local }
var body: some View {
Section("Covalence Cloud") {
Toggle("Enable Covalence Cloud", isOn: $runnerEnabled)
if runnerEnabled {
Stepper(
"Maximum runners: \(maxRunners)",
value: $maxRunners,
in: RunnerSettings.maxRunnersRange)
Text(
"The most cloud runners to spin up at once. Each runner is an independent "
+ "machine that joins your mesh like a sibling Mac and runs its chats in its "
+ "own container. The fleet scales up on demand — a new chat with no free "
+ "runner boots another, up to this cap — and idle runners shut down on their "
+ "own. Past the cap, new chats queue until a runner frees up.")
.font(.caption).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
// COVALENCE_RUNNER §5, item 6: how the runner replaces Apple Foundation
// Models. Rides the same debounced settings push; the container picks it up on
// its next boot as NUCLEIC_RUNNER_INTELLIGENCE_MODE.
Picker("Runner intelligence", selection: $intelligenceMode) {
ForEach(RunnerIntelligenceMode.allCases, id: \.rawValue) { mode in
Text(mode.displayName).tag(mode.rawValue)
}
}
Text(
"How the runner names chats and writes summaries. “Your devices” delegates "
+ "to this Mac / your iPhone over the mesh (background work may run on the "
+ "phone; time-sensitive work stays on Macs); “Agent” spends a small model's "
+ "tokens on the runner itself. Applies when the runner container next boots.")
.font(.caption).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if isDevBuild {
TextField(
"Control URL (dev)", text: $controlURL,
prompt: Text("https://runner.nucleic.blakeslee.xyz"))
.autocorrectionDisabled()
}
HStack {
Button("Provision runner") { provision() }
.disabled(poolBusy)
Button("Refresh") { Task { await refreshStatus() } }
.disabled(poolBusy)
// Recovery escape hatch: marks every runner stopped (and destroys live
// containers), so the next Provision is a clean boot with a fresh epoch.
Button("Stop runners", role: .destructive) { stopPool() }
.disabled(poolBusy || (poolStatus?.instances.allSatisfy { $0.state == "stopped" } ?? true))
// Registry cleanup: stopped rows hold no container — delete them so the
// list shows only live capacity. Live runners are untouched.
Button("Delete stopped") { pruneStopped() }
.disabled(poolBusy
|| !(poolStatus?.instances.contains { $0.state == "stopped" } ?? false))
if poolBusy { ProgressView().controlSize(.small) }
}
if let poolError {
Text(poolError).font(.caption).foregroundStyle(.red)
.fixedSize(horizontal: false, vertical: true)
}
if let status = poolStatus {
if status.instances.isEmpty {
Text("No runners yet — Provision boots the first one.")
.font(.caption).foregroundStyle(.secondary)
}
ForEach(status.instances) { instance in
HStack {
Image(systemName: "shippingbox")
.foregroundStyle(instance.state == "ready" && !instance.stale ? .green : .secondary)
Text(instance.instanceId).font(.callout.monospaced())
Text(instance.stale ? "stale" : instance.state)
.font(.caption).foregroundStyle(.secondary)
// A running runner whose mesh identity this Mac hasn't paired can't
// take dispatches — "online" in the pool is not "in the mesh".
// Provision adopts it (pull pairing → join) rather than booting more.
if instance.state == "ready", !instance.stale,
let room = instance.roomId,
!store.pairedDevices.contains(where: { $0.deviceID == room }) {
Text("not in mesh — Provision to link")
.font(.caption).foregroundStyle(.orange)
}
Spacer()
Text("\(instance.sessions) session\(instance.sessions == 1 ? "" : "s")")
.font(.caption).foregroundStyle(.secondary)
}
}
}
if let pairingNote {
Text(pairingNote)
.font(.caption).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
// Credential mesh (COVALENCE_RUNNER §6): what this Mac hands a runner when it
// asks. Read-only — a runner requests these over the E2EE mesh; the relay and
// control plane only ever see ciphertext.
Divider()
Text("Credentials shared with runners")
.font(.callout.weight(.medium))
if sharedCredentials.isEmpty {
Text("No shareable credentials found on this Mac. Sign in to Claude (or add "
+ "a GitHub token / API key) and they'll be offered to a runner securely.")
.font(.caption).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
} else {
ForEach(sharedCredentials, id: \.rawValue) { kind in
HStack(spacing: 8) {
Image(systemName: "key.fill").foregroundStyle(.secondary)
Text(Self.credentialLabel(kind))
Spacer()
Image(systemName: "checkmark.seal.fill").foregroundStyle(.green)
}
.font(.caption)
}
Text("Sealed to the runner's key on request — never exposed to the relay.")
.font(.caption2).foregroundStyle(.secondary)
}
}
}
// Knob changes ride to the pool debounced; the DO re-clamps server-side.
.onChange(of: maxRunners) { _, _ in pushKnobs() }
.onChange(of: intelligenceMode) { _, _ in pushKnobs() }
.onChange(of: runnerEnabled) { _, on in
if on {
refreshSharedCredentials()
Task { await refreshStatus() }
}
}
.onAppear {
if runnerEnabled {
refreshSharedCredentials()
Task { await refreshStatus() }
}
}
}
/// Refreshes the shared-credentials list without touching the Keychain on the main
/// thread — the store hops the securityd reads off the main actor, and only the result
/// lands back here. Opening Settings once beach-balled inside a synchronous version of
/// this read (it ran mid window-layout), so it must stay async.
private func refreshSharedCredentials() {
Task { sharedCredentials = await store.credentialKindsSharedWithRunners() }
}
/// A friendly label for a credential kind in the shared list.
private static func credentialLabel(_ kind: CredentialKind) -> String {
switch kind {
case .claudeOAuth: return "Claude login"
case .codexAuth: return "Codex login"
case .grokConfig: return "Grok config"
case .githubToken: return "GitHub token"
case .anthropicAPIKey: return "Anthropic API key"
case .openAIAPIKey: return "OpenAI API key"
case .xaiAPIKey: return "xAI API key"
case .gitCredentialBundle: return "Git credentials"
default: return kind.rawValue
}
}
/// Provision + auto-join, delegated to `AppStore.provisionAndJoinRunner` (COVALENCE_RUNNER §3:
/// the provisioning device completes the Noise handshake itself; roster gossip then introduces
/// the runner to every other device). The same choreography backs the composer's
/// auto-provision, so the logic lives in the store; this button just drives it and mirrors the
/// published `runnerProvisionPhase` into the section's note.
private func provision() {
poolBusy = true
poolError = nil
pairingNote = nil
Task {
defer { poolBusy = false }
_ = await store.provisionAndJoinRunner()
pairingNote = Self.note(for: store.runnerProvisionPhase)
await refreshStatus()
}
}
/// A user-facing line for each provisioning phase.
private static func note(for phase: AppStore.RunnerProvisionPhase) -> String? {
switch phase {
case .idle: return nil
case .booting: return "Runner container booting…"
case .pairing: return "Runner ready — joining the mesh…"
case .joined:
return "Runner paired — it appears in the mesh like a sibling Mac, and your other "
+ "devices learn it automatically."
case .failed(let why): return why
}
}
/// Stop every container in the pool. The recovery path for a wedged/stale host — the next
/// Provision then boots clean with a fresh fencing epoch.
private func stopPool() {
poolBusy = true
poolError = nil
Task {
defer { poolBusy = false }
do {
try await store.runnerPoolClient().stopAll()
pairingNote = nil
await refreshStatus()
} catch {
poolError = error.localizedDescription
}
}
}
/// Delete every stopped runner row from the pool registry (the rows are bookkeeping only —
/// their containers are already gone). Live runners are untouched.
private func pruneStopped() {
poolBusy = true
poolError = nil
Task {
defer { poolBusy = false }
do {
try await store.runnerPoolClient().pruneStopped()
await refreshStatus()
} catch {
poolError = error.localizedDescription
}
}
}
private func refreshStatus() async {
do {
poolStatus = try await store.runnerPoolClient().status()
poolError = nil
} catch {
poolError = error.localizedDescription
}
}
/// Debounced Settings push — a stepper spins through values fast; only the settled one
/// ships. Fire-and-forget: a failure surfaces in the error row, and the DO's clamp makes
/// a lost update harmless (the next push carries the full knob state).
private func pushKnobs() {
settingsPush?.cancel()
settingsPush = Task {
try? await Task.sleep(for: .milliseconds(600))
guard !Task.isCancelled else { return }
do {
try await store.runnerPoolClient().pushSettings(
maxRunners: maxRunners,
intelligenceMode: RunnerIntelligenceMode(rawValue: intelligenceMode))
poolError = nil
} catch {
poolError = error.localizedDescription
}
}
}
}
/// Settings ▸ Remote ▸ Interrupted arrivals (mesh P5): chats that arrived from a peer Mac and were
/// staged + verified here, but this Mac relaunched before the transfer committed. Each can be
/// activated anyway (the work is on disk) or discarded.
private struct InterruptedArrivalsSection: View {
@Environment(AppStore.self) private var store
private func sourceName(_ deviceID: String) -> String {
store.pairedDevices.first { $0.deviceID == deviceID }?.label ?? "another Mac"
}
var body: some View {
Section("Interrupted arrivals") {
Text("These chats arrived from another Mac but weren't finished before this Mac "
+ "restarted. Their work is here — activate to keep it, or discard it.")
.font(.caption).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
ForEach(store.pendingArrivedTransfers) { prompt in
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(prompt.title.isEmpty ? "Untitled chat" : prompt.title)
.font(.callout).lineLimit(1)
Text("from \(sourceName(prompt.sourceDeviceID))")
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
Button("Activate") {
Task { await store.activateArrivedSession(prompt.transferID) }
}
.buttonStyle(.borderless)
Button("Discard", role: .destructive) {
Task { await store.discardArrivedSession(prompt.transferID) }
}
.buttonStyle(.borderless)
.foregroundStyle(.red)
}
}
}
}
}
/// The sibling Macs this one is meshed with (mesh P4): live presence and the connection
/// method in use, plus joining and leaving. Rendered inside the combined "Mesh" section
/// (it has no Section of its own). "Join mesh…" is the joining side of the ceremony — the
/// *other* Mac opens its pairing window (Add device…), the link is copied from its QR
/// sheet, and pasted here; it's hidden once this Mac is already a mesh member. Bulk hand-off
/// lives elsewhere now, so there's no per-peer "Hand off…" here.
private struct MeshMacsView: View {
@Environment(AppStore.self) private var store
@Binding var showPairing: Bool
@State private var showPairMac = false
@State private var confirmLeave = false
private var pairedMacs: [PairedDevice] {
store.pairedDevices.filter { $0.kind == .mac }
}
/// A cloud runner is also a mesh member, even though it is not rendered in the Mac list.
/// Use the membership predicate here so it cannot expose a redundant join action.
private var isMeshMember: Bool {
store.pairedDevices.contains { $0.kind.canOwnSessions }
}
var body: some View {
Group {
HStack {
Button {
showPairing = true
Task { await store.beginPairing() }
} label: {
Label("Add device…", systemImage: "qrcode")
}
.disabled(!store.syncRunning)
// Only offer joining when this Mac isn't already in a mesh — a member grows the
// group by having *new* Macs join it (scan/paste its code), not by joining outward
// again, which would be joining a group it's already in.
if !isMeshMember {
Button {
showPairMac = true
} label: {
Label("Join mesh…", systemImage: "link")
}
}
Spacer()
}
ForEach(pairedMacs, id: \.deviceID) { mac in
let presence = store.meshPeers.first { $0.deviceID == mac.deviceID }
let inbound = store.inboundConnectedDeviceIDs.contains(mac.deviceID)
let online = (presence?.connected ?? false) || inbound
VStack(alignment: .leading, spacing: 2) {
HStack {
Label {
Text(mac.label)
} icon: {
Image(systemName: mac.kind.meshSymbol)
}
Circle()
.fill(online ? Color.green : Color.secondary.opacity(0.4))
.frame(width: 7, height: 7)
if let transport = presence?.transport, presence?.connected == true {
Text("via \(transport.label)")
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
Button("Revoke") { Task { await store.revokeDevice(mac.deviceID) } }
.buttonStyle(.borderless)
.foregroundStyle(.red)
}
.font(.callout)
if let error = presence?.lastError, !online {
Text(error)
.font(.caption).foregroundStyle(.orange)
.fixedSize(horizontal: false, vertical: true)
}
}
}
if !pairedMacs.isEmpty {
Button(role: .destructive) {
confirmLeave = true
} label: {
Label("Leave mesh", systemImage: "rectangle.portrait.and.arrow.right")
}
}
}
.confirmationDialog(
"Leave the mesh?", isPresented: $confirmLeave, titleVisibility: .visible
) {
Button("Leave mesh", role: .destructive) { Task { await store.leaveMesh() } }
Button("Cancel", role: .cancel) {}
} message: {
Text("This leaves the group, forgetting all \(pairedMacs.count) mesh Mac\(pairedMacs.count == 1 ? "" : "s") and dropping every mesh connection. Your sessions stop appearing on those Macs, and theirs stop appearing here. The other members drop this Mac too. You can re-join anytime.")
}
.sheet(isPresented: $showPairMac) {
PairMacSheet()
}
}
}
/// Bulk hand-off (mesh P5): pick which live chats to move to one connected peer Mac at once —
/// the "take the laptop home, hand its sessions to the Mac that stays" flow. Each selected chat
/// rides the same single-session transfer path, run in sequence.
///
/// Currently unwired from Settings — the per-peer "Hand off…" button was removed from the Mesh
/// section; this sheet is retained for the relocated hand-off entry point.
private struct HandoffSheet: View {
@Environment(AppStore.self) private var store
@Environment(\.dismiss) private var dismiss
let deviceID: String
let label: String
@State private var sessions: [NucleicCore.SessionSummary] = []
@State private var selected: Set<SessionID> = []
@State private var loaded = false
@State private var moving = false
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Hand off to \(label)").font(.title2).bold()
if !loaded {
ProgressView().frame(maxWidth: .infinity, minHeight: 120)
} else if sessions.isEmpty {
Text("No chats are ready to move. A chat must be idle, standalone (not part of a "
+ "stack), and have its own worktree.")
.font(.callout).foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
} else {
Text("Each chat is quiesced, bundled, and reopened on \(label). It leaves this Mac "
+ "as a “Moved to \(label)” tombstone.")
.font(.caption).foregroundStyle(.secondary)
HStack {
Button(selected.count == sessions.count ? "Deselect all" : "Select all") {
selected = selected.count == sessions.count ? [] : Set(sessions.map(\.id))
}
.buttonStyle(.borderless).font(.caption)
Spacer()
}
ScrollView {
VStack(alignment: .leading, spacing: 2) {
ForEach(sessions) { summary in
Toggle(isOn: Binding(
get: { selected.contains(summary.id) },
set: { on in
if on { selected.insert(summary.id) } else { selected.remove(summary.id) }
}
)) {
VStack(alignment: .leading, spacing: 1) {
Text(summary.title).lineLimit(1)
Text(store.project(summary.projectID)?.name ?? "")
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
}
}
}
.padding(.vertical, 4)
}
.frame(minHeight: 160)
}
HStack {
Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction)
Spacer()
if !sessions.isEmpty {
Button(moving ? "Moving…" : moveButtonTitle) {
moving = true
Task {
await store.moveSessionsToPeer(Array(selected), to: deviceID, label: label)
dismiss()
}
}
.keyboardShortcut(.defaultAction)
.disabled(selected.isEmpty || moving)
}
}
}
.padding(20)
.frame(width: 440, height: 480)
.task {
sessions = await store.transferableSessions()
selected = Set(sessions.map(\.id)) // default: hand off everything
loaded = true
}
}
private var moveButtonTitle: String {
selected.count == 1 ? "Move 1 chat" : "Move \(selected.count) chats"
}
}
/// The joining Mac's paste-a-link pairing flow (mesh P4). There is no URL-scheme handler —
/// `nucleic://pair?d=…` is just text; `PairingPayload(qrString:)` decodes whatever is pasted.
private struct PairMacSheet: View {
@Environment(AppStore.self) private var store
@Environment(\.dismiss) private var dismiss
@State private var code = ""
@State private var error: String?
@State private var pairing = false
@State private var pairTask: Task<Void, Never>?
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Join mesh").font(.title2).bold()
Text("On a Mac already in the mesh, enable the meshing service, click “Add device…”, "
+ "copy the join code, and paste it here. Its user confirms over there — and this "
+ "joins you to the whole group, not just that one Mac.")
.font(.caption).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
TextField("Join link", text: $code, prompt: Text("nucleic://pair?d=…"))
.autocorrectionDisabled()
.disabled(pairing)
if pairing {
Text("Connecting — waiting for the other Mac to let you in…")
.font(.caption).foregroundStyle(.secondary)
}
if let error {
Text(error)
.font(.caption).foregroundStyle(.orange)
.fixedSize(horizontal: false, vertical: true)
}
HStack {
if pairing { ProgressView().controlSize(.small) }
Spacer()
Button("Cancel") { dismiss() }
Button("Join") { pairTask = Task { await pair() } }
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
.disabled(pairing || code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
.padding(20)
.frame(width: 460)
// Dismissing (Cancel, Esc, click-away) aborts an in-flight attempt — otherwise pair()
// keeps running headless and could pin the peer after the user backed out.
.onDisappear { pairTask?.cancel() }
}
private func pair() async {
pairing = true
error = nil
defer { pairing = false }
do {
try await store.joinMesh(with: code)
dismiss()
} catch let pairError as PeerPairError {
error = pairError.errorDescription
} catch is CancellationError {
// Sheet dismissed mid-attempt; nothing to report.
} catch {
self.error = "Pairing failed: \(error.localizedDescription)"
}
}
}
/// Settings ▸ Remote ▸ Connection methods: which methods paired devices reach this Mac
/// over — a multi-select, not a picker; the sync server listens on every enabled method
/// at once and clients dial whichever they can reach. Local network is the zero-config
/// default. Tailnet runs an embedded Tailscale node (TailscaleKit) so devices connect
/// from anywhere on the user's tailnet — no relay in the path, same Noise E2EE. Covalence
/// (SYNC_PROTOCOL §3.2) is the zero-config internet path: direct peer-to-peer when the
/// network allows, otherwise the Covalence Relay — a Cloudflare room both sides dial out
/// to that only ever sees ciphertext. When only Local network is on, a banner strongly
/// recommends adding Tailnet or Covalence — devices lose each other off-network otherwise.
private struct ConnectionTransportSection: View {
@Environment(AppStore.self) private var store
@AppStorage(SyncTransportSetting.setDefaultsKey) private var transportsRaw = ""
/// When lock-screen approvals resolve over the Nucleic Edge, this Mac must stay in the relay
/// room to receive them — so the Relay method is forced on and locked (host enforces the same
/// at the sync layer; the lock just makes it legible and un-uncheckable here).
@AppStorage(SyncedSettings.resolveApprovalsViaCloudKey) private var resolveApprovalsViaCloud = false
/// The enabled method set; an empty stored string defers to `resolveEnabled`, which
/// migrates the legacy single-choice picker value.
private var enabled: Set<SyncTransportHint> {
let parsed = SyncTransportSetting.parseEnabled(transportsRaw)
return parsed.isEmpty ? SyncTransportSetting.resolveEnabled() : parsed
}
var body: some View {
Section("Connection methods") {
methodToggle(.lan, title: "Local network")
methodToggle(.tailnet, title: "Tailnet")
if enabled.contains(.tailnet) {
tailnetDetails
}
methodToggle(
.relay, title: "Covalence", lockedOn: resolveApprovalsViaCloud,
description: "Connects your devices over the internet — directly when possible, "
+ "or through the end-to-end encrypted Covalence Relay when a direct path "
+ "isn't available.")
if enabled == [.lan] {
lanOnlyWarning
}
}
// Turning Tailnet on registers the node, which mints a browser-login URL — take the
// user straight there once (independent of remote access); the button below re-opens.
.onChange(of: store.tailnetLoginURL) {
if let url = store.tailnetLoginURL { NSWorkspace.shared.open(url) }
}
}
@ViewBuilder
private func methodToggle(
_ hint: SyncTransportHint, title: String, lockedOn: Bool = false, description: String? = nil
) -> some View {
VStack(alignment: .leading, spacing: 2) {
// `lockedOn` pins the toggle checked and disabled (a dependent feature requires it).
Toggle(isOn: lockedOn ? .constant(true) : methodBinding(hint)) {
HStack(spacing: 6) {
Text(title)
statusDot(hint)
}
}
.disabled(lockedOn || enabled == [hint]) // locked, or the last method can't be unchecked
if let description {
Text(description)
.font(.caption).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
if lockedOn {
Text("Required by lock-screen approvals over the Nucleic Edge (keeps Covalence on).")
.font(.caption).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
if let failure = store.syncTransportHealth[hint], enabled.contains(hint) {
Text(failure)
.font(.caption).foregroundStyle(.orange)
.fixedSize(horizontal: false, vertical: true)
}
}
}
/// A labeled connection state for the method, so "is the relay actually up?" is legible
/// rather than inferred from a bare dot: green "Connected" = listener up (for the relay,
/// dialed into its room and reachable), orange "Not reachable" = enabled but the start
/// failed, "Connecting…" while a (re)start is in flight. Nothing when the sync server is
/// off or the method is unchecked.
@ViewBuilder
private func statusDot(_ hint: SyncTransportHint) -> some View {
// `syncRunning` first: the post-commit tail of a (re)start — paired-device refresh,
// peer-client bring-up — keeps `syncStartInFlight` true for a while after the server
// is already listening, and that window must read as the committed state, not
// "Connecting…".
if store.syncRunning, enabled.contains(hint) {
let active = store.syncActiveTransports.contains(hint)
Circle()
.fill(active ? Color.green : Color.orange)
.frame(width: 7, height: 7)
Text(active ? "Connected" : "Not reachable")
.font(.caption)
.foregroundStyle(active ? Color.green : Color.orange)
} else if store.syncStartInFlight, enabled.contains(hint) {
Text("Connecting…")
.font(.caption).foregroundStyle(.secondary)
}
}
private var tailnetDetails: some View {
Group {
if TailnetSupport.isBuiltIn {
if let status = store.tailnetStatus {
LabeledContent("Tailscale node", value: status)
}
if let loginURL = store.tailnetLoginURL {
Button {
NSWorkspace.shared.open(loginURL)
} label: {
Label("Open Tailscale login page", systemImage: "person.crop.circle.badge.checkmark")
}
}
} else {
Text("This build doesn't include Tailscale support — run scripts/build-tailscalekit.sh and rebuild.")
.font(.caption).foregroundStyle(.orange)
.fixedSize(horizontal: false, vertical: true)
}
}
}
/// The strong recommendation (mesh P1): LAN-only means devices lose each other the
/// moment they leave the network. Persistent while the condition holds.
private var lanOnlyWarning: some View {
VStack(alignment: .leading, spacing: 4) {
Label("Local network only", systemImage: "exclamationmark.triangle.fill")
.font(.callout.weight(.semibold))
.foregroundStyle(.orange)
Text("Your devices will lose each other the moment they leave this network. "
+ "Add Tailnet or Covalence so they can always reconnect.")
.font(.caption)
.fixedSize(horizontal: false, vertical: true)
HStack {
Button("Use Covalence") { methodBinding(.relay).wrappedValue = true }
Button("Use my Tailnet") { methodBinding(.tailnet).wrappedValue = true }
}
}
.padding(.vertical, 4)
}
private func methodBinding(_ hint: SyncTransportHint) -> Binding<Bool> {
Binding(
get: { enabled.contains(hint) },
set: { on in
var set = enabled
if on { set.insert(hint) } else { set.remove(hint) }
guard !set.isEmpty else { return } // at least one method stays enabled
transportsRaw = SyncTransportSetting.serializeEnabled(set)
// Tailnet registers its node the moment it's enabled — independent of remote
// access — so the browser login opens right away. When remote access *is*
// running, the restart binds/unbinds the listener (and owns the node) instead.
if hint == .tailnet, !store.syncRunning {
Task { on ? await store.startTailnetNode() : await store.stopTailnetNode() }
} else {
restartIfRunning()
}
})
}
/// Settings are read when the sync server starts; make a method change take effect
/// immediately by bouncing remote access when it's running (same as the Relay toggle).
private func restartIfRunning() {
guard store.syncRunning else { return }
Task {
await store.stopSyncServer()
await store.startSyncServer()
}
}
}
/// Renders the active pairing QR (`store.pairingQR`) for the phone to scan — or, for a
/// sibling Mac, the same payload as a copyable link (mesh P4). Also hosts the confirm
/// dialog a Mac pairing raises: the code came from this window, so the user is right here.
struct PairingQRSheet: View {
@Environment(AppStore.self) private var store
@Environment(\.dismiss) private var dismiss
@State private var copiedCode = false
@State private var copyResetTask: Task<Void, Never>?
var body: some View {
VStack(spacing: 16) {
Text("Join this group").font(.headline)
if let qr = store.pairingQR {
if let image = Self.qrImage(qr) {
Image(nsImage: image)
.interpolation(.none)
.resizable()
.frame(width: 240, height: 240)
.background(.white)
.padding(8)
.background(.white, in: RoundedRectangle(cornerRadius: 12))
} else {
ProgressView().frame(width: 240, height: 240)
}
Text("Scan from an iPhone or iPad (Nucleic Remote ▸ Join) to join it to the "
+ "whole group — every Mac and iPhone here then sees each other.")
.font(.caption).foregroundStyle(.secondary)
.multilineTextAlignment(.center)
// The copy path is a peer to the QR, not a fallback: a sibling Mac has no
// camera, so pasting this code is the *only* way to join one. An "or" divider
// and a full-width prominent button give it the same weight as the QR.
HStack(spacing: 8) {
VStack { Divider() }
Text("or").font(.caption).foregroundStyle(.secondary)
VStack { Divider() }
}
VStack(spacing: 8) {
Text(qr)
.font(.system(.caption, design: .monospaced))
.lineLimit(1)
.truncationMode(.middle)
.textSelection(.enabled)
.padding(.horizontal, 10)
.padding(.vertical, 7)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.quaternary, in: RoundedRectangle(cornerRadius: 8))
Button(action: copyCode) {
Label(copiedCode ? "Copied" : "Copy join code",
systemImage: copiedCode ? "checkmark" : "doc.on.doc")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(copiedCode ? .green : .accentColor)
Text("Paste into “Join mesh…” on another Mac, or “Enter code manually” "
+ "on an iPhone or iPad.")
.font(.caption).foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
} else {
ProgressView().frame(width: 240, height: 240)
}
Button("Done") { dismiss() }.keyboardShortcut(.defaultAction)
}
.padding(24)
.frame(width: 320)
.onDisappear { copyResetTask?.cancel() }
.confirmationDialog(
"Let “\(store.pendingMacPairRequest?.label ?? "another Mac")” join?",
isPresented: Binding(
get: { store.pendingMacPairRequest != nil },
set: { if !$0 { store.resolveMacPairing(false) } }),
titleVisibility: .visible
) {
Button("Join") { store.resolveMacPairing(true) }
Button("Cancel", role: .cancel) { store.resolveMacPairing(false) }
} message: {
Text("It joins the whole group — it gets the same control of this Mac's sessions an "
+ "iPhone does, and every Mac here stays connected for hand-offs.")
}
}
/// Copy the join code and briefly flip the button to a checkmark — same confirmation
/// pattern as `CopyButton`, inlined here so the prominent labeled button owns its state.
private func copyCode() {
guard let qr = store.pairingQR else { return }
Clipboard.copy(qr)
withAnimation(.easeOut(duration: 0.12)) { copiedCode = true }
copyResetTask?.cancel()
copyResetTask = Task {
try? await Task.sleep(for: .seconds(1.4))
guard !Task.isCancelled else { return }
withAnimation(.easeIn(duration: 0.2)) { copiedCode = false }
}
}
private static func qrImage(_ string: String) -> NSImage? {
let filter = CIFilter.qrCodeGenerator()
filter.message = Data(string.utf8)
filter.correctionLevel = "M"
guard let output = filter.outputImage else { return nil }
let scaled = output.transformed(by: CGAffineTransform(scaleX: 10, y: 10))
let context = CIContext()
guard let cg = context.createCGImage(scaled, from: scaled.extent) else { return nil }
return NSImage(cgImage: cg, size: NSSize(width: scaled.extent.width, height: scaled.extent.height))
}
}