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: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "prism",
"version": "0.117.1",
"version": "0.117.2",
"description": "A fast, universal media viewer for Windows.",
"author": "Max",
"license": "MIT",
Expand Down
74 changes: 38 additions & 36 deletions src/main/ipc/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,21 @@ import type {
PasteResult
} from '@shared/ipcTypes'
import { basename, dirname, extname, join, resolve, sep } from 'path'
import { copyFile, stat, writeFile } from 'fs/promises'
import { copyFile, readFile, stat, writeFile } from 'fs/promises'
import { execFile, spawn } from 'child_process'
import { copyWindowsFiles, readWindowsFiles } from '../fileClipboard'
import { insideDesktop } from '../desktopAccess'
import { container } from '../archiveBrowse'
import { holders } from '../holders'
import { documentImages } from '../docImages'
import { encodeText } from '../textFile'
import { encodeText, fallbackShape, moveShapes } from '../textFile'
import { replaceFile } from '../replaceFile'
import { readTail, startTail, stopTail } from '../fileTail'
import { binIfEmpty, makeFolder, renameFile, uniqueNameAsync } from '../fileOps'
import { appsForExt, argsFor, type AppCandidate } from '../openWith'
import { addToArchive, archiveTooLarge, moveMembers } from '../archive'
import { insideSelf, moveEntries } from '../moveOps'
import { copyCounted, pastesIntoItself } from '../pasteCopy'
import { fileKind, isMarkdown } from '@shared/fileKind'
import { samePath } from '@shared/path'
import type { OnClash, OpenWithApp, RenameResult, TextRead, WriteResult } from '@shared/types'
Expand Down Expand Up @@ -87,8 +89,13 @@ export function registerFilesIpc(ctx: MainCtx, ready: ReadyCtx): void {
await holders.release([p]) // a rename is refused for the same reason a move is (#127)
const r = await renameFile(p, name, onClash, (t) => shell.trashItem(t))
await written(p, r.ok ? r.path : dirname(p))
// The file's memory follows it (#124): its place and its choices.
if (r.ok) void positions.rename(p, r.path)
// The file's memory follows it (#124): its place and its choices. Its
// encoding and line endings too (2026-10-16), or a dirty buffer renamed
// before its save was written as utf-8 with LF.
if (r.ok) {
void positions.rename(p, r.path)
moveShapes(textShape, p, r.path)
}
return r
}
)
Expand Down Expand Up @@ -171,11 +178,18 @@ export function registerFilesIpc(ctx: MainCtx, ready: ReadyCtx): void {
// Back in the shape it came in. CodeMirror rejoins its document with
// a bare newline whatever it read, so without this one fixed typo in
// a .bat was 400 changed lines, and a UTF-16 file came back as UTF-8.
const shape = textShape.get(p.toLowerCase()) ?? {
encoding: 'utf8' as const,
eol: 'lf' as const
// With no remembered read, the file as it is on disk now (2026-10-16:
// the fallback was utf-8 with LF whatever the file held). Capped like
// a read: a file too big to open is not sniffed whole either.
const remembered = textShape.get(p.toLowerCase())
const onDisk = async (): Promise<Buffer | null> => {
const st = await stat(p).catch(() => null)
return st?.isFile() && st.size <= TEXT_MAX_BYTES ? readFile(p).catch(() => null) : null
}
await writeFile(p, encodeText(text, shape))
const shape = remembered ?? fallbackShape(await onDisk())
// Whole or not at all (2026-10-16): a truncating write left the file
// empty on a full or pulled disk (replaceFile.ts).
await replaceFile(p, encodeText(text, shape))
return { ok: true }
} catch (e) {
// EACCES, EROFS, ENOSPC: the cases worth naming rather than leaving
Expand All @@ -193,7 +207,7 @@ export function registerFilesIpc(ctx: MainCtx, ready: ReadyCtx): void {

/** Explorer's verbs, Open with, the clipboard and paste, file icons, restore. */
export function registerShellIpc(ctx: MainCtx): void {
const { ownWrite, readable, shellVerbSetting, written } = ctx
const { ownWrite, readable, shellVerbSetting, textShape, written } = ctx
// "Open file" / "Open as project" in File Explorer's own context menu
// (HKCU only).
ipcMain.handle(CALL.shellVerbStatus, () => shellVerbSetting.status())
Expand Down Expand Up @@ -326,30 +340,6 @@ export function registerShellIpc(ctx: MainCtx): void {
return total
}

/** Copy a file or a whole folder, streaming files so the byte count can
* tick. fs.cp gives no progress at all, which for a 4GB ISO is minutes
* of nothing (owner, 2026-09-03). */
async function copyCounted(
src: string,
target: string,
tick: (bytes: number) => void
): Promise<void> {
const fs = await import('fs/promises')
const st = await fs.stat(src)
if (st.isDirectory()) {
await fs.mkdir(target)
for (const e of await fs.readdir(src)) {
await copyCounted(join(src, e), join(target, e), tick)
}
return
}
const { createReadStream, createWriteStream } = await import('fs')
const { pipeline } = await import('stream/promises')
const reader = createReadStream(src)
reader.on('data', (chunk) => tick(chunk.length))
await pipeline(reader, createWriteStream(target, { flags: 'wx' }))
}

/**
* Paste, with PROGRESS and an answer that names what landed (2026-09-03).
* Windows copy/cut metadata is authoritative, including when another app
Expand Down Expand Up @@ -392,6 +382,13 @@ export function registerShellIpc(ctx: MainCtx): void {
let failed = 0
const paths: string[] = []
for (const s of src) {
// A folder pasted into itself or below itself is skipped and counted
// as failed (2026-10-16): it used to copy its own copy until the path
// ran out, and a cut fell through to the same copy (pasteCopy.ts).
if (pastesIntoItself(s, destDir)) {
failed += 1
continue
}
try {
const target = join(destDir, await uniqueNameAsync(destDir, basename(s)))
if (moving) {
Expand All @@ -408,6 +405,8 @@ export function registerShellIpc(ctx: MainCtx): void {
void moved
await fs.rm(s, { recursive: true })
}
// A cut carries the text shape like a drag-move does (2026-10-16).
moveShapes(textShape, s, target)
} else {
await copyCounted(s, target, tick)
}
Expand Down Expand Up @@ -480,7 +479,7 @@ export function registerShellIpc(ctx: MainCtx): void {

/** Drag and drop: move, add to a zip, move members. */
export function registerMoveIpc(ctx: MainCtx, ready: ReadyCtx): void {
const { archiveOk, movable, ownWrite, written } = ctx
const { archiveOk, movable, ownWrite, textShape, written } = ctx
const { positions } = ready
/* ----- drag and drop (#70): move, add to a zip, extract out of one ----- */

Expand Down Expand Up @@ -535,8 +534,11 @@ export function registerMoveIpc(ctx: MainCtx, ready: ReadyCtx): void {
const r = await moveEntries(wanted, destDir, onClash === 'ask' ? 'ask' : onClash, (t) =>
shell.trashItem(t)
).finally(() => written(destDir, ...wanted))
// Each file's memory follows it (#124).
for (const m of r.moved) void positions.rename(m.from, m.to)
// Each file's memory follows it (#124), its text shape too (2026-10-16).
for (const m of r.moved) {
void positions.rename(m.from, m.to)
moveShapes(textShape, m.from, m.to)
}
return r
}
)
Expand Down
41 changes: 41 additions & 0 deletions src/main/pasteCopy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { copyCounted, pastesIntoItself } from './pasteCopy'

describe('pastesIntoItself (2026-10-16)', () => {
it('refuses a folder pasted into itself or anywhere below it', () => {
expect(pastesIntoItself('C:\\a', 'C:\\a')).toBe(true)
expect(pastesIntoItself('C:\\a', 'C:\\a\\b\\c')).toBe(true)
expect(pastesIntoItself('C:\\A\\', 'c:/a/b')).toBe(true)
})

it('lets a copy land beside its source, or in a sibling that shares a prefix', () => {
expect(pastesIntoItself('C:\\a', 'C:\\')).toBe(false)
expect(pastesIntoItself('C:\\a\\f.txt', 'C:\\a')).toBe(false)
expect(pastesIntoItself('C:\\a', 'C:\\ab')).toBe(false)
})
})

describe('copyCounted', () => {
it('copies a folder whole and counts its bytes', async () => {
const root = mkdtempSync(join(tmpdir(), 'prism-paste-'))
mkdirSync(join(root, 'src', 'inner'), { recursive: true })
writeFileSync(join(root, 'src', 'a.txt'), 'abc')
writeFileSync(join(root, 'src', 'inner', 'b.txt'), 'de')
let bytes = 0
await copyCounted(join(root, 'src'), join(root, 'out'), (n) => (bytes += n))
expect(readFileSync(join(root, 'out', 'inner', 'b.txt'), 'utf8')).toBe('de')
expect(bytes).toBe(5)
})

it('lists the source before making the target, so a copy into its own child does not copy itself', async () => {
const root = mkdtempSync(join(tmpdir(), 'prism-paste-'))
const a = join(root, 'A')
mkdirSync(a)
writeFileSync(join(a, 'f.txt'), 'x')
await copyCounted(a, join(a, 'A'), () => {})
expect(readdirSync(join(a, 'A')).sort()).toEqual(['f.txt'])
}, 10_000)
})
44 changes: 44 additions & 0 deletions src/main/pasteCopy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { createReadStream, createWriteStream } from 'node:fs'
import { mkdir, readdir, stat } from 'node:fs/promises'
import { pipeline } from 'node:stream/promises'
import { join } from 'node:path'
import { insideSelf } from './moveOps'

/**
* Would pasting `src` into `destDir` put a folder inside itself (2026-10-16)?
*
* Paste never asked, though the drag-move always did (`insideSelf` in
* moveOps). A folder pasted into itself or one of its own subfolders copied
* the copy it was making, level after level (A\A\A...), until the path grew
* past 32k characters, and left a tree Explorer could barely delete. A cut
* took the same road: the rename fails with EINVAL and the copy fallback ran.
*/
export function pastesIntoItself(src: string, destDir: string): boolean {
return insideSelf(src, destDir)
}

/** Copy a file or a whole folder, streaming files so the byte count can
* tick. fs.cp gives no progress at all, which for a 4GB ISO is minutes
* of nothing (owner, 2026-09-03). */
export async function copyCounted(
src: string,
target: string,
tick: (bytes: number) => void
): Promise<void> {
const st = await stat(src)
if (st.isDirectory()) {
// Listed BEFORE the new folder is made (2026-10-16): made first, a target
// inside `src` showed up in its own listing and was copied into itself.
// `pastesIntoItself` is the real guard; this keeps one level honest even
// for a caller that forgot it.
const names = await readdir(src)
await mkdir(target)
for (const e of names) {
await copyCounted(join(src, e), join(target, e), tick)
}
return
}
const reader = createReadStream(src)
reader.on('data', (chunk) => tick(chunk.length))
await pipeline(reader, createWriteStream(target, { flags: 'wx' }))
}
66 changes: 66 additions & 0 deletions src/main/replaceFile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { mkdtempSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'

// A disk that fills up part way through a write, on demand.
const fault = vi.hoisted(() => ({
code: '' as string,
when: (() => false) as (p: string) => boolean
}))
vi.mock('node:fs/promises', async (orig) => {
const real = await orig<typeof import('node:fs/promises')>()
return {
...real,
writeFile: async (p: string, data: Uint8Array) => {
if (fault.code && fault.when(String(p))) {
// Truncate-then-fail, the way a full disk leaves a file.
await real.writeFile(p, data.subarray(0, 1))
throw Object.assign(new Error(fault.code), { code: fault.code })
}
return real.writeFile(p, data)
}
}
})

const { replaceFile } = await import('./replaceFile')

let dir = ''
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'prism-replace-'))
fault.code = ''
fault.when = () => false
})

describe('replaceFile (2026-10-16)', () => {
it('writes the bytes and leaves no tmp file behind', async () => {
const f = join(dir, 'run.bat')
writeFileSync(f, 'old')
await replaceFile(f, Buffer.from('new\r\n'))
expect(readFileSync(f, 'utf8')).toBe('new\r\n')
expect(readdirSync(dir)).toEqual(['run.bat'])
})

it('leaves the original whole when the disk fills mid-write', async () => {
const f = join(dir, 'notes.txt')
writeFileSync(f, 'the original document')
fault.code = 'ENOSPC'
fault.when = () => true
await expect(replaceFile(f, Buffer.from('a much longer replacement'))).rejects.toThrow('ENOSPC')
expect(readFileSync(f, 'utf8')).toBe('the original document')
expect(readdirSync(dir)).toEqual(['notes.txt'])
})

it('writes in place where a file may not be made beside it', async () => {
const f = join(dir, 'locked.txt')
writeFileSync(f, 'old')
fault.code = 'EACCES'
// Only the tmp file fails. Matched by its name, not `p !== f`: replaceFile
// writes in place through realpath, which expands an 8.3 TEMP such as the
// CI runner's C:\Users\RUNNER~1, so the in-place path need not equal `f`.
fault.when = (p) => p.endsWith('.tmp')
await replaceFile(f, Buffer.from('new'))
expect(readFileSync(f, 'utf8')).toBe('new')
expect(readdirSync(dir)).toEqual(['locked.txt'])
})
})
47 changes: 47 additions & 0 deletions src/main/replaceFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { randomUUID } from 'node:crypto'
import { basename, dirname, join } from 'node:path'
import { realpath, rename, rm, writeFile } from 'node:fs/promises'

/** Refusals that mean "not this way", not "the disk is broken": a folder the
* user may not create files in, or a file another program holds open without
* delete sharing. An in-place write still works for those. */
const IN_PLACE_OK = new Set(['EPERM', 'EACCES', 'EBUSY'])

const code = (e: unknown): string => String((e as NodeJS.ErrnoException)?.code ?? '')

/**
* The editor's save, whole or not at all (2026-10-16).
*
* It was `writeFile(p, ...)`, which truncates first: on a full disk or a
* pulled USB stick the document was left empty or half written and all the
* user saw was "Couldn't save". The bytes now go to a hidden tmp file beside
* the file (dot-named, so the watchers ignore it) and a rename swaps them in,
* so a failed write leaves the original exactly as it was.
*
* A link is followed first: renaming over the link would replace the link
* with a plain file. Where the swap is refused rather than failed (a folder
* that allows writing the file but not creating one beside it, a file another
* program holds open without delete sharing), it writes in place, as it
* always did. Everything else throws, for the caller to name.
*/
export async function replaceFile(target: string, bytes: Uint8Array): Promise<void> {
const real = await realpath(target).catch(() => target)
const tmp = join(dirname(real), `.${basename(real)}.${randomUUID()}.tmp`)
try {
try {
await writeFile(tmp, bytes)
} catch (e) {
if (!IN_PLACE_OK.has(code(e))) throw e
await writeFile(real, bytes)
return
}
try {
await rename(tmp, real)
} catch (e) {
if (!IN_PLACE_OK.has(code(e))) throw e
await writeFile(real, bytes)
}
} finally {
await rm(tmp, { force: true }).catch(() => {})
}
}
Loading
Loading