diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..73db0d37 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,72 @@ +name: Build Anywhere Unsigned IPA + +on: + workflow_dispatch: + push: + branches: [main] + paths: + - '.github/workflows/build.yml' + - '.github/ipa-build-trigger' + +permissions: + contents: read + +jobs: + build: + name: macos-14 unsigned iphoneos build + runs-on: macos-15 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Select Xcode 26.3 toolchain + run: sudo xcode-select -s /Applications/Xcode_26.3.app + + - name: Verify Xcode toolchain + run: | + xcode-select -p + xcodebuild -version + xcodebuild -showsdks | grep iphoneos || true + + - name: Build Anywhere (Debug, iphoneos, unsigned) + run: | + set -euo pipefail + DERIVED_DATA="${RUNNER_TEMP}/DerivedData" + xcodebuild \ + -project Anywhere.xcodeproj \ + -scheme Anywhere \ + -configuration Debug \ + -sdk iphoneos \ + -derivedDataPath "${DERIVED_DATA}" \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGN_IDENTITY="" \ + DEVELOPMENT_TEAM="" \ + ONLY_ACTIVE_ARCH=NO \ + build + + - name: Package unsigned IPA + run: | + set -euo pipefail + DERIVED_DATA="${RUNNER_TEMP}/DerivedData" + APP_PATH="$(find "${DERIVED_DATA}" -path '*/Build/Products/Debug-iphoneos/Anywhere.app' -type d | head -1)" + + if [[ -z "${APP_PATH}" || ! -d "${APP_PATH}" ]]; then + echo "Anywhere.app not found under ${DERIVED_DATA}" >&2 + find "${DERIVED_DATA}" -name 'Anywhere.app' -type d || true + exit 1 + fi + + rm -rf Payload Anywhere.ipa + mkdir -p Payload + cp -R "${APP_PATH}" Payload/ + zip -qr Anywhere.ipa Payload + ls -lh Anywhere.ipa + + - name: Upload unsigned IPA artifact + uses: actions/upload-artifact@v4 + with: + name: Anywhere-Unsigned-IPA + path: Anywhere.ipa + if-no-files-found: error diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..60b6748e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,118 @@ +# AGENTS.md + +Guidance for AI agents working in the Anywhere repository. + +## Product overview + +**Anywhere** is a native iOS/iPadOS/tvOS proxy/VPN client (Swift + vendored C). It is **not** a web app or server-side project. There is no `package.json`, Docker stack, or backend API to run locally. + +| Target | Scheme | Purpose | +| --- | --- | --- | +| **Anywhere** (required) | `Anywhere` | Main SwiftUI app | +| **Network Extension** (required for VPN E2E) | `Anywhere Network Extension` | Packet tunnel (`NEPacketTunnelProvider`) | +| Anywhere TV (optional) | `Anywhere TV` | tvOS variant | +| Anywhere Widget (optional) | `Anywhere Widget` | WidgetKit VPN toggle | + +## Development environment (macOS — required for build/run) + +Full development requires **macOS with Xcode** (project `LastUpgradeVersion = 2640`, iOS deployment target **17.0+**). + +1. Open `Anywhere.xcodeproj` in Xcode. +2. Select the **Anywhere** scheme and an iOS Simulator or device. +3. Set your own **Development Team** (project currently references team `C7AS5D38Q8`). +4. Build and run (`⌘R`). The Network Extension is embedded and launches when VPN connects. + +**Signing:** Network Extension, App Group (`group.com.argsment.Anywhere`), and Keychain entitlements require valid Apple provisioning. + +**SPM dependencies** (resolved by Xcode): [Argsment/BLAKE3](https://github.com/Argsment/BLAKE3.git), [Argsment/YAML](https://github.com/Argsment/YAML.git). + +**Bundled data:** `Shared/DataStore/Rules.db` (SQLite routing rules, ~39k rules). + +**No automated test targets** (`XCTest` / test plans) are configured in this repo. + +### Useful commands (macOS only) + +```bash +# Build for iOS Simulator +xcodebuild -project Anywhere.xcodeproj -scheme Anywhere \ + -destination 'platform=iOS Simulator,name=iPhone 16' build + +# Build Network Extension +xcodebuild -project Anywhere.xcodeproj -scheme "Anywhere Network Extension" \ + -destination 'platform=iOS Simulator,name=iPhone 16' build +``` + +### External runtime dependencies (for meaningful E2E proxy testing) + +- User-configured proxy servers (VLESS, Hysteria2, Trojan, Shadowsocks, etc.) or subscription URLs +- Not part of the repo; needed to test real proxy connectivity beyond UI/build + +## Code navigation + +| Area | Path | +| --- | --- | +| Proxy protocols | `Shared/Networking/Protocols/` | +| Packet tunnel / lwIP / MITM | `Anywhere Network Extension/` | +| Shared models & stores | `Shared/` | +| Routing rules docs | `Documentations/Routing.md` | +| MITM rewrite docs | `Documentations/MITM.md` | + +**Important:** `README.md` is a curated summary, not a spec. Verify behavior in source code when making claims about protocol support. + +## Cursor Cloud specific instructions + +### Platform limitation + +This Cloud Agent VM runs **Linux**. The Anywhere iOS app **cannot be built or run here** — there is no Xcode, iOS SDK, or Simulator. Treat macOS + Xcode as a hard requirement for compile/run/debug workflows. + +### What works on Linux (validation only) + +Agents can still verify repo health without macOS: + +1. **Project structure** — `Anywhere.xcodeproj`, four shared schemes, `Rules.db` present. +2. **SPM remotes** — pins in `Package.resolved` match `main` on GitHub (`Argsment/BLAKE3`, `Argsment/YAML`). +3. **Rules.db** — `sqlite3 Shared/DataStore/Rules.db ".tables"` → `metadata`, `rules`. +4. **SPM package compile** — BLAKE3 and YAML build with Swift on Linux (`swift build` in cloned repos); this validates dependency availability, not the iOS app. + +Swift toolchain (if installed on the VM): `/opt/swift/usr/bin` — add to `PATH` before running `swift`. + +### What does NOT work on Linux + +- `xcodebuild`, iOS Simulator, device deploy, VPN/Network Extension testing +- SwiftUI preview, WidgetKit, JavaScriptCore MITM scripting in-app +- Lint/format — no `.swiftlint.yml` or CI lint config in repo + +### Services + +| Service | Linux VM | macOS + Xcode | +| --- | --- | --- | +| Anywhere app | Cannot run | Required | +| Network Extension | Cannot run | Required for VPN E2E | +| External proxy server | N/A (user-provided) | Optional for real traffic tests | + +No long-running dev servers, databases, or Docker compose stacks exist in this repository. + +### Headless cloud compilation (GitHub Actions) + +Linux agents can orchestrate **unsigned IPA builds** on `macos-14` runners via `scripts/cloud-build/`: + +```bash +# Validate gh auth and preview pipeline +./scripts/cloud-build/cloud-compile.sh --dry-run --fork "$(gh api user -q .login)/Anywhere" + +# Full loop: inject workflow → dispatch → watch → download Anywhere.ipa +./scripts/cloud-build/cloud-compile.sh --fork "$(gh api user -q .login)/Anywhere" +``` + +**Requirements:** `gh` authenticated with `repo` + `workflow` scopes (integration tokens may inject files but cannot `workflow_dispatch`). + +| Script | Role | +| --- | --- | +| `cloud-compile.sh` | Main orchestrator | +| `lib/gh-auth.sh` | Validates `gh`; falls back to `gh auth login` | +| `lib/fork.sh` | Idempotent fork of `NodePassProject/Anywhere` | +| `lib/github-api.sh` | Base64 Contents API `PUT` for `.github/workflows/build.yml` | +| `lib/workflow-watch.sh` | `workflow_dispatch`, `gh run watch`, artifact download | +| `workflows/build.yml` | macOS build template (also at `.github/workflows/build.yml`) | + +Build flags: Xcode 15.4, `Release` + `iphoneos`, `CODE_SIGNING_ALLOWED=NO`, artifact `Anywhere-Unsigned-IPA`. diff --git a/Anywhere/Views/ProxyList/ProxyListView.swift b/Anywhere/Views/ProxyList/ProxyListView.swift index d3c11e06..9708c4b7 100644 --- a/Anywhere/Views/ProxyList/ProxyListView.swift +++ b/Anywhere/Views/ProxyList/ProxyListView.swift @@ -33,106 +33,122 @@ struct ProxyListView: View { } var body: some View { - List { - Section { - ForEach(standaloneItems) { item in - proxyRow(item, editingDisabled: false) + proxyList + .overlay { emptyOverlay } + .navigationTitle("Proxies") + .toolbar { + if standaloneItems.count > 1 || subscriptionStore.subscriptions.count > 1 { + ToolbarItemGroup { reorderLink } } - } - ForEach(subscriptionStore.subscriptions) { subscription in - let editingDisabled = SubscriptionDomainHelper.shouldDisableProxyEditing(for: subscription.url) - Section { - if !collapsedSubscriptions.contains(subscription.id) { - ForEach(items(for: subscription)) { item in - proxyRow(item, editingDisabled: editingDisabled) - } - } - } header: { - subscriptionHeader(subscription) + if #available(iOS 26.0, *) { + ToolbarSpacer() } - } - } - .overlay { - if configStore.configurations.isEmpty { - ContentUnavailableView("No Proxies", systemImage: "network") - } - } - .navigationTitle("Proxies") - .toolbar { - if standaloneItems.count > 1 || subscriptionStore.subscriptions.count > 1 { - if #available(iOS 27.0, *) { - ToolbarItemGroup { - NavigationLink { - ReorderProxiesView() - } label: { - Label("Reorder Proxies", systemImage: "arrow.up.arrow.down") - } + ToolbarItemGroup { + Button(action: testAllVisibleLatencies) { + Label("Test All", systemImage: "gauge.with.dots.needle.67percent") } - .visibilityPriority(.low) - } else { - ToolbarItemGroup { - NavigationLink { - ReorderProxiesView() - } label: { - Label("Reorder Proxies", systemImage: "arrow.up.arrow.down") - } + Button { showingAddSheet = true } label: { + Label("Add", systemImage: "plus") } } } - - if #available(iOS 26.0, *) { - ToolbarSpacer() + .sheet(isPresented: $showingAddSheet) { addProxySheet } + .sheet(isPresented: $showingManualAddSheet) { manualAddSheet } + .sheet(item: $configurationToEdit) { configuration in editProxySheet(configuration) } + .alert("Update Failed", isPresented: $showingSubscriptionError) { + Button("OK", role: .cancel) { } + } message: { + Text(subscriptionErrorMessage) } - - ToolbarItemGroup { - Button { - let visible = configStore.configurations.filter { configuration in - guard let subId = configuration.subscriptionId else { return true } - return !collapsedSubscriptions.contains(subId) + .alert("Rename", isPresented: renameBinding) { + TextField("Name", text: $renameText) + Button("OK") { + if let subscription = renamingSubscription, !renameText.isEmpty { + subscriptionStore.rename(subscription, to: renameText) } - viewModel.testLatencies(for: visible) - } label: { - Label("Test All", systemImage: "gauge.with.dots.needle.67percent") } - Button { - showingAddSheet = true - } label: { - Label("Add", systemImage: "plus") + Button("Cancel", role: .cancel) { } + } + .onAppear { + collapsedSubscriptions = Set(subscriptionStore.subscriptions.filter(\.collapsed).map(\.id)) + } + } + + @ViewBuilder + private var proxyList: some View { + List { + Section { + ForEach(standaloneItems) { item in + proxyRow(item, editingDisabled: false) } } - } - .sheet(isPresented: $showingAddSheet) { - DynamicSheet(animation: .snappy(duration: 0.3, extraBounce: 0)) { - AddProxyView(showingManualAddSheet: $showingManualAddSheet) + ForEach(subscriptionStore.subscriptions) { subscription in + subscriptionSection(subscription) } } - .sheet(isPresented: $showingManualAddSheet) { - ProxyEditorView { configuration in - configStore.add(configuration); viewModel.selectIfNone(configuration) + } + + @ViewBuilder + private func subscriptionSection(_ subscription: Subscription) -> some View { + let editingDisabled = SubscriptionDomainHelper.shouldDisableProxyEditing(for: subscription.url) + Section { + if !collapsedSubscriptions.contains(subscription.id) { + ForEach(items(for: subscription)) { item in + proxyRow(item, editingDisabled: editingDisabled) + } } + } header: { + subscriptionHeader(subscription) } - .sheet(item: $configurationToEdit) { configuration in - ProxyEditorView(configuration: configuration) { updated in - configStore.update(updated) - } + } + + @ViewBuilder + private var emptyOverlay: some View { + if configStore.configurations.isEmpty { + ContentUnavailableView("No Proxies", systemImage: "network") } - .alert("Update Failed", isPresented: $showingSubscriptionError) { - Button("OK", role: .cancel) { } - } message: { - Text(subscriptionErrorMessage) + } + + private var reorderLink: some View { + NavigationLink { + ReorderProxiesView() + } label: { + Label("Reorder Proxies", systemImage: "arrow.up.arrow.down") } - .alert("Rename", isPresented: Binding(get: { renamingSubscription != nil }, set: { if !$0 { renamingSubscription = nil } })) { - TextField("Name", text: $renameText) - Button("OK") { - if let subscription = renamingSubscription, !renameText.isEmpty { - subscriptionStore.rename(subscription, to: renameText) - } - } - Button("Cancel", role: .cancel) { } + } + + private var addProxySheet: some View { + DynamicSheet(animation: .snappy(duration: 0.3, extraBounce: 0)) { + AddProxyView(showingManualAddSheet: $showingManualAddSheet) } - .onAppear { - collapsedSubscriptions = Set(subscriptionStore.subscriptions.filter(\.collapsed).map(\.id)) + } + + private var manualAddSheet: some View { + ProxyEditorView { configuration in + configStore.add(configuration) + viewModel.selectIfNone(configuration) + } + } + + private func editProxySheet(_ configuration: ProxyConfiguration) -> some View { + ProxyEditorView(configuration: configuration) { updated in + configStore.update(updated) + } + } + + private var renameBinding: Binding { + Binding( + get: { renamingSubscription != nil }, + set: { if !$0 { renamingSubscription = nil } } + ) + } + + private func testAllVisibleLatencies() { + let visible = configStore.configurations.filter { configuration in + guard let subId = configuration.subscriptionId else { return true } + return !collapsedSubscriptions.contains(subId) } + viewModel.testLatencies(for: visible) } // MARK: - Subscription Header diff --git a/scripts/cloud-build/README.md b/scripts/cloud-build/README.md new file mode 100644 index 00000000..03759657 --- /dev/null +++ b/scripts/cloud-build/README.md @@ -0,0 +1,65 @@ +# Cloud Compile — Headless iOS Build Loop + +Remote unsigned IPA builds for **Anywhere** via GitHub Actions (`macos-14` + Xcode 15.4). + +## Prerequisites + +- [GitHub CLI](https://cli.github.com/) (`gh`) installed +- Authenticated session with scopes: `repo`, `workflow` +- A fork of `NodePassProject/Anywhere` under your GitHub account (auto-created if missing) + +## Quick start + +```bash +# Validate auth and preview the pipeline +./scripts/cloud-build/cloud-compile.sh --dry-run + +# Full loop: inject workflow → dispatch → watch → download Anywhere.ipa +./scripts/cloud-build/cloud-compile.sh + +# Use an existing fork explicitly +./scripts/cloud-build/cloud-compile.sh --fork "$(gh api user -q .login)/Anywhere" +``` + +## Pipeline stages + +| Stage | Implementation | +| --- | --- | +| Auth validation | `lib/gh-auth.sh` — `gh auth status --json` with `hostname,username` or `hosts` fallback; interactive `gh auth login` trap | +| Fork linkage | `lib/fork.sh` — `gh repo fork NodePassProject/Anywhere --clone=false` (idempotent) | +| Workflow injection | `lib/github-api.sh` — Base64 `PUT` to `.github/workflows/build.yml` via Contents API | +| Build & package | `workflows/build.yml` — `xcodebuild` Release/iphoneos unsigned → `Payload/` → `Anywhere.ipa` | +| Dispatch & watch | `lib/workflow-watch.sh` — `workflow_dispatch`, `gh run watch`, artifact download | + +## Workflow build flags + +- Runner: `macos-14` +- Toolchain: `sudo xcode-select -s /Applications/Xcode_15.4.app` +- Build: `-scheme Anywhere -configuration Release -sdk iphoneos` +- Overrides: `CODE_SIGNING_ALLOWED=NO`, `ONLY_ACTIVE_ARCH=NO` +- Artifact: `Anywhere-Unsigned-IPA` (contains `Anywhere.ipa`) + +## Advanced flags + +```bash +./scripts/cloud-build/cloud-compile.sh --help +./scripts/cloud-build/cloud-compile.sh --print-base64 # emit workflow YAML as Base64 +./scripts/cloud-build/cloud-compile.sh --skip-dispatch # inject only +./scripts/cloud-build/cloud-compile.sh --skip-download # inject + dispatch + watch +./scripts/cloud-build/cloud-compile.sh --branch main --dest . +``` + +## Layout + +``` +scripts/cloud-build/ +├── cloud-compile.sh # Main orchestrator +├── lib/ +│ ├── common.sh +│ ├── gh-auth.sh +│ ├── fork.sh +│ ├── github-api.sh +│ └── workflow-watch.sh +└── workflows/ + └── build.yml # GitHub Actions workflow template +``` diff --git a/scripts/cloud-build/cloud-compile.sh b/scripts/cloud-build/cloud-compile.sh new file mode 100755 index 00000000..41e932b5 --- /dev/null +++ b/scripts/cloud-build/cloud-compile.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +# +# Headless remote cloud-compilation loop for NodePassProject/Anywhere. +# +# Usage: +# ./scripts/cloud-build/cloud-compile.sh +# ./scripts/cloud-build/cloud-compile.sh --dry-run +# ./scripts/cloud-build/cloud-compile.sh --fork zBossPC/Anywhere --branch main +# ./scripts/cloud-build/cloud-compile.sh --print-base64 +# +# Examples: +# ./scripts/cloud-build/cloud-compile.sh --dry-run +# ./scripts/cloud-build/cloud-compile.sh --fork "$(gh api user -q .login)/Anywhere" +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/common.sh +source "${SCRIPT_DIR}/lib/common.sh" +# shellcheck source=lib/gh-auth.sh +source "${SCRIPT_DIR}/lib/gh-auth.sh" +# shellcheck source=lib/fork.sh +source "${SCRIPT_DIR}/lib/fork.sh" +# shellcheck source=lib/github-api.sh +source "${SCRIPT_DIR}/lib/github-api.sh" +# shellcheck source=lib/workflow-watch.sh +source "${SCRIPT_DIR}/lib/workflow-watch.sh" + +UPSTREAM="${DEFAULT_UPSTREAM}" +TARGET_REPO="" +BRANCH="${DEFAULT_BRANCH}" +WORKFLOW_FILE="build.yml" +DEST_DIR="${WORKSPACE_ROOT}" +DRY_RUN=0 +PRINT_BASE64=0 +SKIP_DOWNLOAD=0 +SKIP_DISPATCH=0 + +usage() { + cat <<'EOF' +Headless cloud compilation for Anywhere (unsigned IPA via GitHub Actions). + +Options: + --upstream Upstream source (default: NodePassProject/Anywhere) + --fork Target fork (skip auto-fork; use existing remote repo) + --branch Git branch for workflow injection (default: main) + --dest Artifact download directory (default: workspace root) + --dry-run Validate auth and print planned actions only + --print-base64 Emit Base64 workflow payload and exit + --skip-dispatch Inject workflow only; do not dispatch or download + --skip-download Dispatch and watch; do not download artifact + -h, --help Show this help + +Examples: + ./scripts/cloud-build/cloud-compile.sh --dry-run + ./scripts/cloud-build/cloud-compile.sh --fork zBossPC/Anywhere + ./scripts/cloud-build/cloud-compile.sh --print-base64 | head -c 80 +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --upstream) + UPSTREAM="$2" + shift 2 + ;; + --fork) + TARGET_REPO="$2" + shift 2 + ;; + --branch) + BRANCH="$2" + shift 2 + ;; + --dest) + DEST_DIR="$2" + shift 2 + ;; + --dry-run) + DRY_RUN=1 + shift + ;; + --print-base64) + PRINT_BASE64=1 + shift + ;; + --skip-dispatch) + SKIP_DISPATCH=1 + shift + ;; + --skip-download) + SKIP_DOWNLOAD=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + die "Unknown argument: $1 (try --help)" + ;; + esac +done + +if [[ "${PRINT_BASE64}" -eq 1 ]]; then + print_workflow_base64_stream "${WORKFLOW_TEMPLATE}" + exit 0 +fi + +main() { + ensure_gh_auth + local repo_slug + repo_slug="$(resolve_target_repo "${UPSTREAM}" "${TARGET_REPO}")" + + log "Upstream: ${UPSTREAM}" + log "Target repository: ${repo_slug}" + log "Workflow branch: ${BRANCH}" + log "Workflow template: ${WORKFLOW_TEMPLATE}" + log "Artifact name: ${ARTIFACT_NAME}" + + if [[ "${DRY_RUN}" -eq 1 ]]; then + log "DRY RUN — planned actions:" + log " 1. PUT ${WORKFLOW_REMOTE_PATH} -> ${repo_slug}@${BRANCH} (Base64 via Contents API)" + log " 2. gh workflow run ${WORKFLOW_FILE} --repo ${repo_slug} --ref ${BRANCH}" + log " 3. gh run watch --repo ${repo_slug}" + log " 4. gh run download -n ${ARTIFACT_NAME} --dir ${DEST_DIR}" + exit 0 + fi + + inject_workflow_via_api "${repo_slug}" "${BRANCH}" + + if [[ "${SKIP_DISPATCH}" -eq 1 ]]; then + log "Skipping workflow dispatch (--skip-dispatch)" + exit 0 + fi + + dispatch_workflow "${repo_slug}" "${BRANCH}" "${WORKFLOW_FILE}" + local run_id + run_id="$(wait_for_latest_run_id "${repo_slug}" "${WORKFLOW_FILE}")" + log "Run ID: ${run_id}" + + watch_workflow_run "${repo_slug}" "${run_id}" + assert_run_success "${repo_slug}" "${run_id}" + + if [[ "${SKIP_DOWNLOAD}" -eq 1 ]]; then + log "Skipping artifact download (--skip-download)" + exit 0 + fi + + download_unsigned_ipa "${repo_slug}" "${run_id}" "${DEST_DIR}" + log "Cloud compile loop complete." +} + +main "$@" diff --git a/scripts/cloud-build/lib/common.sh b/scripts/cloud-build/lib/common.sh new file mode 100755 index 00000000..04170470 --- /dev/null +++ b/scripts/cloud-build/lib/common.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Shared helpers for cloud-compile automation. + +if [[ -n "${CLOUD_BUILD_COMMON_LOADED:-}" ]]; then + return 0 2>/dev/null || exit 0 +fi +CLOUD_BUILD_COMMON_LOADED=1 + +set -euo pipefail + +readonly CLOUD_BUILD_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +readonly WORKSPACE_ROOT="$(cd "${CLOUD_BUILD_ROOT}/../.." && pwd)" +readonly WORKFLOW_TEMPLATE="${CLOUD_BUILD_ROOT}/workflows/build.yml" +readonly WORKFLOW_REMOTE_PATH=".github/workflows/build.yml" +readonly ARTIFACT_NAME="Anywhere-Unsigned-IPA" +readonly DEFAULT_UPSTREAM="NodePassProject/Anywhere" +readonly DEFAULT_SCHEME="Anywhere" +readonly DEFAULT_BRANCH="main" + +log() { + printf '[cloud-compile] %s\n' "$*" >&2 +} + +die() { + log "ERROR: $*" + exit 1 +} + +require_cmd() { + local cmd="$1" + command -v "$cmd" >/dev/null 2>&1 || die "Required command not found: ${cmd}" +} + +base64_encode_file() { + local file="$1" + if base64 --help 2>&1 | grep -q -- '-w'; then + base64 -w0 "$file" + else + base64 <"$file" | tr -d '\n' + fi +} + +repo_owner() { + local slug="$1" + echo "${slug%%/*}" +} + +repo_name() { + local slug="$1" + echo "${slug#*/}" +} diff --git a/scripts/cloud-build/lib/fork.sh b/scripts/cloud-build/lib/fork.sh new file mode 100755 index 00000000..e55e3f77 --- /dev/null +++ b/scripts/cloud-build/lib/fork.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Fork upstream repository into the authenticated user's namespace. + +if [[ -n "${CLOUD_BUILD_FORK_LOADED:-}" ]]; then + return 0 2>/dev/null || exit 0 +fi +CLOUD_BUILD_FORK_LOADED=1 + +set -euo pipefail + +# shellcheck source=common.sh +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" +# shellcheck source=gh-auth.sh +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/gh-auth.sh" + +fork_exists_for_user() { + local user="$1" + local repo_name="$2" + gh api "repos/${user}/${repo_name}" --silent >/dev/null 2>&1 +} + +ensure_fork() { + local upstream="${1:-${DEFAULT_UPSTREAM}}" + local user repo_name fork_slug + + ensure_gh_auth + user="$(get_gh_username)" + repo_name="$(repo_name "$upstream")" + fork_slug="${user}/${repo_name}" + + if fork_exists_for_user "$user" "$repo_name"; then + log "Fork already exists: ${fork_slug}" + printf '%s' "${fork_slug}" + return 0 + fi + + log "Forking ${upstream} -> ${user} (headless, no clone)..." + if gh repo fork "${upstream}" --clone=false 2>/dev/null; then + log "Fork created: ${fork_slug}" + printf '%s' "${fork_slug}" + return 0 + fi + + # Namespace collision or eventual consistency — re-check before failing. + sleep 2 + if fork_exists_for_user "$user" "$repo_name"; then + log "Fork available after retry: ${fork_slug}" + printf '%s' "${fork_slug}" + return 0 + fi + + die "Unable to fork ${upstream}. Ensure token scopes include repo and that the upstream repository is accessible." +} + +resolve_target_repo() { + local upstream="${1:-${DEFAULT_UPSTREAM}}" + local explicit_fork="${2:-}" + + if [[ -n "${explicit_fork}" ]]; then + log "Using explicit target repository: ${explicit_fork}" + printf '%s' "${explicit_fork}" + return 0 + fi + + ensure_fork "${upstream}" +} diff --git a/scripts/cloud-build/lib/gh-auth.sh b/scripts/cloud-build/lib/gh-auth.sh new file mode 100755 index 00000000..28cfc36f --- /dev/null +++ b/scripts/cloud-build/lib/gh-auth.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# GitHub CLI presence and session validation. + +if [[ -n "${CLOUD_BUILD_GH_AUTH_LOADED:-}" ]]; then + return 0 2>/dev/null || exit 0 +fi +CLOUD_BUILD_GH_AUTH_LOADED=1 + +set -euo pipefail + +# shellcheck source=common.sh +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" + +GH_HOSTNAME="" +GH_USERNAME="" + +parse_auth_json_hostname_username() { + local json="$1" + GH_HOSTNAME="$(printf '%s' "$json" | jq -r '.hostname // empty')" + GH_USERNAME="$(printf '%s' "$json" | jq -r '.username // empty')" +} + +parse_auth_json_hosts() { + local json="$1" + GH_HOSTNAME="$(printf '%s' "$json" | jq -r '.hosts."github.com"[0].host // "github.com"')" + GH_USERNAME="$(printf '%s' "$json" | jq -r '.hosts."github.com"[0].login // empty')" +} + +resolve_username_via_api() { + if GH_USERNAME="$(gh api user --jq .login 2>/dev/null)"; then + GH_HOSTNAME="${GH_HOSTNAME:-github.com}" + return 0 + fi + return 1 +} + +interactive_gh_login_trap() { + log "GitHub session is not authorized. Starting interactive gh auth login..." + log "Grant scopes: repo, workflow, read:org (minimum for fork, contents API, and Actions)." + gh auth login +} + +ensure_gh_cli() { + require_cmd gh + require_cmd jq + log "gh CLI present: $(gh --version | head -1)" +} + +ensure_gh_auth() { + ensure_gh_cli + + local auth_json="" + if auth_json="$(gh auth status --json hostname,username 2>/dev/null)"; then + parse_auth_json_hostname_username "$auth_json" + elif auth_json="$(gh auth status --json hosts 2>/dev/null)"; then + parse_auth_json_hosts "$auth_json" + fi + + if [[ -z "${GH_USERNAME}" ]]; then + resolve_username_via_api || true + fi + + if [[ -z "${GH_USERNAME}" ]]; then + interactive_gh_login_trap + ensure_gh_auth + return + fi + + local state="" + if auth_json="$(gh auth status --json hosts 2>/dev/null)"; then + state="$(printf '%s' "$auth_json" | jq -r '.hosts."github.com"[0].state // empty')" + fi + + if [[ "${state}" == "failed" ]]; then + interactive_gh_login_trap + ensure_gh_auth + return + fi + + GH_HOSTNAME="${GH_HOSTNAME:-github.com}" + log "GitHub session OK: ${GH_USERNAME}@${GH_HOSTNAME}" +} + +get_gh_username() { + [[ -n "${GH_USERNAME}" ]] || ensure_gh_auth + printf '%s' "${GH_USERNAME}" +} + +get_gh_hostname() { + [[ -n "${GH_HOSTNAME}" ]] || ensure_gh_auth + printf '%s' "${GH_HOSTNAME}" +} diff --git a/scripts/cloud-build/lib/github-api.sh b/scripts/cloud-build/lib/github-api.sh new file mode 100755 index 00000000..54a431e9 --- /dev/null +++ b/scripts/cloud-build/lib/github-api.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Push workflow manifest to a remote repository via GitHub Contents REST API. + +if [[ -n "${CLOUD_BUILD_GITHUB_API_LOADED:-}" ]]; then + return 0 2>/dev/null || exit 0 +fi +CLOUD_BUILD_GITHUB_API_LOADED=1 + +set -euo pipefail + +# shellcheck source=common.sh +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" + +get_remote_file_sha() { + local owner="$1" + local repo="$2" + local path="$3" + local branch="$4" + local sha="" + + sha="$(gh api "repos/${owner}/${repo}/contents/${path}?ref=${branch}" --jq .sha 2>/dev/null || true)" + if [[ "${sha}" =~ ^[a-f0-9]{40}$ ]]; then + printf '%s' "${sha}" + fi +} + +inject_workflow_via_api() { + local repo_slug="$1" + local branch="${2:-${DEFAULT_BRANCH}}" + local local_file="${3:-${WORKFLOW_TEMPLATE}}" + local remote_path="${4:-${WORKFLOW_REMOTE_PATH}}" + local message="${5:-chore(ci): inject headless unsigned IPA build workflow}" + + local owner repo_name content_b64 sha + + owner="$(repo_owner "$repo_slug")" + repo_name="$(repo_name "$repo_slug")" + [[ -f "${local_file}" ]] || die "Workflow template not found: ${local_file}" + + content_b64="$(base64_encode_file "${local_file}")" + sha="$(get_remote_file_sha "${owner}" "${repo_name}" "${remote_path}" "${branch}")" + + log "Uploading workflow via Contents API: ${repo_slug}:${remote_path} (branch=${branch})" + + local -a api_args=( + --method PUT + "repos/${owner}/${repo_name}/contents/${remote_path}" + -f message="${message}" + -f content="${content_b64}" + -f branch="${branch}" + ) + + if [[ -n "${sha}" ]]; then + api_args+=(-f sha="${sha}") + log "Updating existing workflow (sha=${sha:0:12}...)" + else + log "Creating new workflow file" + fi + + gh api "${api_args[@]}" --jq '{commit: .commit.sha, content: .content.path}' +} + +print_workflow_base64_stream() { + local local_file="${1:-${WORKFLOW_TEMPLATE}}" + [[ -f "${local_file}" ]] || die "Workflow template not found: ${local_file}" + base64_encode_file "${local_file}" +} diff --git a/scripts/cloud-build/lib/workflow-watch.sh b/scripts/cloud-build/lib/workflow-watch.sh new file mode 100755 index 00000000..e0a131fc --- /dev/null +++ b/scripts/cloud-build/lib/workflow-watch.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# Dispatch workflow, watch execution, and download build artifacts. + +if [[ -n "${CLOUD_BUILD_WORKFLOW_WATCH_LOADED:-}" ]]; then + return 0 2>/dev/null || exit 0 +fi +CLOUD_BUILD_WORKFLOW_WATCH_LOADED=1 + +set -euo pipefail + +# shellcheck source=common.sh +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" + +dispatch_workflow() { + local repo_slug="$1" + local branch="${2:-${DEFAULT_BRANCH}}" + local workflow_file="${3:-build.yml}" + + log "Dispatching workflow_dispatch: ${repo_slug}/${workflow_file} (ref=${branch})" + gh workflow run "${workflow_file}" --repo "${repo_slug}" --ref "${branch}" +} + +wait_for_latest_run_id() { + local repo_slug="$1" + local workflow_file="${2:-build.yml}" + local attempts="${3:-30}" + local sleep_secs="${4:-5}" + local run_id="" + + for ((i = 1; i <= attempts; i++)); do + run_id="$(gh run list \ + --repo "${repo_slug}" \ + --workflow "${workflow_file}" \ + --limit 1 \ + --json databaseId,status,conclusion \ + --jq '.[0].databaseId // empty' 2>/dev/null || true)" + + if [[ -n "${run_id}" ]]; then + printf '%s' "${run_id}" + return 0 + fi + + sleep "${sleep_secs}" + done + + die "Timed out waiting for workflow run to appear for ${repo_slug}/${workflow_file}" +} + +watch_workflow_run() { + local repo_slug="$1" + local run_id="$2" + + log "Watching run ${run_id} on ${repo_slug}..." + gh run watch "${run_id}" --repo "${repo_slug}" --exit-status +} + +assert_run_success() { + local repo_slug="$1" + local run_id="$2" + local conclusion + + conclusion="$(gh run view "${run_id}" --repo "${repo_slug}" --json conclusion --jq .conclusion)" + if [[ "${conclusion}" != "success" ]]; then + die "Workflow run ${run_id} finished with conclusion=${conclusion:-unknown}" + fi + log "Workflow run ${run_id} succeeded" +} + +download_unsigned_ipa() { + local repo_slug="$1" + local run_id="$2" + local dest_dir="${3:-${WORKSPACE_ROOT}}" + + mkdir -p "${dest_dir}" + log "Downloading artifact '${ARTIFACT_NAME}' to ${dest_dir}" + gh run download "${run_id}" \ + --repo "${repo_slug}" \ + --name "${ARTIFACT_NAME}" \ + --dir "${dest_dir}" + + if [[ -f "${dest_dir}/Anywhere.ipa" ]]; then + log "Artifact ready: ${dest_dir}/Anywhere.ipa" + ls -lh "${dest_dir}/Anywhere.ipa" + else + find "${dest_dir}" -maxdepth 2 -name '*.ipa' -print + fi +} diff --git a/scripts/cloud-build/workflows/build.yml b/scripts/cloud-build/workflows/build.yml new file mode 100644 index 00000000..73db0d37 --- /dev/null +++ b/scripts/cloud-build/workflows/build.yml @@ -0,0 +1,72 @@ +name: Build Anywhere Unsigned IPA + +on: + workflow_dispatch: + push: + branches: [main] + paths: + - '.github/workflows/build.yml' + - '.github/ipa-build-trigger' + +permissions: + contents: read + +jobs: + build: + name: macos-14 unsigned iphoneos build + runs-on: macos-15 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Select Xcode 26.3 toolchain + run: sudo xcode-select -s /Applications/Xcode_26.3.app + + - name: Verify Xcode toolchain + run: | + xcode-select -p + xcodebuild -version + xcodebuild -showsdks | grep iphoneos || true + + - name: Build Anywhere (Debug, iphoneos, unsigned) + run: | + set -euo pipefail + DERIVED_DATA="${RUNNER_TEMP}/DerivedData" + xcodebuild \ + -project Anywhere.xcodeproj \ + -scheme Anywhere \ + -configuration Debug \ + -sdk iphoneos \ + -derivedDataPath "${DERIVED_DATA}" \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGN_IDENTITY="" \ + DEVELOPMENT_TEAM="" \ + ONLY_ACTIVE_ARCH=NO \ + build + + - name: Package unsigned IPA + run: | + set -euo pipefail + DERIVED_DATA="${RUNNER_TEMP}/DerivedData" + APP_PATH="$(find "${DERIVED_DATA}" -path '*/Build/Products/Debug-iphoneos/Anywhere.app' -type d | head -1)" + + if [[ -z "${APP_PATH}" || ! -d "${APP_PATH}" ]]; then + echo "Anywhere.app not found under ${DERIVED_DATA}" >&2 + find "${DERIVED_DATA}" -name 'Anywhere.app' -type d || true + exit 1 + fi + + rm -rf Payload Anywhere.ipa + mkdir -p Payload + cp -R "${APP_PATH}" Payload/ + zip -qr Anywhere.ipa Payload + ls -lh Anywhere.ipa + + - name: Upload unsigned IPA artifact + uses: actions/upload-artifact@v4 + with: + name: Anywhere-Unsigned-IPA + path: Anywhere.ipa + if-no-files-found: error