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.
- package/LICENSE +661 -0
- package/NOTICE +20 -0
- package/README.md +153 -0
- package/assets/icon-128.png +0 -0
- package/assets/icon-16.png +0 -0
- package/assets/icon-256.png +0 -0
- package/assets/icon-32.png +0 -0
- package/assets/icon-48.png +0 -0
- package/assets/icon-512.png +0 -0
- package/assets/icon-64.png +0 -0
- package/assets/icon-vector.svg +30 -0
- package/assets/icon.icns +0 -0
- package/assets/icon.ico +0 -0
- package/assets/icon.svg +30 -0
- package/assets/vendors/claude.svg +3 -0
- package/assets/vendors/codex.svg +3 -0
- package/assets/vendors/copilot.svg +5 -0
- package/assets/vendors/cursor.svg +3 -0
- package/assets/vendors/gemini.svg +3 -0
- package/assets/vendors/opencode.svg +3 -0
- package/dist/acp-client.js +137 -0
- package/dist/acp-types.js +2 -0
- package/dist/edit.js +34 -0
- package/dist/hub.js +348 -0
- package/dist/icons.js +235 -0
- package/dist/jsonrpc.js +109 -0
- package/dist/launcher.js +161 -0
- package/dist/log.js +35 -0
- package/dist/main.js +389 -0
- package/dist/mcp-skills-server.js +177 -0
- package/dist/open.js +141 -0
- package/dist/persona.js +217 -0
- package/dist/recipes.js +261 -0
- package/dist/room.js +2124 -0
- package/dist/server.js +433 -0
- package/dist/shortcuts.js +176 -0
- package/dist/skills.js +344 -0
- package/dist/tui.js +109 -0
- package/package.json +61 -0
- package/scripts/install.mjs +34 -0
- package/scripts/render-icon.mjs +84 -0
- package/scripts/update.mjs +29 -0
- package/ui/app.css +346 -0
- package/ui/app.js +2834 -0
- package/ui/avatars.js +113 -0
- package/ui/fonts/OFL.txt +93 -0
- package/ui/fonts/nunito-cyrillic-ext.woff2 +0 -0
- package/ui/fonts/nunito-cyrillic.woff2 +0 -0
- package/ui/fonts/nunito-latin-ext.woff2 +0 -0
- package/ui/fonts/nunito-latin.woff2 +0 -0
- package/ui/fonts/nunito-vietnamese.woff2 +0 -0
- package/ui/fonts/nunito.css +6 -0
- package/ui/icons.js +76 -0
- package/ui/index.html +217 -0
- package/ui/manifest.json +14 -0
- package/ui/theme.css +425 -0
package/dist/hub.js
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, renameSync } from "node:fs";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { DEFAULT_EDITOR_SETTINGS } from "./open.js";
|
|
8
|
+
import { listRecipes } from "./recipes.js";
|
|
9
|
+
import { DEFAULT_ROOM_SETTINGS } from "./persona.js";
|
|
10
|
+
import { Room } from "./room.js";
|
|
11
|
+
import { SkillLibrary } from "./skills.js";
|
|
12
|
+
export const DIAGRAM_PRESETS = ["pop", "lavender", "mint", "sunset", "slate"];
|
|
13
|
+
const ROOM_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
14
|
+
const INSTRUCTION_FILES = ["CLAUDE.md", "AGENTS.md", "GEMINI.md", ".cursorrules"];
|
|
15
|
+
export class Hub extends EventEmitter {
|
|
16
|
+
dataDir;
|
|
17
|
+
rooms = new Map();
|
|
18
|
+
skills;
|
|
19
|
+
settings;
|
|
20
|
+
log;
|
|
21
|
+
optionCache = new Map();
|
|
22
|
+
mcpTokens = new Map();
|
|
23
|
+
hubUrl = null;
|
|
24
|
+
skillsBridge;
|
|
25
|
+
constructor(dataDir, log, initialHumanName) {
|
|
26
|
+
super();
|
|
27
|
+
this.dataDir = resolve(dataDir);
|
|
28
|
+
this.log = log;
|
|
29
|
+
mkdirSync(join(this.dataDir, "rooms"), { recursive: true });
|
|
30
|
+
this.skills = new SkillLibrary(join(this.dataDir, "skills"), log.child("skills"));
|
|
31
|
+
try {
|
|
32
|
+
this.skills.seedBuiltins();
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
log.warn(`built-in skills could not be seeded: ${String(error)}`);
|
|
36
|
+
}
|
|
37
|
+
this.skillsBridge = {
|
|
38
|
+
library: this.skills,
|
|
39
|
+
serverScript: fileURLToPath(new URL("./mcp-skills-server.js", import.meta.url)),
|
|
40
|
+
hubUrl: () => this.hubUrl,
|
|
41
|
+
needApproval: () => this.settings.agentSkillsNeedApproval === true,
|
|
42
|
+
save: (draft) => {
|
|
43
|
+
const { body: _b, ...meta } = this.saveSkillInternal(draft);
|
|
44
|
+
return meta;
|
|
45
|
+
},
|
|
46
|
+
issueToken: (roomId, participantId) => {
|
|
47
|
+
const token = randomBytes(18).toString("base64url");
|
|
48
|
+
this.mcpTokens.set(token, { roomId, participantId });
|
|
49
|
+
return token;
|
|
50
|
+
},
|
|
51
|
+
revokeToken: (token) => {
|
|
52
|
+
this.mcpTokens.delete(token);
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
this.settings = this.loadSettings(initialHumanName);
|
|
56
|
+
this.loadRooms();
|
|
57
|
+
}
|
|
58
|
+
setHubUrl(url) {
|
|
59
|
+
this.hubUrl = url.replace(/\/+$/, "");
|
|
60
|
+
}
|
|
61
|
+
resolveMcpToken(token) {
|
|
62
|
+
const entry = this.mcpTokens.get(token);
|
|
63
|
+
if (!entry)
|
|
64
|
+
return null;
|
|
65
|
+
const room = this.rooms.get(entry.roomId);
|
|
66
|
+
return room ? { room, participantId: entry.participantId } : null;
|
|
67
|
+
}
|
|
68
|
+
listSkills() {
|
|
69
|
+
return this.skills.list();
|
|
70
|
+
}
|
|
71
|
+
saveSkill(draft) {
|
|
72
|
+
const { body: _b, ...meta } = this.saveSkillInternal({ ...draft, reviewed: true, draft: false });
|
|
73
|
+
return meta;
|
|
74
|
+
}
|
|
75
|
+
saveSkillInternal(draft) {
|
|
76
|
+
const skill = this.skills.save(draft);
|
|
77
|
+
this.skillsChanged(skill.name);
|
|
78
|
+
return skill;
|
|
79
|
+
}
|
|
80
|
+
approveSkill(name) {
|
|
81
|
+
const skill = this.skills.approve(name);
|
|
82
|
+
this.skillsChanged(skill.name);
|
|
83
|
+
const { body: _b, ...meta } = skill;
|
|
84
|
+
return meta;
|
|
85
|
+
}
|
|
86
|
+
removeSkill(name) {
|
|
87
|
+
this.skills.remove(name);
|
|
88
|
+
this.skillsChanged(name);
|
|
89
|
+
}
|
|
90
|
+
skillsChanged(name) {
|
|
91
|
+
for (const room of this.rooms.values())
|
|
92
|
+
room.skillChanged(name);
|
|
93
|
+
this.emit("event", { type: "skills", skills: this.skills.list() });
|
|
94
|
+
}
|
|
95
|
+
settingsPath() {
|
|
96
|
+
return join(this.dataDir, "settings.json");
|
|
97
|
+
}
|
|
98
|
+
loadSettings(initialHumanName) {
|
|
99
|
+
const defaults = {
|
|
100
|
+
humanName: initialHumanName ?? "Human",
|
|
101
|
+
humanDescription: "",
|
|
102
|
+
humanAvatar: "",
|
|
103
|
+
bypassPermissionsByDefault: true,
|
|
104
|
+
profileCompleted: !!initialHumanName,
|
|
105
|
+
agentSkillsNeedApproval: false,
|
|
106
|
+
diagrams: { preset: "pop", primary: null },
|
|
107
|
+
editor: { ...DEFAULT_EDITOR_SETTINGS },
|
|
108
|
+
roomDefaults: {},
|
|
109
|
+
vendorPresets: {},
|
|
110
|
+
};
|
|
111
|
+
if (!existsSync(this.settingsPath())) {
|
|
112
|
+
writeJson(this.settingsPath(), defaults);
|
|
113
|
+
return defaults;
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
const raw = JSON.parse(readFileSync(this.settingsPath(), "utf8"));
|
|
117
|
+
return { ...defaults, ...raw, roomDefaults: raw.roomDefaults ?? {}, vendorPresets: raw.vendorPresets ?? {} };
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
this.log.warn(`settings.json unreadable (${String(error)}); using defaults`);
|
|
121
|
+
return defaults;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
updateSettings(patch) {
|
|
125
|
+
const next = { ...this.settings, roomDefaults: { ...this.settings.roomDefaults }, vendorPresets: { ...this.settings.vendorPresets } };
|
|
126
|
+
if (patch.humanName !== undefined) {
|
|
127
|
+
const name = String(patch.humanName).trim();
|
|
128
|
+
if (!/^[\p{L}\p{N}][\p{L}\p{N}_-]{0,23}$/u.test(name))
|
|
129
|
+
throw new Error("humanName must be 1-24 letters, digits, _ or - (no spaces)");
|
|
130
|
+
next.humanName = name;
|
|
131
|
+
}
|
|
132
|
+
if (patch.humanDescription !== undefined)
|
|
133
|
+
next.humanDescription = String(patch.humanDescription).slice(0, 200);
|
|
134
|
+
if (patch.humanAvatar !== undefined)
|
|
135
|
+
next.humanAvatar = String(patch.humanAvatar).slice(0, 8);
|
|
136
|
+
if (patch.bypassPermissionsByDefault !== undefined)
|
|
137
|
+
next.bypassPermissionsByDefault = patch.bypassPermissionsByDefault === true || patch.bypassPermissionsByDefault === "true";
|
|
138
|
+
if (patch.profileCompleted !== undefined)
|
|
139
|
+
next.profileCompleted = patch.profileCompleted === true || patch.profileCompleted === "true";
|
|
140
|
+
if (patch.agentSkillsNeedApproval !== undefined)
|
|
141
|
+
next.agentSkillsNeedApproval = patch.agentSkillsNeedApproval === true || patch.agentSkillsNeedApproval === "true";
|
|
142
|
+
if (patch.diagrams !== undefined && typeof patch.diagrams === "object" && patch.diagrams) {
|
|
143
|
+
const d = patch.diagrams;
|
|
144
|
+
const preset = String(d.preset ?? next.diagrams?.preset ?? "pop");
|
|
145
|
+
if (!DIAGRAM_PRESETS.includes(preset))
|
|
146
|
+
throw new Error(`diagrams.preset must be one of ${DIAGRAM_PRESETS.join(", ")}`);
|
|
147
|
+
let primary = next.diagrams?.primary ?? null;
|
|
148
|
+
if (d.primary !== undefined) {
|
|
149
|
+
primary = d.primary === null || d.primary === "" ? null : String(d.primary).trim().toLowerCase();
|
|
150
|
+
if (primary !== null && !/^#[0-9a-f]{6}$/.test(primary))
|
|
151
|
+
throw new Error("diagrams.primary must be a #rrggbb colour or empty");
|
|
152
|
+
}
|
|
153
|
+
next.diagrams = { preset, primary };
|
|
154
|
+
}
|
|
155
|
+
if (!next.diagrams)
|
|
156
|
+
next.diagrams = { preset: "pop", primary: null };
|
|
157
|
+
if (patch.editor !== undefined && typeof patch.editor === "object" && patch.editor) {
|
|
158
|
+
const e = patch.editor;
|
|
159
|
+
const mode = String(e.mode ?? next.editor?.mode ?? "auto");
|
|
160
|
+
if (mode !== "auto" && mode !== "default-app" && mode !== "custom")
|
|
161
|
+
throw new Error("editor.mode must be auto, default-app or custom");
|
|
162
|
+
const command = String(e.command ?? next.editor?.command ?? "").trim().slice(0, 500);
|
|
163
|
+
if (mode === "custom" && !command.includes("{file}"))
|
|
164
|
+
throw new Error("editor.command must mention {file} (and usually {line})");
|
|
165
|
+
next.editor = { mode, command };
|
|
166
|
+
}
|
|
167
|
+
if (!next.editor)
|
|
168
|
+
next.editor = { ...DEFAULT_EDITOR_SETTINGS };
|
|
169
|
+
if (patch.roomDefaults !== undefined && typeof patch.roomDefaults === "object" && patch.roomDefaults) {
|
|
170
|
+
next.roomDefaults = { ...next.roomDefaults, ...patch.roomDefaults };
|
|
171
|
+
}
|
|
172
|
+
if (patch.vendorPresets !== undefined && typeof patch.vendorPresets === "object" && patch.vendorPresets) {
|
|
173
|
+
for (const [vendor, preset] of Object.entries(patch.vendorPresets)) {
|
|
174
|
+
next.vendorPresets[vendor] = {
|
|
175
|
+
model: preset?.model ?? null,
|
|
176
|
+
effort: preset?.effort ?? null,
|
|
177
|
+
mode: preset?.mode ?? null,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
this.settings = next;
|
|
182
|
+
writeJson(this.settingsPath(), this.settings);
|
|
183
|
+
for (const room of this.rooms.values()) {
|
|
184
|
+
room.applyProgramSettings({
|
|
185
|
+
humanName: next.humanName,
|
|
186
|
+
humanDescription: next.humanDescription,
|
|
187
|
+
bypassPermissionsByDefault: next.bypassPermissionsByDefault,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
this.emit("event", { type: "settings", settings: this.settings });
|
|
191
|
+
this.saveRooms();
|
|
192
|
+
return this.settings;
|
|
193
|
+
}
|
|
194
|
+
roomsPath() {
|
|
195
|
+
return join(this.dataDir, "rooms.json");
|
|
196
|
+
}
|
|
197
|
+
loadRooms() {
|
|
198
|
+
if (!existsSync(this.roomsPath()))
|
|
199
|
+
return;
|
|
200
|
+
let file;
|
|
201
|
+
try {
|
|
202
|
+
file = JSON.parse(readFileSync(this.roomsPath(), "utf8"));
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
this.log.warn(`rooms.json unreadable (${String(error)}); starting with no rooms`);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
for (const stored of file.rooms ?? []) {
|
|
209
|
+
try {
|
|
210
|
+
const room = this.instantiate(stored.id, stored.name, stored.dir, stored.settings, stored.createdAt);
|
|
211
|
+
room.restore(stored.participants ?? []);
|
|
212
|
+
this.log.info(`restored room "${stored.name}" (${stored.id}): ${room.messages.length} messages, ${stored.participants?.length ?? 0} participants offline`);
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
this.log.error(`could not restore room ${stored.id}: ${String(error)}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
saveRooms() {
|
|
220
|
+
const file = {
|
|
221
|
+
version: 1,
|
|
222
|
+
rooms: [...this.rooms.values()].map((room) => room.toStored()),
|
|
223
|
+
};
|
|
224
|
+
writeJson(this.roomsPath(), file);
|
|
225
|
+
}
|
|
226
|
+
instantiate(id, name, dir, settings, createdAt) {
|
|
227
|
+
const room = new Room({
|
|
228
|
+
id,
|
|
229
|
+
name,
|
|
230
|
+
dir,
|
|
231
|
+
dataDir: join(this.dataDir, "rooms", id),
|
|
232
|
+
createdAt,
|
|
233
|
+
humanName: this.settings.humanName,
|
|
234
|
+
programHumanDescription: this.settings.humanDescription,
|
|
235
|
+
bypassPermissionsByDefault: this.settings.bypassPermissionsByDefault,
|
|
236
|
+
settings: { ...this.settings.roomDefaults, ...settings },
|
|
237
|
+
log: this.log.child(`room:${id}`),
|
|
238
|
+
optionCache: this.optionCache,
|
|
239
|
+
skills: this.skillsBridge,
|
|
240
|
+
});
|
|
241
|
+
room.on("event", (event) => {
|
|
242
|
+
this.emit("event", { type: "room.event", roomId: id, event });
|
|
243
|
+
if (event.type === "participant" || event.type === "participant.removed" || event.type === "room")
|
|
244
|
+
this.saveRooms();
|
|
245
|
+
});
|
|
246
|
+
this.rooms.set(id, room);
|
|
247
|
+
return room;
|
|
248
|
+
}
|
|
249
|
+
workspaceNotice(dir) {
|
|
250
|
+
const found = INSTRUCTION_FILES.filter((f) => existsSync(join(dir, f)));
|
|
251
|
+
if (existsSync(join(dir, ".cursor", "rules")))
|
|
252
|
+
found.push(".cursor/rules");
|
|
253
|
+
return found.length ? `The working directory contains ${found.join(", ")}; agents will read these by their own conventions in addition to the room brief.` : null;
|
|
254
|
+
}
|
|
255
|
+
createRoom(input) {
|
|
256
|
+
const name = input.name.trim();
|
|
257
|
+
if (!name || name.length > 60)
|
|
258
|
+
throw new Error("room name must be 1-60 characters");
|
|
259
|
+
let id = slugify(name);
|
|
260
|
+
if (!ROOM_ID_PATTERN.test(id))
|
|
261
|
+
id = `room-${Date.now().toString(36)}`;
|
|
262
|
+
let candidate = id;
|
|
263
|
+
for (let n = 2; this.rooms.has(candidate); n++)
|
|
264
|
+
candidate = `${id}-${n}`;
|
|
265
|
+
id = candidate;
|
|
266
|
+
const notices = [];
|
|
267
|
+
let dir;
|
|
268
|
+
if (input.dir && input.dir.trim()) {
|
|
269
|
+
dir = resolve(input.dir.trim());
|
|
270
|
+
if (!existsSync(dir))
|
|
271
|
+
throw new Error(`working directory does not exist: ${dir}`);
|
|
272
|
+
const notice = this.workspaceNotice(dir);
|
|
273
|
+
if (notice)
|
|
274
|
+
notices.push(notice);
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
dir = join(this.dataDir, "rooms", id, "workspace");
|
|
278
|
+
mkdirSync(dir, { recursive: true });
|
|
279
|
+
}
|
|
280
|
+
const room = this.instantiate(id, name, dir, input.settings ?? {}, Date.now());
|
|
281
|
+
for (const notice of notices)
|
|
282
|
+
room.postNotice(notice);
|
|
283
|
+
this.saveRooms();
|
|
284
|
+
this.emit("event", { type: "room.created", room: room.snapshot() });
|
|
285
|
+
this.log.info(`created room "${name}" (${id}) with workspace ${dir}`);
|
|
286
|
+
return { room, notices };
|
|
287
|
+
}
|
|
288
|
+
getRoom(id) {
|
|
289
|
+
const room = this.rooms.get(id);
|
|
290
|
+
if (!room)
|
|
291
|
+
throw new Error(`no such room: ${id}`);
|
|
292
|
+
return room;
|
|
293
|
+
}
|
|
294
|
+
async removeRoom(id) {
|
|
295
|
+
const room = this.getRoom(id);
|
|
296
|
+
await room.shutdown();
|
|
297
|
+
this.rooms.delete(id);
|
|
298
|
+
this.saveRooms();
|
|
299
|
+
this.emit("event", { type: "room.removed", roomId: id });
|
|
300
|
+
this.log.info(`removed room ${id} (history kept on disk)`);
|
|
301
|
+
}
|
|
302
|
+
snapshot() {
|
|
303
|
+
return {
|
|
304
|
+
settings: this.settings,
|
|
305
|
+
recipes: listRecipes().map(({ build: _b, ...r }) => r),
|
|
306
|
+
skills: this.skills.list(),
|
|
307
|
+
roomDefaults: { ...DEFAULT_ROOM_SETTINGS, ...this.settings.roomDefaults },
|
|
308
|
+
rooms: [...this.rooms.values()].map((room) => room.snapshot()),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
async shutdown() {
|
|
312
|
+
for (const room of this.rooms.values())
|
|
313
|
+
await room.shutdown();
|
|
314
|
+
this.saveRooms();
|
|
315
|
+
}
|
|
316
|
+
async reset() {
|
|
317
|
+
this.log.warn("erasing the whole data folder on the human's request");
|
|
318
|
+
for (const room of this.rooms.values())
|
|
319
|
+
await room.shutdown();
|
|
320
|
+
this.rooms.clear();
|
|
321
|
+
for (const token of this.mcpTokens.keys())
|
|
322
|
+
this.mcpTokens.delete(token);
|
|
323
|
+
rmSync(join(this.dataDir, "rooms"), { recursive: true, force: true });
|
|
324
|
+
rmSync(this.roomsPath(), { force: true });
|
|
325
|
+
rmSync(this.skills.dir, { recursive: true, force: true });
|
|
326
|
+
mkdirSync(join(this.dataDir, "rooms"), { recursive: true });
|
|
327
|
+
mkdirSync(this.skills.dir, { recursive: true });
|
|
328
|
+
this.skills.list();
|
|
329
|
+
this.skills.seedBuiltins();
|
|
330
|
+
rmSync(this.settingsPath(), { force: true });
|
|
331
|
+
this.settings = this.loadSettings();
|
|
332
|
+
this.emit("event", { type: "reset" });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
function slugify(name) {
|
|
336
|
+
return name
|
|
337
|
+
.toLowerCase()
|
|
338
|
+
.normalize("NFKD")
|
|
339
|
+
.replace(/[̀-ͯ]/g, "")
|
|
340
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
341
|
+
.replace(/^-+|-+$/g, "")
|
|
342
|
+
.slice(0, 40);
|
|
343
|
+
}
|
|
344
|
+
function writeJson(path, value) {
|
|
345
|
+
const tmp = `${path}.tmp`;
|
|
346
|
+
writeFileSync(tmp, JSON.stringify(value, null, 2));
|
|
347
|
+
renameSync(tmp, path);
|
|
348
|
+
}
|
package/dist/icons.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
|
|
2
|
+
import { deflateSync, inflateSync } from "node:zlib";
|
|
3
|
+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
4
|
+
function assertPng(icon) {
|
|
5
|
+
if (icon.data.length < 8 || !icon.data.subarray(0, 8).equals(PNG_SIGNATURE))
|
|
6
|
+
throw new Error(`icon ${icon.size}: not a PNG`);
|
|
7
|
+
}
|
|
8
|
+
const CHANNELS = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
|
|
9
|
+
export function decodePng(png) {
|
|
10
|
+
if (png.length < 8 || !png.subarray(0, 8).equals(PNG_SIGNATURE))
|
|
11
|
+
throw new Error("not a PNG");
|
|
12
|
+
let width = 0;
|
|
13
|
+
let height = 0;
|
|
14
|
+
let bitDepth = 0;
|
|
15
|
+
let colorType = 0;
|
|
16
|
+
let interlace = 0;
|
|
17
|
+
let palette;
|
|
18
|
+
let trns;
|
|
19
|
+
const idat = [];
|
|
20
|
+
let pos = 8;
|
|
21
|
+
while (pos + 8 <= png.length) {
|
|
22
|
+
const len = png.readUInt32BE(pos);
|
|
23
|
+
const type = png.toString("ascii", pos + 4, pos + 8);
|
|
24
|
+
const body = png.subarray(pos + 8, pos + 8 + len);
|
|
25
|
+
if (type === "IHDR") {
|
|
26
|
+
width = body.readUInt32BE(0);
|
|
27
|
+
height = body.readUInt32BE(4);
|
|
28
|
+
bitDepth = body[8];
|
|
29
|
+
colorType = body[9];
|
|
30
|
+
interlace = body[12];
|
|
31
|
+
}
|
|
32
|
+
else if (type === "PLTE")
|
|
33
|
+
palette = body;
|
|
34
|
+
else if (type === "tRNS")
|
|
35
|
+
trns = body;
|
|
36
|
+
else if (type === "IDAT")
|
|
37
|
+
idat.push(body);
|
|
38
|
+
else if (type === "IEND")
|
|
39
|
+
break;
|
|
40
|
+
pos += 12 + len;
|
|
41
|
+
}
|
|
42
|
+
const channels = CHANNELS[colorType];
|
|
43
|
+
if (!width || !height || !channels)
|
|
44
|
+
throw new Error("png: unsupported image header");
|
|
45
|
+
if (bitDepth !== 8 || interlace !== 0)
|
|
46
|
+
throw new Error("png: only 8-bit non-interlaced images are supported");
|
|
47
|
+
const raw = inflateSync(Buffer.concat(idat));
|
|
48
|
+
const stride = width * channels;
|
|
49
|
+
const out = Buffer.alloc(width * height * 4);
|
|
50
|
+
let prev = Buffer.alloc(stride);
|
|
51
|
+
let p = 0;
|
|
52
|
+
for (let y = 0; y < height; y++) {
|
|
53
|
+
const filter = raw[p++];
|
|
54
|
+
const line = Buffer.from(raw.subarray(p, p + stride));
|
|
55
|
+
p += stride;
|
|
56
|
+
for (let i = 0; i < stride; i++) {
|
|
57
|
+
const a = i >= channels ? line[i - channels] : 0;
|
|
58
|
+
const b = prev[i];
|
|
59
|
+
const c = i >= channels ? prev[i - channels] : 0;
|
|
60
|
+
let v = line[i];
|
|
61
|
+
if (filter === 1)
|
|
62
|
+
v += a;
|
|
63
|
+
else if (filter === 2)
|
|
64
|
+
v += b;
|
|
65
|
+
else if (filter === 3)
|
|
66
|
+
v += Math.floor((a + b) / 2);
|
|
67
|
+
else if (filter === 4) {
|
|
68
|
+
const pp = a + b - c;
|
|
69
|
+
const pa = Math.abs(pp - a);
|
|
70
|
+
const pb = Math.abs(pp - b);
|
|
71
|
+
const pc = Math.abs(pp - c);
|
|
72
|
+
v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
73
|
+
}
|
|
74
|
+
else if (filter !== 0)
|
|
75
|
+
throw new Error(`png: unknown filter ${filter}`);
|
|
76
|
+
line[i] = v & 255;
|
|
77
|
+
}
|
|
78
|
+
for (let x = 0; x < width; x++) {
|
|
79
|
+
const o = (y * width + x) * 4;
|
|
80
|
+
const s = x * channels;
|
|
81
|
+
if (colorType === 6) {
|
|
82
|
+
out[o] = line[s];
|
|
83
|
+
out[o + 1] = line[s + 1];
|
|
84
|
+
out[o + 2] = line[s + 2];
|
|
85
|
+
out[o + 3] = line[s + 3];
|
|
86
|
+
}
|
|
87
|
+
else if (colorType === 2) {
|
|
88
|
+
out[o] = line[s];
|
|
89
|
+
out[o + 1] = line[s + 1];
|
|
90
|
+
out[o + 2] = line[s + 2];
|
|
91
|
+
out[o + 3] = 255;
|
|
92
|
+
}
|
|
93
|
+
else if (colorType === 0 || colorType === 4) {
|
|
94
|
+
out[o] = out[o + 1] = out[o + 2] = line[s];
|
|
95
|
+
out[o + 3] = colorType === 4 ? line[s + 1] : 255;
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
const idx = line[s];
|
|
99
|
+
const pal = palette ?? Buffer.alloc(0);
|
|
100
|
+
out[o] = pal[idx * 3] ?? 0;
|
|
101
|
+
out[o + 1] = pal[idx * 3 + 1] ?? 0;
|
|
102
|
+
out[o + 2] = pal[idx * 3 + 2] ?? 0;
|
|
103
|
+
out[o + 3] = trns && idx < trns.length ? trns[idx] : 255;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
prev = line;
|
|
107
|
+
}
|
|
108
|
+
return { width, height, data: out };
|
|
109
|
+
}
|
|
110
|
+
let crcTable;
|
|
111
|
+
function crc32(buf) {
|
|
112
|
+
if (!crcTable) {
|
|
113
|
+
crcTable = new Uint32Array(256);
|
|
114
|
+
for (let n = 0; n < 256; n++) {
|
|
115
|
+
let c = n;
|
|
116
|
+
for (let k = 0; k < 8; k++)
|
|
117
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
118
|
+
crcTable[n] = c >>> 0;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
let crc = 0xffffffff;
|
|
122
|
+
for (const byte of buf)
|
|
123
|
+
crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
|
124
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
125
|
+
}
|
|
126
|
+
export function encodePng(img) {
|
|
127
|
+
const { width, height, data } = img;
|
|
128
|
+
const rowLen = width * 4 + 1;
|
|
129
|
+
const raw = Buffer.alloc(rowLen * height);
|
|
130
|
+
for (let y = 0; y < height; y++) {
|
|
131
|
+
raw[y * rowLen] = 0;
|
|
132
|
+
data.copy(raw, y * rowLen + 1, y * width * 4, (y + 1) * width * 4);
|
|
133
|
+
}
|
|
134
|
+
const chunk = (type, body) => {
|
|
135
|
+
const len = Buffer.alloc(4);
|
|
136
|
+
len.writeUInt32BE(body.length, 0);
|
|
137
|
+
const typed = Buffer.concat([Buffer.from(type, "ascii"), body]);
|
|
138
|
+
const crc = Buffer.alloc(4);
|
|
139
|
+
crc.writeUInt32BE(crc32(typed), 0);
|
|
140
|
+
return Buffer.concat([len, typed, crc]);
|
|
141
|
+
};
|
|
142
|
+
const ihdr = Buffer.alloc(13);
|
|
143
|
+
ihdr.writeUInt32BE(width, 0);
|
|
144
|
+
ihdr.writeUInt32BE(height, 4);
|
|
145
|
+
ihdr[8] = 8;
|
|
146
|
+
ihdr[9] = 6;
|
|
147
|
+
return Buffer.concat([PNG_SIGNATURE, chunk("IHDR", ihdr), chunk("IDAT", deflateSync(raw)), chunk("IEND", Buffer.alloc(0))]);
|
|
148
|
+
}
|
|
149
|
+
export function dibFromRgba(img) {
|
|
150
|
+
const { width, height, data } = img;
|
|
151
|
+
const maskStride = Math.ceil(width / 32) * 4;
|
|
152
|
+
const header = Buffer.alloc(40);
|
|
153
|
+
header.writeUInt32LE(40, 0);
|
|
154
|
+
header.writeInt32LE(width, 4);
|
|
155
|
+
header.writeInt32LE(height * 2, 8);
|
|
156
|
+
header.writeUInt16LE(1, 12);
|
|
157
|
+
header.writeUInt16LE(32, 14);
|
|
158
|
+
header.writeUInt32LE(0, 16);
|
|
159
|
+
header.writeUInt32LE(width * height * 4 + maskStride * height, 20);
|
|
160
|
+
const pixels = Buffer.alloc(width * height * 4);
|
|
161
|
+
const mask = Buffer.alloc(maskStride * height);
|
|
162
|
+
for (let y = 0; y < height; y++) {
|
|
163
|
+
const srcRow = height - 1 - y;
|
|
164
|
+
for (let x = 0; x < width; x++) {
|
|
165
|
+
const s = (srcRow * width + x) * 4;
|
|
166
|
+
const d = (y * width + x) * 4;
|
|
167
|
+
pixels[d] = data[s + 2];
|
|
168
|
+
pixels[d + 1] = data[s + 1];
|
|
169
|
+
pixels[d + 2] = data[s];
|
|
170
|
+
pixels[d + 3] = data[s + 3];
|
|
171
|
+
if (data[s + 3] < 128)
|
|
172
|
+
mask[y * maskStride + (x >> 3)] |= 0x80 >> (x & 7);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return Buffer.concat([header, pixels, mask]);
|
|
176
|
+
}
|
|
177
|
+
export function packIco(icons) {
|
|
178
|
+
if (!icons.length)
|
|
179
|
+
throw new Error("no icons");
|
|
180
|
+
const sorted = [...icons].sort((a, b) => a.size - b.size);
|
|
181
|
+
for (const icon of sorted)
|
|
182
|
+
assertPng(icon);
|
|
183
|
+
const payloads = sorted.map((icon) => (icon.size >= 256 ? icon.data : dibFromRgba(decodePng(icon.data))));
|
|
184
|
+
const header = Buffer.alloc(6);
|
|
185
|
+
header.writeUInt16LE(0, 0);
|
|
186
|
+
header.writeUInt16LE(1, 2);
|
|
187
|
+
header.writeUInt16LE(sorted.length, 4);
|
|
188
|
+
const entries = [];
|
|
189
|
+
let offset = 6 + 16 * sorted.length;
|
|
190
|
+
sorted.forEach((icon, i) => {
|
|
191
|
+
const entry = Buffer.alloc(16);
|
|
192
|
+
entry.writeUInt8(icon.size >= 256 ? 0 : icon.size, 0);
|
|
193
|
+
entry.writeUInt8(icon.size >= 256 ? 0 : icon.size, 1);
|
|
194
|
+
entry.writeUInt8(0, 2);
|
|
195
|
+
entry.writeUInt8(0, 3);
|
|
196
|
+
entry.writeUInt16LE(1, 4);
|
|
197
|
+
entry.writeUInt16LE(32, 6);
|
|
198
|
+
entry.writeUInt32LE(payloads[i].length, 8);
|
|
199
|
+
entry.writeUInt32LE(offset, 12);
|
|
200
|
+
entries.push(entry);
|
|
201
|
+
offset += payloads[i].length;
|
|
202
|
+
});
|
|
203
|
+
return Buffer.concat([header, ...entries, ...payloads]);
|
|
204
|
+
}
|
|
205
|
+
const ICNS_TYPES = { 16: "icp4", 32: "icp5", 64: "icp6", 128: "ic07", 256: "ic08", 512: "ic09", 1024: "ic10" };
|
|
206
|
+
export function packIcns(icons) {
|
|
207
|
+
const chunks = [];
|
|
208
|
+
for (const icon of [...icons].sort((a, b) => a.size - b.size)) {
|
|
209
|
+
const type = ICNS_TYPES[icon.size];
|
|
210
|
+
if (!type)
|
|
211
|
+
continue;
|
|
212
|
+
assertPng(icon);
|
|
213
|
+
const head = Buffer.alloc(8);
|
|
214
|
+
head.write(type, 0, 4, "ascii");
|
|
215
|
+
head.writeUInt32BE(icon.data.length + 8, 4);
|
|
216
|
+
chunks.push(head, icon.data);
|
|
217
|
+
}
|
|
218
|
+
if (!chunks.length)
|
|
219
|
+
throw new Error("no icon sizes usable for icns (16, 32, 64, 128, 256, 512, 1024)");
|
|
220
|
+
const total = chunks.reduce((n, c) => n + c.length, 0) + 8;
|
|
221
|
+
const header = Buffer.alloc(8);
|
|
222
|
+
header.write("icns", 0, 4, "ascii");
|
|
223
|
+
header.writeUInt32BE(total, 4);
|
|
224
|
+
return Buffer.concat([header, ...chunks]);
|
|
225
|
+
}
|
|
226
|
+
export function readIcoDirectory(ico) {
|
|
227
|
+
const count = ico.readUInt16LE(4);
|
|
228
|
+
const out = [];
|
|
229
|
+
for (let i = 0; i < count; i++) {
|
|
230
|
+
const at = 6 + i * 16;
|
|
231
|
+
const w = ico.readUInt8(at);
|
|
232
|
+
out.push({ size: w === 0 ? 256 : w, bytes: ico.readUInt32LE(at + 8), offset: ico.readUInt32LE(at + 12) });
|
|
233
|
+
}
|
|
234
|
+
return out;
|
|
235
|
+
}
|
package/dist/jsonrpc.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
export class MethodNotFound extends Error {
|
|
4
|
+
method;
|
|
5
|
+
constructor(method) {
|
|
6
|
+
super(`Method not found: ${method}`);
|
|
7
|
+
this.method = method;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export class RemoteError extends Error {
|
|
11
|
+
rpc;
|
|
12
|
+
method;
|
|
13
|
+
constructor(rpc, method) {
|
|
14
|
+
super(`${method} failed: ${rpc.message} (code ${rpc.code})`);
|
|
15
|
+
this.rpc = rpc;
|
|
16
|
+
this.method = method;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export class JsonRpcPeer {
|
|
20
|
+
input;
|
|
21
|
+
handlers;
|
|
22
|
+
nextId = 1;
|
|
23
|
+
pending = new Map();
|
|
24
|
+
closed = false;
|
|
25
|
+
constructor(input, output, handlers) {
|
|
26
|
+
this.input = input;
|
|
27
|
+
this.handlers = handlers;
|
|
28
|
+
const rl = createInterface({ input: output });
|
|
29
|
+
rl.on("line", (line) => {
|
|
30
|
+
void this.onLine(line);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
request(method, params) {
|
|
34
|
+
if (this.closed)
|
|
35
|
+
return Promise.reject(new Error(`connection closed (${method})`));
|
|
36
|
+
const id = this.nextId++;
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
this.pending.set(id, { method, resolve, reject });
|
|
39
|
+
this.write({ jsonrpc: "2.0", id, method, params });
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
notify(method, params) {
|
|
43
|
+
this.write({ jsonrpc: "2.0", method, params });
|
|
44
|
+
}
|
|
45
|
+
close(reason) {
|
|
46
|
+
if (this.closed)
|
|
47
|
+
return;
|
|
48
|
+
this.closed = true;
|
|
49
|
+
for (const [id, p] of this.pending) {
|
|
50
|
+
this.pending.delete(id);
|
|
51
|
+
p.reject(new Error(`${p.method}: ${reason}`));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
get isClosed() {
|
|
55
|
+
return this.closed;
|
|
56
|
+
}
|
|
57
|
+
write(message) {
|
|
58
|
+
if (this.closed)
|
|
59
|
+
return;
|
|
60
|
+
this.handlers.onRaw?.("out", message);
|
|
61
|
+
this.input.write(JSON.stringify(message) + "\n");
|
|
62
|
+
}
|
|
63
|
+
async onLine(line) {
|
|
64
|
+
const trimmed = line.trim();
|
|
65
|
+
if (!trimmed)
|
|
66
|
+
return;
|
|
67
|
+
let msg;
|
|
68
|
+
try {
|
|
69
|
+
msg = JSON.parse(trimmed);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
this.handlers.onProtocolError?.(`non-JSON line on stdout: ${trimmed.slice(0, 200)}`);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
this.handlers.onRaw?.("in", msg);
|
|
76
|
+
if ("method" in msg) {
|
|
77
|
+
const hasId = "id" in msg && msg.id !== undefined && msg.id !== null;
|
|
78
|
+
if (hasId) {
|
|
79
|
+
const id = msg.id;
|
|
80
|
+
try {
|
|
81
|
+
const result = await this.handlers.onRequest(msg.method, msg.params);
|
|
82
|
+
this.write({ jsonrpc: "2.0", id, result: result ?? null });
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
const rpcError = error instanceof MethodNotFound
|
|
86
|
+
? { code: -32601, message: error.message }
|
|
87
|
+
: { code: -32603, message: error instanceof Error ? error.message : String(error) };
|
|
88
|
+
this.write({ jsonrpc: "2.0", id, error: rpcError });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
this.handlers.onNotification(msg.method, msg.params);
|
|
93
|
+
}
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if ("id" in msg) {
|
|
97
|
+
const p = this.pending.get(msg.id);
|
|
98
|
+
if (!p) {
|
|
99
|
+
this.handlers.onProtocolError?.(`response for unknown id ${String(msg.id)}`);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
this.pending.delete(msg.id);
|
|
103
|
+
if (msg.error)
|
|
104
|
+
p.reject(new RemoteError(msg.error, p.method));
|
|
105
|
+
else
|
|
106
|
+
p.resolve(msg.result);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|