diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..32e7d76 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,343 @@ +# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist +# +# Copyright 2022-2024, axodotdev +# SPDX-License-Identifier: MIT or Apache-2.0 +# +# CI that: +# +# * checks for a Git Tag that looks like a release +# * builds artifacts with dist (archives, installers, hashes) +# * uploads those artifacts to temporary workflow zip +# * on success, uploads the artifacts to a GitHub Release +# +# Note that the GitHub Release will be created with a generated +# title/body based on your changelogs. + +name: Release +permissions: + "contents": "write" + +# This task will run whenever you push a git tag that looks like a version +# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. +# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where +# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION +# must be a Cargo-style SemVer Version (must have at least major.minor.patch). +# +# If PACKAGE_NAME is specified, then the announcement will be for that +# package (erroring out if it doesn't have the given version or isn't dist-able). +# +# If PACKAGE_NAME isn't specified, then the announcement will be for all +# (dist-able) packages in the workspace with that version (this mode is +# intended for workspaces with only one dist-able package, or with all dist-able +# packages versioned/released in lockstep). +# +# If you push multiple tags at once, separate instances of this workflow will +# spin up, creating an independent announcement for each one. However, GitHub +# will hard limit this to 3 tags per commit, as it will assume more tags is a +# mistake. +# +# If there's a prerelease-style suffix to the version, then the release(s) +# will be marked as a prerelease. +on: + pull_request: + push: + tags: + - '**[0-9]+.[0-9]+.[0-9]+*' + +jobs: + # Run 'dist plan' (or host) to determine what tasks we need to do + plan: + runs-on: "ubuntu-22.04" + outputs: + val: ${{ steps.plan.outputs.manifest }} + tag: ${{ !github.event.pull_request && github.ref_name || '' }} + tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} + publishing: ${{ !github.event.pull_request }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install dist + # we specify bash to get pipefail; it guards against the `curl` command + # failing. otherwise `sh` won't catch that `curl` returned non-0 + shell: bash + run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.30.4/cargo-dist-installer.sh | sh" + - name: Cache dist + uses: actions/upload-artifact@v6 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/dist + # sure would be cool if github gave us proper conditionals... + # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible + # functionality based on whether this is a pull_request, and whether it's from a fork. + # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* + # but also really annoying to build CI around when it needs secrets to work right.) + - id: plan + run: | + dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json + echo "dist ran successfully" + cat plan-dist-manifest.json + echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@v6 + with: + name: artifacts-plan-dist-manifest + path: plan-dist-manifest.json + + # Build and packages all the platform-specific things + build-local-artifacts: + name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) + # Let the initial task tell us to not run (currently very blunt) + needs: + - plan + if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }} + strategy: + fail-fast: false + # Target platforms/runners are computed by dist in create-release. + # Each member of the matrix has the following arguments: + # + # - runner: the github runner + # - dist-args: cli flags to pass to dist + # - install-dist: expression to run to install dist on the runner + # + # Typically there will be: + # - 1 "global" task that builds universal installers + # - N "local" tasks that build each platform's binaries and platform-specific installers + matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} + runs-on: ${{ matrix.runner }} + container: ${{ matrix.container && matrix.container.image || null }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json + steps: + - name: enable windows longpaths + run: | + git config --global core.longpaths true + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install Rust non-interactively if not already installed + if: ${{ matrix.container }} + run: | + if ! command -v cargo > /dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + fi + - name: Install dist + run: ${{ matrix.install_dist.run }} + # Get the dist-manifest + - name: Fetch local artifacts + uses: actions/download-artifact@v7 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - name: Install dependencies + run: | + ${{ matrix.packages_install }} + - name: Build artifacts + run: | + # Actually do builds and make zips and whatnot + dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json + echo "dist ran successfully" + - id: cargo-dist + name: Post-build + # We force bash here just because github makes it really hard to get values up + # to "real" actions without writing to env-vars, and writing to env-vars has + # inconsistent syntax between shell and powershell. + shell: bash + run: | + # Parse out what we just built and upload it to scratch storage + echo "paths<> "$GITHUB_OUTPUT" + dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@v6 + with: + name: artifacts-build-local-${{ join(matrix.targets, '_') }} + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + + # Build and package all the platform-agnostic(ish) things + build-global-artifacts: + needs: + - plan + - build-local-artifacts + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install cached dist + uses: actions/download-artifact@v7 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist + # Get all the local artifacts for the global tasks to use (for e.g. checksums) + - name: Fetch local artifacts + uses: actions/download-artifact@v7 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: cargo-dist + shell: bash + run: | + dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json + echo "dist ran successfully" + + # Parse out what we just built and upload it to scratch storage + echo "paths<> "$GITHUB_OUTPUT" + jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@v6 + with: + name: artifacts-build-global + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + # Determines if we should publish/announce + host: + needs: + - plan + - build-local-artifacts + - build-global-artifacts + # Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine) + if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + runs-on: "ubuntu-22.04" + outputs: + val: ${{ steps.host.outputs.manifest }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install cached dist + uses: actions/download-artifact@v7 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist + # Fetch artifacts from scratch-storage + - name: Fetch artifacts + uses: actions/download-artifact@v7 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: host + shell: bash + run: | + dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json + echo "artifacts uploaded and released successfully" + cat dist-manifest.json + echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@v6 + with: + # Overwrite the previous copy + name: artifacts-dist-manifest + path: dist-manifest.json + # Create a GitHub Release while uploading all files to it + - name: "Download GitHub Artifacts" + uses: actions/download-artifact@v7 + with: + pattern: artifacts-* + path: artifacts + merge-multiple: true + - name: Cleanup + run: | + # Remove the granular manifests + rm -f artifacts/*-dist-manifest.json + - name: Create GitHub Release + env: + PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" + ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" + ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" + RELEASE_COMMIT: "${{ github.sha }}" + run: | + # Write and read notes from a file to avoid quoting breaking things + echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt + + gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* + + publish-homebrew-formula: + needs: + - plan + - host + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PLAN: ${{ needs.plan.outputs.val }} + GITHUB_USER: "axo bot" + GITHUB_EMAIL: "admin+bot@axo.dev" + if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: true + repository: "hypervideo/homebrew-tap" + token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + # So we have access to the formula + - name: Fetch homebrew formulae + uses: actions/download-artifact@v7 + with: + pattern: artifacts-* + path: Formula/ + merge-multiple: true + # This is extra complex because you can make your Formula name not match your app name + # so we need to find releases with a *.rb file, and publish with that filename. + - name: Commit formula files + run: | + git config --global user.name "${GITHUB_USER}" + git config --global user.email "${GITHUB_EMAIL}" + + for release in $(echo "$PLAN" | jq --compact-output '.releases[] | select([.artifacts[] | endswith(".rb")] | any)'); do + filename=$(echo "$release" | jq '.artifacts[] | select(endswith(".rb"))' --raw-output) + name=$(echo "$filename" | sed "s/\.rb$//") + version=$(echo "$release" | jq .app_version --raw-output) + + export PATH="/home/linuxbrew/.linuxbrew/bin:$PATH" + brew update + # We avoid reformatting user-provided data such as the app description and homepage. + brew style --except-cops FormulaAudit/Homepage,FormulaAudit/Desc,FormulaAuditStrict --fix "Formula/${filename}" || true + + git add "Formula/${filename}" + git commit -m "${name} ${version}" + done + git push + + announce: + needs: + - plan + - host + - publish-homebrew-formula + # use "always() && ..." to allow us to wait for all publish jobs while + # still allowing individual publish jobs to skip themselves (for prereleases). + # "host" however must run to completion, no skipping allowed! + if: ${{ always() && needs.host.result == 'success' && (needs.publish-homebrew-formula.result == 'skipped' || needs.publish-homebrew-formula.result == 'success') }} + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive diff --git a/2026-04-17_build-macos-binary.md b/2026-04-17_build-macos-binary.md new file mode 100644 index 0000000..32151a4 --- /dev/null +++ b/2026-04-17_build-macos-binary.md @@ -0,0 +1,287 @@ +# Plan: Build macOS Binary Distribution + +## Goal + +Set this repo up to ship prebuilt macOS binaries for colleagues without requiring a local Rust toolchain. + +Keep the implementation simple: + +- use `dist` (`cargo-dist`) to build release archives and generate the release workflow, +- make local macOS Chrome discovery work outside the Nix shell, +- run the macOS release builds on Blacksmith macOS runners. + +This plan is intentionally limited to shipping the `client-simulator` CLI for macOS. +It does not try to redesign the runtime, build a `.app` bundle, or add extra packaging formats unless `dist` gives them to us cheaply. + +## Constraints And Assumptions + +- The shipped binary still depends on a locally installed Chrome/Chromium runtime. +- The current code only discovers Chrome via `PATH`, which is not enough for normal macOS machines. +- `ffmpeg` is only needed for custom fake-media conversion. That should not block the base distribution plan. +- This repo is already a normal Rust workspace with a root binary package, which fits `dist` well. + +## Implementation Steps + +### 1. Add `dist` configuration for macOS release artifacts + +STATUS: completed + +Objective: + +- introduce the minimal `dist` config needed to build release archives for: + - `aarch64-apple-darwin` + - `x86_64-apple-darwin` + +Work: + +- install `dist` locally using our nix flake and the `cargo-dist` derivation and initialize the workspace with `dist init` +- commit the generated `Cargo.toml` metadata and any generated workflow/config files +- keep the initial target list macOS-only instead of enabling a larger cross-platform matrix right now +- make sure the root package metadata is present and suitable for generated release artifacts: + - `description` + - `homepage` or a repository/homepage value usable by release tooling +- use the generated `profile.dist` instead of inventing custom release profiles + +Expected result: + +- `dist` can produce release archives for both macOS architectures from this workspace +- the repo has a standard `dist` config that can be regenerated later with `dist init` + +Notes: + +- `dist` can also manage Homebrew later, but the base GitHub Release artifact flow should land first. +- Avoid custom `dist` scripting unless the generated defaults prove insufficient. + +### 2. Add `just` commands for local release workflows + +STATUS: completed + +Objective: + +- make local release-related operations obvious and repeatable + +Work: + +- add a small set of `justfile` commands, likely: + - `just dist-init` + - `just dist-generate` + - `just dist-plan` + - `just dist-build` +- keep the commands thin wrappers around `dist` +- prefer names that match the `dist` subcommands directly + +Expected result: + +- contributors can regenerate config and test release builds locally without remembering `dist` syntax + +Notes: + +- Do not add a large release DSL to `justfile` +- Do not duplicate CI logic in `just`; keep it as a thin local entrypoint + +### 3. Extend Chrome discovery for normal macOS installs + +STATUS: completed + +Objective: + +- make the released binary work on a colleague’s Mac without relying on the Nix shell adding Chrome to `PATH` + +Current code: + +- `browser/src/participant/local/session.rs` looks up: + - `chromium` + - `google-chrome` + - `google-chrome-stable` + - `chrome` +- if none are on `PATH`, startup fails + +Work: + +- keep the existing `PATH` lookup first +- add a macOS fallback that checks common app-bundle executable locations, starting with: + - `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome` +- optionally also check: + - `/Applications/Chromium.app/Contents/MacOS/Chromium` + - `~/Applications/Google Chrome.app/Contents/MacOS/Google Chrome` +- return the first existing executable path +- keep the change scoped to one helper, likely `get_binary()` +- add focused unit coverage for the path-resolution logic if the function can be made testable without filesystem-heavy integration tests + +Expected result: + +- the binary works on standard macOS setups where Chrome is installed in `/Applications` + +Notes: + +- Keep this as detection logic only +- Do not add a full browser-install manager +- Do not introduce a macOS-only CLI flag unless the fallback logic turns out to be too brittle + +### 4. Generate the GitHub release workflow with `dist` + +STATUS: completed + +Objective: + +- let `dist` own the release workflow rather than hand-writing a separate packaging pipeline + +Work: + +- use `dist`’s GitHub CI integration so `.github/workflows/release.yml` is generated from config +- review the generated workflow and only make the smallest necessary customizations +- keep the CI model close to upstream `dist` conventions so `dist init` / `dist generate` stay usable later + +Expected result: + +- release builds are driven by a standard `dist` workflow and tag-based release process + +Notes: + +- Avoid hand-maintaining a fully custom release workflow if `dist` can express the same thing in config +- If workflow customization is needed, prefer `dist` config knobs over direct YAML edits + +### 5. Route macOS targets to Blacksmith macOS runners + +STATUS: completed + +Objective: + +- build macOS release artifacts on Blacksmith using the requested runner class + +Work: + +- configure `dist` custom runners for the macOS targets so generated release jobs use: + - `blacksmith-12vcpu-macos-latest` +- keep any non-macOS global/planning jobs on the simplest supported runner unless there is a strong reason to move them too +- confirm the generated workflow still remains mostly `dist`-managed + +Expected result: + +- macOS artifact jobs run on Blacksmith instead of GitHub-hosted macOS runners + +Notes: + +- The main customization point should be `dist`’s GitHub custom runner configuration, not a hand-edited workflow matrix +- If `dist` requires a separate global runner setting for plan/host jobs, keep that explicit and minimal + +### 6. Validate the end-to-end release path + +STATUS: completed + +Objective: + +- confirm the new flow works before relying on it for colleague distribution + +Work: + +- run the local `just dist-plan` / `just dist-build` commands +- verify the generated artifacts include the `client-simulator` binary for both macOS targets +- open or inspect the generated release workflow +- if practical, test one dry-run or pre-release tag in GitHub Actions +- verify the binary can find Chrome on a normal macOS install outside the Nix shell + +Expected result: + +- confidence that a tagged release will produce usable macOS artifacts + +### 7. Add Homebrew installer support + +STATUS: implemented in-repo, pending GitHub secret setup + +Objective: + +- let colleagues install `client-simulator` with Homebrew instead of manually downloading release archives + +Work: + +- enable the `homebrew` installer in `dist` +- publish to the dedicated tap `hypervideo/homebrew-tap` +- configure the required Homebrew metadata in `dist` so generated formulae point at the GitHub Release artifacts for this repo +- regenerate the `dist` release workflow so Homebrew publishing is managed by `dist` rather than a separate hand-written pipeline +- document the expected install flow for users, including: + - `brew tap ...` + - `brew install client-simulator` +- verify the generated Homebrew formula installs the macOS binary and that the installed binary still relies on the local Chrome/Chromium runtime as expected + +Current state: + +- `dist-workspace.toml` enables the `homebrew` installer, sets `tap = "hypervideo/homebrew-tap"`, and asks `dist` to run the `homebrew` publish job +- `.github/workflows/release.yml` is generated from `dist` and includes the `publish-homebrew-formula` job that pushes `Formula/*.rb` into the tap repo using `HOMEBREW_TAP_TOKEN` +- the public tap repository now exists at `https://github.com/hypervideo/homebrew-tap` +- local verification on 2026-04-20 succeeded: + - `cargo dist manifest --artifacts=all --output-format=json --no-local-paths --allow-dirty --tag=v0.1.0` reported `client-simulator.rb` plus the macOS release archives + - `cargo dist build --target aarch64-apple-darwin --artifacts=all --allow-dirty --tag=v0.1.0` generated a real arm64 archive and formula + - installing that generated formula from a throwaway local tap succeeded + - `client-simulator --help` ran successfully after the Homebrew install +- the installed Homebrew package contains the simulator binary and README only; it does not bundle Chrome/Chromium, so runtime browser discovery still depends on the local machine as intended + +Remaining GitHub setup: + +1. create a GitHub token with write access to `hypervideo/homebrew-tap` +2. add that token as the `HOMEBREW_TAP_TOKEN` secret on `hypervideo/browser-simulator` +3. push a stable version tag so the `Release` workflow publishes the formula into the tap + +Expected user install flow: + +- `brew tap hypervideo/tap` +- `brew install client-simulator` +- `brew upgrade client-simulator` + +Expected result: + +- tagged releases publish both plain GitHub Release archives and a Homebrew formula +- colleagues can install or upgrade with `brew` + +Notes: + +- Keep Homebrew support scoped to the existing macOS CLI distribution +- Do not broaden this into notarization, `.app` bundling, or extra installer formats +- Prefer `dist`'s native Homebrew support over maintaining a custom formula by hand +- Homebrew only tracks the latest published formula version; prereleases do not publish unless `publish-prereleases` is explicitly enabled + +## Suggested Order Of Execution + +1. Add `dist` config. +2. Add the `just` commands. +3. Fix macOS Chrome discovery. +4. Generate and review the release workflow. +5. Configure Blacksmith runners through `dist`. +6. Validate the local and CI release flow. +7. Add Homebrew installer support once the base release flow is stable. + +## Non-Goals For This Pass + +- building a signed `.app` bundle +- notarization +- DMG packaging +- automatic Chrome installation +- broad Linux/Windows release support + +Homebrew is a follow-up phase after the base GitHub Release artifact flow is stable. + +## Documentation References + +- dist introduction: https://axodotdev.github.io/cargo-dist/book/introduction.html +- dist install: https://axodotdev.github.io/cargo-dist/book/install.html +- dist simple workspace guide: https://axodotdev.github.io/cargo-dist/book/workspaces/simple-guide.html +- dist config reference, including `github-custom-runners`: https://axodotdev.github.io/cargo-dist/book/reference/config.html +- dist Homebrew installer docs, for later follow-up: https://axodotdev.github.io/cargo-dist/book/installers/homebrew.html +- Blacksmith quickstart and runner-tag mapping: https://docs.blacksmith.sh/introduction/quickstart + +## Practical Deliverables + +When this plan is implemented, the resulting diff should roughly contain: + +- `Cargo.toml` updates for `dist` +- `justfile` release commands +- a small macOS Chrome discovery change in `browser/src/participant/local/session.rs` +- a generated `.github/workflows/release.yml` +- any `dist`-managed config files generated by initialization + +If the Homebrew follow-up is implemented too, the resulting diff should also roughly contain: + +- `dist` installer config enabling Homebrew +- any Homebrew-specific `dist` metadata needed for the chosen tap +- regenerated `dist` release workflow changes required for Homebrew publishing +- short contributor or user-facing install docs showing the `brew tap` / `brew install` flow diff --git a/Cargo.toml b/Cargo.toml index afabef3..ffb0508 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,8 @@ members = [ version = "0.1.0" authors = ["hyper.video "] edition = "2021" -repository = "https://github.com/hypervideo/hyper.video" +homepage = "https://github.com/hypervideo/browser-simulator" +repository = "https://github.com/hypervideo/browser-simulator" [workspace.dependencies] better-panic = "0.3.0" @@ -74,6 +75,8 @@ name = "client-simulator" version.workspace = true authors.workspace = true edition.workspace = true +description = "A Rust TUI for simulating Chromium-backed browser participants against hyper.video sessions." +homepage.workspace = true repository.workspace = true [dependencies] @@ -105,3 +108,8 @@ development = [ [lints] workspace = true + +# The profile that 'dist' will build with +[profile.dist] +inherits = "release" +lto = "thin" diff --git a/README.md b/README.md index 6371ecb..ce3b99e 100644 --- a/README.md +++ b/README.md @@ -5,3 +5,21 @@ The active workspace is centered on: - **browser/**: participant automation and remote stub support - **config/**: CLI and YAML configuration - **tui/**: terminal UI components + +## Install + +Tagged macOS releases are published as Homebrew formulae. + +```sh +brew tap hypervideo/tap +brew install client-simulator +``` + +To upgrade later: + +```sh +brew upgrade client-simulator +``` + +The Homebrew package installs the simulator binary only. Chrome or Chromium must +still be installed locally on the Mac where you run it. diff --git a/browser/src/participant/local/session.rs b/browser/src/participant/local/session.rs index 7b12cfc..11cf37d 100644 --- a/browser/src/participant/local/session.rs +++ b/browser/src/participant/local/session.rs @@ -13,47 +13,50 @@ use crate::{ lite::ParticipantInnerLite, }, shared::{ - messages::{ - ParticipantLogMessage, - ParticipantMessage, - }, DriverTermination, ParticipantDriverSession, ParticipantLaunchSpec, ResolvedFrontendKind, + messages::{ + ParticipantLogMessage, + ParticipantMessage, + }, }, }, }; use chromiumoxide::{ + Browser, + Handler, + Page, browser, cdp::browser_protocol::target::{ CreateTargetParams, EventDetachedFromTarget, }, - Browser, - Handler, - Page, }; use client_simulator_config::{ + BrowserConfig, media::{ FakeMedia, FakeMediaFiles, }, - BrowserConfig, }; use eyre::{ - bail, Context as _, ContextCompat as _, Result, + bail, }; use futures::{ - future::BoxFuture, FutureExt as _, StreamExt as _, + future::BoxFuture, }; use std::{ - path::PathBuf, + path::{ + Path, + PathBuf, + }, sync::Arc, }; use tokio::{ @@ -297,18 +300,98 @@ impl LocalFrontendBuilder { } } +const CHROME_BINARY_NAMES: &[&str] = &["chromium", "google-chrome", "google-chrome-stable", "chrome"]; +#[cfg(any(test, target_os = "macos"))] +const MACOS_GOOGLE_CHROME_APP_BINARY: &str = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; +#[cfg(any(test, target_os = "macos"))] +const MACOS_CHROMIUM_APP_BINARY: &str = "/Applications/Chromium.app/Contents/MacOS/Chromium"; +#[cfg(any(test, target_os = "macos"))] +const MACOS_USER_GOOGLE_CHROME_APP_BINARY: &str = "Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; + fn get_binary() -> Result { - let chrome = ["chromium", "google-chrome", "google-chrome-stable", "chrome"] + let chrome = resolve_binary_with( + |name| which::which(name).ok(), + macos_app_bundle_candidates(), + is_executable_file, + ) + .ok_or_else(|| eyre::eyre!("failed to find chromium or google-chrome binary"))?; + debug!(?chrome, "chrome found at"); + Ok(chrome) +} + +fn resolve_binary_with( + path_lookup: Lookup, + fallback_candidates: Candidates, + is_executable: Exists, +) -> Option +where + Lookup: Fn(&str) -> Option, + Candidates: IntoIterator, + Exists: Fn(&Path) -> bool, +{ + CHROME_BINARY_NAMES .iter() .find_map(|name| { - which::which(name).ok().map(|path| { + path_lookup(name).map(|path| { debug!(?path, "found {} at", name); path }) }) - .ok_or_else(|| eyre::eyre!("failed to find chromium or google-chrome binary"))?; - debug!(?chrome, "chrome found at"); - Ok(chrome) + .or_else(|| { + fallback_candidates.into_iter().find(|path| { + let found = is_executable(path); + if found { + debug!(?path, "found chrome app-bundle executable"); + } + found + }) + }) +} + +#[cfg(target_os = "macos")] +fn macos_app_bundle_candidates() -> Vec { + build_macos_app_bundle_candidates(std::env::var_os("HOME").map(PathBuf::from)) +} + +#[cfg(not(target_os = "macos"))] +fn macos_app_bundle_candidates() -> Vec { + Vec::new() +} + +#[cfg(any(test, target_os = "macos"))] +fn build_macos_app_bundle_candidates(home_dir: Option) -> Vec { + let mut candidates = vec![ + PathBuf::from(MACOS_GOOGLE_CHROME_APP_BINARY), + PathBuf::from(MACOS_CHROMIUM_APP_BINARY), + ]; + + if let Some(home_dir) = home_dir { + candidates.push(home_dir.join(MACOS_USER_GOOGLE_CHROME_APP_BINARY)); + } + + candidates +} + +fn is_executable_file(path: &Path) -> bool { + let Ok(metadata) = std::fs::metadata(path) else { + return false; + }; + + if !metadata.is_file() { + return false; + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + + metadata.permissions().mode() & 0o111 != 0 + } + + #[cfg(not(unix))] + { + true + } } async fn create_browser(browser_config: &BrowserConfig) -> Result<(Browser, Handler)> { @@ -374,6 +457,62 @@ async fn create_browser(browser_config: &BrowserConfig) -> Result<(Browser, Hand .context("failed to launch browser") } +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + + #[test] + fn resolve_binary_prefers_path_lookup_before_fallbacks() { + let looked_up = RefCell::new(Vec::new()); + + let resolved = resolve_binary_with( + |name| { + looked_up.borrow_mut().push(name.to_string()); + (name == "google-chrome").then(|| PathBuf::from("/usr/local/bin/google-chrome")) + }, + vec![PathBuf::from(MACOS_GOOGLE_CHROME_APP_BINARY)], + |_| true, + ); + + assert_eq!(resolved, Some(PathBuf::from("/usr/local/bin/google-chrome"))); + assert_eq!( + looked_up.into_inner(), + vec!["chromium".to_string(), "google-chrome".to_string()] + ); + } + + #[test] + fn resolve_binary_returns_first_existing_fallback() { + let first = PathBuf::from(MACOS_GOOGLE_CHROME_APP_BINARY); + let second = PathBuf::from(MACOS_CHROMIUM_APP_BINARY); + + let resolved = resolve_binary_with( + |_| None, + vec![first.clone(), second.clone()], + |path| path == second.as_path(), + ); + + assert_eq!(resolved, Some(second)); + } + + #[test] + fn build_macos_candidates_adds_user_applications_chrome() { + let home_dir = PathBuf::from("/Users/tester"); + + let candidates = build_macos_app_bundle_candidates(Some(home_dir.clone())); + + assert_eq!( + candidates, + vec![ + PathBuf::from(MACOS_GOOGLE_CHROME_APP_BINARY), + PathBuf::from(MACOS_CHROMIUM_APP_BINARY), + home_dir.join(MACOS_USER_GOOGLE_CHROME_APP_BINARY), + ] + ); + } +} + fn drive_browser_events( name: &str, mut handler: Handler, diff --git a/dist-workspace.toml b/dist-workspace.toml new file mode 100644 index 0000000..4364451 --- /dev/null +++ b/dist-workspace.toml @@ -0,0 +1,23 @@ +[workspace] +members = ["cargo:."] + +# Config for 'dist' +[dist] +# The preferred dist version to use in CI (Cargo.toml SemVer syntax) +cargo-dist-version = "0.30.4" +# CI backends to support +ci = "github" +# The installers to generate for each app +installers = ["homebrew"] +# Target platforms to build apps for (Rust target-triple syntax) +targets = ["aarch64-apple-darwin", "x86_64-apple-darwin"] +# Where to host releases +hosting = "github" +# A GitHub repo to push Homebrew formulas to +tap = "hypervideo/homebrew-tap" +# Publish jobs to run in CI +publish-jobs = ["homebrew"] + +[dist.github-custom-runners] +aarch64-apple-darwin = "blacksmith-12vcpu-macos-latest" +x86_64-apple-darwin = "blacksmith-12vcpu-macos-latest" diff --git a/flake.nix b/flake.nix index 2af9510..63b33bc 100644 --- a/flake.nix +++ b/flake.nix @@ -33,6 +33,7 @@ rust-analyzer (rustfmt.override { asNightly = true; }) cargo-nextest + cargo-dist ] ++ lib.optionals pkgs.stdenv.isDarwin [ google-chrome ] ++ lib.optionals (!pkgs.stdenv.isDarwin) [ chromium ]; diff --git a/justfile b/justfile index 381296b..80cdf0a 100644 --- a/justfile +++ b/justfile @@ -30,6 +30,20 @@ fmt: # -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- +dist-init *args="": + dist init {{ args }} + +dist-generate *args="": + dist generate {{ args }} + +dist-plan *args="": + dist plan {{ args }} + +dist-build *args="": + dist build {{ args }} + +# -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- + fetch-cookie username="simulator-user" server-url="http://localhost:8081": cargo run -q -- cookie --url {{ server-url }} --user {{ username }}