diff --git a/README.md b/README.md index b0efa188862..8fee85ea258 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ Works with any CLI agent. Built for local worktree-based development. | **タブカラー設定** | タブを右クリック → Set Color で13色から背景色を設定可能。ワークスペースセクションと同じカラーパレットを再利用。アクティブ/非アクティブで濃淡が変化し、設定は自動永続化 | [#12](https://github.com/MocA-Love/superset/pull/12) | 2026-03-29 | | **クラッシュリカバリー強化** | macOS でアプリが白画面/フリーズする問題を修正。GPU クラッシュ時に最大化/フルスクリーンでもコンポジター再構築を実行、レンダラークラッシュ時の自動リロード/再起動、clipboard 操作のエラーハンドリング追加 | [#13](https://github.com/MocA-Love/superset/pull/13) | 2026-03-29 | | **Excel 描画オブジェクト・斜線表示** | Excel ファイルの描画オブジェクト(線・矩形)とセル斜線を表示。xlsx ZIP から drawing XML を直接パースし、CSS transform 方式の SVG オーバーレイで正確に配置 | [#16](https://github.com/MocA-Love/superset/pull/16) | 2026-03-29 | +| **Excel diff インラインハイライト** | Excel 差分表示で変更セル内のテキスト差分を文字レベルでインライン表示。追加部分は緑、削除部分は赤+取り消し線。セルからはみ出る場合はホバーでツールチップにフル差分を表示 | [#19](https://github.com/MocA-Love/superset/pull/19) | 2026-03-29 | ## Fork のビルド方法 (macOS) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 1535fd0f46e..3f3106fe123 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -156,6 +156,7 @@ "clsx": "^2.1.1", "culori": "^4.0.2", "date-fns": "^4.1.0", + "diff": "^7.0.0", "default-shell": "^2.2.0", "dnd-core": "^16.0.1", "dotenv": "^17.3.1", @@ -230,6 +231,7 @@ "@tanstack/router-cli": "^1.149.0", "@tanstack/router-plugin": "^1.149.0", "@types/better-sqlite3": "^7.6.13", + "@types/diff": "^6.0.0", "@types/bun": "^1.2.17", "@types/culori": "^4.0.1", "@types/http-proxy": "^1.17.17", diff --git a/apps/desktop/src/lib/trpc/routers/extensions/index.ts b/apps/desktop/src/lib/trpc/routers/extensions/index.ts new file mode 100644 index 00000000000..a7ad8d8ff99 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/extensions/index.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; +import { publicProcedure, router } from "../.."; +import { + installExtension, + listExtensions, + toggleExtension, + uninstallExtension, +} from "main/lib/extensions/extension-manager"; + +export const createExtensionsRouter = () => { + return router({ + list: publicProcedure.query(async () => { + return listExtensions(); + }), + + install: publicProcedure + .input(z.object({ input: z.string() })) + .mutation(async ({ input }) => { + return installExtension(input.input); + }), + + uninstall: publicProcedure + .input(z.object({ extensionId: z.string() })) + .mutation(async ({ input }) => { + await uninstallExtension(input.extensionId); + }), + + toggle: publicProcedure + .input(z.object({ extensionId: z.string(), enabled: z.boolean() })) + .mutation(async ({ input }) => { + return toggleExtension(input.extensionId, input.enabled); + }), + }); +}; diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index 40e786bdcfc..903ac37169d 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -28,6 +28,7 @@ import { createTerminalRouter } from "./terminal"; import { createUiStateRouter } from "./ui-state"; import { createWindowRouter } from "./window"; import { createTabTearoffRouter } from "./tab-tearoff"; +import { createExtensionsRouter } from "./extensions"; import { createWorkspacesRouter } from "./workspaces"; export const createAppRouter = ( @@ -63,6 +64,7 @@ export const createAppRouter = ( ringtone: createRingtoneRouter(getWindow), hostServiceManager: createHostServiceManagerRouter(), tabTearoff: createTabTearoffRouter(wm), + extensions: createExtensionsRouter(), }); }; diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 8e41a9236c7..f2188e58411 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -28,6 +28,7 @@ import { setupAutoUpdater } from "./lib/auto-updater"; import { resolveDevWorkspaceName } from "./lib/dev-workspace-name"; import { setWorkspaceDockIcon } from "./lib/dock-icon"; import { loadWebviewBrowserExtension } from "./lib/extensions"; +import { loadInstalledExtensions } from "./lib/extensions/extension-manager"; import { getHostServiceManager } from "./lib/host-service-manager"; import { localDb } from "./lib/local-db"; import { ensureProjectIconsDir, getProjectIconPath } from "./lib/project-icons"; @@ -334,6 +335,7 @@ if (!gotTheLock) { await initAppState(); await loadWebviewBrowserExtension(); + await loadInstalledExtensions(); // Must happen before renderer restore runs await reconcileDaemonSessions(); diff --git a/apps/desktop/src/main/lib/extensions/compatibility-checker.ts b/apps/desktop/src/main/lib/extensions/compatibility-checker.ts new file mode 100644 index 00000000000..d0bbc28928d --- /dev/null +++ b/apps/desktop/src/main/lib/extensions/compatibility-checker.ts @@ -0,0 +1,273 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { glob } from "fast-glob"; +import type { ChromeManifest } from "./crx-downloader"; + +/** APIs fully supported in Electron */ +const SUPPORTED_APIS = new Set([ + "chrome.devtools.inspectedWindow", + "chrome.devtools.network", + "chrome.devtools.panels", + "chrome.scripting", + "chrome.webRequest", + "chrome.storage.local", + "chrome.runtime.lastError", + "chrome.runtime.id", + "chrome.runtime.getManifest", + "chrome.runtime.getURL", + "chrome.runtime.connect", + "chrome.runtime.sendMessage", + "chrome.runtime.onConnect", + "chrome.runtime.onMessage", + "chrome.runtime.onInstalled", + "chrome.runtime.onStartup", + "chrome.extension.getURL", + "chrome.extension.getBackgroundPage", +]); + +/** Permissions that Electron cannot provide */ +const UNSUPPORTED_PERMISSIONS = new Set([ + "bookmarks", + "browsingData", + "contentSettings", + "cookies", + "debugger", + "declarativeContent", + "declarativeNetRequest", + "desktopCapture", + "downloads", + "downloads.shelf", + "enterprise.deviceAttributes", + "enterprise.platformKeys", + "fontSettings", + "gcm", + "geolocation", + "history", + "identity", + "idle", + "loginState", + "nativeMessaging", + "notifications", + "pageCapture", + "platformKeys", + "power", + "printerProvider", + "printing", + "printingMetrics", + "privacy", + "proxy", + "search", + "sessions", + "signedInDevices", + "system.cpu", + "system.display", + "system.memory", + "system.storage", + "tabCapture", + "tabGroups", + "topSites", + "tts", + "ttsEngine", + "wallpaper", + "webNavigation", +]); + +/** chrome.* API patterns that don't work in Electron */ +const UNSUPPORTED_API_PATTERNS = [ + "chrome.bookmarks", + "chrome.browsingData", + "chrome.contentSettings", + "chrome.cookies", + "chrome.debugger", + "chrome.declarativeContent", + "chrome.declarativeNetRequest", + "chrome.desktopCapture", + "chrome.downloads", + "chrome.fontSettings", + "chrome.gcm", + "chrome.history", + "chrome.identity", + "chrome.notifications", + "chrome.pageCapture", + "chrome.privacy", + "chrome.proxy", + "chrome.sessions", + "chrome.tabCapture", + "chrome.tabGroups", + "chrome.topSites", + "chrome.tts", + "chrome.ttsEngine", + "chrome.webNavigation", + "chrome.storage.sync", + "chrome.storage.managed", + "chrome.tabs.create", + "chrome.tabs.remove", + "chrome.tabs.move", + "chrome.tabs.group", + "chrome.tabs.ungroup", + "chrome.tabs.duplicate", + "chrome.tabs.discard", + "chrome.tabs.captureVisibleTab", + "chrome.tabs.goBack", + "chrome.tabs.goForward", + "chrome.windows.create", + "chrome.windows.remove", + "chrome.windows.update", +]; + +export type CompatibilityLevel = "full" | "partial" | "low"; + +export interface CompatibilityIssue { + type: "unsupported_permission" | "unsupported_api" | "unsupported_feature"; + severity: "warning" | "error"; + message: string; + detail?: string; +} + +export interface CompatibilityReport { + level: CompatibilityLevel; + issues: CompatibilityIssue[]; + summary: string; +} + +/** + * Check extension manifest for unsupported features. + */ +function checkManifest(manifest: ChromeManifest): CompatibilityIssue[] { + const issues: CompatibilityIssue[] = []; + + // Check permissions + const allPermissions = [ + ...(manifest.permissions ?? []), + ...(manifest.optional_permissions ?? []), + ]; + + for (const perm of allPermissions) { + if (UNSUPPORTED_PERMISSIONS.has(perm)) { + issues.push({ + type: "unsupported_permission", + severity: "warning", + message: `Permission "${perm}" is not supported in Electron`, + }); + } + } + + // Check popup UI + const action = manifest.action ?? manifest.browser_action; + if (action?.default_popup) { + issues.push({ + type: "unsupported_feature", + severity: "warning", + message: "Popup UI (browser_action/action popup) is not supported", + detail: + "The extension's popup window will not be displayed in Electron", + }); + } + + // Check chrome_url_overrides + if (manifest.chrome_url_overrides) { + issues.push({ + type: "unsupported_feature", + severity: "error", + message: "Chrome URL overrides (new tab, history, bookmarks pages) are not supported", + }); + } + + // Check options_ui + if (manifest.options_ui || manifest.options_page) { + issues.push({ + type: "unsupported_feature", + severity: "warning", + message: "Options page may not work as expected", + detail: + "Extension options pages rely on chrome.runtime.openOptionsPage() which has limited support", + }); + } + + return issues; +} + +/** + * Scan the extension's JS files for usage of unsupported chrome.* APIs. + */ +async function scanJsForUnsupportedApis( + extensionDir: string, +): Promise { + const issues: CompatibilityIssue[] = []; + const seen = new Set(); + + const jsFiles = await glob("**/*.js", { + cwd: extensionDir, + absolute: true, + ignore: ["**/node_modules/**"], + }); + + for (const file of jsFiles) { + let content: string; + try { + content = await readFile(file, "utf-8"); + } catch { + continue; + } + + for (const api of UNSUPPORTED_API_PATTERNS) { + if (seen.has(api)) continue; + + // Escape dots for regex, match the API call pattern + const pattern = api.replace(/\./g, "\\."); + const regex = new RegExp(`${pattern}\\b`); + + if (regex.test(content)) { + seen.add(api); + issues.push({ + type: "unsupported_api", + severity: "warning", + message: `Uses "${api}" which is not supported in Electron`, + detail: `Found in ${path.basename(file)}`, + }); + } + } + } + + return issues; +} + +/** + * Run a full compatibility check on an unpacked extension. + */ +export async function checkCompatibility( + extensionDir: string, + manifest: ChromeManifest, +): Promise { + const manifestIssues = checkManifest(manifest); + const apiIssues = await scanJsForUnsupportedApis(extensionDir); + + const issues = [...manifestIssues, ...apiIssues]; + + const errorCount = issues.filter((i) => i.severity === "error").length; + const warningCount = issues.filter((i) => i.severity === "warning").length; + + let level: CompatibilityLevel; + if (errorCount > 0 || warningCount >= 5) { + level = "low"; + } else if (warningCount > 0) { + level = "partial"; + } else { + level = "full"; + } + + let summary: string; + switch (level) { + case "full": + summary = "This extension is expected to work well in Electron."; + break; + case "partial": + summary = `This extension may have limited functionality (${warningCount} potential issue${warningCount > 1 ? "s" : ""}).`; + break; + case "low": + summary = `This extension is likely incompatible (${errorCount} critical, ${warningCount} warning${warningCount > 1 ? "s" : ""}).`; + break; + } + + return { level, issues, summary }; +} diff --git a/apps/desktop/src/main/lib/extensions/crx-downloader.ts b/apps/desktop/src/main/lib/extensions/crx-downloader.ts new file mode 100644 index 00000000000..4835cf25b7b --- /dev/null +++ b/apps/desktop/src/main/lib/extensions/crx-downloader.ts @@ -0,0 +1,261 @@ +import { createWriteStream, existsSync, mkdirSync } from "node:fs"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { pipeline } from "node:stream/promises"; +import { Readable } from "node:stream"; +import { app, net } from "electron"; +import JSZip from "jszip"; + +/** Electron version string used in the CRX download URL */ +const ELECTRON_VERSION = process.versions.chrome ?? "130.0.0.0"; + +const CRX_DOWNLOAD_URL = + "https://clients2.google.com/service/update2/crx?response=redirect&prodversion=VERSION&acceptformat=crx2,crx3&x=id%3DID%26uc"; + +/** + * Parse a Chrome Web Store URL or raw extension ID into just the extension ID. + * + * Accepts: + * - Full URL: https://chromewebstore.google.com/detail/some-name/abcdefghijklmnopabcdefghijklmnop + * - Short URL: https://chrome.google.com/webstore/detail/abcdefghijklmnopabcdefghijklmnop + * - Raw 32-char extension ID: abcdefghijklmnopabcdefghijklmnop + */ +export function parseExtensionId(input: string): string | null { + const trimmed = input.trim(); + + // Raw extension ID (32 lowercase alpha chars) + if (/^[a-p]{32}$/.test(trimmed)) return trimmed; + + try { + const url = new URL(trimmed); + // New Chrome Web Store: /detail// or /detail/ + const segments = url.pathname.split("/").filter(Boolean); + for (const seg of segments) { + if (/^[a-p]{32}$/.test(seg)) return seg; + } + } catch { + // Not a URL + } + + return null; +} + +/** + * Build the CRX download URL from an extension ID. + */ +function buildCrxUrl(extensionId: string): string { + return CRX_DOWNLOAD_URL.replace("VERSION", ELECTRON_VERSION).replace( + "ID", + extensionId, + ); +} + +/** + * Get the root directory where user-installed extensions are stored. + */ +export function getExtensionsDir(): string { + return path.join(app.getPath("userData"), "extensions"); +} + +/** + * Download a CRX file from Google's update servers. + * Returns the path to the downloaded CRX file. + */ +async function downloadCrx(extensionId: string): Promise { + const tmpDir = path.join(os.tmpdir(), `superset-crx-${extensionId}`); + if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true }); + + const crxPath = path.join(tmpDir, `${extensionId}.crx`); + const url = buildCrxUrl(extensionId); + + const response = await net.fetch(url, { redirect: "follow" }); + if (!response.ok) { + throw new Error( + `Failed to download extension ${extensionId}: HTTP ${response.status}`, + ); + } + + const body = response.body; + if (!body) throw new Error("Empty response body"); + + const arrayBuffer = await response.arrayBuffer(); + await writeFile(crxPath, Buffer.from(arrayBuffer)); + + return crxPath; +} + +/** + * Strip the CRX header and extract the ZIP payload. + * + * CRX3 format: + * [4 bytes] "Cr24" magic number + * [4 bytes] CRX version (3) + * [4 bytes] header length + * [header_length bytes] protobuf header + * [rest] ZIP data + * + * CRX2 format: + * [4 bytes] "Cr24" magic number + * [4 bytes] CRX version (2) + * [4 bytes] public key length + * [4 bytes] signature length + * [public_key_length bytes] public key + * [signature_length bytes] signature + * [rest] ZIP data + */ +function extractZipFromCrx(crxBuffer: Buffer): Buffer { + const magic = crxBuffer.toString("ascii", 0, 4); + if (magic !== "Cr24") { + // Maybe it's already a ZIP + if (crxBuffer[0] === 0x50 && crxBuffer[1] === 0x4b) { + return crxBuffer; + } + throw new Error(`Invalid CRX file: unexpected magic "${magic}"`); + } + + const version = crxBuffer.readUInt32LE(4); + + if (version === 3) { + const headerLength = crxBuffer.readUInt32LE(8); + const zipStart = 12 + headerLength; + return crxBuffer.subarray(zipStart); + } + + if (version === 2) { + const pubKeyLength = crxBuffer.readUInt32LE(8); + const sigLength = crxBuffer.readUInt32LE(12); + const zipStart = 16 + pubKeyLength + sigLength; + return crxBuffer.subarray(zipStart); + } + + throw new Error(`Unsupported CRX version: ${version}`); +} + +/** + * Unpack a ZIP buffer into the target directory. + */ +async function unpackZip( + zipBuffer: Buffer, + targetDir: string, +): Promise { + const zip = await JSZip.loadAsync(zipBuffer); + + await mkdir(targetDir, { recursive: true }); + + const entries = Object.entries(zip.files); + for (const [relativePath, file] of entries) { + const fullPath = path.join(targetDir, relativePath); + + if (file.dir) { + await mkdir(fullPath, { recursive: true }); + continue; + } + + // Ensure parent directory exists + await mkdir(path.dirname(fullPath), { recursive: true }); + + const content = await file.async("nodebuffer"); + await writeFile(fullPath, content); + } +} + +export interface CrxDownloadResult { + extensionId: string; + extensionDir: string; + manifest: ChromeManifest; +} + +export interface ChromeManifest { + manifest_version: number; + name: string; + version: string; + description?: string; + permissions?: string[]; + optional_permissions?: string[]; + host_permissions?: string[]; + background?: { + service_worker?: string; + scripts?: string[]; + page?: string; + }; + content_scripts?: Array<{ + matches: string[]; + js?: string[]; + css?: string[]; + run_at?: string; + }>; + action?: { + default_popup?: string; + default_icon?: string | Record; + default_title?: string; + }; + browser_action?: { + default_popup?: string; + default_icon?: string | Record; + default_title?: string; + }; + icons?: Record; + devtools_page?: string; + chrome_url_overrides?: Record; + options_ui?: { page: string; open_in_tab?: boolean }; + options_page?: string; +} + +/** + * Download and install an extension from the Chrome Web Store. + * + * 1. Download the CRX + * 2. Strip the CRX header to get the ZIP + * 3. Extract into userData/extensions/ + * 4. Return the extracted manifest + */ +export async function downloadAndExtractExtension( + extensionId: string, +): Promise { + const extensionsRoot = getExtensionsDir(); + const extensionDir = path.join(extensionsRoot, extensionId); + + // Clean up any previous install + if (existsSync(extensionDir)) { + await rm(extensionDir, { recursive: true, force: true }); + } + + let crxPath: string | null = null; + try { + // Download + crxPath = await downloadCrx(extensionId); + + // Extract ZIP from CRX + const crxBuffer = await readFile(crxPath); + const zipBuffer = extractZipFromCrx(crxBuffer); + + // Unpack + await unpackZip(zipBuffer, extensionDir); + + // Read manifest + const manifestPath = path.join(extensionDir, "manifest.json"); + if (!existsSync(manifestPath)) { + throw new Error("Extension does not contain a manifest.json"); + } + const manifest: ChromeManifest = JSON.parse( + await readFile(manifestPath, "utf-8"), + ); + + return { extensionId, extensionDir, manifest }; + } catch (error) { + // Clean up on failure + if (existsSync(extensionDir)) { + await rm(extensionDir, { recursive: true, force: true }).catch( + () => {}, + ); + } + throw error; + } finally { + // Clean up temp CRX + if (crxPath) { + const tmpDir = path.dirname(crxPath); + await rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + } + } +} diff --git a/apps/desktop/src/main/lib/extensions/extension-manager.ts b/apps/desktop/src/main/lib/extensions/extension-manager.ts new file mode 100644 index 00000000000..7ad74cac3ab --- /dev/null +++ b/apps/desktop/src/main/lib/extensions/extension-manager.ts @@ -0,0 +1,251 @@ +import { existsSync } from "node:fs"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { app, session } from "electron"; +import type { CompatibilityReport } from "./compatibility-checker"; +import { checkCompatibility } from "./compatibility-checker"; +import { + type ChromeManifest, + type CrxDownloadResult, + downloadAndExtractExtension, + getExtensionsDir, + parseExtensionId, +} from "./crx-downloader"; + +const APP_PARTITION = "persist:superset"; + +export interface InstalledExtension { + id: string; + name: string; + version: string; + description: string; + enabled: boolean; + installedAt: string; + compatibility: CompatibilityReport; + iconPath?: string; +} + +interface ExtensionStore { + extensions: InstalledExtension[]; +} + +function getStorePath(): string { + return path.join(app.getPath("userData"), "extension-store.json"); +} + +async function readStore(): Promise { + const storePath = getStorePath(); + try { + const data = await readFile(storePath, "utf-8"); + return JSON.parse(data) as ExtensionStore; + } catch { + return { extensions: [] }; + } +} + +async function writeStore(store: ExtensionStore): Promise { + const storePath = getStorePath(); + await writeFile(storePath, JSON.stringify(store, null, 2), "utf-8"); +} + +/** + * Resolve the best icon path from the manifest icons object. + */ +function resolveIconPath( + manifest: ChromeManifest, + extensionDir: string, +): string | undefined { + if (!manifest.icons) return undefined; + + // Prefer larger icons + const sizes = Object.keys(manifest.icons) + .map(Number) + .sort((a, b) => b - a); + + for (const size of sizes) { + const iconRelPath = manifest.icons[String(size)]; + if (iconRelPath) { + const fullPath = path.join(extensionDir, iconRelPath); + if (existsSync(fullPath)) return fullPath; + } + } + + return undefined; +} + +/** + * Load all enabled extensions into the Electron session. + * Called at app startup. + */ +export async function loadInstalledExtensions(): Promise { + const store = await readStore(); + const ses = session.fromPartition(APP_PARTITION); + + for (const ext of store.extensions) { + if (!ext.enabled) continue; + + const extensionDir = path.join(getExtensionsDir(), ext.id); + if (!existsSync(path.join(extensionDir, "manifest.json"))) { + console.warn( + `[extensions] Extension ${ext.id} (${ext.name}) directory missing, skipping`, + ); + continue; + } + + try { + // Skip if already loaded + if (ses.extensions.getExtension(ext.id)) continue; + + await ses.extensions.loadExtension(extensionDir); + console.log(`[extensions] Loaded: ${ext.name} v${ext.version}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes("already loaded")) continue; + console.error(`[extensions] Failed to load ${ext.name}:`, error); + } + } +} + +/** + * Install an extension from the Chrome Web Store. + */ +export async function installExtension(input: string): Promise { + const extensionId = parseExtensionId(input); + if (!extensionId) { + throw new Error( + "Invalid input. Please provide a Chrome Web Store URL or extension ID.", + ); + } + + // Check if already installed + const store = await readStore(); + const existing = store.extensions.find((e) => e.id === extensionId); + if (existing) { + throw new Error(`Extension "${existing.name}" is already installed.`); + } + + // Download and extract + const result = await downloadAndExtractExtension(extensionId); + + // Run compatibility check + const compatibility = await checkCompatibility( + result.extensionDir, + result.manifest, + ); + + const iconPath = resolveIconPath(result.manifest, result.extensionDir); + + const installed: InstalledExtension = { + id: extensionId, + name: result.manifest.name, + version: result.manifest.version, + description: result.manifest.description ?? "", + enabled: true, + installedAt: new Date().toISOString(), + compatibility, + iconPath, + }; + + // Load into session + const ses = session.fromPartition(APP_PARTITION); + try { + await ses.extensions.loadExtension(result.extensionDir); + console.log( + `[extensions] Installed and loaded: ${installed.name} v${installed.version}`, + ); + } catch (error) { + console.error( + `[extensions] Installed but failed to load ${installed.name}:`, + error, + ); + installed.enabled = false; + } + + // Persist + store.extensions.push(installed); + await writeStore(store); + + return installed; +} + +/** + * Uninstall an extension. + */ +export async function uninstallExtension(extensionId: string): Promise { + const store = await readStore(); + const idx = store.extensions.findIndex((e) => e.id === extensionId); + if (idx === -1) { + throw new Error("Extension not found."); + } + + // Unload from session + const ses = session.fromPartition(APP_PARTITION); + try { + ses.extensions.removeExtension(extensionId); + } catch { + // May not be loaded + } + + // Remove files + const extensionDir = path.join(getExtensionsDir(), extensionId); + if (existsSync(extensionDir)) { + await rm(extensionDir, { recursive: true, force: true }); + } + + // Update store + store.extensions.splice(idx, 1); + await writeStore(store); + + console.log(`[extensions] Uninstalled: ${extensionId}`); +} + +/** + * Toggle an extension's enabled state. + */ +export async function toggleExtension( + extensionId: string, + enabled: boolean, +): Promise { + const store = await readStore(); + const ext = store.extensions.find((e) => e.id === extensionId); + if (!ext) { + throw new Error("Extension not found."); + } + + const ses = session.fromPartition(APP_PARTITION); + + if (enabled) { + const extensionDir = path.join(getExtensionsDir(), extensionId); + if (!existsSync(path.join(extensionDir, "manifest.json"))) { + throw new Error("Extension files are missing. Please reinstall."); + } + try { + if (!ses.extensions.getExtension(extensionId)) { + await ses.extensions.loadExtension(extensionDir); + } + } catch (error) { + throw new Error( + `Failed to enable extension: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } else { + try { + ses.extensions.removeExtension(extensionId); + } catch { + // Already unloaded + } + } + + ext.enabled = enabled; + await writeStore(store); + + return ext; +} + +/** + * List all installed extensions. + */ +export async function listExtensions(): Promise { + const store = await readStore(); + return store.extensions; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/GeneralSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/GeneralSettings.tsx index f464e5c705a..ec0513b8635 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/GeneralSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/GeneralSettings.tsx @@ -11,6 +11,7 @@ import { HiOutlinePuzzlePiece, HiOutlineShieldCheck, HiOutlineSparkles, + HiOutlineSquare3Stack3D, HiOutlineUser, } from "react-icons/hi2"; import { LuBrain, LuGitBranch, LuKeyboard } from "react-icons/lu"; @@ -33,6 +34,7 @@ type SettingsRoute = | "/settings/terminal" | "/settings/models" | "/settings/integrations" + | "/settings/extensions" | "/settings/billing" | "/settings/api-keys" | "/settings/permissions"; @@ -113,6 +115,12 @@ const SECTION_GROUPS: SectionGroup[] = [ label: "Models", icon: , }, + { + id: "/settings/extensions", + section: "extensions", + label: "Extensions", + icon: , + }, ], }, { diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/extensions/components/ExtensionsSettings/ExtensionsSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/extensions/components/ExtensionsSettings/ExtensionsSettings.tsx new file mode 100644 index 00000000000..13b0fba58ea --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/extensions/components/ExtensionsSettings/ExtensionsSettings.tsx @@ -0,0 +1,262 @@ +import { Badge } from "@superset/ui/badge"; +import { Button } from "@superset/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, +} from "@superset/ui/card"; +import { Input } from "@superset/ui/input"; +import { useCallback, useState } from "react"; +import { + HiOutlineGlobeAlt, + HiOutlinePuzzlePiece, + HiOutlineTrash, +} from "react-icons/hi2"; +import { LuLoaderCircle } from "react-icons/lu"; +import { electronTrpc } from "renderer/lib/electron-trpc"; + +export function ExtensionsSettings() { + const [installInput, setInstallInput] = useState(""); + const [isInstalling, setIsInstalling] = useState(false); + const [error, setError] = useState(null); + + const utils = electronTrpc.useUtils(); + const { data: extensions, isLoading } = + electronTrpc.extensions.list.useQuery(); + + const installMutation = electronTrpc.extensions.install.useMutation({ + onSuccess: () => { + setInstallInput(""); + setError(null); + utils.extensions.list.invalidate(); + }, + onError: (err) => { + setError(err.message); + }, + onSettled: () => { + setIsInstalling(false); + }, + }); + + const uninstallMutation = electronTrpc.extensions.uninstall.useMutation({ + onSuccess: () => { + utils.extensions.list.invalidate(); + }, + }); + + const toggleMutation = electronTrpc.extensions.toggle.useMutation({ + onSuccess: () => { + utils.extensions.list.invalidate(); + }, + }); + + const handleInstall = useCallback(() => { + if (!installInput.trim()) return; + setIsInstalling(true); + setError(null); + installMutation.mutate({ input: installInput.trim() }); + }, [installInput, installMutation]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleInstall(); + } + }, + [handleInstall], + ); + + return ( +
+
+

Browser Extensions

+

+ Install Chrome extensions from the Chrome Web Store +

+
+ + {/* Install form */} + + +
+
+ +
+
+ Install from Chrome Web Store + + Paste a Chrome Web Store URL or extension ID + +
+
+
+ +
+ { + setInstallInput(e.target.value); + setError(null); + }} + onKeyDown={handleKeyDown} + placeholder="https://chromewebstore.google.com/detail/... or extension ID" + className="flex-1" + disabled={isInstalling} + /> + +
+ {error && ( +

{error}

+ )} +
+
+ + {/* Installed extensions list */} + {isLoading ? ( +
+ + Loading extensions... +
+ ) : extensions && extensions.length > 0 ? ( +
+ {extensions.map((ext) => ( + + +
+
+
+ +
+
+
+ + {ext.name} + + + v{ext.version} + + +
+ {ext.description && ( + + {ext.description} + + )} +
+
+
+ + +
+
+
+ {ext.compatibility.issues.length > 0 && ( + +
+ + {ext.compatibility.issues.length} compatibility{" "} + {ext.compatibility.issues.length === 1 + ? "issue" + : "issues"} + +
    + {ext.compatibility.issues.map((issue, i) => ( +
  • + + {issue.severity === "error" ? "x" : "!"} + + {issue.message} +
  • + ))} +
+
+
+ )} +
+ ))} +
+ ) : ( +
+ +

No extensions installed

+

+ Install extensions from the Chrome Web Store using the form + above. Not all extensions are compatible with Electron. +

+
+ )} +
+ ); +} + +function CompatibilityBadge({ + level, +}: { level: "full" | "partial" | "low" }) { + switch (level) { + case "full": + return ( + + Compatible + + ); + case "partial": + return ( + + Partial + + ); + case "low": + return ( + + Low Compat + + ); + } +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/extensions/components/ExtensionsSettings/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/extensions/components/ExtensionsSettings/index.ts new file mode 100644 index 00000000000..ad94fa55215 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/extensions/components/ExtensionsSettings/index.ts @@ -0,0 +1 @@ +export { ExtensionsSettings } from "./ExtensionsSettings"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/extensions/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/extensions/page.tsx new file mode 100644 index 00000000000..a89f64c8080 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/extensions/page.tsx @@ -0,0 +1,12 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { ExtensionsSettings } from "./components/ExtensionsSettings"; + +export const Route = createFileRoute( + "/_authenticated/settings/extensions/", +)({ + component: ExtensionsSettingsPage, +}); + +function ExtensionsSettingsPage() { + return ; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx index d148a36582d..df0396ec77c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx @@ -26,6 +26,7 @@ const SECTION_ORDER: SettingsSection[] = [ "git", "terminal", "models", + "extensions", "organization", "integrations", "billing", @@ -44,6 +45,7 @@ function getSectionFromPath(pathname: string): SettingsSection | null { if (pathname.includes("/settings/terminal")) return "terminal"; if (pathname.includes("/settings/models")) return "models"; if (pathname.includes("/settings/integrations")) return "integrations"; + if (pathname.includes("/settings/extensions")) return "extensions"; if (pathname.includes("/settings/permissions")) return "permissions"; if (pathname.includes("/settings/project")) return "project"; return null; @@ -71,6 +73,8 @@ function getPathFromSection(section: SettingsSection): string { return "/settings/models"; case "integrations": return "/settings/integrations"; + case "extensions": + return "/settings/extensions"; case "permissions": return "/settings/permissions"; default: diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts index d7e56ed6276..dc66f8b1e42 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts @@ -62,6 +62,8 @@ export const SETTING_ITEM_ID = { API_KEYS_LIST: "api-keys-list", API_KEYS_GENERATE: "api-keys-generate", + EXTENSIONS_BROWSER: "extensions-browser", + PERMISSIONS_FULL_DISK_ACCESS: "permissions-full-disk-access", PERMISSIONS_ACCESSIBILITY: "permissions-accessibility", PERMISSIONS_MICROPHONE: "permissions-microphone", @@ -896,6 +898,22 @@ export const SETTINGS_ITEMS: SettingsItem[] = [ "claude code", ], }, + { + id: SETTING_ITEM_ID.EXTENSIONS_BROWSER, + section: "extensions", + title: "Browser Extensions", + description: "Install and manage Chrome extensions from the Chrome Web Store", + keywords: [ + "extensions", + "chrome", + "browser", + "web store", + "addon", + "plugin", + "install", + "crx", + ], + }, { id: SETTING_ITEM_ID.PERMISSIONS_FULL_DISK_ACCESS, section: "permissions", diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/SpreadsheetViewer/SpreadsheetDiffViewer.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/SpreadsheetViewer/SpreadsheetDiffViewer.tsx index f1af2950115..c05e165b185 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/SpreadsheetViewer/SpreadsheetDiffViewer.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/SpreadsheetViewer/SpreadsheetDiffViewer.tsx @@ -1,8 +1,21 @@ -import { type RefObject, useCallback, useMemo, useRef, useState } from "react"; +import { + type RefObject, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { createPortal } from "react-dom"; import type { ChangeCategory } from "shared/changes-types"; import useResizeObserver from "use-resize-observer"; import type { ParsedCell, RichTextPart } from "./parseWorkbook"; -import { type DiffParsedRow, useSpreadsheetDiff } from "./useSpreadsheetDiff"; +import { + type DiffParsedCell, + type DiffParsedRow, + type DiffSegment, + useSpreadsheetDiff, +} from "./useSpreadsheetDiff"; interface SpreadsheetDiffViewerProps { workspaceId: string; @@ -48,6 +61,147 @@ function CellContent({ cell }: { cell: ParsedCell }) { return <>{cell.value}; } +function InlineDiffContent({ segments }: { segments: DiffSegment[] }) { + return ( + <> + {segments.map((seg, i) => { + const key = `${i}-${seg.type}-${seg.text.slice(0, 8)}`; + switch (seg.type) { + case "added": + return ( + + {seg.text} + + ); + case "removed": + return ( + + {seg.text} + + ); + default: + return {seg.text}; + } + })} + + ); +} + +function DiffCellTooltip({ + segments, + anchorEl, +}: { + segments: DiffSegment[]; + anchorEl: HTMLElement; +}) { + const [pos, setPos] = useState({ top: 0, left: 0 }); + const tooltipRef = useRef(null); + + useEffect(() => { + const rect = anchorEl.getBoundingClientRect(); + const top = rect.top - 6; + const left = Math.min(rect.left, window.innerWidth - 500); + setPos({ top, left: Math.max(4, left) }); + }, [anchorEl]); + + return createPortal( +
+ +
, + document.body, + ); +} + +interface DiffCellProps { + cell: DiffParsedCell; + cellKey: string; +} + +function DiffCell({ cell, cellKey }: DiffCellProps) { + const [hovered, setHovered] = useState(false); + const tdRef = useRef(null); + + const cellStyle: React.CSSProperties = { + overflow: "hidden", + padding: "1px 2px", + whiteSpace: "nowrap", + lineHeight: "normal", + boxSizing: "border-box", + ...cell.style, + }; + if (cell.diffStatus) { + cellStyle.backgroundColor = DIFF_BG[cell.diffStatus]; + cellStyle.outline = DIFF_BORDER[cell.diffStatus]; + cellStyle.outlineOffset = "-2px"; + } + if (cell.wrapText) { + cellStyle.whiteSpace = "pre-wrap"; + cellStyle.wordBreak = "break-all"; + } + if (cell.diffSegments) { + cellStyle.cursor = "default"; + } + + return ( + setHovered(true) : undefined} + onMouseLeave={cell.diffSegments ? () => setHovered(false) : undefined} + > + {cell.diffSegments ? ( + + ) : ( + + )} + {hovered && cell.diffSegments && tdRef.current && ( + + )} + + ); +} + function DiffTable({ rows, columnWidths, @@ -160,33 +314,12 @@ function DiffTable({ {row.cells.map((cell, colIdx) => { if (cell.hidden) return null; - const cellStyle: React.CSSProperties = { - overflow: "hidden", - padding: "1px 2px", - whiteSpace: "nowrap", - lineHeight: "normal", - boxSizing: "border-box", - ...cell.style, - }; - if (cell.diffStatus) { - cellStyle.backgroundColor = DIFF_BG[cell.diffStatus]; - cellStyle.outline = DIFF_BORDER[cell.diffStatus]; - cellStyle.outlineOffset = "-2px"; - } - if (cell.wrapText) { - cellStyle.whiteSpace = "pre-wrap"; - cellStyle.wordBreak = "break-all"; - } - return ( - - - + cell={cell} + cellKey={`${rowIdx + 1}-${getColumnLabel(colIdx)}`} + /> ); })} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/SpreadsheetViewer/useSpreadsheetDiff.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/SpreadsheetViewer/useSpreadsheetDiff.ts index 69eea1ab308..c6a0f94f6ea 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/SpreadsheetViewer/useSpreadsheetDiff.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/SpreadsheetViewer/useSpreadsheetDiff.ts @@ -1,3 +1,4 @@ +import { diffChars } from "diff"; import { useEffect, useMemo, useState } from "react"; import { electronTrpc } from "renderer/lib/electron-trpc"; import type { ChangeCategory } from "shared/changes-types"; @@ -5,8 +6,14 @@ import type { ParsedCell, ParsedSheet } from "./useSpreadsheetData"; const MAX_SPREADSHEET_SIZE = 10 * 1024 * 1024; +export interface DiffSegment { + text: string; + type: "added" | "removed" | "unchanged"; +} + export interface DiffParsedCell extends ParsedCell { diffStatus?: "added" | "removed" | "modified"; + diffSegments?: DiffSegment[]; } export interface DiffParsedRow { @@ -23,6 +30,31 @@ export interface DiffParsedSheet { sheetStatus?: "added" | "removed"; } +function computeDiffSegments( + oldValue: string, + newValue: string, + side: "original" | "modified", +): DiffSegment[] { + const changes = diffChars(oldValue, newValue); + const segments: DiffSegment[] = []; + for (const change of changes) { + if (change.added) { + if (side === "modified") { + segments.push({ text: change.value, type: "added" }); + } + // skip added parts on original side + } else if (change.removed) { + if (side === "original") { + segments.push({ text: change.value, type: "removed" }); + } + // skip removed parts on modified side + } else { + segments.push({ text: change.value, type: "unchanged" }); + } + } + return segments; +} + async function parseBase64Workbook( base64Content: string, ): Promise { @@ -126,10 +158,16 @@ function buildDiffSheets( origCells.push({ ...origCell, diffStatus: changed ? "modified" : undefined, + diffSegments: changed + ? computeDiffSegments(origCell.value, modCell.value, "original") + : undefined, }); modCells.push({ ...modCell, diffStatus: changed ? "modified" : undefined, + diffSegments: changed + ? computeDiffSegments(origCell.value, modCell.value, "modified") + : undefined, }); } else { origCells.push(emptyCell); diff --git a/apps/desktop/src/renderer/stores/settings-state.ts b/apps/desktop/src/renderer/stores/settings-state.ts index 4a34b186814..c8343ae663b 100644 --- a/apps/desktop/src/renderer/stores/settings-state.ts +++ b/apps/desktop/src/renderer/stores/settings-state.ts @@ -13,6 +13,7 @@ export type SettingsSection = | "terminal" | "models" | "integrations" + | "extensions" | "billing" | "apikeys" | "permissions" diff --git a/bun.lock b/bun.lock index d246a145d45..994c2f3c08f 100644 --- a/bun.lock +++ b/bun.lock @@ -234,6 +234,7 @@ "culori": "^4.0.2", "date-fns": "^4.1.0", "default-shell": "^2.2.0", + "diff": "^7.0.0", "dnd-core": "^16.0.1", "dotenv": "^17.3.1", "drizzle-orm": "0.45.1", @@ -309,6 +310,7 @@ "@types/better-sqlite3": "^7.6.13", "@types/bun": "^1.2.17", "@types/culori": "^4.0.1", + "@types/diff": "^6.0.0", "@types/http-proxy": "^1.17.17", "@types/lodash": "^4.17.20", "@types/node": "^24.9.1", @@ -2645,6 +2647,8 @@ "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], + "@types/diff": ["@types/diff@6.0.0", "", {}, "sha512-dhVCYGv3ZSbzmQaBSagrv1WJ6rXCdkyTcDyoNu1MD8JohI7pR7k8wdZEm+mvdxRKXyHVwckFzWU1vJc+Z29MlA=="], + "@types/draco3d": ["@types/draco3d@1.4.10", "", {}, "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw=="], "@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="], @@ -3429,7 +3433,7 @@ "devtools-protocol": ["devtools-protocol@0.0.1581282", "", {}, "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ=="], - "diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], + "diff": ["diff@7.0.0", "", {}, "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw=="], "dir-compare": ["dir-compare@4.2.0", "", { "dependencies": { "minimatch": "^3.0.5", "p-limit": "^3.1.0 " } }, "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ=="], @@ -5813,6 +5817,8 @@ "@opentelemetry/instrumentation-pg/@types/pg": ["@types/pg@8.15.6", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ=="], + "@pierre/diffs/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], + "@poppinss/colors/kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], "@poppinss/dumper/@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], @@ -5951,6 +5957,8 @@ "@tanstack/router-plugin/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@tanstack/router-utils/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], + "@types/plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], "@types/three/fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],