commit dec66db5aa37f4db080a017b67eaba7e97da1db0 Author: logan Date: Fri Sep 18 00:16:59 2026 +0800 feat: OpsxAPI v1.0 全协议接口工作台 - 六协议调试:HTTP/GraphQL/WebSocket/SSE/Socket.IO/MQTT - 集合树/多标签/多环境变量/历史记录,localStorage 持久化 - 导入:OpenAPI 2/3/3.1、Postman、Apifox、Apipost、cURL、HAR、 Insomnia、Hoppscotch、Markdown(自动识别,21 个单测覆盖) - 导出:OpenAPI 3.0 JSON/YAML、Postman v2.1、Markdown 文档、 全量备份;单请求生成 cURL/fetch/Python/Go 代码 - Gin 服务:HTTP 代理、NDJSON 流式代理(SSE 透传)、 WebSocket 隧道(浏览器自定义握手 Header) - 内置产品官网 /home 与用户文档站 /docs(SSR,随二进制交付) - lucide 图标体系 + 品牌标识(Logo/favicon),明暗双主题 - 环境变量:彩色徽标切换器、管理弹窗、{{ 自动补全 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0128e54 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +node_modules/ +dist/ +build/ +target/ +.DS_Store +*.log +.env +.env.* +!.env.example +release/ +*.local +.vite/ +coverage/ +.turbo/ +.idea/ +.vscode/ +apps/server/opsxapi +apps/server/server +apps/desktop/src-tauri/gen/ +*.exe +*.dylib +*.so +Cargo.lock +!apps/desktop/src-tauri/Cargo.lock + +logs/ +.*.pid +apps/server/bin/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4679e29 --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ +.PHONY: install web gui all server stop status build-web test desktop + +install: + npm install + npm --prefix apps/desktop install + +web: + ./run.sh web + +gui: + ./run.sh gui + +all: + ./run.sh all + +server: + ./run.sh server + +stop: + ./run.sh stop + +status: + ./run.sh status + +desktop: + ./run.sh desktop + +build-web: + npm run build:web + +test: + npm run test:core diff --git a/README.md b/README.md new file mode 100644 index 0000000..998cdc4 --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# OpsxAPI + +给运维和开发用的专业接口工作台 —— 对标 Apifox / Apipost 的自研替代。 + +**HTTP · GraphQL · WebSocket · SSE · Socket.IO · MQTT,一个工作台全搞定;数据全在本地。** + +``` +packages/core 数据模型 / 树操作 / 导入导出器(纯 TS,21 个单测) +packages/transport browser | gin-proxy | tauri-native + 流式/SSE/WS隧道/Socket.IO/MQTT 客户端 +packages/ui 共享工作台 UI(集合树 / 多标签 / 编辑器 / 对话框) +apps/web Vite + React 入口(:5173) +apps/desktop Tauri 2 壳(frontendDist → web dist) +apps/server Gin:HTTP 代理 + 流式代理 + WebSocket 隧道(:8787) +``` + +核心原则:**一套 UI、一份请求契约,Web / Desktop 只换 Transport。** + +## 功能总览 + +### 协议 +- **HTTP/REST**:GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS;Params / Body(JSON·XML·Text·HTML·x-www-form-urlencoded·form-data)/ Headers / Cookies / Auth(Bearer·Basic·APIKey) +- **GraphQL**:Query + Variables 编辑、独立响应面板 +- **WebSocket**:消息日志(收/发/系统分色)、快捷发送、握手 Header(经 Gin 隧道,绕开浏览器限制) +- **SSE**:断点续传(Last-Event-ID)、直连失败自动切 Gin 代理、事件按 `event:` 分发 +- **Socket.IO**:事件监听(可 onAny)、emit 参数 JSON、path/transports/auth 连接选项 +- **MQTT**:wss broker、主题订阅(通配符)、发布、QoS 0/1/2、自动重连 + +### 导入(粘贴 / 文件 / URL 三种来源,格式自动识别) +| 格式 | 说明 | +| --- | --- | +| OpenAPI 3.0 / 3.1 / Swagger 2.0 | JSON 或 YAML;Apifox 导出同格式直接可用 | +| Swagger URL | 经本地 Gin 代理拉取,避开 CORS | +| Postman Collection v2.0 / v2.1 | 含鉴权、变量、GraphQL body | +| Apipost 项目 | `api_collection` 宽容解析 | +| cURL | 引号 / 续行 / -d / -F / -u / --data-urlencode 全支持 | +| HAR 抓包 | 按域名分组、去重 | +| Insomnia 导出 | v3 / v4(workspace/group 层级) | +| Hoppscotch 集合 | 数组或单对象 | +| Markdown 接口文档 | 提取 ```curl 代码块 | +| OpsxAPI 备份 | 全量集合 + 环境 | +| YApi / Apidoc / Apizza / Eolink | 请先导出为 OpenAPI 或 Postman 格式再导入 | + +导入后自动:tag→目录、`{path}` 参数→`{{变量}}` 并写入环境、schema→示例 body、security→鉴权配置。 + +### 导出 +- OpenAPI 3.0(JSON / YAML,带 tag 分组)· Postman v2.1 · Markdown 接口文档 · 全量备份 +- 单请求代码生成:cURL / JavaScript fetch / Python requests / Go net/http + +### 工作台 +集合树(搜索 / 右键菜单 / 拖拽移动 / 复制 / 重命名)· 多标签页 · 历史记录(点击回跳) +· 环境变量(多环境切换、`{{var}}` 全局替换)· 明暗双主题 · localStorage 自动持久化 + +## 怎么跑 + +```bash +make install # npm install(含 desktop 依赖) +./run.sh web # 浏览器:Gin(:8787) + Vite(:5173) ← 推荐 +./run.sh gui # 桌面 Tauri 窗口(共用 Vite) +./run.sh all # 全开 +./run.sh stop | status +``` + +打开 http://127.0.0.1:5173 —— 内置「示例集合」覆盖全部协议,开箱即用。 + +## 用户文档站(/docs) + +Gin 内置产品文档,Markdown 嵌入二进制、SSR 渲染,随服务一键部署: + +- 地址:`http://127.0.0.1:8787/docs`(开发态 `http://127.0.0.1:5173/docs`,Vite 已反代;工作台顶栏「📖 文档」直达) +- 布局:顶栏搜索 + 左侧分组导航(16 篇:开始使用 / 请求调试 / 数据管理 / 参考)+ 右侧本页目录 +- 能力:全文搜索、代码块复制 + 高亮、提示块、上一页/下一页、反馈组件、打印导出 PDF、移动端响应式、404 页 +- 内容在 `apps/server/internal/docs/content/*.md`,改完重新 `go build` 即生效 + +## 产品官网(/home) + +Gin 内置营销单页,与 /docs 同为 SSR 嵌入交付(`internal/site`): + +- 地址:`/` 与 `/home` 双入口(开发态 `http://127.0.0.1:5173/home`);文档站顶栏「官网」互链 +- 区块:Hero(纯 CSS 产品界面 mock)/ 卖点 / 功能 / 六协议 / 导入导出生态 / 与 Apifox·Apipost 对比表 / 定价 / 让利计划 / 购买 FAQ / 联系 CTA / Footer +- 定价三档(标价/成交价分离):个人版 ¥99/年、团队版 ¥99/人/年(入门锚点);企业私有化版标价 **¥8,000/年 · ¥30,000 买断**,经早鸟/教育/多年预付/案例合作落在成交区间 ¥5,000–8,000/年、¥15,000–30,000 +- 让利机制四项:早鸟创始价(前 20 席 5–7 折永久)、教育/开源 6–8 折、多年预付 8–9 折、案例换授权;销售原则:不直接降价,用「永久授权 + 1 年升级 + 5 人团队 1–2 年回本」回应预算顾虑 +- 交付前替换:联系邮箱 `hello@opsxapi.dev`(template.html 内两处)与对比表脚注中的价格核对 + +## 服务端端点(apps/server) + +| 端点 | 用途 | +| --- | --- | +| `GET /health` | 健康检查 | +| `POST /v1/proxy/http` | HTTP 代理(绕 CORS,全方法) | +| `POST /v1/proxy/http/stream` | NDJSON 流式代理(SSE / chunked) | +| `POST /v1/proxy/ws/connect` | WS 隧道:拨号目标(带自定义 Header),返回 sessionId | +| `GET /v1/proxy/ws/attach` | WS 隧道:浏览器 attach,双向桥接 | +| `POST /v1/proxy/ws/close` | 关闭隧道会话 | + +浏览器直连 WS 无法带自定义 Header 时,transport 层自动走隧道;SSE 直连 CORS 失败自动切代理。 + +## 架构决策 + +- **一套 UI**:集合 / 编辑器 / 对话框全部在 `packages/ui`,Web 与桌面零分叉 +- **判别联合数据模型**:`node.kind === "http"` 自动收窄 `node.data` 类型 +- **实时会话不落盘**:WS/SSE/Socket.IO/MQTT 会话存模块级注册表,消息(500 条上限)与状态进 store +- **zustand 选择器必须返回稳定引用**:`?? []` 会触发 useSyncExternalStore 无限重渲染(踩过的坑) + +## 测试 + +```bash +make test # core 21 个单测:导入导出全格式往返、树操作、cURL 解析 +cd apps/server && go test ./... +``` diff --git a/api/openapi.yaml b/api/openapi.yaml new file mode 100644 index 0000000..c4515ec --- /dev/null +++ b/api/openapi.yaml @@ -0,0 +1,69 @@ +openapi: 3.0.3 +info: + title: OpsxAPI Server + version: 0.1.0 + description: Minimal health + HTTP proxy API +servers: + - url: http://127.0.0.1:8787 +paths: + /health: + get: + summary: Health check + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + ok: { type: boolean } + service: { type: string } + /v1/proxy/http: + post: + summary: Proxy an HTTP request + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/HttpSpec" + responses: + "200": + description: Proxied result + content: + application/json: + schema: + $ref: "#/components/schemas/HttpResult" +components: + schemas: + HttpSpec: + type: object + required: [method, url] + properties: + method: + type: string + enum: [GET, POST, PUT, PATCH, DELETE] + url: { type: string } + headers: + type: object + additionalProperties: { type: string } + query: + type: object + additionalProperties: { type: string } + body: + type: object + properties: + mode: { type: string, enum: [none, json, raw, form, urlencoded] } + raw: { type: string } + timeoutMs: { type: integer } + HttpResult: + type: object + properties: + status: { type: integer } + headers: + type: object + additionalProperties: { type: string } + body: { type: string } + timeMs: { type: number } + size: { type: integer } diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json new file mode 100644 index 0000000..48f0420 --- /dev/null +++ b/apps/desktop/package-lock.json @@ -0,0 +1,220 @@ +{ + "name": "@opsxapi/desktop", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@opsxapi/desktop", + "version": "0.1.0", + "devDependencies": { + "@tauri-apps/cli": "^2.4.0" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz", + "integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==", + "dev": true, + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.11.4", + "@tauri-apps/cli-darwin-x64": "2.11.4", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", + "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", + "@tauri-apps/cli-linux-arm64-musl": "2.11.4", + "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-musl": "2.11.4", + "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", + "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", + "@tauri-apps/cli-win32-x64-msvc": "2.11.4" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz", + "integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz", + "integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz", + "integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz", + "integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz", + "integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz", + "integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz", + "integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz", + "integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz", + "integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz", + "integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz", + "integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + } + } +} diff --git a/apps/desktop/package.json b/apps/desktop/package.json new file mode 100644 index 0000000..9867536 --- /dev/null +++ b/apps/desktop/package.json @@ -0,0 +1,11 @@ +{ + "name": "@opsxapi/desktop", + "version": "0.1.0", + "private": true, + "scripts": { + "tauri": "tauri" + }, + "devDependencies": { + "@tauri-apps/cli": "^2.4.0" + } +} diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock new file mode 100644 index 0000000..14e7335 --- /dev/null +++ b/apps/desktop/src-tauri/Cargo.lock @@ -0,0 +1,4808 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.2", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.5", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.6+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.2", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jiff" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link 0.2.1", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.2", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6480ccc157a1389bb2e4891b24751b0f798ba640d22386f23143fbcc89da195a" +dependencies = [ + "libc", +] + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.20", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opsxapi" +version = "0.1.0" +dependencies = [ + "reqwest 0.12.28", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tokio", + "url", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plist" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb" +dependencies = [ + "base64 0.23.1", + "indexmap 2.14.2", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.15+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.20", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.13.2", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4" +dependencies = [ + "base64 0.23.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.2", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e45c444e496845d3f2a351146bff59aae4975b2280238df1dfaa0c7d1846f38e" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.13.2", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest 0.13.5", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.20", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.119", + "tauri-utils", + "thiserror 2.0.20", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.20", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.20", + "toml 1.1.6+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.6+spec-1.1.0", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.2", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "indexmap 2.14.2", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.2", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.20", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.20", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.20", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml new file mode 100644 index 0000000..cc3576c --- /dev/null +++ b/apps/desktop/src-tauri/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "opsxapi" +version = "0.1.0" +description = "OpsxAPI — API workbench for ops & developers" +authors = ["OpsxAPI"] +edition = "2021" +default-run = "OpsxAPI" + +[lib] +name = "opsxapi_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[[bin]] +name = "OpsxAPI" +path = "src/main.rs" + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +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"] } +url = "2" diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/apps/desktop/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json new file mode 100644 index 0000000..93feea7 --- /dev/null +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -0,0 +1,11 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Default capabilities for the OpsxAPI main window", + "windows": [ + "main" + ], + "permissions": [ + "core:default" + ] +} diff --git a/apps/desktop/src-tauri/icons/128x128.png b/apps/desktop/src-tauri/icons/128x128.png new file mode 100644 index 0000000..be05088 Binary files /dev/null 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 new file mode 100644 index 0000000..1cef1b7 Binary files /dev/null and b/apps/desktop/src-tauri/icons/128x128@2x.png differ diff --git a/apps/desktop/src-tauri/icons/32x32.png b/apps/desktop/src-tauri/icons/32x32.png new file mode 100644 index 0000000..b3ebe1c Binary files /dev/null and b/apps/desktop/src-tauri/icons/32x32.png differ diff --git a/apps/desktop/src-tauri/icons/64x64.png b/apps/desktop/src-tauri/icons/64x64.png new file mode 100644 index 0000000..c861732 Binary files /dev/null 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 new file mode 100644 index 0000000..7885a51 Binary files /dev/null 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 new file mode 100644 index 0000000..885f198 Binary files /dev/null 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 new file mode 100644 index 0000000..f9799a7 Binary files /dev/null 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 new file mode 100644 index 0000000..a13ec44 Binary files /dev/null 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 new file mode 100644 index 0000000..3d8ea32 Binary files /dev/null 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 new file mode 100644 index 0000000..dbf6c5f Binary files /dev/null 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 new file mode 100644 index 0000000..07ce2e4 Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square44x44Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square71x71Logo.png b/apps/desktop/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 0000000..e9ecde0 Binary files /dev/null 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 new file mode 100644 index 0000000..a13ec3a Binary files /dev/null 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 new file mode 100644 index 0000000..85bb53b Binary files /dev/null and b/apps/desktop/src-tauri/icons/StoreLogo.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml b/apps/desktop/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..2ffbf24 --- /dev/null +++ b/apps/desktop/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file 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 new file mode 100644 index 0000000..6177315 Binary files /dev/null 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 new file mode 100644 index 0000000..34a8a07 Binary files /dev/null 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 new file mode 100644 index 0000000..77a3311 Binary files /dev/null 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 new file mode 100644 index 0000000..8466930 Binary files /dev/null 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 new file mode 100644 index 0000000..85be220 Binary files /dev/null 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 new file mode 100644 index 0000000..e51e297 Binary files /dev/null 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 new file mode 100644 index 0000000..1e5119c Binary files /dev/null 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 new file mode 100644 index 0000000..3470c23 Binary files /dev/null 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 new file mode 100644 index 0000000..03028f9 Binary files /dev/null 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 new file mode 100644 index 0000000..51fc3f0 Binary files /dev/null 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 new file mode 100644 index 0000000..edbfbe9 Binary files /dev/null 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 new file mode 100644 index 0000000..4686d87 Binary files /dev/null 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 new file mode 100644 index 0000000..eb05e0c Binary files /dev/null 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 new file mode 100644 index 0000000..dc0df3c Binary files /dev/null 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 new file mode 100644 index 0000000..002ab19 Binary files /dev/null and b/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/apps/desktop/src-tauri/icons/android/values/ic_launcher_background.xml b/apps/desktop/src-tauri/icons/android/values/ic_launcher_background.xml new file mode 100644 index 0000000..ea9c223 --- /dev/null +++ b/apps/desktop/src-tauri/icons/android/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #fff + \ No newline at end of file diff --git a/apps/desktop/src-tauri/icons/icon.icns b/apps/desktop/src-tauri/icons/icon.icns new file mode 100644 index 0000000..331cf03 Binary files /dev/null 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 new file mode 100644 index 0000000..cae568f Binary files /dev/null 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 new file mode 100644 index 0000000..5710de4 Binary files /dev/null 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 new file mode 100644 index 0000000..e7835b5 --- /dev/null +++ b/apps/desktop/src-tauri/icons/icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + API + Opsx + + diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png new file mode 100644 index 0000000..ba91d70 Binary files /dev/null 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 new file mode 100644 index 0000000..9af249a Binary files /dev/null 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 new file mode 100644 index 0000000..9af249a Binary files /dev/null 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 new file mode 100644 index 0000000..88bd0da Binary files /dev/null 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 new file mode 100644 index 0000000..b04d76f Binary files /dev/null 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 new file mode 100644 index 0000000..3d4ca88 Binary files /dev/null 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 new file mode 100644 index 0000000..3d4ca88 Binary files /dev/null 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 new file mode 100644 index 0000000..ddcdddf Binary files /dev/null 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 new file mode 100644 index 0000000..9af249a Binary files /dev/null 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 new file mode 100644 index 0000000..59a535f Binary files /dev/null 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 new file mode 100644 index 0000000..59a535f Binary files /dev/null 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 new file mode 100644 index 0000000..5bb0874 Binary files /dev/null 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 new file mode 100644 index 0000000..3f96515 Binary files /dev/null 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 new file mode 100644 index 0000000..5bb0874 Binary files /dev/null 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 new file mode 100644 index 0000000..2735556 Binary files /dev/null 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 new file mode 100644 index 0000000..b043b83 Binary files /dev/null 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 new file mode 100644 index 0000000..eb659c7 Binary files /dev/null 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 new file mode 100644 index 0000000..4c43722 Binary files /dev/null and b/apps/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs new file mode 100644 index 0000000..33da137 --- /dev/null +++ b/apps/desktop/src-tauri/src/lib.rs @@ -0,0 +1,136 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct BodySpec { + mode: String, + raw: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct HttpSpec { + method: String, + url: String, + #[serde(default)] + headers: HashMap, + query: Option>, + body: Option, + timeout_ms: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct HttpResult { + status: u16, + headers: HashMap, + body: String, + time_ms: u128, + size: usize, +} + +#[derive(Debug, Deserialize)] +struct WsSpec { + url: String, + protocols: Option>, + headers: Option>, +} + +fn validate_http_url(raw: &str) -> Result { + let parsed = url::Url::parse(raw).map_err(|e| format!("invalid URL: {e}"))?; + match parsed.scheme() { + "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" => { + return Err("blocked metadata host".into()) + } + _ => {} + } + Ok(parsed) +} + +#[tauri::command] +async fn http_send(spec: HttpSpec) -> Result { + let mut url = validate_http_url(&spec.url)?; + if let Some(query) = &spec.query { + let mut pairs = url.query_pairs_mut(); + for (key, value) in query { + pairs.append_pair(key, value); + } + } + + let method = reqwest::Method::from_bytes(spec.method.as_bytes()) + .map_err(|e| format!("invalid method: {e}"))?; + let timeout = Duration::from_millis(spec.timeout_ms.unwrap_or(30_000)); + let client = reqwest::Client::builder() + .timeout(timeout) + .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); + } + if let Some(body) = spec.body { + if body.mode != "none" { + request = request.body(body.raw.unwrap_or_default()); + } + } + + let started = Instant::now(); + let response = request.send().await.map_err(|e| e.to_string())?; + let status = response.status().as_u16(); + let headers = response + .headers() + .iter() + .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string())) + .collect(); + let bytes = response.bytes().await.map_err(|e| e.to_string())?; + let size = bytes.len(); + let body = String::from_utf8_lossy(&bytes).into_owned(); + Ok(HttpResult { + status, + headers, + body, + time_ms: started.elapsed().as_millis(), + size, + }) +} + +#[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 + )) +} + +#[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()) +} + +#[tauri::command] +async fn ws_close(session_id: String) -> Result<(), String> { + let _ = session_id; + Ok(()) +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .invoke_handler(tauri::generate_handler![ + http_send, + ws_connect, + ws_send, + ws_close + ]) + .run(tauri::generate_context!()) + .expect("error while running OpsxAPI desktop"); +} diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs new file mode 100644 index 0000000..4a72382 --- /dev/null +++ b/apps/desktop/src-tauri/src/main.rs @@ -0,0 +1,5 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + opsxapi_lib::run(); +} diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json new file mode 100644 index 0000000..9a3bedd --- /dev/null +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "OpsxAPI", + "version": "0.1.0", + "identifier": "com.opsx.api", + "build": { + "beforeDevCommand": "bash -lc 'curl -fsS http://127.0.0.1:5173 >/dev/null 2>&1 || npm --prefix ../.. run dev:web'", + "devUrl": "http://127.0.0.1:5173", + "beforeBuildCommand": "npm --prefix ../.. run build:web", + "frontendDist": "../../web/dist" + }, + "app": { + "windows": [ + { + "title": "OpsxAPI", + "width": 1280, + "height": 800, + "minWidth": 960, + "minHeight": 640, + "resizable": true, + "fullscreen": false, + "label": "main" + } + ], + "security": { + "csp": null + } + }, + "bundle": { + "active": true, + "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "category": "DeveloperTool", + "shortDescription": "OpsxAPI — API workbench", + "longDescription": "OpsxAPI is an API workbench for ops and developers: HTTP / WebSocket, one React UI, Web and Desktop share Transport." + } +} diff --git a/apps/server/cmd/opsxapi/main.go b/apps/server/cmd/opsxapi/main.go new file mode 100644 index 0000000..88f3fea --- /dev/null +++ b/apps/server/cmd/opsxapi/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "log" + "os" + + "opsxapi/server/internal/server" +) + +func main() { + addr := os.Getenv("OPSXAPI_ADDR") + if addr == "" { + addr = ":8787" + } + log.Printf("OpsxAPI server listening on %s", addr) + if err := server.NewRouter().Run(addr); err != nil { + log.Fatal(err) + } +} diff --git a/apps/server/config.example.yaml b/apps/server/config.example.yaml new file mode 100644 index 0000000..f4d3a4a --- /dev/null +++ b/apps/server/config.example.yaml @@ -0,0 +1,7 @@ +server: + addr: ":8787" +proxy: + timeout_ms: 30000 +# Environment overrides: +# OPSXAPI_ADDR=:8787 +# OPSXAPI_PROXY_TIMEOUT_MS=30000 diff --git a/apps/server/go.mod b/apps/server/go.mod new file mode 100644 index 0000000..3dc0e7e --- /dev/null +++ b/apps/server/go.mod @@ -0,0 +1,38 @@ +module opsxapi/server + +go 1.23.0 + +require ( + github.com/gin-gonic/gin v1.10.0 + github.com/gorilla/websocket v1.5.3 + github.com/yuin/goldmark v1.8.6 +) + +require ( + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.23.0 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/text v0.15.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/apps/server/go.sum b/apps/server/go.sum new file mode 100644 index 0000000..71fb00b --- /dev/null +++ b/apps/server/go.sum @@ -0,0 +1,93 @@ +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4= +github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/apps/server/internal/docs/assets/docs.css b/apps/server/internal/docs/assets/docs.css new file mode 100644 index 0000000..4d56f63 --- /dev/null +++ b/apps/server/internal/docs/assets/docs.css @@ -0,0 +1,246 @@ +/* OpsxAPI 文档站 —— 清爽专业风格(浅色) */ +:root { + --blue: #2563eb; + --blue-dark: #1d4ed8; + --blue-soft: rgba(37, 99, 235, 0.08); + --bg: #f6f8fb; + --panel: #ffffff; + --border: #e4e9f1; + --text: #1c2434; + --text-2: #5b6880; + --text-3: #98a2b3; + --code-bg: #f3f6fb; + --code-border: #e1e8f3; + --green: #059669; + --amber: #b45309; + --red: #dc2626; + --sidebar-w: 262px; + --toc-w: 220px; + --topbar-h: 56px; + --radius: 10px; + --shadow: 0 1px 3px rgba(16, 24, 40, 0.06), 0 8px 24px rgba(16, 24, 40, 0.05); + --mono: "JetBrains Mono", SFMono-Regular, Menlo, Consolas, monospace; +} +* { box-sizing: border-box; } +html { scroll-behavior: smooth; scroll-padding-top: 72px; } +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif; + background: var(--bg); + color: var(--text); + font-size: 14.5px; + line-height: 1.75; +} +a { color: var(--blue); text-decoration: none; } +.i { width: 15px; height: 15px; stroke: currentColor; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; vertical-align: -2px; } +a:hover { color: var(--blue-dark); text-decoration: underline; } + +/* ---------- 顶栏 ---------- */ +.topbar { + position: sticky; top: 0; z-index: 50; + background: rgba(255, 255, 255, 0.92); + backdrop-filter: saturate(1.4) blur(8px); + border-bottom: 1px solid var(--border); +} +.topbar-inner { + max-width: 1440px; margin: 0 auto; height: var(--topbar-h); + display: flex; align-items: center; gap: 14px; padding: 0 20px; +} +.menu-btn { + display: none; border: 1px solid var(--border); background: #fff; + border-radius: 8px; width: 34px; height: 34px; cursor: pointer; color: var(--text-2); + place-items: center; +} +.menu-btn .i { width: 17px; height: 17px; } +.brand { display: flex; align-items: center; gap: 9px; text-decoration: none !important; } +.brand-logo { display: inline-flex; } +.brand-logo svg { display: block; border-radius: 7px; } +.brand-name { font-size: 16px; font-weight: 700; color: var(--text); } +.brand-name em { font-style: normal; font-weight: 500; color: var(--text-2); margin-left: 2px; } +.ver-badge { + font-size: 11px; font-weight: 600; color: var(--blue); + background: var(--blue-soft); border: 1px solid rgba(37, 99, 235, 0.22); + padding: 1px 9px; border-radius: 999px; white-space: nowrap; +} +.search { position: relative; flex: 1; max-width: 420px; margin-left: 8px; } +.search input { + width: 100%; height: 36px; border: 1px solid var(--border); border-radius: 9px; + background: var(--bg); padding: 0 14px 0 34px; font-size: 13.5px; color: var(--text); outline: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' fill='none' stroke='%2398a2b3' stroke-width='2' viewBox='0 0 24 24'%3E%3Ccircle cx='11' cy='11' r='7'/%3E%3Cpath d='m20 20-3.5-3.5'/%3E%3C/svg%3E"); + background-repeat: no-repeat; background-position: 12px center; +} +.search input:focus { border-color: var(--blue); background: #fff; box-shadow: 0 0 0 3px var(--blue-soft); } +.search-panel { + position: absolute; top: 44px; left: 0; right: 0; background: #fff; + border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); + max-height: 420px; overflow: auto; padding: 6px; +} +.search-item { display: block; padding: 8px 12px; border-radius: 8px; text-decoration: none !important; } +.search-item:hover, .search-item.sel { background: var(--blue-soft); } +.search-item .si-title { font-weight: 600; color: var(--text); font-size: 13.5px; } +.search-item .si-group { color: var(--text-3); font-size: 11px; margin-left: 6px; } +.search-item .si-snippet { color: var(--text-2); font-size: 12px; margin-top: 2px; display: block; } +.search-empty { padding: 14px; text-align: center; color: var(--text-3); font-size: 13px; } +.top-links { margin-left: auto; display: flex; gap: 16px; align-items: center; } +.top-links a { font-size: 13.5px; font-weight: 500; } + +/* ---------- 三栏布局 ---------- */ +.layout { + max-width: 1440px; margin: 0 auto; display: grid; + grid-template-columns: var(--sidebar-w) minmax(0, 1fr) var(--toc-w); + gap: 0 28px; padding: 24px 20px 60px; +} + +/* 侧栏 */ +.sidebar { position: sticky; top: calc(var(--topbar-h) + 24px); align-self: start; max-height: calc(100vh - var(--topbar-h) - 48px); overflow-y: auto; padding-right: 8px; } +.sidebar::-webkit-scrollbar { width: 4px; } +.sidebar::-webkit-scrollbar-thumb { background: #d6dde8; border-radius: 4px; } +.nav-group { margin-bottom: 18px; } +.nav-group-title { + font-size: 11.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; + color: var(--text-3); padding: 0 10px 6px; +} +.nav-link { + display: block; padding: 6px 10px; border-radius: 8px; font-size: 13.5px; + color: var(--text-2); text-decoration: none !important; border-left: 2px solid transparent; +} +.nav-link:hover { background: rgba(37, 99, 235, 0.06); color: var(--text); } +.nav-link.active { + background: var(--blue-soft); color: var(--blue-dark); font-weight: 600; + border-left-color: var(--blue); +} +.sidebar-foot { margin-top: 24px; padding: 12px 10px 0; border-top: 1px solid var(--border); font-size: 11.5px; color: var(--text-3); } + +/* 内容 */ +.content { min-width: 0; max-width: 860px; } +.breadcrumb { font-size: 12.5px; color: var(--text-3); margin-bottom: 10px; } +.breadcrumb span { margin: 0 6px; } +.page-title { font-size: 30px; font-weight: 700; letter-spacing: -0.02em; margin: 0 0 6px; line-height: 1.3; } +.page-meta { font-size: 12.5px; color: var(--text-3); margin-bottom: 22px; } + +/* Markdown 排版 */ +.md h2 { font-size: 21px; font-weight: 700; margin: 38px 0 12px; padding-top: 8px; letter-spacing: -0.01em; } +.md h3 { font-size: 16.5px; font-weight: 700; margin: 26px 0 8px; } +.md h2 + h3, .md h1 + h2 { margin-top: 18px; } +.md p { margin: 10px 0; color: #2a3348; } +.md ul, .md ol { padding-left: 22px; margin: 10px 0; } +.md li { margin: 5px 0; color: #2a3348; } +.md li::marker { color: var(--blue); } +.md strong { color: var(--text); } +.md hr { border: none; border-top: 1px solid var(--border); margin: 30px 0; } +.md img { max-width: 100%; border-radius: var(--radius); border: 1px solid var(--border); } +.md blockquote { + margin: 14px 0; padding: 10px 16px; border-left: 3px solid var(--blue); + background: var(--blue-soft); border-radius: 0 8px 8px 0; color: #33415e; +} + +/* 表格 */ +.md table { border-collapse: collapse; width: 100%; margin: 16px 0; font-size: 13.5px; background: #fff; border-radius: var(--radius); overflow: hidden; box-shadow: 0 0 0 1px var(--border); } +.md th, .md td { padding: 9px 14px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: top; } +.md th { background: #f9fbfe; font-weight: 600; color: var(--text); white-space: nowrap; } +.md tr:last-child td { border-bottom: none; } + +/* 代码块 */ +.md code { font-family: var(--mono); font-size: 12.8px; background: var(--code-bg); border: 1px solid var(--code-border); border-radius: 5px; padding: 1px 6px; color: #334155; } +.md pre { + position: relative; background: #0f172a; color: #dbe4f3; border-radius: var(--radius); + padding: 16px 16px 14px; overflow-x: auto; margin: 16px 0; font-size: 13px; line-height: 1.65; +} +.md pre code { background: none; border: none; color: inherit; padding: 0; font-size: inherit; } +.md pre .lang-tag { + position: absolute; top: 8px; right: 12px; font-size: 10.5px; color: #7c8db0; + font-family: var(--mono); text-transform: lowercase; letter-spacing: 0.04em; +} +.md pre .copy-btn { + position: absolute; top: 6px; right: 60px; font-size: 11px; color: #9fb0d0; + background: rgba(255, 255, 255, 0.07); border: none; border-radius: 6px; padding: 2px 9px; cursor: pointer; + opacity: 0; transition: opacity 0.15s; +} +.md pre:hover .copy-btn { opacity: 1; } +.md pre .copy-btn:hover { color: #fff; background: rgba(255, 255, 255, 0.14); } +/* 行内高亮 token */ +.md pre .tok-key { color: #7dd3fc; } +.md pre .tok-str { color: #86efac; } +.md pre .tok-num { color: #fcd34d; } +.md pre .tok-com { color: #64748b; font-style: italic; } + +/* 提示块(通过 blockquote 首字符约定) */ +.md blockquote.tip { border-left-color: var(--green); background: rgba(5, 150, 105, 0.07); } +.md blockquote.warn { border-left-color: var(--amber); background: rgba(180, 83, 9, 0.07); } + +/* 上下页 */ +.pn-nav { display: flex; gap: 14px; margin-top: 48px; } +.pn { + flex: 1; border: 1px solid var(--border); background: #fff; border-radius: var(--radius); + padding: 13px 16px; text-decoration: none !important; transition: border-color 0.15s, box-shadow 0.15s; +} +.pn:hover { border-color: var(--blue); box-shadow: var(--shadow); } +.pn.next { text-align: right; } +.pn-label { display: block; font-size: 11.5px; color: var(--text-3); } +.pn-title { display: block; font-weight: 600; color: var(--text); font-size: 14px; margin-top: 2px; } + +/* 反馈 */ +.feedback { + margin-top: 28px; padding: 16px 18px; border-top: 1px solid var(--border); + display: flex; align-items: center; gap: 10px; color: var(--text-2); font-size: 13px; flex-wrap: wrap; +} +.fb-btn { + border: 1px solid var(--border); background: #fff; border-radius: 8px; padding: 5px 12px; + font-size: 12.5px; cursor: pointer; color: var(--text-2); transition: all 0.15s; +} +.fb-btn:hover { border-color: var(--blue); color: var(--blue); } +.fb-btn.done { border-color: var(--green); color: var(--green); background: rgba(5, 150, 105, 0.06); } +.fb-thanks { color: var(--green); font-size: 12.5px; } + +/* 右侧目录 */ +.toc { position: sticky; top: calc(var(--topbar-h) + 24px); align-self: start; max-height: calc(100vh - var(--topbar-h) - 48px); overflow-y: auto; padding-top: 34px; } +.toc-title { font-size: 11.5px; font-weight: 700; letter-spacing: 0.08em; color: var(--text-3); margin-bottom: 8px; } +.toc-nav { display: flex; flex-direction: column; gap: 1px; border-left: 2px solid var(--border); padding-left: 0; } +.toc-nav a { + font-size: 12.5px; color: var(--text-2); padding: 4px 0 4px 12px; text-decoration: none !important; + border-left: 2px solid transparent; margin-left: -2px; line-height: 1.5; +} +.toc-nav a:hover { color: var(--blue); } +.toc-nav a.toc-l3 { padding-left: 26px; font-size: 12px; color: var(--text-3); } +.toc-nav a.active { color: var(--blue); border-left-color: var(--blue); font-weight: 600; } +.print-btn { + margin-top: 18px; width: 100%; border: 1px solid var(--border); background: #fff; + border-radius: 8px; padding: 7px; font-size: 12.5px; color: var(--text-2); cursor: pointer; +} +.print-btn:hover { border-color: var(--blue); color: var(--blue); } + +/* 404 */ +.doc-404 { display: grid; place-items: center; min-height: 100vh; text-align: center; } +.doc-404 h1 { font-size: 56px; margin: 0; color: var(--blue); } +.doc-404 .btn { + display: inline-block; margin-top: 12px; background: var(--blue); color: #fff; + padding: 8px 18px; border-radius: 8px; text-decoration: none !important; font-weight: 600; +} + +/* ---------- 响应式 ---------- */ +@media (max-width: 1200px) { + .layout { grid-template-columns: var(--sidebar-w) minmax(0, 1fr); } + .toc { display: none; } +} +@media (max-width: 880px) { + .menu-btn { display: inline-grid; place-items: center; } + .layout { grid-template-columns: minmax(0, 1fr); padding-top: 12px; } + .sidebar { + position: fixed; z-index: 60; left: 0; top: var(--topbar-h); bottom: 0; + width: 280px; background: #fff; border-right: 1px solid var(--border); + padding: 16px; transform: translateX(-100%); transition: transform 0.2s; max-height: none; + } + .sidebar.open { transform: none; box-shadow: var(--shadow); } + .ver-badge { display: none; } + .top-links a { font-size: 12.5px; } + .page-title { font-size: 24px; } +} + +/* ---------- 打印 ---------- */ +@media print { + .topbar, .sidebar, .toc, .pn-nav, .feedback, .menu-btn { display: none !important; } + .layout { display: block; max-width: none; padding: 0; } + body { background: #fff; } + .md pre { background: #f1f5f9 !important; color: #1e293b !important; } + .md pre .copy-btn, .md pre .lang-tag { display: none; } +} diff --git a/apps/server/internal/docs/assets/docs.js b/apps/server/internal/docs/assets/docs.js new file mode 100644 index 0000000..005b49e --- /dev/null +++ b/apps/server/internal/docs/assets/docs.js @@ -0,0 +1,219 @@ +/* OpsxAPI 文档站交互:搜索 / 代码复制 / 目录高亮 / 反馈 / 移动端导航 */ +(function () { + "use strict"; + + /* ---------- 代码块:语言标签 + 复制按钮 + 轻量高亮 ---------- */ + document.querySelectorAll("article.md pre").forEach(function (pre) { + var code = pre.querySelector("code"); + if (!code) return; + var lang = (code.className.match(/language-([\w-]+)/) || [, ""])[1]; + if (lang) { + var tag = document.createElement("span"); + tag.className = "lang-tag"; + tag.textContent = lang; + pre.appendChild(tag); + } + // 复制按钮 + var btn = document.createElement("button"); + btn.className = "copy-btn"; + btn.type = "button"; + btn.textContent = "复制"; + btn.addEventListener("click", function () { + navigator.clipboard.writeText(code.textContent).then( + function () { + btn.textContent = "已复制 ✓"; + setTimeout(function () { btn.textContent = "复制"; }, 1400); + }, + function () { btn.textContent = "复制失败"; } + ); + }); + pre.appendChild(btn); + // JSON / bash 轻量高亮 + if (lang === "json") highlightJSON(code); + else if (lang === "bash" || lang === "sh" || lang === "shell") highlightBash(code); + }); + + function esc(s) { + return s.replace(/&/g, "&").replace(//g, ">"); + } + function highlightJSON(code) { + var src = code.textContent; + code.innerHTML = esc(src).replace( + /("(?:\\.|[^"\\])*")(\s*:)?|\b(true|false|null)\b|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g, + function (m, str, colon, kw, num) { + if (str && colon) return '' + str + "" + colon; + if (str) return '' + str + ""; + if (kw) return '' + kw + ""; + if (num) return '' + num + ""; + return m; + } + ); + } + function highlightBash(code) { + var lines = code.textContent.split("\n"); + code.innerHTML = lines + .map(function (line) { + if (/^\s*#/.test(line)) return '' + esc(line) + ""; + return esc(line).replace(/(^|\s)(-{1,2}[\w-]+)/g, '$1$2'); + }) + .join("\n"); + } + + /* ---------- blockquote 提示样式约定:> **提示** / > **注意** ---------- */ + document.querySelectorAll("article.md blockquote").forEach(function (bq) { + var t = (bq.textContent || "").trim(); + if (/^(注意|警告|⚠)/.test(t)) bq.classList.add("warn"); + else if (/^(提示|说明|💡|Tip)/i.test(t)) bq.classList.add("tip"); + }); + + /* ---------- 搜索 ---------- */ + var input = document.getElementById("search-input"); + var panel = document.getElementById("search-panel"); + var entries = null; + var selIndex = -1; + + function ensureIndex(cb) { + if (entries) return cb(); + fetch("/docs/search-data.json") + .then(function (r) { return r.json(); }) + .then(function (data) { entries = data; cb(); }) + .catch(function () { entries = []; cb(); }); + } + + function snippet(text, q) { + var i = text.toLowerCase().indexOf(q); + if (i < 0) return text.slice(0, 60) + "…"; + var start = Math.max(0, i - 24); + return (start > 0 ? "…" : "") + text.slice(start, i + q.length + 40) + "…"; + } + + function doSearch() { + var q = input.value.trim().toLowerCase(); + selIndex = -1; + if (!q) { panel.hidden = true; return; } + ensureIndex(function () { + var hits = []; + entries.forEach(function (e) { + var titleHit = e.title.toLowerCase().indexOf(q) >= 0; + var idx = e.text.toLowerCase().indexOf(q); + if (titleHit || idx >= 0) { + hits.push({ + slug: e.slug, + title: e.title, + group: e.group, + score: (titleHit ? 10 : 0) + (idx === 0 ? 3 : 0), + snippet: titleHit ? snippet(e.text, q) : snippet(e.text, q), + }); + } + }); + hits.sort(function (a, b) { return b.score - a.score; }); + hits = hits.slice(0, 8); + if (!hits.length) { + panel.innerHTML = '
没有找到相关内容,换个关键词试试
'; + } else { + panel.innerHTML = hits + .map(function (h) { + return ( + '' + + '' + h.title + '' + h.group + "" + + '' + h.snippet + "" + ); + }) + .join(""); + } + panel.hidden = false; + }); + } + + var debounce; + if (input) { + input.addEventListener("input", function () { + clearTimeout(debounce); + debounce = setTimeout(doSearch, 160); + }); + input.addEventListener("focus", function () { + if (input.value.trim()) doSearch(); + }); + input.addEventListener("keydown", function (e) { + var items = panel.querySelectorAll(".search-item"); + if (e.key === "ArrowDown" && items.length) { + e.preventDefault(); + selIndex = Math.min(selIndex + 1, items.length - 1); + } else if (e.key === "ArrowUp" && items.length) { + e.preventDefault(); + selIndex = Math.max(selIndex - 1, 0); + } else if (e.key === "Enter") { + if (selIndex >= 0 && items[selIndex]) { location.href = items[selIndex].href; return; } + var first = items[0]; + if (first) location.href = first.href; + return; + } else if (e.key === "Escape") { + panel.hidden = true; + return; + } else { + return; + } + items.forEach(function (el, i) { el.classList.toggle("sel", i === selIndex); }); + }); + document.addEventListener("click", function (e) { + if (!e.target.closest(".search")) panel.hidden = true; + }); + } + + /* ---------- 目录滚动高亮 ---------- */ + var tocLinks = Array.prototype.slice.call(document.querySelectorAll(".toc-nav a")); + if (tocLinks.length && "IntersectionObserver" in window) { + var byId = {}; + tocLinks.forEach(function (a) { byId[a.getAttribute("href").slice(1)] = a; }); + var io = new IntersectionObserver( + function (entries) { + entries.forEach(function (en) { + if (en.isIntersecting) { + tocLinks.forEach(function (a) { a.classList.remove("active"); }); + var a = byId[en.target.id]; + if (a) a.classList.add("active"); + } + }); + }, + { rootMargin: "-72px 0px -70% 0px", threshold: 0 } + ); + document.querySelectorAll("article.md h2[id], article.md h3[id]").forEach(function (h) { io.observe(h); }); + } + + /* ---------- 反馈组件 ---------- */ + var fb = document.getElementById("feedback"); + if (fb) { + var pageKey = "opsx-docs-fb:" + location.pathname; + var voted = null; + try { voted = localStorage.getItem(pageKey); } catch (e) {} + var btns = fb.querySelectorAll(".fb-btn"); + var thanks = document.getElementById("fb-thanks"); + function mark(v) { + btns.forEach(function (b) { + if (b.dataset.v === v) b.classList.add("done"); + }); + thanks.hidden = false; + try { localStorage.setItem(pageKey, v); } catch (e) {} + } + if (voted) mark(voted); + btns.forEach(function (b) { + b.addEventListener("click", function () { mark(b.dataset.v); }); + }); + } + + /* ---------- 打印 ---------- */ + var printBtn = document.getElementById("print-btn"); + if (printBtn) printBtn.addEventListener("click", function () { window.print(); }); + + /* ---------- 移动端侧栏 ---------- */ + var menuBtn = document.getElementById("menu-btn"); + var sidebar = document.getElementById("sidebar"); + if (menuBtn && sidebar) { + menuBtn.addEventListener("click", function () { sidebar.classList.toggle("open"); }); + document.addEventListener("click", function (e) { + if (sidebar.classList.contains("open") && !e.target.closest(".sidebar") && !e.target.closest(".menu-btn")) { + sidebar.classList.remove("open"); + } + }); + } +})(); diff --git a/apps/server/internal/docs/content/01-intro.md b/apps/server/internal/docs/content/01-intro.md new file mode 100644 index 0000000..890eb40 --- /dev/null +++ b/apps/server/internal/docs/content/01-intro.md @@ -0,0 +1,36 @@ +OpsxAPI 是一款**本地优先**的专业接口工作台,面向运维与开发,把 HTTP、GraphQL、WebSocket、SSE、Socket.IO、MQTT 六种协议的调试放进同一个窗口。 + +无需注册、无需联网同步,数据全部保存在本地浏览器(localStorage),导出备份随时带走。 + +## 功能总览 + +| 能力 | 说明 | +| --- | --- | +| 六种协议 | HTTP / GraphQL / WebSocket / SSE / Socket.IO / MQTT | +| 集合管理 | 目录树、搜索、右键菜单、拖拽移动、多标签页 | +| 多环境变量 | `{{baseUrl}}`、`{{token}}` 全局替换,一键切换环境 | +| 数据导入 | OpenAPI/Swagger、Postman、Apifox、Apipost、cURL、HAR、Insomnia、Hoppscotch、Markdown 等 | +| 数据导出 | OpenAPI 3.0、Postman v2.1、Markdown 文档、全量备份 | +| 代码生成 | cURL / JavaScript fetch / Python requests / Go net/http | +| 代理能力 | 本地 Gin 代理绕开 CORS;WebSocket 隧道支持自定义握手 Header | +| 双端形态 | 浏览器(Web)与桌面端(Tauri)共用同一套界面 | + +## 支持的协议与场景 + +| 协议 | 典型场景 | 特色 | +| --- | --- | --- | +| HTTP / REST | 调试 REST 接口、运维平台 API | 7 种方法、6 种请求体、3 种鉴权 | +| GraphQL | 查询网关、中台接口 | Query + Variables 独立编辑 | +| WebSocket | 实时推送、行情、IM | 自定义握手 Header(经本地隧道) | +| SSE | AI 流式输出、监控事件流 | 直连失败自动切代理、断点续传 | +| Socket.IO | Node 实时应用 | 事件监听、emit 参数、path/auth 配置 | +| MQTT | IoT 设备、消息总线 | 通配符订阅、QoS、自动重连 | + +## 为什么选 OpsxAPI + +- **数据主权**:所有集合、环境、历史保存在本机,不经过任何第三方服务器。 +- **开箱即用**:`./run.sh web` 一条命令启动;首次打开自带覆盖全部协议的示例集合。 +- **工程友好**:导入自动建目录、路径参数自动转变量;导出即标准 OpenAPI,可回流 Apifox / Swagger 生态。 +- **轻量克制**:一个 Go 二进制 + 一份前端,资源占用小,内网可部署。 + +> **提示**:想快速感受?直接看下一章《快速开始》,30 秒发出第一个请求。 diff --git a/apps/server/internal/docs/content/02-quickstart.md b/apps/server/internal/docs/content/02-quickstart.md new file mode 100644 index 0000000..f9d36a3 --- /dev/null +++ b/apps/server/internal/docs/content/02-quickstart.md @@ -0,0 +1,48 @@ +## 环境要求 + +| 组件 | 版本 | 用途 | +| --- | --- | --- | +| Node.js | ≥ 20 | Web 前端(Vite) | +| Go | ≥ 1.23 | 本地代理服务(Gin) | +| Rust + Cargo | 可选 | 仅编译桌面端(Tauri)时需要 | + +## 启动 + +```bash +git clone <你的仓库地址> opsxapi +cd opsxapi +make install # 安装依赖(npm install,含桌面端) + +./run.sh web # 启动:Gin(:8787) + Web UI(:5173) +``` + +看到下面的输出即可打开浏览器: + +```bash +[OK] 后端 UP http://127.0.0.1:8787/health +[OK] Web UP http://127.0.0.1:5173 +``` + +| 命令 | 说明 | +| --- | --- | +| `./run.sh web` | 浏览器版(推荐) | +| `./run.sh gui` | 桌面窗口(Tauri,共用同一 UI) | +| `./run.sh all` | Web + 桌面一起开 | +| `./run.sh status` / `stop` | 查看状态 / 停止 | + +## 30 秒发出第一个请求 + +1. 打开 ,左侧是内置的「示例集合」。 +2. 点击 **HTTP · httpbin → 获取请求回显**。 +3. 点击右上角 **发送**。 +4. 下方响应区显示 `200`、耗时与 JSON 高亮响应体 —— 完成! + +## 桌面端(可选) + +```bash +./run.sh gui +``` + +桌面端复用同一份界面与数据通道,HTTP 请求走 Tauri 原生网络栈,不依赖浏览器 CORS。 + +> **提示**:首次打开会自动创建两个环境(正式 / 本地)和一个覆盖全部协议的示例集合,可随时在「设置」中重置。 diff --git a/apps/server/internal/docs/content/03-interface.md b/apps/server/internal/docs/content/03-interface.md new file mode 100644 index 0000000..bbf9e94 --- /dev/null +++ b/apps/server/internal/docs/content/03-interface.md @@ -0,0 +1,41 @@ +OpsxAPI 主界面分为五个区域: + +## 顶栏 + +| 元素 | 作用 | +| --- | --- | +| + 新建 | 快捷创建各协议请求 / 目录(默认建在第一个集合) | +| 导入 / 导出 | 打开数据导入、导出对话框 | +| 环境选择器 | 切换当前环境(变量随之生效),「⚙ 环境」管理环境与变量 | +| 🌙 / ☀️ | 明暗主题切换 | +| ⋯ | 设置(主题、超时、重置工作区) | + +## 左侧集合树 + +- 点击请求 → 在标签页中打开;点击目录 → 展开 / 折叠。 +- **右键**任意节点:新建、重命名、复制、导出、删除。 +- **拖拽**请求或目录到另一个目录,即可移动(支持跨集合)。 +- 顶部搜索框按名称 / 方法 / URL 实时过滤。 +- 底部为**历史记录**:显示最近请求的状态码与耗时,点击可回跳到对应接口。 + +## 标签页栏 + +同时打开多个接口,中键或 × 关闭;右键支持「关闭其他 / 关闭全部」。正在连接中的实时协议标签会显示呼吸圆点。 + +## 请求编辑区(上半部分) + +按协议呈现不同编辑器: + +| 协议 | 标签页 | +| --- | --- | +| HTTP | Params · Body · Headers · Cookies · Auth · 说明 | +| GraphQL | Query · Variables · Headers · Auth | +| WebSocket | 消息 · 握手配置 · 说明 | +| SSE | 事件流 · 连接配置 · 说明 | +| Socket.IO | 事件 · 连接配置 · 说明 | +| MQTT | 消息 · Broker / 订阅 · 发布 · 说明 | + +## 响应区(下半部分) + +- **HTTP / GraphQL**:响应体(Pretty JSON 高亮)、Raw、响应头,右上角状态 / 耗时 / 大小 / 复制。 +- **实时协议**:分色消息日志(接收 · 绿、发送 · 蓝、系统 · 灰、错误 · 红),顶部实时统计收发条数与连接时长。 diff --git a/apps/server/internal/docs/content/04-http.md b/apps/server/internal/docs/content/04-http.md new file mode 100644 index 0000000..dfafae7 --- /dev/null +++ b/apps/server/internal/docs/content/04-http.md @@ -0,0 +1,47 @@ +## 发送请求 + +1. 选择方法(GET / POST / PUT / PATCH / DELETE / HEAD / OPTIONS)。 +2. 输入 URL,支持 `{{baseUrl}}/path` 形式的变量(见《环境与变量》)。 +3. 点击 **发送**,或直接在 URL 输入框按 **Enter**。 + +请求默认经本地 Gin 代理(`/v1/proxy/http`)发出,天然绕开浏览器 CORS 限制。 + +## Params(查询参数) + +键值对编辑器,每行可勾选启用 / 停用、填写说明。URL 中已有的查询串会被拆分到表格里,两边双向同步。 + +## Body(请求体) + +| 模式 | 适用场景 | +| --- | --- | +| none | GET 等无体请求 | +| JSON | REST 接口(默认自动补 `Content-Type: application/json`,带格式化按钮) | +| XML / Text / HTML | 原始报文 | +| x-www-form-urlencoded | 表单提交 | +| form-data | multipart 文本字段(文件上传请用代码生成的 cURL) | + +## Headers / Cookies + +- Headers:键值对,支持 `{{变量}}`;`Authorization`、`Cookie` 由 Auth / Cookies 标签统一管理时避免重复。 +- Cookies:键值对,发送时自动拼接为 `Cookie` 请求头。 + +## 代码生成 + +点击 URL 栏右侧 **`` 代码**,可生成: + +- cURL(可直接在终端执行) +- JavaScript fetch +- Python requests +- Go net/http + +生成内容保留 `{{变量}}` 原样,便于粘贴到脚本后再替换。 + +## 响应面板 + +| 标签 | 内容 | +| --- | --- | +| 响应体 | JSON 自动格式化 + 语法高亮;其他类型原样展示 | +| Raw | 未格式化的原始文本 | +| 响应头 | 全部响应头表格 | + +右上角常驻 **状态码 / 耗时 / 大小 / Content-Type**,并提供「复制体」按钮。 diff --git a/apps/server/internal/docs/content/05-auth.md b/apps/server/internal/docs/content/05-auth.md new file mode 100644 index 0000000..5cdb509 --- /dev/null +++ b/apps/server/internal/docs/content/05-auth.md @@ -0,0 +1,29 @@ +在 HTTP / GraphQL / SSE 请求的 **Auth** 标签中选择鉴权方式: + +## Bearer Token + +```text +Authorization: Bearer +``` + +Token 支持 `{{token}}` 变量引用 —— 推荐把 token 放进环境变量,一次配置全局生效。 + +## Basic Auth + +填写用户名 / 密码,自动进行 Base64 编码后放入 `Authorization` 头。 + +## API Key + +| 字段 | 说明 | +| --- | --- | +| Key 名 | 如 `X-API-Key` | +| 值 | 支持 `{{变量}}` | +| 位置 | Header(默认)或 Query 参数 | + +## 组合规则 + +- 鉴权产生的 Header 会与 Headers 标签中的同名项合并(鉴权优先)。 +- API Key 放 Query 时,会追加到 Params,不会重复出现在 Headers。 +- 导入 OpenAPI / Postman 时,文档中声明的 `securitySchemes` 会自动映射到对应鉴权方式。 + +> **注意**:鉴权信息保存在本地集合数据中。如需分享集合,请先通过「导出」审查是否包含敏感 token。 diff --git a/apps/server/internal/docs/content/06-env.md b/apps/server/internal/docs/content/06-env.md new file mode 100644 index 0000000..235175a --- /dev/null +++ b/apps/server/internal/docs/content/06-env.md @@ -0,0 +1,50 @@ +## 变量语法 + +在 URL、Params、Headers、Cookies、Body、鉴权字段中,使用双花括号引用变量: + +```text +{{baseUrl}}/v1/users?page={{page}} +Authorization: Bearer {{token}} +``` + +发送时统一替换为**当前启用环境**中同名变量的值;未定义的变量保留原样,便于发现遗漏。 + +## 切换环境 + +顶栏环境切换器显示**彩色单字徽标 + 环境名 + 变量数**,点击弹出环境列表(当前环境带「当前」标记),一眼防误发;底部可进入「管理环境」或「不使用环境」。 + +## 管理环境 + +顶栏 **环境** 按钮打开管理弹窗(左侧环境列表 + 右侧编辑区): + +- **左栏**:环境列表(徽标 + 名称 + 当前标记),底部虚线按钮新建环境。 +- **右栏**:提示条(变量引用语法 + 文档直达)、环境重命名、设为当前 / 取消使用、**复制环境**、删除;变量表支持**按名称 / 描述搜索**、逐行启用停用、内联编辑。 +- 所有修改即时生效并自动保存。 + +## 输入 {{ 自动补全 + +在 URL 输入框或任意「值」输入框键入 `{{`,会弹出当前环境的变量浮层: + +- ↑↓ 选择、Enter / Tab 插入完整的 `{{变量名}}`;Esc 关闭。 +- 浮层同时显示变量值,选之前即可确认。 + +内置示例环境: + +| 环境 | baseUrl | token | +| --- | --- | --- | +| 正式 | `https://httpbin.org` | demo-token | +| 本地 | `http://127.0.0.1:8787` | dev-token | + +## 变量从哪来自动生成 + +| 来源 | 行为 | +| --- | --- | +| 导入 OpenAPI / Swagger | `servers` 地址写入 `baseUrl`;路径参数 `{id}` 转为 `{{id}}` 并写入示例值 | +| 导入 Postman | `collection.variable` 写入当前环境 | +| 手动输入 | 在环境面板随时增改 | + +## 实际地址预览 + +URL 含 `{{ }}` 时,编辑器会在 URL 栏下方实时显示替换后的实际地址,切换环境立即更新。 + +> **提示**:路径参数用变量而不是写死,是接口可复用的关键 —— 复制接口后只改变量值即可调试另一个资源。 diff --git a/apps/server/internal/docs/content/07-graphql.md b/apps/server/internal/docs/content/07-graphql.md new file mode 100644 index 0000000..bce7335 --- /dev/null +++ b/apps/server/internal/docs/content/07-graphql.md @@ -0,0 +1,32 @@ +## 编辑查询 + +GraphQL 请求由三部分组成: + +| 部分 | 说明 | +| --- | --- | +| Query | GraphQL 查询语句,支持变量定义 `$code: ID!` | +| Variables | JSON 对象,随查询一起发送;非法 JSON 会红框提示 | +| Headers / Auth | 与 HTTP 请求一致 | + +## 发送 + +点击 **执行**(或 URL 栏按 Enter)。实际发送的是标准 GraphQL over HTTP: + +```json +{ + "query": "query ($code: ID!) { country(code: $code) { name capital currency } }", + "variables": { "code": "CN" } +} +``` + +响应面板会自动格式化 `data` / `errors`,字段名高亮。 + +## 内置示例 + +示例集合中的「国家查询 · countries」连接公共 GraphQL 服务 ,无需鉴权,可直接体验。 + +## 最佳实践 + +- 把 endpoint 存为 `{{baseUrl}}/graphql`,环境切换即切换网关。 +- 需要鉴权时在 Auth 标签配置 Bearer,不必手写 Header。 +- 想看实际报文?点 `` 代码 生成 cURL,即为此查询的完整 HTTP 请求。 diff --git a/apps/server/internal/docs/content/08-websocket.md b/apps/server/internal/docs/content/08-websocket.md new file mode 100644 index 0000000..f2ff524 --- /dev/null +++ b/apps/server/internal/docs/content/08-websocket.md @@ -0,0 +1,34 @@ +## 连接 + +1. 填写 `ws://` 或 `wss://` 地址。 +2. 点击 **连接**;状态点变绿(已连接)后即可收发消息。 +3. 再次点击按钮即断开。 + +## 消息收发 + +- **消息日志**:接收(绿)/ 发送(蓝)/ 系统(灰)/ 错误(红)分色展示,顶部统计 ↑↓ 条数与连接时长。 +- **发送框**:支持 JSON / 纯文本;**⌘/Ctrl + Enter** 快速发送;「格式化 JSON」一键美化。 + +## 子协议 + +「握手配置」标签中的**子协议**填逗号分隔列表,例如: + +```text +graphql-transport-ws, v1.json +``` + +## 自定义握手 Header(隧道模式) + +浏览器原生 WebSocket **不能**携带自定义 Header(如 `Authorization`、`X-API-Key`)。OpsxAPI 的处理方式: + +1. 「握手配置」中填写 Header 后,工具自动改走**本地 Gin 隧道**: + - 浏览器 → Gin `/v1/proxy/ws/attach`(普通 WS) + - Gin → 目标服务器(携带你填的全部 Header) +2. 不填 Header 时直连目标,零额外开销。 + +> **注意**:隧道要求本地 Gin 服务(:8787)在运行,即用 `./run.sh web` / `gui` 启动的默认形态。 + +## 调试技巧 + +- 消息过多时点「清空」;日志上限 500 条,超出自动滚动淘汰。 +- 把常用报文保存在「说明」里,或为不同报文建多个 WS 请求节点。 diff --git a/apps/server/internal/docs/content/09-sse.md b/apps/server/internal/docs/content/09-sse.md new file mode 100644 index 0000000..050c0d6 --- /dev/null +++ b/apps/server/internal/docs/content/09-sse.md @@ -0,0 +1,29 @@ +## 连接事件流 + +1. 填写 SSE 地址(如 `https://stream.wikimedia.org/v2/stream/recentchange`)。 +2. 点击 **连接**,事件按到达顺序流入消息日志。 +3. 每条消息按 `event:` 名称归类显示;`data` 为 JSON 时自动格式化。 + +## 直连与代理自动切换 + +- 默认**直连**目标站(fetch 流式读取,延迟最低)。 +- 直连因 CORS / 网络失败时,自动降级走**本地 Gin 流式代理**,日志中会提示切换过程,无需手工干预。 + +## 断点续传(Last-Event-ID) + +「连接配置」中填写 Last-Event-ID,连接时以 `Last-Event-ID` 请求头携带,支持断点续传的流会在该事件之后继续推送。 + +## 连接配置项 + +| 配置 | 说明 | +| --- | --- | +| Query 参数 / Headers | 以键值对附加 | +| Last-Event-ID | 断点续传游标 | +| 仅显示 data 内容 | 开启后只显示 `data:` 载荷;关闭则连同 `event:` 名称一起显示 | +| 鉴权 | 与 HTTP 相同(Bearer / Basic / API Key) | + +## 典型场景 + +- LLM 流式输出接口(`text/event-stream`) +- 监控 / 审计事件流 +- 需要长期挂机观察的推送通道 diff --git a/apps/server/internal/docs/content/10-socketio.md b/apps/server/internal/docs/content/10-socketio.md new file mode 100644 index 0000000..cb9e0b6 --- /dev/null +++ b/apps/server/internal/docs/content/10-socketio.md @@ -0,0 +1,45 @@ +## 连接 + +1. 填写服务地址(**不含** `/socket.io` 路径),如 `http://127.0.0.1:3000`。 +2. 点击 **连接**,状态变为「已连接」后即可收发事件。 + +## 监听事件 + +「连接配置」→ **监听事件** 填逗号分隔的事件名: + +```text +message, notification +``` + +留空则接收全部服务端事件(onAny 模式)。事件名在每条日志中标注。 + +## 发送(emit) + +「事件」标签底部的发送区: + +- **事件名**:如 `message` +- **参数**:JSON 数组,作为 `socket.emit(event, ...args)` 的参数 + +```json +["hello", 42] +``` + +## 连接选项 + +以 JSON 配置传给 socket.io-client: + +```json +{ + "path": "/socket.io", + "transports": ["websocket"], + "auth": { "token": "{{token}}" } +} +``` + +| 选项 | 说明 | +| --- | --- | +| path | 服务端挂载路径,默认 `/socket.io` | +| transports | `websocket` / `polling`,默认两者皆可 | +| auth | 握手鉴权载荷,支持 `{{变量}}` | + +> **提示**:浏览器直连外部站点同样受 CORS 限制,Socket.IO 服务端需配置对应 origin,或把服务与本工具部署在同源下。 diff --git a/apps/server/internal/docs/content/11-mqtt.md b/apps/server/internal/docs/content/11-mqtt.md new file mode 100644 index 0000000..fdb1995 --- /dev/null +++ b/apps/server/internal/docs/content/11-mqtt.md @@ -0,0 +1,41 @@ +## 连接 Broker + +浏览器环境要求 **WebSocket 形式**的 Broker 地址: + +```text +wss://broker.emqx.io:8084/mqtt +``` + +| 配置 | 说明 | +| --- | --- | +| 用户名 / 密码 | 可选,支持 `{{变量}}` | +| ClientId | 默认随机生成;同一 Broker 上必须唯一 | +| QoS | 0 至多一次 · 1 至少一次 · 2 恰好一次 | + +## 订阅 + +「Broker / 订阅」中填写逗号分隔的主题,支持通配符: + +```text +opsxapi/demo, sensor/+, home/# +``` + +> **注意**:订阅在**建立连接时**生效,修改主题后需断开重连。 + +## 发布 + +「发布」标签配置主题与 Payload(JSON / 纯文本),点击发布按钮即送达;已发布消息以蓝色出现在日志中并标注主题。 + +## 公共测试 Broker + +| Broker | 地址 | +| --- | --- | +| EMQX | `wss://broker.emqx.io:8084/mqtt` | +| HiveMQ | `wss://broker.hivemq.com:8884/mqtt` | + +两个浏览器窗口连接同一主题,即可互相收发验证。 + +## 行为说明 + +- 断线自动重连(3 秒间隔),状态点显示「重连中」。 +- 消息日志上限 500 条;QoS 与主题逐条标注。 diff --git a/apps/server/internal/docs/content/12-import.md b/apps/server/internal/docs/content/12-import.md new file mode 100644 index 0000000..13d5384 --- /dev/null +++ b/apps/server/internal/docs/content/12-import.md @@ -0,0 +1,40 @@ +顶栏 **导入** 打开导入对话框,支持三种来源: + +| 来源 | 说明 | +| --- | --- | +| 粘贴文本 / cURL | 直接粘贴任意受支持格式的内容 | +| 文件导入 | 选择或拖入 `.json` / `.yaml` / `.har` / `.md` / `.txt` | +| URL 导入(Swagger) | 填写 OpenAPI 地址,经本地 Gin 代理拉取,避开 CORS | + +格式**自动识别**,也可手动指定。 + +## 支持格式 + +| 格式 | 细节 | +| --- | --- | +| OpenAPI 3.0 / 3.1、Swagger 2.0 | JSON 或 YAML;Apifox 导出的 OpenAPI 同样适用 | +| Postman Collection | v2.0 / v2.1,含鉴权、变量、GraphQL body | +| Apipost 项目 | `api_collection` 格式(宽容解析) | +| cURL | 引号、续行、`-d` / `-F` / `-u` / `--data-urlencode`、cookie、代理等参数 | +| HAR | 按域名分组、相同请求去重 | +| Insomnia | 导出文件 v3 / v4,含 workspace / group 层级 | +| Hoppscotch | 集合 JSON(数组或单对象) | +| Markdown | 提取文中的 ```curl 代码块 | +| OpsxAPI 备份 | 全量集合 + 环境,用于迁移恢复 | + +YApi、Apidoc、Apizza、Eolink 请先在原平台导出为 **OpenAPI 或 Postman** 格式,再导入本工具。 + +## 导入时自动完成 + +| 原始内容 | 转换结果 | +| --- | --- | +| OpenAPI `tags` | 目录分组 | +| 路径参数 `/pets/{petId}` | `{{petId}}` 变量 + 环境示例值 | +| `servers` 地址 | 当前环境 `baseUrl` 变量 | +| requestBody schema | JSON 示例请求体 | +| securitySchemes | Bearer / Basic / API Key 鉴权配置 | +| Postman `variable` | 当前环境变量 | + +## 结果确认 + +导入完成显示汇总:新建集合、接口数量、写入的环境变量与告警(如跳过的文件上传字段)。集合名重复时自动追加序号。 diff --git a/apps/server/internal/docs/content/13-export.md b/apps/server/internal/docs/content/13-export.md new file mode 100644 index 0000000..0bdcbc6 --- /dev/null +++ b/apps/server/internal/docs/content/13-export.md @@ -0,0 +1,30 @@ +顶栏 **导出**(多集合时可选择目标集合): + +| 格式 | 用途 | +| --- | --- | +| OpenAPI 3.0 · JSON / YAML | 回流 Apifox、Swagger UI、代码生成器 | +| Postman v2.1 | 直接导入 Postman | +| Markdown 接口文档 | 带目录、参数表格、请求体代码块,可放 Wiki | +| 全量备份 | 全部集合 + 环境,用于迁移与灾备 | + +每种格式均支持**复制到剪贴板**与**下载文件**;对话框内实时预览导出内容。 + +## 全量备份与恢复 + +- 备份文件是包含 `__opsxapi__: "backup"` 标记的 JSON。 +- 恢复:导入对话框 → 粘贴备份内容或选择备份文件 → 格式自动识别为「OpsxAPI 备份」。 + +## 代码生成(单请求) + +在 HTTP / GraphQL 编辑器点 **`` 代码**: + +| 目标 | 说明 | +| --- | --- | +| cURL | 含方法、头、体、cookie,可直接执行 | +| JavaScript fetch | `async/await` 风格 | +| Python requests | 含 `import requests` 完整脚本 | +| Go net/http | 完整 `main` 函数 | + +`{{变量}}` 保留原样输出,方便接入你自己的配置体系。 + +> **提示**:导出的 OpenAPI 中,目录名会成为 `tags`,请求说明写入 `description`,鉴权自动生成 `securitySchemes`。 diff --git a/apps/server/internal/docs/content/14-manage.md b/apps/server/internal/docs/content/14-manage.md new file mode 100644 index 0000000..2c7f71b --- /dev/null +++ b/apps/server/internal/docs/content/14-manage.md @@ -0,0 +1,39 @@ +## 新建 + +| 入口 | 行为 | +| --- | --- | +| 顶栏「+ 新建」 | 快捷菜单,建在第一个集合 | +| 树右键「新建请求 / 目录」 | 精确指定位置 | +| 新建对话框 | 选择类型(六协议 + 目录)、命名、目标集合与目录 | + +接口名自动避免重复(追加序号)。 + +## 集合树操作 + +| 操作 | 方式 | +| --- | --- | +| 重命名 | 右键 → 重命名 | +| 复制 | 右键 → 复制(深拷贝子树,名字加「副本」) | +| 移动 | 拖拽到目标目录(支持跨集合) | +| 删除 | 右键 → 删除(直接生效,请先备份重要数据) | +| 搜索 | 侧栏搜索框,匹配名称 / 方法 / URL | + +## 标签页 + +- 点击树节点在标签页打开;同时打开数量不限。 +- 中键 / × 关闭;右键「关闭其他 / 关闭全部」。 +- 关闭实时协议(WS / SSE / MQTT…)标签时自动断开连接。 + +## 历史记录 + +- 每次 HTTP / GraphQL 发送记录:时间、方法、接口名、状态码,失败标红。 +- 点击记录跳回对应接口;最多保留 100 条;可一键清空。 + +## 数据存储与备份 + +- 位置:浏览器 localStorage(键 `opsxapi.workspace.v2`),自动保存(防抖 400ms)。 +- 内容:集合、环境、历史、设置。标签页会话与实时消息不落盘。 +- 备份:导出 → 全量备份;恢复:导入备份文件。 +- 重置:设置 → 重置工作区(恢复初始示例,请先备份)。 + +> **注意**:桌面端与浏览器是两份独立的 localStorage。跨端迁移请使用全量备份 / 导入。 diff --git a/apps/server/internal/docs/content/15-server-api.md b/apps/server/internal/docs/content/15-server-api.md new file mode 100644 index 0000000..d0767c7 --- /dev/null +++ b/apps/server/internal/docs/content/15-server-api.md @@ -0,0 +1,75 @@ +本地服务(Gin)默认监听 `:8787`,可用环境变量 `OPSXAPI_ADDR` 修改。所有端点仅监听本机回环,供 Web UI 与桌面端调用。 + +## 端点总览 + +| 方法 | 路径 | 用途 | +| --- | --- | --- | +| GET | `/health` | 健康检查 | +| POST | `/v1/proxy/http` | HTTP 代理(全方法、自定义头、超时) | +| POST | `/v1/proxy/http/stream` | NDJSON 流式代理(SSE / chunked 透传) | +| POST | `/v1/proxy/ws/connect` | 建立到目标 WS 的隧道会话 | +| GET | `/v1/proxy/ws/attach` | 浏览器 attach 到隧道会话(双向桥接) | +| POST | `/v1/proxy/ws/close` | 关闭隧道会话 | +| GET | `/docs` | 本文档站 | + +## HTTP 代理 + +```bash +curl -s -X POST http://127.0.0.1:8787/v1/proxy/http \ + -H 'Content-Type: application/json' \ + -d '{"method":"GET","url":"https://httpbin.org/get","headers":{}}' +``` + +响应: + +```json +{ + "status": 200, + "headers": { "content-type": "application/json" }, + "body": "{ ... }", + "timeMs": 320, + "size": 274 +} +``` + +请求体 `body.mode` 支持 `json` / `xml` / `text` / `html` / `urlencoded` / `form-data`,`raw` 为原始报文;`timeoutMs` 可选。 + +## 流式代理 + +请求体与 HTTP 代理一致,响应为 NDJSON 事件流: + +```text +{"type":"meta","status":200,"headers":{...}} +{"type":"chunk","data":"","size":1024} +{"type":"end","timeMs":1200,"size":8192} +``` + +## WebSocket 隧道 + +```bash +# 1. 建立会话(可带自定义 Header) +curl -s -X POST http://127.0.0.1:8787/v1/proxy/ws/connect \ + -H 'Content-Type: application/json' \ + -d '{"url":"wss://echo.example.com","headers":{"Authorization":"Bearer t"}}' +# → {"sessionId":"..."} + +# 2. 浏览器 / 客户端 attach(普通 WebSocket) +# ws://127.0.0.1:8787/v1/proxy/ws/attach?sessionId=... + +# 3. 关闭 +curl -s -X POST http://127.0.0.1:8787/v1/proxy/ws/close \ + -H 'Content-Type: application/json' -d '{"sessionId":"..."}' +``` + +attach 后第一条消息为控制帧 `{"type":"open"}`;此后浏览器与目标服务器双向透传。 + +## 安全边界 + +- 仅允许 `http/https`(代理)与 `ws/wss`(隧道)目标。 +- 拒绝云元数据地址(169.254.169.254 等)防 SSRF。 +- 单响应上限 10 MiB;隧道会话 10 分钟无活动自动回收。 + +## 部署建议 + +- 开发:`./run.sh web`(Vite :5173 反代 `/v1`、`/docs` 到 :8787)。 +- 生产:`npm run build:web` 产物交给任意静态服务器,`opsxapi-server` 独立部署;生产环境建议置于内网并按需增加鉴权反向代理。 diff --git a/apps/server/internal/docs/content/16-faq.md b/apps/server/internal/docs/content/16-faq.md new file mode 100644 index 0000000..fd9125c --- /dev/null +++ b/apps/server/internal/docs/content/16-faq.md @@ -0,0 +1,45 @@ +## 请求报 CORS 错误? + +Web 端请求默认经本地 Gin 代理(gin-proxy)发出,不会遇到 CORS。若仍报错: + +- 确认 Gin 在运行:`curl http://127.0.0.1:8787/health`。 +- 用 `./run.sh web` / `gui` 场景启动(Vite 已配置 `/v1` 反代)。 +- 桌面端优先走 Tauri 原生网络栈,同样不受 CORS 限制。 + +## 浏览器 WebSocket 不能加自定义 Header? + +规范限制。在 WS「握手配置」里填写 Header 后,OpsxAPI 自动改走本地 Gin 隧道(见《WebSocket》章节),Header 由 Gin 在握手时代为携带。不填 Header 则直连。 + +## 外部 wss:// 地址连不上(DNS 失败)? + +本机网络环境可能限制部分域名解析。可先用 `curl -v wss://…` 或换用公共 echo 服务验证;自建服务请确认防火墙与证书。 + +## MQTT 一定要 wss:// 吗? + +浏览器只能通过 WebSocket 连 MQTT。常见 Broker 的 WS 端口:EMQX `8084/mqtt`(TLS)、`8083/mqtt`(明文,仅 http 页面)。`mqtt://` TCP 直连仅在桌面原生环境可行。 + +## 数据存在哪里?换电脑怎么办? + +集合 / 环境 / 历史保存在浏览器 localStorage。迁移:**导出 → 全量备份** 生成 JSON,在新设备 **导入** 即可。 + +## 误删了接口 / 想回到初始状态? + +设置(顶栏 ⋯)→ 重置工作区,恢复内置示例集合。日常请养成导出备份的习惯。 + +## SSE 事件流看着卡住了? + +- 事件频率低(如告警流)属正常,等待即可。 +- 部分网关会缓冲 SSE,工具会在直连失败时自动切换 Gin 流式代理;若仍无事件,检查中间层(Nginx 需关闭 `proxy_buffering`)。 + +## 发送按钮点击后没反应? + +查看响应区是否有红色错误提示(如代理不可达)。`` 代码 生成 cURL 在终端执行可快速分辨是工具问题还是网络 / 接口问题。 + +## 支持文件上传吗? + +form-data 当前支持文本字段;文件上传请用 `` 代码 生成的 cURL / 脚本执行。 + +## 更多问题? + +- 服务端能力与边界:见《服务端 API 参考》 +- 各协议用法:见左侧「请求调试」分组 diff --git a/apps/server/internal/docs/content/manifest.json b/apps/server/internal/docs/content/manifest.json new file mode 100644 index 0000000..b04288a --- /dev/null +++ b/apps/server/internal/docs/content/manifest.json @@ -0,0 +1,41 @@ +{ + "productVersion": "1.0", + "groups": [ + { + "name": "开始使用", + "pages": [ + { "file": "01-intro.md", "slug": "intro", "title": "产品介绍", "updated": "2026-09-17" }, + { "file": "02-quickstart.md", "slug": "quickstart", "title": "快速开始", "updated": "2026-09-17" }, + { "file": "03-interface.md", "slug": "interface", "title": "界面导览", "updated": "2026-09-17" } + ] + }, + { + "name": "请求调试", + "pages": [ + { "file": "04-http.md", "slug": "http", "title": "发送 HTTP 请求", "updated": "2026-09-17" }, + { "file": "05-auth.md", "slug": "auth", "title": "接口鉴权", "updated": "2026-09-17" }, + { "file": "06-env.md", "slug": "env", "title": "环境与变量", "updated": "2026-09-17" }, + { "file": "07-graphql.md", "slug": "graphql", "title": "GraphQL", "updated": "2026-09-17" }, + { "file": "08-websocket.md", "slug": "websocket", "title": "WebSocket", "updated": "2026-09-17" }, + { "file": "09-sse.md", "slug": "sse", "title": "SSE 事件流", "updated": "2026-09-17" }, + { "file": "10-socketio.md", "slug": "socketio", "title": "Socket.IO", "updated": "2026-09-17" }, + { "file": "11-mqtt.md", "slug": "mqtt", "title": "MQTT", "updated": "2026-09-17" } + ] + }, + { + "name": "数据管理", + "pages": [ + { "file": "12-import.md", "slug": "import", "title": "数据导入", "updated": "2026-09-17" }, + { "file": "13-export.md", "slug": "export", "title": "导出与代码生成", "updated": "2026-09-17" }, + { "file": "14-manage.md", "slug": "manage", "title": "集合与历史", "updated": "2026-09-17" } + ] + }, + { + "name": "参考", + "pages": [ + { "file": "15-server-api.md", "slug": "server-api", "title": "服务端 API 参考", "updated": "2026-09-17" }, + { "file": "16-faq.md", "slug": "faq", "title": "常见问题", "updated": "2026-09-17" } + ] + } + ] +} diff --git a/apps/server/internal/docs/docs.go b/apps/server/internal/docs/docs.go new file mode 100644 index 0000000..e1611d8 --- /dev/null +++ b/apps/server/internal/docs/docs.go @@ -0,0 +1,264 @@ +// Package docs 在 /docs 路径提供产品用户文档(Markdown 嵌入二进制,SSR 渲染)。 +package docs + +import ( + "embed" + "encoding/json" + "fmt" + "net/http" + "path" + "regexp" + "strings" + + "github.com/gin-gonic/gin" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" + gmhtml "github.com/yuin/goldmark/renderer/html" +) + +//go:embed content assets template.html +var files embed.FS + +type PageDef struct { + File string `json:"file"` + Slug string `json:"slug"` + Title string `json:"title"` + Updated string `json:"updated,omitempty"` +} + +type GroupDef struct { + Name string `json:"name"` + Pages []PageDef `json:"pages"` +} + +type Manifest struct { + ProductVersion string `json:"productVersion"` + Groups []GroupDef `json:"groups"` +} + +// TocItem 本页目录项 +type TocItem struct { + ID string `json:"id"` + Level int `json:"level"` + Text string `json:"text"` +} + +type SearchEntry struct { + Slug string `json:"slug"` + Title string `json:"title"` + Group string `json:"group"` + Text string `json:"text"` +} + +type NavLink struct { + Slug string + Title string +} + +type Page struct { + Slug string + Title string + Group string + Updated string + HTML string + TOC []TocItem + Prev *NavLink + Next *NavLink +} + +// Handler 文档服务 +type Handler struct { + manifest Manifest + pages map[string]*Page + order []string + searchData []byte + tpl string +} + +func New() *Handler { + h := &Handler{pages: map[string]*Page{}} + raw, err := files.ReadFile("content/manifest.json") + if err != nil { + panic("docs: manifest.json 缺失: " + err.Error()) + } + if err := json.Unmarshal(raw, &h.manifest); err != nil { + panic("docs: manifest.json 解析失败: " + err.Error()) + } + tplRaw, err := files.ReadFile("template.html") + if err != nil { + panic("docs: template.html 缺失: " + err.Error()) + } + h.tpl = string(tplRaw) + + md := goldmark.New( + goldmark.WithExtensions(extension.Table, extension.Strikethrough, extension.TaskList), + goldmark.WithRendererOptions(gmhtml.WithHardWraps()), + ) + + search := []SearchEntry{} + var prev *NavLink + for _, group := range h.manifest.Groups { + for _, def := range group.Pages { + src, err := files.ReadFile("content/" + def.File) + if err != nil { + panic("docs: 缺少内容文件 " + def.File) + } + var buf strings.Builder + if err := md.Convert(src, &buf); err != nil { + panic("docs: 渲染失败 " + def.File + ": " + err.Error()) + } + body, toc := injectHeadingIDs(buf.String()) + page := &Page{ + Slug: def.Slug, + Title: def.Title, + Group: group.Name, + Updated: def.Updated, + HTML: body, + TOC: toc, + Prev: prev, + } + h.pages[def.Slug] = page + h.order = append(h.order, def.Slug) + prev = &NavLink{Slug: def.Slug, Title: def.Title} + search = append(search, SearchEntry{ + Slug: def.Slug, + Title: def.Title, + Group: group.Name, + Text: plainText(string(src)), + }) + } + } + // 下一页链接 + for i, slug := range h.order { + if i+1 < len(h.order) { + next := h.pages[h.order[i+1]] + h.pages[slug].Next = &NavLink{Slug: next.Slug, Title: next.Title} + } + } + searchJSON, _ := json.Marshal(search) + h.searchData = searchJSON + return h +} + +// Register 注册路由(单一 catch-all,避免静态/参数段冲突) +func (h *Handler) Register(r *gin.Engine) { + r.GET("/docs", func(c *gin.Context) { + c.Redirect(http.StatusFound, "/docs/"+h.order[0]) + }) + r.GET("/docs/*path", h.dispatch) +} + +func (h *Handler) dispatch(c *gin.Context) { + p := strings.Trim(c.Param("path"), "/") + switch { + case p == "": + c.Redirect(http.StatusFound, "/docs/"+h.order[0]) + case p == "search-data.json": + h.SearchData(c) + case strings.HasPrefix(p, "assets/"): + c.Params = []gin.Param{{Key: "file", Value: "/" + strings.TrimPrefix(p, "assets")}} + h.Asset(c) + default: + // 取第一段作为 slug(忽略子路径) + slug := p + if i := strings.IndexByte(p, '/'); i >= 0 { + slug = p[:i] + } + c.Params = []gin.Param{{Key: "slug", Value: slug}} + h.Page(c) + } +} + +// Page 渲染文档页 +func (h *Handler) Page(c *gin.Context) { + slug := c.Param("slug") + page, ok := h.pages[slug] + if !ok { + c.Status(http.StatusNotFound) + _, _ = c.Writer.WriteString(notFoundHTML(h.order[0])) //nolint:errcheck + return + } + c.Header("Content-Type", "text/html; charset=utf-8") + c.Header("Cache-Control", "no-cache") + _, _ = c.Writer.WriteString(renderPage(h, page)) +} + +// SearchData 搜索索引 +func (h *Handler) SearchData(c *gin.Context) { + c.Data(http.StatusOK, "application/json; charset=utf-8", h.searchData) +} + +// Asset 静态资源 +func (h *Handler) Asset(c *gin.Context) { + name := path.Clean(c.Param("file")) + if strings.Contains(name, "..") { + c.Status(http.StatusBadRequest) + return + } + data, err := files.ReadFile("assets" + name) + if err != nil { + c.Status(http.StatusNotFound) + return + } + switch { + case strings.HasSuffix(name, ".css"): + c.Header("Content-Type", "text/css; charset=utf-8") + case strings.HasSuffix(name, ".js"): + c.Header("Content-Type", "application/javascript; charset=utf-8") + case strings.HasSuffix(name, ".svg"): + c.Header("Content-Type", "image/svg+xml") + default: + c.Header("Content-Type", "application/octet-stream") + } + c.Header("Cache-Control", "public, max-age=3600") + c.Data(http.StatusOK, "", data) +} + +// ManifestExport 供测试使用 +func (h *Handler) ManifestExport() Manifest { return h.manifest } + +func (h *Handler) FirstSlug() string { return h.order[0] } + +var ( + hRe = regexp.MustCompile(`(?s)<(h[23])>(.*?)`) + tagRe = regexp.MustCompile(`<[^>]+>`) +) + +// injectHeadingIDs 给 h2/h3 加锚点 id,并产出目录 +func injectHeadingIDs(html string) (string, []TocItem) { + var toc []TocItem + counter := 0 + out := hRe.ReplaceAllStringFunc(html, func(m string) string { + sub := hRe.FindStringSubmatch(m) + tag, inner := sub[1], sub[2] + counter++ + id := fmt.Sprintf("sec-%d", counter) + text := strings.TrimSpace(tagRe.ReplaceAllString(inner, "")) + level := 2 + if tag == "h3" { + level = 3 + } + toc = append(toc, TocItem{ID: id, Level: level, Text: text}) + return fmt.Sprintf(`<%s id="%s">%s`, tag, id, inner, tag) + }) + return out, toc +} + +var mdNoise = regexp.MustCompile("(?s)```.*?```|`[^`]*`|!\\[[^\\]]*\\]\\([^)]*\\)|\\[[^\\]]*\\]\\([^)]*\\)|[#>*_\\-|]+") + +// plainText 供搜索索引的纯文本 +func plainText(src string) string { + s := mdNoise.ReplaceAllString(src, " ") + s = strings.Join(strings.Fields(s), " ") + if len(s) > 6000 { + s = s[:6000] + } + return s +} + +func notFoundHTML(home string) string { + return fmt.Sprintf(`404 · OpsxAPI 文档 + +

404

文档页面不存在或已被移动。

返回文档首页
+`, home) +} diff --git a/apps/server/internal/docs/docs_test.go b/apps/server/internal/docs/docs_test.go new file mode 100644 index 0000000..1bd41ac --- /dev/null +++ b/apps/server/internal/docs/docs_test.go @@ -0,0 +1,122 @@ +package docs + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +func newTestRouter(t *testing.T) *gin.Engine { + t.Helper() + gin.SetMode(gin.TestMode) + r := gin.New() + New().Register(r) + return r +} + +func TestDocsIndexRedirect(t *testing.T) { + r := newTestRouter(t) + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/docs", nil)) + if w.Code != http.StatusFound { + t.Fatalf("expect 302, got %d", w.Code) + } + if !strings.HasPrefix(w.Header().Get("Location"), "/docs/") { + t.Fatalf("bad redirect: %s", w.Header().Get("Location")) + } +} + +func TestDocsAllPagesRender(t *testing.T) { + h := New() + r := newTestRouter(t) + for _, slug := range h.order { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/docs/"+slug, nil) + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("page %s: expect 200, got %d", slug, w.Code) + } + body := w.Body.String() + if !strings.Contains(body, "") || !strings.Contains(body, `class="md"`) { + t.Fatalf("page %s: html structure incomplete", slug) + } + // 正文有渲染出的标题与段落 + if !strings.Contains(body, "<h1") { + t.Fatalf("page %s: missing h1", slug) + } + // 侧栏包含全部分组 + for _, g := range h.manifest.Groups { + if !strings.Contains(body, g.Name) { + t.Fatalf("page %s: sidebar missing group %s", slug, g.Name) + } + } + } +} + +func TestDocsPageContainsTOCAndNav(t *testing.T) { + r := newTestRouter(t) + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/docs/http", nil)) + body := w.Body.String() + if !strings.Contains(body, "sec-1") { + t.Fatal("expect heading anchors") + } + if !strings.Contains(body, "上一页") { + t.Fatal("expect prev link") + } +} + +func TestDocsSearchData(t *testing.T) { + r := newTestRouter(t) + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/docs/search-data.json", nil)) + if w.Code != http.StatusOK { + t.Fatalf("expect 200, got %d", w.Code) + } + var entries []SearchEntry + if err := json.Unmarshal(w.Body.Bytes(), &entries); err != nil { + t.Fatalf("invalid json: %v", err) + } + if len(entries) < 10 { + t.Fatalf("expect >= 10 search entries, got %d", len(entries)) + } + // MQTT 页应可被检索(标题或正文命中) + found := false + for _, e := range entries { + if e.Slug == "mqtt" && (e.Title == "MQTT" || strings.Contains(e.Text, "MQTT")) { + found = true + } + } + if !found { + t.Fatal("mqtt entry missing searchable text") + } +} + +func TestDocsAssetsAnd404(t *testing.T) { + r := newTestRouter(t) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/docs/assets/docs.css", nil)) + if w.Code != http.StatusOK || !strings.Contains(w.Header().Get("Content-Type"), "text/css") { + t.Fatalf("css asset: %d %s", w.Code, w.Header().Get("Content-Type")) + } + + w = httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/docs/assets/docs.js", nil)) + if w.Code != http.StatusOK { + t.Fatalf("js asset: %d", w.Code) + } + + w = httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/docs/not-exist-page", nil)) + if w.Code != http.StatusNotFound { + t.Fatalf("404 page: %d", w.Code) + } + if !strings.Contains(w.Body.String(), "返回文档首页") { + t.Fatal("404 page missing home link") + } +} diff --git a/apps/server/internal/docs/render.go b/apps/server/internal/docs/render.go new file mode 100644 index 0000000..9f464ab --- /dev/null +++ b/apps/server/internal/docs/render.go @@ -0,0 +1,25 @@ +package docs + +import ( + "html/template" + "strings" +) + +func renderPage(h *Handler, p *Page) string { + tpl, err := template.New("doc").Parse(h.tpl) + if err != nil { + return "<!doctype html><meta charset=utf-8><title>errortemplate error: " + template.HTMLEscapeString(err.Error()) + } + + var sb strings.Builder + data := map[string]any{ + "Manifest": h.manifest, + "Page": p, + "TOC": p.TOC, + "Content": template.HTML(p.HTML), //nolint:gosec —— 内容为内置受信 Markdown + } + if err := tpl.Execute(&sb, data); err != nil { + return "errorrender error: " + template.HTMLEscapeString(err.Error()) + } + return sb.String() +} diff --git a/apps/server/internal/docs/template.html b/apps/server/internal/docs/template.html new file mode 100644 index 0000000..b17595d --- /dev/null +++ b/apps/server/internal/docs/template.html @@ -0,0 +1,110 @@ + + + + + +{{.Page.Title}} · OpsxAPI 文档 + + + + + + + +
+
+ + + + OpsxAPI 文档 + + v{{.Manifest.ProductVersion}} + + +
+
+ +
+ + +
+ +

{{.Page.Title}}

+ {{if .Page.Updated}}
更新于 {{.Page.Updated}}
{{end}} +
+{{.Content}} +
+ +
+ {{with .Page.Prev}} + + {{else}}{{end}} + {{with .Page.Next}} + + {{else}}{{end}} +
+ + +
+ + +
+ + + + diff --git a/apps/server/internal/proxy/guard.go b/apps/server/internal/proxy/guard.go new file mode 100644 index 0000000..fc2184e --- /dev/null +++ b/apps/server/internal/proxy/guard.go @@ -0,0 +1,36 @@ +package proxy + +import ( + "fmt" + "net" + "net/url" + "strings" +) + +var blockedHosts = map[string]struct{}{ + "169.254.169.254": {}, + "metadata.google.internal": {}, +} + +// ValidateURL applies MVP SSRF basics. Week 2 should add configurable CIDR +// deny-lists and DNS-rebinding protection before exposing this beyond localhost. +func ValidateURL(raw string) (*url.URL, error) { + u, err := url.Parse(raw) + if err != nil { + return nil, fmt.Errorf("invalid url: %w", err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return nil, fmt.Errorf("only http/https schemes are allowed") + } + if u.Hostname() == "" { + return nil, fmt.Errorf("url host is required") + } + host := strings.ToLower(strings.TrimSuffix(u.Hostname(), ".")) + if _, blocked := blockedHosts[host]; blocked { + return nil, fmt.Errorf("blocked metadata host") + } + if ip := net.ParseIP(host); ip != nil && ip.Equal(net.ParseIP("169.254.169.254")) { + return nil, fmt.Errorf("blocked metadata address") + } + return u, nil +} diff --git a/apps/server/internal/proxy/guard_test.go b/apps/server/internal/proxy/guard_test.go new file mode 100644 index 0000000..2750500 --- /dev/null +++ b/apps/server/internal/proxy/guard_test.go @@ -0,0 +1,23 @@ +package proxy + +import "testing" + +func TestValidateURL(t *testing.T) { + for _, tc := range []struct { + raw string + ok bool + }{ + {"https://example.com/a", true}, + {"file:///etc/passwd", false}, + {"http://169.254.169.254/latest/meta-data", false}, + {"http://metadata.google.internal/computeMetadata/v1", false}, + } { + _, err := ValidateURL(tc.raw) + if tc.ok && err != nil { + t.Fatalf("%s: %v", tc.raw, err) + } + if !tc.ok && err == nil { + t.Fatalf("expected rejection: %s", tc.raw) + } + } +} diff --git a/apps/server/internal/proxy/handler.go b/apps/server/internal/proxy/handler.go new file mode 100644 index 0000000..c364244 --- /dev/null +++ b/apps/server/internal/proxy/handler.go @@ -0,0 +1,116 @@ +package proxy + +import ( + "bytes" + "io" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +const maxResponseBytes = 10 << 20 // 10 MiB MVP safety cap + +type Handler struct { + BaseClient *http.Client +} + +func NewHandler() *Handler { + return &Handler{BaseClient: &http.Client{Timeout: 30 * time.Second}} +} + +func normalizeMethod(raw string) string { + m := strings.ToUpper(strings.TrimSpace(raw)) + switch m { + case http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, + http.MethodDelete, http.MethodHead, http.MethodOptions: + return m + default: + return http.MethodGet + } +} + +func (h *Handler) ProxyHTTP(c *gin.Context) { + var spec HttpSpec + if err := c.ShouldBindJSON(&spec); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid HttpSpec", "detail": err.Error()}) + return + } + + method := normalizeMethod(spec.Method) + + u, err := ValidateURL(spec.URL) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + query := u.Query() + for key, value := range spec.Query { + query.Set(key, value) + } + u.RawQuery = query.Encode() + + var body io.Reader + if spec.Body != nil && spec.Body.Mode != "none" && method != http.MethodGet && method != http.MethodHead { + body = bytes.NewBufferString(spec.Body.Raw) + } + + req, err := http.NewRequestWithContext(c.Request.Context(), method, u.String(), body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "cannot create request", "detail": err.Error()}) + return + } + for key, value := range spec.Headers { + // Hop-by-hop headers are managed by net/http. + if !isHopByHop(key) { + req.Header.Set(key, value) + } + } + if spec.Body != nil && spec.Body.Mode == "json" && req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/json") + } + + client := h.BaseClient + if spec.TimeoutMS > 0 { + client = &http.Client{Timeout: time.Duration(spec.TimeoutMS) * time.Millisecond} + } + started := time.Now() + res, err := client.Do(req) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": "upstream request failed", "detail": err.Error()}) + return + } + defer res.Body.Close() + + payload, err := io.ReadAll(io.LimitReader(res.Body, maxResponseBytes+1)) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": "cannot read upstream response", "detail": err.Error()}) + return + } + if len(payload) > maxResponseBytes { + c.JSON(http.StatusBadGateway, gin.H{"error": "upstream response exceeds 10 MiB limit"}) + return + } + + headers := make(map[string]string, len(res.Header)) + for key, values := range res.Header { + headers[strings.ToLower(key)] = strings.Join(values, ", ") + } + c.JSON(http.StatusOK, HttpResult{ + Status: res.StatusCode, + Headers: headers, + Body: string(payload), + TimeMS: time.Since(started).Milliseconds(), + Size: len(payload), + }) +} + +func isHopByHop(name string) bool { + switch strings.ToLower(name) { + case "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade", "host", "content-length": + return true + default: + return false + } +} diff --git a/apps/server/internal/proxy/stream.go b/apps/server/internal/proxy/stream.go new file mode 100644 index 0000000..052d922 --- /dev/null +++ b/apps/server/internal/proxy/stream.go @@ -0,0 +1,145 @@ +package proxy + +import ( + "bufio" + "bytes" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "time" + + "github.com/gin-gonic/gin" +) + +// ProxyHTTPStream 以 NDJSON 流式转发响应(SSE / chunked / 大响应体)。 +// 事件序列:meta → chunk* → end | error +type streamEvent struct { + Type string `json:"type"` + Status int `json:"status,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + Data string `json:"data,omitempty"` + Message string `json:"message,omitempty"` + TimeMS int64 `json:"timeMs,omitempty"` + Size int `json:"size,omitempty"` +} + +func (h *Handler) ProxyHTTPStream(c *gin.Context) { + var spec HttpSpec + if err := c.ShouldBindJSON(&spec); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid HttpSpec", "detail": err.Error()}) + return + } + + method := normalizeMethod(spec.Method) + u, err := ValidateURL(spec.URL) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + query := u.Query() + for key, value := range spec.Query { + query.Set(key, value) + } + u.RawQuery = query.Encode() + + var body io.Reader + if spec.Body != nil && spec.Body.Mode != "none" && method != http.MethodGet && method != http.MethodHead { + body = bytes.NewBufferString(spec.Body.Raw) + } + + req, err := http.NewRequestWithContext(c.Request.Context(), method, u.String(), body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "cannot create request", "detail": err.Error()}) + return + } + for key, value := range spec.Headers { + if !isHopByHop(key) { + req.Header.Set(key, value) + } + } + if spec.Body != nil && spec.Body.Mode == "json" && req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/json") + } + + client := h.BaseClient + if spec.TimeoutMS > 0 { + client = &http.Client{Timeout: time.Duration(spec.TimeoutMS) * time.Millisecond} + } else { + // 流式场景默认不设整体超时(SSE 长连接),交给客户端断开 + client = &http.Client{} + } + + started := time.Now() + res, err := client.Do(req) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": "upstream request failed", "detail": err.Error()}) + return + } + defer res.Body.Close() + + headers := make(map[string]string, len(res.Header)) + for key, values := range res.Header { + headers[key] = joinNonEmpty(values) + } + + flusher, ok := c.Writer.(http.Flusher) + if !ok { + c.JSON(http.StatusInternalServerError, gin.H{"error": "streaming unsupported"}) + return + } + c.Header("Content-Type", "application/x-ndjson") + c.Header("Cache-Control", "no-cache") + c.Header("X-Accel-Buffering", "no") + c.Status(http.StatusOK) + + enc := json.NewEncoder(c.Writer) + writeEvent := func(ev streamEvent) bool { + if err := enc.Encode(ev); err != nil { + return false + } + flusher.Flush() + return true + } + + headers["content-type"] = res.Header.Get("Content-Type") + if !writeEvent(streamEvent{Type: "meta", Status: res.StatusCode, Headers: headers}) { + return + } + + reader := bufio.NewReaderSize(res.Body, 32*1024) + buf := make([]byte, 16*1024) + total := 0 + for { + n, readErr := reader.Read(buf) + if n > 0 { + total += n + ok := writeEvent(streamEvent{ + Type: "chunk", + Data: base64.StdEncoding.EncodeToString(buf[:n]), + Size: n, + }) + if !ok { + return + } + } + if readErr != nil { + break + } + if c.Request.Context().Err() != nil { + return + } + } + writeEvent(streamEvent{Type: "end", TimeMS: time.Since(started).Milliseconds(), Size: total}) +} + +func joinNonEmpty(values []string) string { + out := "" + for i, v := range values { + if i > 0 { + out += ", " + } + out += v + } + return out +} diff --git a/apps/server/internal/proxy/types.go b/apps/server/internal/proxy/types.go new file mode 100644 index 0000000..e945925 --- /dev/null +++ b/apps/server/internal/proxy/types.go @@ -0,0 +1,23 @@ +package proxy + +type BodySpec struct { + Mode string `json:"mode"` + Raw string `json:"raw,omitempty"` +} + +type HttpSpec struct { + Method string `json:"method" binding:"required"` + URL string `json:"url" binding:"required"` + Headers map[string]string `json:"headers"` + Query map[string]string `json:"query,omitempty"` + Body *BodySpec `json:"body,omitempty"` + TimeoutMS int `json:"timeoutMs,omitempty"` +} + +type HttpResult struct { + Status int `json:"status"` + Headers map[string]string `json:"headers"` + Body string `json:"body"` + TimeMS int64 `json:"timeMs"` + Size int `json:"size"` +} diff --git a/apps/server/internal/proxy/ws_tunnel.go b/apps/server/internal/proxy/ws_tunnel.go new file mode 100644 index 0000000..877a9b1 --- /dev/null +++ b/apps/server/internal/proxy/ws_tunnel.go @@ -0,0 +1,236 @@ +package proxy + +import ( + "crypto/rand" + "encoding/hex" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +// WSTunnel 让浏览器借道 Gin 连接带自定义 Header 的 WebSocket: +// +// POST /v1/proxy/ws/connect {url, headers, protocols} → {sessionId} +// GET /v1/proxy/ws/attach?sessionId=… (浏览器 WebSocket 双向桥) +// POST /v1/proxy/ws/close {sessionId} +type wsTunnelSpec struct { + URL string `json:"url" binding:"required"` + Headers map[string]string `json:"headers,omitempty"` + Protocols []string `json:"protocols,omitempty"` +} + +type wsTunnelSession struct { + id string + target *websocket.Conn + attach chan *websocket.Conn + createdAt time.Time +} + +type WSTunnel struct { + mu sync.Mutex + sessions map[string]*wsTunnelSession + upgrader websocket.Upgrader +} + +func NewWSTunnel() *WSTunnel { + t := &WSTunnel{ + sessions: map[string]*wsTunnelSession{}, + upgrader: websocket.Upgrader{ + ReadBufferSize: 32 * 1024, + WriteBufferSize: 32 * 1024, + CheckOrigin: func(r *http.Request) bool { + // 仅本地 UI 使用;Origin 校验交给 CORS 中间件 + return true + }, + }, + } + go t.gc() + return t +} + +func (t *WSTunnel) gc() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for range ticker.C { + t.mu.Lock() + for id, s := range t.sessions { + // 未 attach 或空闲超过 10 分钟的会话回收 + if time.Since(s.createdAt) > 10*time.Minute { + _ = s.target.Close() + delete(t.sessions, id) + } + } + t.mu.Unlock() + } +} + +// ValidateWSURL 校验 ws/wss 目标(http/https 自动换算) +func ValidateWSURL(raw string) (*url.URL, error) { + u, err := url.Parse(raw) + if err != nil { + return nil, err + } + switch u.Scheme { + case "ws", "http": + u.Scheme = "ws" + case "wss", "https": + u.Scheme = "wss" + default: + return nil, errOnlyWS + } + if u.Hostname() == "" { + return nil, errEmptyHost + } + if _, blocked := blockedHosts[strings.ToLower(u.Hostname())]; blocked { + return nil, errBlockedHost + } + return u, nil +} + +var ( + errOnlyWS = &wsTunnelError{"only ws/wss schemes are allowed"} + errEmptyHost = &wsTunnelError{"url host is required"} + errBlockedHost = &wsTunnelError{"blocked metadata host"} +) + +type wsTunnelError struct{ msg string } + +func (e *wsTunnelError) Error() string { return e.msg } + +func (t *WSTunnel) Connect(c *gin.Context) { + var spec wsTunnelSpec + if err := c.ShouldBindJSON(&spec); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid spec", "detail": err.Error()}) + return + } + u, err := ValidateWSURL(spec.URL) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + dialer := websocket.Dialer{HandshakeTimeout: 15 * time.Second} + dialHeader := http.Header{} + for key, value := range spec.Headers { + if !isHopByHop(key) { + dialHeader.Set(key, value) + } + } + target, _, err := dialer.Dial(u.String(), dialHeader) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": "dial failed", "detail": err.Error()}) + return + } + + id := randomSessionID() + session := &wsTunnelSession{id: id, target: target, attach: make(chan *websocket.Conn, 1), createdAt: time.Now()} + t.mu.Lock() + t.sessions[id] = session + t.mu.Unlock() + + c.JSON(http.StatusOK, gin.H{"sessionId": id}) + + // 30 秒内没有浏览器 attach 就回收 + go func() { + select { + case client := <-session.attach: + t.bridge(client, session) + case <-time.After(30 * time.Second): + t.mu.Lock() + if s, ok := t.sessions[id]; ok { + _ = s.target.Close() + delete(t.sessions, id) + } + t.mu.Unlock() + } + }() +} + +func (t *WSTunnel) Attach(c *gin.Context) { + id := c.Query("sessionId") + t.mu.Lock() + session, ok := t.sessions[id] + t.mu.Unlock() + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "session not found"}) + return + } + client, err := t.upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + return + } + select { + case session.attach <- client: + // bridge goroutine 已在 Connect 中启动 + default: + _ = client.Close() + } +} + +func (t *WSTunnel) Close(c *gin.Context) { + var body struct { + SessionID string `json:"sessionId"` + } + _ = c.ShouldBindJSON(&body) + t.mu.Lock() + session, ok := t.sessions[body.SessionID] + if ok { + delete(t.sessions, body.SessionID) + } + t.mu.Unlock() + if ok { + _ = session.target.Close() + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// bridge 双向转发:浏览器 → 目标;目标 → 浏览器 +func (t *WSTunnel) bridge(client *websocket.Conn, session *wsTunnelSession) { + defer func() { + _ = client.Close() + _ = session.target.Close() + t.mu.Lock() + delete(t.sessions, session.id) + t.mu.Unlock() + }() + + done := make(chan struct{}) + // 浏览器 → 目标 + go func() { + defer close(done) + for { + msgType, data, err := client.ReadMessage() + if err != nil { + return + } + if err := session.target.WriteMessage(msgType, data); err != nil { + return + } + } + }() + // 目标 → 浏览器(bridge 协议:控制帧 + 数据帧) + _ = client.WriteMessage(websocket.TextMessage, []byte(`{"type":"open"}`)) + for { + msgType, data, err := session.target.ReadMessage() + if err != nil { + _ = client.WriteMessage(websocket.TextMessage, []byte(`{"type":"closed","reason":"target closed"}`)) + return + } + if err := client.WriteMessage(msgType, data); err != nil { + return + } + } +} + +func randomSessionID() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return time.Now().Format("20060102150405.000000000") + } + return hex.EncodeToString(b) +} diff --git a/apps/server/internal/server/router.go b/apps/server/internal/server/router.go new file mode 100644 index 0000000..d81c821 --- /dev/null +++ b/apps/server/internal/server/router.go @@ -0,0 +1,59 @@ +package server + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "opsxapi/server/internal/docs" + "opsxapi/server/internal/proxy" + "opsxapi/server/internal/site" +) + +func NewRouter() *gin.Engine { + r := gin.New() + _ = r.SetTrustedProxies(nil) + r.Use(gin.Logger(), gin.Recovery(), localhostCORS()) + + r.GET("/health", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true, "service": "opsxapi"}) + }) + p := proxy.NewHandler() + r.POST("/v1/proxy/http", p.ProxyHTTP) + r.POST("/v1/proxy/http/stream", p.ProxyHTTPStream) + + tunnel := proxy.NewWSTunnel() + r.POST("/v1/proxy/ws/connect", tunnel.Connect) + r.GET("/v1/proxy/ws/attach", tunnel.Attach) + r.POST("/v1/proxy/ws/close", tunnel.Close) + + docs.New().Register(r) + site.New().Register(r) + + return r +} + +func localhostCORS() gin.HandlerFunc { + allowed := map[string]struct{}{ + "http://localhost:5173": {}, + "http://127.0.0.1:5173": {}, + "http://localhost:1420": {}, + "http://127.0.0.1:1420": {}, + "tauri://localhost": {}, + "http://tauri.localhost": {}, + } + return func(c *gin.Context) { + origin := c.GetHeader("Origin") + if _, ok := allowed[strings.TrimSuffix(origin, "/")]; ok { + c.Header("Access-Control-Allow-Origin", origin) + c.Header("Vary", "Origin") + c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization") + c.Header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + } + if c.Request.Method == http.MethodOptions { + c.AbortWithStatus(http.StatusNoContent) + return + } + c.Next() + } +} diff --git a/apps/server/internal/site/assets/site.css b/apps/server/internal/site/assets/site.css new file mode 100644 index 0000000..6035df1 --- /dev/null +++ b/apps/server/internal/site/assets/site.css @@ -0,0 +1,282 @@ +/* OpsxAPI 官网 —— 营销单页样式 */ +:root { + --blue: #2563eb; + --blue-dark: #1d4ed8; + --blue-soft: rgba(37, 99, 235, 0.08); + --bg: #ffffff; + --bg-alt: #f6f8fc; + --border: #e4e9f1; + --text: #111827; + --text-2: #5b6880; + --text-3: #98a2b3; + --green: #059669; + --amber: #d97706; + --radius: 14px; + --shadow: 0 1px 3px rgba(16,24,40,.06), 0 12px 32px rgba(16,24,40,.07); + --mono: "JetBrains Mono", SFMono-Regular, Menlo, monospace; +} +* { box-sizing: border-box; scroll-behavior: smooth; } +body { + margin: 0; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif; + color: var(--text); background: var(--bg); font-size: 15px; line-height: 1.75; +} +a { color: var(--blue); text-decoration: none; } +h1,h2,h3 { letter-spacing: -0.015em; } +section[id] { scroll-margin-top: 76px; } +.wrap { max-width: 1120px; margin: 0 auto; padding: 0 24px; } +.wrap.narrow { max-width: 820px; } +/* ---------- SVG 图标(lucide sprite) ---------- */ +.i { width: 15px; height: 15px; stroke: currentColor; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; vertical-align: -2px; } +.i-lg { width: 28px; height: 28px; vertical-align: middle; } +code { font-family: var(--mono); font-size: 13px; background: var(--blue-soft); color: var(--blue-dark); border-radius: 5px; padding: 1px 6px; } + +/* ---------- 按钮 ---------- */ +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: 6px; + border-radius: 10px; padding: 9px 20px; font-size: 14.5px; font-weight: 600; cursor: pointer; + transition: all .18s; border: 1px solid transparent; white-space: nowrap; +} +.btn-lg { padding: 12px 26px; font-size: 15.5px; } +.btn-primary { background: var(--blue); color: #fff !important; box-shadow: 0 6px 18px rgba(37,99,235,.28); } +.btn-primary:hover { background: var(--blue-dark); transform: translateY(-1px); } +.btn-ghost { border-color: var(--border); color: var(--text) !important; background: #fff; } +.btn-ghost:hover { border-color: var(--blue); color: var(--blue) !important; } +.btn-inverse { background: #fff; color: var(--blue) !important; } +.btn-inverse:hover { transform: translateY(-1px); } +.btn-outline { border-color: rgba(255,255,255,.5); color: #fff !important; } +.btn-outline:hover { border-color: #fff; background: rgba(255,255,255,.1); } + +/* ---------- 导航 ---------- */ +.nav { + position: sticky; top: 0; z-index: 50; background: rgba(255,255,255,.9); + backdrop-filter: blur(10px); border-bottom: 1px solid var(--border); +} +.nav-inner { max-width: 1120px; margin: 0 auto; padding: 0 24px; height: 60px; display: flex; align-items: center; gap: 28px; } +.brand { display: flex; align-items: center; gap: 9px; } +.brand-logo { display: inline-flex; } +.brand-logo svg { display: block; border-radius: 8px; } +.brand-name { font-size: 17px; font-weight: 800; color: var(--text) !important; } +.nav-links { display: flex; gap: 22px; } +.nav-links a { font-size: 14px; font-weight: 500; color: var(--text-2) !important; } +.nav-links a:hover { color: var(--blue) !important; } +.nav-cta { margin-left: auto; } + +/* ---------- Hero ---------- */ +.hero { + position: relative; overflow: hidden; + background: + radial-gradient(1200px 500px at 85% -10%, rgba(37,99,235,.14), transparent 60%), + radial-gradient(800px 400px at -10% 10%, rgba(5,150,105,.08), transparent 55%), + linear-gradient(180deg, #f8faff 0%, #fff 100%); + border-bottom: 1px solid var(--border); +} +.hero-inner { + max-width: 1120px; margin: 0 auto; padding: 72px 24px 64px; + display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); gap: 48px; align-items: center; +} +.hero-badge { + display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; font-weight: 600; color: var(--blue-dark); + background: #fff; border: 1px solid rgba(37,99,235,.25); border-radius: 999px; padding: 4px 14px; + box-shadow: 0 2px 8px rgba(37,99,235,.1); margin-bottom: 18px; +} +.hero h1 { font-size: 44px; line-height: 1.22; font-weight: 800; margin: 0 0 16px; } +.hero h1 em { font-style: normal; color: var(--blue); } +.hero-sub { color: var(--text-2); font-size: 16px; margin: 0 0 26px; } +.hero-actions { display: flex; gap: 14px; flex-wrap: wrap; } +.hero-actions.center { justify-content: center; } +.hero-pills { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 22px; } +.hero-pills span { + font-size: 12px; color: var(--text-2); background: #fff; border: 1px solid var(--border); + padding: 3px 12px; border-radius: 999px; +} + +/* ---------- Mock 界面 ---------- */ +.mock { + border-radius: 14px; overflow: hidden; box-shadow: var(--shadow); border: 1px solid #1e2b45; + background: #0d1526; transform: perspective(1400px) rotateY(-4deg) rotateX(1.5deg); +} +.mock-titlebar { + display: flex; align-items: center; gap: 7px; height: 34px; padding: 0 12px; + background: #101a30; border-bottom: 1px solid #1e2b45; +} +.mock-titlebar i { width: 11px; height: 11px; border-radius: 50%; background: #3a4a6b; } +.mock-titlebar i:nth-child(1) { background: #f87171; } .mock-titlebar i:nth-child(2) { background: #fbbf24; } .mock-titlebar i:nth-child(3) { background: #34d399; } +.mock-titlebar span { margin-left: 8px; color: #8b99b0; font-size: 11px; } +.mock-body { display: grid; grid-template-columns: 158px minmax(0,1fr); min-height: 320px; } +.mock-side { border-right: 1px solid #1e2b45; padding: 10px 8px; background: #0c1322; } +.mock-search { height: 22px; border-radius: 6px; background: #16213a; margin-bottom: 10px; } +.mock-group { color: #8b99b0; font-size: 11px; padding: 6px 6px 3px; } +.mock-item { display: flex; align-items: center; gap: 6px; color: #aab8d0; font-size: 11.5px; padding: 4px 6px; border-radius: 6px; } +.mock-item.active { background: rgba(37,99,235,.18); color: #93c5fd; } +.mi { width: 11px; height: 11px; stroke: currentColor; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex-shrink: 0; opacity: .8; } +.m-get { color: #34d399; font-style: normal; font-size: 9.5px; min-width: 30px; } +.m-post { color: #fbbf24; font-style: normal; font-size: 9.5px; min-width: 30px; } +.mock-main { padding: 10px 12px; display: flex; flex-direction: column; gap: 9px; } +.mock-urlbar { display: flex; align-items: center; gap: 8px; } +.mock-url { + flex: 1; background: #0a1120; border: 1px solid #22304f; border-radius: 7px; color: #cbd5e1; + font-family: var(--mono); font-size: 11px; padding: 6px 10px; +} +.mock-send { + background: var(--blue); color: #fff; font-size: 11px; font-weight: 600; + border-radius: 7px; padding: 6px 14px; +} +.mock-req, .mock-resp { border: 1px solid #1e2b45; border-radius: 9px; padding: 8px 10px; background: #0c1322; } +.mock-req .tab, .mock-resp .tab { color: #5c6a83; font-size: 10.5px; margin-right: 12px; } +.mock-req .tab.on { color: #93c5fd; border-bottom: 2px solid var(--blue); padding-bottom: 2px; } +.mock-resp .tab.on { color: #6ee7b7; border-bottom: 2px solid var(--green); padding-bottom: 2px; } +.mock-status { float: right; color: #34d399; font-size: 10px; font-family: var(--mono); } +.mock pre { + margin: 8px 0 2px; font-family: var(--mono); font-size: 11px; line-height: 1.6; color: #cbd5e1; + white-space: pre-wrap; +} +.mock pre i { font-style: normal; color: #7dd3fc; } +.mock pre u { text-decoration: none; color: #86efac; } + +/* ---------- 通用 Section ---------- */ +.section { padding: 72px 0; } +.section.alt { background: var(--bg-alt); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); } +.sec-title { font-size: 30px; font-weight: 800; text-align: center; margin: 0 0 8px; } +.sec-sub { text-align: center; color: var(--text-2); margin: 0 0 40px; } +.grid { display: grid; gap: 18px; } +.g3 { grid-template-columns: repeat(3, minmax(0,1fr)); } +.g4 { grid-template-columns: repeat(4, minmax(0,1fr)); } + +/* ---------- 卡片 ---------- */ +.card { + background: #fff; border: 1px solid var(--border); border-radius: var(--radius); + padding: 22px; box-shadow: 0 1px 2px rgba(16,24,40,.04); transition: all .18s; +} +.card:hover { transform: translateY(-3px); box-shadow: var(--shadow); border-color: rgba(37,99,235,.35); } +.card h3 { font-size: 16px; margin: 0 0 6px; } +.card p { color: var(--text-2); font-size: 13.5px; margin: 0; } +.card-icon { color: var(--blue); margin-bottom: 10px; } +.card-icon .i-lg { width: 30px; height: 30px; } +.card.row { display: flex; gap: 14px; align-items: flex-start; } +.card.row .card-icon { margin: 2px 0 0; } +.card.mini h3 { font-size: 15px; display: flex; align-items: center; gap: 8px; } +.card.mini h3 .i-lg { width: 20px; height: 20px; color: var(--blue); } +.card.mini p { font-size: 13px; } + +/* ---------- 协议 ---------- */ +.proto { + background: #fff; border: 1px solid var(--border); border-left: 3px solid var(--blue); + border-radius: 12px; padding: 18px 20px; +} +.proto h3 { margin: 0 0 6px; font-size: 15.5px; display: flex; align-items: center; gap: 8px; } +.proto h3 .i { color: var(--blue); width: 17px; height: 17px; } +.proto p { margin: 0; color: var(--text-2); font-size: 13.5px; } + +/* ---------- 导入导出 ---------- */ +.badges { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; margin-bottom: 18px; } +.badges span { + background: #fff; border: 1px solid var(--border); border-radius: 999px; + padding: 6px 16px; font-size: 13px; font-weight: 500; color: var(--text); +} +.badges span.more { border-style: dashed; color: var(--text-2); } +.footnote { text-align: center; color: var(--text-3); font-size: 12.5px; margin: 10px 0 0; } +.exchange-actions { display: flex; align-items: center; justify-content: center; gap: 12px; flex-wrap: wrap; margin-top: 26px; } +.chip { + display: inline-flex; align-items: center; gap: 6px; + background: #fff; border: 1px solid var(--border); border-radius: 10px; padding: 8px 16px; + font-size: 13px; font-weight: 600; color: var(--text-2); +} +.chip.big { background: var(--blue); border-color: var(--blue); color: #fff; padding: 10px 22px; } +.exchange-arrow { color: var(--text-3); font-size: 15px; } + +/* ---------- 对比表 ---------- */ +.table-wrap { overflow-x: auto; } +.cmp { border-collapse: collapse; width: 100%; background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 0 0 1px var(--border); font-size: 14px; } +.cmp th, .cmp td { padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border); } +.cmp th { background: #f9fbfe; font-size: 13.5px; } +.cmp tr:last-child td { border-bottom: none; } +.cmp td.hl { background: rgba(37,99,235,.05); color: var(--blue-dark); } +.cmp th.hl { background: var(--blue); color: #fff; } + +/* ---------- 定价 ---------- */ +.pricing { display: grid; grid-template-columns: repeat(2, minmax(0, 380px)); gap: 22px; justify-content: center; } +.pricing.three { grid-template-columns: repeat(3, minmax(0, 1fr)); max-width: 1080px; margin: 0 auto; } +.price-card { + position: relative; background: #fff; border: 1px solid var(--border); border-radius: 16px; + padding: 30px 28px 26px; box-shadow: 0 1px 2px rgba(16,24,40,.04); display: flex; flex-direction: column; +} +.price-card.featured { border: 2px solid var(--blue); box-shadow: 0 12px 36px rgba(37,99,235,.16); } +.price-card.enterprise { + background: linear-gradient(160deg, #101a30 0%, #0d1526 70%); + border: 1px solid #2a3b5e; box-shadow: 0 14px 40px rgba(13,21,38,.4); +} +.price-card.enterprise .plan { color: #8b99b0; } +.price-card.enterprise .price b { color: #fff; font-size: 36px; } +.price-card.enterprise .price span { color: #8b99b0; } +.price-card.enterprise .plan-sub { color: #8b99b0; } +.price-card.enterprise li { color: #aab8d0; border-bottom-color: #22304f; } +.price-card.enterprise .deal-note { + margin-top: 14px; font-size: 11.5px; line-height: 1.7; color: #8b99b0; + background: rgba(255,255,255,.05); border: 1px dashed #2a3b5e; border-radius: 9px; padding: 9px 12px; +} +.price-card.enterprise .deal-note b { color: #93c5fd; } +.btn-dark { background: #fff; color: #0d1526 !important; } +.btn-dark:hover { transform: translateY(-1px); background: #e8eefc; } +.plan-tag { + position: absolute; top: -13px; left: 50%; transform: translateX(-50%); + background: var(--blue); color: #fff; font-size: 12px; font-weight: 700; + padding: 3px 14px; border-radius: 999px; white-space: nowrap; +} +.plan-tag.dark { background: #0d1526; border: 1px solid #2a3b5e; } +.plan { font-size: 16px; font-weight: 700; color: var(--text-2); } +.price { margin: 8px 0 2px; } +.price b { font-size: 44px; font-weight: 800; letter-spacing: -.02em; color: var(--text); } +.price span { color: var(--text-3); font-size: 14px; margin-left: 4px; } +.plan-sub { color: var(--text-3); font-size: 13px; margin-bottom: 18px; } +.price-card ul { list-style: none; padding: 0; margin: 0 0 22px; flex: 1; } +.price-card li { font-size: 13.5px; color: var(--text-2); padding: 6px 0; border-bottom: 1px dashed var(--border); } +.price-card li:last-child { border-bottom: none; } +.price-card .btn { width: 100%; } +.growth-title { text-align: center; font-size: 20px; font-weight: 700; margin: 52px 0 22px; } + +/* ---------- FAQ ---------- */ +.faq details { + background: #fff; border: 1px solid var(--border); border-radius: 12px; + padding: 16px 20px; margin-bottom: 12px; +} +.faq summary { font-weight: 600; cursor: pointer; font-size: 15px; list-style: none; display: flex; justify-content: space-between; } +.faq summary::after { content: "+"; color: var(--text-3); font-weight: 400; } +.faq details[open] summary::after { content: "-"; } +.faq details p { margin: 10px 0 0; color: var(--text-2); font-size: 14px; } + +/* ---------- CTA ---------- */ +.cta { + background: linear-gradient(135deg, #1d4ed8 0%, #2563eb 55%, #3b82f6 100%); + color: #fff; text-align: center; padding: 72px 24px; +} +.cta h2 { font-size: 32px; font-weight: 800; margin: 0 0 10px; } +.cta p { color: rgba(255,255,255,.85); margin: 0 0 28px; } + +/* ---------- Footer ---------- */ +.footer { background: #0d1526; color: #8b99b0; padding: 52px 0 0; font-size: 13.5px; } +.footer-grid { display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; gap: 32px; padding-bottom: 36px; } +.footer .brand-name { color: #fff !important; } +.footer .f-logo { display: block; border-radius: 8px; } +.footer .brand { display: flex; align-items: center; gap: 9px; } +.footer-desc { margin: 12px 0 0; font-size: 13px; line-height: 1.8; } +.footer h4 { color: #fff; font-size: 13.5px; margin: 4px 0 12px; } +.footer a { display: block; color: #8b99b0 !important; padding: 4px 0; font-size: 13px; } +.footer a:hover { color: #fff !important; } +.footer-bar { border-top: 1px solid #1e2b45; text-align: center; padding: 18px; font-size: 12px; color: #5c6a83; } + +/* ---------- 响应式 ---------- */ +@media (max-width: 1020px) { + .hero-inner { grid-template-columns: 1fr; padding-top: 52px; } + .mock { transform: none; } + .g4 { grid-template-columns: repeat(2, minmax(0,1fr)); } + .g3 { grid-template-columns: repeat(2, minmax(0,1fr)); } + .pricing, .pricing.three { grid-template-columns: minmax(0, 420px); } + .footer-grid { grid-template-columns: repeat(2, 1fr); } +} +@media (max-width: 680px) { + .nav-links { display: none; } + .hero h1 { font-size: 32px; } + .sec-title { font-size: 24px; } + .g3, .g4 { grid-template-columns: 1fr; } + .footer-grid { grid-template-columns: 1fr; } +} diff --git a/apps/server/internal/site/site.go b/apps/server/internal/site/site.go new file mode 100644 index 0000000..4d7e427 --- /dev/null +++ b/apps/server/internal/site/site.go @@ -0,0 +1,67 @@ +// Package site 在 /home(及 /)提供产品官网单页(SSR,资源嵌入二进制)。 +package site + +import ( + "embed" + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +//go:embed template.html assets +var files embed.FS + +// Handler 官网服务 +type Handler struct { + html string +} + +func New() *Handler { + raw, err := files.ReadFile("template.html") + if err != nil { + panic("site: template.html 缺失: " + err.Error()) + } + return &Handler{html: string(raw)} +} + +// Register 注册路由:/ 与 /home 均渲染官网 +func (h *Handler) Register(r *gin.Engine) { + r.GET("/", h.Page) + r.GET("/home", h.Page) + r.GET("/index.html", h.Page) + r.GET("/site/assets/*file", h.Asset) +} + +// Page 渲染官网 +func (h *Handler) Page(c *gin.Context) { + c.Header("Content-Type", "text/html; charset=utf-8") + c.Header("Cache-Control", "no-cache") + _, _ = c.Writer.WriteString(h.html) +} + +// Asset 静态资源 +func (h *Handler) Asset(c *gin.Context) { + name := strings.TrimPrefix(c.Param("file"), "/") + if strings.Contains(name, "..") { + c.Status(http.StatusBadRequest) + return + } + data, err := files.ReadFile("assets/" + name) + if err != nil { + c.Status(http.StatusNotFound) + return + } + switch { + case strings.HasSuffix(name, ".css"): + c.Header("Content-Type", "text/css; charset=utf-8") + case strings.HasSuffix(name, ".js"): + c.Header("Content-Type", "application/javascript; charset=utf-8") + case strings.HasSuffix(name, ".svg"): + c.Header("Content-Type", "image/svg+xml") + default: + c.Header("Content-Type", "application/octet-stream") + } + c.Header("Cache-Control", "public, max-age=3600") + c.Data(http.StatusOK, "", data) +} diff --git a/apps/server/internal/site/site_test.go b/apps/server/internal/site/site_test.go new file mode 100644 index 0000000..f9e0272 --- /dev/null +++ b/apps/server/internal/site/site_test.go @@ -0,0 +1,63 @@ +package site + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +func newTestRouter(t *testing.T) *gin.Engine { + t.Helper() + gin.SetMode(gin.TestMode) + r := gin.New() + New().Register(r) + return r +} + +func TestSitePageRender(t *testing.T) { + r := newTestRouter(t) + for _, path := range []string{"/", "/home", "/index.html"} { + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) + if w.Code != http.StatusOK { + t.Fatalf("%s: expect 200, got %d", path, w.Code) + } + body := w.Body.String() + for _, want := range []string{ + "OpsxAPI", // 标题 + `id="features"`, // 功能区 + `id="pricing"`, // 定价区 + "¥99", // 入门价 + "¥8,000", // 企业档标价 + "¥30,000", // 买断标价 + "¥5,000–8,000", // 成交区间 + "永久买断", // 买断 FAQ + "早鸟创始价", // 让利策略 + "Socket.IO", // 协议覆盖 + "hello@opsxapi.dev", // 联系方式 + `href="/docs"`, // 文档互链 + `href="/site/assets/site.css"`, // 样式资源 + } { + if !strings.Contains(body, want) { + t.Fatalf("%s: missing %q", path, want) + } + } + } +} + +func TestSiteAsset(t *testing.T) { + r := newTestRouter(t) + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/site/assets/site.css", nil)) + if w.Code != http.StatusOK || !strings.Contains(w.Header().Get("Content-Type"), "text/css") { + t.Fatalf("css: %d %s", w.Code, w.Header().Get("Content-Type")) + } + w = httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/site/assets/../template.html", nil)) + if w.Code != http.StatusBadRequest && w.Code != http.StatusNotFound { + t.Fatalf("path traversal should be rejected, got %d", w.Code) + } +} diff --git a/apps/server/internal/site/template.html b/apps/server/internal/site/template.html new file mode 100644 index 0000000..9d78d8b --- /dev/null +++ b/apps/server/internal/site/template.html @@ -0,0 +1,357 @@ +<!doctype html> +<html lang="zh-CN"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>OpsxAPI — 本地优先的全协议接口工作台 + + + + + + + + + + + + +
+
+
+
v1.0 正式发布 · 数据 100% 本地
+

一个工作台,
调完六种协议的接口

+

+ HTTP · GraphQL · WebSocket · SSE · Socket.IO · MQTT。
+ 集合管理、多环境变量、全格式导入导出、代码生成、内置文档站 —— 运维与开发的自托管接口利器。 +

+ +
+ 本地优先自定义 WS 握手头SSE 断点续传OpenAPI 双向同步 +
+
+ + + +
+
+ + +
+
+

为什么团队选择 OpsxAPI

+

不追赶云端协作的花活,专注把「调试接口」这件事做到极致。

+
+
+
+

本地优先,数据主权

+

集合、环境、历史全部保存在本机,不经过任何第三方服务器。内网、涉密项目放心用。

+
+
+
+

六协议一个窗口

+

HTTP / GraphQL / WebSocket / SSE / Socket.IO / MQTT 统一工作台,实时消息分色日志。

+
+
+
+

浏览器也能带握手头

+

内置 WebSocket 隧道与流式代理,突破浏览器 CORS 与 Header 限制,调试网关级接口。

+
+
+
+

生态互通

+

Apifox / Postman / Swagger / cURL / HAR 一键导入导出,迁移零成本,文档即代码。

+
+
+
+
+ + +
+
+

开箱即用的专业能力

+

从单人调试到团队交付,该有的都有。

+
+

集合与多标签

目录树、搜索、右键菜单、拖拽移动;多标签并行调试。

+

多环境变量

{{baseUrl}} 全局替换,正式 / 测试 / 本地一键切换。

+

鉴权管理

Bearer / Basic / API Key,token 走变量,一处配置全局生效。

+

实时协议调试

WS 握手头、SSE 断点续传、Socket.IO 事件收发、MQTT 通配符订阅。

+

代码生成

任意请求一键生成 cURL / fetch / Python / Go,直接进脚本与 CI。

+

内置文档站

随服务自带 /docs 用户文档与 Markdown 接口文档导出,交付即完整。

+
+
+
+ + +
+
+

六种协议,全覆盖

+

每个协议都有为它定制的编辑器,而不是塞进同一个表单。

+
+

HTTP / REST

7 种方法 · 6 种请求体 · 响应 Pretty 高亮 · 历史回跳

+

GraphQL

Query + Variables 编辑 · errors 高亮 · 生成 cURL 报文

+

WebSocket

自定义握手 Header(本地隧道)· 子协议 · 分色消息日志

+

SSE

直连失败自动切代理 · Last-Event-ID 续传 · 事件按名称归类

+

Socket.IO

事件监听(onAny)· emit 参数 JSON · path / auth 配置

+

MQTT

wss Broker · 通配符订阅 · QoS 0/1/2 · 断线自动重连

+
+
+
+ + +
+
+

导入导出,迁移零成本

+

粘贴、文件、URL 三种来源,格式自动识别。

+
+ OpenAPI 3.0 / 3.1Swagger 2.0ApifoxPostman v2.1 + ApipostcURLHARInsomniaHoppscotchMarkdown + YApi / Apidoc / Apizza / Eolink ① +
+

① 经 OpenAPI / Postman 格式中转导入。导入自动:tag 建目录、路径参数转变量、schema 生成示例体、security 映射鉴权。

+
+
导入
──→
OpsxAPI
──→
+
OpenAPI / Postman
Markdown 文档
代码生成
全量备份
+
+
+
+ + +
+
+

与主流工具的对比

+

价格为各官网公开标价(2026-09 整理),以官方为准。

+
+ + + + + + + + + + + + +
能力OpsxAPIApifoxApipost
入门付费价格¥99/年(个人)¥99/人/年②免费 + 商业版
专业档价格¥99/人/年(团队)¥199/人/年按席位订阅
数据存储位置100% 本地云端 / 私有化云端 / 私有化
调试协议HTTP · GraphQL · WS · SSE · Socket.IO · MQTTHTTP · WS · GraphQLHTTP · WS · gRPC 等
WS 自定义握手 Header(Web)✓ 内置隧道桌面端支持桌面端支持
自托管部署单二进制 + 静态页私有化付费私有化付费
私有化 / 买断参考价¥8,000/年起 · ¥30,000 买断询价询价
内置用户文档站✓ /docs——
+
+

② Apifox 免费版即含大部分调试能力,付费档面向团队协作;对比仅帮助按需选型,各有所长。

+
+
+ + +
+
+

简单透明的定价

+

先在工作台免费用起来,需要授权与服务时再付费。

+
+
+
个人版
+
¥99/ 年
+
单设备授权 · 全功能
+
    +
  • ✓ 六种协议全部调试能力
  • +
  • ✓ 集合 / 环境 / 历史无限量
  • +
  • ✓ 全格式导入导出 + 代码生成
  • +
  • ✓ 桌面端 + Web 双形态
  • +
  • ✓ 1 年免费升级
  • +
+ 购买咨询 +
+ +
+
私有化 · 单法人不限席位
+
企业私有化版
+
¥8,000/ 年 · 标价
+
或 ¥30,000 一次性永久买断(标价)
+
    +
  • ✓ 整机 / 内网部署,数据不出企业
  • +
  • ✓ 不限席位,团队版全部能力
  • +
  • ✓ 远程部署协助 + 专属支持
  • +
  • ✓ 定制开发通道(另行报价)
  • +
  • ✓ 买断可选:永久使用 + 1 年升级
  • +
+ 获取报价 +
+ 标价透明,成交灵活:经早鸟 / 教育 / 多年预付 / 案例合作,常见成交区间 ¥5,000–8,000/年、买断 ¥15,000–30,000。 +
+
+
+ + +

早鸟与让利计划 —— 把优惠变成共同成长

+
+
+

早鸟创始价

+

前 20 个付费席位享 5-7 折永久优惠,并进入创始客户名单,参与需求优先排期。

+
+
+

教育 / 开源

+

高校师生、开源维护者凭证享 6-8 折。教育用途可申请批量授权。

+
+
+

多年预付

+

一次预付 2 年 9 折、3 年 8 折,锁定当期价格,改善双方现金流。

+
+
+

案例换授权

+

愿意公开使用案例、撰写评测或作为推荐客户的,可获 额外折扣或免费席位。

+
+
+

以上为官网标价机制;最终成交价支持按规模与场景商务沟通,多年预付 / 服务范围(部署协助、定制开发)可组合。

+
+
+ + +
+
+

购买常见问题

+
+
授权如何交付?包含哪些内容?

邮件交付授权码与部署包(单二进制 + Web 静态资源),含使用文档。团队版附内网部署指南。

+
一年后不续费还能用吗?

可以。授权期内下载的版本可 永久使用,续费获得后续升级与支持。

+
团队版怎么计费?人员变动怎么办?

按启用席位每年计费,5 席起。人员变动可在授权期内置换席位,无需额外费用。

+
可以开发票吗?

支持增值税普通发票与专票,对公转账或线上支付均可。

+
我们能自己部署吗?安全吗?

完全可以,这正是 OpsxAPI 的设计初衷:数据 100% 本地,服务仅监听本机回环,内网部署无外联。

+
可以永久买断吗?

可以。企业私有化版支持一次性买断(标价 ¥30,000,含 1 年免费升级与支持),买断版本永久使用,续费仅用于继续获得升级。订阅档同理:授权期内下载的版本永久可用。

+
预算有限,有优惠空间吗?

我们不直接降价,但提供四种「让利换增长」机制:早鸟创始价(前 20 席 5–7 折永久)、教育 / 开源 6–8 折、多年预付 8–9 折、以案例 / 评测换折扣。相比按年订阅的 SaaS,5 人以上团队通常 1–2 年即可回本;可自行部署(我们提供文档)或以案例分享换取创始价。

+
先试用再决定可以吗?

欢迎。工作台全部调试能力可直接体验;授权版差异主要在商业支持与升级服务。

+
+
+
+ + +
+
+

把接口调试收回自己手里

+

现在打开工作台,30 秒发出第一个请求;采购事宜邮件联系,当天回复。

+ +
+
+ + + + + + diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..0da3773 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,14 @@ + + + + + + + + OpsxAPI + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..a6e506e --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,30 @@ +{ + "name": "@opsxapi/web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1 --port 5173", + "build": "tsc -b && vite build", + "preview": "vite preview --host 127.0.0.1 --port 5173" + }, + "dependencies": { + "@opsxapi/core": "*", + "@opsxapi/transport": "*", + "@opsxapi/ui": "*", + "@tauri-apps/api": "^2.5.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/node": "^22.20.3", + "@types/react": "^19.0.10", + "@types/react-dom": "^19.0.4", + "@vitejs/plugin-react": "^4.4.1", + "autoprefixer": "^10.4.21", + "postcss": "^8.5.3", + "tailwindcss": "^3.4.17", + "typescript": "^5.8.2", + "vite": "^6.2.2" + } +} diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/apps/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg new file mode 100644 index 0000000..7e6d4c5 --- /dev/null +++ b/apps/web/public/favicon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..efb08cb --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { OpsxWorkspace } from "@opsxapi/ui"; +import "./styles.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css new file mode 100644 index 0000000..e307f96 --- /dev/null +++ b/apps/web/src/styles.css @@ -0,0 +1,417 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* ============ 主题变量 ============ */ +:root, +:root[data-theme="dark"] { + color-scheme: dark; + --bg: #0b1220; + --panel: #0f1726; + --panel-2: #131e33; + --panel-3: #182540; + --border: #1e2b45; + --border-strong: #2b3b5c; + --text: #dbe4f3; + --text-dim: #8b99b0; + --text-faint: #5c6a83; + --accent: #3b82f6; + --accent-strong: #60a5fa; + --accent-soft: rgba(59, 130, 246, 0.14); + --success: #10b981; + --warn: #f59e0b; + --danger: #f43f5e; + --input-bg: #0a1120; + --code-bg: #070c16; + --hover: rgba(148, 163, 184, 0.08); + --active: rgba(59, 130, 246, 0.12); + --scrollbar: #334155; + + --m-GET: #34d399; + --m-POST: #fbbf24; + --m-PUT: #60a5fa; + --m-PATCH: #c084fc; + --m-DELETE: #fb7185; + --m-HEAD: #94a3b8; + --m-OPTIONS: #38bdf8; +} + +:root[data-theme="light"] { + color-scheme: light; + --bg: #eef1f6; + --panel: #ffffff; + --panel-2: #f6f8fb; + --panel-3: #eef2f8; + --border: #e3e8f0; + --border-strong: #cbd5e1; + --text: #1c2434; + --text-dim: #5b6880; + --text-faint: #8a95a9; + --accent: #2563eb; + --accent-strong: #1d4ed8; + --accent-soft: rgba(37, 99, 235, 0.1); + --success: #059669; + --warn: #d97706; + --danger: #e11d48; + --input-bg: #ffffff; + --code-bg: #f5f7fa; + --hover: rgba(100, 116, 139, 0.08); + --active: rgba(37, 99, 235, 0.1); + --scrollbar: #c4cdda; + + --m-GET: #059669; + --m-POST: #d97706; + --m-PUT: #2563eb; + --m-PATCH: #9333ea; + --m-DELETE: #e11d48; + --m-HEAD: #64748b; + --m-OPTIONS: #0284c7; +} + +@layer base { + * { + scrollbar-color: var(--scrollbar) transparent; + scrollbar-width: thin; + } + body { + @apply m-0 font-sans antialiased; + background: var(--bg); + color: var(--text); + } + button, + input, + select, + textarea { + font: inherit; + color: inherit; + } + ::selection { + background: var(--accent-soft); + } +} + +@layer components { + /* ---------- 文本工具 ---------- */ + .ops-dim { color: var(--text-dim); } + .ops-faint { color: var(--text-faint); } + .ops-accent { color: var(--accent-strong); } + .ops-success { color: var(--success); } + .ops-warn { color: var(--warn); } + .ops-danger { color: var(--danger); } + + /* ---------- 面板 ---------- */ + .ops-card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 0.75rem; + } + .ops-panel { background: var(--panel); } + .ops-panel-2 { background: var(--panel-2); } + .ops-border { border-color: var(--border); } + .ops-divider { border-bottom: 1px solid var(--border); } + + /* ---------- 输入 ---------- */ + .ops-input { + width: 100%; + background: var(--input-bg); + border: 1px solid var(--border); + border-radius: 0.5rem; + padding: 0.4rem 0.7rem; + font-size: 0.8125rem; + color: var(--text); + outline: none; + transition: border-color 0.15s, box-shadow 0.15s; + } + .ops-input::placeholder { color: var(--text-faint); } + .ops-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); + } + .ops-input-sm { + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + } + .ops-label { + display: block; + margin-bottom: 0.375rem; + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-faint); + } + + /* ---------- 按钮 ---------- */ + .ops-btn { + @apply inline-flex items-center justify-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold transition; + border: 1px solid transparent; + cursor: pointer; + } + .ops-btn:disabled { opacity: 0.45; cursor: not-allowed; } + .ops-btn-primary { background: var(--accent); color: #fff; } + .ops-btn-primary:hover:not(:disabled) { background: var(--accent-strong); } + .ops-btn-success { background: var(--success); color: #fff; } + .ops-btn-success:hover:not(:disabled) { filter: brightness(1.1); } + .ops-btn-danger { background: var(--danger); color: #fff; } + .ops-btn-danger:hover:not(:disabled) { filter: brightness(1.1); } + .ops-btn-secondary { + background: var(--panel-2); + border-color: var(--border); + color: var(--text); + } + .ops-btn-secondary:hover:not(:disabled) { border-color: var(--border-strong); background: var(--hover); } + .ops-btn-ghost { + background: transparent; + color: var(--text-dim); + } + .ops-btn-ghost:hover:not(:disabled) { background: var(--hover); color: var(--text); } + + /* ---------- chip / badge ---------- */ + .ops-chip { + @apply rounded-full px-2.5 py-1 text-[11px] font-medium transition; + border: 1px solid var(--border); + background: var(--panel-2); + color: var(--text-dim); + } + .ops-chip:hover { border-color: var(--accent); color: var(--accent-strong); } + .ops-chip-active { + background: var(--accent-soft); + border-color: var(--accent); + color: var(--accent-strong); + } + + /* ---------- Tab ---------- */ + .ops-tab { + @apply px-3.5 py-2 text-xs font-medium transition; + color: var(--text-faint); + border-bottom: 2px solid transparent; + white-space: nowrap; + } + .ops-tab:hover { color: var(--text); } + .ops-tab-active { + color: var(--accent-strong); + border-bottom-color: var(--accent); + } + .ops-tab .ops-count { + @apply ml-1 rounded-full px-1.5 text-[10px]; + background: var(--hover); + color: var(--text-dim); + } + + /* ---------- 代码 ---------- */ + .ops-code { + background: var(--code-bg); + border: 1px solid var(--border); + border-radius: 0.5rem; + padding: 0.75rem; + font-family: "JetBrains Mono", SFMono-Regular, Menlo, monospace; + font-size: 0.75rem; + line-height: 1.55; + color: var(--text); + outline: none; + } + .ops-code:focus { border-color: var(--accent); } + .ops-code-no-bg { + background: transparent; + border: none; + padding: 0; + } + + /* ---------- 方法色 ---------- */ + .ops-method-GET { color: var(--m-GET); } + .ops-method-POST { color: var(--m-POST); } + .ops-method-PUT { color: var(--m-PUT); } + .ops-method-PATCH { color: var(--m-PATCH); } + .ops-method-DELETE { color: var(--m-DELETE); } + .ops-method-HEAD { color: var(--m-HEAD); } + .ops-method-OPTIONS { color: var(--m-OPTIONS); } + .ops-method-badge { + @apply inline-block rounded px-1.5 py-0.5 text-[10px] font-bold tracking-wide; + background: var(--hover); + } + + /* ---------- 树 ---------- */ + .ops-tree-row { + @apply flex items-center gap-1.5 rounded-md px-2 py-1.5 text-[13px] cursor-pointer transition; + color: var(--text-dim); + } + .ops-tree-row:hover { background: var(--hover); color: var(--text); } + .ops-tree-row-active { + background: var(--active); + color: var(--accent-strong); + } + + /* ---------- 弹窗 ---------- */ + .ops-modal-mask { + @apply fixed inset-0 z-50 grid place-items-center p-6; + background: rgba(2, 6, 18, 0.55); + backdrop-filter: blur(2px); + } + .ops-modal { + @apply w-full max-w-lg rounded-2xl shadow-2xl flex flex-col overflow-hidden; + background: var(--panel); + border: 1px solid var(--border); + max-height: 85vh; + } + .ops-modal-header { + @apply flex items-center justify-between px-5 py-3.5; + border-bottom: 1px solid var(--border); + } + .ops-modal-title { @apply text-sm font-semibold; } + .ops-modal-body { @apply px-5 py-4 overflow-auto; } + .ops-modal-footer { + @apply flex items-center justify-end gap-2 px-5 py-3; + border-top: 1px solid var(--border); + background: var(--panel-2); + } + + /* ---------- 右键菜单 ---------- */ + .ops-menu { + @apply fixed z-[60] min-w-[180px] rounded-xl py-1.5 shadow-2xl; + background: var(--panel); + border: 1px solid var(--border); + } + .ops-menu-item { + @apply flex w-full items-center gap-2.5 px-3.5 py-1.5 text-left text-[13px] transition; + color: var(--text-dim); + } + .ops-menu-item:hover { background: var(--hover); color: var(--text); } + .ops-menu-item-danger:hover { color: var(--danger); } + .ops-menu-sep { border-top: 1px solid var(--border); margin: 0.375rem 0; } + + /* ---------- 实时消息 ---------- */ + .ops-msg { + @apply rounded-lg border px-2.5 py-1.5 font-mono text-[12px] leading-5 whitespace-pre-wrap break-words; + } + .ops-msg-in { border-color: color-mix(in srgb, var(--success) 30%, transparent); background: color-mix(in srgb, var(--success) 8%, transparent); } + .ops-msg-out { border-color: color-mix(in srgb, var(--accent) 30%, transparent); background: color-mix(in srgb, var(--accent) 8%, transparent); } + .ops-msg-sys { background: var(--hover); border-color: var(--border); color: var(--text-dim); } + .ops-msg-err { border-color: color-mix(in srgb, var(--danger) 40%, transparent); background: color-mix(in srgb, var(--danger) 8%, transparent); } + + /* ---------- JSON 高亮 ---------- */ + .ops-json-key { color: var(--accent-strong); } + .ops-json-str { color: var(--success); } + .ops-json-num { color: var(--m-POST); } + .ops-json-bool { color: var(--m-PATCH); } + .ops-json-null { color: var(--text-faint); } + + /* ---------- 状态点 ---------- */ + .ops-dot { @apply inline-block h-2 w-2 rounded-full; } + .ops-dot-open { background: var(--success); box-shadow: 0 0 6px var(--success); } + .ops-dot-connecting { background: var(--warn); animation: ops-pulse 1s infinite; } + .ops-dot-closed { background: var(--text-faint); } + .ops-dot-error { background: var(--danger); } + + /* ---------- 滚动区 ---------- */ + .ops-scroll { overflow: auto; } + + /* ---------- 环境徽标 ---------- */ + .env-badge { + display: inline-grid; + place-items: center; + border-radius: 6px; + font-weight: 700; + line-height: 1; + border: 1px solid transparent; + flex-shrink: 0; + user-select: none; + } + + /* ---------- 顶栏环境切换器 ---------- */ + .ops-envswitch { + @apply inline-flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-xs font-medium transition; + border: 1px solid var(--border); + background: var(--panel-2); + color: var(--text); + max-width: 240px; + } + .ops-envswitch:hover { border-color: var(--border-strong); } + + /* ---------- 菜单项 flex(支持徽标行) ---------- */ + .ops-menu-item-flex { display: flex; align-items: center; width: 100%; } + + /* ---------- 环境管理弹窗 ---------- */ + .env-dialog { display: grid; grid-template-columns: 196px minmax(0, 1fr); gap: 0 18px; min-height: 420px; margin: 0 -1.25rem -0.25rem; } + .env-dialog-side { + border-right: 1px solid var(--border); + padding-right: 14px; + display: flex; flex-direction: column; gap: 2px; + max-height: 60vh; overflow-y: auto; + } + .env-dialog-group { @apply mb-1.5 px-2 text-[11px] font-semibold uppercase tracking-[0.08em]; color: var(--text-faint); } + .env-dialog-item { + @apply flex items-center gap-2 rounded-lg px-2 py-1.5 text-[13px] transition; + color: var(--text-dim); + } + .env-dialog-item:hover { background: var(--hover); color: var(--text); } + .env-dialog-item.active { background: var(--active); color: var(--accent-strong); } + .env-dialog-current { + @apply rounded-full px-1.5 py-0.5 text-[10px] font-semibold; + background: color-mix(in srgb, var(--success) 14%, transparent); + color: var(--success); + } + .env-dialog-new { + @apply mt-2 flex items-center justify-center gap-1 rounded-lg border border-dashed px-2 py-2 text-xs transition; + border-color: var(--border); + color: var(--text-dim); + } + .env-dialog-new:hover { border-color: var(--accent); color: var(--accent-strong); } + .env-dialog-main { min-width: 0; display: flex; flex-direction: column; } + .env-dialog-vars { margin-top: 10px; } + + /* ---------- 信息提示条 ---------- */ + .ops-hint { + @apply mb-3 flex items-start gap-2 rounded-lg px-3 py-2 text-xs leading-5; + background: color-mix(in srgb, var(--accent) 8%, transparent); + border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent); + color: var(--text-dim); + } + .ops-hint svg { color: var(--accent-strong); margin-top: 2px; } + .ops-hint b { color: var(--accent-strong); font-family: inherit; } + + /* ---------- {{变量}} 补全浮层 ---------- */ + .var-hint-wrap { position: relative; min-width: 0; flex: 1; display: inline-flex; } + .var-hint-panel { + position: absolute; + top: calc(100% + 4px); + left: 0; + z-index: 40; + min-width: 280px; + max-width: 420px; + @apply overflow-hidden rounded-xl py-1 shadow-2xl; + background: var(--panel); + border: 1px solid var(--border); + } + .var-hint-head { + @apply flex items-center gap-1.5 px-2.5 py-1.5 text-[11px]; + color: var(--text-dim); + border-bottom: 1px solid var(--border); + } + .var-hint-head .ops-faint { margin-left: auto; } + .var-hint-item { + @apply flex w-full items-center gap-3 px-2.5 py-1.5 text-left font-mono text-[12px] transition; + color: var(--text-dim); + } + .var-hint-item:hover, .var-hint-item.sel { background: var(--active); color: var(--text); } + .var-hint-name { color: var(--accent-strong); flex-shrink: 0; } + .var-hint-value { @apply truncate; color: var(--text-dim); } + + /* ---------- misc ---------- */ + .ops-link { color: var(--accent-strong); text-decoration: none; } + .ops-link:hover { text-decoration: underline; } + .ops-selected-input { + outline: 1.5px solid var(--accent); + border-radius: 0.25rem; + } +} + +@keyframes ops-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.35; } +} + +/* 拖拽落点提示 */ +.ops-drop-target { + outline: 1.5px dashed var(--accent); + outline-offset: -2px; +} diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts new file mode 100644 index 0000000..6250766 --- /dev/null +++ b/apps/web/tailwind.config.ts @@ -0,0 +1,15 @@ +import type { Config } from "tailwindcss"; + +export default { + content: [ + "./index.html", + "./src/**/*.{ts,tsx}", + "../../packages/ui/src/**/*.{ts,tsx}", + ], + theme: { + extend: { + fontFamily: { mono: ["JetBrains Mono", "SFMono-Regular", "Menlo", "monospace"] }, + }, + }, + plugins: [], +} satisfies Config; diff --git a/apps/web/tsconfig.app.json b/apps/web/tsconfig.app.json new file mode 100644 index 0000000..7a3d8ce --- /dev/null +++ b/apps/web/tsconfig.app.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "noEmit": true, + "allowImportingTsExtensions": true, + "types": [ + "vite/client" + ] + }, + "include": [ + "src", + "../../packages/ui/src", + "../../packages/core/src", + "../../packages/transport/src" + ] +} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..ea9d0cd --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.node.json" + } + ] +} diff --git a/apps/web/tsconfig.node.json b/apps/web/tsconfig.node.json new file mode 100644 index 0000000..d8ff2ca --- /dev/null +++ b/apps/web/tsconfig.node.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "noEmit": true, + "composite": true, + "types": [ + "node" + ] + }, + "include": [ + "vite.config.ts" + ] +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000..ab70e35 --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,41 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import path from "node:path"; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + "@opsxapi/core": path.resolve(__dirname, "../../packages/core/src/index.ts"), + "@opsxapi/transport": path.resolve(__dirname, "../../packages/transport/src/index.ts"), + "@opsxapi/ui": path.resolve(__dirname, "../../packages/ui/src/index.ts"), + }, + }, + server: { + host: "127.0.0.1", + port: 5173, + strictPort: true, + proxy: { + "/v1": { + target: "http://127.0.0.1:8787", + changeOrigin: true, + }, + "/health": { + target: "http://127.0.0.1:8787", + changeOrigin: true, + }, + "/docs": { + target: "http://127.0.0.1:8787", + changeOrigin: true, + }, + "/home": { + target: "http://127.0.0.1:8787", + changeOrigin: true, + }, + "/site": { + target: "http://127.0.0.1:8787", + changeOrigin: true, + }, + }, + }, +}); diff --git a/docs/refs/.keep b/docs/refs/.keep new file mode 100644 index 0000000..e69de29 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..419fb2e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3714 @@ +{ + "name": "opsxapi", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "opsxapi", + "workspaces": [ + "apps/web", + "packages/*" + ], + "dependencies": { + "js-yaml": "^5.4.2", + "mqtt": "^5.16.0", + "socket.io-client": "^4.8.3" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9" + }, + "engines": { + "node": ">=20" + } + }, + "apps/web": { + "name": "@opsxapi/web", + "version": "0.1.0", + "dependencies": { + "@opsxapi/core": "*", + "@opsxapi/transport": "*", + "@opsxapi/ui": "*", + "@tauri-apps/api": "^2.5.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/node": "^22.20.3", + "@types/react": "^19.0.10", + "@types/react-dom": "^19.0.4", + "@vitejs/plugin-react": "^4.4.1", + "autoprefixer": "^10.4.21", + "postcss": "^8.5.3", + "tailwindcss": "^3.4.17", + "typescript": "^5.8.2", + "vite": "^6.2.2" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.3.0.tgz", + "integrity": "sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@opsxapi/core": { + "resolved": "packages/core", + "link": true + }, + "node_modules/@opsxapi/transport": { + "resolved": "packages/transport", + "link": true + }, + "node_modules/@opsxapi/ui": { + "resolved": "packages/ui", + "link": true + }, + "node_modules/@opsxapi/web": { + "resolved": "apps/web", + "link": true + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.3.tgz", + "integrity": "sha512-w3Jnvi1ocaVm/c7yVPpfB98XeSRBMyzp6njL5MVVbGyXjpmUkN+s6Hp4t0PqhGCCaI1ZHMKXt/w0lA1RCaLVcw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.3.tgz", + "integrity": "sha512-uI/ESiaIbbRYAEhzy8PCUWDp1hB0bjAqM06mW9flOoNO4Q8DQpeoREhBR5Hegfl+wpXiguyJv6XSPzEN7OxyHQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.3.tgz", + "integrity": "sha512-oxhrd1jmXLwWZ83eQYDXxuqRdkqkzrjR3JobKeuUyfdNZo11FuQIvqEOZhyIT7OBHxXoGslDDjN0cQcM6T0TqQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.3.tgz", + "integrity": "sha512-7/YiIMghVE8DrxKvNdorAaJVdriOFgOIpdStnPx8ppx5zfTwC3jBCSEAIzB7JD5404m65THl6H93UTTVUvypmg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.3.tgz", + "integrity": "sha512-GXFZRRoMAytaI5z6N3Zhfw0WL18Q0M8r95D5hlC4GqE/lGk8pbSJNUBoOWDfbm6dTciqHj2nU87tI5f6XhQiOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.3.tgz", + "integrity": "sha512-77W+8X3ddYgPxUpB8nZFQs2Mq+wc4HVlcSRtApXLjYBcnPMkttrSnU8VwKQjeWYhMsITHFs5cWBQ8vz1Q+5RHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.3.tgz", + "integrity": "sha512-FVkwK+iUC+mq+GipVK46rRVticfAPtvPUNlqlGXUDxdVk/UGjQiiiUVPUrEXdSpU2ufU0XxLGyTqDtBidDOVmg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.3.tgz", + "integrity": "sha512-+aGU1t3398yQOVj1Bz8o3e+KtswxAPvO+mtxtNdfXYMkXIHu7XhhkCD7/DEH9q8tF8uhDnMWvfpUKI8y1sZJsg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.3.tgz", + "integrity": "sha512-cR0kjpRXR2KJ2oQK8E2KTPtphs+b9hZ8IhTZubNryt/RsqgdOZBQ2Zq0q5UedtiIi0rs3jVhJh55RE1ZHUVGUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.3.tgz", + "integrity": "sha512-y1RYi4Q3/9ByVWSSt9kX2ustE0B7kFYbJ6zZdVZVyqopZs3yhCTwRfrjIX4vezUJInma/Gs6BOFDJg7yZmJ0IQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.3.tgz", + "integrity": "sha512-DNhEA5viIj3Z5bZLE4z4oV8N5ozWqDwyt7T6KG7VdLDJ0nW+rNOYlphBl4/3HQkK75qipPLsVOfStHHOwN9WSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.3.tgz", + "integrity": "sha512-17gQCqrIpXBX2Cmi9/TygnVOqGbzsba/iaqcYSL8FY7lNugg+7AiYNs5c5nKWD+NRQha36Sa0CqkJqH4XVHwnQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.3.tgz", + "integrity": "sha512-6LwVnZRIyINpdku/yOcI8Tm9YqLmhHK5emmlOOnW9tO0SYEm1FmKPcsSAGp0NBlqR2P04xaND4jvN6sTHqhq8A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.3.tgz", + "integrity": "sha512-xMUqkTXlEUtI/p5AAukMwBRr1enU3efsTeF+bskeFfk8t1C9rcC8sLREcZXmTfAXEbvRdJVSonVJez3TMlbR3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.3.tgz", + "integrity": "sha512-S3E94co9F9WRRqEaUoQZ38K1gCz6KiM+nL7/3ijq7fDGF3OznjS5TasgYITlvl27GQKtu4lOAOsr5MFwkijvOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.3.tgz", + "integrity": "sha512-1QtRDwG42x5BJI3s9mxu5rEjDnfbSnk20HQ9/ylTAYnSwYwxMVb+Vgu34wzzTQ7ogqBybebgQNUDAvZVQ38DbA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.3.tgz", + "integrity": "sha512-BQhejF6ZXOpxbngiNTP12GCGQeaDVL2QXGeBVViKIYzFHM5RKxTxwUMB1fr1BeNFphFMpnRqC5QSXFSa4z6UQw==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.3.tgz", + "integrity": "sha512-SXagRwnI2Wlwlitllu59UK/nGVbD1CKPcNqDplHwIC4BqJcpXFjD32d1R/RbuISa95HdQrZM3/7v4bKiowFaLA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.3.tgz", + "integrity": "sha512-2IPozoEALRCziGqE8O9KMK60PMu5TS1huv4fwoeCexj+WjmcwFtX9CTOVbfXCUqcELAubEwRFPYlzb/WvwY2HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.3.tgz", + "integrity": "sha512-AoxqosUHT9IX54hFn2TiN6A7d6ZKTtE6pd2bqWtqkkNJ6HJGaU6FRouGX8L1O7R/ZwsnCnpQrHzb4pDEx+UHRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.3.tgz", + "integrity": "sha512-d+CaftKgmkFBzCwezMqqy1d0QNNYugqLCMcYVQWBy5SS2YfeMP8Q8ripkgx9O8IyBXXLHrJ+aaCV4U96usv6Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.3.tgz", + "integrity": "sha512-xXlDF6nR1eOuXbdDy5Hl5fmtY7teUDevF/k0O7IPoZe4Tpmdv+lgdE5JRsnhQtt37ql9P0VF2kAN9a0OCZdo+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.3.tgz", + "integrity": "sha512-YtXAgLN+JP7Ay6qG3eWhc7IHMQPzLc8r3uvhAvlJIoCz/4Q32+Bl9Fmnywidh8v1GOIMmymjovfqY9ETAtysvA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.3.tgz", + "integrity": "sha512-WuWtSJRNo549vzcfZyEgfqb6zeSgn1F+UE5kQ+BCjzz0W4MGCjntUHkZVc1VRuAM7+ULaSyhiPxD1spyewFvkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.3.tgz", + "integrity": "sha512-+lIKX7O0+IGe7WuhATaAMMeT7B76vfhXH/l9wLQL+nvyhbw2ohYCKIdWL56JfDu75CWt5oKRP4QFH/jkMtBquA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, + "node_modules/@tauri-apps/api": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz", + "integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.3.tgz", + "integrity": "sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "devOptional": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@types/readable-stream": { + "version": "4.0.24", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.24.tgz", + "integrity": "sha512-NRvUNC/JFGPJvqdAfEve8oginbM6V08u5NzLWpG8MwA2kTPOLnqk+wpwuPT+mp3aUsxyuT6m2gnrPuHYCruzEg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/autoprefixer": { + "version": "10.6.1", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.6.1.tgz", + "integrity": "sha512-cL1Qz6ADZhcEbny/8HPfe99J6HhNoYtpX2LFLIbhgGE7Q1hlQVkYFdetDN7Id3KiQxhDrHwzlHr/YQCnZ8+xSA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "browserslist": "^4.28.9", + "caniuse-lite": "^1.0.30001810", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.24", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.24.tgz", + "integrity": "sha512-hYrgxie335U08WqICoGqKRzV1HFXv6zdxwJE4ekCb80CM9a0SVVsN4QPwT67RraRo+9h8IATk6uxHJw7QSkdOg==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bl": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz", + "integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==", + "license": "MIT", + "dependencies": { + "@types/readable-stream": "^4.0.0", + "buffer": "^6.0.3", + "inherits": "^2.0.4", + "readable-stream": "^4.2.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/broker-factory": { + "version": "3.1.15", + "resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.15.tgz", + "integrity": "sha512-ko+aWvgNuP49meGrdjUu7rC+Y+Wai3cCPxP3xWwHsHfehFjOh5ZQM2yC4gEB2UddeZ/YXhm0K1eG/L6fxym2Og==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "fast-unique-numbers": "^9.0.27", + "tslib": "^2.8.1", + "worker-factory": "^7.0.50" + } + }, + "node_modules/browserslist": { + "version": "4.29.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz", + "integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "baseline-browser-mapping": "^2.11.23", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.427", + "node-releases": "^2.0.55", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commist": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/commist/-/commist-3.2.0.tgz", + "integrity": "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==", + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.430", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.430.tgz", + "integrity": "sha512-e1QEj72Y4zd8RlNZVmoTg+iCOSVwpk05IOiiQwdrkwCSVlZfPthevErhE+nckGd2YbsXfp1SkisznhGVIXP2NQ==", + "dev": true + }, + "node_modules/engine.io-client": { + "version": "6.6.6", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz", + "integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-unique-numbers": { + "version": "9.0.27", + "resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-9.0.27.tgz", + "integrity": "sha512-nDA9ADeINN8SA2u2wCtU+siWFTTDqQR37XvgPIDDmboWQeExz7X0mImxuaN+kJddliIqy2FpVRmnvRZ+j8i1/A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.2.0" + } + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "license": "MIT" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", + "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "node_modules/js-yaml": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.2.tgz", + "integrity": "sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.mjs" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mqtt": { + "version": "5.16.0", + "resolved": "https://registry.npmjs.org/mqtt/-/mqtt-5.16.0.tgz", + "integrity": "sha512-G0VsEtuJz5sJUgZJaaADMVfW7SIFPzkIuHR+d6rk+tBeSwdCfd9BaIRycRfd2AbyFDEmLlFM+SDQeti/+IbOnw==", + "license": "MIT", + "dependencies": { + "@types/readable-stream": "^4.0.21", + "@types/ws": "^8.18.1", + "commist": "^3.2.0", + "concat-stream": "^2.0.0", + "debug": "^4.4.1", + "help-me": "^5.0.0", + "lru-cache": "^10.4.3", + "minimist": "^1.2.8", + "mqtt-packet": "^9.0.2", + "readable-stream": "^4.7.0", + "rfdc": "^1.4.1", + "socks": "^2.8.6", + "split2": "^4.2.0", + "worker-timers": "^8.0.23", + "ws": "^8.18.3" + }, + "bin": { + "mqtt": "build/bin/mqtt.js", + "mqtt_pub": "build/bin/pub.js", + "mqtt_sub": "build/bin/sub.js" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/mqtt-packet": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-9.0.2.tgz", + "integrity": "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==", + "license": "MIT", + "dependencies": { + "bl": "^6.0.8", + "debug": "^4.3.4", + "process-nextick-args": "^2.0.1" + } + }, + "node_modules/mqtt/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.55", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz", + "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.2.tgz", + "integrity": "sha512-/peqiBB/n07gQGLsWaHho3WfvUyRscw0gYTsEFMhrIe/nWLkYaf5SbKYjGYqtRV3aPwykJgF2VEMo1ac4bnsGA==", + "dev": true + }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.3.tgz", + "integrity": "sha512-1i2XreiAoMMXuPGD6Msj2xWrMMkHojNRKivInxGQcg7/1KuPuYlfUutLyh4drnOxUTHX9cHI4wFoat8D/NKaBw==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.3", + "@rollup/rollup-android-arm64": "4.63.3", + "@rollup/rollup-darwin-arm64": "4.63.3", + "@rollup/rollup-darwin-x64": "4.63.3", + "@rollup/rollup-freebsd-arm64": "4.63.3", + "@rollup/rollup-freebsd-x64": "4.63.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.3", + "@rollup/rollup-linux-arm-musleabihf": "4.63.3", + "@rollup/rollup-linux-arm64-gnu": "4.63.3", + "@rollup/rollup-linux-arm64-musl": "4.63.3", + "@rollup/rollup-linux-loong64-gnu": "4.63.3", + "@rollup/rollup-linux-loong64-musl": "4.63.3", + "@rollup/rollup-linux-ppc64-gnu": "4.63.3", + "@rollup/rollup-linux-ppc64-musl": "4.63.3", + "@rollup/rollup-linux-riscv64-gnu": "4.63.3", + "@rollup/rollup-linux-riscv64-musl": "4.63.3", + "@rollup/rollup-linux-s390x-gnu": "4.63.3", + "@rollup/rollup-linux-x64-gnu": "4.63.3", + "@rollup/rollup-linux-x64-musl": "4.63.3", + "@rollup/rollup-openbsd-x64": "4.63.3", + "@rollup/rollup-openharmony-arm64": "4.63.3", + "@rollup/rollup-win32-arm64-msvc": "4.63.3", + "@rollup/rollup-win32-ia32-msvc": "4.63.3", + "@rollup/rollup-win32-x64-gnu": "4.63.3", + "@rollup/rollup-win32-x64-msvc": "4.63.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz", + "integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.10", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.10.tgz", + "integrity": "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/worker-factory": { + "version": "7.0.50", + "resolved": "https://registry.npmjs.org/worker-factory/-/worker-factory-7.0.50.tgz", + "integrity": "sha512-hhwc0G+sFwM4qBuhJIUBn2p1Jf8v/FwmLUANBf/Q+Lt2uI8mfIZQhXaZQACodQD4R7Zp6cn/6702bIvNn2puJQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "fast-unique-numbers": "^9.0.27", + "tslib": "^2.8.1" + } + }, + "node_modules/worker-timers": { + "version": "8.0.34", + "resolved": "https://registry.npmjs.org/worker-timers/-/worker-timers-8.0.34.tgz", + "integrity": "sha512-WXL+Dqsm0G6dnC66rQsvM3tPT2adhbqSirWUCZGglkALOr8ocS0KlpU0iuKbjflJOlu3ydZikMEVrYnHOM9suw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "tslib": "^2.8.1", + "worker-timers-broker": "^8.0.18", + "worker-timers-worker": "^9.0.15" + } + }, + "node_modules/worker-timers-broker": { + "version": "8.0.18", + "resolved": "https://registry.npmjs.org/worker-timers-broker/-/worker-timers-broker-8.0.18.tgz", + "integrity": "sha512-FrjzDVX1wKfZN0gRbCFqv8VHuTncG4sbI/WGEg4tSSQeIsnwqg4YBYWMAHYLJtUDEYYmiK65UKFrVMVk2irDSg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "broker-factory": "^3.1.15", + "fast-unique-numbers": "^9.0.27", + "tslib": "^2.8.1", + "worker-timers-worker": "^9.0.15" + } + }, + "node_modules/worker-timers-worker": { + "version": "9.0.15", + "resolved": "https://registry.npmjs.org/worker-timers-worker/-/worker-timers-worker-9.0.15.tgz", + "integrity": "sha512-KKUe7lZ/Aignr51H6hOUik8LwTnIgojH/1lwhli8A8qIEIyewogZTpNpMW5B6BF7nmwBOkUoTYgf1H3QShcjSA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "tslib": "^2.8.1", + "worker-factory": "^7.0.50" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/zustand": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz", + "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + }, + "packages/core": { + "name": "@opsxapi/core", + "version": "0.1.0", + "dependencies": { + "js-yaml": "^4.1.1" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "typescript": "^5.8.2", + "vitest": "^3.0.9" + } + }, + "packages/core/node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "packages/transport": { + "name": "@opsxapi/transport", + "version": "0.1.0", + "dependencies": { + "@opsxapi/core": "*", + "mqtt": "^5.10.1", + "socket.io-client": "^4.8.1" + }, + "devDependencies": { + "typescript": "^5.8.2" + }, + "peerDependencies": { + "@tauri-apps/api": "^2.0.0" + }, + "peerDependenciesMeta": { + "@tauri-apps/api": { + "optional": true + } + } + }, + "packages/ui": { + "name": "@opsxapi/ui", + "version": "0.1.0", + "dependencies": { + "@opsxapi/core": "*", + "@opsxapi/transport": "*", + "zustand": "^5.0.3" + }, + "devDependencies": { + "@types/react": "^19.0.10", + "@types/react-dom": "^19.0.4", + "typescript": "^5.8.2" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e236b78 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "opsxapi", + "private": true, + "workspaces": [ + "apps/web", + "packages/*" + ], + "scripts": { + "dev:web": "npm run dev -w @opsxapi/web", + "build:web": "npm run build -w @opsxapi/web", + "test": "npm run test:core", + "test:core": "npm run test -w @opsxapi/core", + "dev:desktop": "npm --prefix apps/desktop run tauri -- dev", + "build:desktop": "npm --prefix apps/desktop run tauri -- build" + }, + "engines": { + "node": ">=20" + }, + "dependencies": { + "js-yaml": "^5.4.2", + "mqtt": "^5.16.0", + "socket.io-client": "^4.8.3" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9" + } +} diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..daa55e2 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,23 @@ +{ + "name": "@opsxapi/core", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "js-yaml": "^4.1.1" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "typescript": "^5.8.2", + "vitest": "^3.0.9" + } +} diff --git a/packages/core/src/exporters/codegen.ts b/packages/core/src/exporters/codegen.ts new file mode 100644 index 0000000..9447434 --- /dev/null +++ b/packages/core/src/exporters/codegen.ts @@ -0,0 +1,184 @@ +/** + * 代码生成:JavaScript fetch / Python requests / Go net/http。 + * 变量保留 {{var}} 原样(用户可自行替换)。 + */ +import type { GraphqlRequestData, HttpRequestData } from "../model"; +import { authHeader, buildUrlWithQuery, exportCurl } from "./curl"; + +export type CodeTarget = "curl" | "fetch" | "python" | "go"; + +export function generateCode( + target: CodeTarget, + data: HttpRequestData | GraphqlRequestData, +): string { + if (data.kind === "graphql") return generateGraphql(target, data); + switch (target) { + case "curl": + return generateCurlHttp(data); + case "fetch": + return generateFetch(data); + case "python": + return generatePython(data); + case "go": + return generateGo(data); + } +} + +/* ---------------- HTTP ---------------- */ + +function commonHeaders(data: HttpRequestData): [string, string][] { + const out: [string, string][] = []; + const auth = authHeader(data); + if (auth) out.push([auth.key, auth.value]); + if (data.auth.mode === "apikey" && data.auth.apiKeyName && data.auth.apiKeyValue && data.auth.apiKeyIn !== "query") { + out.push([data.auth.apiKeyName, data.auth.apiKeyValue]); + } + for (const h of data.headers) { + if (!h.enabled || !h.key) continue; + if (["authorization", "cookie"].includes(h.key.toLowerCase())) continue; + out.push([h.key, h.value]); + } + const cookie = data.cookies.filter((c) => c.enabled && c.key).map((c) => `${c.key}=${c.value}`).join("; "); + if (cookie) out.push(["Cookie", cookie]); + if (data.bodyMode === "json" && !out.some(([k]) => k.toLowerCase() === "content-type")) { + out.push(["Content-Type", "application/json"]); + } + if (data.bodyMode === "urlencoded" && !out.some(([k]) => k.toLowerCase() === "content-type")) { + out.push(["Content-Type", "application/x-www-form-urlencoded"]); + } + return out; +} + +function bodyPayload(data: HttpRequestData): { mode: "raw"; raw: string } | { mode: "urlencoded"; pairs: [string, string][] } | { mode: "form"; pairs: [string, string][] } | null { + if (data.bodyMode === "urlencoded") { + return { mode: "urlencoded", pairs: data.bodyForm.filter((f) => f.enabled && f.key).map((f) => [f.key, f.value]) }; + } + if (data.bodyMode === "form-data") { + return { mode: "form", pairs: data.bodyForm.filter((f) => f.enabled && f.key).map((f) => [f.key, f.value]) }; + } + if (data.bodyMode !== "none" && data.bodyRaw) return { mode: "raw", raw: data.bodyRaw }; + return null; +} + +function generateCurlHttp(data: HttpRequestData): string { + return exportCurl(data); +} + +function generateFetch(data: HttpRequestData): string { + const headers = commonHeaders(data); + const body = bodyPayload(data); + let bodyExpr = ""; + if (body?.mode === "raw") bodyExpr = ` body: ${JSON.stringify(body.raw)},\n`; + else if (body?.mode === "urlencoded") bodyExpr = ` body: new URLSearchParams(${JSON.stringify(Object.fromEntries(body.pairs))}).toString(),\n`; + else if (body?.mode === "form") bodyExpr = ` body: new FormData(${JSON.stringify(Object.fromEntries(body.pairs))} as any),\n`; + + return `const response = await fetch(${JSON.stringify(buildUrlWithQuery(data))}, { + method: ${JSON.stringify(data.method)}, + headers: ${JSON.stringify(Object.fromEntries(headers), null, 2).replace(/\n/g, "\n ")}, +${bodyExpr}}); +if (!response.ok) throw new Error(\`HTTP \${response.status}\`); +const data = await response.json(); +console.log(data); +`; +} + +function generatePython(data: HttpRequestData): string { + const headers = commonHeaders(data); + const body = bodyPayload(data); + let bodyLine = ""; + if (body?.mode === "raw") { + const asJson = data.bodyMode === "json"; + bodyLine = asJson ? `data = json.dumps(${body.raw})` : `data = ${JSON.stringify(body.raw)}`; + } else if (body?.mode === "urlencoded") { + bodyLine = `data = ${JSON.stringify(Object.fromEntries(body.pairs))}`; + } else if (body?.mode === "form") { + bodyLine = `data = ${JSON.stringify(Object.fromEntries(body.pairs))}`; + } + + const needsJson = data.bodyMode === "json"; + return `import requests${needsJson ? "\nimport json" : ""} + +url = ${JSON.stringify(buildUrlWithQuery(data))} +headers = ${JSON.stringify(Object.fromEntries(headers), null, 4).replace(/\n/g, "\n")} +${bodyLine ? bodyLine + "\n" : ""}response = requests.${pyMethod(data.method)}(url, headers=headers${bodyLine ? ", data=data" : ""}) +print(response.status_code) +print(response.text) +`; +} + +function pyMethod(method: string): string { + return method.toLowerCase(); +} + +function generateGo(data: HttpRequestData): string { + const headers = commonHeaders(data); + const body = bodyPayload(data); + const bodyLiteral = body?.mode === "raw" ? body.raw : body?.mode === "urlencoded" || body?.mode === "form" ? new URLSearchParams(body.pairs).toString() : ""; + const payload = bodyLiteral ? JSON.stringify(bodyLiteral) : `""`; + + const headerLines = headers.map(([k, v]) => `\treq.Header.Set(${JSON.stringify(k)}, ${JSON.stringify(v)})`).join("\n"); + + return `package main + +import ( + "fmt" + "io" + "net/http" + "strings" +) + +func main() { + payload := strings.NewReader(${payload}) + req, _ := http.NewRequest(${JSON.stringify(data.method)}, ${JSON.stringify(buildUrlWithQuery(data))}, payload) +${headerLines} + res, err := http.DefaultClient.Do(req) + if err != nil { + panic(err) + } + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + fmt.Println(res.Status) + fmt.Println(string(body)) +} +`; +} + +/* ---------------- GraphQL ---------------- */ + +function generateGraphql(target: CodeTarget, data: GraphqlRequestData): string { + const gqlBody = JSON.stringify({ query: data.query, variables: safeParse(data.variables) }, null, 2); + const httpLike: HttpRequestData = { + kind: "http", + method: "POST", + url: data.url, + query: [], + headers: [...data.headers.filter((h) => h.enabled && h.key).map((h) => ({ ...h })), { id: "ct", key: "Content-Type", value: "application/json", enabled: true }], + cookies: [], + auth: data.auth, + bodyMode: "json", + bodyRaw: gqlBody, + bodyForm: [], + }; + switch (target) { + case "curl": + return generateCurlHttp(httpLike); + case "fetch": { + const code = generateFetch(httpLike); + return `// GraphQL → POST { query, variables }\n${code}`; + } + case "python": { + const code = generatePython(httpLike); + return `# GraphQL → POST { query, variables }\n${code}`; + } + case "go": + return generateGo(httpLike); + } +} + +function safeParse(s: string): unknown { + try { + return JSON.parse(s); + } catch { + return {}; + } +} diff --git a/packages/core/src/exporters/curl.ts b/packages/core/src/exporters/curl.ts new file mode 100644 index 0000000..1fb7bec --- /dev/null +++ b/packages/core/src/exporters/curl.ts @@ -0,0 +1,76 @@ +/** + * cURL 命令导出(保留 {{var}} 变量原样)。 + */ +import type { HttpRequestData } from "../model"; + +export function shellQuote(s: string): string { + if (/^[A-Za-z0-9_\-:/.@%^=+,?&]+$/.test(s) && !s.includes(" ")) return s; + return `'${s.replaceAll("'", "'\\''")}'`; +} + +export function buildUrlWithQuery(data: HttpRequestData): string { + const enabled = data.query.filter((q) => q.enabled && q.key); + if (!enabled.length) return data.url; + const qs = enabled.map((q) => `${encodeURIComponent(q.key)}=${encodeURIComponent(q.value)}`).join("&"); + return data.url.includes("?") ? `${data.url}&${qs}` : `${data.url}?${qs}`; +} + +export function authHeader(data: HttpRequestData): { key: string; value: string } | null { + if (data.auth.mode === "bearer" && data.auth.bearer) { + return { key: "Authorization", value: `Bearer ${data.auth.bearer}` }; + } + if (data.auth.mode === "basic") { + const raw = `${data.auth.basicUser ?? ""}:${data.auth.basicPass ?? ""}`; + try { + return { key: "Authorization", value: `Basic ${btoa(raw)}` }; + } catch { + return null; + } + } + return null; +} + +export function exportCurl(data: HttpRequestData, name?: string): string { + const lines: string[] = []; + const url = buildUrlWithQuery(data); + const parts = [`curl -X ${data.method} ${shellQuote(url)}`]; + + if (data.auth.mode === "apikey" && data.auth.apiKeyName && data.auth.apiKeyValue) { + if (data.auth.apiKeyIn === "query") { + parts[0] = parts[0].replace(shellQuote(url), shellQuote(`${url}${url.includes("?") ? "&" : "?"}${encodeURIComponent(data.auth.apiKeyName)}=${encodeURIComponent(data.auth.apiKeyValue)}`)); + } else { + parts.push(` -H ${shellQuote(`${data.auth.apiKeyName}: ${data.auth.apiKeyValue}`)}`); + } + } + const auth = authHeader(data); + if (auth) parts.push(` -H ${shellQuote(`${auth.key}: ${auth.value}`)}`); + + for (const h of data.headers) { + if (!h.enabled || !h.key) continue; + if (["authorization", "cookie"].includes(h.key.toLowerCase())) continue; + parts.push(` -H ${shellQuote(`${h.key}: ${h.value}`)}`); + } + + const cookie = data.cookies.filter((c) => c.enabled && c.key).map((c) => `${c.key}=${c.value}`).join("; "); + if (cookie) parts.push(` -b ${shellQuote(cookie)}`); + + const hasJsonHeader = data.headers.some((h) => h.enabled && h.key.toLowerCase() === "content-type" && h.value.includes("json")); + + if (data.bodyMode === "urlencoded") { + if (!hasJsonHeader) parts.push(` -H ${shellQuote("Content-Type: application/x-www-form-urlencoded")}`); + const body = data.bodyForm.filter((b) => b.enabled && b.key).map((b) => `${encodeURIComponent(b.key)}=${encodeURIComponent(b.value)}`).join("&"); + if (body) parts.push(` --data-raw ${shellQuote(body)}`); + } else if (data.bodyMode === "form-data") { + if (!hasJsonHeader) parts.push(` -H ${shellQuote("Content-Type: multipart/form-data")}`); + for (const f of data.bodyForm) { + if (f.enabled && f.key) parts.push(` -F ${shellQuote(`${f.key}=${f.value}`)}`); + } + } else if (data.bodyMode !== "none" && data.bodyRaw) { + if (data.bodyMode === "json" && !hasJsonHeader) parts.push(` -H ${shellQuote("Content-Type: application/json")}`); + if (data.bodyMode === "xml" && !hasJsonHeader) parts.push(` -H ${shellQuote("Content-Type: application/xml")}`); + parts.push(` --data-raw ${shellQuote(data.bodyRaw)}`); + } + + lines.push(parts.join(" \\\n")); + return lines.join("\n"); +} diff --git a/packages/core/src/exporters/index.ts b/packages/core/src/exporters/index.ts new file mode 100644 index 0000000..156b647 --- /dev/null +++ b/packages/core/src/exporters/index.ts @@ -0,0 +1,61 @@ +/** + * 导出入口 + 备份序列化。 + */ +import { dump as yamlDump } from "js-yaml"; +import type { Collection, Environment, WorkspaceData } from "../model"; +import { exportOpenApiWithFolders, type OpenApiExportOptions } from "./openapi"; +import { exportPostman } from "./postman"; +import { exportMarkdown } from "./markdown"; + +export type ExportFormat = "openapi-json" | "openapi-yaml" | "postman" | "markdown" | "single-curl" | "backup"; + +export function exportCollectionAs( + format: ExportFormat, + collection: Collection, + options: OpenApiExportOptions = {}, +): string { + switch (format) { + case "openapi-json": + return JSON.stringify(exportOpenApiWithFolders(collection, options), null, 2); + case "openapi-yaml": + return yamlDump(exportOpenApiWithFolders(collection, options), { lineWidth: 120 }); + case "postman": + return JSON.stringify(exportPostman(collection), null, 2); + case "markdown": + return exportMarkdown(collection); + default: + throw new Error(`格式 ${format} 不适用于集合导出`); + } +} + +export function downloadText(filename: string, text: string, mime = "application/octet-stream"): void { + const blob = new Blob([text], { type: mime }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} + +export function exportBackup( + collections: Collection[], + environments: Environment[], +): string { + return JSON.stringify( + { + __opsxapi__: "backup", + version: 2, + exportedAt: new Date().toISOString(), + collections, + environments, + }, + null, + 2, + ); +} + +export { exportOpenApiWithFolders, exportPostman, exportMarkdown }; +export type { OpenApiExportOptions } from "./openapi"; diff --git a/packages/core/src/exporters/markdown.ts b/packages/core/src/exporters/markdown.ts new file mode 100644 index 0000000..cb50d6d --- /dev/null +++ b/packages/core/src/exporters/markdown.ts @@ -0,0 +1,95 @@ +/** + * 集合 → Markdown 接口文档。 + */ +import type { Collection, CollectionNode, HttpRequestData } from "../model"; +import { buildUrlWithQuery } from "./curl"; + +export function exportMarkdown(collection: Collection): string { + const lines: string[] = []; + lines.push(`# ${collection.name}`, ""); + if (collection.description) lines.push(collection.description, ""); + lines.push("> 由 OpsxAPI 导出 · " + new Date().toLocaleString("zh-CN"), ""); + + const toc: string[] = []; + const visitToc = (nodes: CollectionNode[], prefix: string) => { + for (const node of nodes) { + if (node.type === "folder") { + toc.push(`${prefix}- 📁 ${node.name}`); + visitToc(node.children, `${prefix} `); + } else { + toc.push(`${prefix}- [${node.name}](#${anchor(node.name)})`); + } + } + }; + visitToc(collection.children, ""); + if (toc.length) lines.push("## 目录", "", ...toc, ""); + + const visit = (nodes: CollectionNode[], depth: number) => { + for (const node of nodes) { + const hashes = "#".repeat(Math.min(depth + 1, 6)); + if (node.type === "folder") { + lines.push(`${hashes} ${node.name}`, ""); + if (node.description) lines.push(node.description, ""); + visit(node.children, depth + 1); + continue; + } + lines.push(`${hashes} ${node.name}`, ""); + if (node.description) lines.push(node.description, ""); + + if (node.kind === "http") { + const d: HttpRequestData = node.data; + lines.push( + "", + `- **Method**:\`${d.method}\``, + `- **URL**:\`${buildUrlWithQuery(d)}\``, + ); + if (d.auth.mode !== "none") { + lines.push(`- **鉴权**:\`${d.auth.mode}\``); + } + if (d.query.filter((q) => q.enabled && q.key).length) { + lines.push("", "**Query 参数**", "", "| 参数 | 示例值 | 说明 |", "| --- | --- | --- |"); + for (const q of d.query) { + if (!q.enabled || !q.key) continue; + lines.push(`| \`${q.key}\` | \`${escapeTable(q.value)}\` | ${escapeTable(q.description ?? "")} |`); + } + } + if (d.headers.filter((h) => h.enabled && h.key).length) { + lines.push("", "**Headers**", "", "| Header | 示例值 | 说明 |", "| --- | --- | --- |"); + for (const h of d.headers) { + if (!h.enabled || !h.key) continue; + lines.push(`| \`${h.key}\` | \`${escapeTable(h.value)}\` | ${escapeTable(h.description ?? "")} |`); + } + } + if (d.bodyMode !== "none") { + const lang = d.bodyMode === "json" ? "json" : d.bodyMode === "xml" ? "xml" : ""; + if (d.bodyMode === "urlencoded" || d.bodyMode === "form-data") { + lines.push("", `**请求体(${d.bodyMode})**`, "", "| 字段 | 示例值 |", "| --- | --- |"); + for (const f of d.bodyForm) { + if (!f.enabled || !f.key) continue; + lines.push(`| \`${f.key}\` | \`${escapeTable(f.value)}\` |`); + } + } else if (d.bodyRaw) { + lines.push("", `**请求体(${d.bodyMode})**`, "", "```" + lang, d.bodyRaw, "```"); + } + } + lines.push(""); + } else { + const d = node.data as { url?: string; brokerUrl?: string }; + const url = "url" in d ? d.url : d.brokerUrl ?? ""; + lines.push("", `- **协议**:\`${node.kind}\``, `- **地址**:\`${url}\``); + lines.push(""); + } + } + }; + visit(collection.children, 1); + + return lines.join("\n"); +} + +function escapeTable(s: string): string { + return s.replaceAll("|", "\\|").replaceAll("\n", " "); +} + +function anchor(name: string): string { + return name.toLowerCase().replace(/[^\p{L}\p{N}\s-]/gu, "").trim().replace(/\s+/g, "-"); +} diff --git a/packages/core/src/exporters/openapi.ts b/packages/core/src/exporters/openapi.ts new file mode 100644 index 0000000..ceef46e --- /dev/null +++ b/packages/core/src/exporters/openapi.ts @@ -0,0 +1,265 @@ +/** + * 集合 → OpenAPI 3.0.3 导出。 + */ +import type { Collection, CollectionNode, FolderNode, HttpApiNode, HttpRequestData, RequestNode } from "../model"; +import { walk } from "../tree"; + +type Spec = Record; +type Op = Record; + +export type OpenApiExportOptions = { + title?: string; + version?: string; + yaml?: boolean; +}; + +export function exportOpenApi( + collection: Collection, + options: OpenApiExportOptions = {}, +): Spec { + const requests: HttpApiNode[] = []; + const skipped: RequestNode[] = []; + walk(collection.children, (node) => { + if (node.type !== "request") return; + if (node.kind === "http") requests.push(node); + else skipped.push(node); + }); + + // server 推断:{{baseUrl}} 前缀或首个绝对 URL 的 origin + const serverUrl = inferServerUrl(requests.map((r) => r.data.url)); + + const paths: Spec = {}; + const usedPathParams = new Map>(); + const securities = new Set(); + + for (const req of requests) { + const data = req.data; + const path = toOpenApiPath(stripServer(data.url, serverUrl)); + const paramsInPath = [...path.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]); + const method = data.method.toLowerCase(); + if (method === "head" || method === "options") continue; // OpenAPI 常规导出不含这两个 + + const op: Op = { + summary: req.name, + operationId: slugify(req.name), + }; + if (req.description) op.description = req.description; + + const parameters: Spec[] = []; + for (const p of paramsInPath) { + if (!usedPathParams.has(path)) usedPathParams.set(path, new Set()); + usedPathParams.get(path)!.add(p); + parameters.push({ name: p, in: "path", required: true, schema: { type: "string" } }); + } + for (const q of data.query) { + if (!q.enabled || !q.key) continue; + parameters.push({ + name: q.key, + in: "query", + required: false, + schema: { type: "string", example: q.value }, + ...(q.description ? { description: q.description } : {}), + }); + } + for (const h of data.headers) { + if (!h.enabled || !h.key) continue; + if (["authorization", "content-type", "cookie"].includes(h.key.toLowerCase())) continue; + parameters.push({ + name: h.key, + in: "header", + required: false, + schema: { type: "string", example: h.value }, + }); + } + if (parameters.length) op.parameters = parameters; + + // body + if (data.bodyMode !== "none" && method !== "get" && method !== "head") { + op.requestBody = buildRequestBody(data); + } + + // security + if (data.auth.mode === "bearer" && data.auth.bearer) { + securities.add("bearerAuth"); + op.security = [{ bearerAuth: [] }]; + } else if (data.auth.mode === "basic") { + securities.add("basicAuth"); + op.security = [{ basicAuth: [] }]; + } else if (data.auth.mode === "apikey" && data.auth.apiKeyName) { + securities.add("apiKeyAuth"); + op.security = [{ apiKeyAuth: [] }]; + } + + if (!paths[path]) paths[path] = {}; + (paths[path] as Spec)[method] = op; + } + + const components: Spec = {}; + const schemes: Spec = {}; + if (securities.has("bearerAuth")) schemes.bearerAuth = { type: "http", scheme: "bearer", bearerFormat: "JWT" }; + if (securities.has("basicAuth")) schemes.basicAuth = { type: "http", scheme: "basic" }; + if (securities.has("apiKeyAuth")) + schemes.apiKeyAuth = { + type: "apiKey", + name: findApiKeyHeader(requests) ?? "X-API-Key", + in: "header", + }; + if (Object.keys(schemes).length) components.securitySchemes = schemes; + + const spec: Spec = { + openapi: "3.0.3", + info: { + title: options.title ?? collection.name, + version: options.version ?? "1.0.0", + ...(collection.description ? { description: collection.description } : {}), + }, + servers: [{ url: serverUrl }], + paths, + ...(Object.keys(components).length ? { components } : {}), + }; + + if (skipped.length) { + (spec.info as Spec).description = `${str((spec.info as Spec).description)}\n\n> 注:${skipped.length} 个非 HTTP 节点(GraphQL/WS/SSE/Socket.IO/MQTT)不参与 OpenAPI 导出。`.trim(); + } + + return spec; +} + +function str(v: unknown): string { + return typeof v === "string" ? v : ""; +} + +function inferServerUrl(urls: string[]): string { + for (const u of urls) { + if (u.includes("{{baseUrl}}")) return "http://localhost"; + } + for (const u of urls) { + try { + return new URL(u).origin; + } catch { + /* next */ + } + } + return "http://localhost"; +} + +function stripServer(url: string, serverUrl: string): string { + let u = url; + if (u.includes("{{baseUrl}}")) { + u = u.split("{{baseUrl}}")[1] || "/"; + } else if (serverUrl && u.startsWith(serverUrl)) { + u = u.slice(serverUrl.length) || "/"; + } + if (!u.startsWith("/")) u = `/${u}`; + return u.split("?")[0]; +} + +function toOpenApiPath(path: string): string { + // {{var}} → {var} + return path.replace(/\{\{([^}]+)\}\}/g, "{$1}"); +} + +function buildRequestBody(data: HttpRequestData): Spec { + if (data.bodyMode === "urlencoded") { + const props: Spec = {}; + for (const f of data.bodyForm) { + if (f.key) props[f.key] = { type: "string", example: f.value }; + } + return { + required: true, + content: { + "application/x-www-form-urlencoded": { + schema: { type: "object", properties: props }, + }, + }, + }; + } + if (data.bodyMode === "form-data") { + const props: Spec = {}; + for (const f of data.bodyForm) { + if (f.key) props[f.key] = { type: "string", example: f.value }; + } + return { + required: true, + content: { + "multipart/form-data": { + schema: { type: "object", properties: props }, + }, + }, + }; + } + const mime = + data.bodyMode === "json" + ? "application/json" + : data.bodyMode === "xml" + ? "application/xml" + : data.bodyMode === "html" + ? "text/html" + : "text/plain"; + let example: unknown = data.bodyRaw; + if (data.bodyMode === "json") { + try { + example = JSON.parse(data.bodyRaw); + } catch { + /* keep raw */ + } + } + return { + required: true, + content: { [mime]: { example } }, + }; +} + +function findApiKeyHeader(requests: HttpApiNode[]): string | null { + for (const r of requests) { + if (r.data.auth.mode === "apikey" && r.data.auth.apiKeyName) return r.data.auth.apiKeyName; + } + return null; +} + +function slugify(name: string): string { + const cleaned = name + .replace(/[^\p{L}\p{N}\s-]/gu, "") + .trim() + .replace(/\s+/g, "_"); + return cleaned || "operation"; +} + +/** 导出包含 tag 分组(目录名 = tag)的 spec */ +export function exportOpenApiWithFolders( + collection: Collection, + options: OpenApiExportOptions = {}, +): Spec { + const spec = exportOpenApi(collection, options); + // 目录名 → tag:重新走一遍树,为每个请求打 tag + const paths = spec.paths as Spec; + const tags: { name: string }[] = []; + const tagByPath = new Map(); + + const visit = (nodes: CollectionNode[], tag: string | null) => { + for (const node of nodes) { + if (node.type === "folder") { + visit(node.children, node.name); + } else if (node.kind === "http" && tag) { + const path = toOpenApiPath(stripServer(node.data.url, "http://localhost")); + tagByPath.set(`${path}:${node.data.method.toLowerCase()}`, tag); + } + } + }; + visit(collection.children, null); + for (const t of tagByPath.values()) { + if (!tags.some((x) => x.name === t)) tags.push({ name: t }); + } + if (tags.length) { + spec.tags = tags; + for (const [path, methods] of Object.entries(paths)) { + for (const [method, opRaw] of Object.entries(methods as Spec)) { + const tag = tagByPath.get(`${path}:${method}`); + if (tag) (opRaw as Op).tags = [tag]; + } + } + } + return spec; +} + +export type { FolderNode }; diff --git a/packages/core/src/exporters/postman.ts b/packages/core/src/exporters/postman.ts new file mode 100644 index 0000000..eab4b45 --- /dev/null +++ b/packages/core/src/exporters/postman.ts @@ -0,0 +1,146 @@ +/** + * 集合 → Postman Collection v2.1 导出。 + */ +import type { Collection, CollectionNode, GraphqlRequestData, HttpRequestData, RequestNode } from "../model"; + +type Obj = Record; + +export function exportPostman(collection: Collection): Obj { + return { + info: { + name: collection.name, + _postman_id: `opsxapi-${collection.id}`, + description: collection.description || undefined, + schema: "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + }, + item: convertNodes(collection.children), + }; +} + +function convertNodes(nodes: CollectionNode[]): Obj[] { + const out: Obj[] = []; + for (const node of nodes) { + if (node.type === "folder") { + out.push({ + name: node.name, + description: node.description || undefined, + item: convertNodes(node.children), + }); + } else if (node.kind === "http" || node.kind === "graphql") { + out.push(convertRequest(node)); + } + } + return out; +} + +function convertRequest(node: RequestNode): Obj { + const data = node.data as HttpRequestData | GraphqlRequestData; + + const header = data.headers + .filter((h) => h.enabled && h.key) + .map((h) => ({ key: h.key, value: h.value, type: "text" })); + + const authObj = postmanAuth(data.auth); + + if (data.kind === "graphql") { + return { + name: node.name, + request: { + method: "POST", + header: [{ key: "Content-Type", value: "application/json", type: "text" }, ...header], + body: { + mode: "graphql", + graphql: { query: data.query, variables: data.variables }, + }, + url: { raw: data.url, host: splitHost(data.url) }, + ...(authObj ? { auth: authObj } : {}), + ...(node.description ? { description: node.description } : {}), + }, + }; + } + + const query = data.query + .filter((q) => q.enabled && q.key) + .map((q) => ({ key: q.key, value: q.value, disabled: false })); + const raw = buildRaw(data); + + let body: Obj | undefined; + if (data.bodyMode === "json" || data.bodyMode === "xml" || data.bodyMode === "text" || data.bodyMode === "html") { + body = { + mode: "raw", + raw: data.bodyRaw, + options: { raw: { language: data.bodyMode === "json" ? "json" : data.bodyMode === "xml" ? "xml" : "text" } }, + }; + } else if (data.bodyMode === "urlencoded") { + body = { + mode: "urlencoded", + urlencoded: data.bodyForm.filter((f) => f.key).map((f) => ({ key: f.key, value: f.value, disabled: !f.enabled })), + }; + } else if (data.bodyMode === "form-data") { + body = { + mode: "formdata", + formdata: data.bodyForm.filter((f) => f.key).map((f) => ({ key: f.key, value: f.value, type: "text", disabled: !f.enabled })), + }; + } + + const cookie = data.cookies.filter((c) => c.enabled && c.key).map((c) => `${c.key}=${c.value}`).join("; "); + + return { + name: node.name, + request: { + method: data.method, + header: header.length ? header : undefined, + body, + ...(cookie ? { header: [...(header ?? []), { key: "Cookie", value: cookie, type: "text" }] } : {}), + url: { + raw, + host: splitHost(raw), + query: query.length ? query : undefined, + }, + ...(authObj ? { auth: authObj } : {}), + ...(node.description ? { description: node.description } : {}), + }, + }; +} + +function buildRaw(data: HttpRequestData): string { + const enabled = data.query.filter((q) => q.enabled && q.key); + if (!enabled.length) return data.url; + const qs = enabled.map((q) => `${encodeURIComponent(q.key)}=${encodeURIComponent(q.value)}`).join("&"); + return data.url.includes("?") ? `${data.url}&${qs}` : `${data.url}?${qs}`; +} + +function splitHost(url: string): string[] { + try { + const u = new URL(url.includes("://") ? url : `http://${url}`); + return [u.host]; + } catch { + return [url]; + } +} + +function postmanAuth(auth: HttpRequestData["auth"]): Obj | undefined { + if (auth.mode === "bearer" && auth.bearer) { + return { type: "bearer", bearer: [{ key: "token", value: auth.bearer, type: "string" }] }; + } + if (auth.mode === "basic") { + return { + type: "basic", + basic: [ + { key: "username", value: auth.basicUser ?? "", type: "string" }, + { key: "password", value: auth.basicPass ?? "", type: "string" }, + ], + }; + } + if (auth.mode === "apikey" && auth.apiKeyName) { + return { + type: "apikey", + apikey: [ + { key: "key", value: auth.apiKeyName, type: "string" }, + { key: "value", value: auth.apiKeyValue ?? "", type: "string" }, + { key: "in", value: auth.apiKeyIn ?? "header", type: "string" }, + ], + }; + } + return undefined; +} diff --git a/packages/core/src/factory.ts b/packages/core/src/factory.ts new file mode 100644 index 0000000..fdf81b8 --- /dev/null +++ b/packages/core/src/factory.ts @@ -0,0 +1,267 @@ +/** + * 节点 / 集合 / 环境的默认工厂。 + */ +import type { + AuthConfig, + Collection, + CollectionNode, + Environment, + FolderNode, + GraphqlRequestData, + HttpRequestData, + KVRow, + MqttRequestData, + RequestKind, + RequestNode, + RequestDataOf, + SocketIoRequestData, + SseRequestData, + WebsocketRequestData, + WorkspaceData, +} from "./model"; +import { newId } from "./tree"; + +export function kv( + key = "", + value = "", + enabled = true, + description?: string, +): KVRow { + return { id: newId(), key, value, enabled, description }; +} + +export const noAuth: AuthConfig = { mode: "none" }; + +export function defaultRequestData(kind: RequestKind) { + switch (kind) { + case "http": + return { + kind, + method: "GET", + url: "{{baseUrl}}/", + query: [], + headers: [], + cookies: [], + auth: { ...noAuth }, + bodyMode: "none", + bodyRaw: "", + bodyForm: [], + } satisfies HttpRequestData; + case "graphql": + return { + kind, + url: "{{baseUrl}}/graphql", + headers: [kv("Content-Type", "application/json")], + auth: { ...noAuth }, + query: "query {\n \n}", + variables: "{}", + } satisfies GraphqlRequestData; + case "websocket": + return { + kind, + url: "wss://echo.websocket.events", + headers: [], + protocols: "", + } satisfies WebsocketRequestData; + case "sse": + return { + kind, + url: "{{baseUrl}}/events", + headers: [kv("Accept", "text/event-stream")], + query: [], + auth: { ...noAuth }, + lastEventId: "", + dataOnly: true, + } satisfies SseRequestData; + case "socketio": + return { + kind, + url: "http://127.0.0.1:3000", + options: '{\n "path": "/socket.io",\n "transports": ["websocket"]\n}', + listenEvents: ["message"], + sendEvent: "message", + sendArgs: '[\n "hello"\n]', + } satisfies SocketIoRequestData; + case "mqtt": + return { + kind, + brokerUrl: "wss://broker.emqx.io:8084/mqtt", + username: "", + password: "", + clientId: `opsxapi-${Math.random().toString(16).slice(2, 10)}`, + subscribeTopics: "opsxapi/demo", + publishTopic: "opsxapi/demo", + publishPayload: '{\n "hello": "opsxapi"\n}', + qos: 0, + } satisfies MqttRequestData; + } +} + +export function createRequest( + kind: K, + name?: string, + partial?: { data?: Partial>; description?: string }, +): Extract { + const data = { + ...defaultRequestData(kind), + ...(partial?.data ?? {}), + kind, + } as RequestDataOf; + const base = { + id: newId(), + type: "request" as const, + name: name ?? defaultName(kind), + description: partial?.description, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + return { ...base, kind, data } as unknown as Extract; +} + +export function defaultName(kind: RequestKind): string { + switch (kind) { + case "http": + return "新建 HTTP 接口"; + case "graphql": + return "新建 GraphQL 查询"; + case "websocket": + return "新建 WebSocket"; + case "sse": + return "新建 SSE"; + case "socketio": + return "新建 Socket.IO"; + case "mqtt": + return "新建 MQTT"; + } +} + +export function createFolder(name = "新建目录", children: CollectionNode[] = []): FolderNode { + return { + id: newId(), + type: "folder", + name, + children, + createdAt: Date.now(), + updatedAt: Date.now(), + }; +} + +export function createCollection( + name: string, + children: CollectionNode[] = [], +): Collection { + return { + id: newId(), + type: "collection", + name, + children, + createdAt: Date.now(), + updatedAt: Date.now(), + }; +} + +export function createEnvironment( + name: string, + vars: { key: string; value: string }[] = [], +): Environment { + return { id: newId(), name, variables: vars.map((v) => kv(v.key, v.value)) }; +} + +/** 首次启动的演示集合:覆盖全部协议,开箱即用 */ +export function createSeedWorkspace(): WorkspaceData { + const http = ( + name: string, + method: HttpRequestData["method"], + url: string, + extra?: Partial, + description?: string, + ): RequestNode => + createRequest("http", name, { + data: { method, url, ...extra }, + description, + }); + + const demo = createCollection("示例集合", [ + createFolder("HTTP · httpbin", [ + http("获取请求回显", "GET", "https://httpbin.org/get", undefined, "经典回显接口,返回请求头 / IP / 参数。"), + http( + "提交 JSON", + "POST", + "https://httpbin.org/post", + { + bodyMode: "json", + bodyRaw: '{\n "name": "opsxapi",\n "lang": "zh-CN"\n}', + headers: [kv("Content-Type", "application/json")], + }, + "POST 一个 JSON 体,观察 Pretty 视图。", + ), + http( + "表单提交", + "POST", + "https://httpbin.org/post", + { + bodyMode: "urlencoded", + bodyForm: [kv("username", "logan"), kv("role", "ops")], + }, + ), + http( + "Bearer 鉴权示例", + "GET", + "https://httpbin.org/bearer", + { + auth: { mode: "bearer", bearer: "{{token}}" }, + }, + "在 Auth 标签填 Token;环境变量 token 默认 demo-token。", + ), + http("状态码 · 404", "GET", "https://httpbin.org/status/404"), + ]), + createFolder("GraphQL", [ + createRequest("graphql", "国家查询 · countries", { + data: { + url: "https://countries.trevorblades.com/graphql", + query: + "query ($code: ID!) {\n country(code: $code) {\n name\n capital\n currency\n }\n}", + variables: '{\n "code": "CN"\n}', + }, + description: "公共 GraphQL 服务,无需鉴权。", + }), + ]), + createFolder("实时协议", [ + createRequest("websocket", "WebSocket · echo", { + data: { url: "wss://echo.websocket.events" }, + description: "发送什么就回什么。", + }), + createRequest("sse", "SSE · 维基媒体变更流", { + data: { + url: "https://stream.wikimedia.org/v2/stream/recentchange", + headers: [kv("Accept", "text/event-stream")], + }, + description: "维基百科实时编辑事件流。", + }), + createRequest("mqtt", "MQTT · EMQX 公共 Broker", { + data: { + subscribeTopics: "opsxapi/demo", + publishTopic: "opsxapi/demo", + }, + description: "wss 公共 broker;两个窗口同时打开可互相收发。", + }), + createRequest("socketio", "Socket.IO · 本地服务", { + description: "连你的本地 socket.io 服务,或改 URL 到任意 demo 站点。", + }), + ]), + ]); + + const envs = [ + createEnvironment("正式", [{ key: "baseUrl", value: "https://httpbin.org" }, { key: "token", value: "demo-token" }]), + createEnvironment("本地", [{ key: "baseUrl", value: "http://127.0.0.1:8787" }, { key: "token", value: "dev-token" }]), + ]; + + return { + version: 2, + collections: [demo], + environments: envs, + activeEnvironmentId: envs[0]?.id ?? null, + history: [], + settings: { theme: "dark", timeoutMs: 30000 }, + }; +} diff --git a/packages/core/src/importers.test.ts b/packages/core/src/importers.test.ts new file mode 100644 index 0000000..8a3b601 --- /dev/null +++ b/packages/core/src/importers.test.ts @@ -0,0 +1,451 @@ +import { describe, expect, it } from "vitest"; +import { + detectImportFormat, + importAuto, + importCurl, + importOpenApi, + parseCurl, +} from "./importers"; +import { importPostman } from "./importers/postman"; +import { importHar } from "./importers/har"; +import { importInsomnia } from "./importers/insomnia"; +import { importHoppscotch } from "./importers/hoppscotch"; +import { importApipost } from "./importers/apipost"; +import { importMarkdown } from "./importers/markdown"; +import { findNode, insertIntoCollection, moveNode, removeNode } from "./tree"; +import { createSeedWorkspace } from "./factory"; +import { exportCollectionAs, exportBackup } from "./exporters"; +import { exportCurl } from "./exporters/curl"; + +const openapi3 = JSON.stringify({ + openapi: "3.0.3", + info: { title: "Petstore", version: "1.0.0" }, + servers: [{ url: "https://petstore.example.com/v1" }], + tags: [{ name: "pets" }], + paths: { + "/pets": { + get: { + tags: ["pets"], + summary: "列出宠物", + operationId: "listPets", + parameters: [ + { name: "limit", in: "query", schema: { type: "integer" }, example: 10 }, + { name: "X-Trace", in: "header", schema: { type: "string" }, example: "abc" }, + ], + responses: {}, + }, + post: { + tags: ["pets"], + summary: "新增宠物", + operationId: "createPet", + security: [{ petstoreAuth: [] }], + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { name: { type: "string" }, age: { type: "integer" } }, + }, + }, + }, + }, + responses: {}, + }, + }, + "/pets/{petId}": { + get: { + summary: "查询单个宠物", + parameters: [{ name: "petId", in: "path", required: true, schema: { type: "string" }, example: "42" }], + responses: {}, + }, + }, + }, + components: { + securitySchemes: { petstoreAuth: { type: "http", scheme: "bearer" } }, + }, +}); + +const openapi2 = ` +swagger: "2.0" +info: + title: Old API + version: "1.0" +host: old.example.com +basePath: /api +schemes: [https] +paths: + /users: + get: + summary: 用户列表 + parameters: + - name: page + in: query + type: integer + example: 1 + responses: + 200: + description: ok + post: + summary: 创建用户 + consumes: [application/json] + parameters: + - name: body + in: body + schema: + type: object + properties: + username: + type: string + responses: + 201: + description: created +`; + +const postmanV21 = JSON.stringify({ + info: { name: "Demo", schema: "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, + item: [ + { + name: "目录A", + item: [ + { + name: "获取用户", + request: { + method: "GET", + url: { raw: "https://api.example.com/users?page=2", query: [{ key: "page", value: "2" }] }, + header: [{ key: "Accept", value: "application/json" }], + }, + }, + { + name: "登录", + request: { + method: "POST", + url: { raw: "{{baseUrl}}/login" }, + header: [{ key: "Content-Type", value: "application/json" }], + body: { mode: "raw", raw: "{\n \"user\": \"a\",\n \"pass\": \"b\"\n}", options: { raw: { language: "json" } } }, + auth: { type: "bearer", bearer: [{ key: "token", value: "xyz" }] }, + }, + }, + ], + }, + ], + variable: [{ key: "baseUrl", value: "https://api.example.com" }], +}); + +const harDoc = JSON.stringify({ + log: { + entries: [ + { + request: { + method: "GET", + url: "https://api.example.com/items?size=5", + headers: [{ name: "Accept", value: "application/json" }], + queryString: [{ name: "size", value: "5" }], + }, + }, + { + request: { + method: "POST", + url: "https://api.example.com/items", + headers: [{ name: "Content-Type", value: "application/json" }], + postData: { mimeType: "application/json", text: '{"a":1}' }, + }, + }, + ], + }, +}); + +const insomniaDoc = JSON.stringify({ + _type: "export", + __export_format: 4, + resources: [ + { _id: "wrk_1", _type: "workspace", name: "我的服务" }, + { _id: "fld_1", _type: "request_group", parentId: "wrk_1", name: "用户" }, + { + _id: "req_1", + _type: "request", + parentId: "fld_1", + name: "资料", + method: "GET", + url: "https://api.example.com/me", + headers: [{ name: "Authorization", value: "Bearer t" }], + parameters: [], + authentication: { type: "bearer", token: "t" }, + body: {}, + }, + ], +}); + +const hoppscotchDoc = JSON.stringify([ + { + name: "SC", + folders: [{ name: "子", requests: [] }], + requests: [ + { + name: "列表", + method: "GET", + url: "https://api.example.com/list", + headers: [{ key: "Accept", value: "application/json", active: true }], + params: [], + bodyMode: "none", + }, + ], + }, +]); + +const apipostDoc = JSON.stringify({ + name: "Apipost 项目", + api_collection: [ + { target_id: "f1", parent_id: "0", name: "订单", children: [] }, + { + target_id: "r1", + parent_id: "f1", + name: "下单", + method: "POST", + url: "https://api.example.com/orders", + request: { + headers: { "Content-Type": "application/json" }, + body: { mode: "json", raw: '{"sku":1}' }, + }, + }, + ], +}); + +const curlCmd = `curl -X POST 'https://api.example.com/v1/users?dry=1' \\ + -H 'Content-Type: application/json' \\ + -H 'Authorization: Bearer tok' \\ + -d '{"name":"logan","tags":["a","b"]}'`; + +describe("格式识别", () => { + it("识别各格式", () => { + expect(detectImportFormat(openapi3)).toBe("openapi"); + expect(detectImportFormat(openapi2)).toBe("openapi"); + expect(detectImportFormat(postmanV21)).toBe("postman"); + expect(detectImportFormat(harDoc)).toBe("har"); + expect(detectImportFormat(insomniaDoc)).toBe("insomnia"); + expect(detectImportFormat(hoppscotchDoc)).toBe("hoppscotch"); + expect(detectImportFormat(apipostDoc)).toBe("apipost"); + expect(detectImportFormat(curlCmd)).toBe("curl"); + expect(detectImportFormat("# 文档\n\n```curl\ncurl http://a.com\n```")).toBe("markdown"); + }); +}); + +describe("cURL", () => { + it("解析方法/URL/头/体", () => { + const parsed = parseCurl(curlCmd); + expect(parsed.method).toBe("POST"); + expect(parsed.url).toBe("https://api.example.com/v1/users"); + expect(parsed.query).toEqual([{ key: "dry", value: "1", enabled: true }]); + expect(parsed.bodyMode).toBe("json"); + expect(JSON.parse(parsed.bodyRaw)).toEqual({ name: "logan", tags: ["a", "b"] }); + }); + + it("表单与 basic auth", () => { + const parsed = parseCurl("curl -u tom:pw -F file=@x.txt -F name=x https://a.io/up"); + expect(parsed.auth.mode).toBe("basic"); + expect(parsed.auth.basicUser).toBe("tom"); + expect(parsed.bodyMode).toBe("form-data"); + expect(parsed.bodyForm.some((f) => f.key === "name" && f.value === "x")).toBe(true); + }); + + it("urlencoded 推断", () => { + const parsed = parseCurl(`curl -d 'a=1&b=2' http://x.io/f`); + expect(parsed.method).toBe("POST"); + expect(parsed.bodyMode).toBe("urlencoded"); + expect(parsed.bodyForm.length).toBe(2); + }); + + it("导入为集合", () => { + const c = importCurl(curlCmd); + expect(c.children.length).toBe(1); + expect(c.children[0].type).toBe("request"); + }); +}); + +describe("OpenAPI", () => { + it("导入 v3(tag 目录、query/header、bearer、path 参数)", () => { + const result = importOpenApi(openapi3); + const c = result.collections[0]; + expect(c.name).toBe("Petstore"); + expect(c.children.length).toBe(2); // pets 目录 + 根级 /pets/{petId} + const folder = c.children[0]; + expect(folder.type).toBe("folder"); + if (folder.type !== "folder") return; + const list = folder.children[0]; + if (list.type !== "request" || list.kind !== "http") return; + expect(list.data.method).toBe("GET"); + expect(list.data.url).toBe("{{baseUrl}}/pets"); + expect(result.environmentVariables.find((v) => v.key === "baseUrl")?.value).toBe( + "https://petstore.example.com/v1", + ); + expect(list.data.query[0].key).toBe("limit"); + expect(list.data.query[0].value).toBe("10"); + expect(list.data.headers.some((h) => h.key === "X-Trace")).toBe(true); + + const single = c.children[1]; + if (single.type !== "request" || single.kind !== "http") return; + expect(single.data.url).toContain("{{petId}}"); + expect(result.environmentVariables.find((v) => v.key === "petId")?.value).toBe("42"); + }); + + it("导入 v2 YAML(basePath、body schema)", () => { + const result = importOpenApi(openapi2); + const c = result.collections[0]; + expect(result.environmentVariables.find((v) => v.key === "baseUrl")?.value).toBe("https://old.example.com/api"); + const post = c.children.find((n) => n.type === "request" && n.kind === "http" && n.data.method === "POST"); + expect(post).toBeTruthy(); + if (post && post.type === "request" && post.kind === "http") { + expect(post.data.bodyMode).toBe("json"); + expect(post.data.bodyRaw).toContain("username"); + } + }); +}); + +describe("Postman", () => { + it("导入 v2.1(目录/鉴权/变量)", () => { + const result = importPostman(postmanV21); + const c = result.collections[0]; + expect(c.name).toBe("Demo"); + const folder = c.children[0]; + if (folder.type !== "folder") throw new Error("应当是目录"); + const login = folder.children[1]; + if (login.type !== "request" || login.kind !== "http") throw new Error("应当是请求"); + expect(login.data.auth.mode).toBe("bearer"); + expect(login.data.bodyMode).toBe("json"); + expect(result.environmentVariables.find((v) => v.key === "baseUrl")?.value).toBe("https://api.example.com"); + const getUser = folder.children[0]; + if (getUser.type !== "request" || getUser.kind !== "http") throw new Error("应当是请求"); + expect(getUser.data.query[0].key).toBe("page"); + }); +}); + +describe("HAR", () => { + it("按域名分组导入", () => { + const result = importHar(harDoc); + const c = result.collections[0]; + expect(c.children.length).toBe(1); + const hostFolder = c.children[0]; + if (hostFolder.type !== "folder") throw new Error("应当是目录"); + expect(hostFolder.name).toBe("api.example.com"); + expect(hostFolder.children.length).toBe(2); + }); +}); + +describe("Insomnia", () => { + it("导入 workspace/group/request 层级", () => { + const result = importInsomnia(insomniaDoc); + const c = result.collections[0]; + expect(c.name).toBe("我的服务"); + const folder = c.children[0]; + if (folder.type !== "folder") throw new Error("应当是目录"); + expect(folder.name).toBe("用户"); + const req = folder.children[0]; + if (req.type !== "request" || req.kind !== "http") throw new Error("应当是请求"); + expect(req.data.url).toBe("https://api.example.com/me"); + expect(req.data.auth.mode).toBe("bearer"); + }); +}); + +describe("Hoppscotch", () => { + it("导入集合数组", () => { + const result = importHoppscotch(hoppscotchDoc); + const c = result.collections[0]; + expect(c.name).toBe("SC"); + expect(c.children.length).toBe(2); // 子目录 + 列表 + }); +}); + +describe("Apipost", () => { + it("导入 api_collection(目录 + 请求)", () => { + const result = importApipost(apipostDoc); + const c = result.collections[0]; + expect(c.name).toBe("Apipost 项目"); + const folder = c.children[0]; + if (folder.type !== "folder") throw new Error("应当是目录"); + expect(folder.name).toBe("订单"); + const req = folder.children[0]; + if (req.type !== "request" || req.kind !== "http") throw new Error("应当是请求"); + expect(req.data.method).toBe("POST"); + expect(req.data.bodyMode).toBe("json"); + }); +}); + +describe("Markdown", () => { + it("提取 curl 代码块", () => { + const md = `# 用户文档 + +## 登录 + +\`\`\`curl +curl -X POST https://a.io/login -d '{"u":1}' +\`\`\` +`; + const result = importMarkdown(md); + const c = result.collections[0]; + expect(c.name).toBe("用户文档"); + expect(c.children.length).toBe(1); + }); +}); + +describe("树操作", () => { + it("插入/移动/删除/查找", () => { + const ws = createSeedWorkspace(); + const collections = ws.collections; + const demo = collections[0]; + const folder = demo.children[0]; + if (folder.type !== "folder") throw new Error("seed 第一个子节点应为目录"); + const req = folder.children[0]; + const found = findNode(collections, req.id); + expect(found?.node.id).toBe(req.id); + + // 移动到顶层 + const moved = moveNode(collections, req.id, demo.id, null); + const movedRef = findNode(moved, req.id); + expect(movedRef?.parent).toBeNull(); + + // 删除 + const removed = removeNode(moved, req.id); + expect(findNode(removed, req.id)).toBeNull(); + + // 插入顶层 + const newNode = { ...req, id: "new-1" }; + const inserted = insertIntoCollection(removed, demo.id, null, newNode); + expect(findNode(inserted, "new-1")).toBeTruthy(); + }); +}); + +describe("导出", () => { + it("OpenAPI / Postman / Markdown 往返", () => { + const imported = importAuto(openapi3); + const c = imported.collections[0]; + const openapiJson = exportCollectionAs("openapi-json", c); + const reparsed = JSON.parse(openapiJson); + expect(reparsed.openapi).toBe("3.0.3"); + expect(reparsed.paths["/pets"].get).toBeTruthy(); + + const postmanJson = exportCollectionAs("postman", c); + const reparsedPostman = JSON.parse(postmanJson); + expect(reparsedPostman.info.schema).toContain("v2.1.0"); + + const md = exportCollectionAs("markdown", c); + expect(md).toContain("# Petstore"); + expect(md).toContain("GET"); + }); + + it("cURL 导出", () => { + const c = importCurl(curlCmd); + const req = c.children[0]; + if (req.type !== "request" || req.kind !== "http") throw new Error("应当是请求"); + const exported = exportCurl(req.data); + expect(exported).toContain("-X POST"); + expect(exported).toContain("--data-raw"); + expect(exported).toContain("api.example.com/v1/users"); + }); + + it("备份导出", () => { + const ws = createSeedWorkspace(); + const backup = exportBackup(ws.collections, ws.environments); + const parsed = JSON.parse(backup); + expect(parsed.collections.length).toBeGreaterThan(0); + expect(detectImportFormat(backup)).toBe("backup"); + }); +}); diff --git a/packages/core/src/importers/apipost.ts b/packages/core/src/importers/apipost.ts new file mode 100644 index 0000000..70adf2b --- /dev/null +++ b/packages/core/src/importers/apipost.ts @@ -0,0 +1,209 @@ +/** + * Apipost 项目导出文件导入器(宽容解析:api_collection / parent_id / children 均支持)。 + * Apidoc / Apizza / Eolink / YApi 建议先导出为 OpenAPI 或 Postman 格式再导入。 + */ +import type { Collection, CollectionNode, FolderNode, HttpRequestData } from "../model"; +import { createCollection, createFolder, createRequest, kv } from "../factory"; +import type { ImportResult } from "../model"; +import { normalizeMethod } from "./curl"; + +type Obj = Record; +const asObj = (v: unknown): Obj => (v && typeof v === "object" ? (v as Obj) : {}); +const asArr = (v: unknown): unknown[] => (Array.isArray(v) ? v : []); +const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? "" : String(v)); + +export function isApipostExport(doc: Obj): boolean { + if (Array.isArray(doc.api_collection)) return true; + if (Array.isArray(asObj(doc.data).api_collection)) return true; + // apipost v7 导出:{"name":..., "api_collection":[...]} 或数组包裹 + if (Array.isArray(doc.item_list)) return true; + return false; +} + +export function importApipost(text: string): ImportResult { + const doc = asObj(JSON.parse(text)); + if (!isApipostExport(doc)) throw new Error("不是 Apipost 导出文件(缺少 api_collection)"); + + const warnings: string[] = []; + const rawItems = asArr(doc.api_collection ?? asObj(doc.data).api_collection ?? doc.item_list); + const items = rawItems.map(asObj); + + // 取出文件夹(target_type/name+children 或 parent_id 结构) + const requestItems = items.filter((it) => !isFolderItem(it)); + const folderItems = items.filter(isFolderItem); + + const byParent = new Map(); + const parentKeyOf = (it: Obj): string => { + const parent = str(it.parent_id) || str(it.parentId); + if (!parent || parent === "0" || parent === "null" || parent === "root") return "__root__"; + return parent; + }; + for (const it of items) { + const parent = parentKeyOf(it); + if (!byParent.has(parent)) byParent.set(parent, []); + byParent.get(parent)!.push(it); + } + + const envVars: { key: string; value: string }[] = []; + + const build = (parentKey: string): CollectionNode[] => { + const out: CollectionNode[] = []; + for (const it of byParent.get(parentKey) ?? []) { + if (isFolderItem(it)) { + const name = str(it.name) || str(it.title) || "目录"; + const folder: FolderNode = createFolder(name, build(str(it.target_id) || str(it.id) || str(it._id) || name)); + out.push(folder); + } else { + const node = convertRequest(it, envVars, warnings); + if (node) out.push(node); + } + } + return out; + }; + + const children = build("__root__"); + // parent_id 断链的请求兜底平铺 + const placed = new Set(); + for (const it of folderItems) placed.add(str(it.target_id) || str(it.id)); + for (const it of requestItems) { + const parent = parentKeyOf(it); + if (parent !== "__root__" && !placed.has(parent)) { + const node = convertRequest(it, envVars, warnings); + if (node) children.push(node); + } + } + + const collection = createCollection(str(doc.name) || str(asObj(doc.data).name) || "Apipost 导入", children); + let count = countNodes(children); + if (count === 0) warnings.push("没有解析到请求;若是新版导出格式,请改用 OpenAPI/Postman 格式导出后导入"); + + // apipost 环境 + for (const env of asArr(doc.env ?? asObj(doc.data).env)) { + const envObj = asObj(env); + for (const v of asArr(envObj.param ?? envObj.variable)) { + const vObj = asObj(v); + const key = str(vObj.key); + if (key) envVars.push({ key, value: str(vObj.value) }); + } + } + + return { collections: [collection], environmentVariables: envVars, warnings }; +} + +function isFolderItem(it: Obj): boolean { + const hasChildren = Array.isArray(it.children) || Array.isArray(it.item); + const typeMark = str(it.target_type ?? it.type ?? it.item_type); + return hasChildren || typeMark === "folder" || typeMark === "group" || typeMark === "目录"; +} + +function convertRequest( + it: Obj, + _envVars: { key: string; value: string }[], + warnings: string[], +): CollectionNode | null { + const name = str(it.name) || str(it.title) || "未命名请求"; + const method = normalizeMethod(str(it.method) || "GET"); + const request = asObj(it.request); + + // url 可能是字符串或对象 + let url = ""; + if (typeof request.url === "string") url = request.url; + else { + url = str(asObj(request.url).raw ?? asObj(it.url).raw ?? it.url ?? ""); + } + + const headers: HttpRequestData["headers"] = []; + const query: HttpRequestData["query"] = []; + + // params/headers 可能是数组或对象 + const paramContainer = request.parameter ?? request.params ?? it.parameter; + collectKV(paramContainer, query); + const headerContainer = request.headers ?? request.header ?? it.headers; + collectKV(headerContainer, headers); + + const body = asObj(request.body ?? it.request_body); + const mode = str(body.mode ?? body.type ?? "none"); + let bodyMode: HttpRequestData["bodyMode"] = "none"; + let bodyRaw = ""; + let bodyForm: HttpRequestData["bodyForm"] = []; + if (mode === "json" || mode === "raw") { + bodyRaw = str(body.raw ?? body.text ?? ""); + bodyMode = looksLikeJson(bodyRaw) || mode === "json" ? "json" : "text"; + if (bodyMode === "json") bodyRaw = formatJson(bodyRaw); + } else if (mode === "urlencoded" || mode === "urlencoded[]") { + bodyMode = "urlencoded"; + collectKV(body.parameter ?? body.params, bodyForm); + } else if (mode === "form-data" || mode === "formdata") { + bodyMode = "form-data"; + collectKV(body.parameter ?? body.params, bodyForm); + } else if (mode === "xml") { + bodyMode = "xml"; + bodyRaw = str(body.raw ?? ""); + } else if (mode === "binary" || mode === "file") { + warnings.push(`${name}:binary body 不支持导入`); + } + + const auth = asObj(request.auth ?? it.auth); + let authConfig: HttpRequestData["auth"] = { mode: "none" }; + const authType = str(auth.type ?? auth.mode); + if (authType === "bearer") authConfig = { mode: "bearer", bearer: str(auth.bearer ?? auth.token) }; + if (authType === "basic") authConfig = { mode: "basic", basicUser: str(auth.user), basicPass: str(auth.password) }; + + return createRequest("http", name, { + data: { + kind: "http", + method, + url, + query, + headers, + cookies: [], + auth: authConfig, + bodyMode, + bodyRaw, + bodyForm, + }, + description: str(it.description) || str(request.description) || undefined, + }); +} + +/** apipost 的 KV 容器:[{key,value}] / [{name,value}] / {k:v} 都收 */ +function collectKV(container: unknown, out: HttpRequestData["headers"] | HttpRequestData["query"] | HttpRequestData["bodyForm"]): void { + if (Array.isArray(container)) { + for (const item of container) { + const itemObj = asObj(item); + const key = str(itemObj.key ?? itemObj.name); + const value = str(itemObj.value ?? ""); + const fieldTypeId = itemObj.field_type ?? itemObj.fieldType; + const disabled = itemObj.is_checked === 0 || itemObj.disabled === true || itemObj.enable === false; + if (key && str(fieldTypeId) !== "2") out.push(kv(key, value, !disabled)); + } + } else if (container && typeof container === "object") { + for (const [k, v] of Object.entries(container as Obj)) { + const vObj = asObj(v); + if (typeof v === "string") out.push(kv(k, v)); + else if (vObj.value !== undefined || vObj.key !== undefined) out.push(kv(str(vObj.key ?? k), str(vObj.value))); + } + } +} + +function looksLikeJson(s: string): boolean { + const t = s.trimStart(); + return t.startsWith("{") || t.startsWith("["); +} + +function formatJson(s: string): string { + try { + return JSON.stringify(JSON.parse(s), null, 2); + } catch { + return s; + } +} + +function countNodes(nodes: CollectionNode[]): number { + let n = 0; + for (const node of nodes) { + if (node.type === "request") n++; + else n += countNodes(node.children); + } + return n; +} diff --git a/packages/core/src/importers/curl.ts b/packages/core/src/importers/curl.ts new file mode 100644 index 0000000..93c4b6b --- /dev/null +++ b/packages/core/src/importers/curl.ts @@ -0,0 +1,386 @@ +/** + * cURL 命令解析器(覆盖 curl 常用 flag,容忍换行续行 / 引号 / 嵌套引号)。 + */ +import type { Collection } from "../model"; +import { kv } from "../factory"; +import { createCollection, createRequest } from "../factory"; + +/** 分词:支持 '…"…'、"…'…"、\" 转义、\ 续行 */ +export function tokenizeCurl(input: string): string[] { + const text = input.replace(/\\\r?\n/g, " ").replace(/\r?\n/g, " "); + const tokens: string[] = []; + let i = 0; + while (i < text.length) { + while (i < text.length && /\s/.test(text[i])) i++; + if (i >= text.length) break; + let token = ""; + let quote: '"' | "'" | null = null; + while (i < text.length) { + const ch = text[i]; + if (quote) { + if (ch === "\\" && quote === '"') { + const next = text[i + 1]; + if (next === '"' || next === "\\") { + token += next; + i += 2; + continue; + } + token += ch; + i++; + continue; + } + if (ch === quote) { + quote = null; + i++; + continue; + } + token += ch; + i++; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + i++; + continue; + } + if (/\s/.test(ch)) break; + token += ch; + i++; + } + if (token) tokens.push(token); + } + return tokens; +} + +export type ParsedCurl = { + method: string; + url: string; + headers: { key: string; value: string; enabled: boolean }[]; + query: { key: string; value: string; enabled: boolean }[]; + cookies: { key: string; value: string; enabled: boolean }[]; + bodyMode: "none" | "json" | "xml" | "text" | "html" | "urlencoded" | "form-data"; + bodyRaw: string; + bodyForm: { key: string; value: string; enabled: boolean }[]; + auth: { mode: "none" | "bearer" | "basic"; bearer?: string; basicUser?: string; basicPass?: string }; + warnings: string[]; +}; + +export function parseCurl(input: string): ParsedCurl { + const warnings: string[] = []; + const tokens = tokenizeCurl(input); + let url = ""; + let method = ""; + const headers: ParsedCurl["headers"] = []; + const formRows: ParsedCurl["bodyForm"] = []; + const dataParts: string[] = []; + let auth: ParsedCurl["auth"] = { mode: "none" }; + let cookieRaw = ""; + let forceJson = false; + + const flagAliases: Record = { + "-X": "--request", + "-H": "--header", + "-d": "--data", + "--data-raw": "--data", + "--data-binary": "--data", + "--data-ascii": "--data", + "--data-urlencode": "--data", + "-F": "--form", + "--form-string": "--form", + "-u": "--user", + "--user": "--user", + "-b": "--cookie", + "-A": "--user-agent", + "-e": "--referer", + "-o": "--output", + "-x": "--proxy", + }; + + for (let i = 0; i < tokens.length; i++) { + let t = tokens[i]; + if (t === "curl") continue; + if (t === "npx" || t === "wget") warnings.push(`检测到 ${t},仅按 curl 子集解析`); + + const next = (): string | undefined => { + const v = tokens[++i]; + return v === "$" || v === undefined ? undefined : v; + }; + + if (!t.startsWith("-")) { + // 位置参数 → URL + const value = t.startsWith("$'") ? t.slice(2, -1) : t; + if (/^https?:\/\//i.test(value) || !url) url = url || value; + continue; + } + + // --flag=value 形式 + let inlineValue: string | undefined; + const eq = t.indexOf("="); + if (t.startsWith("--") && eq > 0) { + inlineValue = t.slice(eq + 1); + t = t.slice(0, eq); + } + const flag = flagAliases[t] ?? t; + + const take = (): string => { + if (inlineValue !== undefined) return inlineValue; + const v = next(); + return v ?? ""; + }; + + switch (flag) { + case "--request": + method = take().toUpperCase(); + break; + case "--header": { + const raw = take(); + const idx = raw.indexOf(":"); + if (idx > 0) { + const key = raw.slice(0, idx).trim(); + const value = raw.slice(idx + 1).trim(); + if (key.endsWith(";")) { + headers.push({ key: key.slice(0, -1), value: "", enabled: false }); + } else { + headers.push({ key, value, enabled: true }); + } + } + break; + } + case "--data": + case "--json": { + if (t === "--json") forceJson = true; + let v = take(); + if (v.startsWith("$'") && v.endsWith("'")) v = v.slice(2, -1); + if (v === "-" || v.startsWith("@")) { + warnings.push("忽略 @file / stdin 形式的数据体"); + break; + } + if (t === "--data-urlencode") { + v = encodeURIComponent(v); + } + dataParts.push(v); + break; + } + case "--form": { + const v = take(); + const [k, ...rest] = v.split("="); + if (k && !k.startsWith("@")) { + formRows.push({ key: k, value: rest.join("="), enabled: true }); + } else { + warnings.push(`忽略文件表单项 ${k}`); + } + break; + } + case "--user": { + const v = take(); + const idx = v.indexOf(":"); + auth = { + mode: "basic", + basicUser: idx >= 0 ? v.slice(0, idx) : v, + basicPass: idx >= 0 ? v.slice(idx + 1) : "", + }; + break; + } + case "--cookie": + cookieRaw = take(); + break; + case "--user-agent": + headers.push({ key: "User-Agent", value: take(), enabled: true }); + break; + case "--referer": + headers.push({ key: "Referer", value: take(), enabled: true }); + break; + case "--output": + case "--proxy": + take(); + break; + default: + // -k -s -L -i --compressed 等 → 忽略;未知带值 flag 尝试吞掉一个值 + if (!flag.startsWith("--") && flag.length > 2 && !inlineValue) { + // 组合短 flag(-sS)忽略 + } else if (flag.startsWith("--") && inlineValue === undefined && !isNoArgFlag(flag)) { + next(); + } + break; + } + } + + const data = dataParts.join("&"); + const inferred = + method || + (data || formRows.length + ? "POST" + : url.includes(";type=") + ? "PUT" + : "GET"); + + const headerMap = new Map(headers.map((h) => [h.key.toLowerCase(), h])); + const contentType = headerMap.get("content-type")?.value ?? ""; + + let bodyMode: ParsedCurl["bodyMode"] = "none"; + let bodyRaw = ""; + const bodyForm: ParsedCurl["bodyForm"] = []; + + if (formRows.length) { + bodyMode = "form-data"; + bodyForm.push(...formRows); + } else if (data) { + if (forceJson || contentType.includes("json") || looksLikeJson(data)) { + bodyMode = "json"; + bodyRaw = tryFormatJson(data); + } else if ( + contentType.includes("x-www-form-urlencoded") || + (!contentType && /^[^={\[][\s\S]*=[^=]*(&|$)/.test(data) && !data.trim().startsWith("<")) + ) { + bodyMode = "urlencoded"; + for (const pair of data.split("&")) { + const idx = pair.indexOf("="); + if (idx > 0) { + bodyForm.push({ + key: decodeMaybe(pair.slice(0, idx)), + value: decodeMaybe(pair.slice(idx + 1)), + enabled: true, + }); + } + } + } else if (contentType.includes("xml") || data.trimStart().startsWith("<")) { + bodyMode = "xml"; + bodyRaw = data; + } else { + bodyMode = "text"; + bodyRaw = data; + } + } + + // Cookie 头 → cookies 行 + const cookies: ParsedCurl["cookies"] = []; + if (cookieRaw) { + for (const pair of cookieRaw.split(";")) { + const idx = pair.indexOf("="); + if (idx > 0) { + cookies.push({ key: pair.slice(0, idx).trim(), value: pair.slice(idx + 1).trim(), enabled: true }); + } + } + } + + // URL 查询参数拆行 + const query: ParsedCurl["query"] = []; + let normalizedUrl = url; + const qIdx = url.indexOf("?"); + if (qIdx >= 0) { + normalizedUrl = url.slice(0, qIdx); + for (const pair of url.slice(qIdx + 1).split("&")) { + if (!pair) continue; + const idx = pair.indexOf("="); + query.push({ + key: decodeMaybe(idx > 0 ? pair.slice(0, idx) : pair), + value: idx > 0 ? decodeMaybe(pair.slice(idx + 1)) : "", + enabled: true, + }); + } + } + + return { + method: inferred, + url: normalizedUrl, + headers, + query, + cookies, + bodyMode, + bodyRaw, + bodyForm, + auth, + warnings, + }; +} + +function isNoArgFlag(flag: string): boolean { + return [ + "--compressed", + "--insecure", + "--silent", + "--verbose", + "--location", + "--include", + "--head", + "-k", + "-s", + "-S", + "-L", + "-i", + "-I", + "-v", + "-f", + "-G", + "--get", + "--no-buffer", + ].includes(flag); +} + +function looksLikeJson(s: string): boolean { + const t = s.trimStart(); + return t.startsWith("{") || t.startsWith("["); +} + +function tryFormatJson(s: string): string { + try { + return JSON.stringify(JSON.parse(s), null, 2); + } catch { + return s; + } +} + +function decodeMaybe(s: string): string { + try { + return decodeURIComponent(s.replace(/\+/g, " ")); + } catch { + return s; + } +} + +export function importCurl(input: string, collectionName = "cURL 导入"): Collection { + const parsed = parseCurl(input); + const name = deriveCurlName(parsed); + const node = createRequest("http", name, { + data: { + method: normalizeMethod(parsed.method), + url: parsed.url, + query: parsed.query.map((q) => kv(q.key, q.value, q.enabled)), + headers: parsed.headers + .filter((h) => h.key.toLowerCase() !== "cookie") + .map((h) => kv(h.key, h.value, h.enabled)), + cookies: parsed.cookies.map((c) => kv(c.key, c.value, c.enabled)), + auth: + parsed.auth.mode === "basic" + ? { mode: "basic", basicUser: parsed.auth.basicUser, basicPass: parsed.auth.basicPass } + : parsed.auth.mode === "bearer" + ? { mode: "bearer", bearer: parsed.auth.bearer } + : { mode: "none" }, + bodyMode: parsed.bodyMode, + bodyRaw: parsed.bodyRaw, + bodyForm: parsed.bodyForm.map((f) => kv(f.key, f.value, f.enabled)), + }, + description: parsed.warnings.join("\n") || undefined, + }); + const collection = createCollection(collectionName, [node]); + collection.description = "由 cURL 命令导入"; + return collection; +} + +export function normalizeMethod(m: string) { + const upper = m.toUpperCase(); + const allowed = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] as const; + return (allowed as readonly string[]).includes(upper) + ? (upper as (typeof allowed)[number]) + : "GET"; +} + +function deriveCurlName(parsed: ParsedCurl): string { + try { + const u = new URL(parsed.url); + const seg = u.pathname.split("/").filter(Boolean).pop(); + return `${parsed.method} /${seg ?? ""}`.trim(); + } catch { + return `${parsed.method} ${parsed.url}`.slice(0, 60); + } +} diff --git a/packages/core/src/importers/har.ts b/packages/core/src/importers/har.ts new file mode 100644 index 0000000..8105e08 --- /dev/null +++ b/packages/core/src/importers/har.ts @@ -0,0 +1,151 @@ +/** + * HAR(HTTP Archive)导入器:按域名分组。 + */ +import type { Collection, CollectionNode, FolderNode, HttpRequestData } from "../model"; +import { createCollection, createFolder, createRequest, kv } from "../factory"; +import type { ImportResult } from "../model"; +import { normalizeMethod } from "./curl"; + +type Obj = Record; +const asObj = (v: unknown): Obj => (v && typeof v === "object" ? (v as Obj) : {}); +const asArr = (v: unknown): unknown[] => (Array.isArray(v) ? v : []); +const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? "" : String(v)); + +export function isHarDocument(doc: Obj): boolean { + const log = asObj(doc.log); + return Array.isArray(log.entries); +} + +export function importHar(text: string): ImportResult { + const doc = asObj(JSON.parse(text)); + if (!isHarDocument(doc)) throw new Error("不是 HAR 文件(缺少 log.entries)"); + + const warnings: string[] = []; + const entries = asArr(asObj(doc.log).entries); + const byHost = new Map(); + const seen = new Set(); + let count = 0; + + for (const entryRaw of entries) { + const entry = asObj(entryRaw); + const request = asObj(entry.request); + const urlStr = str(request.url); + if (!/^https?:\/\//i.test(urlStr)) continue; + const method = normalizeMethod(str(request.method) || "GET"); + const dedupeKey = `${method} ${urlStr}`; + if (seen.has(dedupeKey)) continue; + seen.add(dedupeKey); + + let u: URL; + try { + u = new URL(urlStr); + } catch { + warnings.push(`跳过非法 URL:${urlStr.slice(0, 60)}`); + continue; + } + + const host = u.host; + let folder = byHost.get(host); + if (!folder) { + folder = createFolder(host, []); + byHost.set(host, folder); + } + + // query + const query: HttpRequestData["query"] = []; + for (const q of asArr(request.queryString)) { + const qObj = asObj(q); + if (qObj.name) query.push(kv(str(qObj.name), str(qObj.value))); + } + // headers(剔除 cookie) + const headers: HttpRequestData["headers"] = []; + const cookies: HttpRequestData["cookies"] = []; + for (const h of asArr(request.headers)) { + const hObj = asObj(h); + const name = str(hObj.name); + if (!name) continue; + if (name.toLowerCase() === "cookie") { + for (const pair of str(hObj.value).split(";")) { + const idx = pair.indexOf("="); + if (idx > 0) cookies.push(kv(pair.slice(0, idx).trim(), pair.slice(idx + 1).trim())); + } + continue; + } + headers.push(kv(name, str(hObj.value))); + } + // body + const postData = asObj(request.postData); + let bodyMode: HttpRequestData["bodyMode"] = "none"; + let bodyRaw = ""; + let bodyForm: HttpRequestData["bodyForm"] = []; + const mime = str(postData.mimeType); + const text = str(postData.text); + if (mime.includes("json") || (!mime && looksLikeJson(text))) { + bodyMode = "json"; + bodyRaw = formatJson(text); + } else if (mime.includes("x-www-form-urlencoded")) { + bodyMode = "urlencoded"; + const params = asArr(postData.params); + if (params.length) { + for (const p of params) { + const pObj = asObj(p); + if (pObj.name) bodyForm.push(kv(str(pObj.name), str(pObj.value))); + } + } else { + for (const pair of text.split("&")) { + const idx = pair.indexOf("="); + if (idx > 0) bodyForm.push(kv(pair.slice(0, idx), pair.slice(idx + 1))); + } + } + } else if (mime.includes("xml")) { + bodyMode = "xml"; + bodyRaw = text; + } else if (mime.includes("multipart")) { + bodyMode = "form-data"; + for (const p of asArr(postData.params)) { + const pObj = asObj(p); + if (pObj.name && str(pObj.fileName) === "") bodyForm.push(kv(str(pObj.name), str(pObj.value))); + } + } else if (mime) { + bodyMode = "text"; + bodyRaw = text; + } + + const pathname = decodeURIComponent(u.pathname) || "/"; + const node = createRequest("http", `${method} ${pathname}`, { + data: { + kind: "http", + method, + url: `${u.origin}${u.pathname}`, + query, + headers, + cookies, + auth: { mode: "none" }, + bodyMode, + bodyRaw, + bodyForm, + }, + }); + folder.children.push(node); + count++; + } + + const children: CollectionNode[] = Array.from(byHost.values()); + const collection = createCollection("HAR 导入", children); + collection.description = `从 HAR 捕获导入 ${count} 个请求`; + if (count === 0) warnings.push("HAR 里没有可导入的 http(s) 请求"); + return { collections: [collection], environmentVariables: [], warnings }; +} + +function looksLikeJson(s: string): boolean { + const t = s.trimStart(); + return t.startsWith("{") || t.startsWith("["); +} + +function formatJson(s: string): string { + try { + return JSON.stringify(JSON.parse(s), null, 2); + } catch { + return s; + } +} diff --git a/packages/core/src/importers/hoppscotch.ts b/packages/core/src/importers/hoppscotch.ts new file mode 100644 index 0000000..cc38d87 --- /dev/null +++ b/packages/core/src/importers/hoppscotch.ts @@ -0,0 +1,135 @@ +/** + * Hoppscotch 集合导入器(数组或单对象,folders/requests 递归)。 + */ +import type { Collection, CollectionNode, FolderNode, HttpRequestData } from "../model"; +import { createCollection, createFolder, createRequest, kv } from "../factory"; +import type { ImportResult } from "../model"; +import { normalizeMethod } from "./curl"; + +type Obj = Record; +const asObj = (v: unknown): Obj => (v && typeof v === "object" ? (v as Obj) : {}); +const asArr = (v: unknown): unknown[] => (Array.isArray(v) ? v : []); +const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? "" : String(v)); + +export function isHoppscotchCollection(doc: unknown): boolean { + const list = Array.isArray(doc) ? doc.map(asObj) : [asObj(doc)]; + return list.some((c) => Array.isArray(c.requests) || Array.isArray(c.folders)); +} + +export function importHoppscotch(text: string): ImportResult { + const parsed: unknown = JSON.parse(text); + const roots = Array.isArray(parsed) ? parsed.map(asObj) : [asObj(parsed)]; + if (!isHoppscotchCollection(parsed)) throw new Error("不是 Hoppscotch 集合(缺少 requests/folders)"); + + const warnings: string[] = []; + const results: Collection[] = []; + + for (const root of roots) { + if (!Array.isArray(root.requests) && !Array.isArray(root.folders)) continue; + const children = convertFolder(root, warnings); + const collection = createCollection(str(root.name) || "Hoppscotch 导入", children); + results.push(collection); + } + + if (results.length === 0) warnings.push("没有可导入的集合"); + return { collections: results, environmentVariables: [], warnings }; +} + +function convertFolder(folder: Obj, warnings: string[]): CollectionNode[] { + const out: CollectionNode[] = []; + for (const f of asArr(folder.folders)) { + const fObj = asObj(f); + out.push(createFolder(str(fObj.name) || "目录", convertFolder(fObj, warnings))); + } + for (const r of asArr(folder.requests)) { + out.push(convertRequest(asObj(r), warnings)); + } + return out; +} + +function convertRequest(r: Obj, warnings: string[]): CollectionNode { + const method = normalizeMethod(str(r.method) || "GET"); + const url = str(r.url) + str(r.path === "/" || r.path === "" ? "" : str(r.path)); + + const headers: HttpRequestData["headers"] = []; + for (const h of asArr(r.headers)) { + const hObj = asObj(h); + if (hObj.key) headers.push(kv(str(hObj.key), str(hObj.value), hObj.active !== false)); + } + const query: HttpRequestData["query"] = []; + for (const p of asArr(r.params)) { + const pObj = asObj(p); + if (pObj.key) query.push(kv(str(pObj.key), str(pObj.value), pObj.active !== false)); + } + + const bodyModeRaw = str(r.bodyMode ?? r.requestBodyMode ?? "none"); + let bodyMode: HttpRequestData["bodyMode"] = "none"; + let bodyRaw = ""; + let bodyForm: HttpRequestData["bodyForm"] = []; + + if (bodyModeRaw === "raw" || bodyModeRaw === "application/json" || bodyModeRaw === "application/xml" || bodyModeRaw === "text/plain") { + bodyRaw = str(r.rawRequestBody ?? r.rawParams ?? ""); + bodyMode = bodyModeRaw.includes("xml") ? "xml" : looksLikeJson(bodyRaw) ? "json" : "text"; + } else if (bodyModeRaw === "urlencoded" || bodyModeRaw === "application/x-www-form-urlencoded") { + bodyMode = "urlencoded"; + for (const p of asArr(r.bodyParams ?? r.params)) { + const pObj = asObj(p); + if (pObj.key) bodyForm.push(kv(str(pObj.key), str(pObj.value), pObj.active !== false)); + } + } else if (bodyModeRaw === "formdata" || bodyModeRaw === "multipart/form-data") { + bodyMode = "form-data"; + for (const p of asArr(r.bodyParams)) { + const pObj = asObj(p); + if (pObj.key && !str(pObj.value).startsWith("@file")) bodyForm.push(kv(str(pObj.key), str(pObj.value), pObj.active !== false)); + } + } + + let auth: HttpRequestData["auth"] = { mode: "none" }; + const authType = str(r.auth); + if (authType === "bearer") auth = { mode: "bearer", bearer: str(r.bearerToken ?? r.httpUser) }; + if (authType === "basic") + auth = { mode: "basic", basicUser: str(r.httpUser), basicPass: str(r.httpPassword) }; + if (authType === "oauth2") auth = { mode: "bearer", bearer: str(r.oauth2Token ?? "") }; + + if (r.graphqlQuery !== undefined || str(r.requestType).includes("graphql")) { + return createRequest("graphql", str(r.name) || "GraphQL", { + data: { + kind: "graphql", + url, + headers, + auth, + query: str(r.graphqlQuery) || "query {\n \n}", + variables: safeStringify(r.graphqlVariables) || "{}", + }, + }); + } + + return createRequest("http", str(r.name) || `${method} 请求`, { + data: { + kind: "http", + method, + url, + query, + headers, + cookies: [], + auth, + bodyMode, + bodyRaw, + bodyForm, + }, + }); +} + +function looksLikeJson(s: string): boolean { + const t = s.trimStart(); + return t.startsWith("{") || t.startsWith("["); +} + +function safeStringify(v: unknown): string { + if (typeof v === "string") return v; + try { + return JSON.stringify(v, null, 2) ?? ""; + } catch { + return ""; + } +} diff --git a/packages/core/src/importers/index.ts b/packages/core/src/importers/index.ts new file mode 100644 index 0000000..c3df4a4 --- /dev/null +++ b/packages/core/src/importers/index.ts @@ -0,0 +1,138 @@ +/** + * 导入入口:格式自动识别 + 统一 importWorkspaceData()。 + */ +import type { Collection, ImportResult } from "../model"; +import { importCurl, tokenizeCurl, parseCurl } from "./curl"; +import { importOpenApi, isOpenApiDocument, parseSpecDocument } from "./openapi"; +import { importPostman, isPostmanCollection } from "./postman"; +import { importHar, isHarDocument } from "./har"; +import { importInsomnia, isInsomniaExport } from "./insomnia"; +import { importHoppscotch, isHoppscotchCollection } from "./hoppscotch"; +import { importApipost, isApipostExport } from "./apipost"; +import { importMarkdown, looksLikeMarkdown } from "./markdown"; + +export type ImportFormat = + | "openapi" + | "postman" + | "curl" + | "har" + | "insomnia" + | "hoppscotch" + | "apipost" + | "markdown" + | "backup" + | "unknown"; + +export const FORMAT_LABELS: Record = { + openapi: "OpenAPI / Swagger / Apifox", + postman: "Postman Collection", + curl: "cURL 命令", + har: "HAR 抓包文件", + insomnia: "Insomnia 导出", + hoppscotch: "Hoppscotch 集合", + apipost: "Apipost 项目", + markdown: "Markdown 文档", + backup: "OpsxAPI 备份", + unknown: "未知格式", +}; + +export function detectImportFormat(text: string): ImportFormat { + const trimmed = text.trim(); + + if (/^(curl|wget)\b/.test(trimmed) || (trimmed.startsWith("npx ") && trimmed.includes(" curl"))) { + return "curl"; + } + + let json: unknown = null; + let isJson = false; + try { + json = JSON.parse(trimmed); + isJson = true; + } catch { + isJson = false; + } + + if (isJson && json && typeof json === "object") { + const doc = json as Record; + if (isOpenApiDocument(doc)) return "openapi"; + if (isPostmanCollection(doc)) return "postman"; + if (isHarDocument(doc)) return "har"; + if (isInsomniaExport(doc)) return "insomnia"; + if (isApipostExport(doc)) return "apipost"; + if (isHoppscotchCollection(json)) return "hoppscotch"; + if (isOpsxBackup(doc)) return "backup"; + // 数组也可能是 hoppscotch + if (Array.isArray(json) && isHoppscotchCollection(json)) return "hoppscotch"; + } + + // YAML(OpenAPI 常见) + if (!isJson && /^(openapi|swagger)\s*:/m.test(trimmed)) { + try { + const doc = parseSpecDocument(trimmed); + if (isOpenApiDocument(doc)) return "openapi"; + } catch { + /* fallthrough */ + } + } + + if (looksLikeMarkdown(trimmed)) return "markdown"; + + return "unknown"; +} + +export function isOpsxBackup(doc: Record): boolean { + return doc.__opsxapi__ === "backup" || (doc.version === 2 && Array.isArray(doc.collections)); +} + +export function importByFormat(text: string, format: ImportFormat): ImportResult { + switch (format) { + case "openapi": + return importOpenApi(text); + case "postman": + return importPostman(text); + case "curl": + return { collections: [importCurl(text)], environmentVariables: [], warnings: [] }; + case "har": + return importHar(text); + case "insomnia": + return importInsomnia(text); + case "hoppscotch": + return importHoppscotch(text); + case "apipost": + return importApipost(text); + case "markdown": + return importMarkdown(text); + case "backup": + return importBackup(text); + case "unknown": { + const detected = detectImportFormat(text); + if (detected !== "unknown") return importByFormat(text, detected); + throw new Error("无法识别格式。支持:OpenAPI/Swagger、Postman、Apifox、Apipost、cURL、HAR、Insomnia、Hoppscotch、Markdown、OpsxAPI 备份。"); + } + } +} + +/** 自动识别并导入 */ +export function importAuto(text: string): ImportResult { + const format = detectImportFormat(text); + return importByFormat(text, format); +} + +export function importBackup(text: string): ImportResult { + const doc = JSON.parse(text) as { collections?: Collection[] }; + if (!Array.isArray(doc.collections)) throw new Error("备份文件缺少 collections"); + return { + collections: doc.collections, + environmentVariables: [], + warnings: [], + }; +} + +export { tokenizeCurl, parseCurl, importCurl }; +export { importOpenApi, isOpenApiDocument, parseSpecDocument }; +export { importPostman, isPostmanCollection }; +export { importHar, isHarDocument }; +export { importInsomnia, isInsomniaExport }; +export { importHoppscotch, isHoppscotchCollection }; +export { importApipost, isApipostExport }; +export { importMarkdown, looksLikeMarkdown }; diff --git a/packages/core/src/importers/insomnia.ts b/packages/core/src/importers/insomnia.ts new file mode 100644 index 0000000..e7b0194 --- /dev/null +++ b/packages/core/src/importers/insomnia.ts @@ -0,0 +1,203 @@ +/** + * Insomnia 导出文件(export_format v3/v4)导入器。 + */ +import type { Collection, CollectionNode, FolderNode, HttpRequestData } from "../model"; +import { createCollection, createFolder, createRequest, kv } from "../factory"; +import type { ImportResult } from "../model"; +import { normalizeMethod } from "./curl"; + +type Obj = Record; +const asObj = (v: unknown): Obj => (v && typeof v === "object" ? (v as Obj) : {}); +const asArr = (v: unknown): unknown[] => (Array.isArray(v) ? v : []); +const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? "" : String(v)); + +export function isInsomniaExport(doc: Obj): boolean { + if (str(doc._type) === "export" && Array.isArray(doc.resources)) return true; + // v4 嵌套格式:resources 里是 workspace + return Array.isArray(doc.resources) && asArr(doc.resources).some((r) => asObj(r)._type === "workspace"); +} + +export function importInsomnia(text: string): ImportResult { + const doc = asObj(JSON.parse(text)); + if (!isInsomniaExport(doc)) throw new Error("不是 Insomnia 导出文件(缺少 resources)"); + + const warnings: string[] = []; + const resources = asArr(doc.resources).map(asObj); + const workspaces = resources.filter((r) => str(r._type) === "workspace"); + const results: Collection[] = []; + + for (const ws of workspaces) { + const scopeIds = new Set([str(ws._id)]); + // 找出属于该 workspace 的所有 resource(递归 parentId) + const byParent = new Map(); + for (const r of resources) { + if (str(r._type) === "workspace") continue; + const parent = str(r.parentId); + if (!byParent.has(parent)) byParent.set(parent, []); + byParent.get(parent)!.push(r); + } + const children = collectChildren(scopeIds, byParent, warnings); + const collection = createCollection(str(ws.name) || "Insomnia 导入", children); + results.push(collection); + } + + if (results.length === 0) { + // 无 workspace(v3):全部平铺 + const byParent = new Map(); + for (const r of resources) { + if (str(r._type) === "workspace") continue; + const parent = str(r.parentId) || "__root__"; + if (!byParent.has(parent)) byParent.set(parent, []); + byParent.get(parent)!.push(r); + } + const children = collectChildren(new Set(["__root__"]), byParent, warnings); + results.push(createCollection("Insomnia 导入", children)); + } + + let total = 0; + for (const c of results) total += countNodes(c.children); + if (total === 0) warnings.push("没有可导入的请求"); + return { collections: results, environmentVariables: [], warnings }; +} + +function collectChildren( + parentIds: Set, + byParent: Map, + warnings: string[], +): CollectionNode[] { + const out: CollectionNode[] = []; + for (const id of parentIds) { + for (const r of byParent.get(id) ?? []) { + const type = str(r._type); + if (type === "request_group") { + const folder = createFolder(str(r.name) || "目录", collectChildren(new Set([str(r._id)]), byParent, warnings)); + if (r.description) folder.description = str(r.description); + out.push(folder); + } else if (type === "request") { + out.push(convertRequest(r, warnings)); + } + } + } + return out; +} + +function convertRequest(r: Obj, warnings: string[]): CollectionNode { + const url = str(r.url) || str(asObj(r.url).raw) || ""; + const method = normalizeMethod(str(r.method) || "GET"); + + const headers: HttpRequestData["headers"] = []; + for (const h of asArr(r.headers)) { + const hObj = asObj(h); + if (hObj.name && str(hObj.name).toLowerCase() !== "cookie") { + headers.push(kv(str(hObj.name), str(hObj.value), hObj.disabled !== true)); + } + } + const query: HttpRequestData["query"] = []; + let baseUrl = url; + for (const p of asArr(r.parameters)) { + const pObj = asObj(p); + if (pObj.name) query.push(kv(str(pObj.name), str(pObj.value), pObj.disabled !== true)); + } + + const body = asObj(r.body); + const mime = str(body.mimeType); + let bodyMode: HttpRequestData["bodyMode"] = "none"; + let bodyRaw = ""; + let bodyForm: HttpRequestData["bodyForm"] = []; + if (mime.includes("graphql")) { + const gqlText = typeof body.text === "string" ? body.text : ""; + const gql = asObj(gqlText ? safeJsonParse(gqlText) : {}); + return createRequest("graphql", str(r.name) || "GraphQL", { + data: { + kind: "graphql", + url: baseUrl, + headers, + auth: authFromInsomnia(asObj(r.authentication)), + query: str(gql.query) || "query {\n \n}", + variables: safeStringify(gql.variables) || "{}", + }, + }); + } + if (mime.includes("urlencoded")) { + bodyMode = "urlencoded"; + for (const p of asArr(body.params)) { + const pObj = asObj(p); + if (pObj.name) bodyForm.push(kv(str(pObj.name), str(pObj.value), pObj.disabled !== true)); + } + } else if (mime.includes("form-data") || mime.includes("multipart")) { + bodyMode = "form-data"; + for (const p of asArr(body.params)) { + const pObj = asObj(p); + if (pObj.name && !pObj.file) bodyForm.push(kv(str(pObj.name), str(pObj.value), pObj.disabled !== true)); + } + } else if (mime.includes("json")) { + bodyMode = "json"; + bodyRaw = formatJson(str(body.text)); + } else if (mime) { + bodyMode = mime.includes("xml") ? "xml" : mime.includes("html") ? "html" : "text"; + bodyRaw = str(body.text); + } + + return createRequest("http", str(r.name) || `${method} 请求`, { + data: { + kind: "http", + method, + url: baseUrl, + query, + headers, + cookies: [], + auth: authFromInsomnia(asObj(r.authentication)), + bodyMode, + bodyRaw, + bodyForm, + }, + description: str(r.description) || undefined, + }); +} + +function authFromInsomnia(auth: Obj): HttpRequestData["auth"] { + const type = str(auth.type); + if (type === "bearer") return { mode: "bearer", bearer: str(auth.token) }; + if (type === "basic") return { mode: "basic", basicUser: str(auth.username), basicPass: str(auth.password) }; + if (type === "apikey" || type === "api_key") + return { + mode: "apikey", + apiKeyName: str(auth.key), + apiKeyValue: str(auth.value), + apiKeyIn: str(auth.in) === "query" ? "query" : "header", + }; + return { mode: "none" }; +} + +function safeJsonParse(s: string): unknown { + try { + return JSON.parse(s); + } catch { + return {}; + } +} + +function safeStringify(v: unknown): string { + try { + return JSON.stringify(v, null, 2) ?? ""; + } catch { + return ""; + } +} + +function formatJson(s: string): string { + try { + return JSON.stringify(JSON.parse(s), null, 2); + } catch { + return s; + } +} + +function countNodes(nodes: CollectionNode[]): number { + let n = 0; + for (const node of nodes) { + if (node.type === "request") n++; + else n += countNodes(node.children); + } + return n; +} diff --git a/packages/core/src/importers/markdown.ts b/packages/core/src/importers/markdown.ts new file mode 100644 index 0000000..a0ac4fc --- /dev/null +++ b/packages/core/src/importers/markdown.ts @@ -0,0 +1,86 @@ +/** + * Markdown API 文档导入器:提取 curl 代码块(及可选 JSON 请求体示例)。 + */ +import type { Collection } from "../model"; +import { createCollection, createFolder } from "../factory"; +import type { ImportResult } from "../model"; +import { importCurl } from "./curl"; + +export function looksLikeMarkdown(text: string): boolean { + const t = text.trimStart(); + if (!t.startsWith("#") && !t.includes("```")) return false; + return /^#{1,6}\s/m.test(text) || /```/.test(text); +} + +export function importMarkdown(text: string): ImportResult { + const warnings: string[] = []; + const blocks: { heading: string; code: string }[] = []; + + const lines = text.split(/\r?\n/); + let heading = ""; + let inBlock = false; + let lang = ""; + let buf: string[] = []; + + for (const line of lines) { + const fence = line.match(/^\s*```(\w*)\s*$/); + if (fence) { + if (!inBlock) { + inBlock = true; + lang = fence[1].toLowerCase(); + buf = []; + } else { + inBlock = false; + const code = buf.join("\n"); + if (["curl", "bash", "sh", "shell", ""].includes(lang) && code.trim().startsWith("curl")) { + blocks.push({ heading: heading || `curl ${blocks.length + 1}`, code }); + } + } + continue; + } + if (inBlock) { + buf.push(line); + continue; + } + const h = line.match(/^#{1,6}\s+(.*)$/); + if (h) heading = h[1].trim(); + } + + if (blocks.length === 0) { + warnings.push("未在 Markdown 中找到 curl 代码块(也支持直接粘贴 cURL 导入)"); + } + + const folders = new Map>(); + const rootChildren = []; + for (const block of blocks) { + try { + const temp = importCurl(block.code, "temp"); + const node = temp.children[0]; + if (!node) continue; + // 一级标题做目录,二级及以下做请求名 + const parts = block.heading.split(/[::]/); + const folderName = parts[0]?.trim(); + if (folderName && folders.has(folderName)) { + folders.get(folderName)!.children.push(node); + } else if (folderName) { + const f = createFolder(folderName, [node]); + folders.set(folderName, f); + } else { + rootChildren.push(node); + } + } catch (e) { + warnings.push(`解析代码块失败:${e instanceof Error ? e.message : String(e)}`); + } + } + + const collection = createCollection( + firstHeading(text) || "Markdown 导入", + [...folders.values(), ...rootChildren], + ); + return { collections: [collection], environmentVariables: [], warnings }; +} + +function firstHeading(text: string): string { + const m = text.match(/^#\s+(.+)$/m); + return m ? m[1].trim().slice(0, 60) : ""; +} diff --git a/packages/core/src/importers/openapi.ts b/packages/core/src/importers/openapi.ts new file mode 100644 index 0000000..933e65d --- /dev/null +++ b/packages/core/src/importers/openapi.ts @@ -0,0 +1,400 @@ +/** + * OpenAPI 3.1 / 3.0 / Swagger 2.0 导入器(JSON 或 YAML;Apifox 导出的 OpenAPI 格式同样适用)。 + */ +import { load as yamlLoad } from "js-yaml"; +import type { + Collection, + CollectionNode, + FolderNode, + HttpRequestData, + RequestMethod, +} from "../model"; +import { createCollection, createFolder, createRequest, kv } from "../factory"; +import type { ImportResult } from "../model"; + +type Obj = Record; +const asObj = (v: unknown): Obj => (v && typeof v === "object" ? (v as Obj) : {}); +const asArr = (v: unknown): unknown[] => (Array.isArray(v) ? v : []); +const str = (v: unknown): string => (typeof v === "string" ? v : v === undefined || v === null ? "" : String(v)); + +const HTTP_METHODS = ["get", "post", "put", "patch", "delete", "head", "options"]; + +export function parseSpecDocument(text: string): Obj { + const trimmed = text.trim(); + if (trimmed.startsWith("{")) { + return asObj(JSON.parse(trimmed)); + } + return asObj(yamlLoad(trimmed)); +} + +export function isOpenApiDocument(doc: Obj): boolean { + return "openapi" in doc || "swagger" in doc; +} + +export function importOpenApi(text: string): ImportResult { + const doc = parseSpecDocument(text); + if (!isOpenApiDocument(doc)) throw new Error("不是 OpenAPI/Swagger 文档(缺少 openapi/swagger 字段)"); + const version = str(doc.openapi || doc.swagger); + const info = asObj(doc.info); + const warnings: string[] = []; + + const baseUrl = resolveBaseUrl(doc); + const envVars: { key: string; value: string }[] = []; + if (baseUrl) envVars.push({ key: "baseUrl", value: baseUrl }); + + const securitySchemes = asObj( + version.startsWith("2") + ? doc.securityDefinitions + : asObj(doc.components).securitySchemes, + ); + + // tag → folder + const tagOrder: string[] = []; + const tagDesc: Record = {}; + for (const t of asArr(doc.tags)) { + const tag = asObj(t); + if (tag.name) { + tagOrder.push(str(tag.name)); + if (tag.description) tagDesc[str(tag.name)] = str(tag.description); + } + } + const folderMap = new Map(); + const rootChildren: CollectionNode[] = []; + const ensureFolder = (tag: string): FolderNode | null => { + if (!tag) return null; + let f = folderMap.get(tag); + if (!f) { + f = createFolder(tag, []); + if (tagDesc[tag]) f.description = tagDesc[tag]; + folderMap.set(tag, f); + } + return f; + }; + + const paths = asObj(doc.paths); + let count = 0; + for (const [path, pathItemRaw] of Object.entries(paths)) { + const pathItem = asObj(pathItemRaw); + for (const method of HTTP_METHODS) { + const opRaw = pathItem[method]; + if (!opRaw) continue; + const op = asObj(opRaw); + count++; + const node = buildOperationNode({ + method: method.toUpperCase() as RequestMethod, + path, + op, + doc, + version, + securitySchemes, + baseUrl, + envVars, + warnings, + name: str(op.summary) || str(op.operationId) || `${method.toUpperCase()} ${path}`, + }); + const tags = asArr(op.tags).map(str).filter(Boolean); + const folder = tags.length ? ensureFolder(tags[0]) : null; + if (folder) folder.children.push(node); + else rootChildren.push(node); + } + } + + if (count === 0) warnings.push("文档中没有可导入的路径操作"); + + const children: CollectionNode[] = [ + ...tagOrder.filter((t) => folderMap.has(t)).map((t) => folderMap.get(t)!), + ...Array.from(folderMap.values()).filter((f) => !tagOrder.includes(f.name)), + ...rootChildren, + ]; + + const collection = createCollection(str(info.title) || "OpenAPI 导入", children); + collection.description = str(info.description) || undefined; + return { collections: [collection], environmentVariables: envVars, warnings }; +} + +function resolveBaseUrl(doc: Obj): string { + // v3: servers[]; v2: host + basePath + schemes + const servers = asArr(doc.servers); + if (servers.length) { + const first = asObj(servers[0]); + let url = str(first.url); + const vars = asObj(first.variables); + for (const [k, v] of Object.entries(vars)) { + const def = asObj(v).default; + if (def !== undefined) url = url.replaceAll(`{${k}}`, str(def)); + } + if (url) return url.replace(/\/$/, ""); + } + const host = str(doc.host); + if (host) { + const schemes = asArr(doc.schemes).map(str); + const scheme = schemes.includes("https") ? "https" : schemes[0] || "https"; + const basePath = str(doc.basePath) || ""; + return `${scheme}://${host}${basePath}`.replace(/\/$/, ""); + } + return ""; +} + +function buildOperationNode(ctx: { + method: RequestMethod; + path: string; + op: Obj; + doc: Obj; + version: string; + securitySchemes: Obj; + baseUrl: string; + envVars: { key: string; value: string }[]; + warnings: string[]; + name: string; +}): CollectionNode { + const { method, path, op, doc, version, securitySchemes, baseUrl, envVars, warnings } = ctx; + + // path 参数 {id} → {{id}} + let urlPath = path; + const pathParams: { name: string; example: string }[] = []; + for (const p of asArr(asObj(op).parameters ?? asObj(asObj(doc.paths)[path]).parameters)) { + const param = asObj(p); + if (str(param.in) === "path" && param.name) { + pathParams.push({ name: str(param.name), example: exampleOf(param) || "1" }); + } + } + for (const { name, example } of pathParams) { + urlPath = urlPath.replaceAll(`{${name}}`, `{{${name}}}`); + if (!envVars.some((v) => v.key === name)) envVars.push({ key: name, value: example }); + } + + // 统一使用 {{baseUrl}} 前缀(服务器地址写入环境变量),保持环境切换能力 + const url = `{{baseUrl}}${urlPath.startsWith("/") ? urlPath : `/${urlPath.replace(/^\{\{baseUrl\}\}?\/?/, "/")}`}`; + + const query: HttpRequestData["query"] = []; + const headers: HttpRequestData["headers"] = []; + let auth: HttpRequestData["auth"] = { mode: "none" }; + + for (const p of asArr(op.parameters)) { + const param = asObj(p); + if (!param.name) continue; + const in_ = str(param.in); + const row = kv(str(param.name), exampleOf(param), true, str(param.description)); + if (in_ === "query") query.push(row); + else if (in_ === "header") headers.push(row); + } + // v2 顶层 parameters(path 级) + const pathLevel = asArr(asObj(asObj(doc.paths)[path]).parameters); + for (const p of pathLevel) { + const param = asObj(p); + if (!param.name) continue; + const in_ = str(param.in); + if (in_ === "query") query.push(kv(str(param.name), exampleOf(param), true, str(param.description))); + else if (in_ === "header") headers.push(kv(str(param.name), exampleOf(param), true, str(param.description))); + } + + // 鉴权 + const securities = asArr(op.security ?? doc.security); + if (securities.length) { + const first = asObj(securities[0]); + const schemeName = Object.keys(first)[0]; + if (schemeName && securitySchemes[schemeName] !== undefined) { + const scheme = asObj(securitySchemes[schemeName]); + const type = str(scheme.type); + if (version.startsWith("2")) { + if (type === "basic") auth = { mode: "basic", basicUser: "", basicPass: "" }; + else if (type === "apiKey") + auth = { + mode: "apikey", + apiKeyName: str(scheme.name), + apiKeyIn: str(scheme.in) === "query" ? "query" : "header", + apiKeyValue: "", + }; + } else { + if (type === "http" && str(scheme.scheme) === "bearer") auth = { mode: "bearer", bearer: "" }; + else if (type === "http" && str(scheme.scheme) === "basic") auth = { mode: "basic", basicUser: "", basicPass: "" }; + else if (type === "apiKey") + auth = { + mode: "apikey", + apiKeyName: str(scheme.name), + apiKeyIn: str(scheme.in) === "query" ? "query" : "header", + apiKeyValue: "", + }; + else if (type === "oauth2") auth = { mode: "bearer", bearer: "" }; + } + } + } + + // 请求体 + let bodyMode: HttpRequestData["bodyMode"] = "none"; + let bodyRaw = ""; + let bodyForm: HttpRequestData["bodyForm"] = []; + + if (version.startsWith("2")) { + for (const p of [...asArr(op.parameters), ...pathLevel]) { + const param = asObj(p); + if (str(param.in) === "body") { + const consumes = asArr(op.consumes).map(str); + const mime = consumes[0] ?? "application/json"; + const example = schemaToExample(asObj(param.schema), doc, 0); + if (mime.includes("json")) { + bodyMode = "json"; + bodyRaw = safeStringify(example); + } else if (mime.includes("xml")) { + bodyMode = "xml"; + bodyRaw = safeStringify(example); + } else { + bodyMode = "text"; + bodyRaw = safeStringify(example); + } + } + if (str(param.in) === "formData") { + bodyMode = asArr(op.consumes).map(str)[0]?.includes("urlencoded") ? "urlencoded" : "form-data"; + bodyForm.push(kv(str(param.name), exampleOf(param), true, str(param.description))); + } + } + } else { + const requestBody = asObj(op.requestBody); + const content = asObj(requestBody.content); + const mimes = Object.keys(content); + if (mimes.length) { + const mime = + mimes.find((m) => m.includes("json")) ?? + mimes.find((m) => m.includes("xml")) ?? + mimes.find((m) => m.includes("urlencoded")) ?? + mimes.find((m) => m.includes("form-data")) ?? + mimes.find((m) => m.includes("text")) ?? + mimes[0]; + const media = asObj(content[mime]); + if (mime.includes("urlencoded")) { + bodyMode = "urlencoded"; + const schema = asObj(media.schema); + for (const [k, v] of Object.entries(asObj(schema.properties))) { + bodyForm.push(kv(k, schemaFieldExample(asObj(v)), true, str(asObj(v).description))); + } + if (media.example) bodyRaw = safeStringify(media.example); + } else if (mime.includes("form-data") || mime.includes("multipart")) { + bodyMode = "form-data"; + const schema = asObj(media.schema); + for (const [k, v] of Object.entries(asObj(schema.properties))) { + bodyForm.push(kv(k, schemaFieldExample(asObj(v)), true, str(asObj(v).description))); + } + } else { + const example = + media.example !== undefined + ? media.example + : firstExampleObject(media.examples) ?? schemaToExample(asObj(media.schema), doc, 0); + if (mime.includes("xml")) { + bodyMode = "xml"; + bodyRaw = str(example); + } else if (mime.includes("text") || mime.includes("html")) { + bodyMode = mime.includes("html") ? "html" : "text"; + bodyRaw = str(example); + } else { + bodyMode = "json"; + bodyRaw = safeStringify(example); + } + } + } + } + + if (bodyMode !== "none" && !headers.some((h) => h.key.toLowerCase() === "content-type")) { + if (bodyMode === "json") headers.unshift(kv("Content-Type", "application/json")); + if (bodyMode === "xml") headers.unshift(kv("Content-Type", "application/xml")); + if (bodyMode === "urlencoded") + headers.unshift(kv("Content-Type", "application/x-www-form-urlencoded")); + } + + const descriptionParts: string[] = []; + if (op.description) descriptionParts.push(str(op.description)); + if (op.deprecated === true) descriptionParts.push("> ⚠️ 已废弃(deprecated)"); + + return createRequest("http", ctx.name.slice(0, 120), { + data: { + kind: "http", + method, + url, + query, + headers, + cookies: [], + auth, + bodyMode, + bodyRaw, + bodyForm, + }, + description: descriptionParts.join("\n\n") || undefined, + }); +} + +function exampleOf(param: Obj): string { + if (param.example !== undefined) return str(param.example); + const ex = asObj(param.examples); + const first = Object.values(ex)[0]; + if (first !== undefined) return str(asObj(first).value); + const schema = asObj(param.schema); + if (schema.default !== undefined) return str(schema.default); + if (schema.example !== undefined) return str(schema.example); + const t = str(schema.type); + if (t === "integer" || t === "number") return "1"; + if (t === "boolean") return "true"; + if (Array.isArray(schema.enum) && schema.enum.length) return str(schema.enum[0]); + return ""; +} + +function firstExampleObject(examples: unknown): unknown { + const first = Object.values(asObj(examples))[0]; + return asObj(first).value; +} + +function schemaFieldExample(field: Obj): string { + if (field.example !== undefined) return str(field.example); + if (field.default !== undefined) return str(field.default); + if (Array.isArray(field.enum) && field.enum.length) return str(field.enum[0]); + const t = str(field.type); + if (t === "integer" || t === "number") return "0"; + if (t === "boolean") return "true"; + return ""; +} + +/** 从 JSON Schema 生成示例值(解析 $ref,深度封顶) */ +export function schemaToExample(schema: Obj, doc: Obj, depth: number): unknown { + if (!schema || depth > 6) return null; + if (schema.example !== undefined) return schema.example; + if (schema.default !== undefined) return schema.default; + if (Array.isArray(schema.enum) && schema.enum.length) return schema.enum[0]; + + const resolved = resolveRef(schema, doc); + if (resolved !== schema) return schemaToExample(resolved, doc, depth + 1); + + const t = str(schema.type); + if (t === "object" || schema.properties) { + const out: Record = {}; + for (const [k, v] of Object.entries(asObj(schema.properties))) { + out[k] = schemaToExample(asObj(v), doc, depth + 1); + } + return out; + } + if (t === "array" || schema.items) { + return [schemaToExample(asObj(schema.items), doc, depth + 1)]; + } + if (t === "integer" || t === "number") return 0; + if (t === "boolean") return true; + return ""; +} + +function resolveRef(schema: Obj, doc: Obj): Obj { + const ref = str(schema.$ref); + if (!ref) return schema; + // #/components/schemas/X 或 #/definitions/X + const parts = ref.replace(/^#\//, "").split("/"); + let cur: unknown = doc; + for (const part of parts) { + cur = asObj(cur)[part]; + if (cur === undefined) return schema; + } + return asObj(cur); +} + +function safeStringify(v: unknown): string { + if (typeof v === "string") return v; + try { + return JSON.stringify(v, null, 2) ?? ""; + } catch { + return ""; + } +} diff --git a/packages/core/src/importers/postman.ts b/packages/core/src/importers/postman.ts new file mode 100644 index 0000000..17db55d --- /dev/null +++ b/packages/core/src/importers/postman.ts @@ -0,0 +1,228 @@ +/** + * Postman Collection v2.0/v2.1 导入器。 + */ +import type { + Collection, + CollectionNode, + FolderNode, + HttpRequestData, + RequestMethod, +} from "../model"; +import { createCollection, createFolder, createRequest, kv } from "../factory"; +import type { ImportResult } from "../model"; +import { normalizeMethod } from "./curl"; + +type Obj = Record; +const asObj = (v: unknown): Obj => (v && typeof v === "object" ? (v as Obj) : {}); +const asArr = (v: unknown): unknown[] => (Array.isArray(v) ? v : []); +const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? "" : String(v)); + +export function isPostmanCollection(doc: Obj): boolean { + const info = asObj(doc.info); + const schema = str(info.schema); + return schema.includes("postman") || Array.isArray(doc.item) || info._postman_id !== undefined; +} + +export function importPostman(text: string): ImportResult { + const doc = asObj(JSON.parse(text)); + if (!isPostmanCollection(doc)) throw new Error("不是 Postman Collection(缺少 item/info.schema)"); + + const warnings: string[] = []; + const envVars: { key: string; value: string }[] = []; + + const root = createFolder("root", []); + convertItems(asArr(doc.item), root, envVars, warnings); + + // collection.variable → 环境变量 + for (const v of asArr(doc.variable)) { + const varObj = asObj(v); + const key = str(varObj.key); + if (key) envVars.push({ key, value: str(varObj.value) }); + } + + const info = asObj(doc.info); + const collection = createCollection(str(info.name) || "Postman 导入", root.children); + collection.description = str(info.description) || undefined; + + let count = 0; + const countReqs = (nodes: CollectionNode[]) => { + for (const n of nodes) { + if (n.type === "request") count++; + else countReqs(n.children); + } + }; + countReqs(collection.children); + if (count === 0) warnings.push("集合里没有请求"); + + return { collections: [collection], environmentVariables: dedupeVars(envVars), warnings }; +} + +function convertItems( + items: unknown[], + folder: FolderNode, + envVars: { key: string; value: string }[], + warnings: string[], +): void { + for (const itemRaw of items) { + const item = asObj(itemRaw); + if (Array.isArray(item.item)) { + const sub = createFolder(str(item.name) || "目录", []); + if (item.description) sub.description = str(item.description); + folder.children.push(sub); + convertItems(item.item, sub, envVars, warnings); + continue; + } + const request = item.request; + if (request === undefined) continue; + if (typeof request === "string") { + // url-only 形式,跳过 + warnings.push(`跳过仅有 URL 的 item:${str(item.name)}`); + continue; + } + folder.children.push(convertRequest(asObj(request), str(item.name) || "未命名请求", envVars)); + } +} + +function convertRequest( + request: Obj, + name: string, + envVars: { key: string; value: string }[], +): CollectionNode { + const urlObj = typeof request.url === "string" ? { raw: request.url } : asObj(request.url); + + // URL:raw 优先;query 行会拼进 raw,因此从 raw 里剥掉 query 后再拼 query 行 + let raw = str(urlObj.raw); + let hostPath = raw; + if (!raw) { + const host = asArr(urlObj.host).map((s) => str(s)).join("."); + const path = asArr(urlObj.path).map((s) => str(s)).join("/"); + const protocol = str(urlObj.protocol) || "https"; + const port = str(urlObj.port) ? `:${str(urlObj.port)}` : ""; + hostPath = `${protocol}://${host}${port}${path ? `/${path}` : ""}`; + raw = hostPath; + } + + const query: HttpRequestData["query"] = []; + for (const q of asArr(urlObj.query)) { + const qObj = asObj(q); + query.push(kv(str(qObj.key), str(qObj.value), qObj.disabled !== true, str(qObj.description))); + } + // raw 自带 query 时拆出来 + const qMark = hostPath.indexOf("?"); + let baseUrl = hostPath; + if (qMark >= 0) { + baseUrl = hostPath.slice(0, qMark); + if (query.length === 0) { + for (const pair of hostPath.slice(qMark + 1).split("&")) { + if (!pair) continue; + const idx = pair.indexOf("="); + query.push(kv(idx > 0 ? pair.slice(0, idx) : pair, idx > 0 ? pair.slice(idx + 1) : "")); + } + } + } + + // path 变量 {{var}} + for (const v of asArr(urlObj.variable)) { + const varObj = asObj(v); + if (varObj.key) envVars.push({ key: str(varObj.key), value: str(varObj.value) }); + } + + const headers: HttpRequestData["headers"] = []; + for (const h of asArr(request.header)) { + const hObj = asObj(h); + headers.push(kv(str(hObj.key), str(hObj.value), hObj.disabled !== true)); + } + + const cookies: HttpRequestData["cookies"] = []; + + // body + let bodyMode: HttpRequestData["bodyMode"] = "none"; + let bodyRaw = ""; + let bodyForm: HttpRequestData["bodyForm"] = []; + const body = asObj(request.body); + if (body.mode === "raw") { + bodyRaw = str(body.raw); + const lang = str(asObj(asObj(body.options).raw).language); + bodyMode = + lang === "json" || looksLikeJson(bodyRaw) + ? "json" + : lang === "xml" + ? "xml" + : lang === "html" + ? "html" + : "text"; + } else if (body.mode === "urlencoded") { + bodyMode = "urlencoded"; + for (const p of asArr(body.urlencoded)) { + const pObj = asObj(p); + bodyForm.push(kv(str(pObj.key), str(pObj.value), pObj.disabled !== true)); + } + } else if (body.mode === "formdata") { + bodyMode = "form-data"; + for (const p of asArr(body.formdata)) { + const pObj = asObj(p); + if (str(pObj.type) === "file") continue; + bodyForm.push(kv(str(pObj.key), str(pObj.value), pObj.disabled !== true)); + } + } else if (body.mode === "graphql") { + const g = asObj(body.graphql); + return createRequest("graphql", name, { + data: { + kind: "graphql", + url: baseUrl, + headers, + auth: authFromPostman(asObj(request.auth)), + query: str(g.query) || "query {\n \n}", + variables: str(g.variables) || "{}", + }, + }); + } + + return createRequest("http", name, { + data: { + kind: "http", + method: normalizeMethod(str(request.method) || "GET") as RequestMethod, + url: baseUrl, + query, + headers, + cookies, + auth: authFromPostman(asObj(request.auth)), + bodyMode, + bodyRaw, + bodyForm, + }, + description: str(request.description) || undefined, + }); +} + +function authFromPostman(auth: Obj): HttpRequestData["auth"] { + const type = str(auth.type); + const read = (key: string): string => { + for (const kvRow of asArr(auth[type])) { + const kvObj = asObj(kvRow); + if (str(kvObj.key) === key) return str(kvObj.value); + } + return ""; + }; + if (type === "bearer") return { mode: "bearer", bearer: read("token") }; + if (type === "basic") return { mode: "basic", basicUser: read("username"), basicPass: read("password") }; + if (type === "apikey") + return { + mode: "apikey", + apiKeyName: read("key"), + apiKeyValue: read("value"), + apiKeyIn: read("in") === "query" ? "query" : "header", + }; + return { mode: "none" }; +} + +function looksLikeJson(s: string): boolean { + const t = s.trimStart(); + return t.startsWith("{") || t.startsWith("["); +} + +function dedupeVars(vars: { key: string; value: string }[]) { + const map = new Map(); + for (const v of vars) if (!map.has(v.key)) map.set(v.key, v.value); + return Array.from(map, ([key, value]) => ({ key, value })); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..fcb0c02 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,118 @@ +// 传输层契约(HttpSpec / WsSpec / Transport) +export type { + HttpMethod, + HttpBodyMode, + HttpSpec, + WsSpec, + HttpResult, + WsSession, + Transport, +} from "./types"; + +// 集合数据模型 +export type { + RequestMethod, + RequestKind, + KVRow, + BodyMode, + AuthMode, + AuthConfig, + HttpRequestData, + GraphqlRequestData, + WebsocketRequestData, + SseRequestData, + SocketIoRequestData, + MqttRequestData, + RequestData, + RequestDataOf, + RequestNodeBase, + HttpApiNode, + GraphqlApiNode, + WebsocketApiNode, + SseApiNode, + SocketIoApiNode, + MqttApiNode, + RequestNode, + FolderNode, + CollectionNode, + Collection, + Environment, + HistoryEntry, + ThemeMode, + WorkspaceSettings, + WorkspaceData, + ImportResult, +} from "./model"; +export { + REQUEST_METHODS, + REQUEST_KINDS, + KIND_LABELS, + BODY_MODE_LABELS, + RAW_BODY_MODES, +} from "./model"; + +// 树操作 +export { + newId, + walk, + findNode, + mapTree, + replaceNode, + removeNode, + insertNode, + insertIntoCollection, + moveNode, + containsId, + deepCopyNode, + uniqueName, + countRequests, + searchTree, +} from "./tree"; +export type { NodeRef } from "./tree"; + +// 工厂 / 种子 +export { + kv, + defaultRequestData, + createRequest, + defaultName, + createFolder, + createCollection, + createEnvironment, + createSeedWorkspace, +} from "./factory"; + +// 变量替换 +export { replaceVariables, replaceVariablesInSpec } from "./replaceVariables"; + +// 导入 +export { + detectImportFormat, + importAuto, + importByFormat, + importBackup, + FORMAT_LABELS, +} from "./importers"; +export type { ImportFormat } from "./importers"; +export { parseCurl, importCurl, tokenizeCurl, normalizeMethod } from "./importers/curl"; +export { importOpenApi } from "./importers/openapi"; +export { importPostman } from "./importers/postman"; +export { importHar } from "./importers/har"; +export { importInsomnia } from "./importers/insomnia"; +export { importHoppscotch } from "./importers/hoppscotch"; +export { importApipost } from "./importers/apipost"; +export { importMarkdown } from "./importers/markdown"; + +// 导出 +export { + exportCollectionAs, + exportBackup, + exportOpenApiWithFolders, + exportPostman, + exportMarkdown, + downloadText, +} from "./exporters"; +export type { ExportFormat, OpenApiExportOptions } from "./exporters"; +export { exportCurl, shellQuote, buildUrlWithQuery, authHeader } from "./exporters/curl"; +export { generateCode } from "./exporters/codegen"; +export type { CodeTarget } from "./exporters/codegen"; diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts new file mode 100644 index 0000000..9911ca9 --- /dev/null +++ b/packages/core/src/model.ts @@ -0,0 +1,250 @@ +/** + * OpsxAPI 集合数据模型 —— UI / 导入导出 / 持久化共用的唯一事实源。 + */ + +export type RequestMethod = + | "GET" + | "POST" + | "PUT" + | "PATCH" + | "DELETE" + | "HEAD" + | "OPTIONS"; + +export const REQUEST_METHODS: RequestMethod[] = [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "HEAD", + "OPTIONS", +]; + +export type RequestKind = "http" | "graphql" | "websocket" | "sse" | "socketio" | "mqtt"; + +export const REQUEST_KINDS: RequestKind[] = [ + "http", + "graphql", + "websocket", + "sse", + "socketio", + "mqtt", +]; + +export const KIND_LABELS: Record = { + http: "HTTP", + graphql: "GraphQL", + websocket: "WebSocket", + sse: "SSE", + socketio: "Socket.IO", + mqtt: "MQTT", +}; + +/** 键值行:Params / Headers / Cookies / 表单 / 环境变量共用 */ +export type KVRow = { + id: string; + key: string; + value: string; + enabled: boolean; + description?: string; +}; + +export type BodyMode = + | "none" + | "json" + | "xml" + | "text" + | "html" + | "urlencoded" + | "form-data"; + +export const BODY_MODE_LABELS: Record = { + none: "none", + json: "JSON", + xml: "XML", + text: "Text", + html: "HTML", + urlencoded: "x-www-form-urlencoded", + "form-data": "form-data", +}; + +export const RAW_BODY_MODES: BodyMode[] = ["json", "xml", "text", "html"]; + +export type AuthMode = "none" | "bearer" | "basic" | "apikey"; + +export type AuthConfig = { + mode: AuthMode; + bearer?: string; + basicUser?: string; + basicPass?: string; + apiKeyName?: string; + apiKeyValue?: string; + apiKeyIn?: "header" | "query"; +}; + +export type HttpRequestData = { + kind: "http"; + method: RequestMethod; + url: string; + query: KVRow[]; + headers: KVRow[]; + cookies: KVRow[]; + auth: AuthConfig; + bodyMode: BodyMode; + bodyRaw: string; + bodyForm: KVRow[]; +}; + +export type GraphqlRequestData = { + kind: "graphql"; + url: string; + headers: KVRow[]; + auth: AuthConfig; + query: string; + variables: string; +}; + +export type WebsocketRequestData = { + kind: "websocket"; + url: string; + headers: KVRow[]; + protocols: string; +}; + +export type SseRequestData = { + kind: "sse"; + url: string; + headers: KVRow[]; + query: KVRow[]; + auth: AuthConfig; + lastEventId: string; + /** 仅解析 data: 行为 JSON */ + dataOnly: boolean; +}; + +export type SocketIoRequestData = { + kind: "socketio"; + url: string; + /** 额外连接参数(JSON:auth / transports / path …) */ + options: string; + /** 监听的服务端事件名 */ + listenEvents: string[]; + sendEvent: string; + sendArgs: string; +}; + +export type MqttRequestData = { + kind: "mqtt"; + brokerUrl: string; + username: string; + password: string; + clientId: string; + /** 订阅主题,逗号分隔 */ + subscribeTopics: string; + publishTopic: string; + publishPayload: string; + qos: 0 | 1 | 2; +}; + +export type RequestData = + | HttpRequestData + | GraphqlRequestData + | WebsocketRequestData + | SseRequestData + | SocketIoRequestData + | MqttRequestData; + +export type RequestDataOf = Extract; + +export type RequestNodeBase = { + id: string; + type: "request"; + name: string; + description?: string; + createdAt: number; + updatedAt: number; +}; + +export type HttpApiNode = RequestNodeBase & { kind: "http"; data: HttpRequestData }; +export type GraphqlApiNode = RequestNodeBase & { kind: "graphql"; data: GraphqlRequestData }; +export type WebsocketApiNode = RequestNodeBase & { kind: "websocket"; data: WebsocketRequestData }; +export type SseApiNode = RequestNodeBase & { kind: "sse"; data: SseRequestData }; +export type SocketIoApiNode = RequestNodeBase & { kind: "socketio"; data: SocketIoRequestData }; +export type MqttApiNode = RequestNodeBase & { kind: "mqtt"; data: MqttRequestData }; + +/** 判别联合:`node.kind === "http"` 时自动收窄 data 类型 */ +export type RequestNode = + | HttpApiNode + | GraphqlApiNode + | WebsocketApiNode + | SseApiNode + | SocketIoApiNode + | MqttApiNode; + +export type FolderNode = { + id: string; + type: "folder"; + name: string; + description?: string; + children: CollectionNode[]; + createdAt: number; + updatedAt: number; +}; + +export type CollectionNode = RequestNode | FolderNode; + +/** 一个集合(项目),本身是一个顶层目录 */ +export type Collection = { + id: string; + type: "collection"; + name: string; + description?: string; + children: CollectionNode[]; + createdAt: number; + updatedAt: number; +}; + +export type Environment = { + id: string; + name: string; + variables: KVRow[]; +}; + +export type HistoryEntry = { + id: string; + at: number; + kind: RequestKind; + label: string; + method?: RequestMethod; + url: string; + status?: number; + durationMs?: number; + ok: boolean; + /** 关联的请求节点 id(可回跳) */ + nodeId?: string; +}; + +export type ThemeMode = "dark" | "light"; + +export type WorkspaceSettings = { + theme: ThemeMode; + /** 请求超时(毫秒) */ + timeoutMs: number; +}; + +export type WorkspaceData = { + version: 2; + collections: Collection[]; + environments: Environment[]; + activeEnvironmentId: string | null; + history: HistoryEntry[]; + settings: WorkspaceSettings; +}; + +/** 导入结果:集合 + 建议写入环境的前缀变量(如 baseUrl) */ +export type ImportResult = { + collections: Collection[]; + environmentVariables: { key: string; value: string }[]; + warnings: string[]; +}; diff --git a/packages/core/src/replaceVariables.test.ts b/packages/core/src/replaceVariables.test.ts new file mode 100644 index 0000000..b27a932 --- /dev/null +++ b/packages/core/src/replaceVariables.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { replaceVariables, replaceVariablesInSpec } from "./replaceVariables"; + +describe("replaceVariables", () => { + it("replaces Postman-style {{var}}", () => { + expect(replaceVariables("{{baseUrl}}/api", { baseUrl: "https://x.test" })).toBe( + "https://x.test/api", + ); + }); + + it("leaves unknown vars intact", () => { + expect(replaceVariables("hello {{missing}}", {})).toBe("hello {{missing}}"); + }); + + it("supports multiple and spaced keys", () => { + expect( + replaceVariables("{{ host }}/{{path}}", { host: "h", path: "p" }), + ).toBe("h/p"); + }); + + it("replaceVariablesInSpec walks nested strings", () => { + const out = replaceVariablesInSpec( + { url: "{{baseUrl}}/v1", headers: { Authorization: "Bearer {{token}}" } }, + { baseUrl: "http://a", token: "t" }, + ); + expect(out.url).toBe("http://a/v1"); + expect(out.headers.Authorization).toBe("Bearer t"); + }); +}); diff --git a/packages/core/src/replaceVariables.ts b/packages/core/src/replaceVariables.ts new file mode 100644 index 0000000..4ce99f5 --- /dev/null +++ b/packages/core/src/replaceVariables.ts @@ -0,0 +1,35 @@ +/** + * Postman-style {{var}} replacement. + * Unknown variables are left as-is. + */ +export function replaceVariables( + input: string, + env: Record, +): string { + if (!input) return input; + return input.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match, key: string) => { + if (Object.prototype.hasOwnProperty.call(env, key)) { + return env[key] ?? ""; + } + return match; + }); +} + +export function replaceVariablesInSpec>( + spec: T, + env: Record, +): T { + const walk = (v: unknown): unknown => { + if (typeof v === "string") return replaceVariables(v, env); + if (Array.isArray(v)) return v.map(walk); + if (v && typeof v === "object") { + const out: Record = {}; + for (const [k, val] of Object.entries(v as Record)) { + out[k] = walk(val); + } + return out; + } + return v; + }; + return walk(spec) as T; +} diff --git a/packages/core/src/tree.ts b/packages/core/src/tree.ts new file mode 100644 index 0000000..82f8f29 --- /dev/null +++ b/packages/core/src/tree.ts @@ -0,0 +1,227 @@ +/** + * 集合树纯函数操作(不可变更新)。 + */ +import type { + Collection, + CollectionNode, + FolderNode, + RequestNode, +} from "./model"; + +export type NodeRef = { + collection: Collection; + /** 父目录;集合顶层时为 null */ + parent: FolderNode | null; + node: CollectionNode; +}; + +export function newId(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID(); + } + return `id-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} + +export function walk( + nodes: CollectionNode[], + fn: (node: CollectionNode, parent: FolderNode | null) => void, + parent: FolderNode | null = null, +): void { + for (const node of nodes) { + fn(node, parent); + if (node.type === "folder") walk(node.children, fn, node); + } +} + +export function findNode( + collections: Collection[], + id: string, +): NodeRef | null { + for (const collection of collections) { + let found: NodeRef | null = null; + walk(collection.children, (node, parent) => { + if (!found && node.id === id) found = { collection, parent, node }; + }); + if (found) return found; + } + return null; +} + +export function mapTree( + nodes: T[], + fn: (node: T) => T, +): T[] { + return nodes.map((node) => { + const mapped = fn(node); + if (mapped.type === "folder") { + return { ...mapped, children: mapTree(mapped.children as T[], fn) }; + } + return mapped; + }); +} + +/** 返回新的 collections 数组;未命中时原样返回 */ +export function replaceNode( + collections: Collection[], + id: string, + updater: (node: CollectionNode) => CollectionNode, +): Collection[] { + const visit = (nodes: CollectionNode[]): CollectionNode[] => + nodes.map((node) => { + if (node.id === id) return updater(node); + if (node.type === "folder") return { ...node, children: visit(node.children) }; + return node; + }); + let changed = false; + const result = collections.map((c) => { + const children = visit(c.children); + if (children !== c.children) changed = true; + return changed || children !== c.children ? { ...c, children } : c; + }); + return result; +} + +export function removeNode( + collections: Collection[], + id: string, +): Collection[] { + const visit = (nodes: CollectionNode[]): CollectionNode[] => + nodes + .filter((node) => node.id !== id) + .map((node) => + node.type === "folder" ? { ...node, children: visit(node.children) } : node, + ); + return collections.map((c) => ({ ...c, children: visit(c.children) })); +} + +export function insertNode( + collections: Collection[], + target: { parentId: string | null }, + node: CollectionNode, +): Collection[] { + const visit = (nodes: CollectionNode[]): CollectionNode[] => { + if (target.parentId === null) return [...nodes, node]; + return nodes.map((n) => { + if (n.type === "folder") { + if (n.id === target.parentId) return { ...n, children: [...n.children, node] }; + return { ...n, children: visit(n.children) }; + } + return n; + }); + }; + // parentId 为 null 时插入第一个集合顶层(调用方一般会用 appendToCollection) + return collections.map((c) => ({ ...c, children: visit(c.children) })); +} + +/** 插入到指定集合(collectionId)的指定目录(parentId 可为 null = 顶层) */ +export function insertIntoCollection( + collections: Collection[], + collectionId: string, + parentId: string | null, + node: CollectionNode, +): Collection[] { + return collections.map((c) => { + if (c.id !== collectionId) return c; + if (parentId === null) return { ...c, children: [...c.children, node] }; + const visit = (nodes: CollectionNode[]): CollectionNode[] => + nodes.map((n) => { + if (n.type !== "folder") return n; + if (n.id === parentId) return { ...n, children: [...n.children, node] }; + return { ...n, children: visit(n.children) }; + }); + return { ...c, children: visit(c.children) }; + }); +} + +/** 把节点移动到目标目录(跨集合移动也支持;null = 集合顶层) */ +export function moveNode( + collections: Collection[], + id: string, + targetCollectionId: string, + targetParentId: string | null, +): Collection[] { + const ref = findNode(collections, id); + if (!ref) return collections; + if (id === targetParentId) return collections; // 不能移动到自己下面 + // 目标是自己后代时拒绝 + if (ref.node.type === "folder" && containsId(ref.node.children, targetParentId)) { + return collections; + } + const node = ref.node; + const removed = removeNode(collections, id); + return insertIntoCollection(removed, targetCollectionId, targetParentId, node); +} + +export function containsId(nodes: CollectionNode[], id: string | null): boolean { + if (id === null) return false; + let hit = false; + walk(nodes, (n) => { + if (n.id === id) hit = true; + }); + return hit; +} + +export function deepCopyNode(node: CollectionNode, rename?: (name: string) => string): CollectionNode { + if (node.type === "request") { + return { ...node, id: newId(), name: rename ? rename(node.name) : node.name, createdAt: Date.now(), updatedAt: Date.now() }; + } + return { + ...node, + id: newId(), + name: rename ? rename(node.name) : node.name, + children: node.children.map((c) => deepCopyNode(c, rename)), + createdAt: Date.now(), + updatedAt: Date.now(), + }; +} + +export function uniqueName(siblings: CollectionNode[], base: string): string { + const names = new Set(siblings.map((n) => n.name)); + if (!names.has(base)) return base; + for (let i = 2; i < 999; i++) { + const candidate = `${base} ${i}`; + if (!names.has(candidate)) return candidate; + } + return `${base} ${Date.now()}`; +} + +export function countRequests(collection: Collection): number { + let n = 0; + walk(collection.children, (node) => { + if (node.type === "request") n++; + }); + return n; +} + +/** 名称匹配搜索(大小写不敏感),返回命中的请求/目录 id 集合(目录命中自身或含命中后代) */ +export function searchTree( + collections: Collection[], + term: string, +): Set { + const q = term.trim().toLowerCase(); + const hits = new Set(); + if (!q) return hits; + const visitFolder = (nodes: CollectionNode[], ancestors: string[]): boolean => { + let any = false; + for (const node of nodes) { + if (node.type === "request") { + const text = `${node.name} ${node.kind} ${ + node.kind === "http" ? node.data.method + " " + node.data.url : "" + }`.toLowerCase(); + if (text.includes(q)) { + hits.add(node.id); + any = true; + } + } else { + const childHit = visitFolder(node.children, [...ancestors, node.id]); + if (childHit || node.name.toLowerCase().includes(q)) { + hits.add(node.id); + any = true; + } + } + } + return any; + }; + for (const c of collections) visitFolder(c.children, [c.id]); + return hits; +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts new file mode 100644 index 0000000..2e5ee7f --- /dev/null +++ b/packages/core/src/types.ts @@ -0,0 +1,57 @@ +export type HttpMethod = + | "GET" + | "POST" + | "PUT" + | "PATCH" + | "DELETE" + | "HEAD" + | "OPTIONS"; + +export type HttpBodyMode = + | "none" + | "json" + | "raw" + | "xml" + | "text" + | "html" + | "urlencoded" + | "form" + | "form-data"; + +export type HttpSpec = { + method: HttpMethod; + url: string; + headers: Record; + query?: Record; + body?: { + mode: HttpBodyMode; + raw?: string; + }; + timeoutMs?: number; +}; + +export type WsSpec = { + url: string; + protocols?: string[]; + headers?: Record; +}; + +export type HttpResult = { + status: number; + headers: Record; + body: string; + timeMs: number; + size: number; +}; + +export interface WsSession { + onMessage(cb: (data: string) => void): void; + onStatus(cb: (s: string) => void): void; + send(data: string): void; + close(): void; +} + +export interface Transport { + http(spec: HttpSpec): Promise; + ws(spec: WsSpec): WsSession; +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..a47cede --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": [ + "src" + ] +} diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts new file mode 100644 index 0000000..f624398 --- /dev/null +++ b/packages/core/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + }, +}); diff --git a/packages/transport/package.json b/packages/transport/package.json new file mode 100644 index 0000000..1c6eee0 --- /dev/null +++ b/packages/transport/package.json @@ -0,0 +1,27 @@ +{ + "name": "@opsxapi/transport", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "@opsxapi/core": "*", + "mqtt": "^5.10.1", + "socket.io-client": "^4.8.1" + }, + "devDependencies": { + "typescript": "^5.8.2" + }, + "peerDependencies": { + "@tauri-apps/api": "^2.0.0" + }, + "peerDependenciesMeta": { + "@tauri-apps/api": { + "optional": true + } + } +} \ No newline at end of file diff --git a/packages/transport/src/browser.ts b/packages/transport/src/browser.ts new file mode 100644 index 0000000..299ebda --- /dev/null +++ b/packages/transport/src/browser.ts @@ -0,0 +1,81 @@ +import type { HttpResult, HttpSpec, Transport, WsSession, WsSpec } from "@opsxapi/core"; + +function buildUrl(spec: HttpSpec): string { + const u = new URL(spec.url); + if (spec.query) { + for (const [k, v] of Object.entries(spec.query)) { + if (v !== undefined && v !== "") u.searchParams.set(k, v); + } + } + return u.toString(); +} + +function headersToRecord(h: Headers): Record { + const out: Record = {}; + h.forEach((v, k) => { + out[k] = v; + }); + return out; +} + +export const browserTransport: Transport = { + async http(spec: HttpSpec): Promise { + const started = performance.now(); + const init: RequestInit = { + method: spec.method, + headers: { ...spec.headers }, + signal: spec.timeoutMs + ? AbortSignal.timeout(spec.timeoutMs) + : undefined, + }; + if (spec.body && spec.body.mode !== "none" && spec.method !== "GET") { + init.body = spec.body.raw ?? ""; + if (spec.body.mode === "json" && !("content-type" in Object.fromEntries( + Object.entries(spec.headers).map(([k, v]) => [k.toLowerCase(), v]), + ))) { + (init.headers as Record)["Content-Type"] = "application/json"; + } + } + const res = await fetch(buildUrl(spec), init); + const body = await res.text(); + const timeMs = Math.round(performance.now() - started); + return { + status: res.status, + headers: headersToRecord(res.headers), + body, + timeMs, + size: new TextEncoder().encode(body).length, + }; + }, + + ws(spec: WsSpec): WsSession { + const socket = new WebSocket(spec.url, spec.protocols); + const messageCbs: Array<(data: string) => void> = []; + const statusCbs: Array<(s: string) => void> = []; + const emitStatus = (s: string) => statusCbs.forEach((cb) => cb(s)); + + emitStatus("connecting"); + socket.onopen = () => emitStatus("open"); + socket.onclose = () => emitStatus("closed"); + socket.onerror = () => emitStatus("error"); + socket.onmessage = (ev) => { + const data = typeof ev.data === "string" ? ev.data : String(ev.data); + messageCbs.forEach((cb) => cb(data)); + }; + + return { + onMessage(cb) { + messageCbs.push(cb); + }, + onStatus(cb) { + statusCbs.push(cb); + }, + send(data) { + socket.send(data); + }, + close() { + socket.close(); + }, + }; + }, +}; diff --git a/packages/transport/src/detect.ts b/packages/transport/src/detect.ts new file mode 100644 index 0000000..90f4076 --- /dev/null +++ b/packages/transport/src/detect.ts @@ -0,0 +1,41 @@ +import type { Transport } from "@opsxapi/core"; +import { browserTransport } from "./browser"; +import { ginProxyTransport } from "./ginProxy"; +import { tauriNativeTransport } from "./tauriNative"; + +export type TransportKind = "tauri-native" | "gin-proxy" | "browser"; + +export type DetectOptions = { + ginBaseUrl?: string; + /** Force a transport regardless of environment */ + force?: TransportKind; +}; + +function isTauri(): boolean { + if (typeof window === "undefined") return false; + const w = window as unknown as { __TAURI_INTERNALS__?: unknown }; + return Boolean(w.__TAURI_INTERNALS__); +} + +export function detectTransport(opts: DetectOptions = {}): { + kind: TransportKind; + transport: Transport; +} { + if (opts.force === "browser") { + return { kind: "browser", transport: browserTransport }; + } + if (opts.force === "tauri-native") { + return { kind: "tauri-native", transport: tauriNativeTransport }; + } + if (opts.force === "gin-proxy") { + const base = opts.ginBaseUrl ?? ""; + return { kind: "gin-proxy", transport: ginProxyTransport(base) }; + } + + if (isTauri()) { + return { kind: "tauri-native", transport: tauriNativeTransport }; + } + + const base = opts.ginBaseUrl ?? ""; + return { kind: "gin-proxy", transport: ginProxyTransport(base) }; +} diff --git a/packages/transport/src/ginProxy.ts b/packages/transport/src/ginProxy.ts new file mode 100644 index 0000000..c419fc6 --- /dev/null +++ b/packages/transport/src/ginProxy.ts @@ -0,0 +1,35 @@ +import type { HttpResult, HttpSpec, Transport, WsSession, WsSpec } from "@opsxapi/core"; +import { browserTransport } from "./browser"; + +/** + * HTTP goes through Gin POST /v1/proxy/http. + * WS (MVP): falls back to browser WebSocket — custom headers require + * desktop/Gin tunnel later (Week 2). + */ +export function ginProxyTransport(baseUrl: string): Transport { + const root = baseUrl.replace(/\/$/, ""); + + return { + async http(spec: HttpSpec): Promise { + const res = await fetch(`${root}/v1/proxy/http`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(spec), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`gin-proxy HTTP ${res.status}: ${text}`); + } + return (await res.json()) as HttpResult; + }, + + ws(spec: WsSpec): WsSession { + // TODO(week2): custom headers need desktop/Gin WS tunnel. + // For MVP, fall back to browser WebSocket (no custom headers). + console.warn( + "[opsxapi/transport] gin-proxy WS falls back to browser WebSocket; custom headers need desktop/Gin tunnel later", + ); + return browserTransport.ws(spec); + }, + }; +} diff --git a/packages/transport/src/index.ts b/packages/transport/src/index.ts new file mode 100644 index 0000000..28efa36 --- /dev/null +++ b/packages/transport/src/index.ts @@ -0,0 +1,29 @@ +export { browserTransport } from "./browser"; +export { ginProxyTransport } from "./ginProxy"; +export { tauriNativeTransport } from "./tauriNative"; +export { detectTransport } from "./detect"; +export type { TransportKind, DetectOptions } from "./detect"; + +export { + SseParser, + httpStreamViaGin, + httpStreamDirect, + openSse, +} from "./streaming"; +export type { + StreamStatus, + StreamMeta, + StreamSession, + StreamCallbacks, + SseEvent, + SseOptions, +} from "./streaming"; + +export { wsSmart, probeGin } from "./wsSmart"; +export type { WsSmartOptions, WsTunnelInfo } from "./wsSmart"; + +export { connectSocketIo } from "./socketio"; +export type { SocketIoSession, SocketIoStatus, SocketIoOptions } from "./socketio"; + +export { connectMqtt } from "./mqtt"; +export type { MqttSession, MqttStatus, MqttMessage, MqttOptions } from "./mqtt"; diff --git a/packages/transport/src/mqtt.ts b/packages/transport/src/mqtt.ts new file mode 100644 index 0000000..27b4d11 --- /dev/null +++ b/packages/transport/src/mqtt.ts @@ -0,0 +1,120 @@ +/** + * MQTT 客户端封装(动态加载 mqtt,浏览器走 ws/wss)。 + */ +export type MqttStatus = "connecting" | "open" | "reconnecting" | "closed" | "error"; + +export type MqttMessage = { + topic: string; + payload: string; + at: number; + qos: number; +}; + +export type MqttSession = { + publish(topic: string, payload: string, qos?: 0 | 1 | 2): void; + subscribe(topics: string[]): void; + unsubscribe(topics: string[]): void; + close(): void; +}; + +export type MqttOptions = { + brokerUrl: string; + username?: string; + password?: string; + clientId?: string; + /** 初始订阅 */ + topics: string[]; + qos?: 0 | 1 | 2; + onMessage(msg: MqttMessage): void; + onStatus?(s: MqttStatus, detail?: string): void; + onError?(msg: string): void; +}; + +export function connectMqtt(opts: MqttOptions): MqttSession { + type MqttClient = { + on(ev: string, cb: (...args: unknown[]) => void): void; + subscribe(topic: string | string[], opts?: { qos?: number }, cb?: (err: unknown) => void): void; + unsubscribe(topic: string | string[], cb?: () => void): void; + publish(topic: string, message: string | Uint8Array, opts?: { qos?: number }, cb?: (err: unknown) => void): void; + end(force?: boolean): void; + }; + let client: MqttClient | null = null; + let disposed = false; + + const topics = opts.topics.map((t) => t.trim()).filter(Boolean); + + void (async () => { + try { + opts.onStatus?.("connecting"); + const mqttMod = await import("mqtt"); + const connect = (mqttMod as unknown as { connect: typeof mqttMod.default.connect }).connect ?? mqttMod.default.connect; + const url = opts.brokerUrl.trim(); + if (!/^(wss?|mqtts?):\/\//i.test(url)) { + throw new Error("浏览器端 MQTT 需 ws:// 或 wss:// 地址(例如 wss://broker.emqx.io:8084/mqtt)"); + } + client = connect(url, { + clientId: opts.clientId || `opsxapi-${Math.random().toString(16).slice(2, 10)}`, + username: opts.username || undefined, + password: opts.password || undefined, + clean: true, + reconnectPeriod: 3000, + connectTimeout: 10_000, + }) as MqttClient; + + client.on("connect", () => { + opts.onStatus?.("open"); + if (topics.length) client?.subscribe(topics, { qos: opts.qos ?? 0 }); + }); + client.on("reconnect", () => opts.onStatus?.("reconnecting")); + client.on("close", () => opts.onStatus?.("closed")); + client.on("error", (...args: unknown[]) => { + const err = args[0]; + opts.onError?.(err instanceof Error ? err.message : String(err)); + opts.onStatus?.("error"); + }); + client.on("message", (...args: unknown[]) => { + const topic = String(args[0]); + const payload = args[1] as Uint8Array; + const packet = args[2] as { qos?: number } | undefined; + const qos = packet?.qos ?? 0; + let text: string; + try { + text = new TextDecoder().decode(payload); + } catch { + text = Array.from(payload ?? []).join(","); + } + opts.onMessage({ topic, payload: text, at: Date.now(), qos }); + }); + + if (disposed) client.end(true); + } catch (e) { + opts.onError?.(`mqtt.js 加载/连接失败:${e instanceof Error ? e.message : String(e)}`); + opts.onStatus?.("error"); + } + })(); + + return { + publish(topic, payload, qos) { + if (!client) { + opts.onError?.("MQTT 尚未连接"); + return; + } + client.publish(topic.trim(), payload, { qos: qos ?? opts.qos ?? 0 }, (err) => { + if (err) opts.onError?.(`发布失败:${String(err)}`); + }); + }, + subscribe(list) { + const t = list.map((x) => x.trim()).filter(Boolean); + if (t.length) client?.subscribe(t, { qos: opts.qos ?? 0 }); + }, + unsubscribe(list) { + const t = list.map((x) => x.trim()).filter(Boolean); + if (t.length) client?.unsubscribe(t); + }, + close() { + disposed = true; + client?.end(true); + opts.onStatus?.("closed"); + }, + }; +} diff --git a/packages/transport/src/socketio.ts b/packages/transport/src/socketio.ts new file mode 100644 index 0000000..c70352a --- /dev/null +++ b/packages/transport/src/socketio.ts @@ -0,0 +1,92 @@ +/** + * Socket.IO 客户端封装(动态加载 socket.io-client)。 + */ +export type SocketIoStatus = "connecting" | "open" | "closed" | "error"; + +export type SocketIoSession = { + emit(event: string, args: unknown[]): void; + close(): void; +}; + +export type SocketIoOptions = { + url: string; + /** 附加选项 JSON:{ path?, transports?, auth?, ... } */ + optionsJson?: string; + /** 监听事件列表;空数组 = onAny 全收 */ + listenEvents: string[]; + onEvent(event: string, args: unknown[]): void; + onStatus?(s: SocketIoStatus, detail?: string): void; + onError?(msg: string): void; +}; + +type IoSocket = { + emit(ev: string, ...args: unknown[]): void; + on(ev: string, cb: (...args: unknown[]) => void): void; + onAny(cb: (ev: string, ...args: unknown[]) => void): void; + disconnect(): void; +}; + +export function connectSocketIo(opts: SocketIoOptions): SocketIoSession { + let socket: IoSocket | null = null; + let disposed = false; + + void (async () => { + try { + opts.onStatus?.("connecting"); + const { io } = await import("socket.io-client"); + const extra: Record = {}; + if (opts.optionsJson?.trim()) { + try { + Object.assign(extra, JSON.parse(opts.optionsJson)); + } catch { + opts.onError?.("连接选项不是合法 JSON,已忽略"); + } + } + const s: IoSocket = io(opts.url, { + transports: ["websocket", "polling"], + reconnection: false, + ...extra, + }) as unknown as IoSocket; + socket = s; + + s.on("connect", () => opts.onStatus?.("open")); + s.on("disconnect", (...args: unknown[]) => + opts.onStatus?.("closed", String(args[0] ?? "")), + ); + s.on("connect_error", (...args: unknown[]) => { + const err = args[0]; + const msg = err instanceof Error ? err.message : String(err); + opts.onError?.(msg); + opts.onStatus?.("error", msg); + }); + + const events = opts.listenEvents.map((e) => e.trim()).filter(Boolean); + if (events.length === 0 || events.includes("*")) { + s.onAny((ev, ...args) => opts.onEvent(ev, args)); + } else { + for (const ev of events) { + s.on(ev, (...args: unknown[]) => opts.onEvent(ev, args)); + } + } + if (disposed) s.disconnect(); + } catch (e) { + opts.onError?.(`socket.io-client 加载/连接失败:${e instanceof Error ? e.message : String(e)}`); + opts.onStatus?.("error"); + } + })(); + + return { + emit(event, args) { + if (!socket) { + opts.onError?.("Socket.IO 尚未连接"); + return; + } + socket.emit(event, ...args); + }, + close() { + disposed = true; + socket?.disconnect(); + opts.onStatus?.("closed"); + }, + }; +} diff --git a/packages/transport/src/streaming.ts b/packages/transport/src/streaming.ts new file mode 100644 index 0000000..6d9669f --- /dev/null +++ b/packages/transport/src/streaming.ts @@ -0,0 +1,351 @@ +/** + * 流式 HTTP / SSE 客户端。 + * - 直连模式:fetch + ReadableStream(目标站需允许 CORS) + * - 代理模式:Gin POST /v1/proxy/http/stream(NDJSON,穿透 SSE/chunked) + */ +import type { HttpSpec } from "@opsxapi/core"; + +export type StreamStatus = "connecting" | "open" | "closed" | "error"; + +export type StreamMeta = { + status: number; + headers: Record; +}; + +export type StreamSession = { + abort(): void; +}; + +export type StreamCallbacks = { + onMeta?(meta: StreamMeta): void; + onChunk?(text: string): void; + onEnd?(info: { timeMs: number; size: number }): void; + onStatus?(status: StreamStatus): void; + onError?(message: string): void; +}; + +/** SSE 帧 */ +export type SseEvent = { + event: string; + data: string; + id?: string; +}; + +/** 增量 SSE 解析器:喂任意分片,吐完整事件 */ +export class SseParser { + private buffer = ""; + private event = "message"; + private dataLines: string[] = []; + private lastId: string | undefined; + + constructor( + private readonly emit: (ev: SseEvent) => void, + private readonly onId?: (id: string) => void, + ) {} + + feed(text: string): void { + this.buffer += text; + let idx: number; + while ((idx = this.buffer.indexOf("\n")) >= 0) { + const line = this.buffer.slice(0, idx).replace(/\r$/, ""); + this.buffer = this.buffer.slice(idx + 1); + this.handleLine(line); + } + // 兼容无结尾换行的流结束 + } + + flush(): void { + if (this.buffer) { + this.handleLine(this.buffer.replace(/\r$/, "")); + this.buffer = ""; + } + this.dispatch(); + } + + private handleLine(line: string): void { + if (line === "") { + this.dispatch(); + return; + } + if (line.startsWith(":")) return; // 注释/心跳 + const colon = line.indexOf(":"); + const field = colon < 0 ? line : line.slice(0, colon); + let value = colon < 0 ? "" : line.slice(colon + 1); + if (value.startsWith(" ")) value = value.slice(1); + switch (field) { + case "event": + this.event = value || "message"; + break; + case "data": + this.dataLines.push(value); + break; + case "id": + this.lastId = value; + this.onId?.(value); + break; + default: + break; // retry 等忽略 + } + } + + private dispatch(): void { + if (this.dataLines.length === 0) return; + const ev: SseEvent = { + event: this.event, + data: this.dataLines.join("\n"), + id: this.lastId, + }; + this.event = "message"; + this.dataLines = []; + this.emit(ev); + } +} + +/** 通过 Gin 流式代理发送(NDJSON 协议) */ +export async function httpStreamViaGin( + ginBase: string, + spec: HttpSpec, + cb: StreamCallbacks, +): Promise { + const controller = new AbortController(); + cb.onStatus?.("connecting"); + const started = performance.now(); + try { + const res = await fetch(`${ginBase}/v1/proxy/http/stream`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(spec), + signal: controller.signal, + }); + if (!res.ok || !res.body) { + const text = await res.text().catch(() => ""); + throw new Error(`代理流式请求失败 ${res.status}: ${text.slice(0, 200)}`); + } + await pumpNdjson(res.body, cb, started); + } catch (e) { + if (controller.signal.aborted) { + cb.onStatus?.("closed"); + return { abort: () => controller.abort() }; + } + cb.onError?.(e instanceof Error ? e.message : String(e)); + cb.onStatus?.("error"); + } + return { abort: () => controller.abort() }; +} + +async function pumpNdjson( + body: ReadableStream, + cb: StreamCallbacks, + started: number, +): Promise { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let size = 0; + let metaSeen = false; + + const handleLine = (line: string) => { + if (!line.trim()) return; + let ev: { + type: string; + status?: number; + headers?: Record; + data?: string; + message?: string; + timeMs?: number; + size?: number; + }; + try { + ev = JSON.parse(line); + } catch { + return; + } + switch (ev.type) { + case "meta": + metaSeen = true; + cb.onMeta?.({ status: ev.status ?? 0, headers: ev.headers ?? {} }); + cb.onStatus?.("open"); + break; + case "chunk": { + size += ev.size ?? 0; + if (ev.data) { + const binary = atob(ev.data); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + cb.onChunk?.(decoder.decode(bytes, { stream: true })); + } + break; + } + case "end": + cb.onEnd?.({ timeMs: ev.timeMs ?? Math.round(performance.now() - started), size: ev.size ?? size }); + cb.onStatus?.("closed"); + break; + case "error": + cb.onError?.(ev.message ?? "代理流错误"); + cb.onStatus?.("error"); + break; + default: + break; + } + }; + + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let idx: number; + while ((idx = buffer.indexOf("\n")) >= 0) { + handleLine(buffer.slice(0, idx)); + buffer = buffer.slice(idx + 1); + } + } + if (buffer) handleLine(buffer); + if (!metaSeen) { + cb.onEnd?.({ timeMs: Math.round(performance.now() - started), size }); + cb.onStatus?.("closed"); + } + } finally { + reader.releaseLock?.(); + } +} + +/** 直连流式 fetch(CORS 允许时) */ +export async function httpStreamDirect(spec: HttpSpec, cb: StreamCallbacks): Promise { + const controller = new AbortController(); + cb.onStatus?.("connecting"); + const started = performance.now(); + const url = new URL(spec.url); + if (spec.query) { + for (const [k, v] of Object.entries(spec.query)) { + if (v !== "") url.searchParams.set(k, v); + } + } + try { + const init: RequestInit = { + method: spec.method, + headers: { ...spec.headers }, + signal: controller.signal, + }; + if (spec.body && spec.body.mode !== "none" && spec.method !== "GET") { + init.body = spec.body.raw ?? ""; + } + const res = await fetch(url.toString(), init); + const headers: Record = {}; + res.headers.forEach((v, k) => { + headers[k] = v; + }); + cb.onMeta?.({ status: res.status, headers }); + cb.onStatus?.("open"); + + if (!res.body) { + const text = await res.text(); + cb.onChunk?.(text); + cb.onEnd?.({ timeMs: Math.round(performance.now() - started), size: text.length }); + cb.onStatus?.("closed"); + return { abort: () => controller.abort() }; + } + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let size = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value?.length ?? 0; + cb.onChunk?.(decoder.decode(value, { stream: true })); + } + cb.onEnd?.({ timeMs: Math.round(performance.now() - started), size }); + cb.onStatus?.("closed"); + } catch (e) { + if (controller.signal.aborted) { + cb.onStatus?.("closed"); + } else { + cb.onError?.(e instanceof Error ? e.message : String(e)); + cb.onStatus?.("error"); + } + } + return { abort: () => controller.abort() }; +} + +export type SseOptions = { + url: string; + headers: Record; + query?: Record; + lastEventId?: string; + /** 强制走 Gin 代理(目标站 CORS 不允许时) */ + forceProxy?: boolean; + ginBase?: string; + onEvent(ev: SseEvent): void; + onStatus?(s: StreamStatus, meta?: StreamMeta): void; + onError?(msg: string, willFallback: boolean): void; +}; + +/** SSE 会话:先试直连,失败自动切代理 */ +export function openSse(opts: SseOptions): StreamSession { + const controller = { aborted: false, abortFns: [] as Array<() => void> }; + const run = async () => { + const parser = new SseParser((ev) => opts.onEvent(ev)); + + const callbacks: StreamCallbacks = { + onMeta: (meta) => opts.onStatus?.("open", meta), + onChunk: (text) => parser.feed(text), + onEnd: () => { + parser.flush(); + opts.onStatus?.("closed"); + }, + onError: (msg) => { + opts.onError?.(msg, false); + opts.onStatus?.("error"); + }, + }; + + const specHeaders = { ...opts.headers }; + if (opts.lastEventId) specHeaders["Last-Event-ID"] = opts.lastEventId; + + if (!opts.forceProxy) { + let direct: StreamSession | null = null; + let directFailed = false; + const directCb: StreamCallbacks = { + ...callbacks, + onError: (msg) => { + directFailed = true; + opts.onError?.(msg, true); + }, + }; + direct = await httpStreamDirect( + { + method: "GET", + url: opts.url, + headers: specHeaders, + query: opts.query, + body: { mode: "none" }, + }, + directCb, + ); + controller.abortFns.push(() => direct?.abort()); + // 直连迅速失败(连接层错误)→ 代理兜底 + if (!directFailed) return; + } + if (controller.aborted) return; + const proxy = await httpStreamViaGin( + opts.ginBase ?? "", + { + method: "GET", + url: opts.url, + headers: specHeaders, + query: opts.query, + body: { mode: "none" }, + }, + callbacks, + ); + controller.abortFns.push(() => proxy.abort()); + }; + void run(); + return { + abort() { + controller.aborted = true; + for (const fn of controller.abortFns) fn(); + opts.onStatus?.("closed"); + }, + }; +} diff --git a/packages/transport/src/tauriNative.ts b/packages/transport/src/tauriNative.ts new file mode 100644 index 0000000..518514f --- /dev/null +++ b/packages/transport/src/tauriNative.ts @@ -0,0 +1,68 @@ +import type { HttpResult, HttpSpec, Transport, WsSession, WsSpec } from "@opsxapi/core"; + +async function invoke(cmd: string, args?: Record): Promise { + try { + const { invoke: tauriInvoke } = await import("@tauri-apps/api/core"); + return await tauriInvoke(cmd, args); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + throw new Error( + `tauri-native invoke("${cmd}") failed (are you running inside OpsxAPI desktop?): ${msg}`, + ); + } +} + +export const tauriNativeTransport: Transport = { + async http(spec: HttpSpec): Promise { + return invoke("http_send", { spec }); + }, + + ws(spec: WsSpec): WsSession { + const messageCbs: Array<(data: string) => void> = []; + const statusCbs: Array<(s: string) => void> = []; + let sessionId: string | null = null; + + const emitStatus = (s: string) => statusCbs.forEach((cb) => cb(s)); + + void (async () => { + try { + emitStatus("connecting"); + sessionId = await invoke("ws_connect", { spec }); + emitStatus("open"); + // Full event listen pipeline is Week 2; stub keeps UI usable. + } catch (e) { + emitStatus("error"); + messageCbs.forEach((cb) => + cb(`[ws_connect error] ${e instanceof Error ? e.message : String(e)}`), + ); + } + })(); + + return { + onMessage(cb) { + messageCbs.push(cb); + }, + onStatus(cb) { + statusCbs.push(cb); + }, + send(data) { + if (!sessionId) { + messageCbs.forEach((cb) => cb("[ws] not connected")); + return; + } + void invoke("ws_send", { sessionId, data }).catch((e) => { + messageCbs.forEach((cb) => + cb(`[ws_send error] ${e instanceof Error ? e.message : String(e)}`), + ); + }); + }, + close() { + if (!sessionId) return; + void invoke("ws_close", { sessionId }).finally(() => { + sessionId = null; + emitStatus("closed"); + }); + }, + }; + }, +}; diff --git a/packages/transport/src/wsSmart.ts b/packages/transport/src/wsSmart.ts new file mode 100644 index 0000000..7757740 --- /dev/null +++ b/packages/transport/src/wsSmart.ts @@ -0,0 +1,190 @@ +/** + * 智能 WebSocket:无自定义 Header → 浏览器直连;有 Header → 借道 Gin WS 隧道。 + */ +import type { WsSession, WsSpec } from "@opsxapi/core"; + +export type WsTunnelInfo = { + mode: "direct" | "tunnel"; + /** tunnel 模式的会话 id */ + sessionId?: string; +}; + +export type WsSmartOptions = { + /** Gin 基地址(默认同源 /v1 反代) */ + ginBase?: string; + /** 有自定义 header 时强制走隧道(默认自动) */ + forceTunnel?: boolean; + onInfo?(info: WsTunnelInfo): void; +}; + +type Spec = WsSpec & { headers?: Record }; + +export function wsSmart(spec: Spec, opts: WsSmartOptions = {}): WsSession { + const headers = spec.headers ?? {}; + const hasCustomHeaders = Object.keys(headers).length > 0; + if (hasCustomHeaders || opts.forceTunnel) { + return tunnelSession(spec, headers, opts); + } + return directSession(spec); +} + +function directSession(spec: Spec): WsSession { + const protocols = spec.protocols?.filter(Boolean); + const socket = new WebSocket(spec.url, protocols?.length ? protocols : undefined); + return wrapSocket(socket); +} + +function wrapSocket(socket: WebSocket): WsSession { + const messageCbs: Array<(data: string) => void> = []; + const statusCbs: Array<(s: string) => void> = []; + const emitStatus = (s: string) => statusCbs.forEach((cb) => cb(s)); + + socket.onopen = () => emitStatus("open"); + socket.onclose = () => emitStatus("closed"); + socket.onerror = () => emitStatus("error"); + socket.onmessage = (ev) => { + let data: string; + if (typeof ev.data === "string") { + data = ev.data; + } else if (ev.data instanceof ArrayBuffer) { + data = new TextDecoder().decode(ev.data); + } else if (ev.data instanceof Blob) { + // Blob 异步读,转同步回调 + void ev.data.text().then((t) => messageCbs.forEach((cb) => cb(t))); + return; + } else { + data = String(ev.data); + } + messageCbs.forEach((cb) => cb(data)); + }; + + return { + onMessage(cb) { + messageCbs.push(cb); + }, + onStatus(cb) { + statusCbs.push(cb); + }, + send(data) { + socket.send(data); + }, + close() { + socket.close(); + }, + }; +} + +function tunnelSession(spec: Spec, headers: Record, opts: WsSmartOptions): WsSession { + const ginBase = opts.ginBase ?? ""; + const messageCbs: Array<(data: string) => void> = []; + const statusCbs: Array<(s: string) => void> = []; + const emitStatus = (s: string) => statusCbs.forEach((cb) => cb(s)); + + let socket: WebSocket | null = null; + let sessionId: string | null = null; + let disposed = false; + + const wsBase = ginBase + .replace(/^http:\/\//i, "ws://") + .replace(/^https:\/\//i, "wss://"); + + emitStatus("connecting"); + + void (async () => { + try { + const res = await fetch(`${ginBase}/v1/proxy/ws/connect`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url: spec.url, + headers, + protocols: spec.protocols ?? [], + }), + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`WS 隧道连接失败 ${res.status}: ${text.slice(0, 200)}`); + } + const data = (await res.json()) as { sessionId: string }; + sessionId = data.sessionId; + if (disposed) { + void closeTunnel(ginBase, sessionId); + return; + } + opts.onInfo?.({ mode: "tunnel", sessionId }); + + socket = new WebSocket(`${wsBase}/v1/proxy/ws/attach?sessionId=${encodeURIComponent(sessionId)}`); + socket.onmessage = (ev) => { + if (typeof ev.data !== "string") return; + // 隧道控制帧(json)与透传数据区分 + if (ev.data.startsWith('{"type":"open"}')) { + emitStatus("open"); + return; + } + if (ev.data.startsWith('{"type":"closed"')) { + emitStatus("closed"); + return; + } + messageCbs.forEach((cb) => cb(ev.data as string)); + }; + socket.onclose = () => emitStatus("closed"); + socket.onerror = () => emitStatus("error"); + } catch (e) { + messageCbs.forEach((cb) => cb(`[ws tunnel] ${e instanceof Error ? e.message : String(e)}`)); + emitStatus("error"); + } + })(); + + return { + onMessage(cb) { + messageCbs.push(cb); + }, + onStatus(cb) { + statusCbs.push(cb); + }, + send(data) { + if (socket && socket.readyState === WebSocket.OPEN) { + socket.send(data); + } else { + messageCbs.forEach((cb) => cb("[ws tunnel] 未连接,无法发送")); + } + }, + close() { + disposed = true; + socket?.close(); + if (sessionId) void closeTunnel(ginBase, sessionId); + emitStatus("closed"); + }, + }; +} + +async function closeTunnel(ginBase: string, sessionId: string): Promise { + try { + await fetch(`${ginBase}/v1/proxy/ws/close`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId }), + }); + } catch { + /* 后台会话回收兜底 */ + } +} + +/** Gin 是否可达(用于 UI 提示) */ +let ginProbeCache: { base: string; ok: boolean; at: number } | null = null; + +export async function probeGin(base = "", maxAgeMs = 15_000): Promise { + const now = Date.now(); + if (ginProbeCache && now - ginProbeCache.at < maxAgeMs && ginProbeCache.base === base) { + return ginProbeCache.ok; + } + try { + const res = await fetch(`${base}/health`, { signal: AbortSignal.timeout(1500) }); + const ok = res.ok; + ginProbeCache = { base, ok, at: now }; + return ok; + } catch { + ginProbeCache = { base, ok: false, at: now }; + return false; + } +} diff --git a/packages/transport/tsconfig.json b/packages/transport/tsconfig.json new file mode 100644 index 0000000..a47cede --- /dev/null +++ b/packages/transport/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": [ + "src" + ] +} diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 0000000..629f267 --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,25 @@ +{ + "name": "@opsxapi/ui", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "@opsxapi/core": "*", + "@opsxapi/transport": "*", + "zustand": "^5.0.3" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.10", + "@types/react-dom": "^19.0.4", + "typescript": "^5.8.2" + } +} diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx new file mode 100644 index 0000000..d15261c --- /dev/null +++ b/packages/ui/src/App.tsx @@ -0,0 +1,118 @@ +/** + * OpsxAPI 工作台入口:组装顶栏 / 侧栏 / 标签页 / 编辑器 / 对话框。 + */ +import { findNode } from "@opsxapi/core"; +import { useEffect } from "react"; +import { closeSession } from "./send"; +import { Icon } from "./components/icons"; +import { Logo } from "./components/Logo"; +import { useWorkspace } from "./workspaceStore"; +import { Sidebar } from "./components/Sidebar"; +import { TabBar } from "./components/TabBar"; +import { TopBar } from "./components/TopBar"; +import { GraphqlEditor } from "./components/editors/GraphqlEditor"; +import { HttpEditor } from "./components/editors/HttpEditor"; +import { MqttEditor } from "./components/editors/MqttEditor"; +import { SocketIoEditor } from "./components/editors/SocketIoEditor"; +import { SseEditor } from "./components/editors/SseEditor"; +import { WebsocketEditor } from "./components/editors/WebsocketEditor"; +import { ExportDialog } from "./components/dialogs/ExportDialog"; +import { ImportDialog } from "./components/dialogs/ImportDialog"; +import { + EnvironmentsDialog, + NewItemDialog, + RenameDialog, + SettingsDialog, +} from "./components/dialogs/WorkspaceDialogs"; + +export function OpsxWorkspace() { + const init = useWorkspace((s) => s.init); + const inited = useWorkspace((s) => s.collections.length > 0 || s.environments.length > 0); + const collections = useWorkspace((s) => s.collections); + const activeTabId = useWorkspace((s) => s.activeTabId); + const dialog = useWorkspace((s) => s.dialog); + const openTab = useWorkspace((s) => s.openTab); + + useEffect(() => { + init(); + }, [init]); + + // 关闭标签页时断开实时会话 + useEffect(() => { + useWorkspace.subscribe((state, prev) => { + const closed = prev.openTabIds.filter((id) => !state.openTabIds.includes(id)); + for (const id of closed) closeSession(id); + }); + }, []); + + const activeRef = activeTabId ? findNode(collections, activeTabId) : null; + const activeNode = activeRef?.node; + + return ( +
+ +
+ +
+ + {inited && activeNode === undefined && ( + collections[0] && openTab(collections[0].id)} /> + )} + {activeNode && activeNode.type === "request" && ( + <> + {activeNode.kind === "http" && } + {activeNode.kind === "graphql" && } + {activeNode.kind === "websocket" && } + {activeNode.kind === "sse" && } + {activeNode.kind === "socketio" && } + {activeNode.kind === "mqtt" && } + + )} + {activeNode && activeNode.type === "folder" && ( + undefined} /> + )} +
+
+ + {dialog?.type === "new" && } + {dialog?.type === "import" && } + {dialog?.type === "export" && } + {dialog?.type === "environments" && } + {dialog?.type === "settings" && } + {dialog?.type === "rename" && } +
+ ); +} + +function EmptyState({ onQuick }: { onQuick: (kind: string) => void }) { + const setDialog = useWorkspace((s) => s.setDialog); + void onQuick; + return ( +
+
+
+
OpsxAPI · 接口工作台
+
+ 从左侧选择接口,或 + + / + +
+
+ HTTP · GraphQL · WebSocket · SSE · Socket.IO · MQTT +
+ 支持 OpenAPI / Postman / Apifox / cURL / HAR 等格式导入导出 +
+
+
+ ); +} diff --git a/packages/ui/src/components/EnvBadge.tsx b/packages/ui/src/components/EnvBadge.tsx new file mode 100644 index 0000000..aa4be97 --- /dev/null +++ b/packages/ui/src/components/EnvBadge.tsx @@ -0,0 +1,61 @@ +/** + * 环境徽标:取环境名首字的彩色单字标(颜色按 id 稳定分配)。 + * 左栏列表、顶栏切换器、环境弹窗共用,帮助一眼识别当前环境。 + */ +import type { CSSProperties } from "react"; + +const PALETTE = [ + "#3b82f6", // blue + "#8b5cf6", // violet + "#10b981", // emerald + "#f59e0b", // amber + "#f43f5e", // rose + "#06b6d4", // cyan + "#ec4899", // pink + "#84cc16", // lime +]; + +function hashColor(id: string): string { + let h = 0; + for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0; + return PALETTE[h % PALETTE.length]; +} + +function firstChar(name: string): string { + const trimmed = (name || "").trim(); + if (!trimmed) return "环"; + // 取第一个字符(含中文);英文取大写 + const ch = Array.from(trimmed)[0]; + return /[a-z]/.test(ch) ? ch.toUpperCase() : ch; +} + +export function EnvBadge({ + envId, + name, + size = 24, + style, +}: { + envId: string; + name: string; + size?: number; + style?: CSSProperties; +}) { + const color = hashColor(envId); + return ( + + ); +} diff --git a/packages/ui/src/components/Logo.tsx b/packages/ui/src/components/Logo.tsx new file mode 100644 index 0000000..ccee1eb --- /dev/null +++ b/packages/ui/src/components/Logo.tsx @@ -0,0 +1,22 @@ +/** + * OpsxAPI 产品标识 —— 蓝→青渐变圆角方块 + 双向请求/响应箭头。 + */ +export function Logo({ size = 26, className }: { size?: number; className?: string }) { + return ( + + + + + + + + + + + + + + + + ); +} diff --git a/packages/ui/src/components/Sidebar.tsx b/packages/ui/src/components/Sidebar.tsx new file mode 100644 index 0000000..f5abc23 --- /dev/null +++ b/packages/ui/src/components/Sidebar.tsx @@ -0,0 +1,306 @@ +/** + * 左侧集合树:搜索 / 目录 / 请求 / 右键菜单 / 拖拽移动 / 历史记录。 + */ +import type { Collection, CollectionNode, RequestKind } from "@opsxapi/core"; +import { KIND_LABELS, findNode, searchTree } from "@opsxapi/core"; +import { useMemo, useState } from "react"; +import { useWorkspace } from "../workspaceStore"; +import { ContextMenu, cx, formatTime, type MenuItem } from "./common"; +import { Icon, type IconName } from "./icons"; +import { Logo } from "./Logo"; + +const KIND_ICONS: Record = { + http: "globe", + graphql: "hexagon", + websocket: "zap", + sse: "radio", + socketio: "plug-zap", + mqtt: "mailbox", +}; + +export function Sidebar() { + const collections = useWorkspace((s) => s.collections); + const search = useWorkspace((s) => s.search); + const setSearch = useWorkspace((s) => s.setSearch); + const history = useWorkspace((s) => s.history); + const clearHistory = useWorkspace((s) => s.clearHistory); + const openTab = useWorkspace((s) => s.openTab); + const [showHistory, setShowHistory] = useState(true); + const [menu, setMenu] = useState<{ x: number; y: number; items: MenuItem[] } | null>(null); + + const hitIds = useMemo(() => searchTree(collections, search), [collections, search]); + + return ( + + ); +} + +function TreeChildren({ + nodes, + depth, + hitIds, + collectionId, + onMenu, +}: { + nodes: CollectionNode[]; + depth: number; + hitIds: Set; + collectionId: string; + onMenu: (m: { x: number; y: number; items: MenuItem[] } | null) => void; +}) { + const expanded = useWorkspace((s) => s.expanded); + const toggleExpanded = useWorkspace((s) => s.toggleExpanded); + const search = useWorkspace((s) => s.search); + + return ( + <> + {nodes + .filter((n) => search === "" || hitIds.has(n.id)) + .map((node) => ( +
+ + {node.type === "folder" && expanded[node.id] && ( + + )} +
+ ))} + + ); +} + +function TreeRow({ + node, + depth, + hitIds, + collectionId, + isCollection = false, + onMenu, +}: { + node: CollectionNode | Collection; + depth: number; + hitIds: Set; + collectionId: string; + isCollection?: boolean; + onMenu: (m: { x: number; y: number; items: MenuItem[] } | null) => void; +}) { + const activeTabId = useWorkspace((s) => s.activeTabId); + const expanded = useWorkspace((s) => s.expanded); + const toggleExpanded = useWorkspace((s) => s.toggleExpanded); + const openTab = useWorkspace((s) => s.openTab); + const removeNodeById = useWorkspace((s) => s.removeNodeById); + const duplicateNodeById = useWorkspace((s) => s.duplicateNodeById); + const addRequest = useWorkspace((s) => s.addRequest); + const addFolder = useWorkspace((s) => s.addFolder); + const addCollection = useWorkspace((s) => s.addCollection); + const setDialog = useWorkspace((s) => s.setDialog); + const moveNodeTo = useWorkspace((s) => s.moveNodeTo); + + const [dragOver, setDragOver] = useState(false); + const isFolder = node.type === "folder" || node.type === "collection"; + const requestNode = node.type === "request" ? node : null; + const isOpen = isFolder && expanded[node.id]; + const isActive = requestNode !== null && node.id === activeTabId; + + const buildMenu = (): MenuItem[] => { + const items: MenuItem[] = []; + if (isCollection) { + items.push( + { label: "新建请求", icon: , onClick: () => setDialog({ type: "new", collectionId, parentId: null }) }, + { label: "新建目录", icon: , onClick: () => addFolder(collectionId, null) }, + { type: "sep" }, + { label: "重命名", icon: , onClick: () => setDialog({ type: "rename", id: node.id }) }, + { label: "导出该集合…", icon: , onClick: () => setDialog({ type: "export", collectionId }) }, + { type: "sep" }, + { label: "新建集合", icon: , onClick: () => addCollection() }, + ); + return items; + } + if (isFolder) { + items.push( + { label: "新建请求", icon: , onClick: () => setDialog({ type: "new", collectionId, parentId: node.id }) }, + { label: "新建子目录", icon: , onClick: () => addFolder(collectionId, node.id) }, + { type: "sep" }, + { label: "重命名", icon: "✎", onClick: () => setDialog({ type: "rename", id: node.id }) }, + { + label: "复制目录", + icon: , + onClick: () => duplicateNodeById(node.id), + }, + { type: "sep" }, + { label: "删除目录", icon: , danger: true, onClick: () => removeNodeById(node.id) }, + ); + return items; + } + // request + if (node.kind === "http") { + items.unshift({ label: "打开", icon: , onClick: () => openTab(node.id) }); + items.push( + { label: "重命名", icon: , onClick: () => setDialog({ type: "rename", id: node.id }) }, + { label: "复制接口", icon: , onClick: () => duplicateNodeById(node.id) }, + { type: "sep" }, + { label: "删除", icon: , danger: true, onClick: () => removeNodeById(node.id) }, + ); + } else { + items.push( + { label: "打开", icon: , onClick: () => openTab(node.id) }, + { label: "重命名", icon: , onClick: () => setDialog({ type: "rename", id: node.id }) }, + { label: "复制", icon: , onClick: () => duplicateNodeById(node.id) }, + { type: "sep" }, + { label: "删除", icon: , danger: true, onClick: () => removeNodeById(node.id) }, + ); + } + return items; + }; + + const label = {node.name}; + + return ( +
{ + e.dataTransfer.setData("text/opsxapi-node", node.id); + e.dataTransfer.effectAllowed = "move"; + }} + onDragOver={(e) => { + if (!isFolder) return; + e.preventDefault(); + setDragOver(true); + }} + onDragLeave={() => setDragOver(false)} + onDrop={(e) => { + e.preventDefault(); + setDragOver(false); + if (!isFolder) return; + const dragId = e.dataTransfer.getData("text/opsxapi-node"); + if (dragId && dragId !== node.id) { + const parentId = isCollection ? null : node.id; + moveNodeTo(dragId, collectionId, parentId); + if (parentId) toggleExpanded(parentId, true); + } + }} + onClick={() => { + if (isFolder) toggleExpanded(node.id); + else openTab(node.id); + }} + onContextMenu={(e) => { + e.preventDefault(); + onMenu({ x: e.clientX, y: e.clientY, items: buildMenu() }); + }} + > + {isFolder ? ( + + ) : ( + + )} + {node.type === "collection" ? ( + + ) : node.type === "folder" ? ( + + ) : requestNode && requestNode.kind === "http" ? ( + + {requestNode.data.method} + + ) : requestNode ? ( + + ) : null} + {label} +
+ ); +} diff --git a/packages/ui/src/components/TabBar.tsx b/packages/ui/src/components/TabBar.tsx new file mode 100644 index 0000000..e357960 --- /dev/null +++ b/packages/ui/src/components/TabBar.tsx @@ -0,0 +1,105 @@ +/** + * 请求标签页栏。 + */ +import { KIND_LABELS, findNode } from "@opsxapi/core"; +import { useState } from "react"; +import { useWorkspace } from "../workspaceStore"; +import { ContextMenu, cx, type MenuItem } from "./common"; + +const KIND_TAB_ICON: Record = { + http: "", + graphql: "◈", + websocket: "⚡", + sse: "📡", + socketio: "🔌", + mqtt: "📮", +}; + +export function TabBar() { + const collections = useWorkspace((s) => s.collections); + const openTabIds = useWorkspace((s) => s.openTabIds); + const activeTabId = useWorkspace((s) => s.activeTabId); + const setActiveTab = useWorkspace((s) => s.setActiveTab); + const closeTab = useWorkspace((s) => s.closeTab); + const closeOtherTabs = useWorkspace((s) => s.closeOtherTabs); + const [menu, setMenu] = useState<{ x: number; y: number; id: string } | null>(null); + + const nodes = openTabIds + .map((id) => findNode(collections, id)?.node) + .filter((n): n is NonNullable => Boolean(n)); + + const menuItems = (id: string): MenuItem[] => [ + { label: "关闭", onClick: () => closeTab(id) }, + { label: "关闭其他", onClick: () => closeOtherTabs(id) }, + { + label: "关闭全部", + onClick: () => { + for (const n of nodes) closeTab(n.id); + }, + }, + ]; + + return ( +
{ + if (e.target === e.currentTarget) e.preventDefault(); + }} + > + {nodes.length === 0 && ( +
没有打开的请求
+ )} + {nodes.map((node) => { + const active = node.id === activeTabId; + const runtime = useWorkspace.getState().runtime[node.id]; + const busy = runtime?.loading || runtime?.status === "connecting" || runtime?.status === "open"; + return ( +
setActiveTab(node.id)} + onAuxClick={(e) => e.button === 1 && closeTab(node.id)} + onContextMenu={(e) => { + e.preventDefault(); + setMenu({ x: e.clientX, y: e.clientY, id: node.id }); + }} + title={node.name} + > + {node.type === "request" && node.kind === "http" ? ( + {node.data.method} + ) : node.type === "request" ? ( + {KIND_TAB_ICON[node.kind]} + ) : null} + {node.name} + {busy && } + +
+ ); + })} + setMenu(null)} + /> +
+ ); +} diff --git a/packages/ui/src/components/TopBar.tsx b/packages/ui/src/components/TopBar.tsx new file mode 100644 index 0000000..a081eb7 --- /dev/null +++ b/packages/ui/src/components/TopBar.tsx @@ -0,0 +1,215 @@ +/** + * 顶栏:logo / 新建 / 导入导出 / 环境 / 主题 / 设置。 + */ +import { detectTransport } from "@opsxapi/transport"; +import { useEffect, useMemo, useState } from "react"; +import { useWorkspace } from "../workspaceStore"; +import { ContextMenu, cx, type MenuItem } from "./common"; +import { EnvBadge } from "./EnvBadge"; +import { Icon, type IconName } from "./icons"; +import { Logo } from "./Logo"; + +const NEW_ITEMS: { kind: string; label: string; icon: IconName }[] = [ + { kind: "http", label: "HTTP 请求", icon: "globe" }, + { kind: "graphql", label: "GraphQL", icon: "hexagon" }, + { kind: "websocket", label: "WebSocket", icon: "zap" }, + { kind: "sse", label: "SSE", icon: "radio" }, + { kind: "socketio", label: "Socket.IO", icon: "plug-zap" }, + { kind: "mqtt", label: "MQTT", icon: "mailbox" }, +]; + +export function TopBar() { + const collections = useWorkspace((s) => s.collections); + const environments = useWorkspace((s) => s.environments); + const activeEnvironmentId = useWorkspace((s) => s.activeEnvironmentId); + const settings = useWorkspace((s) => s.settings); + const setActiveEnvironment = useWorkspace((s) => s.setActiveEnvironment); + const setTheme = useWorkspace((s) => s.setTheme); + const setDialog = useWorkspace((s) => s.setDialog); + const addRequest = useWorkspace((s) => s.addRequest); + const addFolder = useWorkspace((s) => s.addFolder); + + const [newMenu, setNewMenu] = useState<{ x: number; y: number } | null>(null); + const [exportMenu, setExportMenu] = useState<{ x: number; y: number } | null>(null); + const transportKind = useMemo(() => detectTransport({ ginBaseUrl: "" }).kind, []); + + useEffect(() => { + document.title = "OpsxAPI · 接口工作台"; + }, []); + + const firstCollection = collections[0]; + + const quickCreate = (kind: string) => { + if (!firstCollection) return; + if (kind === "folder") addFolder(firstCollection.id, null); + else addRequest(firstCollection.id, null, kind as never); + }; + + const newItems: MenuItem[] = [ + ...NEW_ITEMS.map((i) => ({ + label: i.label, + icon: , + onClick: () => quickCreate(i.kind), + })), + { type: "sep" as const }, + { label: "新建目录", icon: , onClick: () => quickCreate("folder") }, + { type: "sep" as const }, + { label: "选择位置新建…", icon: , onClick: () => setDialog({ type: "new", collectionId: firstCollection?.id ?? "", parentId: null }) }, + ]; + + const activeEnv = environments.find((e) => e.id === activeEnvironmentId); + + const [envMenu, setEnvMenu] = useState<{ x: number; y: number } | null>(null); + const envItems: MenuItem[] = [ + ...environments.map((env) => ({ + label: ( + + + {env.name} + {env.id === activeEnvironmentId ? ( + ● 当前 + ) : ( + + {env.variables.filter((v) => v.enabled && v.key).length} 变量 + + )} + + ), + onClick: () => setActiveEnvironment(env.id), + })), + { type: "sep" as const }, + { + label: "不使用环境", + icon: , + onClick: () => setActiveEnvironment(null), + }, + { + label: "管理环境…", + icon: , + onClick: () => setDialog({ type: "environments" }), + }, + ]; + + return ( +
+
+
+ + OpsxAPI +
+ + HTTP · GraphQL · WS · SSE · Socket.IO · MQTT + +
+ +
+ + + + +
+ + + + +
+ + + + 文档 + + + + {transportKind} + +
+ + setNewMenu(null)} + /> + setEnvMenu(null)} + /> + ({ + label: c.name, + onClick: () => setDialog({ type: "export", collectionId: c.id }), + })), + } + : null + } + onClose={() => setExportMenu(null)} + /> +
+ ); +} diff --git a/packages/ui/src/components/VarHintInput.tsx b/packages/ui/src/components/VarHintInput.tsx new file mode 100644 index 0000000..1c640e2 --- /dev/null +++ b/packages/ui/src/components/VarHintInput.tsx @@ -0,0 +1,179 @@ +/** + * 带环境变量自动补全的输入框: + * 输入 "{{" 后弹出当前环境变量浮层,↑↓ 选择、Enter/Tab 插入 {{变量名}}。 + */ +import { useWorkspace } from "../workspaceStore"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; +import { EnvBadge } from "./EnvBadge"; + +type Candidate = { name: string; value: string; description?: string }; + +export function VarHintInput({ + value, + onChange, + placeholder, + className, + onKeyDown, + autoFocus, + spellCheck = false, + ariaLabel, +}: { + value: string; + onChange: (v: string) => void; + placeholder?: string; + className?: string; + onKeyDown?: (e: KeyboardEvent) => void; + autoFocus?: boolean; + spellCheck?: boolean; + ariaLabel?: string; +}) { + const inputRef = useRef(null); + const [query, setQuery] = useState<{ prefix: string; caret: number } | null>(null); + const [active, setActive] = useState(0); + + const candidates = useMemo(() => { + const state = useWorkspace.getState(); + const env = state.environments.find((e) => e.id === state.activeEnvironmentId); + return (env?.variables ?? []) + .filter((v) => v.enabled && v.key.trim()) + .map((v) => ({ name: v.key.trim(), value: v.value, description: v.description })); + }, [query]); // query 变化时重查(环境可能被改) + + const filtered = useMemo(() => { + if (!query) return []; + const q = query.prefix.toLowerCase(); + const list = candidates.filter((c) => c.name.toLowerCase().includes(q)); + return list.slice(0, 8); + }, [candidates, query]); + + // 光标前未闭合的 {{ 即触发 + const detect = (v: string, caret: number) => { + const before = v.slice(0, caret); + const idx = before.lastIndexOf("{{"); + if (idx < 0) return null; + const frag = before.slice(idx + 2); + if (frag.includes("}") || frag.includes("{") || frag.includes(" ") || frag.length > 40) return null; + return { prefix: frag, caret }; + }; + + const sync = () => { + const el = inputRef.current; + if (!el) return; + const q = detect(el.value, el.selectionStart ?? 0); + setQuery(q); + setActive(0); + }; + + const insert = (name: string) => { + const el = inputRef.current; + if (!el || !query) return; + const idx = el.value.slice(0, query.caret).lastIndexOf("{{"); + if (idx < 0) return; + const next = `${el.value.slice(0, idx)}{{${name}}}` + el.value.slice(query.caret); + onChange(next); + setQuery(null); + const caretTarget = idx + name.length + 4; + requestAnimationFrame(() => { + el.focus(); + el.setSelectionRange(caretTarget, caretTarget); + }); + }; + + // 过滤结果为空时收起 + useEffect(() => { + if (query && filtered.length === 0) setQuery(null); + }, [filtered.length, query]); + + // 外部清空时重置 + useLayoutEffect(() => { + if (!value) setQuery(null); + }, [value]); + + const handleKeyDown = (e: KeyboardEvent) => { + if (query && filtered.length > 0) { + if (e.key === "ArrowDown") { + e.preventDefault(); + e.stopPropagation(); + setActive((a) => Math.min(a + 1, filtered.length - 1)); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + e.stopPropagation(); + setActive((a) => Math.max(a - 1, 0)); + return; + } + if (e.key === "Enter" || e.key === "Tab") { + e.preventDefault(); + e.stopPropagation(); + insert(filtered[active].name); + return; + } + if (e.key === "Escape") { + e.stopPropagation(); + setQuery(null); + return; + } + } + onKeyDown?.(e); + }; + + const envName = useWorkspace( + (s) => s.environments.find((e) => e.id === s.activeEnvironmentId)?.name ?? "", + ); + const envId = useWorkspace((s) => s.activeEnvironmentId); + + return ( +
+ { + const el = e.currentTarget; + onChange(el.value); + // 同步阶段探测(input 事件时 DOM 已更新,避免 React 并发提交的竞态) + setQuery(detect(el.value, el.selectionStart ?? el.value.length)); + setActive(0); + }} + onKeyUp={sync} + onClick={sync} + onBlur={() => setTimeout(() => setQuery(null), 120)} + onKeyDown={handleKeyDown} + /> + {query && filtered.length > 0 && ( +
e.preventDefault()}> +
+ {envId ? ( + <> + + {envName || "当前环境"} + + ) : ( + 未选择环境 + )} + {"输入 {{变量名}} 引用 · Enter 插入"} +
+ {filtered.map((c, i) => ( + + ))} +
+ )} +
+ ); +} diff --git a/packages/ui/src/components/common.tsx b/packages/ui/src/components/common.tsx new file mode 100644 index 0000000..476128c --- /dev/null +++ b/packages/ui/src/components/common.tsx @@ -0,0 +1,357 @@ +/** + * 基础组件:Modal / 右键菜单 / JSON 高亮 / KV 编辑器 / 复制按钮。 + */ +import type { KVRow } from "@opsxapi/core"; +import { kv } from "@opsxapi/core"; +import { Icon } from "./icons"; +import { VarHintInput } from "./VarHintInput"; +import { useEffect, useRef, useState, type ReactNode } from "react"; + +export function cx(...xs: Array): string { + return xs.filter(Boolean).join(" "); +} + +/* ---------------- Modal ---------------- */ + +export function Modal({ + title, + onClose, + children, + footer, + width, +}: { + title: ReactNode; + onClose: () => void; + children: ReactNode; + footer?: ReactNode; + width?: string; +}) { + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onClose]); + + return ( +
e.target === e.currentTarget && onClose()}> +
+
+
{title}
+ +
+
{children}
+ {footer &&
{footer}
} +
+
+ ); +} + +/* ---------------- 右键菜单 ---------------- */ + +export type MenuItem = + | { type: "sep" } + | { + type?: "item"; + label: ReactNode; + icon?: ReactNode; + danger?: boolean; + onClick: () => void; + }; + +export function ContextMenu({ + menu, + onClose, +}: { + menu: { x: number; y: number; items: MenuItem[] } | null; + onClose: () => void; +}) { + const ref = useRef(null); + + useEffect(() => { + if (!menu) return; + const close = () => onClose(); + const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose(); + window.addEventListener("click", close); + window.addEventListener("resize", close); + window.addEventListener("keydown", onKey); + return () => { + window.removeEventListener("click", close); + window.removeEventListener("resize", close); + window.removeEventListener("keydown", onKey); + }; + }, [menu, onClose]); + + if (!menu) return null; + + const style: React.CSSProperties = { + left: Math.min(menu.x, window.innerWidth - 210), + top: Math.min(menu.y, window.innerHeight - menu.items.length * 30 - 20), + }; + + return ( +
e.stopPropagation()}> + {menu.items.map((item, i) => + "type" in item && item.type === "sep" ? ( +
+ ) : ( + + ), + )} +
+ ); +} + +/* ---------------- JSON 高亮 ---------------- */ + +export function JsonView({ text, className }: { text: string; className?: string }) { + let value: unknown; + try { + value = JSON.parse(text); + } catch { + return {text}; + } + return
{renderJson(value, 0)}
; +} + +function renderJson(value: unknown, indent: number, key?: string): ReactNode { + const pad = " ".repeat(indent); + const padEnd = " ".repeat(Math.max(0, indent - 1)); + const keyPart = key !== undefined ? {JSON.stringify(key)}: : null; + + if (value === null) { + return ( +
+ {pad} + {keyPart} + null +
+ ); + } + if (typeof value === "string") { + return ( +
+ {pad} + {keyPart} + {JSON.stringify(value)} +
+ ); + } + if (typeof value === "number") { + return ( +
+ {pad} + {keyPart} + {String(value)} +
+ ); + } + if (typeof value === "boolean") { + return ( +
+ {pad} + {keyPart} + {String(value)} +
+ ); + } + if (Array.isArray(value)) { + if (value.length === 0) { + return ( +
+ {pad} + {keyPart}[] +
+ ); + } + return ( +
+ {pad} + {keyPart}{"["} + {value.map((v, i) => ( +
{renderJson(v, indent + 1)}
+ ))} + {padEnd}] +
+ ); + } + // object + const entries = Object.entries(value as Record); + if (entries.length === 0) { + return ( +
+ {pad} + {keyPart}{"{}"} +
+ ); + } + return ( +
+ {pad} + {keyPart}{"{"} + {entries.map(([k, v]) => ( +
{renderJson(v, indent + 1, k)}
+ ))} + {padEnd}{"}"} +
+ ); +} + +/* ---------------- 复制按钮 ---------------- */ + +export function CopyButton({ text, label = "复制" }: { text: string; label?: string }) { + const [done, setDone] = useState(false); + return ( + + ); +} + +/* ---------------- KV 编辑器 ---------------- */ + +export function KVEditor({ + rows, + onChange, + onAdd, + onUpdate, + onRemove, + keyPlaceholder = "Key", + valuePlaceholder = "Value", + withDescription = false, + compact = false, +}: { + rows: KVRow[]; + onChange?: (rows: KVRow[]) => void; + onAdd?: () => void; + onUpdate?: (rowId: string, patch: Partial) => void; + onRemove?: (rowId: string) => void; + keyPlaceholder?: string; + valuePlaceholder?: string; + withDescription?: boolean; + compact?: boolean; +}) { + const update = (id: string, patch: Partial) => { + if (onUpdate) onUpdate(id, patch); + else onChange?.(rows.map((r) => (r.id === id ? { ...r, ...patch } : r))); + }; + const remove = (id: string) => { + if (onRemove) onRemove(id); + else onChange?.(rows.filter((r) => r.id !== id)); + }; + const add = () => { + if (onAdd) onAdd(); + else onChange?.([...rows, kv()]); + }; + const enabledCount = rows.filter((r) => r.enabled && r.key.trim()).length; + + return ( +
+ {!compact && rows.length > 0 && ( +
+ 共 {rows.length} 行 · 启用 {enabledCount} 行 +
+ )} + {rows.map((r) => ( +
+ update(r.id, { enabled: e.target.checked })} + className="mx-auto accent-[var(--accent)]" + aria-label="启用" + /> + update(r.id, { key: e.target.value })} + /> + update(r.id, { value: v })} + /> + {withDescription && ( + update(r.id, { description: e.target.value })} + /> + )} + +
+ ))} + +
+ ); +} + +/* ---------------- 状态点 ---------------- */ + +export function StatusDot({ status }: { status: string }) { + const cls = + status === "open" + ? "ops-dot-open" + : status === "connecting" || status === "reconnecting" + ? "ops-dot-connecting" + : status === "error" + ? "ops-dot-error" + : "ops-dot-closed"; + return ; +} + +export function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / 1024 / 1024).toFixed(2)} MB`; +} + +export function formatTime(at: number): string { + const d = new Date(at); + return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}:${String( + d.getSeconds(), + ).padStart(2, "0")}`; +} diff --git a/packages/ui/src/components/dialogs/ExportDialog.tsx b/packages/ui/src/components/dialogs/ExportDialog.tsx new file mode 100644 index 0000000..c025523 --- /dev/null +++ b/packages/ui/src/components/dialogs/ExportDialog.tsx @@ -0,0 +1,134 @@ +/** + * 导出对话框:OpenAPI JSON/YAML、Postman、Markdown、全量备份。 + */ +import { countRequests, exportBackup, exportCollectionAs, type ExportFormat } from "@opsxapi/core"; +import { useMemo, useState } from "react"; +import { useWorkspace } from "../../workspaceStore"; +import { CopyButton, Modal, cx } from "../common"; +import { Icon } from "../icons"; + +const FORMATS: { key: ExportFormat; label: string; ext: string; hint: string }[] = [ + { key: "openapi-json", label: "OpenAPI 3.0 · JSON", ext: "json", hint: "标准 OpenAPI 规范,可回导 Apifox/Swagger" }, + { key: "openapi-yaml", label: "OpenAPI 3.0 · YAML", ext: "yaml", hint: "标准 OpenAPI 规范(YAML)" }, + { key: "postman", label: "Postman v2.1", ext: "json", hint: "Postman Collection,可直接导入 Postman" }, + { key: "markdown", label: "Markdown 接口文档", ext: "md", hint: "带目录的接口文档,可放 Wiki" }, +]; + +export function ExportDialog({ collectionId }: { collectionId: string }) { + const collections = useWorkspace((s) => s.collections); + const environments = useWorkspace((s) => s.environments); + const setDialog = useWorkspace((s) => s.setDialog); + const [selectedCollection, setSelectedCollection] = useState(collectionId); + const [backupMode, setBackupMode] = useState(collections.length === 0); + const [format, setFormat] = useState("openapi-json"); + + const collection = collections.find((c) => c.id === selectedCollection) ?? collections[0]; + + const { content, filename } = useMemo(() => { + if (!collection) return { content: "", filename: "" }; + const meta = FORMATS.find((f) => f.key === format) ?? FORMATS[0]; + const safeName = collection.name.replace(/[\\/:*?"<>|\s]+/g, "-"); + try { + return { + content: exportCollectionAs(format, collection), + filename: `${safeName}.${meta.ext}`, + }; + } catch (e) { + return { content: `导出失败:${e instanceof Error ? e.message : String(e)}`, filename: "error.txt" }; + } + }, [collection, format]); + + const backupContent = useMemo( + () => exportBackup(collections, environments), + [collections, environments], + ); + + const finalContent = backupMode ? backupContent : content; + const finalFilename = backupMode + ? `opsxapi-backup-${new Date().toISOString().slice(0, 10)}.json` + : filename; + + const download = () => { + const blob = new Blob([finalContent], { type: "text/plain;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = finalFilename; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( + setDialog(null)} + width="720px" + footer={ + <> + + + + + } + > +
+ + + {!backupMode && ( + <> + {collections.length > 1 && ( +
+ 集合 + +
+ )} + +
+ {FORMATS.map((f) => ( + + ))} +
+ + )} + +
+          {finalContent.slice(0, 4000)}
+          {finalContent.length > 4000 ? "\n…(预览截断,导出为完整内容)" : ""}
+        
+
+
+ ); +} diff --git a/packages/ui/src/components/dialogs/ImportDialog.tsx b/packages/ui/src/components/dialogs/ImportDialog.tsx new file mode 100644 index 0000000..2d1073b --- /dev/null +++ b/packages/ui/src/components/dialogs/ImportDialog.tsx @@ -0,0 +1,288 @@ +/** + * 导入对话框:粘贴 / 文件 / URL(Swagger URL)三种来源 + 格式自动识别。 + */ +import type { Collection } from "@opsxapi/core"; +import { + FORMAT_LABELS, + detectImportFormat, + importByFormat, + countRequests, + type ImportFormat, +} from "@opsxapi/core"; +import { useMemo, useRef, useState } from "react"; +import { useWorkspace } from "../../workspaceStore"; +import { Modal, cx } from "../common"; +import { Icon } from "../icons"; + +type Source = "paste" | "file" | "url"; + +const ALL_FORMATS: (ImportFormat | "auto")[] = [ + "auto", + "openapi", + "postman", + "apipost", + "curl", + "har", + "insomnia", + "hoppscotch", + "markdown", + "backup", +]; + +type ImportSummary = { + collections: Collection[]; + requestCount: number; + envVarCount: number; + warnings: string[]; +}; + +export function ImportDialog() { + const setDialog = useWorkspace((s) => s.setDialog); + const importCollections = useWorkspace((s) => s.importCollections); + + const [source, setSource] = useState("paste"); + const [text, setText] = useState(""); + const [url, setUrl] = useState(""); + const [format, setFormat] = useState("auto"); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [summary, setSummary] = useState(null); + const fileRef = useRef(null); + + const detected = useMemo(() => (text.trim() ? detectImportFormat(text) : null), [text]); + + const effectiveFormat = (): ImportFormat => { + if (format !== "auto") return format; + if (!text.trim()) throw new Error("请先粘贴内容 / 选择文件 / 填写 URL"); + const d = detectImportFormat(text); + if (d === "unknown") throw new Error("无法识别格式,请手动选择格式"); + return d; + }; + + const doImport = (raw?: string) => { + setError(null); + setBusy(true); + try { + const content = raw ?? text; + if (!content.trim()) throw new Error("内容为空"); + const fmt = effectiveFormat(); + const result = importByFormat(content, fmt); + const requestCount = result.collections.reduce((acc, c) => acc + countRequests(c), 0); + importCollections(result.collections, result.environmentVariables); + setSummary({ + collections: result.collections, + requestCount, + envVarCount: result.environmentVariables.length, + warnings: result.warnings, + }); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + + const loadFile = (file: File) => { + const reader = new FileReader(); + reader.onload = () => { + const content = String(reader.result ?? ""); + setText(content); + setSource("paste"); + setTimeout(() => doImport(content), 30); + }; + reader.readAsText(file); + }; + + const loadUrl = async () => { + setError(null); + setBusy(true); + try { + if (!url.trim()) throw new Error("请填写 URL"); + // 经 Gin 代理拉取,避开 CORS + const res = await fetch("/v1/proxy/http", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + method: "GET", + url: url.trim(), + headers: { Accept: "application/json, text/yaml, text/plain, */*" }, + body: { mode: "none" }, + }), + }); + if (!res.ok) { + const t = await res.text().catch(() => ""); + throw new Error(`拉取失败 ${res.status}:${t.slice(0, 160)}`); + } + const payload = (await res.json()) as { body?: string; status?: number }; + if (payload.status && payload.status >= 400) { + throw new Error(`目标返回 HTTP ${payload.status}`); + } + const content = payload.body ?? ""; + if (!content.trim()) throw new Error("URL 返回内容为空"); + setText(content); + setSource("paste"); + doImport(content); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + + return ( + setDialog(null)} + width="680px" + footer={ + summary ? ( + + ) : ( + <> + + + + ) + } + > + {summary ? ( +
+
导入成功 ✓
+
+ {summary.collections.map((c) => ( +
+ 📚 + {c.name} + · {countRequests(c)} 个接口 +
+ ))} +
+ {summary.envVarCount > 0 && ( +
+ 已向当前环境追加 {summary.envVarCount} 个变量(如 baseUrl / 路径参数) +
+ )} + {summary.warnings.length > 0 && ( +
+ {summary.warnings.map((w, i) => ( +
⚠ {w}
+ ))} +
+ )} +
+ ) : ( +
+ {/* 来源切换 */} +
+ {( + [ + ["paste", "粘贴文本 / cURL"], + ["file", "文件导入"], + ["url", "URL 导入(Swagger)"], + ] as [Source, string][] + ).map(([key, label]) => ( + + ))} +
+ + {source === "paste" && ( +