Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@
"vega-embed": "^7.1.0",
"vue-i18n": "^11.4.6",
"vue-router": "^4.6.4",
"webfontloader": "^1.6.28"
"webfontloader": "^1.6.28",
"write-file-atomic": "^7.0.1"
},
"optionalDependencies": {
"native-keymap": "^3.3.9"
Expand All @@ -126,6 +127,7 @@
"@types/node": "^22.20.0",
"@types/turndown": "^5.0.6",
"@types/webfontloader": "^1.6.38",
"@types/write-file-atomic": "^4.0.3",
"@vitejs/plugin-vue": "^6.0.7",
"@vitest/coverage-v8": "^4.1.9",
"cross-env": "^10.1.0",
Expand Down
29 changes: 7 additions & 22 deletions packages/desktop/src/main/editorBufferStore/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import fs from 'fs'
import path from 'path'
import writeFileAtomic from 'write-file-atomic'
import { BrowserWindow, ipcMain, type IpcMainInvokeEvent } from 'electron'
import { TypedEmitter } from '@shared/types/typedEmitter'
import type BaseWindow from '../windows/base'
Expand Down Expand Up @@ -32,7 +33,6 @@ class EditorBufferStore extends TypedEmitter<EditorBufferStoreEvents> {
bufferStores: Record<string, BufferStoreEntry> | null
serviceName: string
encryptKeys: string[]
writeSequence: number

constructor(paths: EditorBufferStorePaths) {
super()
Expand All @@ -45,7 +45,6 @@ class EditorBufferStore extends TypedEmitter<EditorBufferStoreEvents> {
this.bufferStores = null
this.serviceName = 'marktext'
this.encryptKeys = []
this.writeSequence = 0

this.init()
}
Expand Down Expand Up @@ -177,26 +176,12 @@ class EditorBufferStore extends TypedEmitter<EditorBufferStoreEvents> {
}

writeBufferStoreFile(filePath: string, newState: unknown): void {
const tempPath = path.join(
path.dirname(filePath),
`.${path.basename(filePath)}.${process.pid}.${Date.now()}.${++this.writeSequence}.tmp`
)

try {
// Write temp file first, then rename to the final file for atomicity
// and reduced risk of data corruption.
fs.writeFileSync(tempPath, JSON.stringify(newState), 'utf8')
fs.renameSync(tempPath, filePath)
} catch (err) {
try {
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath)
}
} catch (cleanupErr) {
console.error('Failed to clean up temporary buffer store file', cleanupErr)
}
throw err
}
// Durable atomic write: write-file-atomic writes to a temp file, fsyncs it,
// then renames it over the target. The previous temp-file + rename here was
// namespace-atomic (crash-safe) but omitted the fsync, so a power loss could
// still leave this crash-recovery buffer — which holds unsaved tab content —
// truncated or zero-filled, the same gap the document save path had (#3786).
writeFileAtomic.sync(filePath, JSON.stringify(newState), 'utf8')
}

updateBufferState(e: IpcMainInvokeEvent, newState: unknown): boolean {
Expand Down
24 changes: 17 additions & 7 deletions packages/desktop/src/main/filesystem/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { readlinkSync, outputFile, type WriteFileOptions } from 'fs-extra'
import { readlinkSync, ensureDir } from 'fs-extra'
import path from 'path'
import writeFileAtomic from 'write-file-atomic'
import { isDirectory, isFile, isSymbolicLink } from 'common/filesystem'

/**
Expand All @@ -21,19 +22,28 @@ export const normalizeAndResolvePath = (pathname: string): string => {
return path.resolve(pathname)
}

export const writeFile = (
export const writeFile = async(
pathname: string,
content: string | Buffer,
extension?: string,
options: WriteFileOptions | undefined = 'utf-8'
options: BufferEncoding | undefined = 'utf-8'
): Promise<void> => {
if (!pathname) {
return Promise.reject(new Error('[ERROR] Cannot save file without path.'))
}
pathname = !extension || pathname.endsWith(extension) ? pathname : `${pathname}${extension}`

// `outputFile` creates any missing parent directories before writing, so a
// save whose folder was moved/deleted recreates it and still succeeds —
// matching VS Code, and keeping (auto)save from ever silently failing (#3509).
return outputFile(pathname, content, options)
// write-file-atomic does not create parent directories; recreate a moved or
// deleted folder first so an (auto)save into it still succeeds (#3509).
await ensureDir(path.dirname(pathname))

// Durable atomic save: write to a temp file in the target's directory, fsync
// it, then rename it over the target. This survives an application crash AND
// a power loss / OS reboot — the fsync before the rename is what closes the
// power-loss window that otherwise leaves a full-length, zero-filled file
// (#3786, #3828); a bare rename is only namespace-atomic, not data-durable.
// write-file-atomic also preserves the target's mode/owner, writes through a
// symlink to its target, and uses a unique temp name — all of which a plain
// temp+rename dropped.
await writeFileAtomic(pathname, content, options)
}
1 change: 0 additions & 1 deletion packages/desktop/src/main/filesystem/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,6 @@ export const writeMarkdownFile = (

const buffer = iconv.encode(content, encoding, { addBOM: isBom })

// TODO(@fxha): "safeSaveDocuments" using temporary file and rename syscall.
return writeFile(pathname, buffer, extension, undefined)
}

Expand Down
28 changes: 22 additions & 6 deletions packages/desktop/src/main/utils/imagePathAutoComplement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,28 @@ const rebuild = (directory: string): void => {

const watchDirectory = (directory: string): void => {
if (watchers.has(directory)) return // Do not duplicate watch the same directory
const watcher = fs.watch(directory, (eventType, _filename) => {
if (eventType === 'rename') {
rebuild(directory)
}
})
watchers.set(directory, watcher)
try {
const watcher = fs.watch(directory, (eventType, _filename) => {
if (eventType === 'rename') {
rebuild(directory)
}
})
// Some directories become unwatchable after construction (network mounts
// dropping, permission changes); swallow the error and stop watching
// rather than leaking an uncaught exception into the main process.
watcher.on('error', (err) => {
log.error('imagePathAutoComplement::watchDirectory:', err)
watcher.close()
watchers.delete(directory)
})
watchers.set(directory, watcher)
} catch (err) {
// `fs.watch` throws synchronously for directories the OS can't watch —
// e.g. UNC / \\wsl.localhost network paths on Windows (EISDIR). Image-path
// auto-complete must degrade to "not watching" instead of crashing the
// main process with an "Unexpected error" dialog (#3779).
log.error('imagePathAutoComplement::watchDirectory:', err)
}
}

export const searchFilesAndDir = (directory: string, key: string): Promise<DirOrImageEntry[]> => {
Expand Down
52 changes: 52 additions & 0 deletions packages/desktop/test/unit/specs/buffer-store-durable.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import path from 'path'
import { afterEach, describe, expect, it, vi } from 'vitest'

// The store registers ipcMain handlers in its constructor; stub electron so the
// module imports without a real main process. writeBufferStoreFile no longer
// touches `this`, so we exercise it via the prototype without booting the store.
vi.mock('electron', () => ({}))

const { default: EditorBufferStore } = await import('main_renderer/editorBufferStore')

// #4852 follow-up: the crash-recovery buffer holds unsaved tab content but used
// a temp+rename with no fsync — the same power-loss zero-fill gap the document
// save path had. writeBufferStoreFile now writes durably via write-file-atomic.
const writeBufferStoreFile = EditorBufferStore.prototype.writeBufferStoreFile

const dirs: string[] = []
function tempDir(): string {
const d = mkdtempSync(path.join(tmpdir(), 'mt-buf-'))
dirs.push(d)
return d
}

afterEach(() => {
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true })
})

describe('EditorBufferStore.writeBufferStoreFile — durable atomic write (#4852 follow-up)', () => {
it('writes the state as JSON and leaves no temp file behind', () => {
const dir = tempDir()
const target = path.join(dir, 'buffer.json')
const state = { tabs: [{ id: '1', markdown: 'hello' }] }

writeBufferStoreFile(target, state)

expect(JSON.parse(readFileSync(target, 'utf8'))).toEqual(state)
// The temp file was renamed over the target — nothing left in the dir.
expect(readdirSync(dir)).toEqual(['buffer.json'])
})

it('overwrites an existing buffer file', () => {
const dir = tempDir()
const target = path.join(dir, 'buffer.json')

writeBufferStoreFile(target, { tabs: ['old'] })
writeBufferStoreFile(target, { tabs: ['new'] })

expect(JSON.parse(readFileSync(target, 'utf8'))).toEqual({ tabs: ['new'] })
expect(readdirSync(dir)).toEqual(['buffer.json'])
})
})
24 changes: 23 additions & 1 deletion packages/desktop/test/unit/specs/image-path-autocomplete.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import fs from 'fs'
import os from 'os'
import path from 'path'
Expand Down Expand Up @@ -103,4 +103,26 @@ describe('searchFilesAndDir', () => {

await expect(searchFilesAndDir(missing, '')).rejects.toBeTruthy()
})

it('still resolves when the directory cannot be watched (UNC/WSL paths, #3779)', async() => {
const dir = seedDir()
tmpDirs.push(dir)

// fs.watch throws synchronously for unwatchable dirs (e.g. \\wsl.localhost
// UNC paths on Windows -> EISDIR). This used to escape as an uncaught
// exception -> "Unexpected error in the main process" dialog.
const spy = vi.spyOn(fs, 'watch').mockImplementation(() => {
throw Object.assign(new Error('EISDIR: illegal operation on a directory, watch'), {
code: 'EISDIR'
})
})

const result = await searchFilesAndDir(dir, '')

expect(result.some((e) => e.file === 'a.png')).toBe(true)
// The unwatchable directory is simply not tracked.
expect(watchers.has(dir)).toBe(false)

spy.mockRestore()
})
})
81 changes: 81 additions & 0 deletions packages/desktop/test/unit/specs/write-file-atomic.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import {
chmodSync,
existsSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
statSync,
writeFileSync
} from 'fs'
import { tmpdir } from 'os'
import path from 'path'
import { afterEach, describe, expect, it } from 'vitest'
import { writeFile } from 'main_renderer/filesystem'

// #3786 / #3828: saves must survive an application crash AND a power loss.
// writeFile writes to a temp file, fsyncs it, then renames it over the target
// (via write-file-atomic), so an interrupted or unflushed write can never leave
// the document truncated or zero-filled — and, unlike a plain temp+rename, the
// target's permission mode is preserved across the save.

const dirs: string[] = []
function tempDir(): string {
const d = mkdtempSync(path.join(tmpdir(), 'mt-atomic-'))
dirs.push(d)
return d
}

afterEach(() => {
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true })
})

describe('writeFile — durable atomic save (#3786, #3828)', () => {
it('overwrites an existing file and leaves no temp file behind', async() => {
const dir = tempDir()
const target = path.join(dir, 'note.md')
writeFileSync(target, 'OLD')

await writeFile(target, 'NEW', undefined)

expect(readFileSync(target, 'utf-8')).toBe('NEW')
// The temp file was renamed over the target — nothing left in the dir.
expect(readdirSync(dir)).toEqual(['note.md'])
})

it('writes a Buffer payload (the markdown save path)', async() => {
const dir = tempDir()
const target = path.join(dir, 'note.md')

await writeFile(target, Buffer.from('buffered', 'utf-8'), undefined)

expect(readFileSync(target, 'utf-8')).toBe('buffered')
})

it('still recreates a missing parent directory (#3509)', async() => {
const base = tempDir()
const target = path.join(base, 'moved-away', 'note.md')

await writeFile(target, 'hello', undefined)

expect(existsSync(path.dirname(target))).toBe(true)
expect(readFileSync(target, 'utf-8')).toBe('hello')
})

it.skipIf(process.platform === 'win32')(
'preserves the target file\'s permission mode across a save',
async() => {
const dir = tempDir()
const target = path.join(dir, 'secret.md')
writeFileSync(target, 'v1')
chmodSync(target, 0o600)

await writeFile(target, 'v2', undefined)

expect(readFileSync(target, 'utf-8')).toBe('v2')
// A plain temp+rename would install a fresh 0o644 inode; write-file-atomic
// restores the original mode.
expect(statSync(target).mode & 0o777).toBe(0o600)
}
)
})
8 changes: 5 additions & 3 deletions packages/muya/src/block/commonMark/codeBlock/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Muya } from '../../../muya';
import type { ICodeBlockState } from '../../../state/types';
import type { TBlockPath } from '../../types';
import diff from 'fast-diff';
import { diffToTextOp } from '../../../utils';
import { diffToTextOp, firstWordOfInfo } from '../../../utils';
import { operateClassName } from '../../../utils/dom';
import logger from '../../../utils/logger';
import { loadLanguage } from '../../../utils/prism';
Expand Down Expand Up @@ -72,8 +72,10 @@ class CodeBlock extends Parent {
operateClassName(this.domNode!, 'add', 'mu-fenced-code');
}

!!value
&& loadLanguage(value)
// `value` is the full info string; load Prism for its first word only.
const language = firstWordOfInfo(value);
!!language
&& loadLanguage(language)
.then((infoList) => {
if (!Array.isArray(infoList))
return;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest';
import { Muya } from '../../../../muya';

// The `meta.lang` field now holds the whole fenced info string; the language
// used for the `language-*` class / Prism is derived as its first word
// (`firstWordOfInfo`). A multi-word info string must therefore neither crash
// the renderer (a space in `classList.add(`language-${lang}`)` would throw) nor
// leak the attributes into the class. The async Prism highlight itself is
// rAF-driven and does not run under happy-dom, so the highlighted class is
// checked in the real app (see the plan's manual verification); here we lock
// the crash-safety and the live-block round-trip.

function boot(markdown: string): Muya {
const host = document.createElement('div');
document.body.appendChild(host);
const muya = new Muya(host, { markdown } as ConstructorParameters<typeof Muya>[1]);
muya.init();
return muya;
}

describe('code fence info string through the live block', () => {
it('does not crash on a language + attributes info string', () => {
expect(() => boot('```js title="app.js"\nconst a = 1\n```\n')).not.toThrow();
});

it('does not crash on a Pandoc attribute info string', () => {
expect(() => boot('```{example, listing1-name}\nx\n```\n')).not.toThrow();
});

it('never builds a language class token containing a space', () => {
const muya = boot('```js title="app.js"\nconst a = 1\n```\n');
expect(muya.domNode!.innerHTML).not.toContain('language-js title');
});

it('round-trips the full info string through the live block', () => {
const muya = boot('```js title="app.js"\nconst a = 1\n```\n');
expect(muya.getMarkdown()).toContain('```js title="app.js"');
});
});
Loading
Loading