diff --git a/app/cmd/panel.js b/app/cmd/panel.js
index 3b134099..33167303 100644
--- a/app/cmd/panel.js
+++ b/app/cmd/panel.js
@@ -523,8 +523,8 @@ function isModifier(e) {
function getValidURL(str) {
if (!str) return { valid: false };
- // Check if it starts with a valid protocol
- const hasValidProtocol = /^(https?|ftp|file):\/\//.test(str);
+ // Check if it starts with a valid protocol (including peek:// for internal pages)
+ const hasValidProtocol = /^(https?|ftp|file|peek):\/\//.test(str);
if (!hasValidProtocol) {
// Check if it looks like a domain (e.g., "example.com" or "localhost")
diff --git a/app/diagnostic.html b/app/diagnostic.html
new file mode 100644
index 00000000..f8f51202
--- /dev/null
+++ b/app/diagnostic.html
@@ -0,0 +1,133 @@
+
+
+
+ Peek Diagnostic
+
+
+
+ Peek Diagnostic Tool
+ This page helps inspect localStorage and datastore contents.
+
+
+ Dump localStorage
+ Dump extension_settings from datastore
+ Dump Both
+ Copy Results
+
+
+
+
+
+
+
diff --git a/backend/electron/entry.ts b/backend/electron/entry.ts
index 5c56e2a4..717b71bc 100644
--- a/backend/electron/entry.ts
+++ b/backend/electron/entry.ts
@@ -60,8 +60,8 @@ unhandled({
}
});
-// Get the root directory (two levels up from dist/backend/electron/)
-const ROOT_DIR = path.resolve(import.meta.dirname, '..', '..', '..');
+// Get the root directory - app.getAppPath() works in both dev and packaged modes
+const ROOT_DIR = app.getAppPath();
const DEBUG = !!process.env.DEBUG;
diff --git a/backend/electron/ipc.ts b/backend/electron/ipc.ts
index 69df4ba3..0a591104 100644
--- a/backend/electron/ipc.ts
+++ b/backend/electron/ipc.ts
@@ -193,10 +193,11 @@ export function registerDatastoreHandlers(): void {
ipcMain.handle('datastore-get-table', async (ev, data) => {
try {
- if (!isValidTable(data.table)) {
- return { success: false, error: `Invalid table: ${data.table}` };
+ const tableName = data.tableName || data.table;
+ if (!isValidTable(tableName)) {
+ return { success: false, error: `Invalid table: ${tableName}` };
}
- const result = getTable(data.table);
+ const result = getTable(tableName);
return { success: true, data: result };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
@@ -206,10 +207,13 @@ export function registerDatastoreHandlers(): void {
ipcMain.handle('datastore-set-row', async (ev, data) => {
try {
- if (!isValidTable(data.table)) {
- return { success: false, error: `Invalid table: ${data.table}` };
+ const tableName = data.tableName || data.table;
+ const rowId = data.rowId || data.id;
+ const rowData = data.rowData || data.row;
+ if (!isValidTable(tableName)) {
+ return { success: false, error: `Invalid table: ${tableName}` };
}
- const result = setRow(data.table, data.id, data.row);
+ const result = setRow(tableName, rowId, rowData);
return { success: true, data: result };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
diff --git a/package.json b/package.json
index 27d2806d..ab2a9252 100644
--- a/package.json
+++ b/package.json
@@ -37,6 +37,7 @@
"smoke:visible": "./scripts/smoke-test.sh --visible",
"test": "npx playwright test",
"test:smoke": "npx playwright test tests/smoke.spec.ts",
+ "test:persistence": "npx playwright test tests/smoke.spec.ts --grep 'Data Persistence' --timeout=120000",
"test:headed": "npx playwright test --headed",
"test:debug": "npx playwright test --debug"
},
diff --git a/scripts/debug-packaged.sh b/scripts/debug-packaged.sh
new file mode 100755
index 00000000..9e2ca7ac
--- /dev/null
+++ b/scripts/debug-packaged.sh
@@ -0,0 +1,72 @@
+#!/bin/bash
+# Debug packaged app - runs the installed app with DEBUG output
+# Usage: ./scripts/debug-packaged.sh [--visible] [--profile NAME] [duration_seconds]
+# Example: ./scripts/debug-packaged.sh # 10 seconds, default profile
+# Example: ./scripts/debug-packaged.sh --visible # 10 seconds, show UI
+# Example: ./scripts/debug-packaged.sh --profile test # fresh test profile
+# Example: ./scripts/debug-packaged.sh --visible --profile test 20
+
+VISIBLE=0
+PROFILE=""
+
+while [[ "$1" == --* ]]; do
+ case "$1" in
+ --visible)
+ VISIBLE=1
+ shift
+ ;;
+ --profile)
+ PROFILE="$2"
+ shift 2
+ ;;
+ *)
+ echo "Unknown option: $1"
+ exit 1
+ ;;
+ esac
+done
+
+DURATION=${1:-10}
+LOGFILE="/tmp/peek-packaged-debug.log"
+
+cleanup() {
+ pkill -f "/Applications/Peek.app" 2>/dev/null || true
+}
+
+trap cleanup EXIT
+
+# Kill any existing instances first
+pkill -f "/Applications/Peek.app" 2>/dev/null || true
+sleep 1
+
+if [ -n "$PROFILE" ]; then
+ echo "Running packaged Peek.app with DEBUG for ${DURATION}s (profile: $PROFILE)..."
+else
+ echo "Running packaged Peek.app with DEBUG for ${DURATION}s (default profile)..."
+fi
+echo "Log file: $LOGFILE"
+echo ""
+
+if [ -n "$PROFILE" ]; then
+ PROFILE="$PROFILE" DEBUG=1 /Applications/Peek.app/Contents/MacOS/Peek > "$LOGFILE" 2>&1 &
+else
+ DEBUG=1 /Applications/Peek.app/Contents/MacOS/Peek > "$LOGFILE" 2>&1 &
+fi
+PID=$!
+
+sleep "$DURATION"
+
+echo "=== Extension manifest loading ==="
+grep -E "\[ext:win\].*Creating window" "$LOGFILE" || echo "(no extension window creation found)"
+echo ""
+
+echo "=== Manifest details ==="
+grep -E "manifest:" "$LOGFILE" | head -20 || echo "(no manifest details found)"
+echo ""
+
+echo "=== Errors ==="
+grep -iE "error|failed|cannot" "$LOGFILE" | head -20 || echo "(no errors found)"
+echo ""
+
+echo "=== Full log available at: $LOGFILE ==="
+echo "View with: cat $LOGFILE"
diff --git a/scripts/test-dev.sh b/scripts/test-dev.sh
new file mode 100755
index 00000000..73e8dca5
--- /dev/null
+++ b/scripts/test-dev.sh
@@ -0,0 +1,53 @@
+#!/bin/bash
+# Test dev app - runs the dev app with a fresh profile
+# Usage: ./scripts/test-dev.sh [--visible] [duration_seconds]
+# Example: ./scripts/test-dev.sh # 8 seconds, headless
+# Example: ./scripts/test-dev.sh --visible # 8 seconds, show UI
+# Example: ./scripts/test-dev.sh --visible 15
+
+VISIBLE=0
+if [ "$1" = "--visible" ]; then
+ VISIBLE=1
+ shift
+fi
+
+DURATION=${1:-8}
+PROFILE="test-$$"
+LOGFILE="/tmp/peek-dev-test.log"
+
+cleanup() {
+ pkill -f "/Users/dietrich/misc/peek/node_modules/electron" 2>/dev/null || true
+}
+
+trap cleanup EXIT
+
+# Kill any existing dev instances
+pkill -f "/Users/dietrich/misc/peek/node_modules/electron" 2>/dev/null || true
+sleep 1
+
+echo "Running dev Peek with test profile '$PROFILE' for ${DURATION}s..."
+echo "Log file: $LOGFILE"
+echo ""
+
+if [ "$VISIBLE" = "1" ]; then
+ PROFILE="$PROFILE" DEBUG=1 yarn start > "$LOGFILE" 2>&1 &
+else
+ PROFILE="$PROFILE" DEBUG=1 PEEK_HEADLESS=1 yarn start > "$LOGFILE" 2>&1 &
+fi
+PID=$!
+
+sleep "$DURATION"
+
+echo "=== Errors ==="
+grep -iE "error|failed|exception" "$LOGFILE" | grep -v "Autofill" | head -20 || echo "(no errors found)"
+echo ""
+
+echo "=== Warnings ==="
+grep -iE "warning|warn" "$LOGFILE" | head -10 || echo "(no warnings found)"
+echo ""
+
+echo "=== Key events ==="
+grep -E "onReady|Loading|loaded|register" "$LOGFILE" | head -20
+echo ""
+
+echo "=== Full log available at: $LOGFILE ==="
diff --git a/scripts/test-persistence.sh b/scripts/test-persistence.sh
new file mode 100755
index 00000000..38e9e1c3
--- /dev/null
+++ b/scripts/test-persistence.sh
@@ -0,0 +1,10 @@
+#!/bin/bash
+# Run data persistence tests
+# Usage: ./scripts/test-persistence.sh
+
+echo "Running data persistence tests..."
+npx playwright test tests/smoke.spec.ts --grep "Data Persistence" --timeout=120000
+
+echo ""
+echo "Done. Test profiles created in ~/Library/Application Support/Peek/test-persistence-*"
+echo "You can delete them with: rm -rf ~/Library/Application\\ Support/Peek/test-*"
diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts
index 80299035..6b8cb31b 100644
--- a/tests/smoke.spec.ts
+++ b/tests/smoke.spec.ts
@@ -18,7 +18,8 @@ import { spawn } from 'child_process';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ROOT = path.join(__dirname, '..');
-const MAIN_PATH = path.join(ROOT, 'index.js');
+// Pass the root directory - Electron will use package.json main field
+const MAIN_PATH = ROOT;
// Helper to wait for a window with specific URL pattern
async function waitForWindow(app: ElectronApplication, urlPattern: string | RegExp, timeout = 10000): Promise {
@@ -424,6 +425,224 @@ test.describe('External URL Opening', () => {
});
});
+// Data Persistence Tests - verify user data survives app restart
+test.describe('Data Persistence', () => {
+ const PERSISTENCE_PROFILE = 'test-persistence-' + Date.now();
+
+ test('peeks and slides settings persist across restart', async () => {
+ // PHASE 1: Launch app and add custom peeks/slides configuration
+ let electronApp = await electron.launch({
+ args: [MAIN_PATH],
+ env: { ...process.env, PROFILE: PERSISTENCE_PROFILE, DEBUG: '1', PEEK_HEADLESS: '1' }
+ });
+ await new Promise(r => setTimeout(r, 4000));
+
+ let bgWindow = await waitForWindow(electronApp, 'app/background.html');
+
+ // Add custom peek items to extension_settings
+ const testPeeks = [
+ { title: 'Test Peek 1', uri: 'https://test-peek-1.example.com', shortcut: 'Option+1' },
+ { title: 'Test Peek 2', uri: 'https://test-peek-2.example.com', shortcut: 'Option+2' },
+ { title: 'Custom Peek', uri: 'https://custom-peek.example.com', shortcut: 'Option+3' }
+ ];
+
+ const testSlides = [
+ { title: 'Test Slide 1', uri: 'https://test-slide-1.example.com', position: 'right', size: 400 },
+ { title: 'Test Slide 2', uri: 'https://test-slide-2.example.com', position: 'bottom', size: 300 }
+ ];
+
+ // Save peeks items to extension_settings via datastore
+ const savePeeksResult = await bgWindow.evaluate(async (items) => {
+ const api = (window as any).app;
+ return await api.datastore.setRow('extension_settings', 'peeks:items', {
+ extensionId: 'peeks',
+ key: 'items',
+ value: JSON.stringify(items),
+ updatedAt: Date.now()
+ });
+ }, testPeeks);
+ expect(savePeeksResult.success).toBe(true);
+
+ // Save slides items to extension_settings
+ const saveSlidesResult = await bgWindow.evaluate(async (items) => {
+ const api = (window as any).app;
+ return await api.datastore.setRow('extension_settings', 'slides:items', {
+ extensionId: 'slides',
+ key: 'items',
+ value: JSON.stringify(items),
+ updatedAt: Date.now()
+ });
+ }, testSlides);
+ expect(saveSlidesResult.success).toBe(true);
+
+ // Also save custom prefs
+ const savePeeksPrefs = await bgWindow.evaluate(async () => {
+ const api = (window as any).app;
+ return await api.datastore.setRow('extension_settings', 'peeks:prefs', {
+ extensionId: 'peeks',
+ key: 'prefs',
+ value: JSON.stringify({ shortcutKeyPrefix: 'Option+' }),
+ updatedAt: Date.now()
+ });
+ });
+ expect(savePeeksPrefs.success).toBe(true);
+
+ const saveSlidesPrefs = await bgWindow.evaluate(async () => {
+ const api = (window as any).app;
+ return await api.datastore.setRow('extension_settings', 'slides:prefs', {
+ extensionId: 'slides',
+ key: 'prefs',
+ value: JSON.stringify({ defaultPosition: 'right', defaultSize: 350 }),
+ updatedAt: Date.now()
+ });
+ });
+ expect(saveSlidesPrefs.success).toBe(true);
+
+ // Verify data was saved
+ const verifyResult = await bgWindow.evaluate(async () => {
+ const api = (window as any).app;
+ return await api.datastore.getTable('extension_settings');
+ });
+ expect(verifyResult.success).toBe(true);
+ const savedRows = Object.values(verifyResult.data);
+ expect(savedRows.length).toBeGreaterThanOrEqual(4);
+
+ // Close the app
+ await electronApp.close();
+ await new Promise(r => setTimeout(r, 1000));
+
+ // PHASE 2: Relaunch with same profile and verify data persisted
+ electronApp = await electron.launch({
+ args: [MAIN_PATH],
+ env: { ...process.env, PROFILE: PERSISTENCE_PROFILE, DEBUG: '1', PEEK_HEADLESS: '1' }
+ });
+ await new Promise(r => setTimeout(r, 4000));
+
+ bgWindow = await waitForWindow(electronApp, 'app/background.html');
+
+ // Query extension_settings to verify persistence
+ const persistedResult = await bgWindow.evaluate(async () => {
+ const api = (window as any).app;
+ return await api.datastore.getTable('extension_settings');
+ });
+ expect(persistedResult.success).toBe(true);
+
+ const persistedData = persistedResult.data as Record;
+
+ // Verify peeks items persisted
+ const peeksItems = persistedData['peeks:items'];
+ expect(peeksItems).toBeTruthy();
+ expect(peeksItems.extensionId).toBe('peeks');
+ expect(peeksItems.key).toBe('items');
+ const parsedPeeks = JSON.parse(peeksItems.value);
+ expect(parsedPeeks.length).toBe(3);
+ expect(parsedPeeks[0].title).toBe('Test Peek 1');
+ expect(parsedPeeks[2].title).toBe('Custom Peek');
+
+ // Verify slides items persisted
+ const slidesItems = persistedData['slides:items'];
+ expect(slidesItems).toBeTruthy();
+ expect(slidesItems.extensionId).toBe('slides');
+ const parsedSlides = JSON.parse(slidesItems.value);
+ expect(parsedSlides.length).toBe(2);
+ expect(parsedSlides[0].position).toBe('right');
+ expect(parsedSlides[1].position).toBe('bottom');
+
+ // Verify prefs persisted
+ const peeksPrefs = persistedData['peeks:prefs'];
+ expect(peeksPrefs).toBeTruthy();
+ const parsedPeeksPrefs = JSON.parse(peeksPrefs.value);
+ expect(parsedPeeksPrefs.shortcutKeyPrefix).toBe('Option+');
+
+ const slidesPrefs = persistedData['slides:prefs'];
+ expect(slidesPrefs).toBeTruthy();
+ const parsedSlidesPrefs = JSON.parse(slidesPrefs.value);
+ expect(parsedSlidesPrefs.defaultPosition).toBe('right');
+
+ await electronApp.close();
+ });
+
+ test('addresses and tags persist across restart', async () => {
+ const ADDR_PROFILE = 'test-addr-persist-' + Date.now();
+
+ // PHASE 1: Add addresses and tags
+ let electronApp = await electron.launch({
+ args: [MAIN_PATH],
+ env: { ...process.env, PROFILE: ADDR_PROFILE, DEBUG: '1', PEEK_HEADLESS: '1' }
+ });
+ await new Promise(r => setTimeout(r, 4000));
+
+ let bgWindow = await waitForWindow(electronApp, 'app/background.html');
+
+ // Add addresses
+ const addr1 = await bgWindow.evaluate(async () => {
+ return await (window as any).app.datastore.addAddress('https://persist-test-1.example.com', {
+ title: 'Persist Test 1',
+ starred: 1
+ });
+ });
+ expect(addr1.success).toBe(true);
+
+ const addr2 = await bgWindow.evaluate(async () => {
+ return await (window as any).app.datastore.addAddress('https://persist-test-2.example.com', {
+ title: 'Persist Test 2'
+ });
+ });
+ expect(addr2.success).toBe(true);
+
+ // Create a tag and tag the addresses
+ const tagResult = await bgWindow.evaluate(async () => {
+ return await (window as any).app.datastore.getOrCreateTag('persist-tag');
+ });
+ expect(tagResult.success).toBe(true);
+ const tagId = tagResult.data?.id;
+
+ if (tagId && addr1.id) {
+ await bgWindow.evaluate(async ({ addressId, tagId }) => {
+ return await (window as any).app.datastore.tagAddress(addressId, tagId);
+ }, { addressId: addr1.id, tagId });
+ }
+
+ await electronApp.close();
+ await new Promise(r => setTimeout(r, 1000));
+
+ // PHASE 2: Verify persistence
+ electronApp = await electron.launch({
+ args: [MAIN_PATH],
+ env: { ...process.env, PROFILE: ADDR_PROFILE, DEBUG: '1', PEEK_HEADLESS: '1' }
+ });
+ await new Promise(r => setTimeout(r, 4000));
+
+ bgWindow = await waitForWindow(electronApp, 'app/background.html');
+
+ // Query addresses - use getTable for more reliable results
+ const tableResult = await bgWindow.evaluate(async () => {
+ return await (window as any).app.datastore.getTable('addresses');
+ });
+ expect(tableResult.success).toBe(true);
+
+ const addresses = Object.values(tableResult.data) as any[];
+ expect(addresses.length).toBeGreaterThanOrEqual(2);
+
+ const persistedAddr1 = addresses.find((a: any) =>
+ a.uri === 'https://persist-test-1.example.com' ||
+ a.uri?.includes('persist-test-1')
+ );
+ expect(persistedAddr1).toBeTruthy();
+ expect(persistedAddr1.title).toBe('Persist Test 1');
+
+ // Query tags
+ const tagsResult = await bgWindow.evaluate(async () => {
+ return await (window as any).app.datastore.getTagsByFrecency(10);
+ });
+ expect(tagsResult.success).toBe(true);
+ const persistTag = tagsResult.data.find((t: any) => t.name === 'persist-tag');
+ expect(persistTag).toBeTruthy();
+
+ await electronApp.close();
+ });
+});
+
// Core functionality tests
test.describe('Core Functionality', () => {
let electronApp: ElectronApplication;