From bbef62930a77bb8df4dd5ad14cd204d10f445339 Mon Sep 17 00:00:00 2001 From: Abdullah Atta Date: Fri, 20 Feb 2026 12:28:26 +0500 Subject: [PATCH] desktop: add handler for nn:// urls --- apps/desktop/electron-builder.config.js | 2 ++ apps/desktop/src/api/bridge.ts | 4 ++- apps/desktop/src/main.ts | 34 ++++++++++++++++++++++++ apps/web/src/app-effects.tsx | 29 ++++++++++++++++++-- packages/core/src/utils/internal-link.ts | 19 ++++++++++--- 5 files changed, 81 insertions(+), 7 deletions(-) diff --git a/apps/desktop/electron-builder.config.js b/apps/desktop/electron-builder.config.js index e633a4e1c..780756ad0 100644 --- a/apps/desktop/electron-builder.config.js +++ b/apps/desktop/electron-builder.config.js @@ -96,6 +96,7 @@ module.exports = { "node_modules/sodium-native/package.json" ], afterPack: "./scripts/removeLocales.js", + protocols: [{ name: "Notesnook", schemes: ["nn"] }], mac: { bundleVersion: "240", minimumSystemVersion: "10.12.0", @@ -181,6 +182,7 @@ module.exports = { icon: "assets/icons/app.icns", description: "Your private note taking space", executableName: linuxExecutableName, + mimeTypes: ["x-scheme-handler/nn"], desktop: { desktopActions: { "new-note": { diff --git a/apps/desktop/src/api/bridge.ts b/apps/desktop/src/api/bridge.ts index cdb6e12c0..d1f1c3b5c 100644 --- a/apps/desktop/src/api/bridge.ts +++ b/apps/desktop/src/api/bridge.ts @@ -24,6 +24,7 @@ import TypedEventEmitter from "typed-emitter"; export type AppEvents = { onCreateItem(name: "note" | "notebook" | "reminder"): void; + onOpenLink(url: string): void; }; const emitter = new EventEmitter(); @@ -31,7 +32,8 @@ const typedEmitter = emitter as TypedEventEmitter; const t = initTRPC.create(); export const bridgeRouter = t.router({ - onCreateItem: createSubscription("onCreateItem") + onCreateItem: createSubscription("onCreateItem"), + onOpenLink: createSubscription("onOpenLink") }); export const bridge: AppEvents = new Proxy({} as AppEvents, { diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index ce0bdb502..788679719 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -52,6 +52,10 @@ locale.then(({ default: locale }) => { }); setI18nGlobal(i18n); +// Pending nn:// link to open once the window is ready (used on Windows/Linux +// when the app is launched via the nn:// protocol for the first time). +let pendingNNLink: string | undefined = findNNLink(process.argv); + // only run a single instance if (!MAC_APP_STORE && !app.requestSingleInstanceLock()) { console.log("Another instance is already running!"); @@ -141,6 +145,11 @@ async function createWindow() { await mainWindow.webContents.loadURL(`${createURL(cliOptions, "/")}`); mainWindow.setOpacity(1); + if (pendingNNLink) { + bridge.onOpenLink(pendingNNLink); + pendingNNLink = undefined; + } + if (config.privacyMode) { await api.integration.setPrivacyMode({ enabled: config.privacyMode }); } @@ -196,6 +205,8 @@ app.once("ready", async () => { if (config.customDns) enableCustomDns(); else disableCustomDns(); + if (!MAC_APP_STORE) app.setAsDefaultProtocolClient("nn"); + if (!isDevelopment()) registerProtocol(); await createWindow(); configureAutoUpdater(); @@ -209,6 +220,12 @@ app.once("window-all-closed", () => { app.on("second-instance", async (_ev, argv) => { if (!globalThis.window) return; + const nnLink = findNNLink(argv); + if (nnLink) { + bridge.onOpenLink(nnLink); + bringToFront(); + return; + } const cliOptions = await parseArguments(argv); if (cliOptions.note) bridge.onCreateItem("note"); if (cliOptions.notebook) bridge.onCreateItem("notebook"); @@ -216,12 +233,29 @@ app.on("second-instance", async (_ev, argv) => { bringToFront(); }); +// macOS opens URLs via this event. The app may or may not be fully loaded yet. +app.on("open-url", (event, url) => { + event.preventDefault(); + if (!url.startsWith("nn://")) return; + if (globalThis.window) { + bridge.onOpenLink(url); + bringToFront(); + } else { + // Window not ready yet — store for when createWindow finishes loading. + pendingNNLink = url; + } +}); + app.on("activate", () => { if (globalThis.window === null) { createWindow(); } }); +function findNNLink(argv: string[]): string | undefined { + return argv.find((arg) => arg.startsWith("nn://")); +} + function createURL(options: CLIOptions, path = "/") { const url = new URL(isDevelopment() ? "http://localhost:3000" : PROTOCOL_URL); diff --git a/apps/web/src/app-effects.tsx b/apps/web/src/app-effects.tsx index 95cb29922..dc2b5603e 100644 --- a/apps/web/src/app-effects.tsx +++ b/apps/web/src/app-effects.tsx @@ -31,10 +31,10 @@ import { } from "./common"; import { AppEventManager, AppEvents } from "./common/app-events"; import { db } from "./common/db"; -import { EVENTS } from "@notesnook/core"; +import { EVENTS, parseInternalLink } from "@notesnook/core"; import { registerKeyMap } from "./common/key-map"; import { updateStatus, removeStatus, getStatus } from "./hooks/use-status"; -import { hashNavigate } from "./navigation"; +import { hashNavigate, navigate } from "./navigation"; import { desktop } from "./common/desktop-bridge"; import { FeatureDialog } from "./dialogs/feature-dialog"; import { AnnouncementDialog } from "./dialogs/announcement-dialog"; @@ -224,6 +224,31 @@ export default function AppEffects() { }; }, []); + useEffect(() => { + const { unsubscribe } = + desktop?.bridge.onOpenLink.subscribe(undefined, { + onData(url) { + const link = parseInternalLink(url); + if (!link) return; + if (link.type === "note") { + useEditorStore.getState().openSession(link.id, { + activeBlockId: link.params?.blockId || undefined + }); + } else if (link.type === "notebook") { + navigate(`/notebooks/${link.id}`); + } else if (link.type === "tag") { + navigate(`/tags/${link.id}`); + } else if (link.type === "color") { + navigate(`/colors/${link.id}`); + } + } + }) || {}; + + return () => { + unsubscribe?.(); + }; + }, []); + return ; } diff --git a/packages/core/src/utils/internal-link.ts b/packages/core/src/utils/internal-link.ts index fc00bb73a..7f9cd307f 100644 --- a/packages/core/src/utils/internal-link.ts +++ b/packages/core/src/utils/internal-link.ts @@ -17,16 +17,24 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -const InternalLinkTypes = ["note"] as const; +const InternalLinkTypes = ["note", "notebook", "tag", "color"] as const; type InternalLinkType = (typeof InternalLinkTypes)[number]; -export type InternalLink = { +type NoteLink = BaseInternalLink<"note">; +type NotebookLink = BaseInternalLink<"notebook">; +type TagLink = BaseInternalLink<"tag">; +type ColorLink = BaseInternalLink<"color">; +type BaseInternalLink< + T extends InternalLinkType = InternalLinkType, + TParams extends InternalLinkParams[T] = InternalLinkParams[T] +> = { type: T; id: string; - params?: Partial; + params?: Partial; }; +export type InternalLink = NoteLink | NotebookLink | TagLink | ColorLink; export type InternalLinkWithOffset< T extends InternalLinkType = InternalLinkType -> = InternalLink & { +> = BaseInternalLink & { start: number; end: number; text: string; @@ -34,6 +42,9 @@ export type InternalLinkWithOffset< type InternalLinkParams = { note: { blockId: string }; + notebook: {}; + tag: {}; + color: {}; }; export function createInternalLink( type: T,