sideby 0.12.0 → 0.13.0
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 +1 -1
- package/dist/build.json +1 -1
- package/dist/src/cli/commands.d.ts +4 -2
- package/dist/src/cli/commands.js +117 -17
- package/dist/src/cli/json-schemas.js +52 -1
- package/dist/src/cli/main.js +34 -3
- package/dist/src/core/brand-mark.d.ts +5 -7
- package/dist/src/core/brand-mark.js +18 -5
- package/dist/src/core/browser-profiles.d.ts +71 -0
- package/dist/src/core/browser-profiles.js +361 -0
- package/dist/src/core/config.d.ts +15 -0
- package/dist/src/core/config.js +13 -1
- package/dist/src/core/create.js +2 -1
- package/dist/src/core/desktop-app.d.ts +3 -1
- package/dist/src/core/desktop-app.js +29 -8
- package/dist/src/core/launch.d.ts +7 -0
- package/dist/src/core/launch.js +22 -2
- package/dist/src/core/leveldb-keys.d.ts +14 -0
- package/dist/src/core/leveldb-keys.js +291 -0
- package/dist/src/core/remote-control.d.ts +17 -6
- package/dist/src/core/remote-control.js +40 -17
- package/dist/src/core/update.d.ts +10 -3
- package/dist/src/core/update.js +38 -3
- package/dist/src/core/version.d.ts +11 -0
- package/dist/src/core/version.js +26 -0
- package/dist/src/desktop/app.d.ts +2 -0
- package/dist/src/desktop/app.js +39 -3
- package/dist/src/families/browser.d.ts +20 -0
- package/dist/src/families/browser.js +23 -0
- package/dist/src/families/claude/browser.d.ts +3 -0
- package/dist/src/families/claude/browser.js +14 -0
- package/dist/src/families/claude/desktop.js +1 -0
- package/dist/src/families/codex/browser.d.ts +7 -0
- package/dist/src/families/codex/browser.js +26 -0
- package/dist/src/families/desktop.d.ts +5 -1
- package/dist/src/families/pi/index.js +2 -0
- package/dist/src/panel/handler.d.ts +6 -0
- package/dist/src/panel/handler.js +49 -6
- package/dist/src/panel/i18n.d.ts +75 -4
- package/dist/src/panel/i18n.js +162 -20
- package/dist/src/panel/page-logic.d.ts +23 -0
- package/dist/src/panel/page-logic.js +44 -0
- package/dist/src/panel/page-script.js +609 -180
- package/dist/src/panel/page-styles.js +72 -17
- package/dist/src/panel/page.d.ts +5 -0
- package/dist/src/panel/page.js +10 -9
- package/dist/src/runtime.d.ts +6 -1
- package/dist/src/runtime.js +22 -11
- package/dist/src/types.d.ts +2 -0
- package/package.json +5 -3
- package/schemas/browser-open.json +47 -0
- package/schemas/browser-switch.json +42 -0
- package/schemas/browser.json +99 -0
- package/schemas/config.json +11 -0
- package/schemas/families.json +6 -0
- package/schemas/list.json +6 -0
- package/schemas/remote-control.json +4 -0
- package/schemas/update.json +4 -0
- package/skills/sideby/SKILL.md +5 -3
- package/skills/sideby/references/setup.md +1 -1
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// Reads the key names of a Chrome Local Storage LevelDB directory, read-only (spec §3.24, ADR-0023). Only keys are
|
|
2
|
+
// kept: a value's bytes are stepped over and never turned into text, compared or returned. A table block is Snappy
|
|
3
|
+
// compressed as a whole, keys and values together, so it is decompressed into a buffer that is dropped after its keys
|
|
4
|
+
// are taken. Nothing is written, locked or copied; a record cut short (Chrome writing while we read) is skipped.
|
|
5
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
/** Files larger than this are skipped and reported. */
|
|
8
|
+
export const MAX_LEVELDB_FILE = 64 * 1024 * 1024;
|
|
9
|
+
class Cut extends Error {
|
|
10
|
+
}
|
|
11
|
+
function varint(buf, pos) {
|
|
12
|
+
let result = 0;
|
|
13
|
+
let shift = 0;
|
|
14
|
+
for (let i = pos; i < buf.length && shift <= 35; i++) {
|
|
15
|
+
const b = buf[i];
|
|
16
|
+
result += (b & 0x7f) * 2 ** shift;
|
|
17
|
+
if (b < 0x80)
|
|
18
|
+
return [result, i + 1];
|
|
19
|
+
shift += 7;
|
|
20
|
+
}
|
|
21
|
+
throw new Cut();
|
|
22
|
+
}
|
|
23
|
+
/** Snappy raw-format decompression (https://github.com/google/snappy/blob/main/format_description.txt). */
|
|
24
|
+
export function snappyDecompress(src) {
|
|
25
|
+
const [len, start] = varint(src, 0);
|
|
26
|
+
if (len > MAX_LEVELDB_FILE)
|
|
27
|
+
throw new Cut();
|
|
28
|
+
const out = new Uint8Array(len);
|
|
29
|
+
let o = 0;
|
|
30
|
+
let i = start;
|
|
31
|
+
while (i < src.length) {
|
|
32
|
+
const tag = src[i++];
|
|
33
|
+
const kind = tag & 3;
|
|
34
|
+
if (kind === 0) {
|
|
35
|
+
let n = tag >> 2;
|
|
36
|
+
if (n >= 60) {
|
|
37
|
+
const extra = n - 59;
|
|
38
|
+
n = 0;
|
|
39
|
+
for (let k = 0; k < extra; k++)
|
|
40
|
+
n += (src[i + k] ?? 0) * 256 ** k;
|
|
41
|
+
i += extra;
|
|
42
|
+
}
|
|
43
|
+
n += 1;
|
|
44
|
+
if (i + n > src.length || o + n > len)
|
|
45
|
+
throw new Cut();
|
|
46
|
+
out.set(src.subarray(i, i + n), o);
|
|
47
|
+
i += n;
|
|
48
|
+
o += n;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
let n;
|
|
52
|
+
let off;
|
|
53
|
+
if (kind === 1) {
|
|
54
|
+
n = ((tag >> 2) & 7) + 4;
|
|
55
|
+
off = ((tag >> 5) << 8) | (src[i++] ?? 0);
|
|
56
|
+
}
|
|
57
|
+
else if (kind === 2) {
|
|
58
|
+
n = (tag >> 2) + 1;
|
|
59
|
+
off = (src[i] ?? 0) | ((src[i + 1] ?? 0) << 8);
|
|
60
|
+
i += 2;
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
n = (tag >> 2) + 1;
|
|
64
|
+
off =
|
|
65
|
+
((src[i] ?? 0) | ((src[i + 1] ?? 0) << 8) | ((src[i + 2] ?? 0) << 16)) + (src[i + 3] ?? 0) * 2 ** 24;
|
|
66
|
+
i += 4;
|
|
67
|
+
}
|
|
68
|
+
if (off === 0 || off > o || o + n > len)
|
|
69
|
+
throw new Cut();
|
|
70
|
+
for (let k = 0; k < n; k++, o++)
|
|
71
|
+
out[o] = out[o - off];
|
|
72
|
+
}
|
|
73
|
+
if (o !== len)
|
|
74
|
+
throw new Cut();
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
/** A `.log` file: 32 KiB blocks of records that together form WriteBatches. */
|
|
78
|
+
function readLog(buf, sink) {
|
|
79
|
+
const BLOCK = 32768;
|
|
80
|
+
const parts = [];
|
|
81
|
+
let pos = 0;
|
|
82
|
+
while (pos + 7 <= buf.length) {
|
|
83
|
+
const left = BLOCK - (pos % BLOCK);
|
|
84
|
+
if (left < 7) {
|
|
85
|
+
pos += left;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const len = buf[pos + 4] | (buf[pos + 5] << 8);
|
|
89
|
+
const type = buf[pos + 6];
|
|
90
|
+
const data = buf.subarray(pos + 7, pos + 7 + len);
|
|
91
|
+
pos += 7 + len;
|
|
92
|
+
if (type === 0 || data.length < len)
|
|
93
|
+
break;
|
|
94
|
+
if (type === 1)
|
|
95
|
+
batch(data, sink);
|
|
96
|
+
else if (type === 2)
|
|
97
|
+
parts.splice(0, parts.length, data);
|
|
98
|
+
else if (type === 3)
|
|
99
|
+
parts.push(data);
|
|
100
|
+
else if (type === 4 && parts.length) {
|
|
101
|
+
parts.push(data);
|
|
102
|
+
batch(concat(parts), sink);
|
|
103
|
+
parts.length = 0;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function concat(parts) {
|
|
108
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
109
|
+
let o = 0;
|
|
110
|
+
for (const p of parts) {
|
|
111
|
+
out.set(p, o);
|
|
112
|
+
o += p.length;
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
function batch(b, sink) {
|
|
117
|
+
if (b.length < 12)
|
|
118
|
+
return;
|
|
119
|
+
const view = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
|
120
|
+
let seq = view.getUint32(0, true) + view.getUint32(4, true) * 2 ** 32;
|
|
121
|
+
const count = view.getUint32(8, true);
|
|
122
|
+
let pos = 12;
|
|
123
|
+
try {
|
|
124
|
+
for (let n = 0; n < count && pos < b.length; n++, seq++) {
|
|
125
|
+
const tag = b[pos++];
|
|
126
|
+
const [kl, p1] = varint(b, pos);
|
|
127
|
+
const key = b.subarray(p1, p1 + kl);
|
|
128
|
+
pos = p1 + kl;
|
|
129
|
+
if (pos > b.length)
|
|
130
|
+
throw new Cut();
|
|
131
|
+
if (tag === 1) {
|
|
132
|
+
const [vl, p2] = varint(b, pos);
|
|
133
|
+
pos = p2 + vl; // the value is stepped over
|
|
134
|
+
sink(key, seq, true);
|
|
135
|
+
}
|
|
136
|
+
else if (tag === 0)
|
|
137
|
+
sink(key, seq, false);
|
|
138
|
+
else
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
catch (e) {
|
|
143
|
+
if (!(e instanceof Cut))
|
|
144
|
+
throw e;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** One block of a table: prefix-compressed entries, then the restart array. Calls `each` with key and value range. */
|
|
148
|
+
function blockEntries(block, each) {
|
|
149
|
+
if (block.length < 4)
|
|
150
|
+
return;
|
|
151
|
+
const view = new DataView(block.buffer, block.byteOffset, block.byteLength);
|
|
152
|
+
const restarts = view.getUint32(block.length - 4, true);
|
|
153
|
+
const end = block.length - 4 - restarts * 4;
|
|
154
|
+
if (end < 0)
|
|
155
|
+
return;
|
|
156
|
+
let pos = 0;
|
|
157
|
+
let last = new Uint8Array(0);
|
|
158
|
+
while (pos < end) {
|
|
159
|
+
const [shared, p1] = varint(block, pos);
|
|
160
|
+
const [unshared, p2] = varint(block, p1);
|
|
161
|
+
const [vlen, p3] = varint(block, p2);
|
|
162
|
+
if (shared > last.length || p3 + unshared + vlen > end)
|
|
163
|
+
throw new Cut();
|
|
164
|
+
const key = new Uint8Array(shared + unshared);
|
|
165
|
+
key.set(last.subarray(0, shared), 0);
|
|
166
|
+
key.set(block.subarray(p3, p3 + unshared), shared);
|
|
167
|
+
each(key, p3 + unshared, p3 + unshared + vlen);
|
|
168
|
+
last = key;
|
|
169
|
+
pos = p3 + unshared + vlen;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function readBlock(file, offset, size) {
|
|
173
|
+
if (offset + size + 5 > file.length)
|
|
174
|
+
throw new Cut();
|
|
175
|
+
const raw = file.subarray(offset, offset + size);
|
|
176
|
+
const type = file[offset + size];
|
|
177
|
+
if (type === 0)
|
|
178
|
+
return raw;
|
|
179
|
+
if (type === 1)
|
|
180
|
+
return snappyDecompress(raw);
|
|
181
|
+
throw new Cut();
|
|
182
|
+
}
|
|
183
|
+
/** A `.ldb` (or `.sst`) table: footer → index block → data blocks. Only internal keys are taken from data blocks. */
|
|
184
|
+
function readTable(buf, sink) {
|
|
185
|
+
if (buf.length < 48)
|
|
186
|
+
return;
|
|
187
|
+
const footer = buf.subarray(buf.length - 48);
|
|
188
|
+
const magic = new DataView(footer.buffer, footer.byteOffset + 40, 8);
|
|
189
|
+
if (magic.getUint32(0, true) !== 0x8b80fb57 || magic.getUint32(4, true) !== 0xdb477524)
|
|
190
|
+
return;
|
|
191
|
+
let [, p] = varint(footer, 0) // metaindex offset
|
|
192
|
+
;
|
|
193
|
+
[, p] = varint(footer, p); // metaindex size
|
|
194
|
+
const [indexOff, p2] = varint(footer, p);
|
|
195
|
+
const [indexSize] = varint(footer, p2);
|
|
196
|
+
const index = readBlock(buf, indexOff, indexSize);
|
|
197
|
+
blockEntries(index, (_key, vs, ve) => {
|
|
198
|
+
const handle = index.subarray(vs, ve);
|
|
199
|
+
const [off, q] = varint(handle, 0);
|
|
200
|
+
const [size] = varint(handle, q);
|
|
201
|
+
let block;
|
|
202
|
+
try {
|
|
203
|
+
block = readBlock(buf, off, size);
|
|
204
|
+
}
|
|
205
|
+
catch (e) {
|
|
206
|
+
if (e instanceof Cut)
|
|
207
|
+
return;
|
|
208
|
+
throw e;
|
|
209
|
+
}
|
|
210
|
+
blockEntries(block, (ikey) => {
|
|
211
|
+
if (ikey.length < 8)
|
|
212
|
+
return;
|
|
213
|
+
const t = new DataView(ikey.buffer, ikey.byteOffset + ikey.length - 8, 8);
|
|
214
|
+
const tag = t.getUint32(0, true) + t.getUint32(4, true) * 2 ** 32;
|
|
215
|
+
sink(ikey.subarray(0, ikey.length - 8), Math.floor(tag / 256), tag % 256 === 1);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
const latin1 = new TextDecoder('latin1');
|
|
220
|
+
const utf16 = new TextDecoder('utf-16le');
|
|
221
|
+
/**
|
|
222
|
+
* The Local Storage key names of `origins` (`https://claude.ai`), from a `Local Storage/leveldb` directory. A key
|
|
223
|
+
* deleted later is left out. `changedAt` is the newest modification time of the files read. Files that are too large
|
|
224
|
+
* or unreadable go to `problems` (their name only).
|
|
225
|
+
*/
|
|
226
|
+
export async function localStorageKeys(dir, origins) {
|
|
227
|
+
const problems = [];
|
|
228
|
+
const keys = new Map(origins.map((o) => [o, new Set()]));
|
|
229
|
+
let names;
|
|
230
|
+
try {
|
|
231
|
+
names = await readdir(dir);
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return { keys, problems };
|
|
235
|
+
}
|
|
236
|
+
const prefixes = origins.map((o) => Buffer.from(`_${o}\u0000`, 'latin1'));
|
|
237
|
+
const states = new Map();
|
|
238
|
+
const sink = (key, seq, live) => {
|
|
239
|
+
const i = prefixes.findIndex((p) => key.length > p.length && Buffer.from(key.subarray(0, p.length)).equals(p));
|
|
240
|
+
if (i < 0)
|
|
241
|
+
return;
|
|
242
|
+
const prefix = prefixes[i];
|
|
243
|
+
const enc = key[prefix.length];
|
|
244
|
+
const body = key.subarray(prefix.length + 1);
|
|
245
|
+
const name = enc === 1 ? latin1.decode(body) : enc === 0 ? utf16.decode(body) : undefined;
|
|
246
|
+
if (name === undefined)
|
|
247
|
+
return;
|
|
248
|
+
const id = `${i}\u0000${name}`;
|
|
249
|
+
const prev = states.get(id);
|
|
250
|
+
if (!prev || seq >= prev.seq)
|
|
251
|
+
states.set(id, { origin: origins[i], name, seq, live });
|
|
252
|
+
};
|
|
253
|
+
let changedAt;
|
|
254
|
+
for (const name of names.sort()) {
|
|
255
|
+
const table = name.endsWith('.ldb') || name.endsWith('.sst');
|
|
256
|
+
if (!table && !name.endsWith('.log'))
|
|
257
|
+
continue;
|
|
258
|
+
const file = join(dir, name);
|
|
259
|
+
try {
|
|
260
|
+
const st = await stat(file);
|
|
261
|
+
if (!st.isFile())
|
|
262
|
+
continue;
|
|
263
|
+
if (st.size > MAX_LEVELDB_FILE) {
|
|
264
|
+
problems.push(`${name} is larger than 64 MB and was skipped`);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
changedAt = Math.max(changedAt ?? 0, st.mtimeMs);
|
|
268
|
+
const buf = new Uint8Array(await readFile(file));
|
|
269
|
+
try {
|
|
270
|
+
if (table)
|
|
271
|
+
readTable(buf, sink);
|
|
272
|
+
else
|
|
273
|
+
readLog(buf, sink);
|
|
274
|
+
}
|
|
275
|
+
catch (e) {
|
|
276
|
+
if (!(e instanceof Cut))
|
|
277
|
+
throw e;
|
|
278
|
+
problems.push(`${name} could not be read completely`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
catch (e) {
|
|
282
|
+
if (e instanceof Cut)
|
|
283
|
+
continue;
|
|
284
|
+
problems.push(`${name} could not be read`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
for (const s of states.values())
|
|
288
|
+
if (s.live)
|
|
289
|
+
keys.get(s.origin)?.add(s.name);
|
|
290
|
+
return { keys, ...(changedAt === undefined ? {} : { changedAt }), problems };
|
|
291
|
+
}
|
|
@@ -13,13 +13,15 @@ export interface RemoteControlGroup {
|
|
|
13
13
|
state: RemoteControlState;
|
|
14
14
|
/** For `blocked` and `missing`: why, and what to do. */
|
|
15
15
|
reason?: string;
|
|
16
|
-
/** sideby
|
|
16
|
+
/** sideby set it here and can put the earlier value back. */
|
|
17
17
|
undo: boolean;
|
|
18
18
|
}
|
|
19
19
|
export interface RemoteControlUndoRecord {
|
|
20
20
|
file: string;
|
|
21
|
-
/** The value before sideby wrote
|
|
21
|
+
/** The value before sideby wrote, or null when the key was not there. */
|
|
22
22
|
previous: boolean | null;
|
|
23
|
+
/** The value sideby wrote; records from before `on` existed lack it and mean `false`. */
|
|
24
|
+
wrote?: boolean;
|
|
23
25
|
at: string;
|
|
24
26
|
}
|
|
25
27
|
export interface RemoteControlChange {
|
|
@@ -37,9 +39,18 @@ export interface RemoteControlChange {
|
|
|
37
39
|
export declare function remoteControlGroups(accounts: readonly Account[], ctx: ReadContext): Promise<RemoteControlGroup[]>;
|
|
38
40
|
/** The groups `off` would change: those that are on, or follow a default that may be on. */
|
|
39
41
|
export declare function groupsToTurnOff(groups: readonly RemoteControlGroup[]): RemoteControlGroup[];
|
|
40
|
-
/**
|
|
41
|
-
export declare function
|
|
42
|
-
/**
|
|
42
|
+
/** The groups `on` would change: those that are off, or follow a default that may be off. */
|
|
43
|
+
export declare function groupsToTurnOn(groups: readonly RemoteControlGroup[]): RemoteControlGroup[];
|
|
44
|
+
/**
|
|
45
|
+
* Sets `remoteControlAtStartup` to `to` in every group that does not hold it yet (a default counts as either),
|
|
46
|
+
* remembering each old value for undo.
|
|
47
|
+
*/
|
|
48
|
+
export declare function setRemoteControl(accounts: readonly Account[], ctx: ReadContext, to: boolean): Promise<RemoteControlChange>;
|
|
49
|
+
/** Sets `remoteControlAtStartup` to false in every group that is on or follows the default. */
|
|
50
|
+
export declare const turnRemoteControlOff: (accounts: readonly Account[], ctx: ReadContext) => Promise<RemoteControlChange>;
|
|
51
|
+
/** Sets `remoteControlAtStartup` to true in every group that is off or follows the default. */
|
|
52
|
+
export declare const turnRemoteControlOn: (accounts: readonly Account[], ctx: ReadContext) => Promise<RemoteControlChange>;
|
|
53
|
+
/** Groups sideby can put back: it wrote there and the key still holds what it wrote. */
|
|
43
54
|
export declare function groupsToUndo(groups: readonly RemoteControlGroup[]): RemoteControlGroup[];
|
|
44
|
-
/** Puts back what each file had before `off`, only while the key
|
|
55
|
+
/** Puts back what each file had before `on` or `off`, only while the key still holds what sideby wrote. */
|
|
45
56
|
export declare function undoRemoteControlOff(accounts: readonly Account[], ctx: ReadContext): Promise<RemoteControlChange>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Remote Control
|
|
1
|
+
// Remote Control at startup (spec §3.22, ADR-0017): reads and writes one key, `remoteControlAtStartup`, of each
|
|
2
2
|
// Claude Account's `settings.json`. Accounts that share a file (a link to the Main Account's) form one group and one
|
|
3
3
|
// write. Nothing but that key is ever read out of the file into an output, an error or the undo record; writes go
|
|
4
4
|
// through `writeIfUnchanged`, keep every other key, and never create, replace or retarget a link.
|
|
@@ -60,7 +60,8 @@ export async function remoteControlGroups(accounts, ctx) {
|
|
|
60
60
|
}
|
|
61
61
|
else {
|
|
62
62
|
group.state = stateOf(read.obj[RC_KEY]);
|
|
63
|
-
|
|
63
|
+
const record = await readRecord(ctx, where.file);
|
|
64
|
+
group.undo = record !== null && read.obj[RC_KEY] === (record.wrote ?? false);
|
|
64
65
|
}
|
|
65
66
|
}
|
|
66
67
|
}
|
|
@@ -70,6 +71,10 @@ export async function remoteControlGroups(accounts, ctx) {
|
|
|
70
71
|
export function groupsToTurnOff(groups) {
|
|
71
72
|
return groups.filter((g) => g.state === 'on' || g.state === 'default');
|
|
72
73
|
}
|
|
74
|
+
/** The groups `on` would change: those that are off, or follow a default that may be off. */
|
|
75
|
+
export function groupsToTurnOn(groups) {
|
|
76
|
+
return groups.filter((g) => g.state === 'off' || g.state === 'default');
|
|
77
|
+
}
|
|
73
78
|
async function writeKey(file, change, expect) {
|
|
74
79
|
const read = await readObject(file);
|
|
75
80
|
if ('error' in read)
|
|
@@ -81,18 +86,23 @@ async function writeKey(file, change, expect) {
|
|
|
81
86
|
const wrote = await writeIfUnchanged(file, sha256(read.text), render(change({ ...read.obj }), read.text), mode);
|
|
82
87
|
return wrote ? null : 'settings.json changed while sideby was writing, so nothing was changed. Try again.';
|
|
83
88
|
}
|
|
84
|
-
/**
|
|
85
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Sets `remoteControlAtStartup` to `to` in every group that does not hold it yet (a default counts as either),
|
|
91
|
+
* remembering each old value for undo.
|
|
92
|
+
*/
|
|
93
|
+
export async function setRemoteControl(accounts, ctx, to) {
|
|
86
94
|
const groups = await remoteControlGroups(accounts, ctx);
|
|
87
|
-
const todo = groupsToTurnOff(groups);
|
|
95
|
+
const todo = to ? groupsToTurnOn(groups) : groupsToTurnOff(groups);
|
|
88
96
|
if (todo.length === 0) {
|
|
89
|
-
const stuck = groups.find((g) => g.state === 'blocked');
|
|
97
|
+
const stuck = groups.find((g) => g.state === 'blocked' || (to && g.state === 'missing'));
|
|
90
98
|
return stuck
|
|
91
99
|
? { ok: false, results: [], message: `Nothing was changed: ${stuck.reason}` }
|
|
92
100
|
: {
|
|
93
101
|
ok: true,
|
|
94
102
|
results: [],
|
|
95
|
-
message:
|
|
103
|
+
message: to
|
|
104
|
+
? 'Remote Control already starts by itself; nothing changed.'
|
|
105
|
+
: 'Remote Control already does not start by itself; nothing changed.',
|
|
96
106
|
};
|
|
97
107
|
}
|
|
98
108
|
const results = [];
|
|
@@ -100,32 +110,43 @@ export async function turnRemoteControlOff(accounts, ctx) {
|
|
|
100
110
|
let previous = null;
|
|
101
111
|
const failed = await writeKey(g.file, (obj) => {
|
|
102
112
|
previous = typeof obj[RC_KEY] === 'boolean' ? obj[RC_KEY] : null;
|
|
103
|
-
obj[RC_KEY] =
|
|
113
|
+
obj[RC_KEY] = to;
|
|
104
114
|
return obj;
|
|
105
|
-
}, (obj) => (obj[RC_KEY] ===
|
|
115
|
+
}, (obj) => (obj[RC_KEY] === to ? `It was turned ${to ? 'on' : 'off'} meanwhile; nothing changed.` : null));
|
|
106
116
|
if (failed) {
|
|
107
117
|
results.push({ file: g.file, accounts: g.accounts, ok: false, message: failed });
|
|
108
118
|
continue;
|
|
109
119
|
}
|
|
110
|
-
const record = {
|
|
120
|
+
const record = {
|
|
121
|
+
file: g.file,
|
|
122
|
+
previous,
|
|
123
|
+
wrote: to,
|
|
124
|
+
at: new Date().toISOString(),
|
|
125
|
+
};
|
|
111
126
|
await mkdir(join(ctx.stateDir, 'remote-control'), { recursive: true });
|
|
112
127
|
await writeFileAtomic(recordPath(ctx, g.file), `${JSON.stringify(record, null, 2)}\n`, 0o600);
|
|
113
|
-
results.push({ file: g.file, accounts: g.accounts, ok: true, message: 'Turned off.' });
|
|
128
|
+
results.push({ file: g.file, accounts: g.accounts, ok: true, message: to ? 'Turned on.' : 'Turned off.' });
|
|
114
129
|
}
|
|
115
130
|
const ok = results.every((r) => r.ok);
|
|
116
131
|
return {
|
|
117
132
|
ok,
|
|
118
133
|
results,
|
|
119
|
-
message: ok
|
|
120
|
-
? '
|
|
121
|
-
:
|
|
134
|
+
message: !ok
|
|
135
|
+
? 'Some files could not be changed; see each line.'
|
|
136
|
+
: to
|
|
137
|
+
? 'Done. New sessions connect to claude.ai and the Claude app by themselves, where every device signed in to that account can read them; restart sessions that are already running.'
|
|
138
|
+
: 'Done. New sessions no longer connect to claude.ai by themselves; restart sessions that are already running. You can still start it in a session with /remote-control.',
|
|
122
139
|
};
|
|
123
140
|
}
|
|
124
|
-
/**
|
|
141
|
+
/** Sets `remoteControlAtStartup` to false in every group that is on or follows the default. */
|
|
142
|
+
export const turnRemoteControlOff = (accounts, ctx) => setRemoteControl(accounts, ctx, false);
|
|
143
|
+
/** Sets `remoteControlAtStartup` to true in every group that is off or follows the default. */
|
|
144
|
+
export const turnRemoteControlOn = (accounts, ctx) => setRemoteControl(accounts, ctx, true);
|
|
145
|
+
/** Groups sideby can put back: it wrote there and the key still holds what it wrote. */
|
|
125
146
|
export function groupsToUndo(groups) {
|
|
126
147
|
return groups.filter((g) => g.undo);
|
|
127
148
|
}
|
|
128
|
-
/** Puts back what each file had before `off`, only while the key
|
|
149
|
+
/** Puts back what each file had before `on` or `off`, only while the key still holds what sideby wrote. */
|
|
129
150
|
export async function undoRemoteControlOff(accounts, ctx) {
|
|
130
151
|
const todo = groupsToUndo(await remoteControlGroups(accounts, ctx));
|
|
131
152
|
if (todo.length === 0)
|
|
@@ -141,7 +162,9 @@ export async function undoRemoteControlOff(accounts, ctx) {
|
|
|
141
162
|
else
|
|
142
163
|
obj[RC_KEY] = record.previous;
|
|
143
164
|
return obj;
|
|
144
|
-
}, (obj) => obj[RC_KEY] ===
|
|
165
|
+
}, (obj) => obj[RC_KEY] === (record.wrote ?? false)
|
|
166
|
+
? null
|
|
167
|
+
: 'The setting was changed after sideby set it, so it was left alone.');
|
|
145
168
|
if (!failed)
|
|
146
169
|
await rm(recordPath(ctx, g.file), { force: true });
|
|
147
170
|
results.push({ file: g.file, accounts: g.accounts, ok: !failed, message: failed ?? 'Put back.' });
|
|
@@ -33,7 +33,7 @@ export interface UpdateCache {
|
|
|
33
33
|
shown?: boolean;
|
|
34
34
|
};
|
|
35
35
|
}
|
|
36
|
-
export type InstallKind = 'npm' | 'pnpm' | 'bun' | 'volta' | 'npx' | 'project' | 'source';
|
|
36
|
+
export type InstallKind = 'npm' | 'pnpm' | 'bun' | 'volta' | 'npx' | 'project' | 'source' | 'local';
|
|
37
37
|
export interface InstallMethod {
|
|
38
38
|
kind: InstallKind;
|
|
39
39
|
/** The installed package folder (real path). */
|
|
@@ -63,6 +63,10 @@ export interface UpdateDeps {
|
|
|
63
63
|
}>;
|
|
64
64
|
/** Starts `sideby ui --background --no-open --port <port>` from the newly installed code, detached; false when it could not start. */
|
|
65
65
|
restartPanel: (env: Env, port: number) => Promise<boolean>;
|
|
66
|
+
/** Whether `sideby app install` put a desktop app in this home (its files carry the app marker). */
|
|
67
|
+
appInstalled: (home: string, env: Env) => Promise<boolean>;
|
|
68
|
+
/** Runs `sideby app install --json` from the newly installed code and waits; false when it failed. */
|
|
69
|
+
refreshApp: (env: Env) => Promise<boolean>;
|
|
66
70
|
}
|
|
67
71
|
/** Internal, for tests: replaces parts of the machine. Not part of the plugin or Portal contract. */
|
|
68
72
|
export declare function bindUpdateDeps(deps: Partial<UpdateDeps>): () => void;
|
|
@@ -84,7 +88,7 @@ type UpdateSettings = {
|
|
|
84
88
|
};
|
|
85
89
|
};
|
|
86
90
|
/** Why Update Checks are off, or null when they are on (spec §3.23). */
|
|
87
|
-
export declare function checksOff(env: Env, config: UpdateSettings): string | null;
|
|
91
|
+
export declare function checksOff(env: Env, config: UpdateSettings, local?: boolean): string | null;
|
|
88
92
|
export declare function readUpdateCache(paths: Paths): Promise<UpdateCache>;
|
|
89
93
|
/**
|
|
90
94
|
* Merges a change into the cache file. The file is read again first, and `change` may be a function of what is there
|
|
@@ -181,11 +185,14 @@ export interface UpdateResult {
|
|
|
181
185
|
message: string;
|
|
182
186
|
/** A background Panel was running and is starting again from the new code. */
|
|
183
187
|
restartedPanel: boolean;
|
|
188
|
+
/** The desktop app was installed and `sideby app install` ran again from the new code. */
|
|
189
|
+
refreshedApp: boolean;
|
|
184
190
|
/** Another Update held the lock; nothing ran. */
|
|
185
191
|
busy?: boolean;
|
|
186
192
|
}
|
|
187
193
|
/**
|
|
188
|
-
* Runs the Update the plan names, then starts a running background Panel again from the new code
|
|
194
|
+
* Runs the Update the plan names, then starts a running background Panel again from the new code and, when the
|
|
195
|
+
* desktop app is installed, runs `sideby app install` again from the new code. The caller has
|
|
189
196
|
* shown the plan and got a confirmation (or this is an Auto Update). One Update runs at a time on this machine (the
|
|
190
197
|
* CLI, a Panel and an Auto Update share the lock). `panelPort` gives the port of the background Panel to restart, or
|
|
191
198
|
* null when none runs. A successful Update is recorded as said, so no notice for it follows.
|
package/dist/src/core/update.js
CHANGED
|
@@ -6,7 +6,7 @@ import { access, constants, mkdir, open, readdir, readFile, realpath, rm, stat }
|
|
|
6
6
|
import { dirname, join, sep } from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import { writeFileAtomic } from './fs-safe.js';
|
|
9
|
-
import { packageVersion } from './version.js';
|
|
9
|
+
import { localBuild, packageVersion, parseLocalBuild } from './version.js';
|
|
10
10
|
export const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
|
|
11
11
|
export const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
12
12
|
export const NOTICE_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
@@ -76,6 +76,20 @@ const realDeps = {
|
|
|
76
76
|
});
|
|
77
77
|
child.once('error', () => resolve(false));
|
|
78
78
|
}),
|
|
79
|
+
appInstalled: async (home, env) => {
|
|
80
|
+
const { appInstalled } = await import('../desktop/app.js');
|
|
81
|
+
return appInstalled({ home, env, platform: process.platform });
|
|
82
|
+
},
|
|
83
|
+
refreshApp: (env) => new Promise((resolve) => {
|
|
84
|
+
// By name, like restartPanel: the new code records its own Node and entry and draws the current mark.
|
|
85
|
+
const child = spawn('sideby', ['app', 'install', '--json'], {
|
|
86
|
+
env: env,
|
|
87
|
+
stdio: 'ignore',
|
|
88
|
+
timeout: 120_000,
|
|
89
|
+
});
|
|
90
|
+
child.once('error', () => resolve(false));
|
|
91
|
+
child.once('close', (code) => resolve(code === 0));
|
|
92
|
+
}),
|
|
79
93
|
};
|
|
80
94
|
let override = {};
|
|
81
95
|
/** Internal, for tests: replaces parts of the machine. Not part of the plugin or Portal contract. */
|
|
@@ -137,7 +151,10 @@ const set = (v) => v !== undefined && v !== '' && v !== '0' && v.toLowerCase() !
|
|
|
137
151
|
/** True in CI, where a notice is noise and a check is a request nobody reads. */
|
|
138
152
|
export const inCi = (env) => set(env.CI) || set(env.CONTINUOUS_INTEGRATION) || set(env.BUILD_NUMBER);
|
|
139
153
|
/** Why Update Checks are off, or null when they are on (spec §3.23). */
|
|
140
|
-
export function checksOff(env, config) {
|
|
154
|
+
export function checksOff(env, config, local = localBuild() !== null) {
|
|
155
|
+
// A Local Build (ADR-0024) follows the checkout: a notice or an Auto Update would only replace it with npm's.
|
|
156
|
+
if (local)
|
|
157
|
+
return 'this is a local build (pnpm local)';
|
|
141
158
|
if (set(env.SIDEBY_NO_UPDATE_NOTIFIER))
|
|
142
159
|
return 'SIDEBY_NO_UPDATE_NOTIFIER is set';
|
|
143
160
|
if (set(env.NO_UPDATE_NOTIFIER))
|
|
@@ -373,6 +390,15 @@ export async function installMethod(env) {
|
|
|
373
390
|
canUpdate: false,
|
|
374
391
|
...extra,
|
|
375
392
|
});
|
|
393
|
+
try {
|
|
394
|
+
if (parseLocalBuild(JSON.parse(await readFile(join(packageDir, 'dist', 'build.json'), 'utf8'))))
|
|
395
|
+
return at('local', {
|
|
396
|
+
reason: 'it is a local build; run `pnpm local --off` in the sideby checkout to go back to npm',
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
// no build file: not a Local Build
|
|
401
|
+
}
|
|
376
402
|
if (p.includes('/_npx/'))
|
|
377
403
|
return at('npx', { reason: 'it runs from the npx cache, which fetches a version per call' });
|
|
378
404
|
// Volta keeps a global package under its own npm prefix; installing there behind its back breaks its records.
|
|
@@ -436,6 +462,8 @@ export function updateCommand(method, version, env = {}) {
|
|
|
436
462
|
return { text: `npm install ${spec}` };
|
|
437
463
|
case 'source':
|
|
438
464
|
return { text: 'git pull && pnpm install' };
|
|
465
|
+
case 'local':
|
|
466
|
+
return { text: 'pnpm local --off' };
|
|
439
467
|
}
|
|
440
468
|
}
|
|
441
469
|
// ---- running sessions
|
|
@@ -514,7 +542,8 @@ export async function planUpdate(paths, env) {
|
|
|
514
542
|
return planFrom(await installMethod(env), packageVersion(), latest, env);
|
|
515
543
|
}
|
|
516
544
|
/**
|
|
517
|
-
* Runs the Update the plan names, then starts a running background Panel again from the new code
|
|
545
|
+
* Runs the Update the plan names, then starts a running background Panel again from the new code and, when the
|
|
546
|
+
* desktop app is installed, runs `sideby app install` again from the new code. The caller has
|
|
518
547
|
* shown the plan and got a confirmation (or this is an Auto Update). One Update runs at a time on this machine (the
|
|
519
548
|
* CLI, a Panel and an Auto Update share the lock). `panelPort` gives the port of the background Panel to restart, or
|
|
520
549
|
* null when none runs. A successful Update is recorded as said, so no notice for it follows.
|
|
@@ -526,6 +555,7 @@ export async function applyUpdate(paths, plan, env, opts = {}) {
|
|
|
526
555
|
output: '',
|
|
527
556
|
message,
|
|
528
557
|
restartedPanel: false,
|
|
558
|
+
refreshedApp: false,
|
|
529
559
|
...extra,
|
|
530
560
|
});
|
|
531
561
|
if (!cmd.run)
|
|
@@ -548,12 +578,17 @@ export async function applyUpdate(paths, plan, env, opts = {}) {
|
|
|
548
578
|
}).catch(() => { });
|
|
549
579
|
const port = opts.panelPort ? await opts.panelPort().catch(() => null) : null;
|
|
550
580
|
const restarted = port !== null && (await d.restartPanel(env, port).catch(() => false));
|
|
581
|
+
// The desktop app records the Node and entry it starts and carries the icon of the version that wrote it, so
|
|
582
|
+
// the new code writes it again. Only where one is installed; a failure here never fails the Update.
|
|
583
|
+
const installed = await d.appInstalled(paths.home, env).catch(() => false);
|
|
584
|
+
const refreshed = installed && (await d.refreshApp(env).catch(() => false));
|
|
551
585
|
return {
|
|
552
586
|
ok: true,
|
|
553
587
|
code: 0,
|
|
554
588
|
output: r.output,
|
|
555
589
|
message: `updated sideby from ${plan.current} to ${plan.latest}`,
|
|
556
590
|
restartedPanel: restarted,
|
|
591
|
+
refreshedApp: refreshed,
|
|
557
592
|
};
|
|
558
593
|
}
|
|
559
594
|
finally {
|
|
@@ -7,3 +7,14 @@ export declare function packageVersion(): string;
|
|
|
7
7
|
* id for tests.
|
|
8
8
|
*/
|
|
9
9
|
export declare function buildId(env?: NodeJS.ProcessEnv): string;
|
|
10
|
+
/** What `pnpm local` records about a Local Build (ADR-0024). */
|
|
11
|
+
export interface LocalBuild {
|
|
12
|
+
commit: string;
|
|
13
|
+
/** The working tree had changes that were not committed. */
|
|
14
|
+
dirty: boolean;
|
|
15
|
+
builtAt: string;
|
|
16
|
+
}
|
|
17
|
+
/** The Local Build fields of a `dist/build.json`, or null for a published build or anything unexpected. */
|
|
18
|
+
export declare function parseLocalBuild(data: unknown): LocalBuild | null;
|
|
19
|
+
/** The running code's Local Build, or null when it was published or runs from source. */
|
|
20
|
+
export declare function localBuild(): LocalBuild | null;
|
package/dist/src/core/version.js
CHANGED
|
@@ -44,3 +44,29 @@ export function buildId(env = process.env) {
|
|
|
44
44
|
cachedBuild = `${packageVersion()}+${id}`;
|
|
45
45
|
return cachedBuild;
|
|
46
46
|
}
|
|
47
|
+
/** The Local Build fields of a `dist/build.json`, or null for a published build or anything unexpected. */
|
|
48
|
+
export function parseLocalBuild(data) {
|
|
49
|
+
if (!data || typeof data !== 'object')
|
|
50
|
+
return null;
|
|
51
|
+
const d = data;
|
|
52
|
+
if (d.local !== true)
|
|
53
|
+
return null;
|
|
54
|
+
return {
|
|
55
|
+
commit: typeof d.commit === 'string' ? d.commit : '',
|
|
56
|
+
dirty: d.dirty === true,
|
|
57
|
+
builtAt: typeof d.builtAt === 'string' ? d.builtAt : '',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
let cachedLocal;
|
|
61
|
+
/** The running code's Local Build, or null when it was published or runs from source. */
|
|
62
|
+
export function localBuild() {
|
|
63
|
+
if (cachedLocal !== undefined)
|
|
64
|
+
return cachedLocal;
|
|
65
|
+
try {
|
|
66
|
+
cachedLocal = parseLocalBuild(JSON.parse(readFileSync(new URL('../../build.json', import.meta.url), 'utf8')));
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
cachedLocal = null;
|
|
70
|
+
}
|
|
71
|
+
return cachedLocal;
|
|
72
|
+
}
|
|
@@ -56,5 +56,7 @@ export interface LauncherSpec {
|
|
|
56
56
|
export declare function launcherScript(s: LauncherSpec): string;
|
|
57
57
|
export declare function infoPlist(version: string): string;
|
|
58
58
|
export declare function desktopEntry(launcher: string): string;
|
|
59
|
+
/** Whether a sideby desktop app is installed for this home: its main file exists and carries APP_MARKER. */
|
|
60
|
+
export declare function appInstalled(ctx: AppContext): Promise<boolean>;
|
|
59
61
|
export declare function installApp(ctx: AppContext, opts: InstallOptions): Promise<InstallResult>;
|
|
60
62
|
export declare function uninstallApp(ctx: AppContext): Promise<UninstallResult>;
|