首页
关于
文章打赏记录
Search
1
项目一:Revit电缆自动布置
54 阅读
2
如何使用 LoRA 微调 DeepSeek R1
13 阅读
3
revit二次开发入门
10 阅读
4
Dynamo 连缀属性
5 阅读
5
第十四章结课考试
4 阅读
linux
AI
Dynamo
Python
C#编程技术基础
C#实战项目
随笔
登录
Search
标签搜索
CSharp
基础知识
Dynamo
数据应用
AI
BIM管理
TomHanck4
累计撰写
41
篇文章
累计收到
6
条评论
首页
栏目
linux
AI
Dynamo
Python
C#编程技术基础
C#实战项目
随笔
页面
关于
文章打赏记录
搜索到
41
篇与
的结果
2026-05-28
自托管 Gitea CI + Inno Setup 安装程序 + 文件级增量更新 -- 复用指南
自托管 Gitea CI + Inno Setup 安装程序 + 文件级增量更新 -- 复用指南1. 架构概览flowchart TD CI[CI: dotnet publish 多文件] --> Manifest[生成 manifest.json + minisign 签名] CI --> Upload[上传所有文件为 release assets] Manifest --> Upload Upload --> Release[Gitea Private Release vX.Y.Z] Local[本地 Windows + ISCC] -.-> Release Release -- "setup.exe(新装用户首次下载)" --> Wizard[Win 安装向导] Wizard --> Installed[安装目录: 多文件布局] Release -- "manifest.json + 单文件 asset (Authorization: token)" --> App[已安装的应用] App --> Updater[updater.exe 替换 sha256 变化的文件]三层职责层职责频率setup.exe首次安装:选目录、铺文件、注册卸载、建快捷方式一次性,极少重出多文件运行安装目录中启动 {{AppName}}.exe,结构同 dotnet publish每次启动增量更新updater 比对 sha256 只下载变化文件每次发版自动核心原则setup.exe 不进 CI,本地 Windows 手工出包setup.exe 不需要每次发版都重出,跨 tag 复用老用户全部走 updater 文件级增量,与 setup.exe 无关2. 占位符约定在新项目中替换以下占位符:占位符含义示例{{AppName}}程序名 / 安装目录名FeishuWorkflowTool{{AppExeName}}主程序 exe 名FeishuWorkflowTool.exe{{AppPublisher}}发布者函证自动化{{AppGuid}}Inno Setup AppId(生成一次永不改){{A3F2C1B0-7E4D-4F9A-8B3C-6D2E1F0A5C9B}}{{GiteaBaseURL}}Gitea 服务器地址http://git.fangsiyuan.top{{Owner}}Gitea 用户名TomHanck4{{Repo}}仓库名feishuworkflowtool{{MainProject}}主程序 csproj 路径FeishuWorkflowTool.csproj{{UpdaterProject}}更新器 csproj 路径Updater/Updater.csproj{{PackProject}}manifest 生成工具 csproj 路径Tools/Pack/Pack.csproj3. Gitea 仓库设置3.1 创建私有仓库Gitea 网页 → 新建仓库 → 设为 Private。3.2 两个 Token 的职责分离Token 名称权限用途存放位置RELEASE_TOKENwrite:repositoryCI 创建 release、上传 assets;本地上传 setup.exeGitea CI Secrets + 本地环境变量UPDATER_RELEASE_TOKENread:repository嵌入客户端二进制,运行时拉 release 检查更新Gitea CI Secrets(构建时注入)两个 token 严格分离。客户端只嵌入只读 token,写权限 token 永远不进二进制。3.3 CI Secrets 配置Gitea 仓库 → Settings → Actions → Secrets,添加:Secret 名值RELEASE_TOKEN写权限 tokenUPDATER_RELEASE_TOKEN只读 tokenMINISIGN_PRIVATE_KEYminisign 签名私钥(可选,用于 manifest 签名)3.4 CI Variables 配置Gitea 仓库 → Settings → Actions → Variables,添加:Variable 名值BASE_URL{{GiteaBaseURL}}REPO_OWNER{{Owner}}REPO_NAME{{Repo}}4. Git 本地配置与推送流程4.1 Remote 配置git remote add origin http://{{Owner}}@{{GiteaBaseURL}}/{{Owner}}/{{Repo}}.git4.2 发版流程# 1. 升版本号(修改 csproj 中的 Version / FileVersion / AssemblyVersion) .\Tools\Bump.ps1 patch # bug 修复:3.2.1 → 3.2.2 .\Tools\Bump.ps1 minor # 新功能:3.2.1 → 3.3.0 .\Tools\Bump.ps1 major # 破坏性变更:3.2.1 → 4.0.0 .\Tools\Bump.ps1 3.5.0 # 直接指定版本号 # 2. 发版(自动 commit + tag + push,触发 CI) .\Tools\Release.ps1 "• 新增xxx`n• 修复yyy"Release.ps1 会:检测未提交变更 → 自动 commit → 创建 annotated tag → push 触发 CI。CI 流水线会自动复用或重建同 tag 的 release,实现幂等。重跑 CI(幂等 tag 策略):git tag -f v1.0.0 HEAD git push origin v1.0.0 --force5. CI 流水线模板文件位置:.gitea/workflows/release.ymlname: Release on: push: tags: - 'v[0-9]*.[0-9]*.[0-9]*' jobs: build-and-release: runs-on: linux container: image: mcr.microsoft.com/dotnet/sdk:8.0 steps: - name: Install tools run: | apt-get update -qq DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ git curl ca-certificates python3 minisign dotnet --info | head -n 20 - name: Checkout run: | TAG="${GITEA_REF_NAME:-${GITHUB_REF_NAME}}" git clone --depth=1 --branch "$TAG" \ "http://{{Owner}}:${{ secrets.RELEASE_TOKEN }}@{{GiteaBaseURL 去协议}}/{{Owner}}/{{Repo}}.git" \ repo echo "REPO_DIR=$(pwd)/repo" >> "$GITHUB_ENV" echo "TAG=$TAG" >> "$GITHUB_ENV" - name: Extract version from tag id: version run: | TAG="${TAG}" VERSION="${TAG#v}" echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Publish main app (win-x64) working-directory: ${{ env.REPO_DIR }} run: | dotnet publish {{MainProject}} \ -c Release \ -r win-x64 \ --self-contained true \ -p:EnableWindowsTargeting=true \ -p:GiteaReleaseToken=${{ secrets.UPDATER_RELEASE_TOKEN }} \ -o ./stage env: DOTNET_CLI_TELEMETRY_OPTOUT: 1 - name: Publish Updater (win-x64) working-directory: ${{ env.REPO_DIR }} run: | dotnet publish {{UpdaterProject}} \ -c Release \ -r win-x64 \ --self-contained true \ -p:EnableWindowsTargeting=true \ -o ./stage env: DOTNET_CLI_TELEMETRY_OPTOUT: 1 - name: Generate manifest.json working-directory: ${{ env.REPO_DIR }} run: | dotnet run --project {{PackProject}} -- \ --stage ./stage \ --out ./stage/manifest.json \ --app-version "${{ steps.version.outputs.version }}" \ --notes "${{ github.event.head_commit.message }}" - name: Sign manifest with minisign working-directory: ${{ env.REPO_DIR }} run: | cat > /tmp/minisign.sec <<'MINISIGN_EOF' ${{ secrets.MINISIGN_PRIVATE_KEY }} MINISIGN_EOF chmod 600 /tmp/minisign.sec # -W = prehashed: 先 BLAKE2b-512(manifest) 再 Ed25519 签名 # 产物签名 blob 前 2 字节为 'ED' (0x45 0x44) # 客户端 MinisignVerifier 必须按 prehashed 模式验签(见 §6.5),否则永远 false minisign -S -W -m ./stage/manifest.json -s /tmp/minisign.sec rm -f /tmp/minisign.sec # ---- 创建 release(幂等:先删同 tag 旧 release) ---- - name: Create Gitea Release id: create_release working-directory: ${{ env.REPO_DIR }} run: | VERSION="${{ steps.version.outputs.version }}" TAG="${{ steps.version.outputs.tag }}" NOTES=$(git log -1 --format=%B "$TAG") PAYLOAD_FILE=/tmp/release-payload.json RESPONSE_FILE=/tmp/create-release-response.json EXISTING_RELEASE_FILE=/tmp/existing-release.json EXISTING_STATUS=$(curl -sS -o "$EXISTING_RELEASE_FILE" -w "%{http_code}" \ -H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \ "${{ vars.BASE_URL }}/api/v1/repos/${{ vars.REPO_OWNER }}/${{ vars.REPO_NAME }}/releases/tags/${TAG}") case "$EXISTING_STATUS" in 200) EXISTING_ID=$(EXISTING_RELEASE_FILE="$EXISTING_RELEASE_FILE" python3 - <<'PYEOF' import json, os, sys text = open(os.environ["EXISTING_RELEASE_FILE"], encoding="utf-8").read() try: data = json.loads(text) except json.JSONDecodeError: sys.exit(0) print(data.get("id", "")) PYEOF ) if [ -n "$EXISTING_ID" ]; then curl -sS -o /dev/null -w "%{http_code}" -X DELETE \ -H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \ "${{ vars.BASE_URL }}/api/v1/repos/${{ vars.REPO_OWNER }}/${{ vars.REPO_NAME }}/releases/${EXISTING_ID}" fi ;; 404) echo "No existing release for tag $TAG, proceeding." ;; *) echo "Unexpected status $EXISTING_STATUS" >&2; exit 1 ;; esac VERSION="$VERSION" TAG="$TAG" NOTES="$NOTES" PAYLOAD_FILE="$PAYLOAD_FILE" python3 - <<'EOF' import json, os payload = { "tag_name": os.environ["TAG"], "name": f"v{os.environ['VERSION']}", "body": os.environ.get("NOTES", ""), "draft": False, "prerelease": False, } with open(os.environ["PAYLOAD_FILE"], "w", encoding="utf-8") as f: json.dump(payload, f, ensure_ascii=False) EOF HTTP_STATUS=$(curl -sS -o "$RESPONSE_FILE" -w "%{http_code}" -X POST \ -H "Content-Type: application/json" \ -H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \ "${{ vars.BASE_URL }}/api/v1/repos/${{ vars.REPO_OWNER }}/${{ vars.REPO_NAME }}/releases" \ --data-binary "@$PAYLOAD_FILE") RELEASE_ID=$(RESPONSE_FILE="$RESPONSE_FILE" python3 - <<'EOF' import json, os, sys text = open(os.environ["RESPONSE_FILE"], encoding="utf-8").read() data = json.loads(text) print(data["id"]) EOF ) echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT" # ---- 上传 assets ---- - name: Upload manifest.json run: | curl -s -X POST \ -H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \ -F "attachment=@./stage/manifest.json" \ "${{ vars.BASE_URL }}/api/v1/repos/${{ vars.REPO_OWNER }}/${{ vars.REPO_NAME }}/releases/${{ steps.create_release.outputs.release_id }}/assets?name=manifest.json" working-directory: ${{ env.REPO_DIR }} - name: Upload manifest.json.minisig run: | curl -s -X POST \ -H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \ -F "attachment=@./stage/manifest.json.minisig" \ "${{ vars.BASE_URL }}/api/v1/repos/${{ vars.REPO_OWNER }}/${{ vars.REPO_NAME }}/releases/${{ steps.create_release.outputs.release_id }}/assets?name=manifest.json.minisig" working-directory: ${{ env.REPO_DIR }} - name: Upload release assets working-directory: ${{ env.REPO_DIR }} run: | python3 - <<'EOF' import json, os, subprocess, sys stage = os.environ.get("STAGE_DIR") base_url = os.environ.get("GITEA_BASE_URL") owner = os.environ.get("GITEA_REPO_OWNER") repo = os.environ.get("GITEA_REPO_NAME") token = os.environ.get("GITEA_TOKEN") rel_id = os.environ.get("RELEASE_ID") with open(os.path.join(stage, "manifest.json")) as f: manifest = json.load(f) for entry in manifest.get("files", []): rel_path = entry["path"] asset_name = entry.get("assetName") or os.path.basename(rel_path) file_path = os.path.join(stage, rel_path.replace("/", os.sep)) if not os.path.exists(file_path): print(f"WARN: missing {file_path}", file=sys.stderr); continue upload_url = f"{base_url}/api/v1/repos/{owner}/{repo}/releases/{rel_id}/assets?name={asset_name}" result = subprocess.run([ "curl", "-s", "-X", "POST", "-H", f"Authorization: token {token}", "-F", f"attachment=@{file_path}", upload_url ], capture_output=True, text=True) if result.returncode != 0: print(f"ERROR uploading {asset_name}: {result.stderr}", file=sys.stderr) sys.exit(1) print(f"Uploaded: {asset_name}") EOF env: STAGE_DIR: ./stage GITEA_BASE_URL: ${{ vars.BASE_URL }} GITEA_REPO_OWNER: ${{ vars.REPO_OWNER }} GITEA_REPO_NAME: ${{ vars.REPO_NAME }} GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }} RELEASE_ID: ${{ steps.create_release.outputs.release_id }} - name: Print release URL run: | echo "Release: ${{ vars.BASE_URL }}/${{ vars.REPO_OWNER }}/${{ vars.REPO_NAME }}/releases/tag/${{ steps.version.outputs.tag }}" echo "If setup.exe needs update: Tools/Installer/build-setup.ps1 -Version ${{ steps.version.outputs.version }}"6. 客户端 Token 注入机制6.1 AppConstants 静态属性// Models/AppConstants.cs public static string GiteaReleaseToken { get; set; } = ""; public static string GiteaBaseUrl { get; set; } = "{{GiteaBaseURL}}"; public static string GiteaRepoOwner { get; set; } = "{{Owner}}"; public static string GiteaRepoName { get; set; } = "{{Repo}}";6.2 csproj 构建期注入<!-- {{MainProject}} 末尾 --> <ItemGroup Condition="'$(GiteaReleaseToken)' != ''"> <AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute"> <_Parameter1>GiteaReleaseToken</_Parameter1> <_Parameter2>$(GiteaReleaseToken)</_Parameter2> </AssemblyAttribute> </ItemGroup>CI 通过 -p:GiteaReleaseToken=${{ secrets.UPDATER_RELEASE_TOKEN }} 传入。6.3 App.xaml.cs 启动时反射读取protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); var releaseToken = Assembly.GetExecutingAssembly() .GetCustomAttributes<AssemblyMetadataAttribute>() .FirstOrDefault(a => a.Key == "GiteaReleaseToken")?.Value; if (!string.IsNullOrEmpty(releaseToken)) AppConstants.GiteaReleaseToken = releaseToken; // ... 后续初始化 }6.4 HttpClient 统一注入 Authorization// Services/Update/GiteaReleaseChannel.cs 构造函数 public GiteaReleaseChannel() { _httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; var token = AppConstants.GiteaReleaseToken; if (!string.IsNullOrEmpty(token)) _httpClient.DefaultRequestHeaders.Add("Authorization", $"token {token}"); }DefaultRequestHeaders 对该 _httpClient 的所有请求生效,覆盖 releases 列表 / manifest 下载 / asset 下载三处。6.5 minisign 验签必须支持 prehashed(关键陷阱)CI 用 minisign -S -W 产出的签名是 prehashed 模式:先 BLAKE2b-512(manifest),再 Ed25519 签 64 字节哈希。签名 blob 前 2 字节为 ASCII ED(0x45 0x44)。普通 Ed25519 验证库直接喂原始 manifest 字节会永远验失败,从而 CheckForUpdateAsync 静默返回"已是最新版本",整个增量更新通道完全不可用。症状:从 v0.0.1 到任意后续版本,"检查更新"始终提示"已是最新";CI/Release/manifest 全部产物正常,仅客户端验签失败。这条路径如果只看代码不抓签名头字节,几乎无法定位。判别:# 取签名 blob 前 2 字节 sed -n '2p' manifest.json.minisig | base64 -d | head -c 2 | xxd # 输出 4544 = 'ED' = prehashed # 输出 4564 = 'Ed' = pure正确实现(BouncyCastle):using Org.BouncyCastle.Crypto.Digests; using Org.BouncyCastle.Crypto.Parameters; using Org.BouncyCastle.Security; public static bool Verify(byte[] data, string signatureFileContent) { var sigBlob = Convert.FromBase64String(signatureFileContent.Split('\n')[1].Trim()); var pkBlob = Convert.FromBase64String(AppConstants.MinisignPublicKeyBase64); var algo = (char)sigBlob[0] + "" + (char)sigBlob[1]; var prehashed = algo == "ED"; if (!prehashed && algo != "Ed") return false; var edSignature = new byte[64]; Array.Copy(sigBlob, 10, edSignature, 0, 64); var pubKeyBytes = new byte[32]; Array.Copy(pkBlob, 10, pubKeyBytes, 0, 32); byte[] message; if (prehashed) { var blake = new Blake2bDigest(512); blake.BlockUpdate(data, 0, data.Length); message = new byte[64]; blake.DoFinal(message, 0); } else { message = data; } var verifier = SignerUtilities.GetSigner("Ed25519"); verifier.Init(false, new Ed25519PublicKeyParameters(pubKeyBytes)); verifier.BlockUpdate(message, 0, message.Length); return verifier.VerifySignature(edSignature); }要点:根据签名头字节决定是否做 BLAKE2b-512 预哈希,不要硬编码模式。CI 改 -S (无 -W) 时签名头会变 Ed,本实现自动兼容。7. Inno Setup 安装程序7.1 前置依赖Windows 上安装 Inno Setup 6默认路径 C:\Program Files (x86)\Inno Setup 6\ISCC.exe7.2 目录结构Tools/Installer/ ├── installer.iss # Inno Setup 脚本 ├── build-setup.ps1 # 本地构建脚本(UTF-8 BOM) └── i18n/ └── ChineseSimplified.isl # 中文语言文件(UTF-8 BOM,随仓库分发)7.3 installer.iss 模板#define AppName "{{AppName}}" #define AppPublisher "{{AppPublisher}}" #define AppExeName "{{AppExeName}}" [Setup] ; AppId 一经设定永不更改,否则 Windows 视为全新程序 AppId={#{{AppGuid}} AppName={#AppName} AppVersion={#AppVersion} AppPublisher={#AppPublisher} DefaultDirName={autopf}\{#AppName} DefaultGroupName={#AppName} OutputBaseFilename={{AppName}}Setup OutputDir={#OutputDir} Compression=lzma2/ultra SolidCompression=yes PrivilegesRequired=lowest UninstallDisplayIcon={app}\{#AppExeName} SetupIconFile={#StageDir}\app.ico WizardStyle=modern [Languages] Name: "chinesesimplified"; MessagesFile: "i18n\ChineseSimplified.isl" [Tasks] Name: "desktopicon"; Description: "创建桌面快捷方式"; GroupDescription: "附加任务:"; Flags: unchecked [Files] Source: "{#StageDir}\*"; DestDir: "{app}"; Flags: recursesubdirs ignoreversion [Icons] Name: "{group}\{#AppName}"; Filename: "{app}\{#AppExeName}" Name: "{group}\卸载 {#AppName}"; Filename: "{uninstallexe}" Name: "{autodesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Tasks: desktopicon [Run] Filename: "{app}\{#AppExeName}"; Description: "立即启动 {#AppName}"; Flags: nowait postinstall skipifsilent [Code] procedure InitializeWizard; begin { D 盘存在时默认安装到 D 盘,否则 fallback 到 {autopf}(lowest 权限下为 %LOCALAPPDATA%\Programs) } if DirExists('D:\') then WizardForm.DirEdit.Text := 'D:\{#AppName}' else WizardForm.DirEdit.Text := ExpandConstant('{autopf}\{#AppName}'); end;InitializeWizard 只设置默认值,用户可在向导中手动修改路径。PrivilegesRequired=lowest 不变,无需管理员权限。7.4 build-setup.ps1 模板要点文件必须保存为 UTF-8 with BOM(PowerShell 5.x 依赖 BOM 按 UTF-8 解码 .ps1)脚本不应含 emoji 和复杂中文标点,否则 Windows GBK 编码会解析失败核心三步:dotnet publish {{MainProject}} → stage 目录dotnet publish {{UpdaterProject}} → 同一 stage 目录ISCC.exe installer.iss /DAppVersion=... /DStageDir=... /DOutputDir=...7.5 AppId 约束AppId 用 New-Guid 生成一次,写入 installer.iss 后永不更改改了 AppId = Windows 认为是全新程序,旧用户无法升级安装SetupIconFile 引用 {#StageDir}\app.ico,要求 publish 输出目录包含 app.ico8. 环境变量速查8.1 本地 Windows 环境变量变量名用途示例值RELEASE_TOKEN写权限 token,上传 setup.exe 到 releasegph_xxxxxxxGITEA_RELEASE_TOKEN只读 token,嵌入 setup.exe 内的二进制gpa_xxxxxxxGITEA_BASE_URLGitea 地址http://git.fangsiyuan.topOWNER仓库拥有者TomHanck4REPO仓库名feishuworkflowtool8.2 设置方式临时(当前 PowerShell 窗口有效)$env:RELEASE_TOKEN = "你的写权限token" $env:GITEA_BASE_URL = "http://git.fangsiyuan.top" $env:OWNER = "TomHanck4" $env:REPO = "feishuworkflowtool"持久(写入注册表,新窗口也有效)[Environment]::SetEnvironmentVariable("RELEASE_TOKEN", "你的写权限token", "User") [Environment]::SetEnvironmentVariable("GITEA_BASE_URL", "http://git.fangsiyuan.top", "User") [Environment]::SetEnvironmentVariable("OWNER", "TomHanck4", "User") [Environment]::SetEnvironmentVariable("REPO", "feishuworkflowtool", "User")持久设置存储在注册表 HKEY_CURRENT_USER\Environment。设置后必须新开 PowerShell 窗口才生效。8.3 验证命令$env:GITEA_BASE_URL; $env:OWNER; $env:REPO; $env:RELEASE_TOKEN.Length9. setup.exe 上传到 Release9.1 获取 release ID$tag = "v1.0.0" $rel = Invoke-RestMethod ` -Headers @{ Authorization = "token $env:RELEASE_TOKEN" } ` "$env:GITEA_BASE_URL/api/v1/repos/$env:OWNER/$env:REPO/releases/tags/$tag" $rel.id9.2 上传curl.exe -H "Authorization: token $env:RELEASE_TOKEN" ` -F "attachment=@dist\{{AppName}}Setup.exe" ` "$env:GITEA_BASE_URL/api/v1/repos/$env:OWNER/$env:REPO/releases/$($rel.id)/assets?name={{AppName}}Setup.exe"9.3 跨 tag 复用setup.exe 不需要每次发版都重出asset 名建议固定为 {{AppName}}Setup.exe(不带版本号),避免每次 tag 都要重传新 release 创建后,从旧 release 下载 setup.exe 再上传到新 release 即可需要重出 setup.exe 的情况(少见):改了 installer.iss 中 [Setup] / [Files] / [Icons] / [Run] 段改了默认安装路径、快捷方式策略、是否需要管理员想给新装用户提供"开箱版本接近最新"的体验不需要重出的情况(绝大多数):业务代码、UI、第三方依赖、资源、updater 自身的任何更改客户端 token 轮换manifest.json 内容变化10. 增量更新架构10.1 manifest.json 结构{ "schema": 1, "version": "1.0.0", "channel": "stable", "releaseDate": "2025-01-01T00:00:00Z", "notes": "changelog here", "files": [ { "path": "{{AppName}}.exe", "assetName": "{{AppName}}.exe", "sha256": "abcdef1234567890..." }, { "path": "Updater.exe", "assetName": "Updater.exe", "sha256": "1234567890abcdef..." } ] }10.2 更新流程客户端启动 → GiteaReleaseChannel 拉取 releases 列表找到最新非草稿 release → 下载 manifest.json + manifest.json.minisigminisign 验签 → 比对本地文件 sha256只下载 sha256 不一致的文件 → 主程序退出 → updater.exe 替换文件updater 完成 → 重启主程序10.3 安全层层机制作用软保护只读 token 嵌入二进制防爬虫,不防逆向硬保护minisign 签名验证防篡改,确保完整性访问控制私有仓库匿名访问 40410.4 检查更新触发时机(关键设计)反模式(曾踩过的坑):把 CheckForUpdateAsync() 唯一调用点放在 OAuth 登录回调里。已恢复 session 的存量用户启动后不会重新走 OAuth → 永远不检查更新。正确设计:启动时 └─ MainWindow.OnContentRendered └─ Task.Delay(500) → CheckForUpdateAsync(silent: true) // 后台静默 主界面状态栏 └─ 「检查更新」按钮 → CheckForUpdateAsync(silent: false) // 手动触发,无更新也提示 OAuth 登录回调 └─ CheckForUpdateAsync(silent: true) // 兜底,覆盖首次安装首次登录场景silent 参数语义:silent有更新无更新异常true(启动时)弹更新对话框状态栏提示,不弹窗静默吞掉,写 Debug 日志false(手动按钮)弹更新对话框弹"已是最新版本 vX.X.X"弹失败提示版本比较:用 System.Version 而非字符串比较,且要先剥离预发布后缀:private static bool IsNewerVersion(string remote, string local) { var r = new Version(remote.Split('-')[0]); var l = new Version(local.Split('-')[0]); return r > l; }11. 验证清单检查项命令 / 操作预期结果匿名访问curl -i {{GiteaBaseURL}}/api/v1/repos/{{Owner}}/{{Repo}}/releases/tags/v0.0.1HTTP 404/403只读 token 访问同上 + -H "Authorization: token <只读token>"HTTP 200minisign 签名头`curl -L .../manifest.json.minisig \sed -n '2p' \base64 -d \head -c 2 \xxd`4544 (ED prehashed)客户端验签启动后看 Debug 输出 [Update] Signature verified必须出现,否则参考 §6.5安装程序双击 {{AppName}}Setup.exe → 选目录 → 安装中文向导界面启动运行从安装目录启动 {{AppName}}.exe程序正常启动启动时检查更新启动后约 1 秒内,Debug 输出包含 [Update] Got manifest: version=...必须出现增量更新推新 tag → CI 跑完 → 客户端检测到更新 → updater 替换文件只下载变化文件卸载控制面板 → 卸载清理干净12. 新项目快速套用步骤在 Gitea 创建私有仓库,配置两个 token + CI Secrets/Variables复制 .gitea/workflows/release.yml,替换 {{占位符}}复制 Tools/Installer/ 整个目录(含 i18n),替换 installer.iss 中的 AppName/GUID 等;如需 D 盘默认路径,保留 [Code] 段的 InitializeWizard主程序 csproj 末尾添加 AssemblyMetadataAttribute 注入块(GiteaReleaseToken 和 ReleaseNotes 两个)App.xaml.cs 启动时反射读取 tokenHTTP 通道类构造函数中注入 Authorization header本地跑 build-setup.ps1 出 setup.exe,上传到首个 release后续发版:.\Tools\Bump.ps1 patch|minor|major 升版本号,再 .\Tools\Release.ps1 "说明" 自动 commit + tag + push,setup.exe 跨 tag 复用13. 版本号管理13.1 语义规则(SemVer)格式:MAJOR.MINOR.PATCH,可选后缀 -suffix(如 -beta)。类型触发条件示例PATCH +1bug 修复、文字/样式调整、依赖小版本升级,不改变功能行为3.2.1 → 3.2.2MINOR +1,PATCH 归零新增功能、UI 新模块、新工作流支持,向后兼容3.2.1 → 3.3.0MAJOR +1,MINOR/PATCH 归零破坏性变更:数据库 schema 不兼容、配置格式变更、删除已有功能3.2.1 → 4.0.013.2 Tools/Bump.ps1只修改 csproj 版本号,不做 commit/tag。同步更新 <Version>、<FileVersion>、<AssemblyVersion>。.\Tools\Bump.ps1 patch # 3.2.1 → 3.2.2 .\Tools\Bump.ps1 minor # 3.2.1 → 3.3.0 .\Tools\Bump.ps1 major # 3.2.1 → 4.0.0 .\Tools\Bump.ps1 3.5.0 # 直接指定版本号13.3 推荐工作流# 1. 开发完成后,确定变更类型 .\Tools\Bump.ps1 patch|minor|major # 2. 发版(自动 commit + tag + push) .\Tools\Release.ps1 "• 新增xxx`n• 修复yyy"14. 设置页 ReleaseNotes 数据驱动14.1 数据流.\Tools\Release.ps1 "• 新增xxx\n• 修复yyy" → CI: dotnet publish -p:ReleaseNotes="..." → csproj AssemblyMetadata["ReleaseNotes"] → SettingsViewModel.ReleaseNoteLines (string[]) → SettingsWindow.xaml ItemsControl 动态渲染 (无内容时整个区块隐藏)14.2 csproj 注入(与 GiteaReleaseToken 同模式)<!-- 构建期注入更新说明(多条用 \n 分隔) --> <ItemGroup Condition="'$(ReleaseNotes)' != ''"> <AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute"> <_Parameter1>ReleaseNotes</_Parameter1> <_Parameter2>$(ReleaseNotes)</_Parameter2> </AssemblyAttribute> </ItemGroup>14.3 CI 透传在 Publish main app 步骤中读取 tag 的 commit message 并透传:- name: Publish main app (win-x64) run: | NOTES=$(git log -1 --format=%B "$TAG") dotnet publish {{MainProject}} \ -p:GiteaReleaseToken=${{ secrets.UPDATER_RELEASE_TOKEN }} \ -p:ReleaseNotes="$NOTES" \ -o ./stage14.4 ViewModel 读取public string[] ReleaseNoteLines { get { var raw = Assembly.GetExecutingAssembly() .GetCustomAttributes(typeof(AssemblyMetadataAttribute), false) .Cast<AssemblyMetadataAttribute>() .FirstOrDefault(a => a.Key == "ReleaseNotes")?.Value; if (string.IsNullOrWhiteSpace(raw)) return []; return raw.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); } } public bool HasReleaseNotes => ReleaseNoteLines.Length > 0;14.5 XAML 动态渲染<Border Visibility="{Binding HasReleaseNotes, Converter={StaticResource BoolToVisibilityConverter}}"> <StackPanel> <TextBlock Text="{Binding UpdateTitle}" /> <ItemsControl ItemsSource="{Binding ReleaseNoteLines}"> <ItemsControl.ItemTemplate> <DataTemplate> <TextBlock Text="{Binding}" TextWrapping="Wrap"/> </DataTemplate> </ItemsControl.ItemTemplate> </ItemsControl> </StackPanel> </Border>Window.Resources 中声明:<BooleanToVisibilityConverter x:Key="BoolToVisibilityConverter"/>注意:本地 build-setup.ps1 不传 ReleaseNotes,设置页更新区块在本地调试时自动隐藏,只在 CI 构建的正式版本中显示。15. 踩坑实录(按发生顺序)15.1 缓存 key 不含 token,导致 token 轮换后旧缓存继续命中症状:在设置中更新 Mineru token,工作流却仍用旧 token 调用第三方 API。根因:WorkflowCacheService.GenerateCacheKey 只用 workflowId + folderName + fileName + fileUrl,token 不进 key。token 变了 key 不变 → 命中旧缓存 → 请求根本没发出去。修法:缓存 key 加 extraKey: string? 参数,调用方传 App.GetEffectiveMineruToken()。token 变 → key 变 → miss → 重发请求。15.2 SettingsViewModel 用 new() 创建,绕过 DI 单例症状:点"清除工作流缓存"按钮,磁盘文件清了,下次执行仍用缓存。根因:WorkflowCacheService 在 DI 注册为 Singleton,但 SettingsViewModel 内部 private readonly WorkflowCacheService _cacheService = new();——构造了一个独立实例。该实例 ClearAllCache 只清自己的内存 + 磁盘,DI 容器里那个被 WorkflowViewModel 持有的 singleton 实例的内存缓存完全没动。修法:SettingsViewModel 改构造注入 + SettingsWindow 接收注入的 ViewModel + MainWindow.OpenSettingsWindow 改走 App.ServiceProvider.GetRequiredService<SettingsViewModel>()。通用经验:任何已被 DI 注册的 service,不要再有 new XxxService() 出现在代码里,无论看起来多无害。15.3 配置默认值优先级:本地用户值 vs. 远端症状:远端 config/defaults.json 已更新 token,客户端启动后仍用本地旧值。根因:早期实现是 "本地值优先,本地空时 fallback 远端"。问题是历史用户已往 settings.json 里写过旧 token,本地永远非空,远端值永远走不到。修法:策略翻转为"远端非空时优先,否则 fallback 到本地用户值"。运维侧 push 新 defaults.json 即可全网生效,无需让用户手动清空设置。public static string GetEffectiveMineruToken() { var remote = AppConstants.RemoteMineruTokenDefault; if (!string.IsNullOrEmpty(remote)) return remote; return Settings?.Coze?.MineruToken ?? string.Empty; }通用经验:可远程轮换的 token / endpoint 配置,应当远端优先,本地只是离线兜底。15.4 检查更新只在 OAuth 回调里触发参见 §10.4。15.5 minisign 验签未支持 prehashed —— 更新通道从未真正工作过参见 §6.5。这是最隐蔽的一个:所有外部产物(manifest、签名、release、CI 日志)都正常,唯独客户端验签永远 false 静默返回。判别只需看签名头字节 = ED 还是 Ed。15.6 这几个坑的连锁效应v0.0.1 ─┐ v3.2.1 ─┤ → 全都装了「永远验不过签的 MinisignVerifier」 v3.3.0 ─┤ → 任意客户端「检查更新」都看不到任何新版本 v3.3.1 ─┤ → 修复一个表层问题就当作版本通了 v3.3.2 ─┘ → 但底层验签问题没暴露 v3.3.3:修复 prehashed verifier教训:发版前必须本地端到端跑一次 manifest 拉取 + 验签,不能只靠 CI 产物外观看起来正确。验证脚本(Python 三行):import base64, hashlib from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey pk = base64.b64decode(open("pk").read())[10:42] sig = base64.b64decode(open("manifest.json.minisig").read().splitlines()[1])[10:74] data = open("manifest.json","rb").read() Ed25519PublicKey.from_public_bytes(pk).verify(sig, hashlib.blake2b(data, digest_size=64).digest()) # 不抛异常即通过
2026年05月28日
1 阅读
0 评论
0 点赞
2026-05-18
SMB 网络共享视频缩略图修复记录
SMB 网络共享视频缩略图修复记录日期:2026-05-18 系统:Ubuntu 24.04.4 LTS 问题路径:smb://mynas.local/jellyfin/media/新建文件夹/001极品问题描述Nautilus 文件管理器在 SMB 网络共享目录中无法显示视频文件缩略图,~/.cache/thumbnails/fail/ 下持续产生 50 个失败标记。根因分析根本原因:Ubuntu 24.04 默认启用 AppArmor 对非特权 user namespace 的限制:/proc/sys/kernel/apparmor_restrict_unprivileged_userns = 1Nautilus(GNOME 46)的所有 thumbnailer 都通过 bwrap --unshare-all 沙箱运行。该限制导致 bwrap 在创建 network namespace 后设置 loopback 时被内核拒绝:bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted沙箱起不来 → wrapper 根本没有机会执行 → 每个文件被直接打上 fail 标记。次要原因:bwrap 沙箱内只挂载 /usr,~/.local/bin/ 路径在沙箱内不可见,thumbnailer wrapper 必须安装到 /usr/local/bin/。修复步骤1. 关闭 AppArmor 非特权 userns 限制(持久化)sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0写入持久化配置 /etc/sysctl.d/60-apparmor-userns.conf:# Re-allow unprivileged user namespaces for bwrap-based thumbnailers, flatpak, etc. kernel.apparmor_restrict_unprivileged_userns=02. 安装 SMB URI 解析 wrapper文件:/usr/local/bin/ffmpegthumbnailer-gvfs(755)功能:将 smb:// URI 解析为 gvfs FUSE 本地路径(/run/user/1000/gvfs/smb-share:...),再调用 /usr/bin/ffmpegthumbnailer。sudo install -m 0755 ~/.local/bin/ffmpegthumbnailer-gvfs /usr/local/bin/ffmpegthumbnailer-gvfs3. 注册用户级 thumbnailer文件:~/.local/share/thumbnailers/ffmpegthumbnailer.thumbnailer[Thumbnailer Entry] TryExec=/usr/local/bin/ffmpegthumbnailer-gvfs Exec=/usr/local/bin/ffmpegthumbnailer-gvfs -i %u -o %o -s %s -f MimeType=video/jpeg;video/mp4;video/mpeg;...(完整 MIME 列表)关键:使用 %u(URI)而非 %i(本地路径),因为 SMB 文件对 GIO 是 non-native,%i 会被跳过。4. 清理失败缓存rm -rf ~/.cache/thumbnails/fail nautilus -q5. 确认 Nautilus 设置gsettings set org.gnome.nautilus.preferences show-image-thumbnails 'always' gsettings set org.gnome.nautilus.preferences thumbnail-limit 4096验证结果修复后 ~/.cache/thumbnails/x-large/ 生成 84 个缩略图 PNG,fail 目录为空。x-large: 84 files fail: 0 files注意事项关闭 apparmor_restrict_unprivileged_userns 会放开本机所有非特权 user namespace(影响 Chromium 沙箱、Flatpak、bwrap 等)。如需更精细控制,可改为单独为 /usr/bin/bwrap 写 AppArmor profile。重启后 sysctl 由 /etc/sysctl.d/60-apparmor-userns.conf 自动加载,无需额外操作。大文件首次扫描较慢(需通过 SMB 读取首帧),属正常现象。
2026年05月18日
0 阅读
0 评论
0 点赞
2026-05-18
Wine 安装与中文字体乱码修复记录
Wine 安装与中文字体乱码修复记录适用环境:Ubuntu 24.04 / amd64 / Wine 9.0。1. 安装 Wine 和 winetricks启用 32 位架构并安装 Wine:sudo dpkg --add-architecture i386 sudo apt update sudo apt install -y --install-recommends wine winetricks验证安装:wine --version winetricks --version本机验证结果:wine-9.0 (Ubuntu 9.0~repack-4build3) winetricks 202401052. 处理 winetricks 反复弹窗如果出现类似提示:Running /usr/bin/wineserver -w. This will hang until all wine processes in prefix=/home/fsy/.wine terminate并且窗口一直弹出,可以终止重复启动的 Wine / winetricks 进程:pkill -u "$USER" -f '[w]inetricks' || true pkill -u "$USER" -f '[w]ineserver' || true pkill -u "$USER" -f '[w]ineboot|[w]inedevice\.exe|[s]ervices\.exe|[e]xplorer\.exe' || true3. 确认中文字体Ubuntu 24.04 通常已安装 fonts-noto-cjk。确认字体是否可用:fc-match 'Noto Sans CJK SC'期望输出类似:NotoSansCJK-Regular.ttc: "Noto Sans CJK SC" "Regular"如果没有该字体,可安装中文字体包:sudo apt install -y fonts-noto-cjk fonts-wqy-microhei fonts-wqy-zenhei4. 修复 Wine 中文菜单方块/乱码当前 Wine 前缀为:/home/fsy/.wine执行以下命令,把常见 Windows 字体替换为 Noto Sans CJK SC:export WINEPREFIX="${WINEPREFIX:-$HOME/.wine}" export WINEDEBUG=-all pkill -u "$USER" -f '[P]otPlayer|[p]otplayer' || true wineserver -k || true sleep 1 wine reg add 'HKCU\Software\Wine\Fonts\Replacements' /f >/dev/null for name in \ Tahoma \ 'MS Sans Serif' \ 'Microsoft Sans Serif' \ Arial \ 'Arial Unicode MS' \ SimSun \ NSimSun \ SimHei \ FangSong \ KaiTi \ 'Microsoft YaHei' \ 'Microsoft JhengHei' \ 'MS UI Gothic' \ 'MS Gothic' \ Gulim \ Dotum \ Batang do wine reg add 'HKCU\Software\Wine\Fonts\Replacements' \ /v "$name" \ /t REG_SZ \ /d 'Noto Sans CJK SC' \ /f >/dev/null done wine reg add 'HKLM\Software\Microsoft\Windows NT\CurrentVersion\FontSubstitutes' \ /v 'MS Shell Dlg' \ /t REG_SZ \ /d 'Noto Sans CJK SC' \ /f >/dev/null wine reg add 'HKLM\Software\Microsoft\Windows NT\CurrentVersion\FontSubstitutes' \ /v 'MS Shell Dlg 2' \ /t REG_SZ \ /d 'Noto Sans CJK SC' \ /f >/dev/null wineboot -u wineserver -w验证替换项:wine reg query 'HKCU\Software\Wine\Fonts\Replacements'应能看到类似内容:Arial REG_SZ Noto Sans CJK SC Microsoft Sans Serif REG_SZ Noto Sans CJK SC Microsoft YaHei REG_SZ Noto Sans CJK SC MS Sans Serif REG_SZ Noto Sans CJK SC Tahoma REG_SZ Noto Sans CJK SC5. 启动中文程序如果程序仍有局部乱码,建议显式使用中文 locale 启动:LANG=zh_CN.UTF-8 wine /path/to/app.exe例如 PotPlayer:LANG=zh_CN.UTF-8 wine "$HOME/.wine/drive_c/Program Files/DAUM/PotPlayer/PotPlayerMini64.exe"6. 说明主界面中文正常但菜单是方块,通常是菜单字体仍使用 Tahoma、MS Sans Serif 等无中文字体。上面的注册表替换会让 Wine 菜单和常见 Windows 字体回退到 Noto Sans CJK SC。如果仍然乱码,优先检查程序自身语言设置、Wine 前缀区域设置,以及是否需要安装更兼容的中文字体包。7. 把 Wine 里的 PotPlayer 加到右键“打开方式”Wine 安装的 Windows 程序默认不一定会出现在 Linux 文件管理器的“打开方式”列表里。可以手动创建一个 Linux 启动包装脚本和 .desktop 入口。本机 NAS 视频位于:smb://mynas.local/jellyfin/文件管理器访问 SMB 时通常会通过 GVFS 挂载到:/run/user/1000/gvfs/smb-share:server=mynas.local,share=jellyfinPotPlayer 在 Wine 下直接接收这种 GVFS 长路径时不稳定。当前包装脚本做了三层处理:把 file:// 和 smb:// URI 转换成本机路径。把 NAS 共享根目录映射为 Wine 的 Y: 盘。双击具体视频时,把视频所在目录临时映射为 Wine 的 P: 盘,只传 P:\文件名.mp4 这种短路径给 PotPlayer。使用 /current 参数把文件投递到当前 PotPlayer 实例。7.1 创建启动包装脚本文件路径:/home/fsy/.local/bin/potplayer-wine内容:#!/usr/bin/env bash set -uo pipefail export WINEPREFIX="${WINEPREFIX:-$HOME/.wine}" export LANG="${LANG:-zh_CN.UTF-8}" export WINEDEBUG="${WINEDEBUG:--all}" player="$WINEPREFIX/drive_c/Program Files/DAUM/PotPlayer/PotPlayerMini64.exe" log_dir="${XDG_CACHE_HOME:-$HOME/.cache}" log_file="$log_dir/potplayer-wine.log" gvfs_root="/run/user/$(id -u)/gvfs" nas_server="mynas.local" nas_share="jellyfin" nas_gvfs="$gvfs_root/smb-share:server=$nas_server,share=$nas_share" mkdir -p "$log_dir" "$WINEPREFIX/dosdevices" if [[ -d "$nas_gvfs" ]]; then ln -sfn "$nas_gvfs" "$WINEPREFIX/dosdevices/y:" fi resolve_to_local_path() { local item="$1" python3 - "$item" "$gvfs_root" "$nas_server" "$nas_share" <<'PY' import os import sys from urllib.parse import unquote, urlparse item = sys.argv[1] gvfs_root = sys.argv[2] nas_server = sys.argv[3] nas_share = sys.argv[4] if item.startswith("file://"): print(unquote(urlparse(item).path)) raise SystemExit if item.startswith("smb://"): parsed = urlparse(item) server = (parsed.hostname or "").lower() parts = [unquote(part) for part in parsed.path.split("/") if part] if server == nas_server.lower() and parts and parts[0].lower() == nas_share.lower(): base = os.path.join(gvfs_root, f"smb-share:server={nas_server},share={nas_share}") print(os.path.join(base, *parts[1:]) if len(parts) > 1 else base) raise SystemExit print(item) PY } args=() for item in "$@"; do resolved="$(resolve_to_local_path "$item")" if [[ -e "$resolved" ]]; then parent_dir="$(dirname "$resolved")" file_name="$(basename "$resolved")" ln -sfn "$parent_dir" "$WINEPREFIX/dosdevices/p:" args+=("P:\\$file_name") else args+=("$resolved") fi done { printf '\n[%s] argv:' "$(date '+%F %T')" printf ' <%s>' "$@" printf '\n[%s] resolved:' "$(date '+%F %T')" printf ' <%s>' "${args[@]}" printf '\n' ls -l "$WINEPREFIX/dosdevices/p:" 2>/dev/null || true } >>"$log_file" nohup wine "$player" /current "${args[@]}" >>"$log_file" 2>&1 </dev/null & exit 0设置执行权限:chmod +x /home/fsy/.local/bin/potplayer-wine日志位置:/home/fsy/.cache/potplayer-wine.log7.2 创建桌面应用入口文件路径:/home/fsy/.local/share/applications/potplayer-wine.desktop内容:[Desktop Entry] Type=Application Name=PotPlayer (Wine) Name[zh_CN]=PotPlayer (Wine) Comment=Play videos with PotPlayer through Wine Comment[zh_CN]=使用 Wine 运行 PotPlayer 播放视频 Exec=/home/fsy/.local/bin/potplayer-wine %U Icon=4845_PotPlayerMini64.0 Terminal=false NoDisplay=false StartupNotify=false StartupWMClass=potplayermini64.exe X-GNOME-UsesNotifications=false Categories=AudioVideo;Player;Video; MimeType=video/mp4;video/x-matroska;video/x-msvideo;video/x-ms-wmv;video/mpeg;video/quicktime;video/x-flv;video/webm;video/ogg;video/3gpp;video/3gpp2;application/ogg;application/x-matroska;StartupNotify=false 用于避免文件管理器一直显示“正在运行”。7.3 刷新桌面数据库并关联视频类型update-desktop-database /home/fsy/.local/share/applications 2>/dev/null || true xdg-mime default potplayer-wine.desktop video/mp4 xdg-mime default potplayer-wine.desktop video/x-matroska xdg-mime default potplayer-wine.desktop video/x-msvideo xdg-mime default potplayer-wine.desktop video/x-ms-wmv xdg-mime default potplayer-wine.desktop video/mpeg xdg-mime default potplayer-wine.desktop video/quicktime xdg-mime default potplayer-wine.desktop video/webm验证 MP4 默认打开方式:xdg-mime query default video/mp4期望输出:potplayer-wine.desktop也可以检查 GNOME/GIO 关联:gio mime video/mp47.4 当前双击 NAS 文件的实际路径转换示例输入:/run/user/1000/gvfs/smb-share:server=mynas.local,share=jellyfin/media/新建文件夹/001极品/1 (1).mp4包装脚本会映射视频所在目录:/home/fsy/.wine/dosdevices/p: -> /run/user/1000/gvfs/smb-share:server=mynas.local,share=jellyfin/media/新建文件夹/001极品并传给 PotPlayer:PotPlayerMini64.exe /current P:\1 (1).mp47.5 已确认的限制当前现象:单个 .mp4 拖进已经打开的 PotPlayer 窗口可以播放。双击 NAS 上的 .mp4 会进入包装脚本,也能转换为 P:\文件名.mp4,但 PotPlayer/Wine 仍可能不播放。这说明文件本身和 NAS 访问权限不是主要问题;失败点在 Wine/PotPlayer 通过命令行读取 GVFS SMB 路径这一层。如果双击仍无法稳定打开,推荐最终方案是不要依赖文件管理器的 smb:// / GVFS 挂载,而是把 NAS 用内核 CIFS 挂载到固定本地目录,例如:/home/fsy/NAS/jellyfin之后让文件管理器从这个真实挂载目录打开视频,Wine 会收到普通本地路径,稳定性通常明显更好。
2026年05月18日
0 阅读
0 评论
0 点赞
2026-05-14
RustDesk + HDMI 诱骗器:远程听不到本机声音的修复记录
RustDesk + HDMI 诱骗器:远程听不到本机声音的修复记录环境:Win11 控制端 → Ubuntu 22.04 被控端,被控为 X11,本机插 HDMI 诱骗器(虚拟显示器),音频走 PipeWire(界面显示为 PulseAudio on PipeWire)。现象:远程连接后,本机明明在放视频/音乐,对端却听不到;或曾经修好过,重启后又没声;或安装 Wine / Win32 播放器后,远端再次听不到本机声音。根因(本次已核实)RustDesk 采的是「某个声卡输出的 Monitor」,不是「物理麦克风」。若采到 板载 analog 输出的 monitor,而实际播放却在 HDMI 诱骗器,对端就会静音(或只有环境噪声)。WirePlumber / stream-restore 会按应用名记住 RustDesk 上次用的采集端,容易一直钉在 analog-stereo 上;仅改「默认信源」有时不会自动改掉已建立的 RustDesk 录音流。播放器输出也必须走同一条 HDMI sink。安装 Wine 后,新出现的 Wine / PotPlayer / Win32 应用播放流可能被旧路由记忆影响,跑到 analog 或其它输出;此时 RustDesk 即使已经采 HDMI monitor,也会因为 HDMI monitor 没有实际音频样本而静音。当时若没有应用在出声,monitor 上也没有波形,听起来也像「没声音」——需本机有播放器在播,且采集端对准正在出声的那条 HDMI。现场诊断命令(建议保留)# 默认 sink / source、设备是否在跑 pactl info | head -20 pactl list short sinks pactl list short sources # 谁在「播放」(应看到浏览器 / PotPlayer / Wine 应用,Sink 应对应 HDMI) pactl list sink-inputs # 谁在「录音」(应出现 application.name = RustDesk) pactl list source-outputs # PipeWire 视角看路由:播放器 output 应到 HDMI playback,RustDesk input 应来自 HDMI monitor wpctl status # 看 RustDesk 绑定的信源索引:信源一行应对应 hdmi 的 .monitor LANG=C pactl list source-outputs | grep -E '^(Source Output|Source:|application.name)'判读要点:pactl list short sources 里 HDMI 诱骗器一般是带 hdmi 且以 .monitor 结尾的条目(例如 alsa_output....hdmi-stereo.monitor)。pactl list sink-inputs 里播放器 / Wine 应用的 Sink: 应指向 HDMI sink;若播放流去了 analog,HDMI monitor 仍然可能是全 0。在 source-outputs 里若看到 RustDesk 的 Source: 指向 analog 的 monitor,而本机声音实际从 HDMI 出来,就需要把该录音流 挪到 HDMI 的 monitor。若 RustDesk 已指向 HDMI monitor 但远端仍无声,应先用测试音确认 HDMI monitor 是否真的有波形;若测试音有波形,重点检查控制端 RustDesk 的音频开关/播放设备。一次性手工修复(当场救急)把 <HDMI_MONITOR> 换成你机器上 pactl list short sources 里那条 hdmi 的 .monitor 名称,把 <HDMI_SINK> 换成 pactl list short sinks 里那条 hdmi 输出名称。# 先设默认播放为 HDMI,默认采集为 HDMI monitor pactl set-default-sink '<HDMI_SINK>' pactl set-default-source '<HDMI_MONITOR>' # 若播放器 / Wine 应用已经在播,查 sink-inputs 里的「Sink Input #数字」并挪到 HDMI pactl move-sink-input <SINK_INPUT_ID> '<HDMI_SINK>' # 若 RustDesk 已在采,查 source-outputs 里的「Source Output #数字」并挪到 HDMI monitor pactl move-source-output <SOURCE_OUTPUT_ID> '<HDMI_MONITOR>'改完后:控制端断开再连一次,并在本机播放一段有声音的内容验证。持久化(本机已部署)架构总览应用播放 ─▶ rustdesk_capture (虚拟 sink, 受系统音量控制) │ ├─▶ monitor ─▶ RustDesk 采集 ─▶ [软件音量镜像] ─▶ 远端 │ └─▶ loopback (固定 100%, 低延迟) ─▶ HDMI 诱骗器之所以走虚拟 sink 而不是直接采 HDMI monitor:HDMI sink 的 monitor 抓取点位于硬件音量节点之前,系统音量条对远端音频完全无效(远端听到的永远是满音量)。但 PipeWire 的 monitor port 始终位于 sink 音量节点之前,单纯改虚拟 sink 音量也无法衰减 monitor 输出。所以本方案在 keep 脚本里把虚拟 sink 当前音量实时镜像到 RustDesk 自身 source-output 的软件音量——RustDesk 采集流末端的软件衰减直接作用于发送给远端的 PCM 数据,调节系统音量条因此可以真正控制远端音量。层 1:PipeWire 虚拟 sink(loopback 模块)文件 ~/.config/pipewire/pipewire.conf.d/99-rustdesk-capture-sink.conf:context.modules = [ { name = libpipewire-module-loopback args = { node.description = "RustDesk Capture Sink" capture.props = { node.name = "rustdesk_capture" node.nick = "RustDesk Capture" media.class = "Audio/Sink" audio.position = [ FL FR ] priority.session = 3000 priority.driver = 3000 node.latency = "256/48000" } playback.props = { node.name = "rustdesk_capture_loopback" media.name = "RustDesk Capture → HDMI" node.passive = true target.object = "alsa_output.pci-0000_08_00.1.hdmi-stereo" stream.dont-remix = true node.latency = "256/48000" } } } ]要点:priority.session = 3000 保证它优先于 HDMI(2000)和 analog,被自动选为默认 sink/source。node.latency = "256/48000" ≈ 5.3 ms,loopback 两端总延迟约 10 ms,听感上无明显延迟。如仍觉得延迟,可降到 128/48000(约 2.7 ms,CPU 占用略升)。target.object 写死了 HDMI 节点名;换显卡时需更新此处。修改后 systemctl --user restart pipewire pipewire-pulse 立即生效。层 2:WirePlumber 优先级规则(备份兜底)~/.config/wireplumber/main.lua.d/51-hdmi-priority.lua:把 HDMI 节点 priority.session 抬高到 2000。即使虚拟 sink 没起来(pipewire conf 出错时),HDMI 仍是次优默认设备,远端不会因为默认设备掉到 analog 而完全失声。alsa_monitor.rules = alsa_monitor.rules or {} table.insert(alsa_monitor.rules, { matches = { { { "node.name", "matches", "alsa_output.*hdmi*" } }, { { "node.name", "matches", "alsa_input.*hdmi*" } }, }, apply_properties = { ["priority.session"] = 2000, ["priority.driver"] = 2000, }, })修改后 systemctl --user restart wireplumber 生效。层 3:systemd keep 服务(路由 + 音量镜像)~/.local/bin/rustdesk-hdmi-audio-keep.sh 由 ~/.config/systemd/user/rustdesk-hdmi-audio.service 拉起,每 2 秒:将默认 sink/source 设为虚拟 rustdesk_capture / rustdesk_capture.monitor;取消 monitor 静音;强制 HDMI sink 音量 100%、不静音(避免双重衰减);把所有普通 sink-input 挪到 rustdesk_capture(loopback 自身被识别后跳过,避免环路);把 application.name = "RustDesk" 的 source-output 挪到 rustdesk_capture.monitor,并将虚拟 sink 当前音量值/静音状态镜像到该 source-output 的软件音量上——这是远端音量真正受控的关键。项目路径虚拟 sink 配置~/.config/pipewire/pipewire.conf.d/99-rustdesk-capture-sink.confWirePlumber 规则~/.config/wireplumber/main.lua.d/51-hdmi-priority.luakeep 脚本~/.local/bin/rustdesk-hdmi-audio-keep.shsystemd unit~/.config/systemd/user/rustdesk-hdmi-audio.service常用命令:systemctl --user daemon-reload systemctl --user enable --now rustdesk-hdmi-audio.service systemctl --user status rustdesk-hdmi-audio.service --no-pager journalctl --user -u rustdesk-hdmi-audio.service -f停用:systemctl --user disable --now rustdesk-hdmi-audio.service换硬件注意:更换主板/显卡后,99-rustdesk-capture-sink.conf 里的 target.object 需更新为新的 HDMI 节点名(pactl list short sinks 第 2 列);其他配置使用 hdmi 通配符匹配,无需修改。使用侧注意远程要听到本机系统声,本机需有应用正在向 HDMI(或当前默认输出)播放;仅「连着没播」时 monitor 上也是空的。安装 Wine / Win32 播放器后,优先检查播放流是否真的去了 HDMI:pactl list sink-inputs 或 wpctl status。控制端可在 RustDesk 工具栏检查 音频相关选项(版本不同菜单位置可能不同);根本对齐仍是被控端 PipeWire 上「播放流进 HDMI、RustDesk 采 HDMI monitor」。记录日期以本机整理为准;修改服务后记得 daemon-reload 再 enable --now。
2026年05月14日
1 阅读
0 评论
0 点赞
2026-05-13
CLAUDE.md
CLAUDE.md用于减少 LLM 编码常见错误的行为准则。可按需与项目专属指令合并使用。语言要求: 默认使用中文思考,并使用中文回复;除非用户明确要求使用其他语言。取舍: 这些准则偏向谨慎而非速度。对于琐碎任务,可酌情判断。1. 编码前先思考不要假设。不要掩盖困惑。主动说明取舍。实施前:明确说明你的假设。如果不确定,就提问。如果存在多种理解,列出来,不要悄悄替用户选择。如果有更简单的方案,说出来。必要时提出反对意见。如果某件事不清楚,停下来。指出困惑点并提问。2. 简单优先用最少代码解决问题。不要做投机式扩展。不添加超出需求的功能。不为一次性代码抽象。不添加用户没有要求的「灵活性」或「可配置性」。不为不可能发生的场景添加错误处理。如果写了 200 行,而 50 行就能解决,请重写。问问自己:「资深工程师会不会认为这过度复杂?」如果答案是会,就简化。3. 外科手术式修改只改必须改的地方。只清理你自己造成的问题。编辑现有代码时:不要「顺手改进」相邻代码、注释或格式。不要重构没有坏掉的东西。匹配现有风格,即使你个人会用另一种写法。如果发现无关的废弃代码,提出来,不要直接删除。当你的改动产生孤立代码时:删除因你的改动而变得未使用的 import、变量或函数。除非用户要求,不要删除原本就存在的废弃代码。检验标准:每一行改动都应该能直接追溯到用户请求。4. 目标驱动执行定义成功标准。循环推进,直到完成验证。把任务转化为可验证目标:「添加校验」→「为非法输入编写测试,然后让测试通过」「修复 bug」→「编写能复现 bug 的测试,然后让测试通过」「重构 X」→「确保重构前后测试都通过」对于多步骤任务,先给出简短计划:1. [步骤] → 验证:[检查项] 2. [步骤] → 验证:[检查项] 3. [步骤] → 验证:[检查项]清晰的成功标准能让你独立循环推进。模糊标准(例如「让它能用」)会导致不断需要澄清。这些准则生效的标志: diff 中不必要的改动更少,因过度复杂导致的重写更少,澄清问题发生在实施前,而不是犯错之后。
2026年05月13日
1 阅读
0 评论
0 点赞
1
2
...
9