@homescriptone/opencode-viewer-extender 0.0.0-stage → 1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 homescriptone Solutions Ltd
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,167 @@
1
- # Temporary Holding Version
1
+ # opencode-viewer-extender
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm](https://img.shields.io/npm/v/@homescriptone/opencode-viewer-extender.svg)](https://www.npmjs.com/package/@homescriptone/opencode-viewer-extender)
4
+ [![license](https://img.shields.io/npm/l/@homescriptone/opencode-viewer-extender.svg)](LICENSE)
5
+
6
+ Local GUI to **browse, edit, and export** your [OpenCode](https://opencode.ai) sessions, messages, and tool calls from the SQLite database — with a small **plugin API** you can extend.
7
+
8
+ Reads and writes `~/.local/share/opencode/opencode.db`. Everything stays on your machine.
9
+
10
+ ```bash
11
+ bunx @homescriptone/opencode-viewer-extender
12
+ ```
13
+
14
+ > On npm: [`@homescriptone/opencode-viewer-extender`](https://www.npmjs.com/package/@homescriptone/opencode-viewer-extender) · Desktop builds (`.dmg`, Linux, Windows) are published on the [releases page](https://github.com/homescriptone/opencode-viewer-extended/releases).
15
+
16
+ ## Features
17
+
18
+ - **Three-panel UI** — projects, sessions, and the full message thread
19
+ - **Every part type rendered** — text (markdown-lite), reasoning, tool calls with input/output, patches, file attachments, images (click-to-expand), agent pills, step-finish costs, compaction markers
20
+ - **Subagents** — interleaved in the timeline; click to drill in, back to return
21
+ - **Search** — cross-project session search scoped to all / user / model messages
22
+ - **Export** — a session as Markdown or JSON, or **select many** projects/sessions and export a single `.zip`
23
+ - **Edit mode** — opt-in create/update/delete (projects, sessions, messages, parts)
24
+ - **Plugins** — drop a `.js` file in `public/plugins/` to add renderers, export formats, and bulk actions; toggle them on/off from the UI
25
+
26
+ ## Requirements
27
+
28
+ - [Bun](https://bun.sh) runtime (`curl -fsSL https://bun.sh/install | bash`), or a desktop build
29
+ - [OpenCode](https://opencode.ai) installed and used at least once
30
+
31
+ ## Usage
32
+
33
+ ```bash
34
+ # Run it (auto-opens the browser)
35
+ bunx @homescriptone/opencode-viewer-extender
36
+
37
+ # Custom port
38
+ bunx @homescriptone/opencode-viewer-extender -p 8080
39
+
40
+ # Custom database path
41
+ bunx @homescriptone/opencode-viewer-extender --db /path/to/opencode.db
42
+
43
+ # Enable edit mode (create/update/delete)
44
+ bunx @homescriptone/opencode-viewer-extender --write
45
+
46
+ # Don't open the browser
47
+ bunx @homescriptone/opencode-viewer-extender --no-open
48
+ ```
49
+
50
+ Default port is `3456` when launched via the CLI. Set `OCV_PORT`, `OCV_DB_PATH`, or `OCV_WRITE=1` to override.
51
+
52
+ ## Edit mode
53
+
54
+ The database is opened **read-only** by default. Writes only become available when you start with `--write` (or `OCV_WRITE=1`); the server then exposes the write API and the UI shows an **Edit** toggle.
55
+
56
+ > **Warning:** edits and deletes hit your real OpenCode database, and deletes **cascade** (deleting a project removes its sessions, messages, and parts). There is no undo. Back up `opencode.db` before making changes.
57
+
58
+ ## Export
59
+
60
+ - **Single session** — the **Export ▾** menu in the message header offers Markdown (`.md`) and JSON (`.json`), served by `GET /api/sessions/:id/export?format=md|json`.
61
+ - **Bulk** — turn on **Select**, pick multiple projects/sessions, and choose **Export selected (.zip)**. The archive contains a `project.json` per project, each session as `sessions/<id>.json`, and a `manifest.json`.
62
+
63
+ ## Plugins
64
+
65
+ Plugins are plain ES scripts discovered at startup from `public/plugins/` (listed by `GET /api/plugins`) and loaded before the UI renders. Enable/disable them from the **Plugins** button; disabled plugins are remembered in `localStorage`.
66
+
67
+ See [`public/plugins/example.js`](public/plugins/example.js) for a complete example.
68
+
69
+ ### Authoring
70
+
71
+ ```js
72
+ OCV.define({
73
+ id: "my-plugin",
74
+ name: "My plugin",
75
+ description: "What it does",
76
+
77
+ setup(api) {
78
+ // Render a custom part type -> HTML string (return falsy to fall back)
79
+ api.registerPartRenderer("snapshot", (part, helpers) => `...`);
80
+
81
+ // Add an export format to the Export menu (built in the browser)
82
+ api.registerExportFormat({
83
+ id: "txt", label: "Plain text (.txt)", extension: "txt", mime: "text/plain",
84
+ build: ({ session, messages }) => "..."
85
+ });
86
+
87
+ // Add a button in the session header
88
+ api.registerSessionAction({ id: "copy", label: "Copy ID", handler: ({ session }) => {} });
89
+
90
+ // Add a bulk action shown when items are selected
91
+ api.registerBulkAction({
92
+ id: "wipe", label: "Delete selected", danger: true, write: true, // write:true => only in Edit mode
93
+ handler: async ({ projectIds, sessionIds, host }) => { /* ... */ }
94
+ });
95
+ }
96
+ });
97
+ ```
98
+
99
+ Every `register*` call returns an unregister function, and registrations are removed automatically when the plugin is disabled.
100
+
101
+ ### Host bridge
102
+
103
+ `api.host` (also `OCV.host`) exposes data and actions:
104
+
105
+ ```js
106
+ host.getProjects() // [{ id, name, worktree }]
107
+ host.getSessions() // current session list
108
+ host.getSelection() // { projectIds, sessionIds }
109
+ host.clearSelection()
110
+ host.api.getProjectSessions(projectId) // all sessions for a project
111
+ host.api.getSessionDetail(sessionId) // { session, messages }
112
+ host.api.deleteProject(id)
113
+ host.api.deleteSession(id)
114
+ host.download(name, content, mime) // save a Blob/string
115
+ host.createZip([{ name, data }]) // -> Blob (store method, no deps)
116
+ host.refresh() // reload projects/sessions
117
+ ```
118
+
119
+ ## How it works
120
+
121
+ ```
122
+ bin/cli.ts → CLI args, env setup, opens the browser
123
+ server/launch.ts → Bun.serve HTTP server, routing, static files, export
124
+ server/database.ts → SQLite connection, prepared statements, write layer
125
+ public/index.html → Page shell (loads plugin-api.js + script.js)
126
+ public/script.js → Single-page app (vanilla JS, no framework)
127
+ public/plugin-api.js → window.OCV plugin registry (+ zip helper)
128
+ public/plugins/*.js → Drop-in plugins
129
+ public/styles.css → Styles
130
+ ```
131
+
132
+ 1. Opens `opencode.db` (read-only unless `--write`).
133
+ 2. Starts a Bun server on `localhost:3456`.
134
+ 3. Serves the static SPA and a small JSON REST API.
135
+ 4. All data stays local — nothing leaves your machine.
136
+
137
+ ### REST API
138
+
139
+ Reads: `GET /api/projects` · `GET /api/projects/:id/sessions` · `GET /api/sessions/search` · `GET /api/sessions/:id` · `GET /api/sessions/:id/subagents` · `GET /api/sessions/:id/export` · `GET /api/parts/:id/image` · `GET /api/plugins` · `GET /api/config`
140
+
141
+ Writes (require `--write`): `POST/PATCH/PUT/DELETE` on `/api/projects`, `/api/sessions`, `/api/sessions/:id/messages`, `/api/messages/:id/parts`.
142
+
143
+ ## Development
144
+
145
+ ```bash
146
+ bun run dev # watch mode (server/launch.ts)
147
+ bun run start # production
148
+ bun run typecheck
149
+ ```
150
+
151
+ ## Releasing
152
+
153
+ Publishing to npm is automated: pushing to `main`/`master` runs [`.github/workflows/publish.yml`](.github/workflows/publish.yml), which type-checks and publishes the current `package.json` version (skipping if that version already exists). Add an `NPM_TOKEN` repository secret (an npm automation token) for it to publish. Bumping the version is all you need to ship a release.
154
+
155
+ ## Desktop app
156
+
157
+ A Tauri wrapper lives in [`src-tauri/`](src-tauri/) and bundles the Bun server as a sidecar; installers are produced by CI and attached to releases.
158
+
159
+ ## Credits
160
+
161
+ Maintained by [homescriptone Solutions Ltd](https://github.com/homescriptone). Built on
162
+ [opencode-viewer](https://github.com/leeweisern/opencode-viewer) by its original author
163
+ [leeweisern](https://github.com/leeweisern) — thank you.
164
+
165
+ ## License
166
+
167
+ MIT © 2026 homescriptone Solutions Ltd. See [LICENSE](LICENSE).
package/bin/cli.js ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ import("./cli.ts");
package/bin/cli.ts ADDED
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { existsSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { parseArgs } from "node:util";
7
+
8
+ const { values } = parseArgs({
9
+ options: {
10
+ port: { type: "string", short: "p", default: "3456" },
11
+ "no-open": { type: "boolean", default: false },
12
+ write: { type: "boolean", default: false },
13
+ help: { type: "boolean", short: "h", default: false },
14
+ db: { type: "string" },
15
+ },
16
+ strict: false,
17
+ });
18
+
19
+ if (values.help) {
20
+ console.log(`
21
+ opencode-viewer — Browse your OpenCode sessions locally
22
+
23
+ Usage:
24
+ bunx opencode-viewer [options]
25
+
26
+ Options:
27
+ -p, --port <port> Port to listen on (default: 3456)
28
+ --db <path> Custom path to opencode.db
29
+ --write Enable edit mode (create/update/delete). Off by default.
30
+ --no-open Don't auto-open browser
31
+ -h, --help Show this help
32
+
33
+ Requires Bun runtime (uses bun:sqlite).
34
+ `);
35
+ process.exit(0);
36
+ }
37
+
38
+ const port = Number(values.port) || 3456;
39
+ const dbPath =
40
+ (values.db as string) ||
41
+ join(homedir(), ".local", "share", "opencode", "opencode.db");
42
+
43
+ if (!existsSync(dbPath)) {
44
+ console.error(`\n ✗ OpenCode database not found at: ${dbPath}\n`);
45
+ console.error(
46
+ " Make sure OpenCode is installed and has been run at least once.",
47
+ );
48
+ console.error(" Or specify a custom path: bunx opencode-viewer --db /path/to/opencode.db\n");
49
+ process.exit(1);
50
+ }
51
+
52
+ // Set env vars for the server to pick up
53
+ process.env.OCV_PORT = String(port);
54
+ process.env.OCV_DB_PATH = dbPath;
55
+ if (values.write) process.env.OCV_WRITE = "1";
56
+
57
+ // Import and start server
58
+ const serverPath = join(import.meta.dir, "..", "server", "launch.ts");
59
+ await import(serverPath);
60
+
61
+ // Auto-open browser
62
+ if (!values["no-open"]) {
63
+ const url = `http://localhost:${port}`;
64
+ const { platform } = process;
65
+ const cmd =
66
+ platform === "darwin"
67
+ ? ["open", url]
68
+ : platform === "win32"
69
+ ? ["cmd", "/c", "start", url]
70
+ : ["xdg-open", url];
71
+
72
+ Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore" });
73
+ }
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "@homescriptone/opencode-viewer-extender",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.2.0",
4
+ "description": "Local GUI to browse, edit, and export your OpenCode sessions, messages, and tool calls from the SQLite database, with a plugin API",
5
+ "bin": {
6
+ "opencode-viewer": "bin/cli.js"
7
+ },
8
+ "files": [
9
+ "bin/",
10
+ "public/",
11
+ "server/"
12
+ ],
13
+ "scripts": {
14
+ "dev": "bun run --watch server/launch.ts",
15
+ "start": "bun run server/launch.ts",
16
+ "typecheck": "bun check",
17
+ "prepublishOnly": "bun check",
18
+ "deploy": "npm version patch --no-git-tag-version && git add -A && git commit -m \"v$(node -p require('./package.json').version)\" && git push && npm publish --access public"
19
+ },
20
+ "keywords": [
21
+ "opencode",
22
+ "opencode-viewer",
23
+ "viewer",
24
+ "sqlite",
25
+ "sessions",
26
+ "ai",
27
+ "cli",
28
+ "bun",
29
+ "plugins"
30
+ ],
31
+ "author": "Homescriptone Solutions Ltd",
32
+ "license": "MIT",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/homescriptone/opencode-viewer-extended.git"
36
+ },
37
+ "homepage": "https://homescriptone.github.io/opencode-viewer-extended/",
38
+ "bugs": {
39
+ "url": "https://github.com/homescriptone/opencode-viewer-extended/issues"
40
+ },
41
+ "engines": {
42
+ "bun": ">=1.0.0"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
47
+ "devDependencies": {
48
+ "@types/bun": "^1.4.3",
49
+ "@types/node": "^26.6.5"
50
+ }
51
+ }
@@ -0,0 +1,23 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>OpenCode Session Viewer</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
10
+ <link rel="stylesheet" href="styles.css">
11
+ </head>
12
+ <body>
13
+ <div id="app">
14
+ <aside id="projects-panel" class="panel"></aside>
15
+ <section id="sessions-panel" class="panel"></section>
16
+ <main id="messages-panel" class="panel"></main>
17
+ </div>
18
+ <div id="bulk-bar" class="bulk-bar" hidden></div>
19
+ <div id="modal-root"></div>
20
+ <script src="plugin-api.js"></script>
21
+ <script src="script.js"></script>
22
+ </body>
23
+ </html>
@@ -0,0 +1,260 @@
1
+ (function () {
2
+ "use strict";
3
+
4
+ const STORAGE_KEY = "ocv.plugins.disabled";
5
+
6
+ const plugins = new Map();
7
+ const partRenderers = new Map();
8
+ const exportFormats = [];
9
+ const sessionActions = [];
10
+ const bulkActions = [];
11
+
12
+ let hostImpl = {};
13
+ let readyResolve = null;
14
+ const readyPromise = new Promise((resolve) => {
15
+ readyResolve = resolve;
16
+ });
17
+
18
+ function readDisabled() {
19
+ try {
20
+ const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
21
+ return new Set(Array.isArray(parsed) ? parsed : []);
22
+ } catch {
23
+ return new Set();
24
+ }
25
+ }
26
+
27
+ const disabled = readDisabled();
28
+
29
+ function persistDisabled() {
30
+ try {
31
+ localStorage.setItem(STORAGE_KEY, JSON.stringify([...disabled]));
32
+ } catch {
33
+ /* storage unavailable */
34
+ }
35
+ }
36
+
37
+ function makeApi(id) {
38
+ const cleanups = [];
39
+
40
+ const track = (arr, item) => {
41
+ const remove = () => {
42
+ const index = arr.indexOf(item);
43
+ if (index >= 0) arr.splice(index, 1);
44
+ };
45
+ cleanups.push(remove);
46
+ return remove;
47
+ };
48
+
49
+ return {
50
+ id,
51
+ get host() {
52
+ return hostImpl;
53
+ },
54
+ registerPartRenderer(type, renderer) {
55
+ const key = String(type ?? "").toLowerCase();
56
+ partRenderers.set(key, renderer);
57
+ const remove = () => {
58
+ if (partRenderers.get(key) === renderer) partRenderers.delete(key);
59
+ };
60
+ cleanups.push(remove);
61
+ return remove;
62
+ },
63
+ registerExportFormat(format) {
64
+ if (!format || typeof format.build !== "function") return () => {};
65
+ exportFormats.push(format);
66
+ return track(exportFormats, format);
67
+ },
68
+ registerSessionAction(action) {
69
+ if (!action || typeof action.handler !== "function") return () => {};
70
+ sessionActions.push(action);
71
+ return track(sessionActions, action);
72
+ },
73
+ registerBulkAction(action) {
74
+ if (!action || typeof action.handler !== "function") return () => {};
75
+ bulkActions.push(action);
76
+ return track(bulkActions, action);
77
+ },
78
+ _cleanups: cleanups,
79
+ };
80
+ }
81
+
82
+ function define(def) {
83
+ if (!def || typeof def.id !== "string" || typeof def.setup !== "function") {
84
+ console.warn("[OCV] define() ignored: needs { id, name?, setup }", def);
85
+ return;
86
+ }
87
+ plugins.set(def.id, { def, enabled: false, cleanups: [], api: null });
88
+ }
89
+
90
+ function enable(id) {
91
+ const entry = plugins.get(id);
92
+ if (!entry || entry.enabled) return;
93
+ const api = makeApi(id);
94
+ entry.api = api;
95
+ entry.enabled = true;
96
+ try {
97
+ const extra = entry.def.setup(api);
98
+ if (Array.isArray(extra)) api._cleanups.push(...extra);
99
+ } catch (error) {
100
+ console.error(`[OCV] plugin "${id}" setup failed:`, error);
101
+ }
102
+ entry.cleanups = api._cleanups;
103
+ }
104
+
105
+ function disable(id) {
106
+ const entry = plugins.get(id);
107
+ if (!entry || !entry.enabled) return;
108
+ for (const cleanup of entry.cleanups) {
109
+ try {
110
+ cleanup();
111
+ } catch (error) {
112
+ console.error(`[OCV] plugin "${id}" cleanup failed:`, error);
113
+ }
114
+ }
115
+ entry.cleanups = [];
116
+ try {
117
+ entry.def.teardown?.(entry.api);
118
+ } catch (error) {
119
+ console.error(`[OCV] plugin "${id}" teardown failed:`, error);
120
+ }
121
+ entry.enabled = false;
122
+ entry.api = null;
123
+ }
124
+
125
+ function setEnabled(id, value) {
126
+ if (value) {
127
+ disabled.delete(id);
128
+ enable(id);
129
+ } else {
130
+ disabled.add(id);
131
+ disable(id);
132
+ }
133
+ persistDisabled();
134
+ hostImpl.onPluginsChanged?.();
135
+ }
136
+
137
+ function listPlugins() {
138
+ return [...plugins.values()].map((entry) => ({
139
+ id: entry.def.id,
140
+ name: entry.def.name || entry.def.id,
141
+ description: entry.def.description || "",
142
+ enabled: entry.enabled,
143
+ }));
144
+ }
145
+
146
+ function activateAll() {
147
+ for (const [id] of plugins) {
148
+ if (!disabled.has(id)) enable(id);
149
+ }
150
+ }
151
+
152
+ // Minimal ZIP writer (store method, no compression) so plugins can bundle
153
+ // multiple JSON files into one download without any dependency.
154
+ const crcTable = (() => {
155
+ const table = new Uint32Array(256);
156
+ for (let n = 0; n < 256; n++) {
157
+ let c = n;
158
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
159
+ table[n] = c >>> 0;
160
+ }
161
+ return table;
162
+ })();
163
+
164
+ function crc32(bytes) {
165
+ let c = 0xffffffff;
166
+ for (let i = 0; i < bytes.length; i++) c = crcTable[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
167
+ return (c ^ 0xffffffff) >>> 0;
168
+ }
169
+
170
+ function createZip(entries) {
171
+ const encoder = new TextEncoder();
172
+ const chunks = [];
173
+ const central = [];
174
+ let offset = 0;
175
+
176
+ for (const entry of entries) {
177
+ const nameBytes = encoder.encode(String(entry.name));
178
+ const dataBytes = typeof entry.data === "string" ? encoder.encode(entry.data) : entry.data;
179
+ const crc = crc32(dataBytes);
180
+
181
+ const local = new Uint8Array(30 + nameBytes.length);
182
+ const lv = new DataView(local.buffer);
183
+ lv.setUint32(0, 0x04034b50, true);
184
+ lv.setUint16(4, 20, true);
185
+ lv.setUint16(6, 0x0800, true);
186
+ lv.setUint16(8, 0, true);
187
+ lv.setUint16(10, 0, true);
188
+ lv.setUint16(12, 0, true);
189
+ lv.setUint32(14, crc, true);
190
+ lv.setUint32(18, dataBytes.length, true);
191
+ lv.setUint32(22, dataBytes.length, true);
192
+ lv.setUint16(26, nameBytes.length, true);
193
+ lv.setUint16(28, 0, true);
194
+ local.set(nameBytes, 30);
195
+ chunks.push(local, dataBytes);
196
+
197
+ const record = new Uint8Array(46 + nameBytes.length);
198
+ const cv = new DataView(record.buffer);
199
+ cv.setUint32(0, 0x02014b50, true);
200
+ cv.setUint16(4, 20, true);
201
+ cv.setUint16(6, 20, true);
202
+ cv.setUint16(8, 0x0800, true);
203
+ cv.setUint16(10, 0, true);
204
+ cv.setUint16(12, 0, true);
205
+ cv.setUint16(14, 0, true);
206
+ cv.setUint32(16, crc, true);
207
+ cv.setUint32(20, dataBytes.length, true);
208
+ cv.setUint32(24, dataBytes.length, true);
209
+ cv.setUint16(28, nameBytes.length, true);
210
+ cv.setUint16(30, 0, true);
211
+ cv.setUint16(32, 0, true);
212
+ cv.setUint16(34, 0, true);
213
+ cv.setUint16(36, 0, true);
214
+ cv.setUint32(38, 0, true);
215
+ cv.setUint32(42, offset, true);
216
+ record.set(nameBytes, 46);
217
+ central.push(record);
218
+
219
+ offset += local.length + dataBytes.length;
220
+ }
221
+
222
+ let centralSize = 0;
223
+ for (const record of central) centralSize += record.length;
224
+
225
+ const end = new Uint8Array(22);
226
+ const ev = new DataView(end.buffer);
227
+ ev.setUint32(0, 0x06054b50, true);
228
+ ev.setUint16(4, 0, true);
229
+ ev.setUint16(6, 0, true);
230
+ ev.setUint16(8, central.length, true);
231
+ ev.setUint16(10, central.length, true);
232
+ ev.setUint32(12, centralSize, true);
233
+ ev.setUint32(16, offset, true);
234
+ ev.setUint16(20, 0, true);
235
+
236
+ return new Blob([...chunks, ...central, end], { type: "application/zip" });
237
+ }
238
+
239
+ window.OCV = {
240
+ version: "2",
241
+ define,
242
+ setEnabled,
243
+ isEnabled: (id) => Boolean(plugins.get(id)?.enabled),
244
+ list: listPlugins,
245
+ ready: () => readyPromise,
246
+ get host() {
247
+ return hostImpl;
248
+ },
249
+ getPartRenderer: (type) => partRenderers.get(String(type ?? "").toLowerCase()) || null,
250
+ listExportFormats: () => exportFormats.slice(),
251
+ listSessionActions: () => sessionActions.slice(),
252
+ listBulkActions: () => bulkActions.slice(),
253
+ helpers: { createZip },
254
+ _registerHost(impl) {
255
+ hostImpl = impl;
256
+ },
257
+ _activateAll: activateAll,
258
+ _markReady: () => readyResolve(),
259
+ };
260
+ })();