chore(client): remove dead files, exports, and unused dependencies

knip findings (repo CI config), each verified including dynamic imports,
HTML refs, and the Rust side:

Files deleted: pluginBridge.ts (its documented PluginContainer.tsx
collaborator never existed in the repo; the server plugin host stays per
D11 — reinstate from git if client plugin UI work ever starts),
message-input/file-upload.ts, message-input/picker-toggle.ts (dir now
empty, removed), message-list/virtual-scroll.ts (MessageList does its
own virtualization via FenwickTree).

Dependencies removed: zod (zero imports; typegen uses
validation_library none — stale CLAUDE.md claim fixed),
@tauri-apps/plugin-store and plugin-updater npm halves (both features
are Rust-driven via StoreExt/UpdaterExt — Rust halves stay), and
tauri-plugin-global-shortcut on BOTH sides (PTT polls via device_query;
zero GlobalShortcutExt use): Cargo.toml dep, lib.rs registration, and
the 5 capability permission lines. Inert webview capability entries
store:default/updater:default also dropped. @stryker-mutator/api added
to devDependencies (stryker.config.mjs imports its types; core pins the
same version, zero install delta).

Exports removed: livekitSession clearOnError bound-const, ConnectPage/
MainPage ReturnType aliases, readAllPersistedLogs (never wired to any
UI) with its test blocks. getLogDir kept as the suite's observability
point, tagged @public for knip. protocolTypes.ts *Value types are
generated surface — knip.json now ignores that file instead.

Rust compile is CI-verified only (no MSVC toolchain here, same as the
F4/F8 TOFU work); Cargo.lock resolution pruned cleanly. Client gate
green: tsc, oxlint/eslint 0 errors, prettier, 3304/3304 vitest, knip
clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
J3vb
2026-07-23 15:29:01 +02:00
co-authored by Claude Fable 5
parent 9d8bbec375
commit f4b20726ff
17 changed files with 14 additions and 747 deletions
+2 -1
View File
@@ -4,7 +4,8 @@
"project": ["src/**/*.ts"],
"ignore": [
"public/**",
"src-tauri/**"
"src-tauri/**",
"src/lib/protocolTypes.ts"
],
"ignoreDependencies": [
"@tauri-apps/cli"
+3 -32
View File
@@ -12,19 +12,16 @@
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-global-shortcut": "^2",
"@tauri-apps/plugin-http": "^2.5.7",
"@tauri-apps/plugin-notification": "^2",
"@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-store": "^2",
"@tauri-apps/plugin-updater": "^2.10.0",
"livekit-client": "^2.18.0",
"zod": "^4.3.6"
"livekit-client": "^2.18.0"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@playwright/test": "^1",
"@stryker-mutator/api": "^9.6.0",
"@stryker-mutator/core": "^9.6.0",
"@stryker-mutator/typescript-checker": "^9.6.0",
"@stryker-mutator/vitest-runner": "^9.6.0",
@@ -3873,15 +3870,6 @@
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-global-shortcut": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-global-shortcut/-/plugin-global-shortcut-2.3.1.tgz",
"integrity": "sha512-vr40W2N6G63dmBPaha1TsBQLLURXG538RQbH5vAm0G/ovVZyXJrmZR1HF1W+WneNloQvwn4dm8xzwpEXRW560g==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-http": {
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.7.tgz",
@@ -3918,24 +3906,6 @@
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-store": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-store/-/plugin-store-2.4.2.tgz",
"integrity": "sha512-0ClHS50Oq9HEvLPhNzTNFxbWVOqoAp3dRvtewQBeqfIQ0z5m3JRnOISIn2ZVPCrQC0MyGyhTS9DWhHjpigQE7A==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-updater": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.0.tgz",
"integrity": "sha512-ljN8jPlnT0aSn8ecYhuBib84alxfMx6Hc8vJSKMJyzGbTPFZAC44T2I1QNFZssgWKrAlofvJqCC6Rr472JWfkQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.10.1"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@@ -8571,6 +8541,7 @@
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
+2 -5
View File
@@ -32,6 +32,7 @@
"devDependencies": {
"@eslint/js": "^9.39.4",
"@playwright/test": "^1",
"@stryker-mutator/api": "^9.6.0",
"@stryker-mutator/core": "^9.6.0",
"@stryker-mutator/typescript-checker": "^9.6.0",
"@stryker-mutator/vitest-runner": "^9.6.0",
@@ -62,14 +63,10 @@
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-global-shortcut": "^2",
"@tauri-apps/plugin-http": "^2.5.7",
"@tauri-apps/plugin-notification": "^2",
"@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-store": "^2",
"@tauri-apps/plugin-updater": "^2.10.0",
"livekit-client": "^2.18.0",
"zod": "^4.3.6"
"livekit-client": "^2.18.0"
}
}
-67
View File
@@ -1578,16 +1578,6 @@ dependencies = [
"version_check",
]
[[package]]
name = "gethostname"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
dependencies = [
"rustix",
"windows-link 0.2.1",
]
[[package]]
name = "getrandom"
version = "0.1.16"
@@ -1725,24 +1715,6 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "global-hotkey"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7"
dependencies = [
"crossbeam-channel",
"keyboard-types",
"objc2",
"objc2-app-kit",
"once_cell",
"serde",
"thiserror 2.0.18",
"windows-sys 0.59.0",
"x11rb",
"xkeysym",
]
[[package]]
name = "globset"
version = "0.4.18"
@@ -2979,7 +2951,6 @@ dependencies = [
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-fs",
"tauri-plugin-global-shortcut",
"tauri-plugin-http",
"tauri-plugin-notification",
"tauri-plugin-opener",
@@ -4931,21 +4902,6 @@ dependencies = [
"url",
]
[[package]]
name = "tauri-plugin-global-shortcut"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "424af23c7e88d05e4a1a6fc2c7be077912f8c76bd7900fd50aa2b7cbf5a2c405"
dependencies = [
"global-hotkey",
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
]
[[package]]
name = "tauri-plugin-http"
version = "2.5.7"
@@ -6887,23 +6843,6 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "x11rb"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
dependencies = [
"gethostname",
"rustix",
"x11rb-protocol",
]
[[package]]
name = "x11rb-protocol"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "xattr"
version = "1.6.1"
@@ -6914,12 +6853,6 @@ dependencies = [
"rustix",
]
[[package]]
name = "xkeysym"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
name = "yoke"
version = "0.8.1"
-1
View File
@@ -19,7 +19,6 @@ devtools = ["tauri/devtools"]
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-store = "2"
tauri-plugin-global-shortcut = "2"
tauri-plugin-notification = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
@@ -20,12 +20,6 @@
"core:window:allow-outer-position",
"core:window:allow-outer-size",
"core:window:allow-available-monitors",
"store:default",
"global-shortcut:default",
"global-shortcut:allow-register",
"global-shortcut:allow-unregister",
"global-shortcut:allow-unregister-all",
"global-shortcut:allow-is-registered",
"notification:default",
"notification:allow-notify",
"notification:allow-request-permission",
@@ -63,7 +57,6 @@
"http:allow-fetch-cancel",
"opener:default",
"dialog:default",
"updater:default",
"process:allow-restart",
"fs:default",
{
-1
View File
@@ -13,7 +13,6 @@ mod ws_proxy;
pub fn run() {
match tauri::Builder::default()
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_opener::init())
@@ -1,101 +0,0 @@
/**
* File upload validation and preview rendering for message input.
*/
import { createElement, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
export const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB matches server limit
export const ALLOWED_TYPES = [
"image/",
"video/",
"audio/",
"application/pdf",
"text/",
"application/zip",
"application/x-zip-compressed",
"application/json",
];
/** Read a File as a data: URL (more reliable than createObjectURL in WebView2). */
export function readFileAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("load", () => resolve(reader.result as string));
reader.addEventListener("error", () => reject(new Error("Failed to read file")));
reader.readAsDataURL(file);
});
}
/** Validate file size and type. Returns an error message or null. */
export function validateFile(file: File): string | null {
if (file.size > MAX_FILE_SIZE) {
return `File too large: ${file.name} exceeds 100 MB limit`;
}
if (file.type === "" || !ALLOWED_TYPES.some((t) => file.type.startsWith(t))) {
return `${file.name} is not a supported file type`;
}
return null;
}
/** Build a preview item element for a file being uploaded. */
export function buildPreviewItem(
file: File,
signal: AbortSignal,
onRemove: () => void,
): HTMLDivElement {
const isImage = file.type.startsWith("image/");
const item = createElement("div", { class: "attachment-preview-item uploading" });
if (isImage) {
const img = createElement("img", {
class: "attachment-preview-img",
alt: file.name,
});
item.appendChild(img);
readFileAsDataUrl(file)
.then((dataUrl) => {
if (signal.aborted) return;
img.src = dataUrl;
})
.catch(() => {
if (signal.aborted) return;
const nameEl = createElement("span", { class: "attachment-preview-name" }, file.name);
img.replaceWith(nameEl);
});
} else {
const icon = createElement("div", { class: "attachment-preview-file" });
icon.appendChild(createIcon("file-text", 16));
const nameEl = createElement("span", { class: "attachment-preview-name" }, file.name);
appendChildren(item, icon, nameEl);
}
// Loading spinner overlay
const spinner = createElement("div", { class: "attachment-preview-spinner" });
spinner.appendChild(createIcon("loader", 16));
item.appendChild(spinner);
const removeBtn = createElement("button", {
class: "attachment-preview-remove",
"data-testid": "attachment-remove",
});
removeBtn.appendChild(createIcon("x", 14));
removeBtn.addEventListener(
"click",
(e) => {
e.stopPropagation();
onRemove();
},
{ signal },
);
item.appendChild(removeBtn);
return item;
}
/** Mark a preview item as uploaded (removes loading state). */
export function markPreviewUploaded(item: HTMLDivElement): void {
item.classList.remove("uploading");
const spinner = item.querySelector(".attachment-preview-spinner");
spinner?.remove();
}
@@ -1,77 +0,0 @@
/**
* Reusable picker toggle — manages open/close/click-outside lifecycle
* for floating panels (emoji picker, GIF picker, etc.).
*/
export interface PickerInstance {
readonly element: HTMLDivElement;
destroy(): void;
}
export interface PickerToggleOptions {
/** Creates and returns a new picker instance. */
readonly create: () => PickerInstance;
/** The trigger button element — clicks on it won't close the picker. */
readonly triggerEl: HTMLElement;
/** Parent element to append the picker to. */
readonly parentEl: HTMLElement | null;
/** Called before opening — use to close other pickers first. */
readonly onBeforeOpen?: () => void;
/** Timer set for deferred cleanup. */
readonly activeTimers: Set<ReturnType<typeof setTimeout>>;
}
export interface PickerToggleHandle {
toggle(): void;
close(): void;
}
export function createPickerToggle(opts: PickerToggleOptions): PickerToggleHandle {
let instance: PickerInstance | null = null;
let pendingTimer: ReturnType<typeof setTimeout> | null = null;
function handleClickOutside(e: MouseEvent): void {
if (instance === null) return;
const target = e.target as Node;
if (
!instance.element.contains(target) &&
target !== opts.triggerEl &&
!opts.triggerEl.contains(target)
) {
close();
}
}
function close(): void {
if (pendingTimer !== null) {
clearTimeout(pendingTimer);
opts.activeTimers.delete(pendingTimer);
pendingTimer = null;
}
if (instance !== null) {
instance.element.remove();
instance.destroy();
instance = null;
document.removeEventListener("mousedown", handleClickOutside);
}
}
function toggle(): void {
opts.onBeforeOpen?.();
if (instance !== null) {
close();
return;
}
instance = opts.create();
opts.parentEl?.appendChild(instance.element);
// Defer so this click doesn't immediately close it
pendingTimer = setTimeout(() => {
opts.activeTimers.delete(pendingTimer!);
pendingTimer = null;
document.addEventListener("mousedown", handleClickOutside);
}, 0);
opts.activeTimers.add(pendingTimer);
}
return { toggle, close };
}
@@ -1,139 +0,0 @@
/**
* Virtual scroll manager — manages height estimation, Fenwick-tree-backed
* offset calculations, and spacer management for DOM windowing.
*/
import { FenwickTree } from "./fenwick";
export interface VirtualScrollItem {
readonly kind: string;
}
export interface VirtualScrollOptions {
/** Number of items to render beyond visible viewport in each direction. */
readonly overscan: number;
/** Estimate height for an item at given index. */
readonly estimateHeight: (index: number) => number;
/** Generate a stable cache key for an item at given index. */
readonly itemKey: (index: number) => string;
}
export interface VisibleRange {
readonly start: number;
readonly end: number;
}
export class VirtualScrollManager {
private readonly heightCache = new Map<string, number>();
private tree: FenwickTree | null = null;
private itemCount = 0;
private readonly opts: VirtualScrollOptions;
constructor(opts: VirtualScrollOptions) {
this.opts = opts;
}
/** Rebuild the Fenwick tree for a new item count, preserving cached heights. */
rebuild(count: number): void {
this.itemCount = count;
this.tree = new FenwickTree(count);
for (let i = 0; i < count; i++) {
const key = this.opts.itemKey(i);
const cached = this.heightCache.get(key);
const h = cached !== undefined ? cached : this.opts.estimateHeight(i);
this.tree.set(i, h);
}
}
/** Get height for item at index (cached or estimated). */
getHeight(index: number): number {
const cached = this.heightCache.get(this.opts.itemKey(index));
if (cached !== undefined) return cached;
return this.opts.estimateHeight(index);
}
/** Cache a measured height for an item. */
setMeasured(index: number, height: number): void {
if (height <= 0) return;
const key = this.opts.itemKey(index);
this.heightCache.set(key, height);
if (this.tree !== null && index < this.tree.size) {
this.tree.set(index, height);
}
}
/** Total estimated height of all items. */
totalHeight(): number {
if (this.tree !== null) return this.tree.total();
let h = 0;
for (let i = 0; i < this.itemCount; i++) {
h += this.getHeight(i);
}
return h;
}
/** Sum of heights for items [0, index). */
offsetBefore(index: number): number {
if (this.tree !== null && index > 0) return this.tree.prefixSum(index - 1);
if (this.tree !== null && index <= 0) return 0;
let offset = 0;
for (let i = 0; i < index && i < this.itemCount; i++) {
offset += this.getHeight(i);
}
return offset;
}
/** Find the item index at a given scroll offset. */
offsetToIndex(scrollTop: number): number {
if (this.tree !== null) return this.tree.findIndex(scrollTop);
let offset = 0;
for (let i = 0; i < this.itemCount; i++) {
const h = this.getHeight(i);
if (offset + h > scrollTop) return i;
offset += h;
}
return Math.max(0, this.itemCount - 1);
}
/** Compute the visible range with overscan. */
visibleRange(scrollTop: number, clientHeight: number): VisibleRange {
const firstVisible = this.offsetToIndex(scrollTop);
const lastVisible = this.offsetToIndex(scrollTop + clientHeight);
return {
start: Math.max(0, firstVisible - this.opts.overscan),
end: Math.min(this.itemCount, lastVisible + this.opts.overscan + 1),
};
}
/** Compute spacer heights for a rendered range. */
spacerHeights(start: number, end: number): { top: number; bottom: number } {
const top = this.offsetBefore(start);
let bottom: number;
if (this.tree !== null) {
const totalH = this.tree.total();
const endOffset = end > 0 ? this.tree.prefixSum(end - 1) : 0;
bottom = totalH - endOffset;
} else {
bottom = 0;
for (let i = end; i < this.itemCount; i++) {
bottom += this.getHeight(i);
}
}
return { top, bottom };
}
/** Clear all cached heights. */
clear(): void {
this.heightCache.clear();
this.tree = null;
this.itemCount = 0;
}
get size(): number {
return this.itemCount;
}
get treeSize(): number {
return this.tree?.size ?? 0;
}
}
@@ -1687,7 +1687,6 @@ owncordNs.lkDebug = session.getSessionDebugInfo.bind(session);
export const setWsClient = session.setWsClient.bind(session);
export const setServerHost = session.setServerHost.bind(session);
export const setOnError = session.setOnError.bind(session);
export const clearOnError = session.clearOnError.bind(session);
export const setOnRemoteVideo = session.setOnRemoteVideo.bind(session);
export const setOnRemoteVideoRemoved = session.setOnRemoteVideoRemoved.bind(session);
export const clearOnRemoteVideo = session.clearOnRemoteVideo.bind(session);
+4 -29
View File
@@ -5,7 +5,7 @@
// Rotation: keeps the most recent MAX_LOG_FILES days of logs.
import { appLogDir, join } from "@tauri-apps/api/path";
import { mkdir, writeTextFile, readDir, remove, exists, readTextFile } from "@tauri-apps/plugin-fs";
import { mkdir, writeTextFile, readDir, remove, exists } from "@tauri-apps/plugin-fs";
import { type LogEntry, addLogListener, createLogger } from "./logger";
const log = createLogger("logPersistence");
@@ -165,35 +165,10 @@ export async function flushLogs(): Promise<void> {
}
/**
* Get the log directory path (for use in debug bundle export).
* Returns null if persistence hasn't been initialized.
* Get the log directory path. Production-unused but exported as the test
* suite's observability point for persistence state.
* @public
*/
export function getLogDir(): string | null {
return logDir;
}
/**
* Read all persisted log files and return their combined content.
* Intended for on-demand export only (reads all files into memory).
*/
export async function readAllPersistedLogs(): Promise<string> {
if (!logDir) return "";
try {
const entries = await readDir(logDir);
const jsonlFiles = entries
.filter((e) => e.name?.endsWith(".jsonl") && !e.isDirectory)
.map((e) => e.name)
.toSorted((a, b) => a.localeCompare(b));
const parts: string[] = [];
for (const file of jsonlFiles) {
// oxlint-disable-next-line no-await-in-loop -- files must be read in sorted order for correct log concatenation
const content = await readTextFile(`${logDir}/${file}`);
parts.push(content);
}
return parts.join("");
} catch (err) {
log.warn("readAllPersistedLogs failed", err);
return "";
}
}
-172
View File
@@ -1,172 +0,0 @@
/**
* Phase C Step 9 — client-side plugin bridge.
*
* Mounts plugin UI tabs in sandboxed iframes and forwards postMessage traffic
* between the host client and each plugin. The host injects theme CSS
* variables on every load so plugin UIs match OwnCord's look and feel
* without each plugin re-implementing them.
*
* The bridge is intentionally tiny: it owns iframe lifecycles and message
* routing; everything else (rendering tabs, fetching the plugin list) lives
* in PluginContainer.tsx.
*/
export interface PluginTabBinding {
pluginId: number;
pluginName: string;
tabId: string;
label: string;
asset: string;
}
export interface PluginMessageEnvelope {
pluginId: number;
type: string;
payload?: unknown;
}
type Listener = (env: PluginMessageEnvelope) => void;
const HOST_ORIGIN_PREFIX = "owncord-plugin-host";
class PluginBridge {
private frames = new Map<number, HTMLIFrameElement>();
private listeners = new Set<Listener>();
private themeVars: Record<string, string> = {};
private hostOrigin: string;
constructor() {
// Plugin iframes are served from /api/v1/plugins/... on the same origin
// as the host page, so postMessage targets that origin explicitly. Using
// "*" as the target origin is unsafe — any frame the user navigates to
// would receive host messages. window.location.origin is undefined in
// some test runners (jsdom prior to 16); fall back to "/" which still
// restricts to same-origin under the strict postMessage matching rules.
this.hostOrigin =
typeof window !== "undefined" && window.location && window.location.origin
? window.location.origin
: "/";
if (typeof window !== "undefined") {
window.addEventListener("message", this.onMessage);
}
}
/**
* destroy unhooks the global message listener and clears all mounted
* frames. Intended for tests that create disposable bridge instances; the
* exported `pluginBridge` singleton lives for the lifetime of the page and
* does not need explicit teardown.
*/
destroy(): void {
if (typeof window !== "undefined") {
window.removeEventListener("message", this.onMessage);
}
for (const frame of this.frames.values()) {
frame.remove();
}
this.frames.clear();
this.listeners.clear();
}
/** Replace the theme variables broadcast to plugin iframes. */
setTheme(vars: Record<string, string>): void {
this.themeVars = { ...vars };
for (const [pid, frame] of this.frames) {
this.postToFrame(pid, frame, { type: "theme", payload: this.themeVars });
}
}
/** Mount an iframe for binding into parent. Returns a destroy function. */
mount(binding: PluginTabBinding, parent: HTMLElement): () => void {
const iframe = document.createElement("iframe");
iframe.className = "plugin-iframe";
iframe.sandbox.add("allow-scripts");
iframe.title = `${binding.pluginName}: ${binding.label}`;
iframe.src = `/api/v1/plugins/${encodeURIComponent(binding.pluginName)}/ui/${binding.asset}`;
iframe.dataset.pluginId = String(binding.pluginId);
iframe.addEventListener("load", () => {
this.postToFrame(binding.pluginId, iframe, { type: "theme", payload: this.themeVars });
this.postToFrame(binding.pluginId, iframe, { type: "ready", payload: null });
});
parent.appendChild(iframe);
this.frames.set(binding.pluginId, iframe);
return () => {
iframe.remove();
this.frames.delete(binding.pluginId);
};
}
/** Listen for messages emitted by any mounted plugin iframe. */
onMessageEnvelope(listener: Listener): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
/** Send a host → plugin message. */
send(pluginId: number, type: string, payload?: unknown): void {
const frame = this.frames.get(pluginId);
if (!frame) return;
this.postToFrame(pluginId, frame, { type, payload });
}
private postToFrame(
pluginId: number,
frame: HTMLIFrameElement,
msg: { type: string; payload: unknown },
): void {
// Restrict the postMessage target origin to the host page origin so a
// navigated-away iframe (or one whose contentWindow has been swapped)
// cannot receive host messages intended for a sandboxed plugin. The
// plugin asset endpoint is same-origin with the host page, so this
// matches every legitimate plugin iframe.
frame.contentWindow?.postMessage(
{ source: HOST_ORIGIN_PREFIX, pluginId, ...msg },
this.hostOrigin,
);
}
/**
* Look up the pluginId of an iframe by its contentWindow. Returns null if
* the source is not one of our managed plugin frames. This is the key
* defense against postMessage spoofing: we never trust the pluginId field
* inside the message body, only the e.source pointer.
*/
private pluginIdForSource(source: MessageEventSource | null): number | null {
if (!source) return null;
for (const [pid, frame] of this.frames) {
if (frame.contentWindow === source) return pid;
}
return null;
}
private onMessage = (e: MessageEvent): void => {
const data = e.data;
if (!data || typeof data !== "object") return;
if ((data as { source?: unknown }).source === HOST_ORIGIN_PREFIX) return; // own echo
// SECURITY: validate the message originated from one of our managed
// plugin iframes by matching e.source against frame.contentWindow.
// Without this check, any arbitrary frame (including a malicious parent
// frame in an embedding scenario, or any same-origin script that
// obtained a window reference) could spoof messages from any plugin by
// claiming an arbitrary pluginId in the body. The pluginId from the
// message body is intentionally ignored — we use the trusted lookup.
const trustedPluginId = this.pluginIdForSource(e.source);
if (trustedPluginId === null) return;
const env = data as { type?: unknown; payload?: unknown };
if (typeof env.type !== "string") return;
const envelope: PluginMessageEnvelope = {
pluginId: trustedPluginId,
type: env.type,
payload: env.payload,
};
for (const l of this.listeners) {
try {
l(envelope);
} catch (err) {
console.error("plugin bridge listener threw", err);
}
}
};
}
export const pluginBridge = new PluginBridge();
@@ -286,5 +286,3 @@ export function createConnectPage(
},
};
}
export type ConnectPage = ReturnType<typeof createConnectPage>;
@@ -559,5 +559,3 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
return { mount, destroy };
}
export type MainPage = ReturnType<typeof createMainPage>;
@@ -576,99 +576,6 @@ describe("log persistence", () => {
});
});
// -----------------------------------------------------------------------
// readAllPersistedLogs
// -----------------------------------------------------------------------
describe("readAllPersistedLogs", () => {
it("returns empty string when not initialized (logDir is null)", async () => {
const { readAllPersistedLogs } = await freshImport();
const result = await readAllPersistedLogs();
expect(result).toBe("");
});
it("reads and concatenates all jsonl files in sorted order", async () => {
captureListener();
const { initLogPersistence, readAllPersistedLogs } = await freshImport();
await initLogPersistence();
mockReadDir.mockResolvedValueOnce([
{ name: "2025-06-14.jsonl", isDirectory: false },
{ name: "2025-06-15.jsonl", isDirectory: false },
{ name: "2025-06-13.jsonl", isDirectory: false },
]);
mockReadTextFile
.mockResolvedValueOnce('{"day":"13"}\n')
.mockResolvedValueOnce('{"day":"14"}\n')
.mockResolvedValueOnce('{"day":"15"}\n');
const result = await readAllPersistedLogs();
// Files should be read in sorted order: 13, 14, 15
expect(mockReadTextFile).toHaveBeenCalledTimes(3);
expect(mockReadTextFile.mock.calls[0]![0]).toContain("2025-06-13");
expect(mockReadTextFile.mock.calls[1]![0]).toContain("2025-06-14");
expect(mockReadTextFile.mock.calls[2]![0]).toContain("2025-06-15");
expect(result).toBe('{"day":"13"}\n{"day":"14"}\n{"day":"15"}\n');
});
it("filters out directories and non-jsonl entries", async () => {
captureListener();
const { initLogPersistence, readAllPersistedLogs } = await freshImport();
await initLogPersistence();
mockReadDir.mockResolvedValueOnce([
{ name: "2025-06-15.jsonl", isDirectory: false },
{ name: "subdir", isDirectory: true },
{ name: "readme.txt", isDirectory: false },
]);
mockReadTextFile.mockResolvedValueOnce('{"msg":"only"}\n');
const result = await readAllPersistedLogs();
expect(mockReadTextFile).toHaveBeenCalledTimes(1);
expect(result).toBe('{"msg":"only"}\n');
});
it("returns empty string when directory has no jsonl files", async () => {
captureListener();
const { initLogPersistence, readAllPersistedLogs } = await freshImport();
await initLogPersistence();
mockReadDir.mockResolvedValueOnce([{ name: "notes.txt", isDirectory: false }]);
const result = await readAllPersistedLogs();
expect(result).toBe("");
expect(mockReadTextFile).not.toHaveBeenCalled();
});
it("returns empty string on readDir failure", async () => {
captureListener();
const { initLogPersistence, readAllPersistedLogs } = await freshImport();
await initLogPersistence();
mockReadDir.mockRejectedValueOnce(new Error("no access"));
const result = await readAllPersistedLogs();
expect(result).toBe("");
});
it("returns empty string on readTextFile failure", async () => {
captureListener();
const { initLogPersistence, readAllPersistedLogs } = await freshImport();
await initLogPersistence();
mockReadDir.mockResolvedValueOnce([{ name: "2025-06-15.jsonl", isDirectory: false }]);
mockReadTextFile.mockRejectedValueOnce(new Error("corrupt file"));
const result = await readAllPersistedLogs();
// The entire function returns "" on any error
expect(result).toBe("");
});
});
// -----------------------------------------------------------------------
// JSONL format
// -----------------------------------------------------------------------
@@ -780,21 +687,6 @@ describe("log persistence", () => {
expect(mockRemove).not.toHaveBeenCalled();
});
it("handles entries with undefined name in readAllPersistedLogs", async () => {
captureListener();
const { initLogPersistence, readAllPersistedLogs } = await freshImport();
await initLogPersistence();
mockReadDir.mockResolvedValueOnce([
{ name: undefined, isDirectory: false },
{ name: "2025-06-15.jsonl", isDirectory: false },
]);
mockReadTextFile.mockResolvedValueOnce('{"msg":"ok"}\n');
const result = await readAllPersistedLogs();
expect(result).toBe('{"msg":"ok"}\n');
});
it("multiple rapid entries reuse the same debounce timer", async () => {
const { getListener } = captureListener();
const { initLogPersistence } = await freshImport();