@blue-soda/dsh-remote 0.4.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +29 -0
- package/README.md +81 -0
- package/bin/ds-harness-remote.js +11 -0
- package/cordis.patch.yml +32 -0
- package/dist/acp.d.ts +75 -0
- package/dist/acp.d.ts.map +1 -0
- package/dist/acp.js +61 -0
- package/dist/acp.js.map +1 -0
- package/dist/api-proxy-switch.d.ts +33 -0
- package/dist/api-proxy-switch.d.ts.map +1 -0
- package/dist/api-proxy-switch.js +109 -0
- package/dist/api-proxy-switch.js.map +1 -0
- package/dist/bin/doctor.d.ts +3 -0
- package/dist/bin/doctor.d.ts.map +1 -0
- package/dist/bin/doctor.js +13 -0
- package/dist/bin/doctor.js.map +1 -0
- package/dist/cli.d.ts +32 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +289 -0
- package/dist/cli.js.map +1 -0
- package/dist/client-runtime.d.ts +239 -0
- package/dist/client-runtime.d.ts.map +1 -0
- package/dist/client-runtime.js +1391 -0
- package/dist/client-runtime.js.map +1 -0
- package/dist/client-secure-transport.d.ts +23 -0
- package/dist/client-secure-transport.d.ts.map +1 -0
- package/dist/client-secure-transport.js +143 -0
- package/dist/client-secure-transport.js.map +1 -0
- package/dist/client.d.ts +13 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.github.js +4231 -0
- package/dist/client.js +4231 -0
- package/dist/codex/app-server.d.ts +71 -0
- package/dist/codex/app-server.d.ts.map +1 -0
- package/dist/codex/app-server.js +268 -0
- package/dist/codex/app-server.js.map +1 -0
- package/dist/codex/codex-image-codec.d.ts +7 -0
- package/dist/codex/codex-image-codec.d.ts.map +1 -0
- package/dist/codex/codex-image-codec.js +288 -0
- package/dist/codex/codex-image-codec.js.map +1 -0
- package/dist/codex/domain.d.ts +93 -0
- package/dist/codex/domain.d.ts.map +1 -0
- package/dist/codex/domain.js +1282 -0
- package/dist/codex/domain.js.map +1 -0
- package/dist/codex/method-policy.d.ts +338 -0
- package/dist/codex/method-policy.d.ts.map +1 -0
- package/dist/codex/method-policy.js +141 -0
- package/dist/codex/method-policy.js.map +1 -0
- package/dist/codex/peer-bridge.d.ts +57 -0
- package/dist/codex/peer-bridge.d.ts.map +1 -0
- package/dist/codex/peer-bridge.js +299 -0
- package/dist/codex/peer-bridge.js.map +1 -0
- package/dist/codex/permissions.d.ts +4 -0
- package/dist/codex/permissions.d.ts.map +1 -0
- package/dist/codex/permissions.js +21 -0
- package/dist/codex/permissions.js.map +1 -0
- package/dist/codex/session-id.d.ts +10 -0
- package/dist/codex/session-id.d.ts.map +1 -0
- package/dist/codex/session-id.js +15 -0
- package/dist/codex/session-id.js.map +1 -0
- package/dist/codex/virtual-harness.d.ts +209 -0
- package/dist/codex/virtual-harness.d.ts.map +1 -0
- package/dist/codex/virtual-harness.js +2616 -0
- package/dist/codex/virtual-harness.js.map +1 -0
- package/dist/codex-workspace-bridge.d.ts +108 -0
- package/dist/codex-workspace-bridge.d.ts.map +1 -0
- package/dist/codex-workspace-bridge.js +502 -0
- package/dist/codex-workspace-bridge.js.map +1 -0
- package/dist/config.d.ts +99 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +127 -0
- package/dist/config.js.map +1 -0
- package/dist/connection-controller.d.ts +44 -0
- package/dist/connection-controller.d.ts.map +1 -0
- package/dist/connection-controller.js +185 -0
- package/dist/connection-controller.js.map +1 -0
- package/dist/control-route.d.ts +34 -0
- package/dist/control-route.d.ts.map +1 -0
- package/dist/control-route.js +136 -0
- package/dist/control-route.js.map +1 -0
- package/dist/control-runtime.d.ts +138 -0
- package/dist/control-runtime.d.ts.map +1 -0
- package/dist/control-runtime.js +543 -0
- package/dist/control-runtime.js.map +1 -0
- package/dist/control-stream.d.ts +67 -0
- package/dist/control-stream.d.ts.map +1 -0
- package/dist/control-stream.js +179 -0
- package/dist/control-stream.js.map +1 -0
- package/dist/defaults.d.ts +9 -0
- package/dist/defaults.d.ts.map +1 -0
- package/dist/defaults.js +9 -0
- package/dist/defaults.js.map +1 -0
- package/dist/file-viewer-bridge.d.ts +30 -0
- package/dist/file-viewer-bridge.d.ts.map +1 -0
- package/dist/file-viewer-bridge.js +128 -0
- package/dist/file-viewer-bridge.js.map +1 -0
- package/dist/file-viewer-contract.d.ts +5 -0
- package/dist/file-viewer-contract.d.ts.map +1 -0
- package/dist/file-viewer-contract.js +4 -0
- package/dist/file-viewer-contract.js.map +1 -0
- package/dist/harness-api-bridge.d.ts +79 -0
- package/dist/harness-api-bridge.d.ts.map +1 -0
- package/dist/harness-api-bridge.js +852 -0
- package/dist/harness-api-bridge.js.map +1 -0
- package/dist/harness-api-history.d.ts +3 -0
- package/dist/harness-api-history.d.ts.map +1 -0
- package/dist/harness-api-history.js +59 -0
- package/dist/harness-api-history.js.map +1 -0
- package/dist/harness-remote-bridge.d.ts +52 -0
- package/dist/harness-remote-bridge.d.ts.map +1 -0
- package/dist/harness-remote-bridge.js +529 -0
- package/dist/harness-remote-bridge.js.map +1 -0
- package/dist/harness-version.d.ts +23 -0
- package/dist/harness-version.d.ts.map +1 -0
- package/dist/harness-version.js +95 -0
- package/dist/harness-version.js.map +1 -0
- package/dist/identity-store.d.ts +45 -0
- package/dist/identity-store.d.ts.map +1 -0
- package/dist/identity-store.js +183 -0
- package/dist/identity-store.js.map +1 -0
- package/dist/ids.d.ts +2 -0
- package/dist/ids.d.ts.map +1 -0
- package/dist/ids.js +17 -0
- package/dist/ids.js.map +1 -0
- package/dist/index.d.ts +78 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28870 -0
- package/dist/index.js.map +7 -0
- package/dist/logging.d.ts +18 -0
- package/dist/logging.d.ts.map +1 -0
- package/dist/logging.js +30 -0
- package/dist/logging.js.map +1 -0
- package/dist/loopback-host.d.ts +20 -0
- package/dist/loopback-host.d.ts.map +1 -0
- package/dist/loopback-host.js +245 -0
- package/dist/loopback-host.js.map +1 -0
- package/dist/loopback-preview.d.ts +19 -0
- package/dist/loopback-preview.d.ts.map +1 -0
- package/dist/loopback-preview.js +232 -0
- package/dist/loopback-preview.js.map +1 -0
- package/dist/native-rtc-helper.d.ts +14 -0
- package/dist/native-rtc-helper.d.ts.map +1 -0
- package/dist/native-rtc-helper.js +575 -0
- package/dist/native-rtc-helper.js.map +1 -0
- package/dist/remote-api-proxy.d.ts +17 -0
- package/dist/remote-api-proxy.d.ts.map +1 -0
- package/dist/remote-api-proxy.js +400 -0
- package/dist/remote-api-proxy.js.map +1 -0
- package/dist/remote-directory-browser.d.ts +16 -0
- package/dist/remote-directory-browser.d.ts.map +1 -0
- package/dist/remote-directory-browser.js +48 -0
- package/dist/remote-directory-browser.js.map +1 -0
- package/dist/remote-file-content-provider.d.ts +48 -0
- package/dist/remote-file-content-provider.d.ts.map +1 -0
- package/dist/remote-file-content-provider.js +93 -0
- package/dist/remote-file-content-provider.js.map +1 -0
- package/dist/remote-typert-gateway.d.ts +20 -0
- package/dist/remote-typert-gateway.d.ts.map +1 -0
- package/dist/remote-typert-gateway.js +332 -0
- package/dist/remote-typert-gateway.js.map +1 -0
- package/dist/rpc-router.d.ts +31 -0
- package/dist/rpc-router.d.ts.map +1 -0
- package/dist/rpc-router.js +201 -0
- package/dist/rpc-router.js.map +1 -0
- package/dist/safe-error.d.ts +8 -0
- package/dist/safe-error.d.ts.map +1 -0
- package/dist/safe-error.js +20 -0
- package/dist/safe-error.js.map +1 -0
- package/dist/server-api.d.ts +109 -0
- package/dist/server-api.d.ts.map +1 -0
- package/dist/server-api.js +520 -0
- package/dist/server-api.js.map +1 -0
- package/dist/server-connection.d.ts +79 -0
- package/dist/server-connection.d.ts.map +1 -0
- package/dist/server-connection.js +1023 -0
- package/dist/server-connection.js.map +1 -0
- package/dist/server-credentials.d.ts +55 -0
- package/dist/server-credentials.d.ts.map +1 -0
- package/dist/server-credentials.js +105 -0
- package/dist/server-credentials.js.map +1 -0
- package/dist/service.d.ts +141 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +432 -0
- package/dist/service.js.map +1 -0
- package/dist/session-format-compat.d.ts +3 -0
- package/dist/session-format-compat.d.ts.map +1 -0
- package/dist/session-format-compat.js +183 -0
- package/dist/session-format-compat.js.map +1 -0
- package/dist/status-stream.d.ts +48 -0
- package/dist/status-stream.d.ts.map +1 -0
- package/dist/status-stream.js +133 -0
- package/dist/status-stream.js.map +1 -0
- package/dist/terminal-policy.d.ts +21 -0
- package/dist/terminal-policy.d.ts.map +1 -0
- package/dist/terminal-policy.js +68 -0
- package/dist/terminal-policy.js.map +1 -0
- package/dist/tui-command.d.ts +13 -0
- package/dist/tui-command.d.ts.map +1 -0
- package/dist/tui-command.js +390 -0
- package/dist/tui-command.js.map +1 -0
- package/dist/typert-gateway-contract.d.ts +59 -0
- package/dist/typert-gateway-contract.d.ts.map +1 -0
- package/dist/typert-gateway-contract.js +2 -0
- package/dist/typert-gateway-contract.js.map +1 -0
- package/dist/typert-gateway-switch.d.ts +85 -0
- package/dist/typert-gateway-switch.d.ts.map +1 -0
- package/dist/typert-gateway-switch.js +315 -0
- package/dist/typert-gateway-switch.js.map +1 -0
- package/dist/types.d.ts +20 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/version.d.ts +12 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +12 -0
- package/dist/version.js.map +1 -0
- package/dist/werift-rtc.d.ts +121 -0
- package/dist/werift-rtc.d.ts.map +1 -0
- package/dist/werift-rtc.js +490 -0
- package/dist/werift-rtc.js.map +1 -0
- package/dsh-plugin.json +41 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +165 -0
- package/public.d.ts +26 -0
|
@@ -0,0 +1,1391 @@
|
|
|
1
|
+
import { LoopbackPreview } from './loopback-preview.js';
|
|
2
|
+
import { CodexRemoteClient, RemoteClientCore } from '@dsh-remote/client-core';
|
|
3
|
+
import { AdaptiveTransport, stunOnlyIceServers, } from '@dsh-remote/webrtc';
|
|
4
|
+
import { ApiProxySwitch } from './api-proxy-switch.js';
|
|
5
|
+
import { ClientSecureTransport } from './client-secure-transport.js';
|
|
6
|
+
import { registerControlRoute } from './control-route.js';
|
|
7
|
+
import { uuidV7 } from './ids.js';
|
|
8
|
+
import { harnessSessionGeneration } from './harness-version.js';
|
|
9
|
+
import { RemoteHarnessApiProxy } from './remote-api-proxy.js';
|
|
10
|
+
import { RemoteTypertGateway } from './remote-typert-gateway.js';
|
|
11
|
+
import { codexProjectWorkspaceId, CodexVirtualHarness, discoverCodexVirtualWorkspaces, } from './codex/virtual-harness.js';
|
|
12
|
+
import { ServerApiError, } from './server-api.js';
|
|
13
|
+
import { TypertGatewaySwitch } from './typert-gateway-switch.js';
|
|
14
|
+
import { loadNodeRtcFactory } from './werift-rtc.js';
|
|
15
|
+
import { safeErrorCode } from './safe-error.js';
|
|
16
|
+
const REMOTE_COMMAND_LIST_MIN_VERSION = [0, 3, 16];
|
|
17
|
+
const REMOTE_FILE_VIEWER_MIN_VERSION = [0, 3, 17];
|
|
18
|
+
const DIRECT_WEBRTC_NEGOTIATE_TIMEOUT_MS = 12_000;
|
|
19
|
+
const DIRECT_LAN_PROGRESS_DISPLAY_MS = 1_400;
|
|
20
|
+
const HOST_AUTHORIZATION_ERRORS = new Set([
|
|
21
|
+
'ACCOUNT_AUTH_REQUIRED',
|
|
22
|
+
'AUTH_INVALID',
|
|
23
|
+
'DEVICE_OWNERSHIP_REQUIRED',
|
|
24
|
+
'DEVICE_REVOKED',
|
|
25
|
+
'TOKEN_EXPIRED',
|
|
26
|
+
]);
|
|
27
|
+
export class ClientModeRuntime {
|
|
28
|
+
config;
|
|
29
|
+
identities;
|
|
30
|
+
server;
|
|
31
|
+
logger;
|
|
32
|
+
host;
|
|
33
|
+
rtcFactoryProvider;
|
|
34
|
+
preview;
|
|
35
|
+
identity;
|
|
36
|
+
connected;
|
|
37
|
+
/**
|
|
38
|
+
* Set when a remote session dropped and the runtime fell back to local.
|
|
39
|
+
*
|
|
40
|
+
* The mode then reads 'local', which is what every "return to local" control is
|
|
41
|
+
* gated on, so without this the user is left in a stale remote view with no way
|
|
42
|
+
* back except signing out.
|
|
43
|
+
*/
|
|
44
|
+
fellBackToLocal = false;
|
|
45
|
+
/**
|
|
46
|
+
* Identifies the live reconnect loop.
|
|
47
|
+
*
|
|
48
|
+
* A dropped transport starts one; anything else that settles the connection —
|
|
49
|
+
* the user returning to local, a logout, a fresh connect — bumps it so the loop
|
|
50
|
+
* stops instead of fighting the newer decision.
|
|
51
|
+
*/
|
|
52
|
+
remoteReconnectRun = 0;
|
|
53
|
+
pendingWorkspaceSelection;
|
|
54
|
+
codexVirtual;
|
|
55
|
+
proxySwitch;
|
|
56
|
+
gatewaySwitch;
|
|
57
|
+
codexStreams = new Map();
|
|
58
|
+
connectionProgress;
|
|
59
|
+
connectionProgressRun = 0;
|
|
60
|
+
closed = false;
|
|
61
|
+
constructor(config, identities, server, apiProxy, typertGateway, logger, host, rtcFactoryProvider = loadNodeRtcFactory) {
|
|
62
|
+
this.config = config;
|
|
63
|
+
this.identities = identities;
|
|
64
|
+
this.server = server;
|
|
65
|
+
this.logger = logger;
|
|
66
|
+
this.host = host;
|
|
67
|
+
this.rtcFactoryProvider = rtcFactoryProvider;
|
|
68
|
+
this.proxySwitch = apiProxy === undefined ? undefined : new ApiProxySwitch(apiProxy);
|
|
69
|
+
this.gatewaySwitch = new TypertGatewaySwitch(typertGateway);
|
|
70
|
+
// Serve the local shell while no peer session is live. Otherwise a remote-mode
|
|
71
|
+
// boot routes its own local services at a peer that may not be there, and the
|
|
72
|
+
// shell never finishes activating.
|
|
73
|
+
this.gatewaySwitch.setRemoteAvailability(() => this.connected !== undefined);
|
|
74
|
+
}
|
|
75
|
+
async start() {
|
|
76
|
+
if (this.closed)
|
|
77
|
+
throw new Error('client remote-mode runtime is closed');
|
|
78
|
+
this.identity = await this.identities.loadOrCreate(this.config.deviceName);
|
|
79
|
+
this.server.bindIdentity(this.identity);
|
|
80
|
+
this.proxySwitch?.install();
|
|
81
|
+
this.gatewaySwitch.install();
|
|
82
|
+
this.logger.info('client remote-mode identity ready', {
|
|
83
|
+
deviceId: shortId(this.identity.deviceId),
|
|
84
|
+
fingerprint: this.identity.fingerprint,
|
|
85
|
+
});
|
|
86
|
+
// A previously authorized Client should make the local Host controllable
|
|
87
|
+
// on startup as well. Do not register an anonymous Client just to probe:
|
|
88
|
+
// only persisted Client credentials opt into this default.
|
|
89
|
+
if (this.config.hostControl?.enabled !== false
|
|
90
|
+
&& this.host !== undefined
|
|
91
|
+
&& this.server.hasStoredAuthorization !== undefined) {
|
|
92
|
+
try {
|
|
93
|
+
if (await this.server.hasStoredAuthorization()
|
|
94
|
+
&& (this.host.hasStoredAuthorization === undefined || !await this.host.hasStoredAuthorization())) {
|
|
95
|
+
await this.authorizeHostByDefault();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
this.logger.warn('automatic Host authorization failed', { code: safeErrorCode(error) });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Whether this installation already holds stored Server credentials for its
|
|
105
|
+
* Client identity.
|
|
106
|
+
*
|
|
107
|
+
* This is the local answer to "is this installation signed in", available
|
|
108
|
+
* without contacting the Server, so the UI can choose its panel immediately
|
|
109
|
+
* instead of inferring the answer from a failed network round trip.
|
|
110
|
+
*/
|
|
111
|
+
async hasStoredAuthorization() {
|
|
112
|
+
return this.server.hasStoredAuthorization === undefined
|
|
113
|
+
? false
|
|
114
|
+
: await this.server.hasStoredAuthorization();
|
|
115
|
+
}
|
|
116
|
+
async authorizeHostByDefault() {
|
|
117
|
+
try {
|
|
118
|
+
if (this.host === undefined)
|
|
119
|
+
return;
|
|
120
|
+
if (this.config.hostControl?.enabled === false)
|
|
121
|
+
return;
|
|
122
|
+
const status = this.host.hostStatus();
|
|
123
|
+
if (status.authorized)
|
|
124
|
+
return;
|
|
125
|
+
// Stored credentials the Server has already rejected must not block
|
|
126
|
+
// re-authorization. A credential left over from another account — for
|
|
127
|
+
// example after a Server state migration — would otherwise keep the Host
|
|
128
|
+
// unregistered while the UI keeps telling the user to authorize again.
|
|
129
|
+
const rejected = status.error !== undefined && HOST_AUTHORIZATION_ERRORS.has(status.error);
|
|
130
|
+
if (!rejected && this.host.hasStoredAuthorization !== undefined && await this.host.hasStoredAuthorization())
|
|
131
|
+
return;
|
|
132
|
+
const credentials = await this.server.authenticate(this.requireIdentity());
|
|
133
|
+
await this.host.authorizeHostAsOwned(credentials.accessToken, credentials.account);
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
this.logger.warn('automatic Host authorization failed', { code: safeErrorCode(error) });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
registerControl(connection, webServer) {
|
|
140
|
+
return registerControlRoute(connection, (endpoint, payload, signal) => this.handleControl(endpoint, payload, signal), webServer);
|
|
141
|
+
}
|
|
142
|
+
status() {
|
|
143
|
+
const targetStatus = this.gatewaySwitch.supportsCarrier()
|
|
144
|
+
? this.gatewaySwitch.status()
|
|
145
|
+
: this.proxySwitch?.status() ?? this.gatewaySwitch.status();
|
|
146
|
+
return {
|
|
147
|
+
available: this.config.serverUrl !== undefined,
|
|
148
|
+
identityReady: this.identity !== undefined,
|
|
149
|
+
deviceId: this.identity?.deviceId,
|
|
150
|
+
deviceName: this.identity?.name,
|
|
151
|
+
serverUrl: this.config.serverUrl,
|
|
152
|
+
...targetStatus,
|
|
153
|
+
connected: this.connected !== undefined,
|
|
154
|
+
fellBackToLocal: this.fellBackToLocal,
|
|
155
|
+
transport: this.connected?.client.getStats().mode ?? 'Disconnected',
|
|
156
|
+
connectedTargetDeviceId: this.connected?.target.deviceId,
|
|
157
|
+
preferredTransports: this.config.forceRelay ? ['relay'] : ['lan', 'p2p', 'turn', 'relay'],
|
|
158
|
+
...(this.connectionProgress === undefined ? {} : {
|
|
159
|
+
connectionProgress: {
|
|
160
|
+
targetDeviceId: this.connectionProgress.targetDeviceId,
|
|
161
|
+
phase: this.connectionProgress.phase,
|
|
162
|
+
...(this.connectionProgress.activeTransports === undefined
|
|
163
|
+
? {}
|
|
164
|
+
: { activeTransports: [...this.connectionProgress.activeTransports] }),
|
|
165
|
+
},
|
|
166
|
+
}),
|
|
167
|
+
remoteFeatures: this.connected?.features ?? remoteHostFeatures(),
|
|
168
|
+
...(this.pendingWorkspaceSelection === undefined
|
|
169
|
+
? {}
|
|
170
|
+
: { workspaceSelection: { ...this.pendingWorkspaceSelection } }),
|
|
171
|
+
backend: this.codexVirtual === undefined ? 'harness' : 'codex',
|
|
172
|
+
hostAuthorizationAvailable: this.host !== undefined,
|
|
173
|
+
...(this.host === undefined ? {} : { host: this.host.hostStatus() }),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
async closePreview() {
|
|
177
|
+
const preview = this.preview;
|
|
178
|
+
this.preview = undefined;
|
|
179
|
+
await preview?.close();
|
|
180
|
+
}
|
|
181
|
+
async detailedStatus() {
|
|
182
|
+
const connected = this.connected;
|
|
183
|
+
if (connected === undefined || this.identity === undefined)
|
|
184
|
+
return this.status();
|
|
185
|
+
const details = await connected.transport.connectionDetails();
|
|
186
|
+
if (this.connected !== connected)
|
|
187
|
+
return this.status();
|
|
188
|
+
return {
|
|
189
|
+
...this.status(),
|
|
190
|
+
network: {
|
|
191
|
+
...details,
|
|
192
|
+
local: {
|
|
193
|
+
deviceId: this.identity.deviceId,
|
|
194
|
+
name: this.identity.name,
|
|
195
|
+
platform: process.platform,
|
|
196
|
+
},
|
|
197
|
+
remote: {
|
|
198
|
+
deviceId: connected.target.deviceId,
|
|
199
|
+
name: connected.target.name,
|
|
200
|
+
platform: connected.target.platform,
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
async devices() {
|
|
206
|
+
this.assertHostAuthorizationForDeviceDiscovery();
|
|
207
|
+
this.requireIdentity();
|
|
208
|
+
const serverDevices = await this.server.listDevices();
|
|
209
|
+
const remoteDevices = serverDevices.filter(device => device.deviceId !== this.host?.hostStatus().deviceId);
|
|
210
|
+
return Promise.all(remoteDevices.map(async (device) => {
|
|
211
|
+
await this.authorizeHostPeer(device);
|
|
212
|
+
const presence = await this.server.presenceFor(device.deviceId).catch(() => ({ online: false }));
|
|
213
|
+
return { ...device, ...presence };
|
|
214
|
+
}));
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Device discovery is exposed through the local app control route. When this
|
|
218
|
+
* installation also runs a Host, keep that route closed after the Host's
|
|
219
|
+
* Server credential has become terminally invalid. The Client credential can
|
|
220
|
+
* remain usable for a short time after a revoke, so checking only
|
|
221
|
+
* `ClientServerApi.listDevices()` would otherwise leak the device directory
|
|
222
|
+
* from a Host that the user has already been told to re-authorize.
|
|
223
|
+
*/
|
|
224
|
+
assertHostAuthorizationForDeviceDiscovery() {
|
|
225
|
+
const status = this.host?.hostStatus();
|
|
226
|
+
if (status === undefined || status.error === undefined || !HOST_AUTHORIZATION_ERRORS.has(status.error))
|
|
227
|
+
return;
|
|
228
|
+
const message = status.error === 'DEVICE_REVOKED'
|
|
229
|
+
? 'The local Host was revoked on the Server. Sign out and authorize this Host again.'
|
|
230
|
+
: 'The local Host authorization is no longer valid. Sign out and authorize this Host again.';
|
|
231
|
+
throw new ClientModeError(status.error, message);
|
|
232
|
+
}
|
|
233
|
+
async authorizeClientWithAccount(email, password) {
|
|
234
|
+
let authorization;
|
|
235
|
+
try {
|
|
236
|
+
authorization = await this.server.authorizeWithAccount(this.requireIdentity(), email, password);
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
if (!(error instanceof ServerApiError) || error.code !== 'DEVICE_REVOKED')
|
|
240
|
+
throw error;
|
|
241
|
+
this.identity = await this.identities.reset(this.config.deviceName);
|
|
242
|
+
this.server.bindIdentity(this.identity);
|
|
243
|
+
authorization = await this.server.authorizeWithAccount(this.identity, email, password);
|
|
244
|
+
}
|
|
245
|
+
await this.authorizeHostByDefault();
|
|
246
|
+
this.logger.info('Client account authorized');
|
|
247
|
+
return authorization;
|
|
248
|
+
}
|
|
249
|
+
async startClientOAuthQrLogin(provider) {
|
|
250
|
+
return this.server.startOAuthQrLogin(provider);
|
|
251
|
+
}
|
|
252
|
+
async pollClientOAuthQrLogin(qrId) {
|
|
253
|
+
const result = await this.server.pollOAuthQrLogin(this.requireIdentity(), qrId, async () => {
|
|
254
|
+
this.identity = await this.identities.reset(this.config.deviceName);
|
|
255
|
+
this.server.bindIdentity(this.identity);
|
|
256
|
+
this.logger.info('Rotated revoked Client identity before QR authorization retry');
|
|
257
|
+
return this.identity;
|
|
258
|
+
});
|
|
259
|
+
if (result.status === 'complete')
|
|
260
|
+
this.logger.info('Client account authorized with QR login');
|
|
261
|
+
if (result.status === 'complete')
|
|
262
|
+
await this.authorizeHostByDefault();
|
|
263
|
+
return result;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Stop this Client from being authorized, keeping its device identity.
|
|
267
|
+
*
|
|
268
|
+
* The device is deliberately *not* revoked and the identity is deliberately
|
|
269
|
+
* *not* rotated. Signing out used to revoke the device, which forced a new
|
|
270
|
+
* identity on the next sign-in and registered a second device for the same
|
|
271
|
+
* installation; an account holds at most 256 devices, so signing out often
|
|
272
|
+
* enough could exhaust it. Keeping the row also means the next sign-in reuses
|
|
273
|
+
* it, and the server invalidates the previous tokens at that point.
|
|
274
|
+
*
|
|
275
|
+
* The cost, by choice: while signed out the device stays in the account and
|
|
276
|
+
* its old tokens stay valid until the next sign-in or their expiry, so signing
|
|
277
|
+
* out is no longer a way to cut a leaked token off immediately. Removing the
|
|
278
|
+
* device for good is an operator action on the server's state file.
|
|
279
|
+
*/
|
|
280
|
+
async clearClientAuthorization() {
|
|
281
|
+
const previous = this.connected;
|
|
282
|
+
this.connected = undefined;
|
|
283
|
+
this.connectionProgress = undefined;
|
|
284
|
+
this.pendingWorkspaceSelection = undefined;
|
|
285
|
+
await this.closeCodexVirtual();
|
|
286
|
+
this.proxySwitch?.selectLocal();
|
|
287
|
+
await this.closePreview();
|
|
288
|
+
this.gatewaySwitch.selectLocal();
|
|
289
|
+
await this.closeCodexStreams(previous?.client);
|
|
290
|
+
await previous?.client.close().catch(() => undefined);
|
|
291
|
+
// A sign-out ends the session for good, so no reconnect loop may keep trying.
|
|
292
|
+
this.remoteReconnectRun += 1;
|
|
293
|
+
// Clear the in-memory authorization as well as the stored credential. The
|
|
294
|
+
// caller clears the credential file, but this API also caches the
|
|
295
|
+
// authorization in memory, and a stale copy keeps reporting the Client as
|
|
296
|
+
// authorized — which stops the Host from re-authorizing and leaves the
|
|
297
|
+
// connection retrying tokens the Server no longer accepts.
|
|
298
|
+
await this.server.clearAuthorization();
|
|
299
|
+
}
|
|
300
|
+
async setHostAuthorization(enabled) {
|
|
301
|
+
if (this.host === undefined)
|
|
302
|
+
throw new ClientModeError('METHOD_NOT_ALLOWED', 'This plugin is not running as a Host.');
|
|
303
|
+
if (!enabled) {
|
|
304
|
+
await this.host.clearHostAuthorization();
|
|
305
|
+
return this.status();
|
|
306
|
+
}
|
|
307
|
+
const credentials = await this.server.authenticate(this.requireIdentity());
|
|
308
|
+
await this.host.authorizeHostAsOwned(credentials.accessToken, credentials.account);
|
|
309
|
+
return this.status();
|
|
310
|
+
}
|
|
311
|
+
async setMode(mode, targetDeviceId, signal) {
|
|
312
|
+
if (mode === 'local') {
|
|
313
|
+
await this.closeCodexVirtual();
|
|
314
|
+
this.proxySwitch?.selectLocal();
|
|
315
|
+
await this.closePreview();
|
|
316
|
+
this.gatewaySwitch.selectLocal();
|
|
317
|
+
const previous = this.connected;
|
|
318
|
+
this.connected = undefined;
|
|
319
|
+
this.connectionProgress = undefined;
|
|
320
|
+
this.pendingWorkspaceSelection = undefined;
|
|
321
|
+
await this.closeCodexStreams(previous?.client);
|
|
322
|
+
await previous?.client.close().catch(() => undefined);
|
|
323
|
+
// Returning to local is the answer to a dropped session, so the record of
|
|
324
|
+
// that drop must not outlive it: otherwise the exit affordances stay on
|
|
325
|
+
// screen after the user has already acted on them. Stop any pending
|
|
326
|
+
// reconnect too — the user asked for local, not for a retry.
|
|
327
|
+
this.fellBackToLocal = false;
|
|
328
|
+
this.remoteReconnectRun += 1;
|
|
329
|
+
this.logger.info('Harness target switched', { mode: 'local' });
|
|
330
|
+
return this.status();
|
|
331
|
+
}
|
|
332
|
+
if (targetDeviceId === undefined || targetDeviceId.length === 0) {
|
|
333
|
+
throw new ClientModeError('INVALID_MESSAGE', 'A targetDeviceId is required for remote mode.');
|
|
334
|
+
}
|
|
335
|
+
const next = await this.connect(targetDeviceId, signal);
|
|
336
|
+
try {
|
|
337
|
+
this.assertRemoteCompatible(next);
|
|
338
|
+
}
|
|
339
|
+
catch (error) {
|
|
340
|
+
this.clearConnectionProgress(next.progressRunId);
|
|
341
|
+
await next.client.close().catch(() => undefined);
|
|
342
|
+
throw error;
|
|
343
|
+
}
|
|
344
|
+
const previous = this.connected;
|
|
345
|
+
await this.closePreview();
|
|
346
|
+
this.connected = next;
|
|
347
|
+
this.clearConnectionProgress(next.progressRunId);
|
|
348
|
+
this.pendingWorkspaceSelection = undefined;
|
|
349
|
+
await this.closeCodexVirtual();
|
|
350
|
+
this.selectRemoteTarget(next);
|
|
351
|
+
// A fresh remote session clears the record of an earlier dropped one.
|
|
352
|
+
this.fellBackToLocal = false;
|
|
353
|
+
await this.closeCodexStreams(previous?.client);
|
|
354
|
+
await previous?.client.close().catch(() => undefined);
|
|
355
|
+
this.logger.info('Harness target switched', { mode: 'remote', targetDeviceId: shortId(next.target.deviceId) });
|
|
356
|
+
return this.status();
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Re-establish a remote session whose transport closed.
|
|
360
|
+
*
|
|
361
|
+
* The UI keeps rendering the remote session after the transport is gone, so
|
|
362
|
+
* without this the user faces a session that silently ignores everything and has
|
|
363
|
+
* to exit and pick the Host again — which is what a suspended and resumed client
|
|
364
|
+
* used to require every time. Retry the same Host with backoff; a loop that is
|
|
365
|
+
* superseded, or one whose session came back another way, stops quietly.
|
|
366
|
+
* @param targetDeviceId - the Host the dropped session was bound to.
|
|
367
|
+
*/
|
|
368
|
+
async reconnectRemoteSession(targetDeviceId) {
|
|
369
|
+
const run = ++this.remoteReconnectRun;
|
|
370
|
+
// Fast attempts first, then a steady low rate. Timers do not run while a
|
|
371
|
+
// client is suspended, so a pending wait simply lands when it comes back —
|
|
372
|
+
// which is what makes a backgrounded phone reconnect on its own. Keeping the
|
|
373
|
+
// loop alive matters for the other order too: attempts spent while the network
|
|
374
|
+
// was down must not leave the session dead until the user acts.
|
|
375
|
+
const fastDelays = [1_000, 2_000, 4_000, 8_000, 15_000];
|
|
376
|
+
let attempt = 0;
|
|
377
|
+
for (;;) {
|
|
378
|
+
const wait = fastDelays[attempt] ?? 30_000;
|
|
379
|
+
await new Promise(resolve => { setTimeout(resolve, wait); });
|
|
380
|
+
if (run !== this.remoteReconnectRun)
|
|
381
|
+
return;
|
|
382
|
+
if (this.connected !== undefined)
|
|
383
|
+
return;
|
|
384
|
+
try {
|
|
385
|
+
await this.setMode('remote', targetDeviceId);
|
|
386
|
+
this.logger.info('remote Harness session reconnected', { targetDeviceId: shortId(targetDeviceId) });
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
catch (error) {
|
|
390
|
+
// Report the early attempts, then only occasionally: a Host that stays
|
|
391
|
+
// away would otherwise fill the log every half minute.
|
|
392
|
+
if (attempt < 3 || attempt % 10 === 0) {
|
|
393
|
+
this.logger.warn('remote Harness reconnect attempt failed', {
|
|
394
|
+
targetDeviceId: shortId(targetDeviceId),
|
|
395
|
+
attempt,
|
|
396
|
+
code: safeErrorCode(error),
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
attempt += 1;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
async listRemoteDirectory(targetDeviceId, path, signal) {
|
|
404
|
+
const remote = await this.ensureConnected(targetDeviceId, signal);
|
|
405
|
+
if (remote.features.remoteGateway) {
|
|
406
|
+
const value = await new RemoteTypertGateway(remote.client).invoke({
|
|
407
|
+
namespace: 'directoryPicker',
|
|
408
|
+
method: 'list',
|
|
409
|
+
args: path === undefined ? {} : { path },
|
|
410
|
+
...(signal === undefined ? {} : { signal }),
|
|
411
|
+
});
|
|
412
|
+
return value;
|
|
413
|
+
}
|
|
414
|
+
const api = new RemoteHarnessApiProxy(remote.client).api;
|
|
415
|
+
const response = await api.host.listDirectory({
|
|
416
|
+
rpcId: `remote-directory-${Date.now()}`,
|
|
417
|
+
payload: path === undefined ? {} : { path },
|
|
418
|
+
}, signal ?? new AbortController().signal);
|
|
419
|
+
return unwrapNativeResult(response);
|
|
420
|
+
}
|
|
421
|
+
async listRemoteWorkspaces(targetDeviceId, signal) {
|
|
422
|
+
const remote = await this.ensureConnected(targetDeviceId, signal);
|
|
423
|
+
if (remote.features.remoteGateway) {
|
|
424
|
+
return readRemoteWorkspaceBaseline(new RemoteTypertGateway(remote.client), signal);
|
|
425
|
+
}
|
|
426
|
+
const api = new RemoteHarnessApiProxy(remote.client).api;
|
|
427
|
+
const response = await api.workspace.list({
|
|
428
|
+
rpcId: `remote-workspaces-${Date.now()}`,
|
|
429
|
+
payload: {},
|
|
430
|
+
});
|
|
431
|
+
const value = unwrapNativeResult(response);
|
|
432
|
+
return value.items;
|
|
433
|
+
}
|
|
434
|
+
async openRemoteWorkspace(targetDeviceId, path, signal) {
|
|
435
|
+
if (path.trim() === '')
|
|
436
|
+
throw new ClientModeError('INVALID_MESSAGE', 'A remote working directory is required.');
|
|
437
|
+
const remote = await this.ensureConnected(targetDeviceId, signal);
|
|
438
|
+
const transport = this.selectHarnessRemoteTransport(remote);
|
|
439
|
+
let workspace;
|
|
440
|
+
if (transport === 'remoteGateway') {
|
|
441
|
+
workspace = await new RemoteTypertGateway(remote.client).invoke({
|
|
442
|
+
namespace: 'workspace',
|
|
443
|
+
method: 'create',
|
|
444
|
+
args: { request: { path } },
|
|
445
|
+
...(signal === undefined ? {} : { signal }),
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
else {
|
|
449
|
+
const api = new RemoteHarnessApiProxy(remote.client).api;
|
|
450
|
+
const response = await api.workspace.create({
|
|
451
|
+
rpcId: `remote-workspace-${Date.now()}`,
|
|
452
|
+
payload: { path },
|
|
453
|
+
});
|
|
454
|
+
workspace = unwrapNativeResult(response);
|
|
455
|
+
}
|
|
456
|
+
await this.closeCodexVirtual();
|
|
457
|
+
this.selectRemoteTarget(remote, transport);
|
|
458
|
+
const workspaceId = workspaceRecordId(workspace.workspace);
|
|
459
|
+
this.pendingWorkspaceSelection = { targetDeviceId: remote.target.deviceId, workspaceId };
|
|
460
|
+
this.logger.info('Remote workspace opened', { targetDeviceId: shortId(remote.target.deviceId) });
|
|
461
|
+
return { ...this.status(), workspace };
|
|
462
|
+
}
|
|
463
|
+
async listCodexWorkspaces(targetDeviceId, signal) {
|
|
464
|
+
const remote = await this.ensureConnected(targetDeviceId, signal);
|
|
465
|
+
remote.features = await probeRemoteHostFeatures(remote.client, remote.clientVersion);
|
|
466
|
+
if (!remote.features.codex) {
|
|
467
|
+
throw new ClientModeError('FEATURE_NOT_SUPPORTED', 'The selected Host does not provide CodeX workspaces.');
|
|
468
|
+
}
|
|
469
|
+
return discoverCodexVirtualWorkspaces(new CodexRemoteClient(remote.client), signal);
|
|
470
|
+
}
|
|
471
|
+
async openCodexWorkspace(targetDeviceId, workspaceId, signal) {
|
|
472
|
+
const remote = await this.ensureConnected(targetDeviceId, signal);
|
|
473
|
+
remote.features = await probeRemoteHostFeatures(remote.client, remote.clientVersion);
|
|
474
|
+
if (!remote.features.codex) {
|
|
475
|
+
throw new ClientModeError('FEATURE_NOT_SUPPORTED', 'The selected Host does not provide CodeX workspaces.');
|
|
476
|
+
}
|
|
477
|
+
this.assertLocalHarnessCarrierAvailable();
|
|
478
|
+
const virtual = CodexVirtualHarness.remote(remote.client, {
|
|
479
|
+
deviceId: remote.target.deviceId,
|
|
480
|
+
name: remote.target.name,
|
|
481
|
+
}, harnessSessionGeneration(this.host?.localHarnessVersion?.()), new RemoteTypertGateway(remote.client));
|
|
482
|
+
let workspace;
|
|
483
|
+
try {
|
|
484
|
+
workspace = await virtual.selectWorkspace(workspaceId, signal);
|
|
485
|
+
}
|
|
486
|
+
catch {
|
|
487
|
+
await virtual.close();
|
|
488
|
+
throw new ClientModeError('WORKSPACE_NOT_FOUND', 'The selected CodeX workspace is no longer available.');
|
|
489
|
+
}
|
|
490
|
+
await this.closeCodexVirtual();
|
|
491
|
+
this.codexVirtual = virtual;
|
|
492
|
+
this.selectCodexTarget(virtual, remote);
|
|
493
|
+
const preferredSessionId = await virtual.preferredSessionId(signal);
|
|
494
|
+
this.pendingWorkspaceSelection = {
|
|
495
|
+
targetDeviceId: remote.target.deviceId,
|
|
496
|
+
workspaceId,
|
|
497
|
+
backend: 'codex',
|
|
498
|
+
...(preferredSessionId === undefined ? {} : { sessionId: preferredSessionId }),
|
|
499
|
+
};
|
|
500
|
+
this.logger.info('CodeX virtual workspace opened', { targetDeviceId: shortId(remote.target.deviceId) });
|
|
501
|
+
return { ...this.status(), workspace };
|
|
502
|
+
}
|
|
503
|
+
async createCodexWorkspace(targetDeviceId, path, signal) {
|
|
504
|
+
const trimmedPath = path.trim();
|
|
505
|
+
if (trimmedPath === '')
|
|
506
|
+
throw new ClientModeError('INVALID_MESSAGE', 'A CodeX project directory is required.');
|
|
507
|
+
const remote = await this.ensureConnected(targetDeviceId, signal);
|
|
508
|
+
remote.features = await probeRemoteHostFeatures(remote.client, remote.clientVersion);
|
|
509
|
+
if (!remote.features.codex) {
|
|
510
|
+
throw new ClientModeError('FEATURE_NOT_SUPPORTED', 'The selected Host does not provide CodeX workspaces.');
|
|
511
|
+
}
|
|
512
|
+
this.assertLocalHarnessCarrierAvailable();
|
|
513
|
+
const result = record(await new CodexRemoteClient(remote.client).request('project/create', {
|
|
514
|
+
name: remoteWorkspaceTitle(trimmedPath),
|
|
515
|
+
roots: [{ path: trimmedPath }],
|
|
516
|
+
idempotencyKey: uuidV7(),
|
|
517
|
+
}, signal));
|
|
518
|
+
const project = record(result.project);
|
|
519
|
+
if (typeof project.id !== 'string' || project.id.length === 0) {
|
|
520
|
+
throw new ClientModeError('INVALID_MESSAGE', 'The Host returned an invalid CodeX project.');
|
|
521
|
+
}
|
|
522
|
+
return this.openCodexWorkspace(targetDeviceId, codexProjectWorkspaceId(project.id), signal);
|
|
523
|
+
}
|
|
524
|
+
consumeWorkspaceSelection(selection) {
|
|
525
|
+
const pending = this.pendingWorkspaceSelection;
|
|
526
|
+
if (pending?.targetDeviceId === selection.targetDeviceId
|
|
527
|
+
&& pending.workspaceId === selection.workspaceId
|
|
528
|
+
&& (pending.backend ?? 'harness') === (selection.backend ?? 'harness')) {
|
|
529
|
+
this.pendingWorkspaceSelection = undefined;
|
|
530
|
+
}
|
|
531
|
+
return this.status();
|
|
532
|
+
}
|
|
533
|
+
async close() {
|
|
534
|
+
await this.closePreview();
|
|
535
|
+
if (this.closed)
|
|
536
|
+
return;
|
|
537
|
+
this.closed = true;
|
|
538
|
+
this.proxySwitch?.selectLocal();
|
|
539
|
+
await this.closePreview();
|
|
540
|
+
this.gatewaySwitch.selectLocal();
|
|
541
|
+
this.pendingWorkspaceSelection = undefined;
|
|
542
|
+
await this.closeCodexVirtual();
|
|
543
|
+
await this.closeCodexStreams(this.connected?.client);
|
|
544
|
+
await this.connected?.client.close().catch(() => undefined);
|
|
545
|
+
this.connected = undefined;
|
|
546
|
+
this.connectionProgress = undefined;
|
|
547
|
+
this.proxySwitch?.restore();
|
|
548
|
+
this.gatewaySwitch.restore();
|
|
549
|
+
}
|
|
550
|
+
async callRemoteFileViewer(endpoint, payload, signal) {
|
|
551
|
+
const remote = this.connected;
|
|
552
|
+
if (remote === undefined || this.status().mode !== 'remote') {
|
|
553
|
+
throw new ClientModeError('REMOTE_NOT_CONNECTED', 'No Remote Host is selected.', true);
|
|
554
|
+
}
|
|
555
|
+
if (!remote.features.fileViewer) {
|
|
556
|
+
throw new ClientModeError('FEATURE_NOT_SUPPORTED', 'The selected Remote Host does not support remote file viewing.');
|
|
557
|
+
}
|
|
558
|
+
return remote.client.rpc('fileviewer.call', { endpoint, payload }, signal);
|
|
559
|
+
}
|
|
560
|
+
activeRemote() {
|
|
561
|
+
return this.connected;
|
|
562
|
+
}
|
|
563
|
+
activeCodexRemote() {
|
|
564
|
+
const remote = this.activeRemote();
|
|
565
|
+
if (remote === undefined)
|
|
566
|
+
return undefined;
|
|
567
|
+
if (!remote.features.codex) {
|
|
568
|
+
return undefined;
|
|
569
|
+
}
|
|
570
|
+
return remote;
|
|
571
|
+
}
|
|
572
|
+
async openCodexStream(payload, signal) {
|
|
573
|
+
const remote = this.activeCodexRemote();
|
|
574
|
+
const value = record(payload);
|
|
575
|
+
if (typeof value.streamId !== 'string' || value.streamId.length === 0 || value.streamId.length > 128
|
|
576
|
+
|| typeof value.threadId !== 'string' || value.threadId.length === 0) {
|
|
577
|
+
throw new ClientModeError('INVALID_MESSAGE', 'A Codex stream and thread are required.');
|
|
578
|
+
}
|
|
579
|
+
if (this.codexStreams.has(value.streamId))
|
|
580
|
+
throw new ClientModeError('REQUEST_CONFLICT', 'The Codex stream is already open.');
|
|
581
|
+
let wake = () => undefined;
|
|
582
|
+
const stream = {
|
|
583
|
+
target: remote === undefined ? { kind: 'local' } : { kind: 'remote', client: remote.client },
|
|
584
|
+
frames: [],
|
|
585
|
+
unsubscribe: () => undefined,
|
|
586
|
+
close: async () => {
|
|
587
|
+
if (remote === undefined) {
|
|
588
|
+
await this.host?.codexCloseStream?.({ streamId: value.streamId }).catch(() => undefined);
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
await remote.client.rpc('codex.app.stream.close', { streamId: value.streamId }).catch(() => undefined);
|
|
592
|
+
},
|
|
593
|
+
wake: () => wake(),
|
|
594
|
+
};
|
|
595
|
+
if (remote === undefined) {
|
|
596
|
+
const host = this.requireLocalCodex();
|
|
597
|
+
this.codexStreams.set(value.streamId, stream);
|
|
598
|
+
try {
|
|
599
|
+
const result = await host.codexOpenStream({ streamId: value.streamId, threadId: value.threadId }, this.publishLocalCodexFrame, signal);
|
|
600
|
+
return result;
|
|
601
|
+
}
|
|
602
|
+
catch (error) {
|
|
603
|
+
this.codexStreams.delete(value.streamId);
|
|
604
|
+
stream.wake();
|
|
605
|
+
throw error;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
stream.unsubscribe = remote.client.onEvent(event => {
|
|
609
|
+
if (event.event === 'codex.app.frame' && isRecord(event.data) && event.data.streamId === value.streamId) {
|
|
610
|
+
this.appendCodexFrame(stream, event.data);
|
|
611
|
+
}
|
|
612
|
+
if (event.event === 'codex.app.stream.closed' && isRecord(event.data) && event.data.streamId === value.streamId) {
|
|
613
|
+
stream.closed = typeof event.data.reason === 'string' ? event.data.reason : 'closed';
|
|
614
|
+
stream.wake();
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
try {
|
|
618
|
+
// Subscribe before opening the Host stream so the first App Server
|
|
619
|
+
// notification cannot race past the loopback listener.
|
|
620
|
+
await remote.client.rpc('codex.app.stream.open', { streamId: value.streamId, threadId: value.threadId }, signal);
|
|
621
|
+
}
|
|
622
|
+
catch (error) {
|
|
623
|
+
stream.unsubscribe();
|
|
624
|
+
throw error;
|
|
625
|
+
}
|
|
626
|
+
this.codexStreams.set(value.streamId, stream);
|
|
627
|
+
return { opened: true, streamId: value.streamId, threadId: value.threadId };
|
|
628
|
+
}
|
|
629
|
+
publishLocalCodexFrame = async (event, data) => {
|
|
630
|
+
const streamId = data.streamId;
|
|
631
|
+
const stream = this.codexStreams.get(streamId);
|
|
632
|
+
if (stream === undefined || stream.target.kind !== 'local')
|
|
633
|
+
return;
|
|
634
|
+
if (event === 'codex.app.frame') {
|
|
635
|
+
this.appendCodexFrame(stream, data);
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
const closed = data;
|
|
639
|
+
stream.closed = typeof closed.reason === 'string' ? closed.reason : 'closed';
|
|
640
|
+
stream.wake();
|
|
641
|
+
};
|
|
642
|
+
appendCodexFrame(stream, data) {
|
|
643
|
+
if (!isRecord(data) || !isRecord(data.frame) || typeof data.frame.method !== 'string')
|
|
644
|
+
return;
|
|
645
|
+
if (stream.frames.length >= 256) {
|
|
646
|
+
stream.closed = 'overflow';
|
|
647
|
+
}
|
|
648
|
+
else {
|
|
649
|
+
stream.frames.push({ method: data.frame.method, params: data.frame.params });
|
|
650
|
+
}
|
|
651
|
+
stream.wake();
|
|
652
|
+
}
|
|
653
|
+
localCodexAvailable() {
|
|
654
|
+
return this.host?.codexStatus?.().available === true;
|
|
655
|
+
}
|
|
656
|
+
requireLocalCodex() {
|
|
657
|
+
if (!this.localCodexAvailable()
|
|
658
|
+
|| this.host?.codexCall === undefined
|
|
659
|
+
|| this.host.codexRespond === undefined
|
|
660
|
+
|| this.host.codexOpenStream === undefined
|
|
661
|
+
|| this.host.codexCloseStream === undefined) {
|
|
662
|
+
throw new ClientModeError('FEATURE_NOT_SUPPORTED', 'Local CodeX is disabled or unavailable on this Host.');
|
|
663
|
+
}
|
|
664
|
+
return {
|
|
665
|
+
codexCall: this.host.codexCall.bind(this.host),
|
|
666
|
+
codexRespond: this.host.codexRespond.bind(this.host),
|
|
667
|
+
codexOpenStream: this.host.codexOpenStream.bind(this.host),
|
|
668
|
+
codexCloseStream: this.host.codexCloseStream.bind(this.host),
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
async nextCodexFrames(payload, signal) {
|
|
672
|
+
const value = record(payload);
|
|
673
|
+
if (typeof value.streamId !== 'string')
|
|
674
|
+
throw new ClientModeError('INVALID_MESSAGE', 'A Codex stream is required.');
|
|
675
|
+
const stream = this.codexStreams.get(value.streamId);
|
|
676
|
+
if (stream === undefined)
|
|
677
|
+
throw new ClientModeError('STREAM_NOT_FOUND', 'The Codex stream is not open.');
|
|
678
|
+
if (stream.frames.length === 0 && stream.closed === undefined)
|
|
679
|
+
await waitForCodexFrames(stream, signal);
|
|
680
|
+
const frames = stream.frames.splice(0, 100);
|
|
681
|
+
return {
|
|
682
|
+
streamId: value.streamId,
|
|
683
|
+
frames,
|
|
684
|
+
closed: stream.closed !== undefined,
|
|
685
|
+
...(stream.closed === undefined ? {} : { reason: stream.closed }),
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
async closeCodexStream(payload) {
|
|
689
|
+
const value = record(payload);
|
|
690
|
+
if (typeof value.streamId !== 'string')
|
|
691
|
+
throw new ClientModeError('INVALID_MESSAGE', 'A Codex stream is required.');
|
|
692
|
+
const stream = this.codexStreams.get(value.streamId);
|
|
693
|
+
if (stream === undefined)
|
|
694
|
+
return { closed: false, streamId: value.streamId };
|
|
695
|
+
this.codexStreams.delete(value.streamId);
|
|
696
|
+
stream.unsubscribe();
|
|
697
|
+
stream.wake();
|
|
698
|
+
await stream.close();
|
|
699
|
+
return { closed: true, streamId: value.streamId };
|
|
700
|
+
}
|
|
701
|
+
async closeCodexStreams(client) {
|
|
702
|
+
const targets = [...this.codexStreams.entries()].filter(([, stream]) => (client === undefined || (stream.target.kind === 'remote' && stream.target.client === client)));
|
|
703
|
+
await Promise.all(targets.map(async ([streamId, stream]) => {
|
|
704
|
+
this.codexStreams.delete(streamId);
|
|
705
|
+
stream.unsubscribe();
|
|
706
|
+
stream.closed = 'peer-disconnected';
|
|
707
|
+
stream.wake();
|
|
708
|
+
await stream.close();
|
|
709
|
+
}));
|
|
710
|
+
}
|
|
711
|
+
selectRemoteTarget(remote, transport = this.selectHarnessRemoteTransport(remote)) {
|
|
712
|
+
const target = { deviceId: remote.target.deviceId, name: remote.target.name };
|
|
713
|
+
if (transport === 'remoteGateway') {
|
|
714
|
+
this.gatewaySwitch.selectRemote(this.remoteTypertGateway(remote), undefined, target);
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
this.proxySwitch?.selectRemote(new RemoteHarnessApiProxy(remote.client, remote.harnessVersion).api, target);
|
|
718
|
+
this.gatewaySwitch.selectRemote(request => invokeRemoteCommand(remote.client, request), {
|
|
719
|
+
execute: true,
|
|
720
|
+
list: remote.features.commandList,
|
|
721
|
+
}, target);
|
|
722
|
+
}
|
|
723
|
+
remoteTypertGateway(remote) {
|
|
724
|
+
const localSessionGeneration = harnessSessionGeneration(this.host?.localHarnessVersion?.());
|
|
725
|
+
return new RemoteTypertGateway(remote.client, localSessionGeneration === 'v3' && remote.features.sessionFormat !== 3 ? 'legacy-to-v3' : undefined, remote.harnessVersion);
|
|
726
|
+
}
|
|
727
|
+
selectCodexTarget(virtual, remote) {
|
|
728
|
+
const target = { deviceId: remote.target.deviceId, name: remote.target.name };
|
|
729
|
+
// The Harness answers every `/api` endpoint once it is the Codex target, so give it
|
|
730
|
+
// the local carriers: everything outside the CodeX domain (the shell's settings
|
|
731
|
+
// bootstrap, plugin registry and account reads) has to describe this installation.
|
|
732
|
+
virtual.setLocalCarrier(this.gatewaySwitch.localCarrier());
|
|
733
|
+
if (this.gatewaySwitch.supportsCarrier()) {
|
|
734
|
+
this.gatewaySwitch.selectRemote(virtual, undefined, target);
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
this.proxySwitch.selectRemote(virtual.api, target);
|
|
738
|
+
this.gatewaySwitch.selectRemote(request => virtual.invoke(request), { execute: true, list: true }, target);
|
|
739
|
+
}
|
|
740
|
+
async closeCodexVirtual() {
|
|
741
|
+
const virtual = this.codexVirtual;
|
|
742
|
+
this.codexVirtual = undefined;
|
|
743
|
+
await virtual?.close();
|
|
744
|
+
}
|
|
745
|
+
assertRemoteCompatible(remote) {
|
|
746
|
+
this.selectHarnessRemoteTransport(remote);
|
|
747
|
+
}
|
|
748
|
+
selectHarnessRemoteTransport(remote) {
|
|
749
|
+
const localRemoteGateway = this.gatewaySwitch.supportsCarrier();
|
|
750
|
+
const localSessionGeneration = harnessSessionGeneration(this.host?.localHarnessVersion?.());
|
|
751
|
+
if (localRemoteGateway && remote.features.remoteGateway) {
|
|
752
|
+
if (localSessionGeneration === 'v3' || remote.features.sessionFormat !== 3)
|
|
753
|
+
return 'remoteGateway';
|
|
754
|
+
throw new ClientModeError('HARNESS_VERSION_INCOMPATIBLE', 'The selected Host uses Harness Session V3, but this Client uses the legacy Typert Remote session format.');
|
|
755
|
+
}
|
|
756
|
+
if (this.proxySwitch !== undefined && remote.features.apiProxy)
|
|
757
|
+
return 'apiProxy';
|
|
758
|
+
throw new ClientModeError('HARNESS_VERSION_INCOMPATIBLE', localRemoteGateway
|
|
759
|
+
? 'The selected Host does not provide a compatible Harness Typert Remote Gateway transport.'
|
|
760
|
+
: 'The selected Host does not provide the legacy Harness ApiProxy transport.');
|
|
761
|
+
}
|
|
762
|
+
assertLocalHarnessCarrierAvailable() {
|
|
763
|
+
if (this.gatewaySwitch.supportsCarrier() || this.proxySwitch !== undefined)
|
|
764
|
+
return;
|
|
765
|
+
throw new ClientModeError('HARNESS_VERSION_INCOMPATIBLE', 'This Client does not provide a compatible Harness carrier for the selected remote workspace.');
|
|
766
|
+
}
|
|
767
|
+
async connect(targetDeviceId, signal) {
|
|
768
|
+
signal?.throwIfAborted();
|
|
769
|
+
const progressRunId = this.connectionProgressRun + 1;
|
|
770
|
+
this.connectionProgressRun = progressRunId;
|
|
771
|
+
this.connectionProgress = { runId: progressRunId, targetDeviceId, phase: 'checking-host' };
|
|
772
|
+
const identity = this.requireIdentity();
|
|
773
|
+
let client;
|
|
774
|
+
try {
|
|
775
|
+
const serverDevice = (await this.server.listDevices()).find(device => device.deviceId === targetDeviceId);
|
|
776
|
+
if (serverDevice === undefined) {
|
|
777
|
+
throw new ClientModeError('MEMBERSHIP_REQUIRED', 'The selected Host is not authorized for this account.');
|
|
778
|
+
}
|
|
779
|
+
this.updateConnectionProgress(progressRunId, 'authorizing-peer');
|
|
780
|
+
const target = await this.authorizeHostPeer(serverDevice);
|
|
781
|
+
const presence = await this.server.presenceFor(targetDeviceId);
|
|
782
|
+
if (!presence.online)
|
|
783
|
+
throw new ClientModeError('HOST_OFFLINE', 'The selected Host is offline.', true);
|
|
784
|
+
const credentials = await this.server.authenticate(identity);
|
|
785
|
+
const rtcFactory = this.config.forceRelay
|
|
786
|
+
? undefined
|
|
787
|
+
: await this.rtcFactoryProvider({ routeTargets: [this.server.baseUrl] }).catch(() => undefined);
|
|
788
|
+
if (!this.config.forceRelay && rtcFactory === undefined) {
|
|
789
|
+
this.logger.warn('remote Harness WebRTC backend unavailable; using relay', {
|
|
790
|
+
targetDeviceId: shortId(target.deviceId),
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
let webRtcFallback = false;
|
|
794
|
+
const createTransport = (attempt) => new AdaptiveTransport(websocketUrl(this.server.baseUrl), {
|
|
795
|
+
role: 'client',
|
|
796
|
+
deviceId: identity.deviceId,
|
|
797
|
+
accessToken: credentials.accessToken,
|
|
798
|
+
targetDeviceId,
|
|
799
|
+
forceRelay: this.config.forceRelay || attempt === 'relay',
|
|
800
|
+
preferredTransports: preferredTransportsForAttempt(attempt),
|
|
801
|
+
negotiateTimeoutMs: attempt === 'direct' ? DIRECT_WEBRTC_NEGOTIATE_TIMEOUT_MS : undefined,
|
|
802
|
+
...(rtcFactory === undefined || attempt === 'relay' ? {} : { rtcFactory }),
|
|
803
|
+
fetchIceServers: async (connectionId) => iceServersForAttempt(attempt, await this.server.turnCredentials(connectionId)),
|
|
804
|
+
onWebRtcFallback: (error, diagnostics) => {
|
|
805
|
+
webRtcFallback = true;
|
|
806
|
+
this.logger.warn(attempt === 'direct'
|
|
807
|
+
? 'remote Harness direct WebRTC failed; trying TURN'
|
|
808
|
+
: 'remote Harness TURN WebRTC failed; using relay', {
|
|
809
|
+
targetDeviceId: shortId(target.deviceId),
|
|
810
|
+
attempt,
|
|
811
|
+
reason: diagnosticReason(error),
|
|
812
|
+
});
|
|
813
|
+
if (diagnostics !== undefined) {
|
|
814
|
+
this.logger.debug('remote Harness WebRTC fallback diagnostics', {
|
|
815
|
+
targetDeviceId: shortId(target.deviceId),
|
|
816
|
+
attempt,
|
|
817
|
+
...webrtcDiagnosticsLogFields(diagnostics),
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
},
|
|
821
|
+
});
|
|
822
|
+
const attempts = this.config.forceRelay || rtcFactory === undefined
|
|
823
|
+
? ['relay']
|
|
824
|
+
: ['direct', 'turn', 'relay'];
|
|
825
|
+
let transport;
|
|
826
|
+
for (const attempt of attempts) {
|
|
827
|
+
webRtcFallback = false;
|
|
828
|
+
const stopProgressTimer = this.beginAttemptProgress(progressRunId, attempt);
|
|
829
|
+
try {
|
|
830
|
+
transport = createTransport(attempt);
|
|
831
|
+
client = new RemoteClientCore(new ClientSecureTransport(transport, identity, target), 60_000);
|
|
832
|
+
await client.connect();
|
|
833
|
+
signal?.throwIfAborted();
|
|
834
|
+
}
|
|
835
|
+
finally {
|
|
836
|
+
stopProgressTimer();
|
|
837
|
+
}
|
|
838
|
+
if (attempt === 'relay' || !webRtcFallback)
|
|
839
|
+
break;
|
|
840
|
+
await client.close();
|
|
841
|
+
client = undefined;
|
|
842
|
+
transport = undefined;
|
|
843
|
+
if (attempt === 'turn') {
|
|
844
|
+
this.logger.info('remote Harness relay fallback re-established', {
|
|
845
|
+
targetDeviceId: shortId(target.deviceId),
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
if (client === undefined || transport === undefined) {
|
|
850
|
+
throw new ClientModeError('CONNECTION_FAILED', 'Unable to establish a remote transport.', true);
|
|
851
|
+
}
|
|
852
|
+
const connectedClient = client;
|
|
853
|
+
const connectedTransport = transport;
|
|
854
|
+
const connectedPreference = transportPreferenceForMode(connectedClient.getStats().mode);
|
|
855
|
+
this.updateConnectionProgress(progressRunId, 'connected', connectedPreference === undefined ? undefined : [connectedPreference]);
|
|
856
|
+
connectedClient.onClose(() => {
|
|
857
|
+
if (this.connected?.client !== connectedClient)
|
|
858
|
+
return;
|
|
859
|
+
void this.closePreview();
|
|
860
|
+
this.connected = undefined;
|
|
861
|
+
this.connectionProgress = undefined;
|
|
862
|
+
this.pendingWorkspaceSelection = undefined;
|
|
863
|
+
void this.closeCodexVirtual();
|
|
864
|
+
this.proxySwitch?.selectLocal();
|
|
865
|
+
this.gatewaySwitch.selectLocal();
|
|
866
|
+
// The UI keeps whatever the remote session rendered and offers no exit
|
|
867
|
+
// route once the mode reads 'local' again, so remember that the session
|
|
868
|
+
// dropped. The card uses this to keep a way back to the local shell.
|
|
869
|
+
this.fellBackToLocal = true;
|
|
870
|
+
void connectedClient.close().catch(() => undefined);
|
|
871
|
+
this.logger.warn('remote Harness transport closed; reconnecting', {
|
|
872
|
+
targetDeviceId: shortId(target.deviceId),
|
|
873
|
+
});
|
|
874
|
+
void this.reconnectRemoteSession(target.deviceId);
|
|
875
|
+
});
|
|
876
|
+
const connectionDetails = await connectedTransport.connectionDetails().catch(() => undefined);
|
|
877
|
+
this.logger.info('remote Harness transport ready', {
|
|
878
|
+
targetDeviceId: shortId(target.deviceId),
|
|
879
|
+
transport: connectedClient.getStats().mode,
|
|
880
|
+
...(connectionDetails === undefined ? {} : {
|
|
881
|
+
preferredTransports: connectionDetails.preferredTransports,
|
|
882
|
+
negotiatedCapabilities: connectionDetails.negotiatedCapabilities,
|
|
883
|
+
webRtcEnabled: connectionDetails.webRtcEnabled,
|
|
884
|
+
}),
|
|
885
|
+
});
|
|
886
|
+
if (connectionDetails?.webRtc?.diagnostics !== undefined) {
|
|
887
|
+
this.logger.debug('remote Harness transport diagnostics', {
|
|
888
|
+
targetDeviceId: shortId(target.deviceId),
|
|
889
|
+
...webrtcDiagnosticsLogFields(connectionDetails.webRtc.diagnostics),
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
const features = await probeRemoteHostFeatures(connectedClient, serverDevice.clientVersion);
|
|
893
|
+
return {
|
|
894
|
+
client: connectedClient,
|
|
895
|
+
target,
|
|
896
|
+
transport: connectedTransport,
|
|
897
|
+
features,
|
|
898
|
+
progressRunId,
|
|
899
|
+
...(serverDevice.clientVersion === undefined ? {} : { clientVersion: serverDevice.clientVersion }),
|
|
900
|
+
...(serverDevice.harnessVersion === undefined ? {} : { harnessVersion: serverDevice.harnessVersion }),
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
catch (error) {
|
|
904
|
+
this.clearConnectionProgress(progressRunId);
|
|
905
|
+
await client?.close().catch(() => undefined);
|
|
906
|
+
throw error;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
async ensureConnected(targetDeviceId, signal) {
|
|
910
|
+
if (this.connected?.target.deviceId === targetDeviceId)
|
|
911
|
+
return this.connected;
|
|
912
|
+
const next = await this.connect(targetDeviceId, signal);
|
|
913
|
+
const previous = this.connected;
|
|
914
|
+
await this.closePreview();
|
|
915
|
+
this.connected = next;
|
|
916
|
+
this.clearConnectionProgress(next.progressRunId);
|
|
917
|
+
await previous?.client.close().catch(() => undefined);
|
|
918
|
+
return next;
|
|
919
|
+
}
|
|
920
|
+
beginAttemptProgress(runId, attempt) {
|
|
921
|
+
if (attempt !== 'direct') {
|
|
922
|
+
this.updateConnectionProgress(runId, 'probing', [attempt]);
|
|
923
|
+
return () => undefined;
|
|
924
|
+
}
|
|
925
|
+
this.updateConnectionProgress(runId, 'probing', ['lan']);
|
|
926
|
+
// LAN and public P2P candidates are gathered inside one ICE attempt. The
|
|
927
|
+
// linear Remote UI still needs a single active route, so advance the
|
|
928
|
+
// visible cue if direct negotiation takes longer than a local probe.
|
|
929
|
+
const timer = setTimeout(() => {
|
|
930
|
+
if (this.connectionProgress?.runId === runId && this.connectionProgress.phase === 'probing') {
|
|
931
|
+
this.updateConnectionProgress(runId, 'probing', ['p2p']);
|
|
932
|
+
}
|
|
933
|
+
}, DIRECT_LAN_PROGRESS_DISPLAY_MS);
|
|
934
|
+
return () => clearTimeout(timer);
|
|
935
|
+
}
|
|
936
|
+
updateConnectionProgress(runId, phase, activeTransports) {
|
|
937
|
+
if (this.connectionProgress?.runId !== runId)
|
|
938
|
+
return;
|
|
939
|
+
this.connectionProgress = {
|
|
940
|
+
runId,
|
|
941
|
+
targetDeviceId: this.connectionProgress.targetDeviceId,
|
|
942
|
+
phase,
|
|
943
|
+
...(activeTransports === undefined ? {} : { activeTransports }),
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
clearConnectionProgress(runId) {
|
|
947
|
+
if (this.connectionProgress?.runId === runId)
|
|
948
|
+
this.connectionProgress = undefined;
|
|
949
|
+
}
|
|
950
|
+
async handleControl(endpoint, payload, signal) {
|
|
951
|
+
try {
|
|
952
|
+
if (endpoint === 'status')
|
|
953
|
+
return ok(await this.detailedStatus());
|
|
954
|
+
if (endpoint === 'devices')
|
|
955
|
+
return ok(await this.devices());
|
|
956
|
+
if (endpoint === 'client.account.login') {
|
|
957
|
+
const value = record(payload);
|
|
958
|
+
if (typeof value.email !== 'string' || typeof value.password !== 'string') {
|
|
959
|
+
throw new ClientModeError('INVALID_MESSAGE', 'Email and password are required.');
|
|
960
|
+
}
|
|
961
|
+
return ok(await this.authorizeClientWithAccount(value.email, value.password));
|
|
962
|
+
}
|
|
963
|
+
if (endpoint === 'client.account.qr.start') {
|
|
964
|
+
const value = record(payload);
|
|
965
|
+
const provider = value.provider ?? 'zhihu';
|
|
966
|
+
if (provider !== 'zhihu' && provider !== 'github') {
|
|
967
|
+
throw new ClientModeError('INVALID_MESSAGE', 'A supported OAuth provider is required.');
|
|
968
|
+
}
|
|
969
|
+
return ok(await this.startClientOAuthQrLogin(provider));
|
|
970
|
+
}
|
|
971
|
+
if (endpoint === 'client.account.qr.poll') {
|
|
972
|
+
const value = record(payload);
|
|
973
|
+
if (typeof value.qrId !== 'string' || value.qrId.length < 20) {
|
|
974
|
+
throw new ClientModeError('INVALID_MESSAGE', 'A QR login session is required.');
|
|
975
|
+
}
|
|
976
|
+
return ok(await this.pollClientOAuthQrLogin(value.qrId));
|
|
977
|
+
}
|
|
978
|
+
if (endpoint === 'preview.open') {
|
|
979
|
+
const remote = this.activeRemote();
|
|
980
|
+
if (remote === undefined)
|
|
981
|
+
throw new ClientModeError('TRANSPORT_CLOSED', 'Connect to a Remote Host first.');
|
|
982
|
+
this.preview ??= new LoopbackPreview(remote.client);
|
|
983
|
+
return ok(await this.preview.open(record(payload).port));
|
|
984
|
+
}
|
|
985
|
+
if (endpoint === 'directory.list') {
|
|
986
|
+
const value = record(payload);
|
|
987
|
+
if (typeof value.targetDeviceId !== 'string')
|
|
988
|
+
throw new ClientModeError('INVALID_MESSAGE', 'A Host is required.');
|
|
989
|
+
return ok(await this.listRemoteDirectory(value.targetDeviceId, typeof value.path === 'string' ? value.path : undefined, signal));
|
|
990
|
+
}
|
|
991
|
+
if (endpoint === 'workspaces.list') {
|
|
992
|
+
const value = record(payload);
|
|
993
|
+
if (typeof value.targetDeviceId !== 'string')
|
|
994
|
+
throw new ClientModeError('INVALID_MESSAGE', 'A Host is required.');
|
|
995
|
+
return ok(await this.listRemoteWorkspaces(value.targetDeviceId, signal));
|
|
996
|
+
}
|
|
997
|
+
if (endpoint === 'codex.workspaces.list') {
|
|
998
|
+
const value = record(payload);
|
|
999
|
+
if (typeof value.targetDeviceId !== 'string')
|
|
1000
|
+
throw new ClientModeError('INVALID_MESSAGE', 'A Host is required.');
|
|
1001
|
+
return ok(await this.listCodexWorkspaces(value.targetDeviceId, signal));
|
|
1002
|
+
}
|
|
1003
|
+
if (endpoint === 'workspace.open') {
|
|
1004
|
+
const value = record(payload);
|
|
1005
|
+
if (typeof value.targetDeviceId !== 'string' || typeof value.path !== 'string') {
|
|
1006
|
+
throw new ClientModeError('INVALID_MESSAGE', 'A Host and working directory are required.');
|
|
1007
|
+
}
|
|
1008
|
+
return ok(await this.openRemoteWorkspace(value.targetDeviceId, value.path, signal));
|
|
1009
|
+
}
|
|
1010
|
+
if (endpoint === 'codex.workspace.open') {
|
|
1011
|
+
const value = record(payload);
|
|
1012
|
+
if (typeof value.targetDeviceId !== 'string' || typeof value.workspaceId !== 'string') {
|
|
1013
|
+
throw new ClientModeError('INVALID_MESSAGE', 'A Host and CodeX Workspace are required.');
|
|
1014
|
+
}
|
|
1015
|
+
return ok(await this.openCodexWorkspace(value.targetDeviceId, value.workspaceId, signal));
|
|
1016
|
+
}
|
|
1017
|
+
if (endpoint === 'codex.workspace.create') {
|
|
1018
|
+
const value = record(payload);
|
|
1019
|
+
if (typeof value.targetDeviceId !== 'string' || typeof value.path !== 'string') {
|
|
1020
|
+
throw new ClientModeError('INVALID_MESSAGE', 'A Host and CodeX project directory are required.');
|
|
1021
|
+
}
|
|
1022
|
+
return ok(await this.createCodexWorkspace(value.targetDeviceId, value.path, signal));
|
|
1023
|
+
}
|
|
1024
|
+
if (endpoint === 'workspace.selection.consume') {
|
|
1025
|
+
const value = record(payload);
|
|
1026
|
+
if (typeof value.targetDeviceId !== 'string' || typeof value.workspaceId !== 'string') {
|
|
1027
|
+
throw new ClientModeError('INVALID_MESSAGE', 'A Host and Workspace are required.');
|
|
1028
|
+
}
|
|
1029
|
+
return ok(this.consumeWorkspaceSelection({
|
|
1030
|
+
targetDeviceId: value.targetDeviceId,
|
|
1031
|
+
workspaceId: value.workspaceId,
|
|
1032
|
+
...(value.backend === 'codex' ? { backend: 'codex' } : {}),
|
|
1033
|
+
...(typeof value.sessionId === 'string' ? { sessionId: value.sessionId } : {}),
|
|
1034
|
+
}));
|
|
1035
|
+
}
|
|
1036
|
+
if (endpoint === 'fileviewer.stat' || endpoint === 'fileviewer.readRange' || endpoint === 'fileviewer.list') {
|
|
1037
|
+
const method = endpoint === 'fileviewer.stat'
|
|
1038
|
+
? 'stat'
|
|
1039
|
+
: endpoint === 'fileviewer.readRange' ? 'readRange' : 'list';
|
|
1040
|
+
return ok(await this.callRemoteFileViewer(method, payload, signal));
|
|
1041
|
+
}
|
|
1042
|
+
if (endpoint === 'codex.call') {
|
|
1043
|
+
const value = record(payload);
|
|
1044
|
+
if (typeof value.method !== 'string' || !('params' in value)) {
|
|
1045
|
+
throw new ClientModeError('INVALID_MESSAGE', 'A Codex method and params are required.');
|
|
1046
|
+
}
|
|
1047
|
+
const remote = this.activeCodexRemote();
|
|
1048
|
+
if (remote !== undefined)
|
|
1049
|
+
return ok(await new CodexRemoteClient(remote.client).request(value.method, value.params, signal));
|
|
1050
|
+
const host = this.requireLocalCodex();
|
|
1051
|
+
return ok(await host.codexCall(value, signal));
|
|
1052
|
+
}
|
|
1053
|
+
if (endpoint === 'codex.probe') {
|
|
1054
|
+
const local = this.localCodexAvailable();
|
|
1055
|
+
let remoteSupported = false;
|
|
1056
|
+
const remote = this.activeRemote();
|
|
1057
|
+
if (remote !== undefined) {
|
|
1058
|
+
try {
|
|
1059
|
+
remote.features = await probeRemoteHostFeatures(remote.client, remote.clientVersion);
|
|
1060
|
+
remoteSupported = remote.features.codex;
|
|
1061
|
+
}
|
|
1062
|
+
catch (error) {
|
|
1063
|
+
if (!local)
|
|
1064
|
+
throw error;
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
return ok({ supported: local || remoteSupported, local, remote: remoteSupported });
|
|
1068
|
+
}
|
|
1069
|
+
if (endpoint === 'codex.respond') {
|
|
1070
|
+
const value = record(payload);
|
|
1071
|
+
const remote = this.activeCodexRemote();
|
|
1072
|
+
if (remote !== undefined)
|
|
1073
|
+
return ok(await remote.client.rpc('codex.app.respond', value, signal));
|
|
1074
|
+
const host = this.requireLocalCodex();
|
|
1075
|
+
return ok(await host.codexRespond(value, signal));
|
|
1076
|
+
}
|
|
1077
|
+
if (endpoint === 'codex.stream.open')
|
|
1078
|
+
return ok(await this.openCodexStream(payload, signal));
|
|
1079
|
+
if (endpoint === 'codex.stream.next')
|
|
1080
|
+
return ok(await this.nextCodexFrames(payload, signal));
|
|
1081
|
+
if (endpoint === 'codex.stream.close')
|
|
1082
|
+
return ok(await this.closeCodexStream(payload));
|
|
1083
|
+
if (endpoint === 'host.account.login') {
|
|
1084
|
+
if (this.host === undefined)
|
|
1085
|
+
throw new ClientModeError('METHOD_NOT_ALLOWED', 'This plugin is not running as a Host.');
|
|
1086
|
+
const value = record(payload);
|
|
1087
|
+
if (typeof value.email !== 'string' || typeof value.password !== 'string') {
|
|
1088
|
+
throw new ClientModeError('INVALID_MESSAGE', 'Email and password are required.');
|
|
1089
|
+
}
|
|
1090
|
+
return ok(await this.host.authorizeHostWithAccount(value.email, value.password));
|
|
1091
|
+
}
|
|
1092
|
+
if (endpoint === 'host.authorization.set') {
|
|
1093
|
+
const value = record(payload);
|
|
1094
|
+
if (typeof value.enabled !== 'boolean') {
|
|
1095
|
+
throw new ClientModeError('INVALID_MESSAGE', 'Host authorization state is required.');
|
|
1096
|
+
}
|
|
1097
|
+
return ok(await this.setHostAuthorization(value.enabled));
|
|
1098
|
+
}
|
|
1099
|
+
if (endpoint === 'host.registration-code.submit') {
|
|
1100
|
+
if (this.host === undefined)
|
|
1101
|
+
throw new ClientModeError('METHOD_NOT_ALLOWED', 'This plugin is not running as a Host.');
|
|
1102
|
+
const value = record(payload);
|
|
1103
|
+
if (typeof value.code !== 'string' || value.code.trim() === '') {
|
|
1104
|
+
throw new ClientModeError('INVALID_MESSAGE', 'A Host registration code is required.');
|
|
1105
|
+
}
|
|
1106
|
+
return ok(await this.host.authorizeHostWithCode(value.code));
|
|
1107
|
+
}
|
|
1108
|
+
if (endpoint === 'mode.set') {
|
|
1109
|
+
const value = record(payload);
|
|
1110
|
+
if (value.mode !== 'local' && value.mode !== 'remote')
|
|
1111
|
+
throw new ClientModeError('INVALID_MESSAGE', 'Mode must be local or remote.');
|
|
1112
|
+
return ok(await this.setMode(value.mode, typeof value.targetDeviceId === 'string' ? value.targetDeviceId : undefined, signal));
|
|
1113
|
+
}
|
|
1114
|
+
throw new ClientModeError('METHOD_NOT_FOUND', 'The remote-mode control method does not exist.');
|
|
1115
|
+
}
|
|
1116
|
+
catch (error) {
|
|
1117
|
+
return fail(error);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
requireIdentity() {
|
|
1121
|
+
if (this.identity === undefined)
|
|
1122
|
+
throw new ClientModeError('IDENTITY_INVALID', 'The client identity is not ready.');
|
|
1123
|
+
return this.identity;
|
|
1124
|
+
}
|
|
1125
|
+
async authorizeHostPeer(serverDevice) {
|
|
1126
|
+
const descriptor = await this.server.deviceFor(serverDevice.deviceId);
|
|
1127
|
+
assertAuthorizedHost(serverDevice, descriptor);
|
|
1128
|
+
const existing = this.identities.trustedPeer(descriptor.deviceId);
|
|
1129
|
+
if (existing !== undefined && existing.publicKey !== descriptor.identityKey) {
|
|
1130
|
+
throw new ClientModeError('PEER_IDENTITY_MISMATCH', 'The authorized Host identity key changed unexpectedly.');
|
|
1131
|
+
}
|
|
1132
|
+
if (existing !== undefined
|
|
1133
|
+
&& existing.membershipId === descriptor.membershipId
|
|
1134
|
+
&& existing.name === descriptor.name
|
|
1135
|
+
&& existing.platform === descriptor.platform) {
|
|
1136
|
+
return existing;
|
|
1137
|
+
}
|
|
1138
|
+
return this.identities.trustPeer({
|
|
1139
|
+
deviceId: descriptor.deviceId,
|
|
1140
|
+
name: descriptor.name,
|
|
1141
|
+
platform: descriptor.platform,
|
|
1142
|
+
publicKey: descriptor.identityKey,
|
|
1143
|
+
membershipId: descriptor.membershipId,
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
export class ClientModeError extends Error {
|
|
1148
|
+
code;
|
|
1149
|
+
retryable;
|
|
1150
|
+
constructor(code, message, retryable = false) {
|
|
1151
|
+
super(message);
|
|
1152
|
+
this.code = code;
|
|
1153
|
+
this.retryable = retryable;
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
function assertAuthorizedHost(listed, descriptor) {
|
|
1157
|
+
if (descriptor.role !== 'host' || descriptor.deviceId !== listed.deviceId
|
|
1158
|
+
|| descriptor.membershipId !== listed.membershipId) {
|
|
1159
|
+
throw new ClientModeError('PEER_IDENTITY_MISMATCH', 'Server Host details do not match the authorized device list.');
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
function websocketUrl(baseUrl) {
|
|
1163
|
+
const url = new URL(baseUrl);
|
|
1164
|
+
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
1165
|
+
url.pathname = `${url.pathname.replace(/\/$/, '')}/ws/v1/connect`;
|
|
1166
|
+
return url.toString();
|
|
1167
|
+
}
|
|
1168
|
+
function webrtcDiagnosticsLogFields(diagnostics) {
|
|
1169
|
+
if (diagnostics === undefined)
|
|
1170
|
+
return {};
|
|
1171
|
+
return {
|
|
1172
|
+
rtcConnectionState: diagnostics.connectionState,
|
|
1173
|
+
rtcIceConnectionState: diagnostics.iceConnectionState,
|
|
1174
|
+
rtcIceGatheringState: diagnostics.iceGatheringState,
|
|
1175
|
+
rtcLocalCandidates: diagnostics.localCandidates,
|
|
1176
|
+
rtcRemoteCandidates: diagnostics.remoteCandidates,
|
|
1177
|
+
rtcCandidatePairs: diagnostics.candidatePairs,
|
|
1178
|
+
rtcFilteredLocalCandidates: diagnostics.filteredLocalCandidates,
|
|
1179
|
+
rtcFilteredCandidatePairs: diagnostics.filteredCandidatePairs,
|
|
1180
|
+
...(diagnostics.selectedPath === undefined ? {} : { rtcSelectedPath: diagnostics.selectedPath }),
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
function record(value) {
|
|
1184
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
1185
|
+
throw new ClientModeError('INVALID_MESSAGE', 'The control request payload is invalid.');
|
|
1186
|
+
}
|
|
1187
|
+
return value;
|
|
1188
|
+
}
|
|
1189
|
+
function isRecord(value) {
|
|
1190
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
1191
|
+
}
|
|
1192
|
+
function ok(value) { return { ok: true, value }; }
|
|
1193
|
+
async function invokeRemoteCommand(client, request) {
|
|
1194
|
+
const rpcId = uuidV7();
|
|
1195
|
+
const response = await client.rpc('harness.api.call', {
|
|
1196
|
+
method: `${request.namespace}.${request.method}`,
|
|
1197
|
+
rpcId,
|
|
1198
|
+
payload: request.args,
|
|
1199
|
+
}, request.signal);
|
|
1200
|
+
if (response.rpcId !== rpcId) {
|
|
1201
|
+
throw new ClientModeError('INVALID_MESSAGE', 'The remote Host returned an invalid command response.');
|
|
1202
|
+
}
|
|
1203
|
+
return unwrapNativeResult(response);
|
|
1204
|
+
}
|
|
1205
|
+
async function readRemoteWorkspaceBaseline(gateway, signal) {
|
|
1206
|
+
const lifetime = new AbortController();
|
|
1207
|
+
const activeSignal = signal === undefined
|
|
1208
|
+
? lifetime.signal
|
|
1209
|
+
: AbortSignal.any([signal, lifetime.signal]);
|
|
1210
|
+
const source = await gateway.open('workspace/follow', { args: {} }, activeSignal);
|
|
1211
|
+
const iterator = source[Symbol.asyncIterator]();
|
|
1212
|
+
try {
|
|
1213
|
+
const first = await iterator.next();
|
|
1214
|
+
if (first.done || !isRecord(first.value) || first.value.type !== 'baseline'
|
|
1215
|
+
|| !isRecord(first.value.value) || !Array.isArray(first.value.value.items)) {
|
|
1216
|
+
throw new ClientModeError('INVALID_MESSAGE', 'The remote Host returned an invalid Workspace baseline.');
|
|
1217
|
+
}
|
|
1218
|
+
return first.value.value.items.map((item) => {
|
|
1219
|
+
if (!isRecord(item) || typeof item.workspaceId !== 'string'
|
|
1220
|
+
|| typeof item.path !== 'string' || typeof item.title !== 'string') {
|
|
1221
|
+
throw new ClientModeError('INVALID_MESSAGE', 'The remote Host returned an invalid Workspace row.');
|
|
1222
|
+
}
|
|
1223
|
+
return { workspaceId: item.workspaceId, path: item.path, title: item.title };
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
finally {
|
|
1227
|
+
lifetime.abort('workspace-baseline-read');
|
|
1228
|
+
await iterator.return?.();
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
function unwrapNativeResult(response) {
|
|
1232
|
+
const result = response.result;
|
|
1233
|
+
if (typeof result !== 'object' || result === null || !('ok' in result)) {
|
|
1234
|
+
throw new ClientModeError('INVALID_MESSAGE', 'The remote Host returned an invalid response.');
|
|
1235
|
+
}
|
|
1236
|
+
if (result.ok !== true || !('value' in result)) {
|
|
1237
|
+
const message = 'error' in result && typeof result.error === 'object' && result.error !== null
|
|
1238
|
+
&& 'message' in result.error && typeof result.error.message === 'string'
|
|
1239
|
+
? result.error.message
|
|
1240
|
+
: 'The remote Host rejected the request.';
|
|
1241
|
+
throw new ClientModeError('REMOTE_API_ERROR', message);
|
|
1242
|
+
}
|
|
1243
|
+
return result.value;
|
|
1244
|
+
}
|
|
1245
|
+
function workspaceRecordId(value) {
|
|
1246
|
+
if (typeof value !== 'object' || value === null || !('workspaceId' in value)
|
|
1247
|
+
|| typeof value.workspaceId !== 'string' || value.workspaceId.length === 0) {
|
|
1248
|
+
throw new ClientModeError('INVALID_MESSAGE', 'The remote Host returned an invalid Workspace.');
|
|
1249
|
+
}
|
|
1250
|
+
return value.workspaceId;
|
|
1251
|
+
}
|
|
1252
|
+
function remoteWorkspaceTitle(path) {
|
|
1253
|
+
const normalized = path.replace(/[\\/]+$/u, '');
|
|
1254
|
+
return normalized.split(/[\\/]+/u).filter(Boolean).at(-1) ?? path;
|
|
1255
|
+
}
|
|
1256
|
+
function fail(error) {
|
|
1257
|
+
const source = error instanceof Error ? error : undefined;
|
|
1258
|
+
const remoteCode = source !== undefined && 'code' in source && typeof source.code === 'string'
|
|
1259
|
+
? source.code
|
|
1260
|
+
: source instanceof ClientModeError ? source.code : undefined;
|
|
1261
|
+
const retryable = source !== undefined && 'retryable' in source && typeof source.retryable === 'boolean'
|
|
1262
|
+
? source.retryable
|
|
1263
|
+
: source instanceof ClientModeError ? source.retryable : false;
|
|
1264
|
+
return {
|
|
1265
|
+
ok: false,
|
|
1266
|
+
error: {
|
|
1267
|
+
code: 'internal',
|
|
1268
|
+
message: source?.message ?? 'The remote-mode operation failed.',
|
|
1269
|
+
details: remoteCode === undefined ? {} : { remoteCode, retryable },
|
|
1270
|
+
},
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
function shortId(value) { return value.length <= 12 ? value : `${value.slice(0, 8)}…${value.slice(-4)}`; }
|
|
1274
|
+
/** Conservative feature profile for Hosts that predate fine-grained capability discovery. */
|
|
1275
|
+
export function remoteHostFeatures(clientVersion) {
|
|
1276
|
+
return {
|
|
1277
|
+
commandList: isVersionAtLeast(clientVersion, REMOTE_COMMAND_LIST_MIN_VERSION),
|
|
1278
|
+
fileViewer: isVersionAtLeast(clientVersion, REMOTE_FILE_VIEWER_MIN_VERSION),
|
|
1279
|
+
terminal: false,
|
|
1280
|
+
apiProxy: true,
|
|
1281
|
+
remoteGateway: false,
|
|
1282
|
+
codex: false,
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
export async function probeRemoteHostFeatures(client, clientVersion) {
|
|
1286
|
+
const fallback = remoteHostFeatures(clientVersion);
|
|
1287
|
+
let value;
|
|
1288
|
+
try {
|
|
1289
|
+
value = await client.rpc('harness.transport.describe', {});
|
|
1290
|
+
}
|
|
1291
|
+
catch (error) {
|
|
1292
|
+
if (error instanceof Error && 'code' in error && error.code === 'METHOD_NOT_FOUND')
|
|
1293
|
+
return fallback;
|
|
1294
|
+
throw error;
|
|
1295
|
+
}
|
|
1296
|
+
if (!isRecord(value) || !Array.isArray(value.capabilities)
|
|
1297
|
+
|| value.capabilities.some(capability => typeof capability !== 'string')) {
|
|
1298
|
+
throw new ClientModeError('INVALID_MESSAGE', 'The remote Host returned invalid transport capabilities.');
|
|
1299
|
+
}
|
|
1300
|
+
const capabilities = new Set(value.capabilities);
|
|
1301
|
+
const apiProxy = capabilities.has('harness.api.v1');
|
|
1302
|
+
const remoteV1 = capabilities.has('harness.remote.v1');
|
|
1303
|
+
const remoteV3 = capabilities.has('harness.remote.v3');
|
|
1304
|
+
const terminal = capabilities.has('harness.terminal.v1');
|
|
1305
|
+
const codex = capabilities.has('codex.appserver.v1');
|
|
1306
|
+
if (remoteV1 && remoteV3) {
|
|
1307
|
+
throw new ClientModeError('INVALID_MESSAGE', 'The remote Host advertised conflicting Harness Session formats.');
|
|
1308
|
+
}
|
|
1309
|
+
const sessionFormat = remoteV3 ? 3 : undefined;
|
|
1310
|
+
const remoteGateway = remoteV3 || remoteV1;
|
|
1311
|
+
if (!apiProxy && !remoteGateway && !codex) {
|
|
1312
|
+
throw new ClientModeError('FEATURE_NOT_SUPPORTED', 'The remote Host exposes no supported Harness transport.');
|
|
1313
|
+
}
|
|
1314
|
+
return {
|
|
1315
|
+
commandList: remoteGateway || (apiProxy && fallback.commandList),
|
|
1316
|
+
fileViewer: capabilities.has('fileviewer.read.v1'),
|
|
1317
|
+
terminal,
|
|
1318
|
+
apiProxy,
|
|
1319
|
+
remoteGateway,
|
|
1320
|
+
...(sessionFormat === undefined ? {} : { sessionFormat }),
|
|
1321
|
+
codex,
|
|
1322
|
+
};
|
|
1323
|
+
}
|
|
1324
|
+
async function waitForCodexFrames(stream, signal) {
|
|
1325
|
+
if (signal?.aborted)
|
|
1326
|
+
throw new ClientModeError('RPC_ABORTED', 'The Codex event poll was cancelled.');
|
|
1327
|
+
await new Promise((resolve, reject) => {
|
|
1328
|
+
const previousWake = stream.wake;
|
|
1329
|
+
const timer = setTimeout(done, 25_000);
|
|
1330
|
+
const onAbort = () => {
|
|
1331
|
+
cleanup();
|
|
1332
|
+
reject(new ClientModeError('RPC_ABORTED', 'The Codex event poll was cancelled.'));
|
|
1333
|
+
};
|
|
1334
|
+
function cleanup() {
|
|
1335
|
+
clearTimeout(timer);
|
|
1336
|
+
stream.wake = previousWake;
|
|
1337
|
+
signal?.removeEventListener('abort', onAbort);
|
|
1338
|
+
}
|
|
1339
|
+
function done() {
|
|
1340
|
+
cleanup();
|
|
1341
|
+
resolve();
|
|
1342
|
+
}
|
|
1343
|
+
stream.wake = () => {
|
|
1344
|
+
previousWake();
|
|
1345
|
+
done();
|
|
1346
|
+
};
|
|
1347
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
function isVersionAtLeast(value, minimum) {
|
|
1351
|
+
const match = value?.match(/^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/);
|
|
1352
|
+
if (match === undefined || match === null)
|
|
1353
|
+
return false;
|
|
1354
|
+
const version = match.slice(1, 4).map(part => Number(part));
|
|
1355
|
+
for (let index = 0; index < minimum.length; index += 1) {
|
|
1356
|
+
const part = version[index] ?? 0;
|
|
1357
|
+
const expected = minimum[index] ?? 0;
|
|
1358
|
+
if (part > expected)
|
|
1359
|
+
return true;
|
|
1360
|
+
if (part < expected)
|
|
1361
|
+
return false;
|
|
1362
|
+
}
|
|
1363
|
+
return true;
|
|
1364
|
+
}
|
|
1365
|
+
function diagnosticReason(error) {
|
|
1366
|
+
const code = 'code' in error && typeof error.code === 'string' ? error.code : undefined;
|
|
1367
|
+
const message = error.message.replace(/[\r\n\t]+/g, ' ').slice(0, 240);
|
|
1368
|
+
return code === undefined ? message : `${code}: ${message}`;
|
|
1369
|
+
}
|
|
1370
|
+
function preferredTransportsForAttempt(attempt) {
|
|
1371
|
+
if (attempt === 'direct')
|
|
1372
|
+
return ['lan', 'p2p', 'relay'];
|
|
1373
|
+
if (attempt === 'turn')
|
|
1374
|
+
return ['turn', 'relay'];
|
|
1375
|
+
return ['relay'];
|
|
1376
|
+
}
|
|
1377
|
+
function transportPreferenceForMode(mode) {
|
|
1378
|
+
if (mode === 'LAN')
|
|
1379
|
+
return 'lan';
|
|
1380
|
+
if (mode === 'P2P')
|
|
1381
|
+
return 'p2p';
|
|
1382
|
+
if (mode === 'TURN')
|
|
1383
|
+
return 'turn';
|
|
1384
|
+
if (mode === 'Relay')
|
|
1385
|
+
return 'relay';
|
|
1386
|
+
return undefined;
|
|
1387
|
+
}
|
|
1388
|
+
function iceServersForAttempt(attempt, iceServers) {
|
|
1389
|
+
return attempt === 'direct' ? stunOnlyIceServers(iceServers) : iceServers;
|
|
1390
|
+
}
|
|
1391
|
+
//# sourceMappingURL=client-runtime.js.map
|