@hienlh/ppm 0.9.0-beta.8 → 0.9.1
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/CHANGELOG.md +238 -0
- package/bun.lock +17 -0
- package/dist/web/assets/api-settings-BUvk6Saw.js +1 -0
- package/dist/web/assets/arrow-up-BYhx9ckd.js +1 -0
- package/dist/web/assets/browser-tab-CrkhFCaw.js +1 -0
- package/dist/web/assets/chat-tab-C6jpiwh7.js +8 -0
- package/dist/web/assets/chevron-right-5HgK6l7K.js +1 -0
- package/dist/web/assets/code-editor-CBIPzlP2.js +2 -0
- package/dist/web/assets/columns-2-cEVJHYd7.js +1 -0
- package/dist/web/assets/createLucideIcon-PuMiQgHl.js +1 -0
- package/dist/web/assets/{csv-preview-DLqYtXxt.js → csv-preview-ncSOnJSC.js} +2 -2
- package/dist/web/assets/database-viewer-BqOJR_zi.js +1 -0
- package/dist/web/assets/diff-viewer-CcLyp4eY.js +4 -0
- package/dist/web/assets/{dist-CALwEtco.js → dist-DIV6WgAG.js} +1 -1
- package/dist/web/assets/{dist-DGDPTxs1.js → dist-ovWkrgO-.js} +1 -1
- package/dist/web/assets/extension-webview-NiZ7Ybvv.js +3 -0
- package/dist/web/assets/git-graph-CoTvMrIo.js +1 -0
- package/dist/web/assets/index-C8byznLO.js +37 -0
- package/dist/web/assets/index-KwC2YrG4.css +2 -0
- package/dist/web/assets/jsx-runtime-kMwlnEGE.js +1 -0
- package/dist/web/assets/keybindings-store-DPYzBe_M.js +1 -0
- package/dist/web/assets/{markdown-renderer-DklUd_Gv.js → markdown-renderer-DPLdR9xc.js} +4 -4
- package/dist/web/assets/postgres-viewer-BeiK4lCa.js +1 -0
- package/dist/web/assets/settings-tab-D3AvU4lu.js +1 -0
- package/dist/web/assets/sqlite-viewer-nA2sD4Yv.js +1 -0
- package/dist/web/assets/tab-store-BOgTrqRr.js +1 -0
- package/dist/web/assets/table-DFevCOMd.js +1 -0
- package/dist/web/assets/tag-CXMT0QB6.js +1 -0
- package/dist/web/assets/{terminal-tab-CqRuiIFn.js → terminal-tab-BBi0pEji.js} +2 -2
- package/dist/web/assets/{use-monaco-theme-Dcz3aLAE.js → use-monaco-theme-B5pG2d1w.js} +1 -1
- package/dist/web/index.html +8 -8
- package/dist/web/monacoeditorwork/css.worker.bundle.js +122 -122
- package/dist/web/monacoeditorwork/editor.worker.bundle.js +78 -78
- package/dist/web/monacoeditorwork/html.worker.bundle.js +110 -110
- package/dist/web/monacoeditorwork/json.worker.bundle.js +108 -108
- package/dist/web/monacoeditorwork/ts.worker.bundle.js +81 -81
- package/dist/web/sw.js +1 -1
- package/docs/code-standards.md +128 -1
- package/docs/codebase-summary.md +79 -12
- package/docs/extension-development-guide.md +532 -0
- package/docs/project-changelog.md +51 -1
- package/docs/project-roadmap.md +9 -3
- package/docs/streaming-input-guide.md +267 -0
- package/docs/system-architecture.md +432 -3
- package/package.json +6 -3
- package/packages/ext-database/package.json +41 -0
- package/packages/ext-database/src/connection-tree.ts +142 -0
- package/packages/ext-database/src/extension.ts +346 -0
- package/packages/ext-database/src/query-panel.ts +120 -0
- package/packages/ext-database/src/table-viewer-panel.ts +410 -0
- package/packages/ext-database/tsconfig.json +8 -0
- package/packages/vscode-compat/package.json +16 -0
- package/packages/vscode-compat/src/commands.ts +39 -0
- package/packages/vscode-compat/src/context.ts +65 -0
- package/packages/vscode-compat/src/disposable.ts +21 -0
- package/packages/vscode-compat/src/env.ts +20 -0
- package/packages/vscode-compat/src/event-emitter.ts +28 -0
- package/packages/vscode-compat/src/index.ts +93 -0
- package/packages/vscode-compat/src/not-supported.ts +15 -0
- package/packages/vscode-compat/src/types.ts +167 -0
- package/packages/vscode-compat/src/uri.ts +65 -0
- package/packages/vscode-compat/src/window.ts +229 -0
- package/packages/vscode-compat/src/workspace.ts +76 -0
- package/packages/vscode-compat/tsconfig.json +10 -0
- package/snapshot-state.md +1526 -0
- package/src/cli/commands/autostart.ts +1 -1
- package/src/cli/commands/ext-cmd.ts +121 -0
- package/src/cli/commands/restart.ts +9 -1
- package/src/cli/commands/status.ts +19 -0
- package/src/index.ts +5 -3
- package/src/providers/claude-agent-sdk.ts +221 -17
- package/src/providers/cli-provider-base.ts +6 -0
- package/src/server/index.ts +55 -155
- package/src/server/routes/chat.ts +81 -11
- package/src/server/routes/extensions.ts +81 -0
- package/src/server/routes/project-scoped.ts +2 -0
- package/src/server/routes/settings.ts +27 -0
- package/src/server/routes/workspace.ts +35 -0
- package/src/server/ws/chat.ts +9 -3
- package/src/server/ws/extensions.ts +175 -0
- package/src/services/account-selector.service.ts +14 -5
- package/src/services/account.service.ts +20 -15
- package/src/services/claude-usage.service.ts +29 -24
- package/src/services/cloud-ws.service.ts +228 -0
- package/src/services/cloud.service.ts +11 -6
- package/src/services/contribution-registry.ts +110 -0
- package/src/services/db.service.ts +181 -4
- package/src/services/extension-host-worker.ts +160 -0
- package/src/services/extension-installer.ts +112 -0
- package/src/services/extension-manifest.ts +65 -0
- package/src/services/extension-rpc-handlers.ts +235 -0
- package/src/services/extension-rpc.ts +105 -0
- package/src/services/extension.service.ts +228 -0
- package/src/services/mcp-config.service.ts +15 -6
- package/src/services/supervisor.ts +271 -25
- package/src/types/api.ts +1 -0
- package/src/types/chat.ts +4 -0
- package/src/types/extension-messages.ts +64 -0
- package/src/types/extension.ts +131 -0
- package/src/web/app.tsx +69 -48
- package/src/web/components/chat/account-rotation-settings.tsx +163 -0
- package/src/web/components/chat/chat-history-bar.tsx +106 -10
- package/src/web/components/chat/chat-tab.tsx +15 -10
- package/src/web/components/chat/chat-welcome.tsx +148 -0
- package/src/web/components/chat/message-list.tsx +19 -6
- package/src/web/components/chat/session-picker.tsx +80 -32
- package/src/web/components/chat/usage-badge.tsx +68 -8
- package/src/web/components/editor/editor-breadcrumb.tsx +20 -29
- package/src/web/components/extensions/extension-inputbox.tsx +92 -0
- package/src/web/components/extensions/extension-quickpick.tsx +194 -0
- package/src/web/components/extensions/extension-tree-view.tsx +240 -0
- package/src/web/components/extensions/extension-webview.tsx +83 -0
- package/src/web/components/layout/command-palette.tsx +22 -2
- package/src/web/components/layout/editor-panel.tsx +163 -18
- package/src/web/components/layout/mobile-nav.tsx +2 -1
- package/src/web/components/layout/sidebar.tsx +21 -3
- package/src/web/components/layout/status-bar.tsx +64 -0
- package/src/web/components/layout/tab-bar.tsx +2 -0
- package/src/web/components/layout/tab-content.tsx +5 -0
- package/src/web/components/layout/upgrade-banner.tsx +15 -5
- package/src/web/components/settings/change-password-section.tsx +128 -0
- package/src/web/components/settings/extension-manager-section.tsx +214 -0
- package/src/web/components/settings/settings-tab.tsx +9 -2
- package/src/web/components/shared/connection-lost-overlay.tsx +89 -0
- package/src/web/hooks/use-chat.ts +28 -0
- package/src/web/hooks/use-extension-ws.ts +181 -0
- package/src/web/hooks/use-global-keybindings.ts +18 -2
- package/src/web/hooks/use-server-reload.ts +9 -0
- package/src/web/hooks/use-url-sync.ts +173 -21
- package/src/web/stores/connection-store.ts +39 -0
- package/src/web/stores/extension-store.ts +204 -0
- package/src/web/stores/panel-store.ts +63 -9
- package/src/web/stores/panel-utils.ts +145 -3
- package/src/web/stores/settings-store.ts +7 -2
- package/src/web/stores/tab-store.ts +2 -1
- package/test-session-ops.mjs +444 -0
- package/test-tokens.mjs +212 -0
- package/tsconfig.json +3 -1
- package/dist/web/assets/api-settings-D21InCnR.js +0 -1
- package/dist/web/assets/arrow-up--LjUXLEt.js +0 -1
- package/dist/web/assets/browser-tab-BEe89aSD.js +0 -1
- package/dist/web/assets/chat-tab-9lqvWozA.js +0 -7
- package/dist/web/assets/chevron-right-CHnjJt4E.js +0 -1
- package/dist/web/assets/code-editor-COAIZx-B.js +0 -2
- package/dist/web/assets/columns-2-DbesTfa7.js +0 -1
- package/dist/web/assets/database-viewer-aRR9n_Ui.js +0 -1
- package/dist/web/assets/diff-viewer-C4KMvpHr.js +0 -4
- package/dist/web/assets/dist-CVTST7Gc.js +0 -1
- package/dist/web/assets/git-graph-CfJjl4E3.js +0 -1
- package/dist/web/assets/index-Db8uky1a.css +0 -2
- package/dist/web/assets/index-DxZuwBDe.js +0 -37
- package/dist/web/assets/jsx-runtime-BRW_vwa9.js +0 -1
- package/dist/web/assets/keybindings-store-_uWVCZMv.js +0 -1
- package/dist/web/assets/postgres-viewer-DEAvAyaX.js +0 -1
- package/dist/web/assets/settings-tab-BQedc-No.js +0 -1
- package/dist/web/assets/sqlite-viewer-BPA5idzT.js +0 -1
- package/dist/web/assets/tab-store-DhK6EpBT.js +0 -1
- package/dist/web/assets/table-CQVQM2SB.js +0 -1
- package/dist/web/assets/tag-Q2dZiSPX.js +0 -1
|
@@ -354,6 +354,7 @@ export async function sendHeartbeat(tunnelUrl: string): Promise<boolean> {
|
|
|
354
354
|
secret_key: device.secret_key,
|
|
355
355
|
tunnel_url: tunnelUrl,
|
|
356
356
|
status: "online",
|
|
357
|
+
name: device.name,
|
|
357
358
|
}),
|
|
358
359
|
});
|
|
359
360
|
return res.ok;
|
|
@@ -362,12 +363,16 @@ export async function sendHeartbeat(tunnelUrl: string): Promise<boolean> {
|
|
|
362
363
|
}
|
|
363
364
|
}
|
|
364
365
|
|
|
365
|
-
|
|
366
|
+
// Survive Bun --hot reloads: persist timer ref across module re-evaluations
|
|
367
|
+
const CLOUD_HOT_KEY = "__PPM_CLOUD_HEARTBEAT__" as const;
|
|
368
|
+
const cloudHotState = ((globalThis as any)[CLOUD_HOT_KEY] ??= {
|
|
369
|
+
heartbeatTimer: null as ReturnType<typeof setInterval> | null,
|
|
370
|
+
}) as { heartbeatTimer: ReturnType<typeof setInterval> | null };
|
|
366
371
|
|
|
367
372
|
/** Start periodic heartbeat (call once after tunnel URL is obtained) */
|
|
368
373
|
export function startHeartbeat(tunnelUrl: string): void {
|
|
369
374
|
// Clear any existing heartbeat to prevent duplicates on restart
|
|
370
|
-
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
375
|
+
if (cloudHotState.heartbeatTimer) clearInterval(cloudHotState.heartbeatTimer);
|
|
371
376
|
|
|
372
377
|
// Initial heartbeat immediately
|
|
373
378
|
sendHeartbeat(tunnelUrl).then((ok) => {
|
|
@@ -376,16 +381,16 @@ export function startHeartbeat(tunnelUrl: string): void {
|
|
|
376
381
|
});
|
|
377
382
|
|
|
378
383
|
// Periodic heartbeat every 5 minutes
|
|
379
|
-
heartbeatTimer = setInterval(() => {
|
|
384
|
+
cloudHotState.heartbeatTimer = setInterval(() => {
|
|
380
385
|
sendHeartbeat(tunnelUrl).catch(() => {});
|
|
381
386
|
}, HEARTBEAT_INTERVAL_MS);
|
|
382
387
|
}
|
|
383
388
|
|
|
384
389
|
/** Stop periodic heartbeat */
|
|
385
390
|
export function stopHeartbeat(): void {
|
|
386
|
-
if (heartbeatTimer) {
|
|
387
|
-
clearInterval(heartbeatTimer);
|
|
388
|
-
heartbeatTimer = null;
|
|
391
|
+
if (cloudHotState.heartbeatTimer) {
|
|
392
|
+
clearInterval(cloudHotState.heartbeatTimer);
|
|
393
|
+
cloudHotState.heartbeatTimer = null;
|
|
389
394
|
}
|
|
390
395
|
}
|
|
391
396
|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { ExtensionContributes, ContributedCommand, ContributedView, ContributedMenu } from "../types/extension.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* In-memory registry of all contribution points from enabled extensions.
|
|
5
|
+
* Populated when extensions activate, cleared when they deactivate.
|
|
6
|
+
*/
|
|
7
|
+
class ContributionRegistry {
|
|
8
|
+
private commands = new Map<string, ContributedCommand & { extId: string }>();
|
|
9
|
+
private views = new Map<string, Map<string, ContributedView & { extId: string }>>();
|
|
10
|
+
private configs = new Map<string, Record<string, unknown>>();
|
|
11
|
+
private menus = new Map<string, Array<ContributedMenu & { extId: string }>>();
|
|
12
|
+
|
|
13
|
+
register(extId: string, contributes: ExtensionContributes): void {
|
|
14
|
+
if (contributes.commands) {
|
|
15
|
+
for (const cmd of contributes.commands) {
|
|
16
|
+
this.commands.set(cmd.command, { ...cmd, extId });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
if (contributes.views) {
|
|
20
|
+
for (const [location, views] of Object.entries(contributes.views)) {
|
|
21
|
+
if (!this.views.has(location)) this.views.set(location, new Map());
|
|
22
|
+
const locationMap = this.views.get(location)!;
|
|
23
|
+
for (const view of views) {
|
|
24
|
+
locationMap.set(view.id, { ...view, extId });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (contributes.configuration?.properties) {
|
|
29
|
+
this.configs.set(extId, contributes.configuration.properties);
|
|
30
|
+
}
|
|
31
|
+
if (contributes.menus) {
|
|
32
|
+
for (const [location, items] of Object.entries(contributes.menus)) {
|
|
33
|
+
if (!this.menus.has(location)) this.menus.set(location, []);
|
|
34
|
+
const list = this.menus.get(location)!;
|
|
35
|
+
for (const item of items) {
|
|
36
|
+
list.push({ ...item, extId });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
unregister(extId: string): void {
|
|
43
|
+
for (const [key, cmd] of this.commands) {
|
|
44
|
+
if (cmd.extId === extId) this.commands.delete(key);
|
|
45
|
+
}
|
|
46
|
+
for (const [, locationMap] of this.views) {
|
|
47
|
+
for (const [key, view] of locationMap) {
|
|
48
|
+
if (view.extId === extId) locationMap.delete(key);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
for (const [location, items] of this.menus) {
|
|
52
|
+
this.menus.set(location, items.filter((m) => m.extId !== extId));
|
|
53
|
+
}
|
|
54
|
+
this.configs.delete(extId);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
getCommands(): Array<ContributedCommand & { extId: string }> {
|
|
58
|
+
return [...this.commands.values()];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
getViews(location?: string): Array<ContributedView & { extId: string }> {
|
|
62
|
+
if (location) {
|
|
63
|
+
return [...(this.views.get(location)?.values() ?? [])];
|
|
64
|
+
}
|
|
65
|
+
const all: Array<ContributedView & { extId: string }> = [];
|
|
66
|
+
for (const locationMap of this.views.values()) {
|
|
67
|
+
all.push(...locationMap.values());
|
|
68
|
+
}
|
|
69
|
+
return all;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
getViewLocations(): string[] {
|
|
73
|
+
return [...this.views.keys()];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
getConfiguration(extId?: string): Record<string, Record<string, unknown>> {
|
|
77
|
+
if (extId) {
|
|
78
|
+
const cfg = this.configs.get(extId);
|
|
79
|
+
return cfg ? { [extId]: cfg } : {};
|
|
80
|
+
}
|
|
81
|
+
return Object.fromEntries(this.configs);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Get all contributions as a single object (for API responses) */
|
|
85
|
+
getAll() {
|
|
86
|
+
const viewsByLocation: Record<string, Array<ContributedView & { extId: string }>> = {};
|
|
87
|
+
for (const location of this.views.keys()) {
|
|
88
|
+
viewsByLocation[location] = this.getViews(location);
|
|
89
|
+
}
|
|
90
|
+
const menusByLocation: Record<string, Array<ContributedMenu & { extId: string }>> = {};
|
|
91
|
+
for (const [location, items] of this.menus) {
|
|
92
|
+
menusByLocation[location] = items;
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
commands: this.getCommands(),
|
|
96
|
+
views: viewsByLocation,
|
|
97
|
+
menus: menusByLocation,
|
|
98
|
+
configuration: this.getConfiguration(),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
clear(): void {
|
|
103
|
+
this.commands.clear();
|
|
104
|
+
this.views.clear();
|
|
105
|
+
this.configs.clear();
|
|
106
|
+
this.menus.clear();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const contributionRegistry = new ContributionRegistry();
|
|
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
|
|
|
4
4
|
import { mkdirSync, existsSync } from "node:fs";
|
|
5
5
|
|
|
6
6
|
const PPM_DIR = process.env.PPM_HOME || resolve(homedir(), ".ppm");
|
|
7
|
-
const CURRENT_SCHEMA_VERSION =
|
|
7
|
+
const CURRENT_SCHEMA_VERSION = 12;
|
|
8
8
|
|
|
9
9
|
let db: Database | null = null;
|
|
10
10
|
let dbProfile: string | null = null;
|
|
@@ -248,6 +248,95 @@ function runMigrations(database: Database): void {
|
|
|
248
248
|
PRAGMA user_version = 8;
|
|
249
249
|
`);
|
|
250
250
|
}
|
|
251
|
+
|
|
252
|
+
if (current < 9) {
|
|
253
|
+
database.exec(`
|
|
254
|
+
CREATE TABLE IF NOT EXISTS session_pins (
|
|
255
|
+
session_id TEXT PRIMARY KEY,
|
|
256
|
+
pinned_at TEXT DEFAULT (datetime('now'))
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
PRAGMA user_version = 9;
|
|
260
|
+
`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (current < 10) {
|
|
264
|
+
database.exec(`
|
|
265
|
+
CREATE TABLE IF NOT EXISTS workspace_state (
|
|
266
|
+
project_name TEXT PRIMARY KEY,
|
|
267
|
+
layout_json TEXT NOT NULL,
|
|
268
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
PRAGMA user_version = 10;
|
|
272
|
+
`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (current < 11) {
|
|
276
|
+
try {
|
|
277
|
+
database.exec(`ALTER TABLE session_map ADD COLUMN project_path TEXT`);
|
|
278
|
+
} catch {
|
|
279
|
+
// Column may already exist
|
|
280
|
+
}
|
|
281
|
+
// Backfill project_path from projects table where project_name matches
|
|
282
|
+
database.exec(`
|
|
283
|
+
UPDATE session_map SET project_path = (
|
|
284
|
+
SELECT path FROM projects WHERE projects.name = session_map.project_name
|
|
285
|
+
) WHERE project_path IS NULL AND project_name IS NOT NULL
|
|
286
|
+
`);
|
|
287
|
+
database.exec(`PRAGMA user_version = 11`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (current < 12) {
|
|
291
|
+
database.exec(`
|
|
292
|
+
CREATE TABLE IF NOT EXISTS extensions (
|
|
293
|
+
id TEXT PRIMARY KEY,
|
|
294
|
+
version TEXT NOT NULL,
|
|
295
|
+
display_name TEXT,
|
|
296
|
+
description TEXT,
|
|
297
|
+
icon TEXT,
|
|
298
|
+
enabled INTEGER DEFAULT 1,
|
|
299
|
+
manifest TEXT NOT NULL,
|
|
300
|
+
installed_at TEXT DEFAULT (datetime('now')),
|
|
301
|
+
updated_at TEXT DEFAULT (datetime('now'))
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
CREATE TABLE IF NOT EXISTS extension_storage (
|
|
305
|
+
ext_id TEXT NOT NULL,
|
|
306
|
+
scope TEXT NOT NULL,
|
|
307
|
+
key TEXT NOT NULL,
|
|
308
|
+
value TEXT,
|
|
309
|
+
PRIMARY KEY (ext_id, scope, key),
|
|
310
|
+
FOREIGN KEY (ext_id) REFERENCES extensions(id) ON DELETE CASCADE
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
PRAGMA user_version = 12;
|
|
314
|
+
`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
// Workspace helpers
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
export interface WorkspaceRow {
|
|
323
|
+
project_name: string;
|
|
324
|
+
layout_json: string;
|
|
325
|
+
updated_at: string;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function getWorkspace(projectName: string): WorkspaceRow | null {
|
|
329
|
+
return getDb().query(
|
|
330
|
+
"SELECT project_name, layout_json, updated_at FROM workspace_state WHERE project_name = ?",
|
|
331
|
+
).get(projectName) as WorkspaceRow | null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function setWorkspace(projectName: string, layoutJson: string): string {
|
|
335
|
+
const now = new Date().toISOString();
|
|
336
|
+
getDb().query(
|
|
337
|
+
"INSERT INTO workspace_state (project_name, layout_json, updated_at) VALUES (?, ?, ?) ON CONFLICT(project_name) DO UPDATE SET layout_json = excluded.layout_json, updated_at = excluded.updated_at",
|
|
338
|
+
).run(projectName, layoutJson, now);
|
|
339
|
+
return now;
|
|
251
340
|
}
|
|
252
341
|
|
|
253
342
|
// ---------------------------------------------------------------------------
|
|
@@ -318,10 +407,15 @@ export function getSessionMapping(ppmId: string): string | null {
|
|
|
318
407
|
return row?.sdk_id ?? null;
|
|
319
408
|
}
|
|
320
409
|
|
|
321
|
-
export function
|
|
410
|
+
export function getSessionProjectPath(ppmId: string): string | null {
|
|
411
|
+
const row = getDb().query("SELECT project_path FROM session_map WHERE ppm_id = ?").get(ppmId) as { project_path: string } | null;
|
|
412
|
+
return row?.project_path ?? null;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export function setSessionMapping(ppmId: string, sdkId: string, projectName?: string, projectPath?: string): void {
|
|
322
416
|
getDb().query(
|
|
323
|
-
"INSERT INTO session_map (ppm_id, sdk_id, project_name) VALUES (?, ?, ?) ON CONFLICT(ppm_id) DO UPDATE SET sdk_id = excluded.sdk_id, project_name = excluded.project_name",
|
|
324
|
-
).run(ppmId, sdkId, projectName ?? null);
|
|
417
|
+
"INSERT INTO session_map (ppm_id, sdk_id, project_name, project_path) VALUES (?, ?, ?, ?) ON CONFLICT(ppm_id) DO UPDATE SET sdk_id = excluded.sdk_id, project_name = COALESCE(excluded.project_name, session_map.project_name), project_path = COALESCE(excluded.project_path, session_map.project_path)",
|
|
418
|
+
).run(ppmId, sdkId, projectName ?? null, projectPath ?? null);
|
|
325
419
|
}
|
|
326
420
|
|
|
327
421
|
export function getAllSessionMappings(): Record<string, string> {
|
|
@@ -358,6 +452,33 @@ export function getSessionTitles(sessionIds: string[]): Record<string, string> {
|
|
|
358
452
|
return result;
|
|
359
453
|
}
|
|
360
454
|
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
// Session pin helpers
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
|
|
459
|
+
export function pinSession(sessionId: string): void {
|
|
460
|
+
getDb().query(
|
|
461
|
+
"INSERT INTO session_pins (session_id, pinned_at) VALUES (?, datetime('now')) ON CONFLICT(session_id) DO UPDATE SET pinned_at = datetime('now')",
|
|
462
|
+
).run(sessionId);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export function unpinSession(sessionId: string): void {
|
|
466
|
+
getDb().query("DELETE FROM session_pins WHERE session_id = ?").run(sessionId);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export function getPinnedSessionIds(): Set<string> {
|
|
470
|
+
const rows = getDb().query("SELECT session_id FROM session_pins ORDER BY pinned_at DESC").all() as { session_id: string }[];
|
|
471
|
+
return new Set(rows.map((r) => r.session_id));
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export function deleteSessionMapping(ppmId: string): void {
|
|
475
|
+
getDb().query("DELETE FROM session_map WHERE ppm_id = ?").run(ppmId);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export function deleteSessionTitle(sessionId: string): void {
|
|
479
|
+
getDb().query("DELETE FROM session_titles WHERE session_id = ?").run(sessionId);
|
|
480
|
+
}
|
|
481
|
+
|
|
361
482
|
// ---------------------------------------------------------------------------
|
|
362
483
|
// Push subscription helpers
|
|
363
484
|
// ---------------------------------------------------------------------------
|
|
@@ -717,5 +838,61 @@ export function incrementAccountRequests(id: string): void {
|
|
|
717
838
|
getDb().query("UPDATE accounts SET total_requests = total_requests + 1 WHERE id = ?").run(id);
|
|
718
839
|
}
|
|
719
840
|
|
|
841
|
+
// ---------------------------------------------------------------------------
|
|
842
|
+
// Extension helpers
|
|
843
|
+
// ---------------------------------------------------------------------------
|
|
844
|
+
|
|
845
|
+
import type { ExtensionRow, ExtensionStorageRow } from "../types/extension.ts";
|
|
846
|
+
|
|
847
|
+
export function getExtensions(): ExtensionRow[] {
|
|
848
|
+
return getDb().query("SELECT * FROM extensions ORDER BY display_name, id").all() as ExtensionRow[];
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
export function getExtensionById(id: string): ExtensionRow | null {
|
|
852
|
+
return getDb().query("SELECT * FROM extensions WHERE id = ?").get(id) as ExtensionRow | null;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
export function insertExtension(row: Omit<ExtensionRow, "installed_at" | "updated_at">): void {
|
|
856
|
+
getDb().query(
|
|
857
|
+
`INSERT INTO extensions (id, version, display_name, description, icon, enabled, manifest)
|
|
858
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
859
|
+
).run(row.id, row.version, row.display_name, row.description, row.icon, row.enabled, row.manifest);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
export function updateExtension(id: string, updates: Partial<Pick<ExtensionRow, "version" | "display_name" | "description" | "icon" | "enabled" | "manifest">>): void {
|
|
863
|
+
const sets: string[] = [];
|
|
864
|
+
const vals: unknown[] = [];
|
|
865
|
+
if (updates.version !== undefined) { sets.push("version = ?"); vals.push(updates.version); }
|
|
866
|
+
if (updates.display_name !== undefined) { sets.push("display_name = ?"); vals.push(updates.display_name); }
|
|
867
|
+
if (updates.description !== undefined) { sets.push("description = ?"); vals.push(updates.description); }
|
|
868
|
+
if (updates.icon !== undefined) { sets.push("icon = ?"); vals.push(updates.icon); }
|
|
869
|
+
if (updates.enabled !== undefined) { sets.push("enabled = ?"); vals.push(updates.enabled); }
|
|
870
|
+
if (updates.manifest !== undefined) { sets.push("manifest = ?"); vals.push(updates.manifest); }
|
|
871
|
+
if (sets.length === 0) return;
|
|
872
|
+
sets.push("updated_at = datetime('now')");
|
|
873
|
+
vals.push(id);
|
|
874
|
+
getDb().query(`UPDATE extensions SET ${sets.join(", ")} WHERE id = ?`).run(...(vals as SQLQueryBindings[]));
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
export function deleteExtension(id: string): void {
|
|
878
|
+
getDb().query("DELETE FROM extensions WHERE id = ?").run(id);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
export function getExtensionStorage(extId: string, scope: string): ExtensionStorageRow[] {
|
|
882
|
+
return getDb().query("SELECT * FROM extension_storage WHERE ext_id = ? AND scope = ?").all(extId, scope) as ExtensionStorageRow[];
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
export function setExtensionStorageValue(extId: string, scope: string, key: string, value: string | null): void {
|
|
886
|
+
getDb().query(
|
|
887
|
+
`INSERT INTO extension_storage (ext_id, scope, key, value)
|
|
888
|
+
VALUES (?, ?, ?, ?)
|
|
889
|
+
ON CONFLICT(ext_id, scope, key) DO UPDATE SET value = excluded.value`,
|
|
890
|
+
).run(extId, scope, key, value);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
export function deleteExtensionStorage(extId: string): void {
|
|
894
|
+
getDb().query("DELETE FROM extension_storage WHERE ext_id = ?").run(extId);
|
|
895
|
+
}
|
|
896
|
+
|
|
720
897
|
// Auto-close on process exit
|
|
721
898
|
process.on("beforeExit", closeDb);
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extension Host Worker — runs inside a Bun Worker thread.
|
|
3
|
+
* Loads, activates, and deactivates extensions in isolation.
|
|
4
|
+
* Communicates with the main process via typed RPC (postMessage).
|
|
5
|
+
*/
|
|
6
|
+
import { RpcChannel } from "./extension-rpc.ts";
|
|
7
|
+
import { createVscodeCompat } from "@ppm/vscode-compat";
|
|
8
|
+
import type { WindowService } from "@ppm/vscode-compat/src/window.ts";
|
|
9
|
+
import type { CommandService } from "@ppm/vscode-compat/src/commands.ts";
|
|
10
|
+
import type { Disposable, RpcMessage } from "../types/extension.ts";
|
|
11
|
+
|
|
12
|
+
// Active extension instances: id → { module, context, deactivate, services }
|
|
13
|
+
const activeExtensions = new Map<string, {
|
|
14
|
+
deactivate?: () => void | Promise<void>;
|
|
15
|
+
context: { subscriptions: Disposable[] };
|
|
16
|
+
window?: WindowService;
|
|
17
|
+
commands?: CommandService;
|
|
18
|
+
}>();
|
|
19
|
+
|
|
20
|
+
const rpc = new RpcChannel((msg) => postMessage(msg));
|
|
21
|
+
|
|
22
|
+
// Listen for messages from main process
|
|
23
|
+
declare const self: Worker;
|
|
24
|
+
self.addEventListener("message", (event: MessageEvent<RpcMessage>) => {
|
|
25
|
+
rpc.handleMessage(event.data);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// --- RPC handlers ---
|
|
29
|
+
|
|
30
|
+
rpc.onRequest("ext:activate", async (params) => {
|
|
31
|
+
const [extId, entryPath, extensionPath, storedState, baseUrl] = params as [string, string, string, Record<string, Record<string, string | null>>?, string?];
|
|
32
|
+
if (activeExtensions.has(extId)) return { ok: true, already: true };
|
|
33
|
+
|
|
34
|
+
// Expose server base URL so extensions can use fetch() with absolute URLs
|
|
35
|
+
if (baseUrl) (globalThis as any).__PPM_BASE_URL__ = baseUrl;
|
|
36
|
+
|
|
37
|
+
// Create RpcClient adapter for vscode-compat (Worker's RPC → vscode-compat interface)
|
|
38
|
+
const rpcClient = {
|
|
39
|
+
request: <T = unknown>(method: string, ...p: unknown[]) => rpc.sendRequest<T>(method, ...p),
|
|
40
|
+
notify: (event: string, data: unknown) => rpc.sendEvent(event, data),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Create vscode-compat API scoped to this extension
|
|
44
|
+
const api = createVscodeCompat({
|
|
45
|
+
extensionId: extId,
|
|
46
|
+
extensionPath,
|
|
47
|
+
storagePath: `${extensionPath}/.storage`,
|
|
48
|
+
rpc: rpcClient,
|
|
49
|
+
storedState: storedState as { global?: Record<string, string | null>; workspace?: Record<string, string | null> },
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const context = api._createContext();
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const mod = await import(entryPath);
|
|
56
|
+
const activateFn = mod.activate || mod.default?.activate;
|
|
57
|
+
if (typeof activateFn === "function") {
|
|
58
|
+
// Activation timeout: 10s max to prevent hanging extensions
|
|
59
|
+
const activatePromise = Promise.resolve(activateFn(context, api));
|
|
60
|
+
const timeoutPromise = new Promise((_, reject) =>
|
|
61
|
+
setTimeout(() => reject(new Error(`Activation timeout (10s) for ${extId}`)), 10_000),
|
|
62
|
+
);
|
|
63
|
+
await Promise.race([activatePromise, timeoutPromise]);
|
|
64
|
+
}
|
|
65
|
+
activeExtensions.set(extId, {
|
|
66
|
+
deactivate: mod.deactivate || mod.default?.deactivate,
|
|
67
|
+
context,
|
|
68
|
+
window: api.window as WindowService,
|
|
69
|
+
commands: api.commands as CommandService,
|
|
70
|
+
});
|
|
71
|
+
return { ok: true };
|
|
72
|
+
} catch (e) {
|
|
73
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
74
|
+
console.error(`[ExtHost] Failed to activate ${extId}:`, msg);
|
|
75
|
+
return { ok: false, error: msg };
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
rpc.onRequest("ext:deactivate", async (params) => {
|
|
80
|
+
const [extId] = params as [string];
|
|
81
|
+
const ext = activeExtensions.get(extId);
|
|
82
|
+
if (!ext) return { ok: true, already: true };
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
if (typeof ext.deactivate === "function") {
|
|
86
|
+
await ext.deactivate();
|
|
87
|
+
}
|
|
88
|
+
// Dispose all subscriptions
|
|
89
|
+
for (const sub of ext.context.subscriptions) {
|
|
90
|
+
try { (sub as Disposable).dispose(); } catch {}
|
|
91
|
+
}
|
|
92
|
+
activeExtensions.delete(extId);
|
|
93
|
+
return { ok: true };
|
|
94
|
+
} catch (e) {
|
|
95
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
96
|
+
console.error(`[ExtHost] Failed to deactivate ${extId}:`, msg);
|
|
97
|
+
activeExtensions.delete(extId);
|
|
98
|
+
return { ok: false, error: msg };
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
rpc.onRequest("ext:command:execute", async (params) => {
|
|
103
|
+
const [command, ...args] = params as [string, ...unknown[]];
|
|
104
|
+
for (const [, ext] of activeExtensions) {
|
|
105
|
+
if (ext.commands) {
|
|
106
|
+
try {
|
|
107
|
+
const result = await (ext.commands as any).executeCommand(command, ...args);
|
|
108
|
+
return { ok: true, result };
|
|
109
|
+
} catch {
|
|
110
|
+
// Command not found in this extension, try next
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { ok: false, error: `Command not found: ${command}` };
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Deliver webview messages from browser → extension's onDidReceiveMessage
|
|
118
|
+
rpc.onRequest("ext:webview:message", async (params) => {
|
|
119
|
+
const [panelId, message] = params as [string, unknown];
|
|
120
|
+
for (const [, ext] of activeExtensions) {
|
|
121
|
+
if (!ext.window) continue;
|
|
122
|
+
try {
|
|
123
|
+
if ((ext.window as any)._deliverWebviewMessage(panelId, message)) {
|
|
124
|
+
return { ok: true };
|
|
125
|
+
}
|
|
126
|
+
} catch (e) {
|
|
127
|
+
console.error(`[ExtHost] webview:message error (${panelId}):`, e);
|
|
128
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return { ok: false, error: `No handler for panel ${panelId}` };
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Handle tree:expand — get children for a tree node
|
|
135
|
+
rpc.onRequest("ext:tree:expand", async (params) => {
|
|
136
|
+
const [viewId, itemId] = params as [string, string | undefined];
|
|
137
|
+
for (const [, ext] of activeExtensions) {
|
|
138
|
+
if (ext.window) {
|
|
139
|
+
try {
|
|
140
|
+
const items = await (ext.window as any)._getTreeChildren(viewId, itemId);
|
|
141
|
+
if (items.length > 0) return { ok: true, items };
|
|
142
|
+
} catch (e) {
|
|
143
|
+
console.error(`[ExtHost] tree:expand error (${viewId}):`, e);
|
|
144
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return { ok: true, items: [] };
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
rpc.onRequest("ext:list-active", () => {
|
|
152
|
+
return [...activeExtensions.keys()];
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
rpc.onRequest("ext:ping", () => "pong");
|
|
156
|
+
|
|
157
|
+
// ExtensionContext is now created by @ppm/vscode-compat's createVscodeCompat()._createContext()
|
|
158
|
+
|
|
159
|
+
// Notify main process that worker is ready
|
|
160
|
+
rpc.sendEvent("worker:ready", {});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { existsSync, mkdirSync, writeFileSync, rmSync, symlinkSync } from "node:fs";
|
|
3
|
+
import type { ExtensionManifest } from "../types/extension.ts";
|
|
4
|
+
import { getExtensionById, insertExtension, updateExtension, deleteExtension, deleteExtensionStorage } from "./db.service.ts";
|
|
5
|
+
import { readManifestAt } from "./extension-manifest.ts";
|
|
6
|
+
|
|
7
|
+
const INSTALL_TIMEOUT = 60_000;
|
|
8
|
+
const NPM_PACKAGE_RE = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*(@[^@]+)?$/;
|
|
9
|
+
|
|
10
|
+
/** Ensure ~/.ppm/extensions/ dir + isolated package.json exist */
|
|
11
|
+
export function ensureExtensionsDir(extensionsDir: string): void {
|
|
12
|
+
if (!existsSync(extensionsDir)) {
|
|
13
|
+
mkdirSync(extensionsDir, { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
const pkgJsonPath = resolve(extensionsDir, "package.json");
|
|
16
|
+
if (!existsSync(pkgJsonPath)) {
|
|
17
|
+
writeFileSync(pkgJsonPath, JSON.stringify({ name: "ppm-extensions", private: true, dependencies: {} }, null, 2));
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Install an npm package into the extensions directory and persist to DB */
|
|
22
|
+
export async function installExtension(name: string, extensionsDir: string): Promise<ExtensionManifest> {
|
|
23
|
+
if (!NPM_PACKAGE_RE.test(name)) throw new Error(`Invalid package name: ${name}`);
|
|
24
|
+
ensureExtensionsDir(extensionsDir);
|
|
25
|
+
|
|
26
|
+
const proc = Bun.spawn(["bun", "add", name], {
|
|
27
|
+
cwd: extensionsDir,
|
|
28
|
+
stdout: "pipe",
|
|
29
|
+
stderr: "pipe",
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const timeout = setTimeout(() => proc.kill(), INSTALL_TIMEOUT);
|
|
33
|
+
const exitCode = await proc.exited;
|
|
34
|
+
clearTimeout(timeout);
|
|
35
|
+
|
|
36
|
+
if (exitCode !== 0) {
|
|
37
|
+
const stderr = await new Response(proc.stderr).text();
|
|
38
|
+
throw new Error(`Install failed (exit ${exitCode}): ${stderr.slice(0, 500)}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const pkgDir = resolve(extensionsDir, "node_modules", name);
|
|
42
|
+
const manifest = readManifestAt(pkgDir);
|
|
43
|
+
if (!manifest) throw new Error(`Installed ${name} but no valid manifest found`);
|
|
44
|
+
|
|
45
|
+
upsertExtensionInDb(manifest);
|
|
46
|
+
console.log(`[ExtService] Installed ${manifest.id}@${manifest.version}`);
|
|
47
|
+
return manifest;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Remove an extension from disk + DB */
|
|
51
|
+
export async function removeExtension(id: string, extensionsDir: string): Promise<void> {
|
|
52
|
+
try {
|
|
53
|
+
const proc = Bun.spawn(["bun", "remove", id], {
|
|
54
|
+
cwd: extensionsDir,
|
|
55
|
+
stdout: "pipe",
|
|
56
|
+
stderr: "pipe",
|
|
57
|
+
});
|
|
58
|
+
await proc.exited;
|
|
59
|
+
} catch (e) {
|
|
60
|
+
console.error(`[ExtService] npm remove ${id} failed (DB record still removed):`, e);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
deleteExtensionStorage(id);
|
|
64
|
+
deleteExtension(id);
|
|
65
|
+
console.log(`[ExtService] Removed ${id}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Symlink a local extension path for development */
|
|
69
|
+
export function devLinkExtension(localPath: string, extensionsDir: string): ExtensionManifest {
|
|
70
|
+
const absPath = resolve(localPath);
|
|
71
|
+
const manifest = readManifestAt(absPath);
|
|
72
|
+
if (!manifest) throw new Error(`No valid package.json at ${absPath}`);
|
|
73
|
+
|
|
74
|
+
ensureExtensionsDir(extensionsDir);
|
|
75
|
+
const nodeModules = resolve(extensionsDir, "node_modules");
|
|
76
|
+
if (!existsSync(nodeModules)) mkdirSync(nodeModules, { recursive: true });
|
|
77
|
+
|
|
78
|
+
const targetDir = resolve(nodeModules, manifest.id);
|
|
79
|
+
const parentDir = resolve(targetDir, "..");
|
|
80
|
+
if (!existsSync(parentDir)) mkdirSync(parentDir, { recursive: true });
|
|
81
|
+
|
|
82
|
+
if (existsSync(targetDir)) rmSync(targetDir, { recursive: true, force: true });
|
|
83
|
+
symlinkSync(absPath, targetDir, "dir");
|
|
84
|
+
|
|
85
|
+
upsertExtensionInDb(manifest);
|
|
86
|
+
console.log(`[ExtService] Dev-linked ${manifest.id} → ${absPath}`);
|
|
87
|
+
return manifest;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Insert or update extension record in DB */
|
|
91
|
+
function upsertExtensionInDb(manifest: ExtensionManifest): void {
|
|
92
|
+
const existing = getExtensionById(manifest.id);
|
|
93
|
+
if (existing) {
|
|
94
|
+
updateExtension(manifest.id, {
|
|
95
|
+
version: manifest.version,
|
|
96
|
+
display_name: manifest.displayName ?? null,
|
|
97
|
+
description: manifest.description ?? null,
|
|
98
|
+
icon: manifest.icon ?? null,
|
|
99
|
+
manifest: JSON.stringify(manifest),
|
|
100
|
+
});
|
|
101
|
+
} else {
|
|
102
|
+
insertExtension({
|
|
103
|
+
id: manifest.id,
|
|
104
|
+
version: manifest.version,
|
|
105
|
+
display_name: manifest.displayName ?? null,
|
|
106
|
+
description: manifest.description ?? null,
|
|
107
|
+
icon: manifest.icon ?? null,
|
|
108
|
+
enabled: 1,
|
|
109
|
+
manifest: JSON.stringify(manifest),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|