Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
597
apps/macos/Sources/OpenClaw/TailscaleIntegrationSection.swift
Normal file
597
apps/macos/Sources/OpenClaw/TailscaleIntegrationSection.swift
Normal file
@@ -0,0 +1,597 @@
|
||||
import SwiftUI
|
||||
|
||||
private enum GatewayTailscaleMode: String, CaseIterable, Identifiable {
|
||||
case off
|
||||
case serve
|
||||
case funnel
|
||||
|
||||
var id: String {
|
||||
self.rawValue
|
||||
}
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .off: "Off"
|
||||
case .serve: "Tailnet (Serve)"
|
||||
case .funnel: "Public (Funnel)"
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .off:
|
||||
"No automatic Tailscale configuration."
|
||||
case .serve:
|
||||
"Tailnet-only HTTPS via Tailscale Serve."
|
||||
case .funnel:
|
||||
"Public HTTPS via Tailscale Funnel (requires auth)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct GatewayTailscaleSettingsSnapshot: Equatable {
|
||||
var mode: GatewayTailscaleMode
|
||||
var requireCredentialsForServe: Bool
|
||||
var password: String
|
||||
|
||||
init(mode: GatewayTailscaleMode, requireCredentialsForServe: Bool, password: String) {
|
||||
self.mode = mode
|
||||
self.requireCredentialsForServe = requireCredentialsForServe
|
||||
self.password = password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
|
||||
private struct GatewayTailscaleLoadedSettings {
|
||||
var snapshot: GatewayTailscaleSettingsSnapshot
|
||||
var displayPassword: String
|
||||
}
|
||||
|
||||
private struct GatewayTailscaleApplyResult {
|
||||
var didApply: Bool
|
||||
var success: Bool
|
||||
var errorMessage: String?
|
||||
var validationMessage: String?
|
||||
}
|
||||
|
||||
private struct GatewayTailscaleApplyMessages {
|
||||
var statusMessage: String?
|
||||
var validationMessage: String?
|
||||
var shouldRecordSuccess: Bool
|
||||
var shouldRestartGateway: Bool
|
||||
}
|
||||
|
||||
private typealias GatewayTailscaleSettingsSaver = @MainActor @Sendable (
|
||||
GatewayTailscaleSettingsSnapshot,
|
||||
AppState.ConnectionMode,
|
||||
Bool) async -> (Bool, String?)
|
||||
|
||||
struct TailscaleIntegrationSection: View {
|
||||
let connectionMode: AppState.ConnectionMode
|
||||
let isPaused: Bool
|
||||
|
||||
@Environment(TailscaleService.self) private var tailscaleService
|
||||
#if DEBUG
|
||||
private var testingService: TailscaleService?
|
||||
#endif
|
||||
|
||||
@State private var hasLoaded = false
|
||||
@State private var tailscaleMode: GatewayTailscaleMode = .serve
|
||||
@State private var requireCredentialsForServe = false
|
||||
@State private var password: String = ""
|
||||
@State private var statusMessage: String?
|
||||
@State private var validationMessage: String?
|
||||
@State private var statusTimer: Timer?
|
||||
@State private var lastAppliedSettings: GatewayTailscaleSettingsSnapshot?
|
||||
|
||||
init(connectionMode: AppState.ConnectionMode, isPaused: Bool) {
|
||||
self.connectionMode = connectionMode
|
||||
self.isPaused = isPaused
|
||||
#if DEBUG
|
||||
self.testingService = nil
|
||||
#endif
|
||||
}
|
||||
|
||||
private var effectiveService: TailscaleService {
|
||||
#if DEBUG
|
||||
return self.testingService ?? self.tailscaleService
|
||||
#else
|
||||
return self.tailscaleService
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Tailscale (dashboard access)")
|
||||
.font(.callout.weight(.semibold))
|
||||
|
||||
self.statusRow
|
||||
|
||||
if !self.effectiveService.isInstalled {
|
||||
self.installButtons
|
||||
} else {
|
||||
self.modePicker
|
||||
if self.tailscaleMode != .off {
|
||||
self.accessURLRow
|
||||
}
|
||||
if self.tailscaleMode == .serve {
|
||||
self.serveAuthSection
|
||||
}
|
||||
if self.tailscaleMode == .funnel {
|
||||
self.funnelAuthSection
|
||||
}
|
||||
}
|
||||
|
||||
if self.connectionMode != .local {
|
||||
Text("Local mode required. Update settings on the gateway host.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let validationMessage {
|
||||
Text(validationMessage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
} else if let statusMessage {
|
||||
Text(statusMessage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.gray.opacity(0.08))
|
||||
.cornerRadius(10)
|
||||
.disabled(self.connectionMode != .local)
|
||||
.task {
|
||||
guard !self.hasLoaded else { return }
|
||||
await self.loadConfig()
|
||||
self.hasLoaded = true
|
||||
await self.effectiveService.checkTailscaleStatus()
|
||||
self.startStatusTimer()
|
||||
}
|
||||
.onDisappear {
|
||||
self.stopStatusTimer()
|
||||
}
|
||||
.onChange(of: self.tailscaleMode) { _, _ in
|
||||
Task { await self.applySettings() }
|
||||
}
|
||||
.onChange(of: self.requireCredentialsForServe) { _, _ in
|
||||
Task { await self.applySettings() }
|
||||
}
|
||||
}
|
||||
|
||||
private var statusRow: some View {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(self.statusColor)
|
||||
.frame(width: 10, height: 10)
|
||||
Text(self.statusText)
|
||||
.font(.callout)
|
||||
Spacer()
|
||||
Button("Refresh") {
|
||||
Task { await self.effectiveService.checkTailscaleStatus() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
private var statusColor: Color {
|
||||
if !self.effectiveService.isInstalled { return .yellow }
|
||||
if self.effectiveService.isRunning { return .green }
|
||||
return .orange
|
||||
}
|
||||
|
||||
private var statusText: String {
|
||||
if !self.effectiveService.isInstalled { return "Tailscale is not installed" }
|
||||
if self.effectiveService.isRunning { return "Tailscale is installed and running" }
|
||||
return "Tailscale is installed but not running"
|
||||
}
|
||||
|
||||
private var installButtons: some View {
|
||||
HStack(spacing: 12) {
|
||||
Button("App Store") { self.effectiveService.openAppStore() }
|
||||
.buttonStyle(.link)
|
||||
Button("Direct Download") { self.effectiveService.openDownloadPage() }
|
||||
.buttonStyle(.link)
|
||||
Button("Setup Guide") { self.effectiveService.openSetupGuide() }
|
||||
.buttonStyle(.link)
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
private var modePicker: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Exposure mode")
|
||||
.font(.callout.weight(.semibold))
|
||||
Picker("Exposure", selection: self.$tailscaleMode) {
|
||||
ForEach(GatewayTailscaleMode.allCases) { mode in
|
||||
Text(mode.label).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
Text(self.tailscaleMode.description)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var accessURLRow: some View {
|
||||
if let host = self.effectiveService.tailscaleHostname {
|
||||
let url = "https://\(host)/ui/"
|
||||
HStack(spacing: 8) {
|
||||
Text("Dashboard URL:")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let link = URL(string: url) {
|
||||
Link(url, destination: link)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
} else {
|
||||
Text(url)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
}
|
||||
}
|
||||
} else if !self.effectiveService.isRunning {
|
||||
Text("Start Tailscale to get your tailnet hostname.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if self.effectiveService.isInstalled, !self.effectiveService.isRunning {
|
||||
Button("Start Tailscale") { self.effectiveService.openTailscaleApp() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
private var serveAuthSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Toggle("Require credentials", isOn: self.$requireCredentialsForServe)
|
||||
.toggleStyle(.checkbox)
|
||||
if self.requireCredentialsForServe {
|
||||
self.authFields
|
||||
} else {
|
||||
Text("Serve uses Tailscale identity headers; no password required.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var funnelAuthSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Funnel requires authentication.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
self.authFields
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var authFields: some View {
|
||||
SecureField("Password", text: self.$password)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: 240)
|
||||
.onSubmit { Task { await self.applySettings() } }
|
||||
Text("Stored in ~/.openclaw/openclaw.json. Prefer OPENCLAW_GATEWAY_PASSWORD for production.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Update password") { Task { await self.applySettings() } }
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
private func loadConfig() async {
|
||||
let root = await ConfigStore.load()
|
||||
let loaded = TailscaleIntegrationSection.loadedSettings(from: root)
|
||||
self.tailscaleMode = loaded.snapshot.mode
|
||||
self.requireCredentialsForServe = loaded.snapshot.requireCredentialsForServe
|
||||
self.password = loaded.displayPassword
|
||||
self.lastAppliedSettings = loaded.snapshot
|
||||
}
|
||||
|
||||
private func applySettings() async {
|
||||
guard self.hasLoaded else { return }
|
||||
let currentSettings = self.currentSettingsSnapshot()
|
||||
let result = await TailscaleIntegrationSection.applySettingsIfChanged(
|
||||
currentSettings: currentSettings,
|
||||
lastAppliedSettings: self.lastAppliedSettings,
|
||||
connectionMode: self.connectionMode,
|
||||
isPaused: self.isPaused,
|
||||
saveSettings: TailscaleIntegrationSection.saveTailscaleSettings)
|
||||
let messages = TailscaleIntegrationSection.messages(
|
||||
for: result,
|
||||
connectionMode: self.connectionMode,
|
||||
isPaused: self.isPaused)
|
||||
self.validationMessage = messages.validationMessage
|
||||
self.statusMessage = messages.statusMessage
|
||||
guard messages.shouldRecordSuccess else { return }
|
||||
|
||||
self.lastAppliedSettings = currentSettings
|
||||
if messages.shouldRestartGateway {
|
||||
self.restartGatewayIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func buildAndSaveTailscaleConfig(
|
||||
tailscaleMode: GatewayTailscaleMode,
|
||||
requireCredentialsForServe: Bool,
|
||||
password: String,
|
||||
connectionMode: AppState.ConnectionMode,
|
||||
isPaused: Bool) async -> (Bool, String?)
|
||||
{
|
||||
let settings = GatewayTailscaleSettingsSnapshot(
|
||||
mode: tailscaleMode,
|
||||
requireCredentialsForServe: requireCredentialsForServe,
|
||||
password: password)
|
||||
let root = await self.buildTailscaleConfigRoot(root: ConfigStore.load(), settings: settings)
|
||||
|
||||
do {
|
||||
try await ConfigStore.save(root, allowGatewayAuthMutation: true)
|
||||
return (true, nil)
|
||||
} catch {
|
||||
return (false, error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private static func buildTailscaleConfigRoot(
|
||||
root originalRoot: [String: Any],
|
||||
settings: GatewayTailscaleSettingsSnapshot) -> [String: Any]
|
||||
{
|
||||
var root = originalRoot
|
||||
var gateway = root["gateway"] as? [String: Any] ?? [:]
|
||||
var tailscale = gateway["tailscale"] as? [String: Any] ?? [:]
|
||||
tailscale["mode"] = settings.mode.rawValue
|
||||
gateway["tailscale"] = tailscale
|
||||
|
||||
if settings.mode != .off {
|
||||
gateway["bind"] = "loopback"
|
||||
}
|
||||
|
||||
if settings.mode == .off {
|
||||
gateway.removeValue(forKey: "auth")
|
||||
} else {
|
||||
var auth = gateway["auth"] as? [String: Any] ?? [:]
|
||||
if settings.mode == .serve, !settings.requireCredentialsForServe {
|
||||
auth["allowTailscale"] = true
|
||||
auth.removeValue(forKey: "mode")
|
||||
auth.removeValue(forKey: "password")
|
||||
} else {
|
||||
auth["allowTailscale"] = false
|
||||
auth["mode"] = "password"
|
||||
auth["password"] = settings.password
|
||||
}
|
||||
|
||||
if auth.isEmpty {
|
||||
gateway.removeValue(forKey: "auth")
|
||||
} else {
|
||||
gateway["auth"] = auth
|
||||
}
|
||||
}
|
||||
|
||||
if gateway.isEmpty {
|
||||
root.removeValue(forKey: "gateway")
|
||||
} else {
|
||||
root["gateway"] = gateway
|
||||
}
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
private func restartGatewayIfNeeded() {
|
||||
guard self.connectionMode == .local, !self.isPaused else { return }
|
||||
Task { await GatewayLaunchAgentManager.kickstart() }
|
||||
}
|
||||
|
||||
private func currentSettingsSnapshot() -> GatewayTailscaleSettingsSnapshot {
|
||||
GatewayTailscaleSettingsSnapshot(
|
||||
mode: self.tailscaleMode,
|
||||
requireCredentialsForServe: self.requireCredentialsForServe,
|
||||
password: self.password.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
}
|
||||
|
||||
private static func loadedSettings(from root: [String: Any]) -> GatewayTailscaleLoadedSettings {
|
||||
let gateway = root["gateway"] as? [String: Any] ?? [:]
|
||||
let tailscale = gateway["tailscale"] as? [String: Any] ?? [:]
|
||||
let modeRaw = (tailscale["mode"] as? String) ?? "serve"
|
||||
let mode = GatewayTailscaleMode(rawValue: modeRaw) ?? .off
|
||||
|
||||
let auth = gateway["auth"] as? [String: Any] ?? [:]
|
||||
let authModeRaw = auth["mode"] as? String
|
||||
let allowTailscale = auth["allowTailscale"] as? Bool
|
||||
let password = auth["password"] as? String ?? ""
|
||||
let requireCredentialsForServe: Bool
|
||||
|
||||
if mode == .serve {
|
||||
let usesExplicitAuth = authModeRaw == "password"
|
||||
if let allowTailscale, allowTailscale == false {
|
||||
requireCredentialsForServe = true
|
||||
} else {
|
||||
requireCredentialsForServe = usesExplicitAuth
|
||||
}
|
||||
} else {
|
||||
requireCredentialsForServe = false
|
||||
}
|
||||
|
||||
return GatewayTailscaleLoadedSettings(
|
||||
snapshot: GatewayTailscaleSettingsSnapshot(
|
||||
mode: mode,
|
||||
requireCredentialsForServe: requireCredentialsForServe,
|
||||
password: password),
|
||||
displayPassword: password)
|
||||
}
|
||||
|
||||
private static func applySettingsIfChanged(
|
||||
currentSettings: GatewayTailscaleSettingsSnapshot,
|
||||
lastAppliedSettings: GatewayTailscaleSettingsSnapshot?,
|
||||
connectionMode: AppState.ConnectionMode,
|
||||
isPaused: Bool,
|
||||
saveSettings: GatewayTailscaleSettingsSaver) async -> GatewayTailscaleApplyResult
|
||||
{
|
||||
guard currentSettings != lastAppliedSettings else {
|
||||
return GatewayTailscaleApplyResult(
|
||||
didApply: false,
|
||||
success: true,
|
||||
errorMessage: nil,
|
||||
validationMessage: nil)
|
||||
}
|
||||
|
||||
let requiresPassword = currentSettings.mode == .funnel
|
||||
|| (currentSettings.mode == .serve && currentSettings.requireCredentialsForServe)
|
||||
if requiresPassword, currentSettings.password.isEmpty {
|
||||
return GatewayTailscaleApplyResult(
|
||||
didApply: true,
|
||||
success: false,
|
||||
errorMessage: nil,
|
||||
validationMessage: "Password required for this mode.")
|
||||
}
|
||||
|
||||
let (success, errorMessage) = await saveSettings(currentSettings, connectionMode, isPaused)
|
||||
return GatewayTailscaleApplyResult(
|
||||
didApply: true,
|
||||
success: success,
|
||||
errorMessage: errorMessage,
|
||||
validationMessage: nil)
|
||||
}
|
||||
|
||||
private static func messages(
|
||||
for result: GatewayTailscaleApplyResult,
|
||||
connectionMode: AppState.ConnectionMode,
|
||||
isPaused: Bool) -> GatewayTailscaleApplyMessages
|
||||
{
|
||||
guard result.didApply else {
|
||||
return GatewayTailscaleApplyMessages(
|
||||
statusMessage: nil,
|
||||
validationMessage: nil,
|
||||
shouldRecordSuccess: false,
|
||||
shouldRestartGateway: false)
|
||||
}
|
||||
|
||||
if let validationMessage = result.validationMessage {
|
||||
return GatewayTailscaleApplyMessages(
|
||||
statusMessage: nil,
|
||||
validationMessage: validationMessage,
|
||||
shouldRecordSuccess: false,
|
||||
shouldRestartGateway: false)
|
||||
}
|
||||
|
||||
if !result.success, let errorMessage = result.errorMessage {
|
||||
return GatewayTailscaleApplyMessages(
|
||||
statusMessage: errorMessage,
|
||||
validationMessage: nil,
|
||||
shouldRecordSuccess: false,
|
||||
shouldRestartGateway: false)
|
||||
}
|
||||
|
||||
let statusMessage = if connectionMode == .local, !isPaused {
|
||||
"Saved to ~/.openclaw/openclaw.json. Restarting gateway…"
|
||||
} else {
|
||||
"Saved to ~/.openclaw/openclaw.json. Restart the gateway to apply."
|
||||
}
|
||||
return GatewayTailscaleApplyMessages(
|
||||
statusMessage: statusMessage,
|
||||
validationMessage: nil,
|
||||
shouldRecordSuccess: true,
|
||||
shouldRestartGateway: true)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func saveTailscaleSettings(
|
||||
settings: GatewayTailscaleSettingsSnapshot,
|
||||
connectionMode: AppState.ConnectionMode,
|
||||
isPaused: Bool) async -> (Bool, String?)
|
||||
{
|
||||
await self.buildAndSaveTailscaleConfig(
|
||||
tailscaleMode: settings.mode,
|
||||
requireCredentialsForServe: settings.requireCredentialsForServe,
|
||||
password: settings.password,
|
||||
connectionMode: connectionMode,
|
||||
isPaused: isPaused)
|
||||
}
|
||||
|
||||
private func startStatusTimer() {
|
||||
self.stopStatusTimer()
|
||||
if ProcessInfo.processInfo.isRunningTests {
|
||||
return
|
||||
}
|
||||
self.statusTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in
|
||||
Task { await self.effectiveService.checkTailscaleStatus() }
|
||||
}
|
||||
}
|
||||
|
||||
private func stopStatusTimer() {
|
||||
self.statusTimer?.invalidate()
|
||||
self.statusTimer = nil
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension TailscaleIntegrationSection {
|
||||
mutating func setTestingState(
|
||||
mode: String,
|
||||
requireCredentials: Bool,
|
||||
password: String = "secret",
|
||||
statusMessage: String? = nil,
|
||||
validationMessage: String? = nil)
|
||||
{
|
||||
if let mode = GatewayTailscaleMode(rawValue: mode) {
|
||||
self.tailscaleMode = mode
|
||||
}
|
||||
self.requireCredentialsForServe = requireCredentials
|
||||
self.password = password
|
||||
self.statusMessage = statusMessage
|
||||
self.validationMessage = validationMessage
|
||||
}
|
||||
|
||||
mutating func setTestingService(_ service: TailscaleService?) {
|
||||
self.testingService = service
|
||||
}
|
||||
|
||||
static func simulateHydrationApplyForTesting(
|
||||
root: [String: Any],
|
||||
connectionMode: AppState.ConnectionMode,
|
||||
isPaused: Bool,
|
||||
saveRoot: @MainActor @Sendable @escaping ([String: Any]) -> Void) async
|
||||
{
|
||||
let loaded = self.loadedSettings(from: root)
|
||||
_ = await self.applySettingsIfChanged(
|
||||
currentSettings: loaded.snapshot,
|
||||
lastAppliedSettings: loaded.snapshot,
|
||||
connectionMode: connectionMode,
|
||||
isPaused: isPaused,
|
||||
saveSettings: { settings, _, _ in
|
||||
let nextRoot = self.buildTailscaleConfigRoot(root: root, settings: settings)
|
||||
saveRoot(nextRoot)
|
||||
return (true, nil)
|
||||
})
|
||||
}
|
||||
|
||||
static func messagesForTesting(
|
||||
didApply: Bool,
|
||||
success: Bool,
|
||||
errorMessage: String? = nil,
|
||||
validationMessage: String? = nil,
|
||||
connectionMode: AppState.ConnectionMode,
|
||||
isPaused: Bool) -> (
|
||||
statusMessage: String?,
|
||||
validationMessage: String?,
|
||||
shouldRecordSuccess: Bool,
|
||||
shouldRestartGateway: Bool)
|
||||
{
|
||||
let messages = self.messages(
|
||||
for: GatewayTailscaleApplyResult(
|
||||
didApply: didApply,
|
||||
success: success,
|
||||
errorMessage: errorMessage,
|
||||
validationMessage: validationMessage),
|
||||
connectionMode: connectionMode,
|
||||
isPaused: isPaused)
|
||||
return (
|
||||
statusMessage: messages.statusMessage,
|
||||
validationMessage: messages.validationMessage,
|
||||
shouldRecordSuccess: messages.shouldRecordSuccess,
|
||||
shouldRestartGateway: messages.shouldRestartGateway)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user