diff --git a/CLAUDE.md b/CLAUDE.md index 1bd5593..37c739d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,6 +9,7 @@ npm install # Install dependencies npm run build # Build for Firefox (default) npm run build:firefox # Build for Firefox npm run build:chrome # Build for Chrome +npm run build:safari # Build for Safari npm run dev # Run in Firefox Developer Edition npm run watch:chrome # Watch mode for Chrome development npm run test # Run unit tests (vitest) @@ -16,11 +17,12 @@ npm run lint # ESLint check npm run build:types # TypeScript type check npm run package # Package for Firefox (web-ext-artifacts/) npm run package:chrome # Package for Chrome (ZIP) +npm run package:safari # Build for Safari + run Apple's Safari Web Extension packager (outputs to web-ext-artifacts/safari/) ``` ## Architecture -**Cross-browser extension (Manifest V3)** for Firefox and Chrome that uses Fetch Proxy to trim ChatGPT conversations before React renders. +**Cross-browser extension (Manifest V3)** for Firefox, Chrome, and Safari that uses Fetch Proxy to trim ChatGPT conversations before React renders. ### Core Components @@ -60,9 +62,10 @@ LightSession Pro counts **messages** (role changes) instead of nodes: ``` extension/ -├── manifest.json # Symlink → manifest.firefox.json (or chrome copy) +├── manifest.json # Symlink → manifest.firefox.json (or chrome/safari copy) ├── manifest.firefox.json # Firefox-specific manifest ├── manifest.chrome.json # Chrome-specific manifest +├── manifest.safari.json # Safari-specific manifest (MV3, no declarativeContent) └── src/ ├── page/ # Page script (Fetch Proxy, runs in page context) ├── content/ # Content scripts (settings, status bar) @@ -70,7 +73,7 @@ extension/ ├── popup/ # Popup HTML/CSS/TS └── shared/ # Types, constants, storage, logger tests/ # Unit tests (vitest + happy-dom) -build.cjs # esbuild build script (supports --target=firefox|chrome) +build.cjs # esbuild build script (supports --target=firefox|chrome|safari) ``` ## Conventions diff --git a/README.md b/README.md index 746465d..dd5a4c0 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,10 @@ Built after too many coding sessions where a single ChatGPT tab would start eati **[Install from Chrome Web Store](https://chromewebstore.google.com/detail/lightsession-pro/cenmillohidhjjjjiocmddkgmlonaigp)** +### Safari + +Currently manual install only. + ### After installation 1. Open any ChatGPT conversation. @@ -84,6 +88,9 @@ npm run build:firefox # Build for Chrome npm run build:chrome + +# Build for Safari +npm run build:safari ``` **Firefox:** @@ -97,6 +104,14 @@ npm run build:chrome 3. Click **Load unpacked** 4. Select the `extension/` folder +**Safari:** +1. Run `npm run build:safari` +2. Open **Safari > Settings > Developer** +3. Click **Add Temporary Extension...** +4. Select the `extension/` folder + +Temporary Safari extensions are for local testing/use. Safari may remove them after 24 hours or when Safari quits, so repeat the last steps after rebuilding or restarting Safari. + --- ## 🚀 Usage @@ -177,7 +192,7 @@ Trimming only affects what the browser renders. The conversation itself remains - Node.js >= 24.10.0 (see `.node-version`) - npm >= 10 -- Firefox >= 115 or Chrome >= 120 +- Firefox >= 115, Chrome >= 120, or Safari >= 15.4 ### Scripts @@ -188,6 +203,7 @@ npm install # Install dependencies npm run build # Build for Firefox (default) npm run build:firefox # Build for Firefox npm run build:chrome # Build for Chrome +npm run build:safari # Build for Safari # Development npm run dev # Run in Firefox Developer Edition @@ -201,6 +217,7 @@ npm run build:types # Type check # Package npm run package # Package for Firefox (web-ext-artifacts/) npm run package:chrome # Package for Chrome (ZIP) +npm run package:safari # Package for Safari with Xcode tooling (optional) ``` ### Project structure @@ -217,6 +234,7 @@ extension/ ├── icons/ # Extension icons ├── manifest.firefox.json # Firefox manifest (MV3) ├── manifest.chrome.json # Chrome manifest (MV3) +├── manifest.safari.json # Safari manifest (MV3) └── manifest.json # Active manifest (symlink/copy from build) ``` @@ -231,7 +249,7 @@ extension/ ## 🌐 Compatibility -- **Browsers:** Firefox >= 115, Chrome >= 120 (Manifest V3) +- **Browsers:** Firefox >= 115, Chrome >= 120, Safari >= 15.4 (Manifest V3) - **OS:** Windows, macOS, Linux - **ChatGPT:** Optimized for the current UI (2025–2026), resilient to small layout changes diff --git a/build.cjs b/build.cjs index 4678b92..379d533 100755 --- a/build.cjs +++ b/build.cjs @@ -7,6 +7,7 @@ * node build.cjs - Development build for Firefox (default) * node build.cjs --target=firefox - Build for Firefox * node build.cjs --target=chrome - Build for Chrome + * node build.cjs --target=safari - Build for Safari * node build.cjs --watch - Watch mode for development * NODE_ENV=production node build.cjs - Production build (minified, no sourcemaps) */ @@ -18,10 +19,10 @@ const path = require('path'); const isWatch = process.argv.includes('--watch'); const isProduction = process.env.NODE_ENV === 'production'; -// Parse --target=firefox|chrome (default: firefox) +// Parse --target=firefox|chrome|safari (default: firefox) const targetArg = process.argv.find((arg) => arg.startsWith('--target=')); const target = targetArg ? targetArg.split('=')[1] : 'firefox'; -const validTargets = ['firefox', 'chrome']; +const validTargets = ['firefox', 'chrome', 'safari']; if (!validTargets.includes(target)) { console.error(`❌ Invalid target: ${target}. Use: ${validTargets.join(', ')}`); process.exit(1); @@ -39,8 +40,8 @@ function copyManifest() { fs.unlinkSync(manifestDest); } - if (target === 'chrome') { - // For Chrome, copy manifest.chrome.json + if (target === 'chrome' || target === 'safari') { + // For Chrome/Safari, copy the target-specific manifest fs.copyFileSync(manifestSrc, manifestDest); console.log(`✓ Copied manifest.${target}.json → manifest.json`); } else { diff --git a/extension/manifest.safari.json b/extension/manifest.safari.json new file mode 100644 index 0000000..0d92a13 --- /dev/null +++ b/extension/manifest.safari.json @@ -0,0 +1,53 @@ +{ + "manifest_version": 3, + "name": "LightSession Pro for ChatGPT", + "version": "1.7.4", + "description": "Keep ChatGPT fast by keeping only the last N messages in the DOM. Local-only.", + "icons": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + }, + "action": { + "default_title": "LightSession Pro", + "default_popup": "popup/popup.html" + }, + "permissions": ["storage", "tabs"], + "host_permissions": [ + "*://chat.openai.com/*", + "*://chatgpt.com/*" + ], + "content_scripts": [ + { + "matches": [ + "*://chat.openai.com/*", + "*://chatgpt.com/*" + ], + "js": ["dist/page-inject.js"], + "run_at": "document_start" + }, + { + "matches": [ + "*://chat.openai.com/*", + "*://chatgpt.com/*" + ], + "js": ["dist/content.js"], + "run_at": "document_idle" + } + ], + "background": { + "service_worker": "dist/background.js" + }, + "web_accessible_resources": [ + { + "resources": ["dist/page-script.js", ".dev"], + "matches": ["*://chat.openai.com/*", "*://chatgpt.com/*"] + } + ], + "browser_specific_settings": { + "safari": { + "strict_min_version": "15.4" + } + } +} diff --git a/extension/src/shared/browser-polyfill.ts b/extension/src/shared/browser-polyfill.ts index 6a5dbbc..ccdc382 100644 --- a/extension/src/shared/browser-polyfill.ts +++ b/extension/src/shared/browser-polyfill.ts @@ -4,6 +4,7 @@ * Cross-browser compatibility layer for WebExtension APIs. * - Firefox: uses global `browser` object (Promise-based) * - Chrome: uses global `chrome` object (callback-based, but MV3 supports Promises) + * - Safari: Web Extensions API (browser/chrome); MV3 from Safari 15.4+ * * Modern Chrome (MV3) supports Promise-based APIs similar to Firefox, * so we can use `chrome` directly as a drop-in replacement for `browser`. diff --git a/package-safari.cjs b/package-safari.cjs new file mode 100644 index 0000000..8bc68b6 --- /dev/null +++ b/package-safari.cjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node +/** + * Package the built extension as a Safari Web Extension Xcode project. + * + * Apple renamed safari-web-extension-converter to safari-web-extension-packager. + * Prefer the current tool name, but keep the old name as a fallback for older + * Xcode installs. + */ + +const { spawnSync } = require('child_process'); + +const TOOLS = ['safari-web-extension-packager', 'safari-web-extension-converter']; +const args = [ + 'extension', + '--project-location', + 'web-ext-artifacts/safari', + '--app-name', + 'LightSession Pro', + '--bundle-identifier', + 'com.lightsession.pro', + '--macos-only', + '--swift', + '--copy-resources', + '--no-open', + '--no-prompt', + '--force', +]; + +function findTool() { + for (const tool of TOOLS) { + const result = spawnSync('xcrun', ['--find', tool], { stdio: 'ignore' }); + if (result.status === 0) { + return tool; + } + } + + return undefined; +} + +const tool = findTool(); + +if (!tool) { + console.error( + 'Safari packaging requires full Xcode with safari-web-extension-packager installed.' + ); + process.exit(1); +} + +const result = spawnSync('xcrun', [tool, ...args], { stdio: 'inherit' }); + +if (result.error) { + console.error(result.error.message); + process.exit(1); +} + +process.exit(result.status ?? 1); diff --git a/package.json b/package.json index d5dd5bc..b19dd92 100644 --- a/package.json +++ b/package.json @@ -10,10 +10,12 @@ "build": "node build.cjs", "build:firefox": "node build.cjs --target=firefox", "build:chrome": "node build.cjs --target=chrome", + "build:safari": "node build.cjs --target=safari", "build:types": "tsc --noEmit", "build:prod": "rm -f extension/.dev && NODE_ENV=production node build.cjs", "build:prod:firefox": "rm -f extension/.dev && NODE_ENV=production node build.cjs --target=firefox", "build:prod:chrome": "rm -f extension/.dev && NODE_ENV=production node build.cjs --target=chrome", + "build:prod:safari": "rm -f extension/.dev && NODE_ENV=production node build.cjs --target=safari", "watch": "node build.cjs --watch", "watch:chrome": "node build.cjs --target=chrome --watch", "lint": "eslint extension/src", @@ -27,6 +29,7 @@ "dev:stable": "npm run build:firefox && web-ext run --source-dir=extension --firefox=firefox --start-url='https://chat.openai.com'", "package": "npm run build:prod:firefox && web-ext build --source-dir=extension --artifacts-dir=web-ext-artifacts", "package:chrome": "npm run build:prod:chrome && cd extension && zip -r ../web-ext-artifacts/lightsession-chrome.zip . -x '*.ts' -x 'src/*' -x 'manifest.*.json'", + "package:safari": "npm run build:prod:safari && node package-safari.cjs", "clean": "rm -rf extension/dist extension/popup/popup.js web-ext-artifacts" }, "keywords": [ diff --git a/tests/unit/manifest.test.ts b/tests/unit/manifest.test.ts index 7f2bc0c..8c79e60 100644 --- a/tests/unit/manifest.test.ts +++ b/tests/unit/manifest.test.ts @@ -4,6 +4,7 @@ * These tests ensure that manifest files are correctly configured for each browser: * - Firefox: requires background.scripts (array), does NOT support service_worker * - Chrome: requires background.service_worker (string), does NOT support scripts + * - Safari: requires background.service_worker (string), does NOT support scripts */ import { describe, it, expect } from 'vitest'; @@ -12,12 +13,18 @@ import * as path from 'path'; // Load manifest files const extensionDir = path.resolve(__dirname, '../../extension'); +const packageJson = JSON.parse( + fs.readFileSync(path.resolve(__dirname, '../../package.json'), 'utf-8') +); const firefoxManifest = JSON.parse( fs.readFileSync(path.join(extensionDir, 'manifest.firefox.json'), 'utf-8') ); const chromeManifest = JSON.parse( fs.readFileSync(path.join(extensionDir, 'manifest.chrome.json'), 'utf-8') ); +const safariManifest = JSON.parse( + fs.readFileSync(path.join(extensionDir, 'manifest.safari.json'), 'utf-8') +); describe('Firefox manifest (manifest.firefox.json)', () => { it('uses manifest_version 3', () => { @@ -108,17 +115,75 @@ describe('Chrome manifest (manifest.chrome.json)', () => { }); }); +describe('Safari manifest (manifest.safari.json)', () => { + it('uses manifest_version 3', () => { + expect(safariManifest.manifest_version).toBe(3); + }); + + it('has background.service_worker string (required for Safari MV3)', () => { + expect(safariManifest.background).toBeDefined(); + expect(safariManifest.background.service_worker).toBeDefined(); + expect(typeof safariManifest.background.service_worker).toBe('string'); + }); + + it('does NOT have background.scripts (Safari MV3 uses service_worker)', () => { + expect(safariManifest.background.scripts).toBeUndefined(); + }); + + it('has browser_specific_settings.safari', () => { + expect(safariManifest.browser_specific_settings).toBeDefined(); + expect(safariManifest.browser_specific_settings.safari).toBeDefined(); + expect(safariManifest.browser_specific_settings.safari.strict_min_version).toBe('15.4'); + }); + + it('does NOT request Chrome-only declarativeContent permission', () => { + expect(safariManifest.permissions).not.toContain('declarativeContent'); + }); + + it('has required permissions', () => { + expect(safariManifest.permissions).toContain('storage'); + expect(safariManifest.permissions).toContain('tabs'); + }); + + it('has host_permissions for ChatGPT domains', () => { + expect(safariManifest.host_permissions).toBeDefined(); + const hosts = safariManifest.host_permissions.join(' '); + expect(hosts).toContain('chat.openai.com'); + expect(hosts).toContain('chatgpt.com'); + }); + + it('has content_scripts configured', () => { + expect(safariManifest.content_scripts).toBeDefined(); + expect(safariManifest.content_scripts.length).toBeGreaterThan(0); + }); + + it('has web_accessible_resources with page-script.js', () => { + expect(safariManifest.web_accessible_resources).toBeDefined(); + const resources = safariManifest.web_accessible_resources[0]?.resources || []; + expect(resources).toContain('dist/page-script.js'); + }); +}); + describe('manifest consistency', () => { - it('both manifests have the same version', () => { + it('all manifests match package.json version', () => { + expect(firefoxManifest.version).toBe(packageJson.version); + expect(chromeManifest.version).toBe(packageJson.version); + expect(safariManifest.version).toBe(packageJson.version); + }); + + it('all manifests have the same version', () => { expect(firefoxManifest.version).toBe(chromeManifest.version); + expect(safariManifest.version).toBe(firefoxManifest.version); }); - it('both manifests have the same name', () => { + it('all manifests have the same name', () => { expect(firefoxManifest.name).toBe(chromeManifest.name); + expect(safariManifest.name).toBe(firefoxManifest.name); }); - it('both manifests have the same description', () => { + it('all manifests have the same description', () => { expect(firefoxManifest.description).toBe(chromeManifest.description); + expect(safariManifest.description).toBe(firefoxManifest.description); }); it('chrome permissions include firefox permissions (plus chrome-only)', () => { @@ -133,35 +198,46 @@ describe('manifest consistency', () => { expect(extraChromePerms.sort()).toEqual(['declarativeContent']); }); - it('both manifests have the same host_permissions', () => { - expect(firefoxManifest.host_permissions.sort()).toEqual( - chromeManifest.host_permissions.sort() + it('safari permissions match firefox permissions', () => { + expect([...safariManifest.permissions].sort()).toEqual([...firefoxManifest.permissions].sort()); + }); + + it('all manifests have the same host_permissions', () => { + expect([...firefoxManifest.host_permissions].sort()).toEqual( + [...chromeManifest.host_permissions].sort() + ); + expect([...safariManifest.host_permissions].sort()).toEqual( + [...firefoxManifest.host_permissions].sort() ); }); - it('both manifests target the same background script', () => { + it('all manifests target the same background script', () => { const firefoxBg = firefoxManifest.background.scripts[0]; const chromeBg = chromeManifest.background.service_worker; + const safariBg = safariManifest.background.service_worker; expect(firefoxBg).toBe(chromeBg); + expect(safariBg).toBe(firefoxBg); }); - it('both manifests have the same content scripts', () => { - expect(firefoxManifest.content_scripts.length).toBe( - chromeManifest.content_scripts.length - ); + it('all manifests have the same content scripts', () => { + expect(firefoxManifest.content_scripts.length).toBe(chromeManifest.content_scripts.length); + expect(safariManifest.content_scripts.length).toBe(firefoxManifest.content_scripts.length); for (let i = 0; i < firefoxManifest.content_scripts.length; i++) { - expect(firefoxManifest.content_scripts[i].js).toEqual( - chromeManifest.content_scripts[i].js - ); + expect(firefoxManifest.content_scripts[i].js).toEqual(chromeManifest.content_scripts[i].js); + expect(safariManifest.content_scripts[i].js).toEqual(firefoxManifest.content_scripts[i].js); expect(firefoxManifest.content_scripts[i].run_at).toBe( chromeManifest.content_scripts[i].run_at ); + expect(safariManifest.content_scripts[i].run_at).toBe( + firefoxManifest.content_scripts[i].run_at + ); } }); - it('both manifests have the same icons', () => { + it('all manifests have the same icons', () => { expect(firefoxManifest.icons).toEqual(chromeManifest.icons); + expect(safariManifest.icons).toEqual(firefoxManifest.icons); }); }); @@ -175,6 +251,10 @@ describe('background script configuration details', () => { expect(chromeManifest.background.service_worker).toBe('dist/background.js'); }); + it('Safari background.service_worker points to background.js', () => { + expect(safariManifest.background.service_worker).toBe('dist/background.js'); + }); + it('Firefox does NOT have preferred_environment (removed for compatibility)', () => { expect(firefoxManifest.background.preferred_environment).toBeUndefined(); });