diff --git a/.github/workflows/_ci-clients.yml b/.github/workflows/_ci-clients.yml index 310ab676b75..e389a3cd581 100644 --- a/.github/workflows/_ci-clients.yml +++ b/.github/workflows/_ci-clients.yml @@ -126,6 +126,8 @@ jobs: if: inputs.lane == 'mobile-swift' && inputs.mobile steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: Test fork iPhone setup with macOS Bash + run: /bin/bash scripts/test-mobile-ios-setup.sh - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - name: Install Flutter dependencies run: cd mobile && flutter pub get diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 935bc6b8e21..d957fe1b427 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -128,6 +128,8 @@ jobs: run: scripts/test-desktop-instance-detection.sh - name: Mobile worktree identity contract run: scripts/test-mobile-worktree-overrides.sh + - name: Mobile fork iPhone setup + run: /bin/bash scripts/test-mobile-ios-setup.sh - name: Codex security review contract run: just security-review-check - name: Rust cache contract diff --git a/Justfile b/Justfile index 014a27dee61..ea6787d528a 100644 --- a/Justfile +++ b/Justfile @@ -980,6 +980,14 @@ mobile_dir := "mobile" mobile-install: unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && flutter pub get +# Save this fork's iPhone signing identity (no Apple credentials are stored) +mobile-ios-setup bundle_id team_id name="Buzz Voltage": + /bin/bash scripts/mobile-ios-setup.sh {{quote(bundle_id)}} {{quote(team_id)}} {{quote(name)}} + +# Install on a named iPhone from a Mac; PHONE_ID comes from flutter devices +mobile-ios-device device: + /bin/bash scripts/mobile-ios-device.sh {{quote(device)}} + # Format all Dart code mobile-fmt: unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && dart format . diff --git a/docs/mobile-remote-agent-launch.md b/docs/mobile-remote-agent-launch.md index 3e20e70daa7..66eca9d4eff 100644 --- a/docs/mobile-remote-agent-launch.md +++ b/docs/mobile-remote-agent-launch.md @@ -80,6 +80,21 @@ Older managers return an unsupported-operation error that mobile displays as an Unregistered agents display the one-time desktop setup instruction. This change does not deploy a manager, sync ArgoCD, or change production permissions. +## Why existing mobile builds need an update + +Existing mobile builds publish the owner's signed channel message, including its addressed agent and thread tags. +They do not send the separate kind 24210 `start` request. +The manager currently subscribes only to kind 24210 requests addressed to its key, so it never receives those channel mentions. + +This is an implementation boundary, not a signature or protocol limitation. +A server-side mention watcher could support existing clients using the saved launch registration and the same owner and thread checks. +That watcher is not implemented here. +It must receive messages through an identity with access to the agent's channels, preserve community boundaries, and reconcile duplicates and reconnect gaps. +The manager's service identity does not automatically inherit each developer's private channel memberships. + +A web client can also send the same `start` operation without changing the cluster agent protocol. +It still needs the user's signing identity, the registered agent, and verified manager replies. + ## Phone acceptance test Use an owned staging Remote workspace agent and a disposable test thread. diff --git a/mobile/IOS_FORK.md b/mobile/IOS_FORK.md new file mode 100644 index 00000000000..fccdd9f99cc --- /dev/null +++ b/mobile/IOS_FORK.md @@ -0,0 +1,100 @@ +# Install your fork on an iPhone + +Build this checkout with your Apple team's signing identity. +Your fork can coexist with the older Buzz app under its own bundle ID. +Later builds update that fork when you keep the bundle ID and signing team unchanged. +An upstream TestFlight installation does not receive changes merged only into this fork. + +## Before the first install + +Use a Mac with full Xcode, CocoaPods, and access to an Apple Developer Program team. +These instructions retain Buzz's existing capabilities; a free Personal Team may not support them. +Open Xcode once to install its components and accept its license. +Sign in under **Xcode → Settings → Apple Accounts**. + +Clone `voltagecloud/buzz`, or update your clean checkout to the reviewed revision on `main`. +From the repository root, activate the pinned tools: + +```bash +. ./bin/activate-hermit +just mobile-ios-setup io.voltage.buzz.mobile YOURTEAMID "Buzz Voltage" +``` + +Replace `YOURTEAMID` with your ten-character Apple Team ID. +Choose a bundle ID that your team owns; `io.voltage.buzz.mobile` is an example. +The command writes `mobile/ios/Flutter/AppOverrides.xcconfig`, which Git ignores. +It preserves unrelated overrides, including your push gateway and capability environments. +It does not create Apple registrations, certificates, or provisioning profiles. + +Ask your Apple team administrator to provision these identifiers: + +| Identifier | Required configuration | +| --- | --- | +| Your bundle ID | App Groups, Push Notifications, App Attest, Declared Age Range, and Communication Notifications | +| `.NotificationService` | App Groups | +| `group.` | Associate this App Group with both targets | + +Use the entitlements in `ios/Runner/Runner.entitlements` and `ios/NotificationService/NotificationService.entitlements` as the source of truth. +After enabling capabilities, regenerate any manually managed profiles. +The setup command selects automatic signing; Xcode still needs access to the matching team and capabilities. + +Connect and unlock your iPhone, then trust the Mac when prompted. +Enable **Settings → Privacy & Security → Developer Mode** on the phone when Xcode requests it. + +## Install or update + +Find the physical iPhone's device ID: + +```bash +flutter devices +``` + +Run the device command with that ID: + +```bash +just mobile-ios-device YOUR_IPHONE_DEVICE_ID +``` + +The command activates Hermit, resolves Flutter packages, installs native dependencies through Flutter, and runs the debug build. +It uses your saved signing identity, including when the checkout is a Git worktree. +If signing needs attention, open `mobile/ios/Runner.xcworkspace` in Xcode. +Select your phone and inspect **Signing & Capabilities** for both **Runner** and **NotificationService**. + +For later updates, update the same checkout to the reviewed revision and repeat the device command. +No checkout reset, app uninstall, or identity change is needed. +Sign into the fork with your existing Buzz identity and the same community. +Keep the older app until you have confirmed that identity and community access in the fork. + +## Push and agent launch + +Push is optional for running Buzz and starting agents while the app is open. +Without a configured gateway, the build leaves push unavailable. +Do not reuse an upstream gateway for a different bundle ID. +Push setup requires matching gateway application identifiers and signing environments; see [the mobile README](README.md#ios-push-capability). + +The server must also support mobile launches, and each agent needs its one-time desktop registration. +See [the launch rollout](../docs/mobile-remote-agent-launch.md#rollout). +Installing this app does not deploy the manager or grant cluster permissions. + +## TestFlight distribution + +For installation without a development Mac, an Apple team member must sign and upload a build of this fork. +Use a stable bundle ID and grant testers access in your own App Store Connect application. +The inherited `mobile-release.sh` and release-candidate workflow target `block/buzz`; they do not publish this fork. +This setup command prepares local device builds, not an automated TestFlight release pipeline. + +## Troubleshooting + +| Symptom | Next action | +| --- | --- | +| No phone in `flutter devices` | Unlock it, trust the Mac, and check Xcode's Devices and Simulators window. | +| Unsupported entitlement or profile mismatch | Have the team administrator enable the listed capability and refresh the profile. | +| Bundle ID belongs to another team | Choose your team's ID and rerun setup; this installs a separate app. | +| Installation cannot replace the old app | Confirm both its bundle ID and signing identity; use a separate fork ID when they differ. | +| `unauthorized_owner` on agent startup | An administrator must provision your Buzz public key in the staging developer list. | +| One-time desktop setup message | Start that agent once from desktop after the manager upgrade, then retry from mobile. | + +[Apple's device instructions](https://developer.apple.com/documentation/xcode/running-your-app-on-simulated-or-physical-devices) +describe signing and device setup. +[Flutter's iOS setup](https://docs.flutter.dev/platform-integration/ios/setup) +describes the host tools. diff --git a/mobile/README.md b/mobile/README.md index eaf82f3a28e..c34d8b6d1d4 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -2,6 +2,8 @@ Flutter mobile client for Buzz. +To install your own fork on a physical iPhone, follow [the fork setup guide](IOS_FORK.md). + See [VISION_MOBILE.md](../VISION_MOBILE.md) for intended behavior and architecture. diff --git a/scripts/mobile-ios-device.sh b/scripts/mobile-ios-device.sh new file mode 100755 index 00000000000..dd2c44df5f5 --- /dev/null +++ b/scripts/mobile-ios-device.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# Use the checkout's pinned Flutter and saved fork identity on a named device. +set -euo pipefail + +if [[ $# -ne 1 || -z "$1" || "$1" == -* ]]; then + echo "Usage: scripts/mobile-ios-device.sh PHONE_ID (from flutter devices)" >&2 + exit 2 +fi +if [[ "$(uname -s)" != Darwin ]]; then + echo "Installing on an iPhone requires a Mac with Xcode. See mobile/IOS_FORK.md." >&2 + exit 1 +fi + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +config="$repo_root/mobile/ios/Flutter/AppOverrides.xcconfig" +if [[ ! -f "$config" ]] || \ + ! grep -Eq '^[[:space:]]*BUNDLE_IDENTIFIER[[:space:]]*=[[:space:]]*[^[:space:]]+' "$config" || \ + ! grep -Eq '^[[:space:]]*BUZZ_DEVELOPMENT_TEAM[[:space:]]*=[[:space:]]*[A-Z0-9]{10}[[:space:]]*$' "$config"; then + echo "Configure your fork first: just mobile-ios-setup BUNDLE_ID TEAM_ID" >&2 + exit 1 +fi +if ! xcodebuild -version >/dev/null 2>&1; then + echo "Select a full Xcode installation with xcode-select, and complete Xcode's first-run setup." >&2 + exit 1 +fi +if ! command -v pod >/dev/null 2>&1; then + echo "Install CocoaPods on your Mac before running this command. See mobile/IOS_FORK.md." >&2 + exit 1 +fi + +cd "$repo_root" +unset GIT_DIR GIT_WORK_TREE +# shellcheck source=../bin/activate-hermit +. ./bin/activate-hermit +./scripts/mobile-worktree-overrides.sh +cd mobile +flutter pub get + +# Match mobile-dev: an explicit origin wins, otherwise use the Xcode override. +# Never evaluate xcconfig as shell code; its https:/$()/ spelling is literal. +gateway_url="${BUZZ_PUSH_GATEWAY_URL:-}" +if [[ -z "$gateway_url" ]]; then + gateway_url="$(sed -nE 's/^[[:space:]]*BUZZ_PUSH_GATEWAY_URL[[:space:]]*=[[:space:]]*(.*[^[:space:]])[[:space:]]*$/\1/p' "$config" | tail -n 1 | sed 's/\$()//g')" +fi +set -- run --debug --no-pub --device-id "$1" +if [[ -n "$gateway_url" ]]; then + set -- "$@" "--dart-define=BUZZ_PUSH_GATEWAY_URL=$gateway_url" +fi +exec flutter "$@" diff --git a/scripts/mobile-ios-setup.sh b/scripts/mobile-ios-setup.sh new file mode 100755 index 00000000000..17ecc6b3945 --- /dev/null +++ b/scripts/mobile-ios-setup.sh @@ -0,0 +1,78 @@ +#!/bin/bash +# Configure a fork's iPhone identity without editing tracked Xcode files. +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: scripts/mobile-ios-setup.sh BUNDLE_ID TEAM_ID [DISPLAY_NAME] + +Example: scripts/mobile-ios-setup.sh io.voltage.buzz.mobile ABCDE12345 "Buzz Voltage" + +Writes the gitignored mobile/ios/Flutter/AppOverrides.xcconfig. Other settings, +including a configured push gateway, are preserved. Use the same bundle ID and +team for subsequent updates. This does not register Apple IDs or signing profiles. +USAGE +} + +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + usage + exit 0 +fi +if [[ $# -lt 2 || $# -gt 3 ]]; then + usage >&2 + exit 2 +fi + +bundle_id="$1" +team_id="$2" +display_name="${3-Buzz Voltage}" +if [[ ! "$bundle_id" =~ ^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$ || ${#bundle_id} -gt 235 ]]; then + echo "Use a reverse-domain bundle ID, with room for .NotificationService (at most 235 characters)." >&2 + exit 2 +fi +if [[ "$bundle_id" == "xyz.block.buzz.mobile" || "$bundle_id" == "xyz.block.buzz.dogfood.mobile" ]]; then + echo "Choose a bundle ID owned by your Apple team, rather than the upstream Buzz IDs." >&2 + exit 2 +fi +if [[ ! "$team_id" =~ ^[A-Z0-9]{10}$ ]]; then + echo "Apple Team ID must contain exactly 10 uppercase letters or digits." >&2 + exit 2 +fi +if [[ -z "$display_name" || ${#display_name} -gt 64 || ! "$display_name" =~ ^[A-Za-z0-9._\(\)\ -]+$ ]]; then + echo "Display name must contain 1–64 letters, numbers, spaces, periods, underscores, parentheses, or hyphens." >&2 + exit 2 +fi + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +config_dir="$repo_root/mobile/ios/Flutter" +config="$config_dir/AppOverrides.xcconfig" +if [[ ! -d "$config_dir" || -L "$config" || ( -e "$config" && ! -f "$config" ) ]]; then + echo "Run from a Buzz checkout with a regular AppOverrides.xcconfig file, or no existing override file." >&2 + exit 1 +fi + +existing=/dev/null +[[ ! -f "$config" ]] || existing="$config" +temporary="$(mktemp "$config_dir/.AppOverrides.xcconfig.XXXXXX")" +trap 'rm -f "$temporary"' EXIT + +# Filter only the four managed settings. Keep comments, gateway definitions, +# capability environments, and other operator settings byte-for-byte per line. +awk '!/^[[:space:]]*(BUNDLE_IDENTIFIER|BUZZ_DEVELOPMENT_TEAM|APP_DISPLAY_NAME|CODE_SIGN_STYLE)[[:space:]]*=/' \ + "$existing" > "$temporary" +cat >> "$temporary" <&2; exit 1; } + +/bin/bash "$setup" io.example.buzz ABCDE12345 "Buzz Example" >/dev/null +grep -qx 'BUNDLE_IDENTIFIER = io.example.buzz' "$config" +grep -qx 'BUZZ_DEVELOPMENT_TEAM = ABCDE12345' "$config" +grep -qx 'APP_DISPLAY_NAME = Buzz Example' "$config" +grep -qx 'CODE_SIGN_STYLE = Automatic' "$config" +echo "PASS fresh fork configuration" + +cat >> "$config" <<'CONFIG' +// Operator settings must survive subsequent setup runs. +BUZZ_PUSH_GATEWAY_URL = https:/$()/push.example +BUZZ_APP_ATTEST_ENVIRONMENT = development +CONFIG +/bin/bash "$setup" io.example.updated ZYXWV98765 "Buzz Updated" >/dev/null +grep -Fqx 'BUZZ_PUSH_GATEWAY_URL = https:/$()/push.example' "$config" +grep -qx 'BUZZ_APP_ATTEST_ENVIRONMENT = development' "$config" +[[ "$(grep -c '^BUNDLE_IDENTIFIER =' "$config")" == 1 ]] +grep -qx 'BUNDLE_IDENTIFIER = io.example.updated' "$config" +grep -qx 'BUZZ_DEVELOPMENT_TEAM = ZYXWV98765' "$config" +cp "$config" "$fixture/before" +/bin/bash "$setup" io.example.updated ZYXWV98765 "Buzz Updated" >/dev/null +cmp "$fixture/before" "$config" +echo "PASS existing settings survive and repeated setup is idempotent" + +reject() { + if /bin/bash "$setup" "$@" >/dev/null 2>&1; then + fail "setup accepted invalid arguments" + fi + cmp "$fixture/before" "$config" || fail "invalid input changed the config" +} +reject example ABCDE12345 +reject 'io.example.buzz$(touch BAD)' ABCDE12345 +reject io.example.buzz short +reject io.example.buzz abcde12345 +reject io.example.buzz ABCDE12345 '' +reject io.example.buzz ABCDE12345 'Bad // comment' +reject io.example.buzz ABCDE12345 $'Bad\nCODE_SIGN_STYLE = Manual' +reject xyz.block.buzz.mobile ABCDE12345 +reject xyz.block.buzz.dogfood.mobile ABCDE12345 +reject io.example.buzz ABCDE12345 Name extra +mv "$config" "$fixture/symlink-target" +ln -s "$fixture/symlink-target" "$config" +reject io.example.buzz ABCDE12345 +rm "$config" +mv "$fixture/symlink-target" "$config" +echo "PASS invalid input and symlink rejection leave configuration intact" + +# Stub only external tools; run the actual device command and config parser. +cat > "$fixture/bin/uname" <<'STUB' +#!/bin/bash +printf '%s\n' "${TEST_HOST_OS:-Darwin}" +STUB +cat > "$fixture/bin/xcodebuild" <<'STUB' +#!/bin/bash +exit "${TEST_XCODE_EXIT:-0}" +STUB +cat > "$fixture/bin/pod" <<'STUB' +#!/bin/bash +exit 0 +STUB +cat > "$fixture/bin/activate-hermit" <<'STUB' +export TEST_HERMIT_ACTIVATED=1 +STUB +cat > "$fixture/scripts/mobile-worktree-overrides.sh" <<'STUB' +#!/bin/bash +printf '%s\n' worktree-overrides >> "$CALL_LOG" +STUB +cat > "$fixture/bin/flutter" <<'STUB' +#!/bin/bash +set -eu +[[ "${TEST_HERMIT_ACTIVATED:-}" == 1 ]] +printf '%s\n' flutter "$@" >> "$CALL_LOG" +if [[ "$1" == pub ]]; then exit "${TEST_PUB_EXIT:-0}"; fi +STUB +chmod +x "$fixture/bin/uname" "$fixture/bin/xcodebuild" "$fixture/bin/pod" \ + "$fixture/bin/flutter" "$fixture/scripts/mobile-worktree-overrides.sh" +export PATH="$fixture/bin:$PATH" +export CALL_LOG="$fixture/calls" +unset BUZZ_PUSH_GATEWAY_URL +/bin/bash "$device" 'physical-phone-id' > /dev/null +printf '%s\n' worktree-overrides flutter pub get flutter run --debug --no-pub \ + --device-id physical-phone-id '--dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example' > "$fixture/expected" +diff -u "$fixture/expected" "$CALL_LOG" +echo "PASS pinned tools, dependency install, device selection, and saved push gateway" + +: > "$CALL_LOG" +BUZZ_PUSH_GATEWAY_URL=https://other.example /bin/bash "$device" 'phone with spaces' > /dev/null +printf '%s\n' worktree-overrides flutter pub get flutter run --debug --no-pub \ + --device-id 'phone with spaces' '--dart-define=BUZZ_PUSH_GATEWAY_URL=https://other.example' > "$fixture/expected" +diff -u "$fixture/expected" "$CALL_LOG" +echo "PASS explicit gateway and device arguments remain separate" + +sed '/^BUZZ_PUSH_GATEWAY_URL/d' "$config" > "$fixture/without-push" +mv "$fixture/without-push" "$config" +: > "$CALL_LOG" +/bin/bash "$device" phone > /dev/null +if grep -q -- --dart-define "$CALL_LOG"; then fail "unconfigured build enabled push"; fi +echo "PASS push stays optional" + +for condition in host xcode dependencies identity arguments; do + : > "$CALL_LOG" + case "$condition" in + host) export TEST_HOST_OS=Linux ;; + xcode) export TEST_XCODE_EXIT=1 ;; + dependencies) export TEST_PUB_EXIT=1 ;; + identity) mv "$config" "$fixture/saved-config" ;; + esac + phone=phone + [[ "$condition" != arguments ]] || phone=--release + if /bin/bash "$device" "$phone" > /dev/null 2>&1; then fail "device command accepted $condition failure"; fi + if grep -qx run "$CALL_LOG"; then fail "device command ran Flutter after $condition failure"; fi + unset TEST_HOST_OS TEST_XCODE_EXIT TEST_PUB_EXIT + [[ "$condition" != identity ]] || mv "$fixture/saved-config" "$config" +done +echo "PASS host, Xcode, dependency, identity, and argument failures stop installation"