diff --git a/.env.macos-release.example b/.env.macos-release.example new file mode 100644 index 0000000..a8640a5 --- /dev/null +++ b/.env.macos-release.example @@ -0,0 +1,8 @@ +# Copy to the repository root .env.macos-release and set permissions to 0600. +# These are placeholders. Never commit a real certificate or password. +BUILD_CERTIFICATE_BASE64=REPLACE_WITH_BASE64_P12 +P12_PASSWORD=REPLACE_WITH_P12_EXPORT_PASSWORD +KEYCHAIN_PASSWORD=REPLACE_WITH_TEMPORARY_KEYCHAIN_PASSWORD +APPLE_ID=developer@example.com +APPLE_TEAM_ID=REPLACE_WITH_TEAM_ID +APPLE_APP_SPECIFIC_PASSWORD=REPLACE_WITH_APP_SPECIFIC_PASSWORD diff --git a/.github/workflows/napstrfy-desktop.yml b/.github/workflows/napstrfy-desktop.yml index ad2c5e8..616f91a 100644 --- a/.github/workflows/napstrfy-desktop.yml +++ b/.github/workflows/napstrfy-desktop.yml @@ -11,6 +11,8 @@ on: - 'remote-protocol/**' - 'static/**' - '.github/workflows/napstrfy-desktop.yml' + - 'scripts/macos-release.mjs' + - 'tests/macos-release.test.mjs' permissions: contents: read @@ -31,9 +33,11 @@ jobs: - name: macOS-Intel runner: macos-15-intel bundles: app + mac_arch: x86_64 - name: macOS-Apple-Silicon runner: macos-15 bundles: app + mac_arch: arm64 runs-on: ${{ matrix.runner }} defaults: run: @@ -47,6 +51,9 @@ jobs: cache: npm cache-dependency-path: android/package-lock.json - uses: dtolnay/rust-toolchain@stable + - name: Verify native macOS runner + if: runner.os == 'macOS' + run: test "$(uname -m)" = '${{ matrix.mac_arch }}' - name: Install Linux build and audio dependencies if: runner.os == 'Linux' run: | @@ -76,26 +83,39 @@ jobs: writeFileSync('src-tauri/tauri.ci.conf.json', JSON.stringify({ version: tag ? tag.slice(1) : config.version })); JS - name: Build installer + if: runner.os != 'macOS' env: APPIMAGE_EXTRACT_AND_RUN: '1' NO_STRIP: '1' run: npm run bundle -- --verbose --config src-tauri/tauri.ci.conf.json --bundles '${{ matrix.bundles }}' -- --locked - - name: Package macOS DMG + - name: Test macOS release helper if: runner.os == 'macOS' + run: node --test ../tests/macos-release.test.mjs + - name: Build community macOS DMG for pull requests + if: runner.os == 'macOS' && github.event_name == 'pull_request' + run: npm run macos-build + - name: Build signed and notarized macOS DMG + if: runner.os == 'macOS' && github.event_name != 'pull_request' + env: + BUILD_TAG: ${{ github.ref_type == 'tag' && github.ref_name || '' }} + BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }} + P12_PASSWORD: ${{ secrets.P12_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} run: | - # Retry only packaging, without recompiling or skipping verification. - # Verbose output exposes hdiutil errors hidden by the default logger. - for attempt in 1 2 3; do - if npm run tauri -- bundle --verbose --ci --config src-tauri/tauri.ci.conf.json --bundles dmg; then - break - fi - if [[ "$attempt" == 3 ]]; then - echo 'Napstrfy DMG packaging failed after three attempts' >&2 - exit 1 - fi - echo "DMG packaging attempt $attempt failed; retrying in 10 seconds" >&2 - sleep 10 - done + args=(--ci) + if [[ -n "$BUILD_TAG" ]]; then + args+=(--tag "$BUILD_TAG") + fi + npm run macos-build:signed -- "${args[@]}" + - name: Always clean macOS signing session + if: always() && runner.os == 'macOS' + run: | + if [[ -f ../scripts/macos-release.mjs ]]; then + node ../scripts/macos-release.mjs --app napstrfy --cleanup + fi - name: Prepare and smoke-test AppImage if: runner.os == 'Linux' run: | @@ -112,31 +132,17 @@ jobs: echo 'Napstrfy failed its AppImage startup check' >&2 exit 1 fi - - name: Verify macOS application signature - if: runner.os == 'macOS' - run: | - # A DMG-only build removes the intermediate .app after packaging. - # Verify the application users will actually install from the DMG. - dmg="$(find src-tauri/target/release/bundle/dmg -maxdepth 1 -name '*.dmg' -print -quit)" - if [[ -z "$dmg" ]]; then - echo 'Tauri did not produce the expected Napstrfy DMG' >&2 - exit 1 - fi - mount_point="$(mktemp -d "$RUNNER_TEMP/napstrfy-dmg.XXXXXX")" - cleanup_dmg() { - hdiutil detach -quiet "$mount_point" >/dev/null 2>&1 || true - rmdir "$mount_point" >/dev/null 2>&1 || true - } - trap cleanup_dmg EXIT - hdiutil attach -readonly -nobrowse -mountpoint "$mount_point" "$dmg" >/dev/null - app="$mount_point/Napstrfy.app" - if [[ ! -f "$app/Contents/MacOS/napstrfy" ]]; then - echo 'The finished DMG does not contain the Napstrfy application' >&2 - exit 1 - fi - codesign --verify --deep --strict --verbose=2 "$app" - name: Collect installers and checksums + env: + MACOS_BUILD_MODE: ${{ github.event_name == 'pull_request' && 'unsigned' || 'signed' }} run: | + if [[ "$RUNNER_OS" == 'macOS' ]]; then + artifacts="src-tauri/target/macos-release/$MACOS_BUILD_MODE" + (cd "$artifacts" && shasum -a 256 -c ./*.dmg.sha256) + mkdir -p installers + cp "$artifacts"/*.dmg "$artifacts"/*.dmg.sha256 installers/ + exit 0 + fi node --input-type=module <<'JS' import { readdirSync, readFileSync, mkdirSync, copyFileSync, writeFileSync } from 'node:fs'; import { join, basename } from 'node:path'; @@ -200,7 +206,7 @@ jobs: cat > "$notes" <<'NOTES' Napstr and Napstrfy installers. - The macOS DMGs are ad-hoc-signed, unnotarized community builds. After the first blocked launch, open System Settings → Privacy & Security and choose Open Anyway. + Release macOS DMGs are Developer ID signed, notarized, and stapled. SHA-256 checksums accompany the installers. NOTES create_args=(release create "$RELEASE_TAG" --verify-tag --draft --title "Napstr $RELEASE_TAG" --notes-file "$notes") if [[ "$RELEASE_TAG" == *-* ]]; then @@ -211,4 +217,4 @@ jobs: gh "${create_args[@]}" || gh release view "$RELEASE_TAG" >/dev/null fi shopt -s nullglob - gh release upload "$RELEASE_TAG" installers/*.AppImage installers/*.exe installers/*.dmg --clobber + gh release upload "$RELEASE_TAG" installers/*.AppImage installers/*.exe installers/*.dmg installers/*.sha256 --clobber diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a152b91..de8aced 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,20 +1,26 @@ -name: Build desktop and Android installers +name: Build Napstr desktop and Napstrfy Android installers on: workflow_dispatch: inputs: release_tag: - description: Release tag to create (for example, v0.1.0 or v0.1.5-rc1) - required: true + description: Release tag to create (v0.1.0 or v0.1.5-rc1); leave empty for macOS test installers + required: false type: string push: tags: ['v*'] + pull_request: + types: [opened, synchronize, reopened, edited] permissions: - contents: write + contents: read jobs: build: + if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.release_tag != '') + name: Napstr ${{ matrix.platform }} + permissions: + contents: write env: RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }} strategy: @@ -33,17 +39,11 @@ jobs: tor_url: https://archive.torproject.org/tor-package-archive/torbrowser/15.0.20/tor-expert-bundle-windows-x86_64-15.0.20.tar.gz tor_sha256: d59bff934e3ad876e1623e24ae60c19aeea56f50178093b9f86fba230639f949 - platform: macos-15-intel - args: --config src-tauri/tauri.macos-community.conf.json --bundles dmg tor_platform: macos mac_arch: x86_64 - tor_url: https://archive.torproject.org/tor-package-archive/torbrowser/15.0.20/tor-expert-bundle-macos-x86_64-15.0.20.tar.gz - tor_sha256: 6ec3048b3a5d55e297f35d84830d0e338884d702aac3db49056633c1223841df - platform: macos-15 - args: --config src-tauri/tauri.macos-community.conf.json --target aarch64-apple-darwin --bundles dmg tor_platform: macos mac_arch: arm64 - tor_url: https://archive.torproject.org/tor-package-archive/torbrowser/15.0.20/tor-expert-bundle-macos-aarch64-15.0.20.tar.gz - tor_sha256: 73fdccde8136678e41a625160993e6a9dc4f4ff8cd376318b5e41e5627d55682 runs-on: ${{ matrix.platform }} steps: - uses: actions/checkout@v7 @@ -63,6 +63,9 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.platform == 'macos-15' && 'aarch64-apple-darwin' || '' }} + - name: Verify native macOS runner + if: runner.os == 'macOS' + run: test "$(uname -m)" = '${{ matrix.mac_arch }}' - name: Install Linux system dependencies if: matrix.platform == 'ubuntu-22.04' run: | @@ -97,14 +100,15 @@ jobs: run: npm run check - name: Test native core run: cargo test --manifest-path src-tauri/Cargo.toml + - name: Test macOS release helper + if: runner.os == 'macOS' + run: node --test tests/macos-release.test.mjs - name: Add pinned Tor Expert Bundle + if: runner.os != 'macOS' shell: bash run: bash scripts/prepare-tor.sh '${{ matrix.tor_platform }}' '${{ matrix.tor_url }}' '${{ matrix.tor_sha256 }}' - - name: Ad-hoc sign bundled macOS Tor runtime - if: matrix.tor_platform == 'macos' - shell: bash - run: bash scripts/adhoc-sign-macos.sh src-tauri/resources/tor/macos '${{ matrix.mac_arch }}' - uses: tauri-apps/tauri-action@v1 + if: runner.os != 'macOS' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Do not let linuxdeploy strip the bundled, already-built Tor runtime. @@ -118,7 +122,7 @@ jobs: releaseBody: | Cross-platform Napstr desktop installers. - The macOS DMGs are ad-hoc-signed, unnotarized community builds. After the first blocked launch, open System Settings → Privacy & Security and choose Open Anyway. + Release macOS DMGs are Developer ID signed, notarized, and stapled. SHA-256 checksums accompany the macOS installers. releaseDraft: true prerelease: ${{ contains(env.RELEASE_TAG, '-') }} args: ${{ matrix.args }} @@ -155,44 +159,121 @@ jobs: # tauri-action has already uploaded the initial bundle. Replace that # asset with the repaired, verified AppImage under the same filename. gh release upload "$RELEASE_TAG" "$appimage" --clobber - - name: Verify macOS community DMG - if: matrix.tor_platform == 'macos' + - name: Build signed and notarized macOS DMG + if: runner.os == 'macOS' + env: + BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }} + P12_PASSWORD: ${{ secrets.P12_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + run: npm run macos-build:signed -- --ci --tag "$RELEASE_TAG" + - name: Always clean macOS signing session + if: always() && runner.os == 'macOS' shell: bash run: | - dmg="$(find src-tauri/target -type f -path '*/release/bundle/dmg/*.dmg' -print -quit)" - if [[ -z "$dmg" ]]; then - echo "Tauri did not produce the expected macOS DMG" >&2 - exit 1 - fi - - mount_point="$(mktemp -d "$RUNNER_TEMP/napstr-dmg.XXXXXX")" - cleanup_dmg() { - hdiutil detach -quiet "$mount_point" >/dev/null 2>&1 || true - rmdir "$mount_point" >/dev/null 2>&1 || true - } - trap cleanup_dmg EXIT - hdiutil attach -readonly -nobrowse -mountpoint "$mount_point" "$dmg" >/dev/null - - app="$(find "$mount_point" -maxdepth 2 -type d -name 'Napstr.app' -print -quit)" - if [[ -z "$app" ]]; then - echo "The finished DMG does not contain Napstr.app" >&2 - exit 1 + if [[ -f scripts/macos-release.mjs ]]; then + node scripts/macos-release.mjs --cleanup fi - codesign --verify --deep --strict --verbose=2 "$app" - app_binary="$app/Contents/MacOS/napstr" - tor_binary="$(find "$app/Contents/Resources" -type f -path '*/tor/macos/tor/tor' -print -quit)" - if [[ ! -f "$app_binary" || -z "$tor_binary" ]]; then - echo "The app bundle is missing Napstr or its bundled Tor executable" >&2 - exit 1 + - name: Upload verified macOS installer and checksum + if: runner.os == 'macOS' + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + artifacts=src-tauri/target/macos-release/signed + (cd "$artifacts" && shasum -a 256 -c ./*.dmg.sha256) + if ! gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + create_args=(release create "$RELEASE_TAG" --target "$GITHUB_SHA" --draft --title "Napstr $RELEASE_TAG" --notes "Napstr and Napstrfy installers. Release macOS DMGs are Developer ID signed, notarized, and stapled.") + if [[ "$RELEASE_TAG" == *-* ]]; then + create_args+=(--prerelease) + fi + gh "${create_args[@]}" || gh release view "$RELEASE_TAG" >/dev/null fi + gh release upload "$RELEASE_TAG" "$artifacts"/*.dmg "$artifacts"/*.dmg.sha256 --clobber - bash scripts/verify-macos-bundle.sh "$app_binary" "$tor_binary" '${{ matrix.mac_arch }}' - - cleanup_dmg - trap - EXIT + macos_test: + if: >- + (github.event_name == 'workflow_dispatch' && inputs.release_tag == '') || + (github.event_name == 'pull_request' && + startsWith(github.event.pull_request.title, '[build]') && + (github.event.action != 'edited' || github.event.changes.title != null)) + name: Napstr ${{ matrix.name }} + env: + MACOS_BUILD_MODE: >- + ${{ (github.event_name == 'workflow_dispatch' || + (github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.user.login != 'dependabot[bot]' && + github.actor != 'dependabot[bot]')) && 'signed' || 'unsigned' }} + strategy: + fail-fast: false + matrix: + include: + - name: macOS-Intel + runner: macos-15-intel + arch: x86_64 + - name: macOS-Apple-Silicon + runner: macos-15 + arch: arm64 + runs-on: ${{ matrix.runner }} + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: npm + - uses: dtolnay/rust-toolchain@stable + - name: Verify native macOS runner + run: test "$(uname -m)" = '${{ matrix.arch }}' + - run: npm ci + - name: Verify interface + run: npm run check + - name: Test JavaScript and macOS release helper + run: npm run test:unit + - name: Test native core + run: cargo test --manifest-path src-tauri/Cargo.toml --locked + - name: Build community macOS DMG for fork or Dependabot pull requests + if: env.MACOS_BUILD_MODE == 'unsigned' + run: npm run macos-build + - name: Build signed and notarized macOS DMG + if: env.MACOS_BUILD_MODE == 'signed' + env: + BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }} + P12_PASSWORD: ${{ secrets.P12_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + run: npm run macos-build:signed -- --ci + - name: Always clean macOS signing session + if: always() + run: | + if [[ -f scripts/macos-release.mjs ]]; then + node scripts/macos-release.mjs --cleanup + fi + - name: Verify installer checksums + run: | + cd "src-tauri/target/macos-release/$MACOS_BUILD_MODE" + shasum -a 256 -c ./*.dmg.sha256 + - name: Upload verified installer and checksum + uses: actions/upload-artifact@v4 + with: + name: Napstr-${{ matrix.name }}-${{ env.MACOS_BUILD_MODE }} + path: | + src-tauri/target/macos-release/${{ env.MACOS_BUILD_MODE }}/*.dmg + src-tauri/target/macos-release/${{ env.MACOS_BUILD_MODE }}/*.dmg.sha256 + if-no-files-found: error android: + if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.release_tag != '') name: Build Napstrfy Android APK + permissions: + contents: write runs-on: ubuntu-22.04 env: RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }} @@ -305,7 +386,7 @@ jobs: cp "$apk" "$asset" if ! gh release view "$RELEASE_TAG" >/dev/null 2>&1; then - create_args=(release create "$RELEASE_TAG" --draft --title "Napstr $RELEASE_TAG" --notes "Cross-platform Napstr desktop installers and the Napstrfy Android companion.") + create_args=(release create "$RELEASE_TAG" --target "$GITHUB_SHA" --draft --title "Napstr $RELEASE_TAG" --notes "Cross-platform Napstr desktop installers and the Napstrfy Android companion.") if [[ "$RELEASE_TAG" == *-* ]]; then create_args+=(--prerelease) fi diff --git a/.gitignore b/.gitignore index d8e8057..2fa720e 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,5 @@ remote-protocol/target/ website/dist/ test-results/ playwright-report/ + +.env.macos-release diff --git a/README.md b/README.md index 17cacae..d382e6c 100644 --- a/README.md +++ b/README.md @@ -44,9 +44,33 @@ npm run bundle Napstr automatically downloads and verifies the pinned official Tor Expert Bundle for your platform before building. -macOS release DMGs are ad-hoc-signed community builds and require no Apple -Developer account. After the first blocked launch, open **System Settings → -Privacy & Security → Open Anyway**. Apple Silicon and Intel builds both include Tor. +On macOS, build a verified DMG and SHA-256 checksum with: + +```bash +npm run macos-build # Ad-hoc community build; no Apple credentials +npm run macos-build:signed # Developer ID signed, notarized, and stapled +``` + +Both commands build for the current Mac's native architecture and include Tor. +Signed builds read the private root `.env.macos-release` file. Neither command +publishes a release. See [macOS release setup](docs/macos-releases.md) for +credentials, output paths, verification, and the equivalent Napstrfy commands. + +To request Napstr macOS installers from GitHub Actions, prefix your PR title with +`[build]`, for example `[build] Fix audio playback`. Adding the prefix to an +existing PR also starts a build; new commits rebuild while the prefix is present. +Same-repository PRs produce signed installers using the Apple signing secrets. +Fork and Dependabot PRs produce artifacts ending in `-unsigned`. +These builds use the existing **Build Napstr desktop and Napstrfy Android +installers** workflow (`release.yml`). For a manual macOS test build, run that +workflow on your branch with `release_tag` empty; providing a tag runs the full +release process. +Download `Napstr-macOS-Intel-signed` or `Napstr-macOS-Apple-Silicon-signed` from +the completed signed run to test on your Mac. + +GitHub release DMGs are signed and notarized for Apple Silicon and Intel. +Local community builds still require **System Settings → Privacy & Security → +Open Anyway** after the first blocked launch. ## Implemented architecture diff --git a/android/README.md b/android/README.md index 3857530..f3e6dbb 100644 --- a/android/README.md +++ b/android/README.md @@ -12,7 +12,8 @@ From `android/`, with the [Tauri prerequisites](https://v2.tauri.app/start/prere npm ci npm run desktop # Develop npm run bundle:windows # EXE (on Windows) -npm run bundle:macos # DMG (on macOS) +npm run macos-build # Ad-hoc community DMG (on macOS) +npm run macos-build:signed # Signed, notarized, and stapled DMG (on macOS) npm run appimage:build # AppImage (Linux with Docker) ``` @@ -26,7 +27,12 @@ The development shell includes the GStreamer plugins WebKit needs for audio playback. After changing the shell dependencies, stop the running client and run this command again to load the updated environment. -Pushing a `v*` tag attaches all installers to a draft release. GitHub displays a SHA-256 checksum for each installer. +Pushing a `v*` tag attaches installers and SHA-256 checksum files to a draft release. +Release macOS builds require Apple signing credentials; pull-request builds use +ad-hoc signatures. `bundle:macos` remains an alias for `macos-build`. +Both macOS commands build the current Mac's native architecture, share the root +`.env.macos-release` for signed local builds, and never upload anything. See +[macOS release setup](../docs/macos-releases.md). ## Android requirements diff --git a/android/package.json b/android/package.json index ffd71f5..8be23d7 100644 --- a/android/package.json +++ b/android/package.json @@ -13,7 +13,9 @@ "bundle:linux": "tauri build --bundles appimage", "appimage:build": "bash scripts/build-linux-container.sh", "bundle:windows": "tauri build --bundles nsis", - "bundle:macos": "tauri build --bundles dmg", + "bundle:macos": "npm run macos-build", + "macos-build": "node ../scripts/macos-release.mjs --app napstrfy", + "macos-build:signed": "node ../scripts/macos-release.mjs --app napstrfy --signed", "android:init": "tauri icon ../src-tauri/icons/icon.png && tauri android init && node scripts/configure-android.mjs", "android:dev": "node scripts/configure-android.mjs && tauri android dev", "android:dev:usb": "node scripts/configure-android.mjs && adb reverse tcp:1421 tcp:1421 && tauri android dev --host 127.0.0.1", diff --git a/docs/macos-releases.md b/docs/macos-releases.md new file mode 100644 index 0000000..acbbe18 --- /dev/null +++ b/docs/macos-releases.md @@ -0,0 +1,195 @@ +# macOS builds and releases + +Napstr and Napstrfy share `scripts/macos-release.mjs` for local and CI builds. +The release sequence follows [LNbits PR #4175](https://github.com/lnbits/lnbits/pull/4175), +using Tauri to build these applications. Local commands never create a tag, +publish a release, or upload installers to GitHub. + +## Prerequisites + +Use a native Intel or Apple Silicon Mac with Node.js 24, Rust/Cargo, Xcode +command-line tools, and the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/). +Accept the Xcode license and ensure `xcrun notarytool --version` works for signed +builds. Run `npm ci` in the application directory first. Cross-compilation and +Rosetta are deliberately unsupported by this helper; build each architecture +on a corresponding Mac or GitHub runner. + +The helper uses Cargo from `PATH`, then checks `$CARGO_HOME/bin` (normally +`~/.cargo/bin`). If the Cargo launcher is missing, it asks `rustup` for the active +toolchain and makes its Cargo and compiler available to build subprocesses. +This preserves [Rustup's toolchain selection](https://rust-lang.github.io/rustup/overrides.html), +including project overrides and `RUSTUP_TOOLCHAIN`, without editing shell settings. +If neither Cargo nor Rustup is installed, install Rust from https://rustup.rs and +reopen your terminal before retrying. + +## Local commands + +From the repository root: + +```sh +npm ci +npm run macos-build +npm run macos-build:signed +``` + +For Napstrfy, either run those same commands from `android/`, or use: + +```sh +npm --prefix android ci +npm --prefix android run macos-build +npm --prefix android run macos-build:signed +``` + +`macos-build` creates an ad-hoc community build without Apple credentials. +Downloaded community builds may require **System Settings → Privacy & Security +→ Open Anyway** after the first blocked launch. `macos-build:signed` requires +Developer ID credentials and creates a signed, notarized, stapled release build. +There is no automatic fallback to a community build when signing fails. + +Outputs are under the selected application's `src-tauri/target/macos-release/`: + +```text +signed/Napstr_0.1.0_arm64.dmg +signed/Napstr_0.1.0_arm64.dmg.sha256 +unsigned/Napstr_0.1.0_arm64.dmg +unsigned/Napstr_0.1.0_arm64.dmg.sha256 +``` + +Napstrfy filenames start with `Napstrfy`; Intel filenames use `x86_64`. +Use `-- --tag v0.1.5-rc1` to set the packaged version without editing tracked +version files. The root release workflow also synchronizes source versions +using its existing `set-release-version.mjs` step. +Apple bundle metadata uses the numeric version (`0.1.5` for `v0.1.5-rc1`); +the DMG filename retains the full release version. + +## Signing credentials + +Signing requires an Apple Developer Program membership and a valid **Developer +ID Application** certificate with its private key. Export that identity from +Keychain Access as a password-protected `.p12`. An Apple Development or Developer +ID Installer certificate is unsuitable. Export exactly one current identity for +the expected team. See [Tauri's macOS signing guide](https://v2.tauri.app/distribute/sign/macos/). + +Use `.env.macos-release.example` as the template for a private file named +`.env.macos-release` at the repository root. Both applications read this same +file. It is Git-ignored and must have mode `0600`: + +```sh +chmod 600 .env.macos-release +``` + +| Variable | Value | +| --- | --- | +| `BUILD_CERTIFICATE_BASE64` | Base64-encoded P12 containing the certificate and private key. | +| `P12_PASSWORD` | Password used to export the P12. | +| `KEYCHAIN_PASSWORD` | A separate password for the disposable build keychain, not the login keychain password. | +| `APPLE_ID` | Apple account email associated with the developer team. | +| `APPLE_TEAM_ID` | Ten-character team ID matching the certificate. | +| `APPLE_APP_SPECIFIC_PASSWORD` | An app-specific password from account.apple.com, not the account login password. | + +Store the base64 value on one line. The parser accepts `NAME=value` and quoted +values; it does not evaluate shell code, expand variables, or support multiline +values or inline comments. Never source this file or commit real credentials. +The helper excludes signing credentials from frontend, Cargo, Tauri, and Tor +subprocess environments, and redacts credential-bearing diagnostics. + +## Build and verification sequence + +1. Validate the requested mode, credentials, and native architecture. Build the + `.app` with Tauri signing disabled so all final signing happens in one place. + Napstr downloads and verifies its pinned Tor Expert Bundle. +2. In Napstr's app, make Tor's bundled library references relative to their + loader. Reject unresolved non-system libraries. This avoids depending on + `DYLD_LIBRARY_PATH` or granting a hardened-runtime exception for it. +3. For signed builds, import the P12 into a unique temporary keychain. Select + exactly one valid Developer ID Application identity for the configured team + and validate notarization credentials. Preserve the existing keychain search + list; never replace the user's default keychain. +4. Sign native code and nested bundles from the inside out, then sign the app. + Developer ID signatures use hardened runtime and secure timestamps. Neither + application needs executable-memory or library-validation exceptions. +5. Verify native architectures and signatures. For signed builds, independently + verify the team, Developer ID requirement, timestamps, hardened runtime, and + absence of extra entitlements. Submit an app ZIP to Apple, require status + `Accepted`, then staple and validate the app's ticket. +6. Copy the finalized app with `ditto`, preserving its ticket and symlinks, into + a DMG payload with an Applications shortcut. Create an uncompressed HFS+ + image and convert it to the final compressed DMG. For signed builds, sign, + independently notarize, staple, and validate that exact final DMG. +7. Verify the image, mount it read-only, repeat app signature and architecture + checks, and run the bundled Tor executable without DYLD overrides. Signed + builds also require Gatekeeper assessments and valid app/DMG tickets. +8. Detach the image, retrying briefly if macOS still reports it busy after the + verification checks. Restore and verify the original keychain search list, and + delete temporary signing material. Only then write and recheck the SHA-256 + checksum of the final stapled image. Failures remove the attempted DMG and + checksum; cleanup failures retain a recovery journal. + +Notarization submits once per artifact, prints the submission ID, and waits up +to 30 minutes. A timeout or rejection fails the build and reports Apple's +diagnostics. The first submission of a new app can take longer; inspect the +submission in Apple's service before retrying. There is no silent skip. + +For recovery after an uncatchable termination or restart: + +```sh +npm run macos-build -- --cleanup +npm --prefix android run macos-build -- --cleanup +``` + +The journal lives at the application's `src-tauri/target/macos-release-state.json` +and contains paths and the original keychain search list, never credentials. +Do not run local macOS builds concurrently: keychain search lists belong to the +current user, including across the two applications. + +## GitHub Actions + +Configure the six names above as repository Actions secrets accessible to +`lnbits/napstr`. The same names are used by LNbits, but secrets restricted to +that repository are not automatically available here. + +`release.yml` handles both Napstr release builds and optional macOS test builds +on `macos-15` (Apple Silicon) and `macos-15-intel`. A `v*` tag or a manual run with +`release_tag` set runs the full Napstr desktop and Napstrfy Android release process. +`napstrfy-desktop.yml` runs signed Napstrfy desktop builds for tags and +manual runs; its pull-request builds use community signatures without secrets. +Manual Napstrfy runs on a branch produce workflow artifacts; tagged runs attach +them to the draft release. Existing `v*` release triggers are preserved. + +For Napstr testing without creating a release, prefix a PR title with `[build]`, +for example `[build] Fix audio playback`. The `macos_test` job in `release.yml` builds +Intel and Apple Silicon installers when that PR opens, reopens, receives new +commits, or has its title edited. Adding the prefix to an existing PR starts a +build; removing it skips subsequent builds. Description-only edits do not build. +There is no changed-file filter: the title flag controls whether a PR builds. +PRs skip the release jobs, including Linux, Windows, Android, and release uploads. + +Same-repository PRs use the six Apple signing secrets to create signed installers. +Fork PRs, Dependabot-authored PRs, and runs triggered by Dependabot use ad-hoc +signatures without Apple credentials. Those artifacts end in `-unsigned` and may +show Gatekeeper's unverified-developer warning. PRs without `[build]` skip the +Napstr macOS build job. + +You can also use **Actions → Build Napstr desktop and Napstrfy Android installers +→ Run workflow**, select a branch, and leave `release_tag` empty for a signed +macOS test build. Setting `release_tag` instead runs the full release process. +Signed PR and manual test runs produce these artifacts: + +- `Napstr-macOS-Intel-signed` +- `Napstr-macOS-Apple-Silicon-signed` + +Download the artifact for your Mac from the completed run, extract it, and open +the enclosed DMG. It includes a SHA-256 checksum. The version comes from the +selected branch's Tauri configuration; no release tag is required or created. +The workflow must first exist on the default branch to enable manual runs; see +[GitHub's manual-run documentation](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/manually-run-a-workflow). + +All signed workflows invoke the same npm commands with `--ci`, which reads credentials +from the environment instead of a local file. Cleanup runs with `always()` +before upload. DMGs and checksum files are uploaded only after successful +verification and cleanup. Release publication remains a separate action. + +Before publishing the first release, test downloaded installers on both an Intel +Mac and an Apple Silicon Mac, including app startup, Tor connection, file +transfer, audio playback, and Napstrfy pairing. The helper's signature and Tor +checks do not replace this interactive application testing. diff --git a/package.json b/package.json index 9f116dd..17a4a86 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "desktop": "tauri dev", "prebundle": "node scripts/prepare-tor-bundle.mjs", "bundle": "tauri build", + "macos-build": "node scripts/macos-release.mjs", + "macos-build:signed": "node scripts/macos-release.mjs --signed", "appimage:build": "bash scripts/build-appimage-local.sh build", "appimage:run": "bash scripts/build-appimage-local.sh launch", "appimage:test-user": "bash scripts/run-appimage-test-user.sh", diff --git a/scripts/macos-release.mjs b/scripts/macos-release.mjs new file mode 100644 index 0000000..eac58ca --- /dev/null +++ b/scripts/macos-release.mjs @@ -0,0 +1,560 @@ +#!/usr/bin/env node + +// Shared local/CI packaging. This command never creates or uploads a release. +import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { constants, createReadStream } from 'node:fs'; +import { access, chmod, lstat, mkdir, mkdtemp, open, readFile, readdir, readlink, realpath, rename, rm, stat, symlink, writeFile } from 'node:fs/promises'; +import { arch, homedir, platform, tmpdir } from 'node:os'; +import { basename, delimiter, dirname, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +export const credentialNames = [ + 'BUILD_CERTIFICATE_BASE64', 'P12_PASSWORD', 'KEYCHAIN_PASSWORD', + 'APPLE_ID', 'APPLE_TEAM_ID', 'APPLE_APP_SPECIFIC_PASSWORD' +]; +const products = { + napstr: { directory: root, name: 'Napstr', binary: 'napstr', identifier: 'social.napstr.desktop' }, + napstrfy: { directory: join(root, 'android'), name: 'Napstrfy', binary: 'napstrfy', identifier: 'net.napstr.nostrfy' } +}; +const profile = 'napstr-notarization'; +const machMagic = new Set(['feedface', 'cefaedfe', 'feedfacf', 'cffaedfe', 'cafebabe', 'bebafeca', 'cafebabf', 'bfbafeca']); + +export function parseCredentials(contents) { + const values = {}; + for (const line of contents.split(/\r?\n/)) { + if (!line.trim() || line.trimStart().startsWith('#')) continue; + const match = /^\s*([A-Z_][A-Z_0-9]*)\s*=\s*(.*?)\s*$/.exec(line); + if (!match || !credentialNames.includes(match[1]) || Object.hasOwn(values, match[1])) { + throw new Error('Invalid or duplicate key in .env.macos-release'); + } + let value = match[2]; + if (value.startsWith('"') || value.startsWith("'")) { + if (value.length < 2 || value.at(-1) !== value[0]) throw new Error('Unclosed credential quote'); + value = value.slice(1, -1); + } + // Parse data only: never source the file or expand shell expressions. + values[match[1]] = value; + } + return values; +} + +export function validateCredentials(values) { + const missing = credentialNames.filter((name) => !values[name]?.trim()); + if (missing.length) throw new Error(`Missing signing credentials: ${missing.join(', ')}`); + if (!/^[A-Z0-9]{10}$/.test(values.APPLE_TEAM_ID)) throw new Error('APPLE_TEAM_ID must be a ten-character team ID'); + const encoded = values.BUILD_CERTIFICATE_BASE64.replace(/\s/g, ''); + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(encoded) || encoded.length % 4 !== 0) { + throw new Error('BUILD_CERTIFICATE_BASE64 must contain a base64-encoded P12'); + } + const certificate = Buffer.from(encoded, 'base64'); + if (certificate.toString('base64') !== encoded) throw new Error('Invalid base64 certificate'); + return certificate; +} + +export function cleanEnvironment(environment = process.env) { + return Object.fromEntries(Object.entries(environment).filter(([name]) => + !credentialNames.includes(name) && !name.startsWith('APPLE_') && + !name.startsWith('DYLD_') && !name.startsWith('TAURI_SIGNING_') && + !['TAURI_CONFIG', 'NODE_OPTIONS', 'CARGO_BUILD_TARGET'].includes(name) + )); +} + +export async function configureRustToolchain(runner, cwd) { + const environment = runner.environment; + const path = environment.PATH || ''; + const cargoBin = resolve(cwd, environment.CARGO_HOME || join(environment.HOME || homedir(), '.cargo'), 'bin'); + async function findExecutable(name, searchPath) { + for (const directory of searchPath.split(delimiter)) { + const candidate = resolve(cwd, directory, name); + try { + await access(candidate, constants.X_OK); + if ((await stat(candidate)).isFile()) return candidate; + } catch (error) { + if (!['ENOENT', 'ENOTDIR', 'EACCES'].includes(error.code)) throw error; + } + } + } + const prepend = (directories) => { + // Keep npm hooks on this Node installation when adding the Rust tools. + environment.PATH = [dirname(process.execPath), ...new Set(directories), path].join(delimiter); + }; + if (await findExecutable('cargo', path)) return; + if (await findExecutable('cargo', cargoBin)) { + prepend([cargoBin]); + return; + } + const rustup = await findExecutable('rustup', [path, cargoBin].join(delimiter)); + if (!rustup) { + throw new Error('Rust/Cargo was not found. Install Rust from https://rustup.rs, then reopen your terminal and retry. For an existing installation, add its bin directory to PATH or set CARGO_HOME.'); + } + // Ask rustup to honor the active toolchain and project overrides, including + // installations that have rustup but are missing the cargo/rustc proxies. + const directories = []; + for (const tool of ['cargo', 'rustc']) { + const result = await runner.run(`Locate Rust ${tool}`, rustup, ['which', tool], { cwd }); + const executable = result.stdout.trim(); + if (!executable || !await findExecutable(basename(executable), dirname(executable))) { + throw new Error(`Rustup could not locate an executable ${tool}. Repair the active Rust toolchain and retry.`); + } + directories.push(dirname(executable)); + } + prepend(directories); +} + +export function selectIdentity(output, team) { + const matches = [...output.matchAll(/^\s*\d+\) ([A-Fa-f0-9]{40}) "Developer ID Application: [^"\n]+ \(([A-Z0-9]{10})\)"\s*$/gm)]; + const identities = new Set(matches.filter((match) => match[2] === team).map((match) => match[1].toUpperCase())); + if (identities.size !== 1) { + throw new Error('Expected exactly one valid Developer ID Application identity with a private key for APPLE_TEAM_ID'); + } + return [...identities][0]; +} + +export class Runner { + constructor(credentials = {}) { + this.secrets = Object.values(credentials).filter(Boolean).sort((a, b) => b.length - a.length); + this.environment = cleanEnvironment(); + // npm hooks and Tauri must use the same Node installation as this helper. + this.environment.PATH = `${dirname(process.execPath)}:${this.environment.PATH || ''}`; + this.child = null; + this.interrupted = false; + } + + redact(value) { + let text = String(value); + for (const secret of this.secrets) text = text.split(secret).join('[REDACTED]'); + return text; + } + + async run(label, command, args = [], { cwd = root, input, timeout = 120_000, check = true, env = {}, quiet = false } = {}) { + if (this.interrupted) throw new Error('Build interrupted'); + if (!quiet) console.log(label); + return await new Promise((accept, reject) => { + const child = spawn(command, args, { cwd, env: { ...this.environment, ...env }, stdio: ['pipe', 'pipe', 'pipe'], detached: true }); + this.child = child; + let stdout = '', stderr = '', timedOut = false; + // Keep commands with credentials out of logs and bound diagnostic memory. + child.stdout.on('data', (chunk) => { stdout = (stdout + chunk).slice(-2_000_000); }); + child.stderr.on('data', (chunk) => { stderr = (stderr + chunk).slice(-2_000_000); }); + const timer = setTimeout(() => { timedOut = true; this.stopChild(); }, timeout); + const progress = quiet ? null : setInterval(() => console.log(`${label} is still running…`), 60_000); + child.on('error', (error) => { clearTimeout(timer); clearInterval(progress); this.child = null; reject(new Error(`${label}: ${this.redact(error.message)}`)); }); + child.on('close', (code, signal) => { + clearTimeout(timer); + clearInterval(progress); + this.child = null; + if (this.interrupted || timedOut || (check && code !== 0)) { + reject(new Error(`${label} failed (${timedOut ? 'timeout' : signal || code}).\n${this.redact(stdout + stderr).slice(-12_000)}`)); + } else accept({ stdout, stderr, code }); + }); + child.stdin.on('error', () => {}); + child.stdin.end(input); + }); + } + + stopChild() { + if (this.child?.pid) { + try { process.kill(-this.child.pid, 'SIGKILL'); } catch (error) { if (error.code !== 'ESRCH') throw error; } + } + } + + async plist(contents) { + const result = await this.run('Read Apple property list', '/usr/bin/plutil', ['-convert', 'json', '-o', '-', '-'], { input: contents, quiet: true }); + return JSON.parse(result.stdout); + } +} + +export class Session { + constructor(path, runner) { + this.path = path; + this.runner = runner; + this.state = { directory: null, keychain: null, searchList: null, mount: null }; + } + + async begin() { + await mkdir(dirname(this.path), { recursive: true }); + let handle; + try { handle = await open(this.path, 'wx', 0o600); } + catch (error) { + if (error.code === 'EEXIST') throw new Error('A macOS build session exists. Run npm run macos-build -- --cleanup before retrying.'); + throw error; + } + await handle.writeFile(JSON.stringify(this.state)); + await handle.close(); + } + + async save() { + await writeFile(`${this.path}.tmp`, JSON.stringify(this.state), { mode: 0o600 }); + await rename(`${this.path}.tmp`, this.path); + } + + async prepare() { + this.state.directory = await mkdtemp(join(tmpdir(), 'napstr-macos-')); + await chmod(this.state.directory, 0o700); + await this.save(); + } + + async setupKeychain(credentials) { + const runner = this.runner; + const original = await runner.run('Read keychain search list', '/usr/bin/security', ['list-keychains', '-d', 'user']); + this.state.searchList = original.stdout.split('\n').filter((line) => line.trim()).map((line) => JSON.parse(line.trim())); + const keychain = join(this.state.directory, 'signing.keychain-db'); + this.state.keychain = keychain; + await this.save(); + const p12 = join(this.state.directory, 'certificate.p12'); + await writeFile(p12, validateCredentials(credentials), { mode: 0o600, flag: 'wx' }); + const password = credentials.KEYCHAIN_PASSWORD; + try { + await runner.run('Create temporary keychain', '/usr/bin/security', ['create-keychain', '-p', password, keychain]); + await runner.run('Unlock temporary keychain', '/usr/bin/security', ['unlock-keychain', '-p', password, keychain]); + await runner.run('Set signing timeout', '/usr/bin/security', ['set-keychain-settings', '-lut', '21600', keychain]); + await runner.run('Add temporary keychain', '/usr/bin/security', ['list-keychains', '-d', 'user', '-s', keychain, ...this.state.searchList]); + await runner.run('Import Developer ID certificate', '/usr/bin/security', ['import', p12, '-k', keychain, '-P', credentials.P12_PASSWORD, '-T', '/usr/bin/codesign', '-T', '/usr/bin/security']); + } finally { await rm(p12, { force: true }); } + await runner.run('Allow unattended signing', '/usr/bin/security', ['set-key-partition-list', '-S', 'apple-tool:,apple:,codesign:', '-s', '-k', password, keychain]); + const result = await runner.run('Select signing identity', '/usr/bin/security', ['find-identity', '-v', '-p', 'codesigning', keychain]); + const identity = selectIdentity(result.stdout, credentials.APPLE_TEAM_ID); + await runner.run('Validate notarization credentials', '/usr/bin/xcrun', ['notarytool', 'store-credentials', profile, '--keychain', keychain, '--apple-id', credentials.APPLE_ID, '--team-id', credentials.APPLE_TEAM_ID, '--password', credentials.APPLE_APP_SPECIFIC_PASSWORD]); + return identity; + } + + async detach() { + if (!this.state.mount) return; + const result = await this.runner.run('Inspect mounted images', '/usr/bin/hdiutil', ['info', '-plist']); + const info = await this.runner.plist(result.stdout); + const mounted = (info.images || []).some((image) => (image['system-entities'] || []).some((entity) => entity['mount-point'] === this.state.mount)); + if (mounted) { + // Gatekeeper/dyld can briefly retain a handle after the final smoke check. + // Retry EBUSY normally; never force-detach an image or hide other failures. + for (let attempt = 0; attempt < 5; attempt++) { + const result = await this.runner.run('Detach final DMG', '/usr/bin/hdiutil', ['detach', this.state.mount], { check: false }); + if (result.code === 0) break; + if (result.code !== 16 || attempt === 4) throw new Error(`Detach final DMG failed (${result.code}).\n${this.runner.redact(result.stdout + result.stderr)}`); + console.log('macOS is still using the verified image; retrying unmount…'); + await new Promise((done) => setTimeout(done, 2_000)); + } + } + this.state.mount = null; + await this.save(); + } + + async cleanup() { + const errors = []; + const attempt = async (action) => { try { await action(); } catch (error) { errors.push(this.runner.redact(error.message)); } }; + await attempt(() => this.detach()); + await attempt(async () => { + if (!this.state.searchList) return; + await this.runner.run('Restore keychain search list', '/usr/bin/security', ['list-keychains', '-d', 'user', '-s', ...this.state.searchList]); + const result = await this.runner.run('Verify restored keychain search list', '/usr/bin/security', ['list-keychains', '-d', 'user']); + const restored = result.stdout.split('\n').filter((line) => line.trim()).map((line) => JSON.parse(line.trim())); + if (JSON.stringify(restored) !== JSON.stringify(this.state.searchList)) throw new Error('Keychain search list was not restored'); + this.state.searchList = null; + await this.save(); + }); + await attempt(async () => { + if (!this.state.keychain) return; + if (await exists(this.state.keychain)) await this.runner.run('Delete temporary keychain', '/usr/bin/security', ['delete-keychain', this.state.keychain]); + if (await exists(this.state.keychain)) throw new Error('Temporary keychain still exists'); + this.state.keychain = null; + await this.save(); + }); + if (errors.length) throw new Error(`Cleanup failed; recovery state retained. ${errors.join('\n')}`); + if (this.state.directory) await rm(this.state.directory, { recursive: true, force: true }); + await rm(this.path, { force: true }); + } +} + +async function exists(path) { + try { await lstat(path); return true; } catch (error) { if (error.code === 'ENOENT') return false; throw error; } +} + +export async function nativeFiles(directory) { + const found = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) found.push(...await nativeFiles(path)); + else if (entry.isFile()) { + if (entry.name === '.env.macos-release' || /\.(p12|keychain-db)$/.test(entry.name)) throw new Error('Signing material must never be bundled'); + const handle = await open(path, 'r'); + const magic = Buffer.alloc(4); + try { await handle.read(magic, 0, 4, 0); } finally { await handle.close(); } + if (machMagic.has(magic.toString('hex'))) found.push(path); + } + } + return found.sort((a, b) => b.split(sep).length - a.split(sep).length || a.localeCompare(b)); +} + +export function libraryChanges(binary, dependencies, files) { + return dependencies.map((dependency) => { + if (dependency.startsWith('/usr/lib/') || dependency.startsWith('/System/Library/')) return null; + const matches = files.filter((path) => basename(path) === basename(dependency)); + if (matches.length !== 1) throw new Error(`Unresolved bundled Tor library: ${dependency}`); + return [dependency, `@loader_path/${relative(dirname(binary), matches[0]).split(sep).join('/')}`]; + }).filter((change) => change && change[0] !== change[1]); +} + +export async function prepareTor(runner, app) { + const directory = join(app, 'Contents/Resources/resources/tor/macos/tor'); + const files = await nativeFiles(directory); + if (!files.includes(join(directory, 'tor'))) throw new Error('The app is missing its bundled Tor executable'); + for (const path of files) { + const linked = await runner.run('Inspect bundled Tor libraries', '/usr/bin/otool', ['-L', path]); + const dependencies = linked.stdout.split('\n').slice(1).map((line) => /^\s+(.+) \(compatibility version /.exec(line)?.[1]).filter(Boolean); + const changes = libraryChanges(path, dependencies, files); + const args = changes.flatMap(([before, after]) => ['-change', before, after]); + if (path.endsWith('.dylib')) args.push('-id', `@loader_path/${basename(path)}`); + if (args.length) await runner.run('Make Tor library paths relocatable', '/usr/bin/install_name_tool', [...args, path]); + } +} + +async function codeTargets(app) { + const files = await nativeFiles(app); + if (!files.length) throw new Error('No native code found in application'); + const bundles = new Set(); + for (const file of files) { + for (let parent = dirname(file); parent !== app; parent = dirname(parent)) { + if (/\.(framework|app|xpc|bundle)$/.test(parent)) bundles.add(parent); + } + } + return [...new Set([...files, ...bundles])].sort((a, b) => b.split(sep).length - a.split(sep).length || a.localeCompare(b)).concat(app); +} + +async function sign(runner, path, identity, keychain, runtime = true) { + const args = ['--force', '--sign', identity]; + if (identity === '-') args.push('--timestamp=none'); + else { + args.push('--keychain', keychain, '--timestamp'); + if (runtime) args.push('--options', 'runtime'); + } + await runner.run(runtime ? 'Sign bundled code' : 'Sign final DMG', '/usr/bin/codesign', [...args, path]); +} + +async function verifyCode(runner, path, team, { deep = false, runtime = true, identifier } = {}) { + const args = ['--verify', '--strict', '--all-architectures']; + if (deep) args.push('--deep'); + if (team) { + let requirement = `anchor apple generic and certificate leaf[field.1.2.840.113635.100.6.1.13] exists and certificate leaf[subject.OU] = "${team}"`; + if (identifier) requirement += ` and identifier "${identifier}"`; + args.push('-R', `=${requirement}`); + } + await runner.run('Verify code signature', '/usr/bin/codesign', [...args, path]); + if (!team) return; + const details = await runner.run('Inspect Developer ID signature', '/usr/bin/codesign', ['--display', '--verbose=4', path]); + const output = details.stdout + details.stderr; + if (!output.includes(`TeamIdentifier=${team}\n`) || !/^Timestamp=.+/m.test(output) || (runtime && !/flags=.*\bruntime\b/.test(output))) { + throw new Error('Signature is missing its expected team, secure timestamp, or hardened runtime'); + } + if (runtime) { + const entitlements = await runner.run('Verify minimal entitlements', '/usr/bin/codesign', ['--display', '--entitlements', ':-', path]); + if (entitlements.stdout.trim() && Object.keys(await runner.plist(entitlements.stdout)).length) throw new Error('Unexpected entitlements in signed code'); + } +} + +async function verifyApp(runner, app, product, expectedArch, team) { + const info = await runner.plist(await readFile(join(app, 'Contents/Info.plist'), 'utf8')); + if (info.CFBundleIdentifier !== product.identifier || info.CFBundleExecutable !== product.binary) throw new Error('Unexpected app bundle identity'); + if (info.CFBundleShortVersionString !== product.bundleVersion || info.CFBundleVersion !== product.bundleVersion) throw new Error('App metadata does not match the release version'); + for (const path of await nativeFiles(app)) { + const result = await runner.run('Verify native architecture', '/usr/bin/lipo', ['-archs', path]); + if (!result.stdout.trim().split(/\s+/).includes(expectedArch)) throw new Error(`Wrong architecture: ${relative(app, path)}`); + } + for (const path of await codeTargets(app)) await verifyCode(runner, path, team, { deep: path === app, identifier: path === app ? product.identifier : undefined }); +} + +export async function notarize(runner, artifact, keychain) { + const authentication = ['--keychain', keychain, '--keychain-profile', profile]; + const submission = await runner.run('Submit notarization', '/usr/bin/xcrun', ['notarytool', 'submit', artifact, ...authentication, '--output-format', 'json'], { timeout: 900_000 }); + const id = JSON.parse(submission.stdout).id; + if (typeof id !== 'string' || !/^[a-f0-9-]{36}$/i.test(id)) throw new Error('Notarization did not return a submission ID'); + console.log(`Notarization submission: ${id}`); + const result = await runner.run('Wait for notarization', '/usr/bin/xcrun', ['notarytool', 'wait', id, ...authentication, '--timeout', '30m', '--output-format', 'json'], { timeout: 1_860_000, check: false }); + let status; + try { status = JSON.parse(result.stdout).status; } catch { status = 'Unknown'; } + if (result.code !== 0 || status !== 'Accepted') { + const log = await runner.run('Read notarization diagnostic', '/usr/bin/xcrun', ['notarytool', 'log', id, ...authentication], { check: false }); + throw new Error(`Notarization ${id} was not Accepted (${status}).\n${runner.redact(result.stdout + result.stderr + log.stdout + log.stderr)}`); + } +} + +async function staple(runner, artifact) { + await runner.run('Staple notarization ticket', '/usr/bin/xcrun', ['stapler', 'staple', artifact]); + await runner.run('Validate notarization ticket', '/usr/bin/xcrun', ['stapler', 'validate', artifact]); +} + +async function verifyImage(runner, session, output, product, expectedArch, team) { + await runner.run('Verify final DMG integrity', '/usr/bin/hdiutil', ['verify', output], { timeout: 600_000 }); + if (team) { + await verifyCode(runner, output, team, { runtime: false }); + await runner.run('Validate DMG ticket', '/usr/bin/xcrun', ['stapler', 'validate', output]); + await runner.run('Assess DMG with Gatekeeper', '/usr/sbin/spctl', ['--assess', '--type', 'open', '--context', 'context:primary-signature', '--verbose=2', output]); + } + const mount = join(session.state.directory, 'mounted'); + await mkdir(mount); + session.state.mount = await realpath(mount); + await session.save(); + await runner.run('Mount final DMG read-only', '/usr/bin/hdiutil', ['attach', output, '-readonly', '-nobrowse', '-mountpoint', session.state.mount]); + if (await readlink(join(mount, 'Applications')) !== '/Applications') throw new Error('Final DMG is missing its Applications shortcut'); + const app = join(mount, `${product.name}.app`); + await verifyApp(runner, app, product, expectedArch, team); + if (team) { + await runner.run('Validate app ticket from final DMG', '/usr/bin/xcrun', ['stapler', 'validate', app]); + await runner.run('Assess app with Gatekeeper', '/usr/sbin/spctl', ['--assess', '--type', 'execute', '--verbose=2', app]); + } + if (product.name === 'Napstr') { + // No DYLD overrides: this exercises the exact hardened runtime users get. + await runner.run('Smoke-test bundled Tor from final DMG', join(app, 'Contents/Resources/resources/tor/macos/tor/tor'), ['--version']); + } +} + +export async function checksum(path) { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return hash.digest('hex'); +} + +// Cleanup is a release gate, including when build/signing/verification fails. +export async function withCleanup(session, runner, output, operation) { + let failure; + try { await operation(); } catch (error) { failure = error; } + if (runner.interrupted && !failure) failure = new Error('Build interrupted'); + runner.interrupted = false; + try { await session.cleanup(); } + catch (error) { failure = new Error([failure?.message, error.message].filter(Boolean).join('\n')); } + if (failure) { + await rm(output, { force: true }); + await rm(`${output}.sha256`, { force: true }); + throw failure; + } + try { + const digest = await checksum(output); + await writeFile(`${output}.sha256`, `${digest} ${basename(output)}\n`); + if (await checksum(output) !== digest) throw new Error('Final DMG checksum verification failed'); + } catch (error) { + await rm(output, { force: true }); + await rm(`${output}.sha256`, { force: true }); + throw error; + } +} + +export function parseOptions(args) { + const options = { app: 'napstr', signed: false, ci: false, cleanup: false }; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (['--signed', '--ci', '--cleanup', '--help'].includes(arg)) options[arg.slice(2)] = true; + else if (arg === '--app' || arg === '--tag') { + if (!args[i + 1] || args[i + 1].startsWith('--')) throw new Error(`Missing value for ${arg}`); + options[arg.slice(2)] = args[++i]; + } else throw new Error(`Unknown macOS build option: ${arg}`); + } + if (!Object.hasOwn(products, options.app)) throw new Error('Choose --app napstr or --app napstrfy'); + if (options.tag && !/^v\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/.test(options.tag)) throw new Error('Invalid release tag'); + return options; +} + +export async function main(args = process.argv.slice(2)) { + const options = parseOptions(args); + if (options.help) { + console.log('Usage: npm run macos-build[:signed] -- [--ci] [--tag v1.2.3] [--cleanup]\nBuilds the current Mac architecture. Signed local builds read the root .env.macos-release; --ci reads Actions secrets. No upload.'); + return; + } + if (platform() !== 'darwin' || !['arm64', 'x64'].includes(arch())) throw new Error('Build on a native Apple Silicon or Intel Mac'); + const product = { ...products[options.app] }; + let credentials = {}; + if (options.signed && !options.cleanup) { + if (options.ci) credentials = Object.fromEntries(credentialNames.map((name) => [name, process.env[name] || ''])); + else { + const path = join(root, '.env.macos-release'); + const stat = await lstat(path); + if (!stat.isFile() || (stat.mode & 0o777) !== 0o600) throw new Error('Use a regular root .env.macos-release file with permissions 0600'); + credentials = parseCredentials(await readFile(path, 'utf8')); + } + validateCredentials(credentials); + } + const runner = new Runner(credentials); + const targetDir = join(product.directory, 'src-tauri/target'); + const session = new Session(join(targetDir, 'macos-release-state.json'), runner); + if (options.cleanup) { + if (await exists(session.path)) { + session.state = JSON.parse(await readFile(session.path, 'utf8')); + await session.cleanup(); + } + return; + } + const native = await runner.run('Check native macOS architecture', '/usr/sbin/sysctl', ['-in', 'sysctl.proc_translated'], { check: false }); + if (native.stdout.trim() === '1') throw new Error('Rosetta builds are not supported; use native Node and a native terminal'); + const expectedArch = arch() === 'arm64' ? 'arm64' : 'x86_64'; + const target = arch() === 'arm64' ? 'aarch64-apple-darwin' : 'x86_64-apple-darwin'; + const rustDirectory = join(product.directory, 'src-tauri'); + await configureRustToolchain(runner, rustDirectory); + await runner.run('Check Rust toolchain', 'cargo', ['--version'], { cwd: rustDirectory }); + await runner.run('Check Rust compiler', 'rustc', ['--version'], { cwd: rustDirectory }); + const config = JSON.parse(await readFile(join(product.directory, 'src-tauri/tauri.conf.json'), 'utf8')); + const version = options.tag ? options.tag.slice(1) : config.version; + if (typeof version !== 'string' || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/.test(version)) throw new Error('Invalid application version'); + product.bundleVersion = version.split('-')[0]; + const outputDir = join(targetDir, 'macos-release', options.signed ? 'signed' : 'unsigned'); + const output = join(outputDir, `${product.name}_${version}_${expectedArch}.dmg`); + await session.begin(); + const interrupt = () => { runner.interrupted = true; runner.stopChild(); }; + process.on('SIGINT', interrupt); + process.on('SIGTERM', interrupt); + try { + await withCleanup(session, runner, output, async () => { + await mkdir(outputDir, { recursive: true }); + await rm(output, { force: true }); + await rm(`${output}.sha256`, { force: true }); + await session.prepare(); + const tauri = join(product.directory, 'node_modules/@tauri-apps/cli/tauri.js'); + if (!await exists(tauri)) throw new Error('Install dependencies with npm ci before building'); + if (product.name === 'Napstr') await runner.run('Prepare pinned Tor bundle', process.execPath, [join(root, 'scripts/prepare-tor-bundle.mjs')], { timeout: 600_000 }); + const override = { version, bundle: { macOS: { signingIdentity: null, hardenedRuntime: options.signed } } }; + // Other platforms may also have local Tor resources; never ship them on Mac. + if (product.name === 'Napstr') override.bundle.resources = ['resources/tor/macos/**/*']; + await runner.run('Build Tauri application', process.execPath, [tauri, 'build', '--ci', '--no-sign', '--target', target, '--bundles', 'app', '--config', JSON.stringify(override), '--', '--locked'], { + cwd: product.directory, env: { CARGO_TARGET_DIR: targetDir }, timeout: 7_200_000 + }); + const app = join(targetDir, target, 'release/bundle/macos', `${product.name}.app`); + // Apple bundle versions are numeric; preserve the full RC tag in the filename. + for (const key of ['CFBundleShortVersionString', 'CFBundleVersion']) { + await runner.run('Finalize macOS bundle version', '/usr/bin/plutil', ['-replace', key, '-string', product.bundleVersion, join(app, 'Contents/Info.plist')]); + } + if (product.name === 'Napstr') await prepareTor(runner, app); + // All bundle mutations finish before the final signatures are made. + await runner.run('Remove packaging extended attributes', '/usr/bin/xattr', ['-cr', app]); + const identity = options.signed ? await session.setupKeychain(credentials) : '-'; + for (const path of await codeTargets(app)) await sign(runner, path, identity, session.state.keychain); + await verifyApp(runner, app, product, expectedArch, options.signed ? credentials.APPLE_TEAM_ID : undefined); + if (options.signed) { + const zip = join(session.state.directory, 'app.zip'); + await runner.run('Archive signed app', '/usr/bin/ditto', ['-c', '-k', '--sequesterRsrc', '--keepParent', app, zip], { timeout: 600_000 }); + await notarize(runner, zip, session.state.keychain); + await staple(runner, app); + await rm(zip); + } + const staging = join(session.state.directory, 'payload'); + await mkdir(staging); + await runner.run('Stage app with its notarization ticket', '/usr/bin/ditto', [app, join(staging, `${product.name}.app`)], { timeout: 600_000 }); + await symlink('/Applications', join(staging, 'Applications')); + const intermediate = join(session.state.directory, 'uncompressed.dmg'); + await runner.run('Create uncompressed DMG', '/usr/bin/hdiutil', ['create', '-volname', product.name, '-srcfolder', staging, '-fs', 'HFS+', '-format', 'UDRW', '-nospotlight', '-verbose', intermediate], { timeout: 1_200_000 }); + await runner.run('Compress final DMG', '/usr/bin/hdiutil', ['convert', intermediate, '-format', 'UDZO', '-tasks', '2', '-verbose', '-o', output], { timeout: 1_200_000 }); + if (options.signed) { + await sign(runner, output, identity, session.state.keychain, false); + await notarize(runner, output, session.state.keychain); + await staple(runner, output); + } + await verifyImage(runner, session, output, product, expectedArch, options.signed ? credentials.APPLE_TEAM_ID : undefined); + }); + console.log(`Verified ${options.signed ? 'signed and notarized' : 'ad-hoc community'} DMG: ${output}\nSHA-256: ${output}.sha256`); + } catch (error) { throw new Error(runner.redact(error.message)); } + finally { + process.off('SIGINT', interrupt); + process.off('SIGTERM', interrupt); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + main().catch((error) => { console.error(error.message); process.exitCode = 1; }); +} diff --git a/shared/i18n/locales/ar.json b/shared/i18n/locales/ar.json index 995a660..b2da269 100644 --- a/shared/i18n/locales/ar.json +++ b/shared/i18n/locales/ar.json @@ -51,6 +51,7 @@ "Browse songs shared by {p0} · {p1}": "تصفح الأغاني التي شاركها {p0} · {p1}", "Browse, listen, save songs for offline listening, and ask Napstr to download tracks over Tor.": "تصفح، والاستماع، وتخزين الأغاني للاستماع خارج الإنترنت، وطلب من Napstr تنزيل المسارات على Tor.", "Browse…": "Browse ...", + "Builds for Apple Silicon and Intel Macs. See the release notes for installation instructions.": "إصدارات لأجهزة Mac بمعالجات Apple Silicon وIntel. راجع ملاحظات الإصدار للاطلاع على تعليمات التثبيت.", "Bundled, managed automatically": "إعادة توزيع وتشغيل تلقائيًا", "Business": "الأعمال", "Camera access is required to scan the Napstr pairing code.": "يتطلب الوصول إلى الكاميرا لفحص الرمز المزوج Napstr.", @@ -489,7 +490,6 @@ "Stopping downloads and cleaning partial files…": "توقف تنزيلات وتنظيف الملفات الجزئية ...", "Surprise me": "مفاجأة لي", "Surprise search failed: {p0}": "البحث المفاجئ فشل: {p0}", - "System Settings → Privacy & Security → Open Anyway": "إعدادات النظام → الخصوصية والأمن → فتح على أي حال", "Tags": "الوسوم", "Tags saved and queued for Nostr publication": "Tags saved and quoted for Nostr نشر", "Tags saved locally": "الوسوم المحلية المحلية", @@ -554,7 +554,6 @@ "Unknown artist": "فنان غير معروف", "Unknown author": "مؤلف غير معروف", "Unlike {p0}": "إزالة {p0} من المفضلة", - "Unnotarized community builds for Apple Silicon and Intel Macs. After the first blocked launch, use": "المجتمع غير المذكور يبني لـ Apple Silicon و Intel Macs. بعد أول إطلاق محظور ، استخدم", "Upbeat": "Upbeat", "Use the heart beside a podcast to keep it here.": "استخدم القلب بجانب البودكاست للحفاظ عليه هنا.", "Users share music from a folder, and their catalog is published over Nostr. Napstr negotiates download requests through private Nostr DMs, while Tor handles the downloads, keeping users’ IP addresses private.": "يشارك المستخدمون الموسيقى من مجلد، ويتم نشر كتالوجهم على Nostr. Napstr يتفاوض طلبات التنزيل عبر Nostr DMs الخاصة، في حين أن Tor يتعامل مع التحميلات، والحفاظ على عناوين IP المستخدمين خاصة.", diff --git a/shared/i18n/locales/bn.json b/shared/i18n/locales/bn.json index ecb8da3..a377578 100644 --- a/shared/i18n/locales/bn.json +++ b/shared/i18n/locales/bn.json @@ -51,6 +51,7 @@ "Browse songs shared by {p0} · {p1}": "{p0}-এর শেয়ার করা গান দেখুন · {p1}", "Browse, listen, save songs for offline listening, and ask Napstr to download tracks over Tor.": "অনুসন্ধান করুন, শুনুন, অফলি শোনার জন্য গান সংরক্ষণ করুন, এবং Napstr এর উপর ট্র্যাক ডাউনলোড করার জন্য অনুরোধ করুন Tor.", "Browse…": "Browse...", + "Builds for Apple Silicon and Intel Macs. See the release notes for installation instructions.": "Apple Silicon ও Intel Mac-এর জন্য সংস্করণ। ইনস্টলেশনের নির্দেশাবলির জন্য রিলিজ নোট দেখুন।", "Bundled, managed automatically": "নিয়ন্ত্রিত, স্বয়ংক্রিয়ভাবে পরিচালিত", "Business": "ব্যবসা", "Camera access is required to scan the Napstr pairing code.": "ক্যামেরার অ্যাক্সেস প্রয়োজন Napstr প্যারিং কোড স্ক্যান করার জন্য।", @@ -489,7 +490,6 @@ "Stopping downloads and cleaning partial files…": "ডাউনলোড বন্ধ এবং অংশ ফাইল পরিষ্কার ...", "Surprise me": "আমাকে বিস্ময়", "Surprise search failed: {p0}": "অসাধারণ অনুসন্ধান ব্যর্থ: {p0}", - "System Settings → Privacy & Security → Open Anyway": "সিস্টেম সেটিংস → গোপনীয়তা & নিরাপত্তা → খোলা যে কোনও", "Tags": "ট্যাগ", "Tags saved and queued for Nostr publication": "ট্যাগ সংরক্ষিত এবং Queed জন্য Nostr পোস্ট", "Tags saved locally": "স্থানীয়ভাবে রক্ষা করা", @@ -554,7 +554,6 @@ "Unknown artist": "অজানা শিল্পী", "Unknown author": "অজানা লেখক", "Unlike {p0}": "পছন্দের তালিকা থেকে {p0} সরান", - "Unnotarized community builds for Apple Silicon and Intel Macs. After the first blocked launch, use": "অনোটারিত সম্প্রদায় Apple Silicon এবং Intel Macs জন্য নির্মাণ করে। প্রথম ব্লক করা প্রচারের পরে, ব্যবহার করুন", "Upbeat": "উঁচু", "Use the heart beside a podcast to keep it here.": "একটি Podcast এর সাথে হৃদয় ব্যবহার করুন, এটি এখানে রাখুন।", "Users share music from a folder, and their catalog is published over Nostr. Napstr negotiates download requests through private Nostr DMs, while Tor handles the downloads, keeping users’ IP addresses private.": "ব্যবহারকারীরা একটি ফোডার থেকে গান শেয়ার করে, এবং তাদের ক্যাটাগোলটি Nostr এর উপর প্রকাশিত হয়। Napstr ব্যক্তিগত Nostr ডিমের মাধ্যমে ডাউনলোড অনুরোধগুলি আলোচনা করে, যদিও Tor ডাউনলোডগুলি পরিচালনা করে, ব্যবহারকারীর আইপি ঠিকানা গোপন রাখে।", diff --git a/shared/i18n/locales/en.json b/shared/i18n/locales/en.json index 6bbd728..09527d0 100644 --- a/shared/i18n/locales/en.json +++ b/shared/i18n/locales/en.json @@ -51,6 +51,7 @@ "Browse songs shared by {p0} · {p1}": "Browse songs shared by {p0} · {p1}", "Browse, listen, save songs for offline listening, and ask Napstr to download tracks over Tor.": "Browse, listen, save songs for offline listening, and ask Napstr to download tracks over Tor.", "Browse…": "Browse…", + "Builds for Apple Silicon and Intel Macs. See the release notes for installation instructions.": "Builds for Apple Silicon and Intel Macs. See the release notes for installation instructions.", "Bundled, managed automatically": "Bundled, managed automatically", "Business": "Business", "Camera access is required to scan the Napstr pairing code.": "Camera access is required to scan the Napstr pairing code.", @@ -489,7 +490,6 @@ "Stopping downloads and cleaning partial files…": "Stopping downloads and cleaning partial files…", "Surprise me": "Surprise me", "Surprise search failed: {p0}": "Surprise search failed: {p0}", - "System Settings → Privacy & Security → Open Anyway": "System Settings → Privacy & Security → Open Anyway", "Tags": "Tags", "Tags saved and queued for Nostr publication": "Tags saved and queued for Nostr publication", "Tags saved locally": "Tags saved locally", @@ -554,7 +554,6 @@ "Unknown artist": "Unknown artist", "Unknown author": "Unknown author", "Unlike {p0}": "Unlike {p0}", - "Unnotarized community builds for Apple Silicon and Intel Macs. After the first blocked launch, use": "Unnotarized community builds for Apple Silicon and Intel Macs. After the first blocked launch, use", "Upbeat": "Upbeat", "Use the heart beside a podcast to keep it here.": "Use the heart beside a podcast to keep it here.", "Users share music from a folder, and their catalog is published over Nostr. Napstr negotiates download requests through private Nostr DMs, while Tor handles the downloads, keeping users’ IP addresses private.": "Users share music from a folder, and their catalog is published over Nostr. Napstr negotiates download requests through private Nostr DMs, while Tor handles the downloads, keeping users’ IP addresses private.", diff --git a/shared/i18n/locales/es.json b/shared/i18n/locales/es.json index 47a971c..cace571 100644 --- a/shared/i18n/locales/es.json +++ b/shared/i18n/locales/es.json @@ -51,6 +51,7 @@ "Browse songs shared by {p0} · {p1}": "Ver canciones compartidas por {p0} · {p1}", "Browse, listen, save songs for offline listening, and ask Napstr to download tracks over Tor.": "Navega, escucha, almacena canciones para escuchar offline y pide a Napstr que descargue las pistas sobre Tor.", "Browse…": "El bro...", + "Builds for Apple Silicon and Intel Macs. See the release notes for installation instructions.": "Versiones para Mac con Apple Silicon e Intel. Consulta las notas de la versión para ver las instrucciones de instalación.", "Bundled, managed automatically": "Conjunto, gestionado automáticamente", "Business": "negocio", "Camera access is required to scan the Napstr pairing code.": "El acceso a la cámara es necesario para escanear el código de pareja Napstr.", @@ -489,7 +490,6 @@ "Stopping downloads and cleaning partial files…": "Detener las descargas y limpiar los archivos parciales.", "Surprise me": "Sorprendeme", "Surprise search failed: {p0}": "La búsqueda de sorpresa falleció: {p0}", - "System Settings → Privacy & Security → Open Anyway": "Configuraciones del sistema → Privacidad y seguridad → Abierto en cualquier caso", "Tags": "Etiquetas", "Tags saved and queued for Nostr publication": "Etiquetas salvadas y queudadas para Nostr publicaciones", "Tags saved locally": "Los talleres rescatados localmente", @@ -554,7 +554,6 @@ "Unknown artist": "Artista desconocido", "Unknown author": "Autor desconocido", "Unlike {p0}": "Quitar {p0} de favoritos", - "Unnotarized community builds for Apple Silicon and Intel Macs. After the first blocked launch, use": "Comunidad no notada construye para Apple Silicon y Intel Macs. Después del primer lanzamiento bloqueado, usar", "Upbeat": "Upbeat", "Use the heart beside a podcast to keep it here.": "Use el corazón junto a un podcast para mantenerlo aquí.", "Users share music from a folder, and their catalog is published over Nostr. Napstr negotiates download requests through private Nostr DMs, while Tor handles the downloads, keeping users’ IP addresses private.": "Los usuarios comparten música de una carpeta, y su catálogo se publica en Nostr. Napstr negocia solicitudes de descarga a través de Nostr DMs privados, mientras que Tor gestiona las descargas, manteniendo los direcciones IP de los usuarios privadas.", diff --git a/shared/i18n/locales/fr.json b/shared/i18n/locales/fr.json index 29d3694..0633163 100644 --- a/shared/i18n/locales/fr.json +++ b/shared/i18n/locales/fr.json @@ -51,6 +51,7 @@ "Browse songs shared by {p0} · {p1}": "Parcourir les chansons partagées par {p0} · {p1}", "Browse, listen, save songs for offline listening, and ask Napstr to download tracks over Tor.": "Brouvez, écoutez, sauvez des chansons pour l'écoute hors ligne et demandez à Napstr de télécharger les traces sur Tor.", "Browse…": "Browse...", + "Builds for Apple Silicon and Intel Macs. See the release notes for installation instructions.": "Versions pour les Mac Apple Silicon et Intel. Consultez les notes de version pour les instructions d’installation.", "Bundled, managed automatically": "Mise en place, gérée automatiquement", "Business": "Entreprises", "Camera access is required to scan the Napstr pairing code.": "L’accès à la caméra est nécessaire pour scanner le code de couple Napstr.", @@ -489,7 +490,6 @@ "Stopping downloads and cleaning partial files…": "Arrêter les téléchargements et nettoyer les fichiers partiels...", "Surprise me": "Surprendre moi", "Surprise search failed: {p0}": "Recherche surprenante échouée: {p0}", - "System Settings → Privacy & Security → Open Anyway": "Configuration de la sécurité et de la confidentialité, ouverture", "Tags": "Étiquettes", "Tags saved and queued for Nostr publication": "Tags sauvé et queue pour Nostr publications", "Tags saved locally": "Tags sauvé local", @@ -554,7 +554,6 @@ "Unknown artist": "Artiste inconnu", "Unknown author": "Auteur inconnu", "Unlike {p0}": "Retirer {p0} des favoris", - "Unnotarized community builds for Apple Silicon and Intel Macs. After the first blocked launch, use": "La communauté non notée construit pour Apple Silicon et Intel Macs. Après le premier lancement bloqué, utilisez", "Upbeat": "Aperçu", "Use the heart beside a podcast to keep it here.": "Utilisez le cœur à côté d'un podcast pour le garder ici.", "Users share music from a folder, and their catalog is published over Nostr. Napstr negotiates download requests through private Nostr DMs, while Tor handles the downloads, keeping users’ IP addresses private.": "Les utilisateurs partagent la musique d'un dossier, et leur catalogue est publié sur Nostr. Napstr négocient les demandes de téléchargement via Nostr DMs privés, tandis que Tor gère les téléchargements, gardant les adresses IP des utilisateurs privées.", diff --git a/shared/i18n/locales/hi.json b/shared/i18n/locales/hi.json index a9c41b4..2b2cb8c 100644 --- a/shared/i18n/locales/hi.json +++ b/shared/i18n/locales/hi.json @@ -51,6 +51,7 @@ "Browse songs shared by {p0} · {p1}": "{p0} के साझा किए गाने देखें · {p1}", "Browse, listen, save songs for offline listening, and ask Napstr to download tracks over Tor.": "ब्राउज़ करें, सुनो, ऑफ़लाइन सुनने के लिए गीतों को सहेजें, और Napstr से पूछें Tor पर ट्रैक डाउनलोड करने के लिए।", "Browse…": "ब्राउज़...", + "Builds for Apple Silicon and Intel Macs. See the release notes for installation instructions.": "Apple Silicon और Intel Mac के लिए संस्करण। इंस्टॉलेशन के निर्देशों के लिए रिलीज़ नोट्स देखें।", "Bundled, managed automatically": "बंडल, स्वचालित रूप से प्रबंधित", "Business": "व्यापार", "Camera access is required to scan the Napstr pairing code.": "कैमरा एक्सेस Napstr जोड़ कोड स्कैन करने के लिए आवश्यक है।", @@ -489,7 +490,6 @@ "Stopping downloads and cleaning partial files…": "डाउनलोड को रोकें और आंशिक फ़ाइलों को साफ करें ...", "Surprise me": "मुझे आश्चर्य", "Surprise search failed: {p0}": "आश्चर्यजनक खोज विफल: {p0}", - "System Settings → Privacy & Security → Open Anyway": "सिस्टम सेटिंग्स → गोपनीयता और सुरक्षा → Open Anyway", "Tags": "टैग", "Tags saved and queued for Nostr publication": "Tags saved and queed for Nostr प्रकाशन के लिए", "Tags saved locally": "स्थानीय रूप से बचाए गए", @@ -554,7 +554,6 @@ "Unknown artist": "अज्ञात कलाकार", "Unknown author": "अज्ञात लेखक", "Unlike {p0}": "{p0} को पसंदीदा से हटाएँ", - "Unnotarized community builds for Apple Silicon and Intel Macs. After the first blocked launch, use": "Apple Silicon और Intel Macs के लिए अनगिनत समुदाय बनाता है. पहली ब्लॉक लॉन्च के बाद, उपयोग करें", "Upbeat": "Upbeat के लिए", "Use the heart beside a podcast to keep it here.": "यहां रखने के लिए एक पॉडकास्ट के बगल में दिल का उपयोग करें।", "Users share music from a folder, and their catalog is published over Nostr. Napstr negotiates download requests through private Nostr DMs, while Tor handles the downloads, keeping users’ IP addresses private.": "उपयोगकर्ता एक फ़ोल्डर से संगीत साझा करते हैं, और उनका कैटलॉग Nostr पर प्रकाशित होता है. Napstr निजी Nostr डीएम के माध्यम से डाउनलोड अनुरोधों पर बातचीत करता है, जबकि Tor डाउनलोड को संभालता है, उपयोगकर्ताओं के आईपी पते को गोपनीय रखता है.", diff --git a/shared/i18n/locales/id.json b/shared/i18n/locales/id.json index 7e3af02..2b18512 100644 --- a/shared/i18n/locales/id.json +++ b/shared/i18n/locales/id.json @@ -51,6 +51,7 @@ "Browse songs shared by {p0} · {p1}": "Jelajahi lagu yang dibagikan oleh {p0} · {p1}", "Browse, listen, save songs for offline listening, and ask Napstr to download tracks over Tor.": "Melayari, mendengarkan, menyimpan lagu untuk mendengarkan offline, dan meminta Napstr untuk mengunduh track di atas Tor.", "Browse…": "Browse...", + "Builds for Apple Silicon and Intel Macs. See the release notes for installation instructions.": "Build untuk Mac Apple Silicon dan Intel. Lihat catatan rilis untuk petunjuk instalasi.", "Bundled, managed automatically": "Mengatur, dikendalikan secara otomatis", "Business": "Bisnis", "Camera access is required to scan the Napstr pairing code.": "Akses kamera diperlukan untuk memindai kode Napstr.", @@ -489,7 +490,6 @@ "Stopping downloads and cleaning partial files…": "Menghentikan download dan membersihkan file parsial.", "Surprise me": "Aku terkejut", "Surprise search failed: {p0}": "Hasil pencarian yang gagal: {p0}", - "System Settings → Privacy & Security → Open Anyway": "Setting Sistem → Privasi & Keamanan → Terbuka Anyway", "Tags": "Tag", "Tags saved and queued for Nostr publication": "Tags diselamatkan dan dicetak untuk Nostr publikasi", "Tags saved locally": "Tag diselamatkan secara lokal", @@ -554,7 +554,6 @@ "Unknown artist": "Artis tidak dikenal", "Unknown author": "Penulis tidak dikenal", "Unlike {p0}": "Batalkan suka {p0}", - "Unnotarized community builds for Apple Silicon and Intel Macs. After the first blocked launch, use": "Komunitas yang tidak diketahui membangun untuk Apple Silicon dan Intel Macs. Setelah peluncuran pertama yang terblokir, gunakan", "Upbeat": "Upbeat", "Use the heart beside a podcast to keep it here.": "Gunakan hati di samping podcast untuk menyimpannya di sini.", "Users share music from a folder, and their catalog is published over Nostr. Napstr negotiates download requests through private Nostr DMs, while Tor handles the downloads, keeping users’ IP addresses private.": "Pengguna berbagi musik dari folder, dan katalog mereka diterbitkan di atas Nostr. Napstr bernegosiasi permintaan download melalui privasi Nostr DMs, sementara Tor mengendalikan muat turun, menjaga alamat IP pengguna pribadi.", diff --git a/shared/i18n/locales/pt.json b/shared/i18n/locales/pt.json index 25b7f70..1bc76db 100644 --- a/shared/i18n/locales/pt.json +++ b/shared/i18n/locales/pt.json @@ -51,6 +51,7 @@ "Browse songs shared by {p0} · {p1}": "Ver músicas compartilhadas por {p0} · {p1}", "Browse, listen, save songs for offline listening, and ask Napstr to download tracks over Tor.": "Browse, ouça, salve músicas para ouvir offline e peça a Napstr para baixar traços sobre Tor.", "Browse…": "Browse...", + "Builds for Apple Silicon and Intel Macs. See the release notes for installation instructions.": "Versões para Macs com Apple Silicon e Intel. Consulte as notas da versão para obter instruções de instalação.", "Bundled, managed automatically": "Conjunto, gerenciado automaticamente", "Business": "negócios", "Camera access is required to scan the Napstr pairing code.": "O acesso à câmera é necessário para escanear o código Napstr.", @@ -489,7 +490,6 @@ "Stopping downloads and cleaning partial files…": "Parar downloads e limpar arquivos parciais...", "Surprise me": "Surpreendendo-me", "Surprise search failed: {p0}": "Surpresa busca falhou: {p0}", - "System Settings → Privacy & Security → Open Anyway": "Configurações do sistema → Privacidade e Segurança → Abre em qualquer lugar", "Tags": "Etiquetas", "Tags saved and queued for Nostr publication": "Tags salvo e queed para Nostr publicação", "Tags saved locally": "Tags resgatados localmente", @@ -554,7 +554,6 @@ "Unknown artist": "Artista desconhecido", "Unknown author": "Autor desconhecido", "Unlike {p0}": "Descurtir {p0}", - "Unnotarized community builds for Apple Silicon and Intel Macs. After the first blocked launch, use": "A comunidade não notada está construindo para o Apple Silicon e Intel Macs. Após o primeiro lançamento bloqueado, use", "Upbeat": "Aperfeição", "Use the heart beside a podcast to keep it here.": "Use o coração ao lado de um podcast para mantê-lo aqui.", "Users share music from a folder, and their catalog is published over Nostr. Napstr negotiates download requests through private Nostr DMs, while Tor handles the downloads, keeping users’ IP addresses private.": "Os usuários compartilham música de uma pasta, e seu catálogo é publicado em Nostr. Napstr negocia pedidos de download através de Nostr DMs privados, enquanto Tor gerencia as downloads, mantendo os endereços IP dos usuários privados.", diff --git a/shared/i18n/locales/ur.json b/shared/i18n/locales/ur.json index 1bf135c..d47a9f7 100644 --- a/shared/i18n/locales/ur.json +++ b/shared/i18n/locales/ur.json @@ -51,6 +51,7 @@ "Browse songs shared by {p0} · {p1}": "{p0} کے اشتراک کردہ گانے دیکھیں · {p1}", "Browse, listen, save songs for offline listening, and ask Napstr to download tracks over Tor.": "براؤز کریں، سنیں، آفلاین سننے کے لئے آوازوں کو محفوظ کریں، اور Napstr سے پوچھیں کہ Tor پر ٹریک ڈاؤن لوڈ کریں۔", "Browse…": "براؤز ...", + "Builds for Apple Silicon and Intel Macs. See the release notes for installation instructions.": "Apple Silicon اور Intel Mac کے لیے ورژن۔ تنصیب کی ہدایات کے لیے ریلیز نوٹس دیکھیں۔", "Bundled, managed automatically": "باندل، خود کار طریقے سے منظم", "Business": "کاروبار", "Camera access is required to scan the Napstr pairing code.": "کیمرے تک رسائی Napstr جوڑے کوڈ کو اسکین کرنے کے لئے ضروری ہے.", @@ -489,7 +490,6 @@ "Stopping downloads and cleaning partial files…": "ڈاؤن لوڈ اور جزوی فائلوں کو صاف کرنے کے لئے ...", "Surprise me": "مجھے حیرت ہے", "Surprise search failed: {p0}": "حیرت انگیز تلاش ناکام: {p0}", - "System Settings → Privacy & Security → Open Anyway": "نظام کی ترتیبات → Privacy & Security → Open Anyway", "Tags": "ٹیگز", "Tags saved and queued for Nostr publication": "ٹیگ محفوظ اور Queed کے لئے Nostr نشر", "Tags saved locally": "مقامی طور پر محفوظ", @@ -554,7 +554,6 @@ "Unknown artist": "نامعلوم فنکار", "Unknown author": "نامعلوم مصنف", "Unlike {p0}": "{p0} کو پسندیدہ سے ہٹائیں", - "Unnotarized community builds for Apple Silicon and Intel Macs. After the first blocked launch, use": "ناخوشگوار کمیونٹی ایپل سیلیکون اور انٹیل میکز کے لئے تعمیر کر رہا ہے.پہلا بلاک لانچ کے بعد، استعمال کریں", "Upbeat": "اوپٹ", "Use the heart beside a podcast to keep it here.": "ایک Podcast کے ساتھ دل کا استعمال کریں اور اسے یہاں رکھیں.", "Users share music from a folder, and their catalog is published over Nostr. Napstr negotiates download requests through private Nostr DMs, while Tor handles the downloads, keeping users’ IP addresses private.": "صارفین ایک فولڈر سے موسیقی کا اشتراک کرتے ہیں، اور ان کے کاتالوگ Nostr پر شائع کیا جاتا ہے، Napstr ذاتی Nostr ڈی ایم کے ذریعے ڈاؤن لوڈ کی درخواستوں پر بات چیت کرتا ہے، جبکہ Tor ڈاؤن لوڈ کو منظم کرتا ہے، صارفین کے IP ایڈریس کو پرائیویٹ رکھتا ہے.", diff --git a/shared/i18n/locales/zh.json b/shared/i18n/locales/zh.json index ec1cb42..f7183ad 100644 --- a/shared/i18n/locales/zh.json +++ b/shared/i18n/locales/zh.json @@ -51,6 +51,7 @@ "Browse songs shared by {p0} · {p1}": "浏览 {p0} 分享的歌曲 · {p1}", "Browse, listen, save songs for offline listening, and ask Napstr to download tracks over Tor.": "浏览、听、保存歌曲,以便在线听,并请求Napstr下载超过Tor的轨道。", "Browse…": "浏览...", + "Builds for Apple Silicon and Intel Macs. See the release notes for installation instructions.": "适用于 Apple Silicon 和 Intel Mac 的版本。安装说明请参阅发行说明。", "Bundled, managed automatically": "包装,自动管理", "Business": "商业", "Camera access is required to scan the Napstr pairing code.": "相机访问需要扫描 Napstr 配对代码。", @@ -489,7 +490,6 @@ "Stopping downloads and cleaning partial files…": "停止下载和清理部分文件", "Surprise me": "惊喜我", "Surprise search failed: {p0}": "惊喜搜索失败: {p0}", - "System Settings → Privacy & Security → Open Anyway": "系统设置 → 隐私和安全 → 无论如何开放", "Tags": "标签", "Tags saved and queued for Nostr publication": "标签保存和引用 Nostr 出版物", "Tags saved locally": "地点保存", @@ -554,7 +554,6 @@ "Unknown artist": "未知艺人", "Unknown author": "未知作者", "Unlike {p0}": "取消喜欢 {p0}", - "Unnotarized community builds for Apple Silicon and Intel Macs. After the first blocked launch, use": "未注册的社区正在为苹果硅和英特尔Mac构建。", "Upbeat": "升起", "Use the heart beside a podcast to keep it here.": "使用心脏在Podcast旁边,在这里保留它。", "Users share music from a folder, and their catalog is published over Nostr. Napstr negotiates download requests through private Nostr DMs, while Tor handles the downloads, keeping users’ IP addresses private.": "用户从文件夹中分享音乐,其目录发布了Nostr.Napstr通过私人NostrDM进行下载请求谈判,而Tor处理下载,保持用户的IP地址隐私。", diff --git a/tests/macos-release.test.mjs b/tests/macos-release.test.mjs new file mode 100644 index 0000000..2dc777a --- /dev/null +++ b/tests/macos-release.test.mjs @@ -0,0 +1,258 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, readFile, readdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + checksum, cleanEnvironment, configureRustToolchain, credentialNames, libraryChanges, nativeFiles, + notarize, parseCredentials, parseOptions, Runner, selectIdentity, Session, + validateCredentials, withCleanup +} from '../scripts/macos-release.mjs'; + +const team = 'ABCDEFGHIJ'; +const fingerprint = 'A'.repeat(40); +const credentials = { + BUILD_CERTIFICATE_BASE64: Buffer.from('fake p12 for tests').toString('base64'), + P12_PASSWORD: 'export-secret', KEYCHAIN_PASSWORD: 'keychain-secret', + APPLE_ID: 'builder@example.invalid', APPLE_TEAM_ID: team, + APPLE_APP_SPECIFIC_PASSWORD: 'app-specific-secret' +}; + +async function temporary(t) { + const directory = await mkdtemp(join(tmpdir(), 'napstr-release-test-')); + t.after(() => rm(directory, { recursive: true, force: true })); + return directory; +} + +test('credential file is parsed as data without shell expansion', () => { + const values = parseCredentials('# comment\nP12_PASSWORD="$(touch /tmp/should-not-exist)"\nAPPLE_ID=literal@example.invalid\n'); + assert.equal(values.P12_PASSWORD, '$(touch /tmp/should-not-exist)'); + assert.throws(() => parseCredentials('P12_PASSWORD=a\nP12_PASSWORD=b'), /duplicate/); + assert.throws(() => parseCredentials('UNEXPECTED=value'), /Invalid/); + assert.throws(() => parseCredentials('P12_PASSWORD="unclosed'), /Unclosed/); + assert.throws(() => validateCredentials({}), /Missing signing credentials/); + assert.throws(() => validateCredentials({ ...credentials, APPLE_TEAM_ID: 'invalid' }), /team ID/); + assert.throws(() => validateCredentials({ ...credentials, BUILD_CERTIFICATE_BASE64: '!!!' }), /base64/); + assert.equal(validateCredentials(credentials).toString(), 'fake p12 for tests'); +}); + +test('build processes cannot inherit signing credentials or automatic Tauri signing', () => { + const environment = cleanEnvironment({ + ...credentials, APPLE_CERTIFICATE: 'certificate', APPLE_PASSWORD: 'password', + TAURI_SIGNING_PRIVATE_KEY: 'updater key', TAURI_CONFIG: '{}', + DYLD_LIBRARY_PATH: '/development/lib', DYLD_INSERT_LIBRARIES: '/injected.dylib', + NODE_OPTIONS: '--require something', CARGO_BUILD_TARGET: 'a-different-target', + PATH: '/bin', CARGO_HOME: '/cargo' + }); + assert.deepEqual(environment, { PATH: '/bin', CARGO_HOME: '/cargo' }); + const runner = new Runner(credentials); + for (const name of credentialNames) assert.equal(runner.environment[name], undefined); + assert.equal(runner.redact(`failed with ${credentials.P12_PASSWORD}`), 'failed with [REDACTED]'); +}); + +async function fakeRust(directory) { + await mkdir(directory, { recursive: true }); + await writeFile(join(directory, 'cargo'), '#!/bin/sh\nexec rustc "$@"\n', { mode: 0o755 }); + await writeFile(join(directory, 'rustc'), '#!/bin/sh\nprintf "fixture compiler\\n"\n', { mode: 0o755 }); +} + +function rustRunner(directory) { + const runner = new Runner(); + runner.environment = { PATH: directory, HOME: directory, CARGO_HOME: join(directory, 'cargo home') }; + return runner; +} + +const posixOnly = { skip: process.platform === 'win32' }; + +test('Rust discovery preserves an existing Cargo on PATH', posixOnly, async (t) => { + const directory = await temporary(t); + await fakeRust(directory); + const runner = rustRunner(directory); + await configureRustToolchain(runner, directory); + assert.equal(runner.environment.PATH, directory); + assert.equal((await runner.run('Fixture Cargo', 'cargo', ['--version'], { cwd: directory, quiet: true })).stdout.trim(), 'fixture compiler'); +}); + +test('Rust discovery adds CARGO_HOME/bin for Cargo and its compiler subprocesses', posixOnly, async (t) => { + const directory = await temporary(t); + const runner = rustRunner(directory); + await fakeRust(join(runner.environment.CARGO_HOME, 'bin')); + await configureRustToolchain(runner, directory); + assert.equal((await runner.run('Fixture Cargo', 'cargo', ['--version'], { cwd: directory, quiet: true })).stdout.trim(), 'fixture compiler'); +}); + +test('missing Cargo proxies are resolved through rustup in the Rust project directory', posixOnly, async (t) => { + const directory = await temporary(t); + const project = join(directory, 'project/src-tauri'); + await mkdir(project, { recursive: true }); + const runner = rustRunner(directory); + runner.environment.RUSTUP_HOME = join(directory, 'rust home'); + runner.environment.RUSTUP_TOOLCHAIN = 'configured-toolchain'; + runner.environment.EXPECTED_PROJECT = await realpath(project); + const bin = join(runner.environment.RUSTUP_HOME, 'toolchains/configured-toolchain/bin'); + await fakeRust(bin); + await writeFile(join(directory, 'rustup'), '#!/bin/sh\n[ "$PWD" = "$EXPECTED_PROJECT" ] && [ "$1" = which ] || exit 1\nprintf "%s/toolchains/%s/bin/%s\\n" "$RUSTUP_HOME" "$RUSTUP_TOOLCHAIN" "$2"\n', { mode: 0o755 }); + await configureRustToolchain(runner, project); + assert.equal((await runner.run('Fixture Cargo', 'cargo', ['--version'], { cwd: project, quiet: true })).stdout.trim(), 'fixture compiler'); + assert.equal((await runner.run('Fixture compiler', 'rustc', ['--version'], { cwd: project, quiet: true })).stdout.trim(), 'fixture compiler'); +}); + +test('Rust discovery reports missing tools and preserves rustup selection failures', posixOnly, async (t) => { + const directory = await temporary(t); + const runner = rustRunner(directory); + // An unexecutable file is not a usable Cargo installation. + await writeFile(join(directory, 'cargo'), 'not executable', { mode: 0o644 }); + await assert.rejects(configureRustToolchain(runner, directory), /Install Rust from https:\/\/rustup.rs/); + await writeFile(join(directory, 'rustup'), '#!/bin/sh\nprintf "selected toolchain is missing\\n" >&2\nexit 1\n', { mode: 0o755 }); + await assert.rejects(configureRustToolchain(runner, directory), /selected toolchain is missing/); +}); + +test('identity selection rejects missing, ambiguous, wrong-team and invalid certificates', () => { + const identity = ` 1) ${fingerprint} "Developer ID Application: Example (${team})"\n`; + assert.equal(selectIdentity(identity, team), fingerprint); + assert.throws(() => selectIdentity(identity, 'OTHERTEAM0'), /exactly one/); + assert.throws(() => selectIdentity(identity + identity.replace(fingerprint, 'B'.repeat(40)), team), /exactly one/); + assert.throws(() => selectIdentity(identity.replace('Application', 'Installer'), team), /exactly one/); + assert.throws(() => selectIdentity(identity.trimEnd() + ' (CSSMERR_TP_CERT_EXPIRED)\n', team), /exactly one/); +}); + +test('native files include executables without extensions, exclude symlinks, and reject signing material', async (t) => { + const directory = await temporary(t); + await mkdir(join(directory, 'nested')); + await writeFile(join(directory, 'nested/tor'), Buffer.from('cffaedfe00000000', 'hex')); + await writeFile(join(directory, 'lib.dylib'), Buffer.from('cafebabe00000000', 'hex')); + await writeFile(join(directory, 'readme'), 'not native code'); + // Windows requires extra privileges for symlink creation. + if (process.platform !== 'win32') { + await symlink(join(directory, 'nested/tor'), join(directory, 'alias')); + await symlink(directory, join(directory, 'cycle')); + } + assert.deepEqual(await nativeFiles(directory), [join(directory, 'nested/tor'), join(directory, 'lib.dylib')]); + await writeFile(join(directory, 'leaked.p12'), 'must not ship'); + await assert.rejects(nativeFiles(directory), /Signing material/); +}); + +test('Tor library references become relative without permitting host dependencies', () => { + const files = ['/bundle/tor', '/bundle/lib/libcrypto.3.dylib']; + assert.deepEqual(libraryChanges(files[0], ['/usr/lib/libSystem.B.dylib', '/build/lib/libcrypto.3.dylib'], files), [ + ['/build/lib/libcrypto.3.dylib', '@loader_path/lib/libcrypto.3.dylib'] + ]); + assert.deepEqual(libraryChanges(files[0], ['@loader_path/lib/libcrypto.3.dylib'], files), []); + assert.throws(() => libraryChanges(files[0], ['/opt/homebrew/lib/missing.dylib'], files), /Unresolved/); + assert.throws(() => libraryChanges(files[0], ['@rpath/libcrypto.3.dylib'], [...files, '/other/libcrypto.3.dylib']), /Unresolved/); +}); + +test('notarization submits once, preserves the ID, and only accepts Accepted', async () => { + const id = '12345678-1234-1234-1234-123456789012'; + for (const status of ['Accepted', 'Invalid', 'In Progress', undefined]) { + const calls = []; + const runner = { + redact: (text) => text, + async run(label, command, args) { + calls.push(args); + return { stdout: JSON.stringify(args[1] === 'submit' ? { id } : { status }), stderr: '', code: 0 }; + } + }; + const result = notarize(runner, '/app.zip', '/temporary.keychain'); + if (status === 'Accepted') await result; + else await assert.rejects(result, /not Accepted/); + assert.equal(calls.filter((args) => args[1] === 'submit').length, 1); + assert.equal(calls[1][2], id); + assert.equal(calls.some((args) => args[1] === 'log'), status !== 'Accepted'); + assert.ok(calls.every((args) => args.includes('--keychain-profile'))); + } +}); + +test('final checksums cover stapled bytes and are written only after cleanup', async (t) => { + const directory = await temporary(t); + const output = join(directory, 'Napstr.dmg'); + const session = { async cleanup() { assert.deepEqual(await readdir(directory), ['Napstr.dmg']); } }; + await withCleanup(session, {}, output, async () => { + await writeFile(output, 'unsigned image'); + await writeFile(output, 'final signed and stapled image'); + }); + assert.equal(await readFile(`${output}.sha256`, 'utf8'), `${await checksum(output)} Napstr.dmg\n`); +}); + +test('build, verification, interruption, and cleanup failures remove both publishable artifacts', async (t) => { + const directory = await temporary(t); + const output = join(directory, 'Napstr.dmg'); + for (const stage of ['build', 'verification', 'interrupted', 'cleanup']) { + let cleaned = false; + const session = { async cleanup() { cleaned = true; if (stage === 'cleanup') throw new Error('cleanup failed'); } }; + await writeFile(`${output}.sha256`, 'stale checksum'); + await assert.rejects(withCleanup(session, { interrupted: stage === 'interrupted' }, output, async () => { + await writeFile(output, 'partly finalized image'); + if (stage === 'build' || stage === 'verification') throw new Error(`${stage} failed`); + }), /failed|interrupted/); + assert.equal(cleaned, true); + assert.deepEqual(await readdir(directory), []); + } +}); + +test('cleanup preserves recovery state on failure and still attempts keychain deletion', async (t) => { + const directory = await temporary(t); + const calls = []; + const keychain = join(directory, 'signing.keychain-db'); + await writeFile(keychain, 'fake keychain'); + const runner = { + redact: (value) => value, + async run(label, command, args) { + calls.push(args[0]); + if (args[0] === 'list-keychains') throw new Error('restore failed'); + if (args[0] === 'delete-keychain') await rm(keychain); + return { stdout: '' }; + } + }; + const session = new Session(join(directory, 'state.json'), runner); + await session.begin(); + session.state.keychain = keychain; + session.state.searchList = ['/login.keychain-db']; + await session.save(); + await assert.rejects(session.cleanup(), /restore failed/); + assert.deepEqual(calls, ['list-keychains', 'delete-keychain']); + assert.equal(JSON.parse(await readFile(session.path, 'utf8')).keychain, null); + await assert.rejects(session.begin(), /session exists/); +}); + +test('unmount retries transient EBUSY, but preserves recovery state for a real failure', async (t) => { + const directory = await temporary(t); + const mount = join(directory, 'mounted'); + const statuses = [16, 0]; + let attempts = 0; + const runner = { + redact: (value) => value, + async plist() { return { images: [{ 'system-entities': [{ 'mount-point': mount }] }] }; }, + async run(label, command, args) { + if (args[0] === 'info') return { stdout: '' }; + attempts++; + return { code: statuses.shift(), stdout: '', stderr: 'unmount diagnostic' }; + } + }; + const session = new Session(join(directory, 'state.json'), runner); + await session.begin(); + session.state.mount = mount; + await session.save(); + await session.detach(); + assert.equal(attempts, 2); + assert.equal(session.state.mount, null); + assert.equal(JSON.parse(await readFile(session.path, 'utf8')).mount, null); + + statuses.push(1); + attempts = 0; + session.state.mount = mount; + await session.save(); + await assert.rejects(session.detach(), /Detach final DMG failed \(1\)/); + assert.equal(attempts, 1); + assert.equal(JSON.parse(await readFile(session.path, 'utf8')).mount, mount); +}); + +test('command options reject unknown modes and unsafe release filenames', () => { + assert.deepEqual(parseOptions(['--app', 'napstrfy', '--signed', '--ci', '--tag', 'v1.2.3-rc1']), { + app: 'napstrfy', signed: true, ci: true, cleanup: false, tag: 'v1.2.3-rc1' + }); + assert.throws(() => parseOptions(['--app', 'unknown']), /Choose/); + assert.throws(() => parseOptions(['--tag', '../some-file']), /Invalid release tag/); + assert.throws(() => parseOptions(['--tag']), /Missing value/); + assert.throws(() => parseOptions(['--skip-verification']), /Unknown/); +}); diff --git a/website/content/download.html b/website/content/download.html index 5198e62..e87b7ab 100644 --- a/website/content/download.html +++ b/website/content/download.html @@ -34,8 +34,7 @@

