From a863fdf0d7a03ac24bacc549c5b6bb81ad36d55d Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Sun, 18 Oct 2026 13:49:39 +0900 Subject: [PATCH 1/2] Linux packages run on Ubuntu 22.04 (glibc 2.35) SharpHook's libuiohook-xrecord.so and libuiohook-x11.so need GLIBC_2.38 (__isoc23_sscanf, built on Ubuntu 26.04) and rhwp's Linux release needs GLIBC_2.39, so neither starts on Ubuntu 22.04 (#102). - portable.yml: a linux-native job builds both from pinned, unmodified source on ubuntu-22.04 and ubuntu-22.04-arm (build/build-linux-native.py: libuiohook at SharpHook 8.0.0's submodule commit with SharpHook's CMake flags and unit tests; rhwp v0.8.7 with cargo +1.93.1 --locked), cached. The Linux package jobs pass them to build-portable.ps1 (-RhwpOverride, -UiohookOverride); local builds keep using the downloads. - build-linux-native.py check fails when Directory.Packages.props or engines.json move to another SharpHook or rhwp version than it builds. - build/check-elf-glibc.py (standard library ELF reader) lists the newest GLIBC version of every ELF file and fails above 2.35, on the native builds and on every Linux package. - The package smoke steps move to build/smoke-portable.ps1 and build/smoke-launch-app.py, add TXT -> HWPX -> HWP -> PDF through rhwp, load libuiohook-xrecord.so and fail on ErrorLinuxLoadBackend in the app log. A new job runs them on Ubuntu 22.04 (x64 and arm64) with its own packages. - The Shortcuts page explains a Linux back end that doesn't load (X11 session, libxtst6 and libxt6) instead of the raw error; the log keeps the libuiohook result. - INSTALL.txt has the Ubuntu 22.04 and 24.04 dependency commands with libxt6; READMEs say Ubuntu 22.04+; notices and docs describe the builds. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/portable.yml | 268 ++++++++++++------ CONTRIBUTING.md | 10 +- README.ko.md | 2 +- README.md | 2 +- README.zh-CN.md | 2 +- THIRD-PARTY-NOTICES.md | 12 + build/build-linux-native.py | 262 +++++++++++++++++ build/build-portable.ps1 | 52 +++- build/check-elf-glibc.py | 253 +++++++++++++++++ build/smoke-launch-app.py | 90 ++++++ build/smoke-portable.ps1 | 78 +++++ docs/ENGINES.md | 29 +- installer/linux/INSTALL.txt | 26 +- src/Filee.App/Assets/i18n/en.json | 1 + src/Filee.App/Assets/i18n/ko.json | 1 + src/Filee.App/Assets/i18n/zh-CN.json | 1 + .../Services/Triggers/TriggerService.cs | 27 +- .../ViewModels/Pages/TriggersPageViewModel.cs | 9 +- tests/Filee.App.Tests/HookFailureTests.cs | 25 ++ 19 files changed, 1033 insertions(+), 117 deletions(-) create mode 100644 build/build-linux-native.py create mode 100644 build/check-elf-glibc.py create mode 100644 build/smoke-launch-app.py create mode 100644 build/smoke-portable.ps1 create mode 100644 tests/Filee.App.Tests/HookFailureTests.cs diff --git a/.github/workflows/portable.yml b/.github/workflows/portable.yml index fe0fc0f2..e82dbe85 100644 --- a/.github/workflows/portable.yml +++ b/.github/workflows/portable.yml @@ -1,5 +1,10 @@ name: Linux and macOS packages +# Linux packages are built on Ubuntu 24.04 but must run on Ubuntu 22.04 (glibc 2.35). The two upstream binaries that +# need a newer glibc (SharpHook's X11 libuiohook back ends and the rhwp CLI) are built from pinned source on Ubuntu +# 22.04 first (linux-native, build/build-linux-native.py) and replace the downloaded ones in the package. Every ELF +# file in a Linux package is then checked against glibc 2.35, and the x64 and arm64 packages are tried on Ubuntu 22.04. + on: workflow_call: inputs: @@ -18,7 +23,88 @@ permissions: contents: read jobs: + linux-native: + name: Linux native ${{ matrix.rid }} (Ubuntu 22.04) + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-22.04 + rid: linux-x64 + - runner: ubuntu-22.04-arm + rid: linux-arm64 + runs-on: ${{ matrix.runner }} + timeout-minutes: 90 + defaults: + run: + shell: bash + env: + NATIVE_OUT: ${{ github.workspace }}/build/.cache/linux-native/${{ matrix.rid }} + steps: + - uses: actions/checkout@v4 + + # Fails when Directory.Packages.props or engines.json moved to another SharpHook or rhwp version while + # build/build-linux-native.py still builds the old sources (also when the build below comes from the cache). + - name: Check pinned sources + run: python3 build/build-linux-native.py check + + - name: Restore native builds + id: native-cache + uses: actions/cache@v4 + with: + path: build/.cache/linux-native/${{ matrix.rid }} + key: linux-native-${{ matrix.rid }}-ubuntu-22.04-${{ hashFiles('build/build-linux-native.py') }} + + # The packages SharpHook's build.yml installs, plus CMake >= 4.2 (libuiohook's minimum) from PyPI, pinned. + - name: Build dependencies + if: steps.native-cache.outputs.cache-hit != 'true' + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y build-essential pkg-config git python3-pip extra-cmake-modules \ + libx11-dev libxtst-dev libxt-dev libxrandr-dev libx11-xcb-dev libxkbcommon-dev libxkbcommon-x11-dev \ + libxkbfile-dev libinput-dev libudev-dev libwayland-dev wayland-protocols + python3 -m pip install --user cmake==4.2.3 + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + if ! command -v rustup >/dev/null; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain none + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + fi + + - name: Restore cargo downloads + if: steps.native-cache.outputs.cache-hit != 'true' + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + key: cargo-registry-${{ matrix.rid }}-${{ hashFiles('build/build-linux-native.py') }} + restore-keys: cargo-registry-${{ matrix.rid }}- + + - name: Build libuiohook back ends + if: steps.native-cache.outputs.cache-hit != 'true' + run: python3 build/build-linux-native.py uiohook --output "$NATIVE_OUT/uiohook" --work "$RUNNER_TEMP/uiohook" + + - name: Build rhwp + if: steps.native-cache.outputs.cache-hit != 'true' + run: python3 build/build-linux-native.py rhwp --output "$NATIVE_OUT/rhwp" --work "$RUNNER_TEMP/rhwp" + + - name: Check glibc requirements + run: python3 build/check-elf-glibc.py --max 2.35 --needed "$NATIVE_OUT" + + - name: Upload native builds + uses: actions/upload-artifact@v4 + with: + name: linux-native-${{ matrix.rid }} + path: build/.cache/linux-native/${{ matrix.rid }}/ + if-no-files-found: error + portable: + needs: linux-native + # macOS packages don't use the Linux builds: they still run when those failed (the Linux legs then stop at the + # download). + if: ${{ !cancelled() }} strategy: fail-fast: false matrix: @@ -39,6 +125,13 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Download Linux native builds + if: runner.os == 'Linux' + uses: actions/download-artifact@v4 + with: + name: linux-native-${{ matrix.rid }} + path: ${{ runner.temp }}/linux-native + - uses: actions/setup-dotnet@v4 with: global-json-file: global.json @@ -49,7 +142,7 @@ jobs: run: | sudo apt-get update sudo apt-get install -y ca-certificates libc6 libgcc-s1 libgssapi-krb5-2 libicu74 libssl3t64 libstdc++6 tzdata zlib1g \ - libfontconfig1 libfreetype6 libx11-6 libice6 libsm6 libxrandr2 libxkbcommon0 libxi6 libxtst6 xvfb fonts-noto-cjk + libfontconfig1 libfreetype6 libx11-6 libice6 libsm6 libxrandr2 libxkbcommon0 libxi6 libxtst6 libxt6 xvfb fonts-noto-cjk - name: Fonts run: ./build/fetch-fonts.ps1 @@ -77,106 +170,31 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } + # Linux: rhwp and SharpHook's X11 back ends from linux-native instead of the downloads (see the top). - name: Build portable package env: PACKAGE_VERSION: ${{ inputs.version }} - run: ./build/build-portable.ps1 -Runtime '${{ matrix.rid }}' -Version $env:PACKAGE_VERSION -SkipFonts - - - name: Check archive and run native converters run: | - $archives = @(Get-ChildItem Releases -Filter '*-${{ matrix.rid }}.tar.gz') - if ($archives.Count -ne 1) { throw 'Expected one portable archive.' } - $archive = $archives[0] - $expected = (Get-Content (Join-Path Releases ($archive.Name.Replace('.tar.gz', '-SHA256SUMS.txt')))).Split(' ')[0] - if ((Get-FileHash $archive.FullName -Algorithm SHA256).Hash -ne $expected) { throw 'Archive checksum mismatch.' } - $smoke = Join-Path $env:RUNNER_TEMP 'filee-smoke' - New-Item -ItemType Directory -Path $smoke | Out-Null - tar -xzf $archive.FullName -C $smoke - if ($LASTEXITCODE -ne 0) { throw 'Archive extraction failed.' } - $package = Join-Path $smoke ($archive.Name.Replace('.tar.gz', '')) - if ($IsMacOS) { - codesign --verify --deep --strict (Join-Path $package 'Filee.app') - if ($LASTEXITCODE -ne 0) { throw 'Extracted app signature did not verify.' } + $native = @{} + if ($IsLinux) { + $native = @{ + RhwpOverride = Join-Path $env:RUNNER_TEMP 'linux-native/rhwp' + UiohookOverride = Join-Path $env:RUNNER_TEMP 'linux-native/uiohook' + } } - $bin = if ($IsMacOS) { Join-Path $package 'Filee.app/Contents/MacOS' } else { $package } - $cli = Join-Path $bin 'filee-cli' - & $cli version - if ($LASTEXITCODE -ne 0) { throw 'CLI did not start.' } - & (Join-Path $bin 'engines/rhwp/rhwp') --version - if ($LASTEXITCODE -ne 0) { throw 'rhwp did not start.' } - & (Join-Path $bin 'engines/7zip/7zz') i | Out-Null - if ($LASTEXITCODE -ne 0) { throw '7-Zip did not start.' } - $inputFile = Join-Path $smoke 'sample.txt' - 'Filee 한글 conversion smoke test' | Set-Content $inputFile -Encoding utf8NoBOM - & $cli convert $inputFile --to hwpx -o (Join-Path $smoke 'converted') --json - if ($LASTEXITCODE -ne 0 -or -not (Test-Path (Join-Path $smoke 'converted/sample.hwpx'))) { throw 'Published CLI conversion failed.' } - "FILEE_SMOKE_APP=$(Join-Path $bin 'Filee')" >> $env:GITHUB_ENV + ./build/build-portable.ps1 -Runtime '${{ matrix.rid }}' -Version $env:PACKAGE_VERSION -SkipFonts @native + + - name: Check archive and run native converters + run: ./build/smoke-portable.ps1 -Runtime '${{ matrix.rid }}' + + - name: Check glibc requirements + if: runner.os == 'Linux' + shell: bash + run: python3 build/check-elf-glibc.py --max 2.35 "$FILEE_SMOKE_PACKAGE" - name: Launch native desktop app shell: bash - run: | - set -euo pipefail - python3 - <<'PY' - import os - import pathlib - import signal - import subprocess - import sys - import time - - app = os.environ['FILEE_SMOKE_APP'] - shots = pathlib.Path(os.environ['RUNNER_TEMP']) / 'filee-screens' - shots.mkdir(exist_ok=True) - command = [app, '--settings', '--install-engines=', '--context-menu=off', '--start-with-system=off'] - if os.environ['RUNNER_OS'] == 'Linux': - command = ['xvfb-run', '-a', 'env', '-u', 'WAYLAND_DISPLAY', 'XDG_SESSION_TYPE=x11', *command] - log_path = pathlib.Path(os.environ['RUNNER_TEMP']) / 'filee-startup.log' - with log_path.open('w') as log: - process = subprocess.Popen(command, stdout=log, stderr=subprocess.STDOUT, start_new_session=True) - try: - time.sleep(8) - if process.poll() is not None: - raise RuntimeError('The desktop app exited before the startup check.') - if os.environ['RUNNER_OS'] == 'macOS': - # The real screen with Filee's window, as a person would see it (uploaded with the results). - subprocess.run(['screencapture', '-x', str(shots / 'settings.png')], check=False, timeout=30) - # A second start forwards to the running Filee, which brings its window to the front. - try: - subprocess.run([app], check=False, timeout=30) - except subprocess.TimeoutExpired: - raise RuntimeError('A second start did not hand over to the running Filee.') - time.sleep(4) - subprocess.run(['screencapture', '-x', str(shots / 'second-start.png')], check=False, timeout=30) - if process.poll() is not None: - raise RuntimeError('The desktop app exited after a second start.') - quit_code = 'import os, sys; os.setsid(); os.execv(sys.argv[1], [sys.argv[1], "--quit"])' - # Reap Filee as soon as it ends while --quit waits for it, as launchd or a shell would: a process - # that has ended but was not reaped by its parent (this script) still counts as running. - quitter = subprocess.Popen([sys.executable, '-c', quit_code, app]) - quit_deadline = time.monotonic() + 30 - while quitter.poll() is None: - process.poll() - if time.monotonic() > quit_deadline: - quitter.kill() - raise RuntimeError('filee --quit did not finish.') - time.sleep(0.05) - if quitter.returncode != 0: - raise RuntimeError(f'filee --quit exited with {quitter.returncode}.') - if process.wait(timeout=15) != 0: - raise RuntimeError('The desktop app did not exit cleanly.') - except BaseException: - log.flush() - print(log_path.read_text(errors='replace'), file=sys.stderr) - raise - finally: - if process.poll() is None: - os.killpg(process.pid, signal.SIGTERM) - try: - process.wait(timeout=10) - except subprocess.TimeoutExpired: - os.killpg(process.pid, signal.SIGKILL) - process.wait() - PY + run: python3 build/smoke-launch-app.py - name: Collect Filee logs if: always() @@ -207,3 +225,67 @@ jobs: ${{ runner.temp }}/filee-startup.log ${{ runner.temp }}/filee-screens/ if-no-files-found: ignore + + # The packages built above, unpacked and started on the oldest supported Ubuntu, with only the runtime libraries a + # user installs from installer/linux/INSTALL.txt. + ubuntu-22-04: + name: Package on Ubuntu 22.04 (${{ matrix.rid }}) + needs: portable + # Runs when another platform's package failed; a missing Linux package fails the download. + if: ${{ !cancelled() }} + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-22.04 + rid: linux-x64 + - runner: ubuntu-22.04-arm + rid: linux-arm64 + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + defaults: + run: + shell: pwsh + steps: + - uses: actions/checkout@v4 + + - name: Linux desktop dependencies (Ubuntu 22.04) + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y ca-certificates libc6 libgcc-s1 libgssapi-krb5-2 libicu70 libssl3 libstdc++6 tzdata zlib1g \ + libfontconfig1 libfreetype6 libx11-6 libice6 libsm6 libxrandr2 libxkbcommon0 libxi6 libxtst6 libxt6 xvfb fonts-noto-cjk + + - name: Download package + uses: actions/download-artifact@v4 + with: + name: portable-${{ matrix.rid }} + path: Releases + + - name: Check archive and run native converters + run: ./build/smoke-portable.ps1 -Runtime '${{ matrix.rid }}' + + - name: Check glibc requirements + shell: bash + run: python3 build/check-elf-glibc.py --max 2.35 "$FILEE_SMOKE_PACKAGE" + + - name: Launch native desktop app + shell: bash + run: python3 build/smoke-launch-app.py + + - name: Collect Filee logs + if: always() + shell: bash + run: | + mkdir -p "$RUNNER_TEMP/filee-screens" + if [ -d "$HOME/.config/Filee/logs" ]; then cp -R "$HOME/.config/Filee/logs" "$RUNNER_TEMP/filee-screens/"; fi + + - name: Upload diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: ubuntu-22-04-results-${{ matrix.rid }} + path: | + ${{ runner.temp }}/filee-startup.log + ${{ runner.temp }}/filee-screens/ + if-no-files-found: ignore diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index de9d21c2..963fdfb8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,8 +35,13 @@ install/uninstall scripts. `Filee---SHA256SUMS.txt` verifies the a `com.filee.app` and a development ad-hoc signature by default; public distribution still needs Developer ID signing and notarization. `-SigningIdentity` can supply a signing identity, but does not perform notarization. +Linux release packages must run on Ubuntu 22.04 (glibc 2.35). CI builds rhwp and SharpHook's X11 input hook back ends +from source on Ubuntu 22.04 (`build/build-linux-native.py`) and passes them with `-RhwpOverride` and +`-UiohookOverride`; a local package without them needs the glibc of the machine that built it. Check a package with +`python3 build/check-elf-glibc.py --max 2.35 `. See [docs/ENGINES.md](docs/ENGINES.md#linux-builds-for-older-glibc). + - **Linux:** drag gestures require X11. Install the Avalonia desktop dependencies (fontconfig, FreeType, X11, - Xrandr, Xi, Xtst, XkbCommon, ICE and SM) and CJK fonts for document comparisons. File actions are installed for + Xrandr, Xi, Xtst, Xt, XkbCommon, ICE and SM) and CJK fonts for document comparisons. File actions are installed for Thunar, Nautilus, Nemo and Dolphin. The first enabled keyboard shortcut uses Thunar's native custom action; close Thunar before changing or pausing that shortcut and reopen it afterwards. Global selected-file queries and hold gestures are not supported. Wayland can use file actions, the drop zone, watch folders and CLI. @@ -48,7 +53,8 @@ and notarization. `-SigningIdentity` can supply a signing identity, but does not The macOS LibreOffice package does not include H2Orestart; native rhwp/Unhwp provide the supported HWP routes. Use Settings > Engines to inspect actual availability before comparing formats. -The reusable `portable.yml` workflow builds and tests all four targets. Native platform tests skip on other OSes. +The reusable `portable.yml` workflow builds and tests all four targets and starts the Linux packages on Ubuntu 22.04. +Native platform tests skip on other OSes. Before calling a port ready, verify the installed package, CLI conversion, Finder/Thunar selection with Unicode and spaced paths, drag and keyboard gestures, permissions denied/granted, Retina/scaling, tray, login startup, engine downloads, watch folders, and uninstall. CI GUI startup alone does not validate these interactions. diff --git a/README.ko.md b/README.ko.md index 75e2cac9..052b4307 100644 --- a/README.ko.md +++ b/README.ko.md @@ -49,7 +49,7 @@ 설치할 때 탐색기 메뉴, 로그인 시 자동 실행, `filee` 명령, 추가 엔진을 고를 수 있어요. - **macOS 14 이상** (미리보기): `osx-arm64` 또는 `osx-x64` 패키지를 풀고 `./install.sh`를 실행해요. 아직 공증 전이라, 처음 실행할 때 시스템 설정 → 개인정보 보호 및 보안 → **그래도 열기**로 허용해 주세요. -- **Linux x64·ARM64** (미리보기): 패키지를 풀고 `./install.sh`를 실행해요(root 불필요, Python 3 필요). +- **Linux x64·ARM64** (미리보기, Ubuntu 22.04 이상): 패키지를 풀고 `./install.sh`를 실행해요(root 불필요, Python 3 필요). 드래그 제스처는 X11에서 돼요. Wayland에서는 드롭 영역과 파일 관리자 우클릭 메뉴를 써 주세요. 새 버전이 나오면 메뉴에 표시되고, 설정과 엔진은 그대로 둔 채 업데이트돼요. diff --git a/README.md b/README.md index 08b5b981..34e75225 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Download from [Releases](https://github.com/KnifeLemon/Filee/releases/latest). Setup can add the Explorer menu, start at sign-in, the `filee` command and optional engines. - **macOS 14+** (preview): unpack the `osx-arm64` or `osx-x64` package and run `./install.sh`. The build isn't notarized yet, so allow the first start in System Settings → Privacy & Security → **Open Anyway**. -- **Linux x64/ARM64** (preview): unpack the package and run `./install.sh` (no root, needs Python 3). +- **Linux x64/ARM64** (preview, Ubuntu 22.04+): unpack the package and run `./install.sh` (no root, needs Python 3). Drag gestures need X11. On Wayland, use the drop zone and the file manager's right-click menu. Filee shows new releases in its menu and updates itself, keeping your settings and engines. diff --git a/README.zh-CN.md b/README.zh-CN.md index 1760ad50..8a145e14 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -49,7 +49,7 @@ 安装时可以添加资源管理器菜单、登录时启动、`filee` 命令和可选引擎。 - **macOS 14 及以上**(预览)解压 `osx-arm64` 或 `osx-x64` 包并运行 `./install.sh`。 目前尚未公证,首次启动时请在系统设置 → 隐私与安全性中点 **仍要打开**。 -- **Linux x64、ARM64**(预览)解压后运行 `./install.sh`(无需 root,需要 Python 3)。 +- **Linux x64、ARM64**(预览,Ubuntu 22.04 及以上)解压后运行 `./install.sh`(无需 root,需要 Python 3)。 拖动手势需要 X11,Wayland 下请使用拖放区域和文件管理器的右键菜单。 有新版本时 Filee 会在菜单中提示,更新时保留设置和引擎。 diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 4e47f4d6..c999ef6e 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -39,6 +39,13 @@ Each component remains under its own license. | Microsoft.Extensions.* | MIT | https://github.com/dotnet/runtime | | cu2qu (fontTools): the cubic-to-quadratic approach followed by `Filee.Engines/Fonts/Cff/CubicToQuadratic.cs` | Apache-2.0, Copyright 2016 Google Inc. | https://github.com/fonttools/fonttools | +libuiohook (the native input hook inside SharpHook) is the fork https://github.com/TolikPylypchuk/libuiohook, licensed +under the GNU LGPL-3.0 (`COPYING.LESSER.md` in its repository). Filee loads it dynamically. In the Linux packages, +`libuiohook-xrecord.so` and `libuiohook-x11.so` are built by Filee's CI from the unmodified source SharpHook 8.0.0 uses +(commit `a41658fb2bef7503a3bcb305ab8bf849755fe906`) with SharpHook's build flags on Ubuntu 22.04, so they run on older +Linux systems; the other libuiohook files are SharpHook's. The build steps are in `build/build-linux-native.py`. You +may replace these libraries with your own build of libuiohook. + Used by the tests only (not redistributed): DocumentFormat.OpenXml (Open XML SDK, MIT, https://github.com/dotnet/Open-XML-SDK) validates the DOCX files Filee writes. ## Engines @@ -50,6 +57,11 @@ Bundled with the installer: | rhwp | MIT | https://github.com/edwardkim/rhwp | | 7-Zip (console: `7z.exe`, `7z.dll`) | GNU LGPL-2.1-or-later; parts of `7z.dll`: LGPL with the unRAR license restriction, BSD-3-Clause, BSD-2-Clause | https://www.7-zip.org | +The Linux packages contain an rhwp built by Filee's CI from the unmodified source of the same release (tag `v0.8.7`, +commit `1a76570e833917d15817415a53c09ad61ab3203f`, `cargo build --release --locked --bin rhwp` on Ubuntu 22.04), so it +runs on older Linux systems than rhwp's own Linux build. It is under the same MIT license; its `LICENSE` is next to it +(`engines/rhwp`). Windows and macOS use rhwp's release builds. + 7-Zip is Copyright (C) 1999-2026 Igor Pavlov; its `License.txt` is installed next to it (`engines/7zip`). The RAR decompression code in `7z.dll` is under the unRAR license restriction: it may be used to unpack RAR archives, but not to develop a RAR (WinRAR) compatible archiver or to re-create the RAR compression algorithm. Filee only reads RAR diff --git a/build/build-linux-native.py b/build/build-linux-native.py new file mode 100644 index 00000000..1a6ba1e6 --- /dev/null +++ b/build/build-linux-native.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +"""Builds the Linux native pieces of the portable package that upstream builds on a newer glibc than Filee supports. + +Filee's Linux packages must start on Ubuntu 22.04 (glibc 2.35). Two upstream binaries need a newer glibc, so CI builds +them from their pinned source on Ubuntu 22.04 and build/build-portable.ps1 puts them into the package: + +- uiohook: SharpHook's libuiohook-xrecord.so and libuiohook-x11.so (the X11 global input hook back ends). SharpHook + builds them on Ubuntu 26.04 in C23 mode, where glibc 2.38+ headers turn sscanf into __isoc23_sscanf (GLIBC_2.38). + The loader libuiohook.so and the Wayland back end stay SharpHook's own: they already run on glibc 2.34. +- rhwp: the rhwp CLI (HWP/HWPX -> PDF). Its release binary is linked on Ubuntu 24.04 (GLIBC_2.39). + +Subcommands: + check verify the pins below still match Directory.Packages.props and engines.json + uiohook --output DIR build libuiohook-xrecord.so and libuiohook-x11.so into DIR + rhwp --output DIR build the rhwp CLI into DIR (rhwp, LICENSE, README files, like its release archive) + +Needs git, and for uiohook CMake >= 4.2, a C compiler and the X11/Wayland development packages (see +.github/workflows/portable.yml); for rhwp rustup. Standard library only. +""" + +import argparse +import json +import os +import platform +import re +import shutil +import subprocess +import sys +import tempfile +import xml.etree.ElementTree as ElementTree + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + +# SharpHook's NuGet package carries a libuiohook build; ours must come from the same source. LIBUIOHOOK_COMMIT is the +# commit of SharpHook's `libuiohook` submodule at tag v{SHARPHOOK_VERSION} (TolikPylypchuk/libuiohook, LGPL-3.0). When +# Directory.Packages.props moves SharpHook to another version, `check` fails until both lines are updated: look up the +# submodule commit of the new tag (`git ls-tree v libuiohook` in SharpHook) and copy its CMake flags from +# SharpHook's .github/workflows/build.yml. +SHARPHOOK_VERSION = "8.0.0" +LIBUIOHOOK_REPOSITORY = "https://github.com/TolikPylypchuk/libuiohook" +LIBUIOHOOK_COMMIT = "a41658fb2bef7503a3bcb305ab8bf849755fe906" +SHARPHOOK_REPOSITORY = "https://github.com/TolikPylypchuk/SharpHook" +# Only the back ends that need the newer glibc are replaced; the names are what the loader dlopens from its folder. +UIOHOOK_LIBRARIES = ["libuiohook-xrecord.so", "libuiohook-x11.so"] + +# The rhwp release pinned in engines.json for Linux (`check` compares the versions). RUST_TOOLCHAIN is the one in the +# tag's rust-toolchain.toml. Cargo.lock in the tag pins every dependency (--locked). +RHWP_VERSION = "0.8.7" +RHWP_REPOSITORY = "https://github.com/edwardkim/rhwp" +RHWP_COMMIT = "1a76570e833917d15817415a53c09ad61ab3203f" +RUST_TOOLCHAIN = "1.93.1" + +# SharpHook's CI flags for each architecture (build.yml, jobs libuiohook-linux-x64 / libuiohook-linux-arm64). +UIOHOOK_FLAGS = { + "x86_64": { + "CC": "x86_64-linux-gnu-gcc", + "CFLAGS": "-O2 -march=x86-64 -mtune=generic -g -fomit-frame-pointer -flto -fno-stack-protector -pipe", + "PKG_CONFIG_PATH": "/usr/lib/x86_64-linux-gnu/pkgconfig", + }, + "aarch64": { + "CC": "aarch64-linux-gnu-gcc", + "CFLAGS": "-Os -march=armv8-a+crc -mtune=cortex-a53 -g -flto -fomit-frame-pointer -fno-stack-protector -pipe", + "PKG_CONFIG_PATH": "/usr/lib/aarch64-linux-gnu/pkgconfig", + }, +} +UIOHOOK_LDFLAGS = "-Wl,-O1 -Wl,--as-needed" +MINIMUM_CMAKE = (4, 2) + + +class BuildError(Exception): + pass + + +def run(command, cwd=None, env=None, capture=False): + print("+ " + " ".join(command), flush=True) + result = subprocess.run(command, cwd=cwd, env=env, check=False, text=True, + stdout=subprocess.PIPE if capture else None) + if result.returncode != 0: + raise BuildError(f"{command[0]} failed with exit code {result.returncode}") + return result.stdout if capture else None + + +def sharphook_version(): + path = os.path.join(ROOT, "Directory.Packages.props") + for element in ElementTree.parse(path).getroot().iter("PackageVersion"): + if element.get("Include") == "SharpHook": + return element.get("Version") + raise BuildError("Directory.Packages.props has no SharpHook PackageVersion.") + + +def linux_rhwp_versions(): + path = os.path.join(ROOT, "src", "Filee.Engines", "Infrastructure", "engines.json") + with open(path, encoding="utf-8") as stream: + manifest = json.load(stream) + return {rid: manifest["platforms"][rid]["components"]["rhwp"]["version"] for rid in ("linux-x64", "linux-arm64")} + + +def check(): + problems = [] + version = sharphook_version() + if version != SHARPHOOK_VERSION: + problems.append( + f"Directory.Packages.props uses SharpHook {version}, but build/build-linux-native.py builds the libuiohook " + f"of SharpHook {SHARPHOOK_VERSION} (commit {LIBUIOHOOK_COMMIT}). Update SHARPHOOK_VERSION, " + "LIBUIOHOOK_COMMIT and UIOHOOK_FLAGS to the new SharpHook tag.") + for rid, rhwp in linux_rhwp_versions().items(): + if rhwp != RHWP_VERSION: + problems.append( + f"engines.json pins rhwp {rhwp} for {rid}, but build/build-linux-native.py builds rhwp {RHWP_VERSION}. " + "Update RHWP_VERSION, RHWP_COMMIT and RUST_TOOLCHAIN (from the tag's rust-toolchain.toml).") + if problems: + raise BuildError("\n".join(problems)) + print(f"Pins match: SharpHook {SHARPHOOK_VERSION} -> libuiohook {LIBUIOHOOK_COMMIT}, " + f"rhwp {RHWP_VERSION} -> {RHWP_COMMIT} (Rust {RUST_TOOLCHAIN}).") + + +def fetch(repository, commit, destination): + """A shallow checkout of exactly one commit (GitHub serves any reachable commit by its hash).""" + os.makedirs(destination) + run(["git", "init", "-q", destination]) + run(["git", "-C", destination, "fetch", "-q", "--depth", "1", repository, commit]) + run(["git", "-C", destination, "checkout", "-q", "--detach", "FETCH_HEAD"]) + head = run(["git", "-C", destination, "rev-parse", "HEAD"], capture=True).strip() + if head != commit: + raise BuildError(f"{repository}: expected commit {commit}, got {head}") + + +def verify_sharphook_submodule(work): + """The submodule pointer of SharpHook's release tag must be the commit we build.""" + sharphook = os.path.join(work, "sharphook") + os.makedirs(sharphook) + run(["git", "init", "-q", sharphook]) + run(["git", "-C", sharphook, "fetch", "-q", "--depth", "1", SHARPHOOK_REPOSITORY, + f"refs/tags/v{SHARPHOOK_VERSION}"]) + entry = run(["git", "-C", sharphook, "ls-tree", "FETCH_HEAD", "libuiohook"], capture=True).split() + if len(entry) < 3 or entry[1] != "commit": + raise BuildError(f"SharpHook v{SHARPHOOK_VERSION} has no libuiohook submodule: {entry}") + if entry[2] != LIBUIOHOOK_COMMIT: + raise BuildError(f"SharpHook v{SHARPHOOK_VERSION} uses libuiohook {entry[2]}, not {LIBUIOHOOK_COMMIT}.") + print(f"SharpHook v{SHARPHOOK_VERSION} uses libuiohook {LIBUIOHOOK_COMMIT}.") + + +def cmake_version(): + output = run(["cmake", "--version"], capture=True) + match = re.search(r"(\d+)\.(\d+)", output) + return (int(match.group(1)), int(match.group(2))) if match else (0, 0) + + +def build_uiohook(output, work): + machine = platform.machine() + if machine not in UIOHOOK_FLAGS: + raise BuildError(f"No libuiohook build flags for {machine}.") + if cmake_version() < MINIMUM_CMAKE: + raise BuildError("libuiohook needs CMake 4.2 or newer (on Ubuntu 22.04: python3 -m pip install cmake).") + verify_sharphook_submodule(work) + source = os.path.join(work, "libuiohook") + fetch(LIBUIOHOOK_REPOSITORY, LIBUIOHOOK_COMMIT, source) + build = os.path.join(work, "uiohook-build") + env = dict(os.environ, LDFLAGS=UIOHOOK_LDFLAGS, **UIOHOOK_FLAGS[machine]) + # CMAKE_POLICY_VERSION_MINIMUM lets CMake 4 read the older extra-cmake-modules of Ubuntu 22.04. + run(["cmake", "-S", source, "-B", build, "-G", "Unix Makefiles", "-D", "CMAKE_VERBOSE_MAKEFILE=true", + "-D", "BUILD_TEST=ON", "-D", "CMAKE_POLICY_VERSION_MINIMUM=3.5"], env=env) + targets = [name[len("lib"):-len(".so")] for name in UIOHOOK_LIBRARIES] + ["uiohook_tests"] + run(["cmake", "--build", build, "--parallel", str(os.cpu_count() or 2), "--target", *targets], env=env) + # The same unit tests SharpHook's CI runs on its build (no display needed). + run([os.path.join(build, "uiohook_tests"), "--headless"], cwd=build) + os.makedirs(output, exist_ok=True) + for name in UIOHOOK_LIBRARIES: + built = os.path.join(build, name) + if not os.path.isfile(built): + raise BuildError(f"The libuiohook build did not produce {name}.") + shutil.copy2(built, os.path.join(output, name)) + for name in ("COPYING.md", "COPYING.LESSER.md"): + shutil.copy2(os.path.join(source, name), os.path.join(output, name)) + write_source_note(output, "libuiohook", LIBUIOHOOK_REPOSITORY, LIBUIOHOOK_COMMIT, + f"SharpHook {SHARPHOOK_VERSION}; CMake flags of SharpHook's build.yml; {machine}") + + +def verify_rhwp_tag(): + """Warns when the tag no longer points at the pinned commit (the pinned commit is built either way).""" + lines = run(["git", "ls-remote", RHWP_REPOSITORY, f"refs/tags/v{RHWP_VERSION}", f"refs/tags/v{RHWP_VERSION}^{{}}"], + capture=True).splitlines() + refs = dict(reversed(line.split("\t", 1)) for line in lines if "\t" in line) + tagged = refs.get(f"refs/tags/v{RHWP_VERSION}^{{}}") or refs.get(f"refs/tags/v{RHWP_VERSION}") + if tagged != RHWP_COMMIT: + print(f"::warning::rhwp tag v{RHWP_VERSION} points at {tagged}, the build uses the pinned {RHWP_COMMIT}.") + + +def build_rhwp(output, work): + if shutil.which("rustup") is None: + raise BuildError("rhwp needs rustup (https://rustup.rs).") + verify_rhwp_tag() + source = os.path.join(work, "rhwp") + fetch(RHWP_REPOSITORY, RHWP_COMMIT, source) + run(["rustup", "toolchain", "install", RUST_TOOLCHAIN, "--profile", "minimal", "--no-self-update"]) + # "+toolchain" takes precedence over the repository's rust-toolchain.toml (whose wasm target the CLI doesn't need). + env = dict(os.environ, CARGO_TARGET_DIR=os.path.join(work, "rhwp-target")) + run(["cargo", f"+{RUST_TOOLCHAIN}", "build", "--release", "--locked", "--bin", "rhwp"], cwd=source, env=env) + program = os.path.join(env["CARGO_TARGET_DIR"], "release", "rhwp") + version = run([program, "--version"], capture=True) + print(version.strip()) + if RHWP_VERSION not in version: + raise BuildError(f"rhwp --version printed {version.strip()!r}, expected {RHWP_VERSION}.") + # The same layout as rhwp's release archive, which build/fetch-portable-engines.ps1 unpacks for local builds. + os.makedirs(output, exist_ok=True) + shutil.copy2(program, os.path.join(output, "rhwp")) + for name in ("LICENSE", "README.md", "README_EN.md"): + if os.path.isfile(os.path.join(source, name)): + shutil.copy2(os.path.join(source, name), os.path.join(output, name)) + write_source_note(output, "rhwp", RHWP_REPOSITORY, RHWP_COMMIT, + f"v{RHWP_VERSION}; cargo +{RUST_TOOLCHAIN} build --release --locked --bin rhwp; {platform.machine()}") + + +def write_source_note(output, name, repository, commit, details): + with open(os.path.join(output, "SOURCE.txt"), "w", encoding="utf-8") as stream: + stream.write(f"{name} built from source by Filee's CI on {distribution()}.\n" + f"Source: {repository}/tree/{commit}\n{details}\n") + + +def distribution(): + try: + with open("/etc/os-release", encoding="utf-8") as stream: + for line in stream: + if line.startswith("PRETTY_NAME="): + return line.split("=", 1)[1].strip().strip('"') + except OSError: + pass + return platform.platform() + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("component", choices=["check", "uiohook", "rhwp"]) + parser.add_argument("--output", help="folder for the built files (must not exist yet)") + parser.add_argument("--work", help="scratch folder (default: a new temporary folder)") + options = parser.parse_args(argv) + try: + check() + if options.component == "check": + return 0 + if not sys.platform.startswith("linux"): + raise BuildError("Build the Linux native pieces on Linux.") + if not options.output: + raise BuildError("--output is required.") + output = os.path.abspath(options.output) + if os.path.exists(output): + raise BuildError(f"{output} already exists. Use a fresh output folder.") + work = os.path.abspath(options.work) if options.work else tempfile.mkdtemp(prefix=f"filee-{options.component}-") + os.makedirs(work, exist_ok=True) + if options.component == "uiohook": + build_uiohook(output, work) + else: + build_rhwp(output, work) + print(f"{options.component} ready in {output}: {', '.join(sorted(os.listdir(output)))}") + return 0 + except BuildError as error: + print(f"::error::{error}" if os.environ.get("GITHUB_ACTIONS") else f"error: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/build/build-portable.ps1 b/build/build-portable.ps1 index 3aa2c007..6172c059 100644 --- a/build/build-portable.ps1 +++ b/build/build-portable.ps1 @@ -1,10 +1,20 @@ +# Builds the Linux or macOS portable package (tar.gz + SHA256SUMS) on its own operating system. +# +# Linux packages must run on Ubuntu 22.04 (glibc 2.35). CI builds the two upstream binaries that need a newer glibc +# from source on Ubuntu 22.04 (build/build-linux-native.py) and passes them in: +# -RhwpOverride a folder with the rhwp program (rhwp's release archive layout) used instead of the +# rhwp download pinned in engines.json +# -UiohookOverride a folder with libuiohook-xrecord.so and libuiohook-x11.so that replace SharpHook's +# Without them (local builds) the package uses the downloads and SharpHook's files, which need a newer glibc. [CmdletBinding()] param( [ValidateSet('linux-x64', 'linux-arm64', 'osx-x64', 'osx-arm64')][string]$Runtime, [string]$Version, [string]$OutputDir = (Join-Path $PSScriptRoot '../Releases'), [string]$SigningIdentity = '-', - [switch]$SkipFonts + [switch]$SkipFonts, + [string]$RhwpOverride, + [string]$UiohookOverride ) $ErrorActionPreference = 'Stop' @@ -17,6 +27,24 @@ if (-not $Runtime) { if (($Runtime.StartsWith('osx-') -and -not $IsMacOS) -or ($Runtime.StartsWith('linux-') -and -not $IsLinux)) { throw "Build $Runtime on its native operating system." } +$uiohookLibraries = @('libuiohook-xrecord.so', 'libuiohook-x11.so') +if (($RhwpOverride -or $UiohookOverride) -and -not $Runtime.StartsWith('linux-')) { + throw '-RhwpOverride and -UiohookOverride are for Linux packages.' +} +if ($RhwpOverride) { + $RhwpOverride = (Resolve-Path -LiteralPath $RhwpOverride).Path + if (-not (Test-Path -LiteralPath (Join-Path $RhwpOverride 'rhwp') -PathType Leaf)) { + throw "-RhwpOverride: $RhwpOverride has no rhwp program." + } +} +if ($UiohookOverride) { + $UiohookOverride = (Resolve-Path -LiteralPath $UiohookOverride).Path + foreach ($library in $uiohookLibraries) { + if (-not (Test-Path -LiteralPath (Join-Path $UiohookOverride $library) -PathType Leaf)) { + throw "-UiohookOverride: $UiohookOverride has no $library." + } + } +} if (-not $Version) { $Version = ([xml](Get-Content -LiteralPath (Join-Path $root 'Directory.Build.props') -Raw)).Project.PropertyGroup.Version | Where-Object { $_ } | Select-Object -First 1 @@ -52,7 +80,27 @@ foreach ($project in @('Filee.App', 'Filee.Cli')) { "-p:Version=$Version" -p:FileeRelease=true -p:PublishReadyToRun=false -o $publish } } -& (Join-Path $PSScriptRoot 'fetch-portable-engines.ps1') -Runtime $Runtime -Destination (Join-Path $publish 'engines') +$engines = Join-Path $publish 'engines' +if ($RhwpOverride) { + & (Join-Path $PSScriptRoot 'fetch-portable-engines.ps1') -Runtime $Runtime -Destination $engines -Only 7zip + Copy-Item -LiteralPath $RhwpOverride -Destination (Join-Path $engines 'rhwp') -Recurse + Invoke-Checked 'rhwp permissions' { chmod +x (Join-Path $engines 'rhwp/rhwp') } + Write-Host "rhwp from $RhwpOverride" +} +else { + & (Join-Path $PSScriptRoot 'fetch-portable-engines.ps1') -Runtime $Runtime -Destination $engines +} +if ($UiohookOverride) { + # SharpHook's loader (libuiohook.so) dlopens these back ends from its own folder, the publish root. + foreach ($library in $uiohookLibraries) { + $target = Join-Path $publish $library + if (-not (Test-Path -LiteralPath $target -PathType Leaf)) { + throw "Publish has no $library to replace: SharpHook's native layout changed." + } + Copy-Item -LiteralPath (Join-Path $UiohookOverride $library) -Destination $target -Force + } + Write-Host "libuiohook back ends from $UiohookOverride" +} foreach ($name in @('Filee', 'filee-cli')) { $executable = Join-Path $publish $name if (-not (Test-Path -LiteralPath $executable -PathType Leaf)) { throw "Publish did not produce $name" } diff --git a/build/check-elf-glibc.py b/build/check-elf-glibc.py new file mode 100644 index 00000000..34a0e8ee --- /dev/null +++ b/build/check-elf-glibc.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +"""Lists the newest GLIBC symbol version every ELF file under the given paths needs, and fails when one needs a +newer glibc than --max allows. + +A Linux package runs only on systems whose glibc is at least as new as the newest GLIBC_x.y version any of its +programs or libraries asks for (the .gnu.version_r section, what `objdump -T` prints). The portable package is +checked against the oldest Ubuntu LTS Filee supports. Standard library only, so it runs on any CI image. + +Usage: check-elf-glibc.py [--max 2.35] [--needed] PATH [PATH ...] +Exit codes: 0 every file is within the limit, 1 a file needs a newer glibc, 2 no ELF files or a file can't be read. +""" + +import argparse +import os +import struct +import sys + +SHT_GNU_VERNEED = 0x6FFFFFFE +PT_LOAD = 1 +PT_DYNAMIC = 2 +DT_NULL = 0 +DT_NEEDED = 1 +DT_STRTAB = 5 +DT_STRSZ = 10 +DT_VERNEED = 0x6FFFFFFE +DT_VERNEEDNUM = 0x6FFFFFFF + + +class ElfError(Exception): + pass + + +class Elf: + """The parts of an ELF file needed to read its version requirements and DT_NEEDED entries.""" + + def __init__(self, data): + if data[:4] != b"\x7fELF": + raise ElfError("not an ELF file") + if data[4] not in (1, 2) or data[5] not in (1, 2): + raise ElfError("unknown ELF class or byte order") + self.data = data + self.is64 = data[4] == 2 + self.endian = "<" if data[5] == 1 else ">" + if self.is64: + (self.phoff, self.shoff) = self._unpack("QQ", 0x20) + (self.phentsize, self.phnum, self.shentsize, self.shnum, self.shstrndx) = self._unpack("HHHHH", 0x36) + else: + (self.phoff, self.shoff) = self._unpack("II", 0x1C) + (self.phentsize, self.phnum, self.shentsize, self.shnum, self.shstrndx) = self._unpack("HHHHH", 0x2A) + + def _unpack(self, fmt, offset): + fmt = self.endian + fmt + end = offset + struct.calcsize(fmt) + if offset < 0 or end > len(self.data): + raise ElfError("truncated file") + return struct.unpack_from(fmt, self.data, offset) + + def _string(self, offset): + if offset < 0 or offset >= len(self.data): + raise ElfError("string outside the file") + end = self.data.find(b"\0", offset) + if end < 0: + raise ElfError("unterminated string") + return self.data[offset:end].decode("ascii", "replace") + + def _sections(self): + for index in range(self.shnum if self.shoff else 0): + offset = self.shoff + index * self.shentsize + if self.is64: + (_, sh_type, _, _, sh_offset, sh_size, sh_link, sh_info) = self._unpack("IIQQQQII", offset) + else: + (_, sh_type, _, _, sh_offset, sh_size, sh_link, sh_info) = self._unpack("IIIIIIII", offset) + yield sh_type, sh_offset, sh_size, sh_link, sh_info + + def _segments(self): + for index in range(self.phnum if self.phoff else 0): + offset = self.phoff + index * self.phentsize + if self.is64: + (p_type, _, p_offset, p_vaddr, _, p_filesz, _, _) = self._unpack("IIQQQQQQ", offset) + else: + (p_type, p_offset, p_vaddr, _, p_filesz, _, _, _) = self._unpack("IIIIIIII", offset) + yield p_type, p_offset, p_vaddr, p_filesz + + def _file_offset(self, address): + for p_type, p_offset, p_vaddr, p_filesz in self._segments(): + if p_type == PT_LOAD and p_vaddr <= address < p_vaddr + p_filesz: + return address - p_vaddr + p_offset + raise ElfError(f"address {address:#x} is not in a loaded segment") + + def _dynamic(self): + """DT_* entries of the PT_DYNAMIC segment as (tag, value) pairs.""" + for p_type, p_offset, _, p_filesz in self._segments(): + if p_type != PT_DYNAMIC: + continue + size = 16 if self.is64 else 8 + for offset in range(p_offset, p_offset + p_filesz - size + 1, size): + tag, value = self._unpack("qQ" if self.is64 else "iI", offset) + if tag == DT_NULL: + return + yield tag, value + return + + def _read_verneed(self, offset, count, strtab): + """Walks Elf_Verneed records and their Elf_Vernaux entries: {library: [version, ...]}.""" + result = {} + for _ in range(count): + (vn_version, vn_cnt, vn_file, vn_aux, vn_next) = self._unpack("HHIII", offset) + if vn_version != 1: + raise ElfError(f"unknown verneed version {vn_version}") + library = self._string(strtab + vn_file) + names = result.setdefault(library, []) + aux = offset + vn_aux + for _ in range(vn_cnt): + (_, _, _, vna_name, vna_next) = self._unpack("IHHII", aux) + names.append(self._string(strtab + vna_name)) + if vna_next == 0: + break + aux += vna_next + if vn_next == 0: + break + offset += vn_next + return result + + def version_needs(self): + """{library: [version names]} from .gnu.version_r, or from the dynamic segment when sections are stripped.""" + sections = list(self._sections()) + for sh_type, sh_offset, _, sh_link, sh_info in sections: + if sh_type == SHT_GNU_VERNEED: + if sh_link >= len(sections): + raise ElfError("bad string table link") + return self._read_verneed(sh_offset, sh_info, sections[sh_link][1]) + dynamic = dict(self._dynamic()) + if DT_VERNEED in dynamic and DT_STRTAB in dynamic: + return self._read_verneed(self._file_offset(dynamic[DT_VERNEED]), dynamic.get(DT_VERNEEDNUM, 0), + self._file_offset(dynamic[DT_STRTAB])) + return {} + + def needed_libraries(self): + entries = list(self._dynamic()) + strtab = next((value for tag, value in entries if tag == DT_STRTAB), None) + if strtab is None: + return [] + base = self._file_offset(strtab) + return [self._string(base + value) for tag, value in entries if tag == DT_NEEDED] + + +def parse_version(text): + try: + return tuple(int(part) for part in text.split(".")) + except ValueError: + return None + + +def glibc_versions(needs): + """The numeric GLIBC_x.y versions in a version_needs() result (GLIBC_PRIVATE is reported separately).""" + versions = [] + for names in needs.values(): + for name in names: + if name.startswith("GLIBC_"): + version = parse_version(name[len("GLIBC_"):]) + if version is not None: + versions.append(version) + return versions + + +def is_elf(path): + try: + with open(path, "rb") as stream: + return stream.read(4) == b"\x7fELF" + except OSError: + return False + + +def elf_files(paths): + for path in paths: + if os.path.isfile(path) and not os.path.islink(path): + if is_elf(path): + yield path, os.path.basename(path) + continue + for directory, folders, files in os.walk(path): + folders.sort() + for name in sorted(files): + full = os.path.join(directory, name) + if not os.path.islink(full) and is_elf(full): + yield full, os.path.relpath(full, path) + + +def format_version(version): + return "GLIBC_" + ".".join(str(part) for part in version) if version else "-" + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("--max", default="2.35", help="newest GLIBC version allowed (default 2.35, Ubuntu 22.04)") + parser.add_argument("--needed", action="store_true", help="also list each file's DT_NEEDED libraries") + parser.add_argument("paths", nargs="+") + options = parser.parse_args(argv) + limit = parse_version(options.max) + if limit is None: + parser.error(f"--max must look like 2.35, not {options.max}") + + rows = [] + unreadable = [] + for full, shown in elf_files(options.paths): + try: + with open(full, "rb") as stream: + elf = Elf(stream.read()) + needs = elf.version_needs() + needed = elf.needed_libraries() if options.needed else [] + except (ElfError, OSError, struct.error) as error: + unreadable.append((shown, str(error))) + continue + versions = glibc_versions(needs) + newest = max(versions) if versions else None + private = any("GLIBC_PRIVATE" in names for names in needs.values()) + rows.append((shown, newest, private, needed)) + + if not rows and not unreadable: + print("No ELF files found in: " + ", ".join(options.paths), file=sys.stderr) + return 2 + + width = max([len(shown) for shown, *_ in rows] + [len(shown) for shown, _ in unreadable] + [4]) + print(f"{'File':<{width}} {'Needs':<12} Result") + print(f"{'-' * width} {'-' * 12} ------") + failed = 0 + for shown, newest, private, needed in rows: + too_new = newest is not None and newest > limit + failed += too_new + result = f"needs a newer glibc than {options.max}" if too_new else "ok" + if private: + result += " (also GLIBC_PRIVATE: tied to one exact glibc build)" + print(f"{shown:<{width}} {format_version(newest):<12} {result}") + if needed: + print(f"{'':<{width}} {'':<12} needs {', '.join(needed)}") + for shown, error in unreadable: + print(f"{shown:<{width}} {'?':<12} cannot read: {error}") + + newest_overall = max((newest for _, newest, _, _ in rows if newest), default=None) + print() + print(f"{len(rows)} ELF files, newest requirement {format_version(newest_overall)}, limit GLIBC_{options.max}.") + sys.stdout.flush() + if failed: + print(f"{failed} file(s) need a glibc newer than {options.max}: they would not start on the oldest " + "supported distribution.", file=sys.stderr) + return 1 + if unreadable: + print(f"{len(unreadable)} ELF file(s) could not be read.", file=sys.stderr) + return 2 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/build/smoke-launch-app.py b/build/smoke-launch-app.py new file mode 100644 index 00000000..e5f32895 --- /dev/null +++ b/build/smoke-launch-app.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Starts the packaged desktop app (FILEE_SMOKE_APP, set by build/smoke-portable.ps1), checks that it stays up, that a +second start hands over to it (macOS) and that `filee --quit` ends it cleanly. On Linux it runs under Xvfb in an X11 +session and fails when the app logged that the X11 input hook back end could not be loaded. + +Used by .github/workflows/portable.yml. Screenshots and the startup log go to $RUNNER_TEMP. +""" + +import os +import pathlib +import signal +import subprocess +import sys +import time + +app = os.environ['FILEE_SMOKE_APP'] +temp = pathlib.Path(os.environ.get('RUNNER_TEMP', '/tmp')) +shots = temp / 'filee-screens' +shots.mkdir(exist_ok=True) +is_linux = sys.platform.startswith('linux') +is_macos = sys.platform == 'darwin' +command = [app, '--settings', '--install-engines=', '--context-menu=off', '--start-with-system=off'] +if is_linux: + command = ['xvfb-run', '-a', 'env', '-u', 'WAYLAND_DISPLAY', 'XDG_SESSION_TYPE=x11', *command] +log_path = temp / 'filee-startup.log' + + +def filee_logs(): + for folder in (pathlib.Path.home() / '.config/Filee/logs', pathlib.Path.home() / 'Library/Application Support/Filee/logs'): + if folder.is_dir(): + yield from sorted(folder.glob('*.log')) + + +def check_input_hook(): + """The SharpHook loader reports ErrorLinuxLoadBackend when dlopen of libuiohook-xrecord.so fails.""" + lines = [line for path in filee_logs() for line in path.read_text(errors='replace').splitlines()] + for line in lines: + if 'input hook' in line: + print(line) + if any('ErrorLinuxLoadBackend' in line for line in lines): + raise RuntimeError('The X11 input hook back end could not be loaded (ErrorLinuxLoadBackend).') + + +with log_path.open('w') as log: + process = subprocess.Popen(command, stdout=log, stderr=subprocess.STDOUT, start_new_session=True) + try: + time.sleep(8) + if process.poll() is not None: + raise RuntimeError('The desktop app exited before the startup check.') + if is_macos: + # The real screen with Filee's window, as a person would see it (uploaded with the results). + subprocess.run(['screencapture', '-x', str(shots / 'settings.png')], check=False, timeout=30) + # A second start forwards to the running Filee, which brings its window to the front. + try: + subprocess.run([app], check=False, timeout=30) + except subprocess.TimeoutExpired: + raise RuntimeError('A second start did not hand over to the running Filee.') + time.sleep(4) + subprocess.run(['screencapture', '-x', str(shots / 'second-start.png')], check=False, timeout=30) + if process.poll() is not None: + raise RuntimeError('The desktop app exited after a second start.') + quit_code = 'import os, sys; os.setsid(); os.execv(sys.argv[1], [sys.argv[1], "--quit"])' + # Reap Filee as soon as it ends while --quit waits for it, as launchd or a shell would: a process + # that has ended but was not reaped by its parent (this script) still counts as running. + quitter = subprocess.Popen([sys.executable, '-c', quit_code, app]) + quit_deadline = time.monotonic() + 30 + while quitter.poll() is None: + process.poll() + if time.monotonic() > quit_deadline: + quitter.kill() + raise RuntimeError('filee --quit did not finish.') + time.sleep(0.05) + if quitter.returncode != 0: + raise RuntimeError(f'filee --quit exited with {quitter.returncode}.') + if process.wait(timeout=15) != 0: + raise RuntimeError('The desktop app did not exit cleanly.') + if is_linux: + check_input_hook() + except BaseException: + log.flush() + print(log_path.read_text(errors='replace'), file=sys.stderr) + raise + finally: + if process.poll() is None: + os.killpg(process.pid, signal.SIGTERM) + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.wait() diff --git a/build/smoke-portable.ps1 b/build/smoke-portable.ps1 new file mode 100644 index 00000000..a89a136f --- /dev/null +++ b/build/smoke-portable.ps1 @@ -0,0 +1,78 @@ +# Checks a portable package the way a user gets it: verifies the archive checksum, unpacks it and runs the CLI, +# rhwp and 7-Zip from it, including conversions through rhwp (TXT -> HWPX -> HWP -> PDF). On Linux it also loads +# the X11 input hook back end (libuiohook-xrecord.so) to prove its libraries and glibc requirement resolve. +# Used by .github/workflows/portable.yml on the build machine and on the oldest supported Ubuntu. +# +# In GitHub Actions it sets FILEE_SMOKE_APP (the desktop app, for build/smoke-launch-app.py) and FILEE_SMOKE_PACKAGE +# (the unpacked package folder). +[CmdletBinding()] +param( + [Parameter(Mandatory)][ValidateSet('linux-x64', 'linux-arm64', 'osx-x64', 'osx-arm64')][string]$Runtime, + [string]$Packages = (Join-Path $PSScriptRoot '../Releases'), + [string]$WorkDir = (Join-Path ($env:RUNNER_TEMP ?? [IO.Path]::GetTempPath()) 'filee-smoke') +) + +$ErrorActionPreference = 'Stop' + +function Invoke-Checked([string]$name, [scriptblock]$command) { + & $command + if ($LASTEXITCODE -ne 0) { throw "$name failed with exit code $LASTEXITCODE" } +} + +$archives = @(Get-ChildItem -LiteralPath $Packages -Filter "*-$Runtime.tar.gz") +if ($archives.Count -ne 1) { throw "Expected one portable archive for $Runtime in $Packages, found $($archives.Count)." } +$archive = $archives[0] +$expected = (Get-Content -LiteralPath (Join-Path $Packages ($archive.Name.Replace('.tar.gz', '-SHA256SUMS.txt')))).Split(' ')[0] +if ((Get-FileHash -LiteralPath $archive.FullName -Algorithm SHA256).Hash -ne $expected) { throw 'Archive checksum mismatch.' } + +if (Test-Path -LiteralPath $WorkDir) { throw "$WorkDir already exists. Use a fresh folder." } +New-Item -ItemType Directory -Path $WorkDir | Out-Null +$smoke = (Resolve-Path -LiteralPath $WorkDir).Path +Invoke-Checked 'Archive extraction' { tar -xzf $archive.FullName -C $smoke } +$package = Join-Path $smoke ($archive.Name.Replace('.tar.gz', '')) +if ($IsMacOS) { + Invoke-Checked 'Extracted app signature' { codesign --verify --deep --strict (Join-Path $package 'Filee.app') } +} +$bin = if ($IsMacOS) { Join-Path $package 'Filee.app/Contents/MacOS' } else { $package } +$cli = Join-Path $bin 'filee-cli' + +Invoke-Checked 'CLI start' { & $cli version } +Invoke-Checked 'rhwp start' { & (Join-Path $bin 'engines/rhwp/rhwp') --version } +Invoke-Checked '7-Zip start' { & (Join-Path $bin 'engines/7zip/7zz') i | Out-Null } + +function Convert-Smoke([string]$source, [string]$target) { + $folder = Join-Path $smoke "to-$target" + $result = & $cli convert $source --to $target -o $folder --json + $code = $LASTEXITCODE + $output = Join-Path $folder ([IO.Path]::GetFileNameWithoutExtension($source) + ".$target") + if ($code -ne 0 -or -not (Test-Path -LiteralPath $output -PathType Leaf)) { + $result | Write-Host + throw "Published CLI conversion to $target failed (exit code $code)." + } + Write-Host "Converted $([IO.Path]::GetFileName($source)) -> $target ($((Get-Item -LiteralPath $output).Length) bytes)" + return $output +} + +$inputFile = Join-Path $smoke 'sample.txt' +'Filee 한글 conversion smoke test' | Set-Content -LiteralPath $inputFile -Encoding utf8NoBOM +$hwpx = Convert-Smoke $inputFile 'hwpx' +# HWPX -> HWP and HWP -> PDF are rhwp's routes: they run the bundled rhwp from the package. +$hwp = Convert-Smoke $hwpx 'hwp' +$pdf = Convert-Smoke $hwp 'pdf' +$header = [Text.Encoding]::ASCII.GetString([IO.File]::ReadAllBytes($pdf), 0, 5) +if ($header -ne '%PDF-') { throw "rhwp's PDF does not start with %PDF- ($header)." } + +if ($IsLinux) { + # The loader dlopens this back end in an X11 session; loading it here fails like the app would when a library + # or the glibc version it needs is missing. + $backend = Join-Path $bin 'libuiohook-xrecord.so' + Invoke-Checked 'Load libuiohook-xrecord.so' { + python3 -c 'import ctypes, sys; ctypes.CDLL(sys.argv[1]); print("Loaded " + sys.argv[1])' $backend + } +} + +if ($env:GITHUB_ENV) { + "FILEE_SMOKE_APP=$(Join-Path $bin 'Filee')" >> $env:GITHUB_ENV + "FILEE_SMOKE_PACKAGE=$package" >> $env:GITHUB_ENV +} +Write-Host "Package checks passed: $package" diff --git a/docs/ENGINES.md b/docs/ENGINES.md index b6c6ca01..5fe5d5a1 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -75,9 +75,32 @@ they can be installed or removed later in Settings → *Engines*. A conversion t (`vcruntime` component) is copied next to `gswin64c.exe`, so it runs on PCs without the VC++ Redistributable. To bump a version, change `url`, `sha256` and `size` in engines.json together, run -`pwsh build/fetch-engines.ps1`, test, and update `THIRD-PARTY-NOTICES.md`. When the LibreOffice team retires a -version from `download.documentfoundation.org/libreoffice/stable/`, its URL stops working: keep the pinned version -current. +`pwsh build/fetch-engines.ps1`, test, and update `THIRD-PARTY-NOTICES.md`. For rhwp, also update `RHWP_VERSION`, +`RHWP_COMMIT` and `RUST_TOOLCHAIN` in `build/build-linux-native.py` (see below); CI fails until they match. When the +LibreOffice team retires a version from `download.documentfoundation.org/libreoffice/stable/`, its URL stops working: +keep the pinned version current. + +## Linux builds for older glibc + +Linux packages run on Ubuntu 22.04 (glibc 2.35). rhwp's Linux release is linked on Ubuntu 24.04 (needs glibc 2.39), and +SharpHook's `libuiohook-xrecord.so` / `libuiohook-x11.so` are built in C23 mode on Ubuntu 26.04 (`__isoc23_sscanf`, +glibc 2.38). The `linux-native` job of `.github/workflows/portable.yml` builds both from their pinned, unmodified +source on Ubuntu 22.04 (x64 and arm64) with `build/build-linux-native.py`: + +- `uiohook`: libuiohook at the commit SharpHook's release tag uses as its submodule (the script checks the tag), with + the CMake flags of SharpHook's `build.yml`, and its unit tests. Only those two back ends are replaced; the loader + `libuiohook.so` and the Wayland back end already run on glibc 2.34. +- `rhwp`: `cargo build --release --locked --bin rhwp` at the tag's commit with its pinned Rust toolchain, in the + layout of rhwp's release archive. +- `check` (also run on its own): fails when `Directory.Packages.props` uses another SharpHook version or engines.json + another rhwp version than the script builds. After bumping SharpHook, look up the new tag's libuiohook submodule + commit (`git ls-tree v libuiohook` in SharpHook) and its CMake flags. + +The Linux package jobs pass the results to `build/build-portable.ps1 -RhwpOverride -UiohookOverride `. +Local builds without these options use the downloaded rhwp and SharpHook's libraries (fine on the machine that +builds them). `build/check-elf-glibc.py` then lists the newest GLIBC version every ELF file in the package needs and +fails above 2.35, and the packages are unpacked and started on Ubuntu 22.04 (`build/smoke-portable.ps1`: CLI, rhwp +conversions, loading the X11 back end; `build/smoke-launch-app.py`: the app under Xvfb). ## Images and vector graphics diff --git a/installer/linux/INSTALL.txt b/installer/linux/INSTALL.txt index 6f854bff..09def096 100644 --- a/installer/linux/INSTALL.txt +++ b/installer/linux/INSTALL.txt @@ -7,16 +7,24 @@ Your settings and converted files are kept. If integration cleanup fails, the installation is kept so you can fix the error and run the uninstaller again. An existing installation is never overwritten; uninstall it before replacing it. -The .NET runtime is bundled; its native system libraries are still required. -On Ubuntu 24.04, install the desktop and runtime dependencies with: +Filee runs on Ubuntu 22.04 or newer and other distributions with glibc 2.35 or +newer. The .NET runtime is bundled; its native system libraries are still +required. Install the desktop and runtime dependencies with: -sudo apt-get install ca-certificates libc6 libgcc-s1 libgssapi-krb5-2 libicu74 libssl3t64 libstdc++6 tzdata zlib1g libfontconfig1 libfreetype6 libx11-6 libice6 libsm6 libxrandr2 libxkbcommon0 libxi6 libxtst6 fonts-noto-cjk python3 +Ubuntu 24.04: +sudo apt-get install ca-certificates libc6 libgcc-s1 libgssapi-krb5-2 libicu74 libssl3t64 libstdc++6 tzdata zlib1g libfontconfig1 libfreetype6 libx11-6 libice6 libsm6 libxrandr2 libxkbcommon0 libxi6 libxtst6 libxt6 fonts-noto-cjk python3 -Other distributions use different package names. The desktop needs an X11 -server (or XWayland), fontconfig, FreeType and the X11 libraries above. -X11 supports the global gesture toolbar. A native Wayland session has additional -input restrictions; use the drop window and file-manager actions if needed. -Ubuntu 24.04 is the automated build and smoke-test baseline. +Ubuntu 22.04: +sudo apt-get install ca-certificates libc6 libgcc-s1 libgssapi-krb5-2 libicu70 libssl3 libstdc++6 tzdata zlib1g libfontconfig1 libfreetype6 libx11-6 libice6 libsm6 libxrandr2 libxkbcommon0 libxi6 libxtst6 libxt6 fonts-noto-cjk python3 + +Later Ubuntu releases ship a newer libicu (find its name with apt-cache search +libicu). Other distributions use different package names. The desktop needs an +X11 server (or XWayland), fontconfig, FreeType and the X11 libraries above. +X11 supports the global gesture toolbar; it needs libXtst and libXt (libxtst6, +libxt6). A native Wayland session has additional input restrictions; use the +drop window and file-manager actions if needed. +Packages are built and smoke-tested on Ubuntu 24.04 and started on Ubuntu 22.04 +by the automated builds. rhwp and 7-Zip are bundled. Other engines are installed from Filee settings or discovered in the system. Format support depends on available native engines. @@ -24,6 +32,6 @@ Optional engines can require additional system libraries. In particular, calibre may require libxcb-cursor0, libxcb-xinerama0 and other Qt/XCB dependencies. Dependency references: -https://learn.microsoft.com/en-us/dotnet/core/install/linux-ubuntu-install#ubuntu-2404 +https://learn.microsoft.com/en-us/dotnet/core/install/linux-ubuntu-install https://docs.avaloniaui.net/docs/deployment/linux https://calibre-ebook.com/download_linux diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index df514816..133a7d81 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -227,6 +227,7 @@ "triggers.hook_running": "Global input detection is running.", "triggers.hook_stopped": "Global input detection is stopped.\nFile manager actions and the drop zone remain available.", "triggers.hook_failed": "Global input detection failed: {0}", + "triggers.hook_linux_backend": "Gestures couldn't start because the X11 input libraries didn't load.\nUse an X11 session and install libxtst6 and libxt6 (Ubuntu/Debian: sudo apt install libxtst6 libxt6), then retry.\nFile manager actions and the drop zone remain available.", "triggers.add": "Add gesture", "triggers.remove": "Remove", "triggers.enabled": "On", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 911d0f3b..17051b58 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -227,6 +227,7 @@ "triggers.hook_running": "전역 입력을 감지하고 있어요.", "triggers.hook_stopped": "전역 입력 감지가 멈춰 있어요.\n파일 관리자 메뉴와 드롭 영역은 사용할 수 있어요.", "triggers.hook_failed": "전역 입력을 감지하지 못했어요: {0}", + "triggers.hook_linux_backend": "X11 입력 라이브러리를 불러오지 못해서 제스처를 시작할 수 없어요.\nX11 세션을 사용하고 libxtst6과 libxt6을 설치한 뒤(Ubuntu/Debian: sudo apt install libxtst6 libxt6) 다시 시도해 주세요.\n파일 관리자 메뉴와 드롭 영역은 계속 사용할 수 있어요.", "triggers.add": "제스처 추가", "triggers.remove": "삭제", "triggers.enabled": "사용", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index d6f4f5b8..c6685f84 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -227,6 +227,7 @@ "triggers.hook_running": "正在检测全局输入。", "triggers.hook_stopped": "全局输入检测已停止。\n仍可使用文件管理器操作和拖放区域。", "triggers.hook_failed": "全局输入检测失败:{0}", + "triggers.hook_linux_backend": "无法加载 X11 输入库,手势未能启动。\n请使用 X11 会话并安装 libxtst6 和 libxt6(Ubuntu/Debian 使用 sudo apt install libxtst6 libxt6),然后重试。\n仍可使用文件管理器操作和拖放区域。", "triggers.add": "添加手势", "triggers.remove": "删除", "triggers.enabled": "启用", diff --git a/src/Filee.App/Services/Triggers/TriggerService.cs b/src/Filee.App/Services/Triggers/TriggerService.cs index 0f3ea557..b438ce3b 100644 --- a/src/Filee.App/Services/Triggers/TriggerService.cs +++ b/src/Filee.App/Services/Triggers/TriggerService.cs @@ -54,6 +54,13 @@ public TriggerService(IPlatformServices platform, ILogger log) public bool IsRunning => _hook?.IsRunning == true; public string? Error { get; private set; } + + /// + /// Linux: libuiohook could not load its X11 back end (libuiohook-xrecord.so), usually a missing X library + /// (libXtst, libXt) or not an X11 session. keeps the raw message. + /// + public bool LinuxBackendMissing { get; private set; } + public event EventHandler? StatusChanged; private void NotifyStatus() => Post(() => StatusChanged?.Invoke(this, EventArgs.Empty)); @@ -87,6 +94,7 @@ public void Start() NotifyStatus(); } Error = null; + LinuxBackendMissing = false; try { // Background threads: a hook that fails to stop can never keep Filee running after it quits. @@ -108,8 +116,7 @@ public void Start() // e.g. macOS without Accessibility permission. Drop zone and context menu keep working. if (t.Exception is not null) { - Error = t.Exception.GetBaseException().Message; - _log.LogError(t.Exception, "Global input hook failed; gestures are unavailable"); + ReportFailure(t.Exception.GetBaseException(), "Global input hook failed; gestures are unavailable"); NotifyStatus(); } }, TaskScheduler.Default); @@ -117,14 +124,26 @@ public void Start() catch (Exception ex) { // e.g. macOS without Accessibility permission. The app keeps working via the drop zone / context menu. - _log.LogError(ex, "Could not start the global input hook"); - Error = ex.Message; + ReportFailure(ex, "Could not start the global input hook"); _hook?.Dispose(); _hook = null; NotifyStatus(); } } + private void ReportFailure(Exception exception, string message) + { + // The raw libuiohook result goes to the log either way (the packaged-app smoke test looks for it). + var result = (exception as HookException)?.Result.ToString() ?? "none"; + _log.LogError(exception, "{Message} (libuiohook result: {Result})", message, result); + LinuxBackendMissing = OperatingSystem.IsLinux() && IsMissingLinuxBackend(exception); + Error = exception.Message; + } + + /// libuiohook's loader could not dlopen its Linux back end (ErrorLinuxLoadBackend). + internal static bool IsMissingLinuxBackend(Exception exception) => + exception is HookException { Result: UioHookResult.ErrorLinuxLoadBackend }; + private void OnMove(MouseHookEventArgs e) { LastCursor = (e.Data.X, e.Data.Y); diff --git a/src/Filee.App/ViewModels/Pages/TriggersPageViewModel.cs b/src/Filee.App/ViewModels/Pages/TriggersPageViewModel.cs index cf1c8bd2..ee4dd7c2 100644 --- a/src/Filee.App/ViewModels/Pages/TriggersPageViewModel.cs +++ b/src/Filee.App/ViewModels/Pages/TriggersPageViewModel.cs @@ -172,9 +172,16 @@ LinuxPlatformServices when OperatingSystem.IsLinux() && LinuxPlatformServices.Is LinuxPlatformServices => _loc["triggers.linux_x11"], _ => null, }; - public string? HookStatus => _triggers?.Error is { } error ? _loc.Format("triggers.hook_failed", error) + public string? HookStatus => _triggers?.Error is { } error ? HookFailure(_loc, error, _triggers.LinuxBackendMissing) : _triggers is null ? null : _loc[_triggers.IsRunning ? "triggers.hook_running" : "triggers.hook_stopped"]; + /// + /// A Linux back end that doesn't load is explained (X11 session, X libraries) instead of showing libuiohook's + /// result code; the code is in the log. + /// + internal static string HookFailure(ILocalizer loc, string error, bool linuxBackendMissing) => + linuxBackendMissing ? loc["triggers.hook_linux_backend"] : loc.Format("triggers.hook_failed", error); + private void OnTriggerStatusChanged(object? sender, EventArgs e) => OnPropertyChanged(nameof(HookStatus)); private void OnTriggerSettingsChanged(object? sender, EventArgs e) => OnPropertyChanged(nameof(IntegrationError)); diff --git a/tests/Filee.App.Tests/HookFailureTests.cs b/tests/Filee.App.Tests/HookFailureTests.cs new file mode 100644 index 00000000..b2a21860 --- /dev/null +++ b/tests/Filee.App.Tests/HookFailureTests.cs @@ -0,0 +1,25 @@ +using Filee.App.Services.Triggers; +using Filee.App.ViewModels.Pages; +using SharpHook; +using SharpHook.Data; + +namespace Filee.App.Tests; + +public class HookFailureTests +{ + [Fact] + public void A_Linux_back_end_that_does_not_load_is_recognised() + { + Assert.True(TriggerService.IsMissingLinuxBackend(new HookException(UioHookResult.ErrorLinuxLoadBackend))); + Assert.False(TriggerService.IsMissingLinuxBackend(new HookException(UioHookResult.ErrorAxApiDisabled))); + Assert.False(TriggerService.IsMissingLinuxBackend(new InvalidOperationException("ErrorLinuxLoadBackend"))); + } + + [Fact] + public void A_missing_Linux_back_end_is_explained_instead_of_showing_the_raw_error() + { + var loc = new KeyLocalizer(); + Assert.Equal("triggers.hook_linux_backend", TriggersPageViewModel.HookFailure(loc, "ErrorLinuxLoadBackend", linuxBackendMissing: true)); + Assert.Equal("triggers.hook_failed|denied", TriggersPageViewModel.HookFailure(loc, "denied", linuxBackendMissing: false)); + } +} From 3aa26379b43d985d5430b7c5ca4e607f70805268 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Sun, 18 Oct 2026 20:20:10 +0900 Subject: [PATCH 2/2] SharpHook 8.0.1 runs on glibc 2.34: stop building libuiohook SharpHook 8.0.1 builds libuiohook for older glibc again: all of its Linux back ends (x64, arm64, arm) need GLIBC_2.34 at most. The Linux packages use SharpHook's own libraries again, so the libuiohook source build, its CI dependencies and -UiohookOverride are gone. rhwp is still built from source on Ubuntu 22.04. The glibc audit and the Ubuntu 22.04 smoke job stay and fail if a SharpHook or engine update raises the requirement again. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/portable.yml | 32 +++------ CONTRIBUTING.md | 6 +- Directory.Packages.props | 2 +- THIRD-PARTY-NOTICES.md | 7 +- build/build-linux-native.py | 122 +++------------------------------ build/build-portable.ps1 | 38 +++------- docs/ENGINES.md | 32 ++++----- 7 files changed, 49 insertions(+), 190 deletions(-) diff --git a/.github/workflows/portable.yml b/.github/workflows/portable.yml index e82dbe85..4a28ad67 100644 --- a/.github/workflows/portable.yml +++ b/.github/workflows/portable.yml @@ -1,9 +1,10 @@ name: Linux and macOS packages -# Linux packages are built on Ubuntu 24.04 but must run on Ubuntu 22.04 (glibc 2.35). The two upstream binaries that -# need a newer glibc (SharpHook's X11 libuiohook back ends and the rhwp CLI) are built from pinned source on Ubuntu -# 22.04 first (linux-native, build/build-linux-native.py) and replace the downloaded ones in the package. Every ELF -# file in a Linux package is then checked against glibc 2.35, and the x64 and arm64 packages are tried on Ubuntu 22.04. +# Linux packages are built on Ubuntu 24.04 but must run on Ubuntu 22.04 (glibc 2.35). The rhwp CLI release needs a +# newer glibc, so it is built from pinned source on Ubuntu 22.04 first (linux-native, build/build-linux-native.py) and +# replaces the download in the package. SharpHook (>= 8.0.1) ships libuiohook builds for glibc 2.34 and is packaged as +# is. Every ELF file in a Linux package is then checked against glibc 2.35, and the x64 and arm64 packages are tried on +# Ubuntu 22.04, which also catches a SharpHook or engine update that raises the requirement again. on: workflow_call: @@ -43,8 +44,8 @@ jobs: steps: - uses: actions/checkout@v4 - # Fails when Directory.Packages.props or engines.json moved to another SharpHook or rhwp version while - # build/build-linux-native.py still builds the old sources (also when the build below comes from the cache). + # Fails when engines.json moved to another rhwp version while build/build-linux-native.py still builds the old + # source (also when the build below comes from the cache). - name: Check pinned sources run: python3 build/build-linux-native.py check @@ -55,17 +56,13 @@ jobs: path: build/.cache/linux-native/${{ matrix.rid }} key: linux-native-${{ matrix.rid }}-ubuntu-22.04-${{ hashFiles('build/build-linux-native.py') }} - # The packages SharpHook's build.yml installs, plus CMake >= 4.2 (libuiohook's minimum) from PyPI, pinned. + # rhwp's default features are pure Rust: a C toolchain for the linker and build scripts is enough. - name: Build dependencies if: steps.native-cache.outputs.cache-hit != 'true' run: | set -euo pipefail sudo apt-get update - sudo apt-get install -y build-essential pkg-config git python3-pip extra-cmake-modules \ - libx11-dev libxtst-dev libxt-dev libxrandr-dev libx11-xcb-dev libxkbcommon-dev libxkbcommon-x11-dev \ - libxkbfile-dev libinput-dev libudev-dev libwayland-dev wayland-protocols - python3 -m pip install --user cmake==4.2.3 - echo "$HOME/.local/bin" >> "$GITHUB_PATH" + sudo apt-get install -y build-essential pkg-config git if ! command -v rustup >/dev/null; then curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain none echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" @@ -82,10 +79,6 @@ jobs: key: cargo-registry-${{ matrix.rid }}-${{ hashFiles('build/build-linux-native.py') }} restore-keys: cargo-registry-${{ matrix.rid }}- - - name: Build libuiohook back ends - if: steps.native-cache.outputs.cache-hit != 'true' - run: python3 build/build-linux-native.py uiohook --output "$NATIVE_OUT/uiohook" --work "$RUNNER_TEMP/uiohook" - - name: Build rhwp if: steps.native-cache.outputs.cache-hit != 'true' run: python3 build/build-linux-native.py rhwp --output "$NATIVE_OUT/rhwp" --work "$RUNNER_TEMP/rhwp" @@ -170,17 +163,14 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } - # Linux: rhwp and SharpHook's X11 back ends from linux-native instead of the downloads (see the top). + # Linux: rhwp from linux-native instead of the download (see the top). - name: Build portable package env: PACKAGE_VERSION: ${{ inputs.version }} run: | $native = @{} if ($IsLinux) { - $native = @{ - RhwpOverride = Join-Path $env:RUNNER_TEMP 'linux-native/rhwp' - UiohookOverride = Join-Path $env:RUNNER_TEMP 'linux-native/uiohook' - } + $native = @{ RhwpOverride = Join-Path $env:RUNNER_TEMP 'linux-native/rhwp' } } ./build/build-portable.ps1 -Runtime '${{ matrix.rid }}' -Version $env:PACKAGE_VERSION -SkipFonts @native diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 963fdfb8..59faee05 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,9 +35,9 @@ install/uninstall scripts. `Filee---SHA256SUMS.txt` verifies the a `com.filee.app` and a development ad-hoc signature by default; public distribution still needs Developer ID signing and notarization. `-SigningIdentity` can supply a signing identity, but does not perform notarization. -Linux release packages must run on Ubuntu 22.04 (glibc 2.35). CI builds rhwp and SharpHook's X11 input hook back ends -from source on Ubuntu 22.04 (`build/build-linux-native.py`) and passes them with `-RhwpOverride` and -`-UiohookOverride`; a local package without them needs the glibc of the machine that built it. Check a package with +Linux release packages must run on Ubuntu 22.04 (glibc 2.35). CI builds rhwp from source on Ubuntu 22.04 +(`build/build-linux-native.py`) and passes it with `-RhwpOverride`; a local package without it needs the glibc of the +machine that built it. Check a package with `python3 build/check-elf-glibc.py --max 2.35 `. See [docs/ENGINES.md](docs/ENGINES.md#linux-builds-for-older-glibc). - **Linux:** drag gestures require X11. Install the Avalonia desktop dependencies (fontconfig, FreeType, X11, diff --git a/Directory.Packages.props b/Directory.Packages.props index d0901cef..18aba06c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -22,7 +22,7 @@ - + diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index c999ef6e..6ca4408c 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -40,11 +40,8 @@ Each component remains under its own license. | cu2qu (fontTools): the cubic-to-quadratic approach followed by `Filee.Engines/Fonts/Cff/CubicToQuadratic.cs` | Apache-2.0, Copyright 2016 Google Inc. | https://github.com/fonttools/fonttools | libuiohook (the native input hook inside SharpHook) is the fork https://github.com/TolikPylypchuk/libuiohook, licensed -under the GNU LGPL-3.0 (`COPYING.LESSER.md` in its repository). Filee loads it dynamically. In the Linux packages, -`libuiohook-xrecord.so` and `libuiohook-x11.so` are built by Filee's CI from the unmodified source SharpHook 8.0.0 uses -(commit `a41658fb2bef7503a3bcb305ab8bf849755fe906`) with SharpHook's build flags on Ubuntu 22.04, so they run on older -Linux systems; the other libuiohook files are SharpHook's. The build steps are in `build/build-linux-native.py`. You -may replace these libraries with your own build of libuiohook. +under the GNU LGPL-3.0 (`COPYING.LESSER.md` in its repository). Filee loads SharpHook's unmodified build of it +dynamically; you may replace those libraries with your own build of libuiohook. Used by the tests only (not redistributed): DocumentFormat.OpenXml (Open XML SDK, MIT, https://github.com/dotnet/Open-XML-SDK) validates the DOCX files Filee writes. diff --git a/build/build-linux-native.py b/build/build-linux-native.py index 1a6ba1e6..123b55a6 100644 --- a/build/build-linux-native.py +++ b/build/build-linux-native.py @@ -1,48 +1,29 @@ #!/usr/bin/env python3 """Builds the Linux native pieces of the portable package that upstream builds on a newer glibc than Filee supports. -Filee's Linux packages must start on Ubuntu 22.04 (glibc 2.35). Two upstream binaries need a newer glibc, so CI builds -them from their pinned source on Ubuntu 22.04 and build/build-portable.ps1 puts them into the package: - -- uiohook: SharpHook's libuiohook-xrecord.so and libuiohook-x11.so (the X11 global input hook back ends). SharpHook - builds them on Ubuntu 26.04 in C23 mode, where glibc 2.38+ headers turn sscanf into __isoc23_sscanf (GLIBC_2.38). - The loader libuiohook.so and the Wayland back end stay SharpHook's own: they already run on glibc 2.34. -- rhwp: the rhwp CLI (HWP/HWPX -> PDF). Its release binary is linked on Ubuntu 24.04 (GLIBC_2.39). +Filee's Linux packages must start on Ubuntu 22.04 (glibc 2.35). The rhwp CLI (HWP/HWPX -> PDF) release binary is linked +on Ubuntu 24.04 (GLIBC_2.39), so CI builds it from its pinned source on Ubuntu 22.04 and build/build-portable.ps1 puts it +into the package. SharpHook's native libraries (libuiohook) come from its NuGet package: from 8.0.1 on they need glibc +2.34 at most. Subcommands: - check verify the pins below still match Directory.Packages.props and engines.json - uiohook --output DIR build libuiohook-xrecord.so and libuiohook-x11.so into DIR + check verify the rhwp pin below still matches engines.json rhwp --output DIR build the rhwp CLI into DIR (rhwp, LICENSE, README files, like its release archive) -Needs git, and for uiohook CMake >= 4.2, a C compiler and the X11/Wayland development packages (see -.github/workflows/portable.yml); for rhwp rustup. Standard library only. +Needs git, a C toolchain and rustup. Standard library only. """ import argparse import json import os import platform -import re import shutil import subprocess import sys import tempfile -import xml.etree.ElementTree as ElementTree ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) -# SharpHook's NuGet package carries a libuiohook build; ours must come from the same source. LIBUIOHOOK_COMMIT is the -# commit of SharpHook's `libuiohook` submodule at tag v{SHARPHOOK_VERSION} (TolikPylypchuk/libuiohook, LGPL-3.0). When -# Directory.Packages.props moves SharpHook to another version, `check` fails until both lines are updated: look up the -# submodule commit of the new tag (`git ls-tree v libuiohook` in SharpHook) and copy its CMake flags from -# SharpHook's .github/workflows/build.yml. -SHARPHOOK_VERSION = "8.0.0" -LIBUIOHOOK_REPOSITORY = "https://github.com/TolikPylypchuk/libuiohook" -LIBUIOHOOK_COMMIT = "a41658fb2bef7503a3bcb305ab8bf849755fe906" -SHARPHOOK_REPOSITORY = "https://github.com/TolikPylypchuk/SharpHook" -# Only the back ends that need the newer glibc are replaced; the names are what the loader dlopens from its folder. -UIOHOOK_LIBRARIES = ["libuiohook-xrecord.so", "libuiohook-x11.so"] - # The rhwp release pinned in engines.json for Linux (`check` compares the versions). RUST_TOOLCHAIN is the one in the # tag's rust-toolchain.toml. Cargo.lock in the tag pins every dependency (--locked). RHWP_VERSION = "0.8.7" @@ -50,22 +31,6 @@ RHWP_COMMIT = "1a76570e833917d15817415a53c09ad61ab3203f" RUST_TOOLCHAIN = "1.93.1" -# SharpHook's CI flags for each architecture (build.yml, jobs libuiohook-linux-x64 / libuiohook-linux-arm64). -UIOHOOK_FLAGS = { - "x86_64": { - "CC": "x86_64-linux-gnu-gcc", - "CFLAGS": "-O2 -march=x86-64 -mtune=generic -g -fomit-frame-pointer -flto -fno-stack-protector -pipe", - "PKG_CONFIG_PATH": "/usr/lib/x86_64-linux-gnu/pkgconfig", - }, - "aarch64": { - "CC": "aarch64-linux-gnu-gcc", - "CFLAGS": "-Os -march=armv8-a+crc -mtune=cortex-a53 -g -flto -fomit-frame-pointer -fno-stack-protector -pipe", - "PKG_CONFIG_PATH": "/usr/lib/aarch64-linux-gnu/pkgconfig", - }, -} -UIOHOOK_LDFLAGS = "-Wl,-O1 -Wl,--as-needed" -MINIMUM_CMAKE = (4, 2) - class BuildError(Exception): pass @@ -80,14 +45,6 @@ def run(command, cwd=None, env=None, capture=False): return result.stdout if capture else None -def sharphook_version(): - path = os.path.join(ROOT, "Directory.Packages.props") - for element in ElementTree.parse(path).getroot().iter("PackageVersion"): - if element.get("Include") == "SharpHook": - return element.get("Version") - raise BuildError("Directory.Packages.props has no SharpHook PackageVersion.") - - def linux_rhwp_versions(): path = os.path.join(ROOT, "src", "Filee.Engines", "Infrastructure", "engines.json") with open(path, encoding="utf-8") as stream: @@ -97,12 +54,6 @@ def linux_rhwp_versions(): def check(): problems = [] - version = sharphook_version() - if version != SHARPHOOK_VERSION: - problems.append( - f"Directory.Packages.props uses SharpHook {version}, but build/build-linux-native.py builds the libuiohook " - f"of SharpHook {SHARPHOOK_VERSION} (commit {LIBUIOHOOK_COMMIT}). Update SHARPHOOK_VERSION, " - "LIBUIOHOOK_COMMIT and UIOHOOK_FLAGS to the new SharpHook tag.") for rid, rhwp in linux_rhwp_versions().items(): if rhwp != RHWP_VERSION: problems.append( @@ -110,8 +61,7 @@ def check(): "Update RHWP_VERSION, RHWP_COMMIT and RUST_TOOLCHAIN (from the tag's rust-toolchain.toml).") if problems: raise BuildError("\n".join(problems)) - print(f"Pins match: SharpHook {SHARPHOOK_VERSION} -> libuiohook {LIBUIOHOOK_COMMIT}, " - f"rhwp {RHWP_VERSION} -> {RHWP_COMMIT} (Rust {RUST_TOOLCHAIN}).") + print(f"Pins match: rhwp {RHWP_VERSION} -> {RHWP_COMMIT} (Rust {RUST_TOOLCHAIN}).") def fetch(repository, commit, destination): @@ -125,57 +75,6 @@ def fetch(repository, commit, destination): raise BuildError(f"{repository}: expected commit {commit}, got {head}") -def verify_sharphook_submodule(work): - """The submodule pointer of SharpHook's release tag must be the commit we build.""" - sharphook = os.path.join(work, "sharphook") - os.makedirs(sharphook) - run(["git", "init", "-q", sharphook]) - run(["git", "-C", sharphook, "fetch", "-q", "--depth", "1", SHARPHOOK_REPOSITORY, - f"refs/tags/v{SHARPHOOK_VERSION}"]) - entry = run(["git", "-C", sharphook, "ls-tree", "FETCH_HEAD", "libuiohook"], capture=True).split() - if len(entry) < 3 or entry[1] != "commit": - raise BuildError(f"SharpHook v{SHARPHOOK_VERSION} has no libuiohook submodule: {entry}") - if entry[2] != LIBUIOHOOK_COMMIT: - raise BuildError(f"SharpHook v{SHARPHOOK_VERSION} uses libuiohook {entry[2]}, not {LIBUIOHOOK_COMMIT}.") - print(f"SharpHook v{SHARPHOOK_VERSION} uses libuiohook {LIBUIOHOOK_COMMIT}.") - - -def cmake_version(): - output = run(["cmake", "--version"], capture=True) - match = re.search(r"(\d+)\.(\d+)", output) - return (int(match.group(1)), int(match.group(2))) if match else (0, 0) - - -def build_uiohook(output, work): - machine = platform.machine() - if machine not in UIOHOOK_FLAGS: - raise BuildError(f"No libuiohook build flags for {machine}.") - if cmake_version() < MINIMUM_CMAKE: - raise BuildError("libuiohook needs CMake 4.2 or newer (on Ubuntu 22.04: python3 -m pip install cmake).") - verify_sharphook_submodule(work) - source = os.path.join(work, "libuiohook") - fetch(LIBUIOHOOK_REPOSITORY, LIBUIOHOOK_COMMIT, source) - build = os.path.join(work, "uiohook-build") - env = dict(os.environ, LDFLAGS=UIOHOOK_LDFLAGS, **UIOHOOK_FLAGS[machine]) - # CMAKE_POLICY_VERSION_MINIMUM lets CMake 4 read the older extra-cmake-modules of Ubuntu 22.04. - run(["cmake", "-S", source, "-B", build, "-G", "Unix Makefiles", "-D", "CMAKE_VERBOSE_MAKEFILE=true", - "-D", "BUILD_TEST=ON", "-D", "CMAKE_POLICY_VERSION_MINIMUM=3.5"], env=env) - targets = [name[len("lib"):-len(".so")] for name in UIOHOOK_LIBRARIES] + ["uiohook_tests"] - run(["cmake", "--build", build, "--parallel", str(os.cpu_count() or 2), "--target", *targets], env=env) - # The same unit tests SharpHook's CI runs on its build (no display needed). - run([os.path.join(build, "uiohook_tests"), "--headless"], cwd=build) - os.makedirs(output, exist_ok=True) - for name in UIOHOOK_LIBRARIES: - built = os.path.join(build, name) - if not os.path.isfile(built): - raise BuildError(f"The libuiohook build did not produce {name}.") - shutil.copy2(built, os.path.join(output, name)) - for name in ("COPYING.md", "COPYING.LESSER.md"): - shutil.copy2(os.path.join(source, name), os.path.join(output, name)) - write_source_note(output, "libuiohook", LIBUIOHOOK_REPOSITORY, LIBUIOHOOK_COMMIT, - f"SharpHook {SHARPHOOK_VERSION}; CMake flags of SharpHook's build.yml; {machine}") - - def verify_rhwp_tag(): """Warns when the tag no longer points at the pinned commit (the pinned commit is built either way).""" lines = run(["git", "ls-remote", RHWP_REPOSITORY, f"refs/tags/v{RHWP_VERSION}", f"refs/tags/v{RHWP_VERSION}^{{}}"], @@ -230,7 +129,7 @@ def distribution(): def main(argv=None): parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) - parser.add_argument("component", choices=["check", "uiohook", "rhwp"]) + parser.add_argument("component", choices=["check", "rhwp"]) parser.add_argument("--output", help="folder for the built files (must not exist yet)") parser.add_argument("--work", help="scratch folder (default: a new temporary folder)") options = parser.parse_args(argv) @@ -247,10 +146,7 @@ def main(argv=None): raise BuildError(f"{output} already exists. Use a fresh output folder.") work = os.path.abspath(options.work) if options.work else tempfile.mkdtemp(prefix=f"filee-{options.component}-") os.makedirs(work, exist_ok=True) - if options.component == "uiohook": - build_uiohook(output, work) - else: - build_rhwp(output, work) + build_rhwp(output, work) print(f"{options.component} ready in {output}: {', '.join(sorted(os.listdir(output)))}") return 0 except BuildError as error: diff --git a/build/build-portable.ps1 b/build/build-portable.ps1 index 6172c059..ec9c5107 100644 --- a/build/build-portable.ps1 +++ b/build/build-portable.ps1 @@ -1,11 +1,10 @@ # Builds the Linux or macOS portable package (tar.gz + SHA256SUMS) on its own operating system. # -# Linux packages must run on Ubuntu 22.04 (glibc 2.35). CI builds the two upstream binaries that need a newer glibc -# from source on Ubuntu 22.04 (build/build-linux-native.py) and passes them in: -# -RhwpOverride a folder with the rhwp program (rhwp's release archive layout) used instead of the -# rhwp download pinned in engines.json -# -UiohookOverride a folder with libuiohook-xrecord.so and libuiohook-x11.so that replace SharpHook's -# Without them (local builds) the package uses the downloads and SharpHook's files, which need a newer glibc. +# Linux packages must run on Ubuntu 22.04 (glibc 2.35). rhwp's Linux release needs a newer glibc, so CI builds it from +# source on Ubuntu 22.04 (build/build-linux-native.py) and passes it in: +# -RhwpOverride a folder with the rhwp program (rhwp's release archive layout) used instead of the rhwp +# download pinned in engines.json +# Without it (local builds) the package uses the download, which needs a newer glibc. [CmdletBinding()] param( [ValidateSet('linux-x64', 'linux-arm64', 'osx-x64', 'osx-arm64')][string]$Runtime, @@ -13,8 +12,7 @@ param( [string]$OutputDir = (Join-Path $PSScriptRoot '../Releases'), [string]$SigningIdentity = '-', [switch]$SkipFonts, - [string]$RhwpOverride, - [string]$UiohookOverride + [string]$RhwpOverride ) $ErrorActionPreference = 'Stop' @@ -27,9 +25,8 @@ if (-not $Runtime) { if (($Runtime.StartsWith('osx-') -and -not $IsMacOS) -or ($Runtime.StartsWith('linux-') -and -not $IsLinux)) { throw "Build $Runtime on its native operating system." } -$uiohookLibraries = @('libuiohook-xrecord.so', 'libuiohook-x11.so') -if (($RhwpOverride -or $UiohookOverride) -and -not $Runtime.StartsWith('linux-')) { - throw '-RhwpOverride and -UiohookOverride are for Linux packages.' +if ($RhwpOverride -and -not $Runtime.StartsWith('linux-')) { + throw '-RhwpOverride is for Linux packages.' } if ($RhwpOverride) { $RhwpOverride = (Resolve-Path -LiteralPath $RhwpOverride).Path @@ -37,14 +34,6 @@ if ($RhwpOverride) { throw "-RhwpOverride: $RhwpOverride has no rhwp program." } } -if ($UiohookOverride) { - $UiohookOverride = (Resolve-Path -LiteralPath $UiohookOverride).Path - foreach ($library in $uiohookLibraries) { - if (-not (Test-Path -LiteralPath (Join-Path $UiohookOverride $library) -PathType Leaf)) { - throw "-UiohookOverride: $UiohookOverride has no $library." - } - } -} if (-not $Version) { $Version = ([xml](Get-Content -LiteralPath (Join-Path $root 'Directory.Build.props') -Raw)).Project.PropertyGroup.Version | Where-Object { $_ } | Select-Object -First 1 @@ -90,17 +79,6 @@ if ($RhwpOverride) { else { & (Join-Path $PSScriptRoot 'fetch-portable-engines.ps1') -Runtime $Runtime -Destination $engines } -if ($UiohookOverride) { - # SharpHook's loader (libuiohook.so) dlopens these back ends from its own folder, the publish root. - foreach ($library in $uiohookLibraries) { - $target = Join-Path $publish $library - if (-not (Test-Path -LiteralPath $target -PathType Leaf)) { - throw "Publish has no $library to replace: SharpHook's native layout changed." - } - Copy-Item -LiteralPath (Join-Path $UiohookOverride $library) -Destination $target -Force - } - Write-Host "libuiohook back ends from $UiohookOverride" -} foreach ($name in @('Filee', 'filee-cli')) { $executable = Join-Path $publish $name if (-not (Test-Path -LiteralPath $executable -PathType Leaf)) { throw "Publish did not produce $name" } diff --git a/docs/ENGINES.md b/docs/ENGINES.md index 5fe5d5a1..01b7e205 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -82,25 +82,23 @@ keep the pinned version current. ## Linux builds for older glibc -Linux packages run on Ubuntu 22.04 (glibc 2.35). rhwp's Linux release is linked on Ubuntu 24.04 (needs glibc 2.39), and -SharpHook's `libuiohook-xrecord.so` / `libuiohook-x11.so` are built in C23 mode on Ubuntu 26.04 (`__isoc23_sscanf`, -glibc 2.38). The `linux-native` job of `.github/workflows/portable.yml` builds both from their pinned, unmodified -source on Ubuntu 22.04 (x64 and arm64) with `build/build-linux-native.py`: - -- `uiohook`: libuiohook at the commit SharpHook's release tag uses as its submodule (the script checks the tag), with - the CMake flags of SharpHook's `build.yml`, and its unit tests. Only those two back ends are replaced; the loader - `libuiohook.so` and the Wayland back end already run on glibc 2.34. +Linux packages run on Ubuntu 22.04 (glibc 2.35). rhwp's Linux release is linked on Ubuntu 24.04 (needs glibc 2.39). +The `linux-native` job of `.github/workflows/portable.yml` builds it from its pinned, unmodified source on Ubuntu 22.04 +(x64 and arm64) with `build/build-linux-native.py`: + - `rhwp`: `cargo build --release --locked --bin rhwp` at the tag's commit with its pinned Rust toolchain, in the layout of rhwp's release archive. -- `check` (also run on its own): fails when `Directory.Packages.props` uses another SharpHook version or engines.json - another rhwp version than the script builds. After bumping SharpHook, look up the new tag's libuiohook submodule - commit (`git ls-tree v libuiohook` in SharpHook) and its CMake flags. - -The Linux package jobs pass the results to `build/build-portable.ps1 -RhwpOverride -UiohookOverride `. -Local builds without these options use the downloaded rhwp and SharpHook's libraries (fine on the machine that -builds them). `build/check-elf-glibc.py` then lists the newest GLIBC version every ELF file in the package needs and -fails above 2.35, and the packages are unpacked and started on Ubuntu 22.04 (`build/smoke-portable.ps1`: CLI, rhwp -conversions, loading the X11 back end; `build/smoke-launch-app.py`: the app under Xvfb). +- `check` (also run on its own): fails when engines.json pins another rhwp version than the script builds. + +The Linux package jobs pass the result to `build/build-portable.ps1 -RhwpOverride `. Local builds without it use +the downloaded rhwp (fine on the machine that builds it). SharpHook's native libraries are packaged as they come +from NuGet: SharpHook 8.0.0 built its X11 back ends against glibc 2.38, 8.0.1 needs glibc 2.34 at most again. + +`build/check-elf-glibc.py` lists the newest GLIBC version every ELF file in the package needs and fails above 2.35, +and the packages are unpacked and started on Ubuntu 22.04 (`build/smoke-portable.ps1`: CLI, rhwp conversions, loading +the X11 input hook back end; `build/smoke-launch-app.py`: the app under Xvfb). If a SharpHook or engine update fails +these checks, keep the previous version until upstream builds for an older glibc again, or build the binary from its +pinned source in `build/build-linux-native.py` like rhwp (PR #113 did this for SharpHook 8.0.0's libuiohook back ends). ## Images and vector graphics