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