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
8 changes: 7 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ PowerShell scripts (`../RCMM/manager/src/RCMM/`), lifted into a real GUI app.

## Design process — READ THIS

Navigation is VERB-first: the rail is Convert / Compress / Resize / Upscale /
Remove BG / Generate / Tools, each owning one queue that may hold several
convert groups at once. A selection never spans two convert groups, because one
options panel can only describe one target set. See `src/shared/tabs.ts`.

**The look is designed collaboratively with the owner. Make NO visual assumptions.**
Before building or restyling any UI, present mockups (self-contained browser HTML, like the
RCMM Show/Hide exploration), offer options, and iterate to explicit sign-off. This covers
Expand Down Expand Up @@ -53,7 +58,8 @@ src/
(planned) ipc.ts renderer <-> engine wiring
preload/ contextBridge — the typed `window.filesmith` API
renderer/ React UI (placeholder until designed)
shared/ (planned) types.ts — Job, ToolId, FileKind, Options, progress events
shared/ types.ts — Job, ToolId, FileKind, Options, progress events
tabs.ts — the VERB-first navigation model (rail tabs + Tools cards)
resources/bin/ bundled CLI binaries (gitignored; fetched by scripts, packed by electron-builder)
```

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ A local file toolkit for Windows. Drop files, pick a tool, get results.

## What it is

Filesmith puts the everyday file jobs behind one clean window: convert, compress, resize, and more, across **images, video, audio, PDFs, documents, and archives**. Drop a pile of files, pick a tool, and it runs the batch with thumbnails, live per-file progress, rich previews, and collision-safe output that never overwrites your originals.
Filesmith puts the everyday file jobs behind one clean window. Pick **what you want done** - convert, compress, resize, and more - then drop files of any supported type: **images, video, audio, PDFs, documents, and archives**. Drop a pile of files, pick a tool, and it runs the batch with thumbnails, live per-file progress, rich previews, and collision-safe output that never overwrites your originals.

Everything runs **locally on your machine**, and your queue and produced files are **remembered across restarts**, so you can close the app mid-batch and pick up where you left off.

Expand Down Expand Up @@ -66,7 +66,7 @@ Download **`Filesmith-Setup-x64-<version>.exe`** from the [latest release](https
## Use

1. Open **Filesmith** from the Start menu.
2. Pick a file type in the left rail (Images, Video, Audio, PDF, Documents, Archives).
2. Pick what you want to do in the left rail (Convert, Compress, Resize, Upscale, Remove BG, Generate, Tools).
3. Choose a tool from the top-right (Convert, Compress, Resize, …), set its options.
4. Drop files onto the drop zone (or click **browse**), then run.

Expand Down
6 changes: 4 additions & 2 deletions e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,10 @@ function runJob(tool: string, input: string, options: Record<string, unknown>):
)
}

test('the app boots to the Images workspace', async () => {
await expect(page.locator('text=Images').first()).toBeVisible()
test('the app boots to the Convert workspace', async () => {
// The rail names the verb, so the app opens on the first one rather than on
// a file type.
await expect(page.locator('h1', { hasText: 'Convert' }).first()).toBeVisible()
// The primary action is on screen without scrolling (the pinned footer).
await expect(page.locator('button', { hasText: 'Convert' }).last()).toBeVisible()
})
Expand Down
63 changes: 36 additions & 27 deletions e2e/workflows.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,35 +169,44 @@ test.afterAll(async () => {

// --- UI navigation -----------------------------------------------------------

test('every category opens its workspace with the right operations', async () => {
const expected: Record<string, string[]> = {
Images: ['Convert', 'Compress', 'Resize', 'Remove Background', 'Upscale', 'Generate'],
Video: ['Convert', 'Compress'],
Audio: ['Convert', 'Compress'],
PDF: [
'Extract text',
'Pages to PNG',
'Merge',
'Split',
'Burst',
'Extract images',
'To CBZ',
'Compress'
],
Documents: ['Convert'],
Archives: ['Convert', 'Extract', 'To PDF']
}
for (const [category, ops] of Object.entries(expected)) {
await page.locator(`button:has-text("${category}")`).first().click()
// The operation switcher lists every operation of this category.
await page.locator('aside button').first().click() // open the switcher
for (const op of ops) {
await expect(page.locator(`text=${op}`).first()).toBeVisible()
}
await page.keyboard.press('Escape')
/** Tools always opens on its grid, however you last left it. */
async function openToolsGrid(p: Page): Promise<void> {
await p.locator('button:has-text("Tools")').first().click()
await expect(p.locator('h1', { hasText: 'Tools' }).first()).toBeVisible()
}

test('the rail lists every verb and opens its workspace', async () => {
for (const verb of ['Convert', 'Compress', 'Resize', 'Upscale', 'Remove BG', 'Generate']) {
await page.locator(`button:has-text("${verb}")`).first().click()
await expect(page.locator('h1', { hasText: verb }).first()).toBeVisible()
}
await openToolsGrid(page)
await page.locator('button:has-text("Convert")').first().click()
})

test('Tools groups its one-off verbs and opens one as a workspace', async () => {
await openToolsGrid(page)
for (const t of ['Extract text', 'Pages to PNG', 'Merge', 'Split', 'Burst']) {
await expect(page.locator(`text=${t}`).first()).toBeVisible()
}
// Archive work is an ordinary Convert now, not a tool hidden in here.
await expect(page.locator('text=Archive to PDF')).toHaveCount(0)
await expect(page.locator('text=PDF to CBZ')).toHaveCount(0)
await page.locator('button:has-text("Merge")').first().click()
// A tool workspace is an ordinary queue titled with the tool's name.
await expect(page.locator('h1', { hasText: 'Merge' }).first()).toBeVisible()
await expect(page.locator('text=Files').first()).toBeVisible()
// Back returns to the grid, which is the app's only second level.
await page.locator('button[aria-label="Back to Tools"]').first().click()
await expect(page.locator('h1', { hasText: 'Tools' }).first()).toBeVisible()
await page.locator('button:has-text("Convert")').first().click()
})

// NOTE: the mixed-kind queue, its group headers and the kind-scoped options
// panel are covered by unit tests (queue-groups, verb-state), not here. Adding
// files to the UI needs a real OS drop or a native file dialog, and neither is
// drivable from Playwright without a production-only test seam.

// --- Images ------------------------------------------------------------------

test('convert: png -> jpg, webp, bmp (flattened), multi-size ico', async () => {
Expand Down Expand Up @@ -570,7 +579,7 @@ test('pdf to-cbz: pages are zero-padded jpegs so a reader orders them correctly'
format: '.cbz',
dpi: 72,
pageFormat: 'jpg',
quality: 80
pageQuality: 80
})
expect(e.status).toBe('done')
expect(e.outputPath!.endsWith('.cbz')).toBe(true)
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "filesmith",
"version": "0.3.0",
"version": "0.4.0",
"description": "A desktop file toolkit: convert, compress, resize, upscale, remove backgrounds, and PDF tools.",
"author": "Max",
"license": "MIT",
Expand Down
7 changes: 4 additions & 3 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ function sweepStaleTempDirs(): void {
}
}

// A private scheme the renderer uses to load local media for the preview
// A private scheme the renderer uses to load local media (generated-image
// thumbnails) without a file:// origin
// window. Registered as a standard, streaming scheme so <video>/<audio> can
// seek — the renderer can't touch file:// directly under web security.
const MEDIA_SCHEME = 'fsmedia'
Expand Down Expand Up @@ -151,7 +152,7 @@ function createWindow(): void {
// Open external links in the OS browser, never in-app.
mainWindow.webContents.setWindowOpenHandler((details) => {
// Scheme allowlist: shell.openExternal will happily launch a file:// or a
// registered protocol handler. previewWindow.ts already does this; not
// registered protocol handler; not
// reachable today (the CSP is script-src 'self' and this window renders no
// untrusted markup) but it costs one line to keep it that way.
if (/^https?:/i.test(details.url)) void shell.openExternal(details.url)
Expand Down Expand Up @@ -203,7 +204,7 @@ if (!app.requestSingleInstanceLock()) {
// lever that fixes a dead model URL for every install without a release.
scheduleChannelRefresh()

// Serve local files for the preview: fsmedia://local/<encoded-abs-path>.
// Serve local files to the renderer: fsmedia://local/<encoded-abs-path>.
protocol.handle(MEDIA_SCHEME, (request) => serveMedia(request))

// ONCE per process, before any window: handlers are process-global, and
Expand Down
57 changes: 5 additions & 52 deletions src/main/ipc.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,14 @@
import { open, readFile, stat } from 'fs/promises'
import { readFile } from 'fs/promises'
import { existsSync } from 'fs'
import { BrowserWindow, dialog, ipcMain, shell } from 'electron'
import type {
FileInfo,
FileKind,
JobEvent,
JobRequest,
PreviewItem,
PreviewPayload
} from '@shared/types'
import type { FileInfo, FileKind, JobEvent, JobRequest } from '@shared/types'
import { imageFilters, pickerFilters } from './pickerFilters'
import { JobQueue } from './jobQueue'
import { fileInfoFromPath } from './fileInfo'
import { removebgStatus, resolveRar } from './toolResolver'
import { ensureUserNcnnDir, listNcnnModels, userNcnnDir } from './tools/ncnnModels'
import { probeDimensions, probeImageDimensions } from './probe'
import { makeThumbnail } from './thumbnail'
import { openPreviewWindow, getPreviewPayload, updatePreviewFiles } from './previewWindow'
import { cudaTierSupport, detectNvidia } from './pid/gpu'
import { basename } from 'path'
import { pidInstalled, comfyEngineReady, pidEnvMarker, PID_BACKBONES } from './pid/paths'
Expand Down Expand Up @@ -94,7 +86,7 @@ export function registerGlobalIpc(): JobQueue {
const queue = new JobQueue((e: JobEvent) => broadcast('job:event', e))

// Custom (frameless) window controls — act on the sender's window so both the
// main window and the preview window control themselves.
// The window controls itself.
ipcMain.on('window:minimize', (e) => BrowserWindow.fromWebContents(e.sender)?.minimize())
ipcMain.on('window:toggle-maximize', (e) => {
const w = BrowserWindow.fromWebContents(e.sender)
Expand All @@ -104,17 +96,8 @@ export function registerGlobalIpc(): JobQueue {
})
ipcMain.on('window:close', (e) => BrowserWindow.fromWebContents(e.sender)?.close())

// Preview window: open/reuse it, and let it fetch its file list on load.
ipcMain.handle('preview:open', (_e, p: PreviewPayload) => openPreviewWindow(p))
ipcMain.handle('preview:data', () => getPreviewPayload())
ipcMain.on('preview:update-list', (_e, files: PreviewItem[]) => updatePreviewFiles(files))

// Reveal an output file in the OS file manager.
ipcMain.on('reveal', (_e, p: string) => {
if (p) shell.showItemInFolder(p)
})

// Open a file in its OS-default application (the "Preview" action).
// Open a file in whatever the OS already uses for it. Filesmith used to
// ship its own viewer window; the desktop already does this job.
ipcMain.on('file:open', (_e, p: string) => {
if (p) void shell.openPath(p)
})
Expand Down Expand Up @@ -182,36 +165,6 @@ export function registerGlobalIpc(): JobQueue {
})
return r.canceled ? [] : r.filePaths.map(fileInfoFromPath).filter(isSupported)
})
// Read a file's bytes so the renderer can play audio / show a PDF from a
// same-origin blob URL (Web Audio needs no CORS taint). Guard the size first:
// a whole-file read into a Uint8Array over IPC would OOM on a huge input, so
// reject it and let the renderer fall back to "open in default app".
ipcMain.handle('file:bytes', async (_e, p: string) => {
try {
const st = await stat(p)
const MAX = 256 * 1024 * 1024 // 256 MB — generous for media/PDF, bounded
if (st.size > MAX) return null
return new Uint8Array(await readFile(p))
} catch {
return null
}
})
// Read the first slice of a text file for the preview. Read ONLY the cap via a
// file handle — never load a multi-GB log fully into memory just to truncate.
ipcMain.handle('file:text', async (_e, p: string) => {
let fh
try {
fh = await open(p, 'r')
const cap = 1024 * 1024 // 1 MB — enough for preview
const buf = Buffer.alloc(cap)
const { bytesRead } = await fh.read(buf, 0, cap, 0)
return buf.subarray(0, bytesRead).toString('utf8')
} catch {
return null
} finally {
await fh?.close()
}
})
// Video DISPLAY dimensions (rotation-aware) for the compress scale preview.
ipcMain.handle('video:dimensions', (_e, p: string) => probeDimensions(p))
// Images go through ImageMagick instead: ffprobe rejects very large ones and
Expand Down
72 changes: 0 additions & 72 deletions src/main/previewWindow.ts

This file was deleted.

5 changes: 4 additions & 1 deletion src/main/tools/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1295,7 +1295,10 @@ const archiveTool: ToolModule = {
assertRarTarget(targetExt)
const dpi = Math.max(36, Math.min(600, Number(options.dpi ?? 150)))
const pageFormat = String(options.pageFormat ?? 'jpg')
const quality = Math.max(1, Math.min(100, Number(options.quality ?? 85)))
// `pageQuality`, not `quality`: on the Convert tab one option bag can
// reach either the convert tool (quality is a preset STRING) or this
// one, and Number('balanced') is NaN.
const quality = Math.max(1, Math.min(100, Number(options.pageQuality ?? 100)))

// Always a neutral temp dir, so mutool draw's printf `-o` pattern can
// never expand a `%` inherited from the source file's name.
Expand Down
Loading
Loading