Skip to content

Commit 7f028c8

Browse files
authored
2.40.0: the Linux updater installs the right package, and every device links attachments the same way (#699)
* Fix(updater): a Linux update installs the package you actually run On Arch, "Install and Relaunch" asked for a root password and then ran `dpkg -i`, a Debian command that does not exist there, so the in-app updater never worked and every update had to be fetched and installed from a terminal by hand. Reported on Discord by Kelv, on CachyOS. The cause is in the build, not the app. electron-builder stamps a `package-type` file into the install, and electron-updater reads it to decide which release asset to download and which installer to run. All five Linux targets are cut from ONE staging directory, they build concurrently (isAsyncSupported defaults to true), and only the deb and rpm targets write that file, so a package ships whichever value a neighbour happened to leave behind. Confirmed against the published 2.39.0 downloads: the .pacman carried `deb`, while the .deb and .rpm were correct. One file and several racing writers cannot be fixed at build time, so this stops trusting the stamp's contents. The format now comes from the running system: /etc/os-release ID, then ID_LIKE, which is what carries the derivatives (CachyOS declares ID_LIKE=arch) we would otherwise have to enumerate one by one. The stamp is still consulted, but only for whether it exists, because that is the one true thing it says: only a ZenNotes system package carries it. An AppImage and an AUR or tarball install have none and are deliberately left exactly as they were, updating in userspace and through their own package manager respectively. An unrecognized distro changes nothing. A build-time fix was written first and dropped on purpose: a hook can stamp each package as it is built, but with the targets racing over one file it is only usually right, and "usually" is what shipped this bug. As a backstop, an update whose format contradicts the system no longer reaches pkexec. Instead of a root prompt for a command the machine does not have, ZenNotes names the package it needs and links the download. Arch users on 2.39.0 or earlier carry the wrong stamp in the copy they already have, so this one still needs a manual install; from here it installs itself. Not exercisable on the release host (no Linux machine): the decision and the guard are unit-tested, and the packaged macOS build was launch checked over CDP, but the pacman install has not been run end to end. Claude-Session: https://claude.ai/code/session_01AYTRixg5TJmxn2j6FCqfUD * Fix(assets): every device links an attached file the same way Attaching or dropping a file wrote a different link depending on which surface you did it from. Paste, and an existing asset dragged in from the sidebar, embedded a vault-relative wikilink; a drop wrote a markdown link with a path relative to the note, `![pic](<../assets/pic.png>)`, and the self-hosted server produced that same note-relative form for the web client. The mobile apps had a third answer again. Same file, same folder, three different links. The note-relative form was also wrong, not merely different: nothing rewrites relative asset paths when a note moves, so `../assets/pic.png` broke the moment the note changed depth. So there is now one rule, everywhere: the file lands in `assets/` and is linked by VAULT-relative path, an image as `![[assets/pic.png]]` and anything else as `[name](<assets/report.pdf>)`. That is what paste and the sidebar drag already wrote, so this moves the odd routes onto the form the app already used most. Existing notes are unaffected: resolveAssetVaultRelativePath tries note-relative, then vault-root, then a unique basename, so both the old and the new form keep resolving on both rendering surfaces. `ImportAsset` no longer reads notePath, since where the note lives can no longer change what is written; it stays in the signature because the handler and clients still send it. The matching mobile fixes ship in the zennotesandroid and zennotesios repos. Claude-Session: https://claude.ai/code/session_01AYTRixg5TJmxn2j6FCqfUD * fix: sanitize saved attachment filenames * chore: prepare desktop v2.40.0 release * fix: normalize Linux install paths portably
1 parent 35ff3f6 commit 7f028c8

17 files changed

Lines changed: 3995 additions & 4093 deletions

File tree

apps/desktop/package.json

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@zennotes/desktop",
33
"productName": "ZenNotes",
4-
"version": "2.39.0",
4+
"version": "2.40.0",
55
"description": "ZenNotes desktop shell",
66
"private": true,
77
"main": "./out/main/index.js",
@@ -96,15 +96,15 @@
9696
"@types/ws": "^8.18.1",
9797
"@vitejs/plugin-react": "^4.3.4",
9898
"autoprefixer": "^10.4.20",
99-
"electron": "41.2.1",
100-
"electron-builder": "^25.1.8",
101-
"electron-vite": "^2.3.0",
99+
"electron": "41.10.7",
100+
"electron-builder": "26.15.7",
101+
"electron-vite": "^3.1.0",
102102
"jsdom": "^29.0.2",
103103
"postcss": "^8.5.10",
104104
"tailwindcss": "^3.4.17",
105105
"typescript": "^5.7.2",
106-
"vite": "^5.4.11",
107-
"vitest": "^2.1.8"
106+
"vite": "^6.4.3",
107+
"vitest": "^3.2.6"
108108
},
109109
"build": {
110110
"appId": "com.adibhanna.zennotes",
@@ -200,7 +200,7 @@
200200
"mimeType": "text/markdown"
201201
}
202202
],
203-
"electronVersion": "41.2.1",
203+
"electronVersion": "41.10.7",
204204
"electronUpdaterCompatibility": ">=2.16",
205205
"mac": {
206206
"icon": "build/icon.icns",
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
diff --git a/node_modules/app-builder-lib/out/node-module-collector/nodeModulesCollector.js b/node_modules/app-builder-lib/out/node-module-collector/nodeModulesCollector.js
2+
index 31798e4..15bb8ce 100644
3+
--- a/node_modules/app-builder-lib/out/node-module-collector/nodeModulesCollector.js
4+
+++ b/node_modules/app-builder-lib/out/node-module-collector/nodeModulesCollector.js
5+
@@ -238,17 +238,22 @@ class NodeModulesCollector {
6+
const deps = (obj[key] || {}).dependencies || [];
7+
for (const dep of deps) {
8+
const child = this.transformToHoisterTree(obj, dep, nodes);
9+
- node.dependencies.add(child);
10+
+ if (child !== node) {
11+
+ node.dependencies.add(child);
12+
+ }
13+
}
14+
}
15+
return node;
16+
}
17+
- async _getNodeModules(dependencies, result) {
18+
+ async _getNodeModules(dependencies, result, ancestors = new Set()) {
19+
var _a;
20+
if (dependencies.size === 0) {
21+
return;
22+
}
23+
for (const d of dependencies.values()) {
24+
+ if (ancestors.has(d)) {
25+
+ continue;
26+
+ }
27+
const reference = [...d.references][0];
28+
const key = `${d.name}@${reference}`;
29+
// Normalize the path to handle mixed separators from pnpm JSON output on Windows
30+
@@ -272,7 +277,9 @@ class NodeModulesCollector {
31+
result.push(node);
32+
if (d.dependencies.size > 0) {
33+
node.dependencies = [];
34+
- await this._getNodeModules(d.dependencies, node.dependencies);
35+
+ ancestors.add(d);
36+
+ await this._getNodeModules(d.dependencies, node.dependencies, ancestors);
37+
+ ancestors.delete(d);
38+
}
39+
}
40+
result.sort((a, b) => a.name.localeCompare(b.name));

apps/desktop/src/main/packaging.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,37 @@
1+
import { readFileSync } from 'node:fs'
2+
import { createRequire } from 'node:module'
13
import { describe, expect, it } from 'vitest'
24
import { PACKAGED_CLI_RUNTIME_PACKAGES } from '../../electron.vite.config'
35
import desktopPackage from '../../package.json'
46

7+
const require = createRequire(import.meta.url)
8+
59
interface ExtraResource {
610
from: string
711
to: string
812
filter?: string[]
913
}
1014

1115
describe('desktop packaging', () => {
16+
it('uses Electron and electron-builder releases with the current security fixes', () => {
17+
expect(desktopPackage.devDependencies.electron).toBe('41.10.7')
18+
expect(desktopPackage.build.electronVersion).toBe('41.10.7')
19+
expect(desktopPackage.devDependencies['electron-builder']).toBe('26.15.7')
20+
})
21+
22+
it('applies the merged upstream cycle guard to electron-builder dependency collection', () => {
23+
// 26.15.7 includes the AppImage security fixes, but its new module
24+
// collector can recurse until OOM on cyclic package graphs. This is the
25+
// exact guard merged upstream in electron-builder#10070; remove the patch
26+
// after the first v26 release containing that change.
27+
const collector = readFileSync(
28+
require.resolve('app-builder-lib/out/node-module-collector/nodeModulesCollector.js'),
29+
'utf8'
30+
)
31+
expect(collector).toContain('if (child !== node)')
32+
expect(collector).toContain('if (ancestors.has(d))')
33+
})
34+
1235
it('ships the CLI chunks beside the unpacked CLI launcher', () => {
1336
const resources = desktopPackage.build.extraResources as ExtraResource[]
1437

apps/desktop/src/main/updater.test.ts

Lines changed: 182 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, it, vi } from 'vitest'
1+
import { afterEach, describe, expect, it, vi } from 'vitest'
22

33
// updater.ts imports electron and electron-updater at module load. Stub both so
44
// we can unit-test the pure Linux-install helpers without an Electron runtime.
@@ -9,14 +9,29 @@ vi.mock('electron', () => ({
99
shell: {}
1010
}))
1111
vi.mock('electron-updater', () => ({
12-
default: { autoUpdater: {} }
12+
default: {
13+
autoUpdater: {},
14+
AppImageUpdater: class {},
15+
DebUpdater: class {},
16+
RpmUpdater: class {},
17+
PacmanUpdater: class {}
18+
}
1319
}))
1420

21+
import FpmTarget from 'app-builder-lib/out/targets/FpmTarget'
22+
import electronUpdater from 'electron-updater'
1523
import {
1624
elevatedInstallScript,
25+
installedLinuxFormat,
26+
isOfficialLinuxSystemPackage,
27+
linuxFormatFromOsRelease,
28+
linuxInstallMismatch,
1729
linuxNeedsRootInstall,
1830
linuxPackageFormat,
19-
manualInstallHint
31+
linuxUpdaterForFormat,
32+
linuxUpdaterFormat,
33+
manualInstallHint,
34+
mismatchedUpdateMessage
2035
} from './updater'
2136

2237
describe('linuxPackageFormat', () => {
@@ -69,3 +84,167 @@ describe('manualInstallHint', () => {
6984
expect(manualInstallHint('rpm', '/tmp/a.rpm')).toBe('sudo rpm -U "/tmp/a.rpm"')
7085
})
7186
})
87+
88+
describe('linuxFormatFromOsRelease', () => {
89+
it('reads the distro family, derivatives included', () => {
90+
expect(linuxFormatFromOsRelease('ID=arch\n')).toBe('pacman')
91+
expect(linuxFormatFromOsRelease('ID=ubuntu\nID_LIKE=debian\n')).toBe('deb')
92+
expect(linuxFormatFromOsRelease('ID=fedora\nVERSION_ID=42\n')).toBe('rpm')
93+
// The reporter's distro: unknown by name, but it declares its ancestor.
94+
expect(linuxFormatFromOsRelease('NAME="CachyOS Linux"\nID=cachyos\nID_LIKE=arch\n')).toBe(
95+
'pacman'
96+
)
97+
expect(linuxFormatFromOsRelease('ID=neon\nID_LIKE="ubuntu debian"\n')).toBe('deb')
98+
})
99+
100+
it('tolerates quotes, spacing and files it cannot place', () => {
101+
expect(linuxFormatFromOsRelease('ID = "manjaro"')).toBe('pacman')
102+
expect(linuxFormatFromOsRelease('ID=nixos\nID_LIKE=\n')).toBe('unknown')
103+
expect(linuxFormatFromOsRelease('')).toBe('unknown')
104+
})
105+
106+
it('gives ID priority over ID_LIKE regardless of declaration order', () => {
107+
expect(linuxFormatFromOsRelease('ID_LIKE=debian\nID=arch\n')).toBe('pacman')
108+
})
109+
})
110+
111+
describe('installedLinuxFormat', () => {
112+
afterEach(() => {
113+
delete process.env.APPIMAGE
114+
})
115+
116+
it('trusts the AppImage env var before anything on disk', () => {
117+
process.env.APPIMAGE = '/home/kelv/Apps/ZenNotes.AppImage'
118+
expect(
119+
installedLinuxFormat(() => {
120+
throw new Error('must not be read')
121+
})
122+
).toBe('appimage')
123+
})
124+
125+
it('falls back to os-release, and to unknown when it cannot be read', () => {
126+
expect(installedLinuxFormat(() => 'ID=arch\n')).toBe('pacman')
127+
expect(
128+
installedLinuxFormat(() => {
129+
throw new Error('ENOENT')
130+
})
131+
).toBe('unknown')
132+
})
133+
})
134+
135+
describe('linuxInstallMismatch', () => {
136+
it('catches the Arch-gets-a-deb case that shipped', () => {
137+
expect(linuxInstallMismatch('deb', 'pacman')).toBe(true)
138+
expect(linuxInstallMismatch('rpm', 'deb')).toBe(true)
139+
})
140+
141+
it('never blocks a match, or a system it could not identify', () => {
142+
expect(linuxInstallMismatch('deb', 'deb')).toBe(false)
143+
expect(linuxInstallMismatch('pacman', 'pacman')).toBe(false)
144+
expect(linuxInstallMismatch('deb', 'unknown')).toBe(false)
145+
expect(linuxInstallMismatch('unknown', 'pacman')).toBe(false)
146+
})
147+
})
148+
149+
describe('mismatchedUpdateMessage', () => {
150+
it('names both formats and points at the right download', () => {
151+
const message = mismatchedUpdateMessage('deb', 'pacman', '2.40.0')
152+
expect(message).toContain('ZenNotes 2.40.0')
153+
expect(message).toContain('.deb package')
154+
expect(message).toContain('https://zennotes.org/download/linux-pacman')
155+
})
156+
})
157+
158+
describe('linuxUpdaterFormat', () => {
159+
const arch = 'NAME="CachyOS Linux"\nID=cachyos\nID_LIKE=arch\n'
160+
161+
it('sends an Arch system package to the pacman updater, whatever the stamp said', () => {
162+
// The shipped case: the .pacman carried a `deb` stamp, so electron-updater
163+
// had picked the deb updater. The stamp's value is never consulted here.
164+
expect(
165+
linuxUpdaterFormat({ isAppImage: false, isOfficialSystemPackage: true, osRelease: arch })
166+
).toBe('pacman')
167+
expect(
168+
linuxUpdaterFormat({
169+
isAppImage: false,
170+
isOfficialSystemPackage: true,
171+
osRelease: 'ID=ubuntu\nID_LIKE=debian\n'
172+
})
173+
).toBe('deb')
174+
expect(
175+
linuxUpdaterFormat({
176+
isAppImage: false,
177+
isOfficialSystemPackage: true,
178+
osRelease: 'ID=fedora\n'
179+
})
180+
).toBe('rpm')
181+
})
182+
183+
it('leaves an AppImage alone even where a stamp leaked into it', () => {
184+
expect(
185+
linuxUpdaterFormat({ isAppImage: true, isOfficialSystemPackage: false, osRelease: arch })
186+
).toBe('appimage')
187+
})
188+
189+
it('forces the safe AppImage updater for AUR and tar installs even if the stamp leaked', () => {
190+
expect(
191+
linuxUpdaterFormat({
192+
isAppImage: false,
193+
isOfficialSystemPackage: false,
194+
osRelease: arch
195+
})
196+
).toBe('appimage')
197+
})
198+
199+
it('stays with the default updater when the distro cannot be identified', () => {
200+
expect(
201+
linuxUpdaterFormat({ isAppImage: false, isOfficialSystemPackage: true, osRelease: null })
202+
).toBe('unknown')
203+
expect(
204+
linuxUpdaterFormat({
205+
isAppImage: false,
206+
isOfficialSystemPackage: true,
207+
osRelease: 'ID=nixos\n'
208+
})
209+
).toBe('unknown')
210+
})
211+
})
212+
213+
describe('isOfficialLinuxSystemPackage', () => {
214+
it('accepts only a stamped electron-builder system-package install', () => {
215+
expect(isOfficialLinuxSystemPackage('/opt/ZenNotes/resources', true)).toBe(true)
216+
expect(isOfficialLinuxSystemPackage('/opt/ZenNotes/resources', false)).toBe(false)
217+
})
218+
219+
it('rejects AUR and tar installs even if a racing target leaked the stamp', () => {
220+
expect(isOfficialLinuxSystemPackage('/opt/zennotes-bin/resources', true)).toBe(false)
221+
expect(
222+
isOfficialLinuxSystemPackage('/tmp/ZenNotes-2.40.0-linux-x64/resources', true)
223+
).toBe(false)
224+
})
225+
})
226+
227+
describe('linuxUpdaterForFormat', () => {
228+
it('creates a fresh AppImage updater instead of reusing the stamp-derived singleton', () => {
229+
const selected = linuxUpdaterForFormat('appimage')
230+
expect(selected).toBeInstanceOf(electronUpdater.AppImageUpdater)
231+
expect(selected).not.toBe(electronUpdater.autoUpdater)
232+
})
233+
234+
it('keeps the existing updater only when the system package format is unknown', () => {
235+
expect(linuxUpdaterForFormat('unknown')).toBe(electronUpdater.autoUpdater)
236+
})
237+
})
238+
239+
describe('Linux updater build support', () => {
240+
// 25.x omits pacman metadata. The pinned 26.15.7 has the current AppImage
241+
// security fixes; apps/desktop/patches carries the cycle guard already
242+
// merged upstream for its module collector. Upgrade only after this
243+
// assertion and an electron-builder --dir package check pass.
244+
it('emits pacman packages into latest-linux.yml', () => {
245+
const supportsAutoUpdate = Reflect.get(FpmTarget.prototype, 'supportsAutoUpdate') as (
246+
target: string
247+
) => boolean
248+
expect(supportsAutoUpdate.call(Object.create(FpmTarget.prototype), 'pacman')).toBe(true)
249+
})
250+
})

0 commit comments

Comments
 (0)