iOS: update onboarding and gateway UI
parent
532b9653be
commit
ff6114599e
|
|
@ -67,6 +67,11 @@ final class GatewayConnectionController {
|
||||||
port: port,
|
port: port,
|
||||||
useTLS: tlsParams?.required == true)
|
useTLS: tlsParams?.required == true)
|
||||||
else { return }
|
else { return }
|
||||||
|
GatewaySettingsStore.saveLastGatewayConnection(
|
||||||
|
host: host,
|
||||||
|
port: port,
|
||||||
|
useTLS: tlsParams?.required == true,
|
||||||
|
stableID: gateway.stableID)
|
||||||
self.didAutoConnect = true
|
self.didAutoConnect = true
|
||||||
self.startAutoConnect(
|
self.startAutoConnect(
|
||||||
url: url,
|
url: url,
|
||||||
|
|
@ -81,13 +86,24 @@ final class GatewayConnectionController {
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
||||||
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
||||||
let stableID = self.manualStableID(host: host, port: port)
|
let resolvedUseTLS = useTLS || self.shouldForceTLS(host: host)
|
||||||
let tlsParams = self.resolveManualTLSParams(stableID: stableID, tlsEnabled: useTLS)
|
guard let resolvedPort = self.resolveManualPort(host: host, port: port, useTLS: resolvedUseTLS)
|
||||||
|
else { return }
|
||||||
|
let stableID = self.manualStableID(host: host, port: resolvedPort)
|
||||||
|
let tlsParams = self.resolveManualTLSParams(
|
||||||
|
stableID: stableID,
|
||||||
|
tlsEnabled: resolvedUseTLS,
|
||||||
|
allowTOFUReset: self.shouldForceTLS(host: host))
|
||||||
guard let url = self.buildGatewayURL(
|
guard let url = self.buildGatewayURL(
|
||||||
host: host,
|
host: host,
|
||||||
port: port,
|
port: resolvedPort,
|
||||||
useTLS: tlsParams?.required == true)
|
useTLS: tlsParams?.required == true)
|
||||||
else { return }
|
else { return }
|
||||||
|
GatewaySettingsStore.saveLastGatewayConnection(
|
||||||
|
host: host,
|
||||||
|
port: resolvedPort,
|
||||||
|
useTLS: tlsParams?.required == true,
|
||||||
|
stableID: stableID)
|
||||||
self.didAutoConnect = true
|
self.didAutoConnect = true
|
||||||
self.startAutoConnect(
|
self.startAutoConnect(
|
||||||
url: url,
|
url: url,
|
||||||
|
|
@ -97,6 +113,38 @@ final class GatewayConnectionController {
|
||||||
password: password)
|
password: password)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func connectLastKnown() async {
|
||||||
|
guard let last = GatewaySettingsStore.loadLastGatewayConnection() else { return }
|
||||||
|
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
||||||
|
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
||||||
|
let resolvedUseTLS = last.useTLS || self.shouldForceTLS(host: last.host)
|
||||||
|
let tlsParams = self.resolveManualTLSParams(
|
||||||
|
stableID: last.stableID,
|
||||||
|
tlsEnabled: resolvedUseTLS,
|
||||||
|
allowTOFUReset: self.shouldForceTLS(host: last.host))
|
||||||
|
guard let url = self.buildGatewayURL(
|
||||||
|
host: last.host,
|
||||||
|
port: last.port,
|
||||||
|
useTLS: tlsParams?.required == true)
|
||||||
|
else { return }
|
||||||
|
if resolvedUseTLS != last.useTLS {
|
||||||
|
GatewaySettingsStore.saveLastGatewayConnection(
|
||||||
|
host: last.host,
|
||||||
|
port: last.port,
|
||||||
|
useTLS: resolvedUseTLS,
|
||||||
|
stableID: last.stableID)
|
||||||
|
}
|
||||||
|
self.didAutoConnect = true
|
||||||
|
self.startAutoConnect(
|
||||||
|
url: url,
|
||||||
|
gatewayStableID: last.stableID,
|
||||||
|
tls: tlsParams,
|
||||||
|
token: token,
|
||||||
|
password: password)
|
||||||
|
}
|
||||||
|
|
||||||
private func updateFromDiscovery() {
|
private func updateFromDiscovery() {
|
||||||
let newGateways = self.discovery.gateways
|
let newGateways = self.discovery.gateways
|
||||||
self.gateways = newGateways
|
self.gateways = newGateways
|
||||||
|
|
@ -143,9 +191,13 @@ final class GatewayConnectionController {
|
||||||
let manualPort = defaults.integer(forKey: "gateway.manual.port")
|
let manualPort = defaults.integer(forKey: "gateway.manual.port")
|
||||||
let resolvedPort = manualPort > 0 ? manualPort : 18789
|
let resolvedPort = manualPort > 0 ? manualPort : 18789
|
||||||
let manualTLS = defaults.bool(forKey: "gateway.manual.tls")
|
let manualTLS = defaults.bool(forKey: "gateway.manual.tls")
|
||||||
|
let resolvedUseTLS = manualTLS || self.shouldForceTLS(host: manualHost)
|
||||||
|
|
||||||
let stableID = self.manualStableID(host: manualHost, port: resolvedPort)
|
let stableID = self.manualStableID(host: manualHost, port: resolvedPort)
|
||||||
let tlsParams = self.resolveManualTLSParams(stableID: stableID, tlsEnabled: manualTLS)
|
let tlsParams = self.resolveManualTLSParams(
|
||||||
|
stableID: stableID,
|
||||||
|
tlsEnabled: resolvedUseTLS,
|
||||||
|
allowTOFUReset: self.shouldForceTLS(host: manualHost))
|
||||||
|
|
||||||
guard let url = self.buildGatewayURL(
|
guard let url = self.buildGatewayURL(
|
||||||
host: manualHost,
|
host: manualHost,
|
||||||
|
|
@ -169,21 +221,60 @@ final class GatewayConnectionController {
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
|
||||||
let candidates = [preferredStableID, lastDiscoveredStableID].filter { !$0.isEmpty }
|
let candidates = [preferredStableID, lastDiscoveredStableID].filter { !$0.isEmpty }
|
||||||
guard let targetStableID = candidates.first(where: { id in
|
if let targetStableID = candidates.first(where: { id in
|
||||||
self.gateways.contains(where: { $0.stableID == id })
|
self.gateways.contains(where: { $0.stableID == id })
|
||||||
}) else { return }
|
}) {
|
||||||
|
guard let target = self.gateways.first(where: { $0.stableID == targetStableID }) else { return }
|
||||||
|
guard let host = self.resolveGatewayHost(target) else { return }
|
||||||
|
let port = target.gatewayPort ?? 18789
|
||||||
|
let tlsParams = self.resolveDiscoveredTLSParams(gateway: target)
|
||||||
|
guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true)
|
||||||
|
else { return }
|
||||||
|
|
||||||
guard let target = self.gateways.first(where: { $0.stableID == targetStableID }) else { return }
|
self.didAutoConnect = true
|
||||||
guard let host = self.resolveGatewayHost(target) else { return }
|
self.startAutoConnect(
|
||||||
let port = target.gatewayPort ?? 18789
|
url: url,
|
||||||
let tlsParams = self.resolveDiscoveredTLSParams(gateway: target)
|
gatewayStableID: target.stableID,
|
||||||
guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true)
|
tls: tlsParams,
|
||||||
|
token: token,
|
||||||
|
password: password)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastKnown = GatewaySettingsStore.loadLastGatewayConnection()
|
||||||
|
if self.gateways.count == 1, lastKnown == nil, let gateway = self.gateways.first {
|
||||||
|
guard let host = self.resolveGatewayHost(gateway) else { return }
|
||||||
|
let port = gateway.gatewayPort ?? 18789
|
||||||
|
let tlsParams = self.resolveDiscoveredTLSParams(gateway: gateway)
|
||||||
|
guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true)
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
self.didAutoConnect = true
|
||||||
|
self.startAutoConnect(
|
||||||
|
url: url,
|
||||||
|
gatewayStableID: gateway.stableID,
|
||||||
|
tls: tlsParams,
|
||||||
|
token: token,
|
||||||
|
password: password)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let lastKnown else { return }
|
||||||
|
let resolvedUseTLS = lastKnown.useTLS || self.shouldForceTLS(host: lastKnown.host)
|
||||||
|
let tlsParams = self.resolveManualTLSParams(
|
||||||
|
stableID: lastKnown.stableID,
|
||||||
|
tlsEnabled: resolvedUseTLS,
|
||||||
|
allowTOFUReset: self.shouldForceTLS(host: lastKnown.host))
|
||||||
|
guard let url = self.buildGatewayURL(
|
||||||
|
host: lastKnown.host,
|
||||||
|
port: lastKnown.port,
|
||||||
|
useTLS: tlsParams?.required == true)
|
||||||
else { return }
|
else { return }
|
||||||
|
|
||||||
self.didAutoConnect = true
|
self.didAutoConnect = true
|
||||||
self.startAutoConnect(
|
self.startAutoConnect(
|
||||||
url: url,
|
url: url,
|
||||||
gatewayStableID: target.stableID,
|
gatewayStableID: lastKnown.stableID,
|
||||||
tls: tlsParams,
|
tls: tlsParams,
|
||||||
token: token,
|
token: token,
|
||||||
password: password)
|
password: password)
|
||||||
|
|
@ -212,7 +303,7 @@ final class GatewayConnectionController {
|
||||||
password: String?)
|
password: String?)
|
||||||
{
|
{
|
||||||
guard let appModel else { return }
|
guard let appModel else { return }
|
||||||
let connectOptions = self.makeConnectOptions()
|
let connectOptions = self.makeConnectOptions(stableID: gatewayStableID)
|
||||||
|
|
||||||
Task { [weak appModel] in
|
Task { [weak appModel] in
|
||||||
guard let appModel else { return }
|
guard let appModel else { return }
|
||||||
|
|
@ -244,13 +335,17 @@ final class GatewayConnectionController {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveManualTLSParams(stableID: String, tlsEnabled: Bool) -> GatewayTLSParams? {
|
private func resolveManualTLSParams(
|
||||||
|
stableID: String,
|
||||||
|
tlsEnabled: Bool,
|
||||||
|
allowTOFUReset: Bool = false) -> GatewayTLSParams?
|
||||||
|
{
|
||||||
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
||||||
if tlsEnabled || stored != nil {
|
if tlsEnabled || stored != nil {
|
||||||
return GatewayTLSParams(
|
return GatewayTLSParams(
|
||||||
required: true,
|
required: true,
|
||||||
expectedFingerprint: stored,
|
expectedFingerprint: stored,
|
||||||
allowTOFU: stored == nil,
|
allowTOFU: stored == nil || allowTOFUReset,
|
||||||
storeKey: stableID)
|
storeKey: stableID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -258,12 +353,12 @@ final class GatewayConnectionController {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveGatewayHost(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
private func resolveGatewayHost(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||||
if let lanHost = gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines), !lanHost.isEmpty {
|
|
||||||
return lanHost
|
|
||||||
}
|
|
||||||
if let tailnet = gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines), !tailnet.isEmpty {
|
if let tailnet = gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines), !tailnet.isEmpty {
|
||||||
return tailnet
|
return tailnet
|
||||||
}
|
}
|
||||||
|
if let lanHost = gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines), !lanHost.isEmpty {
|
||||||
|
return lanHost
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -276,16 +371,20 @@ final class GatewayConnectionController {
|
||||||
return components.url
|
return components.url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func shouldForceTLS(host: String) -> Bool {
|
||||||
|
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
if trimmed.isEmpty { return false }
|
||||||
|
return trimmed.hasSuffix(".ts.net") || trimmed.hasSuffix(".ts.net.")
|
||||||
|
}
|
||||||
|
|
||||||
private func manualStableID(host: String, port: Int) -> String {
|
private func manualStableID(host: String, port: Int) -> String {
|
||||||
"manual|\(host.lowercased())|\(port)"
|
"manual|\(host.lowercased())|\(port)"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeConnectOptions() -> GatewayConnectOptions {
|
private func makeConnectOptions(stableID: String?) -> GatewayConnectOptions {
|
||||||
let defaults = UserDefaults.standard
|
let defaults = UserDefaults.standard
|
||||||
let displayName = self.resolvedDisplayName(defaults: defaults)
|
let displayName = self.resolvedDisplayName(defaults: defaults)
|
||||||
let manualClientId = defaults.string(forKey: "gateway.manual.clientId")?
|
let resolvedClientId = self.resolvedClientId(defaults: defaults, stableID: stableID)
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let resolvedClientId = manualClientId?.isEmpty == false ? manualClientId! : "openclaw-ios"
|
|
||||||
|
|
||||||
return GatewayConnectOptions(
|
return GatewayConnectOptions(
|
||||||
role: "node",
|
role: "node",
|
||||||
|
|
@ -298,6 +397,31 @@ final class GatewayConnectionController {
|
||||||
clientDisplayName: displayName)
|
clientDisplayName: displayName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func resolvedClientId(defaults: UserDefaults, stableID: String?) -> String {
|
||||||
|
if let stableID,
|
||||||
|
let override = GatewaySettingsStore.loadGatewayClientIdOverride(stableID: stableID) {
|
||||||
|
return override
|
||||||
|
}
|
||||||
|
let manualClientId = defaults.string(forKey: "gateway.manual.clientId")?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if manualClientId?.isEmpty == false {
|
||||||
|
return manualClientId!
|
||||||
|
}
|
||||||
|
return "openclaw-ios"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveManualPort(host: String, port: Int, useTLS: Bool) -> Int? {
|
||||||
|
if port > 0 {
|
||||||
|
return port <= 65535 ? port : nil
|
||||||
|
}
|
||||||
|
let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmedHost.isEmpty else { return nil }
|
||||||
|
if useTLS && self.shouldForceTLS(host: trimmedHost) {
|
||||||
|
return 443
|
||||||
|
}
|
||||||
|
return 18789
|
||||||
|
}
|
||||||
|
|
||||||
private func resolvedDisplayName(defaults: UserDefaults) -> String {
|
private func resolvedDisplayName(defaults: UserDefaults) -> String {
|
||||||
let key = "node.displayName"
|
let key = "node.displayName"
|
||||||
let existingRaw = defaults.string(forKey: key)
|
let existingRaw = defaults.string(forKey: key)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,11 @@ enum GatewaySettingsStore {
|
||||||
private static let manualTlsDefaultsKey = "gateway.manual.tls"
|
private static let manualTlsDefaultsKey = "gateway.manual.tls"
|
||||||
private static let manualPasswordDefaultsKey = "gateway.manual.password"
|
private static let manualPasswordDefaultsKey = "gateway.manual.password"
|
||||||
private static let discoveryDebugLogsDefaultsKey = "gateway.discovery.debugLogs"
|
private static let discoveryDebugLogsDefaultsKey = "gateway.discovery.debugLogs"
|
||||||
|
private static let lastGatewayHostDefaultsKey = "gateway.last.host"
|
||||||
|
private static let lastGatewayPortDefaultsKey = "gateway.last.port"
|
||||||
|
private static let lastGatewayTlsDefaultsKey = "gateway.last.tls"
|
||||||
|
private static let lastGatewayStableIDDefaultsKey = "gateway.last.stableID"
|
||||||
|
private static let clientIdOverrideDefaultsPrefix = "gateway.clientIdOverride."
|
||||||
|
|
||||||
private static let instanceIdAccount = "instanceId"
|
private static let instanceIdAccount = "instanceId"
|
||||||
private static let preferredGatewayStableIDAccount = "preferredStableID"
|
private static let preferredGatewayStableIDAccount = "preferredStableID"
|
||||||
|
|
@ -109,6 +114,49 @@ enum GatewaySettingsStore {
|
||||||
account: self.gatewayPasswordAccount(instanceId: instanceId))
|
account: self.gatewayPasswordAccount(instanceId: instanceId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func saveLastGatewayConnection(host: String, port: Int, useTLS: Bool, stableID: String) {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
defaults.set(host, forKey: self.lastGatewayHostDefaultsKey)
|
||||||
|
defaults.set(port, forKey: self.lastGatewayPortDefaultsKey)
|
||||||
|
defaults.set(useTLS, forKey: self.lastGatewayTlsDefaultsKey)
|
||||||
|
defaults.set(stableID, forKey: self.lastGatewayStableIDDefaultsKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadLastGatewayConnection() -> (host: String, port: Int, useTLS: Bool, stableID: String)? {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
let host = defaults.string(forKey: self.lastGatewayHostDefaultsKey)?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let port = defaults.integer(forKey: self.lastGatewayPortDefaultsKey)
|
||||||
|
let useTLS = defaults.bool(forKey: self.lastGatewayTlsDefaultsKey)
|
||||||
|
let stableID = defaults.string(forKey: self.lastGatewayStableIDDefaultsKey)?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
|
||||||
|
guard !host.isEmpty, port > 0, port <= 65535, !stableID.isEmpty else { return nil }
|
||||||
|
return (host: host, port: port, useTLS: useTLS, stableID: stableID)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadGatewayClientIdOverride(stableID: String) -> String? {
|
||||||
|
let trimmedID = stableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmedID.isEmpty else { return nil }
|
||||||
|
let key = self.clientIdOverrideDefaultsPrefix + trimmedID
|
||||||
|
let value = UserDefaults.standard.string(forKey: key)?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if value?.isEmpty == false { return value }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveGatewayClientIdOverride(stableID: String, clientId: String?) {
|
||||||
|
let trimmedID = stableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmedID.isEmpty else { return }
|
||||||
|
let key = self.clientIdOverrideDefaultsPrefix + trimmedID
|
||||||
|
let trimmedClientId = clientId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if trimmedClientId.isEmpty {
|
||||||
|
UserDefaults.standard.removeObject(forKey: key)
|
||||||
|
} else {
|
||||||
|
UserDefaults.standard.set(trimmedClientId, forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static func gatewayTokenAccount(instanceId: String) -> String {
|
private static func gatewayTokenAccount(instanceId: String) -> String {
|
||||||
"gateway-token.\(instanceId)"
|
"gateway-token.\(instanceId)"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,16 +41,6 @@
|
||||||
<string>OpenClaw uses your location when you allow location sharing.</string>
|
<string>OpenClaw uses your location when you allow location sharing.</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>OpenClaw needs microphone access for voice wake.</string>
|
<string>OpenClaw needs microphone access for voice wake.</string>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
|
||||||
<string>OpenClaw can read recent photos when requested via the gateway.</string>
|
|
||||||
<key>NSContactsUsageDescription</key>
|
|
||||||
<string>OpenClaw can access your contacts when requested via the gateway.</string>
|
|
||||||
<key>NSCalendarsUsageDescription</key>
|
|
||||||
<string>OpenClaw can read and add calendar events when requested via the gateway.</string>
|
|
||||||
<key>NSRemindersUsageDescription</key>
|
|
||||||
<string>OpenClaw can read and add reminders when requested via the gateway.</string>
|
|
||||||
<key>NSMotionUsageDescription</key>
|
|
||||||
<string>OpenClaw can read motion activity and pedometer data when requested via the gateway.</string>
|
|
||||||
<key>NSSpeechRecognitionUsageDescription</key>
|
<key>NSSpeechRecognitionUsageDescription</key>
|
||||||
<string>OpenClaw uses on-device speech recognition for voice wake.</string>
|
<string>OpenClaw uses on-device speech recognition for voice wake.</string>
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ final class NodeAppModel {
|
||||||
private var gatewayTask: Task<Void, Never>?
|
private var gatewayTask: Task<Void, Never>?
|
||||||
private var voiceWakeSyncTask: Task<Void, Never>?
|
private var voiceWakeSyncTask: Task<Void, Never>?
|
||||||
@ObservationIgnored private var cameraHUDDismissTask: Task<Void, Never>?
|
@ObservationIgnored private var cameraHUDDismissTask: Task<Void, Never>?
|
||||||
@ObservationIgnored private var capabilityRouter: NodeCapabilityRouter
|
@ObservationIgnored private lazy var capabilityRouter: NodeCapabilityRouter = self.buildCapabilityRouter()
|
||||||
private let gatewayHealthMonitor = GatewayHealthMonitor()
|
private let gatewayHealthMonitor = GatewayHealthMonitor()
|
||||||
private let notificationCenter: NotificationCentering
|
private let notificationCenter: NotificationCentering
|
||||||
let voiceWake = VoiceWakeManager()
|
let voiceWake = VoiceWakeManager()
|
||||||
|
|
@ -111,8 +111,6 @@ final class NodeAppModel {
|
||||||
self.remindersService = remindersService
|
self.remindersService = remindersService
|
||||||
self.motionService = motionService
|
self.motionService = motionService
|
||||||
self.talkMode = talkMode
|
self.talkMode = talkMode
|
||||||
self.capabilityRouter = NodeCapabilityRouter(handlers: [:])
|
|
||||||
self.capabilityRouter = self.buildCapabilityRouter()
|
|
||||||
|
|
||||||
self.voiceWake.configure { [weak self] cmd in
|
self.voiceWake.configure { [weak self] cmd in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
|
@ -298,6 +296,9 @@ final class NodeAppModel {
|
||||||
|
|
||||||
self.gatewayTask = Task {
|
self.gatewayTask = Task {
|
||||||
var attempt = 0
|
var attempt = 0
|
||||||
|
var currentOptions = connectOptions
|
||||||
|
var didFallbackClientId = false
|
||||||
|
let trimmedStableID = gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
if attempt == 0 {
|
if attempt == 0 {
|
||||||
|
|
@ -314,7 +315,7 @@ final class NodeAppModel {
|
||||||
url: url,
|
url: url,
|
||||||
token: token,
|
token: token,
|
||||||
password: password,
|
password: password,
|
||||||
connectOptions: connectOptions,
|
connectOptions: currentOptions,
|
||||||
sessionBox: sessionBox,
|
sessionBox: sessionBox,
|
||||||
onConnected: { [weak self] in
|
onConnected: { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
|
@ -363,6 +364,23 @@ final class NodeAppModel {
|
||||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||||
} catch {
|
} catch {
|
||||||
if Task.isCancelled { break }
|
if Task.isCancelled { break }
|
||||||
|
if !didFallbackClientId,
|
||||||
|
let fallbackClientId = self.legacyClientIdFallback(
|
||||||
|
currentClientId: currentOptions.clientId,
|
||||||
|
error: error)
|
||||||
|
{
|
||||||
|
didFallbackClientId = true
|
||||||
|
currentOptions.clientId = fallbackClientId
|
||||||
|
if !trimmedStableID.isEmpty {
|
||||||
|
GatewaySettingsStore.saveGatewayClientIdOverride(
|
||||||
|
stableID: trimmedStableID,
|
||||||
|
clientId: fallbackClientId)
|
||||||
|
}
|
||||||
|
await MainActor.run {
|
||||||
|
self.gatewayStatusText = "Gateway rejected client id. Retrying…"
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
attempt += 1
|
attempt += 1
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.gatewayStatusText = "Gateway error: \(error.localizedDescription)"
|
self.gatewayStatusText = "Gateway error: \(error.localizedDescription)"
|
||||||
|
|
@ -394,6 +412,16 @@ final class NodeAppModel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func legacyClientIdFallback(currentClientId: String, error: Error) -> String? {
|
||||||
|
let normalizedClientId = currentClientId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
guard normalizedClientId == "openclaw-ios" else { return nil }
|
||||||
|
let message = error.localizedDescription.lowercased()
|
||||||
|
guard message.contains("invalid connect params"), message.contains("/client/id") else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return "moltbot-ios"
|
||||||
|
}
|
||||||
|
|
||||||
func disconnectGateway() {
|
func disconnectGateway() {
|
||||||
self.gatewayTask?.cancel()
|
self.gatewayTask?.cancel()
|
||||||
self.gatewayTask = nil
|
self.gatewayTask = nil
|
||||||
|
|
@ -507,7 +535,10 @@ final class NodeAppModel {
|
||||||
guard let self else { return false }
|
guard let self else { return false }
|
||||||
do {
|
do {
|
||||||
let data = try await self.gateway.request(method: "health", paramsJSON: nil, timeoutSeconds: 6)
|
let data = try await self.gateway.request(method: "health", paramsJSON: nil, timeoutSeconds: 6)
|
||||||
return (try? JSONDecoder().decode(OpenClawGatewayHealthOK.self, from: data))?.ok ?? true
|
guard let decoded = try? JSONDecoder().decode(OpenClawGatewayHealthOK.self, from: data) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return decoded.ok ?? false
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,311 @@
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
struct GatewayOnboardingView: View {
|
||||||
|
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
|
||||||
|
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
|
||||||
|
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
|
||||||
|
@AppStorage("gateway.lastDiscoveredStableID") private var lastDiscoveredGatewayStableID: String = ""
|
||||||
|
@AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false
|
||||||
|
@AppStorage("gateway.manual.host") private var manualGatewayHost: String = ""
|
||||||
|
@AppStorage("gateway.manual.port") private var manualGatewayPort: Int = 18789
|
||||||
|
@AppStorage("gateway.manual.tls") private var manualGatewayTLS: Bool = true
|
||||||
|
@State private var connectStatusText: String?
|
||||||
|
@State private var connectingGatewayID: String?
|
||||||
|
@State private var showManualEntry: Bool = false
|
||||||
|
@State private var manualGatewayPortText: String = ""
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
Text("Connect to your gateway to get started.")
|
||||||
|
LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
|
||||||
|
LabeledContent("Status", value: self.appModel.gatewayStatusText)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Gateways") {
|
||||||
|
self.gatewayList()
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
DisclosureGroup(isExpanded: self.$showManualEntry) {
|
||||||
|
TextField("Host", text: self.$manualGatewayHost)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
|
||||||
|
TextField("Port (optional)", text: self.manualPortBinding)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
|
||||||
|
Toggle("Use TLS", isOn: self.$manualGatewayTLS)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task { await self.connectManual() }
|
||||||
|
} label: {
|
||||||
|
if self.connectingGatewayID == "manual" {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(.circular)
|
||||||
|
Text("Connecting...")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("Connect manual gateway")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(self.connectingGatewayID != nil || self.manualGatewayHost
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.isEmpty || !self.manualPortIsValid)
|
||||||
|
|
||||||
|
Button("Paste gateway URL") {
|
||||||
|
self.pasteGatewayURL()
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"Use this when discovery is blocked. "
|
||||||
|
+ "Leave port empty for 443 on tailnet DNS (TLS) or 18789 otherwise.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} label: {
|
||||||
|
Text("Manual gateway")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let text = self.connectStatusText {
|
||||||
|
Section {
|
||||||
|
Text(text)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Connect Gateway")
|
||||||
|
.onAppear {
|
||||||
|
self.syncManualPortText()
|
||||||
|
}
|
||||||
|
.onChange(of: self.manualGatewayPort) { _, _ in
|
||||||
|
self.syncManualPortText()
|
||||||
|
}
|
||||||
|
.onChange(of: self.appModel.gatewayServerName) { _, _ in
|
||||||
|
self.connectStatusText = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func gatewayList() -> some View {
|
||||||
|
if self.gatewayController.gateways.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("No gateways found yet.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Make sure you are on the same Wi-Fi as your gateway, or your tailnet DNS is set.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection() {
|
||||||
|
Button {
|
||||||
|
Task { await self.connectLastKnown() }
|
||||||
|
} label: {
|
||||||
|
self.lastKnownButtonLabel(host: lastKnown.host, port: lastKnown.port)
|
||||||
|
}
|
||||||
|
.disabled(self.connectingGatewayID != nil)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(self.appModel.seamColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ForEach(self.gatewayController.gateways) { gateway in
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(gateway.name)
|
||||||
|
let detailLines = self.gatewayDetailLines(gateway)
|
||||||
|
ForEach(detailLines, id: \.self) { line in
|
||||||
|
Text(line)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task { await self.connect(gateway) }
|
||||||
|
} label: {
|
||||||
|
if self.connectingGatewayID == gateway.id {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(.circular)
|
||||||
|
} else {
|
||||||
|
Text("Connect")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(self.connectingGatewayID != nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
|
||||||
|
self.connectingGatewayID = gateway.id
|
||||||
|
self.manualGatewayEnabled = false
|
||||||
|
self.preferredGatewayStableID = gateway.stableID
|
||||||
|
GatewaySettingsStore.savePreferredGatewayStableID(gateway.stableID)
|
||||||
|
self.lastDiscoveredGatewayStableID = gateway.stableID
|
||||||
|
GatewaySettingsStore.saveLastDiscoveredGatewayStableID(gateway.stableID)
|
||||||
|
defer { self.connectingGatewayID = nil }
|
||||||
|
await self.gatewayController.connect(gateway)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func connectLastKnown() async {
|
||||||
|
self.connectingGatewayID = "last-known"
|
||||||
|
defer { self.connectingGatewayID = nil }
|
||||||
|
await self.gatewayController.connectLastKnown()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var manualPortBinding: Binding<String> {
|
||||||
|
Binding(
|
||||||
|
get: { self.manualGatewayPortText },
|
||||||
|
set: { newValue in
|
||||||
|
let filtered = newValue.filter(\.isNumber)
|
||||||
|
if self.manualGatewayPortText != filtered {
|
||||||
|
self.manualGatewayPortText = filtered
|
||||||
|
}
|
||||||
|
if filtered.isEmpty {
|
||||||
|
if self.manualGatewayPort != 0 {
|
||||||
|
self.manualGatewayPort = 0
|
||||||
|
}
|
||||||
|
} else if let port = Int(filtered), self.manualGatewayPort != port {
|
||||||
|
self.manualGatewayPort = port
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private var manualPortIsValid: Bool {
|
||||||
|
if self.manualGatewayPortText.isEmpty { return true }
|
||||||
|
return self.manualGatewayPort >= 1 && self.manualGatewayPort <= 65535
|
||||||
|
}
|
||||||
|
|
||||||
|
private func syncManualPortText() {
|
||||||
|
if self.manualGatewayPort > 0 {
|
||||||
|
let next = String(self.manualGatewayPort)
|
||||||
|
if self.manualGatewayPortText != next {
|
||||||
|
self.manualGatewayPortText = next
|
||||||
|
}
|
||||||
|
} else if !self.manualGatewayPortText.isEmpty {
|
||||||
|
self.manualGatewayPortText = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func lastKnownButtonLabel(host: String, port: Int) -> some View {
|
||||||
|
if self.connectingGatewayID == "last-known" {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(.circular)
|
||||||
|
Text("Connecting...")
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
} else {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "bolt.horizontal.circle.fill")
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Connect last known")
|
||||||
|
Text("\(host):\(port)")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func connectManual() async {
|
||||||
|
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !host.isEmpty else {
|
||||||
|
self.connectStatusText = "Failed: host required"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard self.manualPortIsValid else {
|
||||||
|
self.connectStatusText = "Failed: invalid port"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.connectingGatewayID = "manual"
|
||||||
|
self.manualGatewayEnabled = true
|
||||||
|
defer { self.connectingGatewayID = nil }
|
||||||
|
|
||||||
|
await self.gatewayController.connectManual(
|
||||||
|
host: host,
|
||||||
|
port: self.manualGatewayPort,
|
||||||
|
useTLS: self.manualGatewayTLS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pasteGatewayURL() {
|
||||||
|
guard let text = UIPasteboard.general.string else {
|
||||||
|
self.connectStatusText = "Clipboard is empty."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if self.applyGatewayInput(text) {
|
||||||
|
self.connectStatusText = nil
|
||||||
|
self.showManualEntry = true
|
||||||
|
} else {
|
||||||
|
self.connectStatusText = "Could not parse gateway URL."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyGatewayInput(_ text: String) -> Bool {
|
||||||
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return false }
|
||||||
|
|
||||||
|
if let components = URLComponents(string: trimmed),
|
||||||
|
let host = components.host?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!host.isEmpty
|
||||||
|
{
|
||||||
|
let scheme = components.scheme?.lowercased()
|
||||||
|
let defaultPort: Int = {
|
||||||
|
let hostLower = host.lowercased()
|
||||||
|
if (scheme == "wss" || scheme == "https"), hostLower.hasSuffix(".ts.net") {
|
||||||
|
return 443
|
||||||
|
}
|
||||||
|
return 18789
|
||||||
|
}()
|
||||||
|
let port = components.port ?? defaultPort
|
||||||
|
if scheme == "wss" || scheme == "https" {
|
||||||
|
self.manualGatewayTLS = true
|
||||||
|
} else if scheme == "ws" || scheme == "http" {
|
||||||
|
self.manualGatewayTLS = false
|
||||||
|
}
|
||||||
|
self.manualGatewayHost = host
|
||||||
|
self.manualGatewayPort = port
|
||||||
|
self.manualGatewayPortText = String(port)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if let hostPort = SettingsNetworkingHelpers.parseHostPort(from: trimmed) {
|
||||||
|
self.manualGatewayHost = hostPort.host
|
||||||
|
self.manualGatewayPort = hostPort.port
|
||||||
|
self.manualGatewayPortText = String(hostPort.port)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func gatewayDetailLines(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> [String] {
|
||||||
|
var lines: [String] = []
|
||||||
|
if let lanHost = gateway.lanHost { lines.append("LAN: \(lanHost)") }
|
||||||
|
if let tailnet = gateway.tailnetDns { lines.append("Tailnet: \(tailnet)") }
|
||||||
|
|
||||||
|
let gatewayPort = gateway.gatewayPort
|
||||||
|
let canvasPort = gateway.canvasPort
|
||||||
|
if gatewayPort != nil || canvasPort != nil {
|
||||||
|
let gw = gatewayPort.map(String.init) ?? "-"
|
||||||
|
let canvas = canvasPort.map(String.init) ?? "-"
|
||||||
|
lines.append("Ports: gateway \(gw) / canvas \(canvas)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if lines.isEmpty {
|
||||||
|
lines.append(gateway.debugID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,7 @@ struct OpenClawApp: App {
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
RootCanvas()
|
RootView()
|
||||||
.environment(self.appModel)
|
.environment(self.appModel)
|
||||||
.environment(self.appModel.voiceWake)
|
.environment(self.appModel.voiceWake)
|
||||||
.environment(self.gatewayController)
|
.environment(self.gatewayController)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct RootView: View {
|
||||||
|
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
|
||||||
|
@AppStorage("gateway.onboardingComplete") private var onboardingComplete: Bool = false
|
||||||
|
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
|
||||||
|
@AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false
|
||||||
|
@AppStorage("gateway.manual.host") private var manualGatewayHost: String = ""
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if self.shouldShowOnboarding {
|
||||||
|
GatewayOnboardingView()
|
||||||
|
} else {
|
||||||
|
RootCanvas()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear { self.bootstrapOnboardingIfNeeded() }
|
||||||
|
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
|
||||||
|
if newValue != nil {
|
||||||
|
self.onboardingComplete = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var shouldShowOnboarding: Bool {
|
||||||
|
if self.appModel.gatewayServerName != nil { return false }
|
||||||
|
if self.onboardingComplete { return false }
|
||||||
|
if self.hasExistingGatewayConfig { return false }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hasExistingGatewayConfig: Bool {
|
||||||
|
if GatewaySettingsStore.loadLastGatewayConnection() != nil { return true }
|
||||||
|
let preferred = self.preferredGatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !preferred.isEmpty { return true }
|
||||||
|
let manualHost = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return self.manualGatewayEnabled && !manualHost.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bootstrapOnboardingIfNeeded() {
|
||||||
|
if !self.onboardingComplete, self.hasExistingGatewayConfig {
|
||||||
|
self.onboardingComplete = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -41,6 +41,7 @@ struct SettingsTab: View {
|
||||||
@State private var lastLocationModeRaw: String = OpenClawLocationMode.off.rawValue
|
@State private var lastLocationModeRaw: String = OpenClawLocationMode.off.rawValue
|
||||||
@State private var gatewayToken: String = ""
|
@State private var gatewayToken: String = ""
|
||||||
@State private var gatewayPassword: String = ""
|
@State private var gatewayPassword: String = ""
|
||||||
|
@State private var manualGatewayPortText: String = ""
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
|
|
@ -121,7 +122,7 @@ struct SettingsTab: View {
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
|
|
||||||
TextField("Port", value: self.$manualGatewayPort, format: .number)
|
TextField("Port (optional)", text: self.manualPortBinding)
|
||||||
.keyboardType(.numberPad)
|
.keyboardType(.numberPad)
|
||||||
|
|
||||||
Toggle("Use TLS", isOn: self.$manualGatewayTLS)
|
Toggle("Use TLS", isOn: self.$manualGatewayTLS)
|
||||||
|
|
@ -141,11 +142,11 @@ struct SettingsTab: View {
|
||||||
}
|
}
|
||||||
.disabled(self.connectingGatewayID != nil || self.manualGatewayHost
|
.disabled(self.connectingGatewayID != nil || self.manualGatewayHost
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
.isEmpty || self.manualGatewayPort <= 0 || self.manualGatewayPort > 65535)
|
.isEmpty || !self.manualPortIsValid)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
"Use this when mDNS/Bonjour discovery is blocked. "
|
"Use this when mDNS/Bonjour discovery is blocked. "
|
||||||
+ "The gateway WebSocket listens on port 18789 by default.")
|
+ "Leave port empty for 443 on tailnet DNS (TLS) or 18789 otherwise.")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
|
@ -233,6 +234,7 @@ struct SettingsTab: View {
|
||||||
.onAppear {
|
.onAppear {
|
||||||
self.localIPAddress = Self.primaryIPv4Address()
|
self.localIPAddress = Self.primaryIPv4Address()
|
||||||
self.lastLocationModeRaw = self.locationEnabledModeRaw
|
self.lastLocationModeRaw = self.locationEnabledModeRaw
|
||||||
|
self.syncManualPortText()
|
||||||
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if !trimmedInstanceId.isEmpty {
|
if !trimmedInstanceId.isEmpty {
|
||||||
self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? ""
|
self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? ""
|
||||||
|
|
@ -256,6 +258,9 @@ struct SettingsTab: View {
|
||||||
guard !instanceId.isEmpty else { return }
|
guard !instanceId.isEmpty else { return }
|
||||||
GatewaySettingsStore.saveGatewayPassword(trimmed, instanceId: instanceId)
|
GatewaySettingsStore.saveGatewayPassword(trimmed, instanceId: instanceId)
|
||||||
}
|
}
|
||||||
|
.onChange(of: self.manualGatewayPort) { _, _ in
|
||||||
|
self.syncManualPortText()
|
||||||
|
}
|
||||||
.onChange(of: self.appModel.gatewayServerName) { _, _ in
|
.onChange(of: self.appModel.gatewayServerName) { _, _ in
|
||||||
self.connectStatus.text = nil
|
self.connectStatus.text = nil
|
||||||
}
|
}
|
||||||
|
|
@ -279,8 +284,24 @@ struct SettingsTab: View {
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func gatewayList(showing: GatewayListMode) -> some View {
|
private func gatewayList(showing: GatewayListMode) -> some View {
|
||||||
if self.gatewayController.gateways.isEmpty {
|
if self.gatewayController.gateways.isEmpty {
|
||||||
Text("No gateways found yet.")
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
.foregroundStyle(.secondary)
|
Text("No gateways found yet.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("If your gateway is on another network, connect it and ensure DNS is working.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection() {
|
||||||
|
Button {
|
||||||
|
Task { await self.connectLastKnown() }
|
||||||
|
} label: {
|
||||||
|
self.lastKnownButtonLabel(host: lastKnown.host, port: lastKnown.port)
|
||||||
|
}
|
||||||
|
.disabled(self.connectingGatewayID != nil)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(self.appModel.seamColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
let connectedID = self.appModel.connectedGatewayID
|
let connectedID = self.appModel.connectedGatewayID
|
||||||
let rows = self.gatewayController.gateways.filter { gateway in
|
let rows = self.gatewayController.gateways.filter { gateway in
|
||||||
|
|
@ -378,13 +399,77 @@ struct SettingsTab: View {
|
||||||
await self.gatewayController.connect(gateway)
|
await self.gatewayController.connect(gateway)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func connectLastKnown() async {
|
||||||
|
self.connectingGatewayID = "last-known"
|
||||||
|
defer { self.connectingGatewayID = nil }
|
||||||
|
await self.gatewayController.connectLastKnown()
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func lastKnownButtonLabel(host: String, port: Int) -> some View {
|
||||||
|
if self.connectingGatewayID == "last-known" {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(.circular)
|
||||||
|
Text("Connecting…")
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
} else {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "bolt.horizontal.circle.fill")
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Connect last known")
|
||||||
|
Text("\(host):\(port)")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var manualPortBinding: Binding<String> {
|
||||||
|
Binding(
|
||||||
|
get: { self.manualGatewayPortText },
|
||||||
|
set: { newValue in
|
||||||
|
let filtered = newValue.filter(\.isNumber)
|
||||||
|
if self.manualGatewayPortText != filtered {
|
||||||
|
self.manualGatewayPortText = filtered
|
||||||
|
}
|
||||||
|
if filtered.isEmpty {
|
||||||
|
if self.manualGatewayPort != 0 {
|
||||||
|
self.manualGatewayPort = 0
|
||||||
|
}
|
||||||
|
} else if let port = Int(filtered), self.manualGatewayPort != port {
|
||||||
|
self.manualGatewayPort = port
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private var manualPortIsValid: Bool {
|
||||||
|
if self.manualGatewayPortText.isEmpty { return true }
|
||||||
|
return self.manualGatewayPort >= 1 && self.manualGatewayPort <= 65535
|
||||||
|
}
|
||||||
|
|
||||||
|
private func syncManualPortText() {
|
||||||
|
if self.manualGatewayPort > 0 {
|
||||||
|
let next = String(self.manualGatewayPort)
|
||||||
|
if self.manualGatewayPortText != next {
|
||||||
|
self.manualGatewayPortText = next
|
||||||
|
}
|
||||||
|
} else if !self.manualGatewayPortText.isEmpty {
|
||||||
|
self.manualGatewayPortText = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func connectManual() async {
|
private func connectManual() async {
|
||||||
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !host.isEmpty else {
|
guard !host.isEmpty else {
|
||||||
self.connectStatus.text = "Failed: host required"
|
self.connectStatus.text = "Failed: host required"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard self.manualGatewayPort > 0, self.manualGatewayPort <= 65535 else {
|
guard self.manualPortIsValid else {
|
||||||
self.connectStatus.text = "Failed: invalid port"
|
self.connectStatus.text = "Failed: invalid port"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -451,7 +451,8 @@ final class TalkModeManager: NSObject {
|
||||||
|
|
||||||
private func handleTranscript(transcript: String, isFinal: Bool) async {
|
private func handleTranscript(transcript: String, isFinal: Bool) async {
|
||||||
let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if self.isSpeaking, self.interruptOnSpeech {
|
let ttsActive = self.isSpeechOutputActive
|
||||||
|
if ttsActive, self.interruptOnSpeech {
|
||||||
if self.shouldInterrupt(with: trimmed) {
|
if self.shouldInterrupt(with: trimmed) {
|
||||||
self.stopSpeaking()
|
self.stopSpeaking()
|
||||||
}
|
}
|
||||||
|
|
@ -470,7 +471,7 @@ final class TalkModeManager: NSObject {
|
||||||
_ = await self.endPushToTalk()
|
_ = await self.endPushToTalk()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if self.captureMode == .continuous, !self.isSpeaking {
|
if self.captureMode == .continuous, !self.isSpeechOutputActive {
|
||||||
await self.processTranscript(trimmed, restartAfter: true)
|
await self.processTranscript(trimmed, restartAfter: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -489,7 +490,7 @@ final class TalkModeManager: NSObject {
|
||||||
|
|
||||||
private func checkSilence() async {
|
private func checkSilence() async {
|
||||||
if self.captureMode == .continuous {
|
if self.captureMode == .continuous {
|
||||||
guard self.isListening, !self.isSpeaking else { return }
|
guard self.isListening, !self.isSpeechOutputActive else { return }
|
||||||
let transcript = self.lastTranscript.trimmingCharacters(in: .whitespacesAndNewlines)
|
let transcript = self.lastTranscript.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !transcript.isEmpty else { return }
|
guard !transcript.isEmpty else { return }
|
||||||
guard let lastHeard else { return }
|
guard let lastHeard else { return }
|
||||||
|
|
@ -895,16 +896,22 @@ final class TalkModeManager: NSObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func stopSpeaking(storeInterruption: Bool = true) {
|
private func stopSpeaking(storeInterruption: Bool = true) {
|
||||||
guard self.isSpeaking else { return }
|
let hasIncremental = self.incrementalSpeechActive ||
|
||||||
let interruptedAt = self.lastPlaybackWasPCM
|
self.incrementalSpeechTask != nil ||
|
||||||
? self.pcmPlayer.stop()
|
!self.incrementalSpeechQueue.isEmpty
|
||||||
: self.mp3Player.stop()
|
if self.isSpeaking {
|
||||||
if storeInterruption {
|
let interruptedAt = self.lastPlaybackWasPCM
|
||||||
self.lastInterruptedAtSeconds = interruptedAt
|
? self.pcmPlayer.stop()
|
||||||
|
: self.mp3Player.stop()
|
||||||
|
if storeInterruption {
|
||||||
|
self.lastInterruptedAtSeconds = interruptedAt
|
||||||
|
}
|
||||||
|
_ = self.lastPlaybackWasPCM
|
||||||
|
? self.mp3Player.stop()
|
||||||
|
: self.pcmPlayer.stop()
|
||||||
|
} else if !hasIncremental {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
_ = self.lastPlaybackWasPCM
|
|
||||||
? self.mp3Player.stop()
|
|
||||||
: self.pcmPlayer.stop()
|
|
||||||
TalkSystemSpeechSynthesizer.shared.stop()
|
TalkSystemSpeechSynthesizer.shared.stop()
|
||||||
self.cancelIncrementalSpeech()
|
self.cancelIncrementalSpeech()
|
||||||
self.isSpeaking = false
|
self.isSpeaking = false
|
||||||
|
|
@ -923,6 +930,13 @@ final class TalkModeManager: NSObject {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var isSpeechOutputActive: Bool {
|
||||||
|
self.isSpeaking ||
|
||||||
|
self.incrementalSpeechActive ||
|
||||||
|
self.incrementalSpeechTask != nil ||
|
||||||
|
!self.incrementalSpeechQueue.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
private func applyDirective(_ directive: TalkDirective?) {
|
private func applyDirective(_ directive: TalkDirective?) {
|
||||||
let requestedVoice = directive?.voiceId?.trimmingCharacters(in: .whitespacesAndNewlines)
|
let requestedVoice = directive?.voiceId?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let resolvedVoice = self.resolveVoiceAlias(requestedVoice)
|
let resolvedVoice = self.resolveVoiceAlias(requestedVoice)
|
||||||
|
|
@ -1348,15 +1362,33 @@ private struct IncrementalSpeechBuffer {
|
||||||
if newText.hasPrefix(self.latestText) {
|
if newText.hasPrefix(self.latestText) {
|
||||||
self.latestText = newText
|
self.latestText = newText
|
||||||
} else if self.latestText.hasPrefix(newText) {
|
} else if self.latestText.hasPrefix(newText) {
|
||||||
// Keep the longer cached text.
|
// Stream reset or correction; prefer the newer prefix.
|
||||||
|
self.latestText = newText
|
||||||
|
self.spokenOffset = min(self.spokenOffset, newText.count)
|
||||||
} else {
|
} else {
|
||||||
self.latestText += newText
|
// Diverged text means chunks arrived out of order or stream restarted.
|
||||||
|
let commonPrefix = Self.commonPrefixCount(self.latestText, newText)
|
||||||
|
self.latestText = newText
|
||||||
|
if self.spokenOffset > commonPrefix {
|
||||||
|
self.spokenOffset = commonPrefix
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if self.spokenOffset > self.latestText.count {
|
if self.spokenOffset > self.latestText.count {
|
||||||
self.spokenOffset = self.latestText.count
|
self.spokenOffset = self.latestText.count
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func commonPrefixCount(_ lhs: String, _ rhs: String) -> Int {
|
||||||
|
let left = Array(lhs)
|
||||||
|
let right = Array(rhs)
|
||||||
|
let limit = min(left.count, right.count)
|
||||||
|
var idx = 0
|
||||||
|
while idx < limit, left[idx] == right[idx] {
|
||||||
|
idx += 1
|
||||||
|
}
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
|
||||||
private mutating func extractSegments(isFinal: Bool) -> [String] {
|
private mutating func extractSegments(isFinal: Bool) -> [String] {
|
||||||
let chars = Array(self.latestText)
|
let chars = Array(self.latestText)
|
||||||
guard self.spokenOffset < chars.count else { return [] }
|
guard self.spokenOffset < chars.count else { return [] }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue