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.
Files changed (60) hide show
  1. package/README.md +1 -1
  2. package/dist/build.json +1 -1
  3. package/dist/src/cli/commands.d.ts +4 -2
  4. package/dist/src/cli/commands.js +117 -17
  5. package/dist/src/cli/json-schemas.js +52 -1
  6. package/dist/src/cli/main.js +34 -3
  7. package/dist/src/core/brand-mark.d.ts +5 -7
  8. package/dist/src/core/brand-mark.js +18 -5
  9. package/dist/src/core/browser-profiles.d.ts +71 -0
  10. package/dist/src/core/browser-profiles.js +361 -0
  11. package/dist/src/core/config.d.ts +15 -0
  12. package/dist/src/core/config.js +13 -1
  13. package/dist/src/core/create.js +2 -1
  14. package/dist/src/core/desktop-app.d.ts +3 -1
  15. package/dist/src/core/desktop-app.js +29 -8
  16. package/dist/src/core/launch.d.ts +7 -0
  17. package/dist/src/core/launch.js +22 -2
  18. package/dist/src/core/leveldb-keys.d.ts +14 -0
  19. package/dist/src/core/leveldb-keys.js +291 -0
  20. package/dist/src/core/remote-control.d.ts +17 -6
  21. package/dist/src/core/remote-control.js +40 -17
  22. package/dist/src/core/update.d.ts +10 -3
  23. package/dist/src/core/update.js +38 -3
  24. package/dist/src/core/version.d.ts +11 -0
  25. package/dist/src/core/version.js +26 -0
  26. package/dist/src/desktop/app.d.ts +2 -0
  27. package/dist/src/desktop/app.js +39 -3
  28. package/dist/src/families/browser.d.ts +20 -0
  29. package/dist/src/families/browser.js +23 -0
  30. package/dist/src/families/claude/browser.d.ts +3 -0
  31. package/dist/src/families/claude/browser.js +14 -0
  32. package/dist/src/families/claude/desktop.js +1 -0
  33. package/dist/src/families/codex/browser.d.ts +7 -0
  34. package/dist/src/families/codex/browser.js +26 -0
  35. package/dist/src/families/desktop.d.ts +5 -1
  36. package/dist/src/families/pi/index.js +2 -0
  37. package/dist/src/panel/handler.d.ts +6 -0
  38. package/dist/src/panel/handler.js +49 -6
  39. package/dist/src/panel/i18n.d.ts +75 -4
  40. package/dist/src/panel/i18n.js +162 -20
  41. package/dist/src/panel/page-logic.d.ts +23 -0
  42. package/dist/src/panel/page-logic.js +44 -0
  43. package/dist/src/panel/page-script.js +609 -180
  44. package/dist/src/panel/page-styles.js +72 -17
  45. package/dist/src/panel/page.d.ts +5 -0
  46. package/dist/src/panel/page.js +10 -9
  47. package/dist/src/runtime.d.ts +6 -1
  48. package/dist/src/runtime.js +22 -11
  49. package/dist/src/types.d.ts +2 -0
  50. package/package.json +5 -3
  51. package/schemas/browser-open.json +47 -0
  52. package/schemas/browser-switch.json +42 -0
  53. package/schemas/browser.json +99 -0
  54. package/schemas/config.json +11 -0
  55. package/schemas/families.json +6 -0
  56. package/schemas/list.json +6 -0
  57. package/schemas/remote-control.json +4 -0
  58. package/schemas/update.json +4 -0
  59. package/skills/sideby/SKILL.md +5 -3
  60. 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 turned it off here and can put the earlier value back. */
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 `false`, or null when the key was not there. */
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
- /** Sets `remoteControlAtStartup` to false in every group that is on or follows the default, remembering each old value. */
41
- export declare function turnRemoteControlOff(accounts: readonly Account[], ctx: ReadContext): Promise<RemoteControlChange>;
42
- /** Groups sideby can put back: it wrote `false` there and the key still holds it. */
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 is still `false`. */
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 off by default (spec §3.22, ADR-0017): reads and writes one key, `remoteControlAtStartup`, of each
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
- group.undo = group.state === 'off' && (await readRecord(ctx, where.file)) !== null;
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
- /** Sets `remoteControlAtStartup` to false in every group that is on or follows the default, remembering each old value. */
85
- export async function turnRemoteControlOff(accounts, ctx) {
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: 'Remote Control already does not start by itself; nothing changed.',
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] = false;
113
+ obj[RC_KEY] = to;
104
114
  return obj;
105
- }, (obj) => (obj[RC_KEY] === false ? 'It was turned off meanwhile; nothing changed.' : null));
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 = { file: g.file, previous, at: new Date().toISOString() };
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
- ? '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.'
121
- : 'Some files could not be changed; see each line.',
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
- /** Groups sideby can put back: it wrote `false` there and the key still holds it. */
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 is still `false`. */
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] === false ? null : 'The setting was changed after sideby set it, so it was left alone.');
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. The caller has
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.
@@ -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. The caller has
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;
@@ -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>;