Skip to content

Commit d537139

Browse files
authored
feat(macos): adopt the shared read-only chat transcript cache (#100275)
* feat(macos): adopt shared read-only chat transcript cache Cache-first cold open for the macOS chat window/panel: the last known transcript and session list paint immediately from the shared SQLite transcript cache, then live gateway history replaces them wholesale. Recent chats stay browsable read-only while the gateway is unreachable; sending remains gated by connection state. - Wires OpenClawChatSQLiteTranscriptCache into WebChatSwiftUIWindowController; DB at ~/Library/Application Support/OpenClaw/chat-cache.sqlite. - Gateway identity (MacChatTranscriptCache.gatewayID), derivable offline: local keys on the canonical gateway state dir; remote/direct keys on the full canonical URL (scheme, host, resolved port, percent-encoded path/query); remote/ssh keys on the SSH target plus the resolved remote gateway port, mirroring the tunnel port resolution. Unconfigured mode gets no cache. - macOS file protection: no per-file Data Protection classes; iOS-only attribute stays gated behind #if os(iOS) in the shared store, and the per-user container plus FileVault protect at rest. - Onboarding chat stays uncached (transient guided setup session). Part of #100194 * feat(macos): wire the offline command outbox into chat windows * style(macos): fix orphaned doc comment; regenerate docs map * style(macos): doc-comment lint fix; regenerate docs map on current main
1 parent 55a0012 commit d537139

5 files changed

Lines changed: 291 additions & 3 deletions

File tree

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import Foundation
2+
import OpenClawChatUI
3+
4+
/// Builds the read-only offline chat transcript cache for the macOS app.
5+
///
6+
/// The database lives in the per-user Application Support container
7+
/// (`~/Library/Application Support/OpenClaw/chat-cache.sqlite`), matching the
8+
/// existing OpenClaw app-support layout. macOS has no per-file Data Protection
9+
/// classes (the iOS store applies `completeUntilFirstUserAuthentication`);
10+
/// at-rest protection here is the per-user container permissions plus FileVault.
11+
enum MacChatTranscriptCache {
12+
/// Stable identity of the gateway this app talks to, derivable offline
13+
/// (the cache pre-paints before any connection is up). Keys must not
14+
/// collide across gateways:
15+
/// - local mode keys on the gateway state dir, the store that owns local
16+
/// session data: distinct profiles (distinct `OPENCLAW_STATE_DIR`) never
17+
/// share cached transcripts even when they reuse the same port;
18+
/// - remote/direct keys on the full canonical remote URL (scheme, host,
19+
/// resolved port, path, query), since one origin can route to several
20+
/// gateways by path;
21+
/// - remote/ssh keys on the SSH target plus the resolved remote gateway
22+
/// port (one SSH target can front several gateways on different ports).
23+
/// The tunneled 127.0.0.1 URL is per-launch and deliberately never used
24+
/// as identity.
25+
static func gatewayID(
26+
mode: AppState.ConnectionMode,
27+
localStateDir: URL,
28+
remoteTransport: AppState.RemoteTransport,
29+
directURL: URL?,
30+
sshTarget: String,
31+
sshRemotePort: Int) -> String?
32+
{
33+
switch mode {
34+
case .unconfigured:
35+
return nil
36+
case .local:
37+
// Canonicalize so /var vs /private/var style aliases of the same
38+
// state dir never split one gateway's cache into two scopes.
39+
return "local:\(localStateDir.resolvingSymlinksInPath().standardizedFileURL.path)"
40+
case .remote:
41+
switch remoteTransport {
42+
case .direct:
43+
guard let directURL,
44+
let scheme = directURL.scheme?.lowercased(),
45+
let host = directURL.host?.lowercased(),
46+
!host.isEmpty
47+
else {
48+
return nil
49+
}
50+
guard let port = GatewayRemoteConfig.defaultPort(for: directURL) else { return nil }
51+
// Keep path/query: one origin can front several gateways via
52+
// reverse-proxy routing, and the app connects to the full URL.
53+
// Percent-encoded forms, not URL.path: decoding would collapse
54+
// distinct request paths like /team%2Fa and /team/a.
55+
// Auth is intentionally not part of the identity: gateway
56+
// transcripts are not partitioned per token/password principal,
57+
// and keying on credentials would drop the cache on rotation.
58+
guard let components = URLComponents(url: directURL, resolvingAgainstBaseURL: false) else {
59+
return nil
60+
}
61+
let query = components.percentEncodedQuery.map { "?\($0)" } ?? ""
62+
return "remote:\(scheme)://\(host):\(port)\(components.percentEncodedPath)\(query)"
63+
case .ssh:
64+
let target = sshTarget.trimmingCharacters(in: .whitespacesAndNewlines)
65+
guard !target.isEmpty else { return nil }
66+
return "ssh:\(target):\(sshRemotePort)"
67+
}
68+
}
69+
}
70+
71+
/// Offline transcript cache scoped to the currently configured gateway.
72+
/// Nil when the app is unconfigured or the remote target cannot be
73+
/// resolved, so foreign rows can never leak into another gateway's scope.
74+
/// Concrete return type: the same SQLite store also backs the offline
75+
/// command outbox, and callers wire both protocol facets from one instance.
76+
static func make() -> OpenClawChatSQLiteTranscriptCache? {
77+
let root = OpenClawConfigFile.loadDict()
78+
let mode = ConnectionModeResolver.resolve(root: root).mode
79+
let resolution = GatewayRemoteConfig.resolveTransportResolution(root: root)
80+
let sshTarget = CommandResolver.connectionSettings(configRoot: root).target
81+
// Mirror the tunnel's remote-port resolution (RemotePortTunnel.create)
82+
// so the identity matches the gateway the forward actually reaches.
83+
let defaultRemotePort = GatewayEnvironment.gatewayPort()
84+
let sshHost = CommandResolver.parseSSHTarget(sshTarget)?.host ?? ""
85+
let sshRemotePort = RemotePortTunnel.resolveRemotePortOverride(
86+
defaultRemotePort: defaultRemotePort,
87+
for: sshHost) ?? defaultRemotePort
88+
let gatewayID = self.gatewayID(
89+
mode: mode,
90+
localStateDir: OpenClawConfigFile.stateDirURL(),
91+
remoteTransport: resolution.transport,
92+
directURL: resolution.directURL,
93+
sshTarget: sshTarget,
94+
sshRemotePort: sshRemotePort)
95+
guard let gatewayID else { return nil }
96+
guard let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
97+
else {
98+
return nil
99+
}
100+
let databaseURL = base.appendingPathComponent("OpenClaw/chat-cache.sqlite", isDirectory: false)
101+
return OpenClawChatSQLiteTranscriptCache(databaseURL: databaseURL, gatewayID: gatewayID)
102+
}
103+
}

apps/macos/Sources/OpenClaw/RemotePortTunnel.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,10 @@ final class RemotePortTunnel: @unchecked Sendable {
189189
throw NSError(domain: "RemotePortTunnel", code: 4, userInfo: [NSLocalizedDescriptionKey: msg])
190190
}
191191

192-
private static func resolveRemotePortOverride(defaultRemotePort: Int, for sshHost: String) -> Int? {
192+
/// Shared with MacChatTranscriptCache: the offline cache identity must key
193+
/// on the same remote gateway port this tunnel actually forwards to, or two
194+
/// gateways behind one SSH target would share cached transcripts.
195+
static func resolveRemotePortOverride(defaultRemotePort: Int, for sshHost: String) -> Int? {
193196
let root = OpenClawConfigFile.loadDict()
194197
if let port = GatewayRemoteConfig.resolveRemotePort(root: root) {
195198
return port

apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -250,15 +250,33 @@ final class WebChatSwiftUIWindowController {
250250
var onVisibilityChanged: ((Bool) -> Void)?
251251

252252
convenience init(sessionKey: String, presentation: WebChatPresentation) {
253-
self.init(sessionKey: sessionKey, presentation: presentation, transport: MacGatewayChatTransport())
253+
// Connection-mode changes tear chat windows down via resetTunnels(),
254+
// so binding the cache identity at construction stays correct. One
255+
// store instance backs both the transcript cache and the offline
256+
// command outbox.
257+
let store = MacChatTranscriptCache.make()
258+
self.init(
259+
sessionKey: sessionKey,
260+
presentation: presentation,
261+
transport: MacGatewayChatTransport(),
262+
transcriptCache: store,
263+
outbox: store)
254264
}
255265

256-
init(sessionKey: String, presentation: WebChatPresentation, transport: any OpenClawChatTransport) {
266+
init(
267+
sessionKey: String,
268+
presentation: WebChatPresentation,
269+
transport: any OpenClawChatTransport,
270+
transcriptCache: (any OpenClawChatTranscriptCache)? = nil,
271+
outbox: (any OpenClawChatCommandOutbox)? = nil)
272+
{
257273
self.sessionKey = sessionKey
258274
self.presentation = presentation
259275
let vm = OpenClawChatViewModel(
260276
sessionKey: sessionKey,
261277
transport: transport,
278+
transcriptCache: transcriptCache,
279+
outbox: outbox,
262280
initialThinkingLevel: Self.persistedThinkingLevel(),
263281
onThinkingLevelChanged: { level in
264282
UserDefaults.standard.set(level, forKey: webChatThinkingLevelDefaultsKey)
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import Foundation
2+
import Testing
3+
@testable import OpenClaw
4+
5+
struct ChatTranscriptCacheIdentityTests {
6+
private static let defaultStateDir = URL(fileURLWithPath: "/Users/tester/.openclaw", isDirectory: true)
7+
8+
@Test func `unconfigured mode has no cache identity`() {
9+
let id = MacChatTranscriptCache.gatewayID(
10+
mode: .unconfigured,
11+
localStateDir: Self.defaultStateDir,
12+
remoteTransport: .ssh,
13+
directURL: nil,
14+
sshTarget: "user@host",
15+
sshRemotePort: 18789)
16+
#expect(id == nil)
17+
}
18+
19+
@Test func `local mode keys on state dir so profiles never collide`() {
20+
let defaultProfile = MacChatTranscriptCache.gatewayID(
21+
mode: .local,
22+
localStateDir: Self.defaultStateDir,
23+
remoteTransport: .ssh,
24+
directURL: nil,
25+
sshTarget: "",
26+
sshRemotePort: 18789)
27+
let devProfile = MacChatTranscriptCache.gatewayID(
28+
mode: .local,
29+
localStateDir: URL(fileURLWithPath: "/Users/tester/.openclaw-dev", isDirectory: true),
30+
remoteTransport: .ssh,
31+
directURL: nil,
32+
sshTarget: "",
33+
sshRemotePort: 18789)
34+
#expect(defaultProfile == "local:/Users/tester/.openclaw")
35+
#expect(devProfile == "local:/Users/tester/.openclaw-dev")
36+
#expect(defaultProfile != devProfile)
37+
}
38+
39+
@Test func `local state dir aliases resolve to one identity`() throws {
40+
// macOS tmp lives behind a /var -> /private/var symlink; both spellings
41+
// of the same state dir must map to a single cache scope.
42+
let canonical = try FileManager.default.temporaryDirectory
43+
.appendingPathComponent("openclaw-cache-identity-\(UUID().uuidString)", isDirectory: true)
44+
try FileManager.default.createDirectory(at: canonical, withIntermediateDirectories: true)
45+
defer { try? FileManager.default.removeItem(at: canonical) }
46+
let resolved = canonical.resolvingSymlinksInPath()
47+
let viaSymlink = MacChatTranscriptCache.gatewayID(
48+
mode: .local,
49+
localStateDir: canonical,
50+
remoteTransport: .ssh,
51+
directURL: nil,
52+
sshTarget: "",
53+
sshRemotePort: 18789)
54+
let viaResolved = MacChatTranscriptCache.gatewayID(
55+
mode: .local,
56+
localStateDir: resolved,
57+
remoteTransport: .ssh,
58+
directURL: nil,
59+
sshTarget: "",
60+
sshRemotePort: 18789)
61+
#expect(viaSymlink == viaResolved)
62+
}
63+
64+
@Test func `remote direct keys on the full canonical url`() {
65+
let explicitPort = MacChatTranscriptCache.gatewayID(
66+
mode: .remote,
67+
localStateDir: Self.defaultStateDir,
68+
remoteTransport: .direct,
69+
directURL: URL(string: "ws://Gateway.Example.com:9001"),
70+
sshTarget: "",
71+
sshRemotePort: 18789)
72+
#expect(explicitPort == "remote:ws://gateway.example.com:9001")
73+
74+
let defaultWSSPort = MacChatTranscriptCache.gatewayID(
75+
mode: .remote,
76+
localStateDir: Self.defaultStateDir,
77+
remoteTransport: .direct,
78+
directURL: URL(string: "wss://gw.example.com"),
79+
sshTarget: "",
80+
sshRemotePort: 18789)
81+
#expect(defaultWSSPort == "remote:wss://gw.example.com:443")
82+
83+
// One origin can route to several gateways by path; each path is its
84+
// own cache scope.
85+
let teamA = MacChatTranscriptCache.gatewayID(
86+
mode: .remote,
87+
localStateDir: Self.defaultStateDir,
88+
remoteTransport: .direct,
89+
directURL: URL(string: "wss://gw.example.com/team-a"),
90+
sshTarget: "",
91+
sshRemotePort: 18789)
92+
let teamB = MacChatTranscriptCache.gatewayID(
93+
mode: .remote,
94+
localStateDir: Self.defaultStateDir,
95+
remoteTransport: .direct,
96+
directURL: URL(string: "wss://gw.example.com/team-b"),
97+
sshTarget: "",
98+
sshRemotePort: 18789)
99+
#expect(teamA == "remote:wss://gw.example.com:443/team-a")
100+
#expect(teamB == "remote:wss://gw.example.com:443/team-b")
101+
#expect(teamA != teamB)
102+
103+
// Percent-encoded path spelling is part of the request URL and must
104+
// not collapse into the decoded form's scope.
105+
let encodedPath = MacChatTranscriptCache.gatewayID(
106+
mode: .remote,
107+
localStateDir: Self.defaultStateDir,
108+
remoteTransport: .direct,
109+
directURL: URL(string: "wss://gw.example.com/team%2Fa"),
110+
sshTarget: "",
111+
sshRemotePort: 18789)
112+
let decodedPath = MacChatTranscriptCache.gatewayID(
113+
mode: .remote,
114+
localStateDir: Self.defaultStateDir,
115+
remoteTransport: .direct,
116+
directURL: URL(string: "wss://gw.example.com/team/a"),
117+
sshTarget: "",
118+
sshRemotePort: 18789)
119+
#expect(encodedPath == "remote:wss://gw.example.com:443/team%2Fa")
120+
#expect(encodedPath != decodedPath)
121+
122+
let missingURL = MacChatTranscriptCache.gatewayID(
123+
mode: .remote,
124+
localStateDir: Self.defaultStateDir,
125+
remoteTransport: .direct,
126+
directURL: nil,
127+
sshTarget: "",
128+
sshRemotePort: 18789)
129+
#expect(missingURL == nil)
130+
}
131+
132+
@Test func `remote ssh keys on the ssh target and remote gateway port`() {
133+
let id = MacChatTranscriptCache.gatewayID(
134+
mode: .remote,
135+
localStateDir: Self.defaultStateDir,
136+
remoteTransport: .ssh,
137+
directURL: nil,
138+
sshTarget: " [email protected] ",
139+
sshRemotePort: 18789)
140+
#expect(id == "ssh:[email protected]:18789")
141+
142+
// One SSH target can front several gateways on different remote ports;
143+
// each must get its own cache scope.
144+
let otherGateway = MacChatTranscriptCache.gatewayID(
145+
mode: .remote,
146+
localStateDir: Self.defaultStateDir,
147+
remoteTransport: .ssh,
148+
directURL: nil,
149+
sshTarget: "[email protected]",
150+
sshRemotePort: 19001)
151+
#expect(otherGateway == "ssh:[email protected]:19001")
152+
#expect(otherGateway != id)
153+
154+
let missingTarget = MacChatTranscriptCache.gatewayID(
155+
mode: .remote,
156+
localStateDir: Self.defaultStateDir,
157+
remoteTransport: .ssh,
158+
directURL: nil,
159+
sshTarget: " ",
160+
sshRemotePort: 18789)
161+
#expect(missingTarget == nil)
162+
}
163+
}

docs/platforms/mac/webchat.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ The macOS menu bar app embeds the WebChat UI as a native SwiftUI view. It connec
2929
- `chat.history` returns a display-normalized transcript: inline directive tags are stripped from visible text, plain-text tool-call XML payloads (`<tool_call>`, `<function_call>`, `<tool_calls>`, `<function_calls>`, including truncated blocks) and leaked model control tokens are stripped, pure silent-token assistant rows such as exact `NO_REPLY`/`no_reply` are omitted, and oversized rows can be replaced with a truncated placeholder.
3030
- Session: defaults to the primary session as above; the UI can switch between sessions.
3131
- Onboarding uses a dedicated session to keep first-run setup separate.
32+
- Offline cache: the app keeps a small read-only cache of recent chat sessions and transcripts per gateway (`~/Library/Application Support/OpenClaw/chat-cache.sqlite`): cold opens paint the last known transcript immediately and refresh once the Gateway responds, and recent chats stay browsable while disconnected (sending stays disabled until the connection is back).
3233

3334
## Security surface
3435

0 commit comments

Comments
 (0)