Skip to content
Merged
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
Binary file added apps/desktop/build/tray/tray.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/desktop/build/tray/tray@2x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/desktop/build/tray/trayTemplate.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/desktop/build/tray/trayTemplate@2x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
21 changes: 15 additions & 6 deletions apps/desktop/electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,13 @@ function rendererManualChunk(id: string): string | undefined {
return 'vendor-textmate'
}

// No manualChunks rule for mermaid / cytoscape / dagre on purpose. Mermaid is
// only ever reached through a dynamic import (Preview.tsx does
// `import("mermaid")`, behind LazyPreview), but forcing its modules into a
// named chunk made Rollup hoist that chunk into the entry's STATIC graph:
// No manualChunks rule for the diagram stacks on purpose. Mermaid fences are
// drawn by Pele, reached only through `import("./pele-runtime")` in
// mermaid-render.ts; mermaid.js itself (with cytoscape / dagre) now arrives
// only with Excalidraw's Mermaid-to-Excalidraw dialog, also behind a dynamic
// import. Both stay async only while Rollup is left to chunk them. When
// mermaid's modules were forced into a named chunk, Rollup hoisted that chunk
// into the entry's STATIC graph:
// `index-*.js` ended up with `import{m as gt}from"./vendor-mermaid-*.js"`,
// where gt is mermaid itself, so every launch fetched and evaluated 2.5MB of
// diagram code (and vendor-markdown with it, which vendor-mermaid imports)
Expand Down Expand Up @@ -200,8 +203,14 @@ function isDeferredRendererPreload(dep: string): boolean {
dep.includes('vendor-highlight') ||
dep.includes('vendor-textmate') ||
dep.includes('vendor-d3') ||
// Mermaid is no longer one named chunk; it splits into mermaid.core plus a
// chunk per diagram type, with cytoscape/dagre alongside.
// Pele, the mermaid-fence renderer: its runtime chunk, after which each
// diagram type fetches a chunk of its own on first use.
dep.includes('pele-runtime') ||
// D2, the ```d2 renderer: one chunk of about 8 MB with its WebAssembly
// compiler inside, fetched only when a note holds a d2 fence.
dep.includes('d2-runtime') ||
// mermaid.js (Excalidraw's Mermaid dialog only) splits into mermaid.core plus
// a chunk per diagram type, with cytoscape/dagre alongside.
dep.includes('mermaid') ||
dep.includes('cytoscape') ||
dep.includes('dagre') ||
Expand Down
9 changes: 7 additions & 2 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/desktop",
"productName": "ZenNotes",
"version": "2.70.0",
"version": "2.71.0",
"description": "ZenNotes desktop shell",
"private": true,
"main": "./out/main/index.js",
Expand Down Expand Up @@ -53,6 +53,7 @@
"@myriaddreamin/typst-ts-web-compiler": "^0.7.0",
"@myriaddreamin/typst.ts": "^0.7.0",
"@replit/codemirror-vim": "^6.3.0",
"@terrastruct/d2": "0.1.33",
"@xyflow/react": "^12.11.2",
"@zennotes/app-core": "*",
"@zennotes/bridge-contract": "*",
Expand All @@ -69,8 +70,8 @@
"highlight.js": "^11.10.0",
"jsxgraph": "^1.12.2",
"katex": "^0.16.15",
"mermaid": "^11.4.1",
"node-tikzjax": "^1.0.5",
"pele": "1.6.0",
"prettier": "^3.8.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
Expand Down Expand Up @@ -139,6 +140,10 @@
"from": "build/icon.png",
"to": "icon.png"
},
{
"from": "build/tray",
"to": "tray"
},
{
"from": "build/zen",
"to": "zen"
Expand Down
194 changes: 192 additions & 2 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
app,
autoUpdater as nativeAutoUpdater,
BrowserWindow,
dialog,
globalShortcut,
Expand Down Expand Up @@ -280,6 +281,15 @@ import {
installRaycastExtension,
} from "./raycast-integration";
import { sendUsageCount } from "./usage-count";
import {
hideTray,
parseTraySyncStatus,
SessionEndWatch,
setTraySyncStatus,
shouldHideOnClose,
showTray,
} from "./tray";
import { shouldQuitWithOnlyQuickCaptureLeft } from "./last-window";
import {
checkForAppUpdates,
describeInstall,
Expand Down Expand Up @@ -367,6 +377,18 @@ const installedBundle = createInstalledBundleGuard(
let mainWindow: BrowserWindow | null = null;
let mainWindowReadyForAppEvents = false;
let creatingMainWindow: Promise<BrowserWindow> | null = null;
// "Keep running in the tray" (#947), mirrored from the persisted config so a
// window's close handler can decide synchronously.
let keepRunningInTray = false;
// Set once the app is really exiting, so close-to-tray stands aside: the
// tray's Quit, Cmd+Q, or an update's restart.
let appQuitting = false;
// Windows signing out, restarting or shutting down stands it aside too, but
// only while the session is really ending: a cancelled sign-out lapses.
const sessionEnd = new SessionEndWatch();
// Workspace windows a close hid to the tray, so the tray brings back exactly
// those and never a window that is still loading behind `show: false`.
const trayHiddenWindowIds = new Set<number>();
let currentVault: VaultInfo | null = null;
let currentWorkspaceMode: "local" | "remote" = "local";
let remoteWorkspaceConfig: PersistedRemoteWorkspaceConfig | null = null;
Expand Down Expand Up @@ -788,6 +810,60 @@ function focusWindow(win: BrowserWindow): void {
}
}

// A real hide, not a minimise: a tiling window manager has nowhere to
// minimise to, which is the case the tray exists for (#947).
function hideWindowToTray(win: BrowserWindow): void {
if (win.isDestroyed()) return;
trayHiddenWindowIds.add(win.id);
win.hide();
}

function hasVisibleWorkspaceWindow(): boolean {
return BrowserWindow.getAllWindows().some(
(win) => !win.isDestroyed() && isWorkspaceWindow(win) && win.isVisible(),
);
}

/** Bring back every window the tray is holding, the main one in front. With
* none held and none open, open the main window. */
function revealTrayHiddenWindows(): void {
const held = BrowserWindow.getAllWindows().filter(
(win) => !win.isDestroyed() && trayHiddenWindowIds.has(win.id),
);
trayHiddenWindowIds.clear();
for (const win of held) if (win !== mainWindow) win.show();
if (mainWindow && !mainWindow.isDestroyed()) {
focusWindow(mainWindow);
return;
}
const last = held.at(-1);
if (last) {
focusWindow(last);
return;
}
void ensureMainWindow();
}

function applyKeepRunningInTray(enabled: boolean): void {
keepRunningInTray = enabled;
if (!enabled) {
hideTray();
// Nothing would bring a held window back once the icon is gone, even
// with another window still open: closing that one later would leave
// the app running with no window and no icon.
if (trayHiddenWindowIds.size > 0) revealTrayHiddenWindows();
return;
}
showTray({
showWindows: revealTrayHiddenWindows,
openQuickCapture: () => void toggleQuickCaptureWindow(),
quit: () => {
appQuitting = true;
app.quit();
},
});
}

// Dispatch a note-open to a specific window, deferring until that
// window's renderer reports ready so the request isn't dropped on a
// freshly created window.
Expand Down Expand Up @@ -1769,8 +1845,32 @@ async function createWindow(
win.on("maximize", scheduleWindowStatePersist);
win.on("unmaximize", scheduleWindowStatePersist);
win.on("close", flushWindowStatePersist);
win.on("close", (event) => {
const hide = shouldHideOnClose({
keepRunningInTray,
quitting: appQuitting,
sessionEnding: sessionEnd.isEnding(performance.now()),
isWorkspaceWindow: isWorkspaceWindow(win),
otherVisibleWorkspaceWindows: BrowserWindow.getAllWindows().filter(
(other) =>
other !== win &&
!other.isDestroyed() &&
isWorkspaceWindow(other) &&
other.isVisible(),
).length,
});
if (!hide) return;
event.preventDefault();
hideWindowToTray(win);
});
// Windows asks every window before logging off; a close we prevent there
// would hold up the session ending. Asking is not ending, so the question
// lapses unless `session-end` follows.
win.on("query-session-end", () => sessionEnd.query(performance.now()));
win.on("session-end", () => sessionEnd.end());
win.on("closed", () => {
if (persistWindowStateTimer) clearTimeout(persistWindowStateTimer);
trayHiddenWindowIds.delete(win.id);
workspaceWindowIds.delete(win.id);
windowVaults.clearWindow(win.id);
readyWindowIds.delete(win.id);
Expand Down Expand Up @@ -4953,6 +5053,35 @@ function registerIpc(): void {
return { ok: result.ok, hotkey: trimmed, error: result.error };
});

handle(IPC.APP_GET_KEEP_RUNNING_IN_TRAY, () => keepRunningInTray);

handle(IPC.APP_SET_KEEP_RUNNING_IN_TRAY, async (_e, enabled: unknown) => {
const next = enabled === true;
await updateConfig((cfg) => ({ ...cfg, keepRunningInTray: next }));
applyKeepRunningInTray(next);
return keepRunningInTray;
});

handle(IPC.WINDOW_HIDE_TO_TRAY, (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (!keepRunningInTray || !win || !isWorkspaceWindow(win)) return false;
hideWindowToTray(win);
return true;
});

on(IPC.APP_QUIT, () => {
appQuitting = true;
app.quit();
});

on(IPC.APP_TRAY_SYNC_STATUS, (event, raw: unknown) => {
// One line for one app: the main window speaks for the tray, and a
// second window's vault does not overwrite it.
const win = BrowserWindow.fromWebContents(event.sender);
if (!win || win !== mainWindow) return;
setTraySyncStatus(parseTraySyncStatus(raw));
});

handle(IPC.APP_GET_QUICK_CAPTURE_PINNED, async () => {
const cfg = await loadConfig();
quickCapturePinned = cfg.quickCapturePinned;
Expand Down Expand Up @@ -5322,6 +5451,9 @@ async function ensureQuickCaptureWindow(): Promise<BrowserWindow> {
if (quickCaptureWindow === win) quickCaptureWindow = null;
windowVaults.clearWindow(win.id);
});
// A panel left on screen after the last window closed holds the app only
// until it hides.
win.on("hide", scheduleQuitIfOnlyQuickCaptureLeft);
win.on("blur", () => {
// Focus-out hides the panel so the user's flow snaps back to whatever
// they were doing — same UX as Spotlight / Raycast. When pinned, the
Expand Down Expand Up @@ -6003,6 +6135,13 @@ app.whenReady().then(async () => {
scheduleBackgroundAppUpdateCheck();
scheduleCliUpdateChecks();

try {
const { keepRunningInTray: trayOn } = await loadConfig();
if (trayOn) applyKeepRunningInTray(true);
} catch (err) {
console.warn("Tray setup failed", err);
}

try {
const cfg = await loadConfig();
// loadConfig always yields a normalized string here, and empty string is
Expand All @@ -6018,8 +6157,10 @@ app.whenReady().then(async () => {
}

app.on("activate", () => {
// A dock click must bring back a usable window when none is left.
// A dock click must bring back a usable window when none is left, and
// the windows the tray is holding when only those are left.
if (!hasWorkspaceWindow()) void ensureMainWindow();
else if (!hasVisibleWorkspaceWindow()) revealTrayHiddenWindows();
});

app.on("new-window-for-tab", () => {
Expand Down Expand Up @@ -6072,11 +6213,15 @@ app.on("second-instance", (_event, argv) => {
// window is left to show.
void settleFileOpens().then(() => {
if (!hasWorkspaceWindow()) return ensureMainWindow();
// A path the opener ignored (not markdown, gone) leaves the windows
// the tray holds out of sight; opening ZenNotes again must show one.
if (!hasVisibleWorkspaceWindow()) revealTrayHiddenWindows();
});
return;
}
if (mainWindow && !mainWindow.isDestroyed()) {
focusWindow(mainWindow);
// Focuses the main window, and shows any the tray is holding (#947).
revealTrayHiddenWindows();
return;
}
void ensureMainWindow();
Expand All @@ -6088,7 +6233,52 @@ app.on("window-all-closed", () => {
if (!isMac()) app.quit();
});

// Electron counts the hidden Quick capture panel as an open window, so
// `window-all-closed` above never fires once the panel has been used.
let windowClosedThisRun = false;

// Deferred so the closed window's own handlers finish and the panel's
// visibility settles before the windows are counted.
function scheduleQuitIfOnlyQuickCaptureLeft(): void {
setImmediate(() => {
const capture =
quickCaptureWindow && !quickCaptureWindow.isDestroyed()
? quickCaptureWindow
: null;
const otherWindows = BrowserWindow.getAllWindows().filter(
(win) => !win.isDestroyed() && win !== capture,
).length;
let quickCapture: "none" | "hidden" | "visible" = "none";
if (capture) quickCapture = capture.isVisible() ? "visible" : "hidden";
const quit = shouldQuitWithOnlyQuickCaptureLeft({
isMac: isMac(),
keepRunningInTray,
quitting: appQuitting,
windowClosed: windowClosedThisRun,
otherWindows,
quickCapture,
});
if (quit) app.quit();
});
}

app.on("browser-window-created", (_event, win) => {
win.once("closed", () => {
windowClosedThisRun = true;
scheduleQuitIfOnlyQuickCaptureLeft();
});
});

// Squirrel.Mac closes every window before `before-quit` fires when it
// installs an update, and a close hidden to the tray would stall that install.
if (process.platform === "darwin") {
nativeAutoUpdater.on("before-quit-for-update", () => {
appQuitting = true;
});
}

app.on("before-quit", () => {
appQuitting = true;
cloudSyncService?.stop();
void cloudAuthLoopbackServer?.stop();
windowVaults.stopAll();
Expand Down
47 changes: 47 additions & 0 deletions apps/desktop/src/main/last-window.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { shouldQuitWithOnlyQuickCaptureLeft } from './last-window'

describe('a hidden Quick capture panel does not keep the app alive', () => {
// The last workspace window just closed on Linux or Windows, the tray is
// off, and the panel was opened once and dismissed.
const base = {
isMac: false,
keepRunningInTray: false,
quitting: false,
windowClosed: true,
otherWindows: 0,
quickCapture: 'hidden' as const
}

it('quits once only the hidden panel is left', () => {
expect(shouldQuitWithOnlyQuickCaptureLeft(base)).toBe(true)
})

it('leaves macOS running with no window, as always', () => {
expect(shouldQuitWithOnlyQuickCaptureLeft({ ...base, isMac: true })).toBe(false)
})

it('keeps running with the tray on, where the icon shows it and the hotkey still works', () => {
expect(shouldQuitWithOnlyQuickCaptureLeft({ ...base, keepRunningInTray: true })).toBe(false)
})

it('stands aside while the app is already quitting', () => {
expect(shouldQuitWithOnlyQuickCaptureLeft({ ...base, quitting: true })).toBe(false)
})

it('keeps any other window open, hidden or not, the way Electron counts them', () => {
expect(shouldQuitWithOnlyQuickCaptureLeft({ ...base, otherWindows: 1 })).toBe(false)
})

it('waits for a panel still on screen to hide', () => {
expect(shouldQuitWithOnlyQuickCaptureLeft({ ...base, quickCapture: 'visible' })).toBe(false)
})

it('leaves the no-window case to window-all-closed', () => {
expect(shouldQuitWithOnlyQuickCaptureLeft({ ...base, quickCapture: 'none' })).toBe(false)
})

it('keeps a quick-capture deep-link launch resident until a window is closed', () => {
expect(shouldQuitWithOnlyQuickCaptureLeft({ ...base, windowClosed: false })).toBe(false)
})
})
Loading
Loading