From 7dfd787f5bf2acb876d88f40bb9d7afd37168cd6 Mon Sep 17 00:00:00 2001 From: Kresna Date: Mon, 27 Jul 2026 01:00:19 +0700 Subject: [PATCH 1/4] feat(desktop): bundle ffmpeg as a sidecar (externalBin) Re-enable externalBin "bin/ffmpeg" so recordings/conversions use a bundled ffmpeg instead of a system install. The compile-time sidecar validation that broke desktop-check before is handled: desktop-check stubs an empty sidecar (cargo check never runs it), release.yml downloads the real per-target binary, and beforeDev/BuildCommand auto-fetch it locally. ffmpeg_path() already prefers the bundled binary next to the exe. --- .github/workflows/desktop-check.yml | 10 ++++++++++ src-tauri/tauri.conf.json | 4 +++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/desktop-check.yml b/.github/workflows/desktop-check.yml index 5b835a6..539bdfb 100644 --- a/.github/workflows/desktop-check.yml +++ b/.github/workflows/desktop-check.yml @@ -69,6 +69,16 @@ jobs: mkdir -p dist echo 'stub' > dist/index.html + # externalBin ("bin/ffmpeg") is validated at compile time by tauri-build; + # an empty file is enough for `cargo check` — we never execute it here. + - name: Stub ffmpeg sidecar + shell: bash + run: | + mkdir -p src-tauri/bin + TRIPLE=$(rustc -vV | sed -n 's/host: //p') + EXT=""; [ "${{ runner.os }}" = "Windows" ] && EXT=".exe" + : > "src-tauri/bin/ffmpeg-${TRIPLE}${EXT}" + - name: cargo check (all targets) working-directory: src-tauri run: cargo check --all-targets diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 519a362..4b40998 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -4,7 +4,8 @@ "version": "1.0.0-beta.2", "identifier": "com.goodwebtools.app", "build": { - "beforeBuildCommand": "npm run build", + "beforeDevCommand": "npm run download:ffmpeg", + "beforeBuildCommand": "npm run download:ffmpeg && npm run build", "frontendDist": "../dist", "devUrl": "http://localhost:4321" }, @@ -78,6 +79,7 @@ "bundle": { "active": true, "createUpdaterArtifacts": true, + "externalBin": ["bin/ffmpeg"], "targets": ["nsis", "app", "dmg", "deb"], "resources": [], "category": "Utility", From f97a2feacbec7c2bfe39d592e10ecceeb9053d69 Mon Sep 17 00:00:00 2001 From: Kresna Date: Mon, 27 Jul 2026 01:17:12 +0700 Subject: [PATCH 2/4] chore(lint): resolve all source-code lint warnings Polish/bug-sweep pass over ESLint warnings (109 -> 99; remaining are intentional no-explicit-any and test scaffolding): - Remove 5 unnecessary eslint-disable directives (deps arrays were already correct) and their leftover blank lines. - Prefix intentionally-unused vars with _ (worker.store destructure-omit, file.service stub param); drop a discarded register() return in HotkeyTest; remove a dead downloadService import in CodeScratchpad. - @ts-ignore -> @ts-expect-error in platform detection. - exhaustive-deps: key ToolHost's useMemo on the stable tool ref; document JsonCompare's intentional toggle-only effect. Both reviewed as non-bugs. No behavior change. 429 tests pass; build green. --- src/islands/ToolHost.tsx | 4 +++- src/islands/dev/HotkeyTest.tsx | 2 +- src/islands/dev/JsonCompare.tsx | 4 +++- src/islands/dev/PasswordGen.tsx | 1 - src/islands/draw/Whiteboard.tsx | 1 - src/islands/image/ImageAnnotate.tsx | 2 -- src/islands/image/ObjectRemove.tsx | 2 +- src/islands/playground/CodeScratchpad.tsx | 1 - src/services/file.service.ts | 2 +- src/services/platform/index.ts | 2 +- src/stores/worker.store.ts | 2 +- 11 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/islands/ToolHost.tsx b/src/islands/ToolHost.tsx index 7fba5a2..33f0208 100644 --- a/src/islands/ToolHost.tsx +++ b/src/islands/ToolHost.tsx @@ -48,10 +48,12 @@ class ToolErrorBoundary extends Component { export default function ToolHost({ toolId }: ToolHostProps) { const tool = getToolById(toolId); + // `tool` is a stable registry reference derived from `toolId`, so keying on + // both is equivalent to keying on `toolId` alone — and satisfies the linter. const LazyTool = useMemo(() => { if (!tool) return null; return lazy(tool.load); - }, [toolId]); + }, [toolId, tool]); if (!tool || !LazyTool) { return
Tool not found.
; diff --git a/src/islands/dev/HotkeyTest.tsx b/src/islands/dev/HotkeyTest.tsx index a8b93f9..4f5941f 100644 --- a/src/islands/dev/HotkeyTest.tsx +++ b/src/islands/dev/HotkeyTest.tsx @@ -29,7 +29,7 @@ export default function HotkeyTest() { const registerTestHotkey = async (keys: string, description: string) => { setError(null); try { - const id = await hotkeyService.register( + await hotkeyService.register( keys, () => { setLastTriggered(`${description} (${keys})`); diff --git a/src/islands/dev/JsonCompare.tsx b/src/islands/dev/JsonCompare.tsx index 8b0171d..723066f 100644 --- a/src/islands/dev/JsonCompare.tsx +++ b/src/islands/dev/JsonCompare.tsx @@ -195,11 +195,13 @@ export default function JsonCompare() { parseAndCompare(leftJson, value); }; - // Re-compare when ignoreArrayOrder changes + // Re-compare only when ignoreArrayOrder toggles; edits to left/right already + // trigger a compare via their change handlers, so they're intentionally omitted. useEffect(() => { if (leftJson.trim() && rightJson.trim()) { parseAndCompare(leftJson, rightJson); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ignoreArrayOrder]); return ( diff --git a/src/islands/dev/PasswordGen.tsx b/src/islands/dev/PasswordGen.tsx index 0736410..dd6974c 100644 --- a/src/islands/dev/PasswordGen.tsx +++ b/src/islands/dev/PasswordGen.tsx @@ -37,7 +37,6 @@ export default function PasswordGen() { // Regenerate whenever any option changes. useEffect(() => { setPassword(generatePassword({ length, enabled, avoidAmbiguous, minNumbers, minSpecial })); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [length, enabled, avoidAmbiguous, minNumbers, minSpecial]); const clampMin = (value: number) => setMinLength(Math.min(Math.max(value || 1, 1), maxLength)); diff --git a/src/islands/draw/Whiteboard.tsx b/src/islands/draw/Whiteboard.tsx index 9ccb211..146207a 100644 --- a/src/islands/draw/Whiteboard.tsx +++ b/src/islands/draw/Whiteboard.tsx @@ -113,7 +113,6 @@ export default function Whiteboard() { window.removeEventListener('pagehide', flushSave); window.removeEventListener('beforeunload', onBeforeUnload); }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { diff --git a/src/islands/image/ImageAnnotate.tsx b/src/islands/image/ImageAnnotate.tsx index 4314c13..6783b63 100644 --- a/src/islands/image/ImageAnnotate.tsx +++ b/src/islands/image/ImageAnnotate.tsx @@ -478,7 +478,6 @@ export default function ImageAnnotate() { takePendingImage().then(pending => { if (pending) onDrop([new File([pending.blob], pending.name, { type: pending.blob.type })]); }); - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const pointer = (e: { clientX: number; clientY: number }) => { @@ -759,7 +758,6 @@ export default function ImageAnnotate() { window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); // undo/redo use functional state updaters, so a stable listener is fine. - // eslint-disable-next-line react-hooks/exhaustive-deps }, [textEdit]); const toPngBlob = async (): Promise => { diff --git a/src/islands/image/ObjectRemove.tsx b/src/islands/image/ObjectRemove.tsx index 293182c..6ed071a 100644 --- a/src/islands/image/ObjectRemove.tsx +++ b/src/islands/image/ObjectRemove.tsx @@ -78,7 +78,7 @@ export default function ObjectRemove() { usePasteImage(f => onDrop([f])); - useEffect(() => { if (ready) redraw(); /* eslint-disable-next-line */ }, [ready]); + useEffect(() => { if (ready) redraw(); }, [ready]); const pointer = (e: React.PointerEvent) => { const c = viewRef.current!; diff --git a/src/islands/playground/CodeScratchpad.tsx b/src/islands/playground/CodeScratchpad.tsx index 47e3f9a..dcc3022 100644 --- a/src/islands/playground/CodeScratchpad.tsx +++ b/src/islands/playground/CodeScratchpad.tsx @@ -4,7 +4,6 @@ import { Button } from '@/components/ui/Button'; import MonacoEditor from './MonacoEditor'; import { extensionToLanguage } from '@/tools/playground/language.lib'; import { loadFiles, saveFiles, type ScratchFile } from '@/tools/playground/scratchpad.store'; -import { downloadService } from '@/services/download'; import { fileService } from '@/services/file'; import { clipboardService } from '@/services/clipboard'; diff --git a/src/services/file.service.ts b/src/services/file.service.ts index 96a7f40..5c605f3 100644 --- a/src/services/file.service.ts +++ b/src/services/file.service.ts @@ -14,7 +14,7 @@ export class FileService { return Array.from(source); } - async getFileHandle(file: File): Promise { + async getFileHandle(_file: File): Promise { // File System Access API - may not be available if (!('showOpenFilePicker' in window)) { return null; diff --git a/src/services/platform/index.ts b/src/services/platform/index.ts index c343987..a639fcb 100644 --- a/src/services/platform/index.ts +++ b/src/services/platform/index.ts @@ -20,7 +20,7 @@ export function getPlatform(): Platform { export function getArchitecture(): Architecture { if (typeof window === 'undefined') return 'unknown'; - // @ts-ignore - navigator.userAgentData is experimental + // @ts-expect-error - navigator.userAgentData is experimental const uaData = navigator.userAgentData; if (uaData && uaData.platform) { diff --git a/src/stores/worker.store.ts b/src/stores/worker.store.ts index 8a57ecc..41ae524 100644 --- a/src/stores/worker.store.ts +++ b/src/stores/worker.store.ts @@ -14,6 +14,6 @@ export function setProgress(id: string, label: string, percent: number): void { export function removeProgress(id: string): void { const current = progressMap.get(); - const { [id]: removed, ...rest } = current; + const { [id]: _removed, ...rest } = current; progressMap.set(rest); } From 48c5ef442e3e708c0a8347467ee93a91fe4042fa Mon Sep 17 00:00:00 2001 From: Kresna Date: Mon, 27 Jul 2026 15:17:04 +0700 Subject: [PATCH 3/4] fix(timestamp): detect and convert microsecond/nanosecond epochs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously parseTimestamp treated any numeric string longer than 10 digits as milliseconds, so a 19-digit nanosecond value (e.g. 1784694237438743460) overflowed Date's range and failed with 'Could not parse that as a date or Unix timestamp.' Now the unit is inferred from digit length — ~10 = seconds, ~13 = ms, ~16 = µs, ~19 = ns — and normalized to milliseconds. The UI surfaces the detected unit so the interpretation is explicit. Seconds/millis cutoffs are unchanged, so existing behavior is preserved. Adds coverage for micro/nanosecond parsing and detectNumericUnit. --- src/islands/dev/Timestamp.tsx | 18 ++++++++++++++-- src/tools/dev/timestamp.lib.test.ts | 28 ++++++++++++++++++++++++ src/tools/dev/timestamp.lib.ts | 33 ++++++++++++++++++++++++++--- 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/src/islands/dev/Timestamp.tsx b/src/islands/dev/Timestamp.tsx index c2ea975..48a6bcd 100644 --- a/src/islands/dev/Timestamp.tsx +++ b/src/islands/dev/Timestamp.tsx @@ -6,6 +6,7 @@ import { Alert } from '@/components/ui/Alert'; import { describeDate, parseTimestamp, + detectNumericUnit, formatInTimeZone, listTimeZones, getLocalTimeZone, @@ -19,6 +20,7 @@ export default function Timestamp() { const [date, setDate] = useState(null); const [timeZone, setTimeZone] = useState(() => getLocalTimeZone()); const [error, setError] = useState(''); + const [detectedUnit, setDetectedUnit] = useState(''); const [pickerValue, setPickerValue] = useState(''); const [pickerZone, setPickerZone] = useState<'local' | 'utc'>('local'); @@ -27,6 +29,7 @@ export default function Timestamp() { setPickerValue(value); setPickerZone(zone); setError(''); + setDetectedUnit(''); if (!value) return; const parsed = parseDateTimeLocal(value, zone); if (parsed) setDate(parsed); @@ -35,6 +38,7 @@ export default function Timestamp() { const convert = () => { setError(''); setDate(null); + setDetectedUnit(''); const trimmed = input.trim(); if (!trimmed) return; const parsed = parseTimestamp(trimmed); @@ -43,10 +47,13 @@ export default function Timestamp() { return; } setDate(parsed); + // Surface how an all-digits value was interpreted (seconds/ms/µs/ns). + if (/^\d+$/.test(trimmed)) setDetectedUnit(detectNumericUnit(trimmed)); }; const now = () => { setError(''); + setDetectedUnit(''); setDate(new Date()); }; @@ -69,7 +76,7 @@ export default function Timestamp() { label="Unix timestamp or date string" value={input} onChange={e => setInput(e.target.value)} - placeholder="1720000000 · 2026-07-12 · Jul 12 2026 10:00" + placeholder="1720000000 (s · ms · µs · ns) · 2026-07-12 · Jul 12 2026 10:00" rows={2} /> @@ -113,13 +120,20 @@ export default function Timestamp() { - {error && {error}} + {detectedUnit && ( +

+ Detected numeric input as{' '} + Unix {detectedUnit}. +

+ )} + {date && ( <>