Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
7679d21
Fix(cloud): a restored note leaves no copy behind where it was
adibhanna Oct 5, 2026
0481473
Fix(cloud): a refused backup says which plan limit it hit
adibhanna Oct 5, 2026
cb9f959
Fix(cloud): Settings → Cloud opens on what needs you, and keeps itsel…
adibhanna Oct 5, 2026
0fd1786
Fix(media): an embed whose file is not on this device says so
adibhanna Oct 5, 2026
4584590
Fix(mobile): Browse lists a folder's files, not only its notes
adibhanna Oct 5, 2026
cfdca73
Fix(editor): a floating window keeps the cursor still when blinking i…
adibhanna Oct 5, 2026
d30ca34
Fix(hints): links in the Edit view get labels and follow like a click…
adibhanna Oct 5, 2026
f6cbd3f
Fix(windows): ⌘W closes Settings and floating windows (#893)
adibhanna Oct 5, 2026
f055846
Fix(links): [[2024.01.15]] opens its note, never https://2024.01.15
adibhanna Oct 5, 2026
d84b960
Fix(links): a right-click on a wikilink opens the menu where you clicked
adibhanna Oct 5, 2026
eab67c2
Chore(deps): keep braces out of the production tree (today's advisory)
adibhanna Oct 5, 2026
e4f6467
Fix(cloud): a phone stopped mid-rename keeps no hidden copy, and a sa…
adibhanna Oct 5, 2026
3c0c36a
Feat(quicklook): Space on a Markdown file in Finder shows it the ZenN…
adibhanna Oct 5, 2026
095ced6
Fix(tasks): a card's menu keeps the keys typed into it
adibhanna Oct 5, 2026
790b606
Feat(kanban): order the cards in every column by due date (#889)
adibhanna Oct 5, 2026
9aebb6f
Fix(config): setting a value back in config.toml applies
adibhanna Oct 5, 2026
fb3d13f
Release: align desktop and shared packages at 2.62.0
adibhanna Oct 5, 2026
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
30 changes: 30 additions & 0 deletions apps/desktop/build/after-pack.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,21 @@ async function rewriteAsFreshFiles(dir) {
}
}

/**
* The identity the app will be signed with, decided the way electron-builder
* decides it for the app itself (MacPackager.sign), so the Quick Look
* extension carries the same one; null when the app is not signed, and the
* extension is then signed ad hoc.
*/
async function quickLookSigning(context) {
const { findIdentity, isSignAllowed } = require('app-builder-lib/out/codeSign/macCodeSign')
const config = context.packager.platformSpecificBuildOptions
if (!isSignAllowed(false) || config.identity === null) return null
const { keychainFile } = await context.packager.codeSigningInfo.value
const identity = await findIdentity('Developer ID Application', config.identity, keychainFile)
return identity ? { identity: identity.hash, keychain: keychainFile, timestamp: config.timestamp || undefined } : null
}

exports.default = async function afterPack(context) {
const platform = context.electronPlatformName
if (platform === 'darwin' || platform === 'linux') {
Expand All @@ -55,6 +70,21 @@ exports.default = async function afterPack(context) {
// cli-install.ts reads the same name.
await stageTerminalArtifact({ platform, arch, localDirectory, allowLocal, output: path.join(resources, 'zn-cli') })
}
if (platform === 'darwin') {
// The Quick Look preview for Markdown files (apps/quicklook). It lands in
// Contents/PlugIns, which electron-builder never signs, so it is signed
// here, before the app's own signature seals it in.
const arch = { 1: 'x64', 3: 'arm64' }[context.arch]
const { buildQuickLookExtension } = await import(pathToFileURL(path.resolve(__dirname, '../../quicklook/scripts/build-extension.mjs')).href)
await buildQuickLookExtension({
appPath: path.join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`),
arch,
appId: context.packager.appInfo.id,
version: context.packager.appInfo.version,
signing: await quickLookSigning(context),
selfTest: process.env.ZENNOTES_QUICKLOOK_SELFTEST === '1' && !process.env.CI
})
}
if (platform !== 'linux') return
await rewriteAsFreshFiles(path.join(context.appOutDir, 'resources', 'arch-extras'))
}
2 changes: 1 addition & 1 deletion 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.61.0",
"version": "2.62.0",
"description": "ZenNotes desktop shell",
"private": true,
"main": "./out/main/index.js",
Expand Down
101 changes: 88 additions & 13 deletions apps/desktop/src/main/app-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,21 @@ vi.mock('electron', () => ({
}
}))

// A gate on the atomic writer, so a test can hold one of the module's own
// writes mid-flight: the moment a watcher read can still observe the write
// before it. Every other write goes straight through.
const writeGate: { hold: Promise<void> | null } = { hold: null }
vi.mock('./vault', async (importOriginal) => {
const actual = await importOriginal<typeof import('./vault')>()
return {
...actual,
writeFileAtomic: async (absPath: string, data: string): Promise<void> => {
if (writeGate.hold) await writeGate.hold
return actual.writeFileAtomic(absPath, data)
}
}
})

import {
getConfigDir,
getConfigFilePath,
Expand Down Expand Up @@ -59,6 +74,7 @@ beforeEach(async () => {
})

afterEach(async () => {
writeGate.hold = null
await stopAppConfigWatcher()
for (const key of ENV_KEYS) {
if (savedEnv[key] === undefined) delete process.env[key]
Expand Down Expand Up @@ -115,6 +131,7 @@ describe('TOML serialization', () => {
systemFolderLabels: { inbox: 'In' },
savedTaskFilters: { 'Project alpha': '@project:alpha', Blocked: '@status:blocked' },
kanbanGroupBy: 'folder',
kanbanCardSort: 'due',
kanbanFolderRoot: 'Projects',
externalApplicationSchemes: ['zotero', 'obsidian'],
ignoredKeys: ['KanaMode', 'F24']
Expand Down Expand Up @@ -146,6 +163,8 @@ describe('TOML serialization', () => {
// The [saved_filters] table keeps the order the chips show (#731).
expect(text).toContain('kanban_folder_root = "Projects"')
expect(round.kanbanGroupBy).toBe('folder')
expect(text).toContain('kanban_card_sort = "due"')
expect(round.kanbanCardSort).toBe('due')
expect(round.kanbanFolderRoot).toBe('Projects')
expect(text).toContain('ignored_keys = ["KanaMode", "F24"]')
expect(round.externalApplicationSchemes).toEqual(['zotero', 'obsidian'])
Expand Down Expand Up @@ -245,24 +264,39 @@ describe('persistence + cache', () => {
})

it('does not let a stale watcher read of an earlier own-write clobber the cache', async () => {
// Regression for a Windows CI flake: two rapid writes let the file watcher
// observe the FIRST own-write out of order; with only a single-value
// loop-guard it treated that stale read as an external edit and reverted
// the freshly-merged cache. The watcher must recognize any recent own-write.
// Regression for a Windows CI flake: with two writes in a row, the watcher
// can read the file while the second is still landing and see the FIRST.
// With only a single-value loop-guard that read counted as an external
// edit and reverted the freshly-merged cache.
process.env.ZENNOTES_CONFIG_DIR = await tmp('zen-cfg-stale-')
await initAppConfig(() => {})
const changes: AppConfigPortable[] = []
await initAppConfig((next) => changes.push(next))

await setPortableConfig({ editorFontSize: 18 })
const staleText = await readFile(getConfigFilePath(), 'utf8')
await setPortableConfig({ editorFontSize: 22 })

// Write the earlier own-write back and let the debounced watcher process it.
await writeFile(getConfigFilePath(), staleText)
await new Promise((resolve) => setTimeout(resolve, 700))
const earlierText = await readFile(getConfigFilePath(), 'utf8')
// Let that write settle, so only the second one being in flight is what
// makes the read below stale.
await delay(1800)

// Hold the second write mid-flight, and let the watcher read the file
// meanwhile: it still holds the earlier own-write.
let release = (): void => {}
writeGate.hold = new Promise<void>((resolve) => (release = resolve))
const second = setPortableConfig({ editorFontSize: 22 })
await writeFile(getConfigFilePath(), earlierText)
await delay(800)
expect(getPortableConfigSnapshot().editorFontSize).toBe(22)

// It's a known own-write, so the cache keeps the latest merged value.
writeGate.hold = null
release()
await second
// Past the settle window the file holds the newer write, so the re-check
// finds nothing to apply either.
await delay(2500)
expect(getPortableConfigSnapshot().editorFontSize).toBe(22)
})
expect(changes).toHaveLength(0)
expect(await readFile(getConfigFilePath(), 'utf8')).toContain('font_size = 22')
}, 10000)

it('ensureConfigFile creates the file when missing', async () => {
process.env.ZENNOTES_CONFIG_DIR = await tmp('zen-cfg2-')
Expand Down Expand Up @@ -299,6 +333,47 @@ describe('persistence + cache', () => {
})

describe('file watching', () => {
it('applies a hand edit that puts back an earlier version of the file', async () => {
// The user changes a setting in the app, then sets it back in config.toml:
// the file is now byte for byte a text the app wrote before. It is still
// the user's edit, and the file is the source of truth.
process.env.ZENNOTES_CONFIG_DIR = await tmp('zen-cfg-putback-')
const changes: AppConfigPortable[] = []
await initAppConfig((next) => changes.push(next))

await setPortableConfig({ editorFontSize: 18 })
const earlierText = await readFile(getConfigFilePath(), 'utf8')
await setPortableConfig({ editorFontSize: 22 })
await delay(2000)

await writeFile(getConfigFilePath(), earlierText)
await waitFor(() => changes.length > 0, 4000)
expect(changes.at(-1)?.editorFontSize).toBe(18)
expect(getPortableConfigSnapshot().editorFontSize).toBe(18)
}, 10000)

it('applies a put-back that lands while the app is still writing, once its writes settle', async () => {
// Right after the app's own write, the watcher cannot tell a stale read of
// that earlier text from the user putting it back. It waits for the writes
// to settle and looks again; a text still in the file then is the user's.
process.env.ZENNOTES_CONFIG_DIR = await tmp('zen-cfg-putback-early-')
const changes: AppConfigPortable[] = []
await initAppConfig((next) => changes.push(next))

await setPortableConfig({ editorFontSize: 18 })
const earlierText = await readFile(getConfigFilePath(), 'utf8')
await setPortableConfig({ editorFontSize: 22 })
await writeFile(getConfigFilePath(), earlierText)

// First read: possibly stale, so the cache holds.
await delay(700)
expect(getPortableConfigSnapshot().editorFontSize).toBe(22)
// The re-check after the writes settle finds it still there.
await waitFor(() => changes.length > 0, 6000)
expect(changes.at(-1)?.editorFontSize).toBe(18)
expect(getPortableConfigSnapshot().editorFontSize).toBe(18)
}, 10000)

it('notifies on external edits but not on its own writes', async () => {
process.env.ZENNOTES_CONFIG_DIR = await tmp('zen-cfg4-')
const changes: AppConfigPortable[] = []
Expand Down
81 changes: 65 additions & 16 deletions apps/desktop/src/main/app-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,11 @@ const SCALAR_FIELDS: Partial<Record<PortablePrefKey, ScalarFieldMap>> = {
tomlKey: 'kanban_group_by',
comment: 'status | priority | folder (each note\'s own folder; see kanban_folder_root) | field:<key>'
},
kanbanCardSort: {
section: 'view',
tomlKey: 'kanban_card_sort',
comment: 'manual | due: cards in a Kanban column keep the order you drag them into, or follow their due dates'
},
kanbanFolderRoot: {
section: 'view',
tomlKey: 'kanban_folder_root',
Expand Down Expand Up @@ -746,13 +751,11 @@ let watcher: FSWatcher | null = null
let writeQueue: Promise<void> = Promise.resolve()

// Texts we've written ourselves. The watcher's loop-guard compares the file
// against `lastKnownText` (our latest write), but rapid writes — two
// setPortableConfig calls in a row — can make the watcher observe an EARLIER
// own-write out of order, especially on Windows where atomic-rename events
// don't coalesce as cleanly. Remembering the last several own-writes lets the
// watcher recognize a stale read of any of them and skip it, instead of
// clobbering the freshly-merged in-memory cache (which reverted settings to
// their defaults). Bounded so it can't grow unbounded.
// against `lastKnownText` (our latest write), but with two setPortableConfig
// calls in a row the watcher can read the file around the second write and see
// the FIRST (a Windows CI flake). Treating that stale read as an edit clobbered
// the freshly-merged in-memory cache and reverted settings to their defaults.
// Bounded so it can't grow unbounded.
const ownWrites = new Set<string>()
const MAX_OWN_WRITES = 16
function rememberOwnWrite(text: string): void {
Expand All @@ -764,6 +767,36 @@ function rememberOwnWrite(text: string): void {
}
}

// A stale read can only happen while our writes are landing, so an earlier
// own-write counts as one only then: during a write, or briefly after the last
// one, for a read that began before it finished. Outside that window the same
// text is someone putting it back (a hand edit, a dotfile sync), and the file
// wins. Remembering the texts forever swallowed exactly that edit.
const OWN_WRITE_SETTLE_MS = 1500
let ownWritesInFlight = 0
let ownWritesSettledAt = 0
let recheckTimer: ReturnType<typeof setTimeout> | null = null

function ownWritesSettling(): boolean {
return ownWritesInFlight > 0 || Date.now() - ownWritesSettledAt < OWN_WRITE_SETTLE_MS
}

/** writeFileAtomic for a text this module wrote, counted while it lands. */
async function writeOwnText(text: string): Promise<void> {
ownWritesInFlight += 1
try {
await writeFileAtomic(getConfigFilePath(), text)
} finally {
ownWritesInFlight -= 1
ownWritesSettledAt = Date.now()
}
}

function clearRecheck(): void {
if (recheckTimer) clearTimeout(recheckTimer)
recheckTimer = null
}

const WATCH_DEBOUNCE_MS = 150

async function readConfigFile(): Promise<{ text: string; portable: AppConfigPortable } | null> {
Expand Down Expand Up @@ -802,19 +835,34 @@ function debounce<Args extends unknown[]>(
function startWatching(): void {
const file = getConfigFilePath()
watcher?.close().catch(() => {})
watcher = chokidar.watch(file, {
clearRecheck()
const active = chokidar.watch(file, {
ignoreInitial: true,
awaitWriteFinish: { stabilityThreshold: 150, pollInterval: 50 }
})
watcher = active
const handle = debounce(() => {
void (async () => {
const res = await readConfigFile()
if (!res) return
// Our own writes (and no-op saves) match lastKnownText — skip them so we
// never feed our writes back into the renderer. Also skip a stale read of
// any recent own-write the watcher observed out of order, so it can't
// clobber the in-memory cache with an older serialized state.
if (res.text === lastKnownText || ownWrites.has(res.text)) return
// A read that outlived its watcher answers for a file nobody watches.
if (!res || watcher !== active) return
// Our latest write, or a save that changed nothing: never fed back.
if (res.text === lastKnownText) return
// An earlier own-write while ours are still landing may be a stale read
// (see OWN_WRITE_SETTLE_MS): look again once they settle. If the text is
// still there then, nothing of ours explains it, and it applies below.
if (ownWrites.has(res.text) && ownWritesSettling()) {
clearRecheck()
const wait =
ownWritesInFlight > 0
? OWN_WRITE_SETTLE_MS
: ownWritesSettledAt + OWN_WRITE_SETTLE_MS - Date.now()
recheckTimer = setTimeout(() => {
recheckTimer = null
handle()
}, Math.max(0, wait))
return
}
cache = res.portable
lastKnownText = res.text
onChangeCb?.({ ...cache })
Expand Down Expand Up @@ -846,7 +894,7 @@ export async function initAppConfig(
lastKnownText = canonical
rememberOwnWrite(canonical)
try {
await writeFileAtomic(getConfigFilePath(), canonical)
await writeOwnText(canonical)
} catch (err) {
console.error('Failed to normalize config.toml', err)
lastKnownText = res.text
Expand Down Expand Up @@ -879,7 +927,7 @@ export function setPortableConfig(partial: AppConfigPortable): Promise<void> {
lastKnownText = text
rememberOwnWrite(text)
try {
await writeFileAtomic(getConfigFilePath(), text)
await writeOwnText(text)
} catch (err) {
console.error('Failed to write config.toml', err)
}
Expand All @@ -901,6 +949,7 @@ export async function ensureConfigFile(): Promise<string> {

/** Test/teardown hook: stop the file watcher. */
export async function stopAppConfigWatcher(): Promise<void> {
clearRecheck()
await watcher?.close().catch(() => {})
watcher = null
}
Loading
Loading