diff --git a/.github/scripts/ios-altool-check.sh b/.github/scripts/ios-altool-check.sh new file mode 100644 index 00000000..488e3230 --- /dev/null +++ b/.github/scripts/ios-altool-check.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Decides whether an altool run worked, from its exit code AND its output +# (#1040). altool has exited 0 with an error in its output, so a clean exit alone +# proves nothing, and nothing short of its success line counts as success. +# +# usage: ios-altool-check.sh +set -eu + +mode="${1:?mode}" +code="${2:?exit code}" +output="${3:?output file}" + +case "${mode}" in + validate) success='VALIDATION SUCCEEDED|No errors validating' ;; + upload) success='UPLOAD SUCCEEDED|No errors uploading' ;; + *) + echo "::error::Unknown altool mode '${mode}'." + exit 1 + ;; +esac + +if [ "${code}" != "0" ]; then + echo "::error::altool ${mode} exited ${code}." + exit 1 +fi +if grep -Eq '\*\*\* Error|ERROR:|FAILED' "${output}"; then + echo "::error::altool ${mode} exited 0, but its output contains an error." + exit 1 +fi +if ! grep -Eq "${success}" "${output}"; then + echo "::error::altool ${mode} exited 0, but its output has no success line." + exit 1 +fi +echo "altool ${mode} succeeded." diff --git a/.github/scripts/ios-assert-frameworks.sh b/.github/scripts/ios-assert-frameworks.sh new file mode 100644 index 00000000..2e286f59 --- /dev/null +++ b/.github/scripts/ios-assert-frameworks.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Fails unless a built Runner.app holds the native-asset libraries (#1037). +# An iOS build can compile with them missing, and the app then fails only at +# runtime, when it opens storage or loads translation. +set -eu + +app="${1:?usage: ios-assert-frameworks.sh }" +frameworks="${app}/Frameworks" + +if [ ! -d "${frameworks}" ]; then + echo "::error::No Frameworks folder at ${frameworks}." + exit 1 +fi + +echo "Frameworks in ${app}:" +ls -1 "${frameworks}" + +missing=0 +for lib in sqlite3 llama; do + if ! ls -1 "${frameworks}" | grep -qi "${lib}"; then + echo "::error::No ${lib} framework in ${frameworks}." + missing=1 + fi +done +exit "${missing}" diff --git a/.github/scripts/notary-wait.py b/.github/scripts/notary-wait.py new file mode 100644 index 00000000..b1805a1c --- /dev/null +++ b/.github/scripts/notary-wait.py @@ -0,0 +1,69 @@ +"""Polls Apple's notary service for one submission until it settles or a deadline passes. + +Usage: notary-wait.py +Env: P8 (path to the App Store Connect .p8), NOTARY_KEY_ID, NOTARY_ISSUER_ID + +Exit 0 only on Accepted. Invalid/Rejected print Apple's log and exit 1. Still +In Progress at the deadline exits 1 with how to resume: the submission keeps +going at Apple, and "Re-run failed jobs" polls the same id again (#1109). +""" +import json +import os +import sys +import time +import urllib.error +import urllib.request + +import jwt + +API = "https://appstoreconnect.apple.com/notary/v2/submissions/" +POLL_SECONDS = 60 + +submission_id = sys.argv[1] +deadline = time.time() + int(sys.argv[2]) * 60 +with open(os.environ["P8"], encoding="utf-8") as fh: + private_key = fh.read() + + +def get(url, auth=True): + headers = {} + if auth: + now = int(time.time()) + token = jwt.encode( + {"iss": os.environ["NOTARY_ISSUER_ID"], "iat": now, "exp": now + 600, "aud": "appstoreconnect-v1"}, + private_key, algorithm="ES256", headers={"kid": os.environ["NOTARY_KEY_ID"], "typ": "JWT"}, + ) + headers["Authorization"] = f"Bearer {token}" + with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=30) as response: + return json.load(response) + + +while True: + try: + body = get(API + submission_id) + status = body["data"]["attributes"]["status"] + except (KeyError, TypeError): + print(f"::error::Unexpected notary API response for {submission_id}: {json.dumps(body)[:500]}") + sys.exit(1) + except urllib.error.HTTPError as error: + if error.code < 500: + print(f"::error::Notary API returned HTTP {error.code} for {submission_id}.") + sys.exit(1) + status = f"HTTP {error.code}, retrying" + except urllib.error.URLError as error: + status = f"network error ({error.reason}), retrying" + print(time.strftime("%H:%M:%S"), status, flush=True) + + if status == "Accepted": + sys.exit(0) + if status in ("Invalid", "Rejected"): + print(f"::error::Apple returned {status} for notary submission {submission_id}.") + log_url = get(API + submission_id + "/logs")["data"]["attributes"]["developerLogUrl"] + print(json.dumps(get(log_url, auth=False), indent=2)) + sys.exit(1) + if time.time() > deadline: + print(f"::error::Notary submission {submission_id} is still In Progress at Apple. " + "Nothing is lost: the signed app is kept as an artifact of this run. " + "Use 'Re-run failed jobs' later to check the same submission again.") + sys.exit(1) + time.sleep(POLL_SECONDS) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 96fb3534..1885c4ac 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -131,8 +131,54 @@ jobs: if: needs.classify.outputs.docs_only != 'true' - run: dart run build_runner build --delete-conflicting-outputs if: needs.classify.outputs.docs_only != 'true' - - run: flutter build ios --release --no-codesign --no-pub + - name: Write dart_defines.json if: needs.classify.outputs.docs_only != 'true' + env: + GIPHY_API_KEY: ${{ secrets.GIPHY_API_KEY }} + run: jq -n --arg k "$GIPHY_API_KEY" '{GIPHY_API_KEY:$k}' > dart_defines.json + - run: flutter build ios --release --no-codesign --no-pub --dart-define-from-file=dart_defines.json + if: needs.classify.outputs.docs_only != 'true' + # The assertion must be able to fail, so it runs against an empty app first. + - name: "Negative: the framework assertion refuses an empty app" + if: needs.classify.outputs.docs_only != 'true' + run: | + set -u + mkdir -p "${RUNNER_TEMP}/empty.app/Frameworks" + # Output discarded: its ::error lines would annotate a passing job. + if bash .github/scripts/ios-assert-frameworks.sh "${RUNNER_TEMP}/empty.app" > /dev/null; then + echo "::error::The framework assertion passed on an empty app." + exit 1 + fi + echo "Refused, as required." + - name: Assert the app holds its native-asset frameworks (#1037) + if: needs.classify.outputs.docs_only != 'true' + run: bash .github/scripts/ios-assert-frameworks.sh build/ios/iphoneos/Runner.app + # ios-signed.yml cannot run on a PR, so its output check is proven here, + # with no secrets: every case that must not count as success (#1040). + - name: "Negative: the altool check refuses anything but success" + if: needs.classify.outputs.docs_only != 'true' + run: | + set -u + T="${RUNNER_TEMP}/altool" + mkdir -p "${T}" + printf 'UPLOAD SUCCEEDED with no errors\n' > "${T}/ok.txt" + printf '*** Error: Unable to upload\n' > "${T}/error.txt" + printf 'UPLOAD SUCCEEDED\nERROR: partial\n' > "${T}/mixed.txt" + printf 'something else\n' > "${T}/silent.txt" + : > "${T}/empty.txt" + CHECK=.github/scripts/ios-altool-check.sh + bash "${CHECK}" upload 0 "${T}/ok.txt" || { echo "::error::A clean upload was refused."; exit 1; } + for f in error mixed silent empty; do + if bash "${CHECK}" upload 0 "${T}/${f}.txt" > /dev/null; then + echo "::error::Exit 0 with '${f}' output was accepted." + exit 1 + fi + done + if bash "${CHECK}" upload 1 "${T}/ok.txt" > /dev/null; then + echo "::error::A non-zero exit was accepted." + exit 1 + fi + echo "Refused every bad case, as required." linux: # PINNED, not ubuntu-latest (#929). GitHub moved ubuntu-latest from 24.04 @@ -201,11 +247,35 @@ jobs: if: needs.classify.outputs.docs_only != 'true' - run: dart run build_runner build --delete-conflicting-outputs if: needs.classify.outputs.docs_only != 'true' + - name: Write dart_defines.json + if: needs.classify.outputs.docs_only != 'true' + env: + GIPHY_API_KEY: ${{ secrets.GIPHY_API_KEY }} + run: jq -n --arg k "$GIPHY_API_KEY" '{GIPHY_API_KEY:$k}' > dart_defines.json - name: Enable native assets (bundles sqlite3 + llamadart libs, #445) if: needs.classify.outputs.docs_only != 'true' run: flutter config --enable-native-assets - - run: flutter build macos --release --no-pub + - run: flutter build macos --release --no-pub --dart-define-from-file=dart_defines.json + if: needs.classify.outputs.docs_only != 'true' + # ditto, not zip: it preserves the .app bundle's symlinks, which plain + # zip flattens into copies that break code-signing validation (#1108). + # lipo output is informational: whether the build plus native assets + # comes out universal or arm64-only is recorded here, not assumed. + - name: Zip macOS app if: needs.classify.outputs.docs_only != 'true' + run: | + set -eu + APP=build/macos/Build/Products/Release/offband_meshcore.app + lipo -archs "${APP}/Contents/MacOS/offband_meshcore" || true + ditto -c -k --keepParent "${APP}" offband-macos.zip + - name: Upload macOS build + if: needs.classify.outputs.docs_only != 'true' + uses: actions/upload-artifact@v4 + with: + name: offband-macos-r${{ github.run_number }}-${{ github.event.pull_request.head.sha || github.sha }} + path: offband-macos.zip + if-no-files-found: error + retention-days: 14 web: runs-on: ubuntu-latest diff --git a/.github/workflows/ios-signed.yml b/.github/workflows/ios-signed.yml new file mode 100644 index 00000000..442cc7ad --- /dev/null +++ b/.github/workflows/ios-signed.yml @@ -0,0 +1,300 @@ +name: iOS signed build and upload + +# Builds a signed IPA and uploads it to App Store Connect (#1038, epic #1013). +# Called by testflight.yml and release-signed.yml. +# +# SECURITY: this repo is PUBLIC, and this job holds the Apple Distribution key +# and the App Store Connect API key. +# +# - NEVER call this from a `pull_request` workflow. A PR (incl. from a fork) +# must never run a job that can read these secrets. +# - Only this job references the `apple-signing` Environment, so only it +# receives them, and only after the required reviewer approves. +# - Secret files live in RUNNER_TEMP or the key folder altool reads, and are +# removed with `if: always()`. +# - Values are passed via env:, never on a command line, never echoed. +# +# There is deliberately no dry-run or skip-upload input. A switch that lets this +# go green without doing its job is the shape of the #904 override. A TestFlight +# upload is private, so the lane proves itself by really uploading. +# +# Every step stops on the first thing it cannot confirm. + +on: + workflow_call: + +permissions: + contents: read + +env: + BUNDLE_ID: app.offband.meshcore.ios + +jobs: + ios-signed: + runs-on: macos-latest + environment: apple-signing # required-reviewer gate; do not remove + steps: + - uses: actions/checkout@v4 + + - name: Check the signing secrets are present + env: + APPLE_DIST_P12_BASE64: ${{ secrets.APPLE_DIST_P12_BASE64 }} + APPLE_DIST_P12_PASSWORD: ${{ secrets.APPLE_DIST_P12_PASSWORD }} + APPLE_PROFILE_BASE64: ${{ secrets.APPLE_PROFILE_BASE64 }} + ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }} + ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }} + ASC_API_ISSUER_ID: ${{ secrets.ASC_API_ISSUER_ID }} + run: | + set -eu + missing=0 + for name in APPLE_DIST_P12_BASE64 APPLE_DIST_P12_PASSWORD APPLE_PROFILE_BASE64 \ + ASC_API_KEY_P8_BASE64 ASC_API_KEY_ID ASC_API_ISSUER_ID; do + if [ -z "${!name:-}" ]; then + echo "::error::Secret ${name} is empty or absent in this run." + missing=1 + fi + done + exit "${missing}" + + - name: Temporary keychain with the Distribution identity + env: + APPLE_DIST_P12_BASE64: ${{ secrets.APPLE_DIST_P12_BASE64 }} + APPLE_DIST_P12_PASSWORD: ${{ secrets.APPLE_DIST_P12_PASSWORD }} + run: | + set -eu + umask 077 + KC="${RUNNER_TEMP}/signing.keychain-db" + KP="$(openssl rand -hex 16)" + echo "::add-mask::${KP}" + echo "KC=${KC}" >> "$GITHUB_ENV" + security create-keychain -p "${KP}" "${KC}" + security set-keychain-settings -lut 21600 "${KC}" + security unlock-keychain -p "${KP}" "${KC}" + P12="${RUNNER_TEMP}/dist.p12" + printf '%s' "${APPLE_DIST_P12_BASE64}" | base64 --decode > "${P12}" + security import "${P12}" -k "${KC}" -P "${APPLE_DIST_P12_PASSWORD}" \ + -T /usr/bin/codesign -T /usr/bin/security + rm -f "${P12}" + security set-key-partition-list -S apple-tool:,apple: -s -k "${KP}" "${KC}" > /dev/null + security list-keychains -d user -s "${KC}" $(security list-keychains -d user | tr -d '"') + IDENTITIES="$(security find-identity -v -p codesigning "${KC}")" + COUNT="$(printf '%s\n' "${IDENTITIES}" | grep -c '"Apple Distribution: ' || true)" + if [ "${COUNT}" != "1" ]; then + echo "::error::Expected one Apple Distribution identity in the keychain, found ${COUNT}." + exit 1 + fi + IDENTITY="$(printf '%s\n' "${IDENTITIES}" | sed -n 's/.*"\(Apple Distribution: .*\)".*/\1/p')" + # Holds the owner's legal name, and env values appear in public step logs. + echo "::add-mask::${IDENTITY}" + echo "IDENTITY=${IDENTITY}" >> "$GITHUB_ENV" + echo "Keychain ready with one Apple Distribution identity." + + - name: Install the provisioning profile + env: + APPLE_PROFILE_BASE64: ${{ secrets.APPLE_PROFILE_BASE64 }} + run: | + set -eu + umask 077 + PROFILE="${RUNNER_TEMP}/dist.mobileprovision" + printf '%s' "${APPLE_PROFILE_BASE64}" | base64 --decode > "${PROFILE}" + PLIST="${RUNNER_TEMP}/profile.plist" + security cms -D -i "${PROFILE}" > "${PLIST}" + PB=/usr/libexec/PlistBuddy + UUID="$("${PB}" -c 'Print :UUID' "${PLIST}")" + NAME="$("${PB}" -c 'Print :Name' "${PLIST}")" + TEAM="$("${PB}" -c 'Print :TeamIdentifier:0' "${PLIST}")" + APP_ID="$("${PB}" -c 'Print :Entitlements:application-identifier' "${PLIST}")" + if [ -z "${UUID}" ] || [ -z "${NAME}" ] || [ -z "${TEAM}" ]; then + echo "::error::Could not read the profile's UUID, name and team." + exit 1 + fi + if [ "${APP_ID}" != "${TEAM}.${BUNDLE_ID}" ]; then + echo "::error::The profile is for ${APP_ID}, not ${TEAM}.${BUNDLE_ID}." + exit 1 + fi + # Xcode 16 and later read the second folder; older versions the first. + for dir in "${HOME}/Library/MobileDevice/Provisioning Profiles" \ + "${HOME}/Library/Developer/Xcode/UserData/Provisioning Profiles"; do + mkdir -p "${dir}" + cp "${PROFILE}" "${dir}/${UUID}.mobileprovision" + done + rm -f "${PROFILE}" "${PLIST}" + { + echo "PROFILE_UUID=${UUID}" + echo "PROFILE_NAME=${NAME}" + echo "TEAM=${TEAM}" + } >> "$GITHUB_ENV" + echo "Profile '${NAME}' installed for ${BUNDLE_ID}." + + # Only Runner's Release config includes this xcconfig, so CocoaPods targets + # keep their own signing. Written here, not committed: the project stays + # free of a team and profile, and the compile job in build.yml is untouched. + - name: Apply manual signing and write ExportOptions.plist + run: | + set -eu + { + echo "" + echo "CODE_SIGN_STYLE = Manual" + echo "DEVELOPMENT_TEAM = ${TEAM}" + echo "PROVISIONING_PROFILE_SPECIFIER = ${PROFILE_NAME}" + echo "CODE_SIGN_IDENTITY = Apple Distribution" + echo "CODE_SIGN_IDENTITY[sdk=iphoneos*] = Apple Distribution" + } >> ios/Flutter/Release.xcconfig + OPTIONS="${RUNNER_TEMP}/ExportOptions.plist" + PB=/usr/libexec/PlistBuddy + "${PB}" -c 'Add :method string app-store-connect' \ + -c 'Add :destination string export' \ + -c 'Add :signingStyle string manual' \ + -c 'Add :signingCertificate string Apple Distribution' \ + -c "Add :teamID string ${TEAM}" \ + -c 'Add :provisioningProfiles dict' \ + -c "Add :provisioningProfiles:${BUNDLE_ID} string ${PROFILE_NAME}" \ + "${OPTIONS}" + echo "OPTIONS=${OPTIONS}" >> "$GITHUB_ENV" + + - uses: subosito/flutter-action@v2 + with: + flutter-version: "3.44.1" + cache: true + - run: flutter pub get + - run: dart run build_runner build --delete-conflicting-outputs + - name: Write dart_defines.json + env: + GIPHY_API_KEY: ${{ secrets.GIPHY_API_KEY }} + run: jq -n --arg k "$GIPHY_API_KEY" '{GIPHY_API_KEY:$k}' > dart_defines.json + + # The build number is the run ID, not the run number: testflight.yml and + # release-signed.yml each count their own runs, so run numbers collide + # between them, and Apple refuses a duplicate. A re-run reuses its ID, so + # Apple refuses that too, which stops the job rather than hiding anything. + - name: Build the signed IPA + run: | + set -eu + BUILD_NAME="$(grep -E '^version:' pubspec.yaml | head -1 | sed 's/version:[[:space:]]*//' | sed 's/+.*//' | tr -d '[:space:]')" + BUILD_NUMBER="${GITHUB_RUN_ID}" + if [ -z "${BUILD_NAME}" ]; then + echo "::error::Could not read the version from pubspec.yaml." + exit 1 + fi + echo "BUILD_NAME=${BUILD_NAME}" >> "$GITHUB_ENV" + echo "BUILD_NUMBER=${BUILD_NUMBER}" >> "$GITHUB_ENV" + echo "Building ${BUILD_NAME} (${BUILD_NUMBER})." + flutter build ipa --release --no-pub \ + --build-name="${BUILD_NAME}" \ + --build-number="${BUILD_NUMBER}" \ + --dart-define-from-file=dart_defines.json \ + --export-options-plist="${OPTIONS}" + + - name: Unpack the IPA + run: | + set -eu + COUNT="$(ls build/ios/ipa/*.ipa 2>/dev/null | wc -l | tr -d ' ')" + if [ "${COUNT}" != "1" ]; then + echo "::error::Expected one IPA in build/ios/ipa, found ${COUNT}." + exit 1 + fi + IPA="$(ls build/ios/ipa/*.ipa)" + echo "IPA=${IPA}" >> "$GITHUB_ENV" + UNPACKED="${RUNNER_TEMP}/ipa" + mkdir -p "${UNPACKED}" + unzip -q "${IPA}" -d "${UNPACKED}" + echo "APP=${UNPACKED}/Payload/Runner.app" >> "$GITHUB_ENV" + + - name: Assert the IPA holds its native-asset frameworks (#1037) + run: bash .github/scripts/ios-assert-frameworks.sh "${APP}" + + - name: Assert the IPA's identity and version + run: | + set -eu + PB=/usr/libexec/PlistBuddy + for pair in "CFBundleIdentifier=${BUNDLE_ID}" \ + "CFBundleShortVersionString=${BUILD_NAME}" \ + "CFBundleVersion=${BUILD_NUMBER}"; do + KEY="${pair%%=*}" + WANT="${pair#*=}" + GOT="$("${PB}" -c "Print :${KEY}" "${APP}/Info.plist")" + if [ "${GOT}" != "${WANT}" ]; then + echo "::error::${KEY} is '${GOT}', expected '${WANT}'." + exit 1 + fi + done + echo "Identity and version are as expected." + + # A green build does not prove what signed it, the same reason the Android + # job pins its certificate. The check runs against a wrong name first, so + # it is shown able to fail on every run. + - name: Verify the IPA is signed by the Distribution certificate + run: | + set -eu + codesign --verify --deep --strict "${APP}" + INFO="$(codesign -dvv "${APP}" 2>&1)" + if printf '%s\n' "${INFO}" | grep -qxF "Authority=Apple Distribution: Not This Team (XXXXXXXXXX)"; then + echo "::error::The signing check accepted a wrong certificate name." + exit 1 + fi + if ! printf '%s\n' "${INFO}" | grep -qxF "Authority=${IDENTITY}"; then + echo "::error::The IPA is not signed by the Apple Distribution identity in the keychain." + exit 1 + fi + if ! printf '%s\n' "${INFO}" | grep -qxF "TeamIdentifier=${TEAM}"; then + echo "::error::The IPA's team is not the profile's team." + exit 1 + fi + echo "IPA signing verified." + + - name: Install the App Store Connect API key + env: + ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }} + ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }} + run: | + set -eu + umask 077 + KEY_DIR="${HOME}/.appstoreconnect/private_keys" + mkdir -p "${KEY_DIR}" + printf '%s' "${ASC_API_KEY_P8_BASE64}" | base64 --decode > "${KEY_DIR}/AuthKey_${ASC_API_KEY_ID}.p8" + + # On the runner's own tool, so the key folder and subcommands are read, not + # recalled (#1040). Prints only documentation. + - name: Record what altool documents here + run: | + xcrun altool --version || true + man altool 2>/dev/null | col -b | grep -n -i -E 'private_keys|--validate-app|--upload-app|--upload-package' || true + + - name: Validate with App Store Connect + env: + ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }} + ASC_API_ISSUER_ID: ${{ secrets.ASC_API_ISSUER_ID }} + run: | + set +e + OUT="${RUNNER_TEMP}/altool-validate.txt" + xcrun altool --validate-app -f "${IPA}" -t ios \ + --apiKey "${ASC_API_KEY_ID}" --apiIssuer "${ASC_API_ISSUER_ID}" > "${OUT}" 2>&1 + CODE=$? + set -e + cat "${OUT}" + bash .github/scripts/ios-altool-check.sh validate "${CODE}" "${OUT}" + + - name: Upload to App Store Connect + env: + ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }} + ASC_API_ISSUER_ID: ${{ secrets.ASC_API_ISSUER_ID }} + run: | + set +e + OUT="${RUNNER_TEMP}/altool-upload.txt" + xcrun altool --upload-app -f "${IPA}" -t ios \ + --apiKey "${ASC_API_KEY_ID}" --apiIssuer "${ASC_API_ISSUER_ID}" > "${OUT}" 2>&1 + CODE=$? + set -e + cat "${OUT}" + bash .github/scripts/ios-altool-check.sh upload "${CODE}" "${OUT}" + echo "Uploaded ${BUILD_NAME} (${BUILD_NUMBER})." + + - name: Remove signing material + if: always() + run: | + [ -n "${KC:-}" ] && security delete-keychain "${KC}" || true + rm -rf "${HOME}/.appstoreconnect/private_keys" + rm -f "${HOME}/Library/MobileDevice/Provisioning Profiles/"*.mobileprovision + rm -f "${HOME}/Library/Developer/Xcode/UserData/Provisioning Profiles/"*.mobileprovision + rm -f dart_defines.json "${RUNNER_TEMP}/dist.p12" "${RUNNER_TEMP}/dist.mobileprovision" "${RUNNER_TEMP}/profile.plist" + echo "Signing material removed." diff --git a/.github/workflows/ios-signing-proof.yml b/.github/workflows/ios-signing-proof.yml new file mode 100644 index 00000000..a4a02661 --- /dev/null +++ b/.github/workflows/ios-signing-proof.yml @@ -0,0 +1,147 @@ +name: iOS signing proof + +# Proves, on the same macOS runner image the signed iOS lane will use, that a +# .p12 packed the way tools/ios-signing/ios-signing.ps1 packs it imports into a +# keychain with its private key paired to its certificate (#1016, epic #1012). +# +# Uses a THROWAWAY self-signed certificate generated here, every run. No real +# identity, no secret, no private key in the repository. Nothing in this job can +# reach the apple-signing Environment. +# +# Why it exists: OpenSSL 3 (which the owner's Windows script uses) writes .p12 +# files with PBES2/AES-256 and a SHA-256 MAC by default; older macOS keychain +# tooling has refused that format. The script packs the legacy format +# (3DES, SHA-1 MAC) instead, and this job is what shows the choice works. + +on: + pull_request: + paths: + - "tools/ios-signing/**" + - ".github/workflows/ios-signing-proof.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + p12-import: + runs-on: macos-latest + env: + CN: "Offband CI throwaway" + steps: + - uses: actions/checkout@v4 + + - name: Locate OpenSSL 3 + run: | + set -eu + PREFIX="$(brew --prefix openssl@3 2>/dev/null || true)" + OPENSSL="${PREFIX}/bin/openssl" + if [ -z "${PREFIX}" ] || [ ! -x "${OPENSSL}" ]; then + echo "::error::Homebrew openssl@3 not found on this runner; cannot reproduce the Windows script's OpenSSL 3 output." + exit 1 + fi + "${OPENSSL}" version + echo "OPENSSL=${OPENSSL}" >> "$GITHUB_ENV" + + - name: Make a throwaway certificate and pack it both ways + run: | + set -eu + umask 077 + W="${RUNNER_TEMP}/p12" + mkdir -p "$W" + PW="$("${OPENSSL}" rand -hex 16)" + echo "::add-mask::${PW}" + echo "P12_PW=${PW}" >> "$GITHUB_ENV" + echo "W=${W}" >> "$GITHUB_ENV" + "${OPENSSL}" req -x509 -newkey rsa:2048 -nodes -days 1 \ + -keyout "$W/k.pem" -out "$W/c.pem" -subj "/CN=${CN}" + # The OpenSSL 3 default, for the record only. + "${OPENSSL}" pkcs12 -export -inkey "$W/k.pem" -in "$W/c.pem" -name "${CN}" \ + -out "$W/default.p12" -passout "pass:${PW}" + # The format the owner's script produces. Keep these flags identical + # to tools/ios-signing/ios-signing.ps1. + "${OPENSSL}" pkcs12 -export -inkey "$W/k.pem" -in "$W/c.pem" -name "${CN}" \ + -keypbe PBE-SHA1-3DES -certpbe PBE-SHA1-3DES -macalg sha1 \ + -out "$W/legacy.p12" -passout "pass:${PW}" + for f in default legacy; do + test -s "$W/$f.p12" || { echo "::error::$f.p12 was not written"; exit 1; } + echo "== $f.p12 ==" + "${OPENSSL}" pkcs12 -info -noout -in "$W/$f.p12" -passin "pass:${PW}" 2>&1 | grep -E "MAC:|Encrypted data|Shrouded" + done + + - name: Temporary keychain + run: | + set -eu + KC="${RUNNER_TEMP}/proof.keychain-db" + KP="$("${OPENSSL}" rand -hex 16)" + echo "::add-mask::${KP}" + security create-keychain -p "${KP}" "${KC}" + security set-keychain-settings -lut 600 "${KC}" + security unlock-keychain -p "${KP}" "${KC}" + echo "KC=${KC}" >> "$GITHUB_ENV" + + # The check that must be able to fail. If a wrong password were accepted, + # every result below would be meaningless. + - name: "Negative: wrong password is refused" + run: | + set -u + if security import "${W}/legacy.p12" -k "${KC}" -P "not-the-password" -T /usr/bin/codesign; then + echo "::error::A wrong password was accepted. This proof cannot be trusted." + exit 1 + fi + echo "Refused, as required." + + - name: "Positive: legacy .p12 imports with its key paired" + run: | + set -eu + security import "${W}/legacy.p12" -k "${KC}" -P "${P12_PW}" -T /usr/bin/codesign + security find-certificate -c "${CN}" "${KC}" > /dev/null + IDS="$(security find-identity "${KC}")" + echo "${IDS}" + if ! printf '%s\n' "${IDS}" | grep -q "${CN}"; then + echo "::error::Certificate imported but no identity: the private key did not pair with it." + exit 1 + fi + echo "Legacy .p12 imported with key and certificate paired." + + # The owner's script itself, not a copy of its flags: run its selftest + # under PowerShell on this runner and import what it produced (#1017). + - name: "Script: ios-signing.ps1 selftest output imports" + run: | + set -eu + command -v pwsh > /dev/null || { echo "::error::pwsh not found on this runner"; exit 1; } + OUT="${RUNNER_TEMP}/selftest" + mkdir -p "${OUT}" + OPENSSL="${OPENSSL}" SELFTEST_OUT="${OUT}" pwsh -NoProfile -File tools/ios-signing/ios-signing.ps1 selftest + test -s "${OUT}/selftest.p12" || { echo "::error::selftest produced no .p12"; exit 1; } + SPW="$(cat "${OUT}/selftest-password.txt")" + echo "::add-mask::${SPW}" + security import "${OUT}/selftest.p12" -k "${KC}" -P "${SPW}" -T /usr/bin/codesign + IDS="$(security find-identity "${KC}")" + if ! printf '%s\n' "${IDS}" | grep -q "Offband Meshcore Distribution"; then + echo "${IDS}" + echo "::error::The script's .p12 imported but its key did not pair with its certificate." + exit 1 + fi + rm -rf "${OUT}" + echo "The script's own .p12 imports with key and certificate paired." + + - name: "Record: does the OpenSSL 3 default import here too?" + run: | + set -u + KC2="${RUNNER_TEMP}/default.keychain-db" + security create-keychain -p tmp "${KC2}" + security unlock-keychain -p tmp "${KC2}" + if security import "${W}/default.p12" -k "${KC2}" -P "${P12_PW}" -T /usr/bin/codesign; then + echo "::notice::The OpenSSL 3 default format also imports on this runner image." + else + echo "::notice::The OpenSSL 3 default format is REFUSED on this runner image; the legacy format is required." + fi + security delete-keychain "${KC2}" || true + + - name: Clean up + if: always() + run: | + [ -n "${KC:-}" ] && security delete-keychain "${KC}" || true + [ -n "${W:-}" ] && rm -rf "${W}" || true + echo "Throwaway keychain and files removed." diff --git a/.github/workflows/macos-signed.yml b/.github/workflows/macos-signed.yml new file mode 100644 index 00000000..05450d8e --- /dev/null +++ b/.github/workflows/macos-signed.yml @@ -0,0 +1,232 @@ +name: macOS signed build + +# Builds the macOS app, signs it with the Developer ID Application identity, +# notarizes it with Apple and staples the ticket, so it opens on any Mac with +# no Gatekeeper warning (#1109, epic #1107). Called by release-signed.yml. +# +# Three jobs, so no Mac runner ever sits waiting on Apple's queue: +# 1. build-sign-submit (Mac): build, sign, submit, keep the signed app. +# 2. notary-wait (Linux): poll Apple for that submission, up to 340 min. +# 3. staple (Mac): staple the ticket, verify, publish the zip. +# If Apple holds a submission past the poll deadline, job 2 fails, the signed +# app stays an artifact of the run, and "Re-run failed jobs" checks the SAME +# submission again later. Nothing is rebuilt or resubmitted. +# +# SEPARATION: this lane uses its own `apple-macos` Environment and its own +# App Store Connect key. It shares no Environment, secret or key with the +# iOS lane (`apple-signing`), so a hold, revocation or failure in one Apple +# lane cannot break the other. +# +# SECURITY: this repo is PUBLIC, and jobs 1 and 2 hold the Developer ID key +# and the notary API key. +# +# - NEVER call this from a `pull_request` workflow. A PR (incl. from a fork) +# must never run a job that can read these secrets. +# - Only jobs 1 and 2 reference the `apple-macos` Environment, so only they +# receive secrets, and only after the required reviewer approves. Job 3 +# needs none: stapling fetches the public ticket by the app's hash. +# - Secret files live in RUNNER_TEMP and are removed with `if: always()`. +# - Values are passed via env:, never echoed. The identity name holds the +# owner's legal name and is masked. +# +# There is deliberately no skip-notarization input: an unsigned or +# unnotarized app must fail this lane, never pass it (#904). + +on: + workflow_call: + +permissions: + contents: read + +jobs: + build-sign-submit: + runs-on: macos-latest + environment: apple-macos # required-reviewer gate; do not remove + timeout-minutes: 60 + outputs: + submission-id: ${{ steps.submit.outputs.id }} + steps: + - uses: actions/checkout@v4 + + - name: Check the signing secrets are present + env: + MACOS_DEVID_P12_BASE64: ${{ secrets.MACOS_DEVID_P12_BASE64 }} + MACOS_DEVID_P12_PASSWORD: ${{ secrets.MACOS_DEVID_P12_PASSWORD }} + MACOS_NOTARY_KEY_P8_BASE64: ${{ secrets.MACOS_NOTARY_KEY_P8_BASE64 }} + MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} + MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} + run: | + set -eu + missing=0 + for name in MACOS_DEVID_P12_BASE64 MACOS_DEVID_P12_PASSWORD \ + MACOS_NOTARY_KEY_P8_BASE64 MACOS_NOTARY_KEY_ID MACOS_NOTARY_ISSUER_ID; do + if [ -z "${!name:-}" ]; then + echo "::error::Secret ${name} is empty or absent in this run." + missing=1 + fi + done + exit "${missing}" + + - name: Temporary keychain with the Developer ID identity + env: + MACOS_DEVID_P12_BASE64: ${{ secrets.MACOS_DEVID_P12_BASE64 }} + MACOS_DEVID_P12_PASSWORD: ${{ secrets.MACOS_DEVID_P12_PASSWORD }} + run: | + set -eu + umask 077 + KC="${RUNNER_TEMP}/signing.keychain-db" + KP="$(openssl rand -hex 16)" + echo "::add-mask::${KP}" + echo "KC=${KC}" >> "$GITHUB_ENV" + security create-keychain -p "${KP}" "${KC}" + security set-keychain-settings -lut 21600 "${KC}" + security unlock-keychain -p "${KP}" "${KC}" + P12="${RUNNER_TEMP}/devid.p12" + printf '%s' "${MACOS_DEVID_P12_BASE64}" | base64 --decode > "${P12}" + security import "${P12}" -k "${KC}" -P "${MACOS_DEVID_P12_PASSWORD}" \ + -T /usr/bin/codesign -T /usr/bin/security + rm -f "${P12}" + security set-key-partition-list -S apple-tool:,apple: -s -k "${KP}" "${KC}" > /dev/null + security list-keychains -d user -s "${KC}" $(security list-keychains -d user | tr -d '"') + IDENTITIES="$(security find-identity -v -p codesigning "${KC}")" + COUNT="$(printf '%s\n' "${IDENTITIES}" | grep -c '"Developer ID Application: ' || true)" + if [ "${COUNT}" != "1" ]; then + echo "::error::Expected one Developer ID Application identity in the keychain, found ${COUNT}." + exit 1 + fi + IDENTITY="$(printf '%s\n' "${IDENTITIES}" | sed -n 's/.*"\(Developer ID Application: .*\)".*/\1/p')" + echo "::add-mask::${IDENTITY}" + echo "IDENTITY=${IDENTITY}" >> "$GITHUB_ENV" + echo "Keychain ready with one Developer ID Application identity." + + - uses: subosito/flutter-action@v2 + with: + flutter-version: "3.44.1" + cache: true + - run: flutter pub get + - run: dart run build_runner build --delete-conflicting-outputs + - name: Write dart_defines.json + env: + GIPHY_API_KEY: ${{ secrets.GIPHY_API_KEY }} + run: jq -n --arg k "$GIPHY_API_KEY" '{GIPHY_API_KEY:$k}' > dart_defines.json + - name: Enable native assets (bundles sqlite3 + llamadart libs, #445) + run: flutter config --enable-native-assets + - run: flutter build macos --release --no-pub --dart-define-from-file=dart_defines.json + + # Inside-out: every nested Mach-O first, then each framework bundle, then + # the app with its entitlements. Hardened Runtime and a secure timestamp + # are what notarization requires. + - name: Sign with Developer ID + run: | + set -eu + APP=build/macos/Build/Products/Release/offband_meshcore.app + echo "APP=${APP}" >> "$GITHUB_ENV" + SIGN=(codesign --force --options runtime --timestamp --sign "${IDENTITY}" --keychain "${KC}") + while IFS= read -r -d '' f; do + if file -b "${f}" | grep -q 'Mach-O'; then + "${SIGN[@]}" "${f}" + fi + done < <(find "${APP}/Contents/Frameworks" -type f -print0) + while IFS= read -r -d '' fw; do + "${SIGN[@]}" "${fw}" + done < <(find "${APP}/Contents/Frameworks" -depth -name '*.framework' -type d -print0) + "${SIGN[@]}" --entitlements macos/Runner/Release.entitlements "${APP}" + codesign --verify --deep --strict --verbose=2 "${APP}" + lipo -archs "${APP}/Contents/MacOS/offband_meshcore" + + # ditto, not zip: it preserves the .app bundle's symlinks (#1108). The + # same zip is what Apple notarizes and what job 3 staples. + - name: Zip the signed app + run: ditto -c -k --keepParent "${APP}" offband-macos-signed.zip + + # Upload only, no --wait: the verdict is job 2's business. + - name: Submit for notarization + id: submit + env: + MACOS_NOTARY_KEY_P8_BASE64: ${{ secrets.MACOS_NOTARY_KEY_P8_BASE64 }} + MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} + MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} + run: | + set -eu + umask 077 + P8="${RUNNER_TEMP}/AuthKey.p8" + printf '%s' "${MACOS_NOTARY_KEY_P8_BASE64}" | base64 --decode > "${P8}" + OUT="${RUNNER_TEMP}/notarize.json" + xcrun notarytool submit offband-macos-signed.zip --key "${P8}" \ + --key-id "${MACOS_NOTARY_KEY_ID}" --issuer "${MACOS_NOTARY_ISSUER_ID}" \ + --output-format json > "${OUT}" + cat "${OUT}" + ID="$(jq -r '.id // empty' "${OUT}")" + if [ -z "${ID}" ]; then + echo "::error::notarytool returned no submission id." + exit 1 + fi + echo "id=${ID}" >> "$GITHUB_OUTPUT" + echo "Submitted for notarization: ${ID}" + + # Kept 30 days so a held submission can be finished by a re-run. + - uses: actions/upload-artifact@v4 + with: + name: macos-signed-unstapled + path: offband-macos-signed.zip + if-no-files-found: error + retention-days: 30 + + - name: Remove the signing material + if: always() + run: | + set +e + if [ -n "${KC:-}" ]; then security delete-keychain "${KC}"; fi + rm -f dart_defines.json "${RUNNER_TEMP}/devid.p12" "${RUNNER_TEMP}/AuthKey.p8" + + notary-wait: + needs: build-sign-submit + runs-on: ubuntu-latest + environment: apple-macos # holds the notary key; do not remove + # GitHub's job limit is 360 minutes. A Linux runner on a public repo costs + # nothing to keep polling, and a hold of a few hours then finishes with no + # manual re-run. The poll deadline leaves room for setup inside the limit. + timeout-minutes: 355 + steps: + - uses: actions/checkout@v4 + - run: pip install --quiet "PyJWT[crypto]==2.10.1" + - name: Wait for Apple's verdict (340 min max) + env: + SUBMISSION_ID: ${{ needs.build-sign-submit.outputs.submission-id }} + MACOS_NOTARY_KEY_P8_BASE64: ${{ secrets.MACOS_NOTARY_KEY_P8_BASE64 }} + NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} + NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} + run: | + set -eu + umask 077 + export P8="${RUNNER_TEMP}/AuthKey.p8" + printf '%s' "${MACOS_NOTARY_KEY_P8_BASE64}" | base64 --decode > "${P8}" + python3 .github/scripts/notary-wait.py "${SUBMISSION_ID}" 340 + - name: Remove the notary key + if: always() + run: rm -f "${RUNNER_TEMP}/AuthKey.p8" + + staple: + needs: notary-wait + runs-on: macos-latest + timeout-minutes: 20 + steps: + - uses: actions/download-artifact@v4 + with: + name: macos-signed-unstapled + - name: Staple and verify + run: | + set -eu + ditto -x -k offband-macos-signed.zip . + APP=offband_meshcore.app + xcrun stapler staple "${APP}" + xcrun stapler validate "${APP}" + codesign --verify --deep --strict --verbose=2 "${APP}" + spctl --assess --type execute --verbose=4 "${APP}" + ditto -c -k --keepParent "${APP}" offband-macos.zip + - uses: actions/upload-artifact@v4 + with: + name: release-macos + path: offband-macos.zip + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/release-signed.yml b/.github/workflows/release-signed.yml index 60c31da5..689237f0 100644 --- a/.github/workflows/release-signed.yml +++ b/.github/workflows/release-signed.yml @@ -1,8 +1,9 @@ name: Release (signed, all platforms) # Produces a COMPLETE, published release from a `v*` tag: signed Android APK + -# AAB, Windows, and Linux, attached to a GitHub release whose body is the -# human-authored release notes. This is what makes a partial release (the +# AAB, Windows and Linux, attached to a GitHub release whose body is the +# human-authored release notes. The notarized macOS zip (#1109) is added after +# Apple accepts it, by `macos-attach`. This is what makes a partial release (the # rc.2/rc.3 failure) impossible. # # SECURITY: this repo is PUBLIC and the `android` job has the signing keystore, @@ -13,7 +14,9 @@ name: Release (signed, all platforms) # run a job that can read the keystore. # - Only the `android` job references the `release-signing` Environment, so # ONLY it receives the keystore secrets, and only after the required -# reviewer approves. The windows/linux/release jobs never see them. +# reviewer approves. The windows/linux/release jobs never see them. The +# `ios` job uses the `apple-signing` Environment (ios-signed.yml); the +# `macos` jobs use the separate `apple-macos` one (macos-signed.yml). # - Keystore written to disk only for the build, deleted with `if: always()`. # - Signing values passed via env:, never on a command line, never echoed. # @@ -249,6 +252,23 @@ jobs: if-no-files-found: error retention-days: 7 + # Developer ID signed and notarized macOS build (#1109). Deliberately not in + # `release`'s needs: Apple's notary queue is outside our control, so it must + # not hold up Android, Windows and Linux. `macos-attach` adds the asset to + # the published release once Apple accepts it. An unnotarized macOS app is + # never published: if notarization fails, the release ships without it. + macos: + needs: gate + uses: ./.github/workflows/macos-signed.yml + secrets: inherit + + # Uploads to App Store Connect (#1039). Deliberately not in `release`'s needs: + # an Apple-side failure must not block the Android and desktop release. + ios: + needs: gate + uses: ./.github/workflows/ios-signed.yml + secrets: inherit + # Creates the release ONLY after all platform builds (and the signing cert # check) have passed, so a signing failure yields no release rather than an # empty one. Published directly: the notes were reviewed in the cut PR. @@ -326,7 +346,7 @@ jobs: else gh release create "${TAG}" \ --draft \ - --title "Offband Meshcore ${VERSION}" \ + --title "Offband MeshCore ${VERSION}" \ --notes-file "${NOTES}" \ "${PRERELEASE_FLAG}" gh release upload "${TAG}" out/* @@ -338,3 +358,20 @@ jobs: gh release edit "${TAG}" "${PRERELEASE_FLAG}" --draft=false fi echo "Release ${TAG} published with $(ls out | wc -l) assets." + + # Adds the notarized macOS zip to the release after both are done (#1109). + # If macOS was held at Apple, "Re-run failed jobs" finishes the macOS lane + # and then this job, with no rebuild. + macos-attach: + needs: [release, macos] + runs-on: ubuntu-latest + permissions: + contents: write # upload one asset to the existing release + steps: + - uses: actions/download-artifact@v4 + with: + name: release-macos + - name: Attach macOS asset + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload "${GITHUB_REF_NAME}" offband-macos.zip --clobber --repo "${GITHUB_REPOSITORY}" diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml new file mode 100644 index 00000000..c62d120e --- /dev/null +++ b/.github/workflows/testflight.yml @@ -0,0 +1,20 @@ +name: TestFlight + +# A signed build uploaded to App Store Connect, on demand (#1039, epic #1013). +# Dispatch it from a `v*` tag or `main`: the apple-signing Environment refuses +# any other ref. NEVER add a `pull_request` trigger; see ios-signed.yml. + +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: testflight + cancel-in-progress: false + +jobs: + ios: + uses: ./.github/workflows/ios-signed.yml + secrets: inherit diff --git a/.gitignore b/.gitignore index 31e840c5..2403b94f 100644 --- a/.gitignore +++ b/.gitignore @@ -68,6 +68,9 @@ macos/Flutter/GeneratedPluginRegistrant.swift **/ios/Flutter/Flutter.framework **/ios/Flutter/Flutter.podspec +# Apple account paperwork, kept local +docs/Apple/ + # Android .gradle/ **/android/.gradle/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d0a08899..d90c8768 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,139 @@ All notable changes to Offband Meshcore. Pre-releases are tagged `-beta.N` / `-rc.N`. +## [1.8.0] - 2026-11-09 + +Production successor to 1.7.0. A channels release: message state keyed by channel identity +instead of slot, no hidden history, every channel write confirmed by the radio, the firmware +1.7.0 slot rules, plus radio-side settings over the `0xC5 0x1C` capability advert. Radio +sound, Display and LED, and rotation need Offband firmware 1.7.0 or newer. Database schema +unchanged (2). + +### Upgrade notes + +- First full sync purges history under channel keys the radio does not hold, plus pre-#194 + slot-index keys for empty slots. Runs only when the sync saw every slot device info + reported, every slot confirmed, none erased, no write in flight, and at least one channel; + every sync logs the result or the skip reason (#1128). +- Slot 0 reserved for Public on every firmware; on stock or older firmware one slot of 40 goes + unused without Public. An existing non-Public occupant of slot 0 is left alone (#1117). +- 65 new UI strings, English-only in all 17 other locales; `untranslated.json` regenerated. + One string retired (`channels_noFreeSlot`). + +### Channels and message history + +- Key change (hashtag rename or new private key) asks keep or delete. Applied once the radio + confirms the clear. Keep runs `moveChannelHistory` (copy, verify, delete only copied rows, + up to 3 passes; a blob-only old key moved onto rows first) plus `moveChannelNotifyMode`. + Delete runs `deleteChannelHistory`. Failed move returns `historyNotMoved`, persistent. A key + whose change could not land is spared from the purge until the connection ends. A different + channel written over an occupied slot is settled by the next full sync, since firmware 1.7.0 + may move the occupant (#1128). +- Fail-closed channel sync: an unanswered slot is UNKNOWN, surfaced by a persistent Retry + banner, never backfilled from the previous list; lookups and notify evaluation for it fail + closed (suppress). Ingress barrier holds live channel traffic until the list is confirmed, + keyed per radio, and re-holds frames for still-unconfirmed slots; pull-based frames are + never dropped; raw-RX no longer matches the offline cache. Unread survives a sync only for + the same PSK; identity changes listed in a dismissible notice. A sync with unconfirmed slots + does not overwrite the last fully confirmed cache; a failed sync on a live link retries + (#1062, #1064). +- In-memory conversation dropped and reloaded on a slot identity change; reassignment clears + only legacy slot-index keys; `sendChannelMessage` refuses an unconfirmed slot with a + persistent error; `_loadChannelMessages` merges messages that arrived during the read + (#1063). +- Channel writes (`setChannel`, `deleteChannel`, `editChannel`) await OK/ERR via the generic + ack queue with a 5 s timeout and return `ok`, `occupied`, `rejected`, `noReply` or + `clearedNotAdded`; history cleared only after OK; key change is clear-then-add; success + toasts only after OK, refusals persistent (#1026, #1025). +- Slot chosen at the write by `decideChannelSlot` / `allocateChannelSlotOrExplain`; refuses + while syncing and when full (no more `return 0`); covers channels screen, QR share, community + join (green toast only when written) and config import (`planChannelImport` requires + `isSyncing`, skips with `channelsSyncing`). Syncs defer while a write is in flight; batches + hold the guard; the last write runs one re-read (#1021, #1022, #1023, #1024, #1025). +- Firmware 1.7.0 slot rules: Public to slot 0, nothing else ever to slot 0; second Public + refused on every path; Delete Public refused at the attempt; Public's key locked in Edit and + refused on another channel; hashtag "public" (namesPublic rule) writes the real Public + (#1117, #1029). +- Restore to radio: `planChannelRestore` places by identity (live keys skipped, Public to slot + 0, others to their remembered slot if free or erased and not 0, else lowest free, live slots + never a destination, erased slots cleared first); every write awaits OK/ERR; reports the + confirmed count and names what did not land; busy table refuses (#1060, #1117). +- Erased (`0xFF`) records and undecodable names refused from the wire and dropped at + `loadCachedChannels`; a sync seeing an erased slot keeps the cache (#1060). +- Edit Channel: hashtag PSK read-only, copyable, re-derived on rename; non-hashtag or `##` + names refused; community hashtag name and key read-only; Save refused when the slot's live + occupant changed since the dialog opened (#1103). + +### Radio settings + +- `0xC5 0x1C` capability advert (TLV, `TAG_CONTINUE` bounded at 8 rounds, unknown tags kept + and skipped) replaces device-info offset 87, which nothing reads now (#1094, #1095, #1096, + #1097). +- Capability gates on caps bits, not `FIRMWARE_VER_CODE`: block list, caplog, packet hash and + observer config (`OFFBAND_CONFIG_MIN_VER` deleted). Companion radio stats `>= 8` and region + scope `>= 13` stay version-gated by owner ruling; removal tracked in #1066 (#1065). +- Radio per-channel notify over `0xC5 0x18/0x19` (unset, all, mentions, off), gated on caps2 + `0x80` and `TAG_CHAN_NOTIFY`, read on every completed sync, keyed by PSK, slot resolved at + send time; independent of the phone setting, which is relabeled Phone notifications (#1068, + #1060). +- Display mode and status LED (device-info bytes 86, 85) and rotation over `0xC5 0x1A/0x1B` + gated on `TAG_DISPLAY_ROTATION` (#523, #1098). Display and LED gated independently on advert + tags `0x04` TAG_DISPLAY_CONTROL and `0x05` TAG_LED_CONTROL, falling back to caps2 `0x04` + when neither tag is advertised, the advert is refused, or it is not answered yet; each SET + refused locally without its own capability; rotation SET gate unchanged. Client half of + firmware #2069; the tags are on firmware-base, not in `offband-v1.7.0` (#1132). +- BLE pairing PIN: `CMD_SET_DEVICE_PIN` (37), read from device-info bytes 4..7, shown in plain + text, 100000-999999 or 0 for the firmware default (#1100). +- Factory reset: `CMD_FACTORY_RESET` (51), no version floor. "Also delete this radio's data + stored in the app" (default on) deletes every perRadio surface from `scoping_inventory.dart` + when the reset is sent; no confirmation from the radio is possible. Failed delete shows a + persistent banner (#1057, #1118). + +### App + +- Sponsor window: 3rd launch and 7 days, Not now 30 days, Sponsor 90 days, Never Show Again; + hidden on iOS; test builds keyed by `GIT_SHA` show once per commit; five global + `sponsor_prompt_*` prefs keys (#1129). +- App-only settings from the connect screen: App Settings, Debug (Share logs, logging switch, + log viewers) and About without a radio; export failures persistent (#693, #1030). +- Bluetooth scan start failure shown persistently, with a Location-off message (#1033). +- Tile User-Agent `OffbandMeshCore (app.offband.meshcore; +https://offband.org)` (not on web); + OSM credit on all six maps (#1111). +- Notify-mode saves, notify decisions and the slot lookup's source logged (#1122). +- Long menus scroll; links beside @mentions tappable (#1074, #1075, #1079). +- "Offband MeshCore" spelling in launcher, titles, strings, notifications, web; Windows + `ProductName` unchanged so the data folder does not move (#1050). +- iOS: Google Play link, Donate and Experimental hidden; back arrow, edge swipe, keyboard + dismiss; `ITSAppUsesNonExemptEncryption` false (#1043, #1048, #1051, #1052). + +### Build and release + +- First App Store release (iOS 1.7.0) went live 2026-11-09. The release `ios` job uploads + 1.8.0 to App Store Connect; submitting it for review is a manual step there (#200, #961). +- macOS build as a release asset (#1108), now Developer ID signed and notarized (#1109): + `macos-signed.yml` (env `apple-macos`) builds, signs and submits on a Mac without waiting, + `notary-wait` polls Apple on Linux for up to 340 minutes, `staple` runs on a Mac, and + `macos-attach` adds `offband-macos.zip` to the published release. Not in `release`'s needs; + an unnotarized app is never published. +- iOS signing toolchain, signed IPA, TestFlight upload, App Store review package (#958, #1012, + #1013, #1014, #1016, #1017, #1019, #1037, #1038, #1039, #1040, #1041, #1044, #1045, #1046, + #1047). +- Plan of record for Play publishing automation (#1077, #1091). +- Release texts gain `announcements/.md`, the GitHub Discussions Announcements post + (#1137). + +### Known issues + +- #1121 Phone notifications Off can still notify. +- #1123 Factory reset leaves stale data for that radio on other devices. +- #1132 On firmware 1.7.0, boards with a display and no controllable LED still show the LED + switch (needs the firmware tags). +- #1124 `_sendIndicatorSet` never clears its pending-write flag, so a stale read can overwrite + a newer change. +- #1028 A refused contact add shows as added; DMs fail silently after 8 s. +- #1134 Position Reporting Interval reports success when the radio refuses it (T1000-E). +- #1066 Two capability reads still use `FIRMWARE_VER_CODE`. + ## [1.7.0] - 2026-10-26 Production successor to 1.6.0. A foundations release: per-message storage with a diff --git a/CLAUDE.md b/CLAUDE.md index 2b3ef290..c5618288 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,8 +1,8 @@ -# Offband Meshcore - Flutter Client +# Offband MeshCore - Flutter Client -**Offband Meshcore** is an open-source Flutter client for MeshCore LoRa mesh networking devices. Connects to MeshCore-compatible radios over **BLE, TCP, or USB serial** and provides direct/channel chat, contact and channel management, on-map node tracking, repeater administration, and on-device message translation. +**Offband MeshCore** is an open-source Flutter client for MeshCore LoRa mesh networking devices. Connects to MeshCore-compatible radios over **BLE, TCP, or USB serial** and provides direct/channel chat, contact and channel management, on-map node tracking, repeater administration, and on-device message translation. -> Offband Meshcore is an independent fork of **[MeshCore Open](https://github.com/zjs81/meshcore-open)** by zjs81, used under the MIT License. See [LICENSE](LICENSE) and the README Credits section. +> Offband MeshCore is an independent fork of **[MeshCore Open](https://github.com/zjs81/meshcore-open)** by zjs81, used under the MIT License. See [LICENSE](LICENSE) and the README Credits section. ## Build Commands diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0545693a..7de22267 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,4 @@ -# How to contribute to Offband Meshcore +# How to contribute to Offband MeshCore Before submitting any pull requests (PR), please review the following information. diff --git a/README.md b/README.md index 8d57e1ef..e4df0c13 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ -# Offband Meshcore +# Offband MeshCore Open-source Flutter client for MeshCore LoRa mesh networking devices. -> **Offband Meshcore** is an independent fork of **[MeshCore Open](https://github.com/zjs81/meshcore-open)** by [zjs81](https://github.com/zjs81), used under the MIT License. Full credit to the original project — see [Credits](#credits). +> **Offband MeshCore** is an independent fork of **[MeshCore Open](https://github.com/zjs81/meshcore-open)** by [zjs81](https://github.com/zjs81), used under the MIT License. Full credit to the original project — see [Credits](#credits). ## Overview -Offband Meshcore is a cross-platform mobile application for communicating with MeshCore LoRa mesh network devices via Bluetooth Low Energy (BLE). The app enables long-range, off-grid communication through peer-to-peer messaging, public channels, and mesh networking capabilities. +Offband MeshCore is a cross-platform mobile application for communicating with MeshCore LoRa mesh network devices via Bluetooth Low Energy (BLE). The app enables long-range, off-grid communication through peer-to-peer messaging, public channels, and mesh networking capabilities. ## Screenshots @@ -250,7 +250,7 @@ For issues, questions, or feature requests, please open an issue on GitHub: ## Credits -Offband Meshcore is an independent fork of **[MeshCore Open](https://github.com/zjs81/meshcore-open)** by **zjs81**, used under the MIT License. The original copyright and license are retained in [LICENSE](LICENSE). Deep thanks to zjs81 and the upstream contributors. +Offband MeshCore is an independent fork of **[MeshCore Open](https://github.com/zjs81/meshcore-open)** by **zjs81**, used under the MIT License. The original copyright and license are retained in [LICENSE](LICENSE). Deep thanks to zjs81 and the upstream contributors. - Built with [Flutter](https://flutter.dev/) - Map tiles from [OpenStreetMap](https://www.openstreetmap.org/) diff --git a/TESTFLIGHT_GUIDE.md b/TESTFLIGHT_GUIDE.md index c3694a10..5d48e160 100644 --- a/TESTFLIGHT_GUIDE.md +++ b/TESTFLIGHT_GUIDE.md @@ -6,7 +6,7 @@ - [x] Xcode installed - [x] Apple Transporter app installed - [x] App icons ready (1024x1024px) -- [x] Bundle ID configured: `app.offband.meshcore` +- [x] Bundle ID configured: `app.offband.meshcore.ios` ## Step 1: Register Bundle Identifier @@ -15,8 +15,8 @@ 3. Select **"App IDs"** → Continue 4. Select **"App"** → Continue 5. Fill in: - - **Description**: Offband Meshcore - - **Bundle ID**: Explicit - `app.offband.meshcore` + - **Description**: Offband MeshCore + - **Bundle ID**: Explicit - `app.offband.meshcore.ios` - **Capabilities**: Leave defaults (or add as needed) 6. Click **Continue** → **Register** @@ -28,9 +28,9 @@ 4. Click the **"+"** button → **"New App"** 5. Fill in the form: - **Platforms**: iOS - - **Name**: Offband Meshcore + - **Name**: Offband MeshCore - **Primary Language**: English (U.S.) - - **Bundle ID**: Select `app.offband.meshcore` from dropdown + - **Bundle ID**: Select `app.offband.meshcore.ios` from dropdown - **SKU**: `meshcore-open-001` (or any unique identifier) - **User Access**: Full Access 6. Click **"Create"** @@ -86,7 +86,7 @@ The IPA will be created at: `build/ios/ipa/meshcore_open.ipa` 3. Fill in **Description**: ``` - Offband Meshcore is a Flutter client for MeshCore LoRa mesh networking devices. + Offband MeshCore is a Flutter client for MeshCore LoRa mesh networking devices. Features: - BLE connectivity to MeshCore devices @@ -105,7 +105,7 @@ The IPA will be created at: `build/ios/ipa/meshcore_open.ipa` ### Version Information 1. **What's New in This Version**: ``` - Initial release of Offband Meshcore + Initial release of Offband MeshCore - BLE device connectivity - Mesh network messaging @@ -241,4 +241,4 @@ Distribution: For issues with: - **App Store Process**: [Apple Developer Support](https://developer.apple.com/contact/) - **Flutter Build Issues**: [Flutter GitHub](https://github.com/flutter/flutter/issues) -- **Offband Meshcore App**: [GitHub Issues](https://github.com/OffbandMesh/meshcore-client/issues) +- **Offband MeshCore App**: [GitHub Issues](https://github.com/OffbandMesh/meshcore-client/issues) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index cd132c33..cf7472db 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -22,7 +22,7 @@