diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 794e472f..9d43eab0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,18 +1,20 @@ # 发一版桌面端。 # -# **每个平台只发一个文件**,外加它的 sha256,和一份三个平台共用的 +# **每一种安装方式只发一个文件**,外加它的 sha256,和一份所有平台共用的 # latest.json: # # macOS ThinkWatch-Lite-<版本>-arm64.dmg # Windows x64 ThinkWatch-Lite-<版本>-x64-setup.exe # Windows arm64 ThinkWatch-Lite-<版本>-arm64-setup.exe +# Linux x86_64 ThinkWatch-Lite-<版本>-x86_64.AppImage、thinkwatch-lite_<版本>_amd64.deb +# Linux aarch64 ThinkWatch-Lite-<版本>-aarch64.AppImage、thinkwatch-lite_<版本>_arm64.deb # # 网页上下载的是它,Homebrew 的 cask / winget 的清单吃的是它,已经装上的 # 应用自己更新下的也是它:latest.json 指向它,签名签的也是它(应用怎么从 # DMG 更新自己,见 `src-tauri/src/dmg.rs`;Windows 上更新器直接跑新的安装 -# 程序)。 +# 程序;Linux 上 AppImage 原地换掉自己,deb 经系统授权交给 apt)。 # -# **三个平台一起发,或者都不发。**各自构建、拆开看过,最后一个 job 才把 +# **所有平台一起发,或者都不发。**各自构建、拆开看过,最后一个 job 才把 # 它们一起挂上去 —— 只发出一部分的话,latest.json 要么缺平台,要么指向 # 一个不存在的文件。 # @@ -20,7 +22,7 @@ # 每个 commit 都过过。这里只做 CI 做不了的那件事 —— 打出安装包并且 # 在发出去之前拆开看一眼。 # -# **推到 `rehearse/` 开头的分支是演练**:全部照做,只是不发布 —— 三个安装 +# **推到 `rehearse/` 开头的分支是演练**:全部照做,只是不发布 —— 各个安装 # 包留在这次运行的产物里,可以下载下来装到真机上看。改了这条流水线,先演练 # 一遍,不要拿一个 tag 去试。 # @@ -355,9 +357,198 @@ jobs: dist/ThinkWatch-Lite-*-setup.exe.sig if-no-files-found: error + linux: + name: ThinkWatch Lite (${{ matrix.target }}) + strategy: + fail-fast: false + matrix: + include: + # **在 22.04 上构建**:glibc 2.35 是 Linux 版的最低要求,在更新的系统 + # 上构建出来的二进制会去要更新的 glibc,在 22.04 上起不来 + - target: x86_64-unknown-linux-gnu + arch: x86_64 + deb: amd64 + runner: ubuntu-22.04 + # 和 Windows 一样在同架构的机器上打,「拆开看一眼」里运行网关那一行才 + # 不用跳过 + - target: aarch64-unknown-linux-gnu + arch: aarch64 + deb: arm64 + runner: ubuntu-22.04-arm + runs-on: ${{ matrix.runner }} + env: + # linuxdeploy 自己是个 AppImage,runner 上不一定能挂 FUSE。解开再跑, + # 不依赖它 + APPIMAGE_EXTRACT_AND_RUN: "1" + steps: + - uses: actions/checkout@v4 + + - name: The tag is the version in the tree + run: | + set -euo pipefail + GOT=$(bash scripts/version.sh) + if [ "$GITHUB_REF_TYPE" = tag ]; then + WANT="${GITHUB_REF_NAME#v}" + if [ "$GOT" != "$WANT" ]; then + echo "tag 是 $WANT,而代码里写的是 $GOT" >&2 + exit 1 + fi + fi + echo "VERSION=$GOT" >> "$GITHUB_ENV" + + # Tauri 在 Linux 上的构建依赖(https://v2.tauri.app/start/prerequisites/)。 + # 托盘走 ayatana 那一套:打包器看到它才会把 `libayatana-appindicator3-1` + # 写进 deb 的依赖、把那个库放进 AppImage。`xdg-utils`:配了深链接的 + # 应用,打 AppImage 时要把 `/usr/bin/xdg-mime` 拷进包里(运行时靠它注册 + # `thinkwatch://`),没有它打包直接失败 + - name: System libraries + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential file libwebkit2gtk-4.1-dev libgtk-3-dev \ + libayatana-appindicator3-dev librsvg2-dev libssl-dev libxdo-dev \ + xdg-utils desktop-file-utils + + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri + key: ${{ matrix.target }} + + - run: pnpm install --frozen-lockfile + + # 打哪两种包写在 `tauri.linux.conf.json` 里。取哪一份 twcore 跟着 + # `--target` 走,见 `fetch-core.sh` + - name: Build + run: pnpm tauri build --target ${{ matrix.target }} + + # 发出去的名字**不带空格**,理由和 Windows 那边一样。deb 按 Debian 的 + # 惯例起名:包名_版本_架构。 + # + # **包名要改一次。**打包器拿产品名转 kebab-case 当包名,「ThinkWatch + # Lite」成了 `think-watch-lite`,和可执行文件、cask、winget 里的 + # `thinkwatch-lite` 对不上,也没有配置项能改。这里拆开改掉 control 里 + # 那一行再封回去;文件本身一个字节不动,md5sums 仍然对得上。**要在签名 + # 之前**:签的是发出去的这一份 + - name: Name the packages + - name: Name the packages + run: | + set -euo pipefail + B="src-tauri/target/${{ matrix.target }}/release/bundle" + shopt -s nullglob + IMAGES=("$B"/appimage/*.AppImage) + DEBS=("$B"/deb/*.deb) + [ "${#IMAGES[@]}" -eq 1 ] || { echo "要一个 AppImage,打出来的是 ${#IMAGES[@]} 个" >&2; exit 1; } + [ "${#DEBS[@]}" -eq 1 ] || { echo "要一个 deb,打出来的是 ${#DEBS[@]} 个" >&2; exit 1; } + mkdir -p dist + cp "${IMAGES[0]}" "dist/ThinkWatch-Lite-$VERSION-${{ matrix.arch }}.AppImage" + R=$(mktemp -d) + dpkg-deb -R "${DEBS[0]}" "$R/pkg" + sed -i 's/^Package: .*/Package: thinkwatch-lite/' "$R/pkg/DEBIAN/control" + dpkg-deb --root-owner-group -Zxz -b "$R/pkg" "dist/thinkwatch-lite_${VERSION}_${{ matrix.deb }}.deb" + echo "APPIMAGE=dist/ThinkWatch-Lite-$VERSION-${{ matrix.arch }}.AppImage" >> "$GITHUB_ENV" + echo "DEB=dist/thinkwatch-lite_${VERSION}_${{ matrix.deb }}.deb" >> "$GITHUB_ENV" + + # **拆开看一眼再发。**和另外两个平台同一个理由:缺了网关、网关是别的 + # 架构、或者更新器认不出这是哪种包,从文件列表上都看不出来。 + # + # 应用靠打包器写进二进制的那个标记区分 AppImage 和 deb(自更新走哪条路、 + # 去哪找网关都看它),所以每个包里的那一份都要带着**自己那种**标记。 + - name: Look inside before shipping it + run: | + set -euo pipefail + case "${{ matrix.arch }}" in + x86_64) ELF="ELF 64-bit.*x86-64" ;; + aarch64) ELF="ELF 64-bit.*aarch64" ;; + esac + # 一个包拆开之后的根目录里,应用和网关都在,且都对 + check() { + local root=$1 marker=$2 + local app="$root/usr/bin/thinkwatch-lite" + local core="$root/usr/lib/ThinkWatch Lite/twcore" + test -x "$app" || { echo "没有 $app" >&2; exit 1; } + test -x "$core" || { echo "没有 $core" >&2; exit 1; } + file "$app" | grep -Eq "$ELF" || { echo "应用的架构不对:$(file "$app")" >&2; exit 1; } + file "$core" | grep -Eq "$ELF" || { echo "网关的架构不对:$(file "$core")" >&2; exit 1; } + # 打包器把 `…_UNK` 那几个字节原地改成这种包的名字(没打补丁的 + # 二进制里只有 `UNK` 这一份,比较用的那几个名字不以字符串的形式出现) + if grep -aq __TAURI_BUNDLE_TYPE_VAR_UNK "$app"; then + echo "应用没打上打包标记" >&2; exit 1 + fi + [ "$(grep -ao "__TAURI_BUNDLE_TYPE_VAR_$marker" "$app" | wc -l)" -eq 1 ] \ + || { echo "应用里的打包标记不是 $marker" >&2; exit 1; } + # glibc 的底线:比 2.35 新的符号意味着在 22.04 上起不来 + local top + top=$(objdump -T "$app" "$core" | grep -o 'GLIBC_[0-9.]*' | sort -uV | tail -n 1) + printf '%s\n' "$top" GLIBC_2.35 | sort -V -C \ + || { echo "要的 glibc 是 $top,超过了 2.35" >&2; exit 1; } + echo "包里的网关:$("$core" --version)" + # 登录回调靠这两行:认领 `thinkwatch://`,并且 Exec 带 `%u` 把链接交进来 + # (见 `src-tauri/linux/main.desktop`) + local desktop="$root/usr/share/applications/ThinkWatch Lite.desktop" + desktop-file-validate "$desktop" + grep -q '^MimeType=.*x-scheme-handler/thinkwatch;' "$desktop" \ + || { echo ".desktop 里没有 thinkwatch 链接" >&2; exit 1; } + grep -q '^Exec=thinkwatch-lite %u$' "$desktop" \ + || { echo ".desktop 的 Exec 不带 %u" >&2; exit 1; } + } + + X=$(mktemp -d) + ( cd "$X" && "$GITHUB_WORKSPACE/$APPIMAGE" --appimage-extract > /dev/null ) + check "$X/squashfs-root" APP + + D=$(mktemp -d) + dpkg-deb -x "$DEB" "$D" + check "$D" DEB + # 不用 `grep -q`:它找到就退出,`dpkg-deb` 还在写就挨一个 SIGPIPE, + # 在 pipefail 下整步失败 + dpkg-deb -c "$DEB" | grep ' ./usr/lib/ThinkWatch Lite/twcore$' > /dev/null + test "$(dpkg-deb -f "$DEB" Package)" = thinkwatch-lite + test "$(dpkg-deb -f "$DEB" Version)" = "$VERSION" + test "$(dpkg-deb -f "$DEB" Architecture)" = "${{ matrix.deb }}" + # 依赖由打包器写:WebKitGTK、GTK、托盘。少一个,apt 装完照样打不开 + DEPENDS=$(dpkg-deb -f "$DEB" Depends) + for dep in libwebkit2gtk-4.1-0 libgtk-3-0 libayatana-appindicator3-1; do + grep -q "$dep" <<< "$DEPENDS" || { echo "deb 的依赖里没有 $dep:$DEPENDS" >&2; exit 1; } + done + + # 两个都签:AppImage 的用户更新时下的是 AppImage,deb 的用户下的是 + # deb,验签验的都是下载下来的原始字节。**私钥没传进来这一步就失败** + - name: Package + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + set -euo pipefail + for f in "$APPIMAGE" "$DEB"; do + ( cd dist && sha256sum "$(basename "$f")" > "$(basename "$f").sha256" ) + cat "$f.sha256" + pnpm tauri signer sign "$f" + done + + - uses: actions/upload-artifact@v4 + with: + name: linux-${{ matrix.arch }} + path: | + dist/ThinkWatch-Lite-*.AppImage + dist/ThinkWatch-Lite-*.AppImage.sha256 + dist/ThinkWatch-Lite-*.AppImage.sig + dist/thinkwatch-lite_*.deb + dist/thinkwatch-lite_*.deb.sha256 + dist/thinkwatch-lite_*.deb.sig + if-no-files-found: error + publish: name: Publish - needs: [app, windows] + needs: [app, windows, linux] # 演练到上面为止 if: github.ref_type == 'tag' runs-on: macos-latest @@ -399,14 +590,18 @@ jobs: path: dist merge-multiple: true - # 清单等三个平台都到齐了才写 —— 少一个它就不写,见 manifest.py。 - - name: One manifest for all three + # 清单等所有平台都到齐了才写 —— 少一个它就不写,见 manifest.py。 + - name: One manifest for every platform run: | set -euo pipefail python3 scripts/manifest.py "$VERSION" notes.txt \ "darwin-aarch64=dist/ThinkWatch-Lite-$VERSION-arm64.dmg" \ "windows-x86_64=dist/ThinkWatch-Lite-$VERSION-x64-setup.exe" \ - "windows-aarch64=dist/ThinkWatch-Lite-$VERSION-arm64-setup.exe" + "windows-aarch64=dist/ThinkWatch-Lite-$VERSION-arm64-setup.exe" \ + "linux-x86_64-appimage=dist/ThinkWatch-Lite-$VERSION-x86_64.AppImage" \ + "linux-x86_64-deb=dist/thinkwatch-lite_${VERSION}_amd64.deb" \ + "linux-aarch64-appimage=dist/ThinkWatch-Lite-$VERSION-aarch64.AppImage" \ + "linux-aarch64-deb=dist/thinkwatch-lite_${VERSION}_arm64.deb" - uses: softprops/action-gh-release@v2 with: @@ -417,6 +612,10 @@ jobs: dist/ThinkWatch-Lite-*-arm64.dmg.sha256 dist/ThinkWatch-Lite-*-setup.exe dist/ThinkWatch-Lite-*-setup.exe.sha256 + dist/ThinkWatch-Lite-*.AppImage + dist/ThinkWatch-Lite-*.AppImage.sha256 + dist/thinkwatch-lite_*.deb + dist/thinkwatch-lite_*.deb.sha256 dist/latest.json generate_release_notes: true diff --git a/scripts/manifest.py b/scripts/manifest.py index 7c0bc970..4ef19335 100755 --- a/scripts/manifest.py +++ b/scripts/manifest.py @@ -3,7 +3,9 @@ 应用去问「有没有新版本」时读的就是这一份清单,下载的是它指向的那个文件 —— 和网页上给人下载的是同一个:macOS 上是 DMG(应用怎么从 DMG 更新自己, -见 `src-tauri/src/dmg.rs`),Windows 上是 NSIS 安装程序,更新器直接跑它。 +见 `src-tauri/src/dmg.rs`),Windows 上是 NSIS 安装程序,更新器直接跑它; +Linux 上 AppImage 原地换掉自己,deb 交给系统授权后由 apt 安装(见 +`src-tauri/src/update.rs`)。 用法:manifest.py <版本> <发布说明文件> <平台>=<文件> [<平台>=<文件> ...] @@ -26,7 +28,20 @@ REPO = "ThinkWatchProject/ThinkWatch-Lite" # 平台键由更新器自己拼:目标系统 + 架构(macOS 上叫 darwin)。**只收这几个** # —— 拼错一个字母,那个平台的用户就永远问不到新版本,而清单看起来完好。 -PLATFORMS = {"darwin-aarch64", "windows-x86_64", "windows-aarch64"} +# +# Linux 的键多一段安装方式。更新器先找 `<系统>-<架构>-<安装方式>`,找不到才退 +# 到 `<系统>-<架构>`(插件的 `get_urls`);安装方式来自打包时写进二进制的标记。 +# 同一台机器上 AppImage 和 deb 要的是不同的文件,所以两个都带后缀、不给不带 +# 后缀的那一个 —— 给了的话,哪天少写一个带后缀的,那一类用户会拿到另一种包。 +PLATFORMS = { + "darwin-aarch64", + "windows-x86_64", + "windows-aarch64", + "linux-x86_64-appimage", + "linux-x86_64-deb", + "linux-aarch64-appimage", + "linux-aarch64-deb", +} def key_id(minisign_block: str) -> bytes: @@ -80,6 +95,12 @@ def main() -> None: sys.exit(f"不认识的平台或写法:{pair}(要的是 <平台>=<文件>,平台是 {sorted(PLATFORMS)} 之一)") if platform in platforms: sys.exit(f"{platform} 给了两次") + # 两种 Linux 包在同一个架构上各有一个键,写反了的话,deb 装的会拿 + # AppImage 的字节交给 apt,AppImage 会被换成一个 deb + want_ext = {"-appimage": ".AppImage", "-deb": ".deb"} + for suffix, ext in want_ext.items(): + if platform.endswith(suffix) and not file.endswith(ext): + sys.exit(f"{platform} 要的是 {ext},给的是 {file}") platforms[platform] = entry(pathlib.Path(file), version, want) # **少一个平台就不发。**缺掉的那个平台上,已经装好的每一份都会停在旧版本, # 而发布页上看起来一切正常 @@ -102,7 +123,7 @@ def main() -> None: out = pathlib.Path(pairs[0].partition("=")[2]).parent / "latest.json" out.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") for p, e in sorted(platforms.items()): - print(f"{p:18} {e['url'].rsplit('/', 1)[1]}") + print(f"{p:23} {e['url'].rsplit('/', 1)[1]}") print(f"签名密钥 {want.hex()},和应用里的公钥是同一把") diff --git a/src-tauri/icons/256x256.png b/src-tauri/icons/256x256.png new file mode 100644 index 00000000..70538930 Binary files /dev/null and b/src-tauri/icons/256x256.png differ diff --git a/src-tauri/icons/512x512.png b/src-tauri/icons/512x512.png new file mode 100644 index 00000000..e91d4a10 Binary files /dev/null and b/src-tauri/icons/512x512.png differ diff --git a/src-tauri/icons/render.py b/src-tauri/icons/render.py index dd78cec2..a3847251 100644 --- a/src-tauri/icons/render.py +++ b/src-tauri/icons/render.py @@ -4,7 +4,8 @@ python3 src-tauri/icons/render.py <目录> # 只导出各个尺寸,用来看效果 第一种用法产出 tauri.conf.json 里列的那几个文件(32x32.png、128x128.png、 -128x128@2x.png、icon.icns),以及 Windows 构建要的 icon.ico。**别手工改 +128x128@2x.png、icon.icns),Windows 构建要的 icon.ico,以及 Linux 的 +256x256.png、512x512.png。**别手工改 它们** —— 它们是这个脚本的输出,手改会和脚本悄悄分叉,下次谁重新生成一次 就被覆盖了。 @@ -215,7 +216,17 @@ def write_ico(path, cache): } # tauri.conf.json 的 bundle.icon 里列的那几个(除 icns 外)。 -BUNDLE_PNGS = {"32x32.png": 32, "128x128.png": 128, "128x128@2x.png": 256} +# +# 256 和 512 是 Linux 的(`tauri.linux.conf.json`):deb 和 AppImage 按像素 +# 尺寸装进 `hicolor/<宽>x<高>/apps/`,而 `128x128@2x.png` 在那里落进的是 +# `256x256@2`,不是桌面环境找大图标时去的那个目录。 +BUNDLE_PNGS = { + "32x32.png": 32, + "128x128.png": 128, + "128x128@2x.png": 256, + "256x256.png": 256, + "512x512.png": 512, +} PREVIEW_SIZES = (16, 32, 64, 128, 256, 512, 1024) diff --git a/src-tauri/linux/main.desktop b/src-tauri/linux/main.desktop new file mode 100644 index 00000000..14bc9581 --- /dev/null +++ b/src-tauri/linux/main.desktop @@ -0,0 +1,16 @@ +[Desktop Entry] +Categories={{categories}} +{{#if comment}} +Comment={{comment}} +{{/if}} +# %u hands a thinkwatch:// URL (sign-in callbacks) to the app. Without a field +# code, GLib appends %f, which drops URLs that are not local files. +Exec={{exec}} %u +StartupWMClass={{exec}} +Icon={{icon}} +Name={{name}} +Terminal=false +Type=Application +{{#if mime_type}} +MimeType={{mime_type}}; +{{/if}} diff --git a/src-tauri/scripts/fetch-core.sh b/src-tauri/scripts/fetch-core.sh index e58c4870..9cf41fa8 100755 --- a/src-tauri/scripts/fetch-core.sh +++ b/src-tauri/scripts/fetch-core.sh @@ -43,6 +43,8 @@ case "${TARGET:-${TAURI_ENV_TARGET_TRIPLE:-$(rustc -vV | sed -n 's/^host: //p')} aarch64-apple-darwin) ASSET="twcore-aarch64-apple-darwin" ;; x86_64-pc-windows-msvc) ASSET="twcore-x86_64-pc-windows-msvc.exe" ;; aarch64-pc-windows-msvc) ASSET="twcore-aarch64-pc-windows-msvc.exe" ;; + x86_64-unknown-linux-gnu) ASSET="twcore-x86_64-unknown-linux-gnu" ;; + aarch64-unknown-linux-gnu) ASSET="twcore-aarch64-unknown-linux-gnu" ;; *) echo "没有为 ${TARGET:-本机} 发布的 twcore —— 发版流水线里加一条,或者用 TARGET= 指一个有的" >&2 exit 1 @@ -128,6 +130,9 @@ if command -v file >/dev/null 2>&1; then *-apple-darwin) EXPECT="arm64" ;; twcore-x86_64-pc-windows*) EXPECT="x86-64" ;; twcore-aarch64-pc-windows*) EXPECT="aarch64|arm64" ;; + # 只写 `x86-64` 的话,一个同架构的 Windows exe 也对得上 + *-x86_64-unknown-linux-gnu) EXPECT="ELF 64-bit.*x86-64" ;; + *-aarch64-unknown-linux-gnu) EXPECT="ELF 64-bit.*aarch64" ;; *) EXPECT="" ;; esac if [ -n "$EXPECT" ] && ! file "$TMP/twcore" | grep -Eqi "$EXPECT"; then diff --git a/src-tauri/src/gateway.rs b/src-tauri/src/gateway.rs index 49863f6a..388d77f7 100644 --- a/src-tauri/src/gateway.rs +++ b/src-tauri/src/gateway.rs @@ -53,7 +53,14 @@ pub const CORE_EXE: &str = if cfg!(windows) { /// 走到了开发那几条候选 —— 包里缺了 `twcore.exe` 的时候,它会去环境变量、 /// 工作目录、PATH 里找一个来跑,正是下面那段注释说要堵上的口子。界面上显示 /// 的路径也因此是框架给的 `\\?\C:\…` 那种写法。 -pub(crate) fn bundled_core() -> Option { +/// +/// **Linux 上看打包时写进二进制的标记**(`bundle_type()`,deb 和 AppImage +/// 各打一份)。资源在 `<可执行文件>/../lib/<产品名>/`:deb 是 +/// `/usr/lib/ThinkWatch Lite/`,AppImage 是挂载点下同样的相对位置(挂载点 +/// 每次启动都换,所以界面上的路径会变,这是正常的)。**从可执行文件的位置 +/// 推,不用框架的 `resource_dir()`**:它在那个目录不存在时改看 `APPDIR` +/// 环境变量 —— 又是一个让环境变量决定执行哪个二进制的口子。 +pub(crate) fn bundled_core(product: &str) -> Option { let exe = std::env::current_exe().ok()?; let dir = exe.parent()?; if dir.ends_with("Contents/MacOS") { @@ -63,6 +70,12 @@ pub(crate) fn bundled_core() -> Option { if update::nsis_installed(&exe) == update::Install::Standalone { return Some(dir.join(CORE_EXE)); } + #[cfg(target_os = "linux")] + if tauri::utils::platform::bundle_type().is_some() { + return Some(dir.parent()?.join("lib").join(product).join(CORE_EXE)); + } + #[cfg(not(target_os = "linux"))] + let _ = product; None } @@ -80,7 +93,7 @@ pub(crate) fn bundled_core() -> Option { /// 的 API key。同理,装好之后也不再看 `THINKWATCH_CORE_BIN` 和 PATH: /// 让环境变量替换掉网关本体,在开发机上是便利,在用户机器上是一个口子。 pub fn locate_core(app: &tauri::AppHandle) -> anyhow::Result { - if let Some(inside) = bundled_core() { + if let Some(inside) = bundled_core(&app.package_info().name) { if inside.exists() { return Ok(inside); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 15934d4e..49f4f338 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -89,6 +89,14 @@ pub struct AppState { } pub fn run() { + // 继承来的 AppImage 变量不是自己的,先清掉,理由见 `update::foreign_appimage_vars` + #[cfg(target_os = "linux")] + for var in update::foreign_appimage_vars(tauri::utils::platform::bundle_type()) { + // SAFETY: 这是 `run()` 的第一件事,在 Tauri、tokio、任何插件起线程之前; + // `main` 在这之前只装了日志订阅器,它不起线程。进程里此刻只有主线程, + // 没有别的线程可能同时读写环境变量。 + unsafe { std::env::remove_var(var) }; + } // 在单实例插件把这个进程判成「第二个」之前放下激活令牌 #[cfg(target_os = "linux")] window::relaunch_token::stash(); diff --git a/src-tauri/src/update.rs b/src-tauri/src/update.rs index 513c23e0..e7d62dfb 100644 --- a/src-tauri/src/update.rs +++ b/src-tauri/src/update.rs @@ -13,17 +13,23 @@ //! 程序,已装版本记在「添加/删除程序」的注册表项里;自己更新时跑的是新版本 //! 的安装程序,它会把那一项一起改掉,所以 winget 之后看到的就是新版本,不会 //! 拿旧的盖回来。 +//! +//! **Linux 上看打包时写进二进制的那个标记,不按路径猜。**打包器给 AppImage 和 +//! deb 各打一份补丁(`tauri::utils::platform::bundle_type()` 读的就是它), +//! 所以「这是哪种包」在编译产物里已经有确定的答案。AppImage 由更新器插件原地 +//! 换掉那个文件;deb 归系统的包管理器管,更新时下载、验签由插件做,安装交给 +//! `pkexec apt-get`,由系统弹授权框 —— **应用不经手用户的密码**(见 `install_deb`)。 use std::path::Path; -#[cfg(not(windows))] +#[cfg(target_os = "macos")] use std::path::PathBuf; /// cask 的名字,也是 Caskroom 下那个目录的名字。 -#[cfg(not(windows))] +#[cfg(target_os = "macos")] const CASK: &str = "thinkwatch-lite"; /// Homebrew 装在哪。 -#[cfg(not(windows))] +#[cfg(target_os = "macos")] /// /// 只有这两个。cask 要求标准前缀 —— arm64 上是 `/opt/homebrew`,另一个是 /// Intel 时代的位置;自定义前缀装不了 cask,也就不会出现在这里。 @@ -52,6 +58,8 @@ pub enum Install { Homebrew, /// 手工下载解压的 `.app`。这一种可以自己更新。 Standalone, + /// Linux 上 deb 装的。更新时下载新的 deb,经系统授权后由 apt 安装。 + Deb, /// 根本不在一个 `.app` 里 —— `tauri dev`。 Dev, } @@ -59,12 +67,12 @@ pub enum Install { impl Install { /// 这一份能不能自己把自己换掉。 pub fn can_self_update(self) -> bool { - matches!(self, Install::Standalone) + matches!(self, Install::Standalone | Install::Deb) } } /// 自己所在的 `.app`。不在一个 `.app` 里就是 `None`。 -#[cfg(not(windows))] +#[cfg(target_os = "macos")] /// /// 只认 `…/Foo.app/Contents/MacOS/可执行文件` 这一种形状。**不往上找到 /// 第一个 `.app` 为止** —— 那样一个放在 `/Applications/别的.app/` 里的 @@ -86,7 +94,7 @@ fn bundle_of(exe: &Path) -> Option<&Path> { } /// 这个 `.app` 是不是 Homebrew 放的。 -#[cfg(not(windows))] +#[cfg(target_os = "macos")] /// /// **比对的是 Caskroom 里那个符号链接。**brew 把 app 移进 `/Applications`, /// 再在 `Caskroom//<版本>/` 留一个链接指回去;每次升级都会重写它, @@ -114,7 +122,7 @@ fn from_homebrew(bundle: &Path, roots: &[PathBuf]) -> bool { } /// 给定可执行文件和 brew 的前缀,判断这是哪一种安装。 -#[cfg(not(windows))] +#[cfg(target_os = "macos")] pub fn kind_at(exe: &Path, roots: &[PathBuf]) -> Install { match bundle_of(exe) { None => Install::Dev, @@ -140,20 +148,209 @@ pub fn nsis_installed(exe: &Path) -> Install { } } +/// Linux 上这一份是怎么装上来的。 +/// +/// **AppImage 还要看 `APPIMAGE` 在不在。**同一个打过补丁的二进制,用户用 +/// `--appimage-extract` 解开之后直接跑,标记照样是 AppImage,而这时根本没有 +/// 一个可以原地替换的 AppImage 文件 —— 插件会去替换正在跑的那个可执行文件。 +/// 运行时(AppImage 的 runtime)挂载时才设这个变量。 +/// +/// rpm 不发,打出来的也不自己更新。 +#[cfg(target_os = "linux")] +pub fn linux_kind( + bundle: Option, + appimage: Option<&std::ffi::OsStr>, +) -> Install { + use tauri::utils::config::BundleType; + match bundle { + Some(BundleType::AppImage) if appimage.is_some_and(|p| !p.is_empty()) => { + Install::Standalone + } + Some(BundleType::Deb) => Install::Deb, + _ => Install::Dev, + } +} + +/// 不是自己的、要在启动时清掉的 AppImage 环境变量。 +/// +/// AppImage 的运行时挂载时设 `APPIMAGE`、`APPDIR`,而环境变量会一路继承: +/// 从一个 AppImage 里的终端、启动器拉起来的 deb 版,手里拿着的是**别人的** +/// 这两个值。Tauri 却照单全收(`Env::default` 读这两个,只打一条警告): +/// `restart()` 重启的是 `$APPIMAGE` 指的那个文件,`resource_dir()` 在自己的 +/// 资源目录不在时改看 `$APPDIR` —— 更新完重启,起来的就可能是另一个程序。 +/// 子进程(twcore)也会继承它们。 +/// +/// 只有打包标记说自己是 AppImage 时,这两个值才是运行时给自己设的。 +#[cfg(target_os = "linux")] +pub fn foreign_appimage_vars( + bundle: Option, +) -> &'static [&'static str] { + if bundle == Some(tauri::utils::config::BundleType::AppImage) { + &[] + } else { + &["APPIMAGE", "APPDIR"] + } +} + /// 这一份是怎么装上来的。 pub fn kind() -> Install { - // 连自己在哪都答不上来时当作 `Dev` —— 那一档不自己更新。**不确定的 - // 时候不要动用户装好的那一份。** - let Ok(exe) = std::env::current_exe() else { - return Install::Dev; - }; - #[cfg(windows)] + // Linux 上不看路径:答案在打包时已经写进二进制里了(见 `linux_kind`) + #[cfg(target_os = "linux")] { - nsis_installed(&exe) + linux_kind( + tauri::utils::platform::bundle_type(), + std::env::var_os("APPIMAGE").as_deref(), + ) } - #[cfg(not(windows))] + #[cfg(not(target_os = "linux"))] { - kind_at(&exe, &BREW_PREFIXES.map(PathBuf::from)) + // 连自己在哪都答不上来时当作 `Dev` —— 那一档不自己更新。**不确定的 + // 时候不要动用户装好的那一份。** + let Ok(exe) = std::env::current_exe() else { + return Install::Dev; + }; + #[cfg(windows)] + { + nsis_installed(&exe) + } + #[cfg(target_os = "macos")] + { + kind_at(&exe, &BREW_PREFIXES.map(PathBuf::from)) + } + } +} + +/// 发布页。自动更新装不上时,从这里手动下载新版本。 +#[cfg(target_os = "linux")] +const RELEASES_URL: &str = "https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/latest"; + +/// 系统授权框。写全路径:这是一个以 root 身份执行东西的入口,不按 PATH 找。 +#[cfg(target_os = "linux")] +const PKEXEC: &str = "/usr/bin/pkexec"; +/// 用 apt 而不是 `dpkg -i`:新版本多了依赖时 apt 会一起补上,dpkg 只会装到 +/// 一半停下,留下一个「依赖未满足」的包。 +#[cfg(target_os = "linux")] +const APT_GET: &str = "/usr/bin/apt-get"; + +/// 装一个已经验过签的 deb。**`bytes` 必须来自更新器插件的 `download()`** +/// —— 它在交出字节之前按应用里的公钥核对签名。 +/// +/// **不用插件的 `install()`。**它先试 pkexec,失败了就用 zenity / kdialog 弹一个 +/// 输入框要用户的密码,再喂给 `sudo -S`;那也失败的话去跑一个没有终端的 +/// `sudo`,会卡住。一个桌面应用不该经手用户的系统密码 —— 授权只交给系统的 +/// 授权框,它失败就是失败。 +/// +/// 包放在 `/tmp` 下一个只有自己能进的新目录里:root 读得到,别的用户读不到、 +/// 也换不掉。目录用 `create` 而不是 `create_dir_all` —— 名字被人抢先占了 +/// 就失败,不去用一个别人建的目录。 +#[cfg(target_os = "linux")] +pub async fn install_deb(bytes: &[u8]) -> Result<(), String> { + use std::os::unix::fs::DirBuilderExt; + + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or_default(); + let dir = + std::env::temp_dir().join(format!("thinkwatch-update-{}-{nanos}", std::process::id())); + std::fs::DirBuilder::new() + .mode(0o700) + .create(&dir) + .map_err(|e| not_installed(&e.to_string()))?; + let file = dir.join("thinkwatch-lite.deb"); + let out = match std::fs::write(&file, bytes) { + Ok(()) => tokio::process::Command::new(PKEXEC) + .arg(APT_GET) + .args(["install", "-y"]) + .arg(&file) + .output() + .await + .map_err(|e| e.to_string()), + Err(e) => Err(e.to_string()), + }; + let _ = std::fs::remove_dir_all(&dir); + + let out = match out { + Ok(out) => out, + Err(e) => { + tracing::error!("更新:没能执行 {PKEXEC}:{e}"); + return Err(if std::path::Path::new(PKEXEC).exists() { + not_installed(&e) + } else { + not_installed(tr!("系统中没有 pkexec", "pkexec is not available")) + }); + } + }; + if out.status.success() { + return Ok(()); + } + tracing::error!( + "更新:apt 安装失败({}):{}", + out.status, + String::from_utf8_lossy(&out.stderr).trim() + ); + Err(not_installed(deb_failure(out.status.code()))) +} + +/// 「更新未安装」加上去哪里下载。 +#[cfg(target_os = "linux")] +fn not_installed(why: &str) -> String { + tr!( + format!("更新未安装:{why}。新版本可从 {RELEASES_URL} 下载。"), + format!( + "The update was not installed: {why}. The new version can be downloaded from {RELEASES_URL}." + ) + ) +} + +/// pkexec 的退出码说成一句话。 +/// +/// 126 是授权框被关掉了,127 是没拿到授权(或者会话里没有授权代理)。 +/// 其余的是 apt 自己的退出码 —— 包管理器那一侧的失败,比如被别的安装占着锁。 +#[cfg(target_os = "linux")] +fn deb_failure(code: Option) -> &'static str { + match code { + Some(126) => tr!("授权已取消", "authorization was cancelled"), + Some(127) => tr!( + "未获得管理员授权", + "administrator authorization was not granted" + ), + _ => tr!( + "软件包管理器未能完成安装", + "the package manager could not complete the installation" + ), + } +} + +/// 插件替换 AppImage 失败时的那一句。 +/// +/// 替换的做法是把原文件改名挪走、在原处写新的,所以 AppImage 所在的目录必须 +/// 可写,而且插件要在同一个文件系统上找到一个临时目录。放在 `/opt` 这类 +/// 只有 root 能写的地方时,两个条件都不满足 —— 这不是重试能解决的,说清楚 +/// 去哪下载。 +#[cfg(target_os = "linux")] +pub fn appimage_failure(e: &tauri_plugin_updater::Error) -> String { + use tauri_plugin_updater::Error; + let location = match e { + Error::TempDirNotOnSameMountPoint => true, + Error::Io(io) => matches!( + io.kind(), + std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::ReadOnlyFilesystem + ), + _ => false, + }; + if location { + tr!( + format!("无法写入 AppImage 所在的位置,更新未安装。新版本可从 {RELEASES_URL} 下载。"), + format!( + "The location of the AppImage is not writable, so the update was not installed. The new version can be downloaded from {RELEASES_URL}." + ) + ) + } else { + tr!( + format!("安装失败:{e}"), + format!("Installation failed: {e}") + ) } } @@ -200,7 +397,7 @@ mod tests { p } - #[cfg(not(windows))] + #[cfg(target_os = "macos")] #[test] fn only_the_one_shape_counts_as_a_bundle() { assert_eq!( @@ -307,6 +504,61 @@ mod tests { std::fs::remove_dir_all(&root).unwrap(); } + /// Linux 上信打包时写进去的标记,不信路径和环境变量。 + #[cfg(target_os = "linux")] + #[test] + fn linux_trusts_the_bundle_marker() { + use tauri::utils::config::BundleType; + let image = std::ffi::OsStr::new("/home/u/Apps/ThinkWatch-Lite.AppImage"); + assert_eq!( + linux_kind(Some(BundleType::AppImage), Some(image)), + Install::Standalone + ); + // `--appimage-extract` 解开之后直接跑:没有一个可以原地替换的文件 + assert_eq!(linux_kind(Some(BundleType::AppImage), None), Install::Dev); + assert_eq!( + linux_kind(Some(BundleType::AppImage), Some(std::ffi::OsStr::new(""))), + Install::Dev + ); + assert_eq!(linux_kind(Some(BundleType::Deb), None), Install::Deb); + assert!(Install::Deb.can_self_update()); + // 不发 rpm;打出来的也不自己更新 + assert_eq!(linux_kind(Some(BundleType::Rpm), None), Install::Dev); + // `cargo run` 的构建没有标记。环境里有个 APPIMAGE 也不算 + assert_eq!(linux_kind(None, Some(image)), Install::Dev); + } + + /// 只有 AppImage 留着这两个变量;deb 和开发构建一律清掉,restart 不会跑去 + /// 一个继承来的 `$APPIMAGE`。 + #[cfg(target_os = "linux")] + #[test] + fn only_an_appimage_keeps_the_appimage_variables() { + use tauri::utils::config::BundleType; + assert!(foreign_appimage_vars(Some(BundleType::AppImage)).is_empty()); + for bundle in [Some(BundleType::Deb), Some(BundleType::Rpm), None] { + assert_eq!(foreign_appimage_vars(bundle), ["APPIMAGE", "APPDIR"]); + } + } + + /// 放在只有 root 能写的目录里,是「换个地方」的问题,不是「再试一次」。 + #[cfg(target_os = "linux")] + #[test] + fn an_unwritable_appimage_location_says_where_to_download() { + use tauri_plugin_updater::Error; + let denied = Error::Io(std::io::Error::from(std::io::ErrorKind::PermissionDenied)); + assert!(appimage_failure(&denied).contains(RELEASES_URL)); + assert!(appimage_failure(&Error::TempDirNotOnSameMountPoint).contains(RELEASES_URL)); + assert!(!appimage_failure(&Error::BinaryNotFoundInArchive).contains(RELEASES_URL)); + } + + #[cfg(target_os = "linux")] + #[test] + fn a_dismissed_authorization_is_not_reported_as_a_broken_package() { + assert_ne!(deb_failure(Some(126)), deb_failure(Some(100))); + assert_ne!(deb_failure(Some(127)), deb_failure(Some(100))); + assert!(not_installed(deb_failure(Some(126))).contains(RELEASES_URL)); + } + /// 读的是 tap 里真实的那份 cask —— 格式变了,这条先红。 #[test] fn the_version_comes_out_of_a_real_cask() { diff --git a/src-tauri/src/updater.rs b/src-tauri/src/updater.rs index c40de79c..f9a5f6e6 100644 --- a/src-tauri/src/updater.rs +++ b/src-tauri/src/updater.rs @@ -6,7 +6,7 @@ use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; #[cfg(target_os = "macos")] use crate::dmg; -#[cfg(windows)] +#[cfg(any(windows, target_os = "linux"))] use crate::gateway::restart_gateway; use crate::{AppState, control::ControlClient, data_dir, error::Out, i18n, notices, prefs, update}; @@ -478,6 +478,23 @@ pub async fn update_install( let marker = data_dir().join(UPDATED_FROM); let from = app.package_info().version.to_string(); + // **deb 不交给插件装**,理由见 `update::install_deb`。和 Windows 一样先停 + // 网关再装:装不成(授权框点了取消最常见)就把网关接回来 + #[cfg(target_os = "linux")] + if install == update::Install::Deb { + state + .supervisor + .stop_and_wait(std::time::Duration::from_secs(5)) + .await; + if let Err(e) = update::install_deb(&bytes).await { + resume_after_failed_update(&app).await; + return Err(e.into()); + } + let _ = std::fs::write(&marker, &from); + let _ = app.emit("update-step", Step::Restarting); + app.restart() + } + // **Windows 上 `install` 不回来。**插件拉起新版本的安装程序之后当场 // `exit(0)`,排在它后面的每一步都轮不到 —— 所以标记和停 core 都得挪到 // 它前面。停 core 在那边还是硬要求:`twcore.exe` 还在跑的话,安装程序 @@ -505,6 +522,11 @@ pub async fn update_install( } #[cfg(not(windows))] { + // Linux 上走到这里的只有 AppImage:插件原地换掉 `$APPIMAGE` 那个文件 + #[cfg(target_os = "linux")] + up.install(&bytes) + .map_err(|e| update::appimage_failure(&e))?; + #[cfg(target_os = "macos")] up.install(&bytes).map_err(|e| { tr!( format!("安装失败:{e}"), @@ -528,7 +550,9 @@ pub async fn update_install( /// **先等守护循环真的退干净。**`stop_and_wait` 在 core 翻成「已停止」时就 /// 返回了,而循环要再走一步才把「正在守护」放下;这之间去拉,会被当成 /// 「守护还在,重启一下」,然后因为 core 不在跑而什么也不做。 -#[cfg(windows)] +/// +/// Windows 的安装程序和 Linux 的 deb 都是先停网关再装,装不成都走这里。 +#[cfg(any(windows, target_os = "linux"))] pub(crate) async fn resume_after_failed_update(app: &tauri::AppHandle) { use std::sync::atomic::Ordering; let state = app.state::(); diff --git a/src-tauri/tauri.linux.conf.json b/src-tauri/tauri.linux.conf.json new file mode 100644 index 00000000..8d0c081c --- /dev/null +++ b/src-tauri/tauri.linux.conf.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "targets": ["appimage", "deb"], + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/256x256.png", + "icons/512x512.png" + ], + "category": "DeveloperTool", + "shortDescription": "Local AI gateway", + "publisher": "ThinkWatchProject", + "homepage": "https://github.com/ThinkWatchProject/ThinkWatch-Lite", + "linux": { + "deb": { + "section": "devel", + "desktopTemplate": "linux/main.desktop" + } + } + } +} diff --git a/src/Update.i18n.ts b/src/Update.i18n.ts index 27949b95..59bf5e7f 100644 --- a/src/Update.i18n.ts +++ b/src/Update.i18n.ts @@ -19,6 +19,7 @@ export const updateText = messages( available: (version: string) => `ThinkWatch Lite ${version} 可用`, closeWhileWaiting: "关闭此窗口不影响更新,更新完成后将发送通知。", standalone: "下载完成后自动安装。网关将在进行中的请求全部结束后重新启动。", + deb: "下载完成后需要管理员授权才能安装。网关将在进行中的请求全部结束后重新启动。", later: "稍后", install: "下载并安装", homebrew: "此应用由 Homebrew 管理,请在终端中执行以下命令完成更新:", @@ -47,6 +48,8 @@ export const updateText = messages( closeWhileWaiting: "Closing this window does not stop the update. A notification is sent when it is complete.", standalone: "Installs automatically after downloading. The gateway restarts once all requests in progress have finished.", + deb: + "Installing requires administrator authorization after downloading. The gateway restarts once all requests in progress have finished.", later: "Later", install: "Download and install", homebrew: "This app is managed by Homebrew. To update, run this command in Terminal:", diff --git a/src/UpdateWindow.tsx b/src/UpdateWindow.tsx index 6dc560f2..d429efef 100644 --- a/src/UpdateWindow.tsx +++ b/src/UpdateWindow.tsx @@ -26,6 +26,7 @@ const COPIED_MS = 2_000; * * · 从网页下载的:「下载并安装」,按一次之后不再问任何问题 —— 下载、 * 等网关手上的请求结束、替换、重启,全部自动。 + * · Linux 的 deb:同一个按钮,只是安装那一步由系统弹授权框。 * · Homebrew 装的:给出那条命令和复制按钮。更新交给 brew。 * · 开发构建:只说明不自动更新。 * @@ -165,7 +166,7 @@ export default function UpdateWindow() { ) : (

