diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 8797a67..7e56c05 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -76,7 +76,12 @@ "Bash(npx vitest:*)", "Bash(do echo:*)", "Bash(do gh:*)", - "Bash(git tag:*)" + "Bash(git tag:*)", + "Bash(git stash:*)", + "Bash(ls /Users/tomasmaritano/Documents/Github/readied/readide/apps/desktop/src/renderer/components/NoteListFilterBar*)", + "Bash(ls /Users/tomasmaritano/Documents/Github/readied/readide/apps/desktop/src/renderer/components/*.module.css)", + "Bash(npm view:*)", + "Bash(git log:*)" ] } } diff --git a/.env.example b/.env.example index 137f7cb..1832a2d 100644 --- a/.env.example +++ b/.env.example @@ -6,20 +6,23 @@ # Set via `wrangler secret put ` for deployed environments. # For local dev, create packages/api/.dev.vars with these values. -TURSO_DATABASE_URL=libsql://your-db.turso.io -TURSO_AUTH_TOKEN=your_token_here -JWT_SECRET=your_secret_here # openssl rand -base64 32 -RESEND_API_KEY=re_your_key_here # Resend email service +TURSO_DATABASE_URL= +TURSO_AUTH_TOKEN= +# Generate with: openssl rand -base64 32 +JWT_SECRET= +# Resend email service API key +RESEND_API_KEY= SITE_URL=https://readied.app # ─── Stripe ───────────────────────────────────────────────── -STRIPE_SECRET_KEY=sk_test_your_key_here -STRIPE_WEBHOOK_SECRET=whsec_your_secret_here -STRIPE_PRICE_MONTHLY=price_your_monthly_id_here -STRIPE_PRICE_ANNUAL=price_your_annual_id_here +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= +STRIPE_PRICE_MONTHLY= +STRIPE_PRICE_ANNUAL= # ─── Admin ────────────────────────────────────────────────── -ADMIN_TOKEN=your_admin_token_here # Token for /admin endpoints +# Token for /admin endpoints +ADMIN_TOKEN= # ─── GitHub Actions (repo secrets) ────────────────────────── # GH_TOKEN # PAT with repo scope (releases, PRs) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 368e2db..01a6468 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -75,7 +75,7 @@ "@types/react-dom": "^18.2.25", "@types/turndown": "^5.0.6", "@vitejs/plugin-react": "^4.2.1", - "electron": "^39.8.5", + "electron": "^35.7.5", "electron-builder": "^26.0.12", "electron-devtools-installer": "^4.0.0", "electron-vite": "^2.1.0", diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index dfc4548..24470d3 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -1249,7 +1249,13 @@ function registerDataHandlers(): void { ipcMain.handle( 'data:exportNote', async (_event: Electron.IpcMainInvokeEvent, content: string, suggestedName: string) => { - const safeName = suggestedName.replace(/[^a-zA-Z0-9\s-]/g, '').substring(0, 80) || 'note'; + const safeName = + suggestedName + .normalize('NFC') + // eslint-disable-next-line no-control-regex + .replace(/[/\\:*?"<>|\x00-\x1f.]/g, '') + .substring(0, 80) + .trim() || 'note'; const { filePath, canceled } = await dialog.showSaveDialog({ title: 'Export Note', defaultPath: join(app.getPath('documents'), `${safeName}.md`), @@ -1262,7 +1268,7 @@ function registerDataHandlers(): void { } try { - writeFileSync(filePath, content, 'utf-8'); + await writeFile(filePath, content, 'utf-8'); return { success: true, path: filePath }; } catch (error) { return { @@ -2298,13 +2304,17 @@ function registerPluginDiscoveryHandlers(): void { const archivePath = filePaths[0]; const fileName = basename(archivePath).toLowerCase(); + // Hoist tmpDir so it can be cleaned up in finally + let tmpDir: string | null = null; + try { // Ensure plugins dir exists await mkdir(paths.plugins, { recursive: true }); // Extract to a temp dir first, then move validated plugin folder - const tmpDir = join(paths.plugins, `__installing_${Date.now()}`); - await mkdir(tmpDir, { recursive: true }); + tmpDir = join(paths.plugins, `__installing_${Date.now()}`); + const extractDir = tmpDir; + await mkdir(extractDir, { recursive: true }); await new Promise((resolve, reject) => { const cb = (error: Error | null) => { @@ -2324,25 +2334,25 @@ function registerPluginDiscoveryHandlers(): void { '-Path', archivePath, '-DestinationPath', - tmpDir, + extractDir, ], cb ); } else { - execFile('unzip', ['-o', archivePath, '-d', tmpDir], cb); + execFile('unzip', ['-o', archivePath, '-d', extractDir], cb); } } else { - execFile('tar', ['-xzf', archivePath, '-C', tmpDir], cb); + execFile('tar', ['-xzf', archivePath, '-C', extractDir], cb); } }); // Find the manifest.json — could be at root or one level deep - const entries = await readdir(tmpDir); - let pluginSourceDir = tmpDir; + const entries = await readdir(extractDir); + let pluginSourceDir = extractDir; // If there's a single subdirectory, use that as the plugin root if (entries.length === 1 && entries[0]) { - const candidatePath = join(tmpDir, entries[0]); + const candidatePath = join(extractDir, entries[0]); const candidateStat = await stat(candidatePath); if (candidateStat.isDirectory()) { pluginSourceDir = candidatePath; @@ -2352,20 +2362,17 @@ function registerPluginDiscoveryHandlers(): void { // Validate: must have manifest.json const manifestPath = join(pluginSourceDir, 'manifest.json'); if (!existsSync(manifestPath)) { - await rm(tmpDir, { recursive: true, force: true }); return { success: false, error: 'No manifest.json found in archive' }; } const manifestRaw = await readFile(manifestPath, 'utf-8'); const manifest = JSON.parse(manifestRaw); if (!manifest.id || !manifest.name) { - await rm(tmpDir, { recursive: true, force: true }); return { success: false, error: 'Invalid manifest: missing id or name' }; } // Validate plugin ID - only allow alphanumeric, hyphens, underscores if (!/^[a-zA-Z0-9_-]+$/.test(manifest.id)) { - await rm(tmpDir, { recursive: true, force: true }); return { success: false, error: 'Invalid plugin ID: must be alphanumeric with hyphens/underscores only', @@ -2375,7 +2382,6 @@ function registerPluginDiscoveryHandlers(): void { // Verify path doesn't escape plugins directory const destDir = join(paths.plugins, manifest.id); if (!normalize(destDir).startsWith(normalize(paths.plugins))) { - await rm(tmpDir, { recursive: true, force: true }); return { success: false, error: 'Invalid plugin ID: path traversal detected' }; } @@ -2386,19 +2392,18 @@ function registerPluginDiscoveryHandlers(): void { await rename(pluginSourceDir, destDir); - // Clean up temp dir (in case pluginSourceDir was a subdirectory) - if (pluginSourceDir !== tmpDir && existsSync(tmpDir)) { - await rm(tmpDir, { recursive: true, force: true }); - } - return { success: true, pluginId: manifest.id, pluginName: manifest.name }; } catch (error) { return { success: false, error: String(error) }; + } finally { + if (tmpDir && existsSync(tmpDir)) { + await rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + } } }); // Install plugin from a remote URL (marketplace download) - ipcMain.handle('plugins:installFromUrl', async (_event, url: string, _pluginSlug: string) => { + ipcMain.handle('plugins:installFromUrl', async (_event, url: string, pluginSlug: string) => { // Safety: only allow https URLs if (!url.startsWith('https://')) { return { success: false, error: 'Only HTTPS URLs are allowed' }; @@ -2429,9 +2434,9 @@ function registerPluginDiscoveryHandlers(): void { return { success: false, error: 'Plugin archive exceeds maximum size of 50 MB' }; } - // Determine archive type from URL or content-type - const lowerUrl = url.toLowerCase(); - const isZip = lowerUrl.endsWith('.zip') || lowerUrl.includes('.zip'); + // Determine archive type from URL pathname + const urlPathname = new URL(url).pathname.toLowerCase(); + const isZip = urlPathname.endsWith('.zip'); const archiveExt = isZip ? '.zip' : '.tar.gz'; const archivePath = join(tmpDir, `plugin${archiveExt}`); await writeFile(archivePath, buffer); @@ -2490,10 +2495,21 @@ function registerPluginDiscoveryHandlers(): void { const manifestRaw = await readFile(manifestPath, 'utf-8'); const manifest = JSON.parse(manifestRaw); + if (typeof manifest !== 'object' || manifest === null || Array.isArray(manifest)) { + return { success: false, error: 'Invalid manifest: not a JSON object' }; + } if (!manifest.id || !manifest.name) { return { success: false, error: 'Invalid manifest: missing id or name' }; } + // Validate manifest.id matches the expected pluginSlug if provided + if (pluginSlug && pluginSlug.length > 0 && manifest.id !== pluginSlug) { + return { + success: false, + error: `Manifest ID "${manifest.id}" does not match expected plugin "${pluginSlug}"`, + }; + } + // Validate plugin ID - only allow alphanumeric, hyphens, underscores if (!/^[a-zA-Z0-9_-]+$/.test(manifest.id)) { return { diff --git a/apps/desktop/src/renderer/components/NoteEditor.tsx b/apps/desktop/src/renderer/components/NoteEditor.tsx index de049a4..7db29b7 100644 --- a/apps/desktop/src/renderer/components/NoteEditor.tsx +++ b/apps/desktop/src/renderer/components/NoteEditor.tsx @@ -134,6 +134,11 @@ export function NoteEditor({ }, [note?.id]); useEffect(() => { + // Guard: if note switched, just sync the ref and bail out + if (trackedNoteIdRef.current !== note?.id) { + prevDirtyRef.current = isDirty; + return; + } if (prevDirtyRef.current && !isDirty) { // Transitioned from dirty to clean — save completed setShowSaved(true); @@ -141,7 +146,7 @@ export function NoteEditor({ savedTimerRef.current = setTimeout(() => setShowSaved(false), 1500); } prevDirtyRef.current = isDirty; - }, [isDirty]); + }, [isDirty, note?.id]); // Cleanup saved timer on unmount useEffect(() => { diff --git a/apps/desktop/src/renderer/components/NoteListFilterBar.tsx b/apps/desktop/src/renderer/components/NoteListFilterBar.tsx index 7b7a73f..9b017df 100644 --- a/apps/desktop/src/renderer/components/NoteListFilterBar.tsx +++ b/apps/desktop/src/renderer/components/NoteListFilterBar.tsx @@ -53,8 +53,8 @@ export function NoteListFilterBar({ sortBy, sortOrder, onSortChange }: NoteListF .then(result => { if (!cancelled) setTags(result); }) - .catch(() => { - // IPC call failed — leave tags empty + .catch((err: unknown) => { + console.error('Failed to load tags:', err); }); return () => { cancelled = true; @@ -96,6 +96,7 @@ export function NoteListFilterBar({ sortBy, sortOrder, onSortChange }: NoteListF key={opt.label} type="button" className={`${styles.pill} ${statusFilter === opt.value ? styles.pillActive : ''}`} + aria-pressed={statusFilter === opt.value} onClick={() => handleStatusClick(opt.value)} > {opt.label} diff --git a/apps/desktop/src/renderer/components/UpdateBanner.tsx b/apps/desktop/src/renderer/components/UpdateBanner.tsx index 52fd5e9..30c5448 100644 --- a/apps/desktop/src/renderer/components/UpdateBanner.tsx +++ b/apps/desktop/src/renderer/components/UpdateBanner.tsx @@ -7,7 +7,7 @@ type BannerState = | { kind: 'available'; version: string } | { kind: 'downloading'; version: string; percent: number } | { kind: 'ready'; version: string } - | { kind: 'error'; version: string }; + | { kind: 'error'; version: string; message?: string }; export function UpdateBanner() { const [state, setState] = useState({ kind: 'hidden' }); @@ -45,11 +45,15 @@ export function UpdateBanner() { ); cleanups.push( - window.readied.updates.onError(() => { + window.readied.updates.onError((err: { message: string }) => { setState(prev => prev.kind === 'hidden' ? prev - : { kind: 'error', version: (prev as { version: string }).version } + : { + kind: 'error', + version: (prev as { version: string }).version, + message: err?.message, + } ); setDismissed(false); }) @@ -60,12 +64,27 @@ export function UpdateBanner() { const handleDownload = useCallback(async () => { try { - await window.readied.updates.startDownload(); - } catch { + const result = await window.readied.updates.startDownload(); + if (!result.ok) { + setState(prev => + prev.kind === 'hidden' + ? prev + : { + kind: 'error', + version: (prev as { version: string }).version, + message: 'Download failed', + } + ); + } + } catch (err) { setState(prev => prev.kind === 'hidden' ? prev - : { kind: 'error', version: (prev as { version: string }).version } + : { + kind: 'error', + version: (prev as { version: string }).version, + message: err instanceof Error ? err.message : undefined, + } ); } }, []); @@ -93,7 +112,9 @@ export function UpdateBanner() { )} {state.kind === 'error' && ( <> - Download failed + + Download failed{state.message ? `: ${state.message}` : ''} + diff --git a/apps/desktop/src/renderer/components/Welcome.tsx b/apps/desktop/src/renderer/components/Welcome.tsx index b6aba91..f05d998 100644 --- a/apps/desktop/src/renderer/components/Welcome.tsx +++ b/apps/desktop/src/renderer/components/Welcome.tsx @@ -5,7 +5,7 @@ * or skip straight into the app. */ -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { Button } from '../ui/primitives'; import styles from './Welcome.module.css'; @@ -30,6 +30,12 @@ const features = [ ] as const; export function Welcome({ onComplete }: WelcomeProps) { + const primaryRef = useRef(null); + + useEffect(() => { + primaryRef.current?.focus(); + }, []); + const handleEscape = useCallback( (e: KeyboardEvent) => { if (e.key === 'Escape') { @@ -68,7 +74,7 @@ export function Welcome({ onComplete }: WelcomeProps) {
-