mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
@@ -4,7 +4,8 @@
|
||||
"project": ["src/**/*.ts"],
|
||||
"ignore": [
|
||||
"public/**",
|
||||
"src-tauri/**"
|
||||
"src-tauri/**",
|
||||
"src/lib/protocolTypes.ts"
|
||||
],
|
||||
"ignoreDependencies": [
|
||||
"@tauri-apps/cli"
|
||||
|
||||
Generated
+3
-32
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
-67
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user