viberoom 0.2.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 (56) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +20 -0
  3. package/README.md +153 -0
  4. package/assets/icon-128.png +0 -0
  5. package/assets/icon-16.png +0 -0
  6. package/assets/icon-256.png +0 -0
  7. package/assets/icon-32.png +0 -0
  8. package/assets/icon-48.png +0 -0
  9. package/assets/icon-512.png +0 -0
  10. package/assets/icon-64.png +0 -0
  11. package/assets/icon-vector.svg +30 -0
  12. package/assets/icon.icns +0 -0
  13. package/assets/icon.ico +0 -0
  14. package/assets/icon.svg +30 -0
  15. package/assets/vendors/claude.svg +3 -0
  16. package/assets/vendors/codex.svg +3 -0
  17. package/assets/vendors/copilot.svg +5 -0
  18. package/assets/vendors/cursor.svg +3 -0
  19. package/assets/vendors/gemini.svg +3 -0
  20. package/assets/vendors/opencode.svg +3 -0
  21. package/dist/acp-client.js +137 -0
  22. package/dist/acp-types.js +2 -0
  23. package/dist/edit.js +34 -0
  24. package/dist/hub.js +348 -0
  25. package/dist/icons.js +235 -0
  26. package/dist/jsonrpc.js +109 -0
  27. package/dist/launcher.js +161 -0
  28. package/dist/log.js +35 -0
  29. package/dist/main.js +389 -0
  30. package/dist/mcp-skills-server.js +177 -0
  31. package/dist/open.js +141 -0
  32. package/dist/persona.js +217 -0
  33. package/dist/recipes.js +261 -0
  34. package/dist/room.js +2124 -0
  35. package/dist/server.js +433 -0
  36. package/dist/shortcuts.js +176 -0
  37. package/dist/skills.js +344 -0
  38. package/dist/tui.js +109 -0
  39. package/package.json +61 -0
  40. package/scripts/install.mjs +34 -0
  41. package/scripts/render-icon.mjs +84 -0
  42. package/scripts/update.mjs +29 -0
  43. package/ui/app.css +346 -0
  44. package/ui/app.js +2834 -0
  45. package/ui/avatars.js +113 -0
  46. package/ui/fonts/OFL.txt +93 -0
  47. package/ui/fonts/nunito-cyrillic-ext.woff2 +0 -0
  48. package/ui/fonts/nunito-cyrillic.woff2 +0 -0
  49. package/ui/fonts/nunito-latin-ext.woff2 +0 -0
  50. package/ui/fonts/nunito-latin.woff2 +0 -0
  51. package/ui/fonts/nunito-vietnamese.woff2 +0 -0
  52. package/ui/fonts/nunito.css +6 -0
  53. package/ui/icons.js +76 -0
  54. package/ui/index.html +217 -0
  55. package/ui/manifest.json +14 -0
  56. package/ui/theme.css +425 -0
