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/package.json b/apps/desktop/package.json index d4c561bc..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", @@ -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", @@ -300,6 +313,16 @@ }, "pacman": { "compression": "xz", + "depends": [ + "gtk3", + "nss", + "alsa-lib", + "mesa", + "libnotify", + "libsecret", + "xdg-utils", + "desktop-file-utils" + ], "fpm": [ "--provides", "zennotes", 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/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/terminal-runtime.test.ts b/apps/desktop/src/main/terminal-runtime.test.ts index 75773ff8..b91191d7 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, @@ -22,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 }) @@ -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/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', 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/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index 84f22855..fdcbd3c0 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 }[]>( () => [ @@ -4089,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" @@ -4109,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 @@ -5468,132 +5602,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 ( 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" diff --git a/tooling/scripts/terminal-artifact.mjs b/tooling/scripts/terminal-artifact.mjs index 6adf9ad6..4895207f 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/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 + // 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 {