From 4be2fd7b1fcf84e6fdeaee1f115a875609326f4f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 14 Nov 2026 23:08:34 -0800 Subject: [PATCH 1/8] chore(quest): claim quest/m0/packaging/macos.md Quest-Claim: 6d7417674061bbfb From 0009c5aee5ffd538bc6e53a4088aeb98b0ab9dc8 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 14 Nov 2026 23:23:18 -0800 Subject: [PATCH 2/8] wip: the macOS app bundle, its signing, and its launch check Co-Authored-By: Claude Opus 5.5 --- .github/workflows/macos.yml | 193 +++++++++++++++++++++++++++++ .github/workflows/nightly.yml | 8 +- .github/workflows/release.yml | 9 +- Cargo.lock | 42 ++++++- justfile | 6 +- packaging/macos/Info.plist | 40 ++++++ packaging/macos/bundle.sh | 30 +++++ packaging/macos/entitlements.plist | 12 ++ packaging/macos/sign.sh | 28 +++++ packaging/macos/smoke.sh | 84 +++++++++++++ rs/moq-studio/Cargo.toml | 3 + rs/moq-studio/build.rs | 60 +++++++-- rs/moq-studio/src/main.rs | 108 +++++++++++----- 13 files changed, 576 insertions(+), 47 deletions(-) create mode 100644 .github/workflows/macos.yml create mode 100644 packaging/macos/Info.plist create mode 100755 packaging/macos/bundle.sh create mode 100644 packaging/macos/entitlements.plist create mode 100755 packaging/macos/sign.sh create mode 100755 packaging/macos/smoke.sh diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml new file mode 100644 index 0000000..83a3f22 --- /dev/null +++ b/.github/workflows/macos.yml @@ -0,0 +1,193 @@ +name: macOS + +# Builds the macOS app bundle for each architecture, signs it ad hoc with the +# release's hardened runtime and entitlements, and checks it launches and +# lists devices. Pull requests that change the packaging run it; nightly runs +# it against main. release.yml calls it with `sign`, which signs the bundles as +# the Developer ID, notarizes them, checks them again, and ships what it packs. + +permissions: + contents: read + +on: + pull_request: + branches: [main] + paths: + - .github/workflows/macos.yml + - packaging/macos/** + - packaging/*.svg + # The bundle's icon. + - rs/moq-studio/build.rs + workflow_call: + inputs: + sign: + description: Sign and notarize the bundles, from the release environment. + type: boolean + default: false + workflow_dispatch: + +concurrency: + # A called run takes its caller's workflow name, keeping its group apart + # from the caller's own. + group: macos-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + bundle: + name: Bundle (${{ matrix.arch }}) + runs-on: ${{ matrix.runs-on }} + timeout-minutes: 120 + + strategy: + fail-fast: false + matrix: + include: + - arch: arm64 + runs-on: macos-15 + - arch: x86_64 + runs-on: macos-15-intel + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + submodules: true + + # The image's rustup installs the toolchain rust-toolchain.toml names, and + # Xcode's own linker and SDK keep the binary free of Nix store paths. + - name: Build + run: packaging/macos/bundle.sh "kixel.live Studio.app" + + - name: Sign ad hoc + run: packaging/macos/sign.sh "kixel.live Studio.app" - + + - name: Smoke test + run: packaging/macos/smoke.sh "kixel.live Studio.app" + + # ditto keeps the bundle's symlinks and signature intact, unlike zip. + - name: Pack + run: ditto -c -k --keepParent "kixel.live Studio.app" "moq-studio-macos-${{ matrix.arch }}.zip" + + - name: Upload + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: unsigned-macos-${{ matrix.arch }} + path: moq-studio-macos-${{ matrix.arch }}.zip + if-no-files-found: error + + sign: + name: Sign (${{ matrix.arch }}) + needs: [bundle] + if: inputs.sign + runs-on: ${{ matrix.runs-on }} + timeout-minutes: 60 + # The signing identity and the notary key, which only protected branches + # can deploy from. Nothing built runs until they are gone from the runner. + environment: release + + strategy: + fail-fast: false + matrix: + include: + - arch: arm64 + runs-on: macos-15 + - arch: x86_64 + runs-on: macos-15-intel + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + sparse-checkout: packaging/macos + sparse-checkout-cone-mode: false + + - name: Download + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 + with: + name: unsigned-macos-${{ matrix.arch }} + + - name: Unpack + run: | + ditto -x -k "moq-studio-macos-${{ matrix.arch }}.zip" . + rm "moq-studio-macos-${{ matrix.arch }}.zip" + + # A keychain of its own, holding the Developer ID certificate and the + # intermediate that chains it to Apple's root, which runner images may + # lack. + - name: Import the identity + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + run: | + keychain=$RUNNER_TEMP/signing.keychain-db + password=$(openssl rand -hex 32) + security create-keychain -p "$password" "$keychain" + security set-keychain-settings -lut 3600 "$keychain" + security unlock-keychain -p "$password" "$keychain" + base64 --decode <<<"$APPLE_CERTIFICATE" >"$RUNNER_TEMP/identity.p12" + security import "$RUNNER_TEMP/identity.p12" -k "$keychain" -P "$APPLE_CERTIFICATE_PASSWORD" -x -T /usr/bin/codesign + rm "$RUNNER_TEMP/identity.p12" + curl -fsSL -o "$RUNNER_TEMP/g2.cer" https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer + echo "f16cd3c54c7f83cea4bf1a3e6a0819c8aaa8e4a1528fd144715f350643d2df3a $RUNNER_TEMP/g2.cer" | shasum -a 256 -c + security import "$RUNNER_TEMP/g2.cer" -k "$keychain" + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$password" "$keychain" >/dev/null + # codesign finds the chain only through the search list. + read -ra keychains < <(security list-keychains -d user | tr -d '"') + security list-keychains -d user -s "$keychain" "${keychains[@]}" + identity=$(security find-identity -v -p codesigning "$keychain" | awk '/"Developer ID Application: .*\(D7D5SDDB5Z\)"/ { print $2; exit }') + [[ -n $identity ]] + echo "KEYCHAIN=$keychain" >>"$GITHUB_ENV" + echo "IDENTITY=$identity" >>"$GITHUB_ENV" + + - name: Sign + run: packaging/macos/sign.sh "kixel.live Studio.app" "$IDENTITY" "$KEYCHAIN" + + - name: Notarize + env: + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_API_PRIVATE_KEY: ${{ secrets.APPLE_API_PRIVATE_KEY }} + run: | + key=$RUNNER_TEMP/notary.p8 + printf '%s\n' "$APPLE_API_PRIVATE_KEY" >"$key" + notary=(--key "$key" --key-id "$APPLE_API_KEY" --issuer "$APPLE_API_ISSUER") + ditto -c -k --keepParent "kixel.live Studio.app" "$RUNNER_TEMP/notarize.zip" + result=$(xcrun notarytool submit "$RUNNER_TEMP/notarize.zip" "${notary[@]}" --wait --timeout 1h --output-format json) + echo "$result" + # The log lists every issue, warnings included, even when accepted. + xcrun notarytool log "$(jq -r .id <<<"$result")" "${notary[@]}" + [[ $(jq -r .status <<<"$result") == Accepted ]] + xcrun stapler staple "kixel.live Studio.app" + + - name: Remove the credentials + if: always() + run: | + if [[ -n ${KEYCHAIN:-} ]]; then + security delete-keychain "$KEYCHAIN" + fi + rm -f "$RUNNER_TEMP/notary.p8" "$RUNNER_TEMP/identity.p12" + + # What Gatekeeper decides for a download: a Developer ID signature with + # its notarization ticket stapled, so a first launch offline passes too. + - name: Verify + run: | + codesign --verify --strict --deep --verbose=2 "kixel.live Studio.app" + xcrun stapler validate "kixel.live Studio.app" + assessment=$(spctl --assess --type execute --verbose=2 "kixel.live Studio.app" 2>&1) + echo "$assessment" + [[ $assessment == *"source=Notarized Developer ID"* ]] + + - name: Smoke test + run: packaging/macos/smoke.sh "kixel.live Studio.app" + + - name: Pack + run: ditto -c -k --keepParent "kixel.live Studio.app" "moq-studio-macos-${{ matrix.arch }}.zip" + + - name: Upload + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: macos-${{ matrix.arch }} + path: moq-studio-macos-${{ matrix.arch }}.zip + if-no-files-found: error diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index bf8ac08..0d48c3c 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -6,8 +6,8 @@ name: Nightly # window's named views drawn there; the virtual camera's # v4l2loopback fallback, a module load no pull request job should make; # `just ci` on macOS and the studio's Windows build, whose capture backends -# Check's Linux runner never compiles; and the Flatpak, a release build of -# everything. +# Check's Linux runner never compiles; and the Flatpak and the macOS app, +# release builds of everything. permissions: contents: read @@ -96,6 +96,10 @@ jobs: name: Flatpak uses: ./.github/workflows/flatpak.yml + macos-app: + name: macOS app + uses: ./.github/workflows/macos.yml + windows: name: Windows build runs-on: windows-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6dbfdfd..0aa1434 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,9 +18,14 @@ jobs: flatpak: uses: ./.github/workflows/flatpak.yml + macos: + uses: ./.github/workflows/macos.yml + with: + sign: true + publish: name: Publish - needs: [flatpak] + needs: [flatpak, macos] runs-on: ubuntu-24.04 # The only job with a write token, and the environment admits protected # branches only. @@ -39,6 +44,8 @@ jobs: - name: Download uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: + # Not the macOS bundles before signing. + pattern: "{flatpak,macos}-*" path: dist merge-multiple: true diff --git a/Cargo.lock b/Cargo.lock index f72ff4f..b0c18be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1696,7 +1696,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" dependencies = [ "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] @@ -1953,6 +1953,27 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -4636,6 +4657,7 @@ dependencies = [ "anyhow", "ashpd 0.13.13", "bytes", + "dirs", "eframe", "egui", "egui_dock", @@ -5731,6 +5753,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "orbclient" version = "0.3.55" @@ -6561,6 +6589,16 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.21", +] + [[package]] name = "ref-cast" version = "1.0.27" @@ -8025,7 +8063,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.6", "windows-sys 0.61.2", diff --git a/justfile b/justfile index 56259b6..248d777 100644 --- a/justfile +++ b/justfile @@ -14,8 +14,8 @@ check: sh/check-ignore-recipes.sh quest check actionlint - shellcheck .claude/hooks/*.sh .codex/hooks/*.sh sh/*.sh packaging/flatpak/*.sh - shfmt -d .claude/hooks/*.sh .codex/hooks/*.sh sh/*.sh packaging/flatpak/*.sh + shellcheck .claude/hooks/*.sh .codex/hooks/*.sh sh/*.sh packaging/flatpak/*.sh packaging/macos/*.sh + shfmt -d .claude/hooks/*.sh .codex/hooks/*.sh sh/*.sh packaging/flatpak/*.sh packaging/macos/*.sh taplo format --check nixfmt --check flake.nix bun install --frozen-lockfile @@ -43,7 +43,7 @@ ci: # Apply formatters without changing program behavior. fix: - shfmt -w .claude/hooks/*.sh .codex/hooks/*.sh sh/*.sh packaging/flatpak/*.sh + shfmt -w .claude/hooks/*.sh .codex/hooks/*.sh sh/*.sh packaging/flatpak/*.sh packaging/macos/*.sh taplo format nixfmt flake.nix bun biome check --write . diff --git a/packaging/macos/Info.plist b/packaging/macos/Info.plist new file mode 100644 index 0000000..a1ca78a --- /dev/null +++ b/packaging/macos/Info.plist @@ -0,0 +1,40 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + kixel.live Studio + CFBundleExecutable + moq-studio + CFBundleIconFile + live.kixel.studio + CFBundleIdentifier + live.kixel.studio + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + kixel.live Studio + CFBundlePackageType + APPL + + CFBundleShortVersionString + 0.0.0 + CFBundleVersion + 0.0.0 + LSApplicationCategoryType + public.app-category.video + + LSMinimumSystemVersion + 13.0 + NSCameraUsageDescription + Cameras you add appear in your scenes. + NSHighResolutionCapable + + NSMicrophoneUsageDescription + Microphones you add are mixed into your stream and recordings. + NSScreenCaptureUsageDescription + Displays, windows, and system audio you add appear in your scenes and are mixed into your stream. + + diff --git a/packaging/macos/bundle.sh b/packaging/macos/bundle.sh new file mode 100755 index 0000000..f466c5f --- /dev/null +++ b/packaging/macos/bundle.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Build moq-studio with the `release` feature for this Mac's architecture and +# lay it out as an app bundle at the path given, unsigned; sign.sh signs it. +# Needs Xcode's command line tools, cargo, and jq. +set -euo pipefail + +app=$1 +packaging=$(cd "$(dirname "$0")" && pwd) +cd "$packaging/../.." + +# Info.plist's LSMinimumSystemVersion, so the linker and OpenH264's C build +# target the oldest macOS the bundle claims. +export MACOSX_DEPLOYMENT_TARGET=13.0 +json=$(cargo build --locked --release -p moq-studio --features release --message-format=json-render-diagnostics) +# The binary, and the build script's output, which holds the icon it drew. +executable=$(jq -r 'select(.reason == "compiler-artifact" and .target.name == "moq-studio" and .executable != null) | .executable' <<<"$json") +out=$(jq -r 'select(.reason == "build-script-executed" and (.package_id | test("/rs/moq-studio#"))) | .out_dir' <<<"$json") +version=$(cargo metadata --locked --no-deps --format-version 1 | jq -r '.packages[] | select(.name == "moq-studio") | .version') +[[ -x $executable && -f $out/live.kixel.studio.icns && -n $version ]] + +# Nested code, such as helpers, frameworks, and system extensions, goes under +# Contents in the directory macOS expects it in, and sign.sh signs it first. +rm -rf "$app" +mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" +cp "$packaging/Info.plist" "$app/Contents/Info.plist" +plutil -replace CFBundleShortVersionString -string "$version" "$app/Contents/Info.plist" +plutil -replace CFBundleVersion -string "$version" "$app/Contents/Info.plist" +plutil -lint "$app/Contents/Info.plist" +cp "$executable" "$app/Contents/MacOS/moq-studio" +cp "$out/live.kixel.studio.icns" "$app/Contents/Resources/live.kixel.studio.icns" diff --git a/packaging/macos/entitlements.plist b/packaging/macos/entitlements.plist new file mode 100644 index 0000000..de6e736 --- /dev/null +++ b/packaging/macos/entitlements.plist @@ -0,0 +1,12 @@ + + + + + + com.apple.security.device.audio-input + + com.apple.security.device.camera + + + diff --git a/packaging/macos/sign.sh b/packaging/macos/sign.sh new file mode 100755 index 0000000..0f2a34b --- /dev/null +++ b/packaging/macos/sign.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Sign an app bundle bundle.sh laid out, with the hardened runtime and the +# entitlements beside this script, as the identity given: a Developer ID +# Application certificate's SHA-1 for a release, or `-` to sign ad hoc, which +# runs the same runtime and entitlements without the release's secrets. +# Optional: the keychain that holds the identity. +set -euo pipefail + +app=$1 +identity=$2 +keychain=${3:-} +packaging=$(cd "$(dirname "$0")" && pwd) + +flags=(--force --options runtime --sign "$identity") +if [[ $identity == - ]]; then + flags+=(--timestamp=none) +else + # Notarization requires a secure timestamp. + flags+=(--timestamp) +fi +if [[ -n $keychain ]]; then + flags+=(--keychain "$keychain") +fi + +# Nested code is signed before the bundle that holds it, innermost first, each +# with its own entitlements; the bundle's signature then seals theirs. +codesign "${flags[@]}" --entitlements "$packaging/entitlements.plist" "$app" +codesign --verify --strict --deep --verbose=2 "$app" diff --git a/packaging/macos/smoke.sh b/packaging/macos/smoke.sh new file mode 100755 index 0000000..9d18f70 --- /dev/null +++ b/packaging/macos/smoke.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Launch the app bundle given as Finder does, through Launch Services with no +# arguments, and check that it lists cameras and audio, opens its window, and +# quits when asked without crashing. A fresh home directory makes it open a +# new scene collection, so guided setup lists the devices. +set -euo pipefail + +app=$(cd "$1" && pwd) +executable=$app/Contents/MacOS/moq-studio +home=$(mktemp -d) +log=$home/studio.log +touch "$log" +pid= + +cleanup() { + if [[ -n $pid ]]; then + kill "$pid" 2>/dev/null || true + fi + echo "--- the studio's log" + cat "$log" + rm -rf "$home" +} +trap cleanup EXIT + +fail() { + echo "$1" >&2 + exit 1 +} + +# Whether the studio has a window on screen. Listing windows by owner needs +# no permission, unlike reading their titles. +window() { + osascript -l JavaScript - "$pid" <<'JS' +ObjC.import("CoreGraphics"); +function run(argv) { + const windows = ObjC.deepUnwrap(ObjC.castRefToObject( + $.CGWindowListCopyWindowInfo($.kCGWindowListOptionOnScreenOnly, $.kCGNullWindowID))); + return windows.some((w) => w.kCGWindowOwnerPID === Number(argv[0]) && w.kCGWindowLayer === 0); +} +JS +} + +# A crash leaves a report here, not in the log. +reports=~/Library/Logs/DiagnosticReports +marker=$home/started +touch "$marker" + +open -n "$app" --stdout "$log" --stderr "$log" --env HOME="$home" --env NO_COLOR=1 +deadline=$((SECONDS + 30)) +until pid=$(pgrep -nfx "$executable"); do + ((SECONDS < deadline)) || fail "the studio never started" + sleep 0.1 +done + +# Guided setup lists the devices before its window opens. +deadline=$((SECONDS + 300)) +until grep -q 'listed devices' "$log"; do + kill -0 "$pid" 2>/dev/null || fail "the studio exited before listing its devices" + ((SECONDS < deadline)) || fail "the studio never listed its devices" + sleep 0.1 +done +listing=$(grep 'listed devices' "$log") +for kind in cameras audio; do + [[ $listing == *"$kind=Ok("* ]] || fail "the studio could not list $kind" +done + +deadline=$((SECONDS + 120)) +until [[ $(window) == true ]]; do + kill -0 "$pid" 2>/dev/null || fail "the studio exited before its window opened" + ((SECONDS < deadline)) || fail "the studio's window never opened" + sleep 0.5 +done + +# Quit as the menu, the Dock, and logging out do. +osascript -e 'tell application id "live.kixel.studio" to quit' +deadline=$((SECONDS + 60)) +while kill -0 "$pid" 2>/dev/null; do + ((SECONDS < deadline)) || fail "the studio did not quit when asked" + sleep 0.1 +done +pid= +crashed=$(find "$reports" -name 'moq-studio*' -newer "$marker" 2>/dev/null) +[[ -z $crashed ]] || fail "the studio crashed: $crashed" +! grep -q 'panicked' "$log" || fail "the studio panicked" diff --git a/rs/moq-studio/Cargo.toml b/rs/moq-studio/Cargo.toml index 3f13ba5..8570f24 100644 --- a/rs/moq-studio/Cargo.toml +++ b/rs/moq-studio/Cargo.toml @@ -45,6 +45,9 @@ cef = ["moq-scene/cef"] [dependencies] anyhow = { workspace = true, features = ["backtrace"] } bytes = { workspace = true } +# The user's videos folder, where recordings go when no scene file is named: +# Linux keeps it in user-dirs.dirs, and Windows lets it be moved. +dirs = "7" # The window, its event loop, and the egui integration. `wgpu` is the renderer # the compositor shares a device with; no glow, no system font discovery. # `persistence` keeps per-user picks, such as the output Listen plays on, out diff --git a/rs/moq-studio/build.rs b/rs/moq-studio/build.rs index 0b29f3a..dff7ee9 100644 --- a/rs/moq-studio/build.rs +++ b/rs/moq-studio/build.rs @@ -1,5 +1,6 @@ //! Rasterizes the app icon, `packaging/live.kixel.studio.svg`, into the RGBA -//! pixels the window's icon takes, so the SVG stays the only drawn copy. +//! pixels the window's icon takes, and for a macOS target into the `.icns` +//! the app bundle names, so the SVG stays the only drawn copy. //! //! With `cef` on Linux, the binary loads `libcef.so` when it starts: from //! beside itself, where a package installs the CEF runtime, or else from @@ -32,16 +33,10 @@ fn icon() { let svg = std::fs::read(&source).unwrap_or_else(|err| panic!("cannot read {}: {err}", source.display())); let tree = resvg::usvg::Tree::from_data(&svg, &resvg::usvg::Options::default()).expect("the icon is not valid SVG"); - let mut pixmap = resvg::tiny_skia::Pixmap::new(ICON_SIZE, ICON_SIZE).unwrap(); - let scale = ICON_SIZE as f32 / tree.size().width().max(tree.size().height()); - resvg::render( - &tree, - resvg::tiny_skia::Transform::from_scale(scale, scale), - &mut pixmap.as_mut(), - ); + let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap()); // tiny-skia keeps premultiplied alpha; the window takes it straight. - let rgba: Vec = pixmap + let rgba: Vec = render(&tree, ICON_SIZE) .pixels() .iter() .flat_map(|pixel| { @@ -49,8 +44,51 @@ fn icon() { [color.red(), color.green(), color.blue(), color.alpha()] }) .collect(); - - let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap()); std::fs::write(out.join("icon.rgba"), rgba).expect("cannot write the icon"); println!("cargo::rustc-env=ICON_SIZE={ICON_SIZE}"); + + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") { + std::fs::write(out.join("live.kixel.studio.icns"), icns(&tree)).expect("cannot write the bundle icon"); + } +} + +/// The icon drawn into a square `size` pixels on an edge. +fn render(tree: &resvg::usvg::Tree, size: u32) -> resvg::tiny_skia::Pixmap { + let mut pixmap = resvg::tiny_skia::Pixmap::new(size, size).unwrap(); + let scale = size as f32 / tree.size().width().max(tree.size().height()); + resvg::render( + tree, + resvg::tiny_skia::Transform::from_scale(scale, scale), + &mut pixmap.as_mut(), + ); + pixmap +} + +/// The `.icns` Finder and the Dock show: every size macOS asks for, 16 to 512 +/// points at 1x and 2x, each a PNG under its type code. +fn icns(tree: &resvg::usvg::Tree) -> Vec { + const ENTRIES: [(&[u8; 4], u32); 10] = [ + (b"icp4", 16), + (b"ic11", 32), + (b"icp5", 32), + (b"ic12", 64), + (b"ic07", 128), + (b"ic13", 256), + (b"ic08", 256), + (b"ic14", 512), + (b"ic09", 512), + (b"ic10", 1024), + ]; + + // Every length is big-endian and counts its own 8-byte header. + let mut file = b"icns\0\0\0\0".to_vec(); + for (kind, size) in ENTRIES { + let png = render(tree, size).encode_png().expect("cannot encode the icon"); + file.extend_from_slice(kind); + file.extend_from_slice(&(png.len() as u32 + 8).to_be_bytes()); + file.extend_from_slice(&png); + } + let len = (file.len() as u32).to_be_bytes(); + file[4..8].copy_from_slice(&len); + file } diff --git a/rs/moq-studio/src/main.rs b/rs/moq-studio/src/main.rs index 889ebfc..7cf3c5f 100644 --- a/rs/moq-studio/src/main.rs +++ b/rs/moq-studio/src/main.rs @@ -4,7 +4,7 @@ //! The scenes, sources, and compositor live in `moq-scene`; this binary is the //! window around them and the program clock that publishes the result. -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::process::ExitCode; use std::sync::{Arc, OnceLock}; @@ -42,7 +42,8 @@ mod watch; #[usage(completion, settings, unknown_env(prefix = "MOQ_"))] pub struct Config { /// The scene collection to open, saved as it changes. If it does not exist, - /// guided setup proposes one and creates it. + /// guided setup proposes one and creates it. Defaults to `scenes.toml` in + /// the studio's data directory, which a launch from the desktop opens. #[usage(value_hint = usage_rs::ValueHint::FilePath, extensions("toml"))] pub scenes: Option, @@ -65,7 +66,8 @@ pub struct Config { #[usage(long)] pub on_demand: bool, - /// The directory recordings are saved in. Defaults to the scene file's. + /// The directory recordings are saved in. Defaults to a named scene file's, + /// or else to the user's videos folder. #[usage(long, value_hint = usage_rs::ValueHint::DirPath)] pub recordings: Option, @@ -96,14 +98,11 @@ pub struct Config { } impl Config { - /// The scene file to open, refusing `--watch` beside the studio's own flags. - fn scenes(&self) -> anyhow::Result> { + /// The scene file to open and the directory recordings go to, or `None` to + /// `--watch` a broadcast, refusing the studio's own flags beside it. + fn files(&self) -> anyhow::Result> { if self.watch.is_none() { - let scenes = self - .scenes - .as_deref() - .context("name a scene file to open, or --watch a broadcast")?; - return Ok(Some(scenes)); + return Files::new(self.scenes.clone(), self.recordings.clone()).map(Some); } anyhow::ensure!( self.scenes.is_none(), @@ -173,6 +172,46 @@ impl Config { } } +/// Where the studio keeps its own files: the window's settings, and the scene +/// collection when none is named. +fn data_dir() -> Option { + eframe::storage_dir("moq-studio") +} + +/// The files the studio opens and writes. +#[derive(Debug, PartialEq)] +struct Files { + scenes: PathBuf, + recordings: PathBuf, +} + +impl Files { + /// A named scene file keeps its recordings beside it. With none named, as + /// from the desktop, which passes no arguments, the collection is the one + /// in the data directory, where nobody looks for a recording, so they go to + /// the videos folder instead. + fn new(scenes: Option, recordings: Option) -> anyhow::Result { + if let Some(scenes) = scenes { + let recordings = recordings.unwrap_or_else(|| match scenes.parent() { + Some(dir) if !dir.as_os_str().is_empty() => dir.to_path_buf(), + _ => PathBuf::from("."), + }); + return Ok(Self { scenes, recordings }); + } + let data = data_dir().context("this system has no data directory to keep scenes in; name a scene file")?; + let recordings = match recordings { + Some(dir) => dir, + None => dirs::video_dir() + .or_else(std::env::home_dir) + .context("this system has no videos folder; pass --recordings")?, + }; + Ok(Self { + scenes: data.join("scenes.toml"), + recordings, + }) + } +} + fn main() -> anyhow::Result { // A browser source's helper, and CEF's subprocesses, are this executable // started again. @@ -183,26 +222,17 @@ fn main() -> anyhow::Result { let config = Config::parse(); config.log.init()?; config.check_deprecated()?; - let Some(scenes) = config.scenes()? else { - let url = config.watch.clone().expect("checked by scenes"); + let Some(Files { scenes, recordings }) = config.files()? else { + let url = config.watch.clone().expect("checked by files"); watch::run(config.client.clone(), config.quic.clone(), url)?; return Ok(ExitCode::SUCCESS); }; - let scenes = scenes.to_path_buf(); let runtime = tokio::runtime::Runtime::new()?; // The QUIC client binds its socket on the current runtime. let _runtime = runtime.enter(); let relays = config.relays()?; let gpu = pollster::block_on(engine::Gpu::new(wgpu::Backends::all()))?; - let recordings = match &config.recordings { - Some(dir) => dir.clone(), - None => scenes - .parent() - .filter(|dir| !dir.as_os_str().is_empty()) - .unwrap_or(Path::new(".")) - .to_path_buf(), - }; let room = config.guests.load(config.client.url.as_ref())?.map(Arc::new); if let Some(room) = &room { let renewal = guests::Renewal::connect(room.clone(), &config.client, config.quic.clone())?; @@ -219,6 +249,11 @@ fn main() -> anyhow::Result { let exists = scenes .try_exists() .with_context(|| format!("reading {}", scenes.display()))?; + if !exists && let Some(dir) = scenes.parent().filter(|dir| !dir.as_os_str().is_empty()) { + // Setup creates the file, but not the data directory a first launch + // from the desktop names. + std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?; + } let app = match exists { true => { let (autosave, loaded) = autosave::Autosave::open(scenes.clone())?; @@ -429,7 +464,7 @@ fn run( }, // eframe would otherwise name the settings directory after the app id; // keep it beside the browser profile, under the app's name. - persistence_path: eframe::storage_dir("moq-studio").map(|dir| dir.join("app.ron")), + persistence_path: data_dir().map(|dir| dir.join("app.ron")), ..Default::default() }; let event_loop = EventLoop::::with_user_event() @@ -469,6 +504,7 @@ fn run( #[cfg(test)] mod tests { + use std::path::Path; use std::time::Duration; use super::*; @@ -482,7 +518,9 @@ mod tests { ]) .expect("a scene file and a relay should parse"); config.check_deprecated().unwrap(); - assert_eq!(config.scenes().unwrap(), Some(Path::new("show.toml"))); + let files = config.files().unwrap().unwrap(); + assert_eq!(files.scenes, Path::new("show.toml")); + assert_eq!(files.recordings, Path::new("."), "recordings go beside the scene file"); assert_eq!(config.broadcast, "studio"); assert!(!config.on_demand, "the program encodes whatever the demand by default"); assert!(config.guests.room.is_none(), "guests are opt-in"); @@ -519,8 +557,7 @@ mod tests { } } - /// `--watch` opens no scene file and names its own relay, and the studio - /// needs a scene file. + /// `--watch` opens no scene file and names its own relay. #[test] fn watch_takes_no_scene_file_or_relay() { let parse = |args: &[&str]| { @@ -528,14 +565,29 @@ mod tests { Config::parse_from(&args).unwrap() }; let watch = parse(&["--watch", "https://relay.example/anon/studio"]); - assert_eq!(watch.scenes().unwrap(), None); + assert_eq!(watch.files().unwrap(), None); for refused in [ &["show.toml", "--watch", "https://a/b"][..], &["--watch", "https://a/b", "--connect", "https://a/"], &["--watch", "https://a/b", "--room", "guests"], - &[], ] { - assert!(parse(refused).scenes().is_err(), "{refused:?} was accepted"); + assert!(parse(refused).files().is_err(), "{refused:?} was accepted"); } } + + /// With no scene file named, as from the desktop, the studio opens the one + /// in its data directory and records to the videos folder, unless told + /// where. + #[test] + fn defaults_to_the_collection_in_the_data_directory() { + let files = Files::new(None, None).unwrap(); + assert!(files.scenes.is_absolute()); + assert!(files.recordings.is_absolute()); + assert_ne!(Some(files.recordings.as_path()), files.scenes.parent()); + + let files = Files::new(None, Some("clips".into())).unwrap(); + assert_eq!(files.recordings, Path::new("clips")); + let files = Files::new(Some("shows/main.toml".into()), None).unwrap(); + assert_eq!(files.recordings, Path::new("shows")); + } } From 36015e5ae4fa27808dcf55f59a3589998b7cde93 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 14 Nov 2026 23:48:10 -0800 Subject: [PATCH 3/8] feat: Cmd-Q ends the studio, and the bundle's files are world-readable Co-Authored-By: Claude Opus 5.5 --- .github/workflows/macos.yml | 7 +++- README.md | 25 ++++++++++---- packaging/macos/bundle.sh | 3 ++ quest/m0/browser-source/README.md | 2 +- quest/m0/browser-source/macos.md | 10 +++--- quest/m0/packaging/README.md | 2 +- quest/m0/packaging/macos.md | 51 --------------------------- quest/m0/vcam/macos.md | 18 +++++++--- rs/moq-studio/src/main.rs | 57 ++++++++++++++++++------------- 9 files changed, 80 insertions(+), 95 deletions(-) delete mode 100644 quest/m0/packaging/macos.md diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 83a3f22..751b0fc 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -83,7 +83,12 @@ jobs: runs-on: ${{ matrix.runs-on }} timeout-minutes: 60 # The signing identity and the notary key, which only protected branches - # can deploy from. Nothing built runs until they are gone from the runner. + # can deploy from, for team D7D5SDDB5Z: APPLE_CERTIFICATE, the Developer ID + # Application .p12 (base64, legacy encryption so `security import` reads + # it) and APPLE_CERTIFICATE_PASSWORD; APPLE_API_KEY, APPLE_API_ISSUER, and + # APPLE_API_PRIVATE_KEY, the App Store Connect key ID, issuer ID, and .p8 + # text. quest/m0/signing-renew.md renews them. Nothing built runs until + # they are gone from the runner. environment: release strategy: diff --git a/README.md b/README.md index c824dad..7e31d9a 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,16 @@ cameras through the camera portal, which asks once, and files under your home directory. The virtual camera is its PipeWire node only, since the sandbox has no `/dev/video*`. +On macOS 13 or later, each release ships the app for Apple silicon +(`moq-studio-macos-arm64.zip`) and Intel (`moq-studio-macos-x86_64.zip`), +signed and notarized. Unzip it and drag **kixel.live Studio** to +Applications. Opened from Finder or the Dock, it opens the scene collection +in `~/Library/Application Support/moq-studio` and saves recordings to your +Movies folder. macOS asks you to grant it the camera, microphone, and Screen & +System Audio Recording permissions, the last in System Settings > Privacy & +Security. Run it from a terminal with the flags below as +`"/Applications/kixel.live Studio.app/Contents/MacOS/moq-studio"`. + Elsewhere, build it from source. The moq crates come from the `moq` submodule: @@ -31,9 +41,10 @@ cargo run --release -p moq-studio --features capture,openh264 -- \ - `show.toml` is the scene collection. If it does not exist yet, [guided setup](#first-launch) proposes one and creates it. Changes are saved - to it a second after you stop editing, with no save step. + to it a second after you stop editing, with no save step. Without one, the + studio opens `scenes.toml` in its data directory, as the macOS app does. - `--recordings ` is where recordings are saved, by default beside - `show.toml`. + `show.toml`, or in your videos folder when no scene file is named. - Without `--connect`, the studio composes, previews, and records but publishes nothing, and broadcast sources stay offline. - `--failover ` publishes the same broadcast to a second relay under the @@ -62,11 +73,11 @@ cargo run --release -p moq-studio --features capture,openh264 -- \ loads `libcef.so` from beside itself or from that `CEF_PATH`. - On Linux, display and window sources capture X11 and XWayland only; a Wayland screen or window cannot be picked yet. -- On macOS, the studio is not an app bundle yet, so the camera, microphone, - and Screen & System Audio Recording permissions are asked of, and granted - to, the app that launched it, such as your terminal. Display, window, and - system-audio sources need the last, granted in System Settings > Privacy & - Security. +- On macOS, a build from source is not an app bundle, so the camera, + microphone, and Screen & System Audio Recording permissions are asked of, + and granted to, the app that launched it, such as your terminal. Display, + window, and system-audio sources need the last, granted in System Settings > + Privacy & Security. - The `--connect-*`, `--quic-*`, and `--log-level` flags are [moq-cli's](https://doc.moq.dev/bin/cli); see `moq-studio --help`. - `moq-studio --watch ` opens no scene file and plays a studio broadcast diff --git a/packaging/macos/bundle.sh b/packaging/macos/bundle.sh index f466c5f..a43badc 100755 --- a/packaging/macos/bundle.sh +++ b/packaging/macos/bundle.sh @@ -21,8 +21,11 @@ version=$(cargo metadata --locked --no-deps --format-version 1 | jq -r '.package # Nested code, such as helpers, frameworks, and system extensions, goes under # Contents in the directory macOS expects it in, and sign.sh signs it first. rm -rf "$app" +# Readable by every user, whatever this checkout's own modes. +umask 022 mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" cp "$packaging/Info.plist" "$app/Contents/Info.plist" +chmod 644 "$app/Contents/Info.plist" plutil -replace CFBundleShortVersionString -string "$version" "$app/Contents/Info.plist" plutil -replace CFBundleVersion -string "$version" "$app/Contents/Info.plist" plutil -lint "$app/Contents/Info.plist" diff --git a/quest/m0/browser-source/README.md b/quest/m0/browser-source/README.md index 2f7074a..d239e12 100644 --- a/quest/m0/browser-source/README.md +++ b/quest/m0/browser-source/README.md @@ -38,4 +38,4 @@ processes. Each platform's quest says what it needs on top. Decided 2026-11-09 (maintainer, /quest-audit): ✅ one quest per platform (rejected: Related links only; leaving one quest). One quest held macOS behind the Windows host, and shaped the macOS app bundle with no link to the -[quest that owns it](/quest/m0/packaging/macos.md). +quest that owned it, which landed as `packaging/macos/`. diff --git a/quest/m0/browser-source/macos.md b/quest/m0/browser-source/macos.md index da51998..1c189f6 100644 --- a/quest/m0/browser-source/macos.md +++ b/quest/m0/browser-source/macos.md @@ -12,13 +12,11 @@ On top of what Linux landed: - The helper is CEF's browser process, so it needs an app bundle with CEF's helper apps (`cef-rs`'s `bundle-cef-app`) and its own NSApplication. They - go into the bundle [the macOS app](/quest/m0/packaging/macos.md) owns, and - through its signing, rather than a second bundle. + go into the studio's app bundle rather than a second one: + `packaging/macos/bundle.sh` lays them out under `Contents/Frameworks`, and + `packaging/macos/sign.sh` signs each, with the entitlements it needs, before + the app that holds them. - An IOSurface crosses processes only as a Mach port (`IOSurfaceCreateMachPort`), so the channel needs a Mach port rendezvous. - Exiting with the studio stays as on Linux: the helper kills the process group it leads. - -## Required - -- [macOS app](/quest/m0/packaging/macos.md) - the app bundle CEF's helper apps join diff --git a/quest/m0/packaging/README.md b/quest/m0/packaging/README.md index e01faa4..164eff7 100644 --- a/quest/m0/packaging/README.md +++ b/quest/m0/packaging/README.md @@ -21,7 +21,7 @@ Decided 2026-11-09 (maintainer, /quest-audit): ✅ one quest per platform leaving one packaging quest). One quest held all three artifacts behind the Apple identity and the Windows host, though the Flatpak needs neither, and three quests shaped the macOS app bundle with no link between them. The -[macOS app](/quest/m0/packaging/macos.md) owns that bundle. +macOS app owns that bundle, which `packaging/macos/` now holds. A release is cut by running `.github/workflows/release.yml` on main, which publishes from the `release` environment. Each artifact's workflow is called diff --git a/quest/m0/packaging/macos.md b/quest/m0/packaging/macos.md deleted file mode 100644 index 907e400..0000000 --- a/quest/m0/packaging/macos.md +++ /dev/null @@ -1,51 +0,0 @@ -# [M] macOS app - -## Goal - -Every release ships a signed and notarized macOS app of `moq-studio`, built -in CI with the `release` feature and verified to launch and list devices on a -Mac. - -This quest owns the app bundle: its layout, its `Info.plist` and -entitlements, and the step that signs and notarizes it. The -[camera extension](/quest/m0/vcam/macos.md) and -[browser sources](/quest/m0/browser-source/macos.md) add to that bundle -rather than build one of their own. - -## Plan - -The feature policy is the [epic](/quest/m0/packaging/README.md)'s. - -Today the studio is not an app bundle, so macOS's capture prompts and grants -belong to whatever launched it. A bundle is asked itself, so it carries the -camera, microphone, and screen recording usage strings. - -Lay the bundle out and order the signing so nested code can be added: a -system extension and CEF's helper apps are each signed before the app that -holds them. - -The bundle's icon is an `.icns` that the build generates from -`packaging/live.kixel.studio.svg`, the only drawn copy, and `Info.plist` -names; never check the `.icns` in. - -The signing material lives in the `release` Actions environment, which only -protected branches can deploy from, for team `D7D5SDDB5Z` and bundle ID -`live.kixel.studio`: - -- `APPLE_CERTIFICATE`: the Developer ID Application `.p12`, base64, legacy - encryption so `security import` reads it. -- `APPLE_CERTIFICATE_PASSWORD`: its password. -- `APPLE_API_KEY`, `APPLE_API_ISSUER`, `APPLE_API_PRIVATE_KEY`: the App Store - Connect key ID, issuer ID, and `.p8` text, for `notarytool --key`. -- `APPLE_PROVISIONING_PROFILE`: the Developer ID profile for `live.kixel.studio` - with the System Extension capability, base64, embedded as - `Contents/embedded.provisionprofile`. - -The certificate (`6WWG279G9M`, G2) expires 2027-12-11, and the profile with it; -see [renewal](/quest/m0/signing-renew.md). - -A Mac camera renders only once AVFoundation asks for NV12 instead of `2vuy`, -which [moq-dev/moq#8251](https://github.com/moq-dev/moq/pull/8251) did; the -studio's pin includes it. Check by hand on a Mac that a camera shows its picture in -program, since the launch check only lists devices and hosted runners have no -camera. diff --git a/quest/m0/vcam/macos.md b/quest/m0/vcam/macos.md index 3806d1e..f3b01cf 100644 --- a/quest/m0/vcam/macos.md +++ b/quest/m0/vcam/macos.md @@ -9,9 +9,17 @@ update installs the new extension without a reboot. ## Plan Decided 2026-11-09 (maintainer, /quest-audit): ✅ the extension joins the -bundle the [macOS app](/quest/m0/packaging/macos.md) owns and goes through -its signing (rejected: a Related link only; leaving the quests unlinked). +studio's app bundle and goes through its signing (rejected: a Related link +only; leaving the quests unlinked). -## Required - -- [macOS app](/quest/m0/packaging/macos.md) - the app bundle that carries and signs the extension +`packaging/macos/bundle.sh` lays the bundle out; the extension goes in +`Contents/Library/SystemExtensions`. `packaging/macos/sign.sh` signs it, with +its own entitlements, before the app. The app then needs the +`com.apple.developer.system-extension.install` entitlement, which only a +provisioning profile grants: embed the `release` environment's +`APPLE_PROVISIONING_PROFILE` (the Developer ID profile for +`live.kixel.studio` with the System Extension capability, base64) as +`Contents/embedded.provisionprofile`, and sign with the +`com.apple.application-identifier` and `com.apple.developer.team-identifier` +entitlements it names. A pull request's ad hoc signature cannot carry these, +so its launch check needs them left out. diff --git a/rs/moq-studio/src/main.rs b/rs/moq-studio/src/main.rs index 7cf3c5f..ba71de2 100644 --- a/rs/moq-studio/src/main.rs +++ b/rs/moq-studio/src/main.rs @@ -268,27 +268,23 @@ fn main() -> anyhow::Result { )?)), }; - let started = Arc::new(OnceLock::new()); - let ran = run(app, gpu, runtime.handle().clone(), started.clone()); - let Some(engine) = started.get() else { - return ran.map(|()| ExitCode::SUCCESS); - }; + run(app, gpu, runtime.handle().clone())?; + Ok(ExitCode::SUCCESS) +} + +/// End the studio on the way out: finish a recording, save the edits since +/// the last autosave, and close each relay session. +fn shutdown(engine: &engine::Engine, runtime: &tokio::runtime::Handle) -> anyhow::Result<()> { // A recording still running reads the program until stopping ends its // tracks, so it holds what the encoders publish on the way out, and then // finishes as a regular MP4. engine.recorder.close(); - // The edits since the last autosave, so quitting loses none. let saved = engine.save(); engine.stop(); // Before the runtime goes, which would drop each relay session with the // tail of the broadcast still on its way. runtime.block_on(async { tokio::join!(engine.close(), engine.recorder.done()) }); - if let (Err(err), Err(_)) = (&saved, &ran) { - tracing::error!(error = format!("{err:#}"), "cannot save on quit"); - } - ran?; - saved?; - Ok(ExitCode::SUCCESS) + saved } /// What the window shows. @@ -305,7 +301,7 @@ struct Window { app: App, render: eframe::egui_wgpu::RenderState, runtime: tokio::runtime::Handle, - /// The engine once the studio is built, for the save on quit. + /// The engine once the studio is built, for ending it on quit. started: Arc>>, keyboard: hotkeys::Keyboard, /// The window's own effects, on the setup screen and in the studio. @@ -364,6 +360,11 @@ struct Events<'a> { /// Whether the window was asked to close, since eframe only logs the error /// that stops it otherwise. closed: bool, + /// The engine once the studio is built, to end on the way out. + started: Arc>>, + runtime: tokio::runtime::Handle, + /// Whether ending the studio could not save it. + unsaved: bool, } impl winit::application::ApplicationHandler for Events<'_> { @@ -418,6 +419,14 @@ impl winit::application::ApplicationHandler for Events<'_> { fn exiting(&mut self, event_loop: &ActiveEventLoop) { self.eframe.exiting(event_loop); + // Here rather than once the loop returns: quitting from the macOS app + // menu or Dock, or logging out, ends the process as this returns. + if let Some(engine) = self.started.get() + && let Err(err) = shutdown(engine, &self.runtime) + { + tracing::error!(error = format!("{err:#}"), "cannot save on quit"); + self.unsaved = true; + } } fn memory_warning(&mut self, event_loop: &ActiveEventLoop) { @@ -436,13 +445,9 @@ fn icon() -> egui::IconData { } } -/// Show the window until it is closed. -fn run( - app: App, - gpu: engine::Gpu, - runtime: tokio::runtime::Handle, - started: Arc>>, -) -> anyhow::Result<()> { +/// Show the window until it is closed, then end the studio. +fn run(app: App, gpu: engine::Gpu, runtime: tokio::runtime::Handle) -> anyhow::Result<()> { + let started = Arc::new(OnceLock::new()); let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_title("kixel.live Studio") @@ -472,6 +477,8 @@ fn run( .context("cannot open the window")?; let keyboard = hotkeys::Keyboard::default(); let window_keyboard = keyboard.clone(); + let window_started = started.clone(); + let window_runtime = runtime.clone(); let eframe = eframe::create_native( "moq-studio", options, @@ -480,12 +487,12 @@ fn run( .wgpu_render_state .clone() .context("the window is not drawn with wgpu")?; - let fx = fx::Fx::new(&render, cc.storage, &cc.egui_ctx, &runtime); + let fx = fx::Fx::new(&render, cc.storage, &cc.egui_ctx, &window_runtime); Ok(Box::new(Window { app, render, - runtime, - started, + runtime: window_runtime, + started: window_started, keyboard: window_keyboard, fx, })) @@ -496,9 +503,13 @@ fn run( eframe, keyboard, closed: false, + started, + runtime, + unsaved: false, }; event_loop.run_app(&mut events).context("the window failed")?; anyhow::ensure!(events.closed, "the window failed; the error is logged above"); + anyhow::ensure!(!events.unsaved, "cannot save on quit; the error is logged above"); Ok(()) } From 8eb7290ecb065e23d4ae8d0a3166efc55427afe0 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 15 Nov 2026 02:00:28 -0800 Subject: [PATCH 4/8] feat: the macOS app ships in a disk image per architecture The release signs, notarizes, and staples the app, then packs it into a disk image laid out for dragging to Applications, and signs, notarizes, and staples the image. The launch check runs the app from the mounted image, in pull requests and the release alike. A follow-up quest holds the maintainer's camera check and the first release's signing check. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/macos.yml | 86 ++++++++++++++++++---------- .github/workflows/release.yml | 2 +- README.md | 4 +- packaging/macos/dmg-requirements.txt | 8 +++ packaging/macos/dmg.py | 17 ++++++ packaging/macos/dmg.sh | 29 ++++++++++ packaging/macos/notarize.sh | 29 ++++++++++ packaging/macos/smoke.sh | 24 +++++++- quest/m0/packaging/macos-try.md | 38 ++++++++++++ 9 files changed, 200 insertions(+), 37 deletions(-) create mode 100644 packaging/macos/dmg-requirements.txt create mode 100644 packaging/macos/dmg.py create mode 100755 packaging/macos/dmg.sh create mode 100755 packaging/macos/notarize.sh create mode 100644 quest/m0/packaging/macos-try.md diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 751b0fc..86782de 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -1,10 +1,11 @@ name: macOS # Builds the macOS app bundle for each architecture, signs it ad hoc with the -# release's hardened runtime and entitlements, and checks it launches and -# lists devices. Pull requests that change the packaging run it; nightly runs -# it against main. release.yml calls it with `sign`, which signs the bundles as -# the Developer ID, notarizes them, checks them again, and ships what it packs. +# release's hardened runtime and entitlements, packs it into a disk image, and +# checks the app in the image launches and lists devices. Pull requests that +# change the packaging run it; nightly runs it against main. release.yml calls +# it with `sign`, which signs and notarizes the app as the Developer ID, then +# the disk image holding it, checks them again, and ships the images. permissions: contents: read @@ -62,18 +63,23 @@ jobs: - name: Sign ad hoc run: packaging/macos/sign.sh "kixel.live Studio.app" - - - name: Smoke test - run: packaging/macos/smoke.sh "kixel.live Studio.app" + # dmg.sh's builder needs a newer Python than macOS ships. + - name: Set up Python + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: "3.13" - # ditto keeps the bundle's symlinks and signature intact, unlike zip. - name: Pack - run: ditto -c -k --keepParent "kixel.live Studio.app" "moq-studio-macos-${{ matrix.arch }}.zip" + run: packaging/macos/dmg.sh "kixel.live Studio.app" "moq-studio-macos-${{ matrix.arch }}.dmg" + + - name: Smoke test + run: packaging/macos/smoke.sh "moq-studio-macos-${{ matrix.arch }}.dmg" - name: Upload uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: unsigned-macos-${{ matrix.arch }} - path: moq-studio-macos-${{ matrix.arch }}.zip + path: moq-studio-macos-${{ matrix.arch }}.dmg if-no-files-found: error sign: @@ -113,10 +119,23 @@ jobs: with: name: unsigned-macos-${{ matrix.arch }} + # The app out of the image, to sign again; the image is packed anew. - name: Unpack run: | - ditto -x -k "moq-studio-macos-${{ matrix.arch }}.zip" . - rm "moq-studio-macos-${{ matrix.arch }}.zip" + image=moq-studio-macos-${{ matrix.arch }}.dmg + volume=$RUNNER_TEMP/unsigned + mkdir "$volume" + hdiutil attach -nobrowse -readonly -noautoopen -mountpoint "$volume" "$image" + ditto "$volume/kixel.live Studio.app" "kixel.live Studio.app" + hdiutil detach "$volume" + rm "$image" + + # dmg.sh's builder needs a newer Python than macOS ships. + - name: Set up Python + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: "3.13" + # A keychain of its own, holding the Developer ID certificate and the # intermediate that chains it to Apple's root, which runner images may @@ -149,22 +168,22 @@ jobs: - name: Sign run: packaging/macos/sign.sh "kixel.live Studio.app" "$IDENTITY" "$KEYCHAIN" - - name: Notarize + - name: Notarize the app env: APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} APPLE_API_PRIVATE_KEY: ${{ secrets.APPLE_API_PRIVATE_KEY }} - run: | - key=$RUNNER_TEMP/notary.p8 - printf '%s\n' "$APPLE_API_PRIVATE_KEY" >"$key" - notary=(--key "$key" --key-id "$APPLE_API_KEY" --issuer "$APPLE_API_ISSUER") - ditto -c -k --keepParent "kixel.live Studio.app" "$RUNNER_TEMP/notarize.zip" - result=$(xcrun notarytool submit "$RUNNER_TEMP/notarize.zip" "${notary[@]}" --wait --timeout 1h --output-format json) - echo "$result" - # The log lists every issue, warnings included, even when accepted. - xcrun notarytool log "$(jq -r .id <<<"$result")" "${notary[@]}" - [[ $(jq -r .status <<<"$result") == Accepted ]] - xcrun stapler staple "kixel.live Studio.app" + run: packaging/macos/notarize.sh "kixel.live Studio.app" + + - name: Pack + run: packaging/macos/dmg.sh "kixel.live Studio.app" "moq-studio-macos-${{ matrix.arch }}.dmg" "$IDENTITY" "$KEYCHAIN" + + - name: Notarize the image + env: + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_API_PRIVATE_KEY: ${{ secrets.APPLE_API_PRIVATE_KEY }} + run: packaging/macos/notarize.sh "moq-studio-macos-${{ matrix.arch }}.dmg" - name: Remove the credentials if: always() @@ -172,27 +191,32 @@ jobs: if [[ -n ${KEYCHAIN:-} ]]; then security delete-keychain "$KEYCHAIN" fi - rm -f "$RUNNER_TEMP/notary.p8" "$RUNNER_TEMP/identity.p12" + rm -f "$RUNNER_TEMP/identity.p12" - # What Gatekeeper decides for a download: a Developer ID signature with - # its notarization ticket stapled, so a first launch offline passes too. + # What Gatekeeper decides for a download, for the image opened and for + # the app run: a Developer ID signature with its notarization ticket + # stapled, so a first launch offline passes too. - name: Verify run: | + image=moq-studio-macos-${{ matrix.arch }}.dmg + codesign --verify --strict --verbose=2 "$image" + xcrun stapler validate "$image" + assessment=$(spctl --assess --type open --context context:primary-signature --verbose=2 "$image" 2>&1) + echo "$assessment" + [[ $assessment == *"source=Notarized Developer ID"* ]] codesign --verify --strict --deep --verbose=2 "kixel.live Studio.app" xcrun stapler validate "kixel.live Studio.app" assessment=$(spctl --assess --type execute --verbose=2 "kixel.live Studio.app" 2>&1) echo "$assessment" [[ $assessment == *"source=Notarized Developer ID"* ]] + # The app as the image holds it, which is what users run. - name: Smoke test - run: packaging/macos/smoke.sh "kixel.live Studio.app" - - - name: Pack - run: ditto -c -k --keepParent "kixel.live Studio.app" "moq-studio-macos-${{ matrix.arch }}.zip" + run: packaging/macos/smoke.sh "moq-studio-macos-${{ matrix.arch }}.dmg" - name: Upload uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: macos-${{ matrix.arch }} - path: moq-studio-macos-${{ matrix.arch }}.zip + path: moq-studio-macos-${{ matrix.arch }}.dmg if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0aa1434..dc060e0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,7 +44,7 @@ jobs: - name: Download uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: - # Not the macOS bundles before signing. + # Not the macOS images before signing. pattern: "{flatpak,macos}-*" path: dist merge-multiple: true diff --git a/README.md b/README.md index be57517..2658531 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,8 @@ directory. The virtual camera is its PipeWire node only, since the sandbox has no `/dev/video*`. On macOS 13 or later, each release ships the app for Apple silicon -(`moq-studio-macos-arm64.zip`) and Intel (`moq-studio-macos-x86_64.zip`), -signed and notarized. Unzip it and drag **kixel.live Studio** to +(`moq-studio-macos-arm64.dmg`) and Intel (`moq-studio-macos-x86_64.dmg`), +signed and notarized. Open the disk image and drag **kixel.live Studio** to Applications. Opened from Finder or the Dock, it opens the scene collection in `~/Library/Application Support/moq-studio` and saves recordings to your Movies folder. macOS asks you to grant it the camera, microphone, and Screen & diff --git a/packaging/macos/dmg-requirements.txt b/packaging/macos/dmg-requirements.txt new file mode 100644 index 0000000..adf22c2 --- /dev/null +++ b/packaging/macos/dmg-requirements.txt @@ -0,0 +1,8 @@ +# dmg.sh's disk image builder, which writes the Finder window's layout itself +# rather than scripting Finder. Pinned by hash, since the release job runs it. +dmgbuild==1.6.7 \ + --hash=sha256:37ee5771c377beb3203d9164aae8046ffed8531c06edf9227f5788b3c599b1bf +ds-store==1.3.3 \ + --hash=sha256:b92a371efbf1b4ccce2a04d1ed13fceacc4736c81ba09cf5aefb74c088160a35 +mac-alias==2.2.3 \ + --hash=sha256:7362b521d2132ef92f606a37abfed5fcd849ceb2f28b6f9743e014b02af92f0d diff --git a/packaging/macos/dmg.py b/packaging/macos/dmg.py new file mode 100644 index 0000000..6739b24 --- /dev/null +++ b/packaging/macos/dmg.py @@ -0,0 +1,17 @@ +# dmgbuild's settings for the disk image dmg.sh builds: the app beside a link +# to Applications, in a window that shows only the two, ready to drag. +import os.path + +app = defines["app"] # noqa: F821, dmgbuild defines it +_name = os.path.basename(app) + +format = "ULFO" +files = [app] +symlinks = {"Applications": "/Applications"} +icon = os.path.join(app, "Contents", "Resources", "live.kixel.studio.icns") + +default_view = "icon-view" +window_rect = ((200, 200), (540, 340)) +icon_size = 128 +text_size = 13 +icon_locations = {_name: (140, 150), "Applications": (400, 150)} diff --git a/packaging/macos/dmg.sh b/packaging/macos/dmg.sh new file mode 100755 index 0000000..4b50cac --- /dev/null +++ b/packaging/macos/dmg.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Pack an app bundle into a disk image at the path given, laid out for +# dragging to Applications. Optional: a Developer ID Application identity +# (its SHA-1) to sign the image with, and the keychain that holds it. +# Needs Python 3.10 or later. +set -euo pipefail + +app=$1 +dmg=$2 +identity=${3:-} +keychain=${4:-} +packaging=$(cd "$(dirname "$0")" && pwd) + +venv=$(mktemp -d) +trap 'rm -rf "$venv"' EXIT +python3 -m venv "$venv" +"$venv/bin/pip" install --quiet --disable-pip-version-check --require-hashes --only-binary :all: -r "$packaging/dmg-requirements.txt" + +rm -f "$dmg" +"$venv/bin/dmgbuild" -s "$packaging/dmg.py" -D app="$app" "$(basename "$app" .app)" "$dmg" + +if [[ -n $identity ]]; then + flags=(--force --timestamp --sign "$identity") + if [[ -n $keychain ]]; then + flags+=(--keychain "$keychain") + fi + codesign "${flags[@]}" "$dmg" + codesign --verify --strict --verbose=2 "$dmg" +fi diff --git a/packaging/macos/notarize.sh b/packaging/macos/notarize.sh new file mode 100755 index 0000000..258cf4f --- /dev/null +++ b/packaging/macos/notarize.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Notarize a signed app bundle or disk image and staple its ticket to it, so a +# first launch offline passes Gatekeeper too. Reads the App Store Connect API +# key from APPLE_API_KEY (its ID), APPLE_API_ISSUER, and APPLE_API_PRIVATE_KEY +# (the .p8 text). +set -euo pipefail + +target=$1 + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +key=$work/notary.p8 +printf '%s\n' "$APPLE_API_PRIVATE_KEY" >"$key" +notary=(--key "$key" --key-id "$APPLE_API_KEY" --issuer "$APPLE_API_ISSUER") + +# The notary service takes a disk image as is, and a bundle zipped. +submission=$target +if [[ $target == *.app ]]; then + submission=$work/notarize.zip + ditto -c -k --keepParent "$target" "$submission" +fi + +result=$(xcrun notarytool submit "$submission" "${notary[@]}" --wait --timeout 1h --output-format json) +echo "$result" +# The log lists every issue, warnings included, even when accepted. +xcrun notarytool log "$(jq -r .id <<<"$result")" "${notary[@]}" +[[ $(jq -r .status <<<"$result") == Accepted ]] +xcrun stapler staple "$target" +xcrun stapler validate "$target" diff --git a/packaging/macos/smoke.sh b/packaging/macos/smoke.sh index 9d18f70..4ead7c1 100755 --- a/packaging/macos/smoke.sh +++ b/packaging/macos/smoke.sh @@ -3,19 +3,24 @@ # arguments, and check that it lists cameras and audio, opens its window, and # quits when asked without crashing. A fresh home directory makes it open a # new scene collection, so guided setup lists the devices. +# Given a disk image instead, it mounts it as a download is and launches the +# app inside, after checking the image offers the drag to Applications. set -euo pipefail -app=$(cd "$1" && pwd) -executable=$app/Contents/MacOS/moq-studio -home=$(mktemp -d) +# Resolved, since /var is a link and the process lists its real path. +home=$(cd "$(mktemp -d)" && pwd -P) log=$home/studio.log touch "$log" pid= +volume= cleanup() { if [[ -n $pid ]]; then kill "$pid" 2>/dev/null || true fi + if [[ -n $volume ]]; then + hdiutil detach -force "$volume" >/dev/null || true + fi echo "--- the studio's log" cat "$log" rm -rf "$home" @@ -27,6 +32,19 @@ fail() { exit 1 } +if [[ $1 == *.dmg ]]; then + volume=$home/volume + mkdir "$volume" + hdiutil attach -nobrowse -readonly -noautoopen -mountpoint "$volume" "$1" >/dev/null + [[ $(readlink "$volume/Applications") == /Applications ]] || fail "the image has no link to Applications" + apps=("$volume"/*.app) + ((${#apps[@]} == 1)) && [[ -d ${apps[0]} ]] || fail "the image holds no single app" + app=${apps[0]} +else + app=$(cd "$1" && pwd -P) +fi +executable=$app/Contents/MacOS/moq-studio + # Whether the studio has a window on screen. Listing windows by owner needs # no permission, unlike reading their titles. window() { diff --git a/quest/m0/packaging/macos-try.md b/quest/m0/packaging/macos-try.md new file mode 100644 index 0000000..fe7d0e5 --- /dev/null +++ b/quest/m0/packaging/macos-try.md @@ -0,0 +1,38 @@ +# [XS] The maintainer tries the macOS app + +## Goal + +The maintainer has run the macOS app from its disk image on a Mac with a +camera and seen the camera's picture in program, and the first release's +`Sign` jobs in `.github/workflows/macos.yml` have signed, notarized, and +stapled both the app and its disk image as the real Developer ID. + +## Plan + +Decided 2026-11-15 (maintainer, on moq-dev/studio#170): ✅ these checks +are this follow-up's, so the macOS app's PR lands without them (rejected: +keeping that PR a draft until the hand check is done). + +CI covers everything else. A pull request that touches `packaging/macos/`, +and Nightly, build each architecture's disk image, sign the app ad hoc, and +launch it from the mounted image with no camera. A pull request cannot deploy +the `release` environment, so the Developer ID signing and both notarizations +first run in a release. + +Once a release run exists, an agent checks its `Sign (arm64)` and +`Sign (x86_64)` jobs passed: `Notarize the app` and `Notarize the image` +print `"status": "Accepted"`, and `Verify` prints `source=Notarized Developer +ID` for both. If one failed, it fixes the workflow in its own PR, linked here. + +## Hosts + +- macos - a Mac with a camera, and a person at it + +## Waiting + +- Maintainer: on a Mac with a camera, open the app from its disk image, approve the camera prompt, pick the camera in guided setup, and check its picture shows in program. Only a person can grant the camera permission (TCC has no scriptable grant outside MDM), and hosted runners have no camera. Take `moq-studio-macos-arm64.dmg` (or `-x86_64`) from the latest release, which is signed and needs no other step; before the first release, download the `unsigned-macos-arm64` artifact from the latest [Nightly](https://github.com/moq-dev/studio/actions/workflows/nightly.yml) run, open the disk image, drag the app to Applications, and run `xattr -dr com.apple.quarantine "/Applications/kixel.live Studio.app"`, since it is signed ad hoc. Check: the maintainer comments on this quest's PR, or edits this item away, with what they saw. +- Maintainer: run `.github/workflows/release.yml` on main to cut the first release with the macOS app, since a release publishes a version only the maintainer chooses. Check: a GitHub release lists `moq-studio-macos-arm64.dmg` and `moq-studio-macos-x86_64.dmg`. + +## Related + +- [Studio packaging](/quest/m0/packaging/README.md) - the release workflow the app's images ship from From 9525c52d572c2dcb90df41dc852f89c2346c45f4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 15 Nov 2026 02:05:43 -0800 Subject: [PATCH 5/8] fix: the Flatpak's menu entry opens the studio's default collection Co-Authored-By: Claude Opus 5.5 --- packaging/flatpak/moq-studio-desktop.sh | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packaging/flatpak/moq-studio-desktop.sh b/packaging/flatpak/moq-studio-desktop.sh index 8362c3c..a265fd8 100755 --- a/packaging/flatpak/moq-studio-desktop.sh +++ b/packaging/flatpak/moq-studio-desktop.sh @@ -1,6 +1,7 @@ #!/bin/sh -# The menu entry's launch, which passes no arguments: the scene collection in -# the app's own data directory, so the first launch runs guided setup, and -# recordings in the Videos folder rather than beside it. +# The menu entry's launch, which passes no arguments: the studio's default +# scene collection in its data directory, the one `flatpak run` opens too, so +# the first launch runs guided setup, with recordings in the Videos folder the +# desktop names. set -eu -exec moq-studio --recordings "$(xdg-user-dir VIDEOS)" "$XDG_DATA_HOME/scenes.toml" "$@" +exec moq-studio --recordings "$(xdg-user-dir VIDEOS)" "$@" From 9cbf55a82da0a6f7d32364db00feab53ca855d1c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 15 Nov 2026 02:55:31 -0800 Subject: [PATCH 6/8] fix: a recording creates its missing folder, and the smoke test takes no crash-report folder The videos folder the platform names may not exist yet. The browser-source quest now says its plan extends the bundle and signing scripts. Co-Authored-By: Claude Opus 5.5 --- packaging/macos/smoke.sh | 5 ++++- quest/m0/browser-source/macos.md | 9 +++++---- rs/moq-studio/src/record.rs | 20 +++++++++++++++++--- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/packaging/macos/smoke.sh b/packaging/macos/smoke.sh index 4ead7c1..f0c0072 100755 --- a/packaging/macos/smoke.sh +++ b/packaging/macos/smoke.sh @@ -97,6 +97,9 @@ while kill -0 "$pid" 2>/dev/null; do sleep 0.1 done pid= -crashed=$(find "$reports" -name 'moq-studio*' -newer "$marker" 2>/dev/null) +crashed= +if [[ -d $reports ]]; then + crashed=$(find "$reports" -name 'moq-studio*' -newer "$marker") +fi [[ -z $crashed ]] || fail "the studio crashed: $crashed" ! grep -q 'panicked' "$log" || fail "the studio panicked" diff --git a/quest/m0/browser-source/macos.md b/quest/m0/browser-source/macos.md index 1c189f6..51a8c9c 100644 --- a/quest/m0/browser-source/macos.md +++ b/quest/m0/browser-source/macos.md @@ -12,10 +12,11 @@ On top of what Linux landed: - The helper is CEF's browser process, so it needs an app bundle with CEF's helper apps (`cef-rs`'s `bundle-cef-app`) and its own NSApplication. They - go into the studio's app bundle rather than a second one: - `packaging/macos/bundle.sh` lays them out under `Contents/Frameworks`, and - `packaging/macos/sign.sh` signs each, with the entitlements it needs, before - the app that holds them. + go into the studio's app bundle rather than a second one. This quest + extends `packaging/macos/bundle.sh` to lay them out under + `Contents/Frameworks`, and `packaging/macos/sign.sh` to sign each, with the + entitlements it needs, before the app that holds them; today both handle + the studio's executable only. - An IOSurface crosses processes only as a Mach port (`IOSurfaceCreateMachPort`), so the channel needs a Mach port rendezvous. - Exiting with the studio stays as on Linux: the helper kills the process diff --git a/rs/moq-studio/src/record.rs b/rs/moq-studio/src/record.rs index 60785f6..75edc02 100644 --- a/rs/moq-studio/src/record.rs +++ b/rs/moq-studio/src/record.rs @@ -738,6 +738,9 @@ fn numbered(stem: &str, number: u32) -> String { /// Create a file named `stem` in `dir`, numbering it rather than replacing /// one already there, and mark it unfinished before it exists. fn create(dir: &Path, stem: &str) -> anyhow::Result<(PathBuf, std::fs::File, Marker)> { + // A default folder, such as the videos folder the platform names, may not + // exist yet. + std::fs::create_dir_all(dir).with_context(|| format!("cannot create {}", dir.display()))?; let mut number: u32 = 1; loop { let path = dir.join(numbered(stem, number)); @@ -2219,6 +2222,15 @@ mod tests { /// A name already taken, such as by a recording started in the same /// second, is numbered rather than replaced. + #[test] + fn a_missing_folder_is_created() { + let dir = tempfile::tempdir().unwrap(); + let folder = dir.path().join("Movies"); + let (path, ..) = create(&folder, "show").unwrap(); + assert_eq!(path, folder.join("show.mp4")); + assert!(path.is_file()); + } + #[test] fn a_taken_name_is_numbered() { let dir = tempfile::tempdir().unwrap(); @@ -2262,12 +2274,14 @@ mod tests { assert!(!Marker::named(&path, &file).unwrap()); } - /// Starting into a directory that does not exist fails at once. + /// Starting into a directory that cannot be created fails at once. #[tokio::test(start_paused = true)] - async fn a_missing_directory_fails_to_start() { + async fn an_uncreatable_directory_fails_to_start() { let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("file"); + std::fs::write(&file, b"").unwrap(); let studio = Studio::new(); - let recorder = studio.recorder(&dir.path().join("missing")); + let recorder = studio.recorder(&file.join("missing")); let err = recorder.start(false).unwrap_err(); assert!(format!("{err:#}").contains("cannot create"), "{err:#}"); assert!(recorder.status().is_none()); From d37c9eb17b4d7693a7f6fd4380cc0cc6370735aa Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 15 Nov 2026 04:01:57 -0800 Subject: [PATCH 7/8] chore(quest): the Flatpak lists cameras again Main's Flatpak launch check fails at the camera portal's D-Bus handshake, found while running it on this branch. Co-Authored-By: Claude Opus 5.5 --- quest/m0/packaging/flatpak-cameras.md | 35 +++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 quest/m0/packaging/flatpak-cameras.md diff --git a/quest/m0/packaging/flatpak-cameras.md b/quest/m0/packaging/flatpak-cameras.md new file mode 100644 index 0000000..086cd48 --- /dev/null +++ b/quest/m0/packaging/flatpak-cameras.md @@ -0,0 +1,35 @@ +# [M] The Flatpak lists cameras again + +## Goal + +The Flatpak's launch check (`packaging/flatpak/smoke.sh`) passes again on +main: inside the sandbox the studio lists cameras through the camera portal +instead of failing its D-Bus handshake. + +## Plan + +Found 2026-11-15 on moq-dev/studio#170. The Flatpak's `Bundle` jobs fail on +main itself ([run](https://github.com/moq-dev/studio/actions/runs/43708081575), +dispatched on `354ee7d`), with `listed devices cameras=Err("camera portal: +ZBus Error: D-Bus handshake failed: Unexpected EOF during handshake")`, then a +wgpu surface panic. The last green Nightly ran on `a73fb69`. + +The likely cause is #163's catch-up to moq's main: moq-video's camera portal +moved to `ashpd` 0.14 on `zbus` 6.3, while the studio's own portals +(`global_shortcuts`, `settings`) stay on `ashpd` 0.13 and `zbus` 5. Reproduce +the handshake with `zbus` 6 in a Flatpak sandbox against the session bus +proxy, then fix it where it lies. A `zbus` or moq-video fix lands in +moq-dev/moq first, with a moq-side test, and a `moq` bump here; aligning the +studio's own `ashpd` with moq's then keeps one `zbus`. + +Whether the wgpu panic ("The surface isn't supported by this adapter") is a +second failure or follows the first is part of the investigation; the smoke +test stops at the camera listing before it matters. + +## Hosts + +- linux - the Flatpak and its sandbox + +## Related + +- [Studio packaging](/quest/m0/packaging/README.md) - the release workflow that ships the Flatpak From 90751a76d3e1c5d96528f4faecf5f4a40011afa9 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 15 Nov 2026 04:03:24 -0800 Subject: [PATCH 8/8] chore: each recorder test keeps its own doc comment Co-Authored-By: Claude Opus 5.5 --- rs/moq-studio/src/record.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/rs/moq-studio/src/record.rs b/rs/moq-studio/src/record.rs index 75edc02..098610b 100644 --- a/rs/moq-studio/src/record.rs +++ b/rs/moq-studio/src/record.rs @@ -2220,8 +2220,8 @@ mod tests { assert!(!replaced(&BTreeMap::new(), &audio(None, None))); } - /// A name already taken, such as by a recording started in the same - /// second, is numbered rather than replaced. + /// A recording's folder, such as a videos folder not yet made, is created + /// when missing. #[test] fn a_missing_folder_is_created() { let dir = tempfile::tempdir().unwrap(); @@ -2231,6 +2231,8 @@ mod tests { assert!(path.is_file()); } + /// A name already taken, such as by a recording started in the same + /// second, is numbered rather than replaced. #[test] fn a_taken_name_is_numbered() { let dir = tempfile::tempdir().unwrap();