diff --git a/AGENTS.md b/AGENTS.md index 28c272393f12..cad7dcc6379e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ WordPress-iOS uses a modular architecture with the main app and separate Swift p - The WordPress scheme uses `WordPressUnitTests.xctestplan` for the full unit test suite, including tests in the `Modules` Swift package. - Add every unit test target to `WordPressUnitTests.xctestplan`. - Run the full suite with `xcodebuild -workspace WordPress.xcworkspace -scheme WordPress -testPlan WordPressUnitTests test`. Do not use `swift test`. -- To verify changes end-to-end on an iOS simulator, follow @docs/simulator-sign-in.md to sign in to the app. +- To sign a Simulator into WordPress.com, run `make sim-login` (it targets the running simulator; the WordPress.com token comes from `~/.wpcom-token` or `WPCOM_TOKEN`, and it prompts if none is set). See @docs/simulator-sign-in.md for options and self-hosted sign-in. ### Important Considerations - **Multi-site Support**: Code must handle both WordPress.com and self-hosted sites diff --git a/Makefile b/Makefile index 3539ae12d1dd..e86c8ee0f5da 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,10 @@ -.PHONY: help dependencies +.PHONY: help dependencies sim-login help: ## Show available targets @grep -E '^[a-zA-Z_-]+:.*##' $(MAKEFILE_LIST) | awk -F ':.*## ' '{printf " %-20s %s\n", $$1, $$2}' dependencies: ## Download and cache Gutenberg XCFrameworks ./Scripts/download-gutenberg-xcframeworks.sh + +sim-login: ## Sign an iOS Simulator into WordPress.com (vars: DEVICE, APP, RESET=1; token from ~/.wpcom-token) + ./Scripts/sim-signin.sh $(if $(APP),--app $(APP)) $(if $(DEVICE),--device $(DEVICE)) $(if $(RESET),--reset) $(ARGS) diff --git a/Scripts/sim-signin.sh b/Scripts/sim-signin.sh new file mode 100755 index 000000000000..e581a170bdb9 --- /dev/null +++ b/Scripts/sim-signin.sh @@ -0,0 +1,191 @@ +#!/bin/bash +# +# Sign an iOS Simulator into WordPress.com with a bearer token, in one command. +# +# Wraps `xcrun simctl launch`, passing the `-wpcom-token` launch argument the app reads on +# the login screen to finish WordPress.com sign-in automatically — no taps required. +# Build and install the app on the simulator first (e.g. from Xcode). + +set -euo pipefail + +usage() { + cat <<'EOF' +Sign an iOS Simulator into WordPress.com with a bearer token, in one command. + +Usage: + Scripts/sim-signin.sh [options] + +Options: + -a, --app App to sign in (default: jetpack) + -d, --device Target simulator (default: the running one; prompts if several) + -r, --reset Uninstall and reinstall the app first, for a clean slate + -h, --help Show this help + +Examples: + Scripts/sim-signin.sh # Jetpack, booted simulator + Scripts/sim-signin.sh --app wordpress # WordPress + Scripts/sim-signin.sh --reset # reinstall for a clean slate first + +The WordPress.com bearer token is read from (in order): + 1. WPCOM_TOKEN environment variable + 2. ~/.wpcom-token file + 3. otherwise the script prompts you to paste one (and offers to save it to ~/.wpcom-token) + +It is deliberately NOT accepted as a command-line flag: a token passed on the command line +would be saved in your shell history. Set WPCOM_TOKEN or write ~/.wpcom-token once. +EOF +} + +app="jetpack" +device="" +reset=false +token="" + +require_value() { + # $1 = option name, $2 = remaining argument count ($#) + if [[ "$2" -lt 2 ]]; then + echo "error: $1 requires a value" >&2 + exit 1 + fi +} + +resolve_device() { + # Set `device` to a booted simulator: the only one if just one is booted, otherwise + # prompt to choose. Errors if none are booted. Called only when --device was omitted. + local udids=() names=() line udid name + while IFS= read -r line; do + # `|| true` so a UUID-less line (or a SIGPIPE from `head` under `pipefail`) doesn't + # trip `set -e` and abort the whole script before the `continue` below can skip it. + udid=$(printf '%s\n' "$line" | grep -oiE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' | head -1 || true) + [[ -z "$udid" ]] && continue + name=$(printf '%s\n' "$line" | sed -E 's/^[[:space:]]*//; s/[[:space:]]*\([0-9A-Fa-f-]{36}\).*$//') + udids+=("$udid") + names+=("$name") + done < <(xcrun simctl list devices booted | grep -F "(Booted)") + + local n=${#udids[@]} + if [[ "$n" -eq 0 ]]; then + echo "error: no booted simulator. Boot one (open Simulator, or 'xcrun simctl boot '), or pass --device ." >&2 + exit 1 + fi + if [[ "$n" -eq 1 ]]; then + device="${udids[0]}" + echo "Using the only booted simulator: ${names[0]} (${device})" + return + fi + + echo "Multiple simulators are booted — choose one:" >&2 + local i + for (( i = 0; i < n; i++ )); do + printf " %2d) %s (%s)\n" "$(( i + 1 ))" "${names[i]}" "${udids[i]}" >&2 + done + local sel + while true; do + printf "Select a simulator [1-%d]: " "$n" >&2 + if ! read -r sel; then + echo >&2 + echo "error: no selection made; re-run with --device ." >&2 + exit 1 + fi + if [[ "$sel" =~ ^[0-9]+$ ]] && [[ "$sel" -ge 1 ]] && [[ "$sel" -le "$n" ]]; then + device="${udids[sel - 1]}" + echo "Using ${names[sel - 1]} (${device})" + return + fi + echo " not a valid choice: '$sel'" >&2 + done +} + +reset_app() { + # A clean slate: uninstall the app (which removes its entire data container — Core Data, + # UserDefaults, caches, cookies) and reinstall the same bundle. Unlike the in-app + # `-ui-test-reset-everything` wipe, `simctl install` is synchronous, so there's no window to + # race before the sign-in launch, and it clears more than just Core Data + UserDefaults. + local app_bundle bundle_name + app_bundle=$(xcrun simctl get_app_container "$device" "$bundle_id" app 2>/dev/null || true) + if [[ -z "$app_bundle" || ! -d "$app_bundle" ]]; then + echo "error: can't reset — $app ($bundle_id) isn't installed on '$device'. Build and install it first (e.g. from Xcode)." >&2 + exit 1 + fi + bundle_name=$(basename "$app_bundle") + + # Uninstall deletes the installed bundle in place, so stage a copy to reinstall from. + # `reset_staging` is intentionally global so the EXIT trap can clean it up at script exit. + reset_staging=$(mktemp -d) + trap 'rm -rf "$reset_staging"' EXIT + cp -R "$app_bundle" "$reset_staging/$bundle_name" + + echo "Resetting $app (uninstall + reinstall for a clean slate)…" + xcrun simctl uninstall "$device" "$bundle_id" + xcrun simctl install "$device" "$reset_staging/$bundle_name" +} + +while [[ $# -gt 0 ]]; do + case "$1" in + -a|--app) require_value "$1" "$#"; app="$2"; shift 2 ;; + -d|--device) require_value "$1" "$#"; device="$2"; shift 2 ;; + -r|--reset) reset=true; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "error: unknown argument '$1'" >&2; usage >&2; exit 1 ;; + esac +done + +# The token is read from the WPCOM_TOKEN environment variable, then a ~/.wpcom-token file. +# It is intentionally not a command-line argument, to keep it out of shell history. +if [[ -z "$token" ]]; then + token="${WPCOM_TOKEN:-}" +fi +if [[ -z "$token" && -f "$HOME/.wpcom-token" ]]; then + token="$(tr -d '[:space:]' < "$HOME/.wpcom-token")" +fi + +# Nothing found anywhere — prompt for one. Input is hidden, since it's a secret. +if [[ -z "$token" ]]; then + printf "No WordPress.com token found (WPCOM_TOKEN / ~/.wpcom-token).\n" >&2 + printf "Paste a bearer token (hidden), or press Return to cancel: " >&2 + read -rs token || true + printf "\n" >&2 + token="$(printf '%s' "$token" | tr -d '[:space:]')" + if [[ -n "$token" ]]; then + printf "Token received (%s chars).\n" "${#token}" >&2 + printf "Save it to ~/.wpcom-token for next time? [y/N] " >&2 + read -r save_reply || true + if [[ "$save_reply" =~ ^[Yy]$ ]]; then + token_file="$HOME/.wpcom-token" + # umask keeps the new file owner-only; chmod covers an existing, looser file. + if (umask 077; printf '%s\n' "$token" > "$token_file"); then + chmod 600 "$token_file" 2>/dev/null || true + printf "Saved to %s (mode 600).\n" "$token_file" >&2 + else + printf "warning: could not write %s; continuing without saving.\n" "$token_file" >&2 + fi + fi + fi +fi + +if [[ -z "$token" ]]; then + echo "error: no token — set WPCOM_TOKEN, write ~/.wpcom-token, or paste one when prompted" >&2 + usage >&2 + exit 1 +fi + +case "$app" in + jetpack) bundle_id="com.automattic.jetpack" ;; + wordpress) bundle_id="org.wordpress" ;; + *) echo "error: unknown app '$app' (expected 'jetpack' or 'wordpress')" >&2; exit 1 ;; +esac + +# When no --device was given, target the running simulator (prompting if several are booted). +if [[ -z "$device" ]]; then + resolve_device +fi + +echo "Signing $app ($bundle_id) into WordPress.com on simulator '$device'…" + +if [[ "$reset" == true ]]; then + reset_app +fi + +xcrun simctl launch --terminate-running-process "$device" "$bundle_id" -wpcom-token "$token" + +echo "Done. The app signs in automatically from the login screen." diff --git a/WordPress/Classes/Login/WordPressDotComAuthenticator.swift b/WordPress/Classes/Login/WordPressDotComAuthenticator.swift index fba48eead683..1c3b4315cea4 100644 --- a/WordPress/Classes/Login/WordPressDotComAuthenticator.swift +++ b/WordPress/Classes/Login/WordPressDotComAuthenticator.swift @@ -57,6 +57,15 @@ struct WordPressDotComAuthenticator { rawValue: "WordPressDotComAuthenticatorCallbackURL" ) + /// A WordPress.com bearer token supplied as a launch argument, used to sign a Simulator in + /// without the web flow. Reads `-wpcom-token`, falling back to the legacy + /// `-ui-test-wpcom-token` argument for backward compatibility. + static var launchArgumentToken: String? { + let defaults = UserDefaults.standard + return defaults.string(forKey: "wpcom-token") + ?? defaults.string(forKey: "ui-test-wpcom-token") + } + static func redirectURI(for scheme: String) -> String { "\(scheme)://oauth2-callback" } @@ -131,7 +140,7 @@ struct WordPressDotComAuthenticator { let token: String do { - if let tokenLaunchArgument = UserDefaults.standard.string(forKey: "ui-test-wpcom-token") { + if let tokenLaunchArgument = Self.launchArgumentToken { token = tokenLaunchArgument } else { token = try await authenticate( diff --git a/WordPress/Classes/System/Root View/RootViewCoordinator.swift b/WordPress/Classes/System/Root View/RootViewCoordinator.swift index 2de493c3b197..da952d486b5e 100644 --- a/WordPress/Classes/System/Root View/RootViewCoordinator.swift +++ b/WordPress/Classes/System/Root View/RootViewCoordinator.swift @@ -92,6 +92,7 @@ class RootViewCoordinator { self.rootViewPresenter = nil WordPressAppDelegate.shared?.autoSignInUITestSite() + WordPressAppDelegate.shared?.autoSignInWPComAccountFromLaunchArgumentIfNeeded() } private func createPresenter(_ appType: AppUIType) -> RootViewPresenter { diff --git a/WordPress/Classes/System/WordPressAppDelegate.swift b/WordPress/Classes/System/WordPressAppDelegate.swift index 3335dec34d24..493677ba4ce5 100644 --- a/WordPress/Classes/System/WordPressAppDelegate.swift +++ b/WordPress/Classes/System/WordPressAppDelegate.swift @@ -63,6 +63,10 @@ public class WordPressAppDelegate: UIResponder, UIApplicationDelegate { private let remoteFeatureFlagStore = RemoteFeatureFlagStore() private let remoteConfigStore = RemoteConfigStore() + /// Limits the `-wpcom-token` launch-argument sign-in to a single attempt per process, + /// so signing out (which returns to the login screen) doesn't immediately re-sign in. + private var didAttemptLaunchArgumentSignIn = false + private var mainContext: NSManagedObjectContext { ContextManager.shared.mainContext } @@ -901,4 +905,36 @@ extension WordPressAppDelegate { } ) } + + /// Completes WordPress.com sign-in automatically when a bearer token is supplied via the + /// `-wpcom-token` launch argument, so a Simulator can be signed in with a single + /// `simctl launch` and no taps on the login screen. + /// + /// No-op when the argument is absent, a WordPress.com account is already signed in, or a + /// launch-argument sign-in was already attempted this process. The last case matters because + /// signing out returns to the login screen — without it, the still-present token would + /// immediately sign the account back in. + func autoSignInWPComAccountFromLaunchArgumentIfNeeded() { + guard WordPressDotComAuthenticator.launchArgumentToken != nil else { + return + } + guard !didAttemptLaunchArgumentSignIn else { + return + } + guard (try? WPAccount.lookupDefaultWordPressComAccount(in: ContextManager.shared.mainContext)) == nil else { + return + } + guard let presenter = window?.topmostPresentedViewController else { + return + } + didAttemptLaunchArgumentSignIn = true + Task { @MainActor in + guard await WordPressDotComAuthenticator().signIn(from: presenter, context: .default) != nil else { + return + } + // Creating the account isn't enough on its own — without this the app stays on the + // login screen. Swap the window root to the signed-in app (no epilogue, so no taps). + windowManager.showUI() + } + } } diff --git a/docs/simulator-sign-in.md b/docs/simulator-sign-in.md index dc60b0721985..ca4a14f81d57 100644 --- a/docs/simulator-sign-in.md +++ b/docs/simulator-sign-in.md @@ -1,17 +1,38 @@ # Simulator Sign-In -Pass credentials as launch arguments, then tap through the sign-in screen to complete sign-in. +Pass credentials as launch arguments. WordPress.com sign-in then completes automatically; the self-hosted flow still needs a couple of taps. ## WordPress.com account -Launch with a bearer token: +The quickest path is **`make sim-login`** — it signs the running simulator into WordPress.com, prompting you to choose when more than one simulator is booted, and to paste a token if none is configured: + +```bash +make sim-login # sign the running simulator into Jetpack +make sim-login APP=wordpress # WordPress instead of Jetpack +make sim-login RESET=1 # reinstall the app first (clean slate) +make sim-login DEVICE= # target a specific simulator +``` + +`make sim-login` forwards to `Scripts/sim-signin.sh`, which you can run directly with the same options as flags — `--app`, `--device`, `--reset`: + +```bash +Scripts/sim-signin.sh --app wordpress --reset +``` + +`--reset` uninstalls and reinstalls the app for a clean slate — more thorough than the in-app data wipe (it also clears caches and cookies) and race-free (the reinstall is synchronous). The app must already be installed. + +The token is resolved from `WPCOM_TOKEN`, then `~/.wpcom-token`; if none is set the script prompts you to paste one (hidden) and offers to save it to `~/.wpcom-token` for next time. There's deliberately no command-line token flag — a token passed as an argument would be saved in your shell history. Set it once (export `WPCOM_TOKEN` in `~/.zshrc`, or write `~/.wpcom-token`) and you never pass it again. + +Or launch directly with the bearer token. The app finishes sign-in automatically while it sits on the login screen — no taps required: ```bash xcrun simctl launch --terminate-running-process booted org.wordpress \ - -ui-test-wpcom-token + -wpcom-token ``` -On the sign-in screen, tap **"Continue with WordPress.com"**. +The legacy `-ui-test-wpcom-token` argument is still accepted for backward compatibility. + +This raw form also saves the token in your shell history — `make sim-login` avoids that, since you never type the token. (Either way the token appears briefly in `ps` while `simctl launch` runs; that's inherent to passing it as a launch argument.) ## Self-hosted site