From 40b2fa30cf8333ea2fd4b14026abf2a9a085b6ad Mon Sep 17 00:00:00 2001 From: Alex-ghost599 <93719340+Alex-ghost599@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:44:29 +0800 Subject: [PATCH 1/4] feat: add macOS source installer --- scripts/install_macos.sh | 223 ++++++++++++++++++++++++++++++++++ tests/test_macos_installer.py | 58 +++++++++ 2 files changed, 281 insertions(+) create mode 100755 scripts/install_macos.sh create mode 100644 tests/test_macos_installer.py diff --git a/scripts/install_macos.sh b/scripts/install_macos.sh new file mode 100755 index 00000000..dbb0547a --- /dev/null +++ b/scripts/install_macos.sh @@ -0,0 +1,223 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +readonly REPO_URL='https://github.com/Alex-ghost599/CapsWriter-Offline-macOS.git' +readonly DEFAULT_REF='v2.6.0-macos.1' + +INSTALL_DIR="${HOME}/CapsWriter-Offline-macOS" +REF="${DEFAULT_REF}" +SKIP_MODELS=0 +SKIP_BUILD=0 +SKIP_SYSTEM_DEPS=0 +DRY_RUN=0 + +usage() { + cat <<'EOF' +Install CapsWriter-Offline-macOS from source on Apple Silicon. + +Usage: + bash install-macos.sh [options] + +Options: + --install-dir PATH Install directory (default: ~/CapsWriter-Offline-macOS) + --ref REF Git tag or branch to install (default: v2.6.0-macos.1) + --skip-models Skip the verified ASR and punctuation model download + --skip-build Skip building dist/CapsWriter.app + --skip-system-deps Do not run Homebrew; require uv and ffmpeg in PATH + --dry-run Print commands without changing the system + -h, --help Show this help + +The installer never resets an existing checkout and never starts the app. +EOF +} + +die() { + printf 'Error: %s\n' "$*" >&2 + exit 1 +} + +handle_error() { + local line="$1" + local status="$2" + printf '\nInstallation stopped at line %s (exit %s).\n' "$line" "$status" >&2 + exit "$status" +} + +trap 'handle_error "$LINENO" "$?"' ERR + +print_command() { + printf ' $' + printf ' %q' "$@" + printf '\n' +} + +run() { + print_command "$@" + if [[ "$DRY_RUN" -eq 0 ]]; then + "$@" + fi +} + +run_in_dir() { + local directory="$1" + shift + printf ' $ cd %q &&' "$directory" + printf ' %q' "$@" + printf '\n' + if [[ "$DRY_RUN" -eq 0 ]]; then + ( + cd "$directory" + "$@" + ) + fi +} + +require_command() { + local command_name="$1" + local help_text="$2" + command -v "$command_name" >/dev/null 2>&1 || die "$help_text" +} + +take_value() { + local option="$1" + local value="${2:-}" + [[ -n "$value" ]] || die "$option requires a value." + printf '%s' "$value" +} + +while [[ "$#" -gt 0 ]]; do + case "$1" in + --install-dir) + INSTALL_DIR="$(take_value "$1" "${2:-}")" + shift 2 + ;; + --ref) + REF="$(take_value "$1" "${2:-}")" + shift 2 + ;; + --skip-models) + SKIP_MODELS=1 + shift + ;; + --skip-build) + SKIP_BUILD=1 + shift + ;; + --skip-system-deps) + SKIP_SYSTEM_DEPS=1 + shift + ;; + --dry-run) + DRY_RUN=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + --) + shift + [[ "$#" -eq 0 ]] || die 'Positional arguments are not supported.' + ;; + *) + printf 'Unknown option: %s\n\n' "$1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ "$INSTALL_DIR" == \~ ]]; then + INSTALL_DIR="$HOME" +elif [[ "$INSTALL_DIR" == \~/* ]]; then + INSTALL_DIR="${HOME}/${INSTALL_DIR:2}" +fi + +[[ "$(uname -s)" == 'Darwin' ]] || die 'This installer supports macOS only.' +[[ "$(uname -m)" == 'arm64' ]] || die 'This release supports Apple Silicon (arm64) only.' + +printf 'CapsWriter-Offline-macOS source installer\n' +printf ' Repository: %s\n' "$REPO_URL" +printf ' Ref: %s\n' "$REF" +printf ' Directory: %s\n\n' "$INSTALL_DIR" + +if [[ "$DRY_RUN" -eq 0 ]]; then + require_command git 'Git is required. Run xcode-select --install, then retry.' + git check-ref-format --branch "$REF" >/dev/null 2>&1 || die "Invalid Git ref: $REF" +fi + +if [[ "$SKIP_SYSTEM_DEPS" -eq 0 ]]; then + if [[ "$DRY_RUN" -eq 0 ]] && ! command -v brew >/dev/null 2>&1; then + die 'Homebrew is required. Install it from https://brew.sh, then retry.' + fi + run env HOMEBREW_NO_AUTO_UPDATE=1 brew install uv ffmpeg +elif [[ "$DRY_RUN" -eq 0 ]]; then + require_command uv 'uv is required in PATH when --skip-system-deps is used.' + require_command ffmpeg 'ffmpeg is required in PATH when --skip-system-deps is used.' +fi + +if [[ "$DRY_RUN" -eq 0 ]]; then + require_command uv 'uv installation failed or uv is not in PATH.' + require_command ffmpeg 'ffmpeg installation failed or ffmpeg is not in PATH.' +fi + +if [[ -e "$INSTALL_DIR" && "$DRY_RUN" -eq 0 ]]; then + [[ -d "$INSTALL_DIR/.git" ]] || die "Install directory exists and is not a Git checkout: $INSTALL_DIR" + + remote_url="$(git -C "$INSTALL_DIR" remote get-url origin 2>/dev/null || true)" + case "$remote_url" in + "$REPO_URL"|'https://github.com/Alex-ghost599/CapsWriter-Offline-macOS'|\ + 'git@github.com:Alex-ghost599/CapsWriter-Offline-macOS.git'|\ + 'git@github.com:Alex-ghost599/CapsWriter-Offline-macOS') + ;; + *) + die "Existing checkout has an unexpected origin: ${remote_url:-}" + ;; + esac + + [[ -z "$(git -C "$INSTALL_DIR" status --porcelain)" ]] || \ + die "Existing checkout has local changes; use another --install-dir or clean it manually." + + run git -C "$INSTALL_DIR" fetch --depth 1 origin "$REF" + expected_commit="$(git -C "$INSTALL_DIR" rev-parse 'FETCH_HEAD^{commit}')" + current_commit="$(git -C "$INSTALL_DIR" rev-parse 'HEAD^{commit}')" + [[ "$current_commit" == "$expected_commit" ]] || \ + die "Existing checkout is not at $REF; use another --install-dir or switch it manually." +elif [[ "$DRY_RUN" -eq 0 ]]; then + run mkdir -p "$(dirname "$INSTALL_DIR")" + run git clone --depth 1 --single-branch --branch "$REF" "$REPO_URL" "$INSTALL_DIR" +else + run mkdir -p "$(dirname "$INSTALL_DIR")" + run git clone --depth 1 --single-branch --branch "$REF" "$REPO_URL" "$INSTALL_DIR" +fi + +run_in_dir "$INSTALL_DIR" uv python install 3.12 +run_in_dir "$INSTALL_DIR" uv sync --all-groups --frozen + +if [[ "$SKIP_MODELS" -eq 0 ]]; then + run_in_dir "$INSTALL_DIR" uv run python scripts/download_macos_models.py +else + printf '\nSkipping model download. Run this later:\n' + printf ' cd %q && uv run python scripts/download_macos_models.py\n' "$INSTALL_DIR" +fi + +if [[ "$SKIP_BUILD" -eq 0 ]]; then + run_in_dir "$INSTALL_DIR" uv run pyinstaller --noconfirm --clean packaging/macos/CapsWriterClient.spec + if [[ "$DRY_RUN" -eq 0 ]]; then + [[ -d "$INSTALL_DIR/dist/CapsWriter.app" ]] || die 'Client build did not produce dist/CapsWriter.app.' + run codesign --verify --deep --strict "$INSTALL_DIR/dist/CapsWriter.app" + fi +else + printf '\nSkipping client build. Run this later:\n' + printf ' cd %q && uv run pyinstaller --noconfirm --clean packaging/macos/CapsWriterClient.spec\n' "$INSTALL_DIR" +fi + +printf '\nInstallation steps completed. The installer did not start any process.\n' +printf 'Start the local server in one Terminal window:\n' +printf ' cd %q && CAPSWRITER_MODEL_TYPE=paraformer uv run python start_server.py\n' "$INSTALL_DIR" +if [[ "$SKIP_BUILD" -eq 0 ]]; then + printf 'Then start the client:\n' + printf ' open %q\n' "$INSTALL_DIR/dist/CapsWriter.app" +fi +printf 'On each Mac, grant Microphone, Accessibility, and Input Monitoring permissions to CapsWriter.\n' diff --git a/tests/test_macos_installer.py b/tests/test_macos_installer.py new file mode 100644 index 00000000..ff87e656 --- /dev/null +++ b/tests/test_macos_installer.py @@ -0,0 +1,58 @@ +import platform +import subprocess +import sys +from pathlib import Path + +import pytest + + +pytestmark = pytest.mark.skipif( + sys.platform != 'darwin' or platform.machine() != 'arm64', + reason='macOS source installer requires Apple Silicon', +) + +SCRIPT = Path(__file__).resolve().parents[1] / 'scripts' / 'install_macos.sh' + + +def run_installer(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ['bash', str(SCRIPT), *args], + check=False, + capture_output=True, + text=True, + ) + + +def test_installer_help(): + result = run_installer('--help') + + assert result.returncode == 0 + assert '--skip-models' in result.stdout + assert '--dry-run' in result.stdout + + +def test_installer_dry_run_is_non_destructive(tmp_path: Path): + install_dir = tmp_path / 'CapsWriter Offline' + + result = run_installer( + '--dry-run', + '--skip-system-deps', + '--skip-models', + '--skip-build', + '--install-dir', + str(install_dir), + ) + + assert result.returncode == 0, result.stderr + assert 'git clone' in result.stdout + assert 'uv sync --all-groups --frozen' in result.stdout + assert 'Skipping model download' in result.stdout + assert 'Skipping client build' in result.stdout + assert not install_dir.exists() + + +def test_installer_rejects_unknown_option(): + result = run_installer('--not-an-option') + + assert result.returncode == 2 + assert 'Unknown option: --not-an-option' in result.stderr From 2087844a07344e55ebd7745ac3b097934085796e Mon Sep 17 00:00:00 2001 From: Alex-ghost599 <93719340+Alex-ghost599@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:44:52 +0800 Subject: [PATCH 2/4] docs: document macOS source release install --- docs/macos-setup.md | 40 ++++++++++++++++++++++++++++++++++++---- readme.md | 21 +++++++++++++++++++-- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/docs/macos-setup.md b/docs/macos-setup.md index acfb566f..e9662842 100644 --- a/docs/macos-setup.md +++ b/docs/macos-setup.md @@ -3,13 +3,44 @@ 当前适配目标为 Apple Silicon macOS。默认使用 Paraformer 语音模型和 Punct-CT-Transformer 标点模型,服务端与客户端均在本机离线运行。 -## 1. 安装环境 +## 推荐:从 Release 安装源码版 + +[`v2.6.0-macos.1`](https://github.com/Alex-ghost599/CapsWriter-Offline-macOS/releases/tag/v2.6.0-macos.1) +是 macOS 源码安装实验版。Release 不包含预编译 App 或模型,只提供可审查的安装脚本和 +SHA-256 校验文件;脚本会拉取固定 tag、以 `uv` 安装冻结依赖、校验下载模型并在本机 +构建 `CapsWriter.app`。 + +先安装 [Homebrew](https://brew.sh),再在终端执行: + +```bash +BASE_URL=https://github.com/Alex-ghost599/CapsWriter-Offline-macOS/releases/download/v2.6.0-macos.1 +curl -fLO "$BASE_URL/install-macos.sh" +curl -fLO "$BASE_URL/SHA256SUMS" +shasum -a 256 -c SHA256SUMS +bash install-macos.sh +``` + +默认安装到 `~/CapsWriter-Offline-macOS`,并下载约 530 MB 的 Paraformer 和标点模型。 +暂时不下载模型可执行: + +```bash +bash install-macos.sh --skip-models +``` + +其他可用参数见 `bash install-macos.sh --help`。安装器不会覆盖有改动或版本不符的已有 +checkout,也不会自动启动服务或客户端。每台 Mac 都需要各自执行安装,并分别授予 +麦克风、辅助功能和输入监控权限。 + +## 1. 手动安装环境 ```bash brew install uv ffmpeg -cd ~/CapsWriter-Offline +git clone --depth 1 --branch v2.6.0-macos.1 \ + https://github.com/Alex-ghost599/CapsWriter-Offline-macOS.git \ + ~/CapsWriter-Offline-macOS +cd ~/CapsWriter-Offline-macOS uv python install 3.12 -uv sync --all-groups +uv sync --all-groups --frozen ``` 仓库固定 Python 3.12,所有解释器、虚拟环境与 Python 依赖均由 `uv` 管理。 @@ -60,7 +91,8 @@ Command-V 和监听全局右 Shift。按住右 Shift 说话,松开后识别文 手动构建使用 ad-hoc 签名。每次重建二进制后,macOS 可能不继承上一版的 TCC 授权,需删除旧权限项、重新添加最终 `CapsWriter.app` 并再启动。要做稳定 -分发,仍需 Apple Developer 签名身份与公证。 +的免警告二进制分发,仍需 Apple Developer 签名身份与公证;这不影响当前源码安装版 +作为 GitHub Release 发布和使用。 ## 5. 源码调试 diff --git a/readme.md b/readme.md index 32862a6a..d961402d 100644 --- a/readme.md +++ b/readme.md @@ -11,6 +11,23 @@ **CapsWriter-Offline** 是一个**完全离线**语音输入工具,支持 Windows;本 fork 另提供 Apple Silicon macOS 手动构建与运行适配。 +## macOS 源码安装实验版 + +[`v2.6.0-macos.1`](https://github.com/Alex-ghost599/CapsWriter-Offline-macOS/releases/tag/v2.6.0-macos.1) +提供可审查的源码安装脚本,不包含预编译 App 或模型。安装 [Homebrew](https://brew.sh) 后执行: + +```bash +BASE_URL=https://github.com/Alex-ghost599/CapsWriter-Offline-macOS/releases/download/v2.6.0-macos.1 +curl -fLO "$BASE_URL/install-macos.sh" +curl -fLO "$BASE_URL/SHA256SUMS" +shasum -a 256 -c SHA256SUMS +bash install-macos.sh +``` + +脚本会固定安装该 tag,以 `uv` 管理 Python 3.12 和冻结依赖,默认校验下载模型并在本机 +构建 `dist/CapsWriter.app`;用 `--skip-models` 可暂不下载模型。完整参数、启动方式和每台 +Mac 的权限设置见 [macOS 安装与运行](docs/macos-setup.md)。 + ## ✨ 核心特性 - **语音输入**:按住 `CapsLock键` 或 `鼠标侧键X2` 说话,松开即输入,超低延迟,默认去除末尾逗句号。支持对讲机模式和单击录音模式。 @@ -79,8 +96,8 @@ CapsWriter 的特别之处在于追求: ## 🎬 快速开始 -Apple Silicon macOS 请直接按 [macOS 安装与运行](docs/macos-setup.md) 操作。以下步骤为 -Windows 发行包流程。 +Apple Silicon macOS 请使用上面的 Release 源码安装入口,或按 +[macOS 安装与运行](docs/macos-setup.md) 手动操作。以下步骤为 Windows 发行包流程。 1. **准备环境**:确保安装了 [VC++ 运行库](https://learn.microsoft.com/zh-cn/cpp/windows/latest-supported-vc-redist)。若要使用文件转录功能,还需安装 [ffmpeg](https://ffmpeg.org/download.html) 并确保其在系统 PATH 中。 2. **下载解压**:下载 [Latest Release](https://github.com/HaujetZhao/CapsWriter-Offline/releases/latest) 里的软件本体,再到 [Models Release](https://github.com/HaujetZhao/CapsWriter-Offline/releases/tag/models) 下载模型压缩包,将模型解压,放入 `models` 文件夹中对应模型的文件夹里。 From 420151d953253be0f12fb1d3ae2e770801d5c13c Mon Sep 17 00:00:00 2001 From: Alex-ghost599 <93719340+Alex-ghost599@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:07:15 +0800 Subject: [PATCH 3/4] fix: harden macOS installer preflight --- .github/workflows/macos-installer.yml | 25 +++++ docs/macos-setup.md | 7 +- scripts/install_macos.sh | 37 ++----- tests/test_macos_installer.py | 147 +++++++++++++++++++++++++- 4 files changed, 179 insertions(+), 37 deletions(-) create mode 100644 .github/workflows/macos-installer.yml diff --git a/.github/workflows/macos-installer.yml b/.github/workflows/macos-installer.yml new file mode 100644 index 00000000..4ea91f30 --- /dev/null +++ b/.github/workflows/macos-installer.yml @@ -0,0 +1,25 @@ +name: macOS installer + +on: + push: + branches: [develop, 'feat--**'] + pull_request: + branches: [develop] + +permissions: + contents: read + +jobs: + installer: + runs-on: macos-14 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + with: + version: '0.11.29' + enable-cache: false + - run: brew install shellcheck + - run: shellcheck --shell=bash scripts/install_macos.sh + - run: /bin/bash -n scripts/install_macos.sh + - run: uvx --from pytest==9.1.1 pytest -q tests/test_macos_installer.py diff --git a/docs/macos-setup.md b/docs/macos-setup.md index e9662842..ec479bbc 100644 --- a/docs/macos-setup.md +++ b/docs/macos-setup.md @@ -27,9 +27,10 @@ bash install-macos.sh bash install-macos.sh --skip-models ``` -其他可用参数见 `bash install-macos.sh --help`。安装器不会覆盖有改动或版本不符的已有 -checkout,也不会自动启动服务或客户端。每台 Mac 都需要各自执行安装,并分别授予 -麦克风、辅助功能和输入监控权限。 +其他可用参数见 `bash install-macos.sh --help`。只要安装目录已经存在,安装器就会在执行 +Homebrew 前停止;请改用新的 `--install-dir`,已有目录始终由用户手动管理。安装器不会 +自动启动服务或客户端。每台 Mac 都需要各自执行安装,并分别授予麦克风、辅助功能和 +输入监控权限。 ## 1. 手动安装环境 diff --git a/scripts/install_macos.sh b/scripts/install_macos.sh index dbb0547a..1e2275f0 100755 --- a/scripts/install_macos.sh +++ b/scripts/install_macos.sh @@ -29,6 +29,7 @@ Options: -h, --help Show this help The installer never resets an existing checkout and never starts the app. +The install directory must not already exist. EOF } @@ -83,6 +84,7 @@ take_value() { local option="$1" local value="${2:-}" [[ -n "$value" ]] || die "$option requires a value." + [[ "$value" != -* ]] || die "$option requires a value, not another option." printf '%s' "$value" } @@ -147,6 +149,10 @@ if [[ "$DRY_RUN" -eq 0 ]]; then git check-ref-format --branch "$REF" >/dev/null 2>&1 || die "Invalid Git ref: $REF" fi +if [[ -e "$INSTALL_DIR" || -L "$INSTALL_DIR" ]]; then + die "Install directory already exists; choose a fresh --install-dir: $INSTALL_DIR" +fi + if [[ "$SKIP_SYSTEM_DEPS" -eq 0 ]]; then if [[ "$DRY_RUN" -eq 0 ]] && ! command -v brew >/dev/null 2>&1; then die 'Homebrew is required. Install it from https://brew.sh, then retry.' @@ -162,35 +168,8 @@ if [[ "$DRY_RUN" -eq 0 ]]; then require_command ffmpeg 'ffmpeg installation failed or ffmpeg is not in PATH.' fi -if [[ -e "$INSTALL_DIR" && "$DRY_RUN" -eq 0 ]]; then - [[ -d "$INSTALL_DIR/.git" ]] || die "Install directory exists and is not a Git checkout: $INSTALL_DIR" - - remote_url="$(git -C "$INSTALL_DIR" remote get-url origin 2>/dev/null || true)" - case "$remote_url" in - "$REPO_URL"|'https://github.com/Alex-ghost599/CapsWriter-Offline-macOS'|\ - 'git@github.com:Alex-ghost599/CapsWriter-Offline-macOS.git'|\ - 'git@github.com:Alex-ghost599/CapsWriter-Offline-macOS') - ;; - *) - die "Existing checkout has an unexpected origin: ${remote_url:-}" - ;; - esac - - [[ -z "$(git -C "$INSTALL_DIR" status --porcelain)" ]] || \ - die "Existing checkout has local changes; use another --install-dir or clean it manually." - - run git -C "$INSTALL_DIR" fetch --depth 1 origin "$REF" - expected_commit="$(git -C "$INSTALL_DIR" rev-parse 'FETCH_HEAD^{commit}')" - current_commit="$(git -C "$INSTALL_DIR" rev-parse 'HEAD^{commit}')" - [[ "$current_commit" == "$expected_commit" ]] || \ - die "Existing checkout is not at $REF; use another --install-dir or switch it manually." -elif [[ "$DRY_RUN" -eq 0 ]]; then - run mkdir -p "$(dirname "$INSTALL_DIR")" - run git clone --depth 1 --single-branch --branch "$REF" "$REPO_URL" "$INSTALL_DIR" -else - run mkdir -p "$(dirname "$INSTALL_DIR")" - run git clone --depth 1 --single-branch --branch "$REF" "$REPO_URL" "$INSTALL_DIR" -fi +run mkdir -p "$(dirname "$INSTALL_DIR")" +run git clone --depth 1 --single-branch --branch "$REF" "$REPO_URL" "$INSTALL_DIR" run_in_dir "$INSTALL_DIR" uv python install 3.12 run_in_dir "$INSTALL_DIR" uv sync --all-groups --frozen diff --git a/tests/test_macos_installer.py b/tests/test_macos_installer.py index ff87e656..de2958f3 100644 --- a/tests/test_macos_installer.py +++ b/tests/test_macos_installer.py @@ -1,4 +1,4 @@ -import platform +import os import subprocess import sys from pathlib import Path @@ -7,19 +7,46 @@ pytestmark = pytest.mark.skipif( - sys.platform != 'darwin' or platform.machine() != 'arm64', - reason='macOS source installer requires Apple Silicon', + sys.platform != 'darwin', + reason='macOS source installer tests run in the dedicated macOS job', ) SCRIPT = Path(__file__).resolve().parents[1] / 'scripts' / 'install_macos.sh' -def run_installer(*args: str) -> subprocess.CompletedProcess[str]: +def write_executable(path: Path, contents: str) -> None: + path.write_text(contents, encoding='utf-8') + path.chmod(0o755) + + +def installer_env(tmp_path: Path, tools: dict[str, str] | None = None) -> dict[str, str]: + fake_bin = tmp_path / 'bin' + fake_bin.mkdir() + write_executable( + fake_bin / 'uname', + """#!/bin/bash +case "$1" in + -s) printf 'Darwin\\n' ;; + -m) printf 'arm64\\n' ;; + *) exit 2 ;; +esac +""", + ) + for name, contents in (tools or {}).items(): + write_executable(fake_bin / name, contents) + + environment = os.environ.copy() + environment['PATH'] = f'{fake_bin}:{environment["PATH"]}' + return environment + + +def run_installer(*args: str, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]: return subprocess.run( - ['bash', str(SCRIPT), *args], + ['/bin/bash', str(SCRIPT), *args], check=False, capture_output=True, text=True, + env=env, ) @@ -41,6 +68,7 @@ def test_installer_dry_run_is_non_destructive(tmp_path: Path): '--skip-build', '--install-dir', str(install_dir), + env=installer_env(tmp_path), ) assert result.returncode == 0, result.stderr @@ -56,3 +84,112 @@ def test_installer_rejects_unknown_option(): assert result.returncode == 2 assert 'Unknown option: --not-an-option' in result.stderr + + +def test_installer_rejects_an_option_as_a_value(): + result = run_installer('--install-dir', '--skip-models') + + assert result.returncode == 1 + assert '--install-dir requires a value, not another option' in result.stderr + + +def test_existing_directory_is_rejected_before_homebrew(tmp_path: Path): + install_dir = tmp_path / 'existing' + install_dir.mkdir() + brew_marker = tmp_path / 'brew-ran' + environment = installer_env( + tmp_path, + { + 'brew': """#!/bin/bash +touch "$FAKE_BREW_MARKER" +""", + }, + ) + environment['FAKE_BREW_MARKER'] = str(brew_marker) + + result = run_installer('--install-dir', str(install_dir), env=environment) + + assert result.returncode == 1 + assert 'Install directory already exists' in result.stderr + assert install_dir.is_dir() + assert not brew_marker.exists() + + +def test_real_flow_preserves_space_in_install_path(tmp_path: Path): + install_dir = tmp_path / 'CapsWriter Offline' + clone_log = tmp_path / 'clone-destination' + environment = installer_env( + tmp_path, + { + 'git': """#!/bin/bash +case "$1" in + check-ref-format) exit 0 ;; + clone) + for argument in "$@"; do destination="$argument"; done + mkdir -p "$destination/.git" + printf '%s\\n' "$destination" > "$FAKE_CLONE_LOG" + ;; + *) exit 70 ;; +esac +""", + 'uv': """#!/bin/bash +case "$*" in + 'python install 3.12'|'sync --all-groups --frozen') exit 0 ;; + 'run pyinstaller --noconfirm --clean packaging/macos/CapsWriterClient.spec') + mkdir -p dist/CapsWriter.app + ;; + *) exit 71 ;; +esac +""", + 'ffmpeg': '#!/bin/bash\nexit 0\n', + 'codesign': """#!/bin/bash +[[ "$1" == '--verify' && "$2" == '--deep' && "$3" == '--strict' && -d "$4" ]] +""", + }, + ) + environment['FAKE_CLONE_LOG'] = str(clone_log) + + result = run_installer( + '--skip-system-deps', + '--skip-models', + '--install-dir', + str(install_dir), + env=environment, + ) + + assert result.returncode == 0, result.stderr + assert clone_log.read_text(encoding='utf-8').strip() == str(install_dir) + assert (install_dir / 'dist' / 'CapsWriter.app').is_dir() + + +def test_clone_failure_stops_before_uv_commands(tmp_path: Path): + install_dir = tmp_path / 'missing-tag' + uv_marker = tmp_path / 'uv-ran' + environment = installer_env( + tmp_path, + { + 'git': """#!/bin/bash +[[ "$1" == 'check-ref-format' ]] && exit 0 +[[ "$1" == 'clone' ]] && exit 42 +exit 70 +""", + 'uv': """#!/bin/bash +touch "$FAKE_UV_MARKER" +""", + 'ffmpeg': '#!/bin/bash\nexit 0\n', + }, + ) + environment['FAKE_UV_MARKER'] = str(uv_marker) + + result = run_installer( + '--skip-system-deps', + '--skip-models', + '--skip-build', + '--install-dir', + str(install_dir), + env=environment, + ) + + assert result.returncode == 42 + assert not uv_marker.exists() + assert not install_dir.exists() From 6de7bed4a5a8959b4912461ee4faf2affee59c22 Mon Sep 17 00:00:00 2001 From: Alex-ghost599 <93719340+Alex-ghost599@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:09:49 +0800 Subject: [PATCH 4/4] ci: pin macOS installer test runtime --- .github/workflows/macos-installer.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/macos-installer.yml b/.github/workflows/macos-installer.yml index 4ea91f30..4aae9d1b 100644 --- a/.github/workflows/macos-installer.yml +++ b/.github/workflows/macos-installer.yml @@ -22,4 +22,5 @@ jobs: - run: brew install shellcheck - run: shellcheck --shell=bash scripts/install_macos.sh - run: /bin/bash -n scripts/install_macos.sh - - run: uvx --from pytest==9.1.1 pytest -q tests/test_macos_installer.py + - run: uv python install 3.12 + - run: uvx --python 3.12 --from pytest==9.1.1 --with pytest-asyncio==1.4.0 pytest -q tests/test_macos_installer.py