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
10 changes: 10 additions & 0 deletions .github/workflows/desktop-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,16 @@ jobs:
mkdir -p dist
echo '<!doctype html><title>stub</title>' > 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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

All notable changes to GoodWebTools are documented here.

## [1.0.0-beta.3] — 2026-07-27

### Added
- Desktop app now **bundles FFmpeg**, so screen recording with audio works without a separate system FFmpeg install.

### Fixed
- **Unix Timestamp Converter** now detects and converts microsecond (16-digit) and nanosecond (19-digit) values instead of failing with a parse error.

### Changed
- Internal: resolved all source-code lint warnings (no behavior change).

## [1.0.0-beta.2] — 2026-07-27

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

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

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "goodwebtools"
version = "1.0.0-beta.1"
version = "1.0.0-beta.3"
description = "Privacy-first client-side tools"
authors = ["Kresna <kresnapmn@gmail.com>"]
edition = "2021"
Expand Down
6 changes: 4 additions & 2 deletions src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "GoodWebTools",
"version": "1.0.0-beta.2",
"version": "1.0.0-beta.3",
"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"
},
Expand Down Expand Up @@ -78,6 +79,7 @@
"bundle": {
"active": true,
"createUpdaterArtifacts": true,
"externalBin": ["bin/ffmpeg"],
"targets": ["nsis", "app", "dmg", "deb"],
"resources": [],
"category": "Utility",
Expand Down
4 changes: 3 additions & 1 deletion src/islands/ToolHost.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,12 @@
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]);

Check warning on line 56 in src/islands/ToolHost.tsx

View workflow job for this annotation

GitHub Actions / Test · Build · Lint

React Hook useMemo has an unnecessary dependency: 'toolId'. Either exclude it or remove the dependency array

Check warning on line 56 in src/islands/ToolHost.tsx

View workflow job for this annotation

GitHub Actions / Test · Build · Lint

React Hook useMemo has an unnecessary dependency: 'toolId'. Either exclude it or remove the dependency array

