feat(runtime): 執行中切換收發與通知
Docs/ChangeHistory.mdDocs/RUNTIME.mdDocs/SOCKET_TRANSPORT.mdDocs/TEST_FILES_REFERENCE.mdRuntimeRoutes.cjsScriptLib/notify/index.mjsScriptLib/runtime/state.cjsScriptLib/socket/endpoint.mjsScriptLib/socket/hub.mjsScriptLib/socket/transport.mjsServer.jsTest/notify.test.mjsTest/runtime_state.test.cjsTest/runtime_ui.cjsTest/socket_hub.test.mjsTest/socket_transport.test.mjsTikTok.jsassets/app.css.build.jsonassets/runtime-start.jslang/en.jsonlang/zh-TW.jsonruntime.html
commit 112269c938c20c6024b6967488ddde8099198c84Author: Nuclear0709 <agp05432@gmail.com>Date: Fri Nov 13 14:52:03 2026 +0800 feat(runtime): 執行中切換收發與通知 新增 POST /api/runtime/transports 與 IPC SET_TRANSPORTS;Socket、Bark、ntfy 可執行中個別開關,不必重啟子程序。Socket 停用時保留端點設定並清空待送佇列,重新開啟會重新連線;Bark/ntfy 即使啟動時未勾選仍保留通道,未設定 URL 的通道由 runtime 頁停用。monitor 鏡像與本場統計不受 Socket 開關影響。 diff --git a/Docs/ChangeHistory.md b/Docs/ChangeHistory.md--- a/Docs/ChangeHistory.md+++ b/Docs/ChangeHistory.md@@ -1,5 +1,31 @@ # ChangeHistory +## 2026.11.13 14:10 執行中切換收發與通知++**類型**: 功能 · **檔案**: `TikTok.js`、`ScriptLib/notify/index.mjs`、`ScriptLib/socket/transport.mjs`、`ScriptLib/socket/endpoint.mjs`、`ScriptLib/socket/hub.mjs`、`ScriptLib/runtime/state.cjs`、`RuntimeRoutes.cjs`、`Server.js`、`runtime.html`、`assets/runtime-start.js`、`lang/*.json`、`Test/notify.test.mjs`、`Test/socket_hub.test.mjs`、`Test/socket_transport.test.mjs`、`Test/runtime_state.test.cjs`、`Test/runtime_ui.cjs`、`Docs/RUNTIME.md`、`Docs/SOCKET_TRANSPORT.md`++### 修改++- 新增 `POST /api/runtime/transports` 與 IPC `SET_TRANSPORTS`;執行中可個別開關 Socket、Bark、ntfy。+- Socket 即使啟動時停用仍保留端點設定,可透過 `setEnabled` 重新連線;關閉會斷線並清空待送佇列。+- Bark/ntfy 即使啟動時未勾選仍保留通道,可動態啟用;未設定 URL 的通道不會被誤開。+- monitor 的即時鏡像與本場統計不受 Socket 開關影響。+- runtime 頁新增「收發與通知」卡片、狀態與開關按鈕;父程序保存 `transports` 狀態。++### 限制++- 只切換既有 `SOCKET_API`/`SOCKET_APIS`、`BARK_API`、`NTFY_API` 設定,不在執行中修改 URL。+- Socket 關閉期間的訊息不補送;重新開啟只送之後的訊息。++### 驗證++- `node --test Test/notify.test.mjs Test/socket_hub.test.mjs Test/socket_transport.test.mjs Test/runtime_state.test.cjs`+- `node Test/runtime_ui.cjs`+- 子程序 IPC smoke test:`TRANSPORT_STATE` 可反映 Bark/ntfy 開關+- `npm run lint`、`npm run check:css`++---+ ## 2026.11.13 12:40 執行中加開平台 **類型**: 功能 · **檔案**: `TikTok.js`、`ScriptLib/runtime/platform-manager.mjs`、`ScriptLib/runtime/state.cjs`、`ScriptLib/lifecycle/autoStop.mjs`、`RuntimeRoutes.cjs`、`Server.js`、`runtime.html`、`assets/runtime-start.js`、`lang/*.json`、`Test/runtime_state.test.cjs`、`Test/runtime_ui.cjs`、`Test/platform_manager.test.mjs`、`Test/auto_stop.test.mjs`、`Docs/RUNTIME.md`、`Docs/TEST_FILES_REFERENCE.md`diff --git a/Docs/RUNTIME.md b/Docs/RUNTIME.md--- a/Docs/RUNTIME.md+++ b/Docs/RUNTIME.md@@ -15,7 +15,7 @@ /runtime 與 /api/runtime 沿用原啟停/日誌端點的公開存取範圍,只回傳程序摘要,不提供環境變數、完整啟動 query 或日誌。 原 /status 純文字及 /status/stream 日誌介面保持相容;需要純文字的外部工具可改查 /status。 -驗證:node --test Test/runtime_state.test.cjs Test/platform_manager.test.mjs Test/auto_stop.test.mjs;測試使用假程序,不啟動直播或通知。`node Test/runtime_ui.cjs` 使用 mock API 檢查來源卡、加開平台、toast、390px 寬度並輸出 `output/playwright/runtime-desktop.png`、`runtime-mobile.png`(需 Chrome/Edge,不在 `test:ci`)。+驗證:node --test Test/runtime_state.test.cjs Test/platform_manager.test.mjs Test/auto_stop.test.mjs;測試使用假程序,不啟動直播或通知。`node Test/runtime_ui.cjs` 使用 mock API 檢查來源卡、收發開關、加開平台、toast、390px 寬度並輸出 `output/playwright/runtime-desktop.png`、`runtime-mobile.png`(需 Chrome/Edge,不在 `test:ci`)。 ## 可視化啟動 @@ -34,6 +34,15 @@ - 目前只支援「加開」,不支援執行中移除單一平台;需要移除時仍停止整個子程序。 - 加開 Twitch 時 AutoClip 不會在事後補建;AutoClip 仍以啟動時是否已啟用 Twitch 為準。 +### 執行中切換收發與通知++程序執行中可在「收發與通知」卡片個別開關 Socket、Bark、ntfy,不需要重啟子程序。`POST /api/runtime/transports` 接受 `{ "socket": true, "bark": false, "ntfy": true }` 的部分組合,主服務以 IPC `SET_TRANSPORTS` 通知子程序,子程序回報 `TRANSPORT_STATE`。++- Socket 端點由 `SOCKET_API`/`SOCKET_APIS` 設定;即使啟動時沒有勾選 Socket,端點仍會建立但保持停用,之後可動態連線。+- Bark/ntfy 由 `BARK_API`/`NTFY_API` 設定;啟動時未勾選的通道仍保留,可之後動態開啟。未設定 URL 的通道按鈕會停用。+- 關閉 Socket 會斷線並清空待送佇列;重新開啟會重新建立連線。關閉通知通道只停止該通道的推播,不影響另一通道。+- Monitor 的即時鏡像與本場統計不屬於 Socket 輸出,切換 Socket 不會停止 monitor 更新。+ Socket 使用既有 SOCKET_API 設定(可再用 SOCKET_APIS 加開多個端點,每端點可用 `?filter=類別` 只收指定分類);Bark 與 ntfy 使用既有通知設定(BARK_API/NTFY_API)。「開啟設定」只導向設定頁。程序啟動成功不代表各平台已完成登入。 ## 直播訊息監控面板diff --git a/Docs/SOCKET_TRANSPORT.md b/Docs/SOCKET_TRANSPORT.md--- a/Docs/SOCKET_TRANSPORT.md+++ b/Docs/SOCKET_TRANSPORT.md@@ -8,7 +8,7 @@ 測試派發可分別關閉 socket/通知;`Notifier.notify` 回傳 `[{ channel, ok }]`,子程序再彙整成 `sent`/`partial`/`failed`/`nochannels`。一般聊天仍同時走 socket 與通知,不受測試選項影響。 -所有平台的 StreamMessage 會附上 `session: { joins, likes, chats, follows, subscriptions, gifts, peakViewers }`,供 monitor 顯示本場累計;一般 socket 端點可忽略此欄位。`peakViewers` 是合併觀眾數的場次峰值,不是目前人數。點讚通知以 3 秒窗口合併,monitor 的逐筆點讚列不受影響。+所有平台的 StreamMessage 會附上 `session: { joins, likes, chats, follows, subscriptions, gifts, peakViewers }`,供 monitor 顯示本場累計;一般 socket 端點可忽略此欄位。`peakViewers` 是合併觀眾數的場次峰值,不是目前人數。點讚通知以 3 秒窗口合併,monitor 的逐筆點讚列不受影響。Socket 端點可在程式執行中透過 runtime 頁開關;關閉時保留端點設定但停止連線與送出。 通訊格式維持 UTF-8 JSON,每筆以換行結尾,不需修改既有接收端協定。 ## 預設限制diff --git a/Docs/TEST_FILES_REFERENCE.md b/Docs/TEST_FILES_REFERENCE.md--- a/Docs/TEST_FILES_REFERENCE.md+++ b/Docs/TEST_FILES_REFERENCE.md@@ -55,8 +55,8 @@ node Test/xxx.mjs | 檔案 | 測試內容 | | ------ | --------- | | `Test/config_login_ui.cjs` | 設定/登入頁深色主題、安全閘門、24 個欄位、桌面側欄/手機導覽、390px 無溢出、未儲存狀態、登入錯誤與成功;輸出 `output/playwright/config-desktop.png`、`config-mobile.png`、`login-desktop.png` |-| `Test/runtime_ui.cjs` | 運行狀態頁的狀態卡、聊天來源 badge、manual/browser 顯示、執行中加開平台、重新檢查/停止 toast、390px 無溢出;輸出 `runtime-desktop.png`、`runtime-mobile.png` |-| `Test/runtime_state.test.cjs` | 程序生命週期、停止逾時、啟動白名單、執行中加開平台 API 與 runtime 狀態 |+| `Test/runtime_ui.cjs` | 運行狀態頁的狀態卡、聊天來源 badge、manual/browser 顯示、執行中收發開關/加開平台、重新檢查/停止 toast、390px 無溢出;輸出 `runtime-desktop.png`、`runtime-mobile.png` |+| `Test/runtime_state.test.cjs` | 程序生命週期、停止逾時、啟動白名單、執行中加開平台與收發 API/runtime 狀態 | | `Test/platform_manager.test.mjs` | 平台啟動冪等、失敗重試與狀態回報 | | `Test/auto_stop.test.mjs` | 自動停止倒數、blocked 平台與執行中加開平台 | | `Test/audience_tracker.test.cjs` | 常客分數、等級、上限、userId 優先、關係篩選、手動合併預覽與別名 |diff --git a/RuntimeRoutes.cjs b/RuntimeRoutes.cjs--- a/RuntimeRoutes.cjs+++ b/RuntimeRoutes.cjs@@ -25,10 +25,32 @@ function sendRuntimePage(res) { res.end(error ? '無法載入運行狀態頁' : html); }); }-function serveRuntime(req, res, runtime, start, addPlatform = null) {+function serveRuntime(req, res, runtime, start, addPlatform = null, setTransports = null) { const pathname = req.url.split('?')[0]; if (!['/runtime', '/api/runtime', '/api/runtime/options', '/api/runtime/start',- '/api/runtime/platforms'].includes(pathname)) return false;+ '/api/runtime/platforms', '/api/runtime/transports'].includes(pathname)) return false;+ if (pathname === '/api/runtime/transports') {+ if (req.method !== 'POST') { res.writeHead(405); res.end(); return true; }+ const origin = (req.socket?.encrypted ? 'https://' : 'http://') + req.headers.host;+ if (req.headers.origin !== origin) { sendJson(res, 403, { code: 'ORIGIN_DENIED', error: '請由主服務管理頁操作' }); return true; }+ void (async () => {+ try {+ const body = await readJson(req, 4096);+ if (!body || typeof body !== 'object' || Array.isArray(body)) {+ sendJson(res, 400, { code: 'INVALID_TRANSPORTS', error: '收發參數無效' });+ return;+ }+ if (!setTransports) {+ sendJson(res, 409, { code: 'RUNTIME_UNAVAILABLE', error: '目前沒有可控制的子程序' });+ return;+ }+ sendJson(res, 202, setTransports(body));+ } catch (error) {+ sendJson(res, error.status || 400, { code: error.code || 'TRANSPORT_UPDATE_FAILED', error: error.message });+ }+ })();+ return true;+ } if (pathname === '/api/runtime/platforms') { if (req.method !== 'POST') { res.writeHead(405); res.end(); return true; } const origin = (req.socket?.encrypted ? 'https://' : 'http://') + req.headers.host;diff --git a/ScriptLib/notify/index.mjs b/ScriptLib/notify/index.mjs--- a/ScriptLib/notify/index.mjs+++ b/ScriptLib/notify/index.mjs@@ -4,15 +4,37 @@ import { NtfyChannel, parseNtfyTarget } from './channels/ntfy.mjs'; export { BarkChannel, NtfyChannel, parseNtfyTarget }; export * from './format.mjs'; -/** 推播器:把一則通知扇出到所有已啟用通道。 */+/** 推播器:把一則通知扇出到所有已啟用通道;通道可執行中個別開關。 */ export class Notifier { constructor(channels = []) {+ // 保留已設定的通道;active 才是執行中開關,enabled 仍代表 URL 有效。 this.channels = channels.filter(channel => channel && channel.enabled);+ for (const channel of this.channels) {+ if (typeof channel.active !== 'boolean') channel.active = true;+ } } /** 目前實際會發送的通道名稱。 */ get enabled() {- return this.channels.map(channel => channel.name);+ return this.channels.filter(channel => channel.active && channel.enabled).map(channel => channel.name);+ }++ /**+ * 執行中開關單一通道;沒有該通道或 URL 無效時仍安全。+ * @param {string} name+ * @param {boolean} enabled+ * @returns {string[]} 套用後實際啟用的通道。+ */+ setEnabled(name, enabled) {+ const channel = this.channels.find(item => item.name === name);+ if (channel) channel.active = Boolean(enabled);+ return this.enabled;+ }++ /** 查詢單一通道是否正在運作(含 URL 有效性)。 */+ isEnabled(name) {+ const channel = this.channels.find(item => item.name === name);+ return Boolean(channel && channel.active && channel.enabled); } /**@@ -24,8 +46,9 @@ export class Notifier { * @returns {Promise<Array<{channel: string, ok: boolean}>>} 各通道結果;未啟用通道不列入。 */ async notify(title = 'Twitch', comment, icon, url) {- const settled = await Promise.allSettled(this.channels.map(channel => channel.send({ title, comment, icon, link: url })));- return this.channels.map((channel, index) => ({+ const active = this.channels.filter(channel => channel.active && channel.enabled);+ const settled = await Promise.allSettled(active.map(channel => channel.send({ title, comment, icon, link: url })));+ return active.map((channel, index) => ({ channel: channel.name, ok: settled[index].status === 'fulfilled' && settled[index].value === true }));@@ -40,8 +63,10 @@ export class Notifier { */ export function createNotifier({ isBark = false, isNtfy = false, barkApi, ntfyApi, getEmojiMap = () => ({}), axios, logger = console } = {}) {- const channels = [];- if (isBark) channels.push(new BarkChannel({ url: barkApi, getEmojiMap, axios, logger }));- if (isNtfy) channels.push(new NtfyChannel({ url: ntfyApi, getEmojiMap, axios, logger }));- return new Notifier(channels);+ // 即使啟動時關閉,也要保留通道物件,之後才能用 setEnabled 動態開啟。+ const bark = new BarkChannel({ url: barkApi, getEmojiMap, axios, logger });+ const ntfy = new NtfyChannel({ url: ntfyApi, getEmojiMap, axios, logger });+ bark.active = Boolean(isBark);+ ntfy.active = Boolean(isNtfy);+ return new Notifier([bark, ntfy]); }diff --git a/ScriptLib/runtime/state.cjs b/ScriptLib/runtime/state.cjs--- a/ScriptLib/runtime/state.cjs+++ b/ScriptLib/runtime/state.cjs@@ -8,7 +8,8 @@ class RuntimeState { this.child = null; this.data = { state: 'stopped', pid: null, startedAt: null, stoppedAt: null, stopRequestedAt: null,- exitCode: null, signal: null, error: null, platforms: [], platformStates: {}+ exitCode: null, signal: null, error: null, platforms: [], platformStates: {},+ transports: { socket: false, bark: false, ntfy: false } }; } _log(...parts) {@@ -31,7 +32,8 @@ class RuntimeState { this.data = { state: 'starting', pid: null, startedAt: null, stoppedAt: null, stopRequestedAt: null, exitCode: null, signal: null, error: null, platforms: [...platforms],- platformStates: Object.fromEntries(platforms.map(platform => [platform, 'starting']))+ platformStates: Object.fromEntries(platforms.map(platform => [platform, 'starting'])),+ transports: { socket: false, bark: false, ntfy: false } }; child.once('spawn', () => { if (this.child !== child) return;@@ -55,6 +57,24 @@ class RuntimeState { this.child = null; }); }+ /** 更新收發/通知狀態;只接受物件,保留既有欄位。 */+ setTransports(next = {}) {+ if (!next || typeof next !== 'object' || Array.isArray(next)) return this.data.transports;+ this.data.transports = { ...(this.data.transports || {}), ...next };+ for (const key of ['socket', 'bark', 'ntfy']) {+ if (typeof this.data.transports[key] !== 'boolean') this.data.transports[key] = false;+ }+ if (this.data.features) {+ this.data.features = {+ ...this.data.features,+ isSocket: this.data.transports.socket,+ isBark: this.data.transports.bark,+ isNtfy: this.data.transports.ntfy+ };+ }+ return this.data.transports;+ }+ /** 執行中加開平台;重複平台不重複加入。 */ addPlatform(platform) { const name = String(platform || '').trim();@@ -146,7 +166,7 @@ class RuntimeState { * @returns {Object} */ snapshot() {- return { ...this.data, resources: this.child && ['running', 'stopping'].includes(this.data.state) && this.resources && this.now() - this.resources.sampledAt <= 10000 ? { ...this.resources } : null, platforms: [...this.data.platforms], platformStates: { ...(this.data.platformStates || {}) }, platformErrors: { ...(this.data.platformErrors || {}) },+ return { ...this.data, resources: this.child && ['running', 'stopping'].includes(this.data.state) && this.resources && this.now() - this.resources.sampledAt <= 10000 ? { ...this.resources } : null, platforms: [...this.data.platforms], platformStates: { ...(this.data.platformStates || {}) }, platformErrors: { ...(this.data.platformErrors || {}) }, transports: { ...(this.data.transports || {}) }, uptimeMs: this.data.startedAt === null ? 0 : Math.max(0, (this.data.stoppedAt ?? this.now()) - this.data.startedAt), stopDelayed: this.data.state === 'stopping' && this.now() - this.data.stopRequestedAt >= 15000 }; }diff --git a/ScriptLib/socket/endpoint.mjs b/ScriptLib/socket/endpoint.mjs--- a/ScriptLib/socket/endpoint.mjs+++ b/ScriptLib/socket/endpoint.mjs@@ -54,6 +54,13 @@ export class SocketEndpoint { /** 建立連線。 */ connect() { this.transport.connect(); } + /**+ * 執行中開關此端點。+ * @param {boolean} enabled+ * @returns {void}+ */+ setEnabled(enabled) { this.transport.setEnabled(enabled); }+ /** * @param {Object} [finalPayload] 關閉前送出的最後一則訊息。 * @param {number} [timeoutMs]diff --git a/ScriptLib/socket/hub.mjs b/ScriptLib/socket/hub.mjs--- a/ScriptLib/socket/hub.mjs+++ b/ScriptLib/socket/hub.mjs@@ -8,7 +8,8 @@ import { MessageDedup } from './dedup.mjs'; export class SocketHub { constructor({ endpoints = [], dedup = new MessageDedup(), replaceEmojis = (value) => value, onSend, mediaCache = null, logger = console } = {}) {- this.endpoints = endpoints.filter(endpoint => endpoint && endpoint.enabled);+ // 保留停用端點,才能執行中重新開啟;send/connect 會自行檢查 enabled。+ this.endpoints = endpoints.filter(endpoint => endpoint); this.dedup = dedup; this.replaceEmojis = replaceEmojis; this.onSend = onSend;@@ -22,12 +23,23 @@ export class SocketHub { try { return this.mediaCache.localUrl(value) || value; } catch { return value; } } - /** 主要(第一個)端點,供既有診斷讀取連線狀態。 */- get primary() { return this.endpoints[0] || null; }- get enabled() { return this.endpoints.length > 0; }+ /** 主要(第一個啟用)端點,供既有診斷讀取連線狀態。 */+ get primary() { return this.endpoints.find(endpoint => endpoint.enabled) || this.endpoints[0] || null; }+ get enabled() { return this.endpoints.some(endpoint => endpoint.enabled); } - /** 建立所有端點連線。 */- connect() { for (const endpoint of this.endpoints) endpoint.connect(); }+ /**+ * 執行中開關所有 socket 端點。+ * @param {boolean} enabled+ * @returns {boolean} 套用後是否有啟用端點。+ */+ setEnabled(enabled) {+ for (const endpoint of this.endpoints) endpoint.setEnabled(enabled);+ if (enabled) this.connect();+ return this.enabled;+ }++ /** 建立所有已啟用端點連線。 */+ connect() { for (const endpoint of this.endpoints) if (endpoint.enabled) endpoint.connect(); } /** * 關閉所有端點(受各端點的 timeoutMs 保護)。@@ -45,7 +57,7 @@ export class SocketHub { */ send(payload) { for (const endpoint of this.endpoints) {- if (endpoint.accepts(payload.category)) endpoint.send(payload);+ if (endpoint.enabled && endpoint.accepts(payload.category)) endpoint.send(payload); } this.onSend?.(payload); }@@ -169,11 +181,10 @@ export function createHub({ socketApi, apis = [], enabled = true, onConnect, onS } const endpoints = [];- if (enabled) {- for (const [url, filters] of byUrl) {- const filter = filters.has(null) ? undefined : [...filters];- endpoints.push(new SocketEndpoint({ url, enabled, filter, onConnect, logger }));- }+ for (const [url, filters] of byUrl) {+ const filter = filters.has(null) ? undefined : [...filters];+ // 即使 enabled=false 也建立端點,之後才能透過 setEnabled 動態連線。+ endpoints.push(new SocketEndpoint({ url, enabled, filter, onConnect, logger })); } return new SocketHub({ endpoints, dedup, replaceEmojis, onSend, mediaCache, logger }); }diff --git a/ScriptLib/socket/transport.mjs b/ScriptLib/socket/transport.mjs--- a/ScriptLib/socket/transport.mjs+++ b/ScriptLib/socket/transport.mjs@@ -134,6 +134,33 @@ export class SocketTransport { * 建立連線;未啟用、已停止或已有連線時不做事。 * @returns {void} */+ /**+ * 執行中開關傳輸;關閉時清掉佇列並斷線,開啟時重新連線。+ * @param {boolean} enabled+ * @returns {void}+ */+ setEnabled(enabled) {+ const next = Boolean(enabled);+ if (this.enabled === next) return;+ this.enabled = next;+ if (!next) {+ clearTimeout(this.retryTimer);+ clearTimeout(this.connectTimer);+ clearTimeout(this.stallTimer);+ this.retryTimer = this.connectTimer = this.stallTimer = null;+ const socket = this.socket;+ this.socket = null;+ this.connected = false;+ this.blocked = false;+ this.queue = [];+ this.bytes = 0;+ if (socket && !socket.destroyed) socket.destroy();+ return;+ }+ this.stopped = false;+ this.retries = 0;+ this.connect();+ } connect() { if (!this.enabled || this.stopped || (this.socket && !this.socket.destroyed)) return; clearTimeout(this.retryTimer);@@ -172,7 +199,7 @@ export class SocketTransport { clearTimeout(this.stallTimer); this.connected = false; this.blocked = false;- if (this.stopped) return;+ if (this.stopped || !this.enabled) return; this.retries++; this.stats.reconnects++; this.report();diff --git a/Server.js b/Server.js--- a/Server.js+++ b/Server.js@@ -525,10 +525,13 @@ function startRuntime(url) { else if (event?.type === 'TEST_RESULT') pushTestResult(event.data); else if (event?.type === 'PLATFORM_STATE' && event.data?.platform) { runtimeState.setPlatformState(event.data.platform, event.data.state, event.data.error || null);+ } else if (event?.type === 'TRANSPORT_STATE' && event.data) {+ runtimeState.setTransports(event.data); } }); runtimeState.attach(launchedProcess, activePlatforms); runtimeState.data.features = { isSocket, isBark, isNtfy };+ runtimeState.setTransports({ socket: isSocket, bark: isBark, ntfy: isNtfy }); launchedProcess.once('error', error => { pushLog('[SYSTEM] 程序啟動失敗:', error.code || 'PROCESS_ERROR'); if (tiktokProcess === launchedProcess) tiktokProcess = null;@@ -646,6 +649,36 @@ function startRuntime(url) { return runtimeState.snapshot(); } +/** 執行中切換 socket/Bark/ntfy;只送差異,不重啟子程序。 */+function setRuntimeTransports(next = {}) {+ if (!next || typeof next !== 'object' || Array.isArray(next)) {+ throw Object.assign(new Error('收發參數無效'), { status: 400, code: 'INVALID_TRANSPORTS' });+ }+ const payload = {};+ for (const key of ['socket', 'bark', 'ntfy']) {+ if (next[key] !== undefined) {+ if (typeof next[key] !== 'boolean') {+ throw Object.assign(new Error(`${key} 必須是布林值`), { status: 400, code: 'INVALID_TRANSPORTS' });+ }+ payload[key] = next[key];+ }+ }+ if (!Object.keys(payload).length) {+ throw Object.assign(new Error('沒有要套用的收發設定'), { status: 400, code: 'INVALID_TRANSPORTS' });+ }+ if (!tiktokProcess || tiktokProcess.connected === false) {+ throw Object.assign(new Error('目前沒有執行中的聊天子程序'), { status: 409, code: 'RUNTIME_STOPPED' });+ }+ try {+ tiktokProcess.send({ type: 'SET_TRANSPORTS', data: payload });+ } catch (error) {+ throw Object.assign(new Error(`無法通知子程序:${error.message}`), { status: 500, code: 'TRANSPORT_UPDATE_FAILED' });+ }+ runtimeState.setTransports(payload);+ pushLog(`[RUNTIME] 收發設定: ${Object.entries(payload).map(([key, value]) => `${key}=${value}`).join(', ')}`);+ return { ok: true, transports: runtimeState.snapshot().transports };+}+ /** 執行中加開平台;不重啟子程序,monitor 場次與統計保持連續。 */ function addRuntimePlatform(platform) { const name = String(platform || '').trim().toLowerCase();@@ -688,7 +721,7 @@ const server = http.createServer((req, res) => { if (require('./TrafficRoutes.cjs').serveTraffic(req,res,isValidToken(parseCookies(req).authToken))) return; if (serveMonitorHistory(req, res, isValidToken(parseCookies(req).authToken))) return; if (require('./RestreamRoutes.cjs').serveRestream(req,res,isValidToken(parseCookies(req).authToken))) return;- if (serveRuntime(req, res, runtimeState, startRuntime, addRuntimePlatform)) return;+ if (serveRuntime(req, res, runtimeState, startRuntime, addRuntimePlatform, setRuntimeTransports)) return; if (diagnosticsRoutes.serveIntake(req, res)) return; if (diagnosticsRoutes.servePage(req, res)) return; if (diagnosticsRoutes.serveApi(req, res, isValidToken(parseCookies(req).authToken))) return;diff --git a/Test/notify.test.mjs b/Test/notify.test.mjs--- a/Test/notify.test.mjs+++ b/Test/notify.test.mjs@@ -94,6 +94,24 @@ test('Notifier 扇出到所有已啟用通道;單一通道失敗不影響其 assert.equal(barkAxios.calls.length, 1); }); +test('Notifier 可在執行中個別開關通道,只送出目前啟用的通道', async () => {+ const barkAxios = fakeAxios();+ const ntfyAxios = fakeAxios();+ const notifier = createNotifier({+ isBark: false, isNtfy: true,+ barkApi: 'https://api.day.app/key', ntfyApi: 'https://ntfy.sh/topic',+ axios: { post: (...args) => (args[0] === 'https://api.day.app/key' ? barkAxios.post(...args) : ntfyAxios.post(...args)) }+ });+ assert.deepEqual(notifier.enabled, ['ntfy']);+ assert.equal(notifier.isEnabled('bark'), false);+ assert.equal(notifier.setEnabled('bark', true).sort().join(','), 'bark,ntfy');+ const results = await notifier.notify('T', 'hello');+ assert.deepEqual(results.map(item => item.channel), ['bark', 'ntfy']);+ notifier.setEnabled('ntfy', false);+ assert.deepEqual(notifier.enabled, ['bark']);+ assert.equal(notifier.setEnabled('unknown', true).includes('unknown'), false);+});+ test('createNotifier 依旗標與設定建立,未設定 URL 或關閉的通道不加入', async () => { const axios = fakeAxios(); assert.deepEqual(createNotifier({ isBark: false, isNtfy: false, axios }).enabled, []);diff --git a/Test/runtime_state.test.cjs b/Test/runtime_state.test.cjs--- a/Test/runtime_state.test.cjs+++ b/Test/runtime_state.test.cjs@@ -34,6 +34,19 @@ test('啟動、運行與優雅停止依實際程序事件更新', () => { assert.equal(state.snapshot().pid, null); }); +test('執行中收發設定會更新狀態與 features', () => {+ const state = new RuntimeState();+ const proc = child();+ state.attach(proc, ['twitch']);+ proc.emit('spawn');+ state.data.features = { isSocket: false, isBark: true, isNtfy: false };+ state.setTransports({ socket: true, bark: false, ntfy: true, socketConnected: false });+ assert.equal(state.snapshot().transports.socket, true);+ assert.equal(state.snapshot().features.isSocket, true);+ assert.equal(state.snapshot().features.isBark, false);+ assert.equal(state.snapshot().transports.ntfy, true);+});+ test('執行中加開平台會更新平台清單與個別狀態', () => { const state = new RuntimeState(); const proc = child();@@ -163,6 +176,31 @@ test('執行中加開平台 API 驗證來源、方法與平台', async () => { assert.equal((await request('POST', 'http://localhost:3332', { platform: 'twitch' })).status, 409); }); +test('執行中收發 API 驗證來源、方法與布林值', async () => {+ const { Readable } = require('node:stream');+ const state = new RuntimeState();+ const updated = [];+ const setTransports = body => {+ if (!Object.keys(body).length) throw Object.assign(new Error('empty'), { status: 400, code: 'INVALID_TRANSPORTS' });+ updated.push(body);+ state.setTransports(body);+ return { ok: true, transports: state.snapshot().transports };+ };+ function request(method, origin, body) {+ return new Promise(resolve => {+ const req = Readable.from([JSON.stringify(body)]);+ Object.assign(req, { url: '/api/runtime/transports', method, headers: { host: 'localhost:3332', origin, 'content-type': 'application/json' } });+ serveRuntime(req, { writeHead(status) { this.status = status; }, end(data) { resolve({ status: this.status, data: data && JSON.parse(data) }); } }, state, null, null, setTransports);+ });+ }+ assert.equal((await request('GET', 'http://localhost:3332', {})).status, 405);+ assert.equal((await request('POST', 'https://other.test', { socket: true })).status, 403);+ const result = await request('POST', 'http://localhost:3332', { ntfy: true });+ assert.equal(result.status, 202);+ assert.deepEqual(updated, [{ ntfy: true }]);+ assert.equal((await request('POST', 'http://localhost:3332', {})).status, 400);+});+ test('子程序統計以摘要記錄,完整快照仍更新快取且分段輸出不遺失', () => { const fs = require('node:fs'); const vm = require('node:vm');diff --git a/Test/runtime_ui.cjs b/Test/runtime_ui.cjs--- a/Test/runtime_ui.cjs+++ b/Test/runtime_ui.cjs@@ -18,7 +18,13 @@ const state = { signal: null, error: null, platforms: ['tiktok', 'twitch'],- features: { isSocket: true, isBark: false, isNtfy: true },+ platformStates: { tiktok: 'running', twitch: 'running' },+ transports: {+ socket: false, bark: true, ntfy: true,+ socketConfigured: true, socketConnected: false,+ barkConfigured: true, ntfyConfigured: true+ },+ features: { isSocket: false, isBark: true, isNtfy: true }, resources: { cpuPercent: 3.2, rssBytes: 220 * 1024 * 1024, sampledAt: Date.now() }, uptimeMs: 125000, stopDelayed: false,@@ -55,6 +61,17 @@ function previewServer() { res.end(JSON.stringify({ options })); return; }+ if (req.method === 'POST' && url.pathname === '/api/runtime/transports') {+ let body = '';+ req.on('data', chunk => { body += chunk; });+ req.on('end', () => {+ const update = JSON.parse(body || '{}');+ state.transports = { ...state.transports, ...update };+ res.writeHead(202, { 'Content-Type': 'application/json; charset=utf-8' });+ res.end(JSON.stringify({ ok: true, transports: state.transports }));+ });+ return;+ } if (req.method === 'POST' && url.pathname === '/api/runtime/platforms') { let body = ''; req.on('data', chunk => { body += chunk; });@@ -116,6 +133,21 @@ async function main() { assert.match(await page.$eval('#mode-youtube', el => el.textContent), /手動 userscript/); assert.equal(await page.$eval('#mode-youtube-badge', el => el.dataset.mode), 'manual'); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);+ assert.equal(await page.$eval('#runtime-transport-card', el => el.hidden), false);+ const transportText = await page.$eval('#runtime-transport-list', el => el.textContent);+ assert.match(transportText, /Socket/overlay:關閉/);+ assert.match(transportText, /Bark:開啟/);+ const [transportResponse] = await Promise.all([+ page.waitForResponse(response => response.url().endsWith('/api/runtime/transports')),+ page.evaluate(() => {+ const button = Array.from(document.querySelectorAll('#runtime-transport-list button'))+ .find(node => node.textContent.includes('開啟 Socket'));+ button.click();+ })+ ]);+ assert.equal(transportResponse.status(), 202);+ await page.waitForFunction(() => document.querySelector('#runtime-transport-list').textContent.includes('Socket/overlay:連線中'));+ assert.equal(await page.$eval('#runtime-add-card', el => el.hidden), false); const addButtons = await page.$$eval('#runtime-add-platforms button', nodes => nodes.map(node => node.textContent)); assert.ok(addButtons.some(label => label.includes('Kick')));diff --git a/Test/socket_hub.test.mjs b/Test/socket_hub.test.mjs--- a/Test/socket_hub.test.mjs+++ b/Test/socket_hub.test.mjs@@ -110,6 +110,26 @@ test('createHub:停用時無端點;啟用時建立單一端點', () => { assert.equal(hub.primary.accepts('chat'), true); }); +test('SocketHub 保留停用端點並可執行中開關', () => {+ const calls = [];+ const endpoint = {+ enabled: false,+ accepts: () => true,+ send: payload => calls.push(payload),+ connect: () => calls.push('connect'),+ setEnabled(enabled) { this.enabled = enabled; calls.push(['enabled', enabled]); }+ };+ const hub = new SocketHub({ endpoints: [endpoint] });+ assert.equal(hub.enabled, false);+ assert.equal(hub.endpoints.length, 1);+ hub.send({ type: 'chat' });+ assert.deepEqual(calls, []);+ hub.setEnabled(true);+ assert.equal(hub.enabled, true);+ hub.send({ type: 'chat' });+ assert.deepEqual(calls, [['enabled', true], 'connect', { type: 'chat' }]);+});+ test('parseEndpointSpec:拆出 ?filter= 與網址', () => { assert.deepEqual(parseEndpointSpec('tcp://localhost:9322'), { url: 'tcp://localhost:9322', filter: undefined }); assert.deepEqual(parseEndpointSpec('tcp://localhost:9322?filter=chat,gift'), { url: 'tcp://localhost:9322', filter: 'chat,gift' });diff --git a/Test/socket_transport.test.mjs b/Test/socket_transport.test.mjs--- a/Test/socket_transport.test.mjs+++ b/Test/socket_transport.test.mjs@@ -74,6 +74,24 @@ test('queue bounds, latest audience state, expiration, disabled transport', t => assert.equal(disabled.send({ n: 1 }), false); assert.equal(disabled.queue.length, 0); });+test('setEnabled 可執行中關閉並清空佇列,再開啟時重新連線', t => {+ const { transport, sockets, socket } = setup(t, { retryMs: 5 });+ socket.emit('connect');+ transport.send({ n: 1 });+ assert.equal(transport.enabled, true);+ transport.setEnabled(false);+ assert.equal(transport.enabled, false);+ assert.equal(transport.connected, false);+ assert.equal(transport.queue.length, 0);+ assert.equal(socket.destroyed, true);+ transport.setEnabled(true);+ assert.equal(transport.enabled, true);+ assert.equal(sockets.length, 2);+ sockets[1].emit('connect');+ assert.equal(transport.send({ n: 2 }), true);+ assert.deepEqual(sockets[1].writes, [{ n: 2 }]);+});+ test('disconnect during backpressure retains only unsent frames and reconnects once', async t => { const { transport, socket, sockets } = setup(t, { retryMs: 5 }); socket.emit('connect');diff --git a/TikTok.js b/TikTok.js--- a/TikTok.js+++ b/TikTok.js@@ -264,6 +264,49 @@ const Ntfy = process.env.NTFY_API; const notifier = createNotifier({ isBark, isNtfy, barkApi: Bark, ntfyApi: Ntfy, getEmojiMap, axios }); const notify = (title, comment, icon, url) => notifier.notify(title, comment, icon, url); +/** 目前收發/通知狀態;configured 表示有設定,active 才是實際啟用。 */+function transportSnapshot() {+ const channel = name => notifier.channels.find(item => item.name === name);+ return {+ socket: isSocket,+ bark: isBark,+ ntfy: isNtfy,+ socketConfigured: socketHub.endpoints.length > 0,+ socketConnected: Boolean(socketHub.primary?.connected),+ socketEndpoints: socketHub.endpoints.length,+ barkConfigured: Boolean(channel('bark')?.enabled),+ ntfyConfigured: Boolean(channel('ntfy')?.enabled)+ };+}++function reportTransportState() {+ if (typeof process.send !== 'function') return;+ try { process.send({ type: 'TRANSPORT_STATE', data: transportSnapshot() }); } catch { /* IPC 已關閉 */ }+}++/**+ * 執行中開關收發與通知;只套用有帶入的布林值。+ * @param {{socket?: boolean, bark?: boolean, ntfy?: boolean}} next+ * @returns {Object} 套用後狀態。+ */+function applyTransports(next = {}) {+ if (typeof next.socket === 'boolean') {+ isSocket = next.socket;+ socketHub.setEnabled(isSocket);+ }+ if (typeof next.bark === 'boolean') {+ isBark = next.bark;+ notifier.setEnabled('bark', isBark);+ }+ if (typeof next.ntfy === 'boolean') {+ isNtfy = next.ntfy;+ notifier.setEnabled('ntfy', isNtfy);+ }+ const state = transportSnapshot();+ reportTransportState();+ return state;+}+ /** * 產生一組平台綁定的 socket 送出器(固定 category 與 platform)。 * @param {string} platform 來源平台名稱。@@ -980,7 +1023,7 @@ function handleRawSocketTest(message) { } } payload.isTest = true;- const endpoints = socketHub.endpoints.length;+ const endpoints = socketHub.endpoints.filter(endpoint => endpoint.enabled).length; socketHub.send(payload); reportTestResult(message.testId, endpoints > 0 ? 'sent' : 'noendpoints', 0, { status: 'skipped', channels: [] }, { mode: 'raw', endpoints });@@ -1368,6 +1411,11 @@ const addPlatform = platform => platformManager.add(platform); if (typeof process.on === 'function') { process.on('message', message => {+ if (message?.type === 'SET_TRANSPORTS') {+ try { applyTransports(message.data); }+ catch (error) { console.error('[TRANSPORT] 套用失敗:', error.message || error); }+ return;+ } if (message?.type !== 'ADD_PLATFORM') return; void addPlatform(message.platform).catch(error => { console.error(`[PLATFORM] 加開 ${message.platform} 失敗:`, error.message || error);@@ -1375,6 +1423,8 @@ if (typeof process.on === 'function') { }); } +reportTransportState();+ for (const [platform, enabled] of [ ['tiktok', isTK], ['twitch', isTwitch], ['kick', isKick], ['youtube', isYoutube], ['odysee', isOdysee]diff --git a/assets/app.css.build.json b/assets/app.css.build.json--- a/assets/app.css.build.json+++ b/assets/app.css.build.json@@ -1,5 +1,5 @@ { "schema": 2,- "inputHash": "edd735287091b41b0fcd39c76675eb440e992fdf9ff292d33ea1b5ebd06e9ded",+ "inputHash": "b879e52e34d585acd13520b70333bb87d180b59fdd110e675e2ba205b5bb4eab", "outputHash": "5e8e8ac9b7a490acf78fc06ee9e9c0e63e69a0a32e695f93025dce6a24930dce" }diff --git a/assets/runtime-start.js b/assets/runtime-start.js--- a/assets/runtime-start.js+++ b/assets/runtime-start.js@@ -6,7 +6,15 @@ const addCard = document.getElementById('runtime-add-card'); const addList = document.getElementById('runtime-add-platforms'); const addStatus = document.getElementById('runtime-add-status');+ const transportCard = document.getElementById('runtime-transport-card');+ const transportList = document.getElementById('runtime-transport-list');+ const transportStatus = document.getElementById('runtime-transport-status'); const platformKeys = { isTK: 'tiktok', isTwitch: 'twitch', isKick: 'kick', isOdysee: 'odysee', isYouTube: 'youtube' };+ const transportKeys = [+ { key: 'socket', label: 'Socket/overlay', configured: 'socketConfigured' },+ { key: 'bark', label: 'Bark', configured: 'barkConfigured' },+ { key: 'ntfy', label: 'ntfy', configured: 'ntfyConfigured' }+ ]; function toast(message, tone = 'info') { window.runtimeToast?.(message, tone); } /** 僅讀寫白名單欄位;儲存不可用時仍能在當頁啟動。 */ function values() {@@ -34,7 +42,66 @@ }).catch(failure => { addStatus.textContent = failure.message; toast(i18n.t('runtime.addPlatformFailed', {message:failure.message}, '加開失敗:{message}'), 'error');- }).finally(() => { busy = false; renderAddPlatforms(state); });+ }).finally(() => { busy = false; renderTransports(state); renderAddPlatforms(state); });+ }++ function setTransport(key, enabled) {+ if (busy) return;+ busy = true;+ transportStatus.textContent = '';+ fetch('/api/runtime/transports', {+ method: 'POST',+ headers: { 'Content-Type': 'application/json' },+ body: JSON.stringify({ [key]: enabled })+ }).then(async response => {+ const result = await response.json().catch(() => ({}));+ if (!response.ok) throw Error(i18n.t('error.' + result.code, {}, result.error || '切換失敗'));+ toast(i18n.t('runtime.transportUpdated', { key }, '已更新 {key}'), 'success');+ await window.runtimeRefresh?.(false);+ }).catch(failure => {+ transportStatus.textContent = failure.message;+ toast(i18n.t('runtime.transportFailed', { message: failure.message }, '切換失敗:{message}'), 'error');+ }).finally(() => { busy = false; renderTransports(state); renderAddPlatforms(state); });+ }++ function transportStateText(item, transports) {+ const active = transports[item.key] === true;+ const configured = transports[item.configured] === true;+ if (!configured) return i18n.t('runtime.transportUnconfigured', {}, '未設定');+ if (!active) return i18n.t('runtime.transportOff', {}, '關閉');+ if (item.key === 'socket') {+ return transports.socketConnected+ ? i18n.t('runtime.transportConnected', {}, '已連線')+ : i18n.t('runtime.transportConnecting', {}, '連線中');+ }+ return i18n.t('runtime.transportOn', {}, '開啟');+ }++ function renderTransports(next) {+ if (!transportCard || !transportList) return;+ const running = next && ['running', 'starting'].includes(next.state);+ transportCard.hidden = !running;+ if (!running) return;+ const transports = next.transports || {};+ const rows = transportKeys.map(item => {+ const configured = transports[item.configured] === true;+ const active = transports[item.key] === true;+ const row = document.createElement('div');+ row.className = 'flex items-center gap-2 px-3 py-2 border border-gray-700 rounded-lg';+ const status = document.createElement('span');+ status.className = 'text-sm text-gray-300';+ status.textContent = item.label + ':' + transportStateText(item, transports);+ const button = document.createElement('button');+ button.type = 'button';+ button.className = 'runtime-button runtime-button-secondary';+ button.disabled = busy || !configured;+ button.textContent = i18n.t(active ? 'runtime.transportTurnOff' : 'runtime.transportTurnOn',+ { name: item.label }, active ? '關閉 ' + item.label : '開啟 ' + item.label);+ button.addEventListener('click', () => setTransport(item.key, !active));+ row.append(status, button);+ return row;+ });+ transportList.replaceChildren(...rows); } function renderAddPlatforms(next) {@@ -81,6 +148,7 @@ const active = state?.features ? options.filter(o => o.group === 'transport' && state.features[o.key]).map(o => o.label) : []; document.getElementById('active-features').textContent = active.length ? i18n.t('runtime.activeFeatures', {}, '本次功能:') + active.join(', ') : '';+ renderTransports(state); renderAddPlatforms(state); } /** 平台名稱與欄位由後端提供;所有文字使用 textContent。 */diff --git a/lang/en.json b/lang/en.json--- a/lang/en.json+++ b/lang/en.json@@ -556,6 +556,17 @@ "error.TAB_CLOSED": "The tab was closed. Refresh the page", "error.BROWSER_CONFIG_INVALID": "Invalid browser configuration. Check BROWSER_DEBUG_URL", "runtime.options": "Startup options",+ "runtime.transports": "Outputs and notifications",+ "runtime.transportHint": "Toggle Socket, Bark, or ntfy while running without restarting the child process.",+ "runtime.transportTurnOn": "Enable {name}",+ "runtime.transportTurnOff": "Disable {name}",+ "runtime.transportOn": "On",+ "runtime.transportOff": "Off",+ "runtime.transportConnected": "Connected",+ "runtime.transportConnecting": "Connecting",+ "runtime.transportUnconfigured": "Not configured",+ "runtime.transportUpdated": "Updated {key}",+ "runtime.transportFailed": "Update failed: {message}", "runtime.addPlatforms": "Add platform while running", "runtime.addPlatformHint": "No child-process restart; the current monitor session and stats stay intact.", "runtime.addPlatformButton": "Add {platform}",diff --git a/lang/zh-TW.json b/lang/zh-TW.json--- a/lang/zh-TW.json+++ b/lang/zh-TW.json@@ -556,6 +556,17 @@ "error.TAB_CLOSED": "分頁已關閉,請重新整理", "error.BROWSER_CONFIG_INVALID": "瀏覽器設定錯誤,請檢查 BROWSER_DEBUG_URL", "runtime.options": "啟動選項",+ "runtime.transports": "收發與通知",+ "runtime.transportHint": "執行中可切換 Socket、Bark、ntfy;不重啟子程序。",+ "runtime.transportTurnOn": "開啟 {name}",+ "runtime.transportTurnOff": "關閉 {name}",+ "runtime.transportOn": "開啟",+ "runtime.transportOff": "關閉",+ "runtime.transportConnected": "已連線",+ "runtime.transportConnecting": "連線中",+ "runtime.transportUnconfigured": "未設定",+ "runtime.transportUpdated": "已更新 {key}",+ "runtime.transportFailed": "切換失敗:{message}", "runtime.addPlatforms": "執行中加開平台", "runtime.addPlatformHint": "不重啟子程序,保留目前 monitor 場次與統計。", "runtime.addPlatformButton": "加開 {platform}",diff --git a/runtime.html b/runtime.html--- a/runtime.html+++ b/runtime.html@@ -91,6 +91,18 @@ </div> </section> + <section id="runtime-transport-card" class="runtime-card" aria-labelledby="transportTitle" hidden>+ <div class="runtime-card-head">+ <div>+ <p class="text-xs uppercase tracking-widest text-blue-300">Output</p>+ <h2 id="transportTitle" class="text-xl font-bold" data-i18n="runtime.transports">收發與通知</h2>+ <p class="text-sm text-gray-400 mt-1" data-i18n="runtime.transportHint">執行中可切換 Socket、Bark、ntfy;不重啟子程序。</p>+ </div>+ </div>+ <div id="runtime-transport-list" class="flex flex-wrap gap-2"></div>+ <p id="runtime-transport-status" class="text-sm mt-3" role="status"></p>+ </section>+ <section id="runtime-add-card" class="runtime-card" aria-labelledby="addPlatformTitle" hidden> <div class="runtime-card-head"> <div>