package/dist/server.js ADDED
@@ -0,0 +1,433 @@
1
+ // viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
2
+ import { spawn } from "node:child_process";
3
+ import { createServer } from "node:http";
4
+ import { readFile, stat } from "node:fs/promises";
5
+ import { fileURLToPath } from "node:url";
6
+ import { existsSync as fileExists } from "node:fs";
7
+ import { classifyOpenTarget, describeOpen, detectEditor, editorCommand, isExecutablePath, openCommand } from "./open.js";
8
+ let editorFound;
9
+ function currentEditor() {
10
+ if (editorFound === undefined)
11
+ editorFound = detectEditor(process.env, process.platform, fileExists);
12
+ return editorFound;
13
+ }
14
+ const STATIC_FILES = {
15
+ "/": { file: "index.html", type: "text/html; charset=utf-8" },
16
+ "/index.html": { file: "index.html", type: "text/html; charset=utf-8" },
17
+ "/app.js": { file: "app.js", type: "text/javascript; charset=utf-8" },
18
+ "/avatars.js": { file: "avatars.js", type: "text/javascript; charset=utf-8" },
19
+ "/icons.js": { file: "icons.js", type: "text/javascript; charset=utf-8" },
20
+ "/theme.css": { file: "theme.css", type: "text/css; charset=utf-8" },
21
+ "/app.css": { file: "app.css", type: "text/css; charset=utf-8" },
22
+ "/styleguide": { file: "styleguide.html", type: "text/html; charset=utf-8" },
23
+ "/manifest.json": { file: "manifest.json", type: "application/manifest+json; charset=utf-8" },
24
+ "/icon.svg": { file: "icon.svg", type: "image/svg+xml", dir: "assets" },
25
+ "/icon-256.png": { file: "icon-256.png", type: "image/png", dir: "assets" },
26
+ "/icon-512.png": { file: "icon-512.png", type: "image/png", dir: "assets" },
27
+ "/favicon.ico": { file: "icon.ico", type: "image/x-icon", dir: "assets" },
28
+ "/vendor-icons/claude.svg": { file: "vendors/claude.svg", type: "image/svg+xml", dir: "assets" },
29
+ "/vendor-icons/codex.svg": { file: "vendors/codex.svg", type: "image/svg+xml", dir: "assets" },
30
+ "/vendor-icons/gemini.svg": { file: "vendors/gemini.svg", type: "image/svg+xml", dir: "assets" },
31
+ "/vendor-icons/cursor.svg": { file: "vendors/cursor.svg", type: "image/svg+xml", dir: "assets" },
32
+ "/vendor-icons/opencode.svg": { file: "vendors/opencode.svg", type: "image/svg+xml", dir: "assets" },
33
+ "/vendor-icons/copilot.svg": { file: "vendors/copilot.svg", type: "image/svg+xml", dir: "assets" },
34
+ "/vendor/mermaid.min.js": { file: "mermaid/dist/mermaid.min.js", type: "text/javascript; charset=utf-8", dir: "node_modules" },
35
+ };
36
+ export function startServer(hub, port, log, info, onShutdownRequest) {
37
+ const uiDir = fileURLToPath(new URL("../ui/", import.meta.url));
38
+ const assetsDir = fileURLToPath(new URL("../assets/", import.meta.url));
39
+ const modulesDir = fileURLToPath(new URL("../node_modules/", import.meta.url));
40
+ const staticDir = (dir) => (dir === "assets" ? assetsDir : dir === "node_modules" ? modulesDir : uiDir);
41
+ const clients = new Set();
42
+ const snapshot = () => ({ ...hub.snapshot(), version: info });
43
+ const broadcast = (event) => {
44
+ const payload = `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
45
+ for (const res of clients)
46
+ res.write(payload);
47
+ };
48
+ hub.on("event", broadcast);
49
+ const heartbeat = setInterval(() => {
50
+ for (const res of clients)
51
+ res.write(": ping\n\n");
52
+ }, 20_000);
53
+ const server = createServer(async (req, res) => {
54
+ try {
55
+ await handle(req, res);
56
+ }
57
+ catch (error) {
58
+ const message = error instanceof Error ? error.message : String(error);
59
+ log.warn(`${req.method} ${req.url}: ${message}`);
60
+ if (!res.headersSent)
61
+ sendJson(res, 400, { error: message });
62
+ else
63
+ res.end();
64
+ }
65
+ });
66
+ async function handle(req, res) {
67
+ const url = new URL(req.url ?? "/", "http://localhost");
68
+ const path = url.pathname;
69
+ const font = req.method === "GET" && path.match(/^\/fonts\/([a-z0-9-]+\.(woff2|css))$/i);
70
+ if (font) {
71
+ try {
72
+ const body = await readFile(`${uiDir}fonts/${font[1]}`);
73
+ res.writeHead(200, { "Content-Type": font[2].toLowerCase() === "css" ? "text/css; charset=utf-8" : "font/woff2", "Cache-Control": "public, max-age=86400" });
74
+ res.end(body);
75
+ }
76
+ catch {
77
+ sendJson(res, 404, { error: "no such font" });
78
+ }
79
+ return;
80
+ }
81
+ if (req.method === "GET" && STATIC_FILES[path]) {
82
+ const entry = STATIC_FILES[path];
83
+ const body = await readFile(staticDir(entry.dir) + entry.file);
84
+ res.writeHead(200, { "Content-Type": entry.type, "Cache-Control": entry.dir === "ui" || !entry.dir ? "no-cache" : "public, max-age=3600" });
85
+ res.end(body);
86
+ return;
87
+ }
88
+ if (req.method === "GET" && path === "/events") {
89
+ res.writeHead(200, {
90
+ "Content-Type": "text/event-stream",
91
+ "Cache-Control": "no-cache",
92
+ Connection: "keep-alive",
93
+ });
94
+ res.write(`event: snapshot\ndata: ${JSON.stringify({ type: "snapshot", snapshot: snapshot() })}\n\n`);
95
+ clients.add(res);
96
+ req.on("close", () => clients.delete(res));
97
+ return;
98
+ }
99
+ if (req.method === "GET" && path === "/api/editor") {
100
+ sendJson(res, 200, { editor: currentEditor(), settings: hub.settings.editor });
101
+ return;
102
+ }
103
+ if (req.method === "GET" && path === "/api/state") {
104
+ sendJson(res, 200, snapshot());
105
+ return;
106
+ }
107
+ if (req.method === "GET" && path === "/api/version") {
108
+ sendJson(res, 200, info);
109
+ return;
110
+ }
111
+ if (req.method === "GET" && path === "/api/skills") {
112
+ sendJson(res, 200, { skills: hub.listSkills() });
113
+ return;
114
+ }
115
+ const skillGet = req.method === "GET" && path.match(/^\/api\/skills\/([^/]+)$/);
116
+ if (skillGet) {
117
+ const skill = hub.skills.get(decodeURIComponent(skillGet[1]));
118
+ if (!skill) {
119
+ sendJson(res, 404, { error: "no such skill" });
120
+ return;
121
+ }
122
+ sendJson(res, 200, { skill });
123
+ return;
124
+ }
125
+ if (req.method === "GET" && path === "/api/mcp/skill") {
126
+ const target = hub.resolveMcpToken(url.searchParams.get("token") ?? "");
127
+ if (!target) {
128
+ sendJson(res, 403, { error: "unknown skills token (the session it belonged to is gone)" });
129
+ return;
130
+ }
131
+ const loaded = target.room.loadSkillForAgent(target.participantId, url.searchParams.get("name") ?? "");
132
+ sendJson(res, 200, loaded);
133
+ return;
134
+ }
135
+ if (req.method === "GET" && path === "/api/settings") {
136
+ sendJson(res, 200, hub.settings);
137
+ return;
138
+ }
139
+ const recipeOptions = req.method === "GET" && path.match(/^\/api\/recipes\/([^/]+)\/options$/);
140
+ if (recipeOptions) {
141
+ const anyRoom = [...hub.rooms.values()][0];
142
+ if (!anyRoom)
143
+ throw new Error("create a room first");
144
+ const info = await anyRoom.discoverOptions(decodeURIComponent(recipeOptions[1]), url.searchParams.get("refresh") === "1");
145
+ sendJson(res, 200, info);
146
+ return;
147
+ }
148
+ const editPreview = req.method === "GET" && path.match(/^\/api\/rooms\/([^/]+)\/messages\/([^/]+)\/edit-preview$/);
149
+ if (editPreview) {
150
+ const room = hub.getRoom(decodeURIComponent(editPreview[1]));
151
+ sendJson(res, 200, room.previewEdit(decodeURIComponent(editPreview[2])));
152
+ return;
153
+ }
154
+ const roomGet = req.method === "GET" && path.match(/^\/api\/rooms\/([^/]+)$/);
155
+ if (roomGet) {
156
+ sendJson(res, 200, hub.getRoom(decodeURIComponent(roomGet[1])).snapshot());
157
+ return;
158
+ }
159
+ if (req.method === "GET" && path === "/api/rooms") {
160
+ sendJson(res, 200, [...hub.rooms.values()].map((r) => r.snapshot()));
161
+ return;
162
+ }
163
+ if (req.method !== "POST") {
164
+ sendJson(res, 404, { error: "not found" });
165
+ return;
166
+ }
167
+ const body = (await readJson(req));
168
+ if (path === "/api/settings") {
169
+ sendJson(res, 200, { ok: true, settings: hub.updateSettings(body) });
170
+ return;
171
+ }
172
+ if (path === "/api/open") {
173
+ const target = classifyOpenTarget(String(body.target ?? ""));
174
+ if (!target)
175
+ throw new Error("only http(s) or mailto links and absolute paths can be opened");
176
+ let reveal = false;
177
+ if (target.kind === "path") {
178
+ try {
179
+ const info = await stat(target.value);
180
+ reveal = info.isFile() && isExecutablePath(target.value);
181
+ }
182
+ catch {
183
+ sendJson(res, 404, { error: `no such file or folder: ${target.value}` });
184
+ return;
185
+ }
186
+ }
187
+ const cmd = (!reveal && editorCommand(target, hub.settings.editor, currentEditor(), process.platform)) || openCommand(target, process.platform, reveal);
188
+ log.info(`open (${cmd.action}${cmd.editor ? ` via ${cmd.editor}` : ""}): ${target.value}${target.line !== undefined ? `:${target.line}` : ""}`);
189
+ const child = spawn(cmd.command, cmd.args, { detached: true, stdio: "ignore", windowsHide: true });
190
+ child.on("error", (error) => log.warn(`open failed: ${error.message}`));
191
+ child.unref();
192
+ sendJson(res, 200, { ok: true, action: cmd.action, editor: cmd.editor ?? null, message: describeOpen(target, cmd.action, cmd.editor) });
193
+ return;
194
+ }
195
+ if (path === "/api/skills") {
196
+ const skill = hub.saveSkill({
197
+ name: String(body.name ?? ""),
198
+ description: String(body.description ?? ""),
199
+ argumentHint: optionalString(body.argumentHint) ?? undefined,
200
+ body: String(body.body ?? ""),
201
+ userInvocable: body.userInvocable === undefined ? undefined : body.userInvocable === true || body.userInvocable === "true",
202
+ agentInvocable: body.agentInvocable === undefined ? undefined : body.agentInvocable === true || body.agentInvocable === "true",
203
+ });
204
+ sendJson(res, 200, { ok: true, skill });
205
+ return;
206
+ }
207
+ const skillDelete = path.match(/^\/api\/skills\/([^/]+)\/delete$/);
208
+ if (skillDelete) {
209
+ hub.removeSkill(decodeURIComponent(skillDelete[1]));
210
+ sendJson(res, 200, { ok: true });
211
+ return;
212
+ }
213
+ const skillApprove = path.match(/^\/api\/skills\/([^/]+)\/approve$/);
214
+ if (skillApprove) {
215
+ sendJson(res, 200, { ok: true, skill: hub.approveSkill(decodeURIComponent(skillApprove[1])) });
216
+ return;
217
+ }
218
+ if (path === "/api/mcp/skills" || path === "/api/mcp/attach") {
219
+ const target = hub.resolveMcpToken(String(body.token ?? ""));
220
+ if (!target) {
221
+ sendJson(res, 403, { error: "unknown skills token (the session it belonged to is gone)" });
222
+ return;
223
+ }
224
+ if (path === "/api/mcp/attach") {
225
+ const to = Array.isArray(body.to) ? body.to.map((v) => String(v)) : body.to === undefined || body.to === null || body.to === "" || body.to === "me" ? "me" : [String(body.to)];
226
+ sendJson(res, 200, target.room.attachSkillForAgent(target.participantId, String(body.name ?? ""), to));
227
+ return;
228
+ }
229
+ sendJson(res, 200, target.room.createSkillForAgent(target.participantId, {
230
+ op: body.op === "update" ? "update" : "create",
231
+ name: String(body.name ?? ""),
232
+ description: String(body.description ?? ""),
233
+ instructions: String(body.instructions ?? ""),
234
+ argumentHint: optionalString(body.argument_hint) ?? undefined,
235
+ userInvocable: body.user_invocable === undefined ? undefined : body.user_invocable === true || body.user_invocable === "true",
236
+ agentInvocable: body.agent_invocable === undefined ? undefined : body.agent_invocable === true || body.agent_invocable === "true",
237
+ dryRun: body.dry_run === true || body.dry_run === "true",
238
+ }));
239
+ return;
240
+ }
241
+ if (path === "/api/mcp/ready") {
242
+ const token = String(body.token ?? "");
243
+ const target = hub.resolveMcpToken(token);
244
+ if (target)
245
+ target.room.skillToolReady(target.participantId, token);
246
+ sendJson(res, 200, { ok: !!target });
247
+ return;
248
+ }
249
+ if (path === "/api/profile/erase") {
250
+ if (String(body.confirm ?? "") !== "erase")
251
+ throw new Error('type "erase" to confirm');
252
+ await hub.reset();
253
+ sendJson(res, 200, { ok: true });
254
+ return;
255
+ }
256
+ if (path === "/api/shutdown") {
257
+ log.info("shutdown requested over the API");
258
+ sendJson(res, 200, { ok: true });
259
+ setTimeout(onShutdownRequest, 50);
260
+ return;
261
+ }
262
+ if (path === "/api/rooms") {
263
+ const { room, notices } = hub.createRoom({
264
+ name: String(body.name ?? ""),
265
+ dir: optionalString(body.dir),
266
+ settings: body.settings ?? {},
267
+ });
268
+ sendJson(res, 200, { ok: true, room: room.snapshot(), notices });
269
+ return;
270
+ }
271
+ const roomAction = path.match(/^\/api\/rooms\/([^/]+)\/(send|typing|invite|settings|focus|rename|dir|delete)$/);
272
+ if (roomAction) {
273
+ const room = hub.getRoom(decodeURIComponent(roomAction[1]));
274
+ const action = roomAction[2];
275
+ if (action === "send") {
276
+ const message = room.postHumanMessage(String(body.text ?? ""));
277
+ sendJson(res, 200, { ok: true, id: message.id });
278
+ }
279
+ else if (action === "typing") {
280
+ room.humanTyping();
281
+ sendJson(res, 200, { ok: true });
282
+ }
283
+ else if (action === "invite") {
284
+ const participant = await room.inviteAgent({
285
+ agentType: String(body.agentType ?? ""),
286
+ name: String(body.name ?? ""),
287
+ tagline: optionalString(body.tagline),
288
+ role: optionalString(body.role),
289
+ avatar: optionalString(body.avatar),
290
+ replyDelay: body.replyDelay === undefined || body.replyDelay === null || body.replyDelay === "" ? undefined : Number(body.replyDelay),
291
+ skills: stringList(body.skills),
292
+ model: optionalString(body.model),
293
+ effort: optionalString(body.effort),
294
+ mode: optionalString(body.mode),
295
+ });
296
+ hub.saveRooms();
297
+ sendJson(res, 200, { ok: true, participant });
298
+ }
299
+ else if (action === "settings") {
300
+ const settings = room.updateSettings(body);
301
+ hub.saveRooms();
302
+ sendJson(res, 200, { ok: true, settings });
303
+ }
304
+ else if (action === "focus") {
305
+ room.focus();
306
+ sendJson(res, 200, { ok: true });
307
+ }
308
+ else if (action === "rename") {
309
+ room.rename(String(body.name ?? ""));
310
+ hub.saveRooms();
311
+ sendJson(res, 200, { ok: true });
312
+ }
313
+ else if (action === "dir") {
314
+ const result = await room.setDir(String(body.dir ?? ""));
315
+ const notice = hub.workspaceNotice(result.dir);
316
+ if (notice)
317
+ room.postNotice(notice);
318
+ hub.saveRooms();
319
+ sendJson(res, 200, { ok: true, ...result });
320
+ }
321
+ else {
322
+ await hub.removeRoom(room.id);
323
+ sendJson(res, 200, { ok: true });
324
+ }
325
+ return;
326
+ }
327
+ const messageEdit = path.match(/^\/api\/rooms\/([^/]+)\/messages\/([^/]+)\/edit$/);
328
+ if (messageEdit) {
329
+ const room = hub.getRoom(decodeURIComponent(messageEdit[1]));
330
+ const mode = body.mode === "rewrite" ? "rewrite" : "notify";
331
+ const result = await room.editMessage(decodeURIComponent(messageEdit[2]), String(body.text ?? ""), mode);
332
+ hub.saveRooms();
333
+ sendJson(res, 200, { ok: true, ...result });
334
+ return;
335
+ }
336
+ const participantAction = path.match(/^\/api\/rooms\/([^/]+)\/participants\/([^/]+)\/(cancel|remove|config|persona|reconnect|mute|unmute)$/);
337
+ if (participantAction) {
338
+ const room = hub.getRoom(decodeURIComponent(participantAction[1]));
339
+ const id = decodeURIComponent(participantAction[2]);
340
+ const action = participantAction[3];
341
+ if (action === "cancel")
342
+ room.cancelTurn(id);
343
+ else if (action === "remove")
344
+ await room.removeParticipant(id);
345
+ else if (action === "reconnect") {
346
+ const mode = body.mode === "load" ? "load" : "replay";
347
+ const replay = body.replay === undefined || body.replay === null || body.replay === "" ? undefined : Number(body.replay);
348
+ await room.reconnect(id, { mode, replay: replay !== undefined && Number.isFinite(replay) ? Math.max(0, Math.min(500, Math.round(replay))) : undefined });
349
+ }
350
+ else if (action === "mute")
351
+ room.setMuted(id, true);
352
+ else if (action === "unmute")
353
+ room.setMuted(id, false);
354
+ else if (action === "persona") {
355
+ room.updatePersona(id, {
356
+ name: body.name === undefined ? undefined : String(body.name),
357
+ tagline: body.tagline === undefined ? undefined : String(body.tagline),
358
+ role: body.role === undefined ? undefined : String(body.role),
359
+ avatar: body.avatar === undefined ? undefined : String(body.avatar),
360
+ replyDelay: body.replyDelay === undefined ? undefined : body.replyDelay === null || body.replyDelay === "" ? null : Number(body.replyDelay),
361
+ skills: stringList(body.skills),
362
+ });
363
+ }
364
+ else
365
+ await room.setConfig(id, String(body.configId ?? ""), body.value);
366
+ hub.saveRooms();
367
+ sendJson(res, 200, { ok: true });
368
+ return;
369
+ }
370
+ const permission = path.match(/^\/api\/rooms\/([^/]+)\/permissions\/([^/]+)$/);
371
+ if (permission) {
372
+ const room = hub.getRoom(decodeURIComponent(permission[1]));
373
+ room.resolvePermission(decodeURIComponent(permission[2]), optionalString(body.optionId) ?? null);
374
+ sendJson(res, 200, { ok: true });
375
+ return;
376
+ }
377
+ sendJson(res, 404, { error: "not found" });
378
+ }
379
+ return new Promise((resolve, reject) => {
380
+ server.once("error", reject);
381
+ server.listen(port, "127.0.0.1", () => {
382
+ const address = server.address();
383
+ const actualPort = typeof address === "object" && address ? address.port : port;
384
+ const url = `http://127.0.0.1:${actualPort}/`;
385
+ resolve({
386
+ url,
387
+ server,
388
+ close: () => {
389
+ clearInterval(heartbeat);
390
+ for (const res of clients)
391
+ res.end();
392
+ server.close();
393
+ },
394
+ });
395
+ });
396
+ });
397
+ }
398
+ function optionalString(value) {
399
+ if (value === undefined)
400
+ return undefined;
401
+ if (value === null || value === "")
402
+ return null;
403
+ return String(value);
404
+ }
405
+ function stringList(value) {
406
+ if (value === undefined)
407
+ return undefined;
408
+ if (!Array.isArray(value))
409
+ return [];
410
+ return value.map((v) => String(v).trim()).filter((v) => v.length > 0);
411
+ }
412
+ function sendJson(res, status, body) {
413
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
414
+ res.end(JSON.stringify(body));
415
+ }
416
+ function readJson(req) {
417
+ return new Promise((resolve, reject) => {
418
+ const chunks = [];
419
+ req.on("data", (chunk) => chunks.push(chunk));
420
+ req.on("end", () => {
421
+ const raw = Buffer.concat(chunks).toString("utf8");
422
+ if (!raw.trim())
423
+ return resolve({});
424
+ try {
425
+ resolve(JSON.parse(raw));
426
+ }
427
+ catch {
428
+ reject(new Error("invalid JSON body"));
429
+ }
430
+ });
431
+ req.on("error", reject);
432
+ });
433
+ }
@@ -0,0 +1,176 @@
1
+ // viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
2
+ import { spawnSync } from "node:child_process";
3
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { basename, join } from "node:path";
6
+ import { findChromium } from "./launcher.js";
7
+ export function vbsLauncher(node, main) {
8
+ return ["' viberoom: start the hub without a console window and open the app window.", 'Set sh = CreateObject("WScript.Shell")', `sh.Run """${node}"" ""${main}"" start", 0, False`, ""].join("\r\n");
9
+ }
10
+ const AUMID_BASE = { "chrome.exe": "Chrome", "msedge.exe": "MSEdge", "brave.exe": "Brave", "chromium.exe": "Chromium" };
11
+ export function appUserModelId(browserPath, url = "http://127.0.0.1:4810/", profileDirName = "browser") {
12
+ if (!browserPath)
13
+ return null;
14
+ const base = AUMID_BASE[basename(browserPath).toLowerCase()];
15
+ if (!base)
16
+ return null;
17
+ const u = new URL(url);
18
+ const clean = profileDirName.replace(/[^A-Za-z0-9]/g, "");
19
+ const profile = clean.length > 12 ? `${clean.slice(0, 10)}${clean.slice(-2)}` : clean;
20
+ return `${base}.${u.hostname}_${u.pathname}.${profile}.Default`;
21
+ }
22
+ export const LNK_AUMID_TYPE = `using System;
23
+ using System.Runtime.InteropServices;
24
+ using System.Runtime.InteropServices.ComTypes;
25
+ public static class LnkAumid {
26
+ [ComImport, Guid("00021401-0000-0000-C000-000000000046")] class ShellLink {}
27
+ [ComImport, Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
28
+ interface IPropertyStore { int GetCount(out uint c); int GetAt(uint i, out PROPERTYKEY k); int GetValue(ref PROPERTYKEY k, out PROPVARIANT v); int SetValue(ref PROPERTYKEY k, ref PROPVARIANT v); int Commit(); }
29
+ [StructLayout(LayoutKind.Sequential)] struct PROPERTYKEY { public Guid fmtid; public uint pid; }
30
+ [StructLayout(LayoutKind.Sequential)] struct PROPVARIANT { public ushort vt; public ushort r1; public ushort r2; public ushort r3; public IntPtr p; public int p2; }
31
+ [DllImport("shell32.dll")] static extern int SHGetPropertyStoreForWindow(IntPtr hwnd, ref Guid riid, out IPropertyStore ppv);
32
+ static PROPERTYKEY Key() { PROPERTYKEY k = new PROPERTYKEY(); k.fmtid = new Guid("9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3"); k.pid = 5; return k; }
33
+ static string Read(IPropertyStore store) { PROPERTYKEY key = Key(); PROPVARIANT v; store.GetValue(ref key, out v); return v.vt == 31 ? Marshal.PtrToStringUni(v.p) : ""; }
34
+ public static string GetWindow(IntPtr hwnd) { Guid iid = new Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99"); IPropertyStore store; if (SHGetPropertyStoreForWindow(hwnd, ref iid, out store) != 0) return ""; return Read(store); }
35
+ public static string Get(string lnk) { IPersistFile link = (IPersistFile)new ShellLink(); link.Load(lnk, 0); return Read((IPropertyStore)link); }
36
+ public static void Set(string lnk, string aumid) {
37
+ IPersistFile link = (IPersistFile)new ShellLink(); link.Load(lnk, 2); // STGM_READWRITE, or Commit fails with STG_E_ACCESSDENIED
38
+ IPropertyStore store = (IPropertyStore)link; PROPERTYKEY key = Key();
39
+ PROPVARIANT v = new PROPVARIANT(); v.vt = 31; v.p = Marshal.StringToCoTaskMemUni(aumid);
40
+ Marshal.ThrowExceptionForHR(store.SetValue(ref key, ref v)); Marshal.ThrowExceptionForHR(store.Commit());
41
+ link.Save(lnk, true); Marshal.FreeCoTaskMem(v.p);
42
+ }
43
+ }`;
44
+ const psq = (s) => s.replace(/'/g, "''");
45
+ export function shortcutScript(lnk, wscript, vbs, root, ico, aumid = null) {
46
+ const lines = [
47
+ "$ErrorActionPreference = 'Stop'",
48
+ `$s = (New-Object -ComObject WScript.Shell).CreateShortcut('${psq(lnk)}')`,
49
+ `$s.TargetPath = '${psq(wscript)}'`,
50
+ `$s.Arguments = '"${psq(vbs)}"'`,
51
+ `$s.WorkingDirectory = '${psq(root)}'`,
52
+ `$s.Description = 'viberoom: rooms for you and your coding agents'`,
53
+ ico ? `$s.IconLocation = '${psq(ico)},0'` : "",
54
+ "$s.Save()",
55
+ ].filter(Boolean);
56
+ if (aumid)
57
+ lines.push("Add-Type -TypeDefinition @'", LNK_AUMID_TYPE, "'@", `[LnkAumid]::Set('${psq(lnk)}', '${psq(aumid)}')`);
58
+ return lines.join("\n");
59
+ }
60
+ export function aumidSyncScript(profileDir, shortcuts) {
61
+ const list = shortcuts.map((s) => `'${psq(s)}'`).join(", ");
62
+ return [
63
+ "$ErrorActionPreference = 'SilentlyContinue'",
64
+ "Add-Type -TypeDefinition @'",
65
+ LNK_AUMID_TYPE,
66
+ "'@",
67
+ `$marker = '${psq(profileDir)}'`,
68
+ "$aumid = ''",
69
+ "for ($i = 0; $i -lt 20 -and -not $aumid; $i++) {",
70
+ " Start-Sleep -Milliseconds 500",
71
+ " foreach ($p in (Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like ('*' + $marker + '*') -and $_.CommandLine -like '*--app=*' })) {",
72
+ " $proc = Get-Process -Id $p.ProcessId -ErrorAction SilentlyContinue",
73
+ " if ($proc -and $proc.MainWindowHandle -ne 0) { $aumid = [LnkAumid]::GetWindow($proc.MainWindowHandle); if ($aumid) { break } }",
74
+ " }",
75
+ "}",
76
+ `if ($aumid) { foreach ($lnk in @(${list})) { if ((Test-Path $lnk) -and ([LnkAumid]::Get($lnk) -ne $aumid)) { [LnkAumid]::Set($lnk, $aumid) } } }`,
77
+ ].join("\n");
78
+ }
79
+ export function windowsShortcutPaths(home, env, desktop) {
80
+ const targets = [join(env.APPDATA ?? join(home, "AppData", "Roaming"), "Microsoft", "Windows", "Start Menu", "Programs", "viberoom.lnk")];
81
+ if (desktop)
82
+ targets.push(join(home, "Desktop", "viberoom.lnk"));
83
+ return targets;
84
+ }
85
+ export function desktopEntry(node, main, icon) {
86
+ return ["[Desktop Entry]", "Type=Application", "Name=viberoom", "Comment=Rooms for you and your coding agents", `Exec="${node}" "${main}" start`, `Icon=${icon}`, "Terminal=false", "StartupWMClass=viberoom", "Categories=Development;Chat;", ""].join("\n");
87
+ }
88
+ export function macPlist(version) {
89
+ return `<?xml version="1.0" encoding="UTF-8"?>
90
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
91
+ <plist version="1.0"><dict>
92
+ <key>CFBundleName</key><string>viberoom</string>
93
+ <key>CFBundleDisplayName</key><string>viberoom</string>
94
+ <key>CFBundleIdentifier</key><string>dev.viberoom.launcher</string>
95
+ <key>CFBundleVersion</key><string>${version}</string>
96
+ <key>CFBundlePackageType</key><string>APPL</string>
97
+ <key>CFBundleExecutable</key><string>viberoom</string>
98
+ <key>CFBundleIconFile</key><string>icon</string>
99
+ <key>LSMinimumSystemVersion</key><string>11.0</string>
100
+ </dict></plist>
101
+ `;
102
+ }
103
+ export function installShortcuts(o) {
104
+ const platform = o.platform ?? process.platform;
105
+ const home = o.home ?? homedir();
106
+ const env = o.env ?? process.env;
107
+ const main = join(o.root, "dist", "main.js");
108
+ const launcherDir = join(o.dataDir, "launcher");
109
+ const result = { files: [], notes: [] };
110
+ mkdirSync(launcherDir, { recursive: true });
111
+ const icoSrc = join(o.root, "assets", "icon.ico");
112
+ const pngSrc = join(o.root, "assets", "icon-256.png");
113
+ const icnsSrc = join(o.root, "assets", "icon.icns");
114
+ if (platform === "win32") {
115
+ const vbs = join(launcherDir, "viberoom.vbs");
116
+ writeFileSync(vbs, vbsLauncher(o.node, main));
117
+ result.files.push(vbs);
118
+ let ico = null;
119
+ if (existsSync(icoSrc)) {
120
+ ico = join(launcherDir, "viberoom.ico");
121
+ copyFileSync(icoSrc, ico);
122
+ result.files.push(ico);
123
+ }
124
+ const wscript = join(env.SystemRoot ?? "C:\\Windows", "System32", "wscript.exe");
125
+ const browser = o.browser === undefined ? findChromium(env, platform) : o.browser;
126
+ const aumid = appUserModelId(browser, "http://127.0.0.1:4810/", basename(join(o.dataDir, "browser")));
127
+ for (const lnk of windowsShortcutPaths(home, env, o.desktop)) {
128
+ mkdirSync(join(lnk, ".."), { recursive: true });
129
+ const r = spawnSync("powershell", ["-NoProfile", "-Command", shortcutScript(lnk, wscript, vbs, o.root, ico, aumid)], { encoding: "utf8" });
130
+ if (r.status === 0)
131
+ result.files.push(lnk);
132
+ else
133
+ result.notes.push(`could not create ${lnk}: ${(r.stderr || "").split("\n")[0]}`);
134
+ }
135
+ result.notes.push("Start Menu: viberoom" + (o.desktop ? "; Desktop: viberoom" : "") + " (double-click opens the app window)");
136
+ result.notes.push(aumid ? `taskbar icon: the shortcuts carry the app window's id (${aumid}); reopen the window to see it` : "taskbar icon: no Chromium found, the window will open in the default browser");
137
+ return result;
138
+ }
139
+ if (platform === "darwin") {
140
+ const app = join(home, "Applications", "viberoom.app");
141
+ mkdirSync(join(app, "Contents", "MacOS"), { recursive: true });
142
+ mkdirSync(join(app, "Contents", "Resources"), { recursive: true });
143
+ writeFileSync(join(app, "Contents", "Info.plist"), macPlist(o.version));
144
+ const exe = join(app, "Contents", "MacOS", "viberoom");
145
+ writeFileSync(exe, `#!/bin/sh\nexec "${o.node}" "${main}" start\n`);
146
+ chmodSync(exe, 0o755);
147
+ if (existsSync(icnsSrc))
148
+ copyFileSync(icnsSrc, join(app, "Contents", "Resources", "icon.icns"));
149
+ result.files.push(app);
150
+ result.notes.push(`${app}: open it from Launchpad or Finder (drag it to the Dock if you like)`);
151
+ return result;
152
+ }
153
+ const iconDir = join(home, ".local", "share", "icons", "hicolor", "256x256", "apps");
154
+ mkdirSync(iconDir, { recursive: true });
155
+ const icon = join(iconDir, "viberoom.png");
156
+ if (existsSync(pngSrc)) {
157
+ copyFileSync(pngSrc, icon);
158
+ result.files.push(icon);
159
+ }
160
+ const entry = desktopEntry(o.node, main, icon);
161
+ const appsDir = join(home, ".local", "share", "applications");
162
+ mkdirSync(appsDir, { recursive: true });
163
+ const menuEntry = join(appsDir, "viberoom.desktop");
164
+ writeFileSync(menuEntry, entry);
165
+ chmodSync(menuEntry, 0o755);
166
+ result.files.push(menuEntry);
167
+ if (o.desktop) {
168
+ mkdirSync(join(home, "Desktop"), { recursive: true });
169
+ const d = join(home, "Desktop", "viberoom.desktop");
170
+ writeFileSync(d, entry);
171
+ chmodSync(d, 0o755);
172
+ result.files.push(d);
173
+ }
174
+ result.notes.push("applications menu: viberoom" + (o.desktop ? "; Desktop: viberoom.desktop (some desktops ask once to trust it)" : ""));
175
+ return result;
176
+ }