diff --git a/.ui-review-icon/icon-big.png b/.ui-review-icon/icon-big.png
new file mode 100644
index 0000000..5028faf
Binary files /dev/null and b/.ui-review-icon/icon-big.png differ
diff --git a/.ui-review-icon2/128.png b/.ui-review-icon2/128.png
new file mode 100644
index 0000000..05d6904
Binary files /dev/null and b/.ui-review-icon2/128.png differ
diff --git a/.ui-review-icon2/64.png b/.ui-review-icon2/64.png
new file mode 100644
index 0000000..b8b4794
Binary files /dev/null and b/.ui-review-icon2/64.png differ
diff --git a/.ui-review-icon2/app-128.png b/.ui-review-icon2/app-128.png
new file mode 100644
index 0000000..c07dd4d
Binary files /dev/null and b/.ui-review-icon2/app-128.png differ
diff --git a/.ui-review-layout/01-home.png b/.ui-review-layout/01-home.png
new file mode 100644
index 0000000..fd463b6
Binary files /dev/null and b/.ui-review-layout/01-home.png differ
diff --git a/.ui-review-layout/02-http.png b/.ui-review-layout/02-http.png
new file mode 100644
index 0000000..5028906
Binary files /dev/null and b/.ui-review-layout/02-http.png differ
diff --git a/.ui-review-layout/03-mock.png b/.ui-review-layout/03-mock.png
new file mode 100644
index 0000000..69b66a9
Binary files /dev/null and b/.ui-review-layout/03-mock.png differ
diff --git a/.ui-review-layout/probe.json b/.ui-review-layout/probe.json
new file mode 100644
index 0000000..0d8cd6a
--- /dev/null
+++ b/.ui-review-layout/probe.json
@@ -0,0 +1,33 @@
+{
+ "home": {
+ "text": "OpsxAPI\n新建\n导入\n导出\n通道\n自动\nWeb 代理\n浏览器直连\n桌面直连\n本\n本地\n1 变量\n本\n接口\n历史\n测试\nMock\n文档\n设置\n登录\n接口\n历史\n示例集合\nHTTP · 本地回归(P0)\nGET\n回归 · GET Query\nPOST\n回归 · POST JSON\nPOST\n回归 · POST urlencoded\nPOST\n回归 · POST form-data\nGET\n回归 · 自定义 Header\nGET\n回归 · Cookie\nGET\n回归 · Bearer\nGET\n回归 · Basic Auth\nHTTP · httpbin\nGET\n获取请求回显\nPOST\n提交 JSON\nPOST\n表单提交\nGET\nBearer 鉴权示例\nGET\n状态码 · 404\nGraphQL\n国家查询 · countries\n实时协议\nWebSocket · echo\nSSE · 维基媒体变更流\nMQTT · EMQX 公共 Broker\nSocket.IO · 本地服务\n没有打开的请求\nOpsxAPI · 接口工作台\n从左侧选择接口,或导入数据/新建请求\nHTTP · GraphQL · WebSocket · SSE · Socket.IO · MQTT\n支持 OpenAPI / Postman / Apifox / cURL / HAR 等格式导入导出",
+ "title": "OpsxAPI · 接口工作台"
+ },
+ "mockProbe": {
+ "hasShareable": false,
+ "hasLocal": false,
+ "hasComing": false,
+ "snippet": "\nWeb 代理\n浏览器直连\n桌面直连\n本\n本地\n1 变量\n本\n接口\n历史\n测试\nMock\n文档\n设置\n登录\n接口\n历史\n示例集合\nHTTP · 本地回归(P0)\nHTTP · httpbin\nGET\n获取请求回显\nPOST\n提交 JSON\nPOST\n表单提交\nGET\nBearer 鉴权示例\nGET\n状态码 · 404\nGraphQL\n国家查询 · countries\n实时协议\nWebSocket · echo\nSSE · 维基媒体变更流\nMQTT · EMQX 公共 Brok"
+ },
+ "layout": {
+ "top": {
+ "w": 1440,
+ "h": 48
+ },
+ "side": {
+ "w": 256,
+ "h": 765
+ },
+ "rail": {
+ "w": 34,
+ "h": 50
+ },
+ "main": {
+ "w": 1128,
+ "h": 765
+ },
+ "topBtns": 5,
+ "minFs": 8,
+ "bodyBg": "rgb(11, 18, 32)"
+ }
+}
\ No newline at end of file
diff --git a/.ui-review-layout2/home.png b/.ui-review-layout2/home.png
new file mode 100644
index 0000000..0df2fc7
Binary files /dev/null and b/.ui-review-layout2/home.png differ
diff --git a/.ui-review/01-home.png b/.ui-review/01-home.png
new file mode 100644
index 0000000..655ab39
Binary files /dev/null and b/.ui-review/01-home.png differ
diff --git a/.ui-review/02-after-click.png b/.ui-review/02-after-click.png
new file mode 100644
index 0000000..655ab39
Binary files /dev/null and b/.ui-review/02-after-click.png differ
diff --git a/.ui-review/03-ws.png b/.ui-review/03-ws.png
new file mode 100644
index 0000000..655ab39
Binary files /dev/null and b/.ui-review/03-ws.png differ
diff --git a/.ui-review/04-http.png b/.ui-review/04-http.png
new file mode 100644
index 0000000..cd12242
Binary files /dev/null and b/.ui-review/04-http.png differ
diff --git a/.ui-review/05-ws.png b/.ui-review/05-ws.png
new file mode 100644
index 0000000..cd12242
Binary files /dev/null and b/.ui-review/05-ws.png differ
diff --git a/.ui-review/10-http.png b/.ui-review/10-http.png
new file mode 100644
index 0000000..bd7922a
Binary files /dev/null and b/.ui-review/10-http.png differ
diff --git a/.ui-review/11-ws.png b/.ui-review/11-ws.png
new file mode 100644
index 0000000..bd7922a
Binary files /dev/null and b/.ui-review/11-ws.png differ
diff --git a/.ui-review/20-http-sent.png b/.ui-review/20-http-sent.png
new file mode 100644
index 0000000..927d9bd
Binary files /dev/null and b/.ui-review/20-http-sent.png differ
diff --git a/.ui-review/21-ws.png b/.ui-review/21-ws.png
new file mode 100644
index 0000000..d2cef87
Binary files /dev/null and b/.ui-review/21-ws.png differ
diff --git a/.ui-review/30-http.png b/.ui-review/30-http.png
new file mode 100644
index 0000000..0cb18a2
Binary files /dev/null and b/.ui-review/30-http.png differ
diff --git a/.ui-review/31-dirty.png b/.ui-review/31-dirty.png
new file mode 100644
index 0000000..6be8d71
Binary files /dev/null and b/.ui-review/31-dirty.png differ
diff --git a/.ui-review/32-ws.png b/.ui-review/32-ws.png
new file mode 100644
index 0000000..bf9336b
Binary files /dev/null and b/.ui-review/32-ws.png differ
diff --git a/.ui-review/33-mock.png b/.ui-review/33-mock.png
new file mode 100644
index 0000000..32150bd
Binary files /dev/null and b/.ui-review/33-mock.png differ
diff --git a/.ui-review/40-home.png b/.ui-review/40-home.png
new file mode 100644
index 0000000..2ed7344
Binary files /dev/null and b/.ui-review/40-home.png differ
diff --git a/.ui-review/41-http.png b/.ui-review/41-http.png
new file mode 100644
index 0000000..2567d32
Binary files /dev/null and b/.ui-review/41-http.png differ
diff --git a/.ui-review/42-ws.png b/.ui-review/42-ws.png
new file mode 100644
index 0000000..8e9c6c7
Binary files /dev/null and b/.ui-review/42-ws.png differ
diff --git a/.ui-review/43-proxy-attempt.png b/.ui-review/43-proxy-attempt.png
new file mode 100644
index 0000000..6879fbe
Binary files /dev/null and b/.ui-review/43-proxy-attempt.png differ
diff --git a/.ui-review/accept-2026-09-19/d3-retest-ws.png b/.ui-review/accept-2026-09-19/d3-retest-ws.png
new file mode 100644
index 0000000..4c3b4e5
Binary files /dev/null and b/.ui-review/accept-2026-09-19/d3-retest-ws.png differ
diff --git a/.ui-review/accept-2026-09-19/desk-after-new.png b/.ui-review/accept-2026-09-19/desk-after-new.png
new file mode 100644
index 0000000..98a3d88
Binary files /dev/null and b/.ui-review/accept-2026-09-19/desk-after-new.png differ
diff --git a/.ui-review/accept-2026-09-19/desk-menu.png b/.ui-review/accept-2026-09-19/desk-menu.png
new file mode 100644
index 0000000..053fbe0
Binary files /dev/null and b/.ui-review/accept-2026-09-19/desk-menu.png differ
diff --git a/.ui-review/accept-2026-09-19/desk-win.png b/.ui-review/accept-2026-09-19/desk-win.png
new file mode 100644
index 0000000..d131262
Binary files /dev/null and b/.ui-review/accept-2026-09-19/desk-win.png differ
diff --git a/.ui-review/accept-2026-09-19/desktop-full.png b/.ui-review/accept-2026-09-19/desktop-full.png
new file mode 100644
index 0000000..867805f
Binary files /dev/null and b/.ui-review/accept-2026-09-19/desktop-full.png differ
diff --git a/.ui-review/accept-2026-09-19/web-01-home.png b/.ui-review/accept-2026-09-19/web-01-home.png
new file mode 100644
index 0000000..4fb0c7d
Binary files /dev/null and b/.ui-review/accept-2026-09-19/web-01-home.png differ
diff --git a/.ui-review/accept-2026-09-19/web-02-channel.png b/.ui-review/accept-2026-09-19/web-02-channel.png
new file mode 100644
index 0000000..4fb0c7d
Binary files /dev/null and b/.ui-review/accept-2026-09-19/web-02-channel.png differ
diff --git a/.ui-review/accept-2026-09-19/web-03-env.png b/.ui-review/accept-2026-09-19/web-03-env.png
new file mode 100644
index 0000000..2f535b1
Binary files /dev/null and b/.ui-review/accept-2026-09-19/web-03-env.png differ
diff --git a/.ui-review/accept-2026-09-19/web-04-tree.png b/.ui-review/accept-2026-09-19/web-04-tree.png
new file mode 100644
index 0000000..ac01af9
Binary files /dev/null and b/.ui-review/accept-2026-09-19/web-04-tree.png differ
diff --git a/.ui-review/accept-2026-09-19/web-05-dirty.png b/.ui-review/accept-2026-09-19/web-05-dirty.png
new file mode 100644
index 0000000..028b3f4
Binary files /dev/null and b/.ui-review/accept-2026-09-19/web-05-dirty.png differ
diff --git a/.ui-review/accept-2026-09-19/web-06-new-menu.png b/.ui-review/accept-2026-09-19/web-06-new-menu.png
new file mode 100644
index 0000000..9abe24b
Binary files /dev/null and b/.ui-review/accept-2026-09-19/web-06-new-menu.png differ
diff --git a/.ui-review/accept-2026-09-19/web-07-ws-editor.png b/.ui-review/accept-2026-09-19/web-07-ws-editor.png
new file mode 100644
index 0000000..8a71cba
Binary files /dev/null and b/.ui-review/accept-2026-09-19/web-07-ws-editor.png differ
diff --git a/.ui-review/accept-2026-09-19/web-08-ws-filled.png b/.ui-review/accept-2026-09-19/web-08-ws-filled.png
new file mode 100644
index 0000000..8672746
Binary files /dev/null and b/.ui-review/accept-2026-09-19/web-08-ws-filled.png differ
diff --git a/.ui-review/accept-2026-09-19/web-10-history.png b/.ui-review/accept-2026-09-19/web-10-history.png
new file mode 100644
index 0000000..ae4d443
Binary files /dev/null and b/.ui-review/accept-2026-09-19/web-10-history.png differ
diff --git a/.ui-review/accept-2026-09-19/web-11-replay.png b/.ui-review/accept-2026-09-19/web-11-replay.png
new file mode 100644
index 0000000..f62e4eb
Binary files /dev/null and b/.ui-review/accept-2026-09-19/web-11-replay.png differ
diff --git a/.ui-review/accept-2026-09-19/web-reg-result.json b/.ui-review/accept-2026-09-19/web-reg-result.json
new file mode 100644
index 0000000..af523be
--- /dev/null
+++ b/.ui-review/accept-2026-09-19/web-reg-result.json
@@ -0,0 +1,24 @@
+{
+ "verdict": "有缺陷",
+ "pass": [
+ "通道切换往返: 自动|Web 代理|浏览器直连|桌面直连",
+ "打开环境变量面板",
+ "集合树切换到回归 GET",
+ "URL 脏点已写入",
+ "脏点/保存相关 UI 可见或已 Cmd+S",
+ "发送 HTTP 以产生历史",
+ "新建 WebSocket",
+ "填写 WS URL",
+ "填写 Authorization Header",
+ "连接后出现状态文案",
+ "重放 toast: 重放成功 · HTTP 200 · 22ms"
+ ],
+ "fail": [
+ "WS流程: locator.click: Timeout 15000ms exceeded.\nCall log:\n - waiting for getByRole('button', { name: /发送/ }).first()\n - locator resolved to \n - attempting click action\n 2 × waiting for element to be visible, enabled and stable\n - element is not enabled\n - retrying click action\n - waiting 20ms\n 2 × waiting for element to be visible, enabled and stable\n - element is not enabled\n - retrying click action\n - waiting 100ms\n 28 × waiting for element to be visible, enabled and stable\n - element is not enabled\n - retrying click action\n - waiting 500ms\n"
+ ],
+ "notes": [
+ "title=OpsxAPI · 接口工作台",
+ "selectCount=1",
+ "select0=自动/Web 代理/浏览器直连/桌面直连"
+ ]
+}
\ No newline at end of file
diff --git a/.ui-review/accept-2026-09-19/web-reg.mjs b/.ui-review/accept-2026-09-19/web-reg.mjs
new file mode 100644
index 0000000..765a6ec
--- /dev/null
+++ b/.ui-review/accept-2026-09-19/web-reg.mjs
@@ -0,0 +1,255 @@
+import { chromium } from 'playwright';
+import fs from 'fs';
+import path from 'path';
+
+const outDir = path.resolve('.ui-review/accept-2026-09-19');
+const log = [];
+const shot = async (page, name) => {
+ const p = path.join(outDir, name);
+ await page.screenshot({ path: p, fullPage: false });
+ log.push(`shot:${name}`);
+};
+
+const result = { pass: [], fail: [], notes: [] };
+
+async function main() {
+ const browser = await chromium.launch({ headless: true });
+ const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
+ page.setDefaultTimeout(12000);
+ await page.goto('http://127.0.0.1:5173/', { waitUntil: 'networkidle' });
+ await shot(page, 'web-01-home.png');
+
+ // Channel switch
+ try {
+ const channel = page.getByRole('combobox').or(page.locator('select, [aria-label*="通道"], button:has-text("通道")')).first();
+ // Top bar has 通道 popup - from AX it was AXPopUpButton 发信通道
+ const pop = page.locator('text=通道').first();
+ await pop.click({ timeout: 5000 }).catch(() => {});
+ // Try select options visible
+ const modes = ['自动', 'Web代理', '浏览器', '桌面'];
+ let switched = false;
+ for (const m of modes) {
+ const opt = page.getByText(m, { exact: true }).first();
+ if (await opt.isVisible().catch(() => false)) {
+ await opt.click();
+ switched = true;
+ result.pass.push(`通道切换点击到「${m}」`);
+ break;
+ }
+ }
+ // Also try native select
+ const select = page.locator('select').filter({ hasText: /自动|代理|浏览器|桌面/ }).first();
+ if (await select.count()) {
+ const options = await select.locator('option').allTextContents();
+ result.notes.push('channel select options: ' + options.join('|'));
+ if (options.length > 1) {
+ await select.selectOption({ index: 1 });
+ await select.selectOption({ index: 0 });
+ switched = true;
+ result.pass.push('通道 select 往返切换');
+ }
+ }
+ if (!switched) result.fail.push('通道切换:未找到可点选项');
+ await shot(page, 'web-02-channel.png');
+ } catch (e) {
+ result.fail.push('通道切换异常: ' + e.message);
+ }
+
+ // Env switch - button 本地 1 变量
+ try {
+ const envBtn = page.getByRole('button', { name: /变量|环境/ }).first();
+ if (await envBtn.count()) {
+ await envBtn.click();
+ result.pass.push('打开环境/变量');
+ await page.keyboard.press('Escape').catch(() => {});
+ } else {
+ result.fail.push('环境切换:未找到环境按钮');
+ }
+ await shot(page, 'web-03-env.png');
+ } catch (e) {
+ result.fail.push('环境: ' + e.message);
+ }
+
+ // Collection tree - click an existing request
+ try {
+ const req = page.getByText('回归 · GET Query').first();
+ await req.click({ timeout: 5000 });
+ result.pass.push('集合树点击切换到「回归 · GET Query」');
+ await shot(page, 'web-04-tree.png');
+ } catch (e) {
+ result.fail.push('集合树点击: ' + e.message);
+ }
+
+ // Dirty save - edit URL then look for save/dirty indicator
+ try {
+ const urlInput = page.locator('input').filter({ hasText: '' }).nth(0);
+ // Prefer visible URL field near method
+ const candidates = page.locator('input[value*="http"], input[placeholder*="http"], textarea, input');
+ let edited = false;
+ const count = await candidates.count();
+ for (let i = 0; i < Math.min(count, 30); i++) {
+ const el = candidates.nth(i);
+ const val = await el.inputValue().catch(() => '');
+ if (val.includes('http') || val.includes('127.0.0.1') || val.includes('/v1/')) {
+ await el.click();
+ await el.fill(val + (val.includes('?') ? '&' : '?') + '_dirty=1');
+ edited = true;
+ result.pass.push('编辑 URL 制造脏点');
+ break;
+ }
+ }
+ if (!edited) result.notes.push('未定位到 URL 输入框,跳过脏点编辑');
+ // Try save shortcut or save button
+ await page.keyboard.press('Meta+s').catch(() => {});
+ const saveBtn = page.getByRole('button', { name: /保存/ }).first();
+ if (await saveBtn.isVisible().catch(() => false)) {
+ await saveBtn.click();
+ result.pass.push('点击保存');
+ } else {
+ result.notes.push('未见「保存」按钮(可能自动持久化)');
+ }
+ await shot(page, 'web-05-dirty.png');
+ } catch (e) {
+ result.fail.push('脏点保存: ' + e.message);
+ }
+
+ // New WebSocket via 新建 menu
+ try {
+ await page.getByRole('button', { name: '新建' }).first().click();
+ await page.waitForTimeout(400);
+ const wsItem = page.getByText(/WebSocket|WS/i).first();
+ if (await wsItem.isVisible().catch(() => false)) {
+ await wsItem.click();
+ result.pass.push('新建 WebSocket');
+ } else {
+ // menu items
+ const items = await page.locator('[role="menu"] >> text=/WebSocket|websocket|WS/i').allTextContents().catch(() => []);
+ result.notes.push('新建菜单项: ' + JSON.stringify(items));
+ result.fail.push('新建菜单未见 WebSocket');
+ }
+ await shot(page, 'web-06-new-ws.png');
+ } catch (e) {
+ result.fail.push('新建 WS: ' + e.message);
+ }
+
+ // Fill WS url + header if editor present
+ try {
+ const urlBox = page.locator('input').filter({ hasNot: page.locator('[type=checkbox]') });
+ // fill first empty-ish or wss field
+ let filledUrl = false;
+ const n = await page.locator('input').count();
+ for (let i = 0; i < n; i++) {
+ const el = page.locator('input').nth(i);
+ const ph = (await el.getAttribute('placeholder').catch(() => '')) || '';
+ const val = await el.inputValue().catch(() => '');
+ if (/wss?|url|地址/i.test(ph) || val.startsWith('ws') || (val === '' && /url/i.test(ph))) {
+ await el.fill('wss://ws.postman-echo.com/raw');
+ filledUrl = true;
+ break;
+ }
+ }
+ if (!filledUrl) {
+ // try active tab url field - often the long input
+ const longInputs = page.locator('input.ops-input, input[class*="input"]');
+ if (await longInputs.count()) {
+ await longInputs.first().fill('wss://ws.postman-echo.com/raw');
+ filledUrl = true;
+ }
+ }
+ if (filledUrl) result.pass.push('填写 WS URL');
+ else result.fail.push('未能填写 WS URL');
+
+ // Headers tab
+ const hdrTab = page.getByRole('tab', { name: /Header|请求头|头/i }).or(page.getByText('Headers')).first();
+ if (await hdrTab.isVisible().catch(() => false)) await hdrTab.click();
+ // Add Authorization row
+ const addHdr = page.getByRole('button', { name: /添加|Add/ }).first();
+ if (await addHdr.isVisible().catch(() => false)) await addHdr.click();
+ // Fill key/value - best effort
+ await page.getByPlaceholder(/[Kk]ey|名称|Header/).first().fill('Authorization').catch(() => {});
+ await page.getByPlaceholder(/[Vv]alue|值/).first().fill('Bearer smoke').catch(() => {});
+ // fallback: any two empty inputs in header editor
+ result.notes.push('Header 填写尽力而为');
+ await shot(page, 'web-07-ws-filled.png');
+
+ const connectBtn = page.getByRole('button', { name: /连接|Connect/ }).first();
+ if (await connectBtn.isVisible().catch(() => false)) {
+ await connectBtn.click();
+ await page.waitForTimeout(2500);
+ result.pass.push('点击连接');
+ const bodyText = await page.locator('body').innerText();
+ if (/open|已连接|连接成功|ping|tunnel|自定义 Header/i.test(bodyText)) {
+ result.pass.push('连接后 UI 出现成功/系统相关文案');
+ } else {
+ result.notes.push('连接后未明确看到成功文案,需人工看截图');
+ }
+ // try send
+ const sendBox = page.locator('textarea, input[placeholder*="消息"], input[placeholder*="发送"]').first();
+ if (await sendBox.isVisible().catch(() => false)) {
+ await sendBox.fill('ping-gui-reg');
+ const sendBtn = page.getByRole('button', { name: /发送|Send/ }).first();
+ if (await sendBtn.isVisible().catch(() => false)) await sendBtn.click();
+ await page.waitForTimeout(1500);
+ result.pass.push('尝试发送消息');
+ }
+ } else {
+ result.fail.push('未见连接按钮');
+ }
+ await shot(page, 'web-08-ws-connected.png');
+ } catch (e) {
+ result.fail.push('WS 填表/连接: ' + e.message);
+ }
+
+ // History replay toast
+ try {
+ await page.getByRole('button', { name: /历史/ }).first().click();
+ await page.waitForTimeout(500);
+ await shot(page, 'web-09-history.png');
+ const replay = page.getByRole('button', { name: /重放/ }).first();
+ if (await replay.isVisible().catch(() => false)) {
+ await replay.click();
+ await page.waitForTimeout(2000);
+ const toast = page.locator('.ops-toast, [class*="toast"]');
+ const toastText = (await toast.allTextContents().catch(() => [])).join(' | ');
+ const body = await page.locator('body').innerText();
+ if (/正在重放|重放成功|重放完成|重放失败|重放连接/i.test(toastText + body)) {
+ result.pass.push('历史重放出现 toast/文案: ' + (toastText || 'body匹配').slice(0, 120));
+ } else {
+ result.fail.push('点了重放但未见 toast 文案');
+ }
+ } else {
+ // hover history rows
+ const rows = page.locator('text=/GET|POST|WS|重放/');
+ result.notes.push('历史面板打开;重放按钮可能需悬停');
+ const hist = page.locator('.ops-tree-row, [class*="history"]');
+ if (await hist.count()) {
+ await hist.first().hover();
+ await page.waitForTimeout(300);
+ if (await page.getByRole('button', { name: /重放/ }).first().isVisible().catch(() => false)) {
+ await page.getByRole('button', { name: /重放/ }).first().click();
+ await page.waitForTimeout(2000);
+ const body = await page.locator('body').innerText();
+ if (/正在重放|重放成功|重放完成|重放失败|重放连接/i.test(body)) {
+ result.pass.push('悬停后重放 toast 文案可见');
+ } else result.fail.push('悬停重放后未见 toast');
+ } else result.fail.push('悬停后仍无重放按钮');
+ } else result.fail.push('历史为空或结构不符');
+ }
+ await shot(page, 'web-10-replay.png');
+ } catch (e) {
+ result.fail.push('历史重放: ' + e.message);
+ }
+
+ await browser.close();
+ const summary = {
+ verdict: result.fail.length ? '有缺陷' : '通过',
+ ...result,
+ };
+ fs.writeFileSync(path.join(outDir, 'web-reg-result.json'), JSON.stringify(summary, null, 2));
+ console.log(JSON.stringify(summary, null, 2));
+}
+
+main().catch((e) => {
+ console.error(e);
+ process.exit(1);
+});
diff --git a/.ui-review/accept-2026-09-19/web-reg2.mjs b/.ui-review/accept-2026-09-19/web-reg2.mjs
new file mode 100644
index 0000000..6b2ee06
--- /dev/null
+++ b/.ui-review/accept-2026-09-19/web-reg2.mjs
@@ -0,0 +1,275 @@
+import { chromium } from 'playwright';
+import fs from 'fs';
+import path from 'path';
+
+const outDir = path.resolve('.ui-review/accept-2026-09-19');
+const result = { pass: [], fail: [], notes: [] };
+const shot = async (page, name) => {
+ await page.screenshot({ path: path.join(outDir, name) });
+};
+
+async function main() {
+ const browser = await chromium.launch({
+ headless: true,
+ channel: 'chrome',
+ });
+ const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
+ page.setDefaultTimeout(15000);
+ await page.goto('http://127.0.0.1:5173/', { waitUntil: 'domcontentloaded' });
+ await page.waitForTimeout(1500);
+ await shot(page, 'web-01-home.png');
+ const title = await page.title();
+ result.notes.push('title=' + title);
+
+ // Channel: look for select near 通道
+ try {
+ const selects = page.locator('select');
+ const sc = await selects.count();
+ result.notes.push('selectCount=' + sc);
+ let channelDone = false;
+ for (let i = 0; i < sc; i++) {
+ const sel = selects.nth(i);
+ const opts = await sel.locator('option').allTextContents();
+ result.notes.push('select' + i + '=' + opts.join('/'));
+ if (opts.some((o) => /自动|代理|浏览器|桌面|Web|browser|tauri|auto/i.test(o))) {
+ const values = await sel.locator('option').evaluateAll((os) => os.map((o) => o.value));
+ if (values.length > 1) {
+ await sel.selectOption(values[1]);
+ await page.waitForTimeout(300);
+ await sel.selectOption(values[0]);
+ result.pass.push('通道切换往返: ' + opts.join('|'));
+ channelDone = true;
+ break;
+ }
+ }
+ }
+ if (!channelDone) {
+ // click text 通道 then options
+ await page.getByText('通道', { exact: true }).first().click().catch(() => {});
+ await page.waitForTimeout(300);
+ for (const m of ['Web 代理', 'Web代理', '浏览器', '桌面', '自动']) {
+ const o = page.getByText(m, { exact: true });
+ if (await o.count()) {
+ await o.first().click();
+ result.pass.push('通道点选 ' + m);
+ channelDone = true;
+ break;
+ }
+ }
+ }
+ if (!channelDone) result.fail.push('通道切换失败');
+ await shot(page, 'web-02-channel.png');
+ } catch (e) {
+ result.fail.push('通道: ' + e.message);
+ }
+
+ // Env
+ try {
+ const envBtn = page.getByRole('button', { name: /变量/ });
+ if (await envBtn.count()) {
+ await envBtn.first().click();
+ await page.waitForTimeout(500);
+ result.pass.push('打开环境变量面板');
+ await page.keyboard.press('Escape');
+ } else result.fail.push('无环境变量按钮');
+ await shot(page, 'web-03-env.png');
+ } catch (e) {
+ result.fail.push('环境: ' + e.message);
+ }
+
+ // Tree
+ try {
+ await page.getByText('回归 · GET Query').first().click({ timeout: 8000 });
+ result.pass.push('集合树切换到回归 GET');
+ await page.waitForTimeout(500);
+ await shot(page, 'web-04-tree.png');
+ } catch (e) {
+ result.fail.push('集合树: ' + e.message);
+ }
+
+ // Dirty
+ try {
+ let edited = false;
+ const inputs = page.locator('input');
+ const n = await inputs.count();
+ for (let i = 0; i < n; i++) {
+ const el = inputs.nth(i);
+ const val = await el.inputValue().catch(() => '');
+ if (/https?:|127\.0\.0\.1|\/v1\//.test(val)) {
+ await el.fill(val.replace(/([?&]_dirty=1)/g, '') + (val.includes('?') ? '&' : '?') + '_dirty=1');
+ edited = true;
+ result.pass.push('URL 脏点已写入');
+ break;
+ }
+ }
+ if (!edited) result.fail.push('未找到可编辑 URL');
+ // look for dirty marker / save
+ await page.keyboard.press('Meta+s');
+ await page.waitForTimeout(400);
+ const body = await page.locator('body').innerText();
+ if (/未保存|脏|dirty|\*/i.test(body) || (await page.getByRole('button', { name: /保存/ }).count())) {
+ result.pass.push('脏点/保存相关 UI 可见或已 Cmd+S');
+ } else {
+ result.notes.push('可能自动持久化,未见显式脏标记');
+ }
+ await shot(page, 'web-05-dirty.png');
+ } catch (e) {
+ result.fail.push('脏点: ' + e.message);
+ }
+
+ // First send HTTP to create history
+ try {
+ const send = page.getByRole('button', { name: /发送/ }).first();
+ if (await send.count()) {
+ await send.click();
+ await page.waitForTimeout(2000);
+ result.pass.push('发送 HTTP 以产生历史');
+ }
+ } catch (e) {
+ result.notes.push('发送: ' + e.message);
+ }
+
+ // New WS
+ try {
+ await page.getByRole('button', { name: '新建' }).first().click();
+ await page.waitForTimeout(500);
+ await shot(page, 'web-06-new-menu.png');
+ const cand = page.locator('text=/WebSocket|Websocket|WS/i');
+ if (await cand.count()) {
+ await cand.first().click();
+ result.pass.push('新建 WebSocket');
+ } else {
+ result.fail.push('新建菜单无 WebSocket: ' + (await page.locator('body').innerText()).slice(0, 200));
+ }
+ await page.waitForTimeout(600);
+ await shot(page, 'web-07-ws-editor.png');
+ } catch (e) {
+ result.fail.push('新建WS: ' + e.message);
+ }
+
+ // Fill WS
+ try {
+ // URL field
+ const inputs = page.locator('input');
+ let ok = false;
+ for (let i = 0; i < await inputs.count(); i++) {
+ const el = inputs.nth(i);
+ const ph = ((await el.getAttribute('placeholder')) || '') + (await el.getAttribute('aria-label') || '');
+ const val = await el.inputValue().catch(() => '');
+ if (/ws|url|地址|主机/i.test(ph + val) || val.startsWith('ws') || (i < 5 && val === '')) {
+ // prefer empty or ws-looking near top of editor
+ }
+ }
+ // Prefer the main URL bar: often largest width input with ws default
+ const urlCandidates = await page.locator('input.ops-input, input').all();
+ for (const el of urlCandidates) {
+ const val = await el.inputValue().catch(() => '');
+ const box = await el.boundingBox().catch(() => null);
+ if (!box || box.width < 200) continue;
+ if (val.startsWith('ws') || val === '' || /echo|localhost|127/.test(val)) {
+ await el.fill('wss://ws.postman-echo.com/raw');
+ ok = true;
+ break;
+ }
+ }
+ if (ok) result.pass.push('填写 WS URL');
+ else {
+ // force first wide input
+ for (const el of urlCandidates) {
+ const box = await el.boundingBox().catch(() => null);
+ if (box && box.width > 300) {
+ await el.fill('wss://ws.postman-echo.com/raw');
+ ok = true;
+ result.pass.push('填写 WS URL(宽输入框)');
+ break;
+ }
+ }
+ }
+ if (!ok) result.fail.push('WS URL 填写失败');
+
+ // Headers
+ const headerTab = page.getByText('Headers', { exact: true }).or(page.getByText('请求头')).or(page.getByRole('tab', { name: /Header/i }));
+ if (await headerTab.count()) await headerTab.first().click().catch(() => {});
+ await page.waitForTimeout(300);
+ // try add row
+ const addBtns = page.getByRole('button', { name: /添加|+|\+/ });
+ if (await addBtns.count()) await addBtns.first().click().catch(() => {});
+ // fill key values in kv editor
+ const placeholders = page.locator('input[placeholder*="Key"], input[placeholder*="键"], input[placeholder*="Header"], input[placeholder*="名称"]');
+ if (await placeholders.count()) {
+ await placeholders.first().fill('Authorization');
+ const valPh = page.locator('input[placeholder*="Value"], input[placeholder*="值"]');
+ if (await valPh.count()) await valPh.first().fill('Bearer smoke');
+ result.pass.push('填写 Authorization Header');
+ } else {
+ // generic: last two empty inputs in view
+ result.notes.push('Header 占位符未命中,尝试通用 KV');
+ const all = page.locator('input');
+ const vals = [];
+ for (let i = 0; i < await all.count(); i++) {
+ const v = await all.nth(i).inputValue().catch(() => null);
+ vals.push(v);
+ }
+ result.notes.push('inputs snapshot count=' + vals.length);
+ }
+
+ await shot(page, 'web-08-ws-filled.png');
+ const connect = page.getByRole('button', { name: /连接/ });
+ if (await connect.count()) {
+ await connect.first().click();
+ await page.waitForTimeout(3000);
+ const text = await page.locator('body').innerText();
+ if (/桌面原生|Gin 隧道|自定义 Header|open|已连接|连接成功|status/i.test(text)) {
+ result.pass.push('连接后出现状态文案');
+ } else result.notes.push('连接后文案不明确');
+ const sendInput = page.locator('textarea, input[placeholder*="消息"], input[placeholder*="发送"]');
+ if (await sendInput.count()) {
+ await sendInput.first().fill('ping-gui-reg');
+ const sendBtn = page.getByRole('button', { name: /发送/ });
+ if (await sendBtn.count()) {
+ await sendBtn.first().click();
+ await page.waitForTimeout(1500);
+ result.pass.push('已发送 ping-gui-reg');
+ }
+ }
+ await shot(page, 'web-09-ws-live.png');
+ } else result.fail.push('无连接按钮');
+ } catch (e) {
+ result.fail.push('WS流程: ' + e.message);
+ }
+
+ // History replay
+ try {
+ await page.getByRole('button', { name: /历史/ }).first().click();
+ await page.waitForTimeout(600);
+ await shot(page, 'web-10-history.png');
+ const rows = page.locator('.ops-tree-row, [class*="history"] >> ..');
+ // hover first history-like row
+ const replayBtn = page.getByRole('button', { name: '重放' });
+ if (!(await replayBtn.count()) || !(await replayBtn.first().isVisible())) {
+ const anyRow = page.locator('button:has-text("GET"), button:has-text("POST"), div.ops-tree-row').first();
+ if (await anyRow.count()) await anyRow.hover();
+ await page.waitForTimeout(400);
+ }
+ if (await replayBtn.count()) {
+ await replayBtn.first().click({ force: true });
+ await page.waitForTimeout(2200);
+ const toast = page.locator('.ops-toast');
+ const t = (await toast.allTextContents()).join(' | ');
+ const body = await page.locator('body').innerText();
+ if (/正在重放|重放成功|重放完成|重放失败|重放连接/i.test(t + body)) {
+ result.pass.push('重放 toast: ' + (t || 'body命中').slice(0, 160));
+ } else result.fail.push('重放后无 toast,toast=' + t);
+ } else result.fail.push('无重放按钮');
+ await shot(page, 'web-11-replay.png');
+ } catch (e) {
+ result.fail.push('重放: ' + e.message);
+ }
+
+ await browser.close();
+ const summary = { verdict: result.fail.length ? '有缺陷' : '通过', ...result };
+ fs.writeFileSync(path.join(outDir, 'web-reg-result.json'), JSON.stringify(summary, null, 2));
+ console.log(JSON.stringify(summary, null, 2));
+}
+
+main().catch((e) => { console.error(e); process.exit(1); });
diff --git a/.ui-review/accept-2026-09-19/web-ws-recheck.png b/.ui-review/accept-2026-09-19/web-ws-recheck.png
new file mode 100644
index 0000000..26bdd3b
Binary files /dev/null and b/.ui-review/accept-2026-09-19/web-ws-recheck.png differ
diff --git a/.ui-review/ax.json b/.ui-review/ax.json
new file mode 100644
index 0000000..317737c
--- /dev/null
+++ b/.ui-review/ax.json
@@ -0,0 +1,4 @@
+{
+ "clicked": "ws:WebSocket · echo",
+ "interesting": []
+}
\ No newline at end of file
diff --git a/.ui-review/body.txt b/.ui-review/body.txt
new file mode 100644
index 0000000..e48e6dd
--- /dev/null
+++ b/.ui-review/body.txt
@@ -0,0 +1,85 @@
+OpsxAPI
+HTTP · GraphQL · WS · SSE · Socket.IO · MQTT
+新建
+导入
+导出
+通道
+自动
+Web 代理
+浏览器直连
+桌面直连
+本
+本地
+1 变量
+本
+接口
+历史
+测试
+Mock
+文档
+设置
+登录
+接口
+历史
+示例集合
+HTTP · 本地回归(P0)
+GET
+回归 · GET Query
+POST
+回归 · POST JSON
+POST
+回归 · POST urlencoded
+POST
+回归 · POST form-data
+GET
+回归 · 自定义 Header
+GET
+回归 · Cookie
+GET
+回归 · Bearer
+GET
+回归 · Basic Auth
+HTTP · httpbin
+GET
+获取请求回显
+POST
+提交 JSON
+POST
+表单提交
+GET
+Bearer 鉴权示例
+GET
+状态码 · 404
+GraphQL
+国家查询 · countries
+实时协议
+WebSocket · echo
+SSE · 维基媒体变更流
+MQTT · EMQX 公共 Broker
+Socket.IO · 本地服务
+GET
+回归 · GET Query
+×
+GET
+POST
+PUT
+PATCH
+DELETE
+HEAD
+OPTIONS
+发送
+代码
+Mock 链接
+Params1
+Body
+Headers
+Cookies
+Auth
+说明
+共 1 行 · 启用 1 行
+添加
+响应体
+Raw
+响应头
+Status · Time · Size
+发送请求后这里显示响应
\ No newline at end of file
diff --git a/.ui-review/body2.txt b/.ui-review/body2.txt
new file mode 100644
index 0000000..d7ae29a
--- /dev/null
+++ b/.ui-review/body2.txt
@@ -0,0 +1,91 @@
+OpsxAPI
+HTTP · GraphQL · WS · SSE · Socket.IO · MQTT
+新建
+导入
+导出
+通道
+自动
+Web 代理
+浏览器直连
+桌面直连
+本
+本地
+1 变量
+本
+接口
+历史
+测试
+Mock
+文档
+设置
+登录
+接口
+历史
+1
+示例集合
+HTTP · 本地回归(P0)
+GET
+回归 · GET Query
+POST
+回归 · POST JSON
+POST
+回归 · POST urlencoded
+POST
+回归 · POST form-data
+GET
+回归 · 自定义 Header
+GET
+回归 · Cookie
+GET
+回归 · Bearer
+GET
+回归 · Basic Auth
+HTTP · httpbin
+GET
+获取请求回显
+POST
+提交 JSON
+POST
+表单提交
+GET
+Bearer 鉴权示例
+GET
+状态码 · 404
+GraphQL
+国家查询 · countries
+实时协议
+WebSocket · echo
+SSE · 维基媒体变更流
+MQTT · EMQX 公共 Broker
+Socket.IO · 本地服务
+GET
+回归 · GET Query
+×
+WebSocket · echo
+×
+WS
+连接
+未连接
+↑ 0 · ↓ 0
+说明
+鉴权 / 握手 HEADERS
+添加
+子协议(逗号分隔)
+快捷命令
+Ping
+Auth
+Subscribe
+Unsubscribe
+点击填入发送区 · Shift+点击直发
+高级 · websocat 命令
+发送消息
+格式化 JSON
+⌘/Ctrl + Enter 快速发送
+消息日志 · 0/0
+全部
+发送
+接收
+系统
+错误
+清空
+连接后收发消息会显示在这里
\ No newline at end of file
diff --git a/.ui-review/dom-texts.json b/.ui-review/dom-texts.json
new file mode 100644
index 0000000..3bc1f0a
--- /dev/null
+++ b/.ui-review/dom-texts.json
@@ -0,0 +1,322 @@
+[
+ {
+ "t": "OpsxAPI\nHTTP · GraphQL · WS · SSE · Sock",
+ "tag": "DIV"
+ },
+ {
+ "t": "OpsxAPI\nHTTP · GraphQL · WS · SSE · Sock",
+ "tag": "DIV"
+ },
+ {
+ "t": "OpsxAPI\nHTTP · GraphQL · WS · SSE · Sock",
+ "tag": "DIV"
+ },
+ {
+ "t": "OpsxAPI",
+ "tag": "DIV"
+ },
+ {
+ "t": "OpsxAPI",
+ "tag": "SPAN"
+ },
+ {
+ "t": "HTTP · GraphQL · WS · SSE · Socket.IO · ",
+ "tag": "SPAN"
+ },
+ {
+ "t": "新建\n导入\n导出\n本\n本地\n1 变量",
+ "tag": "DIV"
+ },
+ {
+ "t": "新建",
+ "tag": "BUTTON"
+ },
+ {
+ "t": "导入",
+ "tag": "BUTTON"
+ },
+ {
+ "t": "导出",
+ "tag": "BUTTON"
+ },
+ {
+ "t": "本\n本地\n1 变量",
+ "tag": "BUTTON"
+ },
+ {
+ "t": "本",
+ "tag": "SPAN"
+ },
+ {
+ "t": "本地",
+ "tag": "SPAN"
+ },
+ {
+ "t": "1 变量",
+ "tag": "SPAN"
+ },
+ {
+ "t": "本\n接口\n历史\n测试\nMock\n文档\n设置\n登录\n接口\n历史\n示例集合\nHTTP",
+ "tag": "DIV"
+ },
+ {
+ "t": "本",
+ "tag": "BUTTON"
+ },
+ {
+ "t": "本",
+ "tag": "SPAN"
+ },
+ {
+ "t": "接口",
+ "tag": "BUTTON"
+ },
+ {
+ "t": "接口",
+ "tag": "SPAN"
+ },
+ {
+ "t": "历史",
+ "tag": "BUTTON"
+ },
+ {
+ "t": "历史",
+ "tag": "SPAN"
+ },
+ {
+ "t": "测试",
+ "tag": "BUTTON"
+ },
+ {
+ "t": "测试",
+ "tag": "SPAN"
+ },
+ {
+ "t": "Mock",
+ "tag": "BUTTON"
+ },
+ {
+ "t": "Mock",
+ "tag": "SPAN"
+ },
+ {
+ "t": "文档",
+ "tag": "A"
+ },
+ {
+ "t": "文档",
+ "tag": "SPAN"
+ },
+ {
+ "t": "设置",
+ "tag": "BUTTON"
+ },
+ {
+ "t": "设置",
+ "tag": "SPAN"
+ },
+ {
+ "t": "登录",
+ "tag": "BUTTON"
+ },
+ {
+ "t": "登录",
+ "tag": "SPAN"
+ },
+ {
+ "t": "接口\n历史\n示例集合\nHTTP · 本地回归(P0)\nGET\n回归 · GET ",
+ "tag": "DIV"
+ },
+ {
+ "t": "接口\n历史",
+ "tag": "DIV"
+ },
+ {
+ "t": "接口\n历史",
+ "tag": "DIV"
+ },
+ {
+ "t": "接口",
+ "tag": "BUTTON"
+ },
+ {
+ "t": "历史",
+ "tag": "BUTTON"
+ },
+ {
+ "t": "示例集合\nHTTP · 本地回归(P0)\nGET\n回归 · GET Query\n",
+ "tag": "DIV"
+ },
+ {
+ "t": "示例集合\nHTTP · 本地回归(P0)\nGET\n回归 · GET Query\n",
+ "tag": "DIV"
+ },
+ {
+ "t": "示例集合",
+ "tag": "DIV"
+ },
+ {
+ "t": "示例集合",
+ "tag": "SPAN"
+ },
+ {
+ "t": "示例集合",
+ "tag": "SPAN"
+ },
+ {
+ "t": "HTTP · 本地回归(P0)\nGET\n回归 · GET Query\nPOST\n",
+ "tag": "DIV"
+ },
+ {
+ "t": "HTTP · 本地回归(P0)",
+ "tag": "DIV"
+ },
+ {
+ "t": "HTTP · 本地回归(P0)",
+ "tag": "SPAN"
+ },
+ {
+ "t": "HTTP · 本地回归(P0)",
+ "tag": "SPAN"
+ },
+ {
+ "t": "GET\n回归 · GET Query",
+ "tag": "DIV"
+ },
+ {
+ "t": "GET\n回归 · GET Query",
+ "tag": "DIV"
+ },
+ {
+ "t": "GET",
+ "tag": "SPAN"
+ },
+ {
+ "t": "回归 · GET Query",
+ "tag": "SPAN"
+ },
+ {
+ "t": "回归 · GET Query",
+ "tag": "SPAN"
+ },
+ {
+ "t": "POST\n回归 · POST JSON",
+ "tag": "DIV"
+ },
+ {
+ "t": "POST\n回归 · POST JSON",
+ "tag": "DIV"
+ },
+ {
+ "t": "POST",
+ "tag": "SPAN"
+ },
+ {
+ "t": "回归 · POST JSON",
+ "tag": "SPAN"
+ },
+ {
+ "t": "回归 · POST JSON",
+ "tag": "SPAN"
+ },
+ {
+ "t": "POST\n回归 · POST urlencoded",
+ "tag": "DIV"
+ },
+ {
+ "t": "POST\n回归 · POST urlencoded",
+ "tag": "DIV"
+ },
+ {
+ "t": "POST",
+ "tag": "SPAN"
+ },
+ {
+ "t": "回归 · POST urlencoded",
+ "tag": "SPAN"
+ },
+ {
+ "t": "回归 · POST urlencoded",
+ "tag": "SPAN"
+ },
+ {
+ "t": "POST\n回归 · POST form-data",
+ "tag": "DIV"
+ },
+ {
+ "t": "POST\n回归 · POST form-data",
+ "tag": "DIV"
+ },
+ {
+ "t": "POST",
+ "tag": "SPAN"
+ },
+ {
+ "t": "回归 · POST form-data",
+ "tag": "SPAN"
+ },
+ {
+ "t": "回归 · POST form-data",
+ "tag": "SPAN"
+ },
+ {
+ "t": "GET\n回归 · 自定义 Header",
+ "tag": "DIV"
+ },
+ {
+ "t": "GET\n回归 · 自定义 Header",
+ "tag": "DIV"
+ },
+ {
+ "t": "GET",
+ "tag": "SPAN"
+ },
+ {
+ "t": "回归 · 自定义 Header",
+ "tag": "SPAN"
+ },
+ {
+ "t": "回归 · 自定义 Header",
+ "tag": "SPAN"
+ },
+ {
+ "t": "GET\n回归 · Cookie",
+ "tag": "DIV"
+ },
+ {
+ "t": "GET\n回归 · Cookie",
+ "tag": "DIV"
+ },
+ {
+ "t": "GET",
+ "tag": "SPAN"
+ },
+ {
+ "t": "回归 · Cookie",
+ "tag": "SPAN"
+ },
+ {
+ "t": "回归 · Cookie",
+ "tag": "SPAN"
+ },
+ {
+ "t": "GET\n回归 · Bearer",
+ "tag": "DIV"
+ },
+ {
+ "t": "GET\n回归 · Bearer",
+ "tag": "DIV"
+ },
+ {
+ "t": "GET",
+ "tag": "SPAN"
+ },
+ {
+ "t": "回归 · Bearer",
+ "tag": "SPAN"
+ },
+ {
+ "t": "回归 · Bearer",
+ "tag": "SPAN"
+ }
+]
\ No newline at end of file
diff --git a/.ui-review/full.png b/.ui-review/full.png
new file mode 100644
index 0000000..4adf4d5
Binary files /dev/null and b/.ui-review/full.png differ
diff --git a/.ui-review/home.png b/.ui-review/home.png
new file mode 100644
index 0000000..220f608
Binary files /dev/null and b/.ui-review/home.png differ
diff --git a/.ui-review/probe30.json b/.ui-review/probe30.json
new file mode 100644
index 0000000..69d03d7
--- /dev/null
+++ b/.ui-review/probe30.json
@@ -0,0 +1,43 @@
+{
+ "probe": {
+ "save": true,
+ "dirtyDot": false,
+ "runMode": true,
+ "l2": true,
+ "bannerClasses": [],
+ "saveButtons": [
+ "● 保存"
+ ],
+ "topBits": [
+ "新建",
+ "导入",
+ "导出",
+ "自动Web 代理浏览器直连桌面直连",
+ "本本地1 变量",
+ "本",
+ "接口",
+ "历史",
+ "测试",
+ "Mock",
+ "设置",
+ "登录",
+ "接口",
+ "历史",
+ "×",
+ "GETPOSTPUTPATCHDELETEHEADOPTIONS",
+ "发送",
+ "● 保存",
+ "代码",
+ "Mock 链接",
+ "Params1",
+ "Body",
+ "Headers",
+ "Cookies",
+ "Auth"
+ ]
+ },
+ "forceBanner": {
+ "keys": []
+ },
+ "body": "OpsxAPI\nHTTP · GraphQL · WS · SSE · Socket.IO · MQTT\n新建\n导入\n导出\n通道\n自动\nWeb 代理\n浏览器直连\n桌面直连\n本\n本地\n1 变量\n本\n接口\n历史\n测试\nMock\n文档\n设置\n登录\n接口\n历史\n示例集合\nHTTP · 本地回归(P0)\nGET\n回归 · GET Query\nPOST\n回归 · POST JSON\nPOST\n回归 · POST urlencoded\nPOST\n回归 · POST form-data\nGET\n回归 · 自定义 Header\nGET\n回归 · Cookie\nGET\n回归 · Bearer\nGET\n回归 · Basic Auth\nHTTP · httpbin\nGET\n获取请求回显\nPOST\n提交 JSON\nPOST\n表单提交\nGET\nBearer 鉴权示例\nGET\n状态码 · 404\nGraphQL\n国家查询 · countries\n实时协议\nWebSocket · echo\nSSE · 维基媒体变更流\nMQTT · EMQX 公共 Broker\nSocket.IO · 本地服务\nGET\n回归 · GET Query\n×\nWebSocket · echo\n×\nWS\n连接\n已保存\n未连接\n↑ 0 · ↓ 0\n说明\n鉴权 / 握手 HEADERS\n添加\n子协议(逗号分隔)\n快捷命令\nPing\nAuth\nSubscribe\nUnsubscribe\n点击填入发送区 · Shift+点击直发\n高级 · websocat 命令\n发送消息\n格式化 JSON\n⌘/Ctrl + Enter 快速发送\n消息日志 · 0/0\n全部\n发送\n接收\n系统\n错误\n清空\n连接后收发消息会显示在这里\n设置\n设置\n外观\n主题与界面\n网络\n请求与超时\nMock 数据\n智能匹配规则\nAI 助手\n模型供应商\n数据管理\n工作区与备份\n关于\n版本与链接\nMock 数据\n智能匹配规则\n启用 Mock 数据\n发送时解析参数与 Body 中的 @mock 表达式(如 @cname、@integer(1,100))\n智能 Mock 匹配\nJSON Body 中字段值未写 Mock 规则时,按字段名自动匹配下方规则生成\n智能匹配规则\n按从上到下的顺序匹配,先命中先用;* 通配任意字符,类型需与 JSON 字段一致。语法速查 →\n61 条\n新增规则\n恢复默认\n数据类型\n匹配方式\n匹配规则\nMock 表达式\n备注\n启用\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配"
+}
\ No newline at end of file
diff --git a/.ui-review/probe40.json b/.ui-review/probe40.json
new file mode 100644
index 0000000..06163df
--- /dev/null
+++ b/.ui-review/probe40.json
@@ -0,0 +1,56 @@
+{
+ "homeProbe": {
+ "logoTitle": "支持协议:HTTP · GraphQL · WebSocket · SSE · Socket.IO · MQTT",
+ "captions": [
+ "OpsxAPIAPI 工作台",
+ "OpsxAPIAPI 工作台",
+ "OpsxAPIAPI 工作台"
+ ],
+ "protoListVisible": false,
+ "channel": true,
+ "l2": [
+ {
+ "t": "测试",
+ "title": "测试 · 即将推出",
+ "opacity": "0.35"
+ },
+ {
+ "t": "Mock",
+ "title": "Mock · 即将推出",
+ "opacity": "0.35"
+ },
+ {
+ "t": "文档",
+ "title": "文档 · 即将推出",
+ "opacity": "0.35"
+ }
+ ]
+ },
+ "wsProbe": {
+ "emptyHeaderHint": true,
+ "connectCaption": true,
+ "backToBottom": false,
+ "sendDisabled": {
+ "t": "发送消息",
+ "disabled": true
+ },
+ "headerPlaceholder": [
+ {
+ "t": "import { injectIntoGlobalHook } from \"/@react-refresh\";\ninje",
+ "cls": "dark"
+ },
+ {
+ "t": "OpsxAPIAPI 工作台 新建 导入 导出通道自动Web 代理浏览器直连桌面直连本本地1 变量本 接口 ",
+ "cls": ""
+ },
+ {
+ "t": "OpsxAPIAPI 工作台 新建 导入 导出通道自动Web 代理浏览器直连桌面直连本本地1 变量本 接口 ",
+ "cls": ""
+ }
+ ]
+ },
+ "errProbe": {
+ "hasBanner": true,
+ "snippet": "\n新建\n导入\n导出\n通道\n自动\nWeb 代理\n浏览器直连\n桌面直连\n本\n本地\n1 变量\n本\n接口\n历史\n测试\nMock\n文档\n设置\n登录\n接口\n历史\n1\n示例集合\nHTTP · 本地回归(P0)\nGET\n回归 · GET Query\nPOST\n回归 · POST JSON\nPOS"
+ }
+}
\ No newline at end of file
diff --git a/.ui-review/rail-b-check.png b/.ui-review/rail-b-check.png
new file mode 100644
index 0000000..b8d8a93
Binary files /dev/null and b/.ui-review/rail-b-check.png differ
diff --git a/.ui-review/wire-account.html b/.ui-review/wire-account.html
new file mode 100644
index 0000000..396e9c6
--- /dev/null
+++ b/.ui-review/wire-account.html
@@ -0,0 +1,110 @@
+
+
+
+
OpsxAPI 左轨账号区线框
+
+
+OpsxAPI · 左轨账号 / 工作区线框 v1
+去掉「本」;本地/团队只在底部;已登录=头像菜单
+
+
+
A · 未登录
+
+
+
接口
+
测试
+
Mock
+
+
文档
+
设置
+
登录
+
+
+
变量入口并入账号区 →
+ 左轨顶无「本」字标
底栏仅「登录」入口
+
+
+
点击 → 登录弹层。登录后变头像。
+
+
+
B · 已登录 · 菜单开主方案
+
+
+
接口
+
测试
+
Mock
+
+
文档
+
设置
+
L
+
+
+
+
+
顶栏变量可弱化/去掉
+ 头像角标:绿=本地 / 蓝=团队
菜单内切工作区 + 变量入口
+
+
+
角标绿本地、蓝团队。变量进菜单。
+
+
+
C · 团队态(角标)
+
+
+
接口
+
测试
+
Mock
+
+
文档
+
设置
+
L
+
+
+
团队 · 已同步
+ 切到团队后角标变蓝;配置鉴权同步(加密)
+
+
+
不登录也能用本地;登录后才可切团队。
+
+
+
+
diff --git a/.ui-review/wire-account.png b/.ui-review/wire-account.png
new file mode 100644
index 0000000..fa55196
Binary files /dev/null and b/.ui-review/wire-account.png differ
diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock
index 14e7335..03f6388 100644
--- a/apps/desktop/src-tauri/Cargo.lock
+++ b/apps/desktop/src-tauri/Cargo.lock
@@ -329,7 +329,7 @@ checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
- "rand_core",
+ "rand_core 0.10.1",
]
[[package]]
@@ -529,6 +529,12 @@ dependencies = [
"syn 3.0.5",
]
+[[package]]
+name = "data-encoding"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
+
[[package]]
name = "dbus"
version = "0.9.12"
@@ -1086,7 +1092,7 @@ dependencies = [
"js-sys",
"libc",
"r-efi 6.0.0",
- "rand_core",
+ "rand_core 0.10.1",
"wasm-bindgen",
]
@@ -1350,7 +1356,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
- "webpki-roots",
+ "webpki-roots 1.0.9",
]
[[package]]
@@ -2170,13 +2176,17 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
name = "opsxapi"
version = "0.1.0"
dependencies = [
+ "futures-util",
+ "http",
"reqwest 0.12.28",
"serde",
"serde_json",
"tauri",
"tauri-build",
"tokio",
+ "tokio-tungstenite",
"url",
+ "uuid",
]
[[package]]
@@ -2373,6 +2383,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
[[package]]
name = "precomputed-hash"
version = "0.1.1"
@@ -2479,7 +2498,7 @@ dependencies = [
"bytes",
"getrandom 0.4.3",
"lru-slab",
- "rand",
+ "rand 0.10.2",
"rand_pcg",
"ring",
"rustc-hash",
@@ -2527,6 +2546,17 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+[[package]]
+name = "rand"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
+dependencies = [
+ "libc",
+ "rand_chacha",
+ "rand_core 0.6.4",
+]
+
[[package]]
name = "rand"
version = "0.10.2"
@@ -2535,7 +2565,26 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20",
"getrandom 0.4.3",
- "rand_core",
+ "rand_core 0.10.1",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.17",
]
[[package]]
@@ -2550,7 +2599,7 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
- "rand_core",
+ "rand_core 0.10.1",
]
[[package]]
@@ -2662,7 +2711,7 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
- "webpki-roots",
+ "webpki-roots 1.0.9",
]
[[package]]
@@ -3041,6 +3090,17 @@ dependencies = [
"stable_deref_trait",
]
+[[package]]
+name = "sha1"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "digest",
+]
+
[[package]]
name = "sha2"
version = "0.10.9"
@@ -3634,9 +3694,21 @@ dependencies = [
"mio",
"pin-project-lite",
"socket2",
+ "tokio-macros",
"windows-sys 0.61.2",
]
+[[package]]
+name = "tokio-macros"
+version = "2.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.5",
+]
+
[[package]]
name = "tokio-rustls"
version = "0.26.5"
@@ -3647,6 +3719,22 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "tokio-tungstenite"
+version = "0.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
+dependencies = [
+ "futures-util",
+ "log",
+ "rustls",
+ "rustls-pki-types",
+ "tokio",
+ "tokio-rustls",
+ "tungstenite",
+ "webpki-roots 0.26.11",
+]
+
[[package]]
name = "tokio-util"
version = "0.7.19"
@@ -3872,6 +3960,26 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+[[package]]
+name = "tungstenite"
+version = "0.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
+dependencies = [
+ "byteorder",
+ "bytes",
+ "data-encoding",
+ "http",
+ "httparse",
+ "log",
+ "rand 0.8.8",
+ "rustls",
+ "rustls-pki-types",
+ "sha1",
+ "thiserror 1.0.69",
+ "utf-8",
+]
+
[[package]]
name = "typeid"
version = "1.0.3"
@@ -3968,6 +4076,12 @@ dependencies = [
"url",
]
+[[package]]
+name = "utf-8"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
+
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -4196,6 +4310,15 @@ dependencies = [
"system-deps",
]
+[[package]]
+name = "webpki-roots"
+version = "0.26.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
+dependencies = [
+ "webpki-roots 1.0.9",
+]
+
[[package]]
name = "webpki-roots"
version = "1.0.9"
@@ -4735,6 +4858,26 @@ dependencies = [
"synstructure",
]
+[[package]]
+name = "zerocopy"
+version = "0.8.57"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.57"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
[[package]]
name = "zerofrom"
version = "0.1.8"
diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml
index cc3576c..8c5a87c 100644
--- a/apps/desktop/src-tauri/Cargo.toml
+++ b/apps/desktop/src-tauri/Cargo.toml
@@ -22,5 +22,9 @@ tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
-tokio = { version = "1", features = ["time"] }
+tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "net"] }
url = "2"
+tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
+futures-util = "0.3"
+uuid = { version = "1", features = ["v4"] }
+http = "1"
diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json
index 93feea7..e1bf037 100644
--- a/apps/desktop/src-tauri/capabilities/default.json
+++ b/apps/desktop/src-tauri/capabilities/default.json
@@ -2,10 +2,10 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capabilities for the OpsxAPI main window",
- "windows": [
- "main"
- ],
+ "windows": ["main"],
"permissions": [
- "core:default"
+ "core:default",
+ "core:event:default",
+ "transport"
]
}
diff --git a/apps/desktop/src-tauri/icons/128x128.png b/apps/desktop/src-tauri/icons/128x128.png
index fcf4d66..2bc70f0 100644
Binary files a/apps/desktop/src-tauri/icons/128x128.png and b/apps/desktop/src-tauri/icons/128x128.png differ
diff --git a/apps/desktop/src-tauri/icons/128x128@2x.png b/apps/desktop/src-tauri/icons/128x128@2x.png
index 40a1d8b..94b0565 100644
Binary files a/apps/desktop/src-tauri/icons/128x128@2x.png and b/apps/desktop/src-tauri/icons/128x128@2x.png differ
diff --git a/apps/desktop/src-tauri/icons/256x256.png b/apps/desktop/src-tauri/icons/256x256.png
new file mode 100644
index 0000000..94b0565
Binary files /dev/null and b/apps/desktop/src-tauri/icons/256x256.png differ
diff --git a/apps/desktop/src-tauri/icons/32x32.png b/apps/desktop/src-tauri/icons/32x32.png
index 7ab5a21..e23bb22 100644
Binary files a/apps/desktop/src-tauri/icons/32x32.png and b/apps/desktop/src-tauri/icons/32x32.png differ
diff --git a/apps/desktop/src-tauri/icons/512x512.png b/apps/desktop/src-tauri/icons/512x512.png
new file mode 100644
index 0000000..d777d0c
Binary files /dev/null and b/apps/desktop/src-tauri/icons/512x512.png differ
diff --git a/apps/desktop/src-tauri/icons/64x64.png b/apps/desktop/src-tauri/icons/64x64.png
index 9a5c66c..739fa50 100644
Binary files a/apps/desktop/src-tauri/icons/64x64.png and b/apps/desktop/src-tauri/icons/64x64.png differ
diff --git a/apps/desktop/src-tauri/icons/Square107x107Logo.png b/apps/desktop/src-tauri/icons/Square107x107Logo.png
index 3a67265..dff432a 100644
Binary files a/apps/desktop/src-tauri/icons/Square107x107Logo.png and b/apps/desktop/src-tauri/icons/Square107x107Logo.png differ
diff --git a/apps/desktop/src-tauri/icons/Square142x142Logo.png b/apps/desktop/src-tauri/icons/Square142x142Logo.png
index ca9d40b..9ef4d4e 100644
Binary files a/apps/desktop/src-tauri/icons/Square142x142Logo.png and b/apps/desktop/src-tauri/icons/Square142x142Logo.png differ
diff --git a/apps/desktop/src-tauri/icons/Square150x150Logo.png b/apps/desktop/src-tauri/icons/Square150x150Logo.png
index 9f4924f..fc75f02 100644
Binary files a/apps/desktop/src-tauri/icons/Square150x150Logo.png and b/apps/desktop/src-tauri/icons/Square150x150Logo.png differ
diff --git a/apps/desktop/src-tauri/icons/Square284x284Logo.png b/apps/desktop/src-tauri/icons/Square284x284Logo.png
index 0b666f0..fc27962 100644
Binary files a/apps/desktop/src-tauri/icons/Square284x284Logo.png and b/apps/desktop/src-tauri/icons/Square284x284Logo.png differ
diff --git a/apps/desktop/src-tauri/icons/Square30x30Logo.png b/apps/desktop/src-tauri/icons/Square30x30Logo.png
index f6d0149..c9ffa40 100644
Binary files a/apps/desktop/src-tauri/icons/Square30x30Logo.png and b/apps/desktop/src-tauri/icons/Square30x30Logo.png differ
diff --git a/apps/desktop/src-tauri/icons/Square310x310Logo.png b/apps/desktop/src-tauri/icons/Square310x310Logo.png
index e7eec54..5099088 100644
Binary files a/apps/desktop/src-tauri/icons/Square310x310Logo.png and b/apps/desktop/src-tauri/icons/Square310x310Logo.png differ
diff --git a/apps/desktop/src-tauri/icons/Square44x44Logo.png b/apps/desktop/src-tauri/icons/Square44x44Logo.png
index ca434bf..ed9c83e 100644
Binary files a/apps/desktop/src-tauri/icons/Square44x44Logo.png and b/apps/desktop/src-tauri/icons/Square44x44Logo.png differ
diff --git a/apps/desktop/src-tauri/icons/Square44x44Logo.targetsize-24_altform-unplated.png b/apps/desktop/src-tauri/icons/Square44x44Logo.targetsize-24_altform-unplated.png
new file mode 100644
index 0000000..ed9c83e
Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square44x44Logo.targetsize-24_altform-unplated.png differ
diff --git a/apps/desktop/src-tauri/icons/Square71x71Logo.png b/apps/desktop/src-tauri/icons/Square71x71Logo.png
index 4cace3c..55d57ef 100644
Binary files a/apps/desktop/src-tauri/icons/Square71x71Logo.png and b/apps/desktop/src-tauri/icons/Square71x71Logo.png differ
diff --git a/apps/desktop/src-tauri/icons/Square89x89Logo.png b/apps/desktop/src-tauri/icons/Square89x89Logo.png
index b227042..1b24e42 100644
Binary files a/apps/desktop/src-tauri/icons/Square89x89Logo.png and b/apps/desktop/src-tauri/icons/Square89x89Logo.png differ
diff --git a/apps/desktop/src-tauri/icons/StoreLogo.png b/apps/desktop/src-tauri/icons/StoreLogo.png
index 0c8d12e..99630f3 100644
Binary files a/apps/desktop/src-tauri/icons/StoreLogo.png and b/apps/desktop/src-tauri/icons/StoreLogo.png differ
diff --git a/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
index 6177315..a1bd0fc 100644
Binary files a/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png and b/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ
diff --git a/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png
index 34a8a07..349bf97 100644
Binary files a/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png and b/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
index 77a3311..48b4bd1 100644
Binary files a/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png and b/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
index 8466930..cc79e87 100644
Binary files a/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png and b/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ
diff --git a/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png
index 85be220..cf8849c 100644
Binary files a/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png and b/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
index e51e297..5e50dcd 100644
Binary files a/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png and b/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
index 1e5119c..b5f6697 100644
Binary files a/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png and b/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ
diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png
index 3470c23..9262141 100644
Binary files a/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png and b/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png
index 03028f9..0af3e0f 100644
Binary files a/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png and b/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
index 51fc3f0..47cc488 100644
Binary files a/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png and b/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png
index edbfbe9..5e9a9fd 100644
Binary files a/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png and b/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png
index 4686d87..540b31c 100644
Binary files a/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png and b/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
index eb05e0c..fec4c96 100644
Binary files a/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png and b/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png
index dc0df3c..07ba282 100644
Binary files a/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png and b/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png
index 002ab19..c6acff9 100644
Binary files a/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png and b/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/apps/desktop/src-tauri/icons/icon.icns b/apps/desktop/src-tauri/icons/icon.icns
index b51715a..f55d7c8 100644
Binary files a/apps/desktop/src-tauri/icons/icon.icns and b/apps/desktop/src-tauri/icons/icon.icns differ
diff --git a/apps/desktop/src-tauri/icons/icon.ico b/apps/desktop/src-tauri/icons/icon.ico
index cae568f..d73f964 100644
Binary files a/apps/desktop/src-tauri/icons/icon.ico and b/apps/desktop/src-tauri/icons/icon.ico differ
diff --git a/apps/desktop/src-tauri/icons/icon.png b/apps/desktop/src-tauri/icons/icon.png
index 28ae3d3..1a5c156 100644
Binary files a/apps/desktop/src-tauri/icons/icon.png and b/apps/desktop/src-tauri/icons/icon.png differ
diff --git a/apps/desktop/src-tauri/icons/icon.svg b/apps/desktop/src-tauri/icons/icon.svg
index 7ffb3b2..9bcc718 100644
--- a/apps/desktop/src-tauri/icons/icon.svg
+++ b/apps/desktop/src-tauri/icons/icon.svg
@@ -1,13 +1,5 @@
-
diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png
index ba91d70..c5b6cc1 100644
Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png differ
diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png
index 9af249a..8ae6604 100644
Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ
diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png
index 9af249a..8ae6604 100644
Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png differ
diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png
index 88bd0da..e7fa7e3 100644
Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png differ
diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png
index b04d76f..ede0898 100644
Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png differ
diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png
index 3d4ca88..fc63825 100644
Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ
diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png
index 3d4ca88..fc63825 100644
Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png differ
diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png
index ddcdddf..e4b2321 100644
Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png differ
diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png
index 9af249a..8ae6604 100644
Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png differ
diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png
index 59a535f..f57f044 100644
Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ
diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png
index 59a535f..f57f044 100644
Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png differ
diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png
index 5bb0874..a2ff797 100644
Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png differ
diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-512@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-512@2x.png
index 3f96515..bbe6c02 100644
Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-512@2x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-512@2x.png differ
diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png
index 5bb0874..a2ff797 100644
Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png differ
diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png
index 2735556..88783d8 100644
Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png differ
diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png
index b043b83..47a8011 100644
Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png differ
diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png
index eb659c7..ad0ac0b 100644
Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png differ
diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
index 4c43722..e1bde15 100644
Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ
diff --git a/apps/desktop/src-tauri/permissions/transport.toml b/apps/desktop/src-tauri/permissions/transport.toml
new file mode 100644
index 0000000..8838124
--- /dev/null
+++ b/apps/desktop/src-tauri/permissions/transport.toml
@@ -0,0 +1,4 @@
+[[permission]]
+identifier = "transport"
+description = "Allow HTTP/WebSocket native transport"
+commands.allow = ["http_send", "ws_connect", "ws_send", "ws_close"]
diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs
index 33da137..4dcacda 100644
--- a/apps/desktop/src-tauri/src/lib.rs
+++ b/apps/desktop/src-tauri/src/lib.rs
@@ -1,6 +1,12 @@
+use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
+use std::sync::Mutex;
use std::time::{Duration, Instant};
+use tauri::{AppHandle, Emitter, Manager, State};
+use tokio::sync::mpsc;
+use tokio_tungstenite::tungstenite::Message;
+use uuid::Uuid;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -32,10 +38,34 @@ struct HttpResult {
}
#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
struct WsSpec {
url: String,
protocols: Option>,
- headers: Option>,
+ #[serde(default)]
+ headers: HashMap,
+}
+
+#[derive(Clone, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct WsEvent {
+ session_id: String,
+ kind: String,
+ data: String,
+}
+
+enum WsCmd {
+ Send(String),
+ Close,
+}
+
+struct WsHandle {
+ tx: mpsc::UnboundedSender,
+}
+
+#[derive(Default)]
+struct WsState {
+ sessions: Mutex>,
}
fn validate_http_url(raw: &str) -> Result {
@@ -44,13 +74,35 @@ fn validate_http_url(raw: &str) -> Result {
"http" | "https" => {}
_ => return Err("only http/https schemes are allowed".into()),
}
+ match parsed.host_str().unwrap_or("").to_ascii_lowercase().as_str() {
+ "169.254.169.254" | "metadata.google.internal" => Err("blocked metadata host".into()),
+ _ => Ok(parsed),
+ }
+}
+
+fn validate_ws_url(raw: &str) -> Result {
+ let mut parsed = url::Url::parse(raw).map_err(|e| format!("invalid URL: {e}"))?;
+ match parsed.scheme() {
+ "ws" | "wss" => {}
+ "http" => {
+ parsed
+ .set_scheme("ws")
+ .map_err(|_| "cannot set ws scheme".to_string())?;
+ }
+ "https" => {
+ parsed
+ .set_scheme("wss")
+ .map_err(|_| "cannot set wss scheme".to_string())?;
+ }
+ _ => return Err("only ws/wss schemes are allowed".into()),
+ }
match parsed.host_str().unwrap_or("").to_ascii_lowercase().as_str() {
"169.254.169.254" | "metadata.google.internal" => {
return Err("blocked metadata host".into())
}
_ => {}
}
- Ok(parsed)
+ Ok(parsed.to_string())
}
#[tauri::command]
@@ -71,8 +123,8 @@ async fn http_send(spec: HttpSpec) -> Result {
.build()
.map_err(|e| e.to_string())?;
let mut request = client.request(method, url);
- for (key, value) in spec.headers {
- request = request.header(&key, &value);
+ for (key, value) in &spec.headers {
+ request = request.header(key, value);
}
if let Some(body) = spec.body {
if body.mode != "none" {
@@ -101,30 +153,135 @@ async fn http_send(spec: HttpSpec) -> Result {
}
#[tauri::command]
-async fn ws_connect(spec: WsSpec) -> Result {
- let _ = (&spec.protocols, &spec.headers);
- // TODO(week2): hold WebSocket sessions in managed state and emit messages.
- Err(format!(
- "WebSocket native tunnel is not implemented yet for {} (use browser WS in MVP)",
- spec.url
- ))
+async fn ws_connect(
+ app: AppHandle,
+ state: State<'_, WsState>,
+ spec: WsSpec,
+) -> Result {
+ let url = validate_ws_url(&spec.url)?;
+ let session_id = Uuid::new_v4().to_string();
+
+ let mut req = http::Request::builder().method("GET").uri(&url);
+ for (k, v) in &spec.headers {
+ req = req.header(k.as_str(), v.as_str());
+ }
+ if let Some(protocols) = &spec.protocols {
+ let joined = protocols
+ .iter()
+ .map(|s| s.trim())
+ .filter(|s| !s.is_empty())
+ .collect::>()
+ .join(", ");
+ if !joined.is_empty() {
+ req = req.header("Sec-WebSocket-Protocol", joined);
+ }
+ }
+ let request = req
+ .body(())
+ .map_err(|e| format!("build ws request: {e}"))?;
+
+ let (tx, mut rx) = mpsc::unbounded_channel::();
+ {
+ let mut map = state.sessions.lock().map_err(|e| e.to_string())?;
+ map.insert(session_id.clone(), WsHandle { tx });
+ }
+
+ let sid = session_id.clone();
+ let app2 = app.clone();
+ tauri::async_runtime::spawn(async move {
+ let emit = |kind: &str, data: String| {
+ let _ = app2.emit(
+ "ws-event",
+ WsEvent {
+ session_id: sid.clone(),
+ kind: kind.to_string(),
+ data,
+ },
+ );
+ };
+
+ match tokio_tungstenite::connect_async(request).await {
+ Ok((ws, _)) => {
+ emit("status", "open".into());
+ let (mut write, mut read) = ws.split();
+ loop {
+ tokio::select! {
+ cmd = rx.recv() => {
+ match cmd {
+ Some(WsCmd::Send(data)) => {
+ if write.send(Message::Text(data.into())).await.is_err() {
+ emit("status", "error".into());
+ break;
+ }
+ }
+ Some(WsCmd::Close) | None => {
+ let _ = write.close().await;
+ break;
+ }
+ }
+ }
+ msg = read.next() => {
+ match msg {
+ Some(Ok(Message::Text(t))) => emit("message", t.to_string()),
+ Some(Ok(Message::Binary(b))) => {
+ emit("message", format!("[binary {} bytes]", b.len()))
+ }
+ Some(Ok(Message::Close(_))) | None => break,
+ Some(Ok(_)) => {}
+ Some(Err(e)) => {
+ emit("message", format!("[ws error] {e}"));
+ emit("status", "error".into());
+ break;
+ }
+ }
+ }
+ }
+ }
+ emit("status", "closed".into());
+ }
+ Err(e) => {
+ emit("message", format!("[ws_connect error] {e}"));
+ emit("status", "error".into());
+ emit("status", "closed".into());
+ }
+ }
+
+ if let Some(st) = app2.try_state::() {
+ if let Ok(mut map) = st.sessions.lock() {
+ map.remove(&sid);
+ }
+ }
+ });
+
+ Ok(session_id)
}
#[tauri::command]
-async fn ws_send(session_id: String, data: String) -> Result<(), String> {
- let _ = (session_id, data);
- Err("WebSocket native send is a Week 2 stub".into())
+async fn ws_send(state: State<'_, WsState>, session_id: String, data: String) -> Result<(), String> {
+ let map = state.sessions.lock().map_err(|e| e.to_string())?;
+ let handle = map
+ .get(&session_id)
+ .ok_or_else(|| format!("unknown ws session {session_id}"))?;
+ handle
+ .tx
+ .send(WsCmd::Send(data))
+ .map_err(|_| "ws session closed".to_string())?;
+ Ok(())
}
#[tauri::command]
-async fn ws_close(session_id: String) -> Result<(), String> {
- let _ = session_id;
+async fn ws_close(state: State<'_, WsState>, session_id: String) -> Result<(), String> {
+ let mut map = state.sessions.lock().map_err(|e| e.to_string())?;
+ if let Some(handle) = map.remove(&session_id) {
+ let _ = handle.tx.send(WsCmd::Close);
+ }
Ok(())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
+ .manage(WsState::default())
.invoke_handler(tauri::generate_handler![
http_send,
ws_connect,
diff --git a/apps/server/cmd/opsxapi/main.go b/apps/server/cmd/opsxapi/main.go
index 88f3fea..db5751c 100644
--- a/apps/server/cmd/opsxapi/main.go
+++ b/apps/server/cmd/opsxapi/main.go
@@ -3,17 +3,28 @@ package main
import (
"log"
"os"
+ "strings"
"opsxapi/server/internal/server"
)
func main() {
- addr := os.Getenv("OPSXAPI_ADDR")
- if addr == "" {
- addr = ":8787"
- }
+ addr := resolveListenAddr()
log.Printf("OpsxAPI server listening on %s", addr)
if err := server.NewRouter().Run(addr); err != nil {
log.Fatal(err)
}
}
+
+// resolveListenAddr:默认仅本机 127.0.0.1:8787(Mock 本机优先)。
+// 显式对外:OPSXAPI_BIND_PUBLIC=1 或 OPSXAPI_ADDR=0.0.0.0:8787 / :8787。
+func resolveListenAddr() string {
+ if v := strings.TrimSpace(os.Getenv("OPSXAPI_ADDR")); v != "" {
+ return v
+ }
+ pub := strings.ToLower(strings.TrimSpace(os.Getenv("OPSXAPI_BIND_PUBLIC")))
+ if pub == "1" || pub == "true" || pub == "yes" {
+ return ":8787"
+ }
+ return "127.0.0.1:8787"
+}
diff --git a/apps/server/config.example.yaml b/apps/server/config.example.yaml
index f4d3a4a..1b46f22 100644
--- a/apps/server/config.example.yaml
+++ b/apps/server/config.example.yaml
@@ -1,7 +1,9 @@
server:
- addr: ":8787"
+ # 默认仅本机;公网需显式 OPSXAPI_BIND_PUBLIC=1 或 OPSXAPI_ADDR=:8787
+ addr: "127.0.0.1:8787"
proxy:
timeout_ms: 30000
# Environment overrides:
-# OPSXAPI_ADDR=:8787
+# OPSXAPI_ADDR=127.0.0.1:8787
+# OPSXAPI_BIND_PUBLIC=1
# OPSXAPI_PROXY_TIMEOUT_MS=30000
diff --git a/apps/server/data/opsxapi.db-shm b/apps/server/data/opsxapi.db-shm
index cb705a6..7a967bc 100644
Binary files a/apps/server/data/opsxapi.db-shm and b/apps/server/data/opsxapi.db-shm differ
diff --git a/apps/server/data/opsxapi.db-wal b/apps/server/data/opsxapi.db-wal
index b30bd1e..b521df8 100644
Binary files a/apps/server/data/opsxapi.db-wal and b/apps/server/data/opsxapi.db-wal differ
diff --git a/apps/server/internal/api/api.go b/apps/server/internal/api/api.go
index 4be826a..8a2d605 100644
--- a/apps/server/internal/api/api.go
+++ b/apps/server/internal/api/api.go
@@ -2,6 +2,7 @@
package api
import (
+ "net"
"net/http"
"strconv"
"strings"
@@ -43,6 +44,9 @@ func (a *API) Register(r *gin.Engine) {
g.GET("/workspaces/:id", a.auth(a.handleGetWorkspace))
g.PUT("/workspaces/:id", a.auth(a.handlePutWorkspace))
g.PUT("/workspaces/:id/mock", a.auth(a.handlePublishMock))
+ g.PUT("/mock/local", a.handlePublishLocalMock)
+ g.DELETE("/mock/local", a.handleRevokeLocalMock)
+ g.DELETE("/workspaces/:id/mock", a.auth(a.handleRevokeMock))
}
/* ---------------- 中间件 ---------------- */
@@ -312,7 +316,8 @@ func (a *API) handlePublishMock(c *gin.Context, user *store.User) {
return
}
var req struct {
- Endpoints []mocksrv.Endpoint `json:"endpoints" binding:"required"`
+ Endpoints []mocksrv.Endpoint `json:"endpoints" binding:"required"`
+ TTLSeconds int64 `json:"ttlSeconds"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 endpoints"})
@@ -322,15 +327,39 @@ func (a *API) handlePublishMock(c *gin.Context, user *store.User) {
c.JSON(http.StatusBadRequest, gin.H{"error": "单空间最多发布 200 个 Mock 接口"})
return
}
+ if req.TTLSeconds < 0 || req.TTLSeconds > 30*24*3600 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "ttlSeconds 需在 0~2592000(30 天)之间"})
+ return
+ }
if err := a.store.ReplaceMockEndpoints(ws.ID, req.Endpoints); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "发布失败:" + err.Error()})
return
}
+ var expiresAt int64
+ if req.TTLSeconds > 0 {
+ expiresAt = time.Now().Unix() + req.TTLSeconds
+ }
+ if err := a.store.SetMockExpiry(ws.ID, expiresAt); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "设置 TTL 失败:" + err.Error()})
+ return
+ }
base := ws.MockToken
if base == "" {
- base = strconv.FormatInt(ws.ID, 10) // 兜底(不应出现)
+ base = strconv.FormatInt(ws.ID, 10)
}
- c.JSON(http.StatusOK, gin.H{"ok": true, "count": len(req.Endpoints), "base": "/mock/" + base})
+ c.JSON(http.StatusOK, gin.H{"ok": true, "count": len(req.Endpoints), "base": "/mock/" + base, "expiresAt": expiresAt})
+}
+
+func (a *API) handleRevokeMock(c *gin.Context, user *store.User) {
+ ws := a.workspaceOf(c, user)
+ if ws == nil {
+ return
+ }
+ if _, err := a.store.RevokeMock(ws.ID); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "作废失败:" + err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"ok": true})
}
/* ---------------- 工作区同步 ---------------- */
@@ -385,3 +414,76 @@ func (a *API) handlePutWorkspace(c *gin.Context, user *store.User) {
}
c.JSON(http.StatusOK, gin.H{"updatedAt": now})
}
+
+/* ---------------- 本机联调 Mock(不绑账号) ---------------- */
+
+func (a *API) handlePublishLocalMock(c *gin.Context) {
+ if !isLoopbackRequest(c) {
+ c.JSON(http.StatusForbidden, gin.H{"error": "本机联调发布仅允许从 127.0.0.1 / ::1 调用"})
+ return
+ }
+ var req struct {
+ Endpoints []mocksrv.Endpoint `json:"endpoints" binding:"required"`
+ TTLSeconds int64 `json:"ttlSeconds"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 endpoints"})
+ return
+ }
+ if len(req.Endpoints) > 200 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "单空间最多发布 200 个 Mock 接口"})
+ return
+ }
+ if req.TTLSeconds < 0 || req.TTLSeconds > 30*24*3600 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "ttlSeconds 需在 0~2592000(30 天)之间"})
+ return
+ }
+ wsID, token, err := a.store.EnsureLocalMockWorkspace()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "本地 Mock 空间初始化失败:" + err.Error()})
+ return
+ }
+ if err := a.store.ReplaceMockEndpoints(wsID, req.Endpoints); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "发布失败:" + err.Error()})
+ return
+ }
+ var expiresAt int64
+ if req.TTLSeconds > 0 {
+ expiresAt = time.Now().Unix() + req.TTLSeconds
+ }
+ if err := a.store.SetMockExpiry(wsID, expiresAt); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "设置 TTL 失败:" + err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"ok": true, "count": len(req.Endpoints), "base": "/mock/" + token, "token": token, "expiresAt": expiresAt})
+}
+
+func (a *API) handleRevokeLocalMock(c *gin.Context) {
+ if !isLoopbackRequest(c) {
+ c.JSON(http.StatusForbidden, gin.H{"error": "本机联调作废仅允许从 127.0.0.1 / ::1 调用"})
+ return
+ }
+ wsID, _, err := a.store.EnsureLocalMockWorkspace()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "本地 Mock 空间初始化失败:" + err.Error()})
+ return
+ }
+ if _, err := a.store.RevokeMock(wsID); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "作废失败:" + err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+}
+
+func isLoopbackRequest(c *gin.Context) bool {
+ ip := net.ParseIP(c.ClientIP())
+ if ip != nil && ip.IsLoopback() {
+ return true
+ }
+ host, _, err := net.SplitHostPort(c.Request.RemoteAddr)
+ if err != nil {
+ host = c.Request.RemoteAddr
+ }
+ ip = net.ParseIP(host)
+ return ip != nil && ip.IsLoopback()
+}
diff --git a/apps/server/internal/server/router.go b/apps/server/internal/server/router.go
index c93b742..5bf5a13 100644
--- a/apps/server/internal/server/router.go
+++ b/apps/server/internal/server/router.go
@@ -62,7 +62,7 @@ func localhostCORS() gin.HandlerFunc {
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Vary", "Origin")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, Cookie, X-Opsx-Probe, X-Opsx-Cookie")
- c.Header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
+ c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
}
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
diff --git a/apps/server/internal/store/store.go b/apps/server/internal/store/store.go
index 4ef3f8f..aa43efa 100644
--- a/apps/server/internal/store/store.go
+++ b/apps/server/internal/store/store.go
@@ -108,6 +108,7 @@ CREATE INDEX IF NOT EXISTS idx_workspaces_owner ON workspaces(owner_id);
if _, err := s.db.Exec(`ALTER TABLE workspaces ADD COLUMN mock_token TEXT`); err == nil {
_, _ = s.db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_workspaces_mock_token ON workspaces(mock_token)`)
}
+ _, _ = s.db.Exec(`ALTER TABLE workspaces ADD COLUMN mock_expires_at INTEGER NOT NULL DEFAULT 0`)
// 老库补擅长列(幂等)
_, _ = s.db.Exec(`ALTER TABLE team_members ADD COLUMN specialty TEXT NOT NULL DEFAULT ''`)
rows, err := s.db.Query(`SELECT id FROM workspaces WHERE mock_token IS NULL OR mock_token = ''`)
@@ -511,19 +512,114 @@ func (s *Store) FindMockEndpoint(workspaceID int64, method, path string) (*mocks
return e, true, nil
}
-// ResolveMockToken:mock token → workspace id(不存在返回 false,不暴露信息)
+// ResolveMockToken:mock token → workspace id(不存在 / 已过期 / 已作废 均返回 false)
func (s *Store) ResolveMockToken(token string) (int64, bool, error) {
var id int64
- err := s.db.QueryRow(`SELECT id FROM workspaces WHERE mock_token = ?`, token).Scan(&id)
+ var expiresAt int64
+ err := s.db.QueryRow(
+ `SELECT id, IFNULL(mock_expires_at, 0) FROM workspaces WHERE mock_token = ? AND mock_token != ''`,
+ token,
+ ).Scan(&id, &expiresAt)
if errors.Is(err, sql.ErrNoRows) {
return 0, false, nil
}
if err != nil {
return 0, false, err
}
+ if expiresAt > 0 && time.Now().Unix() >= expiresAt {
+ return 0, false, nil
+ }
return id, true, nil
}
+// SetMockExpiry 设置可选 TTL(expiresAtUnix=0 表示不过期)。
+func (s *Store) SetMockExpiry(workspaceID int64, expiresAtUnix int64) error {
+ _, err := s.db.Exec(`UPDATE workspaces SET mock_expires_at = ? WHERE id = ?`, expiresAtUnix, workspaceID)
+ return err
+}
+
+// RevokeMock 作废当前 mock token:轮换新 token、清零过期、清空 endpoints;旧链接立刻失效。
+func (s *Store) RevokeMock(workspaceID int64) (newToken string, err error) {
+ newToken = newMockToken()
+ tx, err := s.db.Begin()
+ if err != nil {
+ return "", err
+ }
+ defer tx.Rollback()
+ if _, err := tx.Exec(
+ `UPDATE workspaces SET mock_token = ?, mock_expires_at = 0 WHERE id = ?`,
+ newToken, workspaceID,
+ ); err != nil {
+ return "", err
+ }
+ if _, err := tx.Exec(`DELETE FROM mock_endpoints WHERE workspace_id = ?`, workspaceID); err != nil {
+ return "", err
+ }
+ if err := tx.Commit(); err != nil {
+ return "", err
+ }
+ return newToken, nil
+}
+
+const localMockWorkspaceName = "__opsxapi_local_mock__"
+const localMockUserEmail = "__local_mock__@opsxapi.internal"
+
+// ensureLocalMockOwner 系统账号,仅作 FK 锚点,不出现在登录流程。
+func (s *Store) ensureLocalMockOwner() (int64, error) {
+ var id int64
+ err := s.db.QueryRow(`SELECT id FROM users WHERE email = ?`, localMockUserEmail).Scan(&id)
+ if err == nil {
+ return id, nil
+ }
+ if !errors.Is(err, sql.ErrNoRows) {
+ return 0, err
+ }
+ res, err := s.db.Exec(
+ `INSERT INTO users(name, email, pass_hash, created_at) VALUES(?,?,?,?)`,
+ "本机联调", localMockUserEmail, hashPassword(newMockToken()), time.Now().Unix(),
+ )
+ if err != nil {
+ return 0, err
+ }
+ return res.LastInsertId()
+}
+
+// EnsureLocalMockWorkspace 为本机联调预留的系统空间(不绑真实用户账号)。
+func (s *Store) EnsureLocalMockWorkspace() (id int64, token string, err error) {
+ err = s.db.QueryRow(
+ `SELECT id, IFNULL(mock_token,'') FROM workspaces WHERE name = ? AND team_id IS NULL LIMIT 1`,
+ localMockWorkspaceName,
+ ).Scan(&id, &token)
+ if err == nil {
+ if token == "" {
+ token = newMockToken()
+ if _, e := s.db.Exec(`UPDATE workspaces SET mock_token = ? WHERE id = ?`, token, id); e != nil {
+ return 0, "", e
+ }
+ }
+ return id, token, nil
+ }
+ if !errors.Is(err, sql.ErrNoRows) {
+ return 0, "", err
+ }
+ ownerID, err := s.ensureLocalMockOwner()
+ if err != nil {
+ return 0, "", err
+ }
+ token = newMockToken()
+ now := time.Now().Unix()
+ res, err := s.db.Exec(
+ `INSERT INTO workspaces(name, team_id, owner_id, data, updated_at, mock_token) VALUES(?,NULL,?,'',?,?)`,
+ localMockWorkspaceName, ownerID, now, token,
+ )
+ if err != nil {
+ return 0, "", err
+ }
+ id, err = res.LastInsertId()
+ return id, token, err
+}
+
+
func (s *Store) SaveWorkspace(wsID int64, data string) (int64, error) {
now := time.Now().Unix()
_, err := s.db.Exec(`UPDATE workspaces SET data = ?, updated_at = ? WHERE id = ?`, data, now, wsID)
diff --git a/apps/web/public/design-proxy-layers.html b/apps/web/public/design-proxy-layers.html
new file mode 100644
index 0000000..1835e4e
--- /dev/null
+++ b/apps/web/public/design-proxy-layers.html
@@ -0,0 +1,57 @@
+
+
+
+
+ OpsxAPI · 路径 B 代理错误分层验收
+
+
+
+ 路径 B · ProxyErrorBanner 中文分层芯片
+ 线框:代理 / 目标 / 鉴权 · unknown 中性灰 · 主按钮 / 次链接
+
+
+
代理代理层失败
+
Failed to fetch · ECONNREFUSED 127.0.0.1:8787
+
+
+
+
目标目标服务失败
+
HTTP 502 · ENOTFOUND api.internal.example
+
+
+
+
鉴权鉴权层失败
+
401 Unauthorized · Bearer 无效
+
+
+
+
未知请求失败(请按层排查)
+
Unexpected error
+
+
+
+
+
diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg
index 7e6d4c5..9bcc718 100644
--- a/apps/web/public/favicon.svg
+++ b/apps/web/public/favicon.svg
@@ -1,15 +1,5 @@
-