@awak-app/simy-cli 0.7.39 → 0.7.41
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/package.json +1 -1
- package/src/cli-contract.js +1 -1
- package/src/desktop-catalog-frames.js +52 -0
- package/src/desktop-frame-writer.js +4 -3
- package/src/desktop-host.js +52 -16
- package/src/session-supervision/controller-store.js +25 -8
- package/src/session-supervision/owner-transport.js +18 -6
package/package.json
CHANGED
package/src/cli-contract.js
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
} from "./durable-local-task-steps-contract.js";
|
|
15
15
|
import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
|
|
16
16
|
|
|
17
|
-
export const CLI_VERSION = "0.7.
|
|
17
|
+
export const CLI_VERSION = "0.7.41";
|
|
18
18
|
export const CLI_API_CONTRACT_VERSION = 10;
|
|
19
19
|
|
|
20
20
|
export function narrativeImageCapabilityMetadata() {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
2
|
+
|
|
3
|
+
function updateRows(previous, incoming, full, unchanged = false) {
|
|
4
|
+
if (full || previous === null || previous.length !== incoming.length) {
|
|
5
|
+
const rows = structuredClone(incoming);
|
|
6
|
+
return { rows, payload: { rows } };
|
|
7
|
+
}
|
|
8
|
+
if (unchanged) return { rows: previous, payload: { count: previous.length, changes: [] } };
|
|
9
|
+
const changes = [];
|
|
10
|
+
for (let index = 0; index < incoming.length; index++) {
|
|
11
|
+
if (!isDeepStrictEqual(previous[index], incoming[index])) {
|
|
12
|
+
const row = structuredClone(incoming[index]);
|
|
13
|
+
previous[index] = row;
|
|
14
|
+
changes.push([index, row]);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return { rows: previous, payload: { count: previous.length, changes } };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// One encoder per ordered output stream. Call only when a frame actually enters
|
|
21
|
+
// that stream, after replaceable/backpressured snapshots have been coalesced.
|
|
22
|
+
// This cache is transport state; it never authorizes work or provider dispatch.
|
|
23
|
+
export function createDesktopCatalogEncoder({ immutableInput = false } = {}) {
|
|
24
|
+
let rows = null, candidates = null, sourceRows = null, sourceCandidates = null, scope = null, revision = 0;
|
|
25
|
+
const reset = () => { rows = null; candidates = null; sourceRows = null; sourceCandidates = null; scope = null; };
|
|
26
|
+
return { reset, encode(frame) {
|
|
27
|
+
if (frame.type !== 'status' || !Array.isArray(frame.status?.sessionSupervision?.sessions)) {
|
|
28
|
+
if (frame.type === 'status') reset();
|
|
29
|
+
return JSON.stringify(frame) + '\n';
|
|
30
|
+
}
|
|
31
|
+
const status = frame.status;
|
|
32
|
+
const incoming = status.sessionSupervision.sessions;
|
|
33
|
+
const identity = JSON.stringify([status.webOrigin, status.auth?.userId, status.auth?.state, status.access?.organizationId,
|
|
34
|
+
status.access?.revision, status.access?.state]);
|
|
35
|
+
const previous = revision++;
|
|
36
|
+
const full = rows === null || scope !== identity || rows.length !== incoming.length;
|
|
37
|
+
const snapshot = updateRows(rows, incoming, full, immutableInput && sourceRows === incoming);
|
|
38
|
+
rows = snapshot.rows; sourceRows = incoming; scope = identity;
|
|
39
|
+
const catalog = { version: 1, revision, scope, ...(full ? {} : { baseRevision: previous }), ...snapshot.payload };
|
|
40
|
+
const { sessions, ...metadata } = status.sessionSupervision;
|
|
41
|
+
let supervision = metadata;
|
|
42
|
+
if (Array.isArray(metadata.selectionCandidates)) {
|
|
43
|
+
// This is another complete directory, used by the conversation picker.
|
|
44
|
+
// Do not silently retransmit it as supposedly small status metadata.
|
|
45
|
+
const snapshot = updateRows(candidates, metadata.selectionCandidates, full, immutableInput && sourceCandidates === metadata.selectionCandidates);
|
|
46
|
+
candidates = snapshot.rows; sourceCandidates = metadata.selectionCandidates; catalog.selectionCandidates = snapshot.payload;
|
|
47
|
+
const { selectionCandidates, ...small } = metadata;
|
|
48
|
+
supervision = small;
|
|
49
|
+
} else { candidates = null; sourceCandidates = null; }
|
|
50
|
+
return JSON.stringify({ ...frame, status: { ...status, sessionSupervision: supervision }, catalog }) + '\n';
|
|
51
|
+
} };
|
|
52
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Status snapshots are replaceable; command receipts and stop notifications are
|
|
2
2
|
// not. Never enqueue periodic snapshots behind a native reader that has paused.
|
|
3
3
|
// Control frames retain the stream's FIFO order and never wait for GUI drain.
|
|
4
|
-
export function createDesktopFrameWriter(output
|
|
4
|
+
export function createDesktopFrameWriter(output, { encode = frame => frame,
|
|
5
|
+
capture = frame => typeof frame === 'string' ? frame : structuredClone(frame) } = {}) {
|
|
5
6
|
let blocked = false, pendingStatus = null, statusClosed = false;
|
|
6
7
|
const drain = () => {
|
|
7
8
|
blocked = false;
|
|
@@ -11,11 +12,11 @@ export function createDesktopFrameWriter(output) {
|
|
|
11
12
|
};
|
|
12
13
|
const write = (frame, isStatus = false) => {
|
|
13
14
|
if (isStatus && statusClosed) return;
|
|
14
|
-
if (isStatus && blocked) { pendingStatus = frame; return; }
|
|
15
|
+
if (isStatus && blocked) { pendingStatus = capture(frame); return; }
|
|
15
16
|
// A control frame must not be followed by a snapshot captured before it.
|
|
16
17
|
// The next authorized projection will publish fresh state.
|
|
17
18
|
if (!isStatus) pendingStatus = null;
|
|
18
|
-
const accepted = output.write(frame);
|
|
19
|
+
const accepted = output.write(encode(frame));
|
|
19
20
|
if (accepted === false && !blocked && !statusClosed) {
|
|
20
21
|
blocked = true;
|
|
21
22
|
output.once('drain', drain);
|
package/src/desktop-host.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createDesktopFrameWriter } from './desktop-frame-writer.js';
|
|
2
|
+
import { createDesktopCatalogEncoder } from './desktop-catalog-frames.js';
|
|
2
3
|
import { boundedDiscovery } from './session-supervision/progressive-discovery.js';
|
|
3
4
|
import { createCatalogRefreshPolicy } from './session-supervision/catalog-refresh.js';
|
|
4
5
|
import { supervisedSession } from './session-supervision/discovery-window.js';
|
|
@@ -159,7 +160,12 @@ export { createDesktopFrameWriter } from './desktop-frame-writer.js';
|
|
|
159
160
|
|
|
160
161
|
export async function startDesktopHost({ input = process.stdin, output = process.stdout, env = process.env,
|
|
161
162
|
updatePackageRoot, dependencies = {}, exit = (code) => process.exit(code) } = {}) {
|
|
162
|
-
|
|
163
|
+
let catalogEncoder = env.SIMY_REMOTE_CHILD !== '1' && env.SIMY_DESKTOP_CATALOG_DELTA === '1'
|
|
164
|
+
? createDesktopCatalogEncoder({ immutableInput: true }) : null;
|
|
165
|
+
// Object frames below own detached snapshots. Queue their references rather
|
|
166
|
+
// than copying multi-MiB history again whenever a native reader is paused.
|
|
167
|
+
const frames = createDesktopFrameWriter(output, { capture: frame => frame,
|
|
168
|
+
encode: frame => typeof frame === 'string' ? frame : catalogEncoder.encode(frame) });
|
|
163
169
|
const pendingRemoteReceipts = new Map();
|
|
164
170
|
let operationJournal, ownerTransport;
|
|
165
171
|
// An explicitly requested window update releases only its viewer. The same
|
|
@@ -177,7 +183,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
177
183
|
}
|
|
178
184
|
ownerTransport?.publish(event);
|
|
179
185
|
if (event.type === 'status') ownerTransport?.publishDesktop?.(event);
|
|
180
|
-
if (!viewerDetached) frames.write(`${JSON.stringify(event)}\n`, event.type === 'status');
|
|
186
|
+
if (!viewerDetached) frames.write(catalogEncoder ? structuredClone(event) : `${JSON.stringify(event)}\n`, event.type === 'status');
|
|
181
187
|
};
|
|
182
188
|
const emit = (event) => {
|
|
183
189
|
const pending = event.type === 'result' ? pendingRemoteReceipts.get(event.id) : null;
|
|
@@ -186,10 +192,11 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
186
192
|
void operationJournal.finish(pending.scope, pending.command, event).then(() => writeEvent(event)).catch(() => writeEvent({ type: 'result', id: event.id, ok: false, code: 'remote_operation_unknown' }));
|
|
187
193
|
} else writeEvent(event);
|
|
188
194
|
};
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
|
|
195
|
+
// Retain complete logical snapshots for authorization-independent display.
|
|
196
|
+
// Legacy streams reuse the encoded body; opted-in native streams encode row
|
|
197
|
+
// deltas later, after backpressure coalescing. Registries may mutate objects,
|
|
198
|
+
// so the comparison snapshot must remain detached from producer references.
|
|
199
|
+
let encodedStatus = null, lastStatus = null, lastStatusFrame = null, updateProgress = null;
|
|
193
200
|
let projectCatalogFrame = status => status;
|
|
194
201
|
let resolveFirstStatus;
|
|
195
202
|
const firstStatus = new Promise(resolve => { resolveFirstStatus = resolve; });
|
|
@@ -222,18 +229,22 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
222
229
|
if (preferenceScope && desktopAccessReady(status.access) && status.sessionSupervision?.sessions) {
|
|
223
230
|
const catalogScope = preferenceScope;
|
|
224
231
|
status = { ...status, sessionSupervision: { ...status.sessionSupervision, sessions: status.sessionSupervision.sessions.filter(row => controllerPreferences.get(catalogScope, row).deleted !== true).map(row => { const saved = controllerPreferences.get(catalogScope, row); return { ...row, controllerPreferences: saved, ...(saved.pinned === undefined ? {} : { listPinned: saved.pinned, listPinScopes: saved.pinScopes }), ...(saved.hidden === undefined ? {} : { localHidden: saved.hidden }) }; }) } };
|
|
225
|
-
const catalog = { observedAt: Date.now(), sessions:
|
|
226
|
-
void controllerPreferences.
|
|
232
|
+
const catalog = { observedAt: Date.now(), sessions: status.sessionSupervision.sessions, hosts: status.remoteHosts?.items || [], providerLinks };
|
|
233
|
+
void controllerPreferences.replaceCatalog(catalogScope, catalog).catch(() => {});
|
|
227
234
|
}
|
|
228
235
|
if (updateProgress) status = { ...status, update: { ...status.update, ...updateProgress } };
|
|
229
|
-
ownerTransport?.publishDesktop?.({ type: 'status', status });
|
|
230
236
|
const { sampledAt, ...content } = status;
|
|
231
237
|
if (!lastStatus || !isDeepStrictEqual(content, lastStatus)) {
|
|
232
|
-
encodedStatus = JSON.stringify(content).slice(1, -1);
|
|
238
|
+
if (!catalogEncoder) encodedStatus = JSON.stringify(content).slice(1, -1);
|
|
233
239
|
lastStatus = structuredClone(content);
|
|
234
240
|
}
|
|
241
|
+
const ownedStatus = { ...lastStatus, ...(sampledAt === undefined ? {} : { sampledAt }) };
|
|
242
|
+
lastStatusFrame = ownedStatus;
|
|
243
|
+
ownerTransport?.publishDesktop?.({ type: 'status', status: ownedStatus });
|
|
235
244
|
const observation = sampledAt === undefined ? '' : `"sampledAt":${JSON.stringify(sampledAt)}${encodedStatus ? ',' : ''}`;
|
|
236
|
-
if (!viewerDetached) frames.write(
|
|
245
|
+
if (!viewerDetached) frames.write(catalogEncoder
|
|
246
|
+
? { type: 'status', status: ownedStatus }
|
|
247
|
+
: `{"type":"status","status":{${observation}${encodedStatus}}}\n`, true);
|
|
237
248
|
resolveFirstStatus?.(); resolveFirstStatus = null;
|
|
238
249
|
};
|
|
239
250
|
const root = env.SIMY_HOME?.trim() || join(homedir(), ".simy");
|
|
@@ -987,9 +998,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
987
998
|
// their replacement is ready, while fresh access/provider data wins.
|
|
988
999
|
if (cached) status = { ...cached, ...status };
|
|
989
1000
|
status.deliveryHooks = sameScope && lastStatus?.deliveryHooks
|
|
990
|
-
? structuredClone(lastStatus.deliveryHooks) : { runs: [], providers: {} };
|
|
1001
|
+
? cached?.deliveryHooks || structuredClone(lastStatus.deliveryHooks) : { runs: [], providers: {} };
|
|
991
1002
|
status.sessionSupervision = sameScope && lastStatus?.sessionSupervision
|
|
992
|
-
? structuredClone(lastStatus.sessionSupervision) : { sessions: [], providers: {}, refreshedAt: null, initialLoading: desktopAccessReady(currentAccess) };
|
|
1003
|
+
? cached?.sessionSupervision || structuredClone(lastStatus.sessionSupervision) : { sessions: [], providers: {}, refreshedAt: null, initialLoading: desktopAccessReady(currentAccess) };
|
|
993
1004
|
// Complete fast frames must retain the current saved preference so OFF
|
|
994
1005
|
// remains available while unrelated conversation enrichment is waiting.
|
|
995
1006
|
status.nativeDevelopmentReports = sameScope && sameReportController(nativeDevelopmentReportsController, {
|
|
@@ -1023,7 +1034,15 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
1023
1034
|
status.sqmChecks = { activeCount: 0, items: [] };
|
|
1024
1035
|
status.sqmTasks = { activeCount: status.sqmTasks?.activeCount || 0, items: [] };
|
|
1025
1036
|
}
|
|
1026
|
-
if (!closed && !relay) {
|
|
1037
|
+
if (!closed && !relay) {
|
|
1038
|
+
connectionRevision += 1;
|
|
1039
|
+
// A finishing full projection supplies these two fields itself. Keep
|
|
1040
|
+
// only the fresh connection envelope for that merge, not a second copy
|
|
1041
|
+
// of the complete historical catalog that will immediately be discarded.
|
|
1042
|
+
const { deliveryHooks, sessionSupervision, ...connection } = status;
|
|
1043
|
+
latestConnectionStatus = structuredClone(connection);
|
|
1044
|
+
emitStatus(status);
|
|
1045
|
+
}
|
|
1027
1046
|
}).finally(() => { connectionPublishing = null; });
|
|
1028
1047
|
return connectionPublishing;
|
|
1029
1048
|
};
|
|
@@ -1306,7 +1325,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
1306
1325
|
try { child = (dependencies.spawn || spawn)(installation.executablePath,
|
|
1307
1326
|
[join(dirname(installation.entryPath), "desktop-host.js")], {
|
|
1308
1327
|
stdio: ["pipe", "pipe", "pipe"],
|
|
1309
|
-
|
|
1328
|
+
// The parent validates complete candidate frames and owns encoding for
|
|
1329
|
+
// its native viewer. Never chain delta baselines through a handoff.
|
|
1330
|
+
env: { ...updateConnectionEnvironment({ env, root, webOrigin }), SIMY_DESKTOP_CATALOG_DELTA: '0', SIMY_DESKTOP_SUPERVISOR_TOKEN: supervisorToken, ...(transferToken ? {SIMY_UPDATE_TRANSFER_TOKEN:transferToken} : {}) },
|
|
1310
1331
|
}); } catch (error) {
|
|
1311
1332
|
if(transferToken)await updateRuntime.reclaimFailedHandoff();
|
|
1312
1333
|
actionAssociations = makeActionAssociations();
|
|
@@ -1490,6 +1511,20 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
1490
1511
|
if (line.length > 16384) return;
|
|
1491
1512
|
let command; try { command = JSON.parse(line); } catch { return; }
|
|
1492
1513
|
if (typeof command.id !== "string" || command.id.length > 128) return;
|
|
1514
|
+
if (command.action === 'desktop_status_resync') {
|
|
1515
|
+
catalogEncoder?.reset();
|
|
1516
|
+
if (command.catalogDeltas === false && catalogEncoder) {
|
|
1517
|
+
catalogEncoder = null;
|
|
1518
|
+
if (lastStatus) encodedStatus = JSON.stringify(lastStatus).slice(1, -1);
|
|
1519
|
+
}
|
|
1520
|
+
emit({ type: 'result', id: command.id, ok: true });
|
|
1521
|
+
// Send after the receipt: a control may discard a pending snapshot.
|
|
1522
|
+
const snapshot = relay ? relayStatus?.value : lastStatusFrame;
|
|
1523
|
+
if (!viewerDetached && snapshot) frames.write(catalogEncoder
|
|
1524
|
+
? { type: 'status', status: structuredClone(snapshot) }
|
|
1525
|
+
: JSON.stringify({ type: 'status', status: snapshot }) + '\n', true);
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1493
1528
|
if (relay) {
|
|
1494
1529
|
if (['prepare_window_update', 'cancel_window_update'].includes(command.action)) {
|
|
1495
1530
|
const authorized = Boolean(ownerTransport) && relayStatus && Date.now() - relayStatus.at < 10_000 && desktopAccessReady(relayStatus.value.access);
|
|
@@ -2512,7 +2547,8 @@ export function startDesktopEntry(options = {}) {
|
|
|
2512
2547
|
if (isDesktopHostEntry()) {
|
|
2513
2548
|
try {
|
|
2514
2549
|
const root = process.env.SIMY_HOME?.trim() || join(homedir(), '.simy');
|
|
2515
|
-
if (process.env.SIMY_DESKTOP_ATTACH_OWNER === '1' && await relayToOwner({ root, webOrigin: resolveWebOrigin(null, process.env), desktopClient: true
|
|
2550
|
+
if (process.env.SIMY_DESKTOP_ATTACH_OWNER === '1' && await relayToOwner({ root, webOrigin: resolveWebOrigin(null, process.env), desktopClient: true,
|
|
2551
|
+
catalogDeltas: process.env.SIMY_DESKTOP_CATALOG_DELTA === '1' })) process.exit(0);
|
|
2516
2552
|
try { await adoptInstalledRuntime({ root }); }
|
|
2517
2553
|
catch (error) {
|
|
2518
2554
|
if (error.code === PROCESS_CONFLICT_CODE) throw error;
|
|
@@ -23,19 +23,36 @@ export function createControllerStore(root) {
|
|
|
23
23
|
if (state.version !== 1 || !state.conversations || !state.events) throw new Error('Invalid controller settings.');
|
|
24
24
|
states.set(scope, state); return state;
|
|
25
25
|
}
|
|
26
|
+
function enqueue(work) {
|
|
27
|
+
const pending = queue.catch(() => {}).then(work);
|
|
28
|
+
queue = pending; return pending;
|
|
29
|
+
}
|
|
30
|
+
async function persist(scope, next) {
|
|
31
|
+
await mkdir(join(root, 'controller'), { recursive: true, mode: 0o700 });
|
|
32
|
+
const path = file(scope), temp = `${path}.${randomUUID()}.tmp`;
|
|
33
|
+
try { await writeFile(temp, JSON.stringify(next) + '\n', { mode: 0o600 }); await rename(temp, path); }
|
|
34
|
+
finally { await rm(temp, { force: true }); }
|
|
35
|
+
states.set(scope, next);
|
|
36
|
+
}
|
|
26
37
|
function change(scope, mutate) {
|
|
27
|
-
|
|
38
|
+
return enqueue(async () => {
|
|
28
39
|
const next = structuredClone(await load(scope));
|
|
29
40
|
const result = await mutate(next);
|
|
30
|
-
await
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
41
|
+
await persist(scope, next); return result;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
function replaceCatalog(scope, catalog) {
|
|
45
|
+
// Own the incoming snapshot immediately, but do not clone the previous
|
|
46
|
+
// multi-MiB catalog just to replace it. Preference writes still serialize
|
|
47
|
+
// on the same queue and commit only after their atomic write succeeds.
|
|
48
|
+
const snapshot = structuredClone(catalog);
|
|
49
|
+
return enqueue(async () => {
|
|
50
|
+
const settings = { ...await load(scope) };
|
|
51
|
+
delete settings.catalog;
|
|
52
|
+
await persist(scope, { ...structuredClone(settings), catalog: snapshot });
|
|
35
53
|
});
|
|
36
|
-
queue = work; return work;
|
|
37
54
|
}
|
|
38
|
-
return { load, change, peek: scope => states.get(scope),
|
|
55
|
+
return { load, change, replaceCatalog, peek: scope => states.get(scope),
|
|
39
56
|
get(scope, target) { return states.get(scope)?.conversations[conversationKey(target)] || {}; },
|
|
40
57
|
async set(scope, target, values) {
|
|
41
58
|
return change(scope, state => { const key = conversationKey(target); state.conversations[key] = { ...state.conversations[key], ...values }; });
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createDesktopFrameWriter } from '../desktop-frame-writer.js';
|
|
2
|
+
import { createDesktopCatalogEncoder } from '../desktop-catalog-frames.js';
|
|
2
3
|
import { createServer, createConnection } from 'node:net';
|
|
3
4
|
import { chmod, readFile } from 'node:fs/promises';
|
|
4
5
|
import { join } from 'node:path';
|
|
@@ -14,6 +15,7 @@ const socketPath = ownerSocketPath;
|
|
|
14
15
|
// account/provider checks still run in the owner's ordinary command handler.
|
|
15
16
|
export async function createOwnerTransport({ root, nonce, allowedActions, dispatch, allowControllerArchive = false, allowDesktopClient = false }) {
|
|
16
17
|
const writers = new WeakMap();
|
|
18
|
+
const catalogEncoders = new WeakMap();
|
|
17
19
|
const peers = new Set(), subscribers = new Set(), desktopSubscribers = new Set(), requests = new Map();
|
|
18
20
|
let latest, latestDesktop;
|
|
19
21
|
const send = (peer, frame) => {
|
|
@@ -22,11 +24,11 @@ export async function createOwnerTransport({ root, nonce, allowedActions, dispat
|
|
|
22
24
|
// socket queue. Preserve room for bounded control receipts rather than
|
|
23
25
|
// disconnecting the viewer as soon as it requests a result behind it.
|
|
24
26
|
if (frame.type !== 'status' && peer.writableLength > (64 + 8) * 1024 * 1024) { peer.destroy(); return; }
|
|
25
|
-
writers.get(peer).write(JSON.stringify(frame) + '\n', frame.type === 'status');
|
|
27
|
+
writers.get(peer).write(catalogEncoders.has(peer) ? frame : JSON.stringify(frame) + '\n', frame.type === 'status');
|
|
26
28
|
};
|
|
27
29
|
const server = createServer(peer => {
|
|
28
30
|
peer.setEncoding('utf8');
|
|
29
|
-
const writer = createDesktopFrameWriter(peer);
|
|
31
|
+
const writer = createDesktopFrameWriter(peer, { encode: frame => typeof frame === 'string' ? frame : catalogEncoders.get(peer).encode(frame) });
|
|
30
32
|
writers.set(peer, writer);
|
|
31
33
|
let authenticated = false, controllerArchive = false, desktopClient = false, buffer = '';
|
|
32
34
|
const timeout = setTimeout(() => peer.destroy(), 5000);
|
|
@@ -43,7 +45,8 @@ export async function createOwnerTransport({ root, nonce, allowedActions, dispat
|
|
|
43
45
|
if (!authenticated) {
|
|
44
46
|
if (command.nonce !== nonce) { peer.destroy(); return; }
|
|
45
47
|
authenticated = true; desktopClient = allowDesktopClient && command.client === 'desktop'; controllerArchive = allowControllerArchive && command.client === 'archive-controller'; clearTimeout(timeout); peers.add(peer);
|
|
46
|
-
if (command.desktopProtocol ===
|
|
48
|
+
if (desktopClient && (command.desktopProtocol === 2 || command.catalogDeltas === 1)) catalogEncoders.set(peer, createDesktopCatalogEncoder());
|
|
49
|
+
if ([1, 2].includes(command.desktopProtocol) && desktopClient) send(peer, { type: 'desktop_owner_ready', version: catalogEncoders.has(peer) ? 2 : 1 });
|
|
47
50
|
// One-shot controls need their result, not multi-megabyte broadcasts
|
|
48
51
|
// that can fill the write queue before the result is sent.
|
|
49
52
|
if (command.subscribeStatus !== false) {
|
|
@@ -53,6 +56,13 @@ export async function createOwnerTransport({ root, nonce, allowedActions, dispat
|
|
|
53
56
|
}
|
|
54
57
|
continue;
|
|
55
58
|
}
|
|
59
|
+
if (desktopClient && command.action === 'desktop_status_resync' && typeof command.id === 'string' && command.id.length <= 128) {
|
|
60
|
+
catalogEncoders.get(peer)?.reset();
|
|
61
|
+
if (command.catalogDeltas === false) catalogEncoders.delete(peer);
|
|
62
|
+
send(peer, { type: 'result', id: command.id, ok: true });
|
|
63
|
+
if (latestDesktop) send(peer, latestDesktop);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
56
66
|
if ((!desktopClient && !allowedActions.has(command.action)) || typeof command.id !== 'string' || command.id.length > 128 || command.hostId && command.hostId !== 'local' && !desktopClient && !(controllerArchive && ['session_archive', 'session_restore', 'session_provider_setup'].includes(command.action))) {
|
|
57
67
|
send(peer, { type: 'result', id: command.id, ok: false, code: 'unsupported_remote_action' }); continue;
|
|
58
68
|
}
|
|
@@ -91,7 +101,7 @@ export async function createOwnerTransport({ root, nonce, allowedActions, dispat
|
|
|
91
101
|
|
|
92
102
|
// Returns false only when no compatible owner endpoint exists. Never starts a
|
|
93
103
|
// second owner, kills a process, or converts a disconnect into an execution retry.
|
|
94
|
-
export async function relayToOwner({ root, webOrigin, input = process.stdin, output = process.stdout, desktopClient = false }) {
|
|
104
|
+
export async function relayToOwner({ root, webOrigin, input = process.stdin, output = process.stdout, desktopClient = false, catalogDeltas = false }) {
|
|
95
105
|
let owner;
|
|
96
106
|
try { owner = JSON.parse(await readFile(join(root, 'desktop-app.lock', 'owner.json'), 'utf8')); }
|
|
97
107
|
catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
|
@@ -107,7 +117,9 @@ export async function relayToOwner({ root, webOrigin, input = process.stdin, out
|
|
|
107
117
|
if (webOrigin && (!owner.webOrigin || normalizeWebOrigin(owner.webOrigin) !== normalizeWebOrigin(webOrigin)))
|
|
108
118
|
throw Object.assign(new Error('The existing SIMY owner belongs to another Web address. Keep its tasks and reconnect using its saved environment.'), { code: 'remote_owner_origin_mismatch' });
|
|
109
119
|
} catch (error) { peer.destroy(); throw error; }
|
|
110
|
-
|
|
120
|
+
// A protocol-1 owner replies with complete frames. New native windows accept
|
|
121
|
+
// both versions, so attaching never requires replacing its execution owner.
|
|
122
|
+
peer.write(JSON.stringify({ nonce: owner.nonce, ...(desktopClient ? { client: 'desktop', desktopProtocol: 1, ...(catalogDeltas ? { catalogDeltas: 1 } : {}) } : {}) }) + '\n');
|
|
111
123
|
// Old owners ignore unknown hello fields and expose only the restricted SSH
|
|
112
124
|
// projection. Never attach a native window or forward its controls as though
|
|
113
125
|
// that were a complete desktop endpoint. Keep the original owner untouched.
|
|
@@ -139,7 +151,7 @@ function confirmDesktopOwner(peer) {
|
|
|
139
151
|
buffer = Buffer.concat([buffer, line]);
|
|
140
152
|
if (newline < 0) return;
|
|
141
153
|
let frame; try { frame = JSON.parse(buffer.toString('utf8')); } catch { finish(incompatible()); return; }
|
|
142
|
-
if (frame?.type !== 'desktop_owner_ready' || frame.version
|
|
154
|
+
if (frame?.type !== 'desktop_owner_ready' || ![1, 2].includes(frame.version)) { finish(incompatible()); return; }
|
|
143
155
|
peer.pause();
|
|
144
156
|
if (newline + 1 < chunk.length) peer.unshift(chunk.subarray(newline + 1));
|
|
145
157
|
finish();
|