my-durable 0.0.6 → 0.0.8
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/README.md +6 -0
- package/apps/dpi-backend/src/config.ts +10 -3
- package/apps/dpi-backend/src/daemon.ts +422 -0
- package/apps/dpi-backend/src/main.ts +148 -7
- package/apps/dpi-backend/src/pair-page.ts +111 -0
- package/apps/dpi-backend/src/pairing-files.ts +39 -9
- package/apps/dpi-backend/src/pairing.ts +368 -59
- package/apps/dpi-backend/src/routes.ts +14 -1
- package/apps/dpi-backend/src/store.ts +1 -0
- package/apps/dpi-backend/src/tunnel.ts +294 -55
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-Cg3vey95.css +1 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CoYMgU-2.js +77 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/index.html +2 -2
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-handoff.ts +17 -4
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair.ts +29 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/qrcode.ts +107 -18
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/AgentView.vue +1 -1
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceHandoff.vue +132 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Inspector.vue +2 -2
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NewSessionDialog.vue +36 -6
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PairDeviceWizard.vue +107 -5
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TreeRow.vue +3 -1
- package/apps/dpi-playground/apps/dpi-playground-hub/src/admin-auth.ts +19 -17
- package/apps/dpi-playground/apps/dpi-playground-hub/src/audit.ts +1 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/credentials.ts +4 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/hub-identity.ts +98 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/main.ts +31 -3
- package/apps/dpi-playground/apps/dpi-playground-hub/src/pairing.ts +172 -25
- package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel.ts +24 -0
- package/apps/dpi-sdk/src/api.ts +2 -0
- package/apps/dpi-sdk/src/node/private-file.ts +140 -0
- package/apps/dpi-sdk/src/node/registry-migration.ts +9 -0
- package/apps/dpi-sdk/src/node/registry.ts +6 -0
- package/apps/dpi-sdk/src/protocol/pairing.ts +137 -16
- package/apps/dpi-sdk/src/protocol/tunnel.ts +162 -38
- package/apps/dpi-tui/src/app.ts +889 -187
- package/apps/dpi-tui/src/hub-feed.ts +71 -0
- package/apps/dpi-tui/src/hub-mux.ts +168 -0
- package/apps/dpi-tui/src/main.ts +5 -1
- package/apps/dpi-tui/src/theme.ts +14 -0
- package/apps/dpi-tui/src/views.ts +311 -0
- package/bin/dpi.mjs +8 -3
- package/package.json +2 -2
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index--aoZ5Tj0.css +0 -1
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-Dq0CZYr7.js +0 -77
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
<meta name="referrer" content="no-referrer" />
|
|
8
8
|
<link rel="icon" type="image/svg+xml" href="/icons/durable-pi.svg" />
|
|
9
9
|
<title>Durable Pi | Network</title>
|
|
10
|
-
<script type="module" crossorigin src="/assets/index-
|
|
11
|
-
<link rel="stylesheet" crossorigin href="/assets/index
|
|
10
|
+
<script type="module" crossorigin src="/assets/index-CoYMgU-2.js"></script>
|
|
11
|
+
<link rel="stylesheet" crossorigin href="/assets/index-Cg3vey95.css">
|
|
12
12
|
</head>
|
|
13
13
|
<body>
|
|
14
14
|
<div id="app"></div>
|
|
@@ -1,15 +1,28 @@
|
|
|
1
1
|
/** Pair links stay in RAM and may only refer to the browser's own hub. */
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
decodePairOffer,
|
|
4
|
+
normalizePairCode,
|
|
5
|
+
parsePairOfferUrl,
|
|
6
|
+
} from "../../../../../dpi-sdk/src/protocol/pairing";
|
|
3
7
|
|
|
4
|
-
export type PairHandoff =
|
|
8
|
+
export type PairHandoff =
|
|
9
|
+
| { code: string; origin: string; offerFragment?: string }
|
|
10
|
+
| { error: string };
|
|
5
11
|
|
|
6
12
|
export function readPairHandoff(url: URL): PairHandoff | undefined {
|
|
7
13
|
if (!/^\/pair\/?$/.test(url.pathname)) return undefined;
|
|
8
|
-
// v2 FIRST: the whole handoff rides the #offer= fragment, decoded client-side —
|
|
14
|
+
// v2/v3 FIRST: the whole handoff rides the #offer= fragment, decoded client-side —
|
|
9
15
|
// the code never travels in a query, so it never reaches any server or CDN log.
|
|
10
16
|
if (url.hash.startsWith("#offer=")) {
|
|
11
17
|
const parsed = parsePairOfferUrl(url.href);
|
|
12
|
-
if (parsed)
|
|
18
|
+
if (parsed)
|
|
19
|
+
return {
|
|
20
|
+
code: parsed.offer.code,
|
|
21
|
+
origin: url.origin,
|
|
22
|
+
// The RAW fragment, RAM-only: /pair's device-handoff card relays it to
|
|
23
|
+
// the device machine's own pairing page (127.0.0.1) without any fetch.
|
|
24
|
+
offerFragment: url.hash.slice(1),
|
|
25
|
+
};
|
|
13
26
|
const offer = decodePairOffer(url.hash.slice("#offer=".length));
|
|
14
27
|
if (offer && offer.hub !== url.origin)
|
|
15
28
|
return {
|
|
@@ -55,9 +55,38 @@ async function call<T>(
|
|
|
55
55
|
/** The only auth failure the browser treats as "authenticate again". */
|
|
56
56
|
export const needsAuth = (e: unknown): boolean => e instanceof DpiError && e.status === 401;
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* The wizard QR payload (gap #3): the v2 offer link when the hub minted one —
|
|
60
|
+
* the whole handoff rides the #offer= fragment, which no server, CDN or proxy
|
|
61
|
+
* ever logs — and only a legacy hub's v1 pairUrl as the fallback. This is the
|
|
62
|
+
* exact string "Copy link" writes to the clipboard, so the QR and the copied
|
|
63
|
+
* link are always the same handoff.
|
|
64
|
+
*/
|
|
65
|
+
export function qrPayload(issue: PairCodeIssue): string {
|
|
66
|
+
return issue.offerUrl ?? issue.pairUrl;
|
|
67
|
+
}
|
|
68
|
+
|
|
58
69
|
export type RevokeReason = "user" | "compromise";
|
|
59
70
|
|
|
71
|
+
/** One device-initiated request as the hub lists it (identity only — no secrets). */
|
|
72
|
+
export interface PendingPairing {
|
|
73
|
+
codeId: string;
|
|
74
|
+
expiresAt: number;
|
|
75
|
+
phase: "awaiting_device" | "awaiting_proof" | "approved_ready" | "cancelled";
|
|
76
|
+
enrollmentId?: string;
|
|
77
|
+
deviceUid?: string;
|
|
78
|
+
deviceName?: string;
|
|
79
|
+
hostname?: string;
|
|
80
|
+
platform?: string;
|
|
81
|
+
keyFingerprint?: string;
|
|
82
|
+
proofVerified?: boolean;
|
|
83
|
+
startedAt?: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
60
86
|
export const pairingAdmin = {
|
|
87
|
+
/** Machines that asked for a code themselves — the identity the operator approves. */
|
|
88
|
+
pending: (signal?: AbortSignal) =>
|
|
89
|
+
call<{ codes: PendingPairing[] }>("GET", "/pair/codes", undefined, signal),
|
|
61
90
|
pairings: (signal?: AbortSignal) => call<PublicPairing[]>("GET", "/pairings", undefined, signal),
|
|
62
91
|
/** Operator opens the wizard: mint a code (admin-gated upstream by Caddy). */
|
|
63
92
|
createCode: (signal?: AbortSignal) => call<PairCodeIssue>("POST", "/pair/code", {}, signal),
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Minimal local QR encoder for the PairDeviceWizard (P2-24: "QR render local;
|
|
3
|
-
* no remote QR service"). Byte mode, EC level L, versions 1–
|
|
4
|
-
* ≤ ~106 bytes)
|
|
5
|
-
*
|
|
3
|
+
* no remote QR service"). Byte mode, EC level L, versions 1–12 — v1–5 covers
|
|
4
|
+
* legacy pair URLs (≤ ~106 bytes); v6–12 covers the v2 offer links whose whole
|
|
5
|
+
* handoff rides the #offer= fragment (~229 bytes today → v9; ~310 once the hub
|
|
6
|
+
* identity fingerprint rides the offer → v11; headroom to v12-L = 367 bytes).
|
|
7
|
+
* Pure function: text → boolean[size][size] module matrix.
|
|
8
|
+
* Cross-validated against the reference `python-qrcode` implementation
|
|
9
|
+
* (every module, every mask, every supported version).
|
|
6
10
|
*/
|
|
7
11
|
|
|
8
12
|
// -- GF(256), poly 0x11D ------------------------------------------------------
|
|
@@ -48,20 +52,63 @@ function rsEncode(data: number[], ecLen: number): number[] {
|
|
|
48
52
|
return rem;
|
|
49
53
|
}
|
|
50
54
|
|
|
51
|
-
// -- version tables (EC level L,
|
|
55
|
+
// -- version tables (EC level L, v1–12; transcribed from python-qrcode's
|
|
56
|
+
// RS_BLOCK_TABLE / PATTERN_POSITION_TABLE so encoder and cross-validation
|
|
57
|
+
// reference can never drift apart) ----------------------------------------
|
|
52
58
|
|
|
53
|
-
const SIZES = [21, 25, 29, 33, 37];
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
const
|
|
59
|
+
const SIZES = [21, 25, 29, 33, 37, 41, 45, 49, 53, 57, 61, 65];
|
|
60
|
+
/** Total DATA codewords per version (all blocks summed). */
|
|
61
|
+
const DATA_CODEWORDS = [19, 34, 55, 80, 108, 136, 156, 194, 232, 274, 324, 370];
|
|
62
|
+
/** Total EC codewords per version (all blocks summed). */
|
|
63
|
+
const EC_CODEWORDS = [7, 10, 15, 20, 26, 36, 40, 48, 60, 72, 80, 96];
|
|
64
|
+
/** EC codewords per single block (equal within a version). */
|
|
65
|
+
const EC_PER_BLOCK = [7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24];
|
|
66
|
+
/** Block groups per version: [count, dataCodewordsPerBlock], shorter first. */
|
|
67
|
+
const BLOCKS: ReadonlyArray<ReadonlyArray<readonly [number, number]>> = [
|
|
68
|
+
[[1, 19]],
|
|
69
|
+
[[1, 34]],
|
|
70
|
+
[[1, 55]],
|
|
71
|
+
[[1, 80]],
|
|
72
|
+
[[1, 108]],
|
|
73
|
+
[[2, 68]],
|
|
74
|
+
[[2, 78]],
|
|
75
|
+
[[2, 97]],
|
|
76
|
+
[[2, 116]],
|
|
77
|
+
[
|
|
78
|
+
[2, 68],
|
|
79
|
+
[2, 69],
|
|
80
|
+
],
|
|
81
|
+
[[4, 81]],
|
|
82
|
+
[
|
|
83
|
+
[2, 92],
|
|
84
|
+
[2, 93],
|
|
85
|
+
],
|
|
86
|
+
];
|
|
87
|
+
const ALIGN = [
|
|
88
|
+
[],
|
|
89
|
+
[6, 18],
|
|
90
|
+
[6, 22],
|
|
91
|
+
[6, 26],
|
|
92
|
+
[6, 30],
|
|
93
|
+
[6, 34],
|
|
94
|
+
[6, 22, 38],
|
|
95
|
+
[6, 24, 42],
|
|
96
|
+
[6, 26, 46],
|
|
97
|
+
[6, 28, 50],
|
|
98
|
+
[6, 30, 54],
|
|
99
|
+
[6, 32, 58],
|
|
100
|
+
]; // alignment centers
|
|
101
|
+
|
|
102
|
+
/** Byte-mode character-count indicator width: 8 bits for v1–9, 16 for v10+. */
|
|
103
|
+
const countBits = (version: number): number => (version <= 9 ? 8 : 16);
|
|
58
104
|
|
|
59
105
|
function selectVersion(byteLen: number): number {
|
|
60
|
-
for (let v = 1; v <=
|
|
61
|
-
// byte mode: 4-bit indicator +
|
|
62
|
-
|
|
106
|
+
for (let v = 1; v <= SIZES.length; v++) {
|
|
107
|
+
// byte mode: 4-bit indicator + count (8/16 bits) + payload; the terminator
|
|
108
|
+
// may be truncated to zero bits, so it never forces a larger version.
|
|
109
|
+
if (4 + countBits(v) + byteLen * 8 <= DATA_CODEWORDS[v - 1] * 8) return v;
|
|
63
110
|
}
|
|
64
|
-
throw new Error(`qr: payload too long (${byteLen} bytes;
|
|
111
|
+
throw new Error(`qr: payload too long (${byteLen} bytes; v12L max ${DATA_CODEWORDS[11] - 3})`);
|
|
65
112
|
}
|
|
66
113
|
|
|
67
114
|
// -- bit stream ---------------------------------------------------------------
|
|
@@ -73,7 +120,7 @@ function buildCodewords(text: string, version: number): number[] {
|
|
|
73
120
|
for (let i = len - 1; i >= 0; i--) bits.push((value >> i) & 1);
|
|
74
121
|
};
|
|
75
122
|
push(0b0100, 4); // byte mode
|
|
76
|
-
push(bytes.length,
|
|
123
|
+
push(bytes.length, countBits(version));
|
|
77
124
|
for (const b of bytes) push(b, 8);
|
|
78
125
|
const dataCodewords = DATA_CODEWORDS[version - 1];
|
|
79
126
|
const capacityBits = dataCodewords * 8;
|
|
@@ -91,6 +138,26 @@ function buildCodewords(text: string, version: number): number[] {
|
|
|
91
138
|
return out;
|
|
92
139
|
}
|
|
93
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Split the data codewords into the version's blocks, add per-block RS error
|
|
143
|
+
* correction, and interleave both block-wise (the spec's placement order for
|
|
144
|
+
* multi-block versions; for v1–5 this degenerates to the old single stream).
|
|
145
|
+
*/
|
|
146
|
+
function interleave(version: number, data: number[]): number[] {
|
|
147
|
+
const ecLen = EC_PER_BLOCK[version - 1];
|
|
148
|
+
const blocks: number[][] = [];
|
|
149
|
+
let offset = 0;
|
|
150
|
+
for (const [count, dataLen] of BLOCKS[version - 1])
|
|
151
|
+
for (let b = 0; b < count; b++) blocks.push(data.slice(offset, (offset += dataLen)));
|
|
152
|
+
const out: number[] = [];
|
|
153
|
+
const maxData = Math.max(...blocks.map((b) => b.length));
|
|
154
|
+
for (let i = 0; i < maxData; i++)
|
|
155
|
+
for (const block of blocks) if (i < block.length) out.push(block[i]);
|
|
156
|
+
const ecs = blocks.map((block) => rsEncode(block, ecLen));
|
|
157
|
+
for (let i = 0; i < ecLen; i++) for (const ec of ecs) out.push(ec[i]);
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
94
161
|
// -- matrix scaffolding ---------------------------------------------------------
|
|
95
162
|
|
|
96
163
|
function makeMatrix(size: number): boolean[][] {
|
|
@@ -130,6 +197,21 @@ function placeFunctionPatterns(m: boolean[][], version: number): void {
|
|
|
130
197
|
}
|
|
131
198
|
// dark module
|
|
132
199
|
m[size - 8][8] = true;
|
|
200
|
+
// version information (v≥7 only): 6-bit version + 12 BCH bits (G18 = 0x1f25), two copies
|
|
201
|
+
if (version >= 7) {
|
|
202
|
+
// long division of version·x¹² by G18, MSB first (same algorithm as the
|
|
203
|
+
// reference implementation's BCH_type_number)
|
|
204
|
+
let d = version << 12;
|
|
205
|
+
for (let bit = 17; bit >= 12; bit--) if ((d >> bit) & 1) d ^= 0x1f25 << (bit - 12);
|
|
206
|
+
const bits = (version << 12) | (d & 0xfff);
|
|
207
|
+
for (let i = 0; i < 18; i++) {
|
|
208
|
+
const bit = ((bits >> i) & 1) === 1;
|
|
209
|
+
const a = size - 11 + (i % 3);
|
|
210
|
+
const b = (i / 3) | 0;
|
|
211
|
+
m[a][b] = bit; // bottom-left strip: rows size-11..size-9, cols 0..5
|
|
212
|
+
m[b][a] = bit; // top-right strip: rows 0..5, cols size-11..size-9
|
|
213
|
+
}
|
|
214
|
+
}
|
|
133
215
|
}
|
|
134
216
|
|
|
135
217
|
// format info: EC level L (01) + mask, BCH(15,5), XOR 0x5412
|
|
@@ -177,7 +259,7 @@ function placeData(m: boolean[][], codewords: number[], version: number, mask: n
|
|
|
177
259
|
const row = upward ? size - 1 - step : step;
|
|
178
260
|
for (const c of [col, col - 1]) {
|
|
179
261
|
if (isFunctionModule(row, c, version)) continue;
|
|
180
|
-
if (bitIndex >= (DATA_CODEWORDS[version - 1] +
|
|
262
|
+
if (bitIndex >= (DATA_CODEWORDS[version - 1] + EC_CODEWORDS[version - 1]) * 8) {
|
|
181
263
|
// past data+ec bits → remainder modules (decoders ignore them)
|
|
182
264
|
bitIndex++;
|
|
183
265
|
continue;
|
|
@@ -220,6 +302,14 @@ function isFunctionModule(row: number, col: number, version: number): boolean {
|
|
|
220
302
|
if (row >= size - 8 && col <= 8) return true;
|
|
221
303
|
// timing
|
|
222
304
|
if (row === 6 || col === 6) return true;
|
|
305
|
+
// version information (v≥7): the two 3×6 strips beside the top-right and
|
|
306
|
+
// bottom-left finders
|
|
307
|
+
if (
|
|
308
|
+
version >= 7 &&
|
|
309
|
+
((row >= size - 11 && row <= size - 9 && col <= 5) ||
|
|
310
|
+
(col >= size - 11 && col <= size - 9 && row <= 5))
|
|
311
|
+
)
|
|
312
|
+
return true;
|
|
223
313
|
// alignment
|
|
224
314
|
const centers = ALIGN[version - 1];
|
|
225
315
|
for (const r of centers)
|
|
@@ -286,8 +376,7 @@ function matrixWithMask(text: string, mask: number): boolean[][] {
|
|
|
286
376
|
const version = selectVersion(new TextEncoder().encode(text).length);
|
|
287
377
|
const size = SIZES[version - 1];
|
|
288
378
|
const data = buildCodewords(text, version);
|
|
289
|
-
const
|
|
290
|
-
const all = [...data, ...ec];
|
|
379
|
+
const all = interleave(version, data);
|
|
291
380
|
const m = makeMatrix(size);
|
|
292
381
|
placeFunctionPatterns(m, version);
|
|
293
382
|
placeData(m, all, version, mask);
|
|
@@ -295,7 +384,7 @@ function matrixWithMask(text: string, mask: number): boolean[][] {
|
|
|
295
384
|
return m;
|
|
296
385
|
}
|
|
297
386
|
|
|
298
|
-
/** Encode text into a QR matrix (true = dark module), EC level L, versions 1–
|
|
387
|
+
/** Encode text into a QR matrix (true = dark module), EC level L, versions 1–12. */
|
|
299
388
|
export function qrMatrix(text: string, maskOverride?: number): boolean[][] {
|
|
300
389
|
if (maskOverride !== undefined) return matrixWithMask(text, maskOverride);
|
|
301
390
|
let best: { m: boolean[][]; score: number } | undefined;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The no-admin "Pair THIS device" card (gap #4): when /pair carries an
|
|
4
|
+
* #offer= fragment, the machine whose BROWSER opened it can relay the offer —
|
|
5
|
+
* unchanged, fragment-only — to its own dpi backend's pairing page. No login,
|
|
6
|
+
* no hub API: the hub wizard stays the operator side (mint + approve).
|
|
7
|
+
*/
|
|
8
|
+
import { computed, ref } from "vue";
|
|
9
|
+
import { MonitorSmartphone, Copy, ExternalLink } from "lucide-vue-next";
|
|
10
|
+
|
|
11
|
+
const props = defineProps<{ offerFragment: string }>();
|
|
12
|
+
const port = ref("4780");
|
|
13
|
+
const copied = ref(false);
|
|
14
|
+
|
|
15
|
+
const deviceUrl = computed(() => {
|
|
16
|
+
const p = /^\d{1,5}$/.test(port.value) ? Number(port.value) : 0;
|
|
17
|
+
if (!p || p < 1 || p > 65535) return undefined;
|
|
18
|
+
return `http://127.0.0.1:${p}/pair#${props.offerFragment}`;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
function open() {
|
|
22
|
+
const url = deviceUrl.value;
|
|
23
|
+
if (url) window.open(url, "_blank", "noopener");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function copy() {
|
|
27
|
+
const url = deviceUrl.value;
|
|
28
|
+
if (!url) return;
|
|
29
|
+
try {
|
|
30
|
+
await navigator.clipboard.writeText(url);
|
|
31
|
+
copied.value = true;
|
|
32
|
+
setTimeout(() => (copied.value = false), 1600);
|
|
33
|
+
} catch {
|
|
34
|
+
/* clipboard denied: the open button still works */
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<template>
|
|
40
|
+
<section class="device-handoff" aria-label="Pair this machine">
|
|
41
|
+
<header>
|
|
42
|
+
<MonitorSmartphone :size="18" />
|
|
43
|
+
<h3>Pair THIS machine</h3>
|
|
44
|
+
</header>
|
|
45
|
+
<p class="what">
|
|
46
|
+
If this browser runs on the device itself, open its local pairing page — the offer is relayed
|
|
47
|
+
inside the link, nothing is sent to the hub. Start <code>dpi device</code> there first.
|
|
48
|
+
</p>
|
|
49
|
+
<div class="row">
|
|
50
|
+
<label>
|
|
51
|
+
local port
|
|
52
|
+
<input
|
|
53
|
+
v-model="port"
|
|
54
|
+
inputmode="numeric"
|
|
55
|
+
spellcheck="false"
|
|
56
|
+
aria-label="Local dpi backend port"
|
|
57
|
+
/>
|
|
58
|
+
</label>
|
|
59
|
+
<button class="primary" type="button" :disabled="!deviceUrl" @click="open">
|
|
60
|
+
<ExternalLink :size="15" />Open the device page
|
|
61
|
+
</button>
|
|
62
|
+
<button class="secondary" type="button" :disabled="!deviceUrl" @click="copy">
|
|
63
|
+
<Copy :size="15" />{{ copied ? "Copied" : "Copy link" }}
|
|
64
|
+
</button>
|
|
65
|
+
</div>
|
|
66
|
+
<p class="target" v-if="deviceUrl" data-testid="device-url">{{ deviceUrl }}</p>
|
|
67
|
+
<p class="err" v-if="!deviceUrl">port must be 1–65535</p>
|
|
68
|
+
</section>
|
|
69
|
+
</template>
|
|
70
|
+
|
|
71
|
+
<style scoped>
|
|
72
|
+
.device-handoff {
|
|
73
|
+
border: 1px solid var(--border);
|
|
74
|
+
border-radius: 12px;
|
|
75
|
+
padding: 14px 16px;
|
|
76
|
+
margin-bottom: 14px;
|
|
77
|
+
background: var(--panel);
|
|
78
|
+
}
|
|
79
|
+
header {
|
|
80
|
+
display: flex;
|
|
81
|
+
align-items: center;
|
|
82
|
+
gap: 8px;
|
|
83
|
+
font-weight: 650;
|
|
84
|
+
margin-bottom: 6px;
|
|
85
|
+
}
|
|
86
|
+
h3 {
|
|
87
|
+
margin: 0;
|
|
88
|
+
font-size: 14.5px;
|
|
89
|
+
}
|
|
90
|
+
.what {
|
|
91
|
+
margin: 0 0 10px;
|
|
92
|
+
font-size: 13px;
|
|
93
|
+
color: var(--muted);
|
|
94
|
+
}
|
|
95
|
+
code {
|
|
96
|
+
font-family: ui-monospace, monospace;
|
|
97
|
+
font-size: 12px;
|
|
98
|
+
}
|
|
99
|
+
.row {
|
|
100
|
+
display: flex;
|
|
101
|
+
flex-wrap: wrap;
|
|
102
|
+
gap: 8px;
|
|
103
|
+
align-items: end;
|
|
104
|
+
}
|
|
105
|
+
label {
|
|
106
|
+
display: grid;
|
|
107
|
+
gap: 4px;
|
|
108
|
+
font-size: 12px;
|
|
109
|
+
color: var(--muted);
|
|
110
|
+
}
|
|
111
|
+
input {
|
|
112
|
+
width: 90px;
|
|
113
|
+
padding: 7px 9px;
|
|
114
|
+
border-radius: 8px;
|
|
115
|
+
border: 1px solid var(--border);
|
|
116
|
+
background: var(--panel);
|
|
117
|
+
color: inherit;
|
|
118
|
+
font: inherit;
|
|
119
|
+
}
|
|
120
|
+
.err {
|
|
121
|
+
color: var(--danger, #f28b82);
|
|
122
|
+
font-size: 12.5px;
|
|
123
|
+
margin: 8px 0 0;
|
|
124
|
+
}
|
|
125
|
+
.target {
|
|
126
|
+
margin: 10px 0 0;
|
|
127
|
+
font-family: ui-monospace, monospace;
|
|
128
|
+
font-size: 12px;
|
|
129
|
+
color: var(--muted);
|
|
130
|
+
word-break: break-all;
|
|
131
|
+
}
|
|
132
|
+
</style>
|
|
@@ -239,7 +239,7 @@ const offeredTools = computed(() => detail.value?.tools.filter((t) => t.offered)
|
|
|
239
239
|
<span class="status-pill" :class="{ working: busy }"
|
|
240
240
|
><i />{{ busy ? "Working" : "Idle" }}</span
|
|
241
241
|
><span v-if="background" class="background-pill">{{ background }} background</span
|
|
242
|
-
><span class="saved"><Server :size="18" />{{ device?.id }}</span>
|
|
242
|
+
><span class="saved" :title="device?.id"><Server :size="18" />{{ device?.name ?? device?.id }}</span>
|
|
243
243
|
<TunnelStatusBar v-if="device" :device="device" />
|
|
244
244
|
</div>
|
|
245
245
|
</section>
|
|
@@ -318,7 +318,7 @@ const offeredTools = computed(() => detail.value?.tools.filter((t) => t.offered)
|
|
|
318
318
|
<Braces :size="18" />
|
|
319
319
|
</div>
|
|
320
320
|
<p class="hint app-state-description">
|
|
321
|
-
One shared JSON object for every agent, session and fork on {{ device?.id }}. You and
|
|
321
|
+
One shared JSON object for every agent, session and fork on {{ device?.name ?? device?.id }}. You and
|
|
322
322
|
your agents can read, create, update or delete values and fields. Changes appear live
|
|
323
323
|
and persist across closed browsers, interrupted agents and backend restarts.
|
|
324
324
|
</p>
|
|
@@ -13,7 +13,31 @@ const deviceId = ref(
|
|
|
13
13
|
devices.value.find((d) => d.id === props.deviceId)?.id ?? devices.value[0]?.id ?? "",
|
|
14
14
|
);
|
|
15
15
|
const device = computed(() => devices.value.find((d) => d.id === deviceId.value));
|
|
16
|
-
const models = computed(() =>
|
|
16
|
+
const models = computed(() => {
|
|
17
|
+
const list = device.value?.info?.models ?? [];
|
|
18
|
+
if (list.length) return list;
|
|
19
|
+
// No usable catalog (device has no provider key): still offer the device's
|
|
20
|
+
// default so a session can be created — chatting needs the key, creating
|
|
21
|
+
// must not. Caught live: the dropdown went empty and create always failed.
|
|
22
|
+
const info = device.value?.info;
|
|
23
|
+
if (info?.defaultModel && info.provider)
|
|
24
|
+
return [
|
|
25
|
+
{
|
|
26
|
+
id: info.defaultModel,
|
|
27
|
+
provider: info.provider,
|
|
28
|
+
name: `${info.defaultModel} (device default)`,
|
|
29
|
+
tier: "—",
|
|
30
|
+
icon: "lab.png",
|
|
31
|
+
contextWindow: 0,
|
|
32
|
+
reasoning: false,
|
|
33
|
+
cost: { input: 0, output: 0 },
|
|
34
|
+
},
|
|
35
|
+
];
|
|
36
|
+
return [];
|
|
37
|
+
});
|
|
38
|
+
const catalogEmpty = computed(
|
|
39
|
+
() => !models.value.length || (device.value?.info?.models?.length ?? 0) === 0,
|
|
40
|
+
);
|
|
17
41
|
const key = (m: { provider: string; id: string }) => `${m.provider}/${m.id}`;
|
|
18
42
|
const draft = ref({ name: "", cwd: "", model: "" });
|
|
19
43
|
const pending = ref(false);
|
|
@@ -22,8 +46,9 @@ const error = ref("");
|
|
|
22
46
|
watch(
|
|
23
47
|
device,
|
|
24
48
|
(d) => {
|
|
49
|
+
const list = models.value;
|
|
25
50
|
const fallback =
|
|
26
|
-
|
|
51
|
+
list.find((m) => m.id === d?.info?.defaultModel) ?? list[0];
|
|
27
52
|
draft.value.model = fallback ? key(fallback) : "";
|
|
28
53
|
draft.value.cwd = "";
|
|
29
54
|
},
|
|
@@ -35,7 +60,7 @@ async function create() {
|
|
|
35
60
|
pending.value = true;
|
|
36
61
|
error.value = "";
|
|
37
62
|
try {
|
|
38
|
-
const model = models.value.find((m) => key(m) === draft.value.model);
|
|
63
|
+
const model = models.value.find((m) => key(m) === draft.value.model) ?? models.value[0];
|
|
39
64
|
if (!model) throw Error("Choose a model");
|
|
40
65
|
const created = await deviceClient(device.value.id).createSession({
|
|
41
66
|
name: draft.value.name.trim() || "New session",
|
|
@@ -79,7 +104,7 @@ async function create() {
|
|
|
79
104
|
<label
|
|
80
105
|
>Device<select v-model="deviceId" aria-label="Device">
|
|
81
106
|
<option v-for="d in devices" :key="d.id" :value="d.id">
|
|
82
|
-
{{ d.
|
|
107
|
+
{{ d.name }}{{ d.info?.hostname && d.info.hostname !== d.name ? ` · ${d.info.hostname}` : "" }}
|
|
83
108
|
</option>
|
|
84
109
|
</select></label
|
|
85
110
|
>
|
|
@@ -91,11 +116,16 @@ async function create() {
|
|
|
91
116
|
maxlength="100"
|
|
92
117
|
/></label>
|
|
93
118
|
<div class="field">
|
|
94
|
-
<span>Working directory on {{ deviceId }}</span>
|
|
119
|
+
<span>Working directory on {{ devices.find((d) => d.id === deviceId)?.name ?? deviceId }}</span>
|
|
95
120
|
<DirBrowser v-model="draft.cwd" :device-id="deviceId" />
|
|
96
121
|
</div>
|
|
97
122
|
<label
|
|
98
|
-
>Model<
|
|
123
|
+
>Model<span
|
|
124
|
+
v-if="catalogEmpty"
|
|
125
|
+
class="hint"
|
|
126
|
+
title="The device reports no usable models — create still works with its default; add the provider key on the device (e.g. OPENROUTER_API_KEY) to see the catalog"
|
|
127
|
+
>no key on device — using default</span
|
|
128
|
+
><select v-model="draft.model" aria-label="New session model">
|
|
99
129
|
<option v-for="m in models" :key="key(m)" :value="key(m)">
|
|
100
130
|
{{ m.tier }} / {{ m.name }}
|
|
101
131
|
</option>
|