app · 112269c

Dev index Recent changes / versions Changelog About TODO

feat(runtime): 執行中切換收發與通知

112269c · 2026-11-13 14:52 · app
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.mdindex b88aa1c..1bfaa9a 100644--- 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.mdindex 1a06620..6222272 100644--- 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.mdindex 05222c9..9084b97 100644--- 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.mdindex 57f1ded..39ddc7b 100644--- 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.cjsindex ceeda15..8ff7fe7 100644--- 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.mjsindex 4f4d3a7..1b869c7 100644--- 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.cjsindex 91cb635..3f105ce 100644--- 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.mjsindex 7bd2358..0216653 100644--- 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.mjsindex bcf9e15..b3ea74e 100644--- 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.mjsindex e140759..99cd70a 100644--- 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.jsindex 677ae91..0342bc6 100644--- 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.mjsindex 0c546f6..3b1ce37 100644--- 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.cjsindex 2153fa3..1e7b6aa 100644--- 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.cjsindex a69efb1..6b9e69a 100644--- 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.mjsindex cc28655..61f596e 100644--- 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.mjsindex d798ef3..ba6ad6e 100644--- 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.jsindex b9c75d9..ec0ecca 100644--- 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.jsonindex 4aa8f74..1f936fe 100644--- 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.jsindex e6b078c..11903b0 100644--- 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.jsonindex bce89b3..d53b429 100644--- 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.jsonindex a504029..455196e 100644--- 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.htmlindex 30bdc42..3daa670 100644--- 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>