N

Napstr For macOS

Latest Version: checking…

- Unnotarized community builds for Apple Silicon and Intel Macs. After the - first blocked launch, use System Settings → Privacy & Security → Open Anyway. + Builds for Apple Silicon and Intel Macs. See the release notes for installation instructions.

download Apple Silicon DMG
download Intel DMG diff --git a/website/downloads/README.md b/website/downloads/README.md index 8ee2903..9b3c5ea 100644 --- a/website/downloads/README.md +++ b/website/downloads/README.md @@ -1,8 +1,11 @@ Release artifacts are no longer copied into the website. The download page discovers the versioned installers attached to the latest published GitHub Release. -Create a `v*` tag to run `.github/workflows/release.yml`; the Tauri action builds and attaches -the Windows installer, Linux AppImage, and architecture-specific macOS community DMGs automatically. +Create a `v*` tag to run `.github/workflows/release.yml`; it builds and attaches +the Windows installer, Linux AppImage, and architecture-specific macOS DMGs automatically. +The macOS release helper signs, notarizes, staples, and verifies the app and DMG +before uploading the installer and its SHA-256 checksum. Configure the six Apple +secrets described in [macOS release setup](../../docs/macos-releases.md) first. The same tag runs `.github/workflows/napstrfy-desktop.yml` for Napstrfy desktop installers; `release.yml` also attaches the Napstrfy Android APK.