desktop: add handler for nn:// urls

This commit is contained in:
Abdullah Atta
2026-02-20 12:28:26 +05:00
parent b20ad48b31
commit bbef62930a
5 changed files with 81 additions and 7 deletions

View File

@@ -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": {

View File

@@ -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<AppEvents>;
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, {

View File

@@ -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);

View File

@@ -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 <React.Fragment />;
}

View File

@@ -17,16 +17,24 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
const InternalLinkTypes = ["note"] as const;
const InternalLinkTypes = ["note", "notebook", "tag", "color"] as const;
type InternalLinkType = (typeof InternalLinkTypes)[number];
export type InternalLink<T extends InternalLinkType = InternalLinkType> = {
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<InternalLinkParams[T]>;
params?: Partial<TParams>;
};
export type InternalLink = NoteLink | NotebookLink | TagLink | ColorLink;
export type InternalLinkWithOffset<
T extends InternalLinkType = InternalLinkType
> = InternalLink<T> & {
> = BaseInternalLink<T> & {
start: number;
end: number;
text: string;
@@ -34,6 +42,9 @@ export type InternalLinkWithOffset<
type InternalLinkParams = {
note: { blockId: string };
notebook: {};
tag: {};
color: {};
};
export function createInternalLink<T extends InternalLinkType>(
type: T,