diff --git a/assets/trayTemplate.png b/assets/trayTemplate.png new file mode 100644 index 00000000..b3009bf4 Binary files /dev/null and b/assets/trayTemplate.png differ diff --git a/assets/trayTemplate.svg b/assets/trayTemplate.svg new file mode 100644 index 00000000..0fe511d2 --- /dev/null +++ b/assets/trayTemplate.svg @@ -0,0 +1,12 @@ + + + diff --git a/assets/trayTemplate@2x.png b/assets/trayTemplate@2x.png new file mode 100644 index 00000000..4afda357 Binary files /dev/null and b/assets/trayTemplate@2x.png differ diff --git a/backend/package-lock.json b/backend/package-lock.json index f18904a9..3ed9fcbd 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@fastify/cors": "^11.0.1", "@fastify/rate-limit": "^10.3.0", + "@fastify/websocket": "^11.3.0", "async-mutex": "^0.5.0", "chokidar": "^4.0.3", "dotenv": "^16.5.0", @@ -1162,6 +1163,43 @@ "toad-cache": "^3.7.0" } }, + "node_modules/@fastify/websocket": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.3.0.tgz", + "integrity": "sha512-g89ag4BCcD9YP5wBZXixzoLnuf5j89p/sXFcfpCiv2pdEkYYukBEoK3heVzqsp0EAtszVDc2BBZG0KZqeAShIA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "duplexify": "^4.1.3", + "fastify-plugin": "^6.0.0", + "ws": "^8.16.0" + } + }, + "node_modules/@fastify/websocket/node_modules/fastify-plugin": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz", + "integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", @@ -3015,6 +3053,18 @@ "node": ">= 0.4" } }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.344", "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", @@ -3042,6 +3092,15 @@ "dev": true, "license": "MIT" }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz", @@ -3790,7 +3849,6 @@ "version": "2.0.4", "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/ipaddr.js": { @@ -5005,7 +5063,6 @@ "version": "1.4.0", "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -5360,6 +5417,20 @@ "dev": true, "license": "MIT" }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-4.1.2.tgz", @@ -5491,6 +5562,26 @@ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "license": "MIT" }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-regex2": { "version": "5.1.1", "resolved": "https://registry.npmmirror.com/safe-regex2/-/safe-regex2-5.1.1.tgz", @@ -5718,6 +5809,21 @@ "node": ">=10" } }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmmirror.com/string-length/-/string-length-4.0.2.tgz", @@ -6110,6 +6216,12 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/uuid": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", @@ -6218,7 +6330,6 @@ "version": "1.0.2", "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/write-file-atomic": { @@ -6235,6 +6346,27 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/ws": { + "version": "8.21.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.2.tgz", + "integrity": "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz", diff --git a/backend/package.json b/backend/package.json index 7153ff7f..6074ed19 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,6 +15,7 @@ "dependencies": { "@fastify/cors": "^11.0.1", "@fastify/rate-limit": "^10.3.0", + "@fastify/websocket": "^11.3.0", "async-mutex": "^0.5.0", "chokidar": "^4.0.3", "dotenv": "^16.5.0", diff --git a/backend/src/api/realtime-websocket.ts b/backend/src/api/realtime-websocket.ts new file mode 100644 index 00000000..3cd3f95b --- /dev/null +++ b/backend/src/api/realtime-websocket.ts @@ -0,0 +1,74 @@ +import websocket from '@fastify/websocket'; +import type { FastifyInstance } from 'fastify'; +import { extractRequestToken, validateRequestToken } from '../utils/auth.js'; + +interface RealtimeSocket { + readyState: number; + send(data: string): void; + on(event: 'message', listener: (data: unknown) => void): void; + on(event: 'close' | 'error', listener: () => void): void; +} + +const OPEN_STATE = 1; +const clients = new Set(); + +export interface FileChangeMessage { + type: 'file_change'; + event: string; + path: string; + timestamp: number; +} + +export function broadcastFileChange(event: string, filePath: string): void { + const message: FileChangeMessage = { + type: 'file_change', + event, + path: filePath, + timestamp: Date.now(), + }; + const payload = JSON.stringify(message); + + for (const client of clients) { + if (client.readyState === OPEN_STATE) { + client.send(payload); + } + } +} + +export async function registerRealtimeWebSocket(app: FastifyInstance): Promise { + await app.register(websocket); + + app.get<{ Querystring: { access_token?: string } }>('/ws', { + websocket: true, + preValidation: async (request, reply) => { + const token = extractRequestToken( + request.headers['x-papyrus-token'], + request.query.access_token, + ); + if (!validateRequestToken(token)) { + await reply.status(401).send({ success: false, error: 'Unauthorized' }); + } + }, + }, (socket) => { + clients.add(socket); + + socket.on('message', (data: unknown) => { + try { + const message = JSON.parse(String(data)) as { type?: unknown }; + if (message.type === 'ping' && socket.readyState === OPEN_STATE) { + socket.send(JSON.stringify({ type: 'pong', timestamp: Date.now() })); + } + } catch { + // Ignore malformed client messages; the channel only accepts optional heartbeat pings. + } + }); + + const removeClient = () => clients.delete(socket); + socket.on('close', removeClient); + socket.on('error', removeClient); + }); +} + +export function clearRealtimeClients(): void { + clients.clear(); +} diff --git a/backend/src/api/server.ts b/backend/src/api/server.ts index 43ac71df..4efd4429 100644 --- a/backend/src/api/server.ts +++ b/backend/src/api/server.ts @@ -17,6 +17,10 @@ import { validateRequestToken, } from '../utils/auth.js'; import { closeDb } from '../db/database.js'; +import { + broadcastFileChange, + registerRealtimeWebSocket, +} from './realtime-websocket.js'; const logger = new PapyrusLogger( paths.logDir, @@ -139,6 +143,8 @@ export async function initApp(): Promise { timeWindow: '1 minute', }); + await registerRealtimeWebSocket(app); + // Local API protection: require token on all /api routes except /api/health. if (isAuthEnabled()) { app.addHook('onRequest', async (request, reply) => { @@ -227,6 +233,7 @@ export async function start(): Promise { startFileWatching((eventType, filePath) => { logger.info(`文件${eventType}: ${filePath}`); + broadcastFileChange(eventType, filePath); }); const { getAutomationScheduler } = await import('../core/automation-scheduler.js'); getAutomationScheduler().start(); diff --git a/backend/tests/integration/realtime-websocket.test.ts b/backend/tests/integration/realtime-websocket.test.ts new file mode 100644 index 00000000..30429d9b --- /dev/null +++ b/backend/tests/integration/realtime-websocket.test.ts @@ -0,0 +1,76 @@ +import type { FastifyInstance } from 'fastify'; +import Fastify from 'fastify'; +import { + broadcastFileChange, + clearRealtimeClients, + registerRealtimeWebSocket, +} from '../../src/api/realtime-websocket.js'; +import { TEST_AUTH_TOKEN } from '../test-auth.js'; + +function nextMessage(socket: { once: (event: string, listener: (data: unknown) => void) => void }): Promise { + return new Promise((resolve) => { + socket.once('message', (data) => resolve(String(data))); + }); +} + +interface TestSocket { + readyState: number; + once(event: string, listener: (data: unknown) => void): void; + send(data: string): void; + terminate(): void; +} + +async function closeSocket(socket: TestSocket): Promise { + if (socket.readyState >= 2) return; + await new Promise((resolve) => { + socket.once('close', () => resolve()); + socket.terminate(); + }); +} + +describe('Realtime WebSocket', () => { + let app: FastifyInstance; + let sockets: TestSocket[]; + + beforeEach(async () => { + process.env.PAPYRUS_AUTH_TOKEN = TEST_AUTH_TOKEN; + app = Fastify(); + sockets = []; + await registerRealtimeWebSocket(app); + await app.ready(); + }); + + afterEach(async () => { + await Promise.all(sockets.map(closeSocket)); + clearRealtimeClients(); + await app.close(); + }); + + it('rejects a connection without the local API token', async () => { + await expect(app.injectWS('/ws')).rejects.toThrow('Unexpected server response: 401'); + }); + + it('accepts authentication from packaged query URLs', async () => { + const socket = await app.injectWS(`/ws?access_token=${encodeURIComponent(TEST_AUTH_TOKEN)}`); + sockets.push(socket); + }); + + it('accepts proxy authentication, heartbeats, and file change broadcasts', async () => { + const socket = await app.injectWS('/ws', { + headers: { 'x-papyrus-token': TEST_AUTH_TOKEN }, + }); + sockets.push(socket); + + const pongPromise = nextMessage(socket); + socket.send(JSON.stringify({ type: 'ping' })); + expect(JSON.parse(await pongPromise)).toMatchObject({ type: 'pong' }); + + const changePromise = nextMessage(socket); + broadcastFileChange('modified', '/tmp/papyrus.sqlite'); + expect(JSON.parse(await changePromise)).toMatchObject({ + type: 'file_change', + event: 'modified', + path: '/tmp/papyrus.sqlite', + }); + }); +}); diff --git a/electron/main.js b/electron/main.js index 08a8ed5d..a39677ef 100644 --- a/electron/main.js +++ b/electron/main.js @@ -438,13 +438,14 @@ function createWindow() { }); // Set Content Security Policy to mitigate XSS risks - // unsafe-inline is needed for React/CSS-in-JS; connect-src allows AI API calls + // Vite injects an inline React Fast Refresh preamble in development. Production remains strict. + const scriptSrc = isDevMode ? "'self' 'unsafe-inline'" : "'self'"; mainWindow.webContents.session.webRequest.onHeadersReceived((details, callback) => { callback({ responseHeaders: { ...details.responseHeaders, 'Content-Security-Policy': [ - "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' http://127.0.0.1:* http://localhost:*; img-src 'self' file: data: http://127.0.0.1:* http://localhost:* blob:; media-src 'self' http://127.0.0.1:* http://localhost:* blob:; font-src 'self' data:; frame-src 'self';", + `default-src 'self'; script-src ${scriptSrc}; style-src 'self' 'unsafe-inline'; connect-src 'self' http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:*; img-src 'self' file: data: http://127.0.0.1:* http://localhost:* blob:; media-src 'self' http://127.0.0.1:* http://localhost:* blob:; font-src 'self' data:; frame-src 'self';`, ], }, }); @@ -511,7 +512,6 @@ function createTray() { try { let trayImage = nativeImage.createFromPath(paths.trayIconPath); if (process.platform === 'darwin') { - trayImage = trayImage.resize({ width: 18, height: 18 }); trayImage.setTemplateImage(true); } tray = new Tray(trayImage); diff --git a/electron/platform-config.js b/electron/platform-config.js index a3248e86..9ea281cb 100644 --- a/electron/platform-config.js +++ b/electron/platform-config.js @@ -24,6 +24,9 @@ function getWindowIconName(platform) { * 未复用窗口图标选择:直接把 `.icns` 交给 Tray 会在浅色/深色菜单栏中产生不一致的缩放与着色。 */ function getTrayIconName(platform) { + if (platform === 'darwin') { + return 'trayTemplate.png'; + } return platform === 'win32' ? 'icon.ico' : 'icon.png'; } diff --git a/frontend/index.html b/frontend/index.html index 7a42c7a8..4e248afd 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,7 +3,7 @@ - + Papyrus Desktop