mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-15 12:26:04 +00:00
* feat(apps): Android, iPhone, and Watch approval clients Squash-rebased #103912 segment onto the deep-links tip on current main. Native approval surfaces: iOS approval presentation with gateway-switch lease preservation and resolution fencing, watchOS inbox + approval actions with shipped-shape payload codec, Android approval notices with publication-tokened dismissal. Native i18n inventory regenerated. (cherry picked from commit 428a76670ffeede54248b7bd7aa4438e2589851b) (cherry picked from commit 80225d5707c3645eeea5435f266131037b50ede6) (cherry picked from commit 2a23b714dc30d773a294aa45adc617e46b80732e) (cherry picked from commit 9ff1153827769e2cbab7c11c153f0f8634662c28) (cherry picked from commit 5b25723525bd562e5f97478af492f7b46ad30fd1) (cherry picked from commit 8c80e8467b5fe89aad0d4c74a0573f1600ed3af9) (cherry picked from commit ad4037bc9846bf6f082b41c129ee68aa344576c3) (cherry picked from commit fdf767dd662cff3c8a5a6d571f38f153410651ca) (cherry picked from commit 00c120376ffd992ea68ce29254a9bc0a25ed1740) (cherry picked from commitf36a95213e) (cherry picked from commit e2c25cbe2baacab44d21871d8cb6734704f065ac) (cherry picked from commit 7c4fda519080486d341a9f4df36d63f9e24b1235) (cherry picked from commit 1b3d4eda3dc5988012124597f9454ae21fb187a1) (cherry picked from commit2a60619722) (cherry picked from commit6f0c386567) (cherry picked from commit784a5857b7) (cherry picked from commit cbf294e026841c9bc2799da0fc7db666a69c52db) * fix(apps): harden approval reconciliation and watch states
1160 lines
49 KiB
Swift
1160 lines
49 KiB
Swift
import Foundation
|
|
import OpenClawKit
|
|
import os
|
|
|
|
enum GatewaySettingsStore {
|
|
private static let productionGatewayService = "ai.openclawfoundation.app.gateway"
|
|
private static var gatewayService: String {
|
|
#if DEBUG
|
|
// Hosted tests share the app's Keychain access group; keep fixtures away from installed-app state.
|
|
if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil {
|
|
return "\(self.productionGatewayService).tests"
|
|
}
|
|
#endif
|
|
return self.productionGatewayService
|
|
}
|
|
|
|
#if DEBUG
|
|
static var _testGatewayService: String {
|
|
self.gatewayService
|
|
}
|
|
#endif
|
|
private static let nodeService = "ai.openclawfoundation.app.node"
|
|
private static let talkService = "ai.openclawfoundation.app.talk"
|
|
|
|
private static let instanceIdDefaultsKey = "node.instanceId"
|
|
private static let preferredGatewayStableIDDefaultsKey = "gateway.preferredStableID"
|
|
private static let lastDiscoveredGatewayStableIDDefaultsKey = "gateway.lastDiscoveredStableID"
|
|
private static let lastGatewayKindDefaultsKey = "gateway.last.kind"
|
|
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 selectedAgentDefaultsPrefix = "gateway.selectedAgentId."
|
|
|
|
private static let instanceIdAccount = "instanceId"
|
|
private static let preferredGatewayStableIDAccount = "preferredStableID"
|
|
private static let lastDiscoveredGatewayStableIDAccount = "lastDiscoveredStableID"
|
|
private static let gatewayRegistryAccount = "gateway-registry"
|
|
private static let lastGatewayConnectionAccount = "lastConnection"
|
|
private static let gatewayCustomHeadersService = "ai.openclawfoundation.app.gateway.custom-headers"
|
|
private static let talkProviderApiKeyAccountPrefix = "provider.apiKey." // pragma: allowlist secret
|
|
|
|
struct GatewayRegistryEntry: Codable, Equatable, Identifiable {
|
|
enum Kind: String, Codable {
|
|
case manual
|
|
case discovered
|
|
}
|
|
|
|
var stableID: String
|
|
var kind: Kind
|
|
var name: String
|
|
var host: String?
|
|
var port: Int?
|
|
var useTLS: Bool
|
|
var lastConnectedAtMs: Int?
|
|
|
|
var id: GatewayStableIdentifier.Key {
|
|
GatewayStableIdentifier.Key(self.stableID)
|
|
}
|
|
|
|
static func == (lhs: Self, rhs: Self) -> Bool {
|
|
GatewayStableIdentifier.matches(lhs.stableID, rhs.stableID) &&
|
|
lhs.kind == rhs.kind &&
|
|
lhs.name == rhs.name &&
|
|
lhs.host == rhs.host &&
|
|
lhs.port == rhs.port &&
|
|
lhs.useTLS == rhs.useTLS &&
|
|
lhs.lastConnectedAtMs == rhs.lastConnectedAtMs
|
|
}
|
|
}
|
|
|
|
struct GatewayRegistry: Codable, Equatable {
|
|
var version: Int = 1
|
|
var activeStableID: String?
|
|
var entries: [GatewayRegistryEntry] = []
|
|
|
|
static let empty = GatewayRegistry()
|
|
}
|
|
|
|
struct GatewayCredentialMetadata: Codable, Equatable {
|
|
let gatewayStableID: String
|
|
let suppressStoredDeviceAuth: Bool
|
|
}
|
|
|
|
/// Credential ownership and secrets must move together. Separate Keychain
|
|
/// entries can survive a partial update and bind one gateway's secret to another.
|
|
private struct GatewayCredentialBundle: Codable {
|
|
let gatewayStableID: String
|
|
let suppressStoredDeviceAuth: Bool
|
|
let token: String?
|
|
let bootstrapToken: String?
|
|
let password: String?
|
|
}
|
|
|
|
struct GatewayCredentials: Equatable {
|
|
let token: String?
|
|
let bootstrapToken: String?
|
|
let password: String?
|
|
let suppressStoredDeviceAuth: Bool
|
|
|
|
static let empty = GatewayCredentials(
|
|
token: nil,
|
|
bootstrapToken: nil,
|
|
password: nil,
|
|
suppressStoredDeviceAuth: false)
|
|
|
|
var hasCredentials: Bool {
|
|
self.token != nil || self.bootstrapToken != nil || self.password != nil
|
|
}
|
|
}
|
|
|
|
static func bootstrapPersistence() {
|
|
self.ensureStableInstanceID()
|
|
self.ensurePreferredGatewayStableID()
|
|
self.ensureLastDiscoveredGatewayStableID()
|
|
self.migrateGatewayRegistryIfNeeded()
|
|
if let instanceID = self.loadStableInstanceID() {
|
|
self.migrateGatewayCredentialBundleIfNeeded(instanceId: instanceID)
|
|
}
|
|
}
|
|
|
|
static func currentInstanceID(defaults: UserDefaults = .standard) -> String {
|
|
self.bootstrapPersistence()
|
|
if let value = defaults.string(forKey: self.instanceIdDefaultsKey)?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!value.isEmpty
|
|
{
|
|
return value
|
|
}
|
|
return self.loadStableInstanceID() ?? ""
|
|
}
|
|
|
|
static func loadStableInstanceID() -> String? {
|
|
if let value = KeychainStore.loadString(service: self.nodeService, account: self.instanceIdAccount)?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!value.isEmpty
|
|
{
|
|
return value
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
static func saveStableInstanceID(_ instanceId: String) {
|
|
_ = KeychainStore.saveString(instanceId, service: self.nodeService, account: self.instanceIdAccount)
|
|
}
|
|
|
|
static func loadPreferredGatewayStableID() -> String? {
|
|
GatewayStableIdentifier.exact(KeychainStore.loadString(
|
|
service: self.gatewayService,
|
|
account: self.preferredGatewayStableIDAccount))
|
|
}
|
|
|
|
static func savePreferredGatewayStableID(_ stableID: String) {
|
|
guard let stableID = GatewayStableIdentifier.exact(stableID) else { return }
|
|
_ = KeychainStore.saveString(
|
|
stableID,
|
|
service: self.gatewayService,
|
|
account: self.preferredGatewayStableIDAccount)
|
|
}
|
|
|
|
static func clearPreferredGatewayStableID(defaults: UserDefaults = .standard) {
|
|
_ = KeychainStore.delete(
|
|
service: self.gatewayService,
|
|
account: self.preferredGatewayStableIDAccount)
|
|
defaults.removeObject(forKey: self.preferredGatewayStableIDDefaultsKey)
|
|
}
|
|
|
|
static func loadLastDiscoveredGatewayStableID() -> String? {
|
|
GatewayStableIdentifier.exact(KeychainStore.loadString(
|
|
service: self.gatewayService,
|
|
account: self.lastDiscoveredGatewayStableIDAccount))
|
|
}
|
|
|
|
static func saveLastDiscoveredGatewayStableID(_ stableID: String) {
|
|
guard let stableID = GatewayStableIdentifier.exact(stableID) else { return }
|
|
_ = KeychainStore.saveString(
|
|
stableID,
|
|
service: self.gatewayService,
|
|
account: self.lastDiscoveredGatewayStableIDAccount)
|
|
}
|
|
|
|
static func clearLastDiscoveredGatewayStableID(defaults: UserDefaults = .standard) {
|
|
_ = KeychainStore.delete(
|
|
service: self.gatewayService,
|
|
account: self.lastDiscoveredGatewayStableIDAccount)
|
|
defaults.removeObject(forKey: self.lastDiscoveredGatewayStableIDDefaultsKey)
|
|
}
|
|
|
|
static func loadGatewayCredentialMetadata(
|
|
instanceId: String,
|
|
gatewayStableID: String) -> GatewayCredentialMetadata?
|
|
{
|
|
guard let bundle = self.loadGatewayCredentialBundle(
|
|
instanceId: instanceId,
|
|
gatewayStableID: gatewayStableID)
|
|
else { return nil }
|
|
return GatewayCredentialMetadata(
|
|
gatewayStableID: bundle.gatewayStableID,
|
|
suppressStoredDeviceAuth: bundle.suppressStoredDeviceAuth)
|
|
}
|
|
|
|
static func loadGatewayCredentials(instanceId: String, gatewayStableID: String) -> GatewayCredentials {
|
|
let stableID = self.authenticationOwnerID(routeStableID: gatewayStableID)
|
|
guard !stableID.isEmpty,
|
|
let bundle = self.loadGatewayCredentialBundle(
|
|
instanceId: instanceId,
|
|
gatewayStableID: stableID)
|
|
else { return .empty }
|
|
return GatewayCredentials(
|
|
token: bundle.token,
|
|
bootstrapToken: bundle.bootstrapToken,
|
|
password: bundle.password,
|
|
suppressStoredDeviceAuth: bundle.suppressStoredDeviceAuth)
|
|
}
|
|
|
|
@discardableResult
|
|
static func saveGatewayCredentials(
|
|
token: String?,
|
|
bootstrapToken: String?,
|
|
password: String?,
|
|
gatewayStableID: String,
|
|
suppressStoredDeviceAuth: Bool,
|
|
instanceId: String) -> Bool
|
|
{
|
|
let stableID = self.authenticationOwnerID(routeStableID: gatewayStableID)
|
|
let trimmedInstanceID = instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !stableID.isEmpty, !trimmedInstanceID.isEmpty else { return false }
|
|
let bundle = GatewayCredentialBundle(
|
|
gatewayStableID: stableID,
|
|
suppressStoredDeviceAuth: suppressStoredDeviceAuth,
|
|
token: self.normalizedCredential(token),
|
|
bootstrapToken: self.normalizedCredential(bootstrapToken),
|
|
password: self.normalizedCredential(password))
|
|
let account = self.gatewayCredentialBundleAccount(
|
|
instanceId: trimmedInstanceID,
|
|
stableID: stableID)
|
|
let hasCredentials = bundle.token != nil || bundle.bootstrapToken != nil || bundle.password != nil
|
|
guard hasCredentials || suppressStoredDeviceAuth else {
|
|
let deleted = KeychainStore.delete(service: self.gatewayService, account: account)
|
|
self.deleteLegacyScopedCredentialBundleIfOwned(
|
|
instanceId: trimmedInstanceID,
|
|
stableID: stableID)
|
|
self.deleteLegacyGatewayCredentials(instanceId: trimmedInstanceID)
|
|
return deleted || KeychainStore.loadString(service: self.gatewayService, account: account) == nil
|
|
}
|
|
guard let data = try? JSONEncoder().encode(bundle),
|
|
let json = String(data: data, encoding: .utf8)
|
|
else {
|
|
_ = KeychainStore.delete(service: self.gatewayService, account: account)
|
|
return false
|
|
}
|
|
guard KeychainStore.saveString(
|
|
json,
|
|
service: self.gatewayService,
|
|
account: account)
|
|
else {
|
|
// The Keychain helper restores the prior item when replacement fails. Keep that
|
|
// known-good bundle; callers already treat this attempted update as uncommitted.
|
|
return false
|
|
}
|
|
self.deleteLegacyScopedCredentialBundleIfOwned(
|
|
instanceId: trimmedInstanceID,
|
|
stableID: stableID)
|
|
self.deleteLegacyGatewayCredentials(instanceId: trimmedInstanceID)
|
|
return true
|
|
}
|
|
|
|
@discardableResult
|
|
static func updateGatewayCredentials(
|
|
token: String?,
|
|
password: String?,
|
|
gatewayStableID: String,
|
|
instanceId: String) -> Bool
|
|
{
|
|
let stableID = self.authenticationOwnerID(routeStableID: gatewayStableID)
|
|
let existing = self.loadGatewayCredentialBundle(
|
|
instanceId: instanceId,
|
|
gatewayStableID: stableID)
|
|
return self.saveGatewayCredentials(
|
|
token: token,
|
|
bootstrapToken: existing?.bootstrapToken,
|
|
password: password,
|
|
gatewayStableID: stableID,
|
|
suppressStoredDeviceAuth: existing?.suppressStoredDeviceAuth == true,
|
|
instanceId: instanceId)
|
|
}
|
|
|
|
@discardableResult
|
|
static func completeGatewayCredentialHandoff(instanceId: String, gatewayStableID: String) -> Bool {
|
|
let stableID = self.authenticationOwnerID(routeStableID: gatewayStableID)
|
|
guard let bundle = self.loadGatewayCredentialBundle(
|
|
instanceId: instanceId,
|
|
gatewayStableID: stableID),
|
|
bundle.suppressStoredDeviceAuth
|
|
else { return false }
|
|
// Device-token issuance and bootstrap consumption are one durable handoff. A relaunch
|
|
// must never observe a spent bootstrap token while stored device auth remains disabled.
|
|
return self.saveGatewayCredentials(
|
|
token: bundle.token,
|
|
bootstrapToken: nil,
|
|
password: bundle.password,
|
|
gatewayStableID: stableID,
|
|
suppressStoredDeviceAuth: false,
|
|
instanceId: instanceId)
|
|
}
|
|
|
|
static func discardUnscopedGatewayCredentials(instanceId: String) {
|
|
// The legacy UI saved fields before a successful connection, so the last route
|
|
// cannot prove who owns these secrets. Re-entry is safer than cross-gateway reuse.
|
|
self.deleteLegacyGatewayCredentials(instanceId: instanceId)
|
|
}
|
|
|
|
/// Certificate pins prove transport trust for one route; they are not gateway identities.
|
|
/// Wildcard certificates and reverse proxies may legitimately reuse a leaf certificate.
|
|
static func authenticationOwnerID(routeStableID: String) -> String {
|
|
GatewayStableIdentifier.exact(routeStableID) ?? ""
|
|
}
|
|
|
|
/// Custom proxy headers are per-gateway credentials (Cloudflare Access-style service
|
|
/// tokens). They live in the Keychain like the other gateway secrets and are read at
|
|
/// connect time; never log their values.
|
|
static func loadGatewayCustomHeaders(gatewayStableID: String) -> [String: String] {
|
|
self.loadGatewayCustomHeaders(gatewayStableID: gatewayStableID, service: self.gatewayCustomHeadersService)
|
|
}
|
|
|
|
static func loadGatewayCustomHeaders(
|
|
gatewayStableID: String,
|
|
service: String) -> [String: String]
|
|
{
|
|
let stableID = self.authenticationOwnerID(routeStableID: gatewayStableID)
|
|
guard !stableID.isEmpty else { return [:] }
|
|
let account = self.customHeadersAccount(stableID: stableID)
|
|
let legacyAccount = self.legacyCustomHeadersAccount(stableID: stableID)
|
|
let canonicalJSON = KeychainStore.loadString(service: service, account: account)
|
|
let legacyJSON = self.canSafelyReadLegacyRawStorageKey(stableID)
|
|
? KeychainStore.loadString(service: service, account: legacyAccount)
|
|
: nil
|
|
guard let json = canonicalJSON ?? legacyJSON,
|
|
let data = json.data(using: .utf8),
|
|
let headers = try? JSONDecoder().decode([String: String].self, from: data)
|
|
else { return [:] }
|
|
if canonicalJSON == nil,
|
|
KeychainStore.saveString(json, service: service, account: account)
|
|
{
|
|
_ = KeychainStore.delete(service: service, account: legacyAccount)
|
|
}
|
|
return GatewayCustomHeaders.sanitized(headers)
|
|
}
|
|
|
|
@discardableResult
|
|
static func saveGatewayCustomHeaders(_ headers: [String: String], gatewayStableID: String) -> Bool {
|
|
self.saveGatewayCustomHeaders(
|
|
headers,
|
|
gatewayStableID: gatewayStableID,
|
|
service: self.gatewayCustomHeadersService)
|
|
}
|
|
|
|
@discardableResult
|
|
static func saveGatewayCustomHeaders(
|
|
_ headers: [String: String],
|
|
gatewayStableID: String,
|
|
service: String) -> Bool
|
|
{
|
|
let stableID = self.authenticationOwnerID(routeStableID: gatewayStableID)
|
|
guard !stableID.isEmpty else { return false }
|
|
let sanitized = GatewayCustomHeaders.sanitized(headers)
|
|
guard !sanitized.isEmpty else {
|
|
return self.clearGatewayCustomHeaders(gatewayStableID: stableID, service: service)
|
|
}
|
|
let account = self.customHeadersAccount(stableID: stableID)
|
|
guard let data = try? JSONEncoder().encode(sanitized),
|
|
let json = String(data: data, encoding: .utf8)
|
|
else { return false }
|
|
guard KeychainStore.saveString(json, service: service, account: account) else { return false }
|
|
if self.canSafelyReadLegacyRawStorageKey(stableID) {
|
|
_ = KeychainStore.delete(
|
|
service: service,
|
|
account: self.legacyCustomHeadersAccount(stableID: stableID))
|
|
}
|
|
return true
|
|
}
|
|
|
|
/// Full onboarding reset is the explicit forget boundary for every gateway's proxy secrets.
|
|
@discardableResult
|
|
static func clearGatewayCustomHeaders() -> Bool {
|
|
self.clearGatewayCustomHeaders(service: self.gatewayCustomHeadersService)
|
|
}
|
|
|
|
@discardableResult
|
|
static func clearGatewayCustomHeaders(gatewayStableID: String) -> Bool {
|
|
self.clearGatewayCustomHeaders(
|
|
gatewayStableID: gatewayStableID,
|
|
service: self.gatewayCustomHeadersService)
|
|
}
|
|
|
|
@discardableResult
|
|
static func clearGatewayCustomHeaders(gatewayStableID: String, service: String) -> Bool {
|
|
let stableID = self.authenticationOwnerID(routeStableID: gatewayStableID)
|
|
guard !stableID.isEmpty else { return false }
|
|
let account = self.customHeadersAccount(stableID: stableID)
|
|
let canonicalDeleted = KeychainStore.delete(service: service, account: account)
|
|
var legacyCleared = true
|
|
if self.canSafelyReadLegacyRawStorageKey(stableID) {
|
|
let legacyAccount = self.legacyCustomHeadersAccount(stableID: stableID)
|
|
let legacyDeleted = KeychainStore.delete(service: service, account: legacyAccount)
|
|
legacyCleared = legacyDeleted || KeychainStore.loadString(service: service, account: legacyAccount) == nil
|
|
}
|
|
let canonicalCleared = canonicalDeleted || KeychainStore.loadString(service: service, account: account) == nil
|
|
return canonicalCleared && legacyCleared
|
|
}
|
|
|
|
@discardableResult
|
|
static func clearGatewayCustomHeaders(service: String) -> Bool {
|
|
KeychainStore.deleteAll(service: service)
|
|
}
|
|
|
|
private static func customHeadersAccount(stableID: String) -> String {
|
|
"customHeaders.v2.\(GatewayStableIdentifier.storageComponent(stableID)!)"
|
|
}
|
|
|
|
private static func legacyCustomHeadersAccount(stableID: String) -> String {
|
|
"customHeaders.\(stableID)"
|
|
}
|
|
|
|
@discardableResult
|
|
static func migrateProvenRelayCredentials(
|
|
instanceId: String,
|
|
gatewayStableID: String,
|
|
token: String?,
|
|
password: String?) -> Bool
|
|
{
|
|
let trimmedInstanceID = instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard let stableID = GatewayStableIdentifier.exact(gatewayStableID),
|
|
!trimmedInstanceID.isEmpty
|
|
else { return false }
|
|
let legacyAccounts = [
|
|
self.gatewayTokenAccount(instanceId: trimmedInstanceID),
|
|
self.gatewayBootstrapTokenAccount(instanceId: trimmedInstanceID),
|
|
self.gatewayPasswordAccount(instanceId: trimmedInstanceID),
|
|
]
|
|
let hasLegacyCredentials = legacyAccounts.contains { account in
|
|
self.normalizedCredential(KeychainStore.loadString(
|
|
service: self.gatewayService,
|
|
account: account)) != nil
|
|
}
|
|
guard hasLegacyCredentials else { return true }
|
|
|
|
// A canonical bundle already owns the fields atomically. Never replace it with
|
|
// older relay data merely because legacy per-field entries still exist.
|
|
if self.loadGatewayCredentialBundle(
|
|
instanceId: trimmedInstanceID,
|
|
gatewayStableID: stableID) != nil
|
|
{
|
|
self.deleteLegacyGatewayCredentials(instanceId: trimmedInstanceID)
|
|
return true
|
|
}
|
|
|
|
let relayToken = self.normalizedCredential(token)
|
|
let relayPassword = self.normalizedCredential(password)
|
|
guard relayToken != nil || relayPassword != nil else {
|
|
self.deleteLegacyGatewayCredentials(instanceId: trimmedInstanceID)
|
|
return true
|
|
}
|
|
// Relay config is written only after a successful connection and therefore proves
|
|
// both the credential values and their gateway owner. Preserve it before cleanup.
|
|
return self.saveGatewayCredentials(
|
|
token: relayToken,
|
|
bootstrapToken: nil,
|
|
password: relayPassword,
|
|
gatewayStableID: stableID,
|
|
suppressStoredDeviceAuth: false,
|
|
instanceId: trimmedInstanceID)
|
|
}
|
|
|
|
static func saveLegacyGatewayTokenForMigrationTest(_ token: String, instanceId: String) {
|
|
_ = KeychainStore.saveString(
|
|
token,
|
|
service: self.gatewayService,
|
|
account: self.gatewayTokenAccount(instanceId: instanceId))
|
|
}
|
|
|
|
private struct LegacyLastGatewayConnectionData: Codable {
|
|
var kind: GatewayRegistryEntry.Kind
|
|
var stableID: String
|
|
var useTLS: Bool
|
|
var host: String?
|
|
var port: Int?
|
|
}
|
|
|
|
static func loadTalkProviderApiKey(provider: String) -> String? {
|
|
guard let providerId = self.normalizedTalkProviderID(provider) else { return nil }
|
|
let account = self.talkProviderApiKeyAccount(providerId: providerId)
|
|
let value = KeychainStore.loadString(
|
|
service: self.talkService,
|
|
account: account)?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if value?.isEmpty == false { return value }
|
|
return nil
|
|
}
|
|
|
|
static func loadGatewayRegistry() -> GatewayRegistry {
|
|
guard let json = KeychainStore.loadString(
|
|
service: self.gatewayService,
|
|
account: self.gatewayRegistryAccount),
|
|
let data = json.data(using: .utf8),
|
|
let registry = try? JSONDecoder().decode(GatewayRegistry.self, from: data),
|
|
registry.version == 1
|
|
else { return .empty }
|
|
return self.normalizedGatewayRegistry(registry)
|
|
}
|
|
|
|
@discardableResult
|
|
static func upsertGatewayRegistryEntry(_ entry: GatewayRegistryEntry) -> Bool {
|
|
self.upsertGatewayRegistryEntry(entry, activate: false)
|
|
}
|
|
|
|
@discardableResult
|
|
static func upsertGatewayRegistryEntry(_ entry: GatewayRegistryEntry, activate: Bool) -> Bool {
|
|
guard let normalized = self.normalizedGatewayRegistryEntry(entry) else { return false }
|
|
var registry = self.loadGatewayRegistry()
|
|
if let index = registry.entries.firstIndex(where: {
|
|
GatewayStableIdentifier.matches($0.stableID, normalized.stableID)
|
|
}) {
|
|
var replacement = normalized
|
|
if replacement.lastConnectedAtMs == nil {
|
|
replacement.lastConnectedAtMs = registry.entries[index].lastConnectedAtMs
|
|
}
|
|
registry.entries[index] = replacement
|
|
} else {
|
|
registry.entries.append(normalized)
|
|
}
|
|
if activate {
|
|
registry.activeStableID = normalized.stableID
|
|
}
|
|
return self.saveGatewayRegistry(registry)
|
|
}
|
|
|
|
@discardableResult
|
|
static func setActiveGateway(stableID: String) -> Bool {
|
|
guard let stableID = GatewayStableIdentifier.exact(stableID) else { return false }
|
|
var registry = self.loadGatewayRegistry()
|
|
guard let storedID = registry.entries.first(where: {
|
|
GatewayStableIdentifier.matches($0.stableID, stableID)
|
|
})?.stableID else { return false }
|
|
registry.activeStableID = storedID
|
|
return self.saveGatewayRegistry(registry)
|
|
}
|
|
|
|
@discardableResult
|
|
static func markGatewayConnected(stableID: String, atMs: Int) -> Bool {
|
|
guard let stableID = GatewayStableIdentifier.exact(stableID) else { return false }
|
|
var registry = self.loadGatewayRegistry()
|
|
guard let index = registry.entries.firstIndex(where: {
|
|
GatewayStableIdentifier.matches($0.stableID, stableID)
|
|
}) else { return false }
|
|
registry.entries[index].lastConnectedAtMs = atMs
|
|
return self.saveGatewayRegistry(registry)
|
|
}
|
|
|
|
@discardableResult
|
|
static func removeGatewayRegistryEntry(stableID: String) -> Bool {
|
|
guard let stableID = GatewayStableIdentifier.exact(stableID) else { return false }
|
|
var registry = self.loadGatewayRegistry()
|
|
registry.entries.removeAll { GatewayStableIdentifier.matches($0.stableID, stableID) }
|
|
if GatewayStableIdentifier.matches(registry.activeStableID, stableID) {
|
|
registry.activeStableID = nil
|
|
}
|
|
return self.saveGatewayRegistry(registry)
|
|
}
|
|
|
|
static func activeGatewayEntry() -> GatewayRegistryEntry? {
|
|
let registry = self.loadGatewayRegistry()
|
|
guard let activeStableID = registry.activeStableID else { return nil }
|
|
return registry.entries.first {
|
|
GatewayStableIdentifier.matches($0.stableID, activeStableID)
|
|
}
|
|
}
|
|
|
|
static func clearLegacyGatewaySelectors(stableID: String) {
|
|
guard let stableID = GatewayStableIdentifier.exact(stableID) else { return }
|
|
let defaults = UserDefaults.standard
|
|
for (defaultsKey, account) in [
|
|
(self.preferredGatewayStableIDDefaultsKey, self.preferredGatewayStableIDAccount),
|
|
(self.lastDiscoveredGatewayStableIDDefaultsKey, self.lastDiscoveredGatewayStableIDAccount),
|
|
] {
|
|
let defaultsValue = defaults.string(forKey: defaultsKey)
|
|
if GatewayStableIdentifier.matches(defaultsValue, stableID) {
|
|
defaults.removeObject(forKey: defaultsKey)
|
|
}
|
|
let keychainValue = KeychainStore.loadString(service: self.gatewayService, account: account)
|
|
if GatewayStableIdentifier.matches(keychainValue, stableID) {
|
|
_ = KeychainStore.delete(service: self.gatewayService, account: account)
|
|
}
|
|
}
|
|
}
|
|
|
|
static func clearGatewayRegistry(defaults: UserDefaults = .standard) {
|
|
_ = KeychainStore.delete(service: self.gatewayService, account: self.gatewayRegistryAccount)
|
|
_ = KeychainStore.delete(service: self.gatewayService, account: self.lastGatewayConnectionAccount)
|
|
self.removeLastGatewayDefaults(defaults)
|
|
}
|
|
|
|
private static func saveGatewayRegistry(_ registry: GatewayRegistry) -> Bool {
|
|
let normalized = self.normalizedGatewayRegistry(registry)
|
|
let encoder = JSONEncoder()
|
|
encoder.outputFormatting = [.sortedKeys]
|
|
guard let data = try? encoder.encode(normalized),
|
|
let json = String(data: data, encoding: .utf8)
|
|
else { return false }
|
|
return KeychainStore.saveString(
|
|
json,
|
|
service: self.gatewayService,
|
|
account: self.gatewayRegistryAccount)
|
|
}
|
|
|
|
private static func normalizedGatewayRegistry(_ registry: GatewayRegistry) -> GatewayRegistry {
|
|
var seen = Set<GatewayStableIdentifier.Key>()
|
|
let entries = registry.entries
|
|
.compactMap(self.normalizedGatewayRegistryEntry)
|
|
.filter { entry in
|
|
guard let key = GatewayStableIdentifier.key(entry.stableID) else { return false }
|
|
return seen.insert(key).inserted
|
|
}
|
|
.sorted { lhs, rhs in
|
|
if lhs.name != rhs.name { return lhs.name < rhs.name }
|
|
return GatewayStableIdentifier.sortsBefore(lhs.stableID, rhs.stableID)
|
|
}
|
|
let activeStableID = registry.activeStableID.flatMap { activeID in
|
|
entries.first(where: {
|
|
GatewayStableIdentifier.matches($0.stableID, activeID)
|
|
})?.stableID
|
|
}
|
|
return GatewayRegistry(version: 1, activeStableID: activeStableID, entries: entries)
|
|
}
|
|
|
|
private static func normalizedGatewayRegistryEntry(
|
|
_ entry: GatewayRegistryEntry) -> GatewayRegistryEntry?
|
|
{
|
|
guard let stableID = GatewayStableIdentifier.exact(entry.stableID) else { return nil }
|
|
let name = entry.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if entry.kind == .manual {
|
|
let host = entry.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
guard !host.isEmpty, let port = entry.port, (1...65535).contains(port) else { return nil }
|
|
return GatewayRegistryEntry(
|
|
stableID: stableID,
|
|
kind: .manual,
|
|
name: name.isEmpty ? "\(host):\(port)" : name,
|
|
host: host,
|
|
port: port,
|
|
useTLS: entry.useTLS,
|
|
lastConnectedAtMs: entry.lastConnectedAtMs)
|
|
}
|
|
return GatewayRegistryEntry(
|
|
stableID: stableID,
|
|
kind: .discovered,
|
|
name: name.isEmpty ? stableID : name,
|
|
host: nil,
|
|
port: nil,
|
|
useTLS: entry.useTLS,
|
|
lastConnectedAtMs: entry.lastConnectedAtMs)
|
|
}
|
|
|
|
private static func migrateGatewayRegistryIfNeeded(defaults: UserDefaults = .standard) {
|
|
if KeychainStore.loadString(service: self.gatewayService, account: self.gatewayRegistryAccount) != nil {
|
|
_ = KeychainStore.delete(service: self.gatewayService, account: self.lastGatewayConnectionAccount)
|
|
self.removeLastGatewayDefaults(defaults)
|
|
return
|
|
}
|
|
|
|
let legacy = self.loadLegacyLastGatewayConnection(defaults: defaults)
|
|
guard let entry = legacy.flatMap(self.gatewayRegistryEntry(from:)) else { return }
|
|
let registry = GatewayRegistry(activeStableID: entry.stableID, entries: [entry])
|
|
guard self.saveGatewayRegistry(registry) else { return }
|
|
_ = KeychainStore.delete(service: self.gatewayService, account: self.lastGatewayConnectionAccount)
|
|
self.removeLastGatewayDefaults(defaults)
|
|
}
|
|
|
|
private static func loadLegacyLastGatewayConnection(
|
|
defaults: UserDefaults) -> LegacyLastGatewayConnectionData?
|
|
{
|
|
if let json = KeychainStore.loadString(
|
|
service: self.gatewayService,
|
|
account: self.lastGatewayConnectionAccount),
|
|
let data = json.data(using: .utf8),
|
|
let stored = try? JSONDecoder().decode(LegacyLastGatewayConnectionData.self, from: data)
|
|
{
|
|
return stored
|
|
}
|
|
guard let stableID = GatewayStableIdentifier.exact(
|
|
defaults.string(forKey: self.lastGatewayStableIDDefaultsKey))
|
|
else { return nil }
|
|
let kindRaw = defaults.string(forKey: self.lastGatewayKindDefaultsKey)?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
let kind = GatewayRegistryEntry.Kind(rawValue: kindRaw) ?? .manual
|
|
return LegacyLastGatewayConnectionData(
|
|
kind: kind,
|
|
stableID: stableID,
|
|
useTLS: defaults.bool(forKey: self.lastGatewayTlsDefaultsKey),
|
|
host: kind == .manual ? defaults.string(forKey: self.lastGatewayHostDefaultsKey) : nil,
|
|
port: kind == .manual ? defaults.object(forKey: self.lastGatewayPortDefaultsKey) as? Int : nil)
|
|
}
|
|
|
|
private static func gatewayRegistryEntry(
|
|
from legacy: LegacyLastGatewayConnectionData) -> GatewayRegistryEntry?
|
|
{
|
|
self.normalizedGatewayRegistryEntry(GatewayRegistryEntry(
|
|
stableID: legacy.stableID,
|
|
kind: legacy.kind,
|
|
name: legacy.kind == .manual
|
|
? "\(legacy.host ?? ""):\(legacy.port ?? 0)"
|
|
: legacy.stableID,
|
|
host: legacy.host,
|
|
port: legacy.port,
|
|
useTLS: legacy.useTLS,
|
|
lastConnectedAtMs: nil))
|
|
}
|
|
|
|
private static func removeLastGatewayDefaults(_ defaults: UserDefaults) {
|
|
defaults.removeObject(forKey: self.lastGatewayKindDefaultsKey)
|
|
defaults.removeObject(forKey: self.lastGatewayHostDefaultsKey)
|
|
defaults.removeObject(forKey: self.lastGatewayPortDefaultsKey)
|
|
defaults.removeObject(forKey: self.lastGatewayTlsDefaultsKey)
|
|
defaults.removeObject(forKey: self.lastGatewayStableIDDefaultsKey)
|
|
}
|
|
|
|
static func deleteGatewayCredentials(instanceId: String, stableID: String) {
|
|
let trimmed = instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard let stableID = GatewayStableIdentifier.exact(stableID), !trimmed.isEmpty else { return }
|
|
_ = KeychainStore.delete(
|
|
service: self.gatewayService,
|
|
account: self.gatewayCredentialBundleAccount(instanceId: trimmed, stableID: stableID))
|
|
self.deleteLegacyScopedCredentialBundleIfOwned(instanceId: trimmed, stableID: stableID)
|
|
}
|
|
|
|
static func deleteAllGatewayCredentials(instanceId: String) {
|
|
let trimmed = instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return }
|
|
_ = KeychainStore.deleteAccounts(
|
|
service: self.gatewayService,
|
|
accountPrefix: self.legacyGatewayCredentialBundleAccount(instanceId: trimmed) + ".")
|
|
_ = KeychainStore.delete(
|
|
service: self.gatewayService,
|
|
account: self.legacyGatewayCredentialBundleAccount(instanceId: trimmed))
|
|
self.deleteLegacyGatewayCredentials(instanceId: trimmed)
|
|
}
|
|
|
|
static func loadGatewayClientIdOverride(stableID: String) -> String? {
|
|
guard let stableID = GatewayStableIdentifier.exact(stableID) else { return nil }
|
|
let defaults = UserDefaults.standard
|
|
let key = self.gatewayDefaultsKey(prefix: self.clientIdOverrideDefaultsPrefix, stableID: stableID)
|
|
let legacyKey = self.clientIdOverrideDefaultsPrefix + stableID
|
|
let value = (defaults.string(forKey: key) ??
|
|
(self.canSafelyReadLegacyRawStorageKey(stableID) ? defaults.string(forKey: legacyKey) : nil))?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if value?.isEmpty == false {
|
|
if defaults.string(forKey: key) == nil {
|
|
defaults.set(value, forKey: key)
|
|
defaults.removeObject(forKey: legacyKey)
|
|
}
|
|
return value
|
|
}
|
|
return nil
|
|
}
|
|
|
|
static func saveGatewayClientIdOverride(stableID: String, clientId: String?) {
|
|
guard let stableID = GatewayStableIdentifier.exact(stableID) else { return }
|
|
let key = self.gatewayDefaultsKey(prefix: self.clientIdOverrideDefaultsPrefix, stableID: stableID)
|
|
let trimmedClientId = clientId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
if trimmedClientId.isEmpty {
|
|
UserDefaults.standard.removeObject(forKey: key)
|
|
} else {
|
|
UserDefaults.standard.set(trimmedClientId, forKey: key)
|
|
}
|
|
if self.canSafelyReadLegacyRawStorageKey(stableID) {
|
|
UserDefaults.standard.removeObject(forKey: self.clientIdOverrideDefaultsPrefix + stableID)
|
|
}
|
|
}
|
|
|
|
static func loadGatewaySelectedAgentId(stableID: String) -> String? {
|
|
guard let stableID = GatewayStableIdentifier.exact(stableID) else { return nil }
|
|
let defaults = UserDefaults.standard
|
|
let key = self.gatewayDefaultsKey(prefix: self.selectedAgentDefaultsPrefix, stableID: stableID)
|
|
let legacyKey = self.selectedAgentDefaultsPrefix + stableID
|
|
let value = (defaults.string(forKey: key) ??
|
|
(self.canSafelyReadLegacyRawStorageKey(stableID) ? defaults.string(forKey: legacyKey) : nil))?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if value?.isEmpty == false {
|
|
if defaults.string(forKey: key) == nil {
|
|
defaults.set(value, forKey: key)
|
|
defaults.removeObject(forKey: legacyKey)
|
|
}
|
|
return value
|
|
}
|
|
return nil
|
|
}
|
|
|
|
static func saveGatewaySelectedAgentId(stableID: String, agentId: String?) {
|
|
guard let stableID = GatewayStableIdentifier.exact(stableID) else { return }
|
|
let key = self.gatewayDefaultsKey(prefix: self.selectedAgentDefaultsPrefix, stableID: stableID)
|
|
let trimmedAgentId = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
if trimmedAgentId.isEmpty {
|
|
UserDefaults.standard.removeObject(forKey: key)
|
|
} else {
|
|
UserDefaults.standard.set(trimmedAgentId, forKey: key)
|
|
}
|
|
if self.canSafelyReadLegacyRawStorageKey(stableID) {
|
|
UserDefaults.standard.removeObject(forKey: self.selectedAgentDefaultsPrefix + stableID)
|
|
}
|
|
}
|
|
|
|
private static func gatewayDefaultsKey(prefix: String, stableID: String) -> String {
|
|
"\(prefix)v2.\(GatewayStableIdentifier.storageComponent(stableID)!)"
|
|
}
|
|
|
|
private static func gatewayTokenAccount(instanceId: String) -> String {
|
|
"gateway-token.\(instanceId)"
|
|
}
|
|
|
|
private static func gatewayBootstrapTokenAccount(instanceId: String) -> String {
|
|
"gateway-bootstrap-token.\(instanceId)"
|
|
}
|
|
|
|
private static func gatewayPasswordAccount(instanceId: String) -> String {
|
|
"gateway-password.\(instanceId)"
|
|
}
|
|
|
|
private static func legacyGatewayCredentialBundleAccount(instanceId: String) -> String {
|
|
"gateway-credentials.\(instanceId)"
|
|
}
|
|
|
|
private static func gatewayCredentialBundleAccount(instanceId: String, stableID: String) -> String {
|
|
"gateway-credentials.\(instanceId).v2.\(GatewayStableIdentifier.storageComponent(stableID)!)"
|
|
}
|
|
|
|
private static func legacyScopedGatewayCredentialBundleAccount(
|
|
instanceId: String,
|
|
stableID: String) -> String
|
|
{
|
|
"gateway-credentials.\(instanceId).\(stableID)"
|
|
}
|
|
|
|
private static func loadGatewayCredentialBundle(
|
|
instanceId: String,
|
|
gatewayStableID: String) -> GatewayCredentialBundle?
|
|
{
|
|
let trimmedInstanceID = instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard let stableID = GatewayStableIdentifier.exact(gatewayStableID),
|
|
!trimmedInstanceID.isEmpty
|
|
else { return nil }
|
|
let account = self.gatewayCredentialBundleAccount(
|
|
instanceId: trimmedInstanceID,
|
|
stableID: stableID)
|
|
let legacyAccount = self.legacyScopedGatewayCredentialBundleAccount(
|
|
instanceId: trimmedInstanceID,
|
|
stableID: stableID)
|
|
let canonicalJSON = KeychainStore.loadString(service: self.gatewayService, account: account)
|
|
guard let json = canonicalJSON ?? KeychainStore.loadString(
|
|
service: self.gatewayService,
|
|
account: legacyAccount),
|
|
let data = json.data(using: .utf8),
|
|
let decoded = try? JSONDecoder().decode(GatewayCredentialBundle.self, from: data)
|
|
else { return nil }
|
|
guard let decodedStableID = GatewayStableIdentifier.exact(decoded.gatewayStableID),
|
|
GatewayStableIdentifier.matches(decodedStableID, stableID)
|
|
else { return nil }
|
|
let bundle = GatewayCredentialBundle(
|
|
gatewayStableID: decodedStableID,
|
|
suppressStoredDeviceAuth: decoded.suppressStoredDeviceAuth,
|
|
token: self.normalizedCredential(decoded.token),
|
|
bootstrapToken: self.normalizedCredential(decoded.bootstrapToken),
|
|
password: self.normalizedCredential(decoded.password))
|
|
if canonicalJSON == nil,
|
|
let migratedData = try? JSONEncoder().encode(bundle),
|
|
let migratedJSON = String(data: migratedData, encoding: .utf8),
|
|
KeychainStore.saveString(migratedJSON, service: self.gatewayService, account: account)
|
|
{
|
|
_ = KeychainStore.delete(service: self.gatewayService, account: legacyAccount)
|
|
}
|
|
return bundle
|
|
}
|
|
|
|
private static func migrateGatewayCredentialBundleIfNeeded(instanceId: String) {
|
|
let instanceID = instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !instanceID.isEmpty else { return }
|
|
let legacyAccount = self.legacyGatewayCredentialBundleAccount(instanceId: instanceID)
|
|
guard let json = KeychainStore.loadString(service: self.gatewayService, account: legacyAccount),
|
|
let data = json.data(using: .utf8),
|
|
let legacy = try? JSONDecoder().decode(GatewayCredentialBundle.self, from: data)
|
|
else { return }
|
|
guard let stableID = GatewayStableIdentifier.exact(legacy.gatewayStableID) else { return }
|
|
let scopedAccount = self.gatewayCredentialBundleAccount(instanceId: instanceID, stableID: stableID)
|
|
let scopedExists = KeychainStore.loadString(service: self.gatewayService, account: scopedAccount) != nil
|
|
guard scopedExists || KeychainStore.saveString(
|
|
json,
|
|
service: self.gatewayService,
|
|
account: scopedAccount)
|
|
else { return }
|
|
_ = KeychainStore.delete(service: self.gatewayService, account: legacyAccount)
|
|
self.deleteLegacyGatewayCredentials(instanceId: instanceID)
|
|
}
|
|
|
|
private static func deleteLegacyScopedCredentialBundleIfOwned(
|
|
instanceId: String,
|
|
stableID: String)
|
|
{
|
|
let account = self.legacyScopedGatewayCredentialBundleAccount(
|
|
instanceId: instanceId,
|
|
stableID: stableID)
|
|
guard let json = KeychainStore.loadString(service: self.gatewayService, account: account),
|
|
let data = json.data(using: .utf8),
|
|
let bundle = try? JSONDecoder().decode(GatewayCredentialBundle.self, from: data),
|
|
GatewayStableIdentifier.matches(bundle.gatewayStableID, stableID)
|
|
else { return }
|
|
_ = KeychainStore.delete(service: self.gatewayService, account: account)
|
|
}
|
|
|
|
private static func canSafelyReadLegacyRawStorageKey(_ stableID: String) -> Bool {
|
|
// Legacy header/default records do not embed their owner. Only ASCII keys outside
|
|
// the v2 namespace can be attributed without aliasing another owner's encoded key.
|
|
!stableID.hasPrefix("v2.") && stableID.unicodeScalars.allSatisfy(\.isASCII)
|
|
}
|
|
|
|
private static func normalizedCredential(_ value: String?) -> String? {
|
|
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
|
|
private static func deleteLegacyGatewayCredentials(instanceId: String) {
|
|
_ = KeychainStore.delete(
|
|
service: self.gatewayService,
|
|
account: self.gatewayTokenAccount(instanceId: instanceId))
|
|
_ = KeychainStore.delete(
|
|
service: self.gatewayService,
|
|
account: self.gatewayBootstrapTokenAccount(instanceId: instanceId))
|
|
_ = KeychainStore.delete(
|
|
service: self.gatewayService,
|
|
account: self.gatewayPasswordAccount(instanceId: instanceId))
|
|
_ = KeychainStore.delete(
|
|
service: self.gatewayService,
|
|
account: "gateway-credential-metadata.\(instanceId)")
|
|
}
|
|
|
|
private static func talkProviderApiKeyAccount(providerId: String) -> String {
|
|
self.talkProviderApiKeyAccountPrefix + providerId
|
|
}
|
|
|
|
private static func normalizedTalkProviderID(_ provider: String) -> String? {
|
|
let trimmed = provider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
|
|
private static func ensureStableInstanceID() {
|
|
let defaults = UserDefaults.standard
|
|
|
|
if let existing = defaults.string(forKey: self.instanceIdDefaultsKey)?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!existing.isEmpty
|
|
{
|
|
if self.loadStableInstanceID() == nil {
|
|
self.saveStableInstanceID(existing)
|
|
}
|
|
return
|
|
}
|
|
|
|
if let stored = self.loadStableInstanceID(), !stored.isEmpty {
|
|
defaults.set(stored, forKey: self.instanceIdDefaultsKey)
|
|
return
|
|
}
|
|
|
|
let fresh = UUID().uuidString
|
|
self.saveStableInstanceID(fresh)
|
|
defaults.set(fresh, forKey: self.instanceIdDefaultsKey)
|
|
}
|
|
|
|
private static func ensurePreferredGatewayStableID() {
|
|
let defaults = UserDefaults.standard
|
|
|
|
if let existing = GatewayStableIdentifier.exact(
|
|
defaults.string(forKey: self.preferredGatewayStableIDDefaultsKey))
|
|
{
|
|
if self.loadPreferredGatewayStableID() == nil {
|
|
self.savePreferredGatewayStableID(existing)
|
|
}
|
|
return
|
|
}
|
|
|
|
if let stored = self.loadPreferredGatewayStableID(), !stored.isEmpty {
|
|
defaults.set(stored, forKey: self.preferredGatewayStableIDDefaultsKey)
|
|
}
|
|
}
|
|
|
|
private static func ensureLastDiscoveredGatewayStableID() {
|
|
let defaults = UserDefaults.standard
|
|
|
|
if let existing = GatewayStableIdentifier.exact(
|
|
defaults.string(forKey: self.lastDiscoveredGatewayStableIDDefaultsKey))
|
|
{
|
|
if self.loadLastDiscoveredGatewayStableID() == nil {
|
|
self.saveLastDiscoveredGatewayStableID(existing)
|
|
}
|
|
return
|
|
}
|
|
|
|
if let stored = self.loadLastDiscoveredGatewayStableID(), !stored.isEmpty {
|
|
defaults.set(stored, forKey: self.lastDiscoveredGatewayStableIDDefaultsKey)
|
|
}
|
|
}
|
|
}
|
|
|
|
enum GatewayDiagnostics {
|
|
struct ScopedLogger {
|
|
private let prefix: String
|
|
|
|
fileprivate init(prefix: String) {
|
|
self.prefix = prefix
|
|
}
|
|
|
|
func stage(_ message: String) {
|
|
GatewayDiagnostics.log("\(self.prefix): \(GatewayDiagnostics.sanitizeScopedMessage(message))")
|
|
}
|
|
|
|
func skipped(_ reason: String) {
|
|
self.stage("registration skipped reason=\(reason)")
|
|
}
|
|
|
|
func failed(_ stage: String, error: Error) {
|
|
let nsError = error as NSError
|
|
let errorType = String(reflecting: type(of: error))
|
|
self
|
|
.stage(
|
|
"\(stage) failed errorType=\(errorType) domain=\(nsError.domain) code=\(nsError.code)")
|
|
}
|
|
}
|
|
|
|
private static let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "GatewayDiag")
|
|
private static let queue = DispatchQueue(label: "ai.openclawfoundation.app.gateway.diagnostics")
|
|
private static let maxLogBytes: Int64 = 512 * 1024
|
|
private static let keepLogBytes: Int64 = 256 * 1024
|
|
private static let logSizeCheckEveryWrites = 50
|
|
private static let logWritesSinceCheck = OSAllocatedUnfairLock(initialState: 0)
|
|
private static let maxScopedMessageCharacters = 320
|
|
|
|
/// Keep relay diagnostics stage-based. Push tokens, relay grants, proofs,
|
|
/// receipts, signed payloads, and handles must never enter this cache log.
|
|
static let pushRelay = ScopedLogger(prefix: "push relay")
|
|
|
|
private static func sanitizeScopedMessage(_ value: String) -> String {
|
|
let collapsed = value
|
|
.replacingOccurrences(of: "\r", with: " ")
|
|
.replacingOccurrences(of: "\n", with: " ")
|
|
.replacingOccurrences(of: "\t", with: " ")
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard collapsed.count > self.maxScopedMessageCharacters else {
|
|
return collapsed
|
|
}
|
|
let end = collapsed.index(collapsed.startIndex, offsetBy: self.maxScopedMessageCharacters)
|
|
return String(collapsed[..<end]) + "..."
|
|
}
|
|
|
|
private static func isoTimestamp() -> String {
|
|
let formatter = ISO8601DateFormatter()
|
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
return formatter.string(from: Date())
|
|
}
|
|
|
|
private static var fileURL: URL? {
|
|
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
|
|
.appendingPathComponent("openclaw-gateway.log")
|
|
}
|
|
|
|
private static func truncateLogIfNeeded(url: URL) {
|
|
guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),
|
|
let sizeNumber = attrs[.size] as? NSNumber
|
|
else { return }
|
|
let size = sizeNumber.int64Value
|
|
guard size > self.maxLogBytes else { return }
|
|
|
|
do {
|
|
let handle = try FileHandle(forReadingFrom: url)
|
|
defer { try? handle.close() }
|
|
|
|
let start = max(Int64(0), size - self.keepLogBytes)
|
|
try handle.seek(toOffset: UInt64(start))
|
|
var tail = try handle.readToEnd() ?? Data()
|
|
|
|
// If we truncated mid-line, drop the first partial line so logs remain readable.
|
|
if start > 0, let nl = tail.firstIndex(of: 10) {
|
|
let next = tail.index(after: nl)
|
|
if next < tail.endIndex {
|
|
tail = tail.suffix(from: next)
|
|
} else {
|
|
tail = Data()
|
|
}
|
|
}
|
|
|
|
try tail.write(to: url, options: .atomic)
|
|
} catch {
|
|
// Best-effort only.
|
|
}
|
|
}
|
|
|
|
private static func appendToLog(url: URL, data: Data) {
|
|
if FileManager.default.fileExists(atPath: url.path) {
|
|
if let handle = try? FileHandle(forWritingTo: url) {
|
|
defer { try? handle.close() }
|
|
_ = try? handle.seekToEnd()
|
|
try? handle.write(contentsOf: data)
|
|
}
|
|
} else {
|
|
try? data.write(to: url, options: .atomic)
|
|
}
|
|
}
|
|
|
|
private static func applyFileProtection(url: URL) {
|
|
try? FileManager.default.setAttributes(
|
|
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
|
|
ofItemAtPath: url.path)
|
|
}
|
|
|
|
static func bootstrap() {
|
|
guard let url = fileURL else { return }
|
|
self.queue.async {
|
|
self.truncateLogIfNeeded(url: url)
|
|
let timestamp = self.isoTimestamp()
|
|
let line = "[\(timestamp)] gateway diagnostics started\n"
|
|
if let data = line.data(using: .utf8) {
|
|
self.appendToLog(url: url, data: data)
|
|
self.applyFileProtection(url: url)
|
|
}
|
|
}
|
|
}
|
|
|
|
static func log(_ message: String) {
|
|
let timestamp = self.isoTimestamp()
|
|
let line = "[\(timestamp)] \(message)"
|
|
self.logger.info("\(line, privacy: .public)")
|
|
|
|
guard let url = fileURL else { return }
|
|
self.queue.async {
|
|
let shouldTruncate = self.logWritesSinceCheck.withLock { count in
|
|
count += 1
|
|
if count >= self.logSizeCheckEveryWrites {
|
|
count = 0
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
if shouldTruncate {
|
|
self.truncateLogIfNeeded(url: url)
|
|
}
|
|
let entry = line + "\n"
|
|
if let data = entry.data(using: .utf8) {
|
|
self.appendToLog(url: url, data: data)
|
|
}
|
|
}
|
|
}
|
|
}
|