



















@@ -29,6 +29,42 @@ private enum GatewayTailscaleMode: String, CaseIterable, Identifiable {
2929}
3030}
313132+private struct GatewayTailscaleSettingsSnapshot: Equatable {
33+var mode: GatewayTailscaleMode
34+var requireCredentialsForServe: Bool
35+var password: String
36+37+init(mode: GatewayTailscaleMode, requireCredentialsForServe: Bool, password: String) {
38+self.mode = mode
39+self.requireCredentialsForServe = requireCredentialsForServe
40+self.password = password.trimmingCharacters(in: .whitespacesAndNewlines)
41+}
42+}
43+44+private struct GatewayTailscaleLoadedSettings {
45+var snapshot: GatewayTailscaleSettingsSnapshot
46+var displayPassword: String
47+}
48+49+private struct GatewayTailscaleApplyResult {
50+var didApply: Bool
51+var success: Bool
52+var errorMessage: String?
53+var validationMessage: String?
54+}
55+56+private struct GatewayTailscaleApplyMessages {
57+var statusMessage: String?
58+var validationMessage: String?
59+var shouldRecordSuccess: Bool
60+var shouldRestartGateway: Bool
61+}
62+63+private typealias GatewayTailscaleSettingsSaver = @MainActor @Sendable (
64+ GatewayTailscaleSettingsSnapshot,
65+ AppState.ConnectionMode,
66+ Bool) async -> (Bool, String?)
67+3268struct TailscaleIntegrationSection: View {
3369let connectionMode: AppState.ConnectionMode
3470let isPaused: Bool
@@ -45,6 +81,7 @@ struct TailscaleIntegrationSection: View {
4581@State private var statusMessage: String?
4682@State private var validationMessage: String?
4783@State private var statusTimer: Timer?
84+@State private var lastAppliedSettings: GatewayTailscaleSettingsSnapshot?
48854986init(connectionMode: AppState.ConnectionMode, isPaused: Bool) {
5087self.connectionMode = connectionMode
@@ -246,60 +283,34 @@ struct TailscaleIntegrationSection: View {
246283247284private func loadConfig() async {
248285let root = await ConfigStore.load()
249-let gateway = root["gateway"] as? [String: Any] ?? [:]
250-let tailscale = gateway["tailscale"] as? [String: Any] ?? [:]
251-let modeRaw = (tailscale["mode"] as? String) ?? "serve"
252-self.tailscaleMode = GatewayTailscaleMode(rawValue: modeRaw) ?? .off
253-254-let auth = gateway["auth"] as? [String: Any] ?? [:]
255-let authModeRaw = auth["mode"] as? String
256-let allowTailscale = auth["allowTailscale"] as? Bool
257-258-self.password = auth["password"] as? String ?? ""
259-260-if self.tailscaleMode == .serve {
261-let usesExplicitAuth = authModeRaw == "password"
262-if let allowTailscale, allowTailscale == false {
263-self.requireCredentialsForServe = true
264-} else {
265-self.requireCredentialsForServe = usesExplicitAuth
266-}
267-} else {
268-self.requireCredentialsForServe = false
269-}
286+let loaded = TailscaleIntegrationSection.loadedSettings(from: root)
287+self.tailscaleMode = loaded.snapshot.mode
288+self.requireCredentialsForServe = loaded.snapshot.requireCredentialsForServe
289+self.password = loaded.displayPassword
290+self.lastAppliedSettings = loaded.snapshot
270291}
271292272293private func applySettings() async {
273294guard self.hasLoaded else { return }
274-self.validationMessage = nil
275-self.statusMessage = nil
276-277-let trimmedPassword = self.password.trimmingCharacters(in: .whitespacesAndNewlines)
278-let requiresPassword = self.tailscaleMode == .funnel
279- || (self.tailscaleMode == .serve && self.requireCredentialsForServe)
280-if requiresPassword, trimmedPassword.isEmpty {
281-self.validationMessage = "Password required for this mode."
282-return
283-}
284-285-let (success, errorMessage) = await TailscaleIntegrationSection.buildAndSaveTailscaleConfig(
286- tailscaleMode: self.tailscaleMode,
287- requireCredentialsForServe: self.requireCredentialsForServe,
288- password: trimmedPassword,
295+let currentSettings = self.currentSettingsSnapshot()
296+let result = await TailscaleIntegrationSection.applySettingsIfChanged(
297+ currentSettings: currentSettings,
298+ lastAppliedSettings: self.lastAppliedSettings,
299+ connectionMode: self.connectionMode,
300+ isPaused: self.isPaused,
301+ saveSettings: TailscaleIntegrationSection.saveTailscaleSettings)
302+let messages = TailscaleIntegrationSection.messages(
303+ for: result,
289304 connectionMode: self.connectionMode,
290305 isPaused: self.isPaused)
306+self.validationMessage = messages.validationMessage
307+self.statusMessage = messages.statusMessage
308+guard messages.shouldRecordSuccess else { return }
291309292-if !success, let errorMessage {
293- self.statusMessage = errorMessage
294-return
310+self.lastAppliedSettings = currentSettings
311+if messages.shouldRestartGateway {
312+self.restartGatewayIfNeeded()
295313}
296-297-if self.connectionMode == .local, !self.isPaused {
298-self.statusMessage = "Saved to ~/.openclaw/openclaw.json. Restarting gateway…"
299-} else {
300-self.statusMessage = "Saved to ~/.openclaw/openclaw.json. Restart the gateway to apply."
301-}
302-self.restartGatewayIfNeeded()
303314}
304315305316@MainActor
@@ -310,28 +321,46 @@ struct TailscaleIntegrationSection: View {
310321 connectionMode: AppState.ConnectionMode,
311322 isPaused: Bool) async -> (Bool, String?)
312323{
313-var root = await ConfigStore.load()
324+let settings = GatewayTailscaleSettingsSnapshot(
325+ mode: tailscaleMode,
326+ requireCredentialsForServe: requireCredentialsForServe,
327+ password: password)
328+let root = await self.buildTailscaleConfigRoot(root: ConfigStore.load(), settings: settings)
329+330+do {
331+try await ConfigStore.save(root, allowGatewayAuthMutation: true)
332+return (true, nil)
333+} catch {
334+return (false, error.localizedDescription)
335+}
336+}
337+338+private static func buildTailscaleConfigRoot(
339+ root originalRoot: [String: Any],
340+ settings: GatewayTailscaleSettingsSnapshot) -> [String: Any]
341+{
342+var root = originalRoot
314343var gateway = root["gateway"] as? [String: Any] ?? [:]
315344var tailscale = gateway["tailscale"] as? [String: Any] ?? [:]
316-tailscale["mode"] = tailscaleMode.rawValue
345+tailscale["mode"] = settings.mode.rawValue
317346gateway["tailscale"] = tailscale
318347319-if tailscaleMode != .off {
348+if settings.mode != .off {
320349gateway["bind"] = "loopback"
321350}
322351323-if tailscaleMode == .off {
352+if settings.mode == .off {
324353 gateway.removeValue(forKey: "auth")
325354} else {
326355var auth = gateway["auth"] as? [String: Any] ?? [:]
327-if tailscaleMode == .serve, !requireCredentialsForServe {
356+if settings.mode == .serve, !settings.requireCredentialsForServe {
328357auth["allowTailscale"] = true
329358 auth.removeValue(forKey: "mode")
330359 auth.removeValue(forKey: "password")
331360} else {
332361auth["allowTailscale"] = false
333362auth["mode"] = "password"
334-auth["password"] = password
363+auth["password"] = settings.password
335364}
336365337366if auth.isEmpty {
@@ -347,19 +376,140 @@ struct TailscaleIntegrationSection: View {
347376root["gateway"] = gateway
348377}
349378350-do {
351-try await ConfigStore.save(root, allowGatewayAuthMutation: true)
352-return (true, nil)
353-} catch {
354-return (false, error.localizedDescription)
355-}
379+return root
356380}
357381358382private func restartGatewayIfNeeded() {
359383guard self.connectionMode == .local, !self.isPaused else { return }
360384Task { await GatewayLaunchAgentManager.kickstart() }
361385}
362386387+private func currentSettingsSnapshot() -> GatewayTailscaleSettingsSnapshot {
388+GatewayTailscaleSettingsSnapshot(
389+ mode: self.tailscaleMode,
390+ requireCredentialsForServe: self.requireCredentialsForServe,
391+ password: self.password.trimmingCharacters(in: .whitespacesAndNewlines))
392+}
393+394+private static func loadedSettings(from root: [String: Any]) -> GatewayTailscaleLoadedSettings {
395+let gateway = root["gateway"] as? [String: Any] ?? [:]
396+let tailscale = gateway["tailscale"] as? [String: Any] ?? [:]
397+let modeRaw = (tailscale["mode"] as? String) ?? "serve"
398+let mode = GatewayTailscaleMode(rawValue: modeRaw) ?? .off
399+400+let auth = gateway["auth"] as? [String: Any] ?? [:]
401+let authModeRaw = auth["mode"] as? String
402+let allowTailscale = auth["allowTailscale"] as? Bool
403+let password = auth["password"] as? String ?? ""
404+let requireCredentialsForServe: Bool
405+406+if mode == .serve {
407+let usesExplicitAuth = authModeRaw == "password"
408+if let allowTailscale, allowTailscale == false {
409+ requireCredentialsForServe = true
410+} else {
411+ requireCredentialsForServe = usesExplicitAuth
412+}
413+} else {
414+ requireCredentialsForServe = false
415+}
416+417+return GatewayTailscaleLoadedSettings(
418+ snapshot: GatewayTailscaleSettingsSnapshot(
419+ mode: mode,
420+ requireCredentialsForServe: requireCredentialsForServe,
421+ password: password),
422+ displayPassword: password)
423+}
424+425+private static func applySettingsIfChanged(
426+ currentSettings: GatewayTailscaleSettingsSnapshot,
427+ lastAppliedSettings: GatewayTailscaleSettingsSnapshot?,
428+ connectionMode: AppState.ConnectionMode,
429+ isPaused: Bool,
430+ saveSettings: GatewayTailscaleSettingsSaver) async -> GatewayTailscaleApplyResult
431+{
432+guard currentSettings != lastAppliedSettings else {
433+return GatewayTailscaleApplyResult(
434+ didApply: false,
435+ success: true,
436+ errorMessage: nil,
437+ validationMessage: nil)
438+}
439+440+let requiresPassword = currentSettings.mode == .funnel
441+ || (currentSettings.mode == .serve && currentSettings.requireCredentialsForServe)
442+if requiresPassword, currentSettings.password.isEmpty {
443+return GatewayTailscaleApplyResult(
444+ didApply: true,
445+ success: false,
446+ errorMessage: nil,
447+ validationMessage: "Password required for this mode.")
448+}
449+450+let (success, errorMessage) = await saveSettings(currentSettings, connectionMode, isPaused)
451+return GatewayTailscaleApplyResult(
452+ didApply: true,
453+ success: success,
454+ errorMessage: errorMessage,
455+ validationMessage: nil)
456+}
457+458+private static func messages(
459+ for result: GatewayTailscaleApplyResult,
460+ connectionMode: AppState.ConnectionMode,
461+ isPaused: Bool) -> GatewayTailscaleApplyMessages
462+{
463+guard result.didApply else {
464+return GatewayTailscaleApplyMessages(
465+ statusMessage: nil,
466+ validationMessage: nil,
467+ shouldRecordSuccess: false,
468+ shouldRestartGateway: false)
469+}
470+471+if let validationMessage = result.validationMessage {
472+return GatewayTailscaleApplyMessages(
473+ statusMessage: nil,
474+ validationMessage: validationMessage,
475+ shouldRecordSuccess: false,
476+ shouldRestartGateway: false)
477+}
478+479+if !result.success, let errorMessage = result.errorMessage {
480+return GatewayTailscaleApplyMessages(
481+ statusMessage: errorMessage,
482+ validationMessage: nil,
483+ shouldRecordSuccess: false,
484+ shouldRestartGateway: false)
485+}
486+487+let statusMessage = if connectionMode == .local, !isPaused {
488+"Saved to ~/.openclaw/openclaw.json. Restarting gateway…"
489+} else {
490+"Saved to ~/.openclaw/openclaw.json. Restart the gateway to apply."
491+}
492+return GatewayTailscaleApplyMessages(
493+ statusMessage: statusMessage,
494+ validationMessage: nil,
495+ shouldRecordSuccess: true,
496+ shouldRestartGateway: true)
497+}
498+499+@MainActor
500+private static func saveTailscaleSettings(
501+ settings: GatewayTailscaleSettingsSnapshot,
502+ connectionMode: AppState.ConnectionMode,
503+ isPaused: Bool) async -> (Bool, String?)
504+{
505+await self.buildAndSaveTailscaleConfig(
506+ tailscaleMode: settings.mode,
507+ requireCredentialsForServe: settings.requireCredentialsForServe,
508+ password: settings.password,
509+ connectionMode: connectionMode,
510+ isPaused: isPaused)
511+}
512+363513private func startStatusTimer() {
364514self.stopStatusTimer()
365515if ProcessInfo.processInfo.isRunningTests {
@@ -397,5 +547,51 @@ extension TailscaleIntegrationSection {
397547mutating func setTestingService(_ service: TailscaleService?) {
398548self.testingService = service
399549}
550+551+static func simulateHydrationApplyForTesting(
552+ root: [String: Any],
553+ connectionMode: AppState.ConnectionMode,
554+ isPaused: Bool,
555+ saveRoot: @MainActor @Sendable @escaping ([String: Any]) -> Void) async
556+{
557+let loaded = self.loadedSettings(from: root)
558+ _ = await self.applySettingsIfChanged(
559+ currentSettings: loaded.snapshot,
560+ lastAppliedSettings: loaded.snapshot,
561+ connectionMode: connectionMode,
562+ isPaused: isPaused,
563+ saveSettings: { settings, _, _ in
564+let nextRoot = self.buildTailscaleConfigRoot(root: root, settings: settings)
565+saveRoot(nextRoot)
566+return (true, nil)
567+})
568+}
569+570+static func messagesForTesting(
571+ didApply: Bool,
572+ success: Bool,
573+ errorMessage: String? = nil,
574+ validationMessage: String? = nil,
575+ connectionMode: AppState.ConnectionMode,
576+ isPaused: Bool) -> (
577+ statusMessage: String?,
578+ validationMessage: String?,
579+ shouldRecordSuccess: Bool,
580+ shouldRestartGateway: Bool)
581+{
582+let messages = self.messages(
583+ for: GatewayTailscaleApplyResult(
584+ didApply: didApply,
585+ success: success,
586+ errorMessage: errorMessage,
587+ validationMessage: validationMessage),
588+ connectionMode: connectionMode,
589+ isPaused: isPaused)
590+return (
591+ statusMessage: messages.statusMessage,
592+ validationMessage: messages.validationMessage,
593+ shouldRecordSuccess: messages.shouldRecordSuccess,
594+ shouldRestartGateway: messages.shouldRestartGateway)
595+}
400596}
401597#endif
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。