feat: add remote clawd toggle
This commit is contained in:
@@ -5,6 +5,11 @@ import SwiftUI
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class AppState: ObservableObject {
|
final class AppState: ObservableObject {
|
||||||
|
enum ConnectionMode: String {
|
||||||
|
case local
|
||||||
|
case remote
|
||||||
|
}
|
||||||
|
|
||||||
@Published var isPaused: Bool {
|
@Published var isPaused: Bool {
|
||||||
didSet { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) }
|
didSet { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) }
|
||||||
}
|
}
|
||||||
@@ -105,6 +110,22 @@ final class AppState: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Published var connectionMode: ConnectionMode {
|
||||||
|
didSet { UserDefaults.standard.set(self.connectionMode.rawValue, forKey: connectionModeKey) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var remoteTarget: String {
|
||||||
|
didSet { UserDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var remoteIdentity: String {
|
||||||
|
didSet { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var remoteProjectRoot: String {
|
||||||
|
didSet { UserDefaults.standard.set(self.remoteProjectRoot, forKey: remoteProjectRootKey) }
|
||||||
|
}
|
||||||
|
|
||||||
private var earBoostTask: Task<Void, Never>?
|
private var earBoostTask: Task<Void, Never>?
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
@@ -133,8 +154,14 @@ final class AppState: ObservableObject {
|
|||||||
self.voiceWakeForwardTarget = UserDefaults.standard
|
self.voiceWakeForwardTarget = UserDefaults.standard
|
||||||
.string(forKey: voiceWakeForwardTargetKey) ?? legacyTarget
|
.string(forKey: voiceWakeForwardTargetKey) ?? legacyTarget
|
||||||
self.voiceWakeForwardIdentity = UserDefaults.standard.string(forKey: voiceWakeForwardIdentityKey) ?? ""
|
self.voiceWakeForwardIdentity = UserDefaults.standard.string(forKey: voiceWakeForwardIdentityKey) ?? ""
|
||||||
self.voiceWakeForwardCommand = UserDefaults.standard
|
|
||||||
|
var storedForwardCommand = UserDefaults.standard
|
||||||
.string(forKey: voiceWakeForwardCommandKey) ?? defaultVoiceWakeForwardCommand
|
.string(forKey: voiceWakeForwardCommandKey) ?? defaultVoiceWakeForwardCommand
|
||||||
|
if !storedForwardCommand.contains("--deliver") || !storedForwardCommand.contains("--session") {
|
||||||
|
storedForwardCommand = defaultVoiceWakeForwardCommand
|
||||||
|
UserDefaults.standard.set(storedForwardCommand, forKey: voiceWakeForwardCommandKey)
|
||||||
|
}
|
||||||
|
self.voiceWakeForwardCommand = storedForwardCommand
|
||||||
if let storedHeartbeats = UserDefaults.standard.object(forKey: heartbeatsEnabledKey) as? Bool {
|
if let storedHeartbeats = UserDefaults.standard.object(forKey: heartbeatsEnabledKey) as? Bool {
|
||||||
self.heartbeatsEnabled = storedHeartbeats
|
self.heartbeatsEnabled = storedHeartbeats
|
||||||
} else {
|
} else {
|
||||||
@@ -142,6 +169,12 @@ final class AppState: ObservableObject {
|
|||||||
UserDefaults.standard.set(true, forKey: heartbeatsEnabledKey)
|
UserDefaults.standard.set(true, forKey: heartbeatsEnabledKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let storedMode = UserDefaults.standard.string(forKey: connectionModeKey)
|
||||||
|
self.connectionMode = ConnectionMode(rawValue: storedMode ?? "local") ?? .local
|
||||||
|
self.remoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
|
||||||
|
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
|
||||||
|
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
||||||
|
|
||||||
if self.swabbleEnabled, !PermissionManager.voiceWakePermissionsGranted() {
|
if self.swabbleEnabled, !PermissionManager.voiceWakePermissionsGranted() {
|
||||||
self.swabbleEnabled = false
|
self.swabbleEnabled = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Foundation
|
|||||||
let serviceName = "com.steipete.clawdis.xpc"
|
let serviceName = "com.steipete.clawdis.xpc"
|
||||||
let launchdLabel = "com.steipete.clawdis"
|
let launchdLabel = "com.steipete.clawdis"
|
||||||
let onboardingVersionKey = "clawdis.onboardingVersion"
|
let onboardingVersionKey = "clawdis.onboardingVersion"
|
||||||
let currentOnboardingVersion = 2
|
let currentOnboardingVersion = 3
|
||||||
let pauseDefaultsKey = "clawdis.pauseEnabled"
|
let pauseDefaultsKey = "clawdis.pauseEnabled"
|
||||||
let iconAnimationsEnabledKey = "clawdis.iconAnimationsEnabled"
|
let iconAnimationsEnabledKey = "clawdis.iconAnimationsEnabled"
|
||||||
let swabbleEnabledKey = "clawdis.swabbleEnabled"
|
let swabbleEnabledKey = "clawdis.swabbleEnabled"
|
||||||
@@ -20,6 +20,10 @@ let voiceWakeForwardUserKey = "clawdis.voiceWakeForwardUser"
|
|||||||
let voiceWakeForwardPortKey = "clawdis.voiceWakeForwardPort"
|
let voiceWakeForwardPortKey = "clawdis.voiceWakeForwardPort"
|
||||||
let voiceWakeForwardIdentityKey = "clawdis.voiceWakeForwardIdentity"
|
let voiceWakeForwardIdentityKey = "clawdis.voiceWakeForwardIdentity"
|
||||||
let voiceWakeForwardCommandKey = "clawdis.voiceWakeForwardCommand"
|
let voiceWakeForwardCommandKey = "clawdis.voiceWakeForwardCommand"
|
||||||
|
let connectionModeKey = "clawdis.connectionMode"
|
||||||
|
let remoteTargetKey = "clawdis.remoteTarget"
|
||||||
|
let remoteIdentityKey = "clawdis.remoteIdentity"
|
||||||
|
let remoteProjectRootKey = "clawdis.remoteProjectRoot"
|
||||||
let modelCatalogPathKey = "clawdis.modelCatalogPath"
|
let modelCatalogPathKey = "clawdis.modelCatalogPath"
|
||||||
let modelCatalogReloadKey = "clawdis.modelCatalogReload"
|
let modelCatalogReloadKey = "clawdis.modelCatalogReload"
|
||||||
let heartbeatsEnabledKey = "clawdis.heartbeatsEnabled"
|
let heartbeatsEnabledKey = "clawdis.heartbeatsEnabled"
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ struct GeneralSettings: View {
|
|||||||
@State private var cliStatus: String?
|
@State private var cliStatus: String?
|
||||||
@State private var cliInstalled = false
|
@State private var cliInstalled = false
|
||||||
@State private var cliInstallLocation: String?
|
@State private var cliInstallLocation: String?
|
||||||
|
@State private var remoteStatus: RemoteStatus = .idle
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 18) {
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
self.connectionSection
|
||||||
|
|
||||||
if !self.state.onboardingSeen {
|
if !self.state.onboardingSeen {
|
||||||
Text("Complete onboarding to finish setup")
|
Text("Complete onboarding to finish setup")
|
||||||
.font(.callout.weight(.semibold))
|
.font(.callout.weight(.semibold))
|
||||||
@@ -87,6 +90,80 @@ struct GeneralSettings: View {
|
|||||||
set: { self.state.isPaused = !$0 })
|
set: { self.state.isPaused = !$0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var connectionSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Clawdis runs")
|
||||||
|
.font(.callout.weight(.semibold))
|
||||||
|
|
||||||
|
Picker("Mode", selection: self.$state.connectionMode) {
|
||||||
|
Text("Local (this Mac)").tag(AppState.ConnectionMode.local)
|
||||||
|
Text("Remote over SSH").tag(AppState.ConnectionMode.remote)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.frame(width: 320)
|
||||||
|
|
||||||
|
if self.state.connectionMode == .remote {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
LabeledContent("SSH target") {
|
||||||
|
TextField("user@host[:22]", text: self.$state.remoteTarget)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 260)
|
||||||
|
}
|
||||||
|
|
||||||
|
LabeledContent("Identity file") {
|
||||||
|
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 260)
|
||||||
|
}
|
||||||
|
|
||||||
|
LabeledContent("Project root") {
|
||||||
|
TextField("/home/you/Projects/clawdis", text: self.$state.remoteProjectRoot)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 320)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Button {
|
||||||
|
Task { await self.testRemote() }
|
||||||
|
} label: {
|
||||||
|
if self.remoteStatus == .checking {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Text("Test remote")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(self.remoteStatus == .checking || self.state.remoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
|
|
||||||
|
switch self.remoteStatus {
|
||||||
|
case .idle:
|
||||||
|
EmptyView()
|
||||||
|
case .checking:
|
||||||
|
Text("Checking…").font(.caption).foregroundStyle(.secondary)
|
||||||
|
case .ok:
|
||||||
|
Label("Ready", systemImage: "checkmark.circle.fill")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
case let .failed(message):
|
||||||
|
Text(message)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Tip: use Tailscale for stable remote access; we recommend enabling it when you pick a remote Clawdis.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(Color.gray.opacity(0.08))
|
||||||
|
.cornerRadius(10)
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var cliInstaller: some View {
|
private var cliInstaller: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
@@ -217,7 +294,27 @@ struct GeneralSettings: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum RemoteStatus: Equatable {
|
||||||
|
case idle
|
||||||
|
case checking
|
||||||
|
case ok
|
||||||
|
case failed(String)
|
||||||
|
}
|
||||||
|
|
||||||
extension GeneralSettings {
|
extension GeneralSettings {
|
||||||
|
@MainActor
|
||||||
|
fileprivate func testRemote() async {
|
||||||
|
self.remoteStatus = .checking
|
||||||
|
let command = CommandResolver.clawdisCommand(subcommand: "status", extraArgs: ["--json"])
|
||||||
|
let response = await ShellRunner.run(command: command, cwd: nil, env: nil, timeout: 10)
|
||||||
|
if response.ok {
|
||||||
|
self.remoteStatus = .ok
|
||||||
|
} else {
|
||||||
|
let msg = response.message ?? "test failed"
|
||||||
|
self.remoteStatus = .failed(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func revealLogs() {
|
private func revealLogs() {
|
||||||
let path = URL(fileURLWithPath: "/tmp/clawdis/clawdis.log")
|
let path = URL(fileURLWithPath: "/tmp/clawdis/clawdis.log")
|
||||||
if FileManager.default.fileExists(atPath: path.path) {
|
if FileManager.default.fileExists(atPath: path.path) {
|
||||||
|
|||||||
@@ -64,10 +64,10 @@ struct OnboardingView: View {
|
|||||||
GeometryReader { _ in
|
GeometryReader { _ in
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
self.welcomePage().frame(width: self.pageWidth)
|
self.welcomePage().frame(width: self.pageWidth)
|
||||||
self.focusPage().frame(width: self.pageWidth)
|
self.connectionPage().frame(width: self.pageWidth)
|
||||||
self.permissionsPage().frame(width: self.pageWidth)
|
self.permissionsPage().frame(width: self.pageWidth)
|
||||||
self.cliPage().frame(width: self.pageWidth)
|
self.cliPage().frame(width: self.pageWidth)
|
||||||
self.launchPage().frame(width: self.pageWidth)
|
self.whatsappPage().frame(width: self.pageWidth)
|
||||||
self.readyPage().frame(width: self.pageWidth)
|
self.readyPage().frame(width: self.pageWidth)
|
||||||
}
|
}
|
||||||
.offset(x: CGFloat(-self.currentPage) * self.pageWidth)
|
.offset(x: CGFloat(-self.currentPage) * self.pageWidth)
|
||||||
@@ -113,25 +113,48 @@ struct OnboardingView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func focusPage() -> some View {
|
private func connectionPage() -> some View {
|
||||||
self.onboardingPage {
|
self.onboardingPage {
|
||||||
Text("What Clawdis handles")
|
Text("Where Clawdis runs")
|
||||||
.font(.largeTitle.weight(.semibold))
|
.font(.largeTitle.weight(.semibold))
|
||||||
|
Text("Pick local or remote. Remote uses SSH; we recommend Tailscale for reliable reachability.")
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.frame(maxWidth: 520)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
self.onboardingCard {
|
self.onboardingCard {
|
||||||
self.featureRow(
|
Picker("Mode", selection: self.$state.connectionMode) {
|
||||||
title: "Owns the TCC prompts",
|
Text("Local (this Mac)").tag(AppState.ConnectionMode.local)
|
||||||
subtitle: "Requests Notifications, Accessibility, and Screen Recording "
|
Text("Remote over SSH").tag(AppState.ConnectionMode.remote)
|
||||||
+ "so your agents stay unblocked.",
|
}
|
||||||
systemImage: "lock.shield")
|
.pickerStyle(.segmented)
|
||||||
self.featureRow(
|
.frame(width: 320)
|
||||||
title: "Native notifications",
|
|
||||||
subtitle: "Shows desktop toasts for agent events with your preferred sound.",
|
if self.state.connectionMode == .remote {
|
||||||
systemImage: "bell.and.waveform")
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
self.featureRow(
|
LabeledContent("SSH target") {
|
||||||
title: "Privileged helpers",
|
TextField("user@host[:22]", text: self.$state.remoteTarget)
|
||||||
subtitle: "Runs screenshots or shell actions from the `clawdis-mac` CLI "
|
.textFieldStyle(.roundedBorder)
|
||||||
+ "with the right permissions.",
|
.frame(width: 280)
|
||||||
systemImage: "terminal")
|
}
|
||||||
|
LabeledContent("Identity file") {
|
||||||
|
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 280)
|
||||||
|
}
|
||||||
|
LabeledContent("Project root") {
|
||||||
|
TextField("/home/you/Projects/clawdis", text: self.$state.remoteProjectRoot)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 320)
|
||||||
|
}
|
||||||
|
Text("Tip: keep a Tailscale IP here so the agent stays reachable off-LAN.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,11 +235,11 @@ struct OnboardingView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func launchPage() -> some View {
|
private func whatsappPage() -> some View {
|
||||||
self.onboardingPage {
|
self.onboardingPage {
|
||||||
Text("Keep it running")
|
Text("Link WhatsApp")
|
||||||
.font(.largeTitle.weight(.semibold))
|
.font(.largeTitle.weight(.semibold))
|
||||||
Text("Let Clawdis launch with macOS so permissions and notifications are ready when automations start.")
|
Text("Run `clawdis login` where the relay runs (local if local mode, remote if remote). Scan the QR to pair your account.")
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
@@ -224,21 +247,18 @@ struct OnboardingView: View {
|
|||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
self.onboardingCard {
|
self.onboardingCard {
|
||||||
HStack {
|
self.featureRow(
|
||||||
Spacer()
|
title: "Open a terminal",
|
||||||
Toggle("Launch at login", isOn: self.$state.launchAtLogin)
|
subtitle: "Use the same host selected above. If remote, SSH in first.",
|
||||||
.toggleStyle(.switch)
|
systemImage: "terminal")
|
||||||
.onChange(of: self.state.launchAtLogin) { _, newValue in
|
self.featureRow(
|
||||||
AppStateStore.updateLaunchAtLogin(enabled: newValue)
|
title: "Run `clawdis login --verbose`",
|
||||||
}
|
subtitle: "Scan the QR code with WhatsApp on your phone. We only use your personal session; no cloud relay involved.",
|
||||||
Spacer()
|
systemImage: "qrcode.viewfinder")
|
||||||
}
|
self.featureRow(
|
||||||
Text(
|
title: "Re-link after timeouts",
|
||||||
"You can pause from the menu bar anytime. Settings keeps a \"Show onboarding\" "
|
subtitle: "If Baileys auth expires, re-run login on that host. Settings → General shows remote/local mode so you know where to run it.",
|
||||||
+ "button if you need to revisit.")
|
systemImage: "clock.arrow.circlepath")
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -257,6 +277,10 @@ struct OnboardingView: View {
|
|||||||
title: "Test a notification",
|
title: "Test a notification",
|
||||||
subtitle: "Send a quick notify via the menu bar to confirm sounds and permissions.",
|
subtitle: "Send a quick notify via the menu bar to confirm sounds and permissions.",
|
||||||
systemImage: "bell.badge")
|
systemImage: "bell.badge")
|
||||||
|
Toggle("Launch at login", isOn: self.$state.launchAtLogin)
|
||||||
|
.onChange(of: self.state.launchAtLogin) { _, newValue in
|
||||||
|
AppStateStore.updateLaunchAtLogin(enabled: newValue)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Text("Finish to save this version of onboarding. We'll reshow automatically when steps change.")
|
Text("Finish to save this version of onboarding. We'll reshow automatically when steps change.")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
|
|||||||
@@ -178,7 +178,26 @@ enum CommandResolver {
|
|||||||
private static let projectRootDefaultsKey = "clawdis.relayProjectRootPath"
|
private static let projectRootDefaultsKey = "clawdis.relayProjectRootPath"
|
||||||
private static let helperName = "clawdis"
|
private static let helperName = "clawdis"
|
||||||
|
|
||||||
|
private static func bundledRelayRoot() -> URL? {
|
||||||
|
guard let resource = Bundle.main.resourceURL else { return nil }
|
||||||
|
let relay = resource.appendingPathComponent("Relay")
|
||||||
|
return FileManager.default.fileExists(atPath: relay.path) ? relay : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func bundledRelayCommand(subcommand: String, extraArgs: [String]) -> [String]? {
|
||||||
|
guard let relay = self.bundledRelayRoot() else { return nil }
|
||||||
|
let bunPath = relay.appendingPathComponent("bun").path
|
||||||
|
let entry = relay.appendingPathComponent("dist/index.js").path
|
||||||
|
guard FileManager.default.isExecutableFile(atPath: bunPath),
|
||||||
|
FileManager.default.isReadableFile(atPath: entry)
|
||||||
|
else { return nil }
|
||||||
|
return [bunPath, entry, subcommand] + extraArgs
|
||||||
|
}
|
||||||
|
|
||||||
static func projectRoot() -> URL {
|
static func projectRoot() -> URL {
|
||||||
|
if let bundled = self.bundledRelayRoot() {
|
||||||
|
return bundled
|
||||||
|
}
|
||||||
if let stored = UserDefaults.standard.string(forKey: self.projectRootDefaultsKey),
|
if let stored = UserDefaults.standard.string(forKey: self.projectRootDefaultsKey),
|
||||||
let url = self.expandPath(stored)
|
let url = self.expandPath(stored)
|
||||||
{
|
{
|
||||||
@@ -203,7 +222,7 @@ enum CommandResolver {
|
|||||||
static func preferredPaths() -> [String] {
|
static func preferredPaths() -> [String] {
|
||||||
let current = ProcessInfo.processInfo.environment["PATH"]?
|
let current = ProcessInfo.processInfo.environment["PATH"]?
|
||||||
.split(separator: ":").map(String.init) ?? []
|
.split(separator: ":").map(String.init) ?? []
|
||||||
let extras = [
|
var extras = [
|
||||||
self.projectRoot().appendingPathComponent("node_modules/.bin").path,
|
self.projectRoot().appendingPathComponent("node_modules/.bin").path,
|
||||||
FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/pnpm").path,
|
FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/pnpm").path,
|
||||||
"/opt/homebrew/bin",
|
"/opt/homebrew/bin",
|
||||||
@@ -211,6 +230,9 @@ enum CommandResolver {
|
|||||||
"/usr/bin",
|
"/usr/bin",
|
||||||
"/bin",
|
"/bin",
|
||||||
]
|
]
|
||||||
|
if let relay = self.bundledRelayRoot() {
|
||||||
|
extras.insert(relay.appendingPathComponent("node_modules/.bin").path, at: 0)
|
||||||
|
}
|
||||||
var seen = Set<String>()
|
var seen = Set<String>()
|
||||||
return (extras + current).filter { seen.insert($0).inserted }
|
return (extras + current).filter { seen.insert($0).inserted }
|
||||||
}
|
}
|
||||||
@@ -242,6 +264,13 @@ enum CommandResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func clawdisCommand(subcommand: String, extraArgs: [String] = []) -> [String] {
|
static func clawdisCommand(subcommand: String, extraArgs: [String] = []) -> [String] {
|
||||||
|
let settings = self.connectionSettings()
|
||||||
|
if settings.mode == .remote, let ssh = self.sshCommand(subcommand: subcommand, extraArgs: extraArgs, settings: settings) {
|
||||||
|
return ssh
|
||||||
|
}
|
||||||
|
if let bundled = self.bundledRelayCommand(subcommand: subcommand, extraArgs: extraArgs) {
|
||||||
|
return bundled
|
||||||
|
}
|
||||||
if let clawdisPath = self.clawdisExecutable() {
|
if let clawdisPath = self.clawdisExecutable() {
|
||||||
return [clawdisPath, subcommand] + extraArgs
|
return [clawdisPath, subcommand] + extraArgs
|
||||||
}
|
}
|
||||||
@@ -257,6 +286,53 @@ enum CommandResolver {
|
|||||||
return ["clawdis", subcommand] + extraArgs
|
return ["clawdis", subcommand] + extraArgs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func sshCommand(subcommand: String, extraArgs: [String], settings: RemoteSettings) -> [String]? {
|
||||||
|
guard !settings.target.isEmpty else { return nil }
|
||||||
|
let parsed = VoiceWakeForwarder.parse(target: settings.target)
|
||||||
|
guard let parsed else { return nil }
|
||||||
|
|
||||||
|
var args: [String] = ["-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes"]
|
||||||
|
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
|
||||||
|
if !settings.identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
args.append(contentsOf: ["-i", settings.identity])
|
||||||
|
}
|
||||||
|
let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
|
||||||
|
args.append(userHost)
|
||||||
|
|
||||||
|
let quotedArgs = (["clawdis", subcommand] + extraArgs).map(self.shellQuote).joined(separator: " ")
|
||||||
|
let cdPrefix = settings.projectRoot.isEmpty ? "" : "cd \(self.shellQuote(settings.projectRoot)) && "
|
||||||
|
let scriptBody = "\(cdPrefix)\(quotedArgs)"
|
||||||
|
let wrapped = VoiceWakeForwarder.commandWithCliPath(scriptBody, target: settings.target)
|
||||||
|
args.append(contentsOf: ["/bin/sh", "-c", wrapped])
|
||||||
|
return ["/usr/bin/ssh"] + args
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct RemoteSettings {
|
||||||
|
let mode: AppState.ConnectionMode
|
||||||
|
let target: String
|
||||||
|
let identity: String
|
||||||
|
let projectRoot: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func connectionSettings() -> RemoteSettings {
|
||||||
|
let modeRaw = UserDefaults.standard.string(forKey: connectionModeKey) ?? "local"
|
||||||
|
let mode = AppState.ConnectionMode(rawValue: modeRaw) ?? .local
|
||||||
|
let target = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
|
||||||
|
let identity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
|
||||||
|
let projectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
||||||
|
return RemoteSettings(mode: mode, target: self.sanitizedTarget(target), identity: identity, projectRoot: projectRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func sanitizedTarget(_ raw: String) -> String {
|
||||||
|
VoiceWakeForwarder.sanitizedTarget(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func shellQuote(_ text: String) -> String {
|
||||||
|
if text.isEmpty { return "''" }
|
||||||
|
let escaped = text.replacingOccurrences(of: "'", with: "'\\''")
|
||||||
|
return "'\(escaped)'"
|
||||||
|
}
|
||||||
|
|
||||||
private static func expandPath(_ path: String) -> URL? {
|
private static func expandPath(_ path: String) -> URL? {
|
||||||
var expanded = path
|
var expanded = path
|
||||||
if expanded.hasPrefix("~") {
|
if expanded.hasPrefix("~") {
|
||||||
|
|||||||
@@ -138,7 +138,9 @@ enum VoiceWakeForwarder {
|
|||||||
}
|
}
|
||||||
args.append(userHost)
|
args.append(userHost)
|
||||||
|
|
||||||
let escaped = Self.shellEscape(transcript) // single-quoted literal, safe for sh/zsh
|
// Avoid stdin and globbing entirely: marshal the transcript as a single-quoted literal.
|
||||||
|
// `shellEscape` keeps it POSIX-safe for /bin/sh even when the text has quotes/parentheses.
|
||||||
|
let escaped = Self.shellEscape(transcript)
|
||||||
let templated: String = config.commandTemplate.contains("${text}")
|
let templated: String = config.commandTemplate.contains("${text}")
|
||||||
? config.commandTemplate.replacingOccurrences(of: "${text}", with: "$CLAW_TEXT")
|
? config.commandTemplate.replacingOccurrences(of: "${text}", with: "$CLAW_TEXT")
|
||||||
: Self.renderedCommand(template: config.commandTemplate, transcript: transcript)
|
: Self.renderedCommand(template: config.commandTemplate, transcript: transcript)
|
||||||
@@ -320,7 +322,7 @@ enum VoiceWakeForwarder {
|
|||||||
return (user: user?.trimmingCharacters(in: .whitespacesAndNewlines), host: host, port: port)
|
return (user: user?.trimmingCharacters(in: .whitespacesAndNewlines), host: host, port: port)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func sanitizedTarget(_ raw: String) -> String {
|
static func sanitizedTarget(_ raw: String) -> String {
|
||||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if trimmed.hasPrefix("ssh ") {
|
if trimmed.hasPrefix("ssh ") {
|
||||||
return trimmed.replacingOccurrences(of: "ssh ", with: "").trimmingCharacters(in: .whitespacesAndNewlines)
|
return trimmed.replacingOccurrences(of: "ssh ", with: "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|||||||
@@ -215,10 +215,15 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler,
|
|||||||
let data: Data
|
let data: Data
|
||||||
do {
|
do {
|
||||||
data = try await Task.detached(priority: .utility) { () -> Data in
|
data = try await Task.detached(priority: .utility) { () -> Data in
|
||||||
|
let command = CommandResolver.clawdisCommand(
|
||||||
|
subcommand: "agent",
|
||||||
|
extraArgs: ["--to", sessionKey, "--message", text, "--json"])
|
||||||
let process = Process()
|
let process = Process()
|
||||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
|
process.executableURL = URL(fileURLWithPath: command.first ?? "/usr/bin/env")
|
||||||
process.arguments = ["pnpm", "clawdis", "agent", "--to", sessionKey, "--message", text, "--json"]
|
process.arguments = Array(command.dropFirst())
|
||||||
process.currentDirectoryURL = URL(fileURLWithPath: "/Users/steipete/Projects/clawdis")
|
if command.first != "/usr/bin/ssh" {
|
||||||
|
process.currentDirectoryURL = URL(fileURLWithPath: CommandResolver.projectRootPath())
|
||||||
|
}
|
||||||
|
|
||||||
let pipe = Pipe()
|
let pipe = Pipe()
|
||||||
process.standardOutput = pipe
|
process.standardOutput = pipe
|
||||||
|
|||||||
29
docs/mac/remote.md
Normal file
29
docs/mac/remote.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Remote Clawd mode (Dec 2025)
|
||||||
|
|
||||||
|
## What it is
|
||||||
|
- Run the Clawdis relay on another machine (Linux/macOS) reachable over SSH while the macOS app keeps TCC, notifications, and UI.
|
||||||
|
- You can toggle Local vs Remote in **Settings → General → Clawdis runs**; remote adds fields for SSH target, identity file, and project root.
|
||||||
|
- We recommend running a Tailscale node on both sides so the target is reachable even off-LAN.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
- SSH access with public-key auth (`BatchMode=yes`); set `user@host[:port]` and an identity file.
|
||||||
|
- The remote host must have a working `clawdis` install in the project root you specify.
|
||||||
|
- `clawdis-mac` is still used for permissioned actions; the CLI path is auto-discovered on the remote via `command -v` + common prefixes.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
- The app builds commands through the new runner:
|
||||||
|
- `clawdis status/health/agent/relay` are wrapped in `ssh … /bin/sh -c '<cd project && clawdis …>'` with CLI path lookup.
|
||||||
|
- `clawdis rpc` is tunneled over a long-lived SSH process so web chat and the app’s Agent tab stay responsive.
|
||||||
|
- Local TCC flows remain unchanged; if the remote agent needs local permissions, it should SSH back here and invoke `clawdis-mac …` (same CLI surface).
|
||||||
|
|
||||||
|
## Setup steps
|
||||||
|
1) Open **Settings → General → Clawdis runs** and pick **Remote over SSH**.
|
||||||
|
2) Fill **SSH target**, **Identity file**, and **Project root** (where `clawdis` lives on the remote).
|
||||||
|
3) Click **Test remote**; it runs `clawdis status --json` remotely and caches the resolved CLI path.
|
||||||
|
4) Run onboarding’s WhatsApp login step on the machine where the relay will run (remote if remote mode is enabled).
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Connection strings accept `user@host:port`; leading `ssh ` is stripped if pasted from a shell snippet.
|
||||||
|
- Project root defaults to the path you enter; if blank, no `cd` is issued before the relay command.
|
||||||
|
- The remote log path remains `/tmp/clawdis/clawdis.log`; view it via SSH if you need details.
|
||||||
|
- If you switch back to Local, existing remote state is left untouched; re-run Test remote when switching again.
|
||||||
Reference in New Issue
Block a user