if (!tool || !LazyTool) {
return <div className="py-12 text-center text-muted-foreground">Tool not found.</div>;
Expand Down
2 changes: 1 addition & 1 deletion src/islands/dev/HotkeyTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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})`);
Expand Down
4 changes: 3 additions & 1 deletion src/islands/dev/JsonCompare.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import { CheckCircle2, XCircle } from 'lucide-react';

// Deep equality check ignoring object property order
function deepEqual(a: any, b: any, ignoreArrayOrder: boolean = true): boolean {

Check warning on line 7 in src/islands/dev/JsonCompare.tsx

View workflow job for this annotation

GitHub Actions / Test · Build · Lint

Unexpected any. Specify a different type

Check warning on line 7 in src/islands/dev/JsonCompare.tsx

View workflow job for this annotation

GitHub Actions / Test · Build · Lint

Unexpected any. Specify a different type

Check warning on line 7 in src/islands/dev/JsonCompare.tsx

View workflow job for this annotation

GitHub Actions / Test · Build · Lint

Unexpected any. Specify a different type

Check warning on line 7 in src/islands/dev/JsonCompare.tsx

View workflow job for this annotation

GitHub Actions / Test · Build · Lint

Unexpected any. Specify a different type
if (a === b) return true;

if (a == null || b == null) return a === b;
Expand Down Expand Up @@ -45,7 +45,7 @@
}

// Find differences between two objects
function findDifferences(a: any, b: any, ignoreArrayOrder: boolean = true, path: string = ''): string[] {

Check warning on line 48 in src/islands/dev/JsonCompare.tsx

View workflow job for this annotation

GitHub Actions / Test · Build · Lint

Unexpected any. Specify a different type

Check warning on line 48 in src/islands/dev/JsonCompare.tsx

View workflow job for this annotation

GitHub Actions / Test · Build · Lint

Unexpected any. Specify a different type

Check warning on line 48 in src/islands/dev/JsonCompare.tsx

View workflow job for this annotation

GitHub Actions / Test · Build · Lint

Unexpected any. Specify a different type

Check warning on line 48 in src/islands/dev/JsonCompare.tsx

View workflow job for this annotation

GitHub Actions / Test · Build · Lint

Unexpected any. Specify a different type
const diffs: string[] = [];

if (a === b) return diffs;
Expand All @@ -68,7 +68,7 @@
if (ignoreArrayOrder) {
// Find unmatched elements (order-independent comparison)
const bCopy = [...b];
const unmatchedA: any[] = [];

Check warning on line 71 in src/islands/dev/JsonCompare.tsx

View workflow job for this annotation

GitHub Actions / Test · Build · Lint

Unexpected any. Specify a different type

Check warning on line 71 in src/islands/dev/JsonCompare.tsx

View workflow job for this annotation

GitHub Actions / Test · Build · Lint

Unexpected any. Specify a different type

for (let i = 0; i < a.length; i++) {
const matchIdx = bCopy.findIndex(bVal => deepEqual(a[i], bVal, ignoreArrayOrder));
Expand Down Expand Up @@ -129,8 +129,8 @@
export default function JsonCompare() {
const [leftJson, setLeftJson] = useState('');
const [rightJson, setRightJson] = useState('');
const [leftParsed, setLeftParsed] = useState<any>(null);

Check warning on line 132 in src/islands/dev/JsonCompare.tsx

View workflow job for this annotation

GitHub Actions / Test · Build · Lint

Unexpected any. Specify a different type

Check warning on line 132 in src/islands/dev/JsonCompare.tsx

View workflow job for this annotation

GitHub Actions / Test · Build · Lint

Unexpected any. Specify a different type
const [rightParsed, setRightParsed] = useState<any>(null);

Check warning on line 133 in src/islands/dev/JsonCompare.tsx

View workflow job for this annotation

GitHub Actions / Test · Build · Lint

Unexpected any. Specify a different type

Check warning on line 133 in src/islands/dev/JsonCompare.tsx

View workflow job for this annotation

GitHub Actions / Test · Build · Lint

Unexpected any. Specify a different type
const [leftError, setLeftError] = useState('');
const [rightError, setRightError] = useState('');
const [isEqual, setIsEqual] = useState<boolean | null>(null);
Expand All @@ -149,7 +149,7 @@
return;
}

let parsedLeft: any = null;

Check warning on line 152 in src/islands/dev/JsonCompare.tsx

View workflow job for this annotation

GitHub Actions / Test · Build · Lint

Unexpected any. Specify a different type

Check warning on line 152 in src/islands/dev/JsonCompare.tsx

View workflow job for this annotation

GitHub Actions / Test · Build · Lint

Unexpected any. Specify a different type
let parsedRight: any = null;

// Parse left
Expand Down Expand Up @@ -195,11 +195,13 @@
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 (
Expand Down
1 change: 0 additions & 1 deletion src/islands/dev/PasswordGen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
18 changes: 16 additions & 2 deletions src/islands/dev/Timestamp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Alert } from '@/components/ui/Alert';
import {
describeDate,
parseTimestamp,
detectNumericUnit,
formatInTimeZone,
listTimeZones,
getLocalTimeZone,
Expand All @@ -19,6 +20,7 @@ export default function Timestamp() {
const [date, setDate] = useState<Date | null>(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');

Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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());
};

Expand All @@ -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}
/>

Expand Down Expand Up @@ -113,13 +120,20 @@ export default function Timestamp() {
</div>
</div>

<Button variant="ghost" onClick={() => { setInput(''); setDate(null); setError(''); setPickerValue(''); }}>
<Button variant="ghost" onClick={() => { setInput(''); setDate(null); setError(''); setDetectedUnit(''); setPickerValue(''); }}>
Clear
</Button>
</div>

{error && <Alert variant="error">{error}</Alert>}

{detectedUnit && (
<p className="text-sm text-muted-foreground">
Detected numeric input as{' '}
<span className="font-bold text-foreground">Unix {detectedUnit}</span>.
</p>
)}

{date && (
<>
<label className="flex flex-wrap items-center gap-2 text-sm">
Expand Down
1 change: 0 additions & 1 deletion src/islands/draw/Whiteboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
2 changes: 0 additions & 2 deletions src/islands/image/ImageAnnotate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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<Blob> => {
Expand Down
2 changes: 1 addition & 1 deletion src/islands/image/ObjectRemove.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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!;
Expand Down
1 change: 0 additions & 1 deletion src/islands/playground/CodeScratchpad.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
2 changes: 1 addition & 1 deletion src/services/file.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export class FileService {
return Array.from(source);
}

async getFileHandle(file: File): Promise<FileSystemFileHandle | null> {
async getFileHandle(_file: File): Promise<FileSystemFileHandle | null> {
// File System Access API - may not be available
if (!('showOpenFilePicker' in window)) {
return null;
Expand Down
2 changes: 1 addition & 1 deletion src/services/platform/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion src/stores/worker.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
28 changes: 28 additions & 0 deletions src/tools/dev/timestamp.lib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
import {
describeDate,
parseTimestamp,
detectNumericUnit,
formatInTimeZone,
listTimeZones,
getLocalTimeZone,
Expand All @@ -21,6 +22,24 @@ describe('parseTimestamp', () => {
expect(date!.getTime()).toBe(1700000000000);
});

it('parses a 16-digit numeric string as Unix microseconds', () => {
const date = parseTimestamp('1700000000000000');
expect(date).not.toBeNull();
expect(date!.getTime()).toBe(1700000000000);
});

it('parses a 19-digit numeric string as Unix nanoseconds', () => {
const date = parseTimestamp('1700000000000000000');
expect(date).not.toBeNull();
expect(date!.getTime()).toBe(1700000000000);
});

it('parses the reported 19-digit nanosecond value instead of erroring', () => {
const date = parseTimestamp('1784694237438743460');
expect(date).not.toBeNull();
expect(date!.getUTCFullYear()).toBe(2026);
});

it('parses an ISO date string', () => {
const date = parseTimestamp('2026-07-12');
expect(date).not.toBeNull();
Expand All @@ -32,6 +51,15 @@ describe('parseTimestamp', () => {
});
});

describe('detectNumericUnit', () => {
it('maps digit length to the epoch unit', () => {
expect(detectNumericUnit('1700000000')).toBe('seconds'); // 10
expect(detectNumericUnit('1700000000000')).toBe('milliseconds'); // 13
expect(detectNumericUnit('1700000000000000')).toBe('microseconds'); // 16
expect(detectNumericUnit('1784694237438743460')).toBe('nanoseconds'); // 19
});
});

describe('describeDate', () => {
it('describes the Unix epoch', () => {
const result = describeDate(new Date(0));
Expand Down
33 changes: 30 additions & 3 deletions src/tools/dev/timestamp.lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,40 @@ export function listTimeZones(): string[] {
return ['UTC', ...zones.filter(zone => zone !== 'UTC')];
}

export type NumericUnit = 'seconds' | 'milliseconds' | 'microseconds' | 'nanoseconds';

/** Convert a numeric epoch value in the given unit to milliseconds. */
const TO_MILLIS: Record<NumericUnit, (n: number) => number> = {
seconds: n => n * 1000,
milliseconds: n => n,
microseconds: n => n / 1e3,
nanoseconds: n => n / 1e6,
};

/**
* Infer the epoch unit of an all-digits timestamp from its length. Tuned so
* present-day values land on the right unit: ~10 digits = seconds,
* ~13 = milliseconds, ~16 = microseconds, ~19 = nanoseconds. The seconds/millis
* cutoffs (≤10, ≤13) match the tool's original behavior.
*/
export function detectNumericUnit(digits: string): NumericUnit {
const len = digits.length;
if (len <= 10) return 'seconds';
if (len <= 13) return 'milliseconds';
if (len <= 16) return 'microseconds';
return 'nanoseconds';
}

export function parseTimestamp(input: string): Date | null {
const trimmed = input.trim();
let date: Date;
if (/^\d+$/.test(trimmed)) {
// Numeric: treat 10-digit as seconds, 13-digit as milliseconds.
const num = Number(trimmed);
date = new Date(trimmed.length <= 10 ? num * 1000 : num);
// Numeric epoch value: infer the unit (s/ms/µs/ns) from its digit length
// and convert to milliseconds for the Date constructor. This lets us accept
// high-resolution timestamps (e.g. 19-digit nanoseconds) that would blow
// past Date's range if naively treated as milliseconds.
const millis = TO_MILLIS[detectNumericUnit(trimmed)](Number(trimmed));
date = new Date(millis);
} else {
date = new Date(trimmed);
}
Expand Down
Loading