1028 lines
48 KiB
Swift
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))
|
|
}
|
|
}
|