diff --git a/.github/BRANCHING.md b/.github/BRANCHING.md index 76d3248..6e4ae32 100644 --- a/.github/BRANCHING.md +++ b/.github/BRANCHING.md @@ -1,39 +1,42 @@ # Branching and release -This repo uses a lightweight git-flow. `main` stays the default working branch; shipping happens on `release`. +This repo uses standard **git-flow** (with `main` as the production branch instead of the historical `master`). ## Branches | Branch | Role | |--------|------| -| `main` | Day-to-day development (git-flow *develop*) | -| `release` | Shippable line (git-flow *main*). Only merge work that is meant to ship | -| `feature/` | New work, branched from `main` | -| `hotfix/` | Production fixes, branched from `release` | +| `main` | Production line (git-flow *master*). Only merge work that is meant to ship | +| `develop` | Day-to-day development (git-flow *develop*). GitHub default branch | +| `feature/` | New work, branched from `develop` | +| `bugfix/` | Non-urgent fixes, branched from `develop` | +| `release/` | Release preparation / version bump, branched from `develop` | +| `hotfix/` | Production fixes, branched from `main` | -Do not commit directly to `release`. Open a pull request. +Do not commit directly to `main`. Open a pull request. ``` -feature/* ──PR──► main ──PR (version bump)──► release ──tag Vx.y.z──► CI -hotfix/* ──PR──► release ──tag──► CI - └──PR──► main +feature/* ──PR──► develop ──PR──► release/x.y.z ──PR──► main ──tag Vx.y.z──► CI +bugfix/* ──PR──► develop │ +hotfix/* ──PR──► main ──tag──► CI └─merge back──► develop + └─merge back──► develop ``` ## First-time setup -The workflow file must exist on the tagged commit, so create `release` from the commit that already contains `.github/workflows/release.yml` (after this work is on `main`): +The workflow file must exist on the tagged commit, so create `main` (production) from the commit that already contains `.github/workflows/release.yml` (after this work is on `develop`): ```bash git fetch origin -git checkout main +git checkout develop git pull -git checkout -b release -git push -u origin release +git checkout -b main +git push -u origin main ``` Then in GitHub: -1. Protect `release` (PR required, no force-push). +1. Set `develop` as the default branch, protect `main` (PR required, no force-push). 2. Settings → Actions → General → Workflow permissions → **Read and write**. Without this, attaching `.exe` / `.dmg` to the GitHub Release fails. Existing tags such as `V1.3.1` will not rebuild automatically. The next *new* version tag is what starts CI. @@ -42,28 +45,32 @@ Existing tags such as `V1.3.1` will not rebuild automatically. The next *new* ve All three must be true or GitHub Actions will not build installers: -1. The commit lives on `release` (merged there, not only on `main`). +1. The commit lives on `main` (merged there, not only on `develop`). 2. `version.json` changed compared with the previous version tag. 3. A version tag `Vx.y.z` or `vx.y.z` is pushed, and it matches `version.json`. Suggested sequence: ```bash -# 1. On a branch from main: bump version.json, src-tauri/Cargo.toml, +# 1. Branch release/x.y.z from develop: bump version.json, src-tauri/Cargo.toml, # src-tauri/tauri.conf.json, and frontend/package.json to the same x.y.z -git checkout main +git checkout develop git checkout -b release/1.4.0 -# 2. Open a PR into release and merge it +# 2. Open a PR into main and merge it -# 3. Tag the merge commit on release, then push the tag -git checkout release +# 3. Tag the merge commit on main, then push the tag +git checkout main git pull git tag V1.4.0 git push origin V1.4.0 + +# 4. Merge main back into develop so development is not left behind ``` -Pushing the tag is what starts the workflow. The gate job then re-checks the other two conditions. If any check fails, Windows/macOS builds are skipped. +Pushing the tag is what starts the workflow. The gate job (`scripts/ci-release-gate.sh`) then re-checks the three conditions. The production branch it verifies defaults to `main` and can be overridden with the `RELEASE_GATE_PROD_BRANCH` environment variable. If any check fails, Windows/macOS builds are skipped. + +Do not add version lines to `README.md` / `README.zh-CN.md`. Those files link to [Releases](https://github.com/Gyanano/RSerialDebugAssistant/releases); CI fills the GitHub Release body from commits since the previous tag. Artifacts: @@ -74,4 +81,4 @@ macOS signing uses GitHub Actions secrets (`APPLE_CERTIFICATE`, `APPLE_CERTIFICA Intel Mac `.dmg` is not built yet. -Hotfix: branch from `release`, bump the patch version, PR back into `release`, tag, then PR `release` into `main` so development is not left behind. +Hotfix: branch `hotfix/x.y.z` from `main`, bump the patch version, PR back into `main`, tag, then merge `main` back into `develop` so development is not left behind. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ef1c117 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,69 @@ +name: CI + +# PR-level safety net. Runs the Rust test suite and a frontend type-check + build. +# Release packaging stays in release.yml (tag pushes only). +on: + pull_request: + branches: [develop, main] + push: + branches: [develop] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + rust: + name: Rust test + # macOS matches the release runner and needs no extra system packages for Tauri. + runs-on: macos-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Rust cache + uses: swatinem/rust-cache@v2 + with: + workspaces: src-tauri + + # tauri::generate_context! requires frontendDist to exist at compile time. + - name: Create frontend dist placeholder + run: | + mkdir -p frontend/dist + echo 'ci placeholder' > frontend/dist/index.html + + - name: Run tests + working-directory: src-tauri + run: cargo test + + frontend: + name: Frontend typecheck + build + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + working-directory: frontend + run: npm install + + - name: Type-check + working-directory: frontend + run: npx tsc --noEmit + + - name: Build + working-directory: frontend + run: npm run build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bf100d4..520b9c3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,7 @@ name: Release # Tag push is the only trigger. The gate job then requires: -# 1. the tagged commit is on `release` +# 1. the tagged commit is on `main` (the production branch) # 2. version.json changed vs the previous version tag # 3. the tag is Vx.y.z / vx.y.z and matches version.json on: @@ -109,3 +109,21 @@ jobs: releaseDraft: false prerelease: false args: ${{ matrix.args }} + + release-notes: + name: Fill release notes + needs: [gate, build] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Generate notes from commits since the previous tag + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.ref_name }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + NOTES="$(gh api "repos/${REPO}/releases/generate-notes" -f tag_name="${TAG}" --jq .body)" + BODY="$(printf '%s\n\n%s\n' "${TAG}" "${NOTES}")" + gh release edit "${TAG}" --repo "${REPO}" --notes "${BODY}" diff --git a/.gitignore b/.gitignore index 202bdba..a670c25 100644 --- a/.gitignore +++ b/.gitignore @@ -61,4 +61,6 @@ coverage/ !README.md !README.zh-CN.md !AGENTS.md +!CONTRIBUTING.md +!CONTRIBUTING.zh-CN.md !.github/**/*.md diff --git a/AGENTS.md b/AGENTS.md index d2ba552..a17190c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ Tauri 2 + React 18 串口调试工具。前端在 `frontend/`,Rust 在 `src-tauri/`。UI 用 shadcn(new-york / zinc),组件在 `frontend/src/components/ui/`。 -发版流程、分支模型和 CI 门禁的完整说明在 `.github/BRANCHING.md`。改 CI、打 Tag、合 `release` 之前先读它。 +发版流程、分支模型和 CI 门禁的完整说明在 `.github/BRANCHING.md`。改 CI、打 Tag、合 `main`(生产线)之前先读它。 ## 本地运行(macOS) @@ -47,12 +47,68 @@ macOS CI 用 Developer ID Application 签名 + App Store Connect API 公证。 双端安装包(Windows NSIS `.exe` + macOS Apple Silicon `.dmg`)只在推送版本 Tag 时启动,并由 `scripts/ci-release-gate.sh` 再检查: -1. 该 Tag 的 commit 在 `origin/release` 上(合进了 release,不是只在 `main`) +1. 该 Tag 的 commit 在 `origin/main`(生产线)上(合进了 main,不是只在 `develop`) 2. `version.json` 相对上一个版本 Tag 有变更 3. Tag 为 `Vx.y.z` / `vx.y.z`,且与 `version.json` 一致 缺一则不编译。工作流:`.github/workflows/release.yml`。 -分支:`main` = 日常开发;`release` = 发版线,不要直接往 `release` 上提交。`feature/*` 从 `main` 拉,`hotfix/*` 从 `release` 拉。 +不要在 README 里手写版本号或版本历史。最新版本用 GitHub Release 徽章显示,变更说明由 CI 根据两次 Tag 之间的提交生成,写在 GitHub Releases 上。 -第一次启用 CI:先把含 workflow 的 commit 推到 `main`,再从该 commit 建并推送 `release`;GitHub Actions 权限设为 Read and write。已有 Tag(如 `V1.3.1`)不会自动重编。 +分支(标准 git-flow):`main` = 生产发版线,不要直接往 `main` 上提交;`develop` = 日常开发(GitHub 默认分支)。`feature/*`、`bugfix/*`、`release/x.y.z` 从 `develop` 拉,`hotfix/x.y.z` 从 `main` 拉。 + +## git-flow 常用流程 + +注意:把分支**推送**到远端不会删除本地分支;删除发生在"完成"时(PR 合并后,或 `git flow feature finish`)。远端分支要单独删(GitHub 合并 PR 后点 Delete branch,或 `git push origin --delete `)。 + +### feature / bugfix(日常开发,从 develop 拉) + +```bash +git checkout develop && git pull +git checkout -b feature/xxx # bugfix 同理:bugfix/xxx +# …开发、提交(Conventional Commits)… +git push -u origin feature/xxx # 推送不删本地分支 +# 开 PR 合入 develop,合并后清理: +git checkout develop && git pull +git branch -d feature/xxx +git push origin --delete feature/xxx # 或在 GitHub 上点 Delete branch +``` + +本地只有小改动、不走 PR 时,可以直接 `--no-ff` 合回 develop 再删分支(仓库里 `1da5a4f` 就是例子)。 + +### release/x.y.z(发版,从 develop 拉) + +```bash +git checkout develop && git pull +git checkout -b release/1.4.0 +# 提升 version.json / Cargo.toml / tauri.conf.json / frontend/package.json 到同一版本 +git push -u origin release/1.4.0 # 开 PR 合入 main +git checkout main && git pull +git tag V1.4.0 && git push origin V1.4.0 # 推 Tag 触发 CI 构建 +git checkout develop && git merge --no-ff main # 回合 develop +git push origin develop +git branch -d release/1.4.0 && git push origin --delete release/1.4.0 +``` + +### hotfix/x.y.z(紧急修复,从 main 拉) + +```bash +git checkout main && git pull +git checkout -b hotfix/1.3.2 +# 修复 + 提升四个文件的 patch 版本 +git push -u origin hotfix/1.3.2 # 开 PR 合入 main +git checkout main && git pull +git tag V1.3.2 && git push origin V1.3.2 +git checkout develop && git merge --no-ff main # 修复回合 develop +git push origin develop +git branch -d hotfix/1.3.2 && git push origin --delete hotfix/1.3.2 +``` + +### 同步与清理 + +```bash +git fetch --prune # 清理远端已删除分支的本地跟踪 +git checkout develop && git pull # 日常开工前保持 develop 最新 +``` + +第一次启用 CI:先把含 workflow 的 commit 推到 `develop`,再从该 commit 建并推送 `main`(生产线);GitHub Actions 权限设为 Read and write。已有 Tag(如 `V1.3.1`)不会自动重编。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d740686 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,121 @@ +# Contributing to RSerial Debug Assistant + +[中文文档](CONTRIBUTING.zh-CN.md) + +Thanks for your interest in contributing! This document explains how the repository is organized and the workflow to follow for new features, fixes, and releases. + +## Branch model (git-flow) + +This repository is managed with a standard **git-flow** branch structure (using `main` as the production branch, in place of the historical `master`): + +| Branch | git-flow role | Purpose | +|--------|---------------|---------| +| `main` | *master* (production) | Shippable line. Only code that is meant to ship lands here. **Never commit directly** — always via pull request. | +| `develop` | *develop* | Day-to-day integration branch, and the GitHub default branch. All feature work is merged here first. | +| `feature/` | feature branches | New features or improvements, branched from `develop`, merged back into `develop` via PR. | +| `bugfix/` | bugfix branches | Non-urgent fixes, branched from `develop`, merged back into `develop` via PR. | +| `release/` | release branches | Release preparation / version bump, branched from `develop`, merged into `main` via PR, then merged back into `develop`. | +| `hotfix/` | hotfix branches | Urgent production fixes, branched from `main`, merged back into `main` via PR, then merged back into `develop`. | + +``` +feature/* ──PR──► develop ──PR──► release/x.y.z ──PR──► main ──tag Vx.y.z──► CI builds installers +bugfix/* ──PR──► develop │ +hotfix/* ──PR──► main ──tag──► CI └─merge back──► develop + └─merge back──► develop +``` + +## Optional: git-flow CLI setup + +If you use the [`git-flow`](https://github.com/nvie/gitflow) CLI, run these once after cloning to match the repo's branch names: + +```bash +git config gitflow.branch.master main +git config gitflow.branch.develop develop +git config gitflow.prefix.feature feature/ +git config gitflow.prefix.bugfix bugfix/ +git config gitflow.prefix.release release/ +git config gitflow.prefix.hotfix hotfix/ +git config gitflow.prefix.support support/ +git config gitflow.prefix.versiontag V +``` + +The CLI is entirely optional — plain `git checkout -b ...` + pull requests follow the exact same model. + +## Developing a new feature + +1. **Fork** the repository and clone your fork. +2. Make sure your local `develop` is up to date: + ```bash + git checkout develop && git pull + ``` +3. Create a feature branch **from `develop`**: + ```bash + git checkout -b feature/your-feature-name + # or with the CLI: git flow feature start your-feature-name + ``` +4. Make your changes and test them locally (see [Local development](#local-development)). +5. Commit using [Conventional Commits](#commit-messages). +6. Push and open a **pull request targeting `develop`** (never `main`). +7. After review and merge, delete the feature branch. + +## Commit messages + +Use [Conventional Commits](https://www.conventionalcommits.org/), as in the existing history: + +``` +feat(macos): add dual-platform release CI and serial port fixes +fix: handle serial port disconnection gracefully +docs: update usage guide +ci(macos): sign and notarize the GitHub dmg +``` + +Common types: `feat`, `fix`, `docs`, `ci`, `refactor`, `test`, `chore`. + +## Version numbers + +When preparing a release, these four files **must** carry the same `x.y.z`: + +- `version.json` +- `src-tauri/Cargo.toml` +- `src-tauri/tauri.conf.json` +- `frontend/package.json` + +Do **not** add version numbers or changelogs to the README files — releases and changelogs live on [GitHub Releases](https://github.com/Gyanano/RSerialDebugAssistant/releases), generated by CI. + +## How a release ships (maintainers) + +Full details, including the CI gate conditions and macOS signing, are in [.github/BRANCHING.md](.github/BRANCHING.md). In short: + +1. Create `release/x.y.z` from `develop`, bump all four version files, PR into `main`. +2. Tag the merge commit on `main` as `Vx.y.z` (capital V, matching `version.json`) and push the tag. +3. CI verifies the gate (commit on `main` + `version.json` changed + tag matches) and builds the Windows `.exe` and macOS `.dmg`. +4. Merge `main` back into `develop`. + +## Hotfixes + +1. Branch `hotfix/x.y.z` **from `main`** (not from `develop`). +2. Fix, bump the **patch** version in all four files, PR back into `main`. +3. Tag the new `Vx.y.z` on `main` and push — CI ships it. +4. Merge `main` back into `develop` so the fix is not lost in development. + +## Local development + +Prerequisites: Node.js, Rust toolchain, and the Tauri 2 system dependencies. + +```bash +# Terminal 1 — frontend dev server (port 5173) +cd frontend && npm install && npm run dev + +# Terminal 2 — Tauri app +npx @tauri-apps/cli@2 dev +``` + +Before committing: + +- Rust: run `cargo fmt` and `cargo clippy` in `src-tauri/`. +- Frontend: follow the existing TypeScript / React conventions; prefer the shadcn components in `frontend/src/components/ui/` over hand-written native elements. +- Test on your platform; mention untested platforms in the PR description. + +## Reporting issues + +Please [open an issue](https://github.com/Gyanano/RSerialDebugAssistant/issues) with a clear title, reproduction steps, expected vs actual behavior, and your OS / app version. diff --git a/CONTRIBUTING.zh-CN.md b/CONTRIBUTING.zh-CN.md new file mode 100644 index 0000000..3a0953b --- /dev/null +++ b/CONTRIBUTING.zh-CN.md @@ -0,0 +1,121 @@ +# 为 RSerial Debug Assistant 做贡献 + +[English](CONTRIBUTING.md) + +感谢你有兴趣参与贡献!本文档说明仓库的组织方式,以及新功能开发、修复和发版时应遵循的工作流程。 + +## 分支模型(git-flow) + +本仓库使用标准 **git-flow** 分支结构(生产分支采用现代惯例的 `main`,取代传统的 `master`): + +| 分支 | git-flow 角色 | 用途 | +|------|---------------|------| +| `main` | *master*(生产) | 发版线。只有准备发布的代码才进入此分支。**禁止直接提交**——一律通过 Pull Request。 | +| `develop` | *develop* | 日常集成分支,也是 GitHub 默认分支。所有功能开发先合并到这里。 | +| `feature/<名称>` | feature 分支 | 新功能或改进,从 `develop` 拉出,通过 PR 合回 `develop`。 | +| `bugfix/<名称>` | bugfix 分支 | 非紧急修复,从 `develop` 拉出,通过 PR 合回 `develop`。 | +| `release/` | release 分支 | 发版准备 / 版本号提升,从 `develop` 拉出,通过 PR 合入 `main`,再回合 `develop`。 | +| `hotfix/` | hotfix 分支 | 紧急线上修复,从 `main` 拉出,通过 PR 合回 `main`,再回合 `develop`。 | + +``` +feature/* ──PR──► develop ──PR──► release/x.y.z ──PR──► main ──打 Tag Vx.y.z──► CI 构建安装包 +bugfix/* ──PR──► develop │ +hotfix/* ──PR──► main ──打 Tag──► CI └─回合──► develop + └─回合──► develop +``` + +## 可选:git-flow CLI 配置 + +如果你使用 [`git-flow`](https://github.com/nvie/gitflow) 命令行工具,克隆后执行一次以下命令即可匹配本仓库的分支命名: + +```bash +git config gitflow.branch.master main +git config gitflow.branch.develop develop +git config gitflow.prefix.feature feature/ +git config gitflow.prefix.bugfix bugfix/ +git config gitflow.prefix.release release/ +git config gitflow.prefix.hotfix hotfix/ +git config gitflow.prefix.support support/ +git config gitflow.prefix.versiontag V +``` + +CLI 完全是可选的——直接用 `git checkout -b ...` 加 Pull Request 遵循的是完全相同的模型。 + +## 开发新功能 + +1. **Fork** 仓库并克隆你的 fork。 +2. 确保本地 `develop` 是最新的: + ```bash + git checkout develop && git pull + ``` +3. **从 `develop`** 创建功能分支: + ```bash + git checkout -b feature/your-feature-name + # 或使用 CLI:git flow feature start your-feature-name + ``` +4. 进行更改并在本地充分测试(见[本地开发](#本地开发))。 +5. 使用 [Conventional Commits](#提交信息) 规范提交。 +6. 推送并打开**目标为 `develop` 的 Pull Request**(永远不要直接对 `main` 提 PR)。 +7. 评审合并后删除功能分支。 + +## 提交信息 + +使用 [Conventional Commits](https://www.conventionalcommits.org/zh-hans/) 规范,与现有提交历史保持一致: + +``` +feat(macos): add dual-platform release CI and serial port fixes +fix: handle serial port disconnection gracefully +docs: update usage guide +ci(macos): sign and notarize the GitHub dmg +``` + +常用类型:`feat`、`fix`、`docs`、`ci`、`refactor`、`test`、`chore`。 + +## 版本号 + +准备发版时,以下四个文件**必须**使用相同的 `x.y.z`: + +- `version.json` +- `src-tauri/Cargo.toml` +- `src-tauri/tauri.conf.json` +- `frontend/package.json` + +**不要**在 README 中手写版本号或更新日志——版本发布和变更说明由 CI 生成,发布在 [GitHub Releases](https://github.com/Gyanano/RSerialDebugAssistant/releases) 上。 + +## 发版流程(维护者) + +完整说明(包括 CI 门禁条件和 macOS 签名)见 [.github/BRANCHING.md](.github/BRANCHING.md)。简要流程: + +1. 从 `develop` 创建 `release/x.y.z` 分支,提升全部四个版本文件,提 PR 合入 `main`。 +2. 在 `main` 的合并提交上打 Tag `Vx.y.z`(大写 V,与 `version.json` 一致)并推送。 +3. CI 校验门禁(提交在 `main` 上 + `version.json` 有变更 + Tag 一致),然后构建 Windows `.exe` 和 macOS `.dmg`。 +4. 将 `main` 回合到 `develop`。 + +## 紧急修复(Hotfix) + +1. **从 `main`**(而不是 `develop`)拉出 `hotfix/x.y.z` 分支。 +2. 修复问题,在全部四个文件中提升 **patch** 版本号,提 PR 合回 `main`。 +3. 在 `main` 上打新的 Tag `Vx.y.z` 并推送——CI 自动发布。 +4. 将 `main` 回合到 `develop`,确保修复不丢回开发线。 + +## 本地开发 + +前置条件:Node.js、Rust 工具链,以及 Tauri 2 的系统依赖。 + +```bash +# 终端 1 —— 前端开发服务器(端口 5173) +cd frontend && npm install && npm run dev + +# 终端 2 —— Tauri 应用 +npx @tauri-apps/cli@2 dev +``` + +提交前: + +- Rust:在 `src-tauri/` 中运行 `cargo fmt` 和 `cargo clippy`。 +- 前端:遵循现有 TypeScript / React 约定;优先使用 `frontend/src/components/ui/` 中已有的 shadcn 组件,不要手写原生元素。 +- 在你的平台上测试;未测试的平台请在 PR 描述中说明。 + +## 报告问题 + +请[提交 Issue](https://github.com/Gyanano/RSerialDebugAssistant/issues),附上清晰的标题、复现步骤、期望与实际行为,以及你的操作系统和应用版本。 diff --git a/README.md b/README.md index 118b9c4..4127f06 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ **A professional-grade, cross-platform serial debugging tool built with Tauri 2.0 + React 18** +[![Release](https://img.shields.io/github/v/release/Gyanano/RSerialDebugAssistant)](https://github.com/Gyanano/RSerialDebugAssistant/releases/latest) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![Rust](https://img.shields.io/badge/rust-1.70+-orange.svg)](https://www.rust-lang.org/) [![Tauri](https://img.shields.io/badge/tauri-2.0-purple.svg)](https://tauri.app/) @@ -72,14 +73,14 @@ Whether you're debugging Arduino projects, communicating with industrial sensors ### Download Pre-built Binaries -Download the latest release from the project repository: +Download the [latest GitHub Release](https://github.com/Gyanano/RSerialDebugAssistant/releases/latest). The version number on that page is the source of truth; this README does not list releases by hand. | Platform | Format | Notes | |----------|--------|-------| | Windows | `.exe` (NSIS) | Built by GitHub Actions on version tags | | macOS (Apple Silicon) | `.dmg` | Built by GitHub Actions on version tags | -Release builds run only when a version tag is pushed **and** that commit is on `release` **and** `version.json` changed. See [`.github/BRANCHING.md`](.github/BRANCHING.md). +Release builds run only when a version tag is pushed **and** that commit is on `main` (the production branch) **and** `version.json` changed. See [`.github/BRANCHING.md`](.github/BRANCHING.md). ### Build from Source @@ -322,6 +323,8 @@ We are actively working on new features to make RSerial Debug Assistant even mor Contributions are welcome! Help us improve the project: +> 📖 **Read [CONTRIBUTING.md](CONTRIBUTING.md) first** — it documents the git-flow branch model (`develop` for development, `main` for shipping, `feature/*` / `release/*` / `hotfix/*` branches) and the full contribution workflow. + ### Getting Started with Development 1. **Fork** the repository on GitHub @@ -422,14 +425,5 @@ This project stands on the shoulders of amazing open-source projects: ⭐ **If you find this project helpful, please consider giving it a star on GitHub!** -
-Version History - -- **v1.3.1** - Bug fix for frame segmentation - simplified UI by removing standalone Delimiter mode. -- **v1.3.0** - Auto-update feature, enhanced log viewer with search/line numbers, quick command improvements, modern UI components (shadcn). -- **v1.2.0** - Frame segmentation, internationalization, timezone support, and advanced configuration options. -- **v1.1.0** - Periodic sending, quick command lists, light/dark theme UI. -- **v1.0.0** - Initial release with core serial debugging functionality. - -
+Version history lives on [GitHub Releases](https://github.com/Gyanano/RSerialDebugAssistant/releases). Each tagged build also gets notes generated from commits since the previous tag. diff --git a/README.zh-CN.md b/README.zh-CN.md index c4d3e27..1f94a62 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -6,6 +6,7 @@ **一个专业级、跨平台的串行调试工具,基于 Tauri 2.0 + React 18 构建** +[![Release](https://img.shields.io/github/v/release/Gyanano/RSerialDebugAssistant)](https://github.com/Gyanano/RSerialDebugAssistant/releases/latest) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![Rust](https://img.shields.io/badge/rust-1.70+-orange.svg)](https://www.rust-lang.org/) [![Tauri](https://img.shields.io/badge/tauri-2.0-purple.svg)](https://tauri.app/) @@ -72,14 +73,14 @@ ### 下载预编译二进制文件 -从项目仓库下载最新版本: +到 [最新 GitHub Release](https://github.com/Gyanano/RSerialDebugAssistant/releases/latest) 下载。版本号以该页面为准,不必在 README 里手写。 | 平台 | 格式 | 说明 | |------|------|------| | Windows | `.exe`(NSIS) | 在版本 Tag 上由 GitHub Actions 构建 | | macOS(Apple Silicon) | `.dmg` | 在版本 Tag 上由 GitHub Actions 构建 | -只有同时满足「提交在 `release` 分支上」「`version.json` 有变更」「推送了版本 Tag」三个条件才会编译。详见 [`.github/BRANCHING.md`](.github/BRANCHING.md)。 +只有同时满足「提交在 `main`(生产线)分支上」「`version.json` 有变更」「推送了版本 Tag」三个条件才会编译。详见 [`.github/BRANCHING.md`](.github/BRANCHING.md)。 ### 从源代码构建 @@ -322,6 +323,8 @@ npm run build # Vite 生产构建 欢迎贡献!帮助我们改进项目: +> 📖 **请先阅读 [CONTRIBUTING.zh-CN.md](CONTRIBUTING.zh-CN.md)**——其中说明了 git-flow 分支模型(`develop` 用于开发、`main` 用于发版、`feature/*` / `release/*` / `hotfix/*` 分支)以及完整的贡献流程。 + ### 开发入门 1. **Fork** GitHub 上的仓库 @@ -422,14 +425,5 @@ MIT 许可证允许你在适当署名的情况下自由使用、修改和分发 ⭐ **如果你觉得本项目有帮助,请考虑在 GitHub 上给个 Star!** -
-版本历史 - -- **v1.3.1** - 帧分段功能修复 - 移除独立的分隔符模式,简化 UI。 -- **v1.3.0** - 自动更新功能、增强的日志查看器(搜索/行号)、快速命令改进、现代化 UI 组件(shadcn)。 -- **v1.2.0** - 帧分段功能、国际化、时区支持及高级配置选项。 -- **v1.1.0** - 定期发送、快速命令列表、亮色/深色主题 UI。 -- **v1.0.0** - 初始版本,包含核心串行调试功能。 - -
+版本历史见 [GitHub Releases](https://github.com/Gyanano/RSerialDebugAssistant/releases)。每次打 Tag 发版时,CI 会根据相对上一版本的提交自动生成说明。 diff --git a/frontend/package.json b/frontend/package.json index 37f9471..fe463a5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "serial-debug-assistant-frontend", - "version": "1.3.3", + "version": "1.4.0", "description": "Beautiful frontend for RSerial Debug Assistant", "scripts": { "dev": "vite", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ccbe142..4424652 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import SettingsModal from './components/SettingsModal'; import { SerialPortInfo, SerialConfig, LogEntry, ConnectionStatus, DataFormat, ChecksumConfig, QuickCommandList, QuickCommand, LineEnding, TextEncoding, FrameSegmentationConfig } from './types'; import { useTheme } from './contexts/ThemeContext'; import { useTranslation } from './i18n'; +import { useSerialLogs } from './hooks/useSerialLogs'; import { appendChecksum } from './utils/checksum'; import { loadTimezone, formatDateForFilename, getSystemTimezoneOffset, parseUtcOffset } from './utils/timezone'; import { Toaster } from './components/ui/sonner'; @@ -135,7 +136,12 @@ function App() { bytes_received: 0, connection_time: null, }); - const [logs, setLogs] = useState([]); + const [polledLogs, setPolledLogs] = useState([]); + // RFC #3 Step 4: event push is the default log path; set + // localStorage 'serialEventPush' = '0' to roll back to 100ms polling. + const [eventPush] = useState(() => localStorage.getItem('serialEventPush') !== '0'); + const { logs: pushedLogs, clearLogs: clearPushedLogs } = useSerialLogs(eventPush); + const logs = eventPush ? pushedLogs : polledLogs; const [sendText, setSendText] = useState(''); const [sendFormat, setSendFormat] = useState('Text'); const [checksumConfig, setChecksumConfig] = useState({ @@ -417,15 +423,17 @@ function App() { // Set up intervals for updating status, logs, and ports const statusInterval = setInterval(updateStatus, 1000); - const logsInterval = setInterval(updateLogs, 100); // More frequent log updates + // Event push (RFC #3 Step 4) replaces the 100ms full-clone log polling; + // the polling interval only runs in rollback mode. + const logsInterval = eventPush ? null : setInterval(updateLogs, 100); const portsInterval = setInterval(loadPorts, 3000); // Check for new ports every 3 seconds return () => { clearInterval(statusInterval); - clearInterval(logsInterval); + if (logsInterval !== null) clearInterval(logsInterval); clearInterval(portsInterval); }; - }, [loadPorts]); // 依赖loadPorts函数 + }, [loadPorts, eventPush]); // 依赖loadPorts函数 const handlePortSelect = (port: string) => { setSelectedPort(port); @@ -435,7 +443,14 @@ function App() { const updateStatus = async () => { try { const status = await invoke('get_connection_status'); - setConnectionStatus(status); + // Surface an unexpected loss (fatal read error, e.g. cable unplugged) + // exactly once per transition; manual disconnects carry no error. + setConnectionStatus((prev) => { + if (prev.is_connected && !status.is_connected && status.connection_error) { + toast.error(t('app.connectionLost').replace('{error}', status.connection_error)); + } + return status; + }); } catch (error) { console.error('Failed to get status:', error); } @@ -444,7 +459,7 @@ function App() { const updateLogs = async () => { try { const newLogs = await invoke('get_logs'); - setLogs(newLogs); + setPolledLogs(newLogs); } catch (error) { console.error('Failed to get logs:', error); } @@ -545,9 +560,13 @@ function App() { }; const handleClearLogs = async () => { + if (eventPush) { + await clearPushedLogs(); + return; + } try { await invoke('clear_logs'); - setLogs([]); + setPolledLogs([]); } catch (error) { console.error('Failed to clear logs:', error); } @@ -855,6 +874,7 @@ function App() { onFormatChange={setSendFormat} onSend={handleSendData} isConnected={connectionStatus.is_connected} + config={config} checksumConfig={checksumConfig} onChecksumConfigChange={setChecksumConfig} quickCommandLists={quickCommandLists} diff --git a/frontend/src/components/LogViewer.tsx b/frontend/src/components/LogViewer.tsx index ace5a53..eec0626 100644 --- a/frontend/src/components/LogViewer.tsx +++ b/frontend/src/components/LogViewer.tsx @@ -27,7 +27,7 @@ const STORAGE_KEY_SHOW_LINE_NUMBERS = 'serialDebug_showLineNumbers'; const SCROLL_BOTTOM_THRESHOLD = 50; const DEFAULT_SPECIAL_CHAR_CONFIG: SpecialCharConfig = { - enabled: true, + enabled: false, convertLF: true, convertCR: true, convertTab: true, @@ -664,7 +664,7 @@ const LogViewer: React.FC = ({ logs, onClear, onExport, isConnec
{logs.map((log, index) => (
{ logEntryRefs.current[index] = el; }} className="py-1 px-2 rounded-[4px] transition-colors duration-150" style={{ diff --git a/frontend/src/components/SendPanel.tsx b/frontend/src/components/SendPanel.tsx index 293ecdd..4c2584f 100644 --- a/frontend/src/components/SendPanel.tsx +++ b/frontend/src/components/SendPanel.tsx @@ -1,6 +1,6 @@ import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react'; -import { Send, Shield, ChevronDown, ChevronUp, List, FileText } from 'lucide-react'; -import { DataFormat, ChecksumType, ChecksumConfig, QuickCommandList, QuickCommand, LineEnding } from '../types'; +import { Send, Shield, ChevronDown, ChevronUp, List, FileText, AlertTriangle } from 'lucide-react'; +import { DataFormat, ChecksumType, ChecksumConfig, QuickCommandList, QuickCommand, LineEnding, SerialConfig } from '../types'; import QuickCommandPanel from './QuickCommandPanel'; import { useTheme } from '../contexts/ThemeContext'; import { useTranslation } from '../i18n'; @@ -28,7 +28,7 @@ export const SEND_PANEL_MIN_HEIGHTS = { }; // Format hex input: filter non-hex chars, add spaces every 2 chars -const formatHexInput = (input: string, previousValue: string): string => { +const formatHexInput = (input: string): string => { // Remove all spaces first const withoutSpaces = input.replace(/\s/g, ''); @@ -51,6 +51,7 @@ interface SendPanelProps { onFormatChange: (format: DataFormat) => void; onSend: () => void; isConnected: boolean; + config: SerialConfig; checksumConfig: ChecksumConfig; onChecksumConfigChange: (config: ChecksumConfig) => void; // Quick Command props @@ -71,6 +72,7 @@ const SendPanel: React.FC = ({ onFormatChange, onSend, isConnected, + config, checksumConfig, onChecksumConfigChange, quickCommandLists, @@ -87,11 +89,38 @@ const SendPanel: React.FC = ({ const [sendMode, setSendMode] = useState('normal'); const [isScheduledEnabled, setIsScheduledEnabled] = useState(false); const [scheduledInterval, setScheduledInterval] = useState(1000); + // Draft string while the interval input is being edited: typing is never + // clamped mid-edit; the value commits (clamped) on blur/Enter, Escape + // cancels. Prevents "5" snapping to the minimum while typing "50". + const [intervalDraft, setIntervalDraft] = useState(null); const [isScheduledRunning, setIsScheduledRunning] = useState(false); const intervalRef = useRef | null>(null); const [isChecksumExpanded, setIsChecksumExpanded] = useState(false); const [isConverting, setIsConverting] = useState(false); + // Line capacity in bytes/s: 1 start bit + data bits + optional parity bit + // + stop bits per byte on the wire. + const lineCapacityBps = useMemo(() => { + const dataBits = { Five: 5, Six: 6, Seven: 7, Eight: 8 }[config.data_bits]; + const parityBit = config.parity === 'None' ? 0 : 1; + const stopBits = { One: 1, OnePointFive: 1.5, Two: 2 }[config.stop_bits]; + return config.baud_rate / (1 + dataBits + parityBit + stopBits); + }, [config]); + + // Payload per scheduled send in bytes (UTF-8 approximation in Text mode; + // this is a guard rail, not an exact meter). + const payloadBytes = useMemo(() => { + const base = format === 'Hex' + ? value.replace(/\s/g, '').length / 2 + : new TextEncoder().encode(value).length; + return base + getChecksumLength(checksumConfig.type); + }, [value, format, checksumConfig.type]); + + const requiredRateBps = (payloadBytes * 1000) / scheduledInterval; + const showRateWarning = isScheduledEnabled && payloadBytes > 0 && requiredRateBps > lineCapacityBps * 0.9; + const formatRate = (bps: number) => + bps >= 1024 ? `${(bps / 1024).toFixed(1)} KB/s` : `${Math.round(bps)} B/s`; + // Calculate disabled state for normal mode (depends on both connection AND content) const isNormalSendDisabled = !isConnected || !value.trim() || isScheduledEnabled || isConverting; // Calculate disabled state for quick mode (depends ONLY on connection) @@ -145,7 +174,7 @@ const SendPanel: React.FC = ({ if (format === 'Hex') { // Format and validate hex input - const formattedHex = formatHexInput(newValue, value); + const formattedHex = formatHexInput(newValue); onChange(formattedHex); } else { // Text mode: no restrictions @@ -164,9 +193,20 @@ const SendPanel: React.FC = ({ } }; - const handleIntervalChange = (e: React.ChangeEvent) => { - const newInterval = Math.max(100, parseInt(e.target.value) || 1000); - setScheduledInterval(newInterval); + // Interval bounds: 10ms floor (event-push RX path can keep up now), + // 60s ceiling, 1s fallback for empty/invalid drafts. + const MIN_INTERVAL_MS = 10; + const MAX_INTERVAL_MS = 60000; + + const commitIntervalDraft = () => { + if (intervalDraft === null) return; + const parsed = parseInt(intervalDraft, 10); + const next = Number.isNaN(parsed) + ? scheduledInterval + : Math.min(MAX_INTERVAL_MS, Math.max(MIN_INTERVAL_MS, parsed)); + setIntervalDraft(null); + if (next === scheduledInterval) return; + setScheduledInterval(next); // Restart interval with new timing if already running if (isScheduledRunning) { @@ -175,6 +215,8 @@ const SendPanel: React.FC = ({ } }; + const cancelIntervalDraft = () => setIntervalDraft(null); + const startScheduledSending = () => { if (!value.trim() || !isConnected) return; @@ -201,10 +243,13 @@ const SendPanel: React.FC = ({ }; }, []); - // Cleanup when disconnected or value becomes empty + // Cleanup when disconnected or value becomes empty: scheduled sending no + // longer has its preconditions, so reset the switch too — otherwise the + // panel shows "scheduled active" with a dead timer underneath. useEffect(() => { if (!isConnected || !value.trim()) { stopScheduledSending(); + setIsScheduledEnabled(false); } }, [isConnected, value]); @@ -559,12 +604,18 @@ const SendPanel: React.FC = ({ setIntervalDraft(String(scheduledInterval))} + onChange={(e) => setIntervalDraft(e.target.value.replace(/[^0-9]/g, ''))} + onBlur={commitIntervalDraft} + onKeyDown={(e) => { + if (e.key === 'Enter') commitIntervalDraft(); + if (e.key === 'Escape') cancelIntervalDraft(); + }} className="w-full h-7 text-xs text-right" - min={100} - max={60000} - step={100} + min={MIN_INTERVAL_MS} + max={MAX_INTERVAL_MS} + step={10} disabled={!isConnected || isScheduledRunning} /> @@ -577,6 +628,18 @@ const SendPanel: React.FC = ({
+ {/* Rate guard: scheduled sending faster than the line can carry */} + {showRateWarning && ( +
+ + + {t('sendPanel.rateExceeded') + .replace('{rate}', formatRate(requiredRateBps)) + .replace('{capacity}', formatRate(lineCapacityBps))} + +
+ )} + {/* Quick Insert Row (Hex mode only) */} {format === 'Hex' && (
= ({ connectionStatus, selectedPort, c const { colors } = useTheme(); const { t } = useTranslation(); - const formatBytes = (bytes: number) => { - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`; - }; - const formatConnectionTime = (timestamp: string | null) => { if (!timestamp) return t('statusBar.notConnected'); diff --git a/frontend/src/hooks/useSerialLogs.ts b/frontend/src/hooks/useSerialLogs.ts new file mode 100644 index 0000000..4c35f71 --- /dev/null +++ b/frontend/src/hooks/useSerialLogs.ts @@ -0,0 +1,172 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { listen } from '@tauri-apps/api/event'; +import { LogEntry } from '../types'; +import { useTranslation } from '../i18n'; + +/** + * Event-driven serial log state (RFC #3 Step 4): replaces the 100 ms + * full-clone polling of `get_logs` with a one-shot snapshot + incremental + * `serial://frames` batches. + * + * Invariants: + * - Snapshot carries (epoch, session); a snapshot that raced a `clear_logs` + * (epoch mismatch) or a newer snapshot (ticket) is discarded — cleared + * logs never resurrect. + * - Batches dedupe by seq: frames already covered by the snapshot or a + * prior batch are skipped; frames from before a clear are skipped. + * - A batch whose session differs from the current one triggers a resync + * snapshot instead of an append (seq restarts at 1 per session). + * - `dropped_before > 0` inserts a placeholder row so channel overload is + * visible instead of silent. + */ + +interface FrameDto { + session: number; + seq: number; + direction: 'Sent' | 'Received'; + len: number; + timestamp: string; + display_text: string; + timestamp_formatted: string | null; +} + +interface FrameBatchDto { + session: number; + first_seq: number; + dropped_before: number; + frames: FrameDto[]; +} + +interface LogsSnapshot { + epoch: number; + session: number; + entries: LogEntry[]; +} + +/** Mirrors the backend default (`SerialManager::new`, clamped 100..10000). */ +const MAX_ENTRIES = 1000; + +export function useSerialLogs(enabled: boolean) { + const { t } = useTranslation(); + const [logs, setLogs] = useState([]); + + const epochRef = useRef(0); + const sessionRef = useRef(0); + const lastSeqRef = useRef(0); + const clearedSeqRef = useRef(0); + const pendingRef = useRef([]); + const rafRef = useRef(null); + const snapTicketRef = useRef(0); + + const flushPending = useCallback(() => { + rafRef.current = null; + if (pendingRef.current.length === 0) return; + const batch = pendingRef.current; + pendingRef.current = []; + setLogs((prev) => { + const next = [...prev, ...batch]; + return next.length > MAX_ENTRIES ? next.slice(next.length - MAX_ENTRIES) : next; + }); + }, []); + + const scheduleFlush = useCallback(() => { + if (rafRef.current === null) { + rafRef.current = requestAnimationFrame(flushPending); + } + }, [flushPending]); + + const takeSnapshot = useCallback(async () => { + const ticket = ++snapTicketRef.current; + try { + const snap = await invoke('get_logs_snapshot'); + if (ticket !== snapTicketRef.current) return; // superseded by a newer snapshot + if (snap.epoch !== epochRef.current) return; // raced a clear: discard + epochRef.current = snap.epoch; + sessionRef.current = snap.session; + lastSeqRef.current = snap.entries.reduce((m, e) => Math.max(m, e.seq ?? 0), 0); + pendingRef.current = []; + setLogs(snap.entries); + } catch (error) { + console.error('Failed to take logs snapshot:', error); + } + }, []); + + useEffect(() => { + if (!enabled) return; + let disposed = false; + let unlisten: (() => void) | undefined; + + void takeSnapshot(); + + listen('serial://frames', (event) => { + if (disposed) return; + const b = event.payload; + if (b.session !== sessionRef.current) { + void takeSnapshot(); // session changed: seq restarted, resync instead of append + return; + } + + const fresh: LogEntry[] = []; + if (b.dropped_before > 0) { + fresh.push({ + timestamp: new Date().toISOString(), + direction: 'Received', + data: [], + format: 'Text', + port_name: '', + display_text: t('logViewer.framesDropped').replace('{n}', String(b.dropped_before)), + timestamp_formatted: undefined, + session: b.session, + gap_key: `gap-${b.session}-${b.first_seq}`, + }); + } + for (const f of b.frames) { + // Skip frames already covered by the snapshot/prior batches, and + // frames that predate the last clear. + if (f.seq <= lastSeqRef.current || f.seq <= clearedSeqRef.current) continue; + fresh.push({ + timestamp: f.timestamp, + direction: f.direction, + data: [], + format: 'Text', + port_name: '', + display_text: f.display_text, + timestamp_formatted: f.timestamp_formatted ?? undefined, + seq: f.seq, + session: f.session, + }); + lastSeqRef.current = f.seq; + } + if (fresh.length > 0) { + pendingRef.current.push(...fresh); + scheduleFlush(); // rAF-throttled append + } + }) + .then((u) => { + unlisten = u; + }) + .catch((error) => console.error('Failed to listen serial://frames:', error)); + + return () => { + disposed = true; + unlisten?.(); + if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enabled, takeSnapshot, scheduleFlush]); + + const clearLogs = useCallback(async () => { + try { + const epoch = await invoke('clear_logs'); + epochRef.current = epoch; + clearedSeqRef.current = lastSeqRef.current; + pendingRef.current = []; + setLogs([]); + } catch (error) { + console.error('Failed to clear logs:', error); + } + }, []); + + return { logs, clearLogs }; +} diff --git a/frontend/src/i18n/LanguageContext.tsx b/frontend/src/i18n/LanguageContext.tsx index 6041f86..750ec99 100644 --- a/frontend/src/i18n/LanguageContext.tsx +++ b/frontend/src/i18n/LanguageContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import React, { createContext, useContext, useState, ReactNode } from 'react'; import en from './translations/en.json'; import zhCN from './translations/zh-CN.json'; diff --git a/frontend/src/i18n/translations/en.json b/frontend/src/i18n/translations/en.json index 4f83bcc..348ce3b 100644 --- a/frontend/src/i18n/translations/en.json +++ b/frontend/src/i18n/translations/en.json @@ -1,7 +1,8 @@ { "app": { "title": "RSerial Debug Assistant", - "subtitle": "Professional Tool" + "subtitle": "Professional Tool", + "connectionLost": "Connection lost: {error}" }, "sidebar": { "expandSidebar": "Expand Sidebar", @@ -76,7 +77,8 @@ "tx": "TX", "rx": "RX", "total": "Total", - "bytes": "Bytes" + "bytes": "Bytes", + "framesDropped": "⋯ dropped {n} frames (channel overloaded) ⋯" }, "sendPanel": { "payload": "Payload", @@ -89,6 +91,7 @@ "stopScheduled": "Stop scheduled sending", "startScheduled": "Start scheduled sending", "intervalTitle": "Send interval in milliseconds", + "rateExceeded": "Send rate ~{rate} exceeds line capacity {capacity} — sends will keep timing out", "characters": "Characters", "bytes": "Bytes", "quickInsert": "Quick Insert", diff --git a/frontend/src/i18n/translations/zh-CN.json b/frontend/src/i18n/translations/zh-CN.json index 4b3d579..830f237 100644 --- a/frontend/src/i18n/translations/zh-CN.json +++ b/frontend/src/i18n/translations/zh-CN.json @@ -1,7 +1,8 @@ { "app": { "title": "RSerial Debug Assistant", - "subtitle": "Professional Tool" + "subtitle": "Professional Tool", + "connectionLost": "连接意外断开:{error}" }, "sidebar": { "expandSidebar": "展开侧边栏", @@ -76,7 +77,8 @@ "tx": "发送", "rx": "接收", "total": "总计", - "bytes": "字节" + "bytes": "字节", + "framesDropped": "⋯ 丢失 {n} 帧(通道过载) ⋯" }, "sendPanel": { "payload": "发送数据", @@ -89,6 +91,7 @@ "stopScheduled": "停止定时发送", "startScheduled": "开始定时发送", "intervalTitle": "发送间隔(毫秒)", + "rateExceeded": "发送速率约 {rate},超过线路容量 {capacity},将持续超时", "characters": "字符数", "bytes": "字节数", "quickInsert": "快速插入", diff --git a/frontend/src/types.ts b/frontend/src/types.ts index cbb6177..f55bce5 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -36,7 +36,6 @@ export interface ChecksumConfig { } export interface LogEntry { - id?: number; timestamp: string; direction: Direction; data: number[]; @@ -46,6 +45,12 @@ export interface LogEntry { display_text: string; /** Pre-formatted timestamp string (undefined if timestamps were disabled when entry was created) */ timestamp_formatted?: string; + /** Session-scoped sequence number (RFC #3 Step 4); 0/undefined for legacy entries */ + seq?: number; + /** Owning session id */ + session?: number; + /** Frontend-only marker for synthesized "frames dropped" placeholder rows */ + gap_key?: string; } export interface ConnectionStatus { @@ -55,6 +60,7 @@ export interface ConnectionStatus { bytes_sent: number; bytes_received: number; connection_time: string | null; + connection_error?: string | null; } // Quick Command types diff --git a/frontend/src/utils/checksum.ts b/frontend/src/utils/checksum.ts index 5261f92..8e21ddd 100644 --- a/frontend/src/utils/checksum.ts +++ b/frontend/src/utils/checksum.ts @@ -48,7 +48,6 @@ export function calculateCRC8(data: Uint8Array): number[] { * Returns 2 bytes in little-endian order (low byte first) */ export function calculateCRC16(data: Uint8Array): number[] { - const polynomial = 0x8005; let crc = 0xFFFF; for (let i = 0; i < data.length; i++) { diff --git a/scripts/ci-release-gate.sh b/scripts/ci-release-gate.sh index 8574390..369e85e 100755 --- a/scripts/ci-release-gate.sh +++ b/scripts/ci-release-gate.sh @@ -1,11 +1,14 @@ #!/usr/bin/env bash # Gate for the dual-platform release workflow. # All three must pass: -# 1. The tagged commit is on origin/release +# 1. The tagged commit is on the production branch (origin/main) # 2. version.json changed vs the previous version tag # 3. The pushed ref is a version tag that matches version.json set -euo pipefail +# Production branch; override via env if the repo ever renames it again. +PROD_BRANCH="${RELEASE_GATE_PROD_BRANCH:-main}" + fail() { echo "::error::$1" echo "should_build=false" >> "${GITHUB_OUTPUT:-/dev/stdout}" @@ -34,15 +37,15 @@ fi echo "Condition 3 passed: tag $TAG_REF matches version.json $VERSION" -if ! git fetch origin refs/heads/release:refs/remotes/origin/release; then - fail "Condition 1 failed: origin/release does not exist. Create it (see .github/BRANCHING.md)" +if ! git fetch origin "refs/heads/${PROD_BRANCH}:refs/remotes/origin/${PROD_BRANCH}"; then + fail "Condition 1 failed: origin/${PROD_BRANCH} does not exist. Create it (see .github/BRANCHING.md)" fi -if ! git merge-base --is-ancestor "$SHA" origin/release; then - fail "Condition 1 failed: tagged commit $SHA is not on the release branch" +if ! git merge-base --is-ancestor "$SHA" "origin/${PROD_BRANCH}"; then + fail "Condition 1 failed: tagged commit $SHA is not on the ${PROD_BRANCH} branch" fi -echo "Condition 1 passed: $SHA is on origin/release" +echo "Condition 1 passed: $SHA is on origin/${PROD_BRANCH}" # Previous version tag (excluding the tag we just pushed) PREV_TAG="$( diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c51fd52..2ea8224 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "serial-debug-assistant" -version = "1.3.3" +version = "1.4.0" description = "Cross-platform serial debugging assistant" authors = ["Gyanano"] license = "MIT" @@ -17,11 +17,8 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tokio = { version = "1.0", features = ["full"] } serialport = "4.4" -rusqlite = { version = "0.31", features = ["bundled"] } chrono = { version = "0.4", features = ["serde"] } -uuid = { version = "1.0", features = ["v4", "serde"] } anyhow = "1.0" -thiserror = "1.0" log = "0.4" env_logger = "0.11" encoding_rs = "0.8" diff --git a/src-tauri/src/bus.rs b/src-tauri/src/bus.rs new file mode 100644 index 0000000..fece286 --- /dev/null +++ b/src-tauri/src/bus.rs @@ -0,0 +1,345 @@ +//! Frame bus: session/seq allocation, bounded per-subscriber queues with +//! drop-oldest backpressure, and Nagle-style batching (RFC #3 Step 4). +//! +//! Contract pinned here: +//! - `seq` is strictly increasing from 1 within a session; TX and RX share +//! one sequence (transcript interleaving preserved). +//! - A slow subscriber loses the OLDEST frames and every batch reports +//! `dropped_before`, so gaps are always detectable (prefix gap + count). +//! - The publisher (read thread / send path) never blocks: queue push is a +//! µs-scale lock, never I/O. + +use crate::types::Direction; +use chrono::{DateTime, Utc}; +use std::collections::VecDeque; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Condvar, Mutex, OnceLock, Weak}; +use std::time::{Duration, Instant}; + +pub type SessionId = u64; +pub type Seq = u64; + +/// Process-level monotonic clock base (reserved for cross-port time alignment). +fn mono_base() -> &'static Instant { + static BASE: OnceLock = OnceLock::new(); + BASE.get_or_init(Instant::now) +} + +fn mono_now_ns() -> u64 { + mono_base().elapsed().as_nanos() as u64 +} + +#[derive(Clone, Debug)] +pub struct Frame { + pub session: SessionId, + pub seq: Seq, + pub dir: Direction, + /// Process-level monotonic timestamp, reserved for future cross-port + /// time alignment (RFC #3: must land now; adding it later would break + /// the transcript format). + #[allow(dead_code)] + pub t_mono_ns: u64, + pub t_wall: DateTime, + /// Arc-shared so fan-out to N subscribers is zero-copy. + pub data: Arc<[u8]>, +} + +#[derive(Debug)] +pub struct FrameBatch { + /// Session of the batch's LAST frame. A batch never mixes sessions in + /// practice (a session change requires a reconnect, which implies a + /// quiet port), but consumers should resync on session change anyway. + pub session: SessionId, + pub first_seq: Seq, + /// Frames dropped for THIS subscriber since the previous batch + /// (drop-oldest => the gap is always a prefix of the sequence). + pub dropped_before: u64, + pub frames: Vec, +} + +#[derive(Debug, Clone, Copy)] +pub struct BatchPolicy { + /// Max time the oldest pending frame may wait before flushing. + pub max_delay: Duration, + /// Minimum spacing between flushes; a lone frame for an idle consumer + /// pushes immediately only if the last flush is at least this old. + pub min_interval: Duration, + /// Flush when pending payload reaches this many bytes. + pub max_bytes: usize, + /// Flush when pending reaches this many frames. + pub max_frames: usize, + /// Bounded queue capacity per subscriber, in frames. + pub queue_frames: usize, +} + +/// GUI default: ~60 fps worst case, IPC batches bounded by the frame cap. +pub const GUI_DEFAULT: BatchPolicy = BatchPolicy { + max_delay: Duration::from_millis(16), + min_interval: Duration::from_millis(4), + max_bytes: 64 * 1024, + max_frames: 512, + queue_frames: 4096, +}; + +struct SubscriberState { + queue: VecDeque, + dropped: u64, +} + +struct SubscriberShared { + state: Mutex, + cond: Condvar, + /// Bounded queue capacity in frames, from the subscriber's policy. + queue_frames: usize, +} + +/// Receiving end of a subscription. The bridge contract is a pump thread +/// calling `recv_batch` in a loop (bounded blocking, never async). +pub struct Subscription { + shared: Arc, + policy: BatchPolicy, + last_flush: Mutex, +} + +impl Subscription { + /// Block (bounded) until a batch is ready per the Nagle rules: + /// 1. idle consumer + lone frame + min_interval elapsed => push immediately + /// 2. otherwise accumulate until max_delay / max_bytes / max_frames + /// Returns `None` on timeout with no frames (lets the pump react to + /// external state); the subscription lives for the app's lifetime. + pub fn recv_batch(&self) -> Option { + let policy = &self.policy; + let mut st = self.shared.state.lock().unwrap(); + + // Wait for the first frame, bounded by max_delay. + while st.queue.is_empty() { + let (g, timed_out) = self + .shared + .cond + .wait_timeout(st, policy.max_delay) + .unwrap(); + st = g; + if st.queue.is_empty() && timed_out.timed_out() { + return None; + } + } + + let now = Instant::now(); + let mut last_flush = self.last_flush.lock().unwrap(); + + // Rule 1: idle consumer, single frame, quiet period honored. + if st.queue.len() == 1 && now.duration_since(*last_flush) >= policy.min_interval { + let frame = st.queue.pop_front().unwrap(); + let dropped_before = std::mem::take(&mut st.dropped); + *last_flush = Instant::now(); + return Some(FrameBatch { + session: frame.session, + first_seq: frame.seq, + dropped_before, + frames: vec![frame], + }); + } + + // Rule 2: collect until a threshold or the max_delay deadline. + let mut frames: Vec = st.queue.drain(..).collect(); + let mut bytes: usize = frames.iter().map(|f| f.data.len()).sum(); + let deadline = Instant::now() + policy.max_delay; + loop { + if frames.len() >= policy.max_frames || bytes >= policy.max_bytes { + break; + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + break; + } + let (g, _) = self.shared.cond.wait_timeout(st, remaining).unwrap(); + st = g; + while let Some(f) = st.queue.pop_front() { + bytes += f.data.len(); + frames.push(f); + } + if Instant::now() >= deadline { + break; + } + } + + let dropped_before = std::mem::take(&mut st.dropped); + *last_flush = Instant::now(); + let first = frames.first().expect("non-empty after wait"); + Some(FrameBatch { + session: frames.last().unwrap().session, + first_seq: first.seq, + dropped_before, + frames: std::mem::take(&mut frames), + }) + } +} + +pub struct FrameBus { + session: AtomicU64, + seq: AtomicU64, + session_counter: AtomicU64, + subscribers: Mutex>>, +} + +impl FrameBus { + pub fn new() -> Self { + Self { + session: AtomicU64::new(0), + seq: AtomicU64::new(0), + session_counter: AtomicU64::new(0), + subscribers: Mutex::new(Vec::new()), + } + } + + /// Start a new session: id increments, seq resets so the next allocated + /// frame is seq 1. Called once per connect. + pub fn start_session(&self) -> SessionId { + let id = self.session_counter.fetch_add(1, Ordering::SeqCst) + 1; + self.seq.store(0, Ordering::SeqCst); + self.session.store(id, Ordering::SeqCst); + id + } + + pub fn current_session(&self) -> SessionId { + self.session.load(Ordering::SeqCst) + } + + /// Allocate a frame (assigns session/seq/timestamps, wraps data in Arc). + /// Separate from `publish` so the caller can also record the same + /// seq/session into its own structures (e.g. LogEntry) before fan-out. + pub fn alloc_frame(&self, dir: Direction, data: Vec) -> Frame { + Frame { + session: self.session.load(Ordering::SeqCst), + seq: self.seq.fetch_add(1, Ordering::SeqCst) + 1, + dir, + t_mono_ns: mono_now_ns(), + t_wall: Utc::now(), + data: Arc::from(data.into_boxed_slice()), + } + } + + /// Fan out to all live subscribers. Never blocks on I/O; a full queue + /// drops the OLDEST frame and counts it for `dropped_before`. + pub fn publish(&self, frame: &Frame) { + let mut subs = self.subscribers.lock().unwrap(); + subs.retain(|weak| { + if let Some(shared) = weak.upgrade() { + let mut st = shared.state.lock().unwrap(); + if st.queue.len() >= shared.queue_frames { + st.queue.pop_front(); + st.dropped += 1; + } + st.queue.push_back(frame.clone()); + drop(st); + shared.cond.notify_one(); + true + } else { + false // prune dead subscriptions + } + }); + } + + pub fn subscribe(&self, policy: BatchPolicy) -> Subscription { + let shared = Arc::new(SubscriberShared { + state: Mutex::new(SubscriberState { + queue: VecDeque::new(), + dropped: 0, + }), + cond: Condvar::new(), + queue_frames: policy.queue_frames, + }); + self.subscribers.lock().unwrap().push(Arc::downgrade(&shared)); + Subscription { + shared, + policy, + // Allow the very first frame to push immediately. + last_flush: Mutex::new(Instant::now() - policy.min_interval), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fast_policy() -> BatchPolicy { + BatchPolicy { + max_delay: Duration::from_millis(5), + min_interval: Duration::from_millis(2), + max_bytes: 1024, + max_frames: 3, + queue_frames: 4, + } + } + + #[test] + fn seq_is_strictly_monotonic_and_resets_per_session() { + let bus = FrameBus::new(); + let s1 = bus.start_session(); + let f1 = bus.alloc_frame(Direction::Received, b"a".to_vec()); + let f2 = bus.alloc_frame(Direction::Sent, b"b".to_vec()); + assert_eq!((f1.session, f1.seq), (s1, 1)); + assert_eq!((f2.session, f2.seq), (s1, 2)); // TX/RX share one sequence + + let s2 = bus.start_session(); + assert!(s2 > s1); + let f3 = bus.alloc_frame(Direction::Received, b"c".to_vec()); + assert_eq!((f3.session, f3.seq), (s2, 1)); + } + + #[test] + fn lone_frame_pushes_immediately_for_idle_consumer() { + let bus = FrameBus::new(); + bus.start_session(); + let sub = bus.subscribe(fast_policy()); + bus.publish(&bus.alloc_frame(Direction::Received, b"hi".to_vec())); + let start = Instant::now(); + let batch = sub.recv_batch().unwrap(); + assert!(start.elapsed() < Duration::from_millis(5)); + assert_eq!(batch.first_seq, 1); + assert_eq!(batch.frames.len(), 1); + assert_eq!(batch.dropped_before, 0); + } + + #[test] + fn queued_frames_collect_into_one_batch_without_waiting() { + let bus = FrameBus::new(); + bus.start_session(); + let sub = bus.subscribe(fast_policy()); + for i in 0..3u8 { + bus.publish(&bus.alloc_frame(Direction::Received, vec![i])); + } + // 3 frames queued > lone-frame case: drained as one batch, no delay. + let batch = sub.recv_batch().unwrap(); + assert_eq!(batch.frames.len(), 3); + assert_eq!(batch.first_seq, 1); + } + + #[test] + fn slow_subscriber_drops_oldest_and_counts_prefix_gap() { + let bus = FrameBus::new(); + bus.start_session(); + let sub = bus.subscribe(fast_policy()); // queue_frames = 4 + // Publish 6 without draining: frames 1-2 are dropped as oldest. + for i in 0..6u32 { + bus.publish(&bus.alloc_frame(Direction::Received, i.to_le_bytes().to_vec())); + } + let batch = sub.recv_batch().unwrap(); + assert_eq!(batch.dropped_before, 2); + assert_eq!(batch.first_seq, 3); + let seqs: Vec = batch.frames.iter().map(|f| f.seq).collect(); + assert_eq!(seqs, vec![3, 4, 5, 6]); + } + + #[test] + fn recv_batch_times_out_when_quiet() { + let bus = FrameBus::new(); + bus.start_session(); + let sub = bus.subscribe(fast_policy()); + let start = Instant::now(); + assert!(sub.recv_batch().is_none()); + let elapsed = start.elapsed(); + assert!(elapsed >= Duration::from_millis(5) && elapsed < Duration::from_millis(50)); + } +} diff --git a/src-tauri/src/framing.rs b/src-tauri/src/framing.rs new file mode 100644 index 0000000..3bf16c0 --- /dev/null +++ b/src-tauri/src/framing.rs @@ -0,0 +1,366 @@ +//! Pure frame segmentation logic, extracted from `SerialManager`'s reader +//! thread (RFC #3, Step 1). +//! +//! Behavior is intentionally identical to the legacy read loop — including +//! its quirks — and is pinned by golden tests both here (unit level, no +//! threads) and in `serial_manager::tests` (driving the real reader thread +//! through a scripted fake port). Step 2 will swap the legacy loop's four +//! duplicated emission blocks over to this component; until then the +//! segmenter is exercised only by tests. + +use crate::types::{FrameSegmentationConfig, FrameSegmentationMode}; +use std::time::{Duration, Instant}; + +/// Hard cap on frame size (RFC #3): a frame may never exceed this many +/// bytes. Continuous streams with no delimiter are cut into cap-sized +/// chunks during `feed`, so memory stays bounded and every frame carries a +/// size guarantee. 64 KiB matches the planned IPC batch budget. +pub const DEFAULT_MAX_FRAME_BYTES: usize = 64 * 1024; + +/// Bytes in, frames out. The caller drives it with explicit timestamps so +/// tests never need to sleep. +pub struct FrameSegmenter { + config: FrameSegmentationConfig, + max_frame_bytes: usize, + buffer: Vec, + last_data_time: Instant, +} + +impl FrameSegmenter { + pub fn new(config: FrameSegmentationConfig, now: Instant) -> Self { + Self::with_max_frame_bytes(config, now, DEFAULT_MAX_FRAME_BYTES) + } + + pub fn with_max_frame_bytes( + config: FrameSegmentationConfig, + now: Instant, + max_frame_bytes: usize, + ) -> Self { + Self { + config, + max_frame_bytes, + buffer: Vec::new(), + last_data_time: now, + } + } + + /// Mirrors the legacy loop, which re-reads the shared config every + /// iteration: swapping config mid-stream does NOT flush or clear + /// already-buffered bytes. + pub fn set_config(&mut self, config: FrameSegmentationConfig) { + self.config = config; + } + + /// Feed bytes just read from the port. Returns frames closed by + /// delimiter processing — delimiter bytes are included in the frame, + /// matching the legacy behavior. Delimiter processing only happens in + /// Combined mode; in Timeout mode everything waits for `flush_if_idle` + /// or the hard cap. + /// + /// Hard cap (new in Step 2, replaces legacy unbounded growth): a + /// delimiter only closes a frame if the match lies fully inside the + /// first `max_frame_bytes` of the buffer; beyond that the buffer is cut + /// into cap-sized chunks. A delimiter straddling the cap boundary can + /// therefore be split — same family as the pinned CRLF-across-reads + /// quirk, deterministic and bounded. Invariant on return: + /// `buffer.len() < max_frame_bytes`. + pub fn feed(&mut self, bytes: &[u8], now: Instant) -> Vec> { + self.buffer.extend_from_slice(bytes); + self.last_data_time = now; + + let delimiter = self.config.delimiter.to_bytes(); + let combined = self.config.mode == FrameSegmentationMode::Combined; + let mut frames = Vec::new(); + loop { + if combined { + let hit = { + let window = &self.buffer[..self.buffer.len().min(self.max_frame_bytes)]; + if self.config.delimiter.is_any_newline() { + find_any_newline(window) + } else { + find_delimiter(window, &delimiter).map(|pos| (pos, delimiter.len())) + } + }; + if let Some((pos, len)) = hit { + frames.push(self.buffer.drain(..pos + len).collect()); + continue; + } + } + if self.buffer.len() >= self.max_frame_bytes { + frames.push(self.buffer.drain(..self.max_frame_bytes).collect()); + continue; + } + break; + } + frames + } + + /// Timeout flush, meant for idle reads (Ok(0) / TimedOut). Mirrors the + /// legacy semantics: applies in Timeout and Combined modes, compares + /// with strict `>`, and takes the WHOLE buffer (a partial frame with no + /// delimiter still flushes whole in Combined mode). + pub fn flush_if_idle(&mut self, now: Instant) -> Option> { + let timeout = Duration::from_millis(self.config.timeout_ms); + let should_flush = matches!( + self.config.mode, + FrameSegmentationMode::Timeout | FrameSegmentationMode::Combined + ) && !self.buffer.is_empty() + && now.duration_since(self.last_data_time) > timeout; + + if should_flush { + Some(std::mem::take(&mut self.buffer)) + } else { + None + } + } + + /// Bytes currently pending (received but not yet framed). + #[cfg(test)] + pub fn pending(&self) -> &[u8] { + &self.buffer + } + + /// Drain any buffered bytes as a final frame regardless of idle time. + /// Used when the reader is shutting down or dying so the tail of the + /// stream is not silently dropped (RFC #3 Step 3). + pub fn flush(&mut self) -> Option> { + if self.buffer.is_empty() { + None + } else { + Some(std::mem::take(&mut self.buffer)) + } + } +} + +/// Find the position of a delimiter in the data buffer. +pub(crate) fn find_delimiter(data: &[u8], delimiter: &[u8]) -> Option { + if delimiter.is_empty() || data.len() < delimiter.len() { + return None; + } + + data.windows(delimiter.len()) + .position(|window| window == delimiter) +} + +/// Find any newline sequence (\r, \n, or \r\n) in the data buffer. +/// Returns (position, length) where length is 1 for \r or \n alone, and 2 +/// for \r\n. +/// +/// QUIRK (pinned, do not "fix" in isolation): a `\r` at the very end of the +/// buffer is returned immediately as a 1-byte match without waiting for a +/// possible `\n` in the next read. A CRLF pair split across two reads +/// therefore produces TWO frames ("...\r" then "\n"). +pub(crate) fn find_any_newline(data: &[u8]) -> Option<(usize, usize)> { + for i in 0..data.len() { + match data[i] { + 0x0D => { + // CR + if i + 1 < data.len() && data[i + 1] == 0x0A { + return Some((i, 2)); // CRLF + } + return Some((i, 1)); // CR alone + } + 0x0A => { + // LF alone (not preceded by CR, as CRLF would have been caught above) + return Some((i, 1)); + } + _ => continue, + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::FrameDelimiter; + + fn combined(delimiter: FrameDelimiter) -> FrameSegmentationConfig { + FrameSegmentationConfig { + mode: FrameSegmentationMode::Combined, + timeout_ms: 10, + delimiter, + } + } + + fn timeout_mode() -> FrameSegmentationConfig { + FrameSegmentationConfig { + mode: FrameSegmentationMode::Timeout, + timeout_ms: 10, + delimiter: FrameDelimiter::AnyNewline, + } + } + + #[test] + fn timeout_mode_flushes_after_idle() { + let t0 = Instant::now(); + let mut seg = FrameSegmenter::new(timeout_mode(), t0); + assert!(seg.feed(b"hello", t0).is_empty()); + + // Strict `>` comparison: exactly at the deadline is NOT a flush. + assert!(seg.flush_if_idle(t0 + Duration::from_millis(10)).is_none()); + assert_eq!( + seg.flush_if_idle(t0 + Duration::from_millis(11)), + Some(b"hello".to_vec()) + ); + assert!(seg.pending().is_empty()); + } + + #[test] + fn timeout_mode_ignores_delimiters() { + let t0 = Instant::now(); + let mut seg = FrameSegmenter::new(timeout_mode(), t0); + // Newlines arrive but Timeout mode never frames on arrival. + assert!(seg.feed(b"OK\r\nOK\r\n", t0).is_empty()); + assert_eq!( + seg.flush_if_idle(t0 + Duration::from_millis(11)), + Some(b"OK\r\nOK\r\n".to_vec()) + ); + } + + #[test] + fn timeout_mode_no_flush_without_data() { + let t0 = Instant::now(); + let mut seg = FrameSegmenter::new(timeout_mode(), t0); + assert!(seg.flush_if_idle(t0 + Duration::from_secs(60)).is_none()); + } + + #[test] + fn combined_any_newline_frames_on_arrival() { + let t0 = Instant::now(); + let mut seg = FrameSegmenter::new(combined(FrameDelimiter::AnyNewline), t0); + let frames = seg.feed(b"AT\r\nOK\r\n", t0); + assert_eq!(frames, vec![b"AT\r\n".to_vec(), b"OK\r\n".to_vec()]); + assert!(seg.pending().is_empty()); + } + + #[test] + fn combined_any_newline_crlf_split_across_reads_is_two_frames() { + // QUIRK pinned: "\r" ending a read matches immediately, so a CRLF + // pair split across read boundaries yields "OK\r" and "\n". + let t0 = Instant::now(); + let mut seg = FrameSegmenter::new(combined(FrameDelimiter::AnyNewline), t0); + assert_eq!(seg.feed(b"OK\r", t0), vec![b"OK\r".to_vec()]); + assert_eq!(seg.feed(b"\n", t0), vec![b"\n".to_vec()]); + } + + #[test] + fn combined_explicit_crlf_delimiter() { + let t0 = Instant::now(); + let mut seg = FrameSegmenter::new(combined(FrameDelimiter::CRLF), t0); + // A lone \n does not match the CRLF delimiter and stays in the stream. + let frames = seg.feed(b"a\r\nb\nc\r\n", t0); + assert_eq!(frames, vec![b"a\r\n".to_vec(), b"b\nc\r\n".to_vec()]); + assert!(seg.pending().is_empty()); + } + + #[test] + fn combined_custom_delimiter_residue_flushes_on_timeout() { + let t0 = Instant::now(); + let mut seg = FrameSegmenter::new(combined(FrameDelimiter::Custom(b"##".to_vec())), t0); + let frames = seg.feed(b"ab##cd##e", t0); + assert_eq!(frames, vec![b"ab##".to_vec(), b"cd##".to_vec()]); + assert_eq!(seg.pending(), b"e"); + // Combined mode: timeout flush takes the whole residue. + assert_eq!( + seg.flush_if_idle(t0 + Duration::from_millis(11)), + Some(b"e".to_vec()) + ); + } + + #[test] + fn combined_empty_custom_delimiter_never_frames() { + let t0 = Instant::now(); + let mut seg = FrameSegmenter::new(combined(FrameDelimiter::Custom(vec![])), t0); + assert!(seg.feed(b"abc", t0).is_empty()); + assert_eq!(seg.pending(), b"abc"); + assert_eq!( + seg.flush_if_idle(t0 + Duration::from_millis(11)), + Some(b"abc".to_vec()) + ); + } + + #[test] + fn config_change_preserves_buffer() { + let t0 = Instant::now(); + let mut seg = FrameSegmenter::new(timeout_mode(), t0); + assert!(seg.feed(b"ab", t0).is_empty()); + + seg.set_config(combined(FrameDelimiter::LF)); + // Bytes buffered under the old config join the new delimiter framing. + let frames = seg.feed(b"c\n", t0); + assert_eq!(frames, vec![b"abc\n".to_vec()]); + } + + #[test] + fn feed_resets_idle_clock() { + let t0 = Instant::now(); + let mut seg = FrameSegmenter::new(timeout_mode(), t0); + seg.feed(b"a", t0); + // Late data restarts the idle window. + seg.feed(b"b", t0 + Duration::from_millis(8)); + assert!(seg.flush_if_idle(t0 + Duration::from_millis(15)).is_none()); + assert_eq!( + seg.flush_if_idle(t0 + Duration::from_millis(19)), + Some(b"ab".to_vec()) + ); + } + + #[test] + fn hard_cap_cuts_continuous_stream_without_waiting_for_idle() { + let t0 = Instant::now(); + let mut seg = + FrameSegmenter::with_max_frame_bytes(timeout_mode(), t0, 1024); + // 2500 bytes with no idle gap: two full chunks cut immediately, + // residue waits for the timeout flush. + let frames = seg.feed(&vec![b'x'; 2500], t0); + assert_eq!(frames.len(), 2); + assert!(frames.iter().all(|f| f.len() == 1024)); + assert_eq!(seg.pending().len(), 452); + assert_eq!( + seg.flush_if_idle(t0 + Duration::from_millis(11)), + Some(vec![b'x'; 452]) + ); + } + + #[test] + fn delimiter_within_cap_window_wins_over_hard_cut() { + let t0 = Instant::now(); + let mut seg = FrameSegmenter::with_max_frame_bytes( + combined(FrameDelimiter::LF), + t0, + 8, + ); + // LF inside the first 8 bytes closes a short frame; rest pends. + let frames = seg.feed(b"ab\ncdefgh", t0); + assert_eq!(frames, vec![b"ab\n".to_vec()]); + assert_eq!(seg.pending(), b"cdefgh"); + } + + #[test] + fn delimiter_beyond_cap_window_gets_hard_cut() { + let t0 = Instant::now(); + let mut seg = FrameSegmenter::with_max_frame_bytes( + combined(FrameDelimiter::LF), + t0, + 8, + ); + // LF at position 8 is outside the 8-byte window: hard cut first, + // then the lone LF frames on its own (cap-boundary split, pinned). + let frames = seg.feed(b"abcdefgh\n", t0); + assert_eq!(frames, vec![b"abcdefgh".to_vec(), b"\n".to_vec()]); + assert!(seg.pending().is_empty()); + } + + #[test] + fn flush_drains_pending_regardless_of_idle() { + let t0 = Instant::now(); + let mut seg = FrameSegmenter::new(combined(FrameDelimiter::LF), t0); + assert!(seg.flush().is_none()); + let frames = seg.feed(b"abc", t0); + assert!(frames.is_empty()); + assert_eq!(seg.flush(), Some(b"abc".to_vec())); + assert!(seg.pending().is_empty()); + assert!(seg.flush().is_none()); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index e91b2c4..5125deb 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -6,6 +6,8 @@ use std::sync::Mutex; use tauri::State; mod serial_manager; +mod bus; +mod framing; mod types; mod updater; @@ -13,6 +15,79 @@ use serial_manager::SerialManager; use types::*; use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering}; +// ── RFC #3 Step 4: event push bridge ───────────────────────────────── +/// Wire DTO for `serial://frames`: the hot path carries no raw bytes +/// (seq/dir/len + decorated display text); raw bytes remain available via +/// the snapshot command and export. +#[derive(Clone, serde::Serialize)] +struct FrameDto { + session: u64, + seq: u64, + direction: Direction, + len: usize, + timestamp: chrono::DateTime, + display_text: String, + timestamp_formatted: Option, +} + +#[derive(Clone, serde::Serialize)] +struct FrameBatchDto { + session: u64, + first_seq: u64, + dropped_before: u64, + frames: Vec, +} + +/// Bridge contract: a plain pump thread with bounded blocking recv; the +/// subscription lives for the app's lifetime (sessions come and go). +fn spawn_frame_pump(app: &tauri::App) { + use tauri::{Emitter, Manager}; + let app_handle = app.handle().clone(); + let state = app.state::(); + let (bus, disp, tz) = { + let manager = state.serial_manager.lock().unwrap(); + ( + manager.bus(), + manager.display_settings_handle(), + manager.timezone_offset_handle(), + ) + }; + std::thread::spawn(move || { + let sub = bus.subscribe(bus::GUI_DEFAULT); + loop { + let Some(batch) = sub.recv_batch() else { + continue; // quiet timeout tick + }; + let settings = disp.lock().map(|g| g.clone()).unwrap_or_default(); + let tz_offset = *tz.lock().unwrap_or_else(|e| e.into_inner()); + let frames: Vec = batch + .frames + .iter() + .map(|f| FrameDto { + session: f.session, + seq: f.seq, + direction: f.dir, + len: f.data.len(), + timestamp: f.t_wall, + display_text: serial_manager::format_data_for_display(&f.data, &settings), + timestamp_formatted: if settings.show_timestamps { + Some(serial_manager::format_timestamp_with_offset(tz_offset)) + } else { + None + }, + }) + .collect(); + let dto = FrameBatchDto { + session: batch.session, + first_seq: batch.first_seq, + dropped_before: batch.dropped_before, + frames, + }; + let _ = app_handle.emit("serial://frames", dto); + } + }); +} + // Application state struct AppState { serial_manager: Mutex, @@ -50,9 +125,17 @@ async fn connect_to_port( #[tauri::command] async fn disconnect_port(state: State<'_, AppState>) -> Result<(), String> { - let mut manager = state.serial_manager.lock().unwrap(); - manager.disconnect() - .map_err(|e| e.to_string()) + // Fast state cleanup under the lock; the (bounded) reader join happens + // AFTER the lock is released so polling commands are never blocked + // behind a thread wait (RFC #3 Step 3). + let handle = { + let mut manager = state.serial_manager.lock().unwrap(); + manager.disconnect().map_err(|e| e.to_string())? + }; + if let Some(h) = handle { + SerialManager::join_reader_bounded(h); + } + Ok(()) } #[tauri::command] @@ -106,7 +189,7 @@ async fn send_data( #[tauri::command] async fn get_connection_status(state: State<'_, AppState>) -> Result { - let manager = state.serial_manager.lock().unwrap(); + let mut manager = state.serial_manager.lock().unwrap(); Ok(manager.get_status()) } @@ -116,11 +199,19 @@ async fn get_logs(state: State<'_, AppState>) -> Result, String> { Ok(manager.get_logs()) } +/// Initial alignment for the event-driven log view (RFC #3 Step 4). #[tauri::command] -async fn clear_logs(state: State<'_, AppState>) -> Result<(), String> { +async fn get_logs_snapshot(state: State<'_, AppState>) -> Result { + let manager = state.serial_manager.lock().unwrap(); + Ok(manager.get_logs_snapshot()) +} + +/// Returns the new log epoch; the frontend uses it to discard snapshots +/// that raced this clear. +#[tauri::command] +async fn clear_logs(state: State<'_, AppState>) -> Result { let mut manager = state.serial_manager.lock().unwrap(); - manager.clear_logs(); - Ok(()) + Ok(manager.clear_logs()) } #[tauri::command] @@ -351,6 +442,10 @@ fn main() { tauri::Builder::default() .manage(AppState::default()) .plugin(tauri_plugin_dialog::init()) + .setup(|app| { + spawn_frame_pump(app); + Ok(()) + }) .invoke_handler(tauri::generate_handler![ list_serial_ports, connect_to_port, @@ -358,6 +453,7 @@ fn main() { send_data, get_connection_status, get_logs, + get_logs_snapshot, clear_logs, export_logs, save_session, diff --git a/src-tauri/src/serial_manager.rs b/src-tauri/src/serial_manager.rs index 86293ec..2dbbd1e 100644 --- a/src-tauri/src/serial_manager.rs +++ b/src-tauri/src/serial_manager.rs @@ -1,3 +1,4 @@ +use crate::framing::FrameSegmenter; use crate::types::*; use anyhow::{anyhow, Result}; use chrono::Utc; @@ -31,6 +32,20 @@ pub struct SerialManager { timezone_offset_minutes: Arc>, // Display settings for pre-formatted log rendering display_settings: Arc>, + // Port opening seam (system opener in production, scripted fake in tests) + port_opener: Arc, + // Reader thread lifecycle (RFC #3 Step 3) + reader_handle: Option>, + // Fatal read error written by the reader thread right before it dies + reader_error: Arc>>, + // Surfaced via ConnectionStatus until the next connect + connection_error: Option, + // Frame bus for event push (RFC #3 Step 4); read/send paths publish, + // the bridge pump thread consumes and emits `serial://frames`. + bus: Arc, + // Bumped on every clear_logs; snapshots carry it to detect + // clear-during-snapshot resurrection. + log_epoch: u64, } #[derive(Debug, Default)] @@ -40,6 +55,53 @@ struct SerialStats { connection_time: Option>, } +/// Abstraction over how a serial port handle is opened (RFC #3 Step 1 seam). +/// Production uses `SystemPortOpener`; tests inject scripted fakes that drive +/// the real reader thread. +pub(crate) trait PortOpener: Send + Sync { + fn open(&self, port_name: &str, config: &SerialConfig) -> Result>; +} + +struct SystemPortOpener; + +impl PortOpener for SystemPortOpener { + fn open(&self, port_name: &str, config: &SerialConfig) -> Result> { + let builder = serialport::new(port_name, config.baud_rate) + .data_bits(match config.data_bits { + DataBits::Five => serialport::DataBits::Five, + DataBits::Six => serialport::DataBits::Six, + DataBits::Seven => serialport::DataBits::Seven, + DataBits::Eight => serialport::DataBits::Eight, + }) + .parity(match config.parity { + Parity::None => serialport::Parity::None, + Parity::Odd => serialport::Parity::Odd, + Parity::Even => serialport::Parity::Even, + Parity::Mark => serialport::Parity::None, + Parity::Space => serialport::Parity::None, + }) + .stop_bits(match config.stop_bits { + StopBits::One => serialport::StopBits::One, + StopBits::OnePointFive => serialport::StopBits::One, + StopBits::Two => serialport::StopBits::Two, + }) + .flow_control(match config.flow_control { + FlowControl::None => serialport::FlowControl::None, + FlowControl::Software => serialport::FlowControl::Software, + FlowControl::Hardware => serialport::FlowControl::Hardware, + }) + .timeout(Duration::from_millis(50)); // Short timeout for responsive reading + + Ok(builder.open()?) + } +} + +/// Write-side timeout (POSIX only, see `connect`). Much longer than the +/// 50 ms read timeout so large or bursty payloads tolerate a full kernel TX +/// buffer draining at line rate instead of failing spuriously. +#[cfg(unix)] +const WRITE_TIMEOUT_MS: u64 = 1000; + impl SerialManager { pub fn new() -> Self { // Default log directory - will be overridden by frontend settings @@ -66,9 +128,22 @@ impl SerialManager { log_directory: Arc::new(Mutex::new(default_log_dir)), timezone_offset_minutes: Arc::new(Mutex::new(0)), display_settings: Arc::new(Mutex::new(DisplaySettings::default())), + port_opener: Arc::new(SystemPortOpener), + reader_handle: None, + reader_error: Arc::new(Mutex::new(None)), + connection_error: None, + bus: Arc::new(crate::bus::FrameBus::new()), + log_epoch: 0, } } + /// Test-only constructor: inject a scripted port opener. + #[cfg(test)] + fn with_port_opener(mut self, opener: Arc) -> Self { + self.port_opener = opener; + self + } + pub fn list_available_ports() -> Result> { let ports = serialport::available_ports()?; let mut port_infos = Vec::new(); @@ -115,38 +190,28 @@ impl SerialManager { pub fn connect(&mut self, port_name: &str, config: SerialConfig) -> Result<()> { if self.is_connected { - self.disconnect()?; + let handle = self.disconnect()?; + if let Some(h) = handle { + Self::join_reader_bounded(h); + } } + // Defensive: a reader that outlived a previous bounded join must be + // dead before the OS will let us reopen the same device (EBUSY race). + if let Some(h) = self.reader_handle.take() { + Self::join_reader_bounded(h); + } + *self.reader_error.lock().unwrap() = None; + self.connection_error = None; - let builder = serialport::new(port_name, config.baud_rate) - .data_bits(match config.data_bits { - DataBits::Five => serialport::DataBits::Five, - DataBits::Six => serialport::DataBits::Six, - DataBits::Seven => serialport::DataBits::Seven, - DataBits::Eight => serialport::DataBits::Eight, - }) - .parity(match config.parity { - Parity::None => serialport::Parity::None, - Parity::Odd => serialport::Parity::Odd, - Parity::Even => serialport::Parity::Even, - Parity::Mark => serialport::Parity::None, - Parity::Space => serialport::Parity::None, - }) - .stop_bits(match config.stop_bits { - StopBits::One => serialport::StopBits::One, - StopBits::OnePointFive => serialport::StopBits::One, - StopBits::Two => serialport::StopBits::Two, - }) - .flow_control(match config.flow_control { - FlowControl::None => serialport::FlowControl::None, - FlowControl::Software => serialport::FlowControl::Software, - FlowControl::Hardware => serialport::FlowControl::Hardware, - }) - .timeout(Duration::from_millis(50)); // Short timeout for responsive reading - - let port = builder.open()?; + // `mut` is only exercised by the POSIX write-timeout tweak below; + // Windows shares timeouts across cloned handles and leaves it alone. + #[allow(unused_mut)] + let mut port = self.port_opener.open(port_name, &config)?; info!("Successfully opened serial port: {}", port_name); + // New session: seq restarts at 1 (RFC #3 Step 4). + self.bus.start_session(); + // Reset and start reading thread self.shutdown_flag.store(false, Ordering::Relaxed); let logs = Arc::clone(&self.logs); @@ -159,12 +224,83 @@ impl SerialManager { let display_settings = Arc::clone(&self.display_settings); let port_name_clone = port_name.to_string(); let shutdown_flag = Arc::clone(&self.shutdown_flag); + let reader_error = Arc::clone(&self.reader_error); + let bus = Arc::clone(&self.bus); let mut read_port = port.try_clone()?; - thread::spawn(move || { - let mut buffer = [0; 1024]; - let mut accumulated_data = Vec::new(); - let mut last_data_time = Instant::now(); + // Give the write side a longer timeout than the read-friendly 50 ms. + // On POSIX the timeout lives on each handle, so this does not slow + // the read loop; on Windows cloned handles share COMMTIMEOUTS, so we + // leave the write side at the builder's value there. + #[cfg(unix)] + if let Err(e) = port.set_timeout(Duration::from_millis(WRITE_TIMEOUT_MS)) { + warn!("Failed to set write timeout on {}: {}", port_name, e); + } + + let reader_handle = thread::spawn(move || { + let mut read_buffer = [0u8; 1024]; + let initial_config = frame_segmentation_config.lock() + .map(|guard| guard.clone()) + .unwrap_or_default(); + let mut segmenter = FrameSegmenter::new(initial_config, Instant::now()); + + // Single frame-emission path (replaces the four duplicated + // blocks): text recording -> display formatting -> log buffer + // -> stats -> bus publish (dual-write 存续期, RFC #3 Step 4). + let emit_frame = |frame_data: Vec, disp_settings: &DisplaySettings| { + // Write to text recording file with timestamp and RX label + if let Ok(mut guard) = text_file.lock() { + if let Some(ref mut file) = *guard { + let tz_offset = *timezone_offset.lock().unwrap_or_else(|e| e.into_inner()); + let timestamp = format_timestamp_with_offset(tz_offset); + let text = String::from_utf8_lossy(&frame_data); + let _ = writeln!(file, "[{}] RX: {}", timestamp, text); + } + } + + // Allocate the bus frame first so the LogEntry carries the + // same seq/session the event subscribers see. + let frame = bus.alloc_frame(Direction::Received, frame_data.clone()); + + // Format display text and timestamp based on current settings + let tz_offset = *timezone_offset.lock().unwrap_or_else(|e| e.into_inner()); + let display_text = format_data_for_display(&frame_data, disp_settings); + let timestamp_formatted = if disp_settings.show_timestamps { + Some(format_timestamp_with_offset(tz_offset)) + } else { + None + }; + + let data_len = frame_data.len() as u64; + let log_entry = LogEntry { + timestamp: Utc::now(), + direction: Direction::Received, + data: frame_data, + format: DataFormat::Text, + port_name: port_name_clone.clone(), + display_text, + timestamp_formatted, + seq: frame.seq, + session: frame.session, + }; + + if let Ok(mut logs_guard) = logs.lock() { + logs_guard.push_back(log_entry); + let max_entries = *max_log_entries.lock().unwrap_or_else(|e| e.into_inner()); + while logs_guard.len() > max_entries { + logs_guard.pop_front(); + } + } + + if let Ok(mut stats_guard) = stats.lock() { + stats_guard.bytes_received += data_len; + } + + // Event fan-out comes last: the buffer write must land first + // so a snapshot racing this batch never misses the entry + // (subscribers dedupe by seq anyway). + bus.publish(&frame); + }; loop { // Check shutdown flag @@ -173,261 +309,64 @@ impl SerialManager { break; } - // Get current segmentation config + // Get current segmentation config (legacy re-read each iteration) let seg_config = frame_segmentation_config.lock() .map(|guard| guard.clone()) .unwrap_or_default(); - let timeout_duration = Duration::from_millis(seg_config.timeout_ms); + segmenter.set_config(seg_config); // Get current display settings for formatting let disp_settings = display_settings.lock() .map(|guard| guard.clone()) .unwrap_or_default(); - match read_port.read(&mut buffer) { + match read_port.read(&mut read_buffer) { Ok(bytes_read) if bytes_read > 0 => { - let received_bytes = &buffer[..bytes_read]; - accumulated_data.extend_from_slice(received_bytes); - last_data_time = Instant::now(); + let received_bytes = &read_buffer[..bytes_read]; - // Write to raw recording file (raw bytes, no framing) + // Write to raw recording file (raw bytes, pre-framing tap) if let Ok(mut guard) = raw_file.lock() { if let Some(ref mut file) = *guard { let _ = file.write_all(received_bytes); } } - // Check for delimiter-based segmentation (only in Combined mode) - if seg_config.mode == FrameSegmentationMode::Combined { - - // Handle AnyNewline specially - it matches \r, \n, or \r\n as single delimiter - if seg_config.delimiter.is_any_newline() { - while let Some((pos, len)) = find_any_newline(&accumulated_data) { - let frame_end = pos + len; - let frame_data: Vec = accumulated_data.drain(..frame_end).collect(); - let data_len = frame_data.len(); - - // Write to text recording file with timestamp and RX label - if let Ok(mut guard) = text_file.lock() { - if let Some(ref mut file) = *guard { - let tz_offset = *timezone_offset.lock().unwrap_or_else(|e| e.into_inner()); - let timestamp = format_timestamp_with_offset(tz_offset); - let text = String::from_utf8_lossy(&frame_data); - let _ = writeln!(file, "[{}] RX: {}", timestamp, text); - } - } - - // Format display text and timestamp based on current settings - let tz_offset = *timezone_offset.lock().unwrap_or_else(|e| e.into_inner()); - let display_text = format_data_for_display(&frame_data, &disp_settings); - let timestamp_formatted = if disp_settings.show_timestamps { - Some(format_timestamp_with_offset(tz_offset)) - } else { - None - }; - - let log_entry = LogEntry { - id: None, - timestamp: Utc::now(), - direction: Direction::Received, - data: frame_data, - format: DataFormat::Text, - port_name: port_name_clone.clone(), - display_text, - timestamp_formatted, - }; - - if let Ok(mut logs_guard) = logs.lock() { - logs_guard.push_back(log_entry); - let max_entries = *max_log_entries.lock().unwrap_or_else(|e| e.into_inner()); - while logs_guard.len() > max_entries { - logs_guard.pop_front(); - } - } - - if let Ok(mut stats_guard) = stats.lock() { - stats_guard.bytes_received += data_len as u64; - } - } - } else { - // Standard delimiter matching - let delimiter_bytes = seg_config.delimiter.to_bytes(); - - // Process all complete frames in accumulated data - while let Some(pos) = find_delimiter(&accumulated_data, &delimiter_bytes) { - let frame_end = pos + delimiter_bytes.len(); - let frame_data: Vec = accumulated_data.drain(..frame_end).collect(); - let data_len = frame_data.len(); - - // Write to text recording file with timestamp and RX label - if let Ok(mut guard) = text_file.lock() { - if let Some(ref mut file) = *guard { - let tz_offset = *timezone_offset.lock().unwrap_or_else(|e| e.into_inner()); - let timestamp = format_timestamp_with_offset(tz_offset); - let text = String::from_utf8_lossy(&frame_data); - let _ = writeln!(file, "[{}] RX: {}", timestamp, text); - } - } - - // Format display text and timestamp based on current settings - let tz_offset = *timezone_offset.lock().unwrap_or_else(|e| e.into_inner()); - let display_text = format_data_for_display(&frame_data, &disp_settings); - let timestamp_formatted = if disp_settings.show_timestamps { - Some(format_timestamp_with_offset(tz_offset)) - } else { - None - }; - - let log_entry = LogEntry { - id: None, - timestamp: Utc::now(), - direction: Direction::Received, - data: frame_data, - format: DataFormat::Text, - port_name: port_name_clone.clone(), - display_text, - timestamp_formatted, - }; - - if let Ok(mut logs_guard) = logs.lock() { - logs_guard.push_back(log_entry); - let max_entries = *max_log_entries.lock().unwrap_or_else(|e| e.into_inner()); - while logs_guard.len() > max_entries { - logs_guard.pop_front(); - } - } - - if let Ok(mut stats_guard) = stats.lock() { - stats_guard.bytes_received += data_len as u64; - } - } - } + for frame in segmenter.feed(received_bytes, Instant::now()) { + emit_frame(frame, &disp_settings); } } Ok(_) => { - // Check if we should flush accumulated data based on timeout - let should_flush_timeout = - (seg_config.mode == FrameSegmentationMode::Timeout || - seg_config.mode == FrameSegmentationMode::Combined) && - !accumulated_data.is_empty() && - last_data_time.elapsed() > timeout_duration; - - if should_flush_timeout { - let data_len = accumulated_data.len(); - - // Write to text recording file with timestamp and RX label - if let Ok(mut guard) = text_file.lock() { - if let Some(ref mut file) = *guard { - let tz_offset = *timezone_offset.lock().unwrap_or_else(|e| e.into_inner()); - let timestamp = format_timestamp_with_offset(tz_offset); - let text = String::from_utf8_lossy(&accumulated_data); - let _ = writeln!(file, "[{}] RX: {}", timestamp, text); - } - } - - // Format display text and timestamp based on current settings - let tz_offset = *timezone_offset.lock().unwrap_or_else(|e| e.into_inner()); - let display_text = format_data_for_display(&accumulated_data, &disp_settings); - let timestamp_formatted = if disp_settings.show_timestamps { - Some(format_timestamp_with_offset(tz_offset)) - } else { - None - }; - - let log_entry = LogEntry { - id: None, - timestamp: Utc::now(), - direction: Direction::Received, - data: accumulated_data.clone(), - format: DataFormat::Text, - port_name: port_name_clone.clone(), - display_text, - timestamp_formatted, - }; - - if let Ok(mut logs_guard) = logs.lock() { - logs_guard.push_back(log_entry); - let max_entries = *max_log_entries.lock().unwrap_or_else(|e| e.into_inner()); - while logs_guard.len() > max_entries { - logs_guard.pop_front(); - } - } - - // Update received bytes statistics - if let Ok(mut stats_guard) = stats.lock() { - stats_guard.bytes_received += data_len as u64; - } - - accumulated_data.clear(); + if let Some(frame) = segmenter.flush_if_idle(Instant::now()) { + emit_frame(frame, &disp_settings); } thread::sleep(Duration::from_millis(1)); } Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => { - // Check if we should flush accumulated data on timeout - let should_flush_timeout = - (seg_config.mode == FrameSegmentationMode::Timeout || - seg_config.mode == FrameSegmentationMode::Combined) && - !accumulated_data.is_empty() && - last_data_time.elapsed() > timeout_duration; - - if should_flush_timeout { - let data_len = accumulated_data.len(); - - // Write to text recording file with timestamp and RX label - if let Ok(mut guard) = text_file.lock() { - if let Some(ref mut file) = *guard { - let tz_offset = *timezone_offset.lock().unwrap_or_else(|e| e.into_inner()); - let timestamp = format_timestamp_with_offset(tz_offset); - let text = String::from_utf8_lossy(&accumulated_data); - let _ = writeln!(file, "[{}] RX: {}", timestamp, text); - } - } - - // Format display text and timestamp based on current settings - let tz_offset = *timezone_offset.lock().unwrap_or_else(|e| e.into_inner()); - let display_text = format_data_for_display(&accumulated_data, &disp_settings); - let timestamp_formatted = if disp_settings.show_timestamps { - Some(format_timestamp_with_offset(tz_offset)) - } else { - None - }; - - let log_entry = LogEntry { - id: None, - timestamp: Utc::now(), - direction: Direction::Received, - data: accumulated_data.clone(), - format: DataFormat::Text, - port_name: port_name_clone.clone(), - display_text, - timestamp_formatted, - }; - - if let Ok(mut logs_guard) = logs.lock() { - logs_guard.push_back(log_entry); - let max_entries = *max_log_entries.lock().unwrap_or_else(|e| e.into_inner()); - while logs_guard.len() > max_entries { - logs_guard.pop_front(); - } - } - - // Update received bytes statistics - if let Ok(mut stats_guard) = stats.lock() { - stats_guard.bytes_received += data_len as u64; - } - - accumulated_data.clear(); + if let Some(frame) = segmenter.flush_if_idle(Instant::now()) { + emit_frame(frame, &disp_settings); } thread::sleep(Duration::from_millis(1)); } Err(e) => { error!("Error reading from serial port: {}", e); + *reader_error.lock().unwrap() = Some(format!("{}", e)); break; } } } + + // Salvage the pending partial frame on ANY exit (shutdown flag or + // fatal error) so the tail of the stream is not silently dropped + // (RFC #3 Step 3). + if let Some(frame) = segmenter.flush() { + let disp_settings = display_settings.lock() + .map(|guard| guard.clone()) + .unwrap_or_default(); + emit_frame(frame, &disp_settings); + } }); + self.reader_handle = Some(reader_handle); self.current_port = Some(port); self.config = Some(config); self.is_connected = true; @@ -444,7 +383,10 @@ impl SerialManager { Ok(()) } - pub fn disconnect(&mut self) -> Result<()> { + /// Disconnect. Returns the reader thread handle so the CALLER can join + /// it outside the big manager lock (RFC #3 Step 3) — see + /// `join_reader_bounded`. State cleanup here is fast and non-blocking. + pub fn disconnect(&mut self) -> Result>> { if self.is_connected { // Signal reading thread to stop self.shutdown_flag.store(true, Ordering::Relaxed); @@ -452,14 +394,16 @@ impl SerialManager { // Stop all recordings before disconnecting self.stop_all_recordings(); - // Close the port first to force the reading thread to exit + // Close the write handle; the reader's cloned fd closes when the + // thread exits. self.current_port = None; - // Wait longer for thread to properly clean up - thread::sleep(Duration::from_millis(200)); - self.is_connected = false; + // A manual disconnect supersedes any concurrent reader death: + // don't surface it as an unexpected loss afterwards. + *self.reader_error.lock().unwrap() = None; + if let Some(port_name) = &self.port_name { // Don't add disconnection log to reduce clutter info!("Disconnected from {}", port_name); @@ -475,7 +419,27 @@ impl SerialManager { info!("Serial port disconnected"); } - Ok(()) + Ok(self.reader_handle.take()) + } + + /// Join a reader thread with a bounded wait. The reader wakes at least + /// every ~50 ms (port read timeout), so 500 ms is generous; if it still + /// has not exited we drop the handle (detaching the thread) rather than + /// block — the thread dies on its own once the shutdown flag is set and + /// the port fd is closed. + pub fn join_reader_bounded(handle: thread::JoinHandle<()>) { + let deadline = Instant::now() + Duration::from_millis(500); + loop { + if handle.is_finished() { + let _ = handle.join(); + return; + } + if Instant::now() >= deadline { + warn!("Reader thread did not exit within 500ms; detaching"); + return; + } + thread::sleep(Duration::from_millis(5)); + } } pub fn send_data(&mut self, data: Vec) -> Result<()> { @@ -495,6 +459,10 @@ impl SerialManager { stats_guard.bytes_sent += data.len() as u64; } + // TX shares the session's single seq sequence (transcript + // interleaving preserved, RFC #3 Step 4). + let frame = self.bus.alloc_frame(Direction::Sent, data.clone()); + // Get current display settings for formatting let disp_settings = self.get_display_settings(); let tz_offset = *self.timezone_offset_minutes.lock().unwrap_or_else(|e| e.into_inner()); @@ -507,7 +475,6 @@ impl SerialManager { // Add to logs self.add_log(LogEntry { - id: None, timestamp: Utc::now(), direction: Direction::Sent, data, @@ -515,21 +482,45 @@ impl SerialManager { port_name: self.port_name.clone().unwrap_or_default(), display_text, timestamp_formatted, + seq: frame.seq, + session: frame.session, }); + // Buffer-first, then fan out (see emit_frame). + self.bus.publish(&frame); + Ok(()) } else { Err(anyhow!("No port available")) } } - pub fn get_status(&self) -> ConnectionStatus { + /// Lazy reader-death detection (RFC #3 Step 3): the reader thread records + /// a fatal error in `reader_error` before dying; the next status poll + /// (frontend: every second) turns that into a visible disconnect. + pub fn get_status(&mut self) -> ConnectionStatus { + if self.is_connected { + let death = self.reader_error.lock().unwrap().clone(); + if let Some(err) = death { + warn!("Reader thread died, marking connection lost: {}", err); + self.connection_error = Some(err); + self.is_connected = false; + // Free the write handle and harvest the (finished) reader so + // the device can be reopened immediately. + self.current_port = None; + self.stop_all_recordings(); + if let Some(h) = self.reader_handle.take() { + let _ = h.join(); + } + } + } + let (bytes_sent, bytes_received, connection_time) = if let Ok(stats_guard) = self.stats.lock() { (stats_guard.bytes_sent, stats_guard.bytes_received, stats_guard.connection_time) } else { (0, 0, None) }; - + ConnectionStatus { is_connected: self.is_connected, port_name: self.port_name.clone(), @@ -537,6 +528,7 @@ impl SerialManager { bytes_sent, bytes_received, connection_time, + connection_error: self.connection_error.clone(), } } @@ -548,10 +540,38 @@ impl SerialManager { } } - pub fn clear_logs(&mut self) { + pub fn clear_logs(&mut self) -> u64 { if let Ok(mut logs) = self.logs.lock() { logs.clear(); } + // Bump the epoch so any snapshot started before this clear is + // recognized as stale by the frontend (no resurrection). + self.log_epoch += 1; + self.log_epoch + } + + /// Initial-alignment snapshot for the event-driven log view. + pub fn get_logs_snapshot(&self) -> LogsSnapshot { + LogsSnapshot { + epoch: self.log_epoch, + session: self.bus.current_session(), + entries: self.get_logs(), + } + } + + /// Event bus handle for the bridge pump (RFC #3 Step 4). + pub fn bus(&self) -> Arc { + Arc::clone(&self.bus) + } + + /// Shared display settings, for the bridge pump's decoration pass. + pub fn display_settings_handle(&self) -> Arc> { + Arc::clone(&self.display_settings) + } + + /// Shared timezone offset, for the bridge pump's decoration pass. + pub fn timezone_offset_handle(&self) -> Arc> { + Arc::clone(&self.timezone_offset_minutes) } pub fn export_logs(&self, file_path: &str, format: ExportFormat, timezone_offset_minutes: i32) -> Result<()> { @@ -897,40 +917,8 @@ impl SerialManager { } } -/// Find the position of a delimiter in the data buffer -fn find_delimiter(data: &[u8], delimiter: &[u8]) -> Option { - if delimiter.is_empty() || data.len() < delimiter.len() { - return None; - } - - data.windows(delimiter.len()) - .position(|window| window == delimiter) -} - -/// Find any newline sequence (\r, \n, or \r\n) in the data buffer. -/// Returns (position, length) where length is 1 for \r or \n alone, and 2 for \r\n. -/// This correctly handles \r\n as a single line ending (not two separate ones). -fn find_any_newline(data: &[u8]) -> Option<(usize, usize)> { - for i in 0..data.len() { - match data[i] { - 0x0D => { // CR - // Check if followed by LF (CRLF sequence) - if i + 1 < data.len() && data[i + 1] == 0x0A { - return Some((i, 2)); // CRLF - } - return Some((i, 1)); // CR alone - } - 0x0A => { // LF alone (not preceded by CR, as CRLF would have been caught above) - return Some((i, 1)); - } - _ => continue, - } - } - None -} - /// Format current UTC time with timezone offset applied -fn format_timestamp_with_offset(offset_minutes: i32) -> String { +pub(crate) fn format_timestamp_with_offset(offset_minutes: i32) -> String { use chrono::FixedOffset; let offset_seconds = offset_minutes * 60; let tz_offset = FixedOffset::east_opt(offset_seconds).unwrap_or_else(|| FixedOffset::east_opt(0).unwrap()); @@ -1094,7 +1082,7 @@ fn sort_usb_ports_first(mut ports: Vec) -> Vec { } /// Format data based on display settings -fn format_data_for_display(data: &[u8], settings: &DisplaySettings) -> String { +pub(crate) fn format_data_for_display(data: &[u8], settings: &DisplaySettings) -> String { match settings.format { ReceiveDisplayFormat::Hex => format_bytes_as_hex(data), ReceiveDisplayFormat::Txt => format_bytes_as_text(data, &settings.encoding, &settings.special_char_config), @@ -1182,4 +1170,380 @@ mod tests { assert_eq!(names(&filtered), vec!["/dev/cu.usbserial-140", "/dev/cu.HUAWEIFreeBudsPro3"]); } + + // ===== Scripted-port golden harness (RFC #3, Step 1) ===== + // Drives the REAL reader thread through a fake port and asserts on the + // frames that land in the log buffer, pinning current framing behavior + // (quirks included) before Step 2 rewires the loop onto FrameSegmenter. + + use std::io::{self, Read, Write}; + + enum ScriptEvent { + /// Next read() returns these bytes. + Bytes(Vec), + /// Next read() fails with a non-timeout error (kills the reader thread). + Fail, + } + + /// Fake SerialPort driven by a script. An exhausted script idles forever + /// (returns TimedOut), mimicking a real quiet port. Writes are recorded. + #[derive(Clone)] + struct ScriptedPort { + name: String, + events: Arc>>, + written: Arc>>, + } + + impl ScriptedPort { + fn new(name: &str, script: Vec) -> Self { + Self { + name: name.to_string(), + events: Arc::new(Mutex::new(script.into_iter().collect())), + written: Arc::new(Mutex::new(Vec::new())), + } + } + } + + impl Read for ScriptedPort { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + let next = self.events.lock().unwrap().pop_front(); + match next { + Some(ScriptEvent::Bytes(bytes)) => { + // Scripts keep chunks smaller than the reader's 1024-byte buffer. + assert!( + bytes.len() <= buf.len(), + "script chunk {} bytes exceeds read buffer {}", + bytes.len(), + buf.len() + ); + buf[..bytes.len()].copy_from_slice(&bytes); + Ok(bytes.len()) + } + Some(ScriptEvent::Fail) => { + Err(io::Error::new(io::ErrorKind::Other, "scripted failure")) + } + None => Err(io::Error::new(io::ErrorKind::TimedOut, "script idle")), + } + } + } + + impl Write for ScriptedPort { + fn write(&mut self, data: &[u8]) -> io::Result { + self.written.lock().unwrap().extend_from_slice(data); + Ok(data.len()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + impl SerialPort for ScriptedPort { + fn name(&self) -> Option { + Some(self.name.clone()) + } + fn baud_rate(&self) -> serialport::Result { + Ok(115200) + } + fn data_bits(&self) -> serialport::Result { + Ok(serialport::DataBits::Eight) + } + fn flow_control(&self) -> serialport::Result { + Ok(serialport::FlowControl::None) + } + fn parity(&self) -> serialport::Result { + Ok(serialport::Parity::None) + } + fn stop_bits(&self) -> serialport::Result { + Ok(serialport::StopBits::One) + } + fn timeout(&self) -> Duration { + Duration::from_millis(50) + } + fn set_baud_rate(&mut self, _baud_rate: u32) -> serialport::Result<()> { + Ok(()) + } + fn set_data_bits(&mut self, _data_bits: serialport::DataBits) -> serialport::Result<()> { + Ok(()) + } + fn set_flow_control( + &mut self, + _flow_control: serialport::FlowControl, + ) -> serialport::Result<()> { + Ok(()) + } + fn set_parity(&mut self, _parity: serialport::Parity) -> serialport::Result<()> { + Ok(()) + } + fn set_stop_bits(&mut self, _stop_bits: serialport::StopBits) -> serialport::Result<()> { + Ok(()) + } + fn set_timeout(&mut self, _timeout: Duration) -> serialport::Result<()> { + Ok(()) + } + fn write_request_to_send(&mut self, _level: bool) -> serialport::Result<()> { + Ok(()) + } + fn write_data_terminal_ready(&mut self, _level: bool) -> serialport::Result<()> { + Ok(()) + } + fn read_clear_to_send(&mut self) -> serialport::Result { + Ok(false) + } + fn read_data_set_ready(&mut self) -> serialport::Result { + Ok(false) + } + fn read_ring_indicator(&mut self) -> serialport::Result { + Ok(false) + } + fn read_carrier_detect(&mut self) -> serialport::Result { + Ok(false) + } + fn bytes_to_read(&self) -> serialport::Result { + Ok(0) + } + fn bytes_to_write(&self) -> serialport::Result { + Ok(0) + } + fn clear(&self, _buffer_to_clear: serialport::ClearBuffer) -> serialport::Result<()> { + Ok(()) + } + fn try_clone(&self) -> serialport::Result> { + Ok(Box::new(self.clone())) + } + fn set_break(&self) -> serialport::Result<()> { + Ok(()) + } + fn clear_break(&self) -> serialport::Result<()> { + Ok(()) + } + } + + struct ScriptedOpener { + port: ScriptedPort, + } + + impl PortOpener for ScriptedOpener { + fn open(&self, _port_name: &str, _config: &SerialConfig) -> Result> { + Ok(Box::new(self.port.clone())) + } + } + + fn seg_timeout() -> FrameSegmentationConfig { + FrameSegmentationConfig { + mode: FrameSegmentationMode::Timeout, + timeout_ms: 10, + delimiter: FrameDelimiter::AnyNewline, + } + } + + fn seg_combined(delimiter: FrameDelimiter) -> FrameSegmentationConfig { + FrameSegmentationConfig { + mode: FrameSegmentationMode::Combined, + timeout_ms: 10, + delimiter, + } + } + + /// Connect a manager to a scripted port, run the script through the real + /// reader thread, and return the log buffer once it holds `expect_frames` + /// entries (plus a settle window to catch any unexpected extra frames). + fn run_script(script: Vec, seg: FrameSegmentationConfig, expect_frames: usize) -> Vec { + let port = ScriptedPort::new("SCRIPT", script); + let mut manager = SerialManager::new() + .with_port_opener(Arc::new(ScriptedOpener { port })); + manager.set_frame_segmentation_config(seg); + manager.connect("SCRIPT", SerialConfig::default()).unwrap(); + + let deadline = Instant::now() + Duration::from_secs(2); + let mut logs = manager.get_logs(); + while logs.len() < expect_frames && Instant::now() < deadline { + thread::sleep(Duration::from_millis(10)); + logs = manager.get_logs(); + } + // Settle window: any mis-framed extra frame shows up here. + thread::sleep(Duration::from_millis(150)); + let logs = manager.get_logs(); + manager.disconnect().unwrap(); + logs + } + + fn frame_bytes(logs: &[LogEntry]) -> Vec> { + logs.iter().map(|l| l.data.clone()).collect() + } + + #[test] + fn golden_timeout_mode_single_frame() { + let logs = run_script(vec![ScriptEvent::Bytes(b"hello".to_vec())], seg_timeout(), 1); + assert_eq!(frame_bytes(&logs), vec![b"hello".to_vec()]); + } + + #[test] + fn golden_timeout_mode_ignores_delimiters() { + // Newlines are not special in Timeout mode: one idle period, one frame. + let logs = run_script(vec![ScriptEvent::Bytes(b"OK\r\nOK\r\n".to_vec())], seg_timeout(), 1); + assert_eq!(frame_bytes(&logs), vec![b"OK\r\nOK\r\n".to_vec()]); + } + + #[test] + fn golden_combined_any_newline_frames_on_arrival() { + let logs = run_script( + vec![ScriptEvent::Bytes(b"AT\r\nOK\r\n".to_vec())], + seg_combined(FrameDelimiter::AnyNewline), + 2, + ); + assert_eq!(frame_bytes(&logs), vec![b"AT\r\n".to_vec(), b"OK\r\n".to_vec()]); + } + + #[test] + fn golden_combined_any_newline_crlf_split_across_reads_is_two_frames() { + // QUIRK pinned: "\r" ending a read matches immediately, so a CRLF pair + // split across read boundaries frames as "OK\r" and "\n" separately. + let logs = run_script( + vec![ + ScriptEvent::Bytes(b"OK\r".to_vec()), + ScriptEvent::Bytes(b"\n".to_vec()), + ], + seg_combined(FrameDelimiter::AnyNewline), + 2, + ); + assert_eq!(frame_bytes(&logs), vec![b"OK\r".to_vec(), b"\n".to_vec()]); + } + + #[test] + fn golden_combined_custom_delimiter_residue_flushes_on_timeout() { + let logs = run_script( + vec![ScriptEvent::Bytes(b"ab##cd##e".to_vec())], + seg_combined(FrameDelimiter::Custom(b"##".to_vec())), + 3, + ); + assert_eq!( + frame_bytes(&logs), + vec![b"ab##".to_vec(), b"cd##".to_vec(), b"e".to_vec()] + ); + } + + #[test] + fn golden_combined_empty_custom_delimiter_never_frames() { + let logs = run_script( + vec![ScriptEvent::Bytes(b"abc".to_vec())], + seg_combined(FrameDelimiter::Custom(vec![])), + 1, + ); + assert_eq!(frame_bytes(&logs), vec![b"abc".to_vec()]); + } + + #[test] + fn golden_send_writes_port_and_interleaves_transcript() { + let port = ScriptedPort::new("SCRIPT", vec![ScriptEvent::Bytes(b"OK\r\n".to_vec())]); + let written = Arc::clone(&port.written); + let mut manager = SerialManager::new() + .with_port_opener(Arc::new(ScriptedOpener { port })); + manager.set_frame_segmentation_config(seg_combined(FrameDelimiter::AnyNewline)); + manager.connect("SCRIPT", SerialConfig::default()).unwrap(); + + // Wait for the RX frame first so ordering is deterministic. + let deadline = Instant::now() + Duration::from_secs(2); + while manager.get_logs().len() < 1 && Instant::now() < deadline { + thread::sleep(Duration::from_millis(10)); + } + manager.send_data(b"AT\r\n".to_vec()).unwrap(); + thread::sleep(Duration::from_millis(50)); + + let logs = manager.get_logs(); + manager.disconnect().unwrap(); + + assert_eq!(*written.lock().unwrap(), b"AT\r\n".to_vec()); + assert_eq!( + logs.iter().map(|l| l.direction).collect::>(), + vec![Direction::Received, Direction::Sent] + ); + } + + #[test] + fn reader_death_marks_connection_lost_and_salvages_pending() { + // RFC #3 Step 3: a fatal read error must surface — the next status + // poll reports disconnected with the error, and the pending partial + // frame is flushed instead of vanishing. + let port = ScriptedPort::new( + "SCRIPT", + vec![ScriptEvent::Bytes(b"abc".to_vec()), ScriptEvent::Fail], + ); + let mut manager = SerialManager::new() + .with_port_opener(Arc::new(ScriptedOpener { port })); + manager.set_frame_segmentation_config(seg_timeout()); + manager.connect("SCRIPT", SerialConfig::default()).unwrap(); + + // Give the thread time to hit Fail and break. + thread::sleep(Duration::from_millis(200)); + + let status = manager.get_status(); + assert!(!status.is_connected); + assert_eq!(status.connection_error.as_deref(), Some("scripted failure")); + // Pending "abc" salvaged as a final frame on death. + assert_eq!(frame_bytes(&manager.get_logs()), vec![b"abc".to_vec()]); + + // A manual disconnect afterwards is a clean no-op. + assert!(manager.disconnect().unwrap().is_none()); + assert!(manager.get_status().connection_error.is_some()); // sticky until next connect + } + + #[test] + fn bus_events_carry_contiguous_seq_across_rx_and_tx() { + // RFC #3 Step 4 acceptance: frames reach subscribers with contiguous + // seqs, TX and RX sharing one sequence, no loss, no duplication. + let port = ScriptedPort::new("SCRIPT", vec![ScriptEvent::Bytes(b"hello".to_vec())]); + let mut manager = SerialManager::new() + .with_port_opener(Arc::new(ScriptedOpener { port })); + manager.set_frame_segmentation_config(seg_timeout()); + let sub = manager.bus().subscribe(crate::bus::GUI_DEFAULT); + manager.connect("SCRIPT", SerialConfig::default()).unwrap(); + + thread::sleep(Duration::from_millis(150)); // RX arrives + idle flush + manager.send_data(b"ping".to_vec()).unwrap(); + + let mut seen: Vec<(u64, Direction)> = Vec::new(); + let deadline = Instant::now() + Duration::from_secs(2); + while Instant::now() < deadline && seen.len() < 2 { + if let Some(batch) = sub.recv_batch() { + seen.extend(batch.frames.iter().map(|f| (f.seq, f.dir))); + } + } + manager.disconnect().unwrap(); + + assert_eq!( + seen, + vec![(1, Direction::Received), (2, Direction::Sent)] + ); + } + + #[test] + fn disconnect_returns_joinable_handle_and_reconnect_is_immediate() { // RFC #3 Step 3: disconnect hands the reader handle to the caller; + // a bounded join then guarantees the device is free for reopen. + let port = ScriptedPort::new("SCRIPT", vec![]); + let mut manager = SerialManager::new() + .with_port_opener(Arc::new(ScriptedOpener { port })); + manager.connect("SCRIPT", SerialConfig::default()).unwrap(); + + let handle = manager.disconnect().unwrap().expect("reader handle"); + SerialManager::join_reader_bounded(handle); + + // Immediate reconnect must succeed (no stale reader, no EBUSY). + manager.connect("SCRIPT", SerialConfig::default()).unwrap(); + assert!(manager.get_status().is_connected); + assert!(manager.get_status().connection_error.is_none()); + manager.disconnect().unwrap(); + } + + #[test] + fn golden_continuous_stream_hard_capped_frames() { + // 150 KiB of back-to-back data with no idle gap: the hard cap must + // cut 64 KiB frames without waiting for a timeout, and the residue + // flushes once the stream goes quiet. + let script: Vec = (0..150) + .map(|_| ScriptEvent::Bytes(vec![b'x'; 1024])) + .collect(); + let logs = run_script(script, seg_timeout(), 3); + let sizes: Vec = logs.iter().map(|l| l.data.len()).collect(); + assert_eq!(sizes, vec![65536, 65536, 22528]); + } } \ No newline at end of file diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 49f6aaa..ba20b88 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -84,7 +84,6 @@ pub enum TextEncoding { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LogEntry { - pub id: Option, pub timestamp: DateTime, pub direction: Direction, pub data: Vec, @@ -94,9 +93,26 @@ pub struct LogEntry { pub display_text: String, /// Pre-formatted timestamp string (None if timestamps were disabled when entry was created) pub timestamp_formatted: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] + /// Session-scoped sequence number, strictly increasing from 1 per + /// session, TX and RX sharing one sequence (RFC #3 Step 4). + #[serde(default)] + pub seq: u64, + /// Session this entry belongs to (0 = pre-event-model legacy entries). + #[serde(default)] + pub session: u64, +} + +/// Initial-alignment snapshot for the event-driven log view (RFC #3 Step 4). +/// `epoch` guards against clear-during-snapshot resurrection: a snapshot +/// taken before a `clear_logs` carries a stale epoch and must be discarded. +#[derive(Debug, Clone, Serialize)] +pub struct LogsSnapshot { + pub epoch: u64, + pub session: u64, + pub entries: Vec, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] pub enum Direction { Sent, Received, @@ -110,6 +126,10 @@ pub struct ConnectionStatus { pub bytes_sent: u64, pub bytes_received: u64, pub connection_time: Option>, + /// Fatal read error that ended the connection unexpectedly (RFC #3 + /// Step 3). `None` for normal connects/disconnects; cleared on connect. + #[serde(default)] + pub connection_error: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -203,7 +223,7 @@ pub struct SpecialCharConfig { impl Default for SpecialCharConfig { fn default() -> Self { Self { - enabled: true, + enabled: false, convert_lf: true, convert_cr: true, convert_tab: true, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9d33a3a..8f9de1d 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2.0.0", "productName": "RSerial Debug Assistant", - "version": "1.3.3", + "version": "1.4.0", "identifier": "Gyanano", "build": { "frontendDist": "../frontend/dist", diff --git a/version.json b/version.json index ba242fc..c9fae75 100644 --- a/version.json +++ b/version.json @@ -1,4 +1,4 @@ { - "version": "1.3.3", - "build": "20260819" + "version": "1.4.0", + "build": "20260902" }