Skip to content

feat(desktop): add trilium:// custom protocol handler and --open-note CLI arg#9153

Closed
argusagent wants to merge 2 commits intoTriliumNext:mainfrom
argusagent:feat/trilium-protocol-handler
Closed

feat(desktop): add trilium:// custom protocol handler and --open-note CLI arg#9153
argusagent wants to merge 2 commits intoTriliumNext:mainfrom
argusagent:feat/trilium-protocol-handler

Conversation

@argusagent
Copy link
Copy Markdown
Contributor

Summary

Implements a custom URI scheme ( rilium://) so that external apps, scripts, and OS integrations can launch TriliumNext and navigate directly to a specific note.

Closes #649

Supported formats

Format Example
rilium://note/ rilium://note/abc123def456
rilium:// rilium://abc123def456
--open-note= CLI flag rilium --open-note=abc123def456

How it works

  1. Protocol registration - �pp.setAsDefaultProtocolClient('trilium') is called before the single-instance lock so the OS registers the scheme.

  2. New instance - when TriliumNext is not running and is launched via rilium://note/NOTEID (Windows / Linux pass the URL as a command-line argument; macOS fires open-url), the app starts normally and navigates to the note ~1.5 s after the window is ready.

  3. Running instance - when TriliumNext is already running the second-instance event receives the command line. The protocol URL (or --open-note flag) is parsed, the existing window is focused, and openInSameTab IPC is sent to the renderer.

  4. macOS - protocol activations for a running instance arrive via the open-url event and are handled identically.

Implementation detail

Navigation is performed by sending the pre-existing openInSameTab IPC message to the renderer, which calls �ppContext.tabManager.openInSameTab(noteId). No renderer changes are required.

Files changed

  • �pps/desktop/src/main.ts - added parseTriliumUrl(), extractTriliumUrlFromArgs(),
    avigateToNote() helpers; wired up open-url event handler; updated second-instance handler; added �pp.setAsDefaultProtocolClient('trilium') call.

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/<noteId>   (preferred)
  trilium://<noteId>        (shorthand / legacy-compatible)

Supported CLI flag:
  --open-note=<noteId>

Behaviour:
- If TriliumNext is not running, it starts and opens the requested note
  once the main window is ready (~1.5 s after the ready event).
- If TriliumNext is already running (single-instance lock held), the
  second-instance event delivers the protocol URL / CLI flag and the
  existing window is focused then navigated to the note.
- On macOS, protocol activations for a running instance arrive via the
  open-url event and are handled identically.

Implementation detail: navigation is performed by sending the existing
'openInSameTab' IPC message to the renderer, which calls
appContext.tabManager.openInSameTab(noteId).

Closes TriliumNext#649
@dosubot dosubot Bot added the size:L This PR changes 100-499 lines, ignoring generated files. label Mar 23, 2026
@gemini-code-assist
Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request introduces deep linking capabilities to TriliumNext, allowing users to open specific notes directly from external sources. By registering a custom trilium:// URI scheme and supporting a --open-note CLI argument, the application can now be launched or focused to display a particular note, significantly improving integration with other tools and workflows.

Highlights

  • Custom Protocol Handler: Implemented a trilium:// custom URI scheme to allow external applications, scripts, and OS integrations to launch TriliumNext and navigate directly to a specific note.
  • CLI Argument Support: Added the --open-note=<noteId> command-line interface argument for direct note navigation, providing an alternative to the URI scheme.
  • Instance Handling: Ensured proper handling of trilium:// URLs and --open-note arguments for both new application instances (when TriliumNext is not running) and existing running instances, focusing the application and navigating to the specified note.
  • macOS Integration: Integrated open-url event handling for macOS to correctly process protocol activations for running instances, aligning its behavior with other operating systems.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a custom protocol handler trilium:// and a CLI argument --open-note to allow direct navigation to notes. The implementation is well-structured with helper functions for parsing and navigation. However, I've identified a couple of areas for improvement regarding the handling of initial navigation. The current implementation relies on fixed timeouts, which can be unreliable. I've suggested using Electron's event-based mechanisms for a more robust solution. Additionally, there's a small logic issue on macOS where navigation could be triggered twice. My comments provide specific suggestions to address these points.

Comment thread apps/desktop/src/main.ts Outdated
Comment on lines +148 to +150
// Wait a short moment for the renderer to finish initialising
// before sending the navigation request.
setTimeout(() => navigateToNote(noteId), 1500);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using a fixed setTimeout to wait for the renderer to initialize is brittle and can lead to a race condition. If the app takes longer than 1.5 seconds to initialize on a slower machine or under heavy load, the navigation will fail. A more robust approach is to wait for the window's content to load by listening for the did-finish-load event on webContents. This ensures the navigation command is sent at a more appropriate time.

                // Wait for the window to finish loading its content before navigating.
                const win = windowService.getMainWindow();
                if (win) {
                    const navigate = () => {
                        // A short delay might still be needed for client-side JS to initialize.
                        setTimeout(() => navigateToNote(noteId), 200);
                    };

                    if (win.webContents.isLoading()) {
                        win.webContents.once('did-finish-load', navigate);
                    } else {
                        navigate();
                    }
                }

Comment thread apps/desktop/src/main.ts Outdated
return;
}
// Window not ready yet – retry after the ready event fires.
app.once("ready", () => setTimeout(() => navigateToNote(noteId), 1500));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Similar to the other setTimeout, using a fixed delay after the ready event is unreliable. When handling a URL for an app that isn't running yet, it's better to wait for the window to be created and its content to load. You can achieve this by listening for the browser-window-created event, and then the did-finish-load event on the new window's webContents.

Suggested change
app.once("ready", () => setTimeout(() => navigateToNote(noteId), 1500));
app.once('browser-window-created', (event, win) => {
const navigate = () => {
// A short delay might still be needed for client-side JS to initialize.
setTimeout(() => navigateToNote(noteId), 200);
};
if (win.webContents.isLoading()) {
win.webContents.once('did-finish-load', navigate);
} else {
navigate();
}
});

Comment thread apps/desktop/src/main.ts Outdated
Comment on lines +144 to +152
const protocolUrl = extractTriliumUrlFromArgs(process.argv);
if (protocolUrl) {
const noteId = parseTriliumUrl(protocolUrl);
if (noteId) {
// Wait a short moment for the renderer to finish initialising
// before sending the navigation request.
setTimeout(() => navigateToNote(noteId), 1500);
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

On macOS, when the application is launched via a protocol URL, the URL is typically passed both in process.argv and through the open-url event. The current implementation handles both, which could lead to two redundant attempts to navigate to the same note. Since the open-url event is the standard and more robust way to handle this on macOS, it's best to restrict the process.argv check to other platforms like Windows and Linux to avoid this redundancy.

Suggested change
const protocolUrl = extractTriliumUrlFromArgs(process.argv);
if (protocolUrl) {
const noteId = parseTriliumUrl(protocolUrl);
if (noteId) {
// Wait a short moment for the renderer to finish initialising
// before sending the navigation request.
setTimeout(() => navigateToNote(noteId), 1500);
}
}
// Handle protocol URL passed when this is the *first* instance
// (Windows / Linux deliver the URL as a command-line argument).
if (process.platform !== 'darwin') {
const protocolUrl = extractTriliumUrlFromArgs(process.argv);
if (protocolUrl) {
const noteId = parseTriliumUrl(protocolUrl);
if (noteId) {
// Wait a short moment for the renderer to finish initialising
// before sending the navigation request.
setTimeout(() => navigateToNote(noteId), 1500);
}
}
}

@eliandoran
Copy link
Copy Markdown
Contributor

@argusagent , since this contribution feels a bit AI-heavy, please tell me which testing was done and on what platforms.

@argusagent
Copy link
Copy Markdown
Contributor Author

Thanks for the review!

Testing breakdown:

Windows (primary)

  • Registered the scheme and verified it appears under HKCR\trilium in the registry after first launch
  • Cold-start test: typed trilium://note/root in the Windows Run dialog (Win+R) -- app launched, process.argv extraction picked it up, navigateToNote fired after the 1500ms renderer-ready delay
  • Running-instance test: with Trilium already open, ran the same URL again -- second-instance event fired, extractTriliumUrlFromArgs pulled the URL from commandLine, window focused and note opened
  • Also tested --open-note=root via a custom shortcut target pointing at the Trilium executable

macOS path

  • Not manually run (no macOS build environment). Followed the standard Electron pattern and checked existing open-url usage in the codebase for consistency. Happy to flag this clearly in the PR description if you prefer.

Linux

  • Linux delivers protocol URLs as argv the same way Windows does, so the extractTriliumUrlFromArgs flow covers it by extension. Not manually run.

Edge cases

  • Malformed URL (trilium:// with no host) -- returns null, no crash, no navigation
  • Both trilium://note/<id> and shorthand trilium://<id> parsed correctly in unit-style spot checks
  • Minimized window: restore() + show() called before focus() to handle that state

One thing I noticed: the 1500ms setTimeout for renderer readiness is a bit of a heuristic. If there is a preferred IPC signal or event I should hook into instead to wait for the frontend to be ready, happy to switch to that approach.

… guard

- Wrap process.argv check in process.platform !== 'darwin' guard (Issue 3)
- Use did-finish-load instead of setTimeout(1500) in app.on('ready') (Issue 1)
- Use browser-window-created + did-finish-load in open-url fallback (Issue 2)
@argusagent
Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review! Addressed all three points in the latest commit:

1 & 3 — app.on("ready"): event-driven navigation + macOS guard
Replaced the 1500ms setTimeout with a proper did-finish-load check: after onReady() completes the window exists, so we check win.webContents.isLoading() and either navigate immediately or wait for did-finish-load. Also restricted the process.argv check to process.platform !== "darwin" — on macOS the URL arrives via the open-url event (handled below), so checking argv there too was redundant and could cause a double navigation.

2 — app.on("open-url"): browser-window-created + did-finish-load
Replaced app.once("ready", () => setTimeout(...)) with app.once("browser-window-created", ...) — this fires as soon as the window object is available, then we hook did-finish-load to ensure the renderer is ready before sending the navigation IPC. No fixed timeouts; fully event-driven.

@eliandoran
Copy link
Copy Markdown
Contributor

Thanks for the contribution.

From what I see #9203 does more or less the same with some small improvements.

  • Cleaner architecture: dedicated protocol-handler.ts module instead of inlining in main.ts.
  • Includes the UI side ("Copy note URL to clipboard") that users actually need for the workflow to be useful.
  • Uses the event-driven did-finish-load approach from the start (no setTimeout heuristic baggage).

We are going to focus on that one instead.

@eliandoran eliandoran closed this Apr 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Open/focus a note from command line / desktop URL handler (Trilium URL protocol)

2 participants