From 36de6b3bc7b6d91e65e5d3c68e5e671048be086f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=8A=A0=E6=81=A9?= <105278771+xinna2001@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:00:44 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E3=80=81=E8=AE=BE=E7=BD=AE=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E4=B8=8E=20Windows=20=E5=AD=97=E8=8A=82=E7=89=88=E5=AE=89?= =?UTF-8?q?=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release.yml | 78 ++++- CHANGELOG.md | 23 ++ DEVELOPMENT_LOG.md | 86 +++++ OutVoice.py | 6 +- ProgramLog.py | 4 +- ProgramsConfigWindow.py | 4 +- README.md | 43 ++- RoleSwitchWindow.py | 4 +- Seele.py | 111 +++++- StartupMode.py | 6 +- UpdateUi.py | 245 ++++++++++++++ VERSION | 2 +- VoiceToText.py | 4 +- WINDOWS_BYTE_INSTALL.md | 68 ++++ app_update.py | 599 +++++++++++++++++++++++++++++++++ app_update_entry.py | 5 + botmux_client.py | 32 +- botmux_setup_coach.py | 56 ++- initialize.py | 6 +- packaging/build_mac_vip.py | 85 +++++ packaging/windows/Seele.iss | 29 +- packaging/windows/build_vip.py | 142 ++++++++ platform_utils.py | 91 +++++ play_vioce.py | 4 +- requirements.txt | 1 + rpa_service.py | 10 +- run_exe.py | 41 +++ tests/test_app_update.py | 377 +++++++++++++++++++++ tests/test_windows_byte.py | 212 ++++++++++++ windows_byte_bootstrap.py | 490 +++++++++++++++++++++++++++ windows_byte_linux.py | 206 ++++++++++++ write_file.py | 6 +- 32 files changed, 3024 insertions(+), 52 deletions(-) create mode 100644 UpdateUi.py create mode 100644 WINDOWS_BYTE_INSTALL.md create mode 100644 app_update.py create mode 100644 app_update_entry.py create mode 100644 packaging/build_mac_vip.py create mode 100644 packaging/windows/build_vip.py create mode 100644 tests/test_app_update.py create mode 100644 tests/test_windows_byte.py create mode 100644 windows_byte_bootstrap.py create mode 100644 windows_byte_linux.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dc0e368..6e88129 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,8 +16,11 @@ permissions: jobs: package-windows: - name: Package Windows x64 + name: Package ${{ matrix.edition }} x64 runs-on: windows-latest + strategy: + matrix: + edition: [windows, windows_byte] environment: name: windows-packaging url: ${{ github.server_url }}/${{ github.repository }}/releases @@ -64,7 +67,10 @@ jobs: shell: pwsh env: APP_VERSION: ${{ steps.version.outputs.version }} + APP_EDITION: ${{ matrix.edition }} run: | + $env:APP_VERSION | Set-Content -Encoding ascii VERSION + $env:APP_EDITION | Set-Content -Encoding ascii EDITION $versionTuple = (($env:APP_VERSION -split '\.') + '0') -join ', ' $versionInfo = Get-Content "packaging/windows/version_info.txt" -Raw $versionInfo = $versionInfo -replace '\(\d+,\s*\d+,\s*\d+,\s*0\)', "($versionTuple)" @@ -72,6 +78,20 @@ jobs: New-Item -ItemType Directory -Force -Path build | Out-Null $versionInfo | Set-Content -Encoding utf8 "build/windows-version-info.txt" + python -m PyInstaller --noconfirm --clean --onefile --console ` + --name SeeleUpdater --distpath build/updater-helper ` + --workpath build/updater-helper-work --specpath build ` + app_update_entry.py + if ($LASTEXITCODE -ne 0) { throw "Updater helper build failed." } + + if ($env:APP_EDITION -eq "windows_byte") { + python -m PyInstaller --noconfirm --clean --onefile --console ` + --name SeeleWindowsBootstrap --distpath build/windows-helper ` + --workpath build/windows-helper-work --specpath build ` + windows_byte_bootstrap.py + if ($LASTEXITCODE -ne 0) { throw "Windows bootstrap helper build failed." } + } + python -m PyInstaller ` --noconfirm ` --clean ` @@ -96,6 +116,10 @@ jobs: --add-data "DEVELOPMENT_LOG.md;." ` --add-data "MAC_BYTE_AUTO_INSTALL_COMMANDS.md;." ` --add-data "MAC_BYTE_ZERO_TOUCH_RESEARCH.md;." ` + --add-data "WINDOWS_BYTE_INSTALL.md;." ` + --add-data "windows_byte_linux.py;." ` + --add-data "botmux_setup_coach.py;." ` + --add-data "platform_utils.py;." ` --add-data "EDITION;." ` --add-data "VERSION;." ` --add-data "LICENSE;." ` @@ -103,6 +127,16 @@ jobs: if (-not (Test-Path "dist/Seele/Seele.exe")) { throw "PyInstaller did not produce dist/Seele/Seele.exe" } + New-Item -ItemType Directory -Force -Path dist/Seele/bin | Out-Null + Copy-Item build/updater-helper/SeeleUpdater.exe dist/Seele/bin/ + & "dist/Seele/Seele.exe" --updater-smoke-test + if ($LASTEXITCODE -ne 0) { throw "Packaged updater smoke test failed." } + if ($env:APP_EDITION -eq "windows_byte") { + New-Item -ItemType Directory -Force -Path dist/Seele/bin | Out-Null + Copy-Item build/windows-helper/SeeleWindowsBootstrap.exe dist/Seele/bin/ + & "dist/Seele/Seele.exe" --windows-bootstrap-smoke-test + if ($LASTEXITCODE -ne 0) { throw "Packaged WSL resource/helper smoke test failed." } + } $env:QT_QPA_PLATFORM = "offscreen" & "dist/Seele/Seele.exe" --smoke-test if ($LASTEXITCODE -ne 0) { @@ -113,18 +147,19 @@ jobs: shell: pwsh env: APP_VERSION: ${{ steps.version.outputs.version }} + APP_EDITION: ${{ matrix.edition }} run: | New-Item -ItemType Directory -Force -Path release | Out-Null Compress-Archive ` -Path "dist/Seele" ` - -DestinationPath "release/Seele-$env:APP_VERSION-windows-x64-portable.zip" ` + -DestinationPath "release/Seele-$env:APP_VERSION-$env:APP_EDITION-x64-portable.zip" ` -CompressionLevel Optimal $iscc = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe" if (-not (Test-Path $iscc)) { throw "Inno Setup compiler was not found." } - & $iscc "/DMyAppVersion=$env:APP_VERSION" "packaging\windows\Seele.iss" + & $iscc "/DMyAppVersion=$env:APP_VERSION" "/DMyEdition=$env:APP_EDITION" "packaging\windows\Seele.iss" if ($LASTEXITCODE -ne 0) { throw "Inno Setup failed with exit code $LASTEXITCODE" } @@ -132,23 +167,24 @@ jobs: $manifest = @{ product = "Seele" version = $env:APP_VERSION + edition = $env:APP_EDITION commit = $env:GITHUB_SHA platform = "windows-x64" built_at = (Get-Date).ToUniversalTime().ToString("o") } | ConvertTo-Json - $manifest | Set-Content -Encoding utf8 "release/release-manifest-windows.json" + $manifest | Set-Content -Encoding utf8 "release/release-manifest-$env:APP_EDITION.json" Get-ChildItem release -File | - Where-Object { $_.Name -ne "SHA256SUMS-windows.txt" } | + Where-Object { $_.Name -ne "SHA256SUMS-$env:APP_EDITION.txt" } | Sort-Object Name | ForEach-Object { $hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLower() "$hash $($_.Name)" - } | Set-Content -Encoding ascii "release/SHA256SUMS-windows.txt" + } | Set-Content -Encoding ascii "release/SHA256SUMS-$env:APP_EDITION.txt" - uses: actions/upload-artifact@v4 with: - name: Seele-${{ steps.version.outputs.version }}-windows + name: Seele-${{ steps.version.outputs.version }}-${{ matrix.edition }} path: release/* if-no-files-found: error @@ -223,6 +259,8 @@ jobs: APP_VERSION: ${{ steps.version.outputs.version }} run: | set -euo pipefail + printf '%s\n' "$APP_VERSION" > VERSION + printf '%s\n' mac_byte > EDITION mkdir -p build/coach-spec python -m PyInstaller \ --noconfirm \ @@ -235,6 +273,12 @@ jobs: --specpath build/coach-spec \ botmux_setup_coach_entry.py + python -m PyInstaller \ + --noconfirm --clean --onefile --console \ + --name SeeleUpdater --distpath build/updater-dist \ + --workpath build/updater-work --specpath build/coach-spec \ + app_update_entry.py + python -m PyInstaller \ --noconfirm \ --clean \ @@ -268,6 +312,8 @@ jobs: cp "build/coach-dist/SeeleBotmuxCoach" \ "dist/Seele.app/Contents/Resources/bin/SeeleBotmuxCoach" chmod 755 "dist/Seele.app/Contents/Resources/bin/SeeleBotmuxCoach" + cp "build/updater-dist/SeeleUpdater" "dist/Seele.app/Contents/Resources/bin/SeeleUpdater" + chmod 755 "dist/Seele.app/Contents/Resources/bin/SeeleUpdater" script -q /dev/null \ env "SEELE_RESOURCES_DIR=$PWD/dist/Seele.app/Contents/Resources" \ "dist/Seele.app/Contents/Resources/bin/SeeleBotmuxCoach" \ @@ -282,6 +328,7 @@ jobs: codesign --verify --deep --strict --verbose=2 "dist/Seele.app" QT_QPA_PLATFORM=offscreen "dist/Seele.app/Contents/MacOS/Seele" --smoke-test "dist/Seele.app/Contents/MacOS/Seele" --botmux-coach-smoke-test + "dist/Seele.app/Contents/MacOS/Seele" --updater-smoke-test - name: Build mac_byte DMG, ZIP, and checksums shell: bash @@ -293,9 +340,12 @@ jobs: zip_path="release/Seele-${APP_VERSION}-mac_byte-macos-arm64.zip" dmg_path="release/Seele-${APP_VERSION}-mac_byte-macos-arm64.dmg" ditto -c -k --sequesterRsrc --keepParent "dist/Seele.app" "$zip_path" + mkdir -p build/dmg-content + ditto "dist/Seele.app" "build/dmg-content/Seele.app" + ln -s /Applications "build/dmg-content/Applications" hdiutil create \ -volname "Seele ${APP_VERSION} mac_byte" \ - -srcfolder "dist/Seele.app" \ + -srcfolder "build/dmg-content" \ -ov \ -format UDZO \ "$dmg_path" @@ -341,7 +391,8 @@ jobs: steps: - uses: actions/download-artifact@v4 with: - name: Seele-${{ needs.package-windows.outputs.version }}-windows + pattern: Seele-${{ needs.package-windows.outputs.version }}-windows* + merge-multiple: true path: release - uses: actions/download-artifact@v4 with: @@ -360,6 +411,13 @@ jobs: ### 本版新增 + - 启动后及每小时自动检查 GitHub 正式新版;右键最后的「检查更新」出现红点,点击自动下载、校验、替换并重启。 + - 角色、语音和 Botmux 设置迁移到当前用户的 Seele 数据目录,更新后保留;新版启动失败自动回滚。 + - macOS ZIP 首次打开可安装到应用程序目录,DMG 提供 Applications 拖放入口。 + - Windows 字节版新增 WSL2 检测、缺失安装、重启保存进度与同用户登录续装。 + - 复用原 Ubuntu、Linux 普通用户和机器人配置;字节 CLI 授权网页在 Windows 打开。 + - systemd/watchdog/linger 配合 Windows 登录任务、恢复触发器和前台保活,自动恢复 Botmux。 + - 桌宠自动读取选定 WSL 用户的 Dashboard 动态配置,安装完成与地址变化后重连。 - 新增「任务面板」:桌宠右键菜单打开独立窗口,展示最近 24 小时内有活动的 Botmux 任务(最初发起者、会话、机器人、标题、状态、相对活动时间),点击任意任务直接跳转到对应飞书话题。 - 任务状态归纳为「进行中 / 等你回复 / 空闲」三类,仅接受 applink.feishu.cn 深链跳转。 - 桌宠自动发现本机 Botmux Dashboard(读取 ~/.botmux 的端口与读取令牌),无需随包附带配置即可连通。 @@ -388,6 +446,8 @@ jobs: - \`Seele-${VERSION}-windows-x64-setup.exe\`:Windows 安装版 - \`Seele-${VERSION}-windows-x64-portable.zip\`:Windows 便携版 + - \`Seele-${VERSION}-windows_byte-x64-setup.exe\`:Windows 字节版安装包 + - \`Seele-${VERSION}-windows_byte-x64-portable.zip\`:Windows 字节版便携包 - \`Seele-${VERSION}-mac_byte-macos-arm64.dmg\`:字节内部 Apple Silicon 安装版 - \`Seele-${VERSION}-mac_byte-macos-arm64.zip\`:字节内部 Apple Silicon 压缩包 - \`Seele-mac_byte-macos-arm64.dmg\` / \`.zip\`:供文档长期引用、始终指向当前 Release 的稳定文件名 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bf37de..fd0f3a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # 更新记录 +## 2.5.0 + +- 启动后和每小时后台检查 GitHub 新版;右键最后一项“检查更新”显示新版红点, + 点击下载、SHA256/清单校验、自检、替换并重启。 +- 独立更新程序在旧应用退出后替换,新版未成功启动则恢复旧版; + 保留 Windows 安装版卸载信息。 +- 角色、语音、自定义工作流和 Botmux 配置迁移到当前用户的 Seele 目录, + 更新后保留;普通版与字节版隔离。 +- macOS ZIP 首次打开提示安装到应用程序,DMG 增加 Applications 拖放入口。 +- 包含上一个未发布的 2.4.0 Windows 字节版改动,仍需 Windows 真机验收。 + +## 2.4.0(并入 2.5.0 发布) + +- 新增 Windows 字节版:检测可运行 WSL2,复用已有 Ubuntu 和普通 Linux 用户; + 缺组件先安装,需重启时保存进度,同一 Windows 用户登录后自动续装。 +- WSL 内安装官方自包含 Botmux 和字节 CLI,保留已有配置;复用 PTY 交互引导, + 在 Windows 浏览器打开授权地址并播放语音。 +- 合并 systemd 设置,启用用户服务、watchdog 和 linger,注册 Windows 登录后 + 自启与恢复任务;增加前台保活,防止 WSL 因没有前台进程而退出。 +- 桌宠后台读取选定 WSL 用户的动态 Dashboard 配置,安装完成与地址变化时重连。 +- Windows 普通版与字节版分别打包,增加冻结安装终端与资源自检。 + 本次未执行真实 Windows 重启与 EXE 构建,须真机验收。 + ## 2.3.1 - 任务面板新增“发起者”信息,使用 Botmux 创建会话时固定保存的 `ownerOpenId` diff --git a/DEVELOPMENT_LOG.md b/DEVELOPMENT_LOG.md index ba6f1db..6031c56 100644 --- a/DEVELOPMENT_LOG.md +++ b/DEVELOPMENT_LOG.md @@ -10,6 +10,92 @@ - 不在本文记录 Token、Cookie、账号、内网凭据或其他秘密。 - 不删除远端 Release、Tag 或回退远端分支,除非用户明确批准。 +## 2026-10-21 安装、自动更新与设置保留(2.5.0 发布准备) + +用户授权实现启动联网检查、右键最后“检查更新”红点、点击下载安装重启、 +设置移出应用包。本批次包含上一个未发布的 Windows 字节版改动; +用户已明确回复“OK啦,推吧”,授权本批次提交、合并和发布。 +采用功能分支 -> PR -> CI 通过后合并 -> Tag/Release,保留分支和 PR 记录。 + +- `platform_utils.py`:区分只读资源与可写用户目录,按 edition 隔离设置; + 首次迁移 state/uid/file_name/config/botmux_config,原子写入与私有文件权限。 + macOS 新 ZIP 可优先迁移已安装同类型旧应用的设置。 +- macOS 设置目录 `~/Library/Application Support/Seele//`, + Windows `%LOCALAPPDATA%/Seele//`,Linux XDG data home。 + `SEELE_DATA_DIR` 用于测试隔离;配置窗口、语音、RPA、Botmux、日志均已接入。 +- `UpdateUi.py`:启动 5 秒后检查、每小时检查、菜单末项红点, + 点击下载进度提示;离线后台静默、手动反馈,不与 Botmux 安装同时替换。 +- `app_update.py`:官方 GitHub latest API,匿名限流时使用官方 latest/download + manifest;严格版本/发行类型/平台选择,SHA256、发布清单、ZIP 路径与链接、 + 包内版本/资源/签名和冻结主程序自检,拒绝降级。随包携带 certifi CA, + 不依赖构建机 Homebrew 证书;兼容 ditto 未标 UTF-8 的中文 ZIP 文件名。 +- `app_update_entry.py`:独立 onefile helper,退出前准备与授权握手, + 等待旧进程退出后同目录原子替换,实际新版 PID/版本/nonce 健康回执; + 失败回滚,保留 Inno 卸载资源,安装目标文件锁,下一次启动清理已完成过程目录。 +- macOS ZIP 首次启动可安装到 /Applications(不可写时 ~/Applications), + 已存在版本不降级、不跨发行类型覆盖;DMG 增加 Applications 拖放入口。 +- 发布 workflow 与本地构建均打包独立更新 helper、增加冻结主程序 helper 自检; + Windows VIP 从自身独立复制的 embeddable Python 执行更新程序, + 切换正式 EXE 后保留“启动希儿.vbs”入口;普通版启动刷新 RPA 的资源和设置路径。 +- 验证:98 项 Python 测试通过(含设置保留、SHA、ZIP/symlink、 + 平台选择、真实子进程健康回执、成功替换/失败回滚/父进程不退出、 + 菜单最后项和红点、离线与点击下载、匿名限流回退);插件测试通过, + release YAML 解析与 diff 格式通过。 +- 在线只读验证成功:官方 latest 显示 2.3.1,mac_byte/arm64 三个版本资产 + 正确解析,真实 ZIP/清单下载 SHA256 与安全解压资源核验通过;临时副本已清理。 +- 冻结 macOS 完整事务实跑:重新解压 ZIP 严格签名与 GUI/helper smoke, + 独立冻结 helper 准备、等待旧 PID 退出、原子替换、真实新版 GUI PID 健康回执、 + 角色保留、替换后严格签名均通过。使用临时目标和隔离数据,未替换工作区或本机应用。 + 冻结主程序在系统 CA 路径设为不存在时 HTTPS 检查通过,确认 certifi 随包可用。 + 当前 2.5.0 不会降级;普通用户菜单安装与未来更高版本点击更新仍待真机验收。 +- VIP 输出固定在 `vip-packages/Seele-2.5.0-vip-test-*.zip`,附 SHA256。 + Windows EXE/WSL 安装、重启和授权仍需 Windows 真机验收。 +- 最终 mac_byte ZIP SHA256: + `be801c99522ef1858849d25802d742eb091b0ed7e4f5c988b4d959c16a78508f`。 +- 最终 windows_byte VIP ZIP SHA256: + `4b166d22df7aaa877b3ede3762ffec5f7e354cf3a2cca5249cf37bee6af4f2cc`。 + 两包 ZIP/摘要核验通过;Windows 包包含 certifi CA、独立 helper 源码与内置 Python。 +- 菜单视觉证据:`vip-packages/Seele-2.5.0-update-menu.png`, + 模拟高版本红点,验证“检查更新”始终在底部;线上当前没有高于 2.5.0 的 Release。 + +## 2026-10-21 Windows 字节版 WSL 安装修复(2.4.0 本地待验收) + +用户授权按其成功部署指南直接修复 Windows 字节版。本批次仅本地修改; +未提交、未推送、未发布。用户新增指南和 `.vscode/` 保留。 + +- `windows_byte_bootstrap.py`:当前用户 WSL2 探测、普通用户复用/初始化、 + 窄范围 UAC 组件安装、原子进度、内核锁、重启续装任务及 HKCU Run 备用。 +- `windows_byte_linux.py`:合并 WSL 配置、官方 Botmux 二进制安装、 + 字节 CLI 安装与授权、插件、systemd/watchdog/linger、前台保活。 +- `botmux_setup_coach.py`:WSL 内调用 Windows PowerShell 播放原语音, + 优先 Chrome 打开授权地址;文字参数用 UTF-16 编码脚本和单引号字面量传递。 +- `Seele.py`/`botmux_client.py`:Windows 字节版避开旧影刀首次安装, + 菜单继续安装、后台 UNC Dashboard 发现、Qt 主线程更新连接。 +- Windows 打包矩阵分普通版/字节版,冻结独立 console helper, + 主程序执行 helper 路径与 Linux 源资源自检;Inno 字节版独立目录与卸载任务。 +- 本地版本升为 2.4.0;线上仍为 2.3.1。安装说明见 `WINDOWS_BYTE_INSTALL.md`。 + +验证:82 项 Python 测试通过,新增 16 项覆盖重启边界、续装、 +Windows 身份、并发锁、systemd 合并、保活恢复、浏览器与 UNC 路由。 +插件 build/validate/MCP smoke、Python 编译、diff 格式、离屏桌宠通过。 +PowerShell 7.4.6 解析安装脚本和 workflow 的 PowerShell 部分通过; +release YAML 解析通过。仅语法校验,不代表 Windows 系统接口已实跑。 + +本地 VIP: + +- `vip-packages/Seele-2.4.0-vip-test-windows_byte-x64-portable.zip`: + 官方 Windows Python 3.11.9 embeddable + Windows Qt/wheels, + 完整解压后双击 `启动希儿.vbs`;没有另装 Python 的前置要求。 + ZIP CRC、资源清单、Python/Qt Windows DLL 校验通过。 +- `vip-packages/Seele-2.4.0-vip-test-mac_byte-macos-arm64.zip`: + 本地 PyInstaller arm64,ad-hoc 严格签名、冻结主程序 smoke、 + coach 路径 smoke 与 ZIP 校验通过。 +- 两包均带 `.zip.sha256`。构建脚本 `packaging/windows/build_vip.py` 和 + `packaging/build_mac_vip.py` 只做本地产物,不发布。 + +真实 Windows WSL 安装、重启/登录、账号授权、计划任务注册与 +Windows PyInstaller EXE 构建未在当前 macOS 主机执行。 + ## 远端基线 - 仓库:`https://github.com/xinna2001/Seele` diff --git a/OutVoice.py b/OutVoice.py index fdf34b9..c140a43 100644 --- a/OutVoice.py +++ b/OutVoice.py @@ -1,7 +1,7 @@ import sys import os import write_file -from platform_utils import get_base_dir as _platform_base_dir +from platform_utils import data_path, get_base_dir as _platform_base_dir from PyQt5.QtCore import Qt, QRectF from PyQt5.QtGui import QIcon, QPixmap, QPainter, QPainterPath from PyQt5.QtWidgets import QApplication, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QMessageBox, QLabel, QFrame @@ -73,14 +73,14 @@ def ui(self) -> None: self.setLayout(root_layout) def YesEvent(self) -> None: - path = os.path.join(get_base_dir(), "state.json") + path = data_path("state.json") dic = write_file.read_dict_from_json(path) or {} dic["stt_state"] = "True" write_file.write_dict_to_json(dic, path) self.close() def hide_event(self) -> None: - path = os.path.join(get_base_dir(), "state.json") + path = data_path("state.json") dic = write_file.read_dict_from_json(path) or {} dic["stt_state"] = "False" write_file.write_dict_to_json(dic, path) diff --git a/ProgramLog.py b/ProgramLog.py index 3171736..0d04184 100644 --- a/ProgramLog.py +++ b/ProgramLog.py @@ -1,10 +1,10 @@ import time -from platform_utils import app_path +from platform_utils import get_data_dir class ProgramLog: def __init__(self): self._time = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime()) - log_dir = app_path("Log") + log_dir = get_data_dir() / "Log" log_dir.mkdir(parents=True, exist_ok=True) self.FileName = log_dir / f"{time.strftime('%Y-%m-%d', time.gmtime())}.log" self.FileName.touch(exist_ok=True) diff --git a/ProgramsConfigWindow.py b/ProgramsConfigWindow.py index 858dacd..33bf314 100644 --- a/ProgramsConfigWindow.py +++ b/ProgramsConfigWindow.py @@ -2,7 +2,7 @@ import os import sys import write_file as wf -from platform_utils import get_base_dir as _platform_base_dir +from platform_utils import data_path, get_base_dir as _platform_base_dir from PyQt5.QtWidgets import QWidget from PyQt5.QtWidgets import QPushButton from PyQt5.QtWidgets import QLabel @@ -17,7 +17,7 @@ def get_base_dir(): return str(_platform_base_dir()) def _json_path(name: str) -> str: - return os.path.join(get_base_dir(), name) + return str(data_path(name)) class ManageWakeWordsWindow(QWidget): diff --git a/README.md b/README.md index a399a88..73eb785 100644 --- a/README.md +++ b/README.md @@ -34,10 +34,12 @@ Botmux 扩展位于 [`botmux-skill/`](botmux-skill/),不需要修改 Botmux ## 版本与安装包 -当前正式版本记录在 [`VERSION`](VERSION) 中。GitHub Release 提供: +当前版本记录在 [`VERSION`](VERSION) 中。GitHub Release 提供: - `Seele--windows-x64-setup.exe`:Windows 安装版; - `Seele--windows-x64-portable.zip`:Windows 便携版; +- `Seele--windows_byte-x64-setup.exe`:Windows 字节版安装包; +- `Seele--windows_byte-x64-portable.zip`:Windows 字节版便携包; - `Seele--mac_byte-macos-arm64.dmg`:字节内部 Apple Silicon 安装版; - `Seele--mac_byte-macos-arm64.zip`:字节内部 Apple Silicon 压缩包; - `Seele-mac_byte-macos-arm64.dmg` / `.zip`:内容与对应版本文件相同的稳定别名,供文档通过 GitHub `releases/latest/download` 永久链接到最新版; @@ -48,6 +50,32 @@ Botmux 扩展位于 [`botmux-skill/`](botmux-skill/),不需要修改 Botmux ## 安装 +macOS:完整解压 ZIP,打开 Seele 后选择安装到“应用程序”,安装完成自动重启。 +若系统应用目录不可写,会使用当前用户的 `~/Applications`。 +DMG 也可将 Seele 拖到 Applications。之后无需保留下载目录。 + +Windows:正式版双击 `setup.exe` 安装;便携版完整解压到固定目录后运行。 + +### 检查更新与设置保存 + +启动 5 秒后在后台检查 GitHub 最新正式版本,之后每小时检查一次。 +右键菜单最后一项始终为“检查更新”;有新版时显示红点,点击即下载、 +校验、替换并重启。无网络时后台静默,手动检查可显示失败提示。 +源码运行支持检查,不替换项目文件。 + +角色、语音、自定义工作流、Botmux 设置和日志保存到当前用户专属目录: + +- macOS:`~/Library/Application Support/Seele//`; +- Windows:`%LOCALAPPDATA%\Seele\\`; +- Linux:`$XDG_DATA_HOME/Seele//`(默认 `~/.local/share`)。 + +第一次启动会迁移旧应用包内已有设置,此后新版不会覆盖。 +普通版与字节版分别保存设置。Botmux 和 WSL 自身的已有登录与配置保留。 +更新按发行类型和平台选择包,验证 SHA256、发布清单和包内版本, +运行启动自检;新版启动失败则恢复旧应用。更新时请保持设备开机。 + +### 源码运行 + 推荐使用 Conda 和 Python 3.11: ```bash @@ -59,6 +87,19 @@ python Seele.py Linux 还需由系统包管理器安装 Qt/XCB、PortAudio 和 Tk。Wayland、macOS 和部分 Linux 桌面可能不允许 `keyboard` 注册全局热键,此时可在桌面宠物右键菜单选择“唤醒输入”。 +## Windows 字节版 + +新增 `windows_byte` 发行类型:复用可用 WSL2,缺失时先安装; +Windows 要求重启则保存进度,同一用户登录后自动继续。 +WSL 内以普通用户部署 Botmux 和字节 CLI,启用 systemd/watchdog, +Windows 登录任务及前台保活负责持续运行与恢复。 + +VIP 便携测试包完整解压后双击 `启动希儿.vbs`。源码可设置 +`SEELE_EDITION=windows_byte` 后启动。详见 [`WINDOWS_BYTE_INSTALL.md`](WINDOWS_BYTE_INSTALL.md)。 +正式构建输出 `Seele--windows_byte-x64-setup.exe` 和 +`Seele--windows_byte-x64-portable.zip`,保留普通 Windows 包。 +真实 Windows 首次安装、重启续装和账号授权仍需真机验收。 + ## mac_byte 版本 仓库根目录的 [`EDITION`](EDITION) 为 `mac_byte` 时,Seele 会在 macOS 首次启动时检查完整工具链: diff --git a/RoleSwitchWindow.py b/RoleSwitchWindow.py index e2aad46..0e96967 100644 --- a/RoleSwitchWindow.py +++ b/RoleSwitchWindow.py @@ -2,7 +2,7 @@ import random import sys import write_file as wf -from platform_utils import get_base_dir as _platform_base_dir +from platform_utils import data_path, get_base_dir as _platform_base_dir from PyQt5.QtCore import Qt, QPoint, QRectF, QSize from PyQt5.QtGui import QCursor, QIcon, QPainter, QPainterPath, QPixmap @@ -28,7 +28,7 @@ def get_base_dir(): def _json_path(name): - return os.path.join(get_base_dir(), name) + return str(data_path(name)) def _responsive_window_size( diff --git a/Seele.py b/Seele.py index d437d39..f7cbfee 100644 --- a/Seele.py +++ b/Seele.py @@ -21,11 +21,13 @@ import ProgramsConfigWindow import RoleSwitchWindow import TaskPanelWindow +import UpdateUi import botmux_client import mac_byte_bootstrap +import windows_byte_bootstrap import rpa_bridge import rpa_service -from platform_utils import get_base_dir as _platform_base_dir, open_external +from platform_utils import data_path, migrate_settings, get_base_dir as _platform_base_dir, open_external from PyQt5.QtCore import Qt, QSize, QPoint, QObject, pyqtSignal from PyQt5.QtGui import QColor, QFontMetrics, QIcon, QPainter from PyQt5.QtCore import QRectF @@ -59,6 +61,7 @@ class _IntegrationSignals(QObject): botmux_state = pyqtSignal(dict) rpa_event = pyqtSignal(dict) bootstrap_event = pyqtSignal(dict) + config_loaded = pyqtSignal(dict) def _apply_material_theme(app: QApplication) -> None: """ @@ -106,11 +109,22 @@ def get_base_dir(): return str(_platform_base_dir()) def _json_path(name): - return os.path.join(get_base_dir(), name) + return str(data_path(name)) +migrate_settings() dic = wf.read_dict_from_json(_json_path('state.json')) or {} -if not mac_byte_bootstrap.is_mac_byte() and dic.get("initialize", "0") == "0": + + +def _is_byte_edition() -> bool: + return mac_byte_bootstrap.is_mac_byte() or windows_byte_bootstrap.is_windows_byte() + + +if (not _is_byte_edition() and dic.get("initialize", "0") == "0" + and os.environ.get("SEELE_EDITION") != "packaging-smoke"): initialize.run(get_base_dir()) +elif not _is_byte_edition() and os.environ.get("SEELE_EDITION") != "packaging-smoke": + # Existing RPA workflows read these links; refresh them after moving/updating. + initialize.set_file(get_base_dir()) class _SpeechBubble(QWidget): """ @@ -328,10 +342,14 @@ def __init__(self): self._first_bubble_done = False self._Tray = Tray.TrayIcon(self) + self._updater = UpdateUi.UpdateController(self) + self._Tray.menu.addSeparator() + self._Tray.menu.addAction(self._updater.action(self._Tray.menu)) self.outvoice = None - if not mac_byte_bootstrap.is_mac_byte(): + if not _is_byte_edition(): self.outvoice = OutVoice.main() - self._botmux_config = botmux_client.load_config() + self._botmux_config = botmux_client.load_config(discover=sys.platform != "win32") + self._config_refresh_thread = None self._botmux_state = { "connected": False, "kind": "offline", @@ -346,6 +364,7 @@ def __init__(self): self._integration_signals.botmux_state.connect(self._handle_botmux_state) self._integration_signals.rpa_event.connect(self._handle_rpa_event) self._integration_signals.bootstrap_event.connect(self._handle_bootstrap_event) + self._integration_signals.config_loaded.connect(self._apply_botmux_config) self._unsubscribe_rpa_events = rpa_service.subscribe_job_events( self._integration_signals.rpa_event.emit, ) @@ -360,6 +379,32 @@ def __init__(self): self._bootstrap_thread = None QTimer.singleShot(0, self._start_integrations) QTimer.singleShot(1200, self.StartMacByteBootstrap) + QTimer.singleShot(1200, self.StartWindowsByteBootstrap) + self._dashboard_refresh_timer = QTimer(self) + self._dashboard_refresh_timer.setInterval(15000) + self._dashboard_refresh_timer.timeout.connect(self._refresh_botmux_config) + self._dashboard_refresh_timer.start() + QTimer.singleShot(0, self._refresh_botmux_config) + + def _refresh_botmux_config(self) -> None: + if self._config_refresh_thread and self._config_refresh_thread.is_alive(): + return + def read_config(): + self._integration_signals.config_loaded.emit(botmux_client.load_config()) + self._config_refresh_thread = threading.Thread( + target=read_config, name="seele-dashboard-discovery", daemon=True, + ) + self._config_refresh_thread.start() + + def _apply_botmux_config(self, config: dict) -> None: + if config == self._botmux_config: + return + self._botmux_monitor.stop() + self._botmux_config = config + self._botmux_monitor = botmux_client.BotmuxMonitor( + self._integration_signals.botmux_state.emit, config, + ) + self._botmux_monitor.start() def _start_integrations(self) -> None: self._botmux_monitor.start() @@ -495,6 +540,9 @@ def OpenTaskPanel(self) -> None: def StartMacByteBootstrap(self) -> None: if not mac_byte_bootstrap.is_mac_byte(): return + if getattr(self, "_updater", None) and self._updater.busy: + QTimer.singleShot(3000, self.StartMacByteBootstrap) + return if self._bootstrap_thread and self._bootstrap_thread.is_alive(): self._show_bubble_text("Botmux 安装终端已经打开。") return @@ -505,6 +553,34 @@ def StartMacByteBootstrap(self) -> None: ) self._bootstrap_thread.start() + def StartWindowsByteBootstrap(self, *, force: bool = False) -> None: + if not windows_byte_bootstrap.is_windows_byte(): + return + if getattr(self, "_updater", None) and self._updater.busy: + QTimer.singleShot(3000, lambda: self.StartWindowsByteBootstrap(force=force)) + return + if self._bootstrap_thread and self._bootstrap_thread.is_alive(): + self._show_bubble_text("Botmux 安装终端已经打开。") + return + if not force and windows_byte_bootstrap.read_status().get("state") == "completed": + self._refresh_botmux_config() + return + self._bootstrap_thread = threading.Thread( + target=self._run_windows_byte_bootstrap, + name="seele-windows-byte-bootstrap", daemon=True, + ) + self._bootstrap_thread.start() + + def _run_windows_byte_bootstrap(self) -> None: + try: + windows_byte_bootstrap.launch_and_wait( + self._integration_signals.bootstrap_event.emit, + ) + except (OSError, RuntimeError, ValueError) as exc: + self._integration_signals.bootstrap_event.emit({ + "state": "failed", "message": str(exc), + }) + def _run_mac_byte_bootstrap(self) -> None: retries_left = 1 while True: @@ -550,7 +626,12 @@ def _run_mac_byte_bootstrap(self) -> None: def _handle_bootstrap_event(self, event: dict) -> None: state = event.get("state") - if state == "launched": + if state == "waiting_reboot": + QMessageBox.information(self, "WSL 安装进度已保存", + event.get("message") or "请重启 Windows,登录后自动继续。") + elif state in {"running", "failed"} and event.get("message"): + self._show_bubble_text(str(event["message"])) + elif state == "launched": if event.get("mode") == "full": text = "未检测到 Botmux,已打开安装终端。登录时请按终端提示操作。" elif event.get("mode") == "repair": @@ -567,6 +648,7 @@ def _handle_bootstrap_event(self, event: dict) -> None: text = "安装终端提前结束,正在重新打开终端继续安装。" self._show_bubble_text(text) elif state == "completed": + self._refresh_botmux_config() self._show_bubble_text("Botmux 安装流程已完成。") elif state == "error": self._show_bubble_text(f"Botmux 安装启动失败:{event.get('message') or '未知错误'}") @@ -575,6 +657,10 @@ def OpenInput(self) -> None: VoiceToText.request_input() def shutdown(self) -> None: + if hasattr(self, "_updater"): + self._updater.stop() + if hasattr(self, "_dashboard_refresh_timer"): + self._dashboard_refresh_timer.stop() VoiceToText.stop() self._botmux_monitor.stop() self._rpa_bridge.stop() @@ -760,7 +846,7 @@ def _WindowMenu(self, _pos=None) -> None: "QMenu::separator { height: 1px; background: rgba(20, 30, 50, 24); margin: 5px 8px; }" ) - show_legacy_controls = not mac_byte_bootstrap.is_mac_byte() + show_legacy_controls = not _is_byte_edition() if show_legacy_controls: self.custom_voice = QAction(QIcon(os.path.join(get_base_dir(), "image", "bs_icon.png")), u"自定义语音唤醒", self) self.Menu.addAction(self.custom_voice) @@ -786,12 +872,17 @@ def _WindowMenu(self, _pos=None) -> None: self.botmux_dashboard = QAction(QIcon(os.path.join(get_base_dir(), "image", "bs_icon.png")), u"打开 Botmux", self) self.Menu.addAction(self.botmux_dashboard) + if windows_byte_bootstrap.is_windows_byte(): + resume = self.Menu.addAction("继续 Botmux 安装") + resume.triggered.connect(lambda: self.StartWindowsByteBootstrap(force=True)) self.StartTray = QAction(QIcon(os.path.join(get_base_dir(), "image", "bs_icon.png")), u"退置托盘", self) self.Menu.addAction(self.StartTray) self.CloseWindowAction = QAction(QIcon(os.path.join(get_base_dir(), "image", "Quit.png")), u"退出程序", self) self.Menu.addAction(self.CloseWindowAction) + self.Menu.addSeparator() + self.Menu.addAction(self._updater.action(self.Menu)) if show_legacy_controls: self.out_voice.triggered.connect(self.WeatherForecast) @@ -954,6 +1045,10 @@ def main(): # Aggressive warm-up before showing any UI (no visible flash). Window._warmup_bubble_hidden() Window.show() + if os.environ.get("SEELE_UPDATE_HEALTH"): + import app_update + path, nonce = os.environ["SEELE_UPDATE_HEALTH"].split("|", 1) + QTimer.singleShot(1500, lambda: app_update.write_health(path, nonce)) ready_file = os.environ.get("SEELE_READY_FILE") if ready_file: try: @@ -962,7 +1057,7 @@ def main(): pass except OSError: pass - if not mac_byte_bootstrap.is_mac_byte(): + if not _is_byte_edition(): VoiceToText.run() app.exec_() diff --git a/StartupMode.py b/StartupMode.py index 117323f..a0f8a25 100644 --- a/StartupMode.py +++ b/StartupMode.py @@ -1,7 +1,7 @@ import sys import os import write_file -from platform_utils import get_base_dir as _platform_base_dir +from platform_utils import data_path, get_base_dir as _platform_base_dir from PyQt5.QtCore import Qt, QRectF from PyQt5.QtGui import QIcon, QPixmap, QPainter, QPainterPath from PyQt5.QtWidgets import QApplication, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QMessageBox, QLabel, QFrame @@ -73,14 +73,14 @@ def ui(self) -> None: self.setLayout(root_layout) def YesEvent(self) -> None: - path = os.path.join(get_base_dir(), "state.json") + path = data_path("state.json") dic = write_file.read_dict_from_json(path) or {} dic["startup_mode"] = "fast" write_file.write_dict_to_json(dic, path) self.close() def hide_event(self) -> None: - path = os.path.join(get_base_dir(), "state.json") + path = data_path("state.json") dic = write_file.read_dict_from_json(path) or {} dic["startup_mode"] = "slow" write_file.write_dict_to_json(dic, path) diff --git a/UpdateUi.py b/UpdateUi.py new file mode 100644 index 0000000..4dfb50a --- /dev/null +++ b/UpdateUi.py @@ -0,0 +1,245 @@ +"""Qt main-thread controller for background checks and user-triggered updates.""" +from __future__ import annotations + +import os +import shutil +import sys +import tempfile +import threading +from pathlib import Path + +from PyQt5.QtCore import QObject, QTimer, pyqtSignal +from PyQt5.QtGui import QColor, QIcon, QPainter, QPixmap +from PyQt5.QtWidgets import QAction, QApplication, QMessageBox + +import app_update +from platform_utils import get_edition, migrate_settings + + +class UpdateController(QObject): + result = pyqtSignal(object, str, bool) + prepared = pyqtSignal(str) + progress = pyqtSignal(int) + failed = pyqtSignal(str) + + def __init__(self, window): + super().__init__(window) + self.window = window + self.release = None + self.busy = False + self.stopped = False + self.actions = [] + self._last_progress = -1 + self.result.connect(self._checked) + self.prepared.connect(self._restart) + self.failed.connect(self._failed) + self.progress.connect(self._progress) + self.timer = QTimer(self) + self.timer.setInterval(60 * 60 * 1000) + self.timer.timeout.connect(self.check) + if os.environ.get("SEELE_DISABLE_UPDATES") != "1": + self.timer.start() + QTimer.singleShot(5000, self.check) + QTimer.singleShot(1000, self._install_offer) + QTimer.singleShot(2000, self._previous_result) + + def stop(self): + self.stopped = True + self.timer.stop() + + def action(self, parent) -> QAction: + action = QAction("检查更新", parent) + action.triggered.connect(self.clicked) + action.destroyed.connect(lambda: self._forget(action)) + self.actions.append(action) + self._decorate(action) + return action + + def _forget(self, action): + if action in self.actions: + self.actions.remove(action) + + def _decorate(self, action): + pixmap = QPixmap(18, 18) + pixmap.fill(QColor(0, 0, 0, 0)) + if self.release: + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.Antialiasing) + painter.setPen(QColor("#e74b53")) + painter.setBrush(QColor("#e74b53")) + painter.drawEllipse(5, 5, 8, 8) + painter.end() + action.setIcon(QIcon(pixmap)) + action.setEnabled(not self.busy) + action.setToolTip(f"发现新版 {self.release.version},点击安装" + if self.release else "检查 GitHub 上的最新正式版本") + + def _refresh(self): + for action in self.actions: + self._decorate(action) + + def _notify(self, message): + self.window._show_bubble_text(message) + if not self.window.isVisible(): + self.window._Tray.showMessage("Seele 更新", message) + + def _worker(self, function): + threading.Thread(target=function, name="seele-updater", daemon=True).start() + + def check(self, manual=False): + if self.stopped or self.busy: + return + self.busy = True + self._refresh() + + def run(): + try: + release = app_update.check_release() + self.result.emit(release, "", manual) + except Exception: + self.result.emit(None, "暂时无法检查更新,请检查网络后重试。", manual) + self._worker(run) + + def _checked(self, release, error, manual): + if self.stopped: + return + self.busy = False + if not error: + self.release = release + self._refresh() + if manual: + self._notify(error or (f"发现新版 {release.version},再点「检查更新」即可安装。" + if release else f"当前已是最新版本 {app_update.current_version()}。")) + + def clicked(self): + if self.stopped or self.busy: + return + if self.release is None: + self.check(manual=True) + return + thread = getattr(self.window, "_bootstrap_thread", None) + if thread and thread.is_alive(): + self._notify("Botmux 安装正在进行,请完成后再更新 Seele。") + return + if app_update.install_root() is None: + self._notify("当前为源码运行,请使用安装包体验自动更新。") + return + self.busy = True + self._last_progress = -1 + self._refresh() + self._notify(f"正在下载 Seele {self.release.version}…") + release = self.release + + def run(): + work = None + helper_started = False + try: + work, staged = app_update.stage_release(release, self.progress.emit) + # Do not replace an application after the user has closed it. + if self.stopped: + shutil.rmtree(work, ignore_errors=True) + return + app_update.start_helper(work, staged, release) + helper_started = True + self.prepared.emit("新版校验完成,正在更新并重启…") + except Exception as exc: + if work and not helper_started and not (work / "job.json").exists(): + shutil.rmtree(work, ignore_errors=True) + self.failed.emit(f"更新未完成:{exc}") + self._worker(run) + + def _progress(self, percent): + if self.stopped: + return + bucket = percent // 25 + if bucket > self._last_progress: + self._last_progress = bucket + self._notify(f"正在下载更新:{percent}%") + + def _failed(self, error): + if self.stopped: + return + self.busy = False + self._refresh() + self._notify(error) + + def _restart(self, message): + if self.stopped: + return + self._notify(message) + QApplication.instance().quit() + + def _previous_result(self): + def run(): + app_update.cleanup_completed() + self._worker(run) + path = app_update.get_data_dir() / "update-status.json" + try: + import json + status = json.loads(path.read_text()) + if not status.get("ok"): + self._notify(status.get("error", "更新失败,已恢复原版本。")) + path.unlink() + except (OSError, ValueError): + pass + + def _install_offer(self): + if (self.stopped or self.busy or sys.platform != "darwin" + or not getattr(sys, "frozen", False) + or os.environ.get("QT_QPA_PLATFORM") == "offscreen"): + return + root = app_update.install_root() + if root is None or root.parent in (Path("/Applications"), Path.home() / "Applications"): + return + try: + migrate_settings() + import json + marker = app_update.get_data_dir() / "install-choice.json" + if marker.exists() and json.loads(marker.read_text()).get("source") == str(root): + return + parent = Path("/Applications") + if not os.access(parent, os.W_OK): + parent = Path.home() / "Applications" + target = parent / "Seele.app" + text = f"将 Seele 安装到 {parent}?\n安装后会自动重启,以后可从「应用程序」打开。" + if target.exists(): + text += "\n该位置已有 Seele,将替换应用并保留用户设置。" + self.busy = True + self._refresh() + answer = QMessageBox.question(self.window, "安装 Seele", text, + QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes) + if answer != QMessageBox.Yes: + app_update.atomic_json(marker, {"source": str(root)}) + self.busy = False + self._refresh() + return + if target.exists(): + resources = app_update.resources_in(target, "macos-arm64") + existing = (resources / "VERSION").read_text().strip() + if app_update.version_tuple(existing) > app_update.version_tuple(app_update.current_version()): + self.busy = False + self._refresh() + self._notify("应用程序中已有更高版本,已取消替换。") + return + if (resources / "EDITION").read_text().strip() != get_edition(): + self.busy = False + self._refresh() + self._notify("应用程序中已有不同类型的 Seele,请使用独立目录安装。") + return + parent.mkdir(parents=True, exist_ok=True) + work = Path(tempfile.mkdtemp(prefix="job-", dir=app_update.updates_dir())) + release = app_update.Release(app_update.current_version(), get_edition(), + "macos-arm64", "", "", "", "") + self.busy = True + self._refresh() + self._notify("正在安装到应用程序…") + + def run(): + try: + app_update.start_helper(work, root, release, target=target, operation="install") + self.prepared.emit("安装完成,正在重启…") + except Exception as exc: + self.failed.emit(f"安装未完成:{exc}") + self._worker(run) + except Exception as exc: + self._failed(f"暂时无法安装:{exc}") diff --git a/VERSION b/VERSION index 2bf1c1c..437459c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.3.1 +2.5.0 diff --git a/VoiceToText.py b/VoiceToText.py index 17201b6..33a5db0 100644 --- a/VoiceToText.py +++ b/VoiceToText.py @@ -7,7 +7,7 @@ import falseIntent as fi import botmux_client import rpa_service -from platform_utils import app_path, get_base_dir as _get_base_dir, get_tools_dir +from platform_utils import app_path, data_path, get_base_dir as _get_base_dir, get_tools_dir try: import keyboard @@ -36,7 +36,7 @@ def get_tools_folder_path(): def _json_path(name): - return str(app_path(name)) + return str(data_path(name)) # Cross-thread input requests: keyboard callback thread -> Qt main thread. diff --git a/WINDOWS_BYTE_INSTALL.md b/WINDOWS_BYTE_INSTALL.md new file mode 100644 index 0000000..c0cc0c0 --- /dev/null +++ b/WINDOWS_BYTE_INSTALL.md @@ -0,0 +1,68 @@ +# Windows 字节版安装与续装 + +本地开发版本:2.4.0,尚未发布。适用 Windows 11 x64,Windows 10 22H2 x64 可参考。 +安装 CLI 需要字节网络;WSL 中也必须能够访问安装源和授权网站。 + +## 运行方式 + +便携 VIP 测试包:完整解压到一个固定目录,双击 `启动希儿.vbs`。 +包内包含 Windows Python 与 Qt,Windows 主机无需另装 Python。 +如无窗口,双击 `查看启动问题.cmd`,保留终端错误。 +路径中有空格或中文也支持。因为重启续装任务引用这个目录,安装完成前不要移动它。 + +源码:安装依赖后,将 `SEELE_EDITION=windows_byte` 再运行 `python run_exe.py`。 +正式 EXE 构建流程已接入 GitHub Actions,但本次本地测试没有运行远程发布。 + +## 自动处理的流程 + +1. 根据当前 Windows 用户注册的发行版,选择已保存的发行版、`Ubuntu-24.04` + 或已有 Ubuntu。可用 `SEELE_WSL_DISTRO` 指定准确名称,名称不存在会停止。 +2. 检查 WSL 版本并实际执行 Linux 探针。已有 WSL2 继续使用;WSL1 保留数据转换。 +3. 缺系统组件时,仅对启用组件与安装 WSL 系统部分请求 UAC。 + 若 Windows 要求重启,先保存进度;用户自行重启,同一用户登录后自动继续。 + 同一次开机重复打开不会再次安装。不能绕过 Windows 首次启用虚拟化的重启。 +4. 缺发行版则安装 `Ubuntu-24.04`;默认普通 Linux 用户保留。 + 默认 root 时,请输入已有用户名,或创建新用户并设置 Linux 密码。 +5. 合并 `/etc/wsl.conf` 的 systemd 与默认用户设置,保留网络与挂载设置。 + 配置变化时只重启目标发行版;请先保存该发行版中的工作。 +6. 安装 Linux 依赖、官方自包含 Botmux、Node 22、Trae CLI、Lark CLI 与 AgentBuddy。 + 已有兼容组件跳过。按终端提示完成本人登录、扫码、名称和邮箱输入; + Windows Chrome 优先打开授权网页,固定 Botmux 菜单项自动填写。 +7. 保留已有 `.botmux/bots.json`,安装 Seele 插件并启用 systemd 用户服务、 + watchdog 和 linger。CLI 及 Botmux 数据放在 Linux 用户目录。 +8. 注册当前 Windows 用户登录任务和每 5 分钟恢复触发器。 + 前台 Linux 保活进程让 WSL 持续存活;服务由 systemd/watchdog 恢复。 + 任务不保存 Windows 密码,只在该用户登录后运行。 +9. 桌宠从选定用户的 WSL UNC 文件读取 Dashboard 动态端口与令牌, + 通过 Windows localhost 连接。显式 `botmux_config.json` 或环境变量优先。 + +## 失败续装与运维 + +安装状态:`%LOCALAPPDATA%\Seele\windows_byte\status.json`。 +桌宠右键选择“继续 Botmux 安装”。失败保留组件、数据和配置。 +续装计划任务被企业策略拒绝时,退回当前用户 HKCU Run 启动项; +长期保活计划任务注册失败会显示失败,需要修复企业策略后重试。 + +安装成功删除临时续装任务,保留 `Seele-Botmux-WSL` 长期任务。 +Windows 睡眠或关机会中断服务,下一次同一用户登录后恢复。 +便携包停止自动唤醒可在任务计划程序中禁用 `Seele-Botmux-WSL`; +EXE 安装版卸载时移除 Windows 任务,保留 WSL 和 Linux 数据。 + +如果 Dashboard 无法连接,检查 WSL localhost 转发与企业网络配置。 +本安装器不改防火墙、全局 `.wslconfig`、电源设置或其他发行版。 +RPA Bridge 默认关闭;Windows 与 WSL 之间的影刀网络配置仍需单独设置。 + +## 真机验收 + +本地模拟测试无法代替以下 Windows 行为: + +- 新机器首次启用 WSL → 显示重启提示 → 重启并用同一用户登录 → 自动续装。 +- 已部署 Botmux 的机器复用原 Ubuntu、普通用户与机器人,避免重复 setup。 +- 首次用户名/密码设置、Trae/Lark/AgentBuddy 授权、飞书扫码后真实回复。 +- 关闭安装窗口、退出桌宠后,Botmux 服务仍在线;Windows 重启登录后自动恢复。 +- `wsl --terminate <发行版>` 后,计划任务重试/恢复触发器重新唤醒。 +- 桌宠任务面板连接 WSL Dashboard,点击任务能打开飞书对应话题。 + +参考用户实机成功指南 `BotMux-WSL-Windows-部署与长期运行指南.md`, +以及 Microsoft 的 WSL 安装、systemd 与 Task Scheduler 文档。 +微软说明 systemd 服务本身不会让 WSL 保持运行,因此增加了前台保活进程。 diff --git a/app_update.py b/app_update.py new file mode 100644 index 0000000..4c0c57d --- /dev/null +++ b/app_update.py @@ -0,0 +1,599 @@ +"""GitHub release updates and an independent, rollback-capable installer. + +No Qt dependency: the copied helper must remain runnable after the old app exits. +""" +from __future__ import annotations + +import argparse +import ctypes +import hashlib +import json +import os +import platform +import re +import shutil +import ssl +import stat +import subprocess +import sys +import tempfile +import time +import uuid +import zipfile +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from urllib.parse import urlparse +from urllib.request import Request, urlopen +from urllib.error import URLError + +from platform_utils import ( + app_path, atomic_json, get_data_dir, get_edition, hidden_subprocess_kwargs, + migrate_settings, +) + +REPO = "xinna2001/Seele" +LATEST_URL = f"https://api.github.com/repos/{REPO}/releases/latest" +MAX_ARCHIVE = 1024 * 1024 * 1024 +MAX_EXPANDED = 3 * MAX_ARCHIVE + + +class UpdateError(RuntimeError): + pass + + +def version_tuple(version: str) -> tuple[int, int, int]: + match = re.fullmatch(r"v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)", version) + if not match: + raise UpdateError("版本号无效") + return tuple(map(int, match.groups())) + + +def current_version() -> str: + return app_path("VERSION").read_text(encoding="utf-8-sig").strip() + + +def update_platform() -> str: + if sys.platform == "darwin" and platform.machine().lower() in ("arm64", "aarch64"): + return "macos-arm64" + if sys.platform == "win32" and platform.machine().lower() in ("amd64", "x86_64"): + return "windows-x64" + raise UpdateError("当前平台暂未提供自动更新包") + + +def trusted_asset_url(url: str) -> bool: + parsed = urlparse(url) + return (parsed.scheme == "https" and parsed.hostname == "github.com" + and parsed.port in (None, 443) and not parsed.username + and not parsed.password + and parsed.path.startswith(f"/{REPO}/releases/download/")) + + +def fetch(url: str, *, limit: int = 2 * 1024 * 1024, + destination: Path | None = None, progress=None, deadline: int = 120) -> bytes: + """Bound both individual socket stalls and total transfer time.""" + request = Request(url, headers={"User-Agent": "Seele-Updater", "Accept": "application/json"}) + started = time.monotonic() + content = bytearray() + file = destination.open("wb") if destination else None + try: + # Frozen macOS cannot rely on this build host's Homebrew CA path. + try: + import certifi + context = ssl.create_default_context(cafile=certifi.where()) + except ImportError: + context = ssl.create_default_context() + with urlopen(request, timeout=20, context=context) as response: + expected = int(response.headers.get("Content-Length", "0")) + if expected > limit: + raise UpdateError("下载文件过大") + total = 0 + while True: + if time.monotonic() - started > deadline: + raise UpdateError("下载超时,请稍后重试") + chunk = response.read(256 * 1024) + if not chunk: + break + total += len(chunk) + if total > limit: + raise UpdateError("下载文件过大") + if file: + file.write(chunk) + else: + content.extend(chunk) + if progress and expected: + progress(min(99, total * 100 // expected)) + if expected and total != expected: + raise UpdateError("下载不完整") + finally: + if file: + file.close() + return bytes(content) + + +@dataclass(frozen=True) +class Release: + version: str + edition: str + platform: str + archive_name: str + archive_url: str + manifest_url: str + checksums_url: str + + +def check_release(current: str | None = None, edition: str | None = None, + target_platform: str | None = None) -> Release | None: + current = current or current_version() + edition = edition or get_edition() + target_platform = target_platform or update_platform() + if (target_platform, edition) not in { + ("macos-arm64", "mac_byte"), ("windows-x64", "windows"), + ("windows-x64", "windows_byte"), + }: + raise UpdateError("当前版本类型暂未提供更新包") + try: + payload = json.loads(fetch(LATEST_URL)) + except (URLError, TimeoutError): + # Public API anonymous limits are shared by NAT users. The official + # latest/download endpoint does not require API tokens or login. + url = f"https://github.com/{REPO}/releases/latest/download/release-manifest-{edition}.json" + manifest = json.loads(fetch(url).decode("utf-8-sig")) + version = str(manifest.get("version", "")) + version_tuple(version) + if any(manifest.get(key) != value for key, value in + (("product", "Seele"), ("edition", edition), ("platform", target_platform))): + raise UpdateError("官方版本清单与当前平台不一致") + suffix = "macos-arm64.zip" if target_platform == "macos-arm64" else "x64-portable.zip" + names = (f"Seele-{version}-{edition}-{suffix}", + f"release-manifest-{edition}.json", f"SHA256SUMS-{edition}.txt") + payload = {"tag_name": "v" + version, "assets": [ + {"name": name, "browser_download_url": + f"https://github.com/{REPO}/releases/download/v{version}/{name}"} + for name in names]} + if payload.get("draft") or payload.get("prerelease"): + return None + version = str(payload.get("tag_name", "")).removeprefix("v") + if version_tuple(version) <= version_tuple(current): + return None + suffix = "macos-arm64.zip" if target_platform == "macos-arm64" else "x64-portable.zip" + name = f"Seele-{version}-{edition}-{suffix}" + assets = {asset["name"]: asset["browser_download_url"] + for asset in payload.get("assets", [])} + names = (name, f"release-manifest-{edition}.json", f"SHA256SUMS-{edition}.txt") + if any(key not in assets for key in names): + raise UpdateError("新版安装包尚未准备完成,请稍后重试") + urls = [assets[key] for key in names] + if not all(trusted_asset_url(url) for url in urls): + raise UpdateError("更新包来源校验失败") + return Release(version, edition, target_platform, name, *urls) + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as file: + for chunk in iter(lambda: file.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def verify_digest(path: Path, sums: str, name: str) -> None: + found = [] + for line in sums.splitlines(): + parts = line.split() + if len(parts) == 2 and parts[1].lstrip("*") == name: + found.append(parts[0].lower()) + if len(found) != 1 or not re.fullmatch("[0-9a-f]{64}", found[0]): + raise UpdateError("缺少有效的 SHA256 校验值") + if sha256(path) != found[0]: + raise UpdateError("安装包完整性校验失败") + + +def safe_extract(archive: Path, output: Path, target_platform: str) -> Path: + """Validate all paths before writing, including macOS framework symlinks.""" + windows = target_platform.startswith("windows") + with zipfile.ZipFile(archive) as file: + entries = [] + seen = set() + links = set() + expanded = 0 + roots = set() + if len(file.infolist()) > 50000: + raise UpdateError("安装包文件数量异常") + for item in file.infolist(): + raw = item.filename + # Apple's ditto stores UTF-8 filenames without always setting ZIP + # bit 11. zipfile otherwise decodes them as CP437, breaking seals. + if target_platform == "macos-arm64" and not item.flag_bits & 0x800: + try: + raw = raw.encode("cp437").decode("utf-8") + except (UnicodeEncodeError, UnicodeDecodeError): + pass + path = PurePosixPath(raw) + if (not raw or "\\" in raw or "\x00" in raw or path.is_absolute() + or ".." in path.parts or ":" in raw): + raise UpdateError("安装包包含不安全路径") + if path.parts[0] == "__MACOSX": + continue + if windows and any(part.endswith((" ", ".")) or + part.split(".")[0].upper() in + {"CON", "PRN", "AUX", "NUL", *(f"COM{i}" for i in range(1, 10)), + *(f"LPT{i}" for i in range(1, 10))} + for part in path.parts): + raise UpdateError("安装包包含无效 Windows 路径") + key = str(path).casefold() if windows else str(path) + if key in seen: + raise UpdateError("安装包包含重复路径") + seen.add(key) + roots.add(path.parts[0]) + expanded += item.file_size + if expanded > MAX_EXPANDED or item.flag_bits & 1: + raise UpdateError("安装包大小或加密格式异常") + mode = item.external_attr >> 16 + kind = stat.S_IFMT(mode) + if kind not in (0, stat.S_IFREG, stat.S_IFDIR, stat.S_IFLNK): + raise UpdateError("安装包包含特殊文件") + if stat.S_ISLNK(mode): + if windows or item.file_size > 4096: + raise UpdateError("安装包包含无效链接") + links.add(path) + entries.append((item, path, mode)) + if len(roots) != 1: + raise UpdateError("安装包目录结构异常") + root = output / next(iter(roots)) + for item, path, mode in entries: + if any(parent in links for parent in path.parents): + raise UpdateError("安装包试图写入链接目录") + if path in links: + link = file.read(item).decode("utf-8") + if "\\" in link or "\x00" in link or PurePosixPath(link).is_absolute(): + raise UpdateError("安装包链接目标无效") + resolved = (output / Path(str(path)).parent / link).resolve() + if not resolved.is_relative_to(root.resolve()): + raise UpdateError("安装包链接超出应用目录") + output.mkdir(mode=0o700, parents=True, exist_ok=False) + # Materialize links last so subsequent writes cannot traverse a symlink. + for item, path, mode in entries: + destination = output / Path(str(path)) + destination.parent.mkdir(parents=True, exist_ok=True) + if item.is_dir(): + destination.mkdir(exist_ok=True) + elif path not in links: + with file.open(item) as source, destination.open("wb") as target: + shutil.copyfileobj(source, target) + destination.chmod((mode & 0o777) or 0o644) + for item, path, mode in entries: + if path in links: + os.symlink(file.read(item).decode("utf-8"), output / Path(str(path))) + return root + + +def executable_in(root: Path, target_platform: str) -> Path: + return root / ("Contents/MacOS/Seele" if target_platform == "macos-arm64" else "Seele.exe") + + +def resources_in(root: Path, target_platform: str) -> Path: + return root / "Contents/Resources" if target_platform == "macos-arm64" else root + + +def clean_child_env() -> dict: + env = {key: value for key, value in os.environ.items() + if not key.startswith("_PYI_") and key not in ("_MEIPASS2", "SEELE_READY_FILE", + "SEELE_UPDATE_HEALTH")} + env["PYINSTALLER_RESET_ENVIRONMENT"] = "1" + return env + + +def validate_package(root: Path, release: Release, *, smoke: bool = True) -> None: + resources = resources_in(root, release.platform) + exe = executable_in(root, release.platform) + if not exe.is_file(): + raise UpdateError("安装包缺少主程序") + for name, expected in (("VERSION", release.version), ("EDITION", release.edition)): + if (resources / name).read_text(encoding="utf-8-sig").strip() != expected: + raise UpdateError("安装包版本或类型与发布信息不一致") + for name in ("state.json", "image/bss.gif", "image/bs_icon.png"): + if not (resources / name).is_file(): + raise UpdateError("安装包缺少桌宠资源") + if not smoke: + return + if release.platform == "macos-arm64": + subprocess.run(["codesign", "--verify", "--deep", "--strict", str(root)], + check=True, capture_output=True, timeout=90) + with tempfile.TemporaryDirectory(prefix="seele-update-smoke-") as directory: + env = {**clean_child_env(), "QT_QPA_PLATFORM": "offscreen", + "SEELE_DATA_DIR": directory, "SEELE_DISABLE_UPDATES": "1"} + for argument in ("--smoke-test", "--updater-smoke-test"): + result = subprocess.run([str(exe), argument], env=env, cwd=root.parent, + capture_output=True, timeout=90, **hidden_subprocess_kwargs()) + if result.returncode: + raise UpdateError("新版启动自检失败,已保留当前版本") + + +def stage_release(release: Release, progress=None) -> tuple[Path, Path]: + """Downloads/staging only; never changes the running application.""" + if version_tuple(release.version) <= version_tuple(current_version()): + raise UpdateError("禁止安装旧版或当前版本") + work = Path(tempfile.mkdtemp(prefix="job-", dir=updates_dir())) + try: + sums = fetch(release.checksums_url).decode("utf-8-sig") + manifest_bytes = fetch(release.manifest_url) + manifest_file = work / f"release-manifest-{release.edition}.json" + manifest_file.write_bytes(manifest_bytes) + verify_digest(manifest_file, sums, manifest_file.name) + manifest = json.loads(manifest_bytes.decode("utf-8-sig")) + if any(manifest.get(key) != value for key, value in + (("product", "Seele"), ("version", release.version), + ("edition", release.edition), ("platform", release.platform))): + raise UpdateError("发布清单与当前平台不一致") + if not re.fullmatch("[0-9a-f]{40}", str(manifest.get("commit", ""))): + raise UpdateError("发布清单缺少提交标识") + archive = work / release.archive_name + fetch(release.archive_url, limit=MAX_ARCHIVE, destination=archive, + progress=progress, deadline=1200) + verify_digest(archive, sums, release.archive_name) + root = safe_extract(archive, work / "extracted", release.platform) + validate_package(root, release) + archive.unlink() + return work, root + except Exception: + shutil.rmtree(work, ignore_errors=True) + raise + + +def updates_dir() -> Path: + directory = get_data_dir() / "updates" + directory.mkdir(mode=0o700, exist_ok=True) + return directory + + +def install_root() -> Path | None: + exe = Path(sys.executable).resolve() + if getattr(sys, "frozen", False): + if sys.platform == "darwin" and exe.parent.name == "MacOS": + return exe.parents[2] + if sys.platform == "win32": + return exe.parent + # The Windows VIP contains an embedded Python, and can move to official EXE. + if sys.platform == "win32" and exe.parent.name == "runtime": + root = exe.parent.parent + if (root / "run_exe.py").is_file() and (root / "EDITION").is_file(): + return root + return None + + +def helper_path() -> Path: + name = "SeeleUpdater.exe" if sys.platform == "win32" else "SeeleUpdater" + return app_path("bin", name) + + +def old_launch_command(root: Path) -> list[str]: + if (root / "runtime/pythonw.exe").is_file(): + return [str(root / "runtime/pythonw.exe"), str(root / "run_exe.py")] + return [str(executable_in(root, update_platform()))] + + +def start_helper(work: Path, staged: Path, release: Release, *, + target: Path | None = None, operation: str = "update") -> Path: + target = target or install_root() + if target is None: + raise UpdateError("源码运行仅支持检查更新,请使用安装包") + if target.is_symlink() or not os.access(target.parent, os.W_OK): + raise UpdateError("应用目录不可写,请将 Seele 安装到当前用户可写的目录") + migrate_settings() + binary = helper_path() + if binary.is_file(): + copied = work / binary.name + shutil.copy2(binary, copied) + copied.chmod(0o755) + command = [str(copied)] + elif sys.platform == "win32" and not getattr(sys, "frozen", False): + # Copy the independent runtime: the installed Python may be replaced. + runtime = work / "runtime" + shutil.copytree(Path(sys.executable).parent, runtime) + for name in ("app_update.py", "platform_utils.py", "app_update_entry.py"): + shutil.copy2(app_path(name), work / name) + (runtime / "python311._pth").write_text("python311.zip\n.\n..\n", encoding="ascii") + command = [str(runtime / "python.exe"), str(work / "app_update_entry.py")] + else: + raise UpdateError("安装包缺少独立更新程序") + job = { + "target": str(target), "staged": str(staged), "parent_pid": os.getpid(), + "version": release.version, "platform": release.platform, + "edition": release.edition, "operation": operation, + "data_dir": str(get_data_dir()), "nonce": uuid.uuid4().hex, + "old_command": old_launch_command(install_root() or target), + } + atomic_json(work / "job.json", job) + subprocess.Popen([*command, "--apply", str(work / "job.json")], cwd=work, + env=clean_child_env(), stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, start_new_session=True, + **hidden_subprocess_kwargs()) + ready = work / "ready.json" + deadline = time.monotonic() + 120 + while time.monotonic() < deadline: + if ready.is_file(): + payload = json.loads(ready.read_text(encoding="utf-8")) + if payload.get("ok"): + atomic_json(work / "activate.json", {"nonce": job["nonce"]}) + return work + raise UpdateError(str(payload.get("error", "更新程序准备失败"))) + time.sleep(0.1) + raise UpdateError("更新程序准备超时,当前版本保持运行") + + +def pid_alive(pid: int) -> bool: + if sys.platform == "win32": + kernel = ctypes.WinDLL("kernel32", use_last_error=True) + kernel.OpenProcess.restype = ctypes.c_void_p + handle = kernel.OpenProcess(0x1000, False, pid) + if not handle: + return False + try: + code = ctypes.c_ulong() + return bool(kernel.GetExitCodeProcess(ctypes.c_void_p(handle), ctypes.byref(code)) + and code.value == 259) + finally: + kernel.CloseHandle(ctypes.c_void_p(handle)) + try: + os.kill(pid, 0) + return True + except ProcessLookupError: + return False + except PermissionError: + return True + + +def replace_with_retry(source: Path, target: Path) -> None: + deadline = time.monotonic() + 30 + while True: + try: + os.replace(source, target) + return + except PermissionError: + if time.monotonic() >= deadline: + raise + time.sleep(0.25) + + +def apply_transaction(job: dict, work: Path, *, wait_parent_seconds=120, + health_seconds=75, launcher=None) -> bool: + """Prepare before quit, atomically replace, restart, or restore the backup.""" + target = Path(job["target"]) + staged = Path(job["staged"]) + nonce = job["nonce"] + prepared = target.with_name(f".{target.name}-new-{nonce}") + backup = target.with_name(f".{target.name}-old-{nonce}") + health = work / "health.json" + status = Path(job["data_dir"]) / "update-status.json" + launch = launcher or (lambda command, env: subprocess.Popen( + command, env=env, cwd=target.parent, **hidden_subprocess_kwargs())) + moved_old = False + installed = False + success = False + child = None + lock = None + try: + lock_path = target.parent / f".{target.name}.update.lock" + fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600) + lock = os.fdopen(fd, "r+b") + if sys.platform == "win32": + import msvcrt + if lock_path.stat().st_size == 0: + lock.write(b"0") + lock.flush() + lock.seek(0) + msvcrt.locking(lock.fileno(), msvcrt.LK_NBLCK, 1) + else: + import fcntl + fcntl.flock(lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + if prepared.exists() or backup.exists(): + raise UpdateError("更新目录冲突") + shutil.copytree(staged, prepared, symlinks=True) + if job.get("operation") == "update": + # Retain Inno Setup's uninstall entry and registry integration. + for file in target.glob("unins000*"): + if file.is_file() and not file.is_symlink(): + shutil.copy2(file, prepared / file.name) + if job["platform"] == "windows-x64" and (target / "启动希儿.vbs").is_file(): + # Keep the VIP's familiar launcher when moving to official EXE. + (prepared / "启动希儿.vbs").write_text( + 'Set fso = CreateObject("Scripting.FileSystemObject")\r\n' + 'Set shell = CreateObject("WScript.Shell")\r\n' + 'root = fso.GetParentFolderName(WScript.ScriptFullName)\r\n' + 'shell.Run Chr(34) & root & "\\Seele.exe" & Chr(34), 0, False\r\n', + encoding="utf-16") + atomic_json(work / "ready.json", {"ok": True}) + deadline = time.monotonic() + wait_parent_seconds + while pid_alive(job["parent_pid"]) or not (work / "activate.json").is_file(): + if time.monotonic() >= deadline: + raise UpdateError("旧程序未退出,更新已取消") + time.sleep(0.1) + if json.loads((work / "activate.json").read_text()).get("nonce") != nonce: + raise UpdateError("更新授权状态无效") + if target.exists(): + replace_with_retry(target, backup) + moved_old = True + replace_with_retry(prepared, target) + installed = True + command = [str(executable_in(target, job["platform"])), + "--update-health", str(health), nonce] + env = {**clean_child_env(), "SEELE_DATA_DIR": job["data_dir"]} + child = launch(command, env) + deadline = time.monotonic() + health_seconds + while time.monotonic() < deadline: + if health.exists(): + result = json.loads(health.read_text(encoding="utf-8")) + if (result.get("nonce") == nonce and result.get("version") == job["version"] + and result.get("pid") == child.pid and child.poll() is None): + success = True + atomic_json(status, {"ok": True, "version": job["version"]}) + break + if child.poll() is not None: + break + time.sleep(0.1) + if not success: + raise UpdateError("新版启动失败,已恢复原版本") + except Exception as exc: + if child and child.poll() is None: + child.terminate() + try: + child.wait(timeout=15) + except subprocess.TimeoutExpired: + child.kill() + child.wait(timeout=15) + if installed and target.exists(): + shutil.rmtree(target) + if moved_old and backup.exists(): + replace_with_retry(backup, target) + atomic_json(status, {"ok": False, "error": str(exc)}) + if not (work / "ready.json").exists(): + atomic_json(work / "ready.json", {"ok": False, "error": str(exc)}) + elif installed or moved_old: + try: + launch(job["old_command"], clean_child_env()) + except OSError: + pass + finally: + if prepared.exists(): + shutil.rmtree(prepared, ignore_errors=True) + if success and backup.exists(): + shutil.rmtree(backup, ignore_errors=True) + if lock: + lock.close() + return success + + +def write_health(path: str, nonce: str) -> None: + atomic_json(Path(path), {"pid": os.getpid(), "version": current_version(), "nonce": nonce}) + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--self-check", action="store_true") + parser.add_argument("--apply", type=Path) + args = parser.parse_args(argv) + if args.self_check: + return 0 + if not args.apply: + return 2 + work = args.apply.resolve().parent + job = json.loads(args.apply.read_text(encoding="utf-8")) + result = apply_transaction(job, work) + # Windows locks the helper/runtime until process exit. The next startup + # removes completed work directories once that process is gone. + atomic_json(work / "completed.json", {"helper_pid": os.getpid()}) + return 0 if result else 1 + + +def cleanup_completed() -> None: + for work in updates_dir().glob("job-*"): + marker = work / "completed.json" + try: + if marker.is_file() and not pid_alive(json.loads(marker.read_text())["helper_pid"]): + shutil.rmtree(work) + except (OSError, ValueError, KeyError): + pass + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/app_update_entry.py b/app_update_entry.py new file mode 100644 index 0000000..b62106c --- /dev/null +++ b/app_update_entry.py @@ -0,0 +1,5 @@ +"""Frozen as a separate one-file executable by all release builds.""" +from app_update import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/botmux_client.py b/botmux_client.py index 65f6a7a..32bbd83 100644 --- a/botmux_client.py +++ b/botmux_client.py @@ -4,6 +4,7 @@ import json import os +import sys import threading import time import uuid @@ -14,7 +15,7 @@ from urllib.parse import quote from urllib.request import Request, urlopen -from platform_utils import app_path +from platform_utils import app_path, data_path DEFAULT_CONFIG = { @@ -61,7 +62,7 @@ def discover_local_dashboard(botmux_home: str | os.PathLike | None = None) -> di info: dict = {} try: port = (base / ".dashboard-port").read_text(encoding="utf-8").strip() - if port.isdigit(): + if port.isdigit() and 1 <= int(port) <= 65535: info["port"] = int(port) except (OSError, ValueError): pass @@ -71,15 +72,36 @@ def discover_local_dashboard(botmux_home: str | os.PathLike | None = None) -> di info["token"] = token except OSError: pass + if not info and botmux_home is None and sys.platform == "win32": + info = discover_wsl_dashboard() return info +def discover_wsl_dashboard() -> dict: + """Use the selected user's UNC files; never copy WSL credentials to Windows.""" + from windows_byte_bootstrap import read_status + + status = read_status() + distro = status.get("distro") + home = status.get("linux_home") + if (not isinstance(distro, str) or not distro or any(c in distro for c in "\\/:") + or not isinstance(home, str) or not home.startswith("/") + or "\\" in home or ".." in home.split("/")): + return {} + for prefix in ("wsl.localhost", "wsl$"): + base = Path(f"\\\\{prefix}\\{distro}" + home.replace("/", "\\") + "\\.botmux") + found = discover_local_dashboard(base) + if found: + return found + return {} + + def load_config( path: str | os.PathLike | None = None, *, discover: bool = True, ) -> dict: - config_path = Path(path) if path else app_path("botmux_config.json") + config_path = Path(path) if path else data_path("botmux_config.json") raw = {} try: with config_path.open("r", encoding="utf-8") as file: @@ -513,6 +535,8 @@ def _run(self) -> None: try: client = BotmuxClient(self.config) state.replace(client.get_sessions()) + if self.stop_event.is_set(): + return self.callback(state.summary()) backoff = 1 if not self.config.get("events_enabled", True): @@ -524,6 +548,8 @@ def _run(self) -> None: if self.stop_event.is_set(): return except (BotmuxError, TypeError, ValueError) as exc: + if self.stop_event.is_set(): + return self.callback(state.summary(connected=False, error=str(exc))) self.stop_event.wait(backoff) backoff = min(backoff * 2, 30) diff --git a/botmux_setup_coach.py b/botmux_setup_coach.py index c0bac3a..b820461 100644 --- a/botmux_setup_coach.py +++ b/botmux_setup_coach.py @@ -3,6 +3,7 @@ from __future__ import annotations import codecs +import base64 import errno import os import re @@ -108,8 +109,35 @@ def _normalize_login_url(candidate: str) -> str | None: return value +def _wsl_powershell(script: str) -> list[str] | None: + if os.environ.get("SEELE_WINDOWS_WSL") != "1": + return None + executable = shutil.which("powershell.exe") + if not executable: + fallback = Path("/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe") + if fallback.is_file(): + executable = str(fallback) + if not executable: + return None + encoded = base64.b64encode(script.encode("utf-16le")).decode("ascii") + return [executable, "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded] + + +def _ps_string(value: str) -> str: + return "'" + value.replace("'", "''") + "'" + + def open_login_url(url: str) -> bool: - commands = [ + wsl = _wsl_powershell(f""" +$url = {_ps_string(url)} +$chrome = @("$env:ProgramFiles\\Google\\Chrome\\Application\\chrome.exe", + "${{env:ProgramFiles(x86)}}\\Google\\Chrome\\Application\\chrome.exe", + "$env:LOCALAPPDATA\\Google\\Chrome\\Application\\chrome.exe") | + Where-Object {{ Test-Path $_ }} | Select-Object -First 1 +if ($chrome) {{ Start-Process -FilePath $chrome -ArgumentList $url }} +else {{ Start-Process $url }} +""") + commands = [wsl] if wsl else [ ["/usr/bin/open", "-a", "Google Chrome", url], ["/usr/bin/open", url], ] @@ -137,6 +165,9 @@ def focus_terminal_for_login() -> bool: sys.stdout.flush() except OSError: pass + if os.environ.get("SEELE_WINDOWS_WSL") == "1": + # The Windows installer already owns a visible console. Keep its output. + return True try: result = subprocess.run( [ @@ -424,7 +455,28 @@ def play(self, cue: VoiceCue) -> None: path = Path(resources) / "audio" / "mac_byte" / cue.filename else: path = app_path("audio", "mac_byte", cue.filename) - if path.is_file() and Path("/usr/bin/afplay").is_file(): + wsl = None + if os.environ.get("SEELE_WINDOWS_WSL") == "1": + try: + windows_path = subprocess.run( + ["wslpath", "-w", str(path)], capture_output=True, + text=True, check=True, timeout=5, + ).stdout.strip() + wsl = _wsl_powershell(f""" +if (Test-Path {_ps_string(windows_path)}) {{ + $player = New-Object System.Media.SoundPlayer {_ps_string(windows_path)} + $player.PlaySync() +}} else {{ + Add-Type -AssemblyName System.Speech + $speaker = New-Object System.Speech.Synthesis.SpeechSynthesizer + $speaker.Speak({_ps_string(cue.text)}) +}} +""") + except (OSError, subprocess.SubprocessError): + pass + if wsl: + command = wsl + elif path.is_file() and Path("/usr/bin/afplay").is_file(): command = ["/usr/bin/afplay", str(path)] elif Path("/usr/bin/say").is_file(): command = ["/usr/bin/say", cue.text] diff --git a/initialize.py b/initialize.py index 2c8fea8..f9701ed 100644 --- a/initialize.py +++ b/initialize.py @@ -3,7 +3,7 @@ import write_file as wf import webbrowser import play_vioce as pv -from platform_utils import app_path, get_tools_dir +from platform_utils import app_path, data_path, get_tools_dir def get_tools_folder_path(): @@ -27,7 +27,7 @@ def set_file(current_dir): # 获取下载路径 download_path = os.path.join(os.path.expanduser("~"), "Downloads") # 实时获取和当前文件同级目录的 state.json 文件路径 - src_file = os.path.join(current_dir, 'state.json') + src_file = str(data_path("state.json")) installation_package_path = os.path.join(current_dir,'installationPackage') # 创建文件夹 folder_path = get_tools_folder_path() @@ -69,7 +69,7 @@ def open_url(url): def main(current_dir): pv.main("1.wav") set_file(current_dir) - state_path = app_path("state.json") + state_path = data_path("state.json") dic = wf.read_dict_from_json(state_path) or {} open_url("https://www.yingdao.com/product/") open_url("https://api.winrobot360.com/redirect/robot/share?inviteKey=050edc1539037e9f") diff --git a/packaging/build_mac_vip.py b/packaging/build_mac_vip.py new file mode 100644 index 0000000..2b2cbce --- /dev/null +++ b/packaging/build_mac_vip.py @@ -0,0 +1,85 @@ +"""Local Apple Silicon test package; never commits or publishes.""" + +from __future__ import annotations + +import hashlib +import pathlib +import shutil +import subprocess +import sys +import tempfile + +ROOT = pathlib.Path(__file__).resolve().parents[1] +RESOURCES = [ + "audio", "image", "botmux-skill", "audio_name_dic.json", + "botmux_config.example.json", "bubble_texts.json", "config.json", + "file_name.json", "state.json", "uid.json", "README.md", "CHANGELOG.md", + "DEVELOPMENT_LOG.md", "MAC_BYTE_AUTO_INSTALL_COMMANDS.md", + "MAC_BYTE_ZERO_TOUCH_RESEARCH.md", "WINDOWS_BYTE_INSTALL.md", + "EDITION", "VERSION", "LICENSE", +] + + +def run(*command: str, **kwargs): + subprocess.run(list(command), check=True, cwd=ROOT, **kwargs) + + +def main(): + version = (ROOT / "VERSION").read_text().strip() + target = ROOT / "vip-packages" / f"Seele-{version}-vip-test-mac_byte-macos-arm64.zip" + with tempfile.TemporaryDirectory(prefix="seele-mac-vip-") as directory: + work = pathlib.Path(directory) + helper = work / "helper" + run(sys.executable, "-m", "PyInstaller", "--noconfirm", "--clean", "--onefile", + "--console", "--name", "SeeleBotmuxCoach", "--distpath", str(helper), + "--workpath", str(work / "coach-work"), "--specpath", str(work), + "botmux_setup_coach_entry.py") + run(sys.executable, "-m", "PyInstaller", "--noconfirm", "--clean", "--onefile", + "--console", "--name", "SeeleUpdater", "--distpath", str(helper), + "--workpath", str(work / "updater-work"), "--specpath", str(work), + "app_update_entry.py") + command = [ + sys.executable, "-m", "PyInstaller", "--noconfirm", "--clean", "--windowed", + "--name", "Seele", "--icon", str(ROOT / "build/Seele.icns"), + "--osx-bundle-identifier", "com.xinna2001.seele.mac-byte", + "--contents-directory", ".", "--collect-all", "qt_material", + "--distpath", str(work / "dist"), "--workpath", str(work / "app-work"), + "--specpath", str(work), + ] + for name in RESOURCES: + source = ROOT / name + if name in ("EDITION", "VERSION"): + source = work / name + source.write_text("mac_byte\n" if name == "EDITION" else version + "\n") + command += ["--add-data", f"{source}:{name if (ROOT / name).is_dir() else '.'}"] + run(*command, "run_exe.py") + app = work / "dist/Seele.app" + resources = app / "Contents/Resources" + (resources / "bin").mkdir(parents=True, exist_ok=True) + shutil.copy2(helper / "SeeleBotmuxCoach", resources / "bin/SeeleBotmuxCoach") + (resources / "bin/SeeleBotmuxCoach").chmod(0o755) + shutil.copy2(helper / "SeeleUpdater", resources / "bin/SeeleUpdater") + (resources / "bin/SeeleUpdater").chmod(0o755) + for key in ("CFBundleShortVersionString", "CFBundleVersion"): + import plistlib + plist = app / "Contents/Info.plist" + info = plistlib.loads(plist.read_bytes()) + info[key] = version + plist.write_bytes(plistlib.dumps(info)) + run("codesign", "--force", "--deep", "--sign", "-", str(app)) + run("codesign", "--verify", "--deep", "--strict", str(app)) + import os + run(str(app / "Contents/MacOS/Seele"), "--smoke-test", + env={**os.environ, "QT_QPA_PLATFORM": "offscreen"}) + run(str(app / "Contents/MacOS/Seele"), "--botmux-coach-smoke-test") + run(str(app / "Contents/MacOS/Seele"), "--updater-smoke-test") + target.parent.mkdir(parents=True, exist_ok=True) + run("ditto", "-c", "-k", "--sequesterRsrc", "--keepParent", str(app), str(target)) + run("unzip", "-tq", str(target)) + digest = hashlib.sha256(target.read_bytes()).hexdigest() + target.with_suffix(".zip.sha256").write_text(f"{digest} {target.name}\n") + print(target) + + +if __name__ == "__main__": + main() diff --git a/packaging/windows/Seele.iss b/packaging/windows/Seele.iss index fb72c0f..d7af39c 100644 --- a/packaging/windows/Seele.iss +++ b/packaging/windows/Seele.iss @@ -2,21 +2,32 @@ #define MyAppVersion "1.0.0" #endif -#define MyAppName "Seele" +#ifndef MyEdition + #define MyEdition "windows" +#endif +#if MyEdition == "windows_byte" + #define MyAppName "Seele 字节版" + #define MyAppDir "Seele-WindowsByte" + #define MyAppId "{{2AFB2C9D-2D29-4423-B52C-83F276308B34}" +#else + #define MyAppName "Seele" + #define MyAppDir "Seele" + #define MyAppId "{{DA99B386-2A68-4FB0-8FB6-E84D8B5AEE28}" +#endif #define MyAppPublisher "Seele Project" #define MyAppExeName "Seele.exe" [Setup] -AppId={{DA99B386-2A68-4FB0-8FB6-E84D8B5AEE28} +AppId={#MyAppId} AppName={#MyAppName} AppVersion={#MyAppVersion} AppPublisher={#MyAppPublisher} -DefaultDirName={localappdata}\Programs\{#MyAppName} +DefaultDirName={localappdata}\Programs\{#MyAppDir} DefaultGroupName={#MyAppName} DisableProgramGroupPage=yes PrivilegesRequired=lowest OutputDir=..\..\release -OutputBaseFilename=Seele-{#MyAppVersion}-windows-x64-setup +OutputBaseFilename=Seele-{#MyAppVersion}-{#MyEdition}-x64-setup SetupIconFile=..\..\exe.ico UninstallDisplayIcon={app}\{#MyAppExeName} Compression=lzma2 @@ -42,3 +53,13 @@ Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: de [Run] Filename: "{app}\{#MyAppExeName}"; Description: "启动 {#MyAppName}"; Flags: nowait postinstall skipifsilent + +#if MyEdition == "windows_byte" +[UninstallRun] +Filename: "{sys}\schtasks.exe"; Parameters: "/Delete /TN Seele-WindowsByte-Resume /F"; Flags: runhidden; RunOnceId: "DeleteSeeleResumeTask" +Filename: "{sys}\schtasks.exe"; Parameters: "/End /TN Seele-Botmux-WSL"; Flags: runhidden; RunOnceId: "EndSeeleWSLTask" +Filename: "{sys}\schtasks.exe"; Parameters: "/Delete /TN Seele-Botmux-WSL /F"; Flags: runhidden; RunOnceId: "DeleteSeeleWSLTask" + +[Registry] +Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueName: "SeeleWindowsByteResume"; Flags: uninsdeletevalue +#endif diff --git a/packaging/windows/build_vip.py b/packaging/windows/build_vip.py new file mode 100644 index 0000000..e21245f --- /dev/null +++ b/packaging/windows/build_vip.py @@ -0,0 +1,142 @@ +"""Build a Windows x64 Python/Qt portable test ZIP from macOS or Linux. + +This is a source-based portable package, not a cross-compiled PyInstaller EXE. +The work directory is temporary; no local credentials or runtime configs ship. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import pathlib +import shutil +import subprocess +import sys +import tempfile +import urllib.request +import zipfile + +ROOT = pathlib.Path(__file__).resolve().parents[2] +DEPENDENCIES = ["PyQt5>=5.15.9,<5.16", "qt-material>=2.14,<3", + "sounddevice>=0.5,<1", "soundfile>=0.13,<1", "certifi>=2024.2"] +SOURCES = [ + "Seele.py", "run_exe.py", "platform_utils.py", "windows_byte_bootstrap.py", + "windows_byte_linux.py", "mac_byte_bootstrap.py", "botmux_setup_coach.py", + "Tray.py", "OutVoice.py", "initialize.py", "VoiceToText.py", "StartupMode.py", + "write_file.py", "ProgramsConfigWindow.py", "RoleSwitchWindow.py", "TaskPanelWindow.py", + "botmux_client.py", "rpa_bridge.py", "rpa_service.py", "play_stare.py", + "play_vioce.py", "falseIntent.py", "ProgramLog.py", + "app_update.py", "app_update_entry.py", "UpdateUi.py", + "audio_name_dic.json", "botmux_config.example.json", "bubble_texts.json", + "config.json", "file_name.json", "state.json", "uid.json", "VERSION", "LICENSE", + "README.md", "CHANGELOG.md", "DEVELOPMENT_LOG.md", "WINDOWS_BYTE_INSTALL.md", +] + + +def build(output: pathlib.Path) -> pathlib.Path: + version = (ROOT / "VERSION").read_text().strip() + output.mkdir(parents=True, exist_ok=True) + target = output / f"Seele-{version}-vip-test-windows_byte-x64-portable.zip" + with tempfile.TemporaryDirectory(prefix="seele-windows-vip-") as directory: + work = pathlib.Path(directory) + package = work / "Seele-WindowsByte" + runtime = package / "runtime" + runtime.mkdir(parents=True) + archive = work / "python.zip" + python_url = "https://www.python.org/ftp/python/3.11.9/python-3.11.9-embed-amd64.zip" + urllib.request.urlretrieve(python_url, archive) + with zipfile.ZipFile(archive) as file: + file.extractall(runtime) + (runtime / "python311._pth").write_text( + "python311.zip\n.\n..\n../site-packages\nimport site\n", encoding="ascii", + ) + wheels = work / "wheels" + subprocess.run([ + sys.executable, "-m", "pip", "download", "--only-binary=:all:", + "--platform", "win_amd64", "--python-version", "311", + "--implementation", "cp", "--abi", "cp311", + "--dest", str(wheels), *DEPENDENCIES, + ], check=True) + site = package / "site-packages" + site.mkdir() + for wheel in wheels.glob("*.whl"): + with zipfile.ZipFile(wheel) as file: + for item in file.infolist(): + parts = pathlib.PurePosixPath(item.filename).parts + if not parts or ".." in parts: + raise ValueError("Invalid wheel path") + if parts[0].endswith(".data"): + if len(parts) < 3 or parts[1] not in ("purelib", "platlib"): + continue + relative = pathlib.Path(*parts[2:]) + else: + relative = pathlib.Path(*parts) + destination = site / relative + if item.is_dir(): + destination.mkdir(parents=True, exist_ok=True) + else: + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(file.read(item)) + for name in SOURCES: + shutil.copy2(ROOT / name, package / name) + for name in ("audio", "image", "botmux-skill"): + shutil.copytree(ROOT / name, package / name, + ignore=shutil.ignore_patterns("__pycache__", ".DS_Store", "config.json")) + (package / "EDITION").write_text("windows_byte\n", encoding="ascii") + # WSH launches pythonw without a terminal; the bootstrap opens its own console. + (package / "启动希儿.vbs").write_text( + 'Set fso = CreateObject("Scripting.FileSystemObject")\r\n' + 'Set shell = CreateObject("WScript.Shell")\r\n' + 'root = fso.GetParentFolderName(WScript.ScriptFullName)\r\n' + 'shell.CurrentDirectory = root\r\n' + 'shell.Run Chr(34) & root & "\\runtime\\pythonw.exe" & Chr(34) & " " & ' + 'Chr(34) & root & "\\run_exe.py" & Chr(34), 0, False\r\n', + encoding="utf-16", + ) + (package / "查看启动问题.cmd").write_text( + '@echo off\r\nchcp 65001 >nul\r\ncd /d "%~dp0"\r\n' + '"%~dp0runtime\\python.exe" "%~dp0run_exe.py"\r\npause\r\n', + encoding="utf-8", + ) + (package / "先读我.txt").write_text( + "完整解压后双击「启动希儿.vbs」。Windows 主机无需另装 Python。\n" + "请解压到固定目录,安装与重启续装完成前不要移动。\n" + "设置保存在 %LOCALAPPDATA%\\Seele\\windows_byte,更新会保留。\n" + "启动及每小时检查 GitHub 新版;右键最后一项「检查更新」出现红点时点击更新。\n" + "首次启用 WSL 若要求重启,进度已保存,登录后会自动继续。\n" + "若双击无窗口,双击「查看启动问题.cmd」。\n" + "这是内置 Windows Python 与 Qt 的源码便携测试包,不是已验收的 EXE 安装包。\n" + "本机已做模拟测试与包结构校验;真实 Windows 授权、WSL 重启需验收。\n" + "详细流程与运维见 WINDOWS_BYTE_INSTALL.md。\n", encoding="utf-8", + ) + manifest = { + "product": "Seele", "version": version, "edition": "windows_byte", + "platform": "windows-x64", "format": "embedded-python-portable", + "python": "3.11.9", "python_source": python_url, + "wheels": sorted(wheel.name for wheel in wheels.glob("*.whl")), + "windows_executed": False, "released": False, + } + (package / "test-package-manifest.json").write_text( + json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8", + ) + with zipfile.ZipFile(target, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as file: + for source in sorted(package.rglob("*")): + if source.is_file(): + file.write(source, source.relative_to(work)) + with zipfile.ZipFile(target) as file: + if file.testzip() is not None: + raise RuntimeError("ZIP validation failed") + for required in ("runtime/python.exe", "runtime/pythonw.exe", "site-packages/PyQt5/Qt5/bin/Qt5Core.dll", + "botmux-skill/dist/mcp/server.js", "windows_byte_linux.py", "EDITION"): + if "Seele-WindowsByte/" + required not in file.namelist(): + raise RuntimeError(f"Missing package resource: {required}") + digest = hashlib.sha256(target.read_bytes()).hexdigest() + target.with_suffix(".zip.sha256").write_text(f"{digest} {target.name}\n", encoding="ascii") + return target + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--output", type=pathlib.Path, default=ROOT / "vip-packages") + print(build(parser.parse_args().output)) diff --git a/platform_utils.py b/platform_utils.py index 002b309..520f4ba 100644 --- a/platform_utils.py +++ b/platform_utils.py @@ -3,8 +3,11 @@ from __future__ import annotations import os +import json +import re import subprocess import sys +import tempfile import webbrowser from pathlib import Path from typing import Iterable @@ -32,6 +35,94 @@ def app_path(*parts: str) -> Path: return get_base_dir().joinpath(*parts) +def get_edition() -> str: + edition = os.environ.get("SEELE_EDITION", "").strip() + if not edition: + try: + edition = app_path("EDITION").read_text(encoding="utf-8").strip() + except OSError: + edition = "windows" if sys.platform == "win32" else "mac_byte" + return edition if re.fullmatch(r"[a-zA-Z0-9_-]+", edition) else "default" + + +def atomic_json(path: Path, value: dict) -> None: + """Replace a complete JSON document; never truncate the last good copy.""" + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + fd, temporary = tempfile.mkstemp(prefix="." + path.name, dir=path.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8") as file: + json.dump(value, file, ensure_ascii=False, indent=2) + file.flush() + os.fsync(file.fileno()) + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def get_data_dir() -> Path: + override = os.environ.get("SEELE_DATA_DIR", "").strip() + if override: + root = Path(override).expanduser() + elif sys.platform == "darwin": + root = Path.home() / "Library/Application Support/Seele" / get_edition() + elif sys.platform == "win32": + root = Path(os.environ.get("LOCALAPPDATA", str(Path.home() / "AppData/Local"))) + root = root / "Seele" / get_edition() + else: + root = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share"))) + root = root / "Seele" / get_edition() + root.mkdir(mode=0o700, parents=True, exist_ok=True) + return root.resolve() + + +def data_path(name: str) -> Path: + """Migrate each bundled legacy setting once, retaining the user's copy.""" + if Path(name).name != name: + raise ValueError("Setting name must be a filename") + target = get_data_dir() / name + if target.exists(): + return target + source = app_path(name) + # A freshly downloaded macOS ZIP may be opened before replacing an older + # installed app. Import that app's settings before initializing defaults. + if sys.platform == "darwin" and getattr(sys, "frozen", False): + for installed in (Path("/Applications/Seele.app"), + Path.home() / "Applications/Seele.app"): + resources = installed / "Contents/Resources" + try: + if (resources / "EDITION").read_text().strip() == get_edition(): + if (resources / name).is_file() and resources.resolve() != get_base_dir().resolve(): + source = resources / name + break + except OSError: + pass + try: + value = json.loads(source.read_text(encoding="utf-8")) + if not isinstance(value, dict): + return target + # Publish a complete file without replacing a concurrent first-run copy. + fd, temporary = tempfile.mkstemp(prefix=".migrate-", dir=target.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8") as file: + json.dump(value, file, ensure_ascii=False, indent=2) + file.flush() + os.fsync(file.fileno()) + os.link(temporary, target) + finally: + os.unlink(temporary) + except (FileExistsError, FileNotFoundError, json.JSONDecodeError): + pass + return target + + +def migrate_settings() -> None: + for name in ("state.json", "uid.json", "file_name.json", "config.json", + "botmux_config.json"): + data_path(name) + + def get_tools_dir() -> Path: configured = os.environ.get("SEELE_TOOLS_DIR", "").strip() if configured: diff --git a/play_vioce.py b/play_vioce.py index 1397a07..3882557 100644 --- a/play_vioce.py +++ b/play_vioce.py @@ -1,5 +1,5 @@ import write_file as wf -from platform_utils import app_path +from platform_utils import app_path, data_path try: import sounddevice as sd @@ -20,7 +20,7 @@ def main(file_name): return def yingdao_main(yingdao_name,file_name,is_finish=False): - state_path = app_path("state.json") + state_path = data_path("state.json") dic = wf.read_dict_from_json(state_path) or {} if dic.get(yingdao_name)=="0": main(file_name) diff --git a/requirements.txt b/requirements.txt index 7b4eaf0..8aadcf3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ pyinstaller>=6.11,<7 qt-material>=2.14,<3 sounddevice>=0.5,<1 soundfile>=0.13,<1 +certifi>=2024.2 # Windows-only helpers used by packaging and native integrations. PyAutoGUI>=0.9.54,<1; sys_platform == "win32" diff --git a/rpa_service.py b/rpa_service.py index 2ade7ea..d340617 100644 --- a/rpa_service.py +++ b/rpa_service.py @@ -15,6 +15,8 @@ from platform_utils import ( app_path, + get_data_dir, + migrate_settings, get_tools_dir, open_external, platform_label, @@ -61,7 +63,9 @@ def _read_json(path: Path) -> dict: def list_workflows(base_dir: Path | None = None) -> list[WorkflowDefinition]: - root = base_dir or app_path() + if base_dir is None: + migrate_settings() + root = base_dir or get_data_dir() uids = _read_json(root / "uid.json") trigger_files = _read_json(root / "file_name.json") names = sorted(set(uids) | set(trigger_files)) @@ -207,7 +211,9 @@ def _trigger_workflow_unlocked( base_dir: Path | None = None, tools_dir: Path | None = None, ) -> WorkflowResult: - root = base_dir or app_path() + if base_dir is None: + migrate_settings() + root = base_dir or get_data_dir() tools = tools_dir or get_tools_dir() workflow = resolve_workflow(name_or_id, root) request_id = str(request_id or uuid.uuid4()) diff --git a/run_exe.py b/run_exe.py index 5af649f..fdf75e1 100644 --- a/run_exe.py +++ b/run_exe.py @@ -40,6 +40,14 @@ def _run_splash(ready_file): def _run_smoke_test(): os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ["SEELE_EDITION"] = "packaging-smoke" + os.environ["SEELE_DISABLE_UPDATES"] = "1" + if not os.environ.get("SEELE_DATA_DIR"): + with tempfile.TemporaryDirectory(prefix="seele-smoke-") as directory: + os.environ["SEELE_DATA_DIR"] = directory + try: + return _run_smoke_test() + finally: + os.environ.pop("SEELE_DATA_DIR", None) from PyQt5.QtWidgets import QApplication import Seele @@ -61,12 +69,45 @@ def _run_botmux_coach_smoke_test(): return 0 if helper.is_file() and os.access(helper, os.X_OK) else 1 +def _run_windows_bootstrap_smoke_test(): + import windows_byte_bootstrap + + command = windows_byte_bootstrap.helper_command() + result = subprocess.run([*command, "--self-check"], timeout=45, + **hidden_subprocess_kwargs()) + return result.returncode + + def main(): argv = sys.argv[1:] + if len(argv) == 3 and argv[0] == "--update-health": + os.environ["SEELE_UPDATE_HEALTH"] = argv[1] + "|" + argv[2] + argv = [] + if "--windows-byte" in argv: + os.environ["SEELE_EDITION"] = "windows_byte" + argv = [arg for arg in argv if arg != "--windows-byte"] if argv == ["--smoke-test"]: return _run_smoke_test() + if argv == ["--updater-network-smoke-test"]: + import app_update + result = app_update.check_release() + print("GitHub check passed:", result.version if result else "no newer release") + return 0 + if argv == ["--updater-smoke-test"]: + import app_update + helper = app_update.helper_path() + if not getattr(sys, "frozen", False) and sys.platform == "win32": + entry = app_update.app_path("app_update_entry.py") + return subprocess.run([sys.executable, str(entry), "--self-check"], + timeout=45, **hidden_subprocess_kwargs()).returncode + if not helper.is_file() or not os.access(helper, os.X_OK): + return 1 + return subprocess.run([str(helper), "--self-check"], timeout=45, + **hidden_subprocess_kwargs()).returncode if argv == ["--botmux-coach-smoke-test"]: return _run_botmux_coach_smoke_test() + if argv == ["--windows-bootstrap-smoke-test"]: + return _run_windows_bootstrap_smoke_test() if argv and argv[0] == "--botmux-setup-coach": import botmux_setup_coach return botmux_setup_coach.main(argv[1:]) diff --git a/tests/test_app_update.py b/tests/test_app_update.py new file mode 100644 index 0000000..f765150 --- /dev/null +++ b/tests/test_app_update.py @@ -0,0 +1,377 @@ +import io +import importlib.util +import json +import os +import stat +import subprocess +import sys +import tempfile +import unittest +import zipfile +from pathlib import Path +from unittest.mock import patch + +import app_update as update +import platform_utils as paths + + +class SettingsTests(unittest.TestCase): + def test_migration_survives_replaced_bundle_and_atomic_save(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + bundle = root / "bundle" + bundle.mkdir() + (bundle / "state.json").write_text('{"character":"furina","initialize":"1"}') + with patch.dict(os.environ, {"SEELE_DATA_DIR": str(root / "user")}), \ + patch.object(paths, "get_base_dir", return_value=bundle): + migrated = paths.data_path("state.json") + self.assertEqual(json.loads(migrated.read_text())["character"], "furina") + (bundle / "state.json").write_text('{"character":"xier"}') + self.assertEqual(paths.data_path("state.json"), migrated) + self.assertEqual(json.loads(migrated.read_text())["character"], "furina") + paths.atomic_json(migrated, {"character": "aili"}) + self.assertEqual(json.loads(migrated.read_text())["character"], "aili") + if sys.platform != "win32": + self.assertEqual(stat.S_IMODE(migrated.stat().st_mode), 0o600) + + def test_platform_directories_and_edition_isolation(self): + with tempfile.TemporaryDirectory() as directory, \ + patch.object(paths.Path, "home", return_value=Path(directory)), \ + patch.dict(os.environ, {"SEELE_EDITION": "mac_byte"}, clear=True), \ + patch.object(paths.sys, "platform", "darwin"): + self.assertEqual(paths.get_data_dir(), (Path(directory) / + "Library/Application Support/Seele/mac_byte").resolve()) + with patch.dict(os.environ, {"SEELE_EDITION": "mac"}): + self.assertNotEqual(paths.get_data_dir().name, "mac_byte") + + +class ReleaseTests(unittest.TestCase): + def payload(self, edition="mac_byte", platform="macos-arm64", version="2.6.0"): + suffix = "macos-arm64.zip" if platform == "macos-arm64" else "x64-portable.zip" + names = [f"Seele-{version}-{edition}-{suffix}", + f"release-manifest-{edition}.json", f"SHA256SUMS-{edition}.txt"] + return {"tag_name": "v" + version, "assets": [ + {"name": name, "browser_download_url": + f"https://github.com/xinna2001/Seele/releases/download/v{version}/{name}"} + for name in names]} + + def test_only_newer_stable_exact_platform_and_edition(self): + for edition, platform in (("mac_byte", "macos-arm64"), + ("windows", "windows-x64"), ("windows_byte", "windows-x64")): + with patch.object(update, "fetch", return_value=json.dumps( + self.payload(edition, platform)).encode()): + release = update.check_release("2.5.0", edition, platform) + self.assertEqual(release.edition, edition) + self.assertEqual(release.platform, platform) + self.assertIsNone(update.check_release("2.6.0", edition, platform)) + self.assertIsNone(update.check_release("2.7.0", edition, platform)) + for key in ("draft", "prerelease"): + payload = self.payload() + payload[key] = True + with patch.object(update, "fetch", return_value=json.dumps(payload).encode()): + self.assertIsNone(update.check_release("2.5.0", "mac_byte", "macos-arm64")) + + def test_missing_asset_or_untrusted_source_rejected(self): + payload = self.payload() + payload["assets"][0]["browser_download_url"] = "https://evil.invalid/update.zip" + with patch.object(update, "fetch", return_value=json.dumps(payload).encode()): + with self.assertRaises(update.UpdateError): + update.check_release("2.5.0", "mac_byte", "macos-arm64") + payload = self.payload() + payload["assets"].pop() + with patch.object(update, "fetch", return_value=json.dumps(payload).encode()): + with self.assertRaises(update.UpdateError): + update.check_release("2.5.0", "mac_byte", "macos-arm64") + + def test_anonymous_rate_limit_uses_official_latest_manifest(self): + from urllib.error import HTTPError + manifest = {"product": "Seele", "version": "2.6.0", "edition": "mac_byte", + "platform": "macos-arm64"} + with patch.object(update, "fetch", side_effect=[ + HTTPError(update.LATEST_URL, 403, "rate limit", {}, None), + json.dumps(manifest).encode()]) as fetch: + release = update.check_release("2.5.0", "mac_byte", "macos-arm64") + self.assertEqual(release.version, "2.6.0") + self.assertIn("/releases/latest/download/", fetch.call_args[0][0]) + self.assertIn("/releases/download/v2.6.0/", release.archive_url) + + def test_digest_corruption_duplicate_and_missing_are_rejected(self): + with tempfile.TemporaryDirectory() as directory: + archive = Path(directory) / "app.zip" + archive.write_bytes(b"good") + line = f"{update.sha256(archive)} app.zip\n" + update.verify_digest(archive, line, "app.zip") + for sums in ("", line + line, "f" * 64 + " app.zip"): + with self.assertRaises(update.UpdateError): + update.verify_digest(archive, sums, "app.zip") + archive.write_bytes(b"broken") + with self.assertRaises(update.UpdateError): + update.verify_digest(archive, line, "app.zip") + + def test_staging_full_contract_and_failure_cleanup(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + archive = root / "source.zip" + payload = self.payload() + with patch.object(update, "fetch", return_value=json.dumps(payload).encode()): + release = update.check_release("2.5.0", "mac_byte", "macos-arm64") + with zipfile.ZipFile(archive, "w") as file: + file.writestr("Seele.app/Contents/Resources/VERSION", "2.6.0") + file.writestr("Seele.app/Contents/Resources/EDITION", "mac_byte") + file.writestr("Seele.app/Contents/Resources/state.json", "{}") + file.writestr("Seele.app/Contents/Resources/image/bss.gif", "gif") + file.writestr("Seele.app/Contents/Resources/image/bs_icon.png", "png") + file.writestr("Seele.app/Contents/MacOS/Seele", "exe") + manifest = json.dumps({"product": "Seele", "version": "2.6.0", + "platform": "macos-arm64", "edition": "mac_byte", + "commit": "a" * 40}).encode() + import hashlib + sums = (f"{update.sha256(archive)} {release.archive_name}\n" + f"{hashlib.sha256(manifest).hexdigest()} release-manifest-mac_byte.json") + + def fetch(url, destination=None, **kwargs): + if url == release.checksums_url: + return sums.encode() + if url == release.manifest_url: + return manifest + destination.write_bytes(archive.read_bytes()) + return b"" + + jobs = root / "jobs" + jobs.mkdir() + real_validate = update.validate_package + def validate(package, candidate): + real_validate(package, candidate, smoke=False) + with patch.object(update, "fetch", side_effect=fetch), \ + patch.object(update, "updates_dir", return_value=jobs), \ + patch.object(update, "current_version", return_value="2.5.0"), \ + patch.object(update, "validate_package", side_effect=validate): + work, package = update.stage_release(release) + self.assertTrue((package / "Contents/MacOS/Seele").is_file()) + self.assertFalse((work / release.archive_name).exists()) + import shutil + shutil.rmtree(work) + archive.write_bytes(b"bad download") + with self.assertRaises(update.UpdateError): + update.stage_release(release) + self.assertEqual(list(jobs.iterdir()), []) + + +class ArchiveTests(unittest.TestCase): + def test_ditto_utf8_name_without_zip_unicode_flag(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + archive = root / "app.zip" + name = "app/设置.txt" + fake_name = "app/" + "x" * len("设置".encode("utf-8")) + ".txt" + with zipfile.ZipFile(archive, "w") as file: + file.writestr(fake_name, "settings") + raw = archive.read_bytes().replace(fake_name.encode(), name.encode("utf-8")) + archive.write_bytes(raw) + package = update.safe_extract(archive, root / "out", "macos-arm64") + self.assertEqual((package / "设置.txt").read_text(), "settings") + + def extract(self, entries, platform="macos-arm64"): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + archive = root / "app.zip" + with zipfile.ZipFile(archive, "w") as file: + for name, content, mode in entries: + entry = zipfile.ZipInfo(name) + entry.create_system = 3 + entry.external_attr = mode << 16 + file.writestr(entry, content) + return update.safe_extract(archive, root / "out", platform) + + def test_traversal_symlink_escape_and_symlink_parent_are_rejected(self): + bad = [ + [("../escape", "", 0)], + [("/tmp/escape", "", 0)], + [("app\\..\\escape", "", 0)], + [("Seele.app/link", "../../escape", stat.S_IFLNK | 0o777)], + [("Seele.app/link", "real", stat.S_IFLNK | 0o777), + ("Seele.app/link/file", "", stat.S_IFREG | 0o644)], + ] + for entries in bad: + with self.subTest(entries=entries), self.assertRaises(update.UpdateError): + self.extract(entries) + + @unittest.skipIf(sys.platform == "win32", "Framework symlinks require macOS/POSIX") + def test_framework_symlink_and_executable_permission_preserved(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + archive = root / "app.zip" + with zipfile.ZipFile(archive, "w") as file: + for name, data, mode in ( + ("Seele.app/Contents/MacOS/Seele", b"exe", stat.S_IFREG | 0o755), + ("Seele.app/Contents/Frameworks/Qt/Versions/A/Qt", b"lib", stat.S_IFREG | 0o644), + ("Seele.app/Contents/Frameworks/Qt/Versions/Current", b"A", stat.S_IFLNK | 0o777)): + item = zipfile.ZipInfo(name) + item.create_system = 3 + item.external_attr = mode << 16 + file.writestr(item, data) + package = update.safe_extract(archive, root / "out", "macos-arm64") + self.assertTrue(os.access(package / "Contents/MacOS/Seele", os.X_OK)) + link = package / "Contents/Frameworks/Qt/Versions/Current" + self.assertTrue(link.is_symlink()) + self.assertEqual((link / "Qt").read_bytes(), b"lib") + + def test_windows_case_collision_and_devices_rejected(self): + for entries in ( + [("app/CON.txt", "", 0)], + [("app/test.", "", 0)], + [("app/A.txt", "", 0), ("app/a.txt", "", 0)]): + with self.assertRaises(update.UpdateError): + self.extract(entries, "windows-x64") + + +class TransactionTests(unittest.TestCase): + def transaction(self, healthy=True, parent_running=False): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + target = root / "installed" + target.mkdir() + (target / "old.txt").write_text("old") + (target / "unins000.dat").write_text("inno") + (target / "启动希儿.vbs").write_text("old launcher", encoding="utf-16") + staged = root / "staged" + staged.mkdir() + (staged / "new.txt").write_text("new") + data = root / "data" + data.mkdir() + (data / "state.json").write_text('{"character":"furina"}') + work = root / "job" + work.mkdir() + job = {"target": str(target), "staged": str(staged), "nonce": "test", + "data_dir": str(data), "parent_pid": 1234, "version": "2.6.0", + "platform": "windows-x64", "operation": "update", + "old_command": ["old"]} + paths.atomic_json(work / "activate.json", {"nonce": "test"}) + launches = [] + + def launch(command, env): + launches.append(command) + class Process: + pid = 4567 + def poll(self): + return None if healthy else 1 + if healthy and command[0] != "old": + paths.atomic_json(work / "health.json", { + "version": "2.6.0", "pid": 4567, "nonce": "test"}) + return Process() + with patch.object(update, "pid_alive", return_value=parent_running): + result = update.apply_transaction(job, work, launcher=launch, + wait_parent_seconds=0.01, health_seconds=0.5) + self.assertEqual(result, healthy and not parent_running) + self.assertEqual((data / "state.json").read_text(), '{"character":"furina"}') + self.assertTrue((target / "unins000.dat").exists()) + if result: + self.assertTrue((target / "new.txt").exists()) + self.assertFalse((target / "old.txt").exists()) + self.assertIn("\\Seele.exe", (target / "启动希儿.vbs").read_text(encoding="utf-16")) + else: + self.assertEqual((target / "old.txt").read_text(), "old") + self.assertFalse((target / "new.txt").exists()) + self.assertEqual(list(root.glob(".installed-*")), []) + if not healthy and not parent_running: + self.assertEqual(launches[-1], ["old"]) + if parent_running: + self.assertEqual(launches, []) + + def test_success_preserves_settings_and_inno_uninstaller(self): + self.transaction() + + def test_startup_failure_restores_old_application(self): + self.transaction(healthy=False) + + def test_parent_still_running_never_replaced(self): + self.transaction(parent_running=True) + + def test_real_child_restart_and_health_handshake(self): + if sys.platform == "win32": + self.skipTest("POSIX executable fixture") + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + target = root / "Seele.app" + target.mkdir() + (target / "old").touch() + staged = root / "staged.app" + exe = staged / "Contents/MacOS/Seele" + exe.parent.mkdir(parents=True) + exe.write_text("#!" + sys.executable + "\n" + "import json,os,sys,time\n" + "json.dump({'pid':os.getpid(),'version':'2.6.0','nonce':sys.argv[3]}," + "open(sys.argv[2],'w'))\n" + "time.sleep(20)\n") + exe.chmod(0o755) + work = root / "job" + work.mkdir() + job = {"target": str(target), "staged": str(staged), "nonce": "real", + "data_dir": str(root / "data"), "parent_pid": 99999999, + "version": "2.6.0", "platform": "macos-arm64", "old_command": []} + paths.atomic_json(work / "activate.json", {"nonce": "real"}) + children = [] + def launch(command, env): + child = subprocess.Popen(command, env=env) + children.append(child) + return child + try: + self.assertTrue(update.apply_transaction(job, work, launcher=launch, + health_seconds=5)) + finally: + for child in children: + child.terminate() + child.wait(timeout=5) + + +class UpdateUiTests(unittest.TestCase): + @unittest.skipUnless(importlib.util.find_spec("PyQt5"), "Qt is optional in core test CI") + def test_hourly_menu_dot_offline_retention_and_download_on_click(self): + # Qt subprocess avoids global binding/environment changes in other tests. + code = r''' +import os,tempfile,time +from unittest.mock import patch +with tempfile.TemporaryDirectory() as directory: + os.environ.update(QT_QPA_PLATFORM="offscreen", SEELE_DISABLE_UPDATES="1", + SEELE_EDITION="packaging-smoke", SEELE_DATA_DIR=directory) + from PyQt5.QtWidgets import QApplication + import Seele, app_update + app = QApplication([]) + window = Seele.DesktopWife() + window._WindowMenu() + controller = window._updater + assert controller.timer.interval() == 3600000 + assert window.Menu.actions()[-1].text() == "检查更新" + assert window._Tray.menu.actions()[-1].text() == "检查更新" + controller.release = app_update.Release("2.6.0","mac_byte","macos-arm64","","","","") + controller._refresh() + image = window.Menu.actions()[-1].icon().pixmap(18,18).toImage() + assert image.pixelColor(9,9).red() > 200 and image.pixelColor(9,9).alpha() > 0 + controller._checked(None, "offline", False) + assert controller.release.version == "2.6.0" + with patch.object(app_update,"stage_release",side_effect=RuntimeError("fixture")) as stage, \ + patch.object(app_update,"install_root",return_value=app_update.Path(directory)): + controller.clicked() + deadline = time.monotonic()+3 + while controller.busy and time.monotonic() WSL2 installer for Seele windows_byte.""" + +from __future__ import annotations + +import base64 +import io +import json +import os +import re +import subprocess +import sys +import tarfile +import time +from pathlib import Path, PurePosixPath + +from platform_utils import app_path, hidden_subprocess_kwargs + +DISTRO = "Ubuntu-24.04" +RESUME_TASK = "Seele-WindowsByte-Resume" +SERVICE_TASK = "Seele-Botmux-WSL" +RUN_VALUE = "SeeleWindowsByteResume" +TERMINAL_STATES = {"completed", "failed", "waiting_reboot"} + + +def is_windows_byte() -> bool: + if sys.platform != "win32": + return False + from mac_byte_bootstrap import current_edition + return current_edition() == "windows_byte" + + +def state_dir() -> Path: + return Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) / "Seele/windows_byte" + + +def read_status() -> dict: + try: + data = json.loads((state_dir() / "status.json").read_text(encoding="utf-8")) + return data if isinstance(data, dict) else {} + except (OSError, ValueError): + return {} + + +def save_status(status: dict, **updates) -> dict: + status.update(updates, updated_at=time.time()) + root = state_dir() + root.mkdir(parents=True, exist_ok=True) + target = root / "status.json" + temporary = target.with_suffix(".tmp") + temporary.write_text(json.dumps(status, ensure_ascii=False, indent=2), encoding="utf-8") + os.replace(temporary, target) + return status + + +class InstallLock: + """Kernel-owned lock: a killed installer never leaves a stale six-hour lock.""" + + def __enter__(self): + state_dir().mkdir(parents=True, exist_ok=True) + self.file = (state_dir() / "installer.lock").open("a+b") + if os.fstat(self.file.fileno()).st_size == 0: + self.file.write(b"0") + self.file.flush() + self.file.seek(0) + try: + if os.name == "nt": + import msvcrt + msvcrt.locking(self.file.fileno(), msvcrt.LK_NBLCK, 1) + else: + import fcntl + fcntl.flock(self.file, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + self.file.close() + raise InstallerAlreadyRunning("已有 Windows 字节版安装终端在运行。") from None + return self + + def __exit__(self, *args): + self.file.close() + + +class InstallerAlreadyRunning(RuntimeError): + pass + + +def ps_string(value: str) -> str: + return "'" + value.replace("'", "''") + "'" + + +def powershell_command(script: str) -> list[str]: + encoded = base64.b64encode(script.encode("utf-16le")).decode("ascii") + return ["powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", + "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded] + + +def run_ps(script: str, *, timeout: int = 45) -> str: + result = subprocess.run(powershell_command( + "[Console]::OutputEncoding = [Text.UTF8Encoding]::new();\n" + "$ErrorActionPreference = 'Stop';\n" + script + ), capture_output=True, timeout=timeout, **hidden_subprocess_kwargs()) + if result.returncode: + # No captured output in status files: auth URLs and secrets can appear there. + raise RuntimeError("Windows 系统命令失败,请检查当前用户权限或企业策略。") + return result.stdout.decode("utf-8-sig", errors="replace").strip() + + +def windows_identity() -> str: + return run_ps("[Security.Principal.WindowsIdentity]::GetCurrent().User.Value") + + +def boot_id() -> str: + return run_ps("(Get-CimInstance Win32_OperatingSystem).LastBootUpTime.ToUniversalTime().ToString('o')") + + +def list_distros() -> list[dict]: + import winreg + rows = [] + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Lxss") as root: + for index in range(winreg.QueryInfoKey(root)[0]): + with winreg.OpenKey(root, winreg.EnumKey(root, index)) as entry: + rows.append({ + "name": winreg.QueryValueEx(entry, "DistributionName")[0], + "version": winreg.QueryValueEx(entry, "Version")[0], + }) + except OSError: + pass + return rows + + +def choose_distro(rows: list[dict], preferred: str = "") -> dict | None: + explicit = os.environ.get("SEELE_WSL_DISTRO", "").strip() or preferred + if explicit: + return next((row for row in rows if row["name"] == explicit), None) + for name in (DISTRO, "Ubuntu"): + match = next((row for row in rows if row["name"] == name), None) + if match: + return match + return next((row for row in rows if row["name"].lower().startswith("ubuntu") + and row["version"] == 2), None) + + +def wsl_command(distro: str, user: str, *command: str) -> list[str]: + return ["wsl.exe", "--distribution", distro, "--user", user, "--exec", *command] + + +def run_wsl(distro: str, user: str, *command: str, data: bytes | None = None, + timeout: int = 120, interactive: bool = False) -> subprocess.CompletedProcess: + kwargs = {} if interactive else hidden_subprocess_kwargs() + result = subprocess.run(wsl_command(distro, user, *command), input=data, + capture_output=not interactive, timeout=timeout, **kwargs) + if result.returncode: + raise RuntimeError( + "WSL 命令未完成。请检查终端提示、网络与 CPU 虚拟化;" + "修复后从桌宠菜单继续安装,已有数据不会删除。" + ) + return result + + +def elevate_components() -> bool: + """Only fixed OS component commands run elevated; never launch a user script.""" + script = r""" +$ErrorActionPreference = 'Stop' +$reboot = $false +foreach ($feature in @('Microsoft-Windows-Subsystem-Linux', 'VirtualMachinePlatform')) { + $before = Get-WindowsOptionalFeature -Online -FeatureName $feature + if ($before.State -eq 'EnablePending') { $reboot = $true; continue } + if ($before.State -ne 'Enabled') { + $result = Enable-WindowsOptionalFeature -Online -FeatureName $feature -All -NoRestart + if ($result.RestartNeeded) { $reboot = $true } + } +} +if ($reboot) { exit 3010 } +& "$env:SystemRoot\System32\wsl.exe" --install --no-distribution +$code = $LASTEXITCODE +if ($code -eq 3010) { $reboot = $true } +elseif ($code -ne 0) { exit 1 } +if ($reboot) { exit 3010 } +exit 0 +""" + encoded = base64.b64encode(script.encode("utf-16le")).decode("ascii") + wrapper = f""" +$process = Start-Process -FilePath "$env:SystemRoot\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" ` + -Verb RunAs -Wait -PassThru -ArgumentList '-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand {encoded}' +$process.ExitCode +""" + code = int(run_ps(wrapper, timeout=1800).splitlines()[-1]) + if code not in (0, 3010): + raise RuntimeError("WSL 系统组件安装未完成,请查看管理员窗口提示后重试。") + return code == 3010 + + +def gui_command() -> list[str]: + if getattr(sys, "frozen", False): + return [str(app_path("Seele.exe")), "--windows-byte"] + executable = Path(sys.executable).with_name("pythonw.exe") + return [str(executable if executable.is_file() else Path(sys.executable)), + str(app_path("run_exe.py")), "--windows-byte"] + + +def task_script(name: str, command: list[str], sid: str, *, keepalive: bool = False) -> str: + argument = subprocess.list2cmdline(command[1:]) + return f""" +$action = New-ScheduledTaskAction -Execute {ps_string(command[0])} -Argument {ps_string(argument)} +$trigger = New-ScheduledTaskTrigger -AtLogOn -User {ps_string(sid)} +{"$recovery = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(5) -RepetitionInterval (New-TimeSpan -Minutes 5); $trigger = @($trigger, $recovery)" if keepalive else ""} +$principal = New-ScheduledTaskPrincipal -UserId {ps_string(sid)} -LogonType Interactive -RunLevel Limited +$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew ` + -ExecutionTimeLimit ([TimeSpan]::Zero) -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) ` + -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries +Register-ScheduledTask -TaskName {ps_string(name)} -Action $action -Trigger $trigger ` + -Principal $principal -Settings $settings -Force | Out-Null +{"Start-ScheduledTask -TaskName " + ps_string(name) if keepalive else ""} +if (-not (Get-ScheduledTask -TaskName {ps_string(name)})) {{ throw 'Task readback failed' }} +""" + + +def set_resume(sid: str) -> str: + try: + run_ps(task_script(RESUME_TASK, gui_command(), sid)) + return "scheduled_task" + except RuntimeError: + import winreg + with winreg.CreateKey(winreg.HKEY_CURRENT_USER, + r"Software\Microsoft\Windows\CurrentVersion\Run") as key: + winreg.SetValueEx(key, RUN_VALUE, 0, winreg.REG_SZ, + subprocess.list2cmdline(gui_command())) + return "hkcu_run" + + +def remove_resume() -> None: + try: + run_ps(f"Unregister-ScheduledTask -TaskName {ps_string(RESUME_TASK)} -Confirm:$false -ErrorAction SilentlyContinue") + except RuntimeError: + pass + import winreg + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, + r"Software\Microsoft\Windows\CurrentVersion\Run", + 0, winreg.KEY_SET_VALUE) as key: + winreg.DeleteValue(key, RUN_VALUE) + except OSError: + pass + + +def reboot_required(status: dict, current_boot: str) -> bool: + return status.get("state") == "waiting_reboot" and status.get("boot_id") == current_boot + + +def prepare_user(distro: str) -> str: + run_wsl(distro, "root", "/bin/sh", "-c", + "command -v python3 >/dev/null || (apt-get update && apt-get install -y python3)") + # wsl without --user reveals the real configured default rather than assuming dev. + result = subprocess.run(["wsl.exe", "-d", distro, "--exec", "id", "-un"], + capture_output=True, timeout=120, **hidden_subprocess_kwargs()) + if result.returncode: + raise RuntimeError("无法识别 WSL 默认用户。") + user = result.stdout.decode("utf-8").strip() + if user == "root": + print("\nWSL 需要一个普通 Linux 用户。已有用户请输入原用户名;新建建议 dev。") + user = input("Linux 用户名 [dev]: ").strip() or "dev" + if not re.fullmatch(r"[a-z_][a-z0-9_-]{0,31}", user) or user == "root": + raise RuntimeError("Linux 用户名无效,请用小写字母开头的普通用户名。") + check = subprocess.run(wsl_command(distro, "root", "id", "-u", user), + capture_output=True, timeout=30, **hidden_subprocess_kwargs()) + if check.returncode: + run_wsl(distro, "root", "useradd", "--create-home", "--shell", "/bin/bash", + "--groups", "sudo", user) + password = run_wsl(distro, "root", "passwd", "--status", user).stdout.decode().split() + if len(password) > 1 and password[1] in {"L", "NP"}: + print("请设置 Linux 密码,用于以后在 WSL 中执行 sudo;输入不会显示。") + run_wsl(distro, "root", "passwd", user, timeout=3600, interactive=True) + return user + + +def configure_linux(distro: str, user: str) -> None: + # Execute the same tested configuration function as a root-only WSL step. + script = ( + "import sys; scope={'__name__':'seele_wsl_config'}; " + "exec(sys.stdin.read(),scope); " + "print('changed' if scope['configure_wsl'](" + "scope['pathlib'].Path('/etc/wsl.conf'),sys.argv[1]) else 'unchanged')" + ) + changed = run_wsl(distro, "root", "python3", "-c", script, user, + data=app_path("windows_byte_linux.py").read_bytes()).stdout.strip() == b"changed" + if changed: + print("systemd 或默认用户配置已更新,将重启当前发行版,请保存其中的工作。") + result = subprocess.run(["wsl.exe", "--terminate", distro], capture_output=True, + timeout=90, **hidden_subprocess_kwargs()) + if result.returncode: + raise RuntimeError("无法重启目标 WSL 发行版,请关闭它的终端后重试。") + # Do not shut down other distributions. + run_wsl(distro, "root", "/bin/sh", "-c", + 'test "$(cat /proc/1/comm)" = systemd', timeout=120) + run_wsl(distro, "root", "/bin/sh", "-c", + "apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y " + "ca-certificates curl git jq tmux unzip zip python3 sudo", timeout=1800, + interactive=True) + run_wsl(distro, "root", "loginctl", "enable-linger", user) + run_wsl(distro, "root", "systemctl", "start", + "user@" + run_wsl(distro, "root", "id", "-u", user).stdout.decode().strip() + ".service") + + +def stage_runtime(distro: str, user: str) -> str: + home = run_wsl(distro, user, "/bin/sh", "-c", 'printf "%s" "$HOME"').stdout.decode("utf-8") + if not PurePosixPath(home).is_absolute() or home in ("/", "/root"): + raise RuntimeError("Botmux 必须部署到普通 Linux 用户的主目录。") + destination = home + "/.seele/windows_byte" + buffer = io.BytesIO() + with tarfile.open(fileobj=buffer, mode="w") as archive: + for name in ("windows_byte_linux.py", "botmux_setup_coach.py", "platform_utils.py", + "audio/mac_byte", "botmux-skill"): + source = app_path(name) + if not source.exists(): + raise RuntimeError(f"安装包缺少资源:{name}") + archive.add(source, arcname=name) + script = 'umask 077; mkdir -p "$1"; tar -xf - -C "$1"; chmod 700 "$1"' + run_wsl(distro, user, "/bin/sh", "-c", script, "seele", destination, + data=buffer.getvalue(), timeout=180) + return home + + +def register_keepalive(distro: str, user: str, home: str, sid: str) -> None: + wsl = wsl_command(distro, user, "/bin/bash", "-lc", + "exec python3 " + __import__("shlex").quote( + home + "/.seele/windows_byte/windows_byte_linux.py") + " --keepalive") + # Hidden PowerShell stays in the foreground of the task while WSL stays alive. + script = "& " + " ".join(ps_string(arg) for arg in wsl) + "; exit $LASTEXITCODE" + command = powershell_command(script) + command[1:1] = ["-WindowStyle", "Hidden"] + run_ps(task_script(SERVICE_TASK, command, sid, keepalive=True)) + + +def run_install() -> int: + status = read_status() + try: + with InstallLock(): + sid = windows_identity() + if status.get("windows_sid") and status["windows_sid"] != sid: + raise RuntimeError("请用首次安装 WSL 的同一个 Windows 账号继续。") + current_boot = boot_id() + if reboot_required(status, current_boot): + print("WSL 系统组件需要重启 Windows。进度已保存,登录后会自动继续。") + return 3010 + save_status(status, state="running", stage="wsl", windows_sid=sid, + resume=set_resume(sid), message="正在检查 WSL2。") + rows = list_distros() + preferred = os.environ.get("SEELE_WSL_DISTRO", "").strip() or status.get("distro", "") + row = choose_distro(rows, preferred) + if preferred and not row: + raise RuntimeError("指定的 WSL 发行版不存在,请检查 SEELE_WSL_DISTRO。") + if not row: + try: + ready = subprocess.run(["wsl.exe", "--status"], capture_output=True, + timeout=120, **hidden_subprocess_kwargs()).returncode == 0 + except FileNotFoundError: + ready = False + if not ready and elevate_components(): + save_status(status, state="waiting_reboot", boot_id=current_boot, + message="请重启 Windows;同一用户登录后自动继续安装。") + print(status["message"]) + return 3010 + version = subprocess.run(["wsl.exe", "--set-default-version", "2"], timeout=120) + if version.returncode: + raise RuntimeError("无法设置 WSL2 默认版本,请检查 WSL 内核和 CPU 虚拟化。") + result = subprocess.run(["wsl.exe", "--install", "--distribution", DISTRO, + "--no-launch"], timeout=1800) + if result.returncode == 3010: + save_status(status, state="waiting_reboot", boot_id=current_boot, + message="Ubuntu 安装需要重启 Windows,登录后继续。") + return 3010 + if result.returncode: + raise RuntimeError("Ubuntu 下载或注册未完成,请检查网络并重启后续装。") + row = choose_distro(list_distros()) + if not row: + raise RuntimeError("Ubuntu 尚未注册,请从开始菜单打开 Ubuntu 后继续安装。") + distro = row["name"] + save_status(status, distro=distro) + if row["version"] != 2: + # Conversion must retain the existing distribution and files. + if elevate_components(): + save_status(status, state="waiting_reboot", boot_id=current_boot, + message="请重启 Windows,之后继续将现有发行版转换为 WSL2。") + return 3010 + result = subprocess.run(["wsl.exe", "--set-version", distro, "2"], timeout=3600) + if result.returncode == 3010: + save_status(status, state="waiting_reboot", boot_id=current_boot, + message="WSL2 转换需要重启 Windows,登录后继续。") + return 3010 + if result.returncode: + raise RuntimeError("WSL2 转换失败,请检查虚拟化与 WSL 内核。") + # A registered distro may still have pending Windows features. + try: + run_wsl(distro, "root", "/bin/true") + except RuntimeError: + if elevate_components(): + save_status(status, state="waiting_reboot", boot_id=current_boot, + message="请重启 Windows;登录后从已保存进度继续。") + return 3010 + run_wsl(distro, "root", "/bin/true") + user = prepare_user(distro) + save_status(status, stage="linux", linux_user=user, + message="正在安装 Linux 依赖并启用 systemd。") + configure_linux(distro, user) + home = stage_runtime(distro, user) + save_status(status, linux_home=home, stage="botmux", + message="请在安装终端完成 CLI 登录和机器人配置。") + run_wsl(distro, user, "/bin/bash", "-lc", + "exec python3 " + __import__("shlex").quote( + home + "/.seele/windows_byte/windows_byte_linux.py"), + timeout=14400, interactive=True) + save_status(status, stage="autostart", message="正在注册登录自启与 WSL 保活。") + register_keepalive(distro, user, home, sid) + remove_resume() + save_status(status, state="completed", stage="ready", + message="Botmux 已安装,已启用当前用户登录后自启。") + print("\n" + status["message"]) + return 0 + except InstallerAlreadyRunning as exc: + print(str(exc)) + return 2 + except (OSError, RuntimeError, ValueError, subprocess.SubprocessError, KeyboardInterrupt, EOFError) as exc: + # Preserve last completed stage and avoid capturing interactive command output. + message = str(exc) if isinstance(exc, RuntimeError) else "安装已中断,可从桌宠菜单继续。" + save_status(status, state="failed", message=message) + print("\n" + message) + return 1 + + +def helper_command() -> list[str]: + if getattr(sys, "frozen", False): + helper = app_path("bin", "SeeleWindowsBootstrap.exe") + if not helper.is_file(): + raise RuntimeError("安装包缺少 Windows 安装终端。") + return [str(helper), "--resources", str(app_path())] + executable = Path(sys.executable).with_name("python.exe") + return [str(executable if executable.is_file() else Path(sys.executable)), + str(app_path("windows_byte_bootstrap.py"))] + + +def launch_and_wait(callback) -> None: + started = time.time() + process = subprocess.Popen(helper_command(), creationflags=subprocess.CREATE_NEW_CONSOLE) + callback({"state": "launched", "mode": "full"}) + last = None + while process.poll() is None: + status = read_status() + if status.get("state") in TERMINAL_STATES and status.get("updated_at", 0) < started: + time.sleep(1) + continue + key = (status.get("state"), status.get("stage"), status.get("message")) + if status and key != last: + last = key + callback(dict(status)) + if status.get("state") in TERMINAL_STATES: + return + time.sleep(1) + status = read_status() + if status.get("state") not in TERMINAL_STATES or status.get("updated_at", 0) < started: + status = {"state": "failed", "message": "安装终端已关闭,可从菜单继续安装。"} + callback(status) + + +def main() -> int: + if "--resources" in sys.argv: + index = sys.argv.index("--resources") + # Explicit resource location passed from the frozen GUI, not from cwd. + import platform_utils + root = Path(sys.argv[index + 1]).resolve() + platform_utils.get_base_dir = lambda: root + if "--self-check" in sys.argv: + return 0 if all(app_path(name).exists() for name in ( + "windows_byte_linux.py", "botmux_setup_coach.py", "platform_utils.py", + "botmux-skill/dist", "audio/mac_byte")) else 1 + if sys.platform != "win32": + raise RuntimeError("此安装入口仅支持 Windows。") + for stream in (sys.stdout, sys.stderr): + if hasattr(stream, "reconfigure"): + stream.reconfigure(encoding="utf-8", errors="replace") + code = run_install() + if code in (1, 3010) and sys.stdin.isatty(): + print("WSL 系统组件需要重启 Windows。" if code == 3010 else "安装未完成。") + try: + input("\n按回车关闭安装窗口。") + except (EOFError, KeyboardInterrupt): + pass + return code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/windows_byte_linux.py b/windows_byte_linux.py new file mode 100644 index 0000000..fee25e3 --- /dev/null +++ b/windows_byte_linux.py @@ -0,0 +1,206 @@ +"""Linux half of the Windows byte installer. Runs in the selected WSL account.""" + +from __future__ import annotations + +import configparser +import json +import os +import pathlib +import shlex +import subprocess +import sys +import time + + +def configure_wsl(path: pathlib.Path, user: str) -> bool: + config = configparser.ConfigParser(interpolation=None, strict=False) + config.optionxform = str + if path.exists(): + config.read(path, encoding="utf-8") + changed = False + for section, key, value in (("boot", "systemd", "true"), ("user", "default", user)): + if not config.has_section(section): + config.add_section(section) + if config.get(section, key, fallback="") != value: + config.set(section, key, value) + changed = True + if changed: + temporary = path.with_name(path.name + ".seele.tmp") + with temporary.open("w", encoding="utf-8") as stream: + config.write(stream) + os.replace(temporary, path) + return changed + + +def shell(script: str, *, timeout: int = 1800) -> None: + subprocess.run(["/bin/bash", "-lc", script], check=True, timeout=timeout) + + +def environment_script() -> str: + return r""" +set -euo pipefail +export NVM_DIR="$HOME/.nvm" +if [ -s "$NVM_DIR/nvm.sh" ]; then + set +u + source "$NVM_DIR/nvm.sh" + set -u +fi +export PATH="$HOME/.local/bin:$HOME/.botmux/bin:$PATH" +export XDG_RUNTIME_DIR="/run/user/$(id -u)" +export DBUS_SESSION_BUS_ADDRESS="unix:path=$XDG_RUNTIME_DIR/bus" +""" + + +def has_bots(path: pathlib.Path) -> bool: + try: + bots = json.loads(path.read_text(encoding="utf-8")) + if isinstance(bots, list): + return any(isinstance(bot, dict) for bot in bots) + if isinstance(bots, dict): + return bool(bots) + except (OSError, ValueError): + pass + return False + + +def build_install_script(runtime: pathlib.Path, *, configured: bool) -> str: + coach = shlex.join(["python3", str(runtime / "botmux_setup_coach.py")]) + login = coach + " --login-command -- " + setup = "printf '\\n保留已有 Botmux 配置。\\n'" if configured else f""" +python3 {shlex.quote(str(runtime / 'windows_byte_linux.py'))} --voice botmux_setup +{coach} +""" + return environment_script() + f""" +if ! command -v botmux >/dev/null 2>&1; then + botmux_installer="$(mktemp)" + trap 'rm -f "$botmux_installer"' EXIT + curl -fsSL https://raw.githubusercontent.com/deepcoldy/botmux/master/install.sh -o "$botmux_installer" + sh "$botmux_installer" + rm -f "$botmux_installer" + trap - EXIT +fi +botmux --version +if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1 || + ! node -e 'process.exit(Number(process.versions.node.split(".")[0]) >= 22 ? 0 : 1)'; then + if [ ! -s "$NVM_DIR/nvm.sh" ]; then + curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash + fi + set +u + source "$NVM_DIR/nvm.sh" + nvm install 22 + nvm use 22 + set -u +fi +node -v +npm -v +if ! command -v traex >/dev/null 2>&1 || ! traex backend cn --help >/dev/null 2>&1; then + curl -fsSL https://code.byted.org/api/tos-proxy/download/traex_install.sh | + env TRAEX_INSTALL_ASSUME_YES=1 TRAEX_INSTALL_REMOVE_COCO=1 TRAEX_INSTALL_REMOVE_BREW=0 sh + hash -r +fi +traex backend cn --help >/dev/null +for profile in "$HOME/.profile" "$HOME/.bashrc"; do + if ! grep -F '# Seele Windows byte CLI PATH' "$profile" >/dev/null 2>&1; then + printf '\\n# Seele Windows byte CLI PATH\\nexport PATH="$HOME/.local/bin:$HOME/.botmux/bin:$PATH"\\n' >> "$profile" + fi +done +if ! command -v lark-cli >/dev/null 2>&1; then + npm install -g @larksuite/cli@latest --registry https://bnpm.byted.org/ +fi +if ! command -v agentbuddy >/dev/null 2>&1; then + npm install -g agentbuddy@latest --registry https://bnpm.byted.org/ +fi +for tool in botmux tmux node npm traex lark-cli agentbuddy; do + command -v "$tool" >/dev/null +done +traex backend cn +if ! traex login status >/dev/null 2>&1; then + python3 {shlex.quote(str(runtime / 'windows_byte_linux.py'))} --voice traex_login + {login}traex login --sso-device +fi +traex login status >/dev/null +if ! lark-cli config show >/dev/null 2>&1; then + python3 {shlex.quote(str(runtime / 'windows_byte_linux.py'))} --voice lark_config + {login}lark-cli config init --new --lang zh_cn --name seele +fi +lark-cli config show >/dev/null +if ! lark-cli auth status --json --verify >/dev/null 2>&1; then + python3 {shlex.quote(str(runtime / 'windows_byte_linux.py'))} --voice lark_login + {login}lark-cli auth login --recommend +fi +lark-cli auth status --json --verify >/dev/null +if ! agentbuddy status --json >/dev/null 2>&1; then + python3 {shlex.quote(str(runtime / 'windows_byte_linux.py'))} --voice agentbuddy_login + {login}agentbuddy login --region cn --login-mode device --json --yes +fi +agentbuddy status --json >/dev/null +{setup} +botmux plugin install {shlex.quote(str(runtime / 'botmux-skill'))} --link +botmux plugin enable seele +botmux autostart enable +systemctl --user is-enabled botmux.service +systemctl --user start botmux.service +systemctl --user is-active botmux.service +systemctl --user is-enabled botmux-watchdog.timer +systemctl --user is-active botmux-watchdog.timer +test "$(loginctl show-user "$(id -un)" -p Linger --value)" = yes +""" + + +def keepalive() -> int: + """Foreground Linux client keeps wsl.exe and the scheduled task alive.""" + import fcntl + + root = pathlib.Path.home() / ".seele/windows_byte" + root.mkdir(parents=True, exist_ok=True, mode=0o700) + with (root / "keepalive.lock").open("a") as lock: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + return 0 + os.environ["XDG_RUNTIME_DIR"] = f"/run/user/{os.getuid()}" + os.environ["DBUS_SESSION_BUS_ADDRESS"] = f"unix:path=/run/user/{os.getuid()}/bus" + failures = 0 + while True: + try: + result = subprocess.run(["systemctl", "--user", "start", "botmux.service"], + capture_output=True, timeout=20) + active = subprocess.run(["systemctl", "--user", "is-active", "--quiet", + "botmux.service"], capture_output=True, timeout=20) + failures = failures + 1 if result.returncode or active.returncode else 0 + except (OSError, subprocess.SubprocessError): + failures += 1 + if failures >= 5: + return 1 # Windows task retries and relaunches the distro. + time.sleep(30) + + +def main() -> int: + if "--keepalive" in sys.argv: + return keepalive() + if "--voice" in sys.argv: + from botmux_setup_coach import VoiceCue, VoicePlayer + voices = { + "traex_login": "请登录 Trae CLI,在浏览器中完成本人授权。", + "lark_config": "请在浏览器初始化飞书 CLI 应用。", + "lark_login": "请在浏览器登录飞书 CLI。", + "agentbuddy_login": "请在浏览器完成 Skill 空间登录。", + "botmux_setup": "请按终端提示设置机器人名称、账号和完整邮箱。", + } + key = sys.argv[sys.argv.index("--voice") + 1] + player = VoicePlayer() + player.play(VoiceCue(key, f"mac_byte_{key}_16k.wav", voices[key], ())) + # PowerShell continues asynchronously after this small voice launcher exits. + return 0 + if os.getuid() == 0: + raise RuntimeError("不要以 root 运行 Botmux 安装。") + runtime = pathlib.Path(__file__).resolve().parent + os.environ["SEELE_RESOURCES_DIR"] = str(runtime) + os.environ["SEELE_WINDOWS_WSL"] = "1" + shell(build_install_script(runtime, configured=has_bots(pathlib.Path.home() / ".botmux/bots.json")), + timeout=14400) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/write_file.py b/write_file.py index f800d51..b4a7165 100644 --- a/write_file.py +++ b/write_file.py @@ -1,4 +1,6 @@ import json +from pathlib import Path +from platform_utils import atomic_json def write_dict_to_json(dictionary, file_path): @@ -10,8 +12,7 @@ def write_dict_to_json(dictionary, file_path): if not isinstance(dictionary, dict): return try: - with open(file_path, 'w', encoding='utf-8') as file: - json.dump(dictionary, file, ensure_ascii=False, indent=4) + atomic_json(Path(file_path), dictionary) except Exception as e: print(f"写入文件时出错: {e}") @@ -35,4 +36,3 @@ def read_dict_from_json(file_path): except Exception as e: print(f"读取文件时出错: {e}") return None - \ No newline at end of file From 310e30c000e2bab89900fdfadd1b93f76ccbe967 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=8A=A0=E6=81=A9?= <105278771+xinna2001@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:04:20 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=8B=B1=E6=96=87=20Wind?= =?UTF-8?q?ows=20=E7=8E=AF=E5=A2=83=E7=9A=84=E4=B8=AD=E6=96=87=E5=AE=89?= =?UTF-8?q?=E8=A3=85=E6=8F=90=E7=A4=BA=E7=BC=96=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DEVELOPMENT_LOG.md | 5 +++++ tests/test_windows_byte.py | 20 ++++++++++++++++++++ windows_byte_bootstrap.py | 9 +++++++++ 3 files changed, 34 insertions(+) diff --git a/DEVELOPMENT_LOG.md b/DEVELOPMENT_LOG.md index 6031c56..13e32c8 100644 --- a/DEVELOPMENT_LOG.md +++ b/DEVELOPMENT_LOG.md @@ -57,6 +57,11 @@ 两包 ZIP/摘要核验通过;Windows 包包含 certifi CA、独立 helper 源码与内置 Python。 - 菜单视觉证据:`vip-packages/Seele-2.5.0-update-menu.png`, 模拟高版本红点,验证“检查更新”始终在底部;线上当前没有高于 2.5.0 的 Release。 +- 发布 CI 修正:首次 Windows CI 在 CP1252 输出中文安装提示时抛 + `UnicodeEncodeError`,导致成功、重启和锁竞争分支异常退出。 + `run_install()` 开始时将可配置 stdout/stderr 设为 UTF-8, + 新增英文系统重定向输出与重启返回码回归,本地 99 项测试通过。 + 该失败触发矩阵 fail-fast;PR/分支 CI 修正后重新运行。 ## 2026-10-21 Windows 字节版 WSL 安装修复(2.4.0 本地待验收) diff --git a/tests/test_windows_byte.py b/tests/test_windows_byte.py index 85aee62..e31472d 100644 --- a/tests/test_windows_byte.py +++ b/tests/test_windows_byte.py @@ -1,5 +1,6 @@ import base64 import configparser +import io import os import pathlib import subprocess @@ -14,6 +15,25 @@ class WindowsByteTests(unittest.TestCase): + def test_english_windows_redirected_console_keeps_chinese_and_reboot_result(self): + buffer = io.BytesIO() + output = io.TextIOWrapper(buffer, encoding="cp1252") + with ( + tempfile.TemporaryDirectory() as directory, + patch.object(host, "state_dir", return_value=pathlib.Path(directory)), + patch.object(host, "windows_identity", return_value="sid-test"), + patch.object(host, "boot_id", return_value="boot-one"), + patch.object(host.sys, "platform", "win32"), + patch.object(host.sys, "stdout", output), + patch.object(host.sys, "stderr", output), + ): + host.save_status({}, state="waiting_reboot", boot_id="boot-one", + windows_sid="sid-test", stage="wsl") + self.assertEqual(host.run_install(), 3010) + output.flush() + self.assertIn("需要重启", buffer.getvalue().decode("utf-8")) + output.close() + def test_same_boot_waits_without_repeating_install_or_registration(self): with ( tempfile.TemporaryDirectory() as directory, diff --git a/windows_byte_bootstrap.py b/windows_byte_bootstrap.py index 1232331..3e0036d 100644 --- a/windows_byte_bootstrap.py +++ b/windows_byte_bootstrap.py @@ -330,7 +330,16 @@ def register_keepalive(distro: str, user: str, home: str, sid: str) -> None: run_ps(task_script(SERVICE_TASK, command, sid, keepalive=True)) +def configure_console() -> None: + """Redirected Windows output defaults to CP1252 on English systems.""" + if sys.platform == "win32": + for stream in (sys.stdout, sys.stderr): + if stream is not None and hasattr(stream, "reconfigure"): + stream.reconfigure(encoding="utf-8", errors="replace") + + def run_install() -> int: + configure_console() status = read_status() try: with InstallLock():