From 9b2dd31f724479d941809bf81a67f0849693c334 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 29 Sep 2026 09:31:43 -0500 Subject: [PATCH 1/8] Fix(updater): repaint only the progress badge while an update downloads The updater broadcasts a new state about once a second during a download. Sidebar and SettingsModal each subscribed at the top of the whole component, so every tick re-rendered the entire sidebar (77 components) and, with Settings open, the entire dialog, to repaint one percentage. The subscription now lives in the leaves that show it: SidebarSettingsAction for the footer badge and AppUpdatesCard for Settings > About. Same markup, badges, titles and buttons; the Settings row keeps its sidebar index across lone re-renders. Per tick: 77 -> 8 components with the editor open, 83 -> 6 with Settings open. This does not claim to fix #868 (typing lost during a download on Niri/Wayland), which does not reproduce on macOS and is waiting on the reporter; it removes churn from the path while that is narrowed down. --- .../app-core/src/components/SettingsModal.tsx | 349 +++++++++--------- packages/app-core/src/components/Sidebar.tsx | 83 +++-- 2 files changed, 228 insertions(+), 204 deletions(-) diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index 84f22855..ed811f4c 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -452,6 +452,181 @@ function formatReleaseNotesForDisplay(notes: string | null): string | null { } } +/** + * The About page's update card. It owns the updater subscription so the + * once-a-second progress broadcast of a download re-renders this card and not + * the whole Settings dialog (#868). + */ +function AppUpdatesCard({ homepage }: { homepage?: string }): JSX.Element { + const appUpdateState = useAppUpdateState(); + + const triggerUpdateCheck = useCallback(() => { + void window.zen.checkForAppUpdates().then( + (state) => { + if (state.phase === "available") { + window.alert( + state.installable + ? `ZenNotes ${state.availableVersion ?? ""} is available. Use “Download Update” to fetch it.` + : state.message, + ); + return; + } + if (state.phase === "not-available") { + window.alert(state.message); + return; + } + if ( + state.phase === "unsupported" || + state.phase === "offline" || + state.phase === "error" + ) { + window.alert(state.message); + } + }, + (error) => { + const message = + error instanceof Error + ? error.message + : "Could not check for updates."; + window.alert(message); + }, + ); + }, []); + + const triggerUpdateDownload = useCallback(() => { + void window.zen.downloadAppUpdate(); + }, []); + + const triggerUpdateInstall = useCallback(() => { + void window.zen.installAppUpdate(); + }, []); + + const displayedReleaseNotes = useMemo( + () => formatReleaseNotesForDisplay(appUpdateState?.releaseNotes ?? null), + [appUpdateState?.releaseNotes], + ); + + return ( +
+
+
+
+ Updates +
+
+ + {formatUpdatePhaseLabel(appUpdateState?.phase ?? "idle")} + + {appUpdateState?.availableVersion && ( + + Latest: v{appUpdateState.availableVersion} + + )} +
+
+
+ {appUpdateState?.phase === "available" && + appUpdateState.installable ? ( + + ) : appUpdateState?.phase === "downloaded" ? ( + + ) : ( + + )} + + View Release + +
+
+

+ {appUpdateState?.message ?? + "Check GitHub releases for a newer ZenNotes build."} +

+ {appUpdateState?.phase === "downloading" && ( +
+
+
+
+
+ {Math.round(appUpdateState.progressPercent ?? 0)}% + {formatBytes(appUpdateState.transferredBytes) && + formatBytes(appUpdateState.totalBytes) && ( + + {formatBytes(appUpdateState.transferredBytes)} /{" "} + {formatBytes(appUpdateState.totalBytes)} + + )} + {formatBytes(appUpdateState.bytesPerSecond) && ( + {formatBytes(appUpdateState.bytesPerSecond)}/s + )} +
+
+ )} + {displayedReleaseNotes && ( +
+ + Release notes + +
+            {displayedReleaseNotes}
+          
+
+ )} +
+ In-app updates use the published GitHub release feed. For general users, + that feed must be publicly reachable. +
+
+ ); +} + /** Read a `--z-*` token's current resolved value off as #rrggbb, for * seeding the Quick-tweaks pickers from the active theme. */ function rgbVarToHex(token: string): string { @@ -763,7 +938,6 @@ export function SettingsModal(): JSX.Element { const setNestedTags = useStore((s) => s.setNestedTags); const pdfExportUseTheme = useStore((s) => s.pdfExportUseTheme); const setPdfExportUseTheme = useStore((s) => s.setPdfExportUseTheme); - const appUpdateState = useAppUpdateState(); const [editingRemoteProfile, setEditingRemoteProfile] = useState<{ mode: "create" | "edit"; value?: RemoteWorkspaceProfileInput; @@ -819,47 +993,6 @@ export function SettingsModal(): JSX.Element { }; }, [searchToolPaths]); - const triggerUpdateCheck = useCallback(() => { - void window.zen.checkForAppUpdates().then( - (state) => { - if (state.phase === "available") { - window.alert( - state.installable - ? `ZenNotes ${state.availableVersion ?? ""} is available. Use “Download Update” to fetch it.` - : state.message, - ); - return; - } - if (state.phase === "not-available") { - window.alert(state.message); - return; - } - if ( - state.phase === "unsupported" || - state.phase === "offline" || - state.phase === "error" - ) { - window.alert(state.message); - } - }, - (error) => { - const message = - error instanceof Error - ? error.message - : "Could not check for updates."; - window.alert(message); - }, - ); - }, []); - - const triggerUpdateDownload = useCallback(() => { - void window.zen.downloadAppUpdate(); - }, []); - - const triggerUpdateInstall = useCallback(() => { - void window.zen.installAppUpdate(); - }, []); - const currentRemoteProfileId = workspaceMode === "remote" ? (remoteWorkspaceInfo?.profileId ?? null) @@ -917,11 +1050,6 @@ export function SettingsModal(): JSX.Element { [deleteRemoteWorkspaceProfile], ); - const displayedReleaseNotes = useMemo( - () => formatReleaseNotesForDisplay(appUpdateState?.releaseNotes ?? null), - [appUpdateState?.releaseNotes], - ); - // Family list — Apple is the default, followed by the other families. const familyOptions = useMemo<{ id: ThemeFamily; label: string }[]>( () => [ @@ -5468,132 +5596,7 @@ export function SettingsModal(): JSX.Element {
-
-
-
-
- Updates -
-
- - {formatUpdatePhaseLabel( - appUpdateState?.phase ?? "idle", - )} - - {appUpdateState?.availableVersion && ( - - Latest: v{appUpdateState.availableVersion} - - )} -
-
-
- {appUpdateState?.phase === "available" && - appUpdateState.installable ? ( - - ) : appUpdateState?.phase === "downloaded" ? ( - - ) : ( - - )} - - View Release - -
-
-

- {appUpdateState?.message ?? - "Check GitHub releases for a newer ZenNotes build."} -

- {appUpdateState?.phase === "downloading" && ( -
-
-
-
-
- - {Math.round(appUpdateState.progressPercent ?? 0)}% - - {formatBytes(appUpdateState.transferredBytes) && - formatBytes(appUpdateState.totalBytes) && ( - - {formatBytes(appUpdateState.transferredBytes)} /{" "} - {formatBytes(appUpdateState.totalBytes)} - - )} - {formatBytes(appUpdateState.bytesPerSecond) && ( - - {formatBytes(appUpdateState.bytesPerSecond)}/s - - )} -
-
- )} - {displayedReleaseNotes && ( -
- - Release notes - -
-                        {displayedReleaseNotes}
-                      
-
- )} -
- In-app updates use the published GitHub release feed. For - general users, that feed must be publicly reachable. -
-
+

{appInfo.description}. Visit{" "} s.toggleSidebar); const setFocusedPanel = useStore((s) => s.setFocusedPanel); const setSettingsOpen = useStore((s) => s.setSettingsOpen); - const appUpdateState = useAppUpdateState(); const renameTag = useStore((s) => s.renameTag); const deleteTag = useStore((s) => s.deleteTag); const tagsCollapsed = useStore((s) => s.tagsCollapsed); @@ -565,15 +564,6 @@ export function Sidebar(): JSX.Element { const setVaultSettings = useStore((s) => s.setVaultSettings); const canRevealInFileManager = window.zen.getAppInfo().runtime === "desktop" && workspaceMode !== "remote"; - const appUpdateBadge = appUpdateBadgeLabel(appUpdateState); - const appUpdateSettingsTitle = - appUpdateState?.phase === "downloaded" - ? "Settings, update ready to install" - : appUpdateState?.phase === "downloading" - ? "Settings, update downloading" - : appUpdateState?.phase === "available" - ? "Settings, update available" - : "Settings"; const canSwitchLocalVaults = window.zen.getAppInfo().runtime === "desktop" && window.zen.getCapabilities().supportsLocalFilesystemPickers; @@ -3742,11 +3732,7 @@ export function Sidebar(): JSX.Element { sidebarFocused={isSidebarFocused} sidebarData={{ type: "help" }} /> - } - label="Settings" - title={appUpdateSettingsTitle} - badgeLabel={appUpdateBadge ?? undefined} + setSettingsOpen(true)} sidebarIdx={idxCounter.current.value++} @@ -5922,6 +5908,56 @@ function SidebarRow({ ); } +interface SidebarFooterActionProps { + icon: JSX.Element; + label: string; + title?: string; + count?: number; + badgeLabel?: string; + active?: boolean; + /** Narrow-sidebar mode (#539): the label is dropped and the tooltip and + * aria-label carry it instead, so the row never outgrows the sidebar. */ + iconOnly?: boolean; + onClick: () => void; + sidebarIdx?: number; + vimHighlight?: boolean; + sidebarFocused?: boolean; + sidebarData?: { type: string }; +} + +/** + * The Settings row carries the updater badge, so it is the one piece of the + * sidebar that needs the updater state. It subscribes here, in a leaf, rather + * than in Sidebar itself: a download broadcasts a new state about once a + * second for its whole duration, and each broadcast used to re-render the + * entire sidebar tree to repaint one percentage badge (#868). + */ +function SidebarSettingsAction( + props: Omit< + SidebarFooterActionProps, + "icon" | "label" | "title" | "badgeLabel" + >, +): JSX.Element { + const appUpdateState = useAppUpdateState(); + const title = + appUpdateState?.phase === "downloaded" + ? "Settings, update ready to install" + : appUpdateState?.phase === "downloading" + ? "Settings, update downloading" + : appUpdateState?.phase === "available" + ? "Settings, update available" + : "Settings"; + return ( + } + label="Settings" + title={title} + badgeLabel={appUpdateBadgeLabel(appUpdateState) ?? undefined} + /> + ); +} + /** Compact labeled action used in the sidebar footer. Same vim-nav * wiring as SidebarRow (sidebarIdx / sidebarData), but kept short so * vault utilities stay legible without stealing space from the tree. */ @@ -5938,22 +5974,7 @@ function SidebarFooterAction({ vimHighlight, sidebarFocused = false, sidebarData, -}: { - icon: JSX.Element; - label: string; - title?: string; - count?: number; - badgeLabel?: string; - active?: boolean; - /** Narrow-sidebar mode (#539): the label is dropped and the tooltip and - * aria-label carry it instead, so the row never outgrows the sidebar. */ - iconOnly?: boolean; - onClick: () => void; - sidebarIdx?: number; - vimHighlight?: boolean; - sidebarFocused?: boolean; - sidebarData?: { type: string }; -}): JSX.Element { +}: SidebarFooterActionProps): JSX.Element { const strongActive = !!active && (!sidebarFocused || !!vimHighlight); const resolvedTitle = title ?? label; return ( From 3dec2a68268c45d711db62be041b4bf0b884b9f7 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 29 Sep 2026 09:51:38 -0500 Subject: [PATCH 2/8] Fix(cli): ship the bundled terminal folder readable on Linux Every Linux package since 2.52.0 installed resources/terminal as a root-owned 0700 folder: the tarball (and the AUR package, which copies it with cp -a), the .deb and the .pacman. The terminal stager builds that folder with mkdtemp, which always creates 0700, then renames it into place, and the packagers keep the mode. The files inside were readable, but no user could enter the folder, so the app never read the manifest, never activated the pinned Go zn, and kept everyone on the legacy Node CLI. Settings > CLI blamed an invalid manifest, because the runtime treated every read failure other than a missing file as a bad manifest. The stager now sets the folder and zn to 0755 and LICENSE and manifest.json to 0644 with chmod, so a builder's umask cannot narrow them. The runtime reports EACCES/EPERM on the bundle as a permission problem, naming the folder and the fix, and keeps "invalid" for a manifest that does not parse. Checked by building the linux-x64 tarball, .deb and .pacman on macOS before and after: resources/terminal goes from drwx------ to drwxr-xr-x root:root in all three. The macOS app carried the same 0700 folder; it worked for the account that installed it, not for other accounts on the same Mac. Upgrading fixes existing installs; until then the workaround is sudo chmod 755 on the installed resources/terminal. --- .../desktop/src/main/terminal-runtime.test.ts | 18 ++++++++ apps/desktop/src/main/terminal-runtime.ts | 11 ++++- tooling/scripts/terminal-artifact.mjs | 13 +++++- tooling/scripts/terminal-artifact.test.mjs | 46 ++++++++++++++++++- 4 files changed, 85 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/terminal-runtime.test.ts b/apps/desktop/src/main/terminal-runtime.test.ts index 75773ff8..a3f62f85 100644 --- a/apps/desktop/src/main/terminal-runtime.test.ts +++ b/apps/desktop/src/main/terminal-runtime.test.ts @@ -1,4 +1,5 @@ import { + chmod, mkdtemp, mkdir, writeFile, @@ -105,5 +106,22 @@ describe.skipIf(process.platform === 'win32')( await rm(options.bundleDir, { recursive: true }) expect(await prepareTerminalRuntime(options)).toBeNull() }) + + // Root reads through any mode, so the denial cannot be staged as root. + it.skipIf(process.getuid?.() === 0)( + 'reports an unreadable bundle as a permission problem, not an invalid manifest (#869)', + async () => { + const options = await fixture() + await chmod(options.bundleDir, 0o000) + try { + const failure = prepareTerminalRuntime(options) + await expect(failure).rejects.toThrow(/cannot be read \(permission denied\)/) + await expect(failure).rejects.toThrow(options.bundleDir) + await expect(failure).rejects.not.toThrow(/invalid/i) + } finally { + await chmod(options.bundleDir, 0o755) + } + }, + ) }, ) diff --git a/apps/desktop/src/main/terminal-runtime.ts b/apps/desktop/src/main/terminal-runtime.ts index e67b0143..74db4bf2 100644 --- a/apps/desktop/src/main/terminal-runtime.ts +++ b/apps/desktop/src/main/terminal-runtime.ts @@ -91,7 +91,16 @@ async function prepare( await fs.readFile(path.join(options.bundleDir, 'manifest.json'), 'utf8'), ) } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null + const code = (error as NodeJS.ErrnoException).code + if (code === 'ENOENT') return null + // A package that installs the bundle without read access for this user + // (#869) is not a broken manifest; say what is wrong and how to fix it. + if (code === 'EACCES' || code === 'EPERM') { + throw new Error( + `The bundled terminal at ${options.bundleDir} cannot be read (permission denied). Make that folder readable (chmod 755) or reinstall ZenNotes.`, + { cause: error }, + ) + } throw new Error('The bundled terminal manifest is invalid.', { cause: error, }) diff --git a/tooling/scripts/terminal-artifact.mjs b/tooling/scripts/terminal-artifact.mjs index 6adf9ad6..8c2b4531 100644 --- a/tooling/scripts/terminal-artifact.mjs +++ b/tooling/scripts/terminal-artifact.mjs @@ -2,6 +2,7 @@ import { createHash, randomUUID } from 'node:crypto' import { execFile } from 'node:child_process' import { promisify } from 'node:util' import { + chmod, mkdir, mkdtemp, readFile, @@ -100,13 +101,23 @@ async function installStage(output, bytes, license, manifest, probe) { let replaced = false try { const binary = join(stage, 'zn') - await writeFile(binary, bytes, { mode: 0o755 }) + await writeFile(binary, bytes) await writeFile(join(stage, 'LICENSE'), license) await writeFile( join(stage, 'manifest.json'), JSON.stringify({ ...manifest, binarySha256: hash(bytes) }, null, 2) + '\n', ) + // The stage directory becomes the packaged `resources/terminal`, and the + // Linux packages (deb, rpm, pacman, and the tarball the AUR repackages + // with `cp -a`) install it root-owned with the mode it has here. mkdtemp + // creates it 0700, which left every user but root unable to read the + // manifest, so the app stayed on the legacy CLI (#869). Set every mode + // explicitly: chmod is not narrowed by the builder's umask. + await chmod(stage, 0o755) + await chmod(binary, 0o755) + await chmod(join(stage, 'LICENSE'), 0o644) + await chmod(join(stage, 'manifest.json'), 0o644) if ( probe && manifest.platform === process.platform && diff --git a/tooling/scripts/terminal-artifact.test.mjs b/tooling/scripts/terminal-artifact.test.mjs index 90be3512..ccd859f0 100644 --- a/tooling/scripts/terminal-artifact.test.mjs +++ b/tooling/scripts/terminal-artifact.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises' +import { mkdtemp, mkdir, writeFile, readFile, rm, stat } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { createHash } from 'node:crypto' @@ -123,6 +123,50 @@ test('local candidates require opt-in and checksum verification without replacin } }) +test('the staged folder is readable by every user, whatever the umask (#869)', async () => { + // Linux packages install this folder root-owned with the mode it is staged + // with; a 0700 folder (mkdtemp's default) hid the bundled CLI from users. + const root = await mkdtemp(join(tmpdir(), 'zn-artifact-modes-')) + const previous = process.umask(0o077) + try { + const local = join(root, 'local'), + staged = join(root, 'output') + const target = join(local, 'linux-x64') + await mkdir(target, { recursive: true }) + const bytes = executable('linux', 'x64') + await writeFile(join(target, 'zn'), bytes) + await writeFile(join(target, 'LICENSE'), 'MIT\n') + await writeFile( + join(target, 'manifest.json'), + JSON.stringify({ + schemaVersion: 1, + protocol: 1, + version: '1.0.0-local', + platform: 'linux', + arch: 'x64', + local: true, + binarySha256: createHash('sha256').update(bytes).digest('hex'), + }), + ) + await stageTerminalArtifact({ + platform: 'linux', + arch: 'x64', + localDirectory: local, + allowLocal: true, + output: staged, + probe: false, + }) + const mode = async (path) => (await stat(path)).mode & 0o777 + assert.equal(await mode(staged), 0o755) + assert.equal(await mode(join(staged, 'zn')), 0o755) + assert.equal(await mode(join(staged, 'LICENSE')), 0o644) + assert.equal(await mode(join(staged, 'manifest.json')), 0o644) + } finally { + process.umask(previous) + await rm(root, { recursive: true, force: true }) + } +}) + test('a disabled release removes a previously staged candidate', async () => { const root = await mkdtemp(join(tmpdir(), 'zn-artifact-disabled-')) try { From 35de243e00d32971740fa2f30bfd6b6233a12b4c Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 29 Sep 2026 10:38:56 -0500 Subject: [PATCH 3/8] Fix(cli): give the bundled CLI folder a new name so upgrades unlock it 3dec2a68 made new packages ship resources/terminal readable, but that only helped fresh installs. dpkg and pacman keep the permissions of a folder that already exists when they upgrade a package (pacman says "directory permissions differ ... filesystem: 700 package: 755" and leaves it), so every install of 2.52.0 to 2.57.0, the people #869 is about, would have kept the locked folder and the legacy CLI after upgrading. 3dec2a68's message said upgrading fixes existing installs; it did not, and this change is what does. The bundle now ships as resources/zn-cli. A folder the old package never had is created with the mode in the package, and the old locked folder goes away with the old package's files. The dev path is unchanged. Checked on Ubuntu 26.04 and Arch in Docker, installing the released 2.57.0 as root and then this build over it: with dpkg, with the .pacman, and with the AUR package built by makepkg (2.57.0-1, then 2.57.0-2 from this tarball), resources/terminal is gone and resources/zn-cli is drwxr-xr-x. Run as a normal user with the same user data before and after, Settings > CLI goes from "The bundled terminal manifest is invalid." to the Go CLI, and zn --version prints zn v0.4.1. An unsigned packaged macOS build finds the bundle at the new path and activates the same version. --- apps/desktop/build/after-pack.js | 8 +++++++- apps/desktop/src/main/cli-install.ts | 3 ++- apps/desktop/src/main/terminal-runtime.test.ts | 2 +- tooling/scripts/terminal-artifact.mjs | 2 +- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/desktop/build/after-pack.js b/apps/desktop/build/after-pack.js index d256cb5f..b5e5a543 100644 --- a/apps/desktop/build/after-pack.js +++ b/apps/desktop/build/after-pack.js @@ -47,7 +47,13 @@ exports.default = async function afterPack(context) { const resources = platform === 'darwin' ? path.join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`, 'Contents', 'Resources') : path.join(context.appOutDir, 'resources') - await stageTerminalArtifact({ platform, arch, localDirectory, allowLocal, output: path.join(resources, 'terminal') }) + // `zn-cli`, not the `terminal` of 2.52.0 to 2.57.0: those releases shipped + // `terminal` as root-owned 0700 (#869), and dpkg and pacman keep an + // existing folder's mode on upgrade, so fixing the mode alone never + // reached installed copies. A new name is created fresh with the packaged + // mode, and the old folder is removed with the old package's files. + // cli-install.ts reads the same name. + await stageTerminalArtifact({ platform, arch, localDirectory, allowLocal, output: path.join(resources, 'zn-cli') }) } if (platform !== 'linux') return await rewriteAsFreshFiles(path.join(context.appOutDir, 'resources', 'arch-extras')) diff --git a/apps/desktop/src/main/cli-install.ts b/apps/desktop/src/main/cli-install.ts index b28922d7..617fed13 100644 --- a/apps/desktop/src/main/cli-install.ts +++ b/apps/desktop/src/main/cli-install.ts @@ -275,8 +275,9 @@ async function locateWrapper(): Promise { let terminal try { terminal = await prepareTerminalRuntime({ + // Named in build/after-pack.js, which says why it is not `terminal` (#869). bundleDir: app.isPackaged - ? path.join(process.resourcesPath, 'terminal') + ? path.join(process.resourcesPath, 'zn-cli') : path.resolve(here, '../../build/terminal', `${process.platform}-${process.arch}`), userData: app.getPath('userData'), platform: process.platform, diff --git a/apps/desktop/src/main/terminal-runtime.test.ts b/apps/desktop/src/main/terminal-runtime.test.ts index a3f62f85..b91191d7 100644 --- a/apps/desktop/src/main/terminal-runtime.test.ts +++ b/apps/desktop/src/main/terminal-runtime.test.ts @@ -23,7 +23,7 @@ afterEach(async () => { async function fixture(version = '1.0.0') { const root = await mkdtemp(path.join(os.tmpdir(), "zn terminal ' ")) roots.push(root) - const bundleDir = path.join(root, 'resources', 'terminal') + const bundleDir = path.join(root, 'resources', 'zn-cli') await mkdir(bundleDir, { recursive: true }) const binary = `#!/bin/sh\nif [ "$1" = --desktop-integration ]; then\n echo '{"protocol":1,"version":"${version}"}'\n exit\nfi\nprintf '%s\\n' "$ZENNOTES_WORKSPACE_SOURCE" "$@"\nexit 7\n` await writeFile(path.join(bundleDir, 'zn'), binary, { mode: 0o755 }) diff --git a/tooling/scripts/terminal-artifact.mjs b/tooling/scripts/terminal-artifact.mjs index 8c2b4531..4895207f 100644 --- a/tooling/scripts/terminal-artifact.mjs +++ b/tooling/scripts/terminal-artifact.mjs @@ -108,7 +108,7 @@ async function installStage(output, bytes, license, manifest, probe) { JSON.stringify({ ...manifest, binarySha256: hash(bytes) }, null, 2) + '\n', ) - // The stage directory becomes the packaged `resources/terminal`, and the + // The stage directory becomes the packaged `resources/zn-cli`, and the // Linux packages (deb, rpm, pacman, and the tarball the AUR repackages // with `cp -a`) install it root-owned with the mode it has here. mkdtemp // creates it 0700, which left every user but root unable to read the From 49890e3ba9d95cdd2a9123d44c4692e25f9b809f Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 29 Sep 2026 10:53:25 -0500 Subject: [PATCH 4/8] Fix(packaging): let the .pacman install on current Arch The .pacman package shipped electron-builder's default pacman dependency list, which names http-parser. Arch dropped http-parser, so pacman -U on an up-to-date Arch stops with 'cannot resolve "http-parser"' and installs nothing; only -dd got past it. Most of that list was stale for an Electron app anyway: ffmpeg is bundled as libffmpeg.so, and c-ares, libevent, libvpx, libxslt, minizip, re2 and snappy are built in, so the binary links none of them. It links neither libXss nor libappindicator either, and ZenNotes has no tray icon. The list now comes from what the app actually loads. On a bare Arch install of the package, readelf and ldd put every library ZenNotes links under gtk3, nss and alsa-lib (with what they pull in), plus mesa for libgbm, which Electron links directly. libnotify (desktop notifications) and libsecret (the keyring behind saved cloud credentials) are loaded at runtime, so no linker check sees them. xdg-utils opens links and files in other apps, and desktop-file-utils registers the zennotes:// handler from the package's .desktop entry, as in the AUR package. Checked in a fresh archlinux container with nothing skipped: the 2.57.0 .pacman still fails on http-parser; this one installs, no library is missing, zennotes:// lands in mimeinfo.cache, and launched as a normal user through /usr/bin/zennotes it opens with the Go CLI active. --- apps/desktop/package.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d4c561bc..584d7999 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -300,6 +300,16 @@ }, "pacman": { "compression": "xz", + "depends": [ + "gtk3", + "nss", + "alsa-lib", + "mesa", + "libnotify", + "libsecret", + "xdg-utils", + "desktop-file-utils" + ], "fpm": [ "--provides", "zennotes", From 134b5d20e84164d7d5fd6b4abb4850d3b2b3ee81 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 29 Sep 2026 11:09:57 -0500 Subject: [PATCH 5/8] Fix(packaging): make the .deb pull in the audio library it links The .deb shipped electron-builder's default deb dependency list, which does not name libasound2 even though the app links libasound.so.2 directly, and does not name libgbm1 either. Desktop installs have both, so it went unnoticed, but on a minimal system apt installed ZenNotes cleanly and the app then refused to start: "error while loading shared libraries: libasound.so.2" (exit 127 on a clean Ubuntu 26.04). Ubuntu 22.04 also left libgbm.so.1 missing. The list keeps electron-builder's defaults and adds libgbm1 and "libasound2t64 | libasound2". The alternative covers the t64 rename: libasound2 is the real package on Ubuntu 22.04 and Debian 12, while on Ubuntu 24.04+ and Debian 13 the real one is libasound2t64 and libasound2 is only a virtual name that the OSS shim liboss4-salsa-asound2 also provides, so the real package is named first. Checked with apt on clean Ubuntu 22.04, 24.04 and 26.04 and Debian 12 and 13: the current .deb leaves libasound.so.2 unresolved on all five; this one resolves every library the app links, libasound.so.2 comes from the real ALSA package on each, and the app launches as a normal user on 22.04 and 26.04. The .rpm needs no change: a clean Fedora 44 installs the 2.57.0 .rpm with nothing missing. --- apps/desktop/package.json | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 584d7999..46faf213 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -292,7 +292,20 @@ }, "deb": { "afterInstall": "build/after-install.sh", - "compression": "xz" + "compression": "xz", + "depends": [ + "libgtk-3-0", + "libnotify4", + "libnss3", + "libxss1", + "libxtst6", + "xdg-utils", + "libatspi2.0-0", + "libuuid1", + "libsecret-1-0", + "libasound2t64 | libasound2", + "libgbm1" + ] }, "rpm": { "afterInstall": "build/after-install.sh", From 14093022ca49e147b17821625217c32492e6cdd9 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 29 Sep 2026 11:29:32 -0500 Subject: [PATCH 6/8] Fix(remote): stop Quick Connect from saving a second "ZenNotes Server" One Quick Connect saved two remote workspaces for the same server: the one you connected to and a "ZenNotes Server" with no vault and no token, and the vault switcher listed both. Quick Connect persists the live connection (remoteWorkspace) a moment before it saves the profile, and the config loader made up a "ZenNotes Server" profile for any remoteWorkspace without a profile at the same URL, with a new random id on every load. The profile save then wrote that made-up entry to disk. The loader now makes one up only for a config from before saved profiles existed (no remoteWorkspaceProfiles key at all), which is what that migration was for; in a current config remoteWorkspace is just the live connection. An extra entry that is already saved stays until the user removes it, since it looks exactly like a real one. Connect on a saved workspace with no token, when the server needs one, now names the workspace and points at Edit, instead of saying to check a token that was never sent. Reproduced with a scratch server and the built app over CDP: one Quick Connect saved both profiles, and Connect on the extra one gave the misleading error. With this change the same run saves one profile with its token, and a tokenless profile gets the new message. Three config tests cover the pre-profile migration and the Quick Connect write order. --- apps/desktop/src/main/index.ts | 32 ++++++++---- apps/desktop/src/main/vault-config.test.ts | 58 ++++++++++++++++++++++ apps/desktop/src/main/vault.ts | 14 +++++- 3 files changed, 94 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index ecb78a47..cfa3b98a 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -1864,11 +1864,12 @@ async function migrateLegacyRemoteWorkspaceSecrets(): Promise { } // After this write the target profile holds the only copy of the - // credential (the config's own copy is stripped right below). The config - // parser synthesizes the legacy workspace's profile with a fresh random - // id on every load and keeps remoteWorkspaceProfileId only when it - // matches a profile, so the common legacy case arrives here with a found - // profile and a null selection. Leaving the selection null strands the + // credential (the config's own copy is stripped right below). For a + // config from before saved profiles existed, the config parser + // synthesizes the legacy workspace's profile with a fresh random id on + // every load and keeps remoteWorkspaceProfileId only when it matches a + // profile, so the common legacy case arrives here with a found profile + // and a null selection. Leaving the selection null strands the // credential: boot resolves the token through the selected profile and // lands on the reconnect screen asking for a token the user already // saved. Never steal an existing valid selection, though. @@ -2628,10 +2629,23 @@ async function connectRemoteWorkspaceProfile( throw new Error("That saved remote workspace no longer exists."); } const authToken = await getRemoteWorkspaceSecret(profile.id); - const result = await setRemoteWorkspace(profile.baseUrl, authToken, { - profileId: profile.id, - vaultPath: profile.vaultPath, - }); + let result: Awaited>; + try { + result = await setRemoteWorkspace(profile.baseUrl, authToken, { + profileId: profile.id, + vaultPath: profile.vaultPath, + }); + } catch (err) { + // A profile with no saved token that the server turns away is missing a + // token, not holding a wrong one: name the profile and where to add it, + // instead of "check the auth token" for a token that was never sent (#870). + if (!authToken && err instanceof RemoteRequestError && err.status === 401) { + throw new Error( + `No auth token is saved for "${profile.name}", and ${profile.baseUrl} needs one. Choose Edit on it under Settings → Vault → Saved Remote Workspaces and add the server's token.`, + ); + } + throw err; + } const connectedAt = Date.now(); await updateConfig((current) => ({ ...current, diff --git a/apps/desktop/src/main/vault-config.test.ts b/apps/desktop/src/main/vault-config.test.ts index 5df31e0f..77e86e07 100644 --- a/apps/desktop/src/main/vault-config.test.ts +++ b/apps/desktop/src/main/vault-config.test.ts @@ -255,3 +255,61 @@ describe('config persistence', () => { expect(raw).toBe('corrupt') }) }) + +describe('remote workspace profiles from the live connection (#870)', () => { + const SERVER = 'https://notes.example.com' + const writeRaw = async (value: unknown): Promise => { + await writeFile(configFile(), JSON.stringify(value), 'utf8') + } + + it('gives a config from before saved profiles one profile for its connection', async () => { + await writeRaw({ workspaceMode: 'remote', remoteWorkspace: { baseUrl: SERVER, authToken: 'legacy-token' } }) + + const cfg = await loadConfig() + expect(cfg.remoteWorkspaceProfiles).toHaveLength(1) + expect(cfg.remoteWorkspaceProfiles[0]).toMatchObject({ + name: 'ZenNotes Server', + baseUrl: SERVER, + authToken: 'legacy-token', + vaultPath: null + }) + }) + + it('does not make up a profile for the live connection of a current config', async () => { + await writeRaw({ + workspaceMode: 'remote', + remoteWorkspace: { baseUrl: SERVER }, + remoteWorkspaceProfileId: null, + remoteWorkspaceProfiles: [] + }) + + const cfg = await loadConfig() + expect(cfg.remoteWorkspace).toEqual({ baseUrl: SERVER, authToken: null }) + expect(cfg.remoteWorkspaceProfiles).toEqual([]) + }) + + it('leaves one saved profile after Quick Connect writes the connection and then the profile', async () => { + await writeRaw({ workspaceMode: 'local', remoteWorkspace: null, remoteWorkspaceProfileId: null, remoteWorkspaceProfiles: [] }) + + // The order Quick Connect writes in: setRemoteWorkspace persists the live + // connection, then saveRemoteWorkspaceProfile adds the profile. + await updateConfig((cfg) => ({ + ...cfg, + workspaceMode: 'remote', + remoteWorkspace: { baseUrl: SERVER }, + remoteWorkspaceProfileId: null + })) + await updateConfig((cfg) => ({ + ...cfg, + remoteWorkspaceProfiles: [ + ...cfg.remoteWorkspaceProfiles, + { id: 'quick-connect', name: 'workspace (notes.example.com)', baseUrl: SERVER, vaultPath: '/workspace', lastConnectedAt: null } + ], + remoteWorkspaceProfileId: 'quick-connect' + })) + + const cfg = await loadConfig() + expect(cfg.remoteWorkspaceProfiles.map((p) => p.name)).toEqual(['workspace (notes.example.com)']) + expect(cfg.remoteWorkspaceProfileId).toBe('quick-connect') + }) +}) diff --git a/apps/desktop/src/main/vault.ts b/apps/desktop/src/main/vault.ts index 9f443205..1e11d0cb 100644 --- a/apps/desktop/src/main/vault.ts +++ b/apps/desktop/src/main/vault.ts @@ -418,7 +418,19 @@ function normalizePersistedConfig(value: unknown): PersistedConfig { .map((entry) => normalizeProfile(entry)) .filter((entry): entry is PersistedRemoteWorkspaceProfile => !!entry) : [] - if (legacyRemoteWorkspace && !remoteWorkspaceProfiles.some((entry) => entry.baseUrl === legacyRemoteWorkspace.baseUrl)) { + // Only a config from before saved remote workspaces existed (no + // `remoteWorkspaceProfiles` key at all) gets a profile made up for its + // connection. In a current config `remoteWorkspace` is just the live + // connection, which Quick Connect writes a moment before it saves the real + // profile, so making one up here turned one Quick Connect into two saved + // workspaces: the real one and a tokenless "ZenNotes Server" that took a new + // random id on every load until some other write kept it (#870). + const isPreProfileConfig = !Array.isArray(candidate.remoteWorkspaceProfiles) + if ( + isPreProfileConfig && + legacyRemoteWorkspace && + !remoteWorkspaceProfiles.some((entry) => entry.baseUrl === legacyRemoteWorkspace.baseUrl) + ) { remoteWorkspaceProfiles.unshift({ id: randomUUID(), name: 'ZenNotes Server', From 178f0429de90833ed409a873e126c0642b1c8126 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 29 Sep 2026 11:43:36 -0500 Subject: [PATCH 7/8] Fix(settings): wrap the Vault Location buttons under the label Connected to a remote vault, Settings > Vault > Location put its four buttons (Change Remote Vault, Return to Local Vault, Open Local Vault, Quick Connect) on one line beside the label, and the line never wrapped. The buttons could not shrink, so only the label gave way: at about 1000 px of window width and below it collapsed to nothing, the first button covered "Remote workspace", Quick Connect was cut off at the card's edge, and the vault path and server address vanished. The row now wraps. The label keeps a minimum width, and the buttons are one group that moves under it when the row is too narrow and wraps within itself when it is narrower still, with the 8 px spacing the other Settings button groups use. Measured over CDP at 1280, 1100, 1000, 900, 800 and 700 px, connected to a scratch server: before, the label box was 0 px wide at 1000 px and below with a button on top of it and one to three buttons clipped; after, the label stays on one line and no button overlaps it or runs past the card at any width, for both the remote row and the local one. --- .../app-core/src/components/SettingsModal.tsx | 74 ++++++++++--------- 1 file changed, 40 insertions(+), 34 deletions(-) diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index ed811f4c..fdcbd3c0 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -4217,11 +4217,15 @@ export function SettingsModal(): JSX.Element { title="Location" description="ZenNotes reads markdown directly from the selected vault folder." > + {/* The buttons are one group that wraps under the label when + the row is too narrow (#871). On one unwrapping line, the + four remote-mode buttons squeezed the label to nothing and + ran past the card's edge. */}

-
+
{workspaceMode === "remote" ? "Remote workspace" @@ -4237,42 +4241,44 @@ export function SettingsModal(): JSX.Element {
)}
- - {workspaceMode === "remote" && ( +
- )} - {workspaceMode === "remote" && ( - - )} - {supportsRemoteWorkspace && ( - - )} + {workspaceMode === "remote" && ( + + )} + {workspaceMode === "remote" && ( + + )} + {supportsRemoteWorkspace && ( + + )} +
{/* Local vaults only (#692): a temporary folder session writes nothing into its folder, and a remote workspace's settings From 230844ffcba5503f8ef8c4d925c223598a046199 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 29 Sep 2026 11:45:35 -0500 Subject: [PATCH 8/8] Release: align desktop and shared packages at 2.58.0 --- apps/desktop/package.json | 2 +- apps/share-viewer/package.json | 2 +- apps/web/package.json | 2 +- package-lock.json | 18 +++++++++--------- package.json | 2 +- packages/app-core/package.json | 2 +- packages/bridge-contract/package.json | 2 +- packages/shared-domain/package.json | 2 +- packages/shared-ui/package.json | 2 +- 9 files changed, 17 insertions(+), 17 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 46faf213..3582c654 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.57.0", + "version": "2.58.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", diff --git a/apps/share-viewer/package.json b/apps/share-viewer/package.json index 1db36cbf..72e50f98 100644 --- a/apps/share-viewer/package.json +++ b/apps/share-viewer/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/share-viewer", "private": true, - "version": "2.57.0", + "version": "2.58.0", "type": "module", "description": "Read-only renderer for publicly shared ZenNotes, embedded by the zennotes.org website", "homepage": "https://zennotes.org", diff --git a/apps/web/package.json b/apps/web/package.json index f59949b9..889cdd1c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.57.0", + "version": "2.58.0", "type": "module", "description": "ZenNotes web client for self-hosted and hosted deployments", "homepage": "https://zennotes.org", diff --git a/package-lock.json b/package-lock.json index 877b7067..21e45f6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.57.0", + "version": "2.58.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.57.0", + "version": "2.58.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -23,7 +23,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.57.0", + "version": "2.58.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -874,7 +874,7 @@ }, "apps/share-viewer": { "name": "@zennotes/share-viewer", - "version": "2.57.0", + "version": "2.58.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -945,7 +945,7 @@ }, "apps/web": { "name": "@zennotes/web", - "version": "2.57.0", + "version": "2.58.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16382,7 +16382,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.57.0", + "version": "2.58.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16469,14 +16469,14 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.57.0", + "version": "2.58.0", "devDependencies": { "typescript": "^5.7.2" } }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.57.0", + "version": "2.58.0", "dependencies": { "@zennotes/bridge-contract": "*", "lz-string": "^1.5.0" @@ -16488,7 +16488,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.57.0" + "version": "2.58.0" } } } diff --git a/package.json b/package.json index d8adcde1..382ff024 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.57.0", + "version": "2.58.0", "description": "ZenNotes monorepo for desktop, web, and self-hosted server builds", "packageManager": "npm@10.9.2", "engines": { diff --git a/packages/app-core/package.json b/packages/app-core/package.json index 2a6e732b..4165a109 100644 --- a/packages/app-core/package.json +++ b/packages/app-core/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/app-core", "private": true, - "version": "2.57.0", + "version": "2.58.0", "type": "module", "exports": { "./main": "./src/main.tsx", diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json index 09c77017..36965b5a 100644 --- a/packages/bridge-contract/package.json +++ b/packages/bridge-contract/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/bridge-contract", "private": true, - "version": "2.57.0", + "version": "2.58.0", "type": "module", "exports": { "./bridge": "./src/bridge.ts", diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json index 65e83d70..41037414 100644 --- a/packages/shared-domain/package.json +++ b/packages/shared-domain/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-domain", "private": true, - "version": "2.57.0", + "version": "2.58.0", "type": "module", "exports": { "./*": "./src/*.ts" diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index df8b2242..f8dc46a2 100644 --- a/packages/shared-ui/package.json +++ b/packages/shared-ui/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-ui", "private": true, - "version": "2.57.0", + "version": "2.58.0", "type": "module", "exports": { ".": "./src/index.ts"