Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 100 additions & 4 deletions apps/electron-demo/electron/main.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { app, BrowserWindow, dialog, ipcMain, protocol, session, shell } from "electron";
import { randomUUID } from "node:crypto";
import { readFile, writeFile, readdir, mkdir, rename, stat } from "node:fs/promises";
import { readFile, writeFile, readdir, mkdir, rename, stat, realpath } from "node:fs/promises";
import { existsSync, watch, type FSWatcher } from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
Expand Down Expand Up @@ -47,6 +47,8 @@ interface WindowRuntimeState {
readonly windowSession: Electron.Session;
readonly hostMode: NexusHostMode;
activeVault: string | null;
readonly legacyResourceCapability: string;
readonly legacyResources: Map<string, string>;
activeWatcher: FSWatcher | null;
watchNotifyTimer: NodeJS.Timeout | null;
shutdownTimer: NodeJS.Timeout | null;
Expand Down Expand Up @@ -122,6 +124,8 @@ export function createWindow(options: CreateWindowOptions = {}): BrowserWindow {
windowSession,
hostMode,
activeVault: null,
legacyResourceCapability: randomUUID(),
legacyResources: new Map(),
activeWatcher: null,
watchNotifyTimer: null,
shutdownTimer: null,
Expand Down Expand Up @@ -193,9 +197,34 @@ function registerVaultProtocol(windowProtocol: Electron.Protocol, senderId: numb
if (segments.length !== 2) return new Response("Not found", { status: 404 });
const [sessionCapability, token] = segments;
const resource = await pluginVaultBroker?.readResource(senderId, sessionCapability, token);
if (!resource) return new Response("Not found", { status: 404 });
return new Response(Uint8Array.from(resource.content).buffer, {
headers: { "content-type": contentTypeForPath(resource.path) },
if (resource) {
return new Response(Uint8Array.from(resource.content).buffer, {
headers: { "content-type": contentTypeForPath(resource.path) },
});
}

const registration = windowRegistry.registrationForSenderId(senderId);
const state = registration?.state;
if (!state || state.legacyResourceCapability !== sessionCapability) {
return new Response("Not found", { status: 404 });
}
const requestedPath = state.legacyResources.get(token);
if (!requestedPath || !state.activeVault) return new Response("Not found", { status: 404 });

const absoluteRoot = await realpath(state.activeVault);
const absolutePath = await realpath(assertInsideVault(state, requestedPath));
const relative = path.relative(absoluteRoot, absolutePath);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
return new Response("Not found", { status: 404 });
}
const info = await stat(absolutePath);
if (!info.isFile()) return new Response("Not found", { status: 404 });
const content = await readFile(absolutePath);
if (state.legacyResources.get(token) !== requestedPath) {
return new Response("Not found", { status: 404 });
}
return new Response(Uint8Array.from(content).buffer, {
headers: { "content-type": contentTypeForPath(absolutePath) },
});
} catch {
return new Response("Not found", { status: 404 });
Expand Down Expand Up @@ -449,6 +478,7 @@ function releaseWindowResources(senderId: number): void {
if (state.shutdownTimer) clearTimeout(state.shutdownTimer);
state.shutdownTimer = null;
stopWatcher(state);
state.legacyResources.clear();
externalNavigationBroker?.closeSender(senderId);
pluginVaultBroker?.closeSender(senderId);
try {
Expand Down Expand Up @@ -612,6 +642,49 @@ async function scanDirectory(dir: string): Promise<VaultNode[]> {
return nodes;
}

async function findUniqueVaultImage(root: string, basename: string): Promise<string | null> {
const wanted = basename.toLocaleLowerCase();
const matches: string[] = [];
const visit = async (directory: string): Promise<void> => {
if (matches.length > 1) return;
const entries = await readdir(directory, { withFileTypes: true });
for (const entry of entries) {
if (entry.name.startsWith(".") || SKIP_DIRS.has(entry.name) || entry.isSymbolicLink()) continue;
const child = path.join(directory, entry.name);
if (entry.isDirectory()) {
await visit(child);
} else if (
entry.isFile() &&
entry.name.toLocaleLowerCase() === wanted &&
contentTypeForPath(entry.name).startsWith("image/")
) {
matches.push(child);
if (matches.length > 1) return;
}
}
};
await visit(root);
if (matches.length > 1) {
throw new Error(`Ambiguous image resource: ${basename}`);
}
return matches[0] ?? null;
}

async function resolveLegacyImagePath(state: WindowRuntimeState, requestedPath: string): Promise<string> {
const absolute = assertInsideVault(state, requestedPath);
try {
const info = await stat(absolute);
if (info.isFile() && contentTypeForPath(absolute).startsWith("image/")) return absolute;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}

if (!state.activeVault) throw new Error("No active vault");
const fallback = await findUniqueVaultImage(state.activeVault, path.basename(absolute));
if (fallback) return assertInsideVault(state, fallback);
throw Object.assign(new Error(`Image resource not found: ${requestedPath}`), { code: "ENOENT" });
}

function stopWatcher(state: WindowRuntimeState): void {
if (state.watchNotifyTimer) clearTimeout(state.watchNotifyTimer);
state.watchNotifyTimer = null;
Expand Down Expand Up @@ -702,6 +775,29 @@ ipcMain.handle("vault:read", async (event, filePath: string) => {
return { path: abs, content };
});

ipcMain.handle("vault:resource-url", async (event, filePath: string) => {
const { state } = registrationForEvent(event);
const abs = await resolveLegacyImagePath(state, filePath);
const info = await stat(abs);
if (!info.isFile() || !contentTypeForPath(abs).startsWith("image/")) {
throw new Error("Vault resource is not a supported image file");
}
const registrationId = randomUUID();
state.legacyResources.set(registrationId, abs);
const capability = encodeURIComponent(state.legacyResourceCapability);
const token = encodeURIComponent(registrationId);
return {
url: `nexus-vault://resource/${capability}/${token}`,
registrationId,
};
});

ipcMain.handle("vault:revoke-resource-url", async (event, registrationId: string) => {
const { state } = registrationForEvent(event);
state.legacyResources.delete(registrationId);
return { ok: true };
});

// Bulk read every markdown file in the active vault — used to seed the
// wiki-link index without N individual round-trips.
async function collectFiles(dir: string, acc: string[]): Promise<void> {
Expand Down
41 changes: 39 additions & 2 deletions apps/electron-demo/electron/plugin-host-broker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
} from "../src/shared/plugin-ipc";

const SUPPORTED_TEXT_EXTENSIONS = new Set([".md", ".markdown", ".txt"]);
const SUPPORTED_IMAGE_EXTENSIONS = new Set([".avif", ".gif", ".jpeg", ".jpg", ".png", ".svg", ".webp"]);
const SKIPPED_DIRECTORIES = new Set(["node_modules", ".git", ".svn", ".hg"]);
const SESSION_ID_PATTERN = /^[a-f0-9-]{16,}$/i;
const PLUGIN_ID_PATTERN = /^[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?$/;
Expand Down Expand Up @@ -737,16 +738,16 @@ export class ElectronVaultBroker {
vaultPath: string,
): Promise<{ readonly url: string; readonly registrationId: string }> {
const session = this.requireSession(sender, sessionId);
const normalized = await this.resolveImageResourcePath(session, vaultPath);
const sessionCapability = session.resourceCapability;
await this.resolveAuthorizedPath(session, vaultPath, false);
await this.resolveAuthorizedPath(session, normalized, false);
const currentSession = this.requireSession(sender, sessionId);
if (currentSession !== session || currentSession.resourceCapability !== sessionCapability) {
throw new PluginHostBrokerError(
"vault-session-invalid",
"Vault session changed while the resource URL was being registered.",
);
}
const normalized = normalizeVaultRelativePath(vaultPath);
const token = randomUUID();
session.resourceTokens.add(token);
this.resources.set(token, { token, sessionId, senderId: sender.id, vaultPath: normalized });
Expand All @@ -756,6 +757,42 @@ export class ElectronVaultBroker {
});
}

private async resolveImageResourcePath(
session: VaultSessionRecord,
vaultPath: string,
): Promise<string> {
const normalized = normalizeVaultRelativePath(vaultPath);
const absolute = path.resolve(session.root, ...normalized.split("/"));
if (existsSync(absolute)) return normalized;

const basename = path.posix.basename(normalized).toLocaleLowerCase();
const matches: string[] = [];
const visit = async (directory: string): Promise<void> => {
if (matches.length > 1) return;
const entries = await readdir(directory, { withFileTypes: true });
for (const entry of entries) {
if (entry.name.startsWith(".") || SKIPPED_DIRECTORIES.has(entry.name) || entry.isSymbolicLink()) continue;
const child = path.join(directory, entry.name);
if (entry.isDirectory()) {
await visit(child);
} else if (
entry.isFile() &&
entry.name.toLocaleLowerCase() === basename &&
SUPPORTED_IMAGE_EXTENSIONS.has(path.extname(entry.name).toLocaleLowerCase())
) {
matches.push(relativeFromRoot(session.root, child));
if (matches.length > 1) return;
}
}
};
await visit(session.root);
if (matches.length > 1) {
throw new PluginHostBrokerError("vault-path-invalid", `Ambiguous image resource: ${basename}`);
}
if (matches.length === 1) return matches[0];
throw new PluginHostBrokerError("vault-path-invalid", `Image resource not found: ${normalized}`);
}

revokeResourceUrl(
sender: SenderIdentity,
sessionId: VaultSessionId,
Expand Down
8 changes: 8 additions & 0 deletions apps/electron-demo/electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ export interface VaultBridge {
list(vaultPath: string): Promise<VaultNode[]>;
read(filePath: string): Promise<DemoFileHandle>;
readAll(): Promise<Array<{ path: string; content: string }>>;
createResourceUrl(filePath: string): Promise<{ url: string; registrationId: string }>;
revokeResourceUrl(registrationId: string): Promise<{ ok: boolean }>;
write(filePath: string, content: string): Promise<{ path: string }>;
createFile(parentDir: string, name: string): Promise<{ path: string }>;
createFolder(parentDir: string, name: string): Promise<{ path: string }>;
Expand Down Expand Up @@ -73,6 +75,12 @@ const vaultBridge: VaultBridge = {
readAll() {
return ipcRenderer.invoke("vault:read-all");
},
createResourceUrl(filePath) {
return ipcRenderer.invoke("vault:resource-url", filePath);
},
revokeResourceUrl(registrationId) {
return ipcRenderer.invoke("vault:revoke-resource-url", registrationId);
},
write(filePath, content) {
return ipcRenderer.invoke("vault:write", filePath, content);
},
Expand Down
46 changes: 45 additions & 1 deletion apps/electron-demo/src/renderer/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
type ReferencePluginFeatureFlags,
} from "@floatboat/nexus-reference-plugins";
import { createState, type AppState } from "./state";
import { createEditorShell, type EditorShell } from "./editor-shell";
import { createEditorShell, type EditorShell, type ImageResource } from "./editor-shell";
import {
loadSettings,
saveSettings,
Expand All @@ -33,6 +33,7 @@ import {
createPluginRuntimeHost,
type PluginRuntimeHost,
} from "./plugin-runtime-host";
import { resolveImageReference } from "./image-resolver";
import {
ElectronRuntimeProductUiAdapter,
} from "./runtime-product-ui-adapter";
Expand Down Expand Up @@ -150,6 +151,48 @@ function resolveWikilink(name: string): string | null {
return fromIndexPath(resolved);
}

async function createImageResource(source: string): Promise<ImageResource | null> {
const resolved = resolveImageReference(source, state.activeFile, state.vaultPath);
if (!resolved) return null;
if (resolved.kind === "external") {
return { url: resolved.url, dispose: () => undefined };
}

if (pluginRuntime?.session && window.nexusPlugins) {
const bridge = requirePluginBridge();
const sessionId = pluginRuntime.session.sessionId;
const created = await bridge.vault.createResourceUrl(sessionId, resolved.path);
return {
url: created.url,
dispose: async () => {
try {
await bridge.vault.revokeResourceUrl(sessionId, created.registrationId);
} catch {
// The runtime may already have revoked the whole session.
}
},
};
}

if (window.nexusDemo) {
const absolutePath = toVaultAbsolutePath(state.vaultPath, resolved.path);
if (!absolutePath) return null;
const created = await requireLegacyBridge().vault.createResourceUrl(absolutePath);
return {
url: created.url,
dispose: async () => {
try {
await requireLegacyBridge().vault.revokeResourceUrl(created.registrationId);
} catch {
// The legacy window cleanup revokes all remaining resources.
}
},
};
}

return null;
}

function createAppToolbar(): HTMLElement {
const toolbar = document.createElement("div");
toolbar.className = "toolbar";
Expand Down Expand Up @@ -833,6 +876,7 @@ export async function boot(options: BootOptions = {}): Promise<ElectronDemoApp>
state,
settings,
onStateChange: renderStatus,
resolveImageResource: createImageResource,
resolveWikilink,
suggestWikilinks: (q) => {
const names = linkIndex?.getAllNoteNames() ?? [];
Expand Down
2 changes: 2 additions & 0 deletions apps/electron-demo/src/renderer/bridge.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ declare global {
list(vaultPath: string): Promise<VaultNode[]>;
read(filePath: string): Promise<DemoFileHandle>;
readAll(): Promise<Array<{ path: string; content: string }>>;
createResourceUrl(filePath: string): Promise<{ url: string; registrationId: string }>;
revokeResourceUrl(registrationId: string): Promise<{ ok: boolean }>;
write(filePath: string, content: string): Promise<{ path: string }>;
createFile(parentDir: string, name: string): Promise<{ path: string }>;
createFolder(parentDir: string, name: string): Promise<{ path: string }>;
Expand Down
Loading