chore: 官网拆分 homepage + 关于页瘦身 + 端口 5175(当前可用状态基线)
This commit is contained in:
1 parent
5eb11826db
commit
be151cb68f
85 files changed
+4420
-1530
No files matched your search
+18
-7
@@ -5,8 +5,12 @@ node_modules
|
||||
|
||||
# Build outputs
|
||||
dist
|
||||
# leftover local copy; installers live in src-tauri/target/<triple>/release/bundle/
|
||||
release
|
||||
*.tsbuildinfo
|
||||
# tsc project-reference emit of vite.config.ts
|
||||
vite.config.js
|
||||
vite.config.d.ts
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
@@ -28,7 +32,7 @@ release
|
||||
Thumbs.db
|
||||
|
||||
# Gin site binary
|
||||
apps/server/bin/
|
||||
server/bin/
|
||||
|
||||
# Logs
|
||||
logs
|
||||
@@ -61,12 +65,12 @@ download/*.csr
|
||||
download/*.certSigningRequest
|
||||
|
||||
# Gin 官网安装包挂载目录(大文件勿提交)
|
||||
apps/server/download/*.dmg
|
||||
apps/server/download/*.exe
|
||||
apps/server/download/*.AppImage
|
||||
apps/server/download/*.msi
|
||||
!apps/server/download/README.md
|
||||
!apps/server/download/SHA256SUMS.txt
|
||||
server/download/*.dmg
|
||||
server/download/*.exe
|
||||
server/download/*.AppImage
|
||||
server/download/*.msi
|
||||
!server/download/README.md
|
||||
!server/download/SHA256SUMS.txt
|
||||
|
||||
# Database
|
||||
*.db
|
||||
@@ -76,3 +80,10 @@ apps/server/download/*.msi
|
||||
# Local agent / IDE workspace
|
||||
.zcode/
|
||||
.claude/
|
||||
|
||||
# UI 验收残留
|
||||
.ui-review/
|
||||
.ui-review-*/
|
||||
|
||||
# 官网静态产物
|
||||
homepage/dist/
|
||||
@@ -7,45 +7,48 @@
|
||||
## 产品形态(硬边界)
|
||||
|
||||
```text
|
||||
浏览器 → 只看官网 + 文档(Gin :8789)
|
||||
浏览器 → 官网/docs 为独立静态站(homepage/dist,可部署自有域名;本地 :8789 亦可预览)
|
||||
操作 → 只走桌面 Tauri(./run.sh gui)
|
||||
Vite :1420 → 仅桌面 WebView 源,不是浏览器产品
|
||||
Vite :5175 → 仅桌面 WebView 源,不是浏览器产品
|
||||
```
|
||||
|
||||
| 职责 | 放哪 |
|
||||
| -------------------------------------------------- | --------------------------------------------------------------- |
|
||||
| 桌面操作(SSH/SFTP/转发/RDP/VNC/串口/本地终端/AI) | **本仓** `src/` + `src-tauri/` |
|
||||
| 产品官网 + 用户文档 | **本仓** `apps/server`(Gin embed;原 `site-opsxshell` 已并入) |
|
||||
| 产品官网 + 用户文档 | **本仓** `homepage/`(静态站源 → tools/homepage-build 出 dist) |
|
||||
| 结账 / 在线授权 | 独立服务 **opsx-licence**;桌面只验 `LIC-` / `LICOFF-` |
|
||||
|
||||
同族对照:**opsxdb / opsxapi** 同形态(浏览器=官网/docs,操作=桌面)。Shell 的 Gin **只有** site/docs/health/download,**没有**操作 API——原生能力全在 Rust。
|
||||
同族对照:**opsxdb / opsxapi** 同形态(浏览器=官网/docs,操作=桌面)。Shell 的 Gin 只有 health/download + 本地静态托管 homepage/dist 预览,**没有**操作 API——原生能力全在 Rust。
|
||||
|
||||
`src/` 里的 React 是 **Tauri WebView UI**,不是浏览器 SaaS 客户端。
|
||||
|
||||
### 目录对齐说明
|
||||
|
||||
- 已对齐:`apps/server`(Gin 官网+docs)、`./run.sh web|gui` 场景。
|
||||
- **暂不**把 `src/` / `src-tauri/` 物理迁到 `apps/web` / `apps/desktop`(db/api 的 monorepo 布局);语义已统一,大挪目录收益小、打断现有打包与 CI。需要时再单独立项。
|
||||
- 已对齐:根级 `src/` + `src-tauri/` + `server/` + `homepage/` + `tools/homepage-build`,`./run.sh web|gui`,Vite :5175。
|
||||
- **不要重建**已废弃的 `apps/`、`packages/`、仓库根 `release/`、`.ui-review/`(db/api 旧 monorepo 空壳)。语义已统一,勿再把 `src/` / `src-tauri/` 迁回 `apps/web` / `apps/desktop`。
|
||||
- **安装包走 Tauri 2 标准目录** `src-tauri/target/{debug,release}/bundle/`(指定 triple 时为 `src-tauri/target/<triple>/release/bundle/`)。不要再造仓库根 `release/` 发版暂存;框架给了标准路径就用标准路径。`./run.sh gui` 是 `tauri dev` 裸二进制,macOS Dock 可能显示默认图标——这是 Tauri 开发态通病,不是缺根目录 `release/`,正式 `.app` / DMG 图标以 `bundle.icon` 为准。
|
||||
|
||||
## 架构(怎么改代码)
|
||||
|
||||
```text
|
||||
UI / Zustand → src/platform/* → Tauri command → src-tauri/services/*
|
||||
apps/server → Gin:/ 官网 · /docs · /health · /download(无操作面)
|
||||
UI / Zustand → src/platform/* → Tauri command → src-tauri/src/services/*
|
||||
server → Gin:/health · /download + 静态托管 homepage/dist(无操作面)
|
||||
```
|
||||
|
||||
- `src/platform/`:唯一 `invoke` / 事件入口;组件与 store **不直接** `invoke`。
|
||||
- `src/features/`:逐步提取功能域模型、Hook 和控件;目前从 settings 开始,页面仍在 `components/`,不要求整仓搬目录。
|
||||
- `src/platform/`:唯一 `invoke` / 事件入口;组件与 store **不直接** `invoke`,复用 `platform/invoke.ts`,不另建 `lib/tauri.ts`。
|
||||
- `src-tauri/src/commands/`:参数边界 + 统一响应;业务在 `services/`。
|
||||
- `shared/types/`:前后端契约;新能力先契约再 command,再补 `desktop.d.ts` / `platform` / `test/setup.ts`。
|
||||
- 文案:`src/i18n` 七语种同步;`test/i18n/localeKeys.test.ts` 校验 key。
|
||||
- 官网 HTML:`apps/server/internal/site/`;用户文档 Markdown:`apps/server/internal/docs/content/`(改完重编 server)。
|
||||
- 官网落地页 + 用户文档:`homepage/`(改完跑 `tools/homepage-build` 重新生成 dist)。Gin 只静态托管 `homepage/dist`,不要把文档源塞回 `server/`。
|
||||
|
||||
扩展顺序:契约 → services → command → platform → UI → 测试。
|
||||
|
||||
### IPC
|
||||
|
||||
- 业务失败(SSH/SFTP/转发/会话/本地文件):信封 `{ success, error, errorCode? }`。
|
||||
- RDP/VNC 运行态:throw,视图里 catch。
|
||||
- RDP/VNC 运行态:throw,视图里 catch。`invokeNative` 将拒绝转换为 `NativeCommandError`,保留错误码与原始原因;业务失败信封仍由调用方检查 `success`。
|
||||
- Rust 错误复用 `domain/error.rs` 的 `AppError` / `codes`;目前仅部分文件操作接入,不代表全量迁移完成。
|
||||
- 文件对话框 command 须在主线程;磁盘 I/O 用 `spawn_blocking`。
|
||||
|
||||
## 硬边界(少而硬)
|
||||
@@ -58,7 +61,7 @@ apps/server → Gin:/ 官网 · /docs · /health · /download(无操作
|
||||
6. 会话运行态(SSH/转发/RDP/VNC worker)只在原生内存;断开即清理桌面 worker。
|
||||
7. 不把结账/签发、CLI/MCP/插件宿主、堡垒机审批做成默认能力。
|
||||
8. 不恢复旧 Electron 桌面壳、旧构建链。
|
||||
9. **不要**把桌面操作做成浏览器工作台;`:1420` 不是对外入口。
|
||||
9. **不要**把桌面操作做成浏览器工作台;`:5175` 不是对外入口。
|
||||
|
||||
## 领域要点(细节跟现有代码,勿另起炉灶)
|
||||
|
||||
@@ -66,9 +69,10 @@ apps/server → Gin:/ 官网 · /docs · /health · /download(无操作
|
||||
- **SSH/SFTP**:协议心跳保链路(勿向 Shell 灌回车空格);意外断开有限次重连;SFTP 分清 upload/download 与路径方向。
|
||||
- **RDP/VNC**:仅桌面端;有限次重连;压缩/帧路径跟 `services/desktop/`,勿新开解压栈。
|
||||
- **AI**:新配置先临时凭据实测再落盘;远程执行只复用当前 SSH;critical 硬拒绝。
|
||||
- **设置与主题**:设置经 `settingsStore` 串行保存,失败撤回对应修改并保留后续待保存项;不要绕过 store 并发写 `app-settings`。主题复用 `themes/app-theme.ts` 与 `useTheme`。
|
||||
- **授权**:只对接 opsx-licence;本地验 `LIC-` / `LICOFF-`。
|
||||
|
||||
实现时优先对照邻近模块与单测,而不是在本文件追加细则。
|
||||
实现时优先对照邻近模块与单测,而不是在本文件追加细则。当前分层与未完成项见 [架构说明](docs/architecture.md) 和 [重构进度](docs/architecture-refactor.md)。
|
||||
|
||||
## 命令
|
||||
|
||||
@@ -79,7 +83,7 @@ apps/server → Gin:/ 官网 · /docs · /health · /download(无操作
|
||||
./run.sh stop | status
|
||||
npm run test:patrol # 改功能优先
|
||||
npm run acceptance:quick # 或 acceptance:full
|
||||
./scripts/build.sh # 当前平台安装包 → release/
|
||||
./scripts/build.sh # 当前平台安装包 → src-tauri/target/<triple>/release/bundle/
|
||||
./scripts/macos-notarize.sh # 新 DMG 公证(有凭据时)
|
||||
```
|
||||
|
||||
@@ -91,3 +95,9 @@ npm run acceptance:quick # 或 acceptance:full
|
||||
- **跟仓内模式**:新功能对齐现有 `platform` / `services` 分包,禁止平行架构。
|
||||
- **少造文档**:非用户要求不要新增长篇 markdown;改行为同步必要注释即可。
|
||||
- **测试够用**:功能改动跑 patrol + 相关单测;不必为每个微规则再加一篇规范。
|
||||
|
||||
## 本地数据路径规则(三族统一)
|
||||
|
||||
- **用户数据**(连接配置/集合/AI key/驱动 jar):纯 Rust 项目走 Tauri `app_data_dir()`(平台规范);**有 Go sidecar 的走 `~/.<程序名>/` dotfile**(程序名叫什么目录就叫什么,如本项目 `~/.opsxshell/`;Go 拿不到 Tauri 路径 API,dotfile 是最干净方案)。**同一项目用户数据只允许一处**,跟着能力层走。
|
||||
- **壳状态**(窗口位置等可丢弃项):`app_data_dir()`,由 tauri 插件自管。
|
||||
- **禁止相对路径存数据**(桌面 cwd 不定,数据会散落)。
|
||||
@@ -9,33 +9,36 @@ Tauri 2 + Rust + React 运维**桌面**终端:SSH、SFTP、代理/端口转发
|
||||
**产品形态**(对齐 opsxdb / opsxapi):
|
||||
|
||||
```text
|
||||
浏览器 → Gin 官网 + docs(./run.sh web → :8789)
|
||||
浏览器 → 官网/docs 独立静态站(homepage/dist;本地 :8789 预览)
|
||||
操作 → 仅桌面(./run.sh gui)
|
||||
```
|
||||
|
||||
Gin **没有**操作 API;不要把 Vite `:1420` 当浏览器工作台。
|
||||
Gin **没有**操作 API;不要把 Vite `:5175` 当浏览器工作台。
|
||||
|
||||
## 和谁分工
|
||||
|
||||
| 仓 / 服务 | 职责 |
|
||||
| ------------------------------ | -------------------------------------------------- |
|
||||
| **本仓** `src/` + `src-tauri/` | 桌面客户端(唯一操作入口) |
|
||||
| **本仓** `apps/server` | 官网落地页 + 用户文档(已并入原 `site-opsxshell`) |
|
||||
| `opsx-licence` | 结账与授权(桌面只验 LIC / LICOFF) |
|
||||
| `opsxdb` / `opsxapi` | 同族;同样「浏览器=官网/docs,操作=桌面」 |
|
||||
| 仓 / 服务 | 职责 |
|
||||
| ------------------------------ | -------------------------------------------------------------------------- |
|
||||
| **本仓** `src/` + `src-tauri/` | 桌面客户端(唯一操作入口) |
|
||||
| **本仓** `homepage/` | 官网落地页 + 用户文档源(tools/homepage-build 出 dist 纯静态,可独立部署) |
|
||||
| `opsx-licence` | 结账与授权(桌面只验 LIC / LICOFF) |
|
||||
| `opsxdb` / `opsxapi` | 同族;同样「浏览器=官网/docs,操作=桌面」 |
|
||||
|
||||
## 目录
|
||||
|
||||
- `src/`:React UI(进 Tauri WebView)
|
||||
- `src/`:React UI(进 Tauri WebView);`src/features/settings/` 已提取设置模型、Hook 和控件
|
||||
- `src-tauri/`:Rust 原生能力
|
||||
- `apps/server/`:Gin site/docs/health(薄服务,无操作面)
|
||||
- `server/`:Gin health/download + 本地静态托管 homepage/dist 预览(薄服务,无操作面)
|
||||
- `homepage/`:官网落地页 + 产品文档源 → `homepage/dist/` 纯静态(自有域名部署)
|
||||
- `shared/`:契约;`src/platform/`:唯一 IPC 适配
|
||||
- `test/patrol/`:改功能优先 `npm run test:patrol`
|
||||
- `./run.sh web|gui` / `./scripts/build.sh`:开发与打包
|
||||
- `./run.sh web|gui` / `./scripts/build.sh`:开发与打包;安装包只在 `src-tauri/target/{debug,release}/bundle/`(Tauri 2 标准,不要仓库根 `release/`)
|
||||
|
||||
暂不强制迁到 `apps/web` / `apps/desktop`;见 AGENTS.md「目录对齐说明」。
|
||||
桌面布局为根级 `src/` + `src-tauri/`(Tauri 主流,三族统一;Vite :5175 仅桌面源)。不要重建已废弃的 `apps/`、`packages/`、仓库根 `release/`。
|
||||
|
||||
数据流:UI → Zustand → `platform` → Tauri command → Rust services。
|
||||
数据流:UI / Zustand → `platform` → Tauri command → Rust services。
|
||||
|
||||
现状与剩余工作见 [架构说明](docs/architecture.md)、[重构进度](docs/architecture-refactor.md);验证方式见 [测试与验收](docs/testing-acceptance.md)。不要把局部下沉误写为所有 service 已脱离 Tauri。
|
||||
|
||||
## 硬边界(摘要)
|
||||
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
操作 → 桌面 Tauri(./run.sh gui)
|
||||
```
|
||||
|
||||
官网与用户文档在本仓 `apps/server`(原独立仓 `site-opsxshell` 已并入)。Gin **不做**桌面操作后端。
|
||||
官网与用户文档为独立静态站(`homepage/` 源 → `tools/homepage-build` 生成 dist,可部署自有域名;本地 `:8789` 预览)。Gin **不做**桌面操作后端。
|
||||
|
||||
需要 Node.js 18+、npm 9+、Rust 1.77+、Go 1.22+(跑官网时)。macOS 打包还要 Xcode Command Line Tools。
|
||||
需要 Node.js 22 LTS(建议 22.12+)、npm 9+、Rust 1.89+、Go 1.22+(跑官网时)。macOS 打包还要 Xcode Command Line Tools。
|
||||
|
||||
## 本地调试
|
||||
|
||||
@@ -30,7 +30,7 @@ DevTools:`Cmd+Option+I` 或 `F12`。桌面应用日志:
|
||||
tail -f "$HOME/Library/Application Support/opsxshell/logs/opsxshell.log"
|
||||
```
|
||||
|
||||
只要前端、不要原生窗:`npm run dev:ui`(默认 `http://127.0.0.1:1420`,**不是**产品入口)。
|
||||
只要前端、不要原生窗:`npm run dev:ui`(默认 `http://127.0.0.1:5175`,**不是**产品入口)。
|
||||
|
||||
## 测试
|
||||
|
||||
@@ -42,21 +42,29 @@ npm run acceptance:full
|
||||
|
||||
## 开发机卫生
|
||||
|
||||
Launchpad / Spotlight 若扫到 `target` 里重复的 Opsx 应用,跑:`~/devops/scripts/clean-opsx-apps.sh`(可选 `--delete-target-apps` / `--reset-launchpad`)。
|
||||
`./run.sh gui` 启动前会自动做**轻量清**(推出残留 Opsx/OpsX DMG 卷、注销非 `/Applications` 且非本次启动路径的幽灵登记;不删 `target`、不重置 Launchpad)。
|
||||
|
||||
若 Launchpad / Spotlight 仍堆出重复图标,手动跑全量:
|
||||
|
||||
```bash
|
||||
~/devops/scripts/clean-opsx-apps.sh
|
||||
# 或:./run.sh clean-apps
|
||||
# 可选:--delete-target-apps --reset-launchpad
|
||||
```
|
||||
|
||||
## 打包
|
||||
|
||||
必须在目标系统上打:macOS 包在 Mac 打,Windows 包在 Windows 打。
|
||||
|
||||
```bash
|
||||
./scripts/build.sh # 当前平台;产物在 release/v<版本>/
|
||||
./scripts/build.sh # 当前平台;产物在 src-tauri/target/<triple>/release/bundle/
|
||||
./scripts/build.sh mac-arm64 --quick # 本机重复打包
|
||||
./scripts/macos-notarize.sh # 新 DMG:Apple 公证 + staple
|
||||
```
|
||||
|
||||
Windows:`scripts\build.bat win-x64`
|
||||
|
||||
本机有 gitignore 的 `src-tauri/tauri.signing.conf.json` 时,`build.sh` 会签 Developer ID。没这份配置就是 ad-hoc,别人电脑会 Gatekeeper 拦截。新打的 DMG **必须再公证**。申请证书、导入钥匙串的步骤见 [download/Apple开发者签名.md](./download/Apple开发者签名.md);发版细节见 [docs/release.md](./docs/release.md)。
|
||||
本机有 gitignore 的 `src-tauri/tauri.signing.conf.json` 时,`build.sh` 会签 Developer ID。没这份配置就是 ad-hoc,别人电脑会 Gatekeeper 拦截。新打的 DMG **必须再公证**。签名配置、公证与发版细节见 [发布文档](./docs/release.md#签名公证和自动更新)。
|
||||
|
||||
Gitee Release(tag 推上去之后):
|
||||
|
||||
@@ -65,7 +73,7 @@ Gitee Release(tag 推上去之后):
|
||||
./scripts/release.py v2.1.1
|
||||
```
|
||||
|
||||
发版安装包也可放到 `apps/server/download/` 供本地 Gin `/download/` 挂载(勿把大 DMG 提交进 Git)。
|
||||
发版安装包也可放到 `server/download/` 供本地 Gin `/download/` 挂载(勿把大 DMG 提交进 Git)。
|
||||
|
||||
## 文档
|
||||
|
||||
|
||||
@@ -1,264 +0,0 @@
|
||||
// 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])>(.*?)</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</%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(homeSlug string) string {
|
||||
return fmt.Sprintf(`<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>404 · OpsXShell 文档</title>
|
||||
<link rel="stylesheet" href="/docs/assets/docs.css"></head><body class="doc-404">
|
||||
<div class="wrap"><h1>404</h1><p>文档页面不存在或已被移动。</p><a class="btn" href="/docs/%s">返回文档首页</a></div>
|
||||
</body></html>`, homeSlug)
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
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>error</title>template 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 "<!doctype html><meta charset=utf-8><title>error</title>render error: " + template.HTMLEscapeString(err.Error())
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"opsxshell/server/internal/docs"
|
||||
"opsxshell/server/internal/site"
|
||||
)
|
||||
|
||||
func NewRouter() *gin.Engine {
|
||||
r := gin.New()
|
||||
_ = r.SetTrustedProxies(nil)
|
||||
r.Use(gin.Logger(), gin.Recovery())
|
||||
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "service": "opsxshell-site"})
|
||||
})
|
||||
|
||||
site.New().Register(r)
|
||||
docs.New().Register(r)
|
||||
|
||||
// 可选安装包目录(磁盘,不进 embed)
|
||||
if dir := downloadDir(); dir != "" {
|
||||
r.Static("/download", dir)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func downloadDir() string {
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
// .../apps/server/internal/server/router.go → ../../download
|
||||
dir := filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", "download"))
|
||||
return dir
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
// Package site 提供产品官网(嵌入 HTML/CSS/JS)。
|
||||
package site
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
//go:embed template.html assets
|
||||
var files embed.FS
|
||||
|
||||
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)}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -1,5 +1,14 @@
|
||||
# opsxshell docs
|
||||
|
||||
开发规则以根目录 [AGENTS.md](../AGENTS.md) 为准,[CLAUDE.md](../CLAUDE.md) 为短入口。
|
||||
|
||||
- [架构说明](architecture.md):当前分层、状态与原生生命周期
|
||||
- [架构重构](architecture-refactor.md):已完成部分与待实施轨道
|
||||
- [测试与验收](testing-acceptance.md):巡检、针对性测试和实机验收
|
||||
- [使用指南](user-guide.md) · [发布](release.md)
|
||||
|
||||
以下为 AI 专题:
|
||||
|
||||
| 文档 | 说明 |
|
||||
| --------------------------------------------------------------------- | ----------------------------------------- |
|
||||
| [01-AI-Agent步数预算与incomplete](01-AI-Agent步数预算与incomplete.md) | MAX_AGENT_STEPS=12 与 incomplete/续跑目标 |
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
# 架构重构(单独轨道,尚未开始)
|
||||
# 架构重构(渐进推进)
|
||||
|
||||
**当前不要做。** 改动面覆盖几乎全部 `src-tauri` 服务、`src/desktop.d.ts`、`src/platform/*` 和测试替身。本文件只记录怎么拆,避免和日常功能 PR 缠在一起。
|
||||
局部职责拆分已开始;下述 EventSink、契约生成和 infrastructure 端口仍是待实施方案。不要把已完成的局部改造当成全仓迁移,也不要顺带引入平行架构。
|
||||
|
||||
已落地:本地文件业务下沉至无 Tauri 依赖的 `services/files.rs`;`AppError` 部分接入;前端 IPC 拒绝错误统一映射;settings 功能模型、Hook 和控件提取;设置串行保存与失败回退;系统主题同步修复。实现位置与限制见 [架构说明](architecture.md)。
|
||||
|
||||
当前真实分层是 **commands → services → domain DTO**。目录里虽有 `application/`、`infrastructure/`,但:
|
||||
|
||||
- `infrastructure/` 只有 `file_log`,services 并不走它。
|
||||
- `application/state.rs` 是服务集合,不是端口组合根。
|
||||
- 稳定 IPC 错误码放在 `domain/error.rs` 的 `codes`(如 `native.taskJoin`),不是空壳 `DomainError` 类型。
|
||||
- `domain/error.rs` 已有稳定 IPC 错误码 `codes` 和部分接入的 `AppError`;尚未覆盖所有业务错误。
|
||||
- SSH / 存储 / 桌面服务直接 `use tauri::AppHandle`;known_hosts 还用 `bind_app_handle` 全局 static。
|
||||
- 前后端契约靠手写 `src/desktop.d.ts` + `shared/types/`,没有 specta / ts-rs。
|
||||
|
||||
@@ -66,7 +68,7 @@ pub trait EventSink: Send + Sync {
|
||||
|
||||
`infrastructure/mod.rs` 里现在的「SQLite / keychain / PTY 适配器」是过期注释,做 C 轨时改成与真实 trait 一致,没做的删掉,避免再写成已存在。
|
||||
|
||||
IPC 错误码继续用 `domain/error.rs` 的 `codes` 常量,不要再加一层空壳 `DomainError`。
|
||||
IPC 错误继续扩展 `domain/error.rs` 的 `AppError` / `codes`,保留既有信封和运行态抛错契约,不另建重复错误类型。
|
||||
|
||||
## 明确不做(本轨道也不做)
|
||||
|
||||
|
||||
+20
-1
@@ -8,12 +8,14 @@ OpsXShell 是简单好用的原生运维终端:短路径完成连接、终端
|
||||
|
||||
```text
|
||||
src/ React 界面、工作区和 Zustand 状态
|
||||
src/features/settings/ 设置模型、快捷键录入 Hook 和设置控件(渐进拆分)
|
||||
src/i18n/ 界面文案(zh-CN / zh-TW / en / ja / ko / vi / ru)与 locale 检测
|
||||
src/platform/ Tauri command/event 适配层
|
||||
shared/types/ 渲染层 TypeScript 契约
|
||||
src-tauri/src/commands/ 参数边界和统一响应
|
||||
src-tauri/src/domain/ Rust 领域模型
|
||||
src-tauri/src/services/ SSH、SFTP、隧道、PTY、串口、桌面、AI、存储和安全策略
|
||||
src-tauri/target/…/bundle/ Tauri 2 标准产物(debug/release 的 .app / dmg / nsis)
|
||||
```
|
||||
|
||||
React 不直接访问 SSH、文件系统或加密实现。Rust 服务持有连接、Shell、SFTP、串口和桌面会话资源,渲染层只保存资源 ID 与界面状态。新增原生接口时必须同步更新:
|
||||
@@ -22,7 +24,7 @@ React 不直接访问 SSH、文件系统或加密实现。Rust 服务持有连
|
||||
2. `src/desktop.d.ts` 类型契约
|
||||
3. 对应 `src/platform/*.ts` 适配实现(通用能力走 `desktop.ts`,RDP / VNC 走 `rdp.ts` / `vnc.ts`)
|
||||
4. `test/setup.ts` 测试替身
|
||||
5. 若有用户可见文案:同步 `src/i18n/locales/{zh-CN,en}/` 并保证 key 对齐
|
||||
5. 若有用户可见文案:同步 `src/i18n/locales/` 七语种并保证 key 对齐
|
||||
|
||||
Rust SSH 服务保留 `ssh.rs` 作为连接与 Shell 门面,已将独立边界拆出:
|
||||
|
||||
@@ -49,6 +51,17 @@ services/
|
||||
|
||||
模块化只约束内部职责,不引入插件框架,也不把 libssh2 或桌面协议引擎资源暴露到 command 层。后续 connection/shell 拆分继续保持现有 Tauri command 契约不变。
|
||||
|
||||
## 已落地的职责边界
|
||||
|
||||
- 本地文件读写、目录列表、元数据和增删改在 `src-tauri/src/services/files.rs`,DTO 在 `domain/files.rs`;该 service 不依赖 Tauri。Command 负责调度 `spawn_blocking` 和返回失败信封;原生文件对话框仍在主线程。
|
||||
- `domain/error.rs` 提供 `AppError` 和稳定错误码,目前部分文件操作接入。SSH、存储、桌面等 service 仍有 Tauri 依赖,全量错误迁移和 EventSink 尚未完成。
|
||||
- `src/platform/invoke.ts` 统一调用与拒绝错误映射;`NativeCommandError` 保留 command、detail、errorCode、originalError。它不把业务失败信封自动抛出,也不统一弹 toast 或为所有原生操作添加超时。
|
||||
- `features/settings/model.ts` 管默认值和归一化,`useKeyRecording.ts` 管录入生命周期,`SettingControls.tsx` 提供设置控件。`components/settings/SettingsView.tsx` 仍负责页面组合与部分交互;其他领域尚未全量迁入 features。
|
||||
- `settingsStore` 串行处理加载与保存:已确认快照叠加待保存修改形成即时 UI,保存成功才更新确认快照,失败只撤回对应项并提示,不把失败修改写入后续快照。错误由 store 消化并通知,不能仅凭 `Promise<void>` 完成就判断保存成功。
|
||||
- 应用主题沿用 `themes/app-theme.ts` 颜色 token;`useTheme` 同步系统偏好、返回的有效主题、DOM 和边框变量。主题偏好随设置保存;尚未完成全界面样式 token 化,也未验证冷启动异步加载设置时完全无闪烁。
|
||||
|
||||
剩余重构见 [架构重构](architecture-refactor.md)。
|
||||
|
||||
## 原生服务
|
||||
|
||||
| 能力 | 实现 |
|
||||
@@ -173,3 +186,9 @@ SSH 传输本身受协议 MAC 保护;当前应用额外执行大小校验,
|
||||
| Linux AppImage | 35 MB |
|
||||
|
||||
Release 使用 LTO、单 codegen unit、`panic = abort` 和符号剥离。平台功能必须在对应系统实机验收。
|
||||
|
||||
## 开发态 Dock 图标(已知限制)
|
||||
|
||||
`./run.sh gui` 走 `tauri dev`,macOS 启动的是 `src-tauri/target/debug/` 下的裸二进制。Dock 对非 `.app` 进程显示默认齿轮图标——这是 Tauri 通病,不是 `bundle.icon` 没配。正式 `tauri build` 的 `.app` / DMG 三族图标一致。
|
||||
|
||||
安装包与校验和只放在 `src-tauri/target/<triple>/release/bundle/`,由 `build.sh` / `release.py` 直接读写,不再拷到仓库根 `release/`。opsxapi 开发态正确 Dock 图标来自临时 `.app` 壳(`src-tauri/target/gui-app/`,刷 `icon.icns`)。要对齐开发态 Dock 图标应复用该壳,而不是另建发版目录。
|
||||
+10
-10
@@ -7,7 +7,7 @@
|
||||
本机已打出 **Tauri 窗口布局** 的 Developer ID 签名 DMG(不是手搓 `hdiutil`,Finder 里是大图标 + 拖到 Applications):
|
||||
|
||||
```text
|
||||
release/v2.1.1/macos-arm64/OpsXShell_2.1.1_aarch64.dmg
|
||||
src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/OpsXShell_2.1.1_aarch64.dmg
|
||||
```
|
||||
|
||||
这份包**还没公证、没 staple**。别人下载后仍可能被 Gatekeeper 拦。你这边只差公证:
|
||||
@@ -49,11 +49,11 @@ scripts\build.bat v2.1.1 win-x64
|
||||
./scripts/build.sh v2.1.1 mac-arm64 --quick
|
||||
```
|
||||
|
||||
`scripts/build.sh` 与 `scripts/build.bat` 面向 v2.1.x,会检查 Node、npm、Rust target 和五处版本元数据。显式传入不同于当前源码的新版本时,脚本会同步版本文件;它们只负责构建,不会自动创建 tag 或推送远程仓库。安装包和 `SHA256SUMS.txt` 输出到:
|
||||
`scripts/build.sh` 与 `scripts/build.bat` 面向 v2.1.x,会检查 Node、npm、Rust target 和五处版本元数据。显式传入不同于当前源码的新版本时,脚本会同步版本文件;它们只负责构建,不会自动创建 tag 或推送远程仓库。安装包和 `SHA256SUMS.txt` 留在 Tauri 2 标准目录:
|
||||
|
||||
```text
|
||||
release/v2.1.1/macos-arm64/
|
||||
release/v2.1.1/windows-x64/
|
||||
src-tauri/target/aarch64-apple-darwin/release/bundle/ # macOS arm64:dmg/ + macos/*.app
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/ # Windows:nsis/ + msi/
|
||||
```
|
||||
|
||||
平台安装包必须在目标系统构建和验证。macOS 不能替代 Windows x86_64 实机打包。
|
||||
@@ -77,7 +77,7 @@ npm ci
|
||||
scripts\build.bat v2.1.1 win-x64
|
||||
```
|
||||
|
||||
默认执行完整门禁;`--quick` 适合开发期重复构建,正式发布不要使用 `--skip-checks`。输出位于 `release\v2.1.1\windows-x64\`。没有代码签名证书时可以生成开发安装包,但 Windows SmartScreen 可能提示未知发布者。
|
||||
默认执行完整门禁;`--quick` 适合开发期重复构建,正式发布不要使用 `--skip-checks`。输出位于 `src-tauri\target\x86_64-pc-windows-msvc\release\bundle\`。没有代码签名证书时可以生成开发安装包,但 Windows SmartScreen 可能提示未知发布者。
|
||||
|
||||
## Gitee Release
|
||||
|
||||
@@ -91,15 +91,15 @@ scripts\build.bat v2.1.1 win-x64
|
||||
./scripts/release.py v2.1.1
|
||||
```
|
||||
|
||||
发布凭据从 `GITEE_TOKEN` 或 `~/.netrc` 的 `gitee.com` 条目读取,脚本不会输出 Token。脚本只读取 `release/v<版本>/` 下的 `.dmg`、`.exe`、`.msi` 和 `SHA256SUMS.txt`,上传成功后保留本地文件。远程已有同名同大小附件时会跳过;同名但大小不同时停止,避免静默覆盖已发布二进制。
|
||||
发布凭据从 `GITEE_TOKEN` 或 `~/.netrc` 的 `gitee.com` 条目读取,脚本不会输出 Token。脚本只读取 `src-tauri/target/<triple>/release/bundle/` 下的 `.dmg`、`.exe`、`.msi` 和 `SHA256SUMS.txt`,上传成功后保留本地文件。远程已有同名同大小附件时会跳过;同名但大小不同时停止,避免静默覆盖已发布二进制。不要再使用仓库根 `release/`。
|
||||
|
||||
macOS App 位于:
|
||||
|
||||
```text
|
||||
src-tauri/target/release/bundle/macos/OpsXShell.app
|
||||
src-tauri/target/aarch64-apple-darwin/release/bundle/macos/OpsXShell.app
|
||||
```
|
||||
|
||||
指定 `aarch64-apple-darwin` target 时,产物位于对应 target 的 `release/bundle` 下。
|
||||
未指定 `--target` 时,产物在 `src-tauri/target/release/bundle/`。开发态 Dock 图标差异见 [架构说明](./architecture.md#开发态-dock-图标已知限制)。
|
||||
|
||||
## 临时 DMG
|
||||
|
||||
@@ -124,7 +124,7 @@ lsof +D /Volumes/<临时卷名>
|
||||
|
||||
## 签名、公证和自动更新
|
||||
|
||||
申请账号和 Developer ID、导入钥匙串的完整步骤见 [download/Apple开发者签名.md](../download/Apple开发者签名.md)。
|
||||
申请 Developer ID 的说明见 [Apple Developer ID](https://developer.apple.com/developer-id/);本仓签名配置与公证方式如下。
|
||||
|
||||
**当前状态(本机可签 macOS;这份 DMG 待公证;自动更新仍未做):**
|
||||
|
||||
@@ -176,7 +176,7 @@ lsof +D /Volumes/<临时卷名>
|
||||
# 先弹出已挂载的安装盘,再:
|
||||
APPLE_ID='开发者邮箱' ./scripts/macos-notarize.sh
|
||||
# 可选:指定 DMG 路径
|
||||
APPLE_ID='开发者邮箱' ./scripts/macos-notarize.sh release/v2.1.1/macos-arm64/OpsXShell_2.1.1_aarch64.dmg
|
||||
APPLE_ID='开发者邮箱' ./scripts/macos-notarize.sh src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/OpsXShell_2.1.1_aarch64.dmg
|
||||
```
|
||||
|
||||
脚本会:已 staple 则退出;否则先 staple;没有票据才要 App 专用密码并 `notarytool submit --wait --timeout 15m`,超时仍 staple。开发者后台网页没有这份 DMG 的进度页,用终端或:
|
||||
|
||||
Vendored
+38
@@ -0,0 +1,38 @@
|
||||
# 变更日志模板
|
||||
|
||||
> 每次发版:复制本模板到 `docs/CHANGELOG.md` 顶部,按类型归类填写;空分类整段删除。
|
||||
> 提交信息与 PR 标题建议带同样 emoji,方便汇总时检索。
|
||||
|
||||
## vX.Y.Z(YYYY-MM-DD)
|
||||
|
||||
### ✨ 新增功能
|
||||
|
||||
-
|
||||
|
||||
### 🚀 优化改进
|
||||
|
||||
-
|
||||
|
||||
### 🐞 修复问题
|
||||
|
||||
-
|
||||
|
||||
### 🔨 破坏性变更
|
||||
|
||||
-
|
||||
|
||||
### 📖 文档
|
||||
|
||||
- ***
|
||||
|
||||
## Emoji 图例
|
||||
|
||||
| Emoji | 含义 | 用途 |
|
||||
| ----- | ---------- | ------------------------------------ |
|
||||
| ✨ | 新增功能 | 新能力、新入口、新引擎支持 |
|
||||
| 🚀 | 优化改进 | 性能、交互打磨、UI 调整、重构 |
|
||||
| 🐞 | 修复问题 | 缺陷修复、回归修复 |
|
||||
| 🔨 | 破坏性变更 | 配置格式/接口/快捷键变更,需迁移说明 |
|
||||
| 📖 | 文档 | 官网、帮助文档、架构文档更新 |
|
||||
| ⚡ | 性能 | 单独标注显著性能提升(可并入 🚀) |
|
||||
| 🗑️ | 移除 | 废弃功能下线 |
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 本地门禁
|
||||
|
||||
日常开发执行:
|
||||
改功能先跑 `npm run test:patrol` 和相关单测,再按范围执行验收:
|
||||
|
||||
```bash
|
||||
npm run acceptance:quick
|
||||
@@ -27,7 +27,7 @@ npm run acceptance:full
|
||||
- Tauri 事件监听注册/销毁竞态
|
||||
- SSH 连接关闭后的状态回收
|
||||
- HTTP CONNECT、SOCKS5 和断点复制
|
||||
- 转发规则不持久化运行状态,复制产生新 ID;原生隧道可启动、查询、停止并在 SSH 断开时清理
|
||||
- 转发规则不持久化运行状态,复制产生新 ID;原生隧道可启动、查询、停止;按断开原因验证保留或清理
|
||||
- 状态栏「转发」抽屉启停动态 `127.0.0.1:1080`,右键复制 SOCKS 地址;每台 SSH 主机仅一条动态规则
|
||||
- i18n:全部 APP_LANGUAGES(zh-CN / zh-TW / en / ja / ko / vi / ru)的 namespace key 对齐(`test/i18n/localeKeys.test.ts`)
|
||||
- RDP / VNC 仅原生可用;非 Tauri 环境不得显示为可连接;桌面会话不参与批量命令
|
||||
@@ -47,6 +47,17 @@ npm run acceptance:full
|
||||
|
||||
测试替身必须完整实现 `DesktopApiContract`。新增或删除桌面接口后,`typecheck:test` 必须能发现未同步的契约。
|
||||
|
||||
## 分层与设置回归
|
||||
|
||||
```bash
|
||||
npx vitest run test/platform/invoke.test.ts test/stores/settingsPersistence.test.ts test/stores/settingsMigration.test.ts test/hooks/useTheme.test.ts
|
||||
cargo test --manifest-path src-tauri/Cargo.toml --lib files
|
||||
```
|
||||
|
||||
覆盖 IPC 拒绝错误码与原始原因、业务失败信封保持、设置串行保存与失败回退、加载期间修改设置、无效主题归一化、系统主题切换,以及本地文件二进制往返和无效 Base64 不覆盖原文件。
|
||||
|
||||
文案变更另跑 `npx vitest run test/i18n/localeKeys.test.ts`。巡检通过不代表全量验收通过;既有失败应明确报告,不能以针对性测试替代全量结果。仅文档变更检查路径、链接和说明与实现一致,不必运行功能测试。
|
||||
|
||||
## AI 助手验收
|
||||
|
||||
自动验收不得使用真实个人 API Key。可以用本机 mock OpenAI Compatible 服务验证以下边界:
|
||||
@@ -104,7 +115,7 @@ Rust 单元测试会用随机本地端口验证动态监听器的唯一 ID、运
|
||||
3. 确认连接数和流量会变化(若界面展示统计)。
|
||||
4. 停止后确认端口无法再连接,不能只检查 UI 颜色。
|
||||
5. 重启应用后规则仍在,但状态必须为未运行。
|
||||
6. 断开 SSH 主连接后,关联的本地、远程和 SOCKS5 监听器全部清理。
|
||||
6. 分别验证意外断开/空闲挂起与用户停止/关闭标签:前者按保留策略维持转发并支持同窗重连接管,后者必须关闭真实 Listener;不能把所有 SSH 断开一概视为清理转发。
|
||||
7. 复制规则后确认新规则 ID 独立且状态为未运行;编辑运行中的规则必须先停止原 Listener。
|
||||
8. 同一 SSH 主机不得出现第二条动态规则。
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ OpsXShell 主打简单好用:常用远程操作走短路径,高级选项默
|
||||
|
||||
## 安装(macOS)
|
||||
|
||||
从 `release/v*/macos-arm64/` 打开 `.dmg`,把 OpsXShell 拖到「应用程序」。Finder 里应是 Tauri 那套大图标布局(应用在左、Applications 在右)。**已经 staple 的正式包**一般可直接打开。
|
||||
从官网下载,或打开本机构建产物 `src-tauri/target/<triple>/release/bundle/dmg/` 里的 `.dmg`,把 OpsXShell 拖到「应用程序」。Finder 里应是 Tauri 那套大图标布局(应用在左、Applications 在右)。**已经 staple 的正式包**一般可直接打开。
|
||||
|
||||
自己改代码后重新打包:先 `./scripts/build.sh mac-arm64 --quick`(本机有签名配置就会签 Developer ID),再 `./scripts/macos-notarize.sh`(会要 App 专用密码)。新打的 DMG 在公证完成前仍会被 Gatekeeper 拦截。你现在要做的步骤见 [发布指南](./release.md#macos-next-steps)。
|
||||
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+1
-1
@@ -4,7 +4,7 @@ OpsXShell 主打简单好用:常用远程操作走短路径,高级选项默
|
||||
|
||||
## 安装(macOS)
|
||||
|
||||
从 `release/v*/macos-arm64/` 打开 `.dmg`,把 OpsXShell 拖到「应用程序」。Finder 里应是 Tauri 那套大图标布局(应用在左、Applications 在右)。**已经 staple 的正式包**一般可直接打开。
|
||||
从官网下载,或打开本机构建产物 `src-tauri/target/<triple>/release/bundle/dmg/` 里的 `.dmg`,把 OpsXShell 拖到「应用程序」。Finder 里应是 Tauri 那套大图标布局(应用在左、Applications 在右)。**已经 staple 的正式包**一般可直接打开。
|
||||
|
||||
自己改代码后重新打包:先 `./scripts/build.sh mac-arm64 --quick`(本机有签名配置就会签 Developer ID),再 `./scripts/macos-notarize.sh`(会要 App 专用密码)。新打的 DMG 在公证完成前仍会被 Gatekeeper 拦截。你现在要做的步骤见 [发布指南](./release.md#macos-next-steps)。
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Generated
+13
-3
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-opener": "^2.7.1",
|
||||
"@vitest/coverage-v8": "^4.1.4",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
@@ -1936,9 +1937,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tauri-apps/api": {
|
||||
"version": "2.11.1",
|
||||
"resolved": "https://registry.npmmirror.com/@tauri-apps/api/-/api-2.11.1.tgz",
|
||||
"integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==",
|
||||
"version": "2.12.3",
|
||||
"resolved": "https://registry.npmmirror.com/@tauri-apps/api/-/api-2.12.3.tgz",
|
||||
"integrity": "sha512-9s0PKw0p4r3kFq92jhpivYSp+cSht15o0gXpZxuQd0+dV9KCexEVkNaLELVoLdJWyoH4KlfQo5h3W2LIM8siJg==",
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -2162,6 +2163,15 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-opener": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmmirror.com/@tauri-apps/plugin-opener/-/plugin-opener-2.7.1.tgz",
|
||||
"integrity": "sha512-XkXdt30rontti9TII/gBBxFRXWfj8DiprFW7RgBFRBj1OhdfjBNVcFqSEWkkZ4hXafHnuEnB7F1sFsmu6uyywQ==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmmirror.com/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
|
||||
+2
-1
@@ -27,7 +27,7 @@
|
||||
"acceptance": "npm run acceptance:quick",
|
||||
"acceptance:quick": "node scripts/acceptance.mjs quick",
|
||||
"acceptance:full": "node scripts/acceptance.mjs full",
|
||||
"clean": "rimraf dist src-tauri/target/release release",
|
||||
"clean": "rimraf dist src-tauri/target/release src-tauri/target/*/release",
|
||||
"prepare": "husky",
|
||||
"dist": "tauri build",
|
||||
"dist:win": "tauri build --target x86_64-pc-windows-msvc",
|
||||
@@ -43,6 +43,7 @@
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-opener": "^2.7.1",
|
||||
"@vitest/coverage-v8": "^4.1.4",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
|
||||
@@ -19,6 +19,11 @@ test('build.sh is valid Bash and documents supported native targets', () => {
|
||||
assert.match(help.stdout, /win-x64/)
|
||||
assert.match(help.stdout, /linux-x64/)
|
||||
assert.match(help.stdout, /不会创建或推送 Git tag/)
|
||||
assert.match(help.stdout, /src-tauri\/target\/<triple>\/release\/bundle\//)
|
||||
|
||||
const script = readFileSync(resolve(root, 'scripts/build.sh'), 'utf8')
|
||||
assert.match(script, /BUNDLE_ROOT=.*release\/bundle/)
|
||||
assert.doesNotMatch(script, /ROOT_DIR\/release\/v/)
|
||||
})
|
||||
|
||||
test('build.bat provides an equivalent native Windows x64 release path', () => {
|
||||
@@ -30,6 +35,8 @@ test('build.bat provides an equivalent native Windows x64 release path', () => {
|
||||
assert.match(script, /nsis\\\*\.exe/)
|
||||
assert.match(script, /SHA256SUMS\.txt/)
|
||||
assert.match(script, /不会创建或推送 Git tag/)
|
||||
assert.match(script, /src-tauri\\target\\<triple>\\release\\bundle\\/)
|
||||
assert.doesNotMatch(script, /release\\v%VERSION%/)
|
||||
})
|
||||
|
||||
test('release metadata versions stay synchronized', () => {
|
||||
|
||||
+9
-22
@@ -132,24 +132,9 @@ set "BUNDLE_ROOT=src-tauri\target\%RUST_TARGET%\release\bundle"
|
||||
node scripts\verify-release.mjs windows "%BUNDLE_ROOT%"
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
set "OUTPUT_DIR=%CD%\release\v%VERSION%\%OUTPUT_PLATFORM%"
|
||||
if exist "%OUTPUT_DIR%" rmdir /S /Q "%OUTPUT_DIR%"
|
||||
mkdir "%OUTPUT_DIR%"
|
||||
if errorlevel 1 (
|
||||
call :fail "无法创建输出目录:%OUTPUT_DIR%"
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
set /A ARTIFACT_COUNT=0
|
||||
for %%F in ("%BUNDLE_ROOT%\nsis\*.exe" "%BUNDLE_ROOT%\msi\*.msi" "%BUNDLE_ROOT%\wix\*.msi") do (
|
||||
if exist "%%~fF" (
|
||||
copy /Y "%%~fF" "%OUTPUT_DIR%\%%~nxF" >nul
|
||||
if errorlevel 1 (
|
||||
call :fail "复制安装包失败:%%~fF"
|
||||
exit /b 1
|
||||
)
|
||||
set /A ARTIFACT_COUNT+=1
|
||||
)
|
||||
if exist "%%~fF" set /A ARTIFACT_COUNT+=1
|
||||
)
|
||||
|
||||
if "%ARTIFACT_COUNT%"=="0" (
|
||||
@@ -158,19 +143,21 @@ if "%ARTIFACT_COUNT%"=="0" (
|
||||
)
|
||||
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command ^
|
||||
"$files = Get-ChildItem -LiteralPath $env:OUTPUT_DIR -File ^| Where-Object { $_.Extension -in '.exe', '.msi' };" ^
|
||||
"$root = $env:BUNDLE_ROOT;" ^
|
||||
"$files = @(); foreach ($dir in @('nsis','msi','wix')) { $p = Join-Path $root $dir; if (Test-Path $p) { $files += Get-ChildItem -LiteralPath $p -File ^| Where-Object { $_.Extension -in '.exe', '.msi' } } };" ^
|
||||
"if ($files.Count -eq 0) { throw 'no installers' };" ^
|
||||
"$lines = @($files ^| ForEach-Object { (Get-FileHash -Algorithm SHA256 -LiteralPath $_.FullName).Hash.ToLowerInvariant() + ' ' + $_.Name });" ^
|
||||
"[System.IO.File]::WriteAllLines((Join-Path $env:OUTPUT_DIR 'SHA256SUMS.txt'), [string[]]$lines, (New-Object System.Text.UTF8Encoding($false)))"
|
||||
"[System.IO.File]::WriteAllLines((Join-Path $root 'SHA256SUMS.txt'), [string[]]$lines, (New-Object System.Text.UTF8Encoding($false)))"
|
||||
if errorlevel 1 (
|
||||
call :fail "生成 SHA256SUMS.txt 失败"
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
call :log "构建完成:%OUTPUT_DIR%"
|
||||
dir "%OUTPUT_DIR%"
|
||||
call :log "构建完成(Tauri bundle):%BUNDLE_ROOT%"
|
||||
dir /S /B "%BUNDLE_ROOT%\nsis\*.exe" "%BUNDLE_ROOT%\msi\*.msi" "%BUNDLE_ROOT%\wix\*.msi" 2>nul
|
||||
echo.
|
||||
echo [build] SHA-256
|
||||
type "%OUTPUT_DIR%\SHA256SUMS.txt"
|
||||
type "%BUNDLE_ROOT%\SHA256SUMS.txt"
|
||||
exit /b 0
|
||||
|
||||
:usage_ok
|
||||
@@ -199,7 +186,7 @@ echo Visual Studio 2022 Build Tools(Desktop development with C++)、
|
||||
echo Windows 10/11 SDK、WebView2 Runtime。
|
||||
echo.
|
||||
echo 说明:
|
||||
echo - 输出 NSIS/MSI 到 release\v版本\windows-x64 并生成 SHA256SUMS.txt。
|
||||
echo - 安装包留在 src-tauri\target\<triple>\release\bundle\(nsis/msi),并生成 SHA256SUMS.txt。
|
||||
echo - 脚本不会创建或推送 Git tag,也不会上传 Release。
|
||||
echo - 正式签名需设置 REQUIRE_SIGNING=true 和 Windows 签名环境变量。
|
||||
exit /b 0
|
||||
|
||||
+24
-36
@@ -42,6 +42,7 @@ OpsXShell 原生安装包构建脚本
|
||||
- 显式传入新版本时,会同步 package.json、package-lock.json、
|
||||
Cargo.toml、Cargo.lock 和 tauri.conf.json,但不会创建或推送 Git tag。
|
||||
- Windows 包必须在 Windows 上构建;macOS 包必须在 macOS 上构建;Linux 包必须在 Linux 上构建。
|
||||
- 安装包留在 Tauri 标准目录 src-tauri/target/<triple>/release/bundle/,不拷到仓库根 release/。
|
||||
- 正式签名构建请先设置 REQUIRE_SIGNING=true 及对应签名环境变量。
|
||||
EOF
|
||||
}
|
||||
@@ -198,12 +199,8 @@ fi
|
||||
BUNDLE_ROOT="${CARGO_TARGET_DIR:-$ROOT_DIR/src-tauri/target}/$RUST_TARGET/release/bundle"
|
||||
node scripts/verify-release.mjs "$RELEASE_PLATFORM" "$BUNDLE_ROOT"
|
||||
|
||||
OUTPUT_DIR="$ROOT_DIR/release/v$VERSION/$OUTPUT_PLATFORM"
|
||||
rm -rf "$OUTPUT_DIR"
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
# 产物留在 Tauri 标准目录,不再拷到仓库根 release/
|
||||
shopt -s nullglob
|
||||
|
||||
COPIED_ARTIFACTS=()
|
||||
if [ "$RELEASE_PLATFORM" = "macos" ]; then
|
||||
SOURCE_ARTIFACTS=("$BUNDLE_ROOT"/dmg/*.dmg)
|
||||
elif [ "$RELEASE_PLATFORM" = "linux" ]; then
|
||||
@@ -213,39 +210,30 @@ else
|
||||
fi
|
||||
|
||||
[ "${#SOURCE_ARTIFACTS[@]}" -gt 0 ] || fail "构建完成但未找到安装包:$BUNDLE_ROOT"
|
||||
|
||||
checksum_line() {
|
||||
local artifact="$1" digest
|
||||
if command -v shasum >/dev/null 2>&1; then
|
||||
digest="$(shasum -a 256 "$artifact" | awk '{print $1}')"
|
||||
elif command -v sha256sum >/dev/null 2>&1; then
|
||||
digest="$(sha256sum "$artifact" | awk '{print $1}')"
|
||||
else
|
||||
digest="$(node --input-type=module -e '
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
console.log(createHash("sha256").update(readFileSync(process.argv[1])).digest("hex"));
|
||||
' "$artifact")"
|
||||
fi
|
||||
printf '%s %s\n' "$digest" "$(basename "$artifact")"
|
||||
}
|
||||
|
||||
CHECKSUM_FILE="$BUNDLE_ROOT/SHA256SUMS.txt"
|
||||
: >"$CHECKSUM_FILE"
|
||||
for artifact in "${SOURCE_ARTIFACTS[@]}"; do
|
||||
destination="$OUTPUT_DIR/$(basename "$artifact")"
|
||||
cp "$artifact" "$destination"
|
||||
COPIED_ARTIFACTS+=("$destination")
|
||||
checksum_line "$artifact" >>"$CHECKSUM_FILE"
|
||||
done
|
||||
|
||||
CHECKSUM_FILE="$OUTPUT_DIR/SHA256SUMS.txt"
|
||||
if command -v shasum >/dev/null 2>&1; then
|
||||
(
|
||||
cd "$OUTPUT_DIR"
|
||||
names=()
|
||||
for artifact in "${COPIED_ARTIFACTS[@]}"; do names+=("$(basename "$artifact")"); done
|
||||
shasum -a 256 "${names[@]}" > SHA256SUMS.txt
|
||||
)
|
||||
elif command -v sha256sum >/dev/null 2>&1; then
|
||||
(
|
||||
cd "$OUTPUT_DIR"
|
||||
names=()
|
||||
for artifact in "${COPIED_ARTIFACTS[@]}"; do names+=("$(basename "$artifact")"); done
|
||||
sha256sum "${names[@]}" > SHA256SUMS.txt
|
||||
)
|
||||
else
|
||||
node --input-type=module -e '
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { basename } from "node:path";
|
||||
for (const path of process.argv.slice(1)) {
|
||||
console.log(`${createHash("sha256").update(readFileSync(path)).digest("hex")} ${basename(path)}`);
|
||||
}
|
||||
' "${COPIED_ARTIFACTS[@]}" > "$CHECKSUM_FILE"
|
||||
fi
|
||||
|
||||
log "构建完成:$OUTPUT_DIR"
|
||||
ls -lh "$OUTPUT_DIR"
|
||||
log "构建完成(Tauri bundle):$BUNDLE_ROOT ($OUTPUT_PLATFORM)"
|
||||
ls -lh "${SOURCE_ARTIFACTS[@]}"
|
||||
printf '\n[build] SHA-256\n'
|
||||
cat "$CHECKSUM_FILE"
|
||||
@@ -0,0 +1,109 @@
|
||||
# sourced by scripts/run.sh — temp OpsxShell.app for Dock icon (icons/icon.icns)
|
||||
# shellcheck shell=bash
|
||||
|
||||
# .noindex:避免 Spotlight 把临时 Dock 壳当成可打开的历史 App
|
||||
GUI_APP_DIR="$SCRIPT_DIR/src-tauri/target/gui-app.noindex"
|
||||
GUI_APP="$GUI_APP_DIR/OpsxShell.app"
|
||||
ICONS_DIR="$SCRIPT_DIR/src-tauri/icons"
|
||||
REL_APP="$SCRIPT_DIR/src-tauri/target/release/bundle/macos/OpsxShell.app"
|
||||
[ -d "$REL_APP" ] || REL_APP="$SCRIPT_DIR/src-tauri/target/release/bundle/macos/OpsXShell.app"
|
||||
DBG_APP="$SCRIPT_DIR/src-tauri/target/debug/bundle/macos/OpsxShell.app"
|
||||
[ -d "$DBG_APP" ] || DBG_APP="$SCRIPT_DIR/src-tauri/target/debug/bundle/macos/OpsXShell.app"
|
||||
REL_BIN="$SCRIPT_DIR/src-tauri/target/release/OpsxShell"
|
||||
[ -x "$REL_BIN" ] || REL_BIN="$SCRIPT_DIR/src-tauri/target/release/OpsXShell"
|
||||
DBG_BIN_A="$SCRIPT_DIR/src-tauri/target/debug/OpsxShell"
|
||||
[ -x "$DBG_BIN_A" ] || DBG_BIN_A="$SCRIPT_DIR/src-tauri/target/debug/OpsXShell"
|
||||
DBG_BIN_B="$SCRIPT_DIR/src-tauri/target/debug/opsxshell"
|
||||
GUI_DEV_NEEDS_VITE=0
|
||||
|
||||
_gui_write_plist() {
|
||||
cat >"$GUI_APP/Contents/Info.plist" <<'PLIST'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDisplayName</key><string>OpsxShell</string>
|
||||
<key>CFBundleExecutable</key><string>OpsxShell</string>
|
||||
<key>CFBundleIconFile</key><string>icon.icns</string>
|
||||
<key>CFBundleIdentifier</key><string>com.opsxshell.desktop.dev</string>
|
||||
<key>CFBundleName</key><string>OpsxShell</string>
|
||||
<key>CFBundlePackageType</key><string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key><string>2.1.1-dev</string>
|
||||
<key>CFBundleVersion</key><string>0</string>
|
||||
<key>LSHighResolutionCapable</key><true/>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
}
|
||||
|
||||
_gui_wrap_bin() {
|
||||
local src="$1"
|
||||
mkdir -p "$GUI_APP/Contents/MacOS" "$GUI_APP/Contents/Resources"
|
||||
cp "$src" "$GUI_APP/Contents/MacOS/OpsxShell"
|
||||
chmod +x "$GUI_APP/Contents/MacOS/OpsxShell"
|
||||
_gui_write_plist
|
||||
}
|
||||
|
||||
ensure_gui_dev_app() {
|
||||
local icon="$ICONS_DIR/icon.icns"
|
||||
GUI_DEV_NEEDS_VITE=0
|
||||
if [ ! -f "$icon" ]; then
|
||||
error "缺少 ${icon}"
|
||||
return 1
|
||||
fi
|
||||
mkdir -p "$GUI_APP_DIR"
|
||||
if [ -d "$DBG_APP" ]; then
|
||||
rm -rf "$GUI_APP"
|
||||
cp -R "$DBG_APP" "$GUI_APP"
|
||||
GUI_DEV_NEEDS_VITE=1
|
||||
elif [ -x "$DBG_BIN_A" ]; then
|
||||
rm -rf "$GUI_APP"
|
||||
_gui_wrap_bin "$DBG_BIN_A"
|
||||
GUI_DEV_NEEDS_VITE=1
|
||||
elif [ -x "$DBG_BIN_B" ]; then
|
||||
rm -rf "$GUI_APP"
|
||||
_gui_wrap_bin "$DBG_BIN_B"
|
||||
GUI_DEV_NEEDS_VITE=1
|
||||
elif [ -d "$REL_APP" ]; then
|
||||
rm -rf "$GUI_APP"
|
||||
cp -R "$REL_APP" "$GUI_APP"
|
||||
elif [ -x "$REL_BIN" ]; then
|
||||
rm -rf "$GUI_APP"
|
||||
_gui_wrap_bin "$REL_BIN"
|
||||
else
|
||||
error "没有可包装的桌面二进制。请先 ./run.sh gui 走一次 tauri 编译,或 npm run tauri -- build --no-bundle。"
|
||||
return 1
|
||||
fi
|
||||
mkdir -p "$GUI_APP/Contents/Resources"
|
||||
cp "$icon" "$GUI_APP/Contents/Resources/icon.icns"
|
||||
# 拷来的正式包可能仍叫 OpsXShell:统一成 OpsxShell(与 OpsxAPI / OpsxDB 同一套大小写)
|
||||
if [ -x "$GUI_APP/Contents/MacOS/OpsXShell" ] && [ ! -x "$GUI_APP/Contents/MacOS/OpsxShell" ]; then
|
||||
mv "$GUI_APP/Contents/MacOS/OpsXShell" "$GUI_APP/Contents/MacOS/OpsxShell"
|
||||
fi
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName OpsxShell" "$GUI_APP/Contents/Info.plist" 2>/dev/null || true
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleName OpsxShell" "$GUI_APP/Contents/Info.plist" 2>/dev/null || true
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleExecutable OpsxShell" "$GUI_APP/Contents/Info.plist" 2>/dev/null || true
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleIconFile icon.icns" "$GUI_APP/Contents/Info.plist" 2>/dev/null || true
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier com.opsxshell.desktop.dev" "$GUI_APP/Contents/Info.plist" 2>/dev/null || true
|
||||
local stamp
|
||||
stamp="$(date +%Y%m%d%H%M%S)"
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${stamp}" "$GUI_APP/Contents/Info.plist" 2>/dev/null || true
|
||||
touch "$GUI_APP"
|
||||
success "临时 .app 已刷 icons/icon.icns → ${GUI_APP}"
|
||||
}
|
||||
|
||||
kill_opsxshell_gui() {
|
||||
stop_detached "$DESKTOP_LABEL"
|
||||
pkill -f "$SCRIPT_DIR/src-tauri/target/debug/opsxshell" 2>/dev/null || true
|
||||
pkill -f "$SCRIPT_DIR/src-tauri/target/debug/OpsXShell" 2>/dev/null || true
|
||||
pkill -f "$SCRIPT_DIR/src-tauri/target/debug/OpsxShell" 2>/dev/null || true
|
||||
pkill -f "$SCRIPT_DIR/node_modules/.bin/tauri" 2>/dev/null || true
|
||||
pkill -f "$GUI_APP" 2>/dev/null || true
|
||||
pkill -x OpsxShell 2>/dev/null || true
|
||||
pkill -x OpsXShell 2>/dev/null || true
|
||||
}
|
||||
|
||||
gui_is_running() {
|
||||
pgrep -f "$GUI_APP/Contents/MacOS/" >/dev/null 2>&1 \
|
||||
|| pgrep -x OpsxShell >/dev/null 2>&1
|
||||
}
|
||||
@@ -6,7 +6,30 @@ set -u
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
VERSION="$(node -p "require('$ROOT_DIR/package.json').version")"
|
||||
DMG="${1:-$ROOT_DIR/release/v$VERSION/macos-arm64/OpsXShell_${VERSION}_aarch64.dmg}"
|
||||
|
||||
default_dmg() {
|
||||
local dir dmg
|
||||
for dir in \
|
||||
"$ROOT_DIR/src-tauri/target/aarch64-apple-darwin/release/bundle/dmg" \
|
||||
"$ROOT_DIR/src-tauri/target/x86_64-apple-darwin/release/bundle/dmg" \
|
||||
"$ROOT_DIR/src-tauri/target/release/bundle/dmg"
|
||||
do
|
||||
dmg="$(ls -t "$dir"/OpsXShell_"${VERSION}"_*.dmg 2>/dev/null | head -n 1 || true)"
|
||||
if [ -n "$dmg" ]; then
|
||||
printf '%s' "$dmg"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
if [ -n "${1:-}" ]; then
|
||||
DMG="$1"
|
||||
elif ! DMG="$(default_dmg)"; then
|
||||
echo "找不到 Tauri bundle 里的 DMG。请先 ./scripts/build.sh,或显式传入路径。" >&2
|
||||
echo "标准位置:src-tauri/target/<triple>/release/bundle/dmg/" >&2
|
||||
exit 1
|
||||
fi
|
||||
APPLE_ID="${APPLE_ID:-}"
|
||||
TEAM_ID="${APPLE_TEAM_ID:-B8DVUUUF6W}"
|
||||
WAIT_TIMEOUT="${NOTARY_WAIT_TIMEOUT:-15m}"
|
||||
|
||||
+35
-14
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create an OpsXShell Gitee Release and upload native installers.
|
||||
|
||||
The script is intentionally non-destructive: release artifacts stay in the
|
||||
local ``release/`` directory after a successful upload.
|
||||
The script is intentionally non-destructive: artifacts stay in Tauri's
|
||||
``src-tauri/target/<triple>/release/bundle/`` after a successful upload.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -74,23 +74,44 @@ def load_token() -> str:
|
||||
fail("缺少 Gitee Token:请设置 GITEE_TOKEN 或 ~/.netrc 的 gitee.com password")
|
||||
|
||||
|
||||
def collect_assets(root: Path, version: str) -> list[Path]:
|
||||
version_dir = root / "release" / version
|
||||
if not version_dir.is_dir():
|
||||
fail(f"发布目录不存在:{version_dir}")
|
||||
TAURI_BUNDLE_ROOTS = (
|
||||
"src-tauri/target/release/bundle",
|
||||
"src-tauri/target/aarch64-apple-darwin/release/bundle",
|
||||
"src-tauri/target/x86_64-apple-darwin/release/bundle",
|
||||
"src-tauri/target/x86_64-pc-windows-msvc/release/bundle",
|
||||
"src-tauri/target/x86_64-unknown-linux-gnu/release/bundle",
|
||||
"src-tauri/target/aarch64-unknown-linux-gnu/release/bundle",
|
||||
)
|
||||
|
||||
assets = sorted(
|
||||
path
|
||||
for path in version_dir.rglob("*")
|
||||
if path.is_file()
|
||||
and (path.suffix.lower() in ALLOWED_SUFFIXES or path.name in CHECKSUM_NAMES)
|
||||
)
|
||||
|
||||
def collect_assets(root: Path, version: str) -> list[Path]:
|
||||
version_num = version.removeprefix("v")
|
||||
found: list[Path] = []
|
||||
for relative in TAURI_BUNDLE_ROOTS:
|
||||
bundle = root / relative
|
||||
if not bundle.is_dir():
|
||||
continue
|
||||
for path in bundle.rglob("*"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
if path.name.startswith("rw."):
|
||||
continue
|
||||
if path.name in CHECKSUM_NAMES:
|
||||
found.append(path)
|
||||
continue
|
||||
if path.suffix.lower() in ALLOWED_SUFFIXES and version_num in path.name:
|
||||
found.append(path)
|
||||
|
||||
assets = sorted(set(found))
|
||||
installers = [path for path in assets if path.suffix.lower() in ALLOWED_SUFFIXES]
|
||||
checksums = [path for path in assets if path.name in CHECKSUM_NAMES]
|
||||
if not installers:
|
||||
fail(f"{version_dir} 中没有安装包")
|
||||
fail(
|
||||
"未在 src-tauri/target/<triple>/release/bundle/ 找到安装包;"
|
||||
f"请先构建 v{version_num}(不要使用仓库根 release/)"
|
||||
)
|
||||
if not checksums:
|
||||
fail(f"{version_dir} 中没有 SHA256SUMS.txt")
|
||||
fail("bundle 目录中没有 SHA256SUMS.txt(由 ./scripts/build.sh 生成)")
|
||||
|
||||
duplicate_names = {path.name for path in assets if sum(item.name == path.name for item in assets) > 1}
|
||||
if duplicate_names:
|
||||
|
||||
+61
-38
@@ -1,22 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
# OpsXShell launcher — scene profiles(对齐 opsxdb / opsxapi)
|
||||
# OpsxShell launcher — scene profiles(对齐 opsxdb / opsxapi)
|
||||
#
|
||||
# ./run.sh # = web:仅 Gin 官网 + docs(:8789)
|
||||
# ./run.sh web # 同上(浏览器不当操作客户端)
|
||||
# ./run.sh gui # 桌面唯一操作入口:Tauri;默认也起 Gin
|
||||
# ./run.sh gui # 桌面唯一操作入口:临时 .app(Dock 图标)+ 默认 Gin
|
||||
# ./run.sh all # 同 gui(兼容旧命令)
|
||||
# ./run.sh server|desktop
|
||||
# ./run.sh clean-apps # 全量开发机卫生(~/devops/scripts/clean-opsx-apps.sh)
|
||||
# ./run.sh stop|restart|status|help
|
||||
#
|
||||
# 产品形态:浏览器 = 官网/docs;操作 = 桌面 Tauri(src/ + src-tauri/)。
|
||||
# Vite :1420 仅作桌面 WebView 源,不要用浏览器当工作台。
|
||||
# Vite :5175 仅作桌面 WebView 源,不要用浏览器当工作台。
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_NAME="OpsXShell"
|
||||
PROJECT_NAME="OpsxShell"
|
||||
SERVER_PORT="${OPSXSHELL_SERVER_PORT:-8789}"
|
||||
WEB_PORT="${OPSXSHELL_WEB_PORT:-1420}"
|
||||
SERVER_DIR="apps/server"
|
||||
WEB_PORT="${OPSXSHELL_WEB_PORT:-5175}"
|
||||
SERVER_DIR="server"
|
||||
SERVER_BIN="opsxshell-server"
|
||||
SERVER_LABEL="com.opsx.shell.server"
|
||||
WEB_LABEL="com.opsx.shell.web"
|
||||
@@ -39,6 +40,36 @@ SERVER_LOG="$LOG_DIR/server.log"
|
||||
WEB_LOG="$LOG_DIR/web.log"
|
||||
DESKTOP_LOG="$LOG_DIR/desktop.log"
|
||||
|
||||
# shellcheck source=gui-dev-app.sh
|
||||
source "$SCRIPT_DIR/scripts/gui-dev-app.sh"
|
||||
|
||||
OPSX_CLEAN_SCRIPT="${OPSX_CLEAN_SCRIPT:-$HOME/devops/scripts/clean-opsx-apps.sh}"
|
||||
|
||||
# gui 启动前轻量清:推残留 DMG + 注销非 /Applications、非 keep-path 的幽灵。
|
||||
# 不删 target、不 ResetLaunchPad / kill Dock。逻辑在 clean-opsx-apps.sh --light。
|
||||
opsx_light_clean_before_gui() {
|
||||
local keep="$1"
|
||||
if [ "$(uname -s)" != "Darwin" ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ ! -x "$OPSX_CLEAN_SCRIPT" ]; then
|
||||
warning "找不到清理脚本: $OPSX_CLEAN_SCRIPT(跳过轻量清)"
|
||||
return 0
|
||||
fi
|
||||
info "gui light-clean (keep-path=${keep})..."
|
||||
"$OPSX_CLEAN_SCRIPT" --light --keep-path="$keep" --no-verify \
|
||||
|| warning "轻量清未完全成功(继续启动)"
|
||||
}
|
||||
|
||||
run_clean_apps() {
|
||||
if [ ! -x "$OPSX_CLEAN_SCRIPT" ]; then
|
||||
error "找不到: $OPSX_CLEAN_SCRIPT"
|
||||
return 1
|
||||
fi
|
||||
# 转发剩余参数(如 --delete-target-apps);默认 full
|
||||
"$OPSX_CLEAN_SCRIPT" "$@"
|
||||
}
|
||||
|
||||
# Same path as src-tauri infrastructure/file_log.rs (dirs::data_local_dir).
|
||||
if [ "$(uname -s)" = "Darwin" ]; then
|
||||
APP_LOG="$HOME/Library/Application Support/opsxshell/logs/opsxshell.log"
|
||||
@@ -174,14 +205,14 @@ start_vite_for_desktop() {
|
||||
success "桌面 UI(Vite) 已在运行 :$WEB_PORT"
|
||||
return 0
|
||||
fi
|
||||
info "启动桌面 UI 源 :$WEB_PORT(仅本机给 Tauri,勿当浏览器客户端)..."
|
||||
info "启动桌面 UI 源 :${WEB_PORT}(仅本机给 Tauri,勿当浏览器客户端)..."
|
||||
: >"$WEB_LOG"
|
||||
ensure_npm
|
||||
kill_port "$WEB_PORT"
|
||||
start_detached "$WEB_LABEL" "$WEB_LOG" \
|
||||
"export PATH=\"$NODE_BIN_DIR:/opt/homebrew/bin:/usr/local/bin:\$PATH\"; cd \"$SCRIPT_DIR\" && exec npm run dev:ui"
|
||||
wait_http "http://127.0.0.1:$WEB_PORT/" "Vite" 80
|
||||
success "桌面 UI(Vite) UP http://127.0.0.1:$WEB_PORT(请用 ./run.sh gui)"
|
||||
success "桌面 UI(Vite) UP http://127.0.0.1:${WEB_PORT}(请用 ./run.sh gui)"
|
||||
}
|
||||
|
||||
stop_web() {
|
||||
@@ -191,32 +222,26 @@ stop_web() {
|
||||
}
|
||||
|
||||
start_desktop() {
|
||||
info "启动桌面 OpsXShell ..."
|
||||
opsx_light_clean_before_gui "$GUI_APP"
|
||||
info "启动桌面 OpsxShell(临时 .app + 当前图标)..."
|
||||
: >"$DESKTOP_LOG"
|
||||
ensure_npm
|
||||
# 清理旧进程
|
||||
pkill -f 'target/debug/opsxshell' 2>/dev/null || true
|
||||
pkill -f 'target/debug/OpsXShell' 2>/dev/null || true
|
||||
stop_detached "$DESKTOP_LABEL"
|
||||
# tauri dev 会自己起 Vite;detached 跑 npm run dev
|
||||
start_detached "$DESKTOP_LABEL" "$DESKTOP_LOG" \
|
||||
"export PATH=\"$NODE_BIN_DIR:/opt/homebrew/bin:/usr/local/bin:\$HOME/.cargo/bin:\$PATH\"; cd \"$SCRIPT_DIR\" && exec npm run dev"
|
||||
kill_opsxshell_gui
|
||||
ensure_gui_dev_app || return 1
|
||||
if [ "${GUI_DEV_NEEDS_VITE:-0}" = "1" ]; then
|
||||
start_vite_for_desktop
|
||||
fi
|
||||
open "$GUI_APP" 2>>"$DESKTOP_LOG" || { error "open 失败,见 $DESKTOP_LOG"; return 1; }
|
||||
|
||||
local i=0
|
||||
while true; do
|
||||
if pgrep -f 'target/debug/opsxshell' >/dev/null 2>&1 \
|
||||
|| pgrep -f 'target/debug/OpsXShell' >/dev/null 2>&1; then
|
||||
success "桌面 OpsXShell 已启动"
|
||||
info "应用日志: tail -f \"$APP_LOG\""
|
||||
if gui_is_running; then
|
||||
success "桌面 OpsxShell 已启动(临时 .app,Dock 用 icon.icns / 名称 OpsxShell)"
|
||||
info "路径: $GUI_APP"
|
||||
return 0
|
||||
fi
|
||||
if grep -Eiq 'error:|panicked|could not compile' "$DESKTOP_LOG" 2>/dev/null; then
|
||||
error "桌面启动失败,见 $DESKTOP_LOG"
|
||||
tail -n 40 "$DESKTOP_LOG" || true
|
||||
return 1
|
||||
fi
|
||||
i=$((i + 1))
|
||||
if [ "$i" -ge 180 ]; then
|
||||
if [ "$i" -ge 30 ]; then
|
||||
error "桌面启动超时,见 $DESKTOP_LOG"
|
||||
tail -n 40 "$DESKTOP_LOG" || true
|
||||
return 1
|
||||
@@ -226,10 +251,7 @@ start_desktop() {
|
||||
}
|
||||
|
||||
stop_desktop() {
|
||||
stop_detached "$DESKTOP_LABEL"
|
||||
pkill -f 'target/debug/opsxshell' 2>/dev/null || true
|
||||
pkill -f 'target/debug/OpsXShell' 2>/dev/null || true
|
||||
pkill -f 'node_modules/.bin/tauri' 2>/dev/null || true
|
||||
kill_opsxshell_gui
|
||||
success "桌面已停止"
|
||||
}
|
||||
|
||||
@@ -241,7 +263,7 @@ status() {
|
||||
warning "server :$SERVER_PORT DOWN"
|
||||
fi
|
||||
if curl -fsS "http://127.0.0.1:$WEB_PORT/" >/dev/null 2>&1; then
|
||||
success "vite :$WEB_PORT UP(桌面源,非浏览器产品)"
|
||||
success "vite :${WEB_PORT} UP(桌面源,非浏览器产品)"
|
||||
else
|
||||
warning "vite :$WEB_PORT DOWN"
|
||||
fi
|
||||
@@ -249,11 +271,10 @@ status() {
|
||||
|| curl -fsS "http://127.0.0.1:$SERVER_PORT/docs" >/dev/null 2>&1; then
|
||||
success "site :$SERVER_PORT 官网/docs 可达"
|
||||
fi
|
||||
if pgrep -f 'target/debug/opsxshell' >/dev/null 2>&1 \
|
||||
|| pgrep -f 'target/debug/OpsXShell' >/dev/null 2>&1; then
|
||||
success "gui OpsXShell UP"
|
||||
if gui_is_running; then
|
||||
success "gui OpsxShell UP"
|
||||
else
|
||||
warning "gui OpsXShell DOWN"
|
||||
warning "gui OpsxShell DOWN"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -263,14 +284,15 @@ $PROJECT_NAME 启动脚本(产品形态:浏览器=官网/docs · 操作=桌
|
||||
|
||||
./run.sh # 同 web
|
||||
./run.sh web # 仅 Gin:官网 + docs → http://127.0.0.1:$SERVER_PORT/
|
||||
./run.sh gui # 桌面唯一操作入口(Tauri;默认也起 Gin)
|
||||
./run.sh gui # 桌面:临时 .app(Dock=OpsxShell / icon.icns;默认也起 Gin)
|
||||
./run.sh all # 同 gui(兼容旧名)
|
||||
|
||||
./run.sh server # 只 Gin(官网/docs)
|
||||
./run.sh desktop # 只 Desktop
|
||||
./run.sh stop | restart | status | help
|
||||
./run.sh clean-apps # 全量开发机卫生(默认只注销不删 target)
|
||||
|
||||
端口: Gin/官网 $SERVER_PORT · 桌面 Vite 源 $WEB_PORT(不对外当客户端)
|
||||
端口: Gin/官网 $SERVER_PORT · 桌面 Vite 源 ${WEB_PORT}(不对外当客户端)
|
||||
日志: logs/{server,web,desktop}.log · 应用日志见 $APP_LOG
|
||||
USAGE
|
||||
}
|
||||
@@ -291,7 +313,7 @@ start_gui_scene() {
|
||||
fi
|
||||
start_desktop
|
||||
echo
|
||||
success "GUI 场景就绪 → 桌面窗口 OpsXShell"
|
||||
success "GUI 场景就绪 → 桌面窗口 OpsxShell"
|
||||
}
|
||||
|
||||
start_all_scene() {
|
||||
@@ -321,6 +343,7 @@ case "$cmd" in
|
||||
start_web_scene
|
||||
;;
|
||||
status) status ;;
|
||||
clean-apps) run_clean_apps "${@:2}" ;;
|
||||
help|-h|--help) usage ;;
|
||||
*) error "未知命令: $cmd"; usage; exit 1 ;;
|
||||
esac
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,71 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func NewRouter() *gin.Engine {
|
||||
r := gin.New()
|
||||
_ = r.SetTrustedProxies(nil)
|
||||
r.Use(gin.Logger(), gin.Recovery())
|
||||
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "service": "opsxshell-site"})
|
||||
})
|
||||
|
||||
// 官网/文档已拆分独立静态站(homepage/dist,构建见 tools/homepage-build);本地仅静态托管预览。
|
||||
for _, dir := range []string{
|
||||
os.Getenv("OPSXSHELL_HOMEPAGE"),
|
||||
"../../homepage/dist",
|
||||
"../homepage/dist",
|
||||
"homepage/dist",
|
||||
} {
|
||||
if dir == "" {
|
||||
continue
|
||||
}
|
||||
if st, err := os.Stat(dir); err == nil && st.IsDir() {
|
||||
fs := http.FileServer(http.Dir(dir))
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
p := c.Request.URL.Path
|
||||
if strings.HasPrefix(p, "/api/") {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "接口不存在"})
|
||||
return
|
||||
}
|
||||
upath := strings.TrimPrefix(p, "/")
|
||||
if upath != "" {
|
||||
if st2, err2 := os.Stat(filepath.Join(dir, upath)); err2 == nil && st2.IsDir() {
|
||||
c.Request.URL.Path = p + "/"
|
||||
} else if !strings.Contains(filepath.Base(upath), ".") {
|
||||
if _, err3 := os.Stat(filepath.Join(dir, upath+".html")); err3 == nil {
|
||||
c.Request.URL.Path = p + ".html"
|
||||
}
|
||||
}
|
||||
}
|
||||
fs.ServeHTTP(c.Writer, c.Request)
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 可选安装包目录(磁盘,不进 embed)
|
||||
if dir := downloadDir(); dir != "" {
|
||||
r.Static("/download", dir)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func downloadDir() string {
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
// .../apps/server/internal/server/router.go → ../../download
|
||||
dir := filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", "download"))
|
||||
return dir
|
||||
}
|
||||
Generated
+151
-267
@@ -110,15 +110,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "alloc-no-stdlib"
|
||||
version = "2.0.4"
|
||||
version = "3.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
|
||||
checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a"
|
||||
|
||||
[[package]]
|
||||
name = "alloc-stdlib"
|
||||
version = "0.2.4"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
|
||||
checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
]
|
||||
@@ -482,6 +482,12 @@ 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 = "base64ct"
|
||||
version = "1.8.3"
|
||||
@@ -587,9 +593,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "8.0.4"
|
||||
version = "9.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3"
|
||||
checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
@@ -598,9 +604,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "brotli-decompressor"
|
||||
version = "5.0.3"
|
||||
version = "6.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
|
||||
checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
@@ -707,12 +713,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cargo_toml"
|
||||
version = "0.22.3"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77"
|
||||
checksum = "82f4b26e751e711a5302649417f2da046dce6391b2ea30a4820f37462314f0b9"
|
||||
dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"toml 1.1.4+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -744,13 +751,13 @@ checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
|
||||
|
||||
[[package]]
|
||||
name = "cfb"
|
||||
version = "0.7.3"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
|
||||
checksum = "a347dcabdae9c31b0825fd6a8bed285ec9c2acb89c47827126d52fa4f59cece3"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"fnv",
|
||||
"uuid",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1093,9 +1100,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cssparser"
|
||||
version = "0.36.0"
|
||||
version = "0.37.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2"
|
||||
checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
|
||||
dependencies = [
|
||||
"cssparser-macros",
|
||||
"dtoa-short",
|
||||
@@ -1106,29 +1113,19 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cssparser-macros"
|
||||
version = "0.6.1"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
|
||||
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctor"
|
||||
version = "0.8.0"
|
||||
version = "1.0.13"
|
||||
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"
|
||||
checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d"
|
||||
|
||||
[[package]]
|
||||
name = "ctr"
|
||||
@@ -1364,6 +1361,15 @@ dependencies = [
|
||||
"dirs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "7.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.5.0"
|
||||
@@ -1442,9 +1448,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dom_query"
|
||||
version = "0.27.0"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89"
|
||||
checksum = "fac5fca71e65e94cc718a6e2af65d6e0f9c6027751c2aa562fbb5087fda639bc"
|
||||
dependencies = [
|
||||
"bit-set",
|
||||
"cssparser",
|
||||
@@ -1485,21 +1491,6 @@ 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"
|
||||
@@ -2369,9 +2360,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.38.0"
|
||||
version = "0.39.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2"
|
||||
checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
|
||||
dependencies = [
|
||||
"log",
|
||||
"markup5ever",
|
||||
@@ -2498,7 +2489,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.62.2",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2668,9 +2659,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "infer"
|
||||
version = "0.19.0"
|
||||
version = "0.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7"
|
||||
checksum = "f4200d433cbd5178df7797c9c2e75b348b728e39631cf14520d1e2fc424201f4"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
]
|
||||
@@ -3060,21 +3051,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "json-patch"
|
||||
version = "3.0.1"
|
||||
version = "4.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08"
|
||||
checksum = "7421438de105a0827e44fadd05377727847d717c80ce29a229f85fd04c427b72"
|
||||
dependencies = [
|
||||
"jsonptr",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror 2.0.19",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonptr"
|
||||
version = "0.6.3"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70"
|
||||
checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3092,13 +3083,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "keyboard-types"
|
||||
version = "0.7.0"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
|
||||
checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"serde",
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3265,9 +3255,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.38.0"
|
||||
version = "0.39.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862"
|
||||
checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
|
||||
dependencies = [
|
||||
"log",
|
||||
"tendril",
|
||||
@@ -3372,9 +3362,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "muda"
|
||||
version = "0.19.3"
|
||||
version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878"
|
||||
checksum = "cca139e57da4383727f189e43e66e84fa372347027fc996f9f1a007ec0a37996"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"dpi",
|
||||
@@ -3406,6 +3396,12 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk-context"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
|
||||
|
||||
[[package]]
|
||||
name = "ndk-sys"
|
||||
version = "0.6.0+11769913"
|
||||
@@ -3563,6 +3559,7 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-foundation",
|
||||
"objc2-quartz-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3764,6 +3761,7 @@ version = "5.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"is-wsl",
|
||||
"libc",
|
||||
]
|
||||
@@ -3804,7 +3802,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
"base64 0.22.1",
|
||||
"dirs",
|
||||
"dirs 6.0.0",
|
||||
"ed25519-dalek 2.2.0",
|
||||
"hex",
|
||||
"hmac 0.12.1",
|
||||
@@ -3828,6 +3826,7 @@ dependencies = [
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-deep-link",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-single-instance",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
@@ -5139,9 +5138,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.36.1"
|
||||
version = "0.38.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c"
|
||||
checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"cssparser",
|
||||
@@ -5684,7 +5683,7 @@ dependencies = [
|
||||
"url",
|
||||
"uuid",
|
||||
"widestring",
|
||||
"windows 0.62.2",
|
||||
"windows",
|
||||
"windows-registry 0.6.1",
|
||||
"winscard",
|
||||
"zeroize",
|
||||
@@ -5734,9 +5733,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "swift-rs"
|
||||
version = "1.0.7"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7"
|
||||
checksum = "cefd87076fd385308ee4aff597256c902f1fbb4d031de1558efc12f8ffefadd9"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"serde",
|
||||
@@ -5811,9 +5810,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tao"
|
||||
version = "0.35.3"
|
||||
version = "0.37.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
|
||||
checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"block2",
|
||||
@@ -5831,20 +5830,20 @@ dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"ndk",
|
||||
"ndk-context",
|
||||
"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 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
"windows",
|
||||
"windows-core",
|
||||
"windows-version",
|
||||
"x11-dl",
|
||||
]
|
||||
@@ -5874,14 +5873,14 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
|
||||
|
||||
[[package]]
|
||||
name = "tauri"
|
||||
version = "2.11.5"
|
||||
version = "2.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5"
|
||||
checksum = "c404ecf956ff241bf6b39dd688614724e1bf1f63255abb4fc46d1c40371672ea"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"dirs",
|
||||
"dirs 7.0.0",
|
||||
"dunce",
|
||||
"embed_plist",
|
||||
"getrandom 0.3.4",
|
||||
@@ -5920,18 +5919,18 @@ dependencies = [
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"window-vibrancy",
|
||||
"windows 0.61.3",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-build"
|
||||
version = "2.6.3"
|
||||
version = "2.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632"
|
||||
checksum = "2eea0b6302f82c574b21af6fc6ae6e86183b9280853c22621d0abf0936911ffc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cargo_toml",
|
||||
"dirs",
|
||||
"dirs 7.0.0",
|
||||
"glob",
|
||||
"heck 0.5.0",
|
||||
"json-patch",
|
||||
@@ -5946,16 +5945,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-codegen"
|
||||
version = "2.6.3"
|
||||
version = "2.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5"
|
||||
checksum = "02f468f9a9bcd404f57e5c191d91024e3de953d4b610f78c2907e7110615cb1c"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"base64 0.23.1",
|
||||
"brotli",
|
||||
"ico",
|
||||
"json-patch",
|
||||
"plist",
|
||||
"png 0.17.16",
|
||||
"png 0.18.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"semver",
|
||||
@@ -5973,9 +5972,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-macros"
|
||||
version = "2.6.3"
|
||||
version = "2.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45"
|
||||
checksum = "131bffec939b20642380317f8403a33c8888489925e14bb965ceaff0d949b1cb"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
@@ -5987,9 +5986,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin"
|
||||
version = "2.6.3"
|
||||
version = "2.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020"
|
||||
checksum = "1140cf34a3b3b836a13103dcab17f18831d5cc3534cbd435dc01a5c6daa65aa2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"glob",
|
||||
@@ -6022,6 +6021,28 @@ dependencies = [
|
||||
"windows-result 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-opener"
|
||||
version = "2.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85dead674ee41937d042ee855d785f07ee67206f19402200c12b9255964d5ac8"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"glob",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
"open",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.19",
|
||||
"url",
|
||||
"windows",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-single-instance"
|
||||
version = "2.4.3"
|
||||
@@ -6040,9 +6061,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.11.3"
|
||||
version = "2.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8"
|
||||
checksum = "c2a57a2a9b8c8b7fc00d35b510fe808ded7f9f1b44fc1cdda6aee38632ea5413"
|
||||
dependencies = [
|
||||
"cookie",
|
||||
"dpi",
|
||||
@@ -6060,14 +6081,14 @@ dependencies = [
|
||||
"url",
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"windows 0.61.3",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime-wry"
|
||||
version = "2.11.4"
|
||||
version = "2.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f"
|
||||
checksum = "7f9e7e0c9f80a5130773cfabb6df4dc924518fb1b2c923726966804634b57d22"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"http",
|
||||
@@ -6085,15 +6106,15 @@ dependencies = [
|
||||
"url",
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"windows 0.61.3",
|
||||
"windows",
|
||||
"wry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-utils"
|
||||
version = "2.9.3"
|
||||
version = "2.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887"
|
||||
checksum = "2ff55a614843b9a3f010f211df637c94df2607175b8d0f6e0f4a7c19e18621ee"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"brotli",
|
||||
@@ -6370,21 +6391,6 @@ dependencies = [
|
||||
"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.0",
|
||||
"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.4+spec-1.1.0"
|
||||
@@ -6409,15 +6415,6 @@ 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"
|
||||
@@ -6587,12 +6584,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tray-icon"
|
||||
version = "0.24.2"
|
||||
version = "0.25.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e"
|
||||
checksum = "b2b9c52859a94554803ccd4a24b98f74148ebc73b90676d783f3490b1bff9d72"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"dirs",
|
||||
"dirs 7.0.0",
|
||||
"libappindicator",
|
||||
"muda",
|
||||
"objc2",
|
||||
@@ -6645,47 +6642,6 @@ dependencies = [
|
||||
"thiserror 2.0.19",
|
||||
]
|
||||
|
||||
[[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"
|
||||
@@ -6745,13 +6701,13 @@ checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
|
||||
[[package]]
|
||||
name = "urlpattern"
|
||||
version = "0.3.0"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d"
|
||||
checksum = "df16f50ef4cc145211879a3867ba757076b25dfee812040dcb0658bd9ae7904b"
|
||||
dependencies = [
|
||||
"icu_properties",
|
||||
"regex",
|
||||
"serde",
|
||||
"unic-ucd-ident",
|
||||
"url",
|
||||
]
|
||||
|
||||
@@ -7083,16 +7039,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com"
|
||||
version = "0.38.2"
|
||||
version = "0.39.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
|
||||
checksum = "3f89fca7a704cee10dcb3654c1dbb8941d1783132f1917358af75bec37a7d7e6"
|
||||
dependencies = [
|
||||
"webview2-com-macros",
|
||||
"webview2-com-sys",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7108,13 +7062,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com-sys"
|
||||
version = "0.38.2"
|
||||
version = "0.39.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
|
||||
checksum = "b3a07132775117d6065853d9d1178157b8c90e228de47129d6bce2c7edebedfb"
|
||||
dependencies = [
|
||||
"thiserror 2.0.19",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
"windows",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7156,51 +7110,30 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "window-vibrancy"
|
||||
version = "0.6.0"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c"
|
||||
checksum = "111e51caca442cafd9bab396628ac4d814880eaea4e63dfd76a12e9f342b5580"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"objc2-quartz-core",
|
||||
"raw-window-handle",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.61.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
|
||||
dependencies = [
|
||||
"windows-collections 0.2.0",
|
||||
"windows-core 0.61.2",
|
||||
"windows-future 0.2.1",
|
||||
"windows-link 0.1.3",
|
||||
"windows-numerics 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
||||
dependencies = [
|
||||
"windows-collections 0.3.2",
|
||||
"windows-core 0.62.2",
|
||||
"windows-future 0.3.2",
|
||||
"windows-numerics 0.3.1",
|
||||
]
|
||||
|
||||
[[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",
|
||||
"windows-collections",
|
||||
"windows-core",
|
||||
"windows-future",
|
||||
"windows-numerics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7209,20 +7142,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
||||
dependencies = [
|
||||
"windows-core 0.62.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",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7238,26 +7158,15 @@ dependencies = [
|
||||
"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 0.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-future"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
||||
dependencies = [
|
||||
"windows-core 0.62.2",
|
||||
"windows-core",
|
||||
"windows-link 0.2.1",
|
||||
"windows-threading 0.2.1",
|
||||
"windows-threading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7294,23 +7203,13 @@ 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-numerics"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
||||
dependencies = [
|
||||
"windows-core 0.62.2",
|
||||
"windows-core",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
@@ -7465,15 +7364,6 @@ dependencies = [
|
||||
"windows_x86_64_msvc 0.53.1",
|
||||
]
|
||||
|
||||
[[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-threading"
|
||||
version = "0.2.1"
|
||||
@@ -7639,12 +7529,6 @@ 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"
|
||||
@@ -7710,15 +7594,15 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||
|
||||
[[package]]
|
||||
name = "wry"
|
||||
version = "0.55.1"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514"
|
||||
checksum = "a819957a01b3119af85e638a38d242af76dbc87d130dca67bfd0441072e21ff0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"block2",
|
||||
"cookie",
|
||||
"crossbeam-channel",
|
||||
"dirs",
|
||||
"dirs 7.0.0",
|
||||
"dom_query",
|
||||
"dpi",
|
||||
"dunce",
|
||||
@@ -7746,8 +7630,8 @@ dependencies = [
|
||||
"webkit2gtk",
|
||||
"webkit2gtk-sys",
|
||||
"webview2-com",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
"windows",
|
||||
"windows-core",
|
||||
"windows-version",
|
||||
"x11-dl",
|
||||
]
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
[package]
|
||||
name = "opsxshell"
|
||||
version = "2.1.1"
|
||||
description = "OpsXShell native remote operations runtime"
|
||||
authors = ["OpsXShell Team"]
|
||||
description = "OpsxShell native remote operations runtime"
|
||||
authors = ["OpsxShell Team"]
|
||||
edition = "2021"
|
||||
rust-version = "1.89"
|
||||
default-run = "OpsxShell"
|
||||
|
||||
[lib]
|
||||
name = "opsxshell_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[[bin]]
|
||||
name = "OpsxShell"
|
||||
path = "src/main.rs"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.4", features = [] }
|
||||
|
||||
@@ -52,6 +57,7 @@ rustls = { version = "0.23", default-features = false, features = ["std", "tls12
|
||||
tokio-rustls = "0.26"
|
||||
x509-cert = { version = "0.2", default-features = false, features = ["std"] }
|
||||
vnc-rs = "0.5"
|
||||
tauri-plugin-opener = "2.5.5"
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"core:event:default",
|
||||
"core:window:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:webview:allow-internal-toggle-devtools"
|
||||
"core:webview:allow-internal-toggle-devtools",
|
||||
"opener:default"
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1 +1,19 @@
|
||||
{"desktop-default":{"identifier":"desktop-default","description":"Minimum capabilities for the OpsXShell main window","remote":{"urls":["http://127.0.0.1:1420","http://127.0.0.1:1420/*","http://localhost:1420","http://localhost:1420/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:window:default","core:window:allow-start-dragging","core:webview:allow-internal-toggle-devtools"]}}
|
||||
{
|
||||
"desktop-default": {
|
||||
"identifier": "desktop-default",
|
||||
"description": "Minimum capabilities for the OpsXShell main window",
|
||||
"remote": {
|
||||
"urls": ["http://127.0.0.1:1420", "http://127.0.0.1:1420/*", "http://localhost:1420", "http://localhost:1420/*"]
|
||||
},
|
||||
"local": true,
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:event:default",
|
||||
"core:window:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:webview:allow-internal-toggle-devtools",
|
||||
"opener:default"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -21,9 +21,7 @@
|
||||
{
|
||||
"description": "A list of capabilities.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"capabilities"
|
||||
],
|
||||
"required": ["capabilities"],
|
||||
"properties": {
|
||||
"capabilities": {
|
||||
"description": "The list of capabilities.",
|
||||
@@ -39,10 +37,7 @@
|
||||
"Capability": {
|
||||
"description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"identifier",
|
||||
"permissions"
|
||||
],
|
||||
"required": ["identifier", "permissions"],
|
||||
"properties": {
|
||||
"identifier": {
|
||||
"description": "Identifier of the capability.\n\n## Example\n\n`main-user-files-write`",
|
||||
@@ -93,10 +88,7 @@
|
||||
},
|
||||
"platforms": {
|
||||
"description": "Limit which target platforms this capability applies to.\n\nBy default all platforms are targeted.\n\n## Example\n\n`[\"macOS\",\"windows\"]`",
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
],
|
||||
"type": ["array", "null"],
|
||||
"items": {
|
||||
"$ref": "#/definitions/Target"
|
||||
}
|
||||
@@ -106,9 +98,7 @@
|
||||
"CapabilityRemote": {
|
||||
"description": "Configuration for remote URLs that are associated with the capability.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"urls"
|
||||
],
|
||||
"required": ["urls"],
|
||||
"properties": {
|
||||
"urls": {
|
||||
"description": "Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).\n\n## Examples\n\n- \"https://*.mydomain.dev\": allows subdomains of mydomain.dev - \"https://mydomain.dev/api/*\": allows any subpath of mydomain.dev/api",
|
||||
@@ -134,6 +124,166 @@
|
||||
"description": "Reference a permission or permission set by identifier and extends its scope.",
|
||||
"type": "object",
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"identifier": {
|
||||
"anyOf": [
|
||||
{
|
||||
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
|
||||
"type": "string",
|
||||
"const": "opener:default",
|
||||
"markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`"
|
||||
},
|
||||
{
|
||||
"description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.",
|
||||
"type": "string",
|
||||
"const": "opener:allow-default-urls",
|
||||
"markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application."
|
||||
},
|
||||
{
|
||||
"description": "Enables the open_path command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "opener:allow-open-path",
|
||||
"markdownDescription": "Enables the open_path command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the open_url command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "opener:allow-open-url",
|
||||
"markdownDescription": "Enables the open_url command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the reveal_item_in_dir command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "opener:allow-reveal-item-in-dir",
|
||||
"markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the open_path command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "opener:deny-open-path",
|
||||
"markdownDescription": "Denies the open_path command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the open_url command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "opener:deny-open-url",
|
||||
"markdownDescription": "Denies the open_url command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the reveal_item_in_dir command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "opener:deny-reveal-item-in-dir",
|
||||
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"allow": {
|
||||
"items": {
|
||||
"title": "OpenerScopeEntry",
|
||||
"description": "Opener scope entry.",
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["url"],
|
||||
"properties": {
|
||||
"app": {
|
||||
"description": "An application to open this url with, for example: firefox.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/Application"
|
||||
}
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["path"],
|
||||
"properties": {
|
||||
"app": {
|
||||
"description": "An application to open this path with, for example: xdg-open.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/Application"
|
||||
}
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"deny": {
|
||||
"items": {
|
||||
"title": "OpenerScopeEntry",
|
||||
"description": "Opener scope entry.",
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["url"],
|
||||
"properties": {
|
||||
"app": {
|
||||
"description": "An application to open this url with, for example: firefox.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/Application"
|
||||
}
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["path"],
|
||||
"properties": {
|
||||
"app": {
|
||||
"description": "An application to open this path with, for example: xdg-open.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/Application"
|
||||
}
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"identifier": {
|
||||
"description": "Identifier of the permission or permission set.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/Identifier"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"identifier": {
|
||||
@@ -146,20 +296,14 @@
|
||||
},
|
||||
"allow": {
|
||||
"description": "Data that defines what is allowed by the scope.",
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
],
|
||||
"type": ["array", "null"],
|
||||
"items": {
|
||||
"$ref": "#/definitions/Value"
|
||||
}
|
||||
},
|
||||
"deny": {
|
||||
"description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.",
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
],
|
||||
"type": ["array", "null"],
|
||||
"items": {
|
||||
"$ref": "#/definitions/Value"
|
||||
}
|
||||
@@ -167,9 +311,7 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"required": [
|
||||
"identifier"
|
||||
]
|
||||
"required": ["identifier"]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -212,6 +354,12 @@
|
||||
"const": "core:app:allow-default-window-icon",
|
||||
"markdownDescription": "Enables the default_window_icon command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the exit command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:app:allow-exit",
|
||||
"markdownDescription": "Enables the exit command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the fetch_data_store_identifiers command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -302,6 +450,12 @@
|
||||
"const": "core:app:deny-default-window-icon",
|
||||
"markdownDescription": "Denies the default_window_icon command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the exit command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:app:deny-exit",
|
||||
"markdownDescription": "Denies the exit command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the fetch_data_store_identifiers command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1562,6 +1716,12 @@
|
||||
"const": "core:window:allow-set-fullscreen",
|
||||
"markdownDescription": "Enables the set_fullscreen command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_fullscreen_on_monitor command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:window:allow-set-fullscreen-on-monitor",
|
||||
"markdownDescription": "Enables the set_fullscreen_on_monitor command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_icon command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -2030,6 +2190,12 @@
|
||||
"const": "core:window:deny-set-fullscreen",
|
||||
"markdownDescription": "Denies the set_fullscreen command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_fullscreen_on_monitor command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:window:deny-set-fullscreen-on-monitor",
|
||||
"markdownDescription": "Denies the set_fullscreen_on_monitor command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_icon command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -2245,6 +2411,54 @@
|
||||
"type": "string",
|
||||
"const": "deep-link:deny-unregister",
|
||||
"markdownDescription": "Denies the unregister command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
|
||||
"type": "string",
|
||||
"const": "opener:default",
|
||||
"markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`"
|
||||
},
|
||||
{
|
||||
"description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.",
|
||||
"type": "string",
|
||||
"const": "opener:allow-default-urls",
|
||||
"markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application."
|
||||
},
|
||||
{
|
||||
"description": "Enables the open_path command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "opener:allow-open-path",
|
||||
"markdownDescription": "Enables the open_path command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the open_url command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "opener:allow-open-url",
|
||||
"markdownDescription": "Enables the open_url command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the reveal_item_in_dir command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "opener:allow-reveal-item-in-dir",
|
||||
"markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the open_path command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "opener:deny-open-path",
|
||||
"markdownDescription": "Denies the open_path command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the open_url command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "opener:deny-open-url",
|
||||
"markdownDescription": "Denies the open_url command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the reveal_item_in_dir command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "opener:deny-reveal-item-in-dir",
|
||||
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -2308,39 +2522,46 @@
|
||||
{
|
||||
"description": "MacOS.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"macOS"
|
||||
]
|
||||
"enum": ["macOS"]
|
||||
},
|
||||
{
|
||||
"description": "Windows.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"windows"
|
||||
]
|
||||
"enum": ["windows"]
|
||||
},
|
||||
{
|
||||
"description": "Linux.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"linux"
|
||||
]
|
||||
"enum": ["linux"]
|
||||
},
|
||||
{
|
||||
"description": "Android.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"android"
|
||||
]
|
||||
"enum": ["android"]
|
||||
},
|
||||
{
|
||||
"description": "iOS.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"iOS"
|
||||
]
|
||||
"enum": ["iOS"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"Application": {
|
||||
"description": "Opener scope application.",
|
||||
"anyOf": [
|
||||
{
|
||||
"description": "Open in default application.",
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"description": "If true, allow open with any application.",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"description": "Allow specific application to open with.",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,9 +21,7 @@
|
||||
{
|
||||
"description": "A list of capabilities.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"capabilities"
|
||||
],
|
||||
"required": ["capabilities"],
|
||||
"properties": {
|
||||
"capabilities": {
|
||||
"description": "The list of capabilities.",
|
||||
@@ -39,10 +37,7 @@
|
||||
"Capability": {
|
||||
"description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"identifier",
|
||||
"permissions"
|
||||
],
|
||||
"required": ["identifier", "permissions"],
|
||||
"properties": {
|
||||
"identifier": {
|
||||
"description": "Identifier of the capability.\n\n## Example\n\n`main-user-files-write`",
|
||||
@@ -93,10 +88,7 @@
|
||||
},
|
||||
"platforms": {
|
||||
"description": "Limit which target platforms this capability applies to.\n\nBy default all platforms are targeted.\n\n## Example\n\n`[\"macOS\",\"windows\"]`",
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
],
|
||||
"type": ["array", "null"],
|
||||
"items": {
|
||||
"$ref": "#/definitions/Target"
|
||||
}
|
||||
@@ -106,9 +98,7 @@
|
||||
"CapabilityRemote": {
|
||||
"description": "Configuration for remote URLs that are associated with the capability.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"urls"
|
||||
],
|
||||
"required": ["urls"],
|
||||
"properties": {
|
||||
"urls": {
|
||||
"description": "Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).\n\n## Examples\n\n- \"https://*.mydomain.dev\": allows subdomains of mydomain.dev - \"https://mydomain.dev/api/*\": allows any subpath of mydomain.dev/api",
|
||||
@@ -134,6 +124,166 @@
|
||||
"description": "Reference a permission or permission set by identifier and extends its scope.",
|
||||
"type": "object",
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"identifier": {
|
||||
"anyOf": [
|
||||
{
|
||||
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
|
||||
"type": "string",
|
||||
"const": "opener:default",
|
||||
"markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`"
|
||||
},
|
||||
{
|
||||
"description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.",
|
||||
"type": "string",
|
||||
"const": "opener:allow-default-urls",
|
||||
"markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application."
|
||||
},
|
||||
{
|
||||
"description": "Enables the open_path command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "opener:allow-open-path",
|
||||
"markdownDescription": "Enables the open_path command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the open_url command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "opener:allow-open-url",
|
||||
"markdownDescription": "Enables the open_url command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the reveal_item_in_dir command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "opener:allow-reveal-item-in-dir",
|
||||
"markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the open_path command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "opener:deny-open-path",
|
||||
"markdownDescription": "Denies the open_path command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the open_url command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "opener:deny-open-url",
|
||||
"markdownDescription": "Denies the open_url command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the reveal_item_in_dir command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "opener:deny-reveal-item-in-dir",
|
||||
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"allow": {
|
||||
"items": {
|
||||
"title": "OpenerScopeEntry",
|
||||
"description": "Opener scope entry.",
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["url"],
|
||||
"properties": {
|
||||
"app": {
|
||||
"description": "An application to open this url with, for example: firefox.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/Application"
|
||||
}
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["path"],
|
||||
"properties": {
|
||||
"app": {
|
||||
"description": "An application to open this path with, for example: xdg-open.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/Application"
|
||||
}
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"deny": {
|
||||
"items": {
|
||||
"title": "OpenerScopeEntry",
|
||||
"description": "Opener scope entry.",
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["url"],
|
||||
"properties": {
|
||||
"app": {
|
||||
"description": "An application to open this url with, for example: firefox.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/Application"
|
||||
}
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["path"],
|
||||
"properties": {
|
||||
"app": {
|
||||
"description": "An application to open this path with, for example: xdg-open.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/Application"
|
||||
}
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"identifier": {
|
||||
"description": "Identifier of the permission or permission set.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/Identifier"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"identifier": {
|
||||
@@ -146,20 +296,14 @@
|
||||
},
|
||||
"allow": {
|
||||
"description": "Data that defines what is allowed by the scope.",
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
],
|
||||
"type": ["array", "null"],
|
||||
"items": {
|
||||
"$ref": "#/definitions/Value"
|
||||
}
|
||||
},
|
||||
"deny": {
|
||||
"description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.",
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
],
|
||||
"type": ["array", "null"],
|
||||
"items": {
|
||||
"$ref": "#/definitions/Value"
|
||||
}
|
||||
@@ -167,9 +311,7 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"required": [
|
||||
"identifier"
|
||||
]
|
||||
"required": ["identifier"]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -212,6 +354,12 @@
|
||||
"const": "core:app:allow-default-window-icon",
|
||||
"markdownDescription": "Enables the default_window_icon command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the exit command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:app:allow-exit",
|
||||
"markdownDescription": "Enables the exit command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the fetch_data_store_identifiers command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -302,6 +450,12 @@
|
||||
"const": "core:app:deny-default-window-icon",
|
||||
"markdownDescription": "Denies the default_window_icon command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the exit command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:app:deny-exit",
|
||||
"markdownDescription": "Denies the exit command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the fetch_data_store_identifiers command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1562,6 +1716,12 @@
|
||||
"const": "core:window:allow-set-fullscreen",
|
||||
"markdownDescription": "Enables the set_fullscreen command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_fullscreen_on_monitor command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:window:allow-set-fullscreen-on-monitor",
|
||||
"markdownDescription": "Enables the set_fullscreen_on_monitor command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_icon command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -2030,6 +2190,12 @@
|
||||
"const": "core:window:deny-set-fullscreen",
|
||||
"markdownDescription": "Denies the set_fullscreen command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_fullscreen_on_monitor command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:window:deny-set-fullscreen-on-monitor",
|
||||
"markdownDescription": "Denies the set_fullscreen_on_monitor command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_icon command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -2245,6 +2411,54 @@
|
||||
"type": "string",
|
||||
"const": "deep-link:deny-unregister",
|
||||
"markdownDescription": "Denies the unregister command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
|
||||
"type": "string",
|
||||
"const": "opener:default",
|
||||
"markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`"
|
||||
},
|
||||
{
|
||||
"description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.",
|
||||
"type": "string",
|
||||
"const": "opener:allow-default-urls",
|
||||
"markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application."
|
||||
},
|
||||
{
|
||||
"description": "Enables the open_path command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "opener:allow-open-path",
|
||||
"markdownDescription": "Enables the open_path command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the open_url command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "opener:allow-open-url",
|
||||
"markdownDescription": "Enables the open_url command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the reveal_item_in_dir command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "opener:allow-reveal-item-in-dir",
|
||||
"markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the open_path command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "opener:deny-open-path",
|
||||
"markdownDescription": "Denies the open_path command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the open_url command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "opener:deny-open-url",
|
||||
"markdownDescription": "Denies the open_url command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the reveal_item_in_dir command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "opener:deny-reveal-item-in-dir",
|
||||
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -2308,39 +2522,46 @@
|
||||
{
|
||||
"description": "MacOS.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"macOS"
|
||||
]
|
||||
"enum": ["macOS"]
|
||||
},
|
||||
{
|
||||
"description": "Windows.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"windows"
|
||||
]
|
||||
"enum": ["windows"]
|
||||
},
|
||||
{
|
||||
"description": "Linux.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"linux"
|
||||
]
|
||||
"enum": ["linux"]
|
||||
},
|
||||
{
|
||||
"description": "Android.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"android"
|
||||
]
|
||||
"enum": ["android"]
|
||||
},
|
||||
{
|
||||
"description": "iOS.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"iOS"
|
||||
]
|
||||
"enum": ["iOS"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"Application": {
|
||||
"description": "Opener scope application.",
|
||||
"anyOf": [
|
||||
{
|
||||
"description": "Open in default application.",
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"description": "If true, allow open with any application.",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"description": "Allow specific application to open with.",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
-262
@@ -1,13 +1,12 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
#[cfg(test)]
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use crate::domain::ssh::OperationResponse;
|
||||
use crate::domain::{files::*, ssh::OperationResponse};
|
||||
use crate::services::files::{self, expand_home};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -36,53 +35,6 @@ pub struct DialogResult {
|
||||
file_paths: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct PathResponse {
|
||||
success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ReadFileResponse {
|
||||
success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
data: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LocalFileEntry {
|
||||
name: String,
|
||||
path: String,
|
||||
#[serde(rename = "type")]
|
||||
entry_type: String,
|
||||
size: u64,
|
||||
modified: u64,
|
||||
permissions: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LocalFilesResponse {
|
||||
success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
files: Option<Vec<LocalFileEntry>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LocalStatsResponse {
|
||||
success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stats: Option<LocalFileEntry>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn show_open_dialog(options: DialogOptions) -> DialogResult {
|
||||
let dialog = configure_dialog(rfd::FileDialog::new(), &options);
|
||||
@@ -139,44 +91,25 @@ pub fn show_save_dialog(app: AppHandle, options: DialogOptions) -> DialogResult
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn read_file(file_path: String) -> ReadFileResponse {
|
||||
tauri::async_runtime::spawn_blocking(move || match fs::read(expand_home(&file_path)) {
|
||||
Ok(data) => ReadFileResponse {
|
||||
success: true,
|
||||
data: Some(STANDARD.encode(data)),
|
||||
error: None,
|
||||
},
|
||||
Err(error) => ReadFileResponse {
|
||||
tauri::async_runtime::spawn_blocking(move || files::read_file(file_path))
|
||||
.await
|
||||
.unwrap_or_else(|_| ReadFileResponse {
|
||||
success: false,
|
||||
data: None,
|
||||
error: Some(error.to_string()),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| ReadFileResponse {
|
||||
success: false,
|
||||
data: None,
|
||||
error: Some(crate::domain::error::codes::TASK_JOIN.to_string()),
|
||||
})
|
||||
error: Some(crate::domain::error::codes::TASK_JOIN.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn write_file(file_path: String, data: String) -> OperationResponse {
|
||||
tauri::async_runtime::spawn_blocking(move || match STANDARD.decode(&data) {
|
||||
Err(error) => OperationResponse::coded(
|
||||
crate::domain::error::codes::INVALID_BASE64,
|
||||
error.to_string(),
|
||||
),
|
||||
Ok(bytes) => {
|
||||
operation(fs::write(expand_home(&file_path), bytes).map_err(|error| error.to_string()))
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
OperationResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"write file background task failed",
|
||||
)
|
||||
})
|
||||
tauri::async_runtime::spawn_blocking(move || files::write_file(file_path, data))
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
OperationResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"write file background task failed",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -204,111 +137,72 @@ pub fn get_downloads_path(app: AppHandle) -> Result<String, String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_downloads_folder(app: AppHandle) -> Result<(), String> {
|
||||
pub async fn open_downloads_folder(app: AppHandle) -> Result<(), String> {
|
||||
let path = app
|
||||
.path()
|
||||
.download_dir()
|
||||
.map_err(|error| error.to_string())?;
|
||||
fs::create_dir_all(&path).map_err(|error| format!("创建下载目录失败: {error}"))?;
|
||||
open::that(&path).map_err(|error| format!("打开下载目录失败: {error}"))
|
||||
tauri::async_runtime::spawn_blocking(move || files::open_downloads_folder(path))
|
||||
.await
|
||||
.map_err(|error| crate::domain::error::AppError::TaskJoin(error.to_string()).to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn local_read_dir(path: String) -> LocalFilesResponse {
|
||||
tauri::async_runtime::spawn_blocking(move || match read_local_dir(&expand_home(&path)) {
|
||||
Ok(files) => LocalFilesResponse {
|
||||
success: true,
|
||||
files: Some(files),
|
||||
error: None,
|
||||
},
|
||||
Err(error) => LocalFilesResponse {
|
||||
tauri::async_runtime::spawn_blocking(move || files::local_read_dir(path))
|
||||
.await
|
||||
.unwrap_or_else(|_| LocalFilesResponse {
|
||||
success: false,
|
||||
files: None,
|
||||
error: Some(error),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| LocalFilesResponse {
|
||||
success: false,
|
||||
files: None,
|
||||
error: Some(crate::domain::error::codes::TASK_JOIN.to_string()),
|
||||
})
|
||||
error: Some(crate::domain::error::codes::TASK_JOIN.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn local_get_stats(path: String) -> LocalStatsResponse {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let path = expand_home(&path);
|
||||
match local_entry(&path) {
|
||||
Ok(stats) => LocalStatsResponse {
|
||||
success: true,
|
||||
stats: Some(stats),
|
||||
error: None,
|
||||
},
|
||||
Err(error) => LocalStatsResponse {
|
||||
success: false,
|
||||
stats: None,
|
||||
error: Some(error),
|
||||
},
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| LocalStatsResponse {
|
||||
success: false,
|
||||
stats: None,
|
||||
error: Some(crate::domain::error::codes::TASK_JOIN.to_string()),
|
||||
})
|
||||
tauri::async_runtime::spawn_blocking(move || files::local_get_stats(path))
|
||||
.await
|
||||
.unwrap_or_else(|_| LocalStatsResponse {
|
||||
success: false,
|
||||
stats: None,
|
||||
error: Some(crate::domain::error::codes::TASK_JOIN.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn local_mkdir(path: String) -> OperationResponse {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
operation(fs::create_dir_all(expand_home(&path)).map_err(|error| error.to_string()))
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
OperationResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"mkdir background task failed",
|
||||
)
|
||||
})
|
||||
tauri::async_runtime::spawn_blocking(move || files::local_mkdir(path))
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
OperationResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"mkdir background task failed",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn local_delete(path: String) -> OperationResponse {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let path = expand_home(&path);
|
||||
let result = if path.is_dir() {
|
||||
fs::remove_dir_all(path)
|
||||
} else {
|
||||
fs::remove_file(path)
|
||||
};
|
||||
operation(result.map_err(|error| error.to_string()))
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
OperationResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"delete background task failed",
|
||||
)
|
||||
})
|
||||
tauri::async_runtime::spawn_blocking(move || files::local_delete(path))
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
OperationResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"delete background task failed",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn local_rename(old_path: String, new_path: String) -> OperationResponse {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
operation(
|
||||
fs::rename(expand_home(&old_path), expand_home(&new_path))
|
||||
.map_err(|error| error.to_string()),
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
OperationResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"rename background task failed",
|
||||
)
|
||||
})
|
||||
tauri::async_runtime::spawn_blocking(move || files::local_rename(old_path, new_path))
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
OperationResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"rename background task failed",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -372,97 +266,6 @@ fn configure_dialog(mut dialog: rfd::FileDialog, options: &DialogOptions) -> rfd
|
||||
dialog
|
||||
}
|
||||
|
||||
fn read_local_dir(path: &Path) -> Result<Vec<LocalFileEntry>, String> {
|
||||
let mut files = Vec::new();
|
||||
for entry in fs::read_dir(path).map_err(|error| error.to_string())? {
|
||||
let entry = entry.map_err(|error| error.to_string())?;
|
||||
files.push(local_entry(&entry.path())?);
|
||||
}
|
||||
files.sort_by(|a, b| {
|
||||
((a.entry_type == "file"), a.name.to_lowercase())
|
||||
.cmp(&((b.entry_type == "file"), b.name.to_lowercase()))
|
||||
});
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn local_entry(path: &Path) -> Result<LocalFileEntry, String> {
|
||||
let metadata = fs::symlink_metadata(path).map_err(|error| error.to_string())?;
|
||||
let entry_type = if metadata.file_type().is_symlink() {
|
||||
"symlink"
|
||||
} else if metadata.is_dir() {
|
||||
"directory"
|
||||
} else {
|
||||
"file"
|
||||
};
|
||||
Ok(LocalFileEntry {
|
||||
name: path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
path: path.to_string_lossy().to_string(),
|
||||
entry_type: entry_type.to_string(),
|
||||
size: metadata.len(),
|
||||
modified: metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0),
|
||||
permissions: permissions(&metadata, entry_type),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn permissions(metadata: &fs::Metadata, kind: &str) -> String {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = metadata.permissions().mode();
|
||||
let prefix = if kind == "directory" {
|
||||
'd'
|
||||
} else if kind == "symlink" {
|
||||
'l'
|
||||
} else {
|
||||
'-'
|
||||
};
|
||||
format!("{}{}", prefix, permission_bits(mode))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn permissions(_metadata: &fs::Metadata, kind: &str) -> String {
|
||||
if kind == "directory" {
|
||||
"drwxr-xr-x".to_string()
|
||||
} else {
|
||||
"-rw-r--r--".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn permission_bits(mode: u32) -> String {
|
||||
let mut output = String::new();
|
||||
for shift in [6, 3, 0] {
|
||||
let bits = (mode >> shift) & 7;
|
||||
output.push(if bits & 4 != 0 { 'r' } else { '-' });
|
||||
output.push(if bits & 2 != 0 { 'w' } else { '-' });
|
||||
output.push(if bits & 1 != 0 { 'x' } else { '-' });
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn expand_home(value: &str) -> PathBuf {
|
||||
if value == "~" {
|
||||
return dirs::home_dir().unwrap_or_else(|| PathBuf::from(value));
|
||||
}
|
||||
let relative = value
|
||||
.strip_prefix("~/")
|
||||
.or_else(|| value.strip_prefix("~\\"));
|
||||
if let Some(relative) = relative {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home.join(std::path::Path::new(relative));
|
||||
}
|
||||
}
|
||||
PathBuf::from(value)
|
||||
}
|
||||
|
||||
fn path_result(path: PathBuf, open: bool) -> DialogResult {
|
||||
let value = path.to_string_lossy().to_string();
|
||||
DialogResult {
|
||||
@@ -495,13 +298,6 @@ fn canceled() -> DialogResult {
|
||||
}
|
||||
}
|
||||
|
||||
fn operation(result: Result<(), String>) -> OperationResponse {
|
||||
match result {
|
||||
Ok(()) => OperationResponse::ok(),
|
||||
Err(error) => OperationResponse::error(error),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -9,3 +9,33 @@ pub mod codes {
|
||||
pub const KNOWN_HOSTS_PATH: &str = "native.knownHostsPath";
|
||||
pub const INVALID_HOST_KEY_DECISION: &str = "native.invalidHostKeyDecision";
|
||||
}
|
||||
|
||||
/// Domain failures retain their cause while exposing stable IPC codes where available.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppError {
|
||||
#[error(transparent)]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error(transparent)]
|
||||
InvalidBase64(#[from] base64::DecodeError),
|
||||
#[error("{0}")]
|
||||
TaskJoin(String),
|
||||
}
|
||||
|
||||
impl AppError {
|
||||
pub fn code(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::Io(_) => None,
|
||||
Self::InvalidBase64(_) => Some(codes::INVALID_BASE64),
|
||||
Self::TaskJoin(_) => Some(codes::TASK_JOIN),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AppError> for crate::domain::ssh::OperationResponse {
|
||||
fn from(error: AppError) -> Self {
|
||||
match error.code() {
|
||||
Some(code) => Self::coded(code, error.to_string()),
|
||||
None => Self::error(error.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct PathResponse {
|
||||
pub success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ReadFileResponse {
|
||||
pub success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LocalFileEntry {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
#[serde(rename = "type")]
|
||||
pub entry_type: String,
|
||||
pub size: u64,
|
||||
pub modified: u64,
|
||||
pub permissions: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LocalFilesResponse {
|
||||
pub success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub files: Option<Vec<LocalFileEntry>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LocalStatsResponse {
|
||||
pub success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stats: Option<LocalFileEntry>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod ai;
|
||||
pub mod desktop;
|
||||
pub mod error;
|
||||
pub mod files;
|
||||
pub mod local_terminal;
|
||||
pub mod serial;
|
||||
pub mod ssh;
|
||||
@@ -47,6 +47,7 @@ pub fn run() {
|
||||
infrastructure::file_log::init();
|
||||
|
||||
let builder = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||
for argument in args {
|
||||
if argument.starts_with("ssh://") || argument.starts_with("opsxshell://") {
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
use crate::domain::{error::AppError, files::*, ssh::OperationResponse};
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
pub fn read_file(file_path: String) -> ReadFileResponse {
|
||||
match fs::read(expand_home(&file_path)) {
|
||||
Ok(data) => ReadFileResponse {
|
||||
success: true,
|
||||
data: Some(STANDARD.encode(data)),
|
||||
error: None,
|
||||
},
|
||||
Err(error) => ReadFileResponse {
|
||||
success: false,
|
||||
data: None,
|
||||
error: Some(error.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write_file(file_path: String, data: String) -> OperationResponse {
|
||||
let result = (|| -> Result<(), AppError> {
|
||||
let bytes = STANDARD.decode(&data)?;
|
||||
fs::write(expand_home(&file_path), bytes)?;
|
||||
Ok(())
|
||||
})();
|
||||
match result {
|
||||
Ok(()) => OperationResponse::ok(),
|
||||
Err(error) => error.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn local_read_dir(path: String) -> LocalFilesResponse {
|
||||
match read_local_dir(&expand_home(&path)) {
|
||||
Ok(files) => LocalFilesResponse {
|
||||
success: true,
|
||||
files: Some(files),
|
||||
error: None,
|
||||
},
|
||||
Err(error) => LocalFilesResponse {
|
||||
success: false,
|
||||
files: None,
|
||||
error: Some(error),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn local_get_stats(path: String) -> LocalStatsResponse {
|
||||
let path = expand_home(&path);
|
||||
match local_entry(&path) {
|
||||
Ok(stats) => LocalStatsResponse {
|
||||
success: true,
|
||||
stats: Some(stats),
|
||||
error: None,
|
||||
},
|
||||
Err(error) => LocalStatsResponse {
|
||||
success: false,
|
||||
stats: None,
|
||||
error: Some(error),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn local_mkdir(path: String) -> OperationResponse {
|
||||
operation(fs::create_dir_all(expand_home(&path)).map_err(|error| error.to_string()))
|
||||
}
|
||||
|
||||
pub fn local_delete(path: String) -> OperationResponse {
|
||||
let path = expand_home(&path);
|
||||
let result = if path.is_dir() {
|
||||
fs::remove_dir_all(path)
|
||||
} else {
|
||||
fs::remove_file(path)
|
||||
};
|
||||
operation(result.map_err(|error| error.to_string()))
|
||||
}
|
||||
|
||||
pub fn local_rename(old_path: String, new_path: String) -> OperationResponse {
|
||||
operation(
|
||||
fs::rename(expand_home(&old_path), expand_home(&new_path))
|
||||
.map_err(|error| error.to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
fn read_local_dir(path: &Path) -> Result<Vec<LocalFileEntry>, String> {
|
||||
let mut files = Vec::new();
|
||||
for entry in fs::read_dir(path).map_err(|error| error.to_string())? {
|
||||
let entry = entry.map_err(|error| error.to_string())?;
|
||||
files.push(local_entry(&entry.path())?);
|
||||
}
|
||||
files.sort_by(|a, b| {
|
||||
((a.entry_type == "file"), a.name.to_lowercase())
|
||||
.cmp(&((b.entry_type == "file"), b.name.to_lowercase()))
|
||||
});
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn local_entry(path: &Path) -> Result<LocalFileEntry, String> {
|
||||
let metadata = fs::symlink_metadata(path).map_err(|error| error.to_string())?;
|
||||
let entry_type = if metadata.file_type().is_symlink() {
|
||||
"symlink"
|
||||
} else if metadata.is_dir() {
|
||||
"directory"
|
||||
} else {
|
||||
"file"
|
||||
};
|
||||
Ok(LocalFileEntry {
|
||||
name: path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
path: path.to_string_lossy().to_string(),
|
||||
entry_type: entry_type.to_string(),
|
||||
size: metadata.len(),
|
||||
modified: metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0),
|
||||
permissions: permissions(&metadata, entry_type),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn permissions(metadata: &fs::Metadata, kind: &str) -> String {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = metadata.permissions().mode();
|
||||
let prefix = if kind == "directory" {
|
||||
'd'
|
||||
} else if kind == "symlink" {
|
||||
'l'
|
||||
} else {
|
||||
'-'
|
||||
};
|
||||
format!("{}{}", prefix, permission_bits(mode))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn permissions(_metadata: &fs::Metadata, kind: &str) -> String {
|
||||
if kind == "directory" {
|
||||
"drwxr-xr-x".to_string()
|
||||
} else {
|
||||
"-rw-r--r--".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn permission_bits(mode: u32) -> String {
|
||||
let mut output = String::new();
|
||||
for shift in [6, 3, 0] {
|
||||
let bits = (mode >> shift) & 7;
|
||||
output.push(if bits & 4 != 0 { 'r' } else { '-' });
|
||||
output.push(if bits & 2 != 0 { 'w' } else { '-' });
|
||||
output.push(if bits & 1 != 0 { 'x' } else { '-' });
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
pub fn expand_home(value: &str) -> PathBuf {
|
||||
if value == "~" {
|
||||
return dirs::home_dir().unwrap_or_else(|| PathBuf::from(value));
|
||||
}
|
||||
let relative = value
|
||||
.strip_prefix("~/")
|
||||
.or_else(|| value.strip_prefix("~\\"));
|
||||
if let Some(relative) = relative {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home.join(std::path::Path::new(relative));
|
||||
}
|
||||
}
|
||||
PathBuf::from(value)
|
||||
}
|
||||
|
||||
fn operation(result: Result<(), String>) -> OperationResponse {
|
||||
match result {
|
||||
Ok(()) => OperationResponse::ok(),
|
||||
Err(error) => OperationResponse::error(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the native downloads path in the command, then perform I/O off the UI thread.
|
||||
pub fn open_downloads_folder(path: PathBuf) -> Result<(), String> {
|
||||
fs::create_dir_all(&path).map_err(|error| format!("创建下载目录失败: {error}"))?;
|
||||
open::that(&path).map_err(|error| format!("打开下载目录失败: {error}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct TestDirectory(PathBuf);
|
||||
impl TestDirectory {
|
||||
fn new() -> Self {
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("opsxshell-files-{}", uuid::Uuid::new_v4()));
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
fn path(&self, name: &str) -> String {
|
||||
self.0.join(name).to_string_lossy().into_owned()
|
||||
}
|
||||
}
|
||||
impl Drop for TestDirectory {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_file_roundtrip_and_directory_operations() {
|
||||
let dir = TestDirectory::new();
|
||||
let bytes = [0, 255, 128, 10];
|
||||
let file = dir.path("z.bin");
|
||||
assert!(write_file(file.clone(), STANDARD.encode(bytes)).success);
|
||||
assert_eq!(read_file(file.clone()).data, Some(STANDARD.encode(bytes)));
|
||||
assert!(local_mkdir(dir.path("folder")).success);
|
||||
let listing = local_read_dir(dir.path(""));
|
||||
assert!(listing.success);
|
||||
let entries = listing.files.unwrap();
|
||||
assert_eq!(entries[0].entry_type, "directory");
|
||||
assert_eq!(entries[1].name, "z.bin");
|
||||
assert_eq!(local_get_stats(file.clone()).stats.unwrap().size, 4);
|
||||
let renamed = dir.path("renamed.bin");
|
||||
assert!(local_rename(file.clone(), renamed.clone()).success);
|
||||
assert!(!Path::new(&file).exists());
|
||||
assert!(local_delete(renamed.clone()).success);
|
||||
assert!(!read_file(renamed).success);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_base64_never_overwrites_existing_file() {
|
||||
let dir = TestDirectory::new();
|
||||
let path = dir.path("keep.bin");
|
||||
fs::write(&path, b"keep").unwrap();
|
||||
let response = write_file(path.clone(), "%%%".into());
|
||||
assert!(!response.success);
|
||||
assert_eq!(
|
||||
response.error_code.as_deref(),
|
||||
Some(crate::domain::error::codes::INVALID_BASE64)
|
||||
);
|
||||
assert_eq!(fs::read(path).unwrap(), b"keep");
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ pub mod ai;
|
||||
pub mod credential_store;
|
||||
pub mod deep_link;
|
||||
pub mod desktop;
|
||||
pub mod files;
|
||||
pub mod license;
|
||||
pub mod local_terminal;
|
||||
pub mod serial;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "OpsXShell",
|
||||
"productName": "OpsxShell",
|
||||
"version": "2.1.1",
|
||||
"identifier": "com.opsxshell.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev:ui",
|
||||
"devUrl": "http://127.0.0.1:1420",
|
||||
"devUrl": "http://127.0.0.1:5175",
|
||||
"beforeBuildCommand": "npm run build:ui",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
@@ -13,7 +13,7 @@
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "OpsXShell",
|
||||
"title": "OpsxShell",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"minWidth": 1024,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "OpsXShell",
|
||||
"title": "OpsxShell",
|
||||
"decorations": true,
|
||||
"titleBarStyle": "Visible",
|
||||
"hiddenTitle": false
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "OpsXShell",
|
||||
"title": "OpsxShell",
|
||||
"decorations": true,
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "OpsXShell",
|
||||
"title": "OpsxShell",
|
||||
"decorations": true,
|
||||
"titleBarStyle": "Visible",
|
||||
"hiddenTitle": false
|
||||
|
||||
@@ -25,14 +25,7 @@ import AccountSettings from './AccountSettings'
|
||||
import { themes, type TerminalThemeId } from '../../themes/terminal'
|
||||
import { getKeybindingCategories, getKeybindingsByCategory, type KeybindingActionId } from '../../constants/keybindings'
|
||||
import { filterSettingsTabsByQuery, type SettingsSearchTab } from '../../utils/settingsSearch'
|
||||
import {
|
||||
buildBugReportUrl,
|
||||
formatRuntimeSystem,
|
||||
GITHUB_DISCUSSIONS_URL,
|
||||
GITHUB_LICENSE_URL,
|
||||
GITHUB_RELEASES_URL,
|
||||
WECHAT_ID,
|
||||
} from '../../utils/bugReport'
|
||||
import { formatRuntimeSystem, GITHUB_DISCUSSIONS_URL, WECHAT_GROUP_URL } from '../../utils/bugReport'
|
||||
|
||||
// ─── Tab 类型 ───────────────────────────────────────────────
|
||||
type SettingsTab = 'general' | 'account' | 'terminal' | 'ai' | 'shortcuts' | 'ssh' | 'security' | 'about'
|
||||
@@ -157,17 +150,6 @@ export default function SettingsView() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const openBugReport = useCallback(() => {
|
||||
const url = buildBugReportUrl({
|
||||
appVersion: runtimeInfo?.appVersion,
|
||||
os: runtimeInfo?.os,
|
||||
arch: runtimeInfo?.arch,
|
||||
logPath: runtimeInfo?.logPath,
|
||||
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined,
|
||||
})
|
||||
openHttpUrl(url)
|
||||
}, [runtimeInfo])
|
||||
|
||||
// 当前 tab 元信息
|
||||
const currentMeta = useMemo(() => TAB_LIST.find((t) => t.id === activeTab)!, [activeTab])
|
||||
|
||||
@@ -188,14 +170,6 @@ export default function SettingsView() {
|
||||
|
||||
const visibleTabs = filtered ? TAB_LIST.filter((t) => filtered.includes(t.id)) : TAB_LIST
|
||||
const appVersion = runtimeInfo?.appVersion ?? '—'
|
||||
const diagnosticText = runtimeInfo
|
||||
? [
|
||||
`OpsXShell v${runtimeInfo.appVersion}`,
|
||||
formatRuntimeSystem(runtimeInfo.os, runtimeInfo.arch),
|
||||
`Data: ${runtimeInfo.dataPath}`,
|
||||
`Log: ${runtimeInfo.logPath}`,
|
||||
].join('\n')
|
||||
: `OpsXShell v${appVersion}`
|
||||
|
||||
// ── 加载态 ────────────────────────────────────────────────
|
||||
if (!isLoaded) {
|
||||
@@ -627,66 +601,27 @@ export default function SettingsView() {
|
||||
case 'about':
|
||||
return (
|
||||
<div className="max-w-xl space-y-6">
|
||||
<SettingCard>
|
||||
<div className="flex items-center gap-4 py-1">
|
||||
<img
|
||||
src="/app-icon.png"
|
||||
alt=""
|
||||
width={64}
|
||||
height={64}
|
||||
className="h-16 w-16 flex-shrink-0 rounded-[18px] shadow-sm"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-lg font-semibold tracking-tight text-content-primary">OpsXShell</h3>
|
||||
<p className="mt-0.5 text-sm text-txt-muted">{t('about.tagline')}</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-txt-faint">
|
||||
<span className="font-mono text-content-secondary">
|
||||
{t('about.appVersion', { version: appVersion })}
|
||||
</span>
|
||||
{runtimeInfo ? (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span>{formatRuntimeSystem(runtimeInfo.os, runtimeInfo.arch)}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingCard>
|
||||
<p className="px-1 text-xs text-txt-faint">
|
||||
<span className="font-mono text-content-secondary">{t('about.appVersion', { version: appVersion })}</span>
|
||||
{runtimeInfo ? (
|
||||
<>
|
||||
<span aria-hidden> · </span>
|
||||
<span>{formatRuntimeSystem(runtimeInfo.os, runtimeInfo.arch)}</span>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
|
||||
<SettingGroup title={t('about.resources')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyText('wechat', WECHAT_ID)}
|
||||
aria-label={t('about.copyWechat')}
|
||||
className="hover:bg-dark-700/35 flex w-full items-center gap-3 px-4 py-3.5 text-left transition-colors"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-content-primary">{t('about.wechat')}</div>
|
||||
<div className="mt-0.5 font-mono text-xs leading-relaxed text-txt-muted">{WECHAT_ID}</div>
|
||||
</div>
|
||||
{copiedField === 'wechat' ? (
|
||||
<Check className="h-3.5 w-3.5 flex-shrink-0 text-accent-green" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5 flex-shrink-0 text-txt-faint" />
|
||||
)}
|
||||
</button>
|
||||
<AboutLink label={t('about.reportBug')} description={t('about.reportBugDesc')} onClick={openBugReport} />
|
||||
<AboutLink
|
||||
label={t('about.wechat')}
|
||||
description={t('about.wechatDesc')}
|
||||
onClick={() => openHttpUrl(WECHAT_GROUP_URL)}
|
||||
/>
|
||||
<AboutLink
|
||||
label={t('about.github')}
|
||||
description={t('about.githubDesc')}
|
||||
onClick={() => openHttpUrl(GITHUB_DISCUSSIONS_URL)}
|
||||
/>
|
||||
<AboutLink
|
||||
label={t('about.releases')}
|
||||
description={t('about.releasesDesc')}
|
||||
onClick={() => openHttpUrl(GITHUB_RELEASES_URL)}
|
||||
/>
|
||||
<AboutLink
|
||||
label={t('about.licenseLink')}
|
||||
description={t('about.licenseLinkDesc')}
|
||||
onClick={() => openHttpUrl(GITHUB_LICENSE_URL)}
|
||||
/>
|
||||
</SettingGroup>
|
||||
|
||||
<SettingGroup title={t('about.diagnostics')}>
|
||||
@@ -706,20 +641,6 @@ export default function SettingsView() {
|
||||
onCopy={() => runtimeInfo?.dataPath && void copyText('data', runtimeInfo.dataPath)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label={t('about.copyDiagnostics')} description={t('about.copyVersion')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyText('version', diagnosticText)}
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary"
|
||||
>
|
||||
{copiedField === 'version' ? (
|
||||
<Check className="h-3.5 w-3.5 text-accent-green" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{copiedField === 'version' ? t('common:action.copied') : t('common:action.copy')}
|
||||
</button>
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
|
||||
<p className="px-1 text-[11px] text-txt-faint">{t('about.license')}</p>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/** 分组卡片:多行设置 */
|
||||
export function SettingGroup({ title, children }: { title?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
{title && <h3 className="mb-2 px-0.5 text-sm font-medium text-content-secondary">{title}</h3>}
|
||||
<div className="border-dark-700/45 bg-dark-800/50 divide-dark-700/35 divide-y overflow-hidden rounded-2xl border">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 卡片容器(快捷键等复用) */
|
||||
export function SettingCard({ children, highlight = false }: { children: React.ReactNode; highlight?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-2xl p-4 transition-colors ${
|
||||
highlight ? 'border border-accent-blue/25 bg-accent-blue/5' : 'bg-dark-800/50 border-dark-700/45 border'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 左文右控行 */
|
||||
export function SettingRow({
|
||||
label,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
description?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-3.5">
|
||||
<div className="min-w-[11rem] flex-1 basis-[14rem]">
|
||||
<div className="text-sm font-medium text-content-primary">{label}</div>
|
||||
{description && <div className="mt-0.5 text-xs leading-relaxed text-txt-muted">{description}</div>}
|
||||
</div>
|
||||
<div className="ml-auto flex-shrink-0">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 开关设置项(行内) */
|
||||
export function SettingToggle({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
description: string
|
||||
checked: boolean
|
||||
onChange: (value: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<SettingRow label={label} description={description}>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative ml-auto h-6 w-11 flex-shrink-0 rounded-full transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-blue ${
|
||||
checked ? 'bg-accent-blue' : 'bg-dark-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 h-4 w-4 rounded-full bg-white shadow transition-transform ${
|
||||
checked ? 'left-6' : 'left-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</SettingRow>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { DEFAULT_KEYWORD_HIGHLIGHT_RULES, type KeywordHighlightRule } from '../../utils/keywordHighlight'
|
||||
import type { AgentSafetyMode } from '../../utils/agentCommandRisk'
|
||||
import { normalizeAgentSafetyMode } from '../../utils/agentCommandRisk'
|
||||
import { DEFAULT_TERMINAL_THEME, resolveTerminalThemeId, type TerminalThemeId } from '../../themes/terminal'
|
||||
import { detectSystemLanguage, isAppLanguage, type AppLanguage } from '../../i18n/detectLocale'
|
||||
|
||||
export interface AppSettings {
|
||||
// 外观
|
||||
theme: 'dark' | 'light' | 'system'
|
||||
/** UI language */
|
||||
language: AppLanguage
|
||||
sidebarWidth: number
|
||||
/** 桌面侧栏是否折叠(专心终端) */
|
||||
sidebarCollapsed: boolean
|
||||
|
||||
// 终端
|
||||
fontFamily: string
|
||||
fontSize: number
|
||||
terminalTheme: TerminalThemeId
|
||||
|
||||
// SSH
|
||||
keepAlive: boolean
|
||||
/** ClientAlive-style interval (seconds). Shorter than typical NAT idle timeouts. */
|
||||
keepAliveIntervalSec: number
|
||||
timeout: number
|
||||
|
||||
// 专业连接管理
|
||||
maxConnections: number
|
||||
maxBackgroundConnections: number
|
||||
idleTimeoutMs: number
|
||||
suspendTimeoutMs: number
|
||||
autoReconnect: boolean
|
||||
maxReconnectAttempts: number
|
||||
reconnectBaseDelayMs: number
|
||||
|
||||
// 终端增强
|
||||
scrollback: number
|
||||
keywordHighlightEnabled: boolean
|
||||
keywordHighlightRules: KeywordHighlightRule[]
|
||||
agentSafetyMode: AgentSafetyMode
|
||||
/** Agent 确认级别:开启后除白名单只读命令外全部需要确认(默认关)。 */
|
||||
agentConfirmAllCommands: boolean
|
||||
/** Extra session group names (may be empty). Session.group values are also merged in UI. */
|
||||
sessionGroups: string[]
|
||||
}
|
||||
|
||||
export const defaultSettings: AppSettings = {
|
||||
theme: 'dark',
|
||||
language: detectSystemLanguage(),
|
||||
sidebarWidth: 256,
|
||||
sidebarCollapsed: false,
|
||||
// Prefer Nerd Font so local zsh powerline prompts render correctly
|
||||
fontFamily: 'MesloLGS NF',
|
||||
fontSize: 14,
|
||||
terminalTheme: DEFAULT_TERMINAL_THEME,
|
||||
keepAlive: true,
|
||||
keepAliveIntervalSec: 15, // 对齐常见 ClientAliveInterval / NAT 超时前
|
||||
timeout: 30,
|
||||
// 专业连接管理默认值(有活跃 Shell/SFTP 时不会 suspend)
|
||||
maxConnections: 10,
|
||||
maxBackgroundConnections: 5,
|
||||
idleTimeoutMs: 30 * 60 * 1000, // 30 分钟(原 5 分钟过短,易误杀后台会话)
|
||||
suspendTimeoutMs: 60 * 60 * 1000, // 挂起后再等 60 分钟才彻底断开
|
||||
autoReconnect: true,
|
||||
maxReconnectAttempts: 5,
|
||||
reconnectBaseDelayMs: 1500,
|
||||
scrollback: 10000,
|
||||
keywordHighlightEnabled: true,
|
||||
keywordHighlightRules: DEFAULT_KEYWORD_HIGHLIGHT_RULES,
|
||||
agentSafetyMode: 'relaxed',
|
||||
agentConfirmAllCommands: false,
|
||||
sessionGroups: [],
|
||||
}
|
||||
|
||||
export function normalizeAppSettings(value: unknown): AppSettings {
|
||||
const source = value && typeof value === 'object' ? (value as Record<string, unknown>) : {}
|
||||
const normalized: Record<string, unknown> = {}
|
||||
|
||||
for (const key of Object.keys(defaultSettings) as Array<keyof AppSettings>) {
|
||||
const candidate = source[key]
|
||||
const fallback = defaultSettings[key]
|
||||
normalized[key] = typeof candidate === typeof fallback ? candidate : fallback
|
||||
}
|
||||
normalized.theme = ['dark', 'light', 'system'].includes(String(source.theme)) ? source.theme : defaultSettings.theme
|
||||
normalized.agentSafetyMode = normalizeAgentSafetyMode(source.agentSafetyMode)
|
||||
normalized.language = isAppLanguage(source.language) ? source.language : detectSystemLanguage()
|
||||
normalized.terminalTheme = resolveTerminalThemeId(
|
||||
typeof normalized.terminalTheme === 'string' ? normalized.terminalTheme : undefined
|
||||
)
|
||||
normalized.sessionGroups = Array.isArray(source.sessionGroups)
|
||||
? source.sessionGroups.filter(
|
||||
(item): item is string => typeof item === 'string' && item.trim().length > 0 && item !== 'default'
|
||||
)
|
||||
: defaultSettings.sessionGroups
|
||||
|
||||
return normalized as unknown as AppSettings
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { KeybindingActionId } from '../../constants/keybindings'
|
||||
|
||||
// ─── 快捷键录入 Hook ─────────────────────────────────────────
|
||||
// 监听键盘事件并格式化为键序列字符串 (如 "Ctrl+Shift+P")
|
||||
export function useKeyRecording() {
|
||||
const [recordingId, setRecordingId] = useState<KeybindingActionId | null>(null)
|
||||
const [recordedKeys, setRecordedKeys] = useState<string>('')
|
||||
|
||||
const startRecording = useCallback((actionId: KeybindingActionId) => {
|
||||
setRecordingId(actionId)
|
||||
setRecordedKeys('')
|
||||
}, [])
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
setRecordingId(null)
|
||||
setRecordedKeys('')
|
||||
}, [])
|
||||
|
||||
// 全局键盘监听
|
||||
useEffect(() => {
|
||||
if (!recordingId) return
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
// Escape 取消录入
|
||||
if (e.key === 'Escape') {
|
||||
stopRecording()
|
||||
return
|
||||
}
|
||||
|
||||
// 忽略单独的修饰键
|
||||
if (['Control', 'Shift', 'Alt', 'Meta'].includes(e.key)) return
|
||||
|
||||
const parts: string[] = []
|
||||
if (e.ctrlKey || e.metaKey) parts.push('Ctrl')
|
||||
if (e.shiftKey) parts.push('Shift')
|
||||
if (e.altKey) parts.push('Alt')
|
||||
|
||||
// 主键
|
||||
let mainKey = e.key
|
||||
if (mainKey === ' ') mainKey = 'Space'
|
||||
else if (mainKey === 'Tab') mainKey = 'Tab'
|
||||
else if (mainKey.length === 1) mainKey = mainKey.toUpperCase()
|
||||
parts.push(mainKey)
|
||||
|
||||
const keySequence = parts.join('+')
|
||||
setRecordedKeys(keySequence)
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown, true)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, true)
|
||||
}, [recordingId, stopRecording])
|
||||
|
||||
return { recordingId, recordedKeys, startRecording, stopRecording }
|
||||
}
|
||||
+22
-63
@@ -1,17 +1,15 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useLayoutEffect, useSyncExternalStore } from 'react'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import { darkColors, lightColors, type AppThemeMode, type ColorMap } from '../themes/app-theme'
|
||||
import { darkColors, lightColors, type ColorMap } from '../themes/app-theme'
|
||||
|
||||
/**
|
||||
* Resolves the *effective* theme ('dark' | 'light') from the user preference,
|
||||
* including the 'system' → prefers-color-scheme mapping.
|
||||
*/
|
||||
function resolveEffectiveTheme(preference: AppThemeMode): 'dark' | 'light' {
|
||||
if (preference === 'system') {
|
||||
if (typeof window === 'undefined') return 'dark'
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
return preference
|
||||
function subscribeSystemTheme(onChange: () => void): () => void {
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
media.addEventListener('change', onChange)
|
||||
return () => media.removeEventListener('change', onChange)
|
||||
}
|
||||
|
||||
function systemThemeSnapshot(): boolean {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,61 +33,22 @@ function applyCSSVars(colors: ColorMap) {
|
||||
export function useTheme() {
|
||||
const theme = useSettingsStore((s) => s.settings.theme)
|
||||
|
||||
const effective = useMemo(() => resolveEffectiveTheme(theme), [theme])
|
||||
const systemDark = useSyncExternalStore(subscribeSystemTheme, systemThemeSnapshot, () => true)
|
||||
const effective = theme === 'system' ? (systemDark ? 'dark' : 'light') : theme
|
||||
|
||||
// Apply theme to DOM whenever effective theme changes
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
const colors = effective === 'dark' ? darkColors : lightColors
|
||||
|
||||
// Set data attribute for CSS selectors
|
||||
document.documentElement.setAttribute('data-theme', effective)
|
||||
|
||||
// Toggle Tailwind dark class
|
||||
if (effective === 'dark') {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
|
||||
// Apply CSS custom properties
|
||||
applyCSSVars(colors)
|
||||
|
||||
// Hairline aliases for legacy border-dark-* remaps in index.css
|
||||
const root = document.documentElement
|
||||
root.style.setProperty('--hairline-soft', colors['hairline-soft'] || colors['border-subtle'] || 'rgba(148,163,184,0.06)')
|
||||
root.style.setProperty('--hairline', colors['hairline'] || colors['border-default'] || 'rgba(148,163,184,0.10)')
|
||||
root.style.setProperty('--hairline-strong', colors['hairline-strong'] || colors['border-strong'] || 'rgba(148,163,184,0.16)')
|
||||
|
||||
// Update body bg & colour from tokens (not hardcoded)
|
||||
document.body.style.backgroundColor = colors['surface-base'] || '#0f172a'
|
||||
document.body.style.color = colors['text-primary'] || '#e2e8f0'
|
||||
root.setAttribute('data-theme', effective)
|
||||
root.classList.toggle('dark', effective === 'dark')
|
||||
root.style.colorScheme = effective
|
||||
applyCSSVars(colors)
|
||||
root.style.setProperty('--hairline-soft', colors['hairline-soft'])
|
||||
root.style.setProperty('--hairline', colors['hairline'])
|
||||
root.style.setProperty('--hairline-strong', colors['hairline-strong'])
|
||||
document.body.style.backgroundColor = colors['surface-base']
|
||||
document.body.style.color = colors['text-primary']
|
||||
}, [effective])
|
||||
|
||||
// Listen for system colour-scheme changes when preference is 'system'
|
||||
useEffect(() => {
|
||||
if (theme !== 'system' || typeof window === 'undefined') return
|
||||
|
||||
const mql = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
const newEffective = e.matches ? 'dark' : 'light'
|
||||
const colors = e.matches ? darkColors : lightColors
|
||||
document.documentElement.setAttribute('data-theme', newEffective)
|
||||
|
||||
if (newEffective === 'dark') {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
|
||||
applyCSSVars(colors)
|
||||
document.body.style.backgroundColor = colors['surface-base'] || '#0f172a'
|
||||
document.body.style.color = colors['text-primary'] || '#e2e8f0'
|
||||
}
|
||||
|
||||
mql.addEventListener('change', handler)
|
||||
return () => mql.removeEventListener('change', handler)
|
||||
}, [theme])
|
||||
|
||||
return { theme, effective }
|
||||
}
|
||||
@@ -125,13 +125,12 @@
|
||||
"tagline": "Simple, practical native ops terminal",
|
||||
"appVersion": "v{{version}}",
|
||||
"copyVersion": "Copy version, system, and paths for a GitHub issue",
|
||||
"copyDiagnostics": "Copy diagnostics",
|
||||
"copyDiagnostics": "Diagnostics",
|
||||
"copyPath": "Copy path",
|
||||
"resources": "Resources",
|
||||
"wechat": "WeChat",
|
||||
"copyWechat": "Copy WeChat ID",
|
||||
"wechat": "WeChat Group",
|
||||
"github": "Discussions",
|
||||
"githubDesc": "https://github.com/L00J/opsxshell/discussions",
|
||||
"githubDesc": "GitHub Discussions 社区讨论",
|
||||
"releases": "Releases",
|
||||
"releasesDesc": "Installers and release notes",
|
||||
"licenseLink": "License",
|
||||
@@ -141,7 +140,8 @@
|
||||
"license": "MIT License · Rust · XTerm.js",
|
||||
"logPath": "Application log",
|
||||
"reportBug": "Report a bug",
|
||||
"reportBugDesc": "Opens GitHub with this version and platform filled in"
|
||||
"reportBugDesc": "Opens GitHub with this version and platform filled in",
|
||||
"wechatDesc": "加入交流群,问题反馈与版本动态"
|
||||
},
|
||||
"ai": {
|
||||
"presets": "Provider presets",
|
||||
@@ -214,5 +214,9 @@
|
||||
"removeDevice": "Unbind",
|
||||
"deviceRemoved": "Device unbound",
|
||||
"seatFull": "All seats are in use. Unbind another device before activating on a new machine."
|
||||
},
|
||||
"persistence": {
|
||||
"loadFailed": "Failed to load settings",
|
||||
"saveFailed": "Failed to save settings; the change was reverted"
|
||||
}
|
||||
}
|
||||
@@ -125,13 +125,12 @@
|
||||
"tagline": "シンプルで使いやすいネイティブ運用端末",
|
||||
"appVersion": "v{{version}}",
|
||||
"copyVersion": "バージョン・環境・パスをコピーして GitHub に貼り付け",
|
||||
"copyDiagnostics": "診断情報をコピー",
|
||||
"copyDiagnostics": "Diagnostics",
|
||||
"copyPath": "パスをコピー",
|
||||
"resources": "リソース",
|
||||
"wechat": "WeChat",
|
||||
"copyWechat": "WeChat ID をコピー",
|
||||
"wechat": "WeChat Group",
|
||||
"github": "ディスカッション",
|
||||
"githubDesc": "https://github.com/L00J/opsxshell/discussions",
|
||||
"githubDesc": "GitHub Discussions 社区讨论",
|
||||
"releases": "リリース",
|
||||
"releasesDesc": "インストーラーと更新内容",
|
||||
"licenseLink": "ライセンス",
|
||||
@@ -141,7 +140,8 @@
|
||||
"license": "MIT License · Rust · XTerm.js",
|
||||
"logPath": "アプリログ",
|
||||
"reportBug": "不具合を報告",
|
||||
"reportBugDesc": "GitHub issue を開き、現在のバージョンと環境を記入します"
|
||||
"reportBugDesc": "GitHub issue を開き、現在のバージョンと環境を記入します",
|
||||
"wechatDesc": "加入交流群,问题反馈与版本动态"
|
||||
},
|
||||
"ai": {
|
||||
"presets": "プロバイダープリセット",
|
||||
@@ -214,5 +214,9 @@
|
||||
"removeDevice": "解除",
|
||||
"deviceRemoved": "デバイスの紐付けを解除しました",
|
||||
"seatFull": "デバイス枠がいっぱいです。新しい端末で有効化する前に、別のデバイスを解除してください。"
|
||||
},
|
||||
"persistence": {
|
||||
"loadFailed": "設定の読み込みに失敗しました",
|
||||
"saveFailed": "設定の保存に失敗したため、変更を元に戻しました"
|
||||
}
|
||||
}
|
||||
@@ -125,13 +125,12 @@
|
||||
"tagline": "간단하고 실용적인 네이티브 운영 터미널",
|
||||
"appVersion": "v{{version}}",
|
||||
"copyVersion": "버전·시스템·경로를 복사해 GitHub에 붙여넣기",
|
||||
"copyDiagnostics": "진단 정보 복사",
|
||||
"copyDiagnostics": "Diagnostics",
|
||||
"copyPath": "경로 복사",
|
||||
"resources": "리소스",
|
||||
"wechat": "WeChat",
|
||||
"copyWechat": "WeChat ID 복사",
|
||||
"wechat": "WeChat Group",
|
||||
"github": "토론",
|
||||
"githubDesc": "https://github.com/L00J/opsxshell/discussions",
|
||||
"githubDesc": "GitHub Discussions 社区讨论",
|
||||
"releases": "릴리스",
|
||||
"releasesDesc": "설치 패키지와 변경 사항",
|
||||
"licenseLink": "라이선스",
|
||||
@@ -141,7 +140,8 @@
|
||||
"license": "MIT License · Rust · XTerm.js",
|
||||
"logPath": "앱 로그",
|
||||
"reportBug": "버그 신고",
|
||||
"reportBugDesc": "GitHub 이슈를 열고 현재 버전과 시스템을 채웁니다"
|
||||
"reportBugDesc": "GitHub 이슈를 열고 현재 버전과 시스템을 채웁니다",
|
||||
"wechatDesc": "加入交流群,问题反馈与版本动态"
|
||||
},
|
||||
"ai": {
|
||||
"presets": "제공자 프리셋",
|
||||
@@ -214,5 +214,9 @@
|
||||
"removeDevice": "해제",
|
||||
"deviceRemoved": "기기 바인딩이 해제됨",
|
||||
"seatFull": "기기 좌석이 가득 찼습니다. 새 기기에서 활성화하기 전에 다른 기기를 해제하세요."
|
||||
},
|
||||
"persistence": {
|
||||
"loadFailed": "설정을 불러오지 못했습니다",
|
||||
"saveFailed": "설정을 저장하지 못해 변경 사항을 되돌렸습니다"
|
||||
}
|
||||
}
|
||||
@@ -125,13 +125,12 @@
|
||||
"tagline": "Простой и удобный нативный терминал для эксплуатации",
|
||||
"appVersion": "v{{version}}",
|
||||
"copyVersion": "Скопировать версию, систему и пути для GitHub",
|
||||
"copyDiagnostics": "Копировать диагностику",
|
||||
"copyDiagnostics": "Diagnostics",
|
||||
"copyPath": "Копировать путь",
|
||||
"resources": "Ресурсы",
|
||||
"wechat": "WeChat",
|
||||
"copyWechat": "Скопировать WeChat ID",
|
||||
"wechat": "WeChat Group",
|
||||
"github": "Обсуждения",
|
||||
"githubDesc": "https://github.com/L00J/opsxshell/discussions",
|
||||
"githubDesc": "GitHub Discussions 社区讨论",
|
||||
"releases": "Релизы",
|
||||
"releasesDesc": "Установщики и заметки к выпуску",
|
||||
"licenseLink": "Лицензия",
|
||||
@@ -141,7 +140,8 @@
|
||||
"license": "MIT License · Rust · XTerm.js",
|
||||
"logPath": "Журнал приложения",
|
||||
"reportBug": "Сообщить об ошибке",
|
||||
"reportBugDesc": "Открывает GitHub issue с текущей версией и платформой"
|
||||
"reportBugDesc": "Открывает GitHub issue с текущей версией и платформой",
|
||||
"wechatDesc": "加入交流群,问题反馈与版本动态"
|
||||
},
|
||||
"ai": {
|
||||
"presets": "Пресеты провайдеров",
|
||||
@@ -214,5 +214,9 @@
|
||||
"removeDevice": "Отвязать",
|
||||
"deviceRemoved": "Устройство отвязано",
|
||||
"seatFull": "Все места заняты. Отвяжите другое устройство перед активацией на новой машине."
|
||||
},
|
||||
"persistence": {
|
||||
"loadFailed": "Не удалось загрузить настройки",
|
||||
"saveFailed": "Не удалось сохранить настройки; изменение отменено"
|
||||
}
|
||||
}
|
||||
@@ -125,13 +125,12 @@
|
||||
"tagline": "Terminal vận hành gốc — đơn giản, tiện, dễ dùng",
|
||||
"appVersion": "v{{version}}",
|
||||
"copyVersion": "Sao chép phiên bản, hệ thống và đường dẫn để dán lên GitHub",
|
||||
"copyDiagnostics": "Sao chép chẩn đoán",
|
||||
"copyDiagnostics": "Diagnostics",
|
||||
"copyPath": "Sao chép đường dẫn",
|
||||
"resources": "Tài nguyên",
|
||||
"wechat": "WeChat",
|
||||
"copyWechat": "Sao chép ID WeChat",
|
||||
"wechat": "WeChat Group",
|
||||
"github": "Thảo luận",
|
||||
"githubDesc": "https://github.com/L00J/opsxshell/discussions",
|
||||
"githubDesc": "GitHub Discussions 社区讨论",
|
||||
"releases": "Bản phát hành",
|
||||
"releasesDesc": "Gói cài đặt và ghi chú",
|
||||
"licenseLink": "Giấy phép",
|
||||
@@ -141,7 +140,8 @@
|
||||
"license": "MIT License · Rust · XTerm.js",
|
||||
"logPath": "Nhật ký ứng dụng",
|
||||
"reportBug": "Báo lỗi",
|
||||
"reportBugDesc": "Mở GitHub issue và điền sẵn phiên bản cùng hệ thống"
|
||||
"reportBugDesc": "Mở GitHub issue và điền sẵn phiên bản cùng hệ thống",
|
||||
"wechatDesc": "加入交流群,问题反馈与版本动态"
|
||||
},
|
||||
"ai": {
|
||||
"presets": "Cài sẵn nhà cung cấp",
|
||||
@@ -214,5 +214,9 @@
|
||||
"removeDevice": "Gỡ gắn",
|
||||
"deviceRemoved": "Đã gỡ gắn thiết bị",
|
||||
"seatFull": "Đã hết chỗ thiết bị. Hãy gỡ thiết bị khác trước khi kích hoạt máy mới."
|
||||
},
|
||||
"persistence": {
|
||||
"loadFailed": "Không thể tải cài đặt",
|
||||
"saveFailed": "Không thể lưu cài đặt; thay đổi đã được hoàn tác"
|
||||
}
|
||||
}
|
||||
@@ -125,13 +125,12 @@
|
||||
"tagline": "简单、便捷、人性化的原生运维终端",
|
||||
"appVersion": "v{{version}}",
|
||||
"copyVersion": "复制版本、系统和路径,便于贴到 GitHub",
|
||||
"copyDiagnostics": "复制诊断信息",
|
||||
"copyDiagnostics": "诊断信息",
|
||||
"copyPath": "复制路径",
|
||||
"resources": "资源",
|
||||
"wechat": "微信",
|
||||
"copyWechat": "复制微信号",
|
||||
"wechat": "微信群",
|
||||
"github": "讨论",
|
||||
"githubDesc": "https://github.com/L00J/opsxshell/discussions",
|
||||
"githubDesc": "GitHub Discussions 社区讨论",
|
||||
"releases": "版本发布",
|
||||
"releasesDesc": "安装包与更新说明",
|
||||
"licenseLink": "许可证",
|
||||
@@ -141,7 +140,8 @@
|
||||
"license": "MIT License · Rust · XTerm.js",
|
||||
"logPath": "应用日志",
|
||||
"reportBug": "报告问题",
|
||||
"reportBugDesc": "打开 GitHub issue,并带上当前版本与系统"
|
||||
"reportBugDesc": "打开 GitHub issue,并带上当前版本与系统",
|
||||
"wechatDesc": "加入交流群,问题反馈与版本动态"
|
||||
},
|
||||
"ai": {
|
||||
"presets": "供应商预设",
|
||||
@@ -214,5 +214,9 @@
|
||||
"removeDevice": "解除绑定",
|
||||
"deviceRemoved": "已解除该设备绑定",
|
||||
"seatFull": "设备席位已满。请先解除其他设备,再在新机器上激活。"
|
||||
},
|
||||
"persistence": {
|
||||
"loadFailed": "加载设置失败",
|
||||
"saveFailed": "保存设置失败,已撤回本次修改"
|
||||
}
|
||||
}
|
||||
@@ -125,13 +125,12 @@
|
||||
"tagline": "簡單、便捷、人性化的原生維運終端",
|
||||
"appVersion": "v{{version}}",
|
||||
"copyVersion": "複製版本、系統與路徑,方便貼到 GitHub",
|
||||
"copyDiagnostics": "複製診斷資訊",
|
||||
"copyDiagnostics": "诊断信息",
|
||||
"copyPath": "複製路徑",
|
||||
"resources": "資源",
|
||||
"wechat": "微信",
|
||||
"copyWechat": "複製微信帳號",
|
||||
"wechat": "微信群",
|
||||
"github": "討論",
|
||||
"githubDesc": "https://github.com/L00J/opsxshell/discussions",
|
||||
"githubDesc": "GitHub Discussions 社区讨论",
|
||||
"releases": "版本發布",
|
||||
"releasesDesc": "安裝包與更新說明",
|
||||
"licenseLink": "授權",
|
||||
@@ -141,7 +140,8 @@
|
||||
"license": "MIT License · Rust · XTerm.js",
|
||||
"logPath": "應用程式日誌",
|
||||
"reportBug": "回報問題",
|
||||
"reportBugDesc": "開啟 GitHub issue,並帶上目前版本與系統"
|
||||
"reportBugDesc": "開啟 GitHub issue,並帶上目前版本與系統",
|
||||
"wechatDesc": "加入交流群,问题反馈与版本动态"
|
||||
},
|
||||
"ai": {
|
||||
"presets": "供應商預設",
|
||||
@@ -214,5 +214,9 @@
|
||||
"removeDevice": "解除綁定",
|
||||
"deviceRemoved": "已解除該裝置綁定",
|
||||
"seatFull": "裝置席位已滿。請先解除其他裝置,再在新機器上啟用。"
|
||||
},
|
||||
"persistence": {
|
||||
"loadFailed": "載入設定失敗",
|
||||
"saveFailed": "儲存設定失敗,已撤回本次修改"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** 外部链接统一打开:桌面走 opener 插件(Tauri 2 拦截 <a target=_blank>),浏览器 dev 回退 window.open */
|
||||
export async function openExternal(href: string): Promise<void> {
|
||||
try {
|
||||
const { openUrl } = await import('@tauri-apps/plugin-opener')
|
||||
await openUrl(href)
|
||||
} catch {
|
||||
window.open(href, '_blank', 'noreferrer')
|
||||
}
|
||||
}
|
||||
+43
-4
@@ -1,5 +1,6 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import i18n from '../i18n'
|
||||
import { translateIpcError } from '../i18n/format'
|
||||
import { isTauriRuntime } from './runtime'
|
||||
|
||||
/**
|
||||
@@ -17,19 +18,57 @@ export function requireNative(messageKey = 'errors:desktop.nativeOnly'): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function invokeNative<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||
export class NativeCommandError extends Error {
|
||||
constructor(
|
||||
readonly command: string,
|
||||
readonly detail: string,
|
||||
readonly errorCode?: string,
|
||||
readonly originalError?: unknown
|
||||
) {
|
||||
super(translateIpcError(detail, errorCode))
|
||||
this.name = 'NativeCommandError'
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeNativeError(command: string, error: unknown): NativeCommandError {
|
||||
if (error instanceof NativeCommandError) return error
|
||||
const fields = error && typeof error === 'object' ? (error as Record<string, unknown>) : undefined
|
||||
const code = typeof fields?.errorCode === 'string' ? fields.errorCode : undefined
|
||||
const detail =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: typeof fields?.error === 'string'
|
||||
? fields.error
|
||||
: typeof fields?.message === 'string'
|
||||
? fields.message
|
||||
: typeof error === 'string'
|
||||
? error
|
||||
: i18n.t('errors:unknown.error')
|
||||
return new NativeCommandError(command, detail, code, error)
|
||||
}
|
||||
|
||||
export async function invokeNative<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||
requireNative()
|
||||
return invoke<T>(command, args)
|
||||
try {
|
||||
return await invoke<T>(command, args)
|
||||
} catch (error) {
|
||||
throw normalizeNativeError(command, error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function invokeEnvelope<T extends Record<string, unknown>>(
|
||||
command: string,
|
||||
args?: Record<string, unknown>
|
||||
): Promise<({ success: true } & T) | { success: false; error: string }> {
|
||||
): Promise<({ success: true } & T) | { success: false; error: string; errorCode?: string }> {
|
||||
try {
|
||||
const value = await invokeNative<T>(command, args)
|
||||
return { success: true, ...(value as T) }
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
const failure = normalizeNativeError(command, error)
|
||||
return {
|
||||
success: false,
|
||||
error: failure.message,
|
||||
...(failure.errorCode ? { errorCode: failure.errorCode } : {}),
|
||||
}
|
||||
}
|
||||
}
|
||||
+72
-140
@@ -1,104 +1,13 @@
|
||||
import { create } from 'zustand'
|
||||
import { createLogger } from '../utils/logger'
|
||||
import { DEFAULT_KEYWORD_HIGHLIGHT_RULES, type KeywordHighlightRule } from '../utils/keywordHighlight'
|
||||
import type { AgentSafetyMode } from '../utils/agentCommandRisk'
|
||||
import { normalizeAgentSafetyMode } from '../utils/agentCommandRisk'
|
||||
import { DEFAULT_TERMINAL_THEME, resolveTerminalThemeId, type TerminalThemeId } from '../themes/terminal'
|
||||
import { detectSystemLanguage, isAppLanguage, type AppLanguage } from '../i18n/detectLocale'
|
||||
import i18n, { setAppLanguage } from '../i18n'
|
||||
import { translateIpcError } from '../i18n/format'
|
||||
import { useToastStore } from './toastStore'
|
||||
import { defaultSettings, normalizeAppSettings, type AppSettings } from '../features/settings/model'
|
||||
export { normalizeAppSettings, type AppSettings } from '../features/settings/model'
|
||||
|
||||
const logger = createLogger('SettingsStore')
|
||||
|
||||
export interface AppSettings {
|
||||
// 外观
|
||||
theme: 'dark' | 'light' | 'system'
|
||||
/** UI language */
|
||||
language: AppLanguage
|
||||
sidebarWidth: number
|
||||
/** 桌面侧栏是否折叠(专心终端) */
|
||||
sidebarCollapsed: boolean
|
||||
|
||||
// 终端
|
||||
fontFamily: string
|
||||
fontSize: number
|
||||
terminalTheme: TerminalThemeId
|
||||
|
||||
// SSH
|
||||
keepAlive: boolean
|
||||
/** ClientAlive-style interval (seconds). Shorter than typical NAT idle timeouts. */
|
||||
keepAliveIntervalSec: number
|
||||
timeout: number
|
||||
|
||||
// 专业连接管理
|
||||
maxConnections: number
|
||||
maxBackgroundConnections: number
|
||||
idleTimeoutMs: number
|
||||
suspendTimeoutMs: number
|
||||
autoReconnect: boolean
|
||||
maxReconnectAttempts: number
|
||||
reconnectBaseDelayMs: number
|
||||
|
||||
// 终端增强
|
||||
scrollback: number
|
||||
keywordHighlightEnabled: boolean
|
||||
keywordHighlightRules: KeywordHighlightRule[]
|
||||
agentSafetyMode: AgentSafetyMode
|
||||
/** Agent 确认级别:开启后除白名单只读命令外全部需要确认(默认关)。 */
|
||||
agentConfirmAllCommands: boolean
|
||||
/** Extra session group names (may be empty). Session.group values are also merged in UI. */
|
||||
sessionGroups: string[]
|
||||
}
|
||||
|
||||
const defaultSettings: AppSettings = {
|
||||
theme: 'dark',
|
||||
language: detectSystemLanguage(),
|
||||
sidebarWidth: 256,
|
||||
sidebarCollapsed: false,
|
||||
// Prefer Nerd Font so local zsh powerline prompts render correctly
|
||||
fontFamily: 'MesloLGS NF',
|
||||
fontSize: 14,
|
||||
terminalTheme: DEFAULT_TERMINAL_THEME,
|
||||
keepAlive: true,
|
||||
keepAliveIntervalSec: 15, // 对齐常见 ClientAliveInterval / NAT 超时前
|
||||
timeout: 30,
|
||||
// 专业连接管理默认值(有活跃 Shell/SFTP 时不会 suspend)
|
||||
maxConnections: 10,
|
||||
maxBackgroundConnections: 5,
|
||||
idleTimeoutMs: 30 * 60 * 1000, // 30 分钟(原 5 分钟过短,易误杀后台会话)
|
||||
suspendTimeoutMs: 60 * 60 * 1000, // 挂起后再等 60 分钟才彻底断开
|
||||
autoReconnect: true,
|
||||
maxReconnectAttempts: 5,
|
||||
reconnectBaseDelayMs: 1500,
|
||||
scrollback: 10000,
|
||||
keywordHighlightEnabled: true,
|
||||
keywordHighlightRules: DEFAULT_KEYWORD_HIGHLIGHT_RULES,
|
||||
agentSafetyMode: 'relaxed',
|
||||
agentConfirmAllCommands: false,
|
||||
sessionGroups: [],
|
||||
}
|
||||
|
||||
export function normalizeAppSettings(value: unknown): AppSettings {
|
||||
const source = value && typeof value === 'object' ? (value as Record<string, unknown>) : {}
|
||||
const normalized: Record<string, unknown> = {}
|
||||
|
||||
for (const key of Object.keys(defaultSettings) as Array<keyof AppSettings>) {
|
||||
const candidate = source[key]
|
||||
const fallback = defaultSettings[key]
|
||||
normalized[key] = typeof candidate === typeof fallback ? candidate : fallback
|
||||
}
|
||||
normalized.agentSafetyMode = normalizeAgentSafetyMode(source.agentSafetyMode)
|
||||
normalized.language = isAppLanguage(source.language) ? source.language : detectSystemLanguage()
|
||||
normalized.terminalTheme = resolveTerminalThemeId(
|
||||
typeof normalized.terminalTheme === 'string' ? normalized.terminalTheme : undefined
|
||||
)
|
||||
normalized.sessionGroups = Array.isArray(source.sessionGroups)
|
||||
? source.sessionGroups.filter(
|
||||
(item): item is string => typeof item === 'string' && item.trim().length > 0 && item !== 'default'
|
||||
)
|
||||
: defaultSettings.sessionGroups
|
||||
|
||||
return normalized as unknown as AppSettings
|
||||
}
|
||||
|
||||
interface SettingsState {
|
||||
settings: AppSettings
|
||||
isLoaded: boolean
|
||||
@@ -107,50 +16,73 @@ interface SettingsState {
|
||||
saveSettings: (settings: Partial<AppSettings>) => Promise<void>
|
||||
}
|
||||
|
||||
export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
settings: defaultSettings,
|
||||
isLoaded: false,
|
||||
export const useSettingsStore = create<SettingsState>((set, get) => {
|
||||
let confirmed = defaultSettings
|
||||
const pending: Partial<AppSettings>[] = []
|
||||
let queue = Promise.resolve()
|
||||
|
||||
loadSettings: async () => {
|
||||
try {
|
||||
const result = await window.opsxApi?.settingsGet('app-settings')
|
||||
if (result?.success && result.value) {
|
||||
const raw = result.value as Record<string, unknown>
|
||||
const settings = normalizeAppSettings(raw)
|
||||
set({ settings, isLoaded: true })
|
||||
// One native write at a time. Failed patches never leak into later snapshots.
|
||||
const enqueue = (work: () => Promise<void>) => {
|
||||
const task = queue.then(work)
|
||||
queue = task.catch(() => {})
|
||||
return task
|
||||
}
|
||||
const publish = () => set({ settings: Object.assign({}, confirmed, ...pending) })
|
||||
const report = (error: unknown, key: string) => {
|
||||
logger.error(key, error)
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
useToastStore.getState().addToast('error', `${i18n.t(key)}: ${translateIpcError(detail)}`)
|
||||
}
|
||||
|
||||
const storedKeys = Object.keys(raw)
|
||||
const currentKeys = new Set(Object.keys(defaultSettings))
|
||||
const modeMigrated = String(raw.agentSafetyMode ?? '') !== settings.agentSafetyMode
|
||||
if (modeMigrated || storedKeys.some((key) => !currentKeys.has(key))) {
|
||||
await window.opsxApi?.settingsSet('app-settings', settings)
|
||||
return {
|
||||
settings: defaultSettings,
|
||||
isLoaded: false,
|
||||
loadSettings: () =>
|
||||
enqueue(async () => {
|
||||
try {
|
||||
const result = await window.opsxApi.settingsGet('app-settings')
|
||||
if (!result.success) throw new Error(result.error || i18n.t('errors:unknown.error'))
|
||||
confirmed = normalizeAppSettings(result.value)
|
||||
publish()
|
||||
if (result.value && typeof result.value === 'object') {
|
||||
const raw = result.value as Record<string, unknown>
|
||||
const obsoleteKeys = Object.keys(raw).some((key) => !(key in defaultSettings))
|
||||
if (obsoleteKeys || raw.agentSafetyMode !== confirmed.agentSafetyMode) {
|
||||
const saved = await window.opsxApi.settingsSet('app-settings', confirmed)
|
||||
if (!saved.success) throw new Error(saved.error || i18n.t('errors:unknown.error'))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
report(error, 'settings:persistence.loadFailed')
|
||||
} finally {
|
||||
set({ isLoaded: true })
|
||||
}
|
||||
} else {
|
||||
set({ isLoaded: true })
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to load settings:', error)
|
||||
set({ isLoaded: true })
|
||||
}
|
||||
},
|
||||
|
||||
setSetting: async (key, value) => {
|
||||
const newSettings = { ...get().settings, [key]: value }
|
||||
set({ settings: newSettings })
|
||||
await window.opsxApi?.settingsSet('app-settings', newSettings)
|
||||
if (key === 'language') {
|
||||
const { setAppLanguage } = await import('../i18n')
|
||||
await setAppLanguage(String(value))
|
||||
}
|
||||
},
|
||||
|
||||
saveSettings: async (newSettings) => {
|
||||
const merged = { ...get().settings, ...newSettings }
|
||||
set({ settings: merged })
|
||||
await window.opsxApi?.settingsSet('app-settings', merged)
|
||||
if (newSettings.language) {
|
||||
const { setAppLanguage } = await import('../i18n')
|
||||
await setAppLanguage(newSettings.language)
|
||||
}
|
||||
},
|
||||
}))
|
||||
}),
|
||||
setSetting: (key, value) => get().saveSettings({ [key]: value }),
|
||||
saveSettings: (patch) => {
|
||||
const change = { ...patch }
|
||||
pending.push(change)
|
||||
publish()
|
||||
return enqueue(async () => {
|
||||
const next = { ...confirmed, ...change }
|
||||
try {
|
||||
const result = await window.opsxApi.settingsSet('app-settings', next)
|
||||
if (!result.success) throw new Error(result.error || i18n.t('errors:unknown.error'))
|
||||
confirmed = next
|
||||
} catch (error) {
|
||||
report(error, 'settings:persistence.saveFailed')
|
||||
} finally {
|
||||
pending.splice(pending.indexOf(change), 1)
|
||||
publish()
|
||||
}
|
||||
if (change.language && confirmed.language === change.language) {
|
||||
try {
|
||||
await setAppLanguage(confirmed.language)
|
||||
} catch (error) {
|
||||
report(error, 'errors:unknown.error')
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -5,7 +5,7 @@ export const GITHUB_RELEASES_URL = `${GITHUB_REPO_URL}/releases`
|
||||
export const GITHUB_LICENSE_URL = `${GITHUB_REPO_URL}/blob/main/LICENSE`
|
||||
export const BUG_REPORT_ISSUE_URL = `${GITHUB_REPO_URL}/issues/new`
|
||||
export const BUG_REPORT_TEMPLATE = 'bug_report.yml'
|
||||
export const WECHAT_ID = 'opsx-plus'
|
||||
export const WECHAT_GROUP_URL = 'https://docs.qq.com/doc/DTEx0TmtpbkVPb3JT'
|
||||
|
||||
export type BugReportPlatform = 'macOS' | 'Windows' | 'Linux'
|
||||
|
||||
|
||||
@@ -57,7 +57,8 @@ const FIXED_ALIASES: Record<SettingsSearchTab, readonly string[]> = {
|
||||
'discussions',
|
||||
'wechat',
|
||||
'微信',
|
||||
'opsx-plus',
|
||||
'微信群',
|
||||
'wechat group',
|
||||
],
|
||||
}
|
||||
|
||||
@@ -112,6 +113,7 @@ const LOCALE_KEYS: Record<SettingsSearchTab, readonly string[]> = {
|
||||
'about.reportBug',
|
||||
'about.reportBugDesc',
|
||||
'about.wechat',
|
||||
'about.wechatDesc',
|
||||
'about.github',
|
||||
'about.releases',
|
||||
'about.diagnostics',
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { useTheme } from '../../src/hooks/useTheme'
|
||||
import { useSettingsStore } from '../../src/stores/settingsStore'
|
||||
import { darkColors, lightColors } from '../../src/themes/app-theme'
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
it('keeps the returned theme, surfaces and borders synchronized with system changes', () => {
|
||||
let matches = false
|
||||
const listeners = new Set<() => void>()
|
||||
vi.stubGlobal('matchMedia', () => ({
|
||||
get matches() {
|
||||
return matches
|
||||
},
|
||||
addEventListener: (_event: string, listener: () => void) => listeners.add(listener),
|
||||
removeEventListener: (_event: string, listener: () => void) => listeners.delete(listener),
|
||||
}))
|
||||
const original = useSettingsStore.getState().settings
|
||||
useSettingsStore.setState({ settings: { ...original, theme: 'system' } })
|
||||
const { result, unmount } = renderHook(() => useTheme())
|
||||
expect(result.current.effective).toBe('light')
|
||||
expect(document.documentElement.style.getPropertyValue('--hairline')).toBe(lightColors.hairline)
|
||||
act(() => {
|
||||
matches = true
|
||||
listeners.forEach((listener) => listener())
|
||||
})
|
||||
expect(result.current.effective).toBe('dark')
|
||||
expect(document.documentElement.dataset.theme).toBe('dark')
|
||||
expect(document.documentElement.style.getPropertyValue('--hairline')).toBe(darkColors.hairline)
|
||||
act(() => useSettingsStore.setState({ settings: { ...original, theme: 'light' } }))
|
||||
expect(result.current.effective).toBe('light')
|
||||
unmount()
|
||||
expect(listeners.size).toBe(0)
|
||||
useSettingsStore.setState({ settings: original })
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() }))
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: invokeMock }))
|
||||
vi.mock('../../src/platform/runtime', () => ({ isTauriRuntime: () => true }))
|
||||
|
||||
import { invokeEnvelope, invokeNative, NativeCommandError } from '../../src/platform/invoke'
|
||||
|
||||
describe('native error boundary', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('preserves business failures as envelopes', async () => {
|
||||
const response = { success: false, error: 'bad data', errorCode: 'native.invalidBase64' }
|
||||
invokeMock.mockResolvedValueOnce(response)
|
||||
expect(await invokeNative('write_file')).toBe(response)
|
||||
})
|
||||
|
||||
it('normalizes structured rejections without losing their code or cause', async () => {
|
||||
const cause = { error: 'invalid encoding', errorCode: 'native.invalidBase64' }
|
||||
invokeMock.mockRejectedValueOnce(cause)
|
||||
const error = await invokeNative('write_file').catch((value: unknown) => value)
|
||||
expect(error).toBeInstanceOf(NativeCommandError)
|
||||
expect(error).toMatchObject({
|
||||
command: 'write_file',
|
||||
detail: 'invalid encoding',
|
||||
errorCode: cause.errorCode,
|
||||
originalError: cause,
|
||||
})
|
||||
expect((error as Error).message).not.toContain('[object Object]')
|
||||
})
|
||||
|
||||
it('retains codes when adapting rejected calls to envelopes', async () => {
|
||||
invokeMock.mockRejectedValueOnce({ error: 'join failed', errorCode: 'native.taskJoin' })
|
||||
expect(await invokeEnvelope('read_file')).toMatchObject({ success: false, errorCode: 'native.taskJoin' })
|
||||
})
|
||||
|
||||
it('preserves plain rejection messages', async () => {
|
||||
invokeMock.mockRejectedValueOnce('custom native failure')
|
||||
await expect(invokeNative('test')).rejects.toThrow('custom native failure')
|
||||
})
|
||||
})
|
||||
@@ -29,4 +29,8 @@ describe('settings migration', () => {
|
||||
const settings = normalizeAppSettings({ language: 'fr-FR' })
|
||||
expect(isAppLanguage(settings.language)).toBe(true)
|
||||
})
|
||||
it('rejects unsupported saved themes', () => {
|
||||
expect(normalizeAppSettings({ theme: 'invalid' }).theme).toBe('dark')
|
||||
expect(normalizeAppSettings({ theme: 'system' }).theme).toBe('system')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
let store: typeof import('../../src/stores/settingsStore').useSettingsStore
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
vi.mocked(window.opsxApi.settingsGet).mockReset().mockResolvedValue({ success: true })
|
||||
vi.mocked(window.opsxApi.settingsSet).mockReset().mockResolvedValue({ success: true })
|
||||
store = (await import('../../src/stores/settingsStore')).useSettingsStore
|
||||
})
|
||||
|
||||
describe('settings persistence', () => {
|
||||
it('serializes writes while keeping later edits visible', async () => {
|
||||
const first = deferred<{ success: boolean }>()
|
||||
vi.mocked(window.opsxApi.settingsSet).mockImplementationOnce(() => first.promise)
|
||||
const saveTheme = store.getState().setSetting('theme', 'light')
|
||||
const saveFont = store.getState().setSetting('fontSize', 20)
|
||||
expect(store.getState().settings).toMatchObject({ theme: 'light', fontSize: 20 })
|
||||
await Promise.resolve()
|
||||
expect(window.opsxApi.settingsSet).toHaveBeenCalledTimes(1)
|
||||
first.resolve({ success: true })
|
||||
await Promise.all([saveTheme, saveFont])
|
||||
expect(window.opsxApi.settingsSet).toHaveBeenLastCalledWith(
|
||||
'app-settings',
|
||||
expect.objectContaining({ theme: 'light', fontSize: 20 })
|
||||
)
|
||||
})
|
||||
|
||||
it('rolls back a rejected envelope without persisting the failed patch in the next write', async () => {
|
||||
vi.mocked(window.opsxApi.settingsSet).mockResolvedValueOnce({ success: false, error: 'disk full' })
|
||||
await Promise.all([store.getState().setSetting('theme', 'light'), store.getState().setSetting('fontSize', 20)])
|
||||
expect(store.getState().settings).toMatchObject({ theme: 'dark', fontSize: 20 })
|
||||
expect(window.opsxApi.settingsSet).toHaveBeenLastCalledWith(
|
||||
'app-settings',
|
||||
expect.objectContaining({ theme: 'dark', fontSize: 20 })
|
||||
)
|
||||
const { useToastStore } = await import('../../src/stores/toastStore')
|
||||
expect(useToastStore.getState().toasts.at(-1)).toMatchObject({
|
||||
type: 'error',
|
||||
message: expect.stringContaining('disk full'),
|
||||
})
|
||||
})
|
||||
|
||||
it('continues saving after a thrown IPC failure, including edits to the same field', async () => {
|
||||
vi.mocked(window.opsxApi.settingsSet).mockRejectedValueOnce(new Error('IPC unavailable'))
|
||||
await Promise.all([store.getState().setSetting('fontSize', 18), store.getState().setSetting('fontSize', 22)])
|
||||
expect(store.getState().settings.fontSize).toBe(22)
|
||||
expect(window.opsxApi.settingsSet).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('merges edits made while initial settings are loading into the persisted snapshot', async () => {
|
||||
const read = deferred<{ success: boolean; value: unknown }>()
|
||||
vi.mocked(window.opsxApi.settingsGet).mockReturnValueOnce(read.promise)
|
||||
const load = store.getState().loadSettings()
|
||||
const save = store.getState().setSetting('fontSize', 22)
|
||||
read.resolve({ success: true, value: { theme: 'light', agentSafetyMode: 'relaxed' } })
|
||||
await Promise.all([load, save])
|
||||
expect(store.getState().settings).toMatchObject({ theme: 'light', fontSize: 22 })
|
||||
expect(window.opsxApi.settingsSet).toHaveBeenLastCalledWith(
|
||||
'app-settings',
|
||||
expect.objectContaining({ theme: 'light', fontSize: 22 })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
module opsxshell/tools/homepage-build
|
||||
|
||||
go 1.25
|
||||
|
||||
require github.com/yuin/goldmark v1.7.8
|
||||
@@ -0,0 +1,2 @@
|
||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
@@ -0,0 +1,172 @@
|
||||
// homepage-build:把 homepage/ 源(落地页+文档 md)构建为纯静态站点 homepage/dist/。
|
||||
// 产物可直接部署到任意静态托管(nginx/Pages)——官网与桌面二进制彻底解耦。
|
||||
// 用法: go run ./tools/homepage-build [-src homepage] [-out homepage/dist]
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
gmhtml "github.com/yuin/goldmark/renderer/html"
|
||||
"html/template"
|
||||
)
|
||||
|
||||
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"`
|
||||
}
|
||||
type TocItem struct {
|
||||
ID string
|
||||
Level int
|
||||
Text string
|
||||
}
|
||||
type SearchEntry struct {
|
||||
Slug, Title, Group, Text string
|
||||
}
|
||||
type NavLink struct {
|
||||
Slug, Title string
|
||||
}
|
||||
type Page struct {
|
||||
Slug, Title, Group, Updated string
|
||||
HTML string
|
||||
TOC []TocItem
|
||||
Prev, Next *NavLink
|
||||
}
|
||||
|
||||
var (
|
||||
hRe = regexp.MustCompile(`(?s)<(h[23])>(.*?)</h[23]>`)
|
||||
tagRe = regexp.MustCompile(`<[^>]+>`)
|
||||
mdNoise = regexp.MustCompile("(?s)```.*?```|`[^`]*`|!\\[[^\\]]*\\]\\([^)]*\\)|\\[[^\\]]*\\]\\([^)]*\\)|[#>*_\\-|]+")
|
||||
)
|
||||
|
||||
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</%s>`, tag, id, inner, tag)
|
||||
})
|
||||
return out, toc
|
||||
}
|
||||
|
||||
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 must(err error, what string) {
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[homepage-build] %s: %v\n", what, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func writeFile(path, content string) {
|
||||
must(os.MkdirAll(filepath.Dir(path), 0o755), "mkdir "+path)
|
||||
must(os.WriteFile(path, []byte(content), 0o644), "write "+path)
|
||||
fmt.Println("[OK]", path)
|
||||
}
|
||||
|
||||
func main() {
|
||||
src := flag.String("src", "homepage", "源目录")
|
||||
out := flag.String("out", "homepage/dist", "产物目录")
|
||||
flag.Parse()
|
||||
|
||||
// 1) 落地页: 纯 HTML 直接拷 + site assets
|
||||
raw, err := os.ReadFile(filepath.Join(*src, "index.html"))
|
||||
must(err, "读 index.html")
|
||||
writeFile(filepath.Join(*out, "index.html"), string(raw))
|
||||
if raw2, err2 := os.ReadFile(filepath.Join(*src, "terms.html")); err2 == nil {
|
||||
writeFile(filepath.Join(*out, "terms.html"), string(raw2))
|
||||
}
|
||||
siteAssets := filepath.Join(*src, "assets")
|
||||
if st, _ := os.Stat(siteAssets); st != nil {
|
||||
must(os.CopyFS(filepath.Join(*out, "site", "assets"), os.DirFS(siteAssets)), "拷 site assets")
|
||||
fmt.Println("[OK]", filepath.Join(*out, "site", "assets"))
|
||||
}
|
||||
|
||||
// 2) 文档: manifest → goldmark 渲染 → docs.html 模板
|
||||
mraw, err := os.ReadFile(filepath.Join(*src, "content", "manifest.json"))
|
||||
must(err, "读 manifest")
|
||||
var manifest Manifest
|
||||
must(json.Unmarshal(mraw, &manifest), "解析 manifest")
|
||||
tplRaw, err := os.ReadFile(filepath.Join(*src, "docs.html"))
|
||||
must(err, "读 docs.html")
|
||||
tpl, err := template.New("doc").Parse(string(tplRaw))
|
||||
must(err, "解析 docs 模板")
|
||||
md := goldmark.New(goldmark.WithExtensions(extension.Table, extension.Strikethrough, extension.TaskList), goldmark.WithRendererOptions(gmhtml.WithHardWraps()))
|
||||
|
||||
var order []string
|
||||
pages := map[string]*Page{}
|
||||
var search []SearchEntry
|
||||
var prev *NavLink
|
||||
for _, g := range manifest.Groups {
|
||||
for _, def := range g.Pages {
|
||||
srcb, err := os.ReadFile(filepath.Join(*src, "content", def.File))
|
||||
must(err, "读文档 "+def.File)
|
||||
var buf strings.Builder
|
||||
must(md.Convert(srcb, &buf), "渲染 "+def.File)
|
||||
body, toc := injectHeadingIDs(buf.String())
|
||||
p := &Page{Slug: def.Slug, Title: def.Title, Group: g.Name, Updated: def.Updated, HTML: body, TOC: toc, Prev: prev}
|
||||
pages[def.Slug] = p
|
||||
order = append(order, def.Slug)
|
||||
prev = &NavLink{Slug: def.Slug, Title: def.Title}
|
||||
search = append(search, SearchEntry{Slug: def.Slug, Title: def.Title, Group: g.Name, Text: plainText(string(srcb))})
|
||||
}
|
||||
}
|
||||
for i, slug := range order {
|
||||
if i+1 < len(order) {
|
||||
n := pages[order[i+1]]
|
||||
pages[slug].Next = &NavLink{Slug: n.Slug, Title: n.Title}
|
||||
}
|
||||
}
|
||||
// /docs/ 首页 = 第一篇(静态站无重定向,直接渲染)
|
||||
render := func(p *Page) string {
|
||||
var sb strings.Builder
|
||||
must(tpl.Execute(&sb, map[string]any{"Manifest": manifest, "Page": p, "TOC": p.TOC, "Content": template.HTML(p.HTML)}), "渲染模板 "+p.Slug)
|
||||
return sb.String()
|
||||
}
|
||||
writeFile(filepath.Join(*out, "docs", "index.html"), render(pages[order[0]]))
|
||||
for _, slug := range order {
|
||||
writeFile(filepath.Join(*out, "docs", slug, "index.html"), render(pages[slug]))
|
||||
}
|
||||
sj, _ := json.Marshal(search)
|
||||
writeFile(filepath.Join(*out, "docs", "search-data.json"), string(sj))
|
||||
// docs assets(模板引用 /docs/assets/*)
|
||||
da := filepath.Join(*src, "docs-assets")
|
||||
if st, _ := os.Stat(da); st != nil {
|
||||
must(os.CopyFS(filepath.Join(*out, "docs", "assets"), os.DirFS(da)), "拷 docs assets")
|
||||
fmt.Println("[OK]", filepath.Join(*out, "docs", "assets"))
|
||||
}
|
||||
fmt.Printf("[DONE] %d 页文档 + 落地页 → %s(纯静态,可部署任意域名)\n", len(order), *out)
|
||||
}
|
||||
+2
-1
@@ -4,7 +4,8 @@
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
+1
-1
@@ -43,7 +43,7 @@ export default defineConfig({
|
||||
server: {
|
||||
// Keep the desktop renderer isolated from the opsx-web dev server on 5173.
|
||||
host: '127.0.0.1',
|
||||
port: 1420,
|
||||
port: 5175,
|
||||
strictPort: true,
|
||||
},
|
||||
optimizeDeps: {
|
||||
|
||||
Reference in new issue
Block a user