diff --git a/.gitignore b/.gitignore index b115b8519..8b15a7c0f 100644 --- a/.gitignore +++ b/.gitignore @@ -40,5 +40,10 @@ chrome.crx # env .env +# App Store Connect credentials for scripts/release_safari.sh — deliberately not +# .env, which build_src.sh ships inside the source-review package +.env.release # generated by scripts/build_src.sh for reproducible source-review rebuilds build-hash.json + +AuthKey_*.p8 diff --git a/README.md b/README.md index d22c41170..c871856f8 100755 --- a/README.md +++ b/README.md @@ -51,8 +51,13 @@ To open as a tab: ### Load Wallet Extension in Safari -1. Open `build/safari` folder from `builds.zip` or `builds` folder when building from sources. -2. Double click on "Casper Wallet.app" file. +The Safari build is distributed through TestFlight — install it from there, +then follow steps 3-4 below. + +To run it from sources instead: + +1. Run `npm run build:safari` to produce the web-extension resources in `build/safari`. +2. Open `xcode-project/Casper Wallet/Casper Wallet.xcodeproj` and run the "Casper Wallet" scheme. 3. Follow instructions and enable Casper Wallet in opened Extensions Preferences window. 4. Open Safari and enable unsigned extensions. Extension should be available. @@ -102,13 +107,8 @@ Firefox: npm run start:firefox ``` -Safari: - -```shell -npm run start:safari -``` - -You can run all these commands in parallel. +You can run both of these commands in parallel. Safari has no watch mode — +build it with `npm run build:safari` and run the app from Xcode. ### Build deliverable from sources (`build` folder) @@ -124,7 +124,7 @@ Firefox: npm run build:firefox ``` -Safari: +Safari (web-extension resources only — see below): ```shell npm run build:safari @@ -136,6 +136,58 @@ All at once: npm run build:all ``` +For Safari both commands stop at the web-extension resources in `build/safari`. +The app around them lives in `xcode-project/Casper Wallet` and is built by +`npm run release:safari` (see below), which reads those resources from +`build/safari` directly — they are not part of the archive below. + +`build:all` bundles the Chrome and Firefox builds into +`build/casper-wallet-rc#.zip`, taking `` from +`package.json`. The rc number restarts at 1 whenever that version changes and +otherwise continues from the highest archive already sitting in `build/` — so +keep the previous archives there if you want the count to carry on. + +Alongside it come `build/casper-wallet-chrome-rc#.zip` and its +`firefox` counterpart, ready to upload to the stores as they are: each holds +the extension at the zip root, which is what the stores expect. + +### Safari release to TestFlight + +`npm run release:safari` takes the Safari extension the whole way: it rebuilds +`build/safari`, stamps the version into the Xcode project, archives the app and +uploads it to App Store Connect. It needs macOS with Xcode and an App Store +Connect API key, described by a gitignored `.env.release` in the repo root: + +```shell +ASC_KEY_PATH=AuthKey_XXXXXXXXXX.p8 +ASC_KEY_ID=XXXXXXXXXX +ASC_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +ASC_TEAM_ID=XXXXXXXXXX +``` + +Keep these out of `.env`, which `npm run build:src` copies into the source-review +package — the script refuses to run if it finds `ASC_` values there. Exported +variables win over the file. `ASC_KEY_PATH` is resolved against the repo root and +`.p8` files are excluded from that package, so the key may sit next to it under +its Apple-issued name. + +`MARKETING_VERSION` comes from `package.json`, so the app version is bumped by +editing `package.json` and nothing else. `CURRENT_PROJECT_VERSION` is incremented +on every upload, because App Store Connect rejects a build number it has already +seen; the script prints the `git commit` that records it in +`Casper Wallet.xcodeproj/project.pbxproj`. A run that ships nothing — a failure, +or `--no-upload` — restores it instead. `SAFARI_BUILD=` overrides it for one +run. + +Signing is left to Xcode's automatic mode, which needs a **Mac Installer +Distribution** certificate in the login keychain to sign the `.pkg`. An API key +with the App Manager or Admin role mints one on demand; otherwise create it once +in the Apple Developer portal. To exercise all of that without sending a build: + +```shell +npm run release:safari -- --no-upload +``` + ### Reproducible builds from the source package `npm run build:src` produces `build/casper-wallet-src#.zip`, the package diff --git a/constants.js b/constants.js index 2d786135d..faced7818 100644 --- a/constants.js +++ b/constants.js @@ -1,6 +1,5 @@ const { NODE_ENV, BROWSER: browserEnvVar } = require('./utils/env'); -const extensionName = 'Casper Wallet'; const buildRootDir = ['test', 'production'].includes(NODE_ENV) ? 'build' : 'output'; @@ -8,7 +7,9 @@ const buildRootDir = ['test', 'production'].includes(NODE_ENV) const ExtensionBuildPath = { Chrome: `${buildRootDir}/chrome`, Firefox: `${buildRootDir}/firefox`, - Safari: `${buildRootDir}/safari/${extensionName}` + // Web-extension resources only. The Xcode project reads them from here; the + // .app it produces is built and shipped from Xcode, never into this tree. + Safari: `${buildRootDir}/safari` }; const ManifestPath = { @@ -23,7 +24,6 @@ const isFirefox = browserEnvVar && browserEnvVar === 'firefox'; module.exports = { ExtensionBuildPath, - extensionName, browserEnvVar, ManifestPath, isFirefox, diff --git a/package-lock.json b/package-lock.json index d5006b091..95da3d9c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -115,8 +115,7 @@ "webextension-polyfill": "0.12.0", "webpack": "^5.109.2", "webpack-bundle-analyzer": "^5.3.1", - "webpack-dev-server": "^6.0.0", - "xcode-build-webpack-plugin": "^1.0.2" + "webpack-dev-server": "^6.0.0" }, "engines": { "node": ">=22", @@ -8344,16 +8343,6 @@ "node": ">= 0.8" } }, - "node_modules/command-exists-promise": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/command-exists-promise/-/command-exists-promise-2.0.2.tgz", - "integrity": "sha512-T6PB6vdFrwnHXg/I0kivM3DqaCGZLjjYSOe0a5WgFKcz1sOnmOeIjnhQPXVXX3QjVbLyTJ85lJkX6lUpukTzaA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/commander": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", @@ -23068,16 +23057,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/xcode-build-webpack-plugin": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/xcode-build-webpack-plugin/-/xcode-build-webpack-plugin-1.0.2.tgz", - "integrity": "sha512-aQu8PHh55SEFn2JS9UuoCcbVabKgWc9jGCIIawe8acGsnuif61X+/kaA4ImSl2/+cyztsdVFxYoNUa60UuEs+Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "command-exists-promise": "^2.0.2" - } - }, "node_modules/xdg-basedir": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", diff --git a/package.json b/package.json index b862c505e..5eee1b530 100644 --- a/package.json +++ b/package.json @@ -20,23 +20,22 @@ "build:manifest:v2:safari": "NODE_ENV=production BROWSER=safari HASH=$(git rev-parse HEAD) node utils/build.js", "build:manifest:v3:chrome": "NODE_ENV=production BROWSER=chrome HASH=$(git rev-parse HEAD) node utils/build.js", "dev:build:manifest:v2:firefox": "NODE_ENV=development BROWSER=firefox HASH=$(git rev-parse HEAD) node utils/build.js", - "dev:build:manifest:v2:safari": "NODE_ENV=development BROWSER=safari HASH=$(git rev-parse HEAD) node utils/build.js", "dev:build:manifest:v3:chrome": "NODE_ENV=development BROWSER=chrome HASH=$(git rev-parse HEAD) node utils/build.js", "build:chrome": "rm -rf ./build/chrome && npm run build:manifest:v3:chrome", "build:analyze": "ANALYZE=true npm run build:chrome", "package:chrome": "web-ext --no-config-discovery build -s ./build/chrome --overwrite-dest", "build:firefox": "rm -rf ./build/firefox && npm run build:manifest:v2:firefox", "package:firefox": "web-ext --no-config-discovery build -s ./build/firefox --overwrite-dest", - "build:safari": "npm run build:manifest:v2:safari && ./scripts/build_safari.sh", + "build:safari": "rm -rf ./build/safari && npm run build:manifest:v2:safari", "build:src": "./scripts/build_src.sh", "build:all": "./scripts/build_all.sh", + "release:safari": "./scripts/release_safari.sh", "dev:build:chrome": "rm -rf ./output/chrome && npm run dev:build:manifest:v3:chrome", "dev:build:firefox": "rm -rf ./output/firefox && npm run dev:build:manifest:v2:firefox", "dev:chrome": "npm run dev:build:chrome && NODE_ENV=development BROWSER=chrome HASH=$(git rev-parse HEAD) PORT=3001 node utils/webserver.js", "start:chrome": "npm run dev:chrome", "dev:firefox": "npm run dev:build:firefox && NODE_ENV=development BROWSER=firefox HASH=$(git rev-parse HEAD) PORT=3002 node utils/webserver.js", "start:firefox": "npm run dev:firefox", - "start:safari": "npm run dev:build:manifest:v2:safari && ./convert-web-extension.sh && NODE_ENV=development BROWSER=safari PORT=3003 node utils/webserver.js", "format:check": "prettier --check 'src/*.{js,jsx,ts,tsx,json,md}'", "format:write": "prettier --write 'src/*.{js,jsx,ts,tsx,json,md}'", "lint": "eslint ./src", @@ -198,8 +197,7 @@ "webextension-polyfill": "0.12.0", "webpack": "^5.109.2", "webpack-bundle-analyzer": "^5.3.1", - "webpack-dev-server": "^6.0.0", - "xcode-build-webpack-plugin": "^1.0.2" + "webpack-dev-server": "^6.0.0" }, "lavamoat": { "allowScripts": { diff --git a/scripts/build-xcode-project.sh b/scripts/build-xcode-project.sh deleted file mode 100755 index aa3380139..000000000 --- a/scripts/build-xcode-project.sh +++ /dev/null @@ -1,4 +0,0 @@ -# Building safari extension binary using XCode Project -cd ./xcode-project/Casper\ Wallet -xcodebuild -quiet -project Casper\ Wallet.xcodeproj -alltargets -configuration Release -cd ../.. diff --git a/scripts/build_all.sh b/scripts/build_all.sh index 6548bf7fd..ecdcce48a 100755 --- a/scripts/build_all.sh +++ b/scripts/build_all.sh @@ -1,3 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")/.." + HASH=$(git rev-parse --short HEAD) +VERSION=$(node -p "require('./package.json').version") + +# The rc counter runs per version: bumping the version in package.json starts +# over at rc1, a rebuild of the same version continues after the highest +# archive already in build/. Clearing build/ therefore restarts the count. +last_rc=0 +for archive in "build/casper-wallet-${VERSION}rc"*"#"*.zip; do + [ -e "$archive" ] || continue + rc=${archive#"build/casper-wallet-${VERSION}rc"} + rc=${rc%%#*} + case $rc in + '' | *[!0-9]*) continue ;; + esac + if [ "$rc" -gt "$last_rc" ]; then + last_rc=$rc + fi +done + +RC=$((last_rc + 1)) +STAMP="${VERSION}rc${RC}#${HASH}" + +npm run build:chrome +npm run build:firefox +npm run build:safari + +# Store uploads want the extension at the zip root, so each one is zipped from +# inside its own build dir. Dotfiles are dropped: a Finder visit leaves a +# .DS_Store behind, and the stores flag it. +for target in chrome firefox; do + store_archive="casper-wallet-${target}-${STAMP}.zip" + rm -f "build/$store_archive" + (cd "./build/$target" && zip -qr -X "../$store_archive" . -x '.*' '*/.*') + echo "Store archive: build/$store_archive" +done + +# Named explicitly, not `./*`: the archives above and the ones from earlier runs +# live in build/ too, and `./*` would nest all of them into this one. Safari is +# out on purpose — Xcode reads those resources straight from build/safari and +# ships them inside the app. +rm -f "build/casper-wallet-${STAMP}.zip" +(cd ./build && zip -qr "casper-wallet-${STAMP}.zip" chrome firefox) + +npm run build:src -npm run build:chrome && npm run build:firefox && cd ./build && zip -r casper-wallet-2.7.0rc1#$HASH.zip ./* && npm run build:src +echo "Archive: build/casper-wallet-${STAMP}.zip" diff --git a/scripts/build_safari.sh b/scripts/build_safari.sh deleted file mode 100755 index 633f4b05f..000000000 --- a/scripts/build_safari.sh +++ /dev/null @@ -1,7 +0,0 @@ -rm -rf build/safari - -./scripts/build-xcode-project.sh - -# Copy binary -mkdir ./build/safari -cp -R xcode-project/Casper\ Wallet/build/Release/Casper\ Wallet.app ./build/safari/Casper\ Wallet.app diff --git a/scripts/build_src.sh b/scripts/build_src.sh index 536aca40c..675684a5d 100755 --- a/scripts/build_src.sh +++ b/scripts/build_src.sh @@ -18,7 +18,10 @@ trap 'rm -f build-hash.json' EXIT printf '{\n "commitHash": "%s"\n}\n' "$HASH" > build-hash.json # `*.*` already covers build-hash.json; naming it keeps the dependency visible. -zip -r "casper-wallet-src#${HASH:0:7}.zip" src scripts utils *.* .env build-hash.json +# It would also sweep up the App Store Connect key that release_safari.sh reads +# from the repo root, and this package goes to store reviewers. Nothing the build +# needs is a .p8, so the exclusion covers the key under any name. +zip -r "casper-wallet-src#${HASH:0:7}.zip" src scripts utils *.* .env build-hash.json -x '*.p8' mkdir -p build mv "casper-wallet-src#${HASH:0:7}.zip" build/ diff --git a/scripts/create-xcode-project.sh b/scripts/create-xcode-project.sh index 36492f876..959e80d71 100755 --- a/scripts/create-xcode-project.sh +++ b/scripts/create-xcode-project.sh @@ -1,3 +1,3 @@ -# Converting Firefox build folder into a Xcode Project -# Firefox extension is built on manifest V2, the same version Safari required -xcrun safari-web-extension-converter ./build/firefox --project-location xcode-project --bundle-identifier software.make.Casper-Wallet --macos-only --no-open --no-prompt +# Converting the Safari build folder into a Xcode Project. +# It carries manifest V2 (src/manifest.v2.safari.json), the version Safari requires. +xcrun safari-web-extension-converter ./build/safari --project-location xcode-project --bundle-identifier software.make.Casper-Wallet --macos-only --no-open --no-prompt diff --git a/scripts/release_safari.sh b/scripts/release_safari.sh new file mode 100755 index 000000000..526d2e904 --- /dev/null +++ b/scripts/release_safari.sh @@ -0,0 +1,199 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +XCODEPROJ="xcode-project/Casper Wallet/Casper Wallet.xcodeproj" +PBXPROJ="$XCODEPROJ/project.pbxproj" +SCHEME="Casper Wallet" +ENV_FILE="${SAFARI_RELEASE_ENV:-.env.release}" + +upload=1 +for arg in "$@"; do + case $arg in + --no-upload) upload=0 ;; + *) + echo "Unknown argument: $arg" >&2 + echo "Usage: $0 [--no-upload]" >&2 + exit 1 + ;; + esac +done + +# `.env` is not the place for these: build_src.sh copies it by name into the +# source package handed to store reviewers, so anything in it ships to Apple. +if [ -f .env ] && grep -q '^[[:space:]]*\(export[[:space:]]\)\?ASC_' .env; then + echo "Move the ASC_* values out of .env and into $ENV_FILE." >&2 + echo ".env is bundled into the source-review package by 'npm run build:src'." >&2 + exit 1 +fi + +# Whatever is already exported wins, so a one-off key can be passed on the +# command line without editing the file. +key_path_from_env=${ASC_KEY_PATH:-} +key_id_from_env=${ASC_KEY_ID:-} +issuer_from_env=${ASC_ISSUER_ID:-} +team_from_env=${ASC_TEAM_ID:-} + +if [ -f "$ENV_FILE" ]; then + set -a + # shellcheck disable=SC1090 + . "$ENV_FILE" + set +a +fi + +ASC_KEY_PATH=${key_path_from_env:-${ASC_KEY_PATH:-}} +ASC_KEY_ID=${key_id_from_env:-${ASC_KEY_ID:-}} +ASC_ISSUER_ID=${issuer_from_env:-${ASC_ISSUER_ID:-}} +ASC_TEAM_ID=${team_from_env:-${ASC_TEAM_ID:-}} + +missing="" +for var in ASC_KEY_PATH ASC_KEY_ID ASC_ISSUER_ID ASC_TEAM_ID; do + if [ -z "${!var:-}" ]; then + missing="$missing $var" + fi +done +if [ -n "$missing" ]; then + cat >&2 <&2 + exit 1 +fi + +VERSION=$(node -p "require('./package.json').version") + +# MARKETING_VERSION must be one to three dot-separated integers. A prerelease +# suffix in package.json would sail through the build and be rejected on upload, +# so it stops here instead. +if ! printf '%s' "$VERSION" | grep -qE '^[0-9]+(\.[0-9]+){0,2}$'; then + echo "package.json version '$VERSION' is not a valid MARKETING_VERSION" >&2 + exit 1 +fi + +# The four CURRENT_PROJECT_VERSION entries (Debug/Release x app/extension) are +# written as one value, so a split between them means someone edited the project +# by hand and there is no single number to increment from. +current_builds=$(sed -n 's/^[[:space:]]*CURRENT_PROJECT_VERSION = \(.*\);$/\1/p' "$PBXPROJ" | sort -u) +if [ "$(printf '%s\n' "$current_builds" | wc -l | tr -d ' ')" -ne 1 ]; then + echo "CURRENT_PROJECT_VERSION differs across build configurations:" >&2 + printf ' %s\n' $current_builds >&2 + exit 1 +fi + +BUILD="${SAFARI_BUILD:-$((current_builds + 1))}" + +echo "Casper Wallet $VERSION ($BUILD)" + +npm run build:safari + +WORK_DIR=$(mktemp -d) +EXPORT_PLIST="$WORK_DIR/ExportOptions.plist" +PBXPROJ_BACKUP="$WORK_DIR/project.pbxproj" +cp "$PBXPROJ" "$PBXPROJ_BACKUP" + +# A failed archive or upload leaves the bumped build number behind otherwise, and +# the next run would increment from a number that never reached TestFlight. +cleanup() { + status=$? + if [ $status -ne 0 ]; then + cp "$PBXPROJ_BACKUP" "$PBXPROJ" + echo "Failed — restored the original build number in project.pbxproj" >&2 + elif [ "$upload" -eq 0 ]; then + # A dry run ships nothing, so the number it used is still free. + cp "$PBXPROJ_BACKUP" "$PBXPROJ" + fi + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +sed -i '' \ + -e "s/^\([[:space:]]*\)CURRENT_PROJECT_VERSION = .*;\$/\1CURRENT_PROJECT_VERSION = $BUILD;/" \ + -e "s/^\([[:space:]]*\)MARKETING_VERSION = .*;\$/\1MARKETING_VERSION = $VERSION;/" \ + "$PBXPROJ" + +if [ "$upload" -eq 1 ]; then + destination=upload +else + destination=export +fi + +cat > "$EXPORT_PLIST" < + + + + method + app-store-connect + destination + $destination + teamID + $ASC_TEAM_ID + + +EOF + +ARCHIVE="build/safari-app/CasperWallet-${VERSION}-${BUILD}.xcarchive" +EXPORT_DIR="build/safari-app/CasperWallet-${VERSION}-${BUILD}" +rm -rf "$ARCHIVE" "$EXPORT_DIR" + +# DEVELOPMENT_TEAM is deliberately absent from the project: passing it here keeps +# the only tracked edits to this file the two version lines above. +xcodebuild archive \ + -project "$XCODEPROJ" \ + -scheme "$SCHEME" \ + -configuration Release \ + -destination 'generic/platform=macOS' \ + -archivePath "$ARCHIVE" \ + DEVELOPMENT_TEAM="$ASC_TEAM_ID" \ + -allowProvisioningUpdates \ + -authenticationKeyPath "$ASC_KEY_PATH" \ + -authenticationKeyID "$ASC_KEY_ID" \ + -authenticationKeyIssuerID "$ASC_ISSUER_ID" + +xcodebuild -exportArchive \ + -archivePath "$ARCHIVE" \ + -exportPath "$EXPORT_DIR" \ + -exportOptionsPlist "$EXPORT_PLIST" \ + -allowProvisioningUpdates \ + -authenticationKeyPath "$ASC_KEY_PATH" \ + -authenticationKeyID "$ASC_KEY_ID" \ + -authenticationKeyIssuerID "$ASC_ISSUER_ID" + +echo +if [ "$upload" -eq 1 ]; then + cat <