From a4a6d924b333fe5f50195405fd30aa7f492090d4 Mon Sep 17 00:00:00 2001 From: Phu Si On Date: Fri, 27 Mar 2026 15:40:47 +0200 Subject: [PATCH 1/2] feat: register trilium:// custom protocol handler for deep linking Register 'trilium://' as a custom URI scheme so external apps and the OS can launch TriliumNext and navigate directly to a specific note. Supported URL formats: trilium://note/ (canonical) trilium:// (shorthand) Supported CLI flag: --open-note= Behaviour: - First launch via protocol URL: starts app, waits for window ready, then navigates to the requested note. - Running instance (Windows/Linux): second-instance event delivers the URL. - Running instance (macOS): open-url event delivers the URL. - Cold start via protocol URL (macOS): open-url fires before ready, note ID is queued and processed after onReady(). Implementation: - New protocol-handler.ts module keeps main.ts clean. - Navigation uses existing 'openInSameTab' IPC channel (no new IPC needed). - Waits for did-finish-load when page is still loading (avoids race conditions). Also adds 'Copy note URL to clipboard' to the tree and breadcrumb context menus so users can easily retrieve a trilium:// URL for any note. Closes TriliumNext#649 --- apps/client/src/components/app_context.ts | 1 + apps/client/src/menus/tree_context_menu.ts | 3 + .../src/translations/en/translation.json | 1 + apps/client/src/widgets/layout/Breadcrumb.tsx | 11 ++ apps/desktop/src/main.ts | 34 +++++ apps/desktop/src/protocol-handler.ts | 123 ++++++++++++++++++ 6 files changed, 173 insertions(+) create mode 100644 apps/desktop/src/protocol-handler.ts diff --git a/apps/client/src/components/app_context.ts b/apps/client/src/components/app_context.ts index 1c1389810a6..52060234eae 100644 --- a/apps/client/src/components/app_context.ts +++ b/apps/client/src/components/app_context.ts @@ -173,6 +173,7 @@ export type CommandMappings = { collapseSubtree: ContextMenuCommandData; sortChildNotes: ContextMenuCommandData; copyNotePathToClipboard: ContextMenuCommandData; + copyNoteUrlToClipboard: ContextMenuCommandData; recentChangesInSubtree: ContextMenuCommandData; cutNotesToClipboard: ContextMenuCommandData; copyNotesToClipboard: ContextMenuCommandData; diff --git a/apps/client/src/menus/tree_context_menu.ts b/apps/client/src/menus/tree_context_menu.ts index 8dca18d9050..2483c946083 100644 --- a/apps/client/src/menus/tree_context_menu.ts +++ b/apps/client/src/menus/tree_context_menu.ts @@ -175,6 +175,7 @@ export default class TreeContextMenu implements SelectMenuItemEventListener[] }, @@ -350,6 +351,8 @@ export default class TreeContextMenu implements SelectMenuItemEventListener(command, { node: this.node, diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 27891a02abd..f827be581d4 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1552,6 +1552,7 @@ "recent-changes-in-subtree": "Recent changes in subtree", "convert-to-attachment": "Convert to attachment", "copy-note-path-to-clipboard": "Copy note path to clipboard", + "copy-note-url-to-clipboard": "Copy note URL to clipboard", "protect-subtree": "Protect subtree", "unprotect-subtree": "Unprotect subtree", "copy-clone": "Copy / clone", diff --git a/apps/client/src/widgets/layout/Breadcrumb.tsx b/apps/client/src/widgets/layout/Breadcrumb.tsx index 8c393f9dade..c909636cbc4 100644 --- a/apps/client/src/widgets/layout/Breadcrumb.tsx +++ b/apps/client/src/widgets/layout/Breadcrumb.tsx @@ -438,6 +438,17 @@ function buildEmptyAreaContextMenu(parentComponent: Component | null, notePath: uiIcon: "bx bx-directions", handler: () => copyTextWithToast(`#${notePath}`) }, + { + title: t("tree-context-menu.copy-note-url-to-clipboard"), + command: "copyNoteUrlToClipboard", + uiIcon: "bx bx-link", + handler: () => { + const noteId = notePath?.split("/").pop(); + if (noteId) { + copyTextWithToast(`trilium://note/${noteId}`); + } + } + }, ], x: e.pageX, y: e.pageY, diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 3617c4c9f4b..90315990f7a 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -12,6 +12,7 @@ import { PRODUCT_NAME } from "./app-info"; import port from "@triliumnext/server/src/services/port.js"; import { join } from "path"; import { deferred, LOCALES } from "../../../packages/commons/src"; +import protocolHandler from "./protocol-handler.js"; async function main() { const userDataPath = getUserData(); @@ -24,6 +25,11 @@ async function main() { process.exit(0); } + // Register trilium:// as a custom URI scheme so external apps and the OS + // can launch Trilium and navigate directly to a note. + // Must be called before app.requestSingleInstanceLock(). + app.setAsDefaultProtocolClient(protocolHandler.PROTOCOL); + // Adds debug features like hotkeys for triggering dev tools and reload electronDebug(); electronDl({ saveAs: true }); @@ -63,13 +69,41 @@ async function main() { await serverInitializedPromise; console.log("Starting Electron..."); await onReady(); + + // Process protocol URL from first-launch argv (Windows/Linux only; + // macOS delivers protocol URLs via the open-url event). + if (process.platform !== "darwin") { + const noteId = protocolHandler.extractNoteIdFromArgs(process.argv); + if (noteId) { + protocolHandler.navigateToNote(noteId); + } + } + + // Handle protocol URL that arrived via open-url before the window was + // ready (macOS cold-start from a trilium:// link). + protocolHandler.processPendingNavigation(); }); app.on("will-quit", () => { globalShortcut.unregisterAll(); }); + // macOS delivers protocol URLs for a *running* instance via open-url + // instead of second-instance. When the app is cold-started from a + // trilium:// link the event fires before "ready", so we queue the note ID. + app.on("open-url", (event, url) => { + event.preventDefault(); + protocolHandler.handleProtocolUrl(url); + }); + app.on("second-instance", (event, commandLine) => { + // Check for a trilium:// URL or --open-note flag first. + const noteId = protocolHandler.extractNoteIdFromArgs(commandLine); + if (noteId) { + protocolHandler.navigateToNote(noteId); + return; + } + const lastFocusedWindow = windowService.getLastFocusedWindow(); if (commandLine.includes("--new-window")) { windowService.createExtraWindow(""); diff --git a/apps/desktop/src/protocol-handler.ts b/apps/desktop/src/protocol-handler.ts new file mode 100644 index 00000000000..52206780741 --- /dev/null +++ b/apps/desktop/src/protocol-handler.ts @@ -0,0 +1,123 @@ +import windowService from "@triliumnext/server/src/services/window.js"; + +const PROTOCOL = "trilium"; + +/** Note ID to navigate to once the main window finishes loading. */ +let pendingNoteId: string | null = null; + +/** + * Parses a `trilium://` URL and returns the note ID, or `null` if the URL + * cannot be parsed. + * + * Supported formats: + * trilium://note/ (canonical) + * trilium:// (shorthand) + */ +function parseTriliumUrl(rawUrl: string): string | null { + let parsed: URL; + try { + parsed = new URL(rawUrl); + } catch { + return null; + } + + if (parsed.protocol !== `${PROTOCOL}:`) { + return null; + } + + // trilium://note/ → hostname = "note", pathname = "/" + if (parsed.hostname === "note") { + const noteId = parsed.pathname.replace(/^\/+/, "").trim(); + return noteId || null; + } + + // trilium:// → hostname = "" + const noteId = parsed.hostname.trim(); + return noteId || null; +} + +/** + * Scans process arguments for a `trilium://` URL or `--open-note=` + * flag and returns the target note ID. + */ +function extractNoteIdFromArgs(args: string[]): string | null { + for (const arg of args) { + if (arg.startsWith(`${PROTOCOL}://`)) { + return parseTriliumUrl(arg); + } + + const match = arg.match(/^--open-note=(.+)$/); + if (match) { + return match[1].trim() || null; + } + } + return null; +} + +/** + * Focuses the appropriate window and sends navigation IPC to the renderer. + * Waits for `did-finish-load` if the page is still loading (first launch). + * + * Returns `false` when no usable window exists yet. + */ +function navigateToNote(noteId: string): boolean { + const win = + windowService.getLastFocusedWindow() ?? windowService.getMainWindow(); + if (!win || win.isDestroyed()) { + return false; + } + + if (win.isMinimized()) { + win.restore(); + } + win.show(); + win.focus(); + + if (win.webContents.isLoading()) { + win.webContents.once("did-finish-load", () => { + win.webContents.send("openInSameTab", noteId); + }); + } else { + win.webContents.send("openInSameTab", noteId); + } + + return true; +} + +/** + * Handles an incoming protocol URL (from `open-url`, `second-instance`, or + * first-launch argv). If the main window is not yet available the note ID is + * queued for {@link processPendingNavigation}. + */ +function handleProtocolUrl(url: string): void { + const noteId = parseTriliumUrl(url); + if (!noteId) { + return; + } + + if (!navigateToNote(noteId)) { + // Window not created yet — defer until after onReady(). + pendingNoteId = noteId; + } +} + +/** + * Navigates to the note that was requested before the window was ready, then + * clears the pending state. Safe to call when there is nothing pending. + */ +function processPendingNavigation(): void { + if (pendingNoteId) { + const noteId = pendingNoteId; + pendingNoteId = null; + navigateToNote(noteId); + } +} + +export default { + PROTOCOL, + parseTriliumUrl, + extractNoteIdFromArgs, + navigateToNote, + handleProtocolUrl, + processPendingNavigation, +}; From aa7f3ff65cc7648b420ecf54173453f4f7c57a7d Mon Sep 17 00:00:00 2001 From: Phu Si On Date: Sat, 28 Mar 2026 15:44:54 +0200 Subject: [PATCH 2/2] fix: improve protocol URL parsing robustness - Handle trailing slashes and extra path segments in trilium://note// URLs - Continue scanning args if parseTriliumUrl returns null instead of early return - Ensures --open-note flag is still checked if trilium:// URL is malformed --- apps/desktop/src/protocol-handler.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/protocol-handler.ts b/apps/desktop/src/protocol-handler.ts index 52206780741..bb1c0a4e9ff 100644 --- a/apps/desktop/src/protocol-handler.ts +++ b/apps/desktop/src/protocol-handler.ts @@ -27,7 +27,9 @@ function parseTriliumUrl(rawUrl: string): string | null { // trilium://note/ → hostname = "note", pathname = "/" if (parsed.hostname === "note") { - const noteId = parsed.pathname.replace(/^\/+/, "").trim(); + // Extract first path segment only, ignoring trailing slashes and extra segments + const pathSegments = parsed.pathname.split("/").filter(Boolean); + const noteId = pathSegments[0]?.trim(); return noteId || null; } @@ -43,7 +45,12 @@ function parseTriliumUrl(rawUrl: string): string | null { function extractNoteIdFromArgs(args: string[]): string | null { for (const arg of args) { if (arg.startsWith(`${PROTOCOL}://`)) { - return parseTriliumUrl(arg); + const noteId = parseTriliumUrl(arg); + // Only return if we got a valid noteId; otherwise continue scanning + if (noteId) { + return noteId; + } + continue; } const match = arg.match(/^--open-note=(.+)$/);