dsh-plugin-dshm 0.0.0-stage → 0.1.1
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/CHANGELOG.md +30 -0
- package/LICENSE +21 -0
- package/README.md +232 -2
- package/README.zh-CN.md +194 -0
- package/cordis.patch.yml +7 -0
- package/lib/auth/signature.d.ts +4 -0
- package/lib/auth/signature.js +24 -0
- package/lib/cli/index.d.ts +4 -0
- package/lib/cli/index.js +41 -0
- package/lib/client.js +419 -0
- package/lib/config.d.ts +10 -0
- package/lib/config.js +27 -0
- package/lib/discovery/advertiser.d.ts +45 -0
- package/lib/discovery/advertiser.js +141 -0
- package/lib/discovery/interfaces.d.ts +5 -0
- package/lib/discovery/interfaces.js +24 -0
- package/lib/errors.d.ts +5 -0
- package/lib/errors.js +10 -0
- package/lib/host/local.d.ts +5 -0
- package/lib/host/local.js +122 -0
- package/lib/host/tailscale.d.ts +43 -0
- package/lib/host/tailscale.js +137 -0
- package/lib/host/types.d.ts +40 -0
- package/lib/host/types.js +1 -0
- package/lib/index.d.ts +30 -0
- package/lib/index.js +98 -0
- package/lib/methods/index.d.ts +9 -0
- package/lib/methods/index.js +206 -0
- package/lib/pairing/state.d.ts +26 -0
- package/lib/pairing/state.js +89 -0
- package/lib/pairing/window.d.ts +18 -0
- package/lib/pairing/window.js +57 -0
- package/lib/projection/client.d.mts +2 -0
- package/lib/projection/client.mjs +11 -0
- package/lib/projection/compatibility.d.mts +2 -0
- package/lib/projection/compatibility.mjs +4 -0
- package/lib/projection/files.d.ts +33 -0
- package/lib/projection/files.js +278 -0
- package/lib/projection/fold-recording.d.mts +50 -0
- package/lib/projection/fold-recording.mjs +440 -0
- package/lib/projection/host.d.ts +6 -0
- package/lib/projection/host.js +86 -0
- package/lib/projection/ids.d.mts +1 -0
- package/lib/projection/ids.mjs +1 -0
- package/lib/projection/index.d.ts +2 -0
- package/lib/projection/index.js +2 -0
- package/lib/projection/interactions.d.ts +10 -0
- package/lib/projection/interactions.js +73 -0
- package/lib/projection/native.d.ts +115 -0
- package/lib/projection/native.js +915 -0
- package/lib/protocol/THIRD_PARTY_LICENSES.txt +135 -0
- package/lib/protocol/dmp.d.ts +402 -0
- package/lib/protocol/dmp.js +1 -0
- package/lib/protocol/schema/dmp-v1/common.schema.json +88 -0
- package/lib/protocol/schema/dmp-v1/events.schema.json +107 -0
- package/lib/protocol/schema/dmp-v1/frames.schema.json +280 -0
- package/lib/protocol/schema/dmp-v1/methods.schema.json +584 -0
- package/lib/protocol/schema/dmp-v1/models.schema.json +674 -0
- package/lib/protocol/scripts/contract.mjs +8106 -0
- package/lib/push/controller.d.ts +64 -0
- package/lib/push/controller.js +290 -0
- package/lib/push/observer.d.ts +51 -0
- package/lib/push/observer.js +266 -0
- package/lib/push/payload.d.ts +15 -0
- package/lib/push/payload.js +18 -0
- package/lib/push/store.d.ts +17 -0
- package/lib/push/store.js +75 -0
- package/lib/server/index.d.ts +30 -0
- package/lib/server/index.js +500 -0
- package/lib/server/rate-limit.d.ts +7 -0
- package/lib/server/rate-limit.js +19 -0
- package/lib/server/uploads.d.ts +21 -0
- package/lib/server/uploads.js +74 -0
- package/lib/types.d.ts +92 -0
- package/lib/types.js +1 -0
- package/lib/ui/card.d.mts +3 -0
- package/lib/ui/card.mjs +59 -0
- package/lib/ui/copy.d.mts +146 -0
- package/lib/ui/copy.mjs +106 -0
- package/lib/ui/remote-setup.d.mts +81 -0
- package/lib/ui/remote-setup.mjs +75 -0
- package/package.json +67 -4
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { methods } from '../protocol/scripts/contract.mjs';
|
|
3
|
+
import { DmpError } from '../errors.js';
|
|
4
|
+
export const capabilities = ['sessions', 'timeline', 'timeline.stream', 'send', 'interactions.approval', 'interactions.question', 'send.steer', 'queue.edit', 'model.select', 'permission.select', 'files.read', 'files.download', 'changes', 'deliverables', 'jobs', 'devices', 'attachments'];
|
|
5
|
+
const implemented = new Set(['host.info', 'unsubscribe', 'sessions.list', 'sessions.watch', 'workspaces.list', 'sessions.create',
|
|
6
|
+
'session.watch', 'session.history', 'session.send', 'session.interrupt', 'interactions.list', 'interaction.answer', 'tool.output', 'timeline.text', 'session.queue.update', 'models.catalog', 'session.setModel', 'permissions.catalog', 'session.setPermission', 'files.list', 'files.read', 'files.stat', 'files.download', 'changes.summary', 'changes.diff', 'deliverables.list', 'deliverables.download', 'jobs.watch', 'job.watch', 'devices.list', 'devices.watch', 'devices.rename', 'devices.revoke', 'attachments.begin', 'attachments.finish', 'push.register', 'push.unregister']);
|
|
7
|
+
const maxFrameBytes = 1048576;
|
|
8
|
+
const clone = (value) => structuredClone(value);
|
|
9
|
+
const encodedSize = (result) => Buffer.byteLength(JSON.stringify({ t: 'res', id: Number.MAX_SAFE_INTEGER, ok: true, result }));
|
|
10
|
+
const orderSeq = (value) => {
|
|
11
|
+
const item = value;
|
|
12
|
+
return item.order.seq;
|
|
13
|
+
};
|
|
14
|
+
/** Remove oldest complete display groups until a snapshot/page fits. */
|
|
15
|
+
export function fitPage(result) {
|
|
16
|
+
const out = clone(result);
|
|
17
|
+
if (encodedSize(out) <= maxFrameBytes)
|
|
18
|
+
return out;
|
|
19
|
+
if (!Array.isArray(out.items) || out.resumed === true)
|
|
20
|
+
throw new DmpError('too-large', 'Snapshot exceeds the frame budget', { limit: maxFrameBytes });
|
|
21
|
+
const items = out.items;
|
|
22
|
+
while (items.length && encodedSize(out) > maxFrameBytes) {
|
|
23
|
+
const oldest = Math.min(...items.map(orderSeq));
|
|
24
|
+
const keep = items.filter(item => orderSeq(item) !== oldest);
|
|
25
|
+
if (!keep.length)
|
|
26
|
+
throw new DmpError('too-large', 'A complete display group exceeds the frame budget', { limit: maxFrameBytes });
|
|
27
|
+
out.items = keep;
|
|
28
|
+
items.splice(0, items.length, ...keep);
|
|
29
|
+
out.items = items;
|
|
30
|
+
out.hasMoreBefore = true;
|
|
31
|
+
}
|
|
32
|
+
if (encodedSize(out) > maxFrameBytes)
|
|
33
|
+
throw new DmpError('too-large', 'Snapshot metadata exceeds the frame budget', { limit: maxFrameBytes });
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
export class CreationLedger {
|
|
37
|
+
entries = new Map();
|
|
38
|
+
async create(deviceId, params, peer) {
|
|
39
|
+
for (const [id, entry] of this.entries)
|
|
40
|
+
if (!entry.inFlight && entry.expires <= Date.now())
|
|
41
|
+
this.entries.delete(id);
|
|
42
|
+
const key = `${deviceId}:${params.sendId}`;
|
|
43
|
+
const parameters = JSON.stringify({ workspaceId: params.workspaceId, ...(params.text !== undefined ? { text: params.text } : {}) });
|
|
44
|
+
const old = this.entries.get(key);
|
|
45
|
+
if (old && old.expires <= Date.now())
|
|
46
|
+
this.entries.delete(key);
|
|
47
|
+
let entry = this.entries.get(key);
|
|
48
|
+
if (entry && entry.parameters !== parameters)
|
|
49
|
+
throw new DmpError('conflict', 'sendId already names different creation parameters');
|
|
50
|
+
if (!entry) {
|
|
51
|
+
entry = { key, parameters, sessionId: randomUUID(), expires: Date.now() + 86400000, created: false, prompted: false };
|
|
52
|
+
this.entries.set(key, entry);
|
|
53
|
+
}
|
|
54
|
+
if (entry.inFlight)
|
|
55
|
+
return entry.inFlight;
|
|
56
|
+
const target = entry;
|
|
57
|
+
// Socket cancellation must not forget a creation that may already exist.
|
|
58
|
+
// The target identity is reserved before crossing the native boundary.
|
|
59
|
+
const signal = AbortSignal.timeout(30000);
|
|
60
|
+
target.inFlight = (async () => {
|
|
61
|
+
if (!target.created) {
|
|
62
|
+
const snapshot = await peer.request('sessions.list', {}, signal);
|
|
63
|
+
const existing = snapshot.sessions.some(item => item.sessionId === target.sessionId);
|
|
64
|
+
if (!existing)
|
|
65
|
+
await peer.create(String(params.workspaceId), target.sessionId, signal);
|
|
66
|
+
target.created = true;
|
|
67
|
+
}
|
|
68
|
+
if (typeof params.text === 'string' && params.text.length && !target.prompted) {
|
|
69
|
+
await peer.request('session.send', { sessionId: target.sessionId, sendId: params.sendId, text: params.text, mode: 'queue' }, signal);
|
|
70
|
+
target.prompted = true;
|
|
71
|
+
}
|
|
72
|
+
return { sessionId: target.sessionId };
|
|
73
|
+
})();
|
|
74
|
+
try {
|
|
75
|
+
return await target.inFlight;
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
target.inFlight = undefined;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export function createMethods(peer, context, ledger) {
|
|
83
|
+
const subscriptions = new Map();
|
|
84
|
+
let disposed = false;
|
|
85
|
+
const dispose = async () => {
|
|
86
|
+
if (disposed)
|
|
87
|
+
return;
|
|
88
|
+
disposed = true;
|
|
89
|
+
await Promise.allSettled([...subscriptions.values()].map(watch => watch.dispose()));
|
|
90
|
+
subscriptions.clear();
|
|
91
|
+
await peer.dispose();
|
|
92
|
+
};
|
|
93
|
+
return {
|
|
94
|
+
dispose,
|
|
95
|
+
async upload(sessionId, name, mime, bytes, signal) {
|
|
96
|
+
if (disposed)
|
|
97
|
+
throw new DmpError('dsh-unavailable');
|
|
98
|
+
return peer.upload(sessionId, name, mime, bytes, signal);
|
|
99
|
+
},
|
|
100
|
+
async request(method, params, signal) {
|
|
101
|
+
if (disposed)
|
|
102
|
+
throw new DmpError('dsh-unavailable');
|
|
103
|
+
const removingPush = method === 'push.unregister' && context.push;
|
|
104
|
+
if (!implemented.has(method) || !removingPush && methods[method]?.cap && !context.identity.caps.includes(methods[method].cap))
|
|
105
|
+
throw new DmpError('unsupported');
|
|
106
|
+
if (method === 'host.info')
|
|
107
|
+
return { result: { host: context.identity.host, bridgeVersion: context.identity.version,
|
|
108
|
+
dshVersion: context.identity.dshVersion, dshStatus: context.identity.dshStatus, caps: context.identity.caps, time: new Date().toISOString() } };
|
|
109
|
+
if (method === 'push.register' || method === 'push.unregister') {
|
|
110
|
+
if (!context.push)
|
|
111
|
+
throw new DmpError('unsupported');
|
|
112
|
+
if (method === 'push.register')
|
|
113
|
+
context.push.register(context.deviceId, params);
|
|
114
|
+
else
|
|
115
|
+
context.push.unregister(context.deviceId);
|
|
116
|
+
return { result: {} };
|
|
117
|
+
}
|
|
118
|
+
if (method === 'unsubscribe') {
|
|
119
|
+
const id = String(params.subId), watch = subscriptions.get(id);
|
|
120
|
+
subscriptions.delete(id);
|
|
121
|
+
await watch?.dispose();
|
|
122
|
+
return { result: {} };
|
|
123
|
+
}
|
|
124
|
+
if (method === 'devices.list')
|
|
125
|
+
return { result: { devices: context.devices.list() } };
|
|
126
|
+
if (method === 'devices.rename')
|
|
127
|
+
return { result: { device: context.devices.rename(String(params.deviceId), String(params.name)) } };
|
|
128
|
+
if (method === 'devices.revoke') {
|
|
129
|
+
context.devices.revoke(String(params.deviceId));
|
|
130
|
+
return { result: {} };
|
|
131
|
+
}
|
|
132
|
+
if (method === 'devices.watch') {
|
|
133
|
+
const subId = randomUUID();
|
|
134
|
+
const stop = context.devices.watch(() => {
|
|
135
|
+
if (!disposed && subscriptions.has(subId))
|
|
136
|
+
context.emit(subId, { kind: 'devices.replace', data: { devices: context.devices.list() } });
|
|
137
|
+
});
|
|
138
|
+
subscriptions.set(subId, { result: {}, dispose: stop });
|
|
139
|
+
return { result: { subId, devices: context.devices.list() } };
|
|
140
|
+
}
|
|
141
|
+
if (method === 'attachments.begin') {
|
|
142
|
+
await peer.request(method, params, signal);
|
|
143
|
+
return { result: context.uploads.begin(params) };
|
|
144
|
+
}
|
|
145
|
+
if (method === 'attachments.finish')
|
|
146
|
+
return { result: await context.uploads.finish(String(params.uploadId), signal) };
|
|
147
|
+
if (method === 'sessions.create')
|
|
148
|
+
return { result: await ledger.create(context.deviceId, params, peer) };
|
|
149
|
+
if (method === 'session.send') {
|
|
150
|
+
if (params.mode === 'steer' && !context.identity.caps.includes('send.steer') || Array.isArray(params.attachments) && params.attachments.length && !context.identity.caps.includes('attachments'))
|
|
151
|
+
throw new DmpError('unsupported');
|
|
152
|
+
if ((typeof params.text !== 'string' || !params.text.trim()) && !(Array.isArray(params.attachments) && params.attachments.length))
|
|
153
|
+
throw new DmpError('invalid-params', 'Text must not be empty');
|
|
154
|
+
}
|
|
155
|
+
if (method === 'tool.output' || method === 'timeline.text' || method === 'files.download' || method === 'deliverables.download')
|
|
156
|
+
return { bytes: await peer.content(method, params, signal), mime: method === 'files.download' || method === 'deliverables.download' ? 'application/octet-stream' : 'text/plain; charset=utf-8' };
|
|
157
|
+
if (method === 'sessions.watch' || method === 'session.watch' || method === 'jobs.watch' || method === 'job.watch') {
|
|
158
|
+
const subId = randomUUID();
|
|
159
|
+
const pending = [];
|
|
160
|
+
let ready = false;
|
|
161
|
+
const emit = (event) => {
|
|
162
|
+
if (disposed || signal.aborted)
|
|
163
|
+
return;
|
|
164
|
+
if (!ready)
|
|
165
|
+
pending.push(event);
|
|
166
|
+
else if (subscriptions.has(subId))
|
|
167
|
+
context.emit(subId, event);
|
|
168
|
+
};
|
|
169
|
+
let watch = await peer.watch(method, params, emit, signal);
|
|
170
|
+
let result;
|
|
171
|
+
try {
|
|
172
|
+
result = fitPage({ ...watch.result, subId });
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
await watch.dispose();
|
|
176
|
+
if (method !== 'session.watch' || watch.result.resumed !== true)
|
|
177
|
+
throw error;
|
|
178
|
+
// A resume merge cannot drop changed items. Reopen as a fresh window.
|
|
179
|
+
pending.length = 0;
|
|
180
|
+
const { sinceSeq: _sinceSeq, ...fresh } = params;
|
|
181
|
+
watch = await peer.watch(method, fresh, emit, signal);
|
|
182
|
+
try {
|
|
183
|
+
result = fitPage({ ...watch.result, subId });
|
|
184
|
+
}
|
|
185
|
+
catch (retryError) {
|
|
186
|
+
await watch.dispose();
|
|
187
|
+
throw retryError;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (disposed || signal.aborted) {
|
|
191
|
+
await watch.dispose();
|
|
192
|
+
throw new DmpError('cancelled');
|
|
193
|
+
}
|
|
194
|
+
const stopWatching = method === 'session.watch' ? context.push?.watch(String(params.sessionId)) : undefined;
|
|
195
|
+
subscriptions.set(subId, { ...watch, async dispose() { stopWatching?.(); await watch.dispose(); } });
|
|
196
|
+
// The RPC response gets enqueued before live events from the new watch.
|
|
197
|
+
setImmediate(() => { ready = true; if (!disposed && subscriptions.has(subId))
|
|
198
|
+
for (const event of pending)
|
|
199
|
+
context.emit(subId, event); pending.length = 0; });
|
|
200
|
+
return { result };
|
|
201
|
+
}
|
|
202
|
+
const result = await peer.request(method, params, signal);
|
|
203
|
+
return { result: method === 'session.history' ? fitPage(result) : result };
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface DeviceRecord {
|
|
2
|
+
name: string;
|
|
3
|
+
model: string;
|
|
4
|
+
publicKey: string;
|
|
5
|
+
secureEnclave: boolean;
|
|
6
|
+
pairedAt: string;
|
|
7
|
+
lastSeenAt?: string;
|
|
8
|
+
revokedAt?: string;
|
|
9
|
+
}
|
|
10
|
+
declare function writePrivate(file: string, value: string | Buffer): void;
|
|
11
|
+
export declare class HostState {
|
|
12
|
+
readonly directory: string;
|
|
13
|
+
readonly hostId: string;
|
|
14
|
+
readonly key: Buffer;
|
|
15
|
+
readonly cert: Buffer;
|
|
16
|
+
readonly fingerprint: string;
|
|
17
|
+
readonly devicesFile: string;
|
|
18
|
+
devices: Record<string, DeviceRecord>;
|
|
19
|
+
constructor(directory: string);
|
|
20
|
+
readDevices(): Record<string, DeviceRecord>;
|
|
21
|
+
reload(): void;
|
|
22
|
+
save(): void;
|
|
23
|
+
pair(record: Omit<DeviceRecord, 'pairedAt'>): string;
|
|
24
|
+
revoke(id: string): boolean;
|
|
25
|
+
}
|
|
26
|
+
export { writePrivate };
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
function writePrivate(file, value) {
|
|
6
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
7
|
+
fs.writeFileSync(temporary, value, { mode: 0o600 });
|
|
8
|
+
fs.chmodSync(temporary, 0o600);
|
|
9
|
+
fs.renameSync(temporary, file);
|
|
10
|
+
}
|
|
11
|
+
export class HostState {
|
|
12
|
+
directory;
|
|
13
|
+
hostId;
|
|
14
|
+
key;
|
|
15
|
+
cert;
|
|
16
|
+
fingerprint;
|
|
17
|
+
devicesFile;
|
|
18
|
+
devices;
|
|
19
|
+
constructor(directory) {
|
|
20
|
+
this.directory = directory;
|
|
21
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
22
|
+
fs.chmodSync(directory, 0o700);
|
|
23
|
+
const keyFile = path.join(directory, 'key.pem'), certFile = path.join(directory, 'cert.pem');
|
|
24
|
+
if (fs.existsSync(certFile) !== fs.existsSync(keyFile))
|
|
25
|
+
throw new Error('Incomplete DSHM TLS identity; restore both certificate and key');
|
|
26
|
+
if (!fs.existsSync(certFile)) {
|
|
27
|
+
const temporaryKey = path.join(directory, 'new-key.pem'), temporaryCert = path.join(directory, 'new-cert.pem');
|
|
28
|
+
try {
|
|
29
|
+
// Node emits named-curve PKCS8. LibreSSL's `req -newkey ec` can encode
|
|
30
|
+
// explicit parameters that Electron's BoringSSL cannot load.
|
|
31
|
+
const keys = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
|
32
|
+
writePrivate(temporaryKey, keys.privateKey.export({ type: 'pkcs8', format: 'pem' }));
|
|
33
|
+
execFileSync('openssl', ['req', '-new', '-x509', '-sha256', '-key', temporaryKey, '-out', temporaryCert, '-days', '825', '-subj', '/CN=DSHM Bridge'], { stdio: 'ignore' });
|
|
34
|
+
fs.chmodSync(temporaryKey, 0o600);
|
|
35
|
+
fs.chmodSync(temporaryCert, 0o600);
|
|
36
|
+
fs.renameSync(temporaryKey, keyFile);
|
|
37
|
+
fs.renameSync(temporaryCert, certFile);
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
fs.rmSync(temporaryKey, { force: true });
|
|
41
|
+
fs.rmSync(temporaryCert, { force: true });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
fs.chmodSync(keyFile, 0o600);
|
|
45
|
+
fs.chmodSync(certFile, 0o600);
|
|
46
|
+
this.key = fs.readFileSync(keyFile);
|
|
47
|
+
this.cert = fs.readFileSync(certFile);
|
|
48
|
+
this.fingerprint = crypto.createHash('sha256').update(new crypto.X509Certificate(this.cert).raw).digest('base64url');
|
|
49
|
+
const hostIdFile = path.join(directory, 'host-id');
|
|
50
|
+
if (!fs.existsSync(hostIdFile))
|
|
51
|
+
writePrivate(hostIdFile, crypto.randomUUID());
|
|
52
|
+
fs.chmodSync(hostIdFile, 0o600);
|
|
53
|
+
this.hostId = fs.readFileSync(hostIdFile, 'utf8').trim();
|
|
54
|
+
this.devicesFile = path.join(directory, 'devices.json');
|
|
55
|
+
if (!fs.existsSync(this.devicesFile))
|
|
56
|
+
writePrivate(this.devicesFile, '{}');
|
|
57
|
+
fs.chmodSync(this.devicesFile, 0o600);
|
|
58
|
+
this.devices = this.readDevices();
|
|
59
|
+
}
|
|
60
|
+
readDevices() {
|
|
61
|
+
const value = JSON.parse(fs.readFileSync(this.devicesFile, 'utf8'));
|
|
62
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
63
|
+
throw new Error('Invalid DSHM device registry');
|
|
64
|
+
for (const record of Object.values(value)) {
|
|
65
|
+
if (!record || typeof record !== 'object' || typeof record.name !== 'string' || typeof record.model !== 'string' || typeof record.publicKey !== 'string' || typeof record.secureEnclave !== 'boolean' || typeof record.pairedAt !== 'string' || (record.revokedAt !== undefined && typeof record.revokedAt !== 'string'))
|
|
66
|
+
throw new Error('Invalid DSHM device record');
|
|
67
|
+
}
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
reload() { this.devices = this.readDevices(); }
|
|
71
|
+
save() { writePrivate(this.devicesFile, JSON.stringify(this.devices, null, 2)); }
|
|
72
|
+
pair(record) {
|
|
73
|
+
this.reload();
|
|
74
|
+
const id = crypto.randomUUID();
|
|
75
|
+
this.devices[id] = { ...record, pairedAt: new Date().toISOString() };
|
|
76
|
+
this.save();
|
|
77
|
+
return id;
|
|
78
|
+
}
|
|
79
|
+
revoke(id) {
|
|
80
|
+
this.reload();
|
|
81
|
+
const record = this.devices[id];
|
|
82
|
+
if (!record)
|
|
83
|
+
return false;
|
|
84
|
+
record.revokedAt = new Date().toISOString();
|
|
85
|
+
this.save();
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export { writePrivate };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export declare class PairingWindow {
|
|
2
|
+
private readonly directory;
|
|
3
|
+
private readonly quiet;
|
|
4
|
+
private state?;
|
|
5
|
+
private timer?;
|
|
6
|
+
private currentUrl?;
|
|
7
|
+
constructor(directory: string, quiet: boolean);
|
|
8
|
+
get url(): string | undefined;
|
|
9
|
+
get expiresAt(): string | undefined;
|
|
10
|
+
open(values: {
|
|
11
|
+
hostId: string;
|
|
12
|
+
name: string;
|
|
13
|
+
fingerprint: string;
|
|
14
|
+
addresses: string[];
|
|
15
|
+
}): string;
|
|
16
|
+
verify(value: string): 'ok' | 'bad-otp' | 'pairing-closed';
|
|
17
|
+
close(): void;
|
|
18
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import qrcode from 'qrcode';
|
|
5
|
+
import { writePrivate } from './state.js';
|
|
6
|
+
export class PairingWindow {
|
|
7
|
+
directory;
|
|
8
|
+
quiet;
|
|
9
|
+
state;
|
|
10
|
+
timer;
|
|
11
|
+
currentUrl;
|
|
12
|
+
constructor(directory, quiet) {
|
|
13
|
+
this.directory = directory;
|
|
14
|
+
this.quiet = quiet;
|
|
15
|
+
}
|
|
16
|
+
get url() { return this.currentUrl; }
|
|
17
|
+
get expiresAt() { return this.state ? new Date(this.state.expires).toISOString() : undefined; }
|
|
18
|
+
open(values) {
|
|
19
|
+
this.close();
|
|
20
|
+
const otp = crypto.randomBytes(16);
|
|
21
|
+
this.state = { otp, expires: Date.now() + 300000, attempts: 0 };
|
|
22
|
+
const params = new URLSearchParams({ v: '1', host: values.hostId, name: values.name, fp: values.fingerprint, addrs: values.addresses.join(','), otp: otp.toString('base64url'), exp: String(Math.floor(this.state.expires / 1000)) });
|
|
23
|
+
this.currentUrl = `dshm://pair?${params}`;
|
|
24
|
+
writePrivate(path.join(this.directory, 'pairing-url.txt'), this.currentUrl);
|
|
25
|
+
this.timer = setTimeout(() => this.close(), 300000);
|
|
26
|
+
this.timer.unref();
|
|
27
|
+
if (!this.quiet) {
|
|
28
|
+
const url = this.currentUrl;
|
|
29
|
+
void qrcode.toString(url, { type: 'terminal', small: true }).then(qr => { if (this.currentUrl === url)
|
|
30
|
+
console.log(qr); });
|
|
31
|
+
console.log('DSHM pairing open for five minutes (one use). Keep the QR private.');
|
|
32
|
+
}
|
|
33
|
+
return this.currentUrl;
|
|
34
|
+
}
|
|
35
|
+
verify(value) {
|
|
36
|
+
if (!this.state || Date.now() >= this.state.expires || this.state.attempts >= 5) {
|
|
37
|
+
this.close();
|
|
38
|
+
return 'pairing-closed';
|
|
39
|
+
}
|
|
40
|
+
this.state.attempts++;
|
|
41
|
+
const received = Buffer.from(value, 'base64url');
|
|
42
|
+
const matches = received.length === this.state.otp.length && received.toString('base64url') === value && crypto.timingSafeEqual(received, this.state.otp);
|
|
43
|
+
if (!matches) {
|
|
44
|
+
if (this.state.attempts >= 5)
|
|
45
|
+
this.close();
|
|
46
|
+
return 'bad-otp';
|
|
47
|
+
}
|
|
48
|
+
return 'ok';
|
|
49
|
+
}
|
|
50
|
+
close() {
|
|
51
|
+
clearTimeout(this.timer);
|
|
52
|
+
this.timer = undefined;
|
|
53
|
+
this.state = undefined;
|
|
54
|
+
this.currentUrl = undefined;
|
|
55
|
+
fs.rmSync(path.join(this.directory, 'pairing-url.txt'), { force: true });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Only this browser boundary names DSH services and shared platform modules.
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
import * as primitives from '@deepseek-ai/dsh-client-ui-primitives';
|
|
4
|
+
import { createSettingsCard } from '../ui/card.mjs';
|
|
5
|
+
export const inject = ['slots'];
|
|
6
|
+
const SettingsCard = createSettingsCard(React, primitives, 'api/dshm');
|
|
7
|
+
export function apply(ctx) {
|
|
8
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
9
|
+
name: 'settings.section', id: 'dshm', order: 60, label: () => 'DSHM',
|
|
10
|
+
}, SettingsCard));
|
|
11
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// Enumerate verified carriers; a future prerelease is not implicitly compatible.
|
|
2
|
+
// Keep package metadata and the release gate aligned with the native test receipt.
|
|
3
|
+
export const supportedDSHVersions = Object.freeze(['0.2.1-rc.3', '0.2.1-rc.5']);
|
|
4
|
+
export const supportsDSH = version => supportedDSHVersions.includes(version);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { DmpObject } from '../types.js';
|
|
2
|
+
import type { NativeRecord } from './fold-recording.mjs';
|
|
3
|
+
interface FileSession {
|
|
4
|
+
cwd: string;
|
|
5
|
+
records: readonly NativeRecord[];
|
|
6
|
+
}
|
|
7
|
+
export interface FileProjectionAccess {
|
|
8
|
+
home: string;
|
|
9
|
+
session(sessionId: string, signal: AbortSignal): Promise<FileSession>;
|
|
10
|
+
call(namespace: string, method: string, args: DmpObject, signal: AbortSignal): Promise<unknown>;
|
|
11
|
+
fetch(request: Request): Promise<Response>;
|
|
12
|
+
changeState?(sessionId: string, seq: number, state: {
|
|
13
|
+
available: boolean;
|
|
14
|
+
fileCount?: number;
|
|
15
|
+
added?: number;
|
|
16
|
+
deleted?: number;
|
|
17
|
+
}): void;
|
|
18
|
+
}
|
|
19
|
+
export declare class FileProjection {
|
|
20
|
+
private access;
|
|
21
|
+
constructor(access: FileProjectionAccess);
|
|
22
|
+
private allowed;
|
|
23
|
+
private target;
|
|
24
|
+
private declaredTarget;
|
|
25
|
+
private recheck;
|
|
26
|
+
private guardedCall;
|
|
27
|
+
private call;
|
|
28
|
+
request(method: string, params: DmpObject, signal: AbortSignal): Promise<DmpObject>;
|
|
29
|
+
content(method: string, params: DmpObject, signal: AbortSignal): Promise<Uint8Array>;
|
|
30
|
+
private changeFetch;
|
|
31
|
+
private changes;
|
|
32
|
+
}
|
|
33
|
+
export {};
|