- {t.standalone} + {offer.install === "deb" ? t.deb : t.standalone}

)} {failed &&

{failed}

} diff --git a/src/updateFlow.test.ts b/src/updateFlow.test.ts index 695ca721..4b023c9d 100644 --- a/src/updateFlow.test.ts +++ b/src/updateFlow.test.ts @@ -15,6 +15,10 @@ describe("谁能在窗口里直接装", () => { expect(canInstall("standalone")).toBe(true); }); + it("deb 装的也可以:安装那一步由系统授权", () => { + expect(canInstall("deb")).toBe(true); + }); + it("开发构建不自己更新", () => { expect(canInstall("dev")).toBe(false); }); diff --git a/src/updateFlow.ts b/src/updateFlow.ts index ef4f217f..c3965e09 100644 --- a/src/updateFlow.ts +++ b/src/updateFlow.ts @@ -8,7 +8,7 @@ import { textOf } from "@/i18n"; import { updateText } from "./Update.i18n"; /** 这一份是怎么装上来的。决定更新由谁做。 */ -export type Install = "homebrew" | "standalone" | "dev"; +export type Install = "homebrew" | "standalone" | "deb" | "dev"; /** 查到的新版本。 */ export interface Found { @@ -46,9 +46,11 @@ export type Step = * 一个已经不在磁盘上的版本,下一次 `brew upgrade` 会把旧的那版盖回来。 * Rust 那一侧也挡着;这里再判断一次,是因为多出来的那个按钮本身就是错 * 的 —— 它承诺了一件做不到的事,用户要点下去才知道。 + * + * Linux 的 deb 可以:下载、验签之后交给系统的授权框和 apt 安装。 */ export function canInstall(install: Install): boolean { - return install === "standalone"; + return install === "standalone" || install === "deb"; } const MB = 1_048_576;