30 Commits

Author SHA1 Message Date
56d19014d4 chore(release): prepare 0.2.4 2026-07-31 04:59:36 +02:00
51bf0abba2 fix(release): configure Store CLI before settings 2026-07-31 04:51:31 +02:00
e0fb84ccb9 chore(release): prepare 0.2.3 2026-07-30 22:22:11 +02:00
30025a4ebf fix(release): download Play APK media 2026-07-30 22:19:40 +02:00
50e9a6c1cc chore(release): prepare 0.2.2 2026-07-30 21:54:54 +02:00
224a8e0e7a fix(release): enforce Apple hardened runtime 2026-07-30 21:27:34 +02:00
efacfab213 fix(release): expose Apple notarization failures 2026-07-30 21:03:30 +02:00
Hammed Abass
0ec7618ce8 Merge pull request #39 from sudosylabs/feat/release-pipeline-fixes
fix(release): repair Apple and Android builds
2026-07-30 20:30:28 +02:00
8b75423b7a fix(release): repair Apple and Android builds 2026-07-30 20:26:27 +02:00
Hammed Abass
7236933b76 Merge pull request #38 from sudosylabs/feat/release-0.2.1
chore(release): prepare 0.2.1
2026-07-30 19:43:19 +02:00
fc732e1b77 chore(release): prepare 0.2.1 2026-07-30 19:27:57 +02:00
Hammed Abass
d097c82f6a Merge pull request #37 from sudosylabs/feat/microsoft-store-publishing
ci: automate Microsoft Store updates
2026-07-30 19:25:15 +02:00
caaa9a472d ci: automate Microsoft Store updates 2026-07-30 19:12:50 +02:00
Hammed Abass
4ce124da7c Merge pull request #36 from sudosylabs/feat/automated-release-versions
feat(release): automate derived store versions
2026-07-30 17:58:18 +02:00
94a8b3481b feat(release): automate derived store versions 2026-07-30 17:40:00 +02:00
Hammed Abass
6d908d8dc3 Merge pull request #35 from sudosylabs/feat/release-pipeline
ci: add coordinated cross-platform release pipeline
2026-07-28 11:58:18 +02:00
c6655da7db refactor(version): derive Apple build numbers 2026-07-28 11:27:45 +02:00
2d7982bbb9 ci: add coordinated release pipeline 2026-07-28 11:09:53 +02:00
Hammed Abass
52d4102308 Merge pull request #34 from sudosylabs/feat/unified-versioning
feat(release): unify cross-platform versioning
2026-07-28 08:45:02 +02:00
407a0d2d60 feat(release): unify cross-platform versioning 2026-07-28 08:22:58 +02:00
Hammed Abass
fc1d27bf45 Merge pull request #33 from sudosylabs/feat/release-test-flight
feat(apple): stable iOS/macOS release + macOS direct-download channel
2026-07-27 16:25:55 +02:00
4730554c2c refactor(apple): make InvitationError typed and localize NFC prompts
Replace the free-form InvitationError.message(String) case with semantic
cases mapped to L10n keys at the UI boundary (Error.uiText), so user-facing
error text is localized instead of substring-matched from English blobs.
.raw(String) remains only for genuinely dynamic system/core messages.

Localize the CoreNFC alertMessage prompts via existing L10n keys, and add
SwiftLint rules (raw_alert_message, raw_invitation_error) to catch raw
alert strings and literal .raw("…") errors going forward.
2026-07-27 16:07:19 +02:00
cbb535d998 chore(apple): stop tracking RELEASE-MACOS.md
Keep the macOS release notes local-only; remove from the index and ignore
so the working copy stays on disk without being committed.
2026-07-27 16:05:35 +02:00
a0ebd7c71b fix(apple): restore macOS approval modal and sandboxed file sharing
Approval modal: since the Share/QR sheet auto-opens after creating a transfer,
it is always up when a receiver request arrives, and macOS silently drops a sheet
presented while another is still dismissing — so the approval sheet never appeared.
Drive the approval sheet from explicit state (not a constant binding) and, on
macOS, defer its presentation one dismiss-beat after closing the Share/QR sheet so
the hand-off is serialized. Still a non-dismissable sheet; iOS timing unchanged.

Sandboxed file sharing: the macOS picker released its security scope immediately,
so the core's later import failed with EPERM under the App Store sandbox (the
non-sandboxed .dmg was unaffected). Capture a security-scoped bookmark at pick
time and re-acquire access across shareFiles() — during which the core imports the
bytes — mirroring the receive-folder scoped-access pattern.
2026-07-27 15:01:01 +02:00
cc194f6a7b feat(apple): add direct-download macOS channel (notarized DMG + Sparkle + Homebrew)
Add a second macOS shipping channel alongside the App Store build:

- New VniDropDirect target (Release-Direct config) sharing VniDrop's sources via
  an AppBase target template; links Sparkle behind the DIRECT_DISTRIBUTION flag so
  the App Store binary never bundles a self-updater. arm64-only (core is arm64).
- Sparkle updater (SparkleUpdater.swift) + "Check for Updates" menu, compiled only
  under DIRECT_DISTRIBUTION; Info.plist SUFeedURL points at the GitHub Release
  /latest/download/appcast.xml, non-sandboxed entitlements for Developer ID.
- build-dmg.sh (archive → Developer ID export → DMG → sign → notarize → staple),
  generate-appcast.sh, and ExportOptions-DeveloperID.plist.
- apple-release.yml: on tag v*.*.*, build/notarize the DMG, publish the GitHub
  Release with appcast, and push the Homebrew cask to sudosylabs/homebrew-vnidrop.
  apple.yml gains a PR compile-check of the direct target.
- CFBundleVersion is stamped at build time as a UTC YYMMDD.HHMM timestamp for both
  channels, replacing the hand-maintained build number.
- Docs (RELEASE-MACOS.md, README), cask template + tap README, localized
  updates_check string, Makefile targets, gitignore for dist/ artifacts.
2026-07-27 14:31:36 +02:00
8de190a36e chore(apple): update Icon Composer app icon definition 2026-07-27 12:19:13 +02:00
73bc87d3d1 fix(apple): use TAG NFC reader format for iOS 26 SDK
App Store upload with the iOS 26 SDK rejects the NDEF value (error 90778
"NDEF is disallowed") and requires TAG. NFCNDEFReaderSession keeps working
under the TAG entitlement, so no code changes are needed.
2026-07-27 10:27:22 +02:00
2166aa9ce4 build(apple): ship TestFlight build 7 as Release
Set CURRENT_PROJECT_VERSION to 7 for the next TestFlight upload, and pin the
scheme's Archive/Profile actions to the Release configuration so Product →
Archive can't pick up Debug.
2026-07-27 10:21:54 +02:00
22b93ce94e feat(apple): keep iOS transfers alive in the background
iOS suspends the process on backgrounding, freezing the core's network
threads so in-flight transfers stall and never fire notifications. Hold a
UIApplication background-task assertion (BackgroundActivityController) while
transfers/shares are active so iOS grants its grace window — long enough to
finish and notify. Released on foreground, on completion, or on expiration.
No UIBackgroundModes added (keeps App Store validation clean); macOS is a
no-op since it already runs unfocused.

Add a localized iOS-only Settings notice explaining the platform limit so it
doesn't read as a bug.
2026-07-27 10:02:31 +02:00
31ba3f40b2 test(shared): resolve UI copy from resources 2026-07-25 19:46:34 +02:00
87 changed files with 4286 additions and 452 deletions

141
.github/workflows/android-release.yml vendored Normal file
View File

@@ -0,0 +1,141 @@
name: Android release package
on:
workflow_call:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: android-release-${{ github.ref }}
cancel-in-progress: false
defaults:
run:
shell: bash
jobs:
build:
name: Build signed Android APK and AAB
runs-on: ubuntu-24.04
timeout-minutes: 90
env:
CARGO_TERM_COLOR: always
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
distribution: temurin
java-version: "21.0.11+10.0.LTS"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-home-cache-strict-match: true
- name: Install Rust 1.91
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # v1
with:
toolchain: "1.91.0"
targets: aarch64-linux-android,x86_64-linux-android
- name: Cache Cargo
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: android-release-cargo-1.91.0-${{ hashFiles('Cargo.lock') }}
restore-keys: |
android-release-cargo-1.91.0-
- name: Set up Android SDK
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
with:
packages: "platform-tools platforms;android-36 build-tools;36.0.0"
- name: Set up Android NDK
id: setup-ndk
uses: nttld/setup-ndk@ed92fe6cadad69be94a966a7ee3271275e62f779 # v1
with:
ndk-version: r27c
link-to-sdk: true
add-to-path: false
- name: Export Android NDK location
run: |
echo "ANDROID_NDK_HOME=${{ steps.setup-ndk.outputs.ndk-path }}" >> "$GITHUB_ENV"
echo "ANDROID_NDK_ROOT=${{ steps.setup-ndk.outputs.ndk-path }}" >> "$GITHUB_ENV"
- name: Resolve canonical version
id: version
run: |
packaging/version/resolve-version.sh verify >/dev/null
echo "app=$(packaging/version/resolve-version.sh product)" >> "$GITHUB_OUTPUT"
echo "code=$(packaging/version/resolve-version.sh android-code)" >> "$GITHUB_OUTPUT"
- name: Validate signing configuration
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_UPLOAD_KEYSTORE_BASE64 }}
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_UPLOAD_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_UPLOAD_KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.ANDROID_UPLOAD_KEY_PASSWORD }}
UPLOAD_CERT_SHA256: ${{ vars.ANDROID_UPLOAD_CERT_SHA256 }}
run: |
for name in \
KEYSTORE_BASE64 \
KEYSTORE_PASSWORD \
KEY_ALIAS \
KEY_PASSWORD \
UPLOAD_CERT_SHA256; do
if [ -z "${!name:-}" ]; then
echo "Missing Android release signing configuration: $name" >&2
exit 1
fi
done
- name: Decode upload keystore
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_UPLOAD_KEYSTORE_BASE64 }}
run: |
keystore="$RUNNER_TEMP/vnidrop-upload.jks"
printf '%s' "$KEYSTORE_BASE64" | base64 --decode > "$keystore"
chmod 600 "$keystore"
test -s "$keystore"
echo "VNIDROP_ANDROID_KEYSTORE_PATH=$keystore" >> "$GITHUB_ENV"
- name: Build and verify signed release
env:
VNIDROP_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_UPLOAD_KEYSTORE_PASSWORD }}
VNIDROP_ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_UPLOAD_KEY_ALIAS }}
VNIDROP_ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_UPLOAD_KEY_PASSWORD }}
VNIDROP_ANDROID_UPLOAD_CERT_SHA256: ${{ vars.ANDROID_UPLOAD_CERT_SHA256 }}
run: packaging/android/build-release.sh
- name: Remove upload keystore
if: always()
run: rm -f "$RUNNER_TEMP/vnidrop-upload.jks"
- name: Upload Android artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: vnidrop-${{ steps.version.outputs.app }}-android-release
path: build/release/android/
if-no-files-found: error
retention-days: 90
compression-level: 0
- name: Summarize Android package
run: |
echo "### Android release package" >> "$GITHUB_STEP_SUMMARY"
echo "- Version: ${{ steps.version.outputs.app }}" >> "$GITHUB_STEP_SUMMARY"
echo "- Version code: ${{ steps.version.outputs.code }}" >> "$GITHUB_STEP_SUMMARY"
echo "- Signing: upload certificate verified" >> "$GITHUB_STEP_SUMMARY"

160
.github/workflows/apple-release.yml vendored Normal file
View File

@@ -0,0 +1,160 @@
name: Apple release (macOS DMG)
# Builds, signs, notarizes, and uploads the direct-download macOS build:
# - a Developer IDsigned, notarized VniDrop-<version>.dmg,
# - a Sparkle appcast.xml.
#
# The central release workflow publishes these artifacts and updates Homebrew.
#
# The App Store / TestFlight build is NOT produced here — that goes through Xcode
# Organizer / App Store Connect. This workflow only covers direct distribution.
#
# Called by the central tag-release workflow, or run manually to validate the
# signed/notarized direct-download artifact.
on:
workflow_call:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: apple-release-${{ github.ref }}
cancel-in-progress: false
defaults:
run:
shell: bash
jobs:
build:
name: Build & notarize DMG
runs-on: macos-latest
timeout-minutes: 90
outputs:
version: ${{ steps.version.outputs.app }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Verify tag is on master
if: github.event_name == 'push'
run: |
if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/master; then
echo "Release tags must point to a commit on master" >&2
exit 1
fi
- name: Resolve canonical version
id: version
run: |
packaging/version/resolve-version.sh verify >/dev/null
version="$(packaging/version/resolve-version.sh product)"
echo "app=$version" >> "$GITHUB_OUTPUT"
- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode.app
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # v1
with:
toolchain: stable
targets: aarch64-apple-darwin
- name: Cache Cargo
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: apple-release-cargo-${{ hashFiles('Cargo.lock') }}
restore-keys: apple-release-cargo-
- name: Install tooling
run: brew install xcodegen swiftlint create-dmg
- name: Install Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- name: Download Sparkle tools
# generate_appcast + sign_update ship in the Sparkle release tarball.
run: |
set -euo pipefail
ver="2.9.4"
curl -fsSL -o /tmp/sparkle.tar.xz \
"https://github.com/sparkle-project/Sparkle/releases/download/${ver}/Sparkle-${ver}.tar.xz"
mkdir -p /tmp/sparkle && tar -xJf /tmp/sparkle.tar.xz -C /tmp/sparkle
echo "SPARKLE_BIN=/tmp/sparkle/bin" >> "$GITHUB_ENV"
- name: Import Developer ID certificate
env:
CERT_P12_BASE64: ${{ secrets.DEVELOPER_ID_CERT_P12 }}
CERT_PASSWORD: ${{ secrets.DEVELOPER_ID_CERT_PASSWORD }}
run: |
set -euo pipefail
keychain="$RUNNER_TEMP/signing.keychain-db"
kpw="$(openssl rand -hex 20)"
security create-keychain -p "$kpw" "$keychain"
security set-keychain-settings -lut 21600 "$keychain"
security unlock-keychain -p "$kpw" "$keychain"
echo "$CERT_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12"
security import "$RUNNER_TEMP/cert.p12" -k "$keychain" -P "$CERT_PASSWORD" \
-T /usr/bin/codesign -T /usr/bin/security
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$kpw" "$keychain"
# Prepend our keychain so codesign/xcodebuild can find the identity.
security list-keychains -d user -s "$keychain" $(security list-keychains -d user | tr -d '"')
rm -f "$RUNNER_TEMP/cert.p12"
- name: Store notarytool credentials
env:
NOTARY_KEY_P8: ${{ secrets.NOTARY_API_KEY }}
NOTARY_KEY_ID: ${{ secrets.NOTARY_KEY_ID }}
NOTARY_ISSUER: ${{ secrets.NOTARY_ISSUER }}
run: |
set -euo pipefail
echo "$NOTARY_KEY_P8" | base64 --decode > "$RUNNER_TEMP/notary.p8"
xcrun notarytool store-credentials vnidrop-notary \
--key "$RUNNER_TEMP/notary.p8" \
--key-id "$NOTARY_KEY_ID" \
--issuer "$NOTARY_ISSUER"
echo "NOTARY_PROFILE=vnidrop-notary" >> "$GITHUB_ENV"
- name: Write Sparkle signing key
env:
SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }}
run: |
printf '%s' "$SPARKLE_ED_PRIVATE_KEY" > "$RUNNER_TEMP/sparkle_ed_private_key"
echo "SPARKLE_ED_KEY_FILE=$RUNNER_TEMP/sparkle_ed_private_key" >> "$GITHUB_ENV"
- name: Build, sign & notarize DMG
run: make build-apple-dmg
- name: Upload notarization diagnostics
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: vnidrop-${{ steps.version.outputs.app }}-notarization-diagnostics
path: apple/dist/*.notary-log.json
if-no-files-found: ignore
retention-days: 14
- name: Generate appcast
env:
RELEASE_REPO: ${{ github.repository }}
run: apple/scripts/generate-appcast.sh
- name: Upload artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: vnidrop-${{ steps.version.outputs.app }}-macos-dmg
path: |
apple/dist/VniDrop-*.dmg
apple/dist/VniDrop-*.build-info.json
apple/dist/appcast.xml
if-no-files-found: error
retention-days: 14

View File

@@ -4,6 +4,8 @@ on:
pull_request: pull_request:
paths: paths:
- "apple/**" - "apple/**"
- "version.properties"
- "packaging/version/**"
- "crates/vnidrop/**" - "crates/vnidrop/**"
- "crates/uniffi-bindgen/**" - "crates/uniffi-bindgen/**"
- "Cargo.toml" - "Cargo.toml"
@@ -18,6 +20,8 @@ on:
- master - master
paths: paths:
- "apple/**" - "apple/**"
- "version.properties"
- "packaging/version/**"
- "crates/vnidrop/**" - "crates/vnidrop/**"
- "crates/uniffi-bindgen/**" - "crates/uniffi-bindgen/**"
- "Cargo.toml" - "Cargo.toml"
@@ -76,3 +80,8 @@ jobs:
- name: Build and test Apple app - name: Build and test Apple app
run: make check-apple run: make check-apple
- name: Build direct-download macOS target (Sparkle, unsigned)
# Keeps the VniDropDirect (.dmg/Sparkle) target compiling; signing and
# notarization happen only in apple-release.yml on a tag.
run: make build-apple-macos-direct

View File

@@ -5,6 +5,8 @@ on:
paths: paths:
- ".github/workflows/linux-packages.yml" - ".github/workflows/linux-packages.yml"
- "packaging/linux/**" - "packaging/linux/**"
- "packaging/version/**"
- "version.properties"
- "assets/linux/**" - "assets/linux/**"
- "desktopApp/**" - "desktopApp/**"
- "shared/**" - "shared/**"
@@ -20,16 +22,8 @@ on:
- "Makefile" - "Makefile"
- "config.mk" - "config.mk"
- "make/**" - "make/**"
push: workflow_call:
tags:
- "v*.*.*"
workflow_dispatch: workflow_dispatch:
inputs:
version:
description: Release version in MAJOR.MINOR.PATCH form
required: true
default: "1.0.0"
type: string
permissions: permissions:
contents: read contents: read
@@ -88,16 +82,14 @@ jobs:
restore-keys: | restore-keys: |
linux-deb-x64-cargo-1.91.0- linux-deb-x64-cargo-1.91.0-
- name: Resolve version - name: Resolve canonical version
id: version id: version
env:
REQUESTED_VERSION: ${{ inputs.version || '1.0.0' }}
run: | run: |
version=$(packaging/linux/resolve-version.sh "$REQUESTED_VERSION") version=$(packaging/linux/resolve-version.sh)
echo "app=$version" >> "$GITHUB_OUTPUT" echo "app=$version" >> "$GITHUB_OUTPUT"
- name: Test and build Debian package - name: Test and build Debian package
run: make package-deb VERSION=${{ steps.version.outputs.app }} run: make package-deb
- name: Upload Debian artifact - name: Upload Debian artifact
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
@@ -193,16 +185,14 @@ jobs:
restore-keys: | restore-keys: |
linux-rpm-x64-cargo-1.91.0- linux-rpm-x64-cargo-1.91.0-
- name: Resolve version - name: Resolve canonical version
id: version id: version
env:
REQUESTED_VERSION: ${{ inputs.version || '1.0.0' }}
run: | run: |
version=$(packaging/linux/resolve-version.sh "$REQUESTED_VERSION") version=$(packaging/linux/resolve-version.sh)
echo "app=$version" >> "$GITHUB_OUTPUT" echo "app=$version" >> "$GITHUB_OUTPUT"
- name: Build RPM package - name: Build RPM package
run: make package-rpm VERSION=${{ steps.version.outputs.app }} run: make package-rpm
- name: Upload RPM artifact - name: Upload RPM artifact
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
@@ -220,74 +210,3 @@ jobs:
echo "- Version: ${{ steps.version.outputs.app }}-1" >> "$GITHUB_STEP_SUMMARY" echo "- Version: ${{ steps.version.outputs.app }}-1" >> "$GITHUB_STEP_SUMMARY"
echo "- Architecture: x86_64" >> "$GITHUB_STEP_SUMMARY" echo "- Architecture: x86_64" >> "$GITHUB_STEP_SUMMARY"
echo "- Build environment: Fedora 43" >> "$GITHUB_STEP_SUMMARY" echo "- Build environment: Fedora 43" >> "$GITHUB_STEP_SUMMARY"
publish-release:
name: Publish GitHub Release assets
if: github.event_name == 'push' && github.ref_type == 'tag'
needs:
- build-deb
- build-rpm
runs-on: ubuntu-22.04
timeout-minutes: 15
permissions:
contents: write
steps:
- name: Checkout release history
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Verify tag is on master
run: |
if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/master; then
echo "Release tags must point to a commit on master" >&2
exit 1
fi
- name: Download Linux artifacts
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
pattern: vnidrop-*-linux-*-x64
path: build/release/linux
merge-multiple: true
- name: Verify artifacts and checksums
run: |
cd build/release/linux
shopt -s nullglob
deb_packages=(*.deb)
rpm_packages=(*.rpm)
checksum_files=(*.sha256)
if (( ${#deb_packages[@]} != 1 || ${#rpm_packages[@]} != 1 || ${#checksum_files[@]} != 2 )); then
echo "Expected one DEB, one RPM, and two checksum sidecars" >&2
exit 1
fi
version=${GITHUB_REF_NAME#v}
if [[ ${deb_packages[0]} != "vnidrop_${version}-1_amd64.deb" || ${rpm_packages[0]} != "vnidrop-${version}-1.x86_64.rpm" ]]; then
echo "Downloaded package names do not match tag $GITHUB_REF_NAME" >&2
exit 1
fi
sha256sum --check "${checksum_files[@]}"
sha256sum "${deb_packages[@]}" "${rpm_packages[@]}" > SHA256SUMS
rm -- "${checksum_files[@]}"
- name: Publish GitHub Release
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
tag=${GITHUB_REF_NAME}
version=${tag#v}
if gh release view "$tag" >/dev/null 2>&1; then
echo "GitHub Release $tag already exists; refusing to replace its assets" >&2
exit 1
fi
gh release create "$tag" \
build/release/linux/*.deb \
build/release/linux/*.rpm \
build/release/linux/SHA256SUMS \
--verify-tag \
--title "VniDrop $version" \
--generate-notes

53
.github/workflows/release-checks.yml vendored Normal file
View File

@@ -0,0 +1,53 @@
name: Release pipeline checks
on:
pull_request:
paths:
- ".github/workflows/android-release.yml"
- ".github/workflows/apple-release.yml"
- ".github/workflows/linux-packages.yml"
- ".github/workflows/release-checks.yml"
- ".github/workflows/release.yml"
- ".github/workflows/windows-store.yml"
- "packaging/android/**"
- "packaging/release/**"
- "packaging/version/**"
- "version.properties"
- "Makefile"
push:
branches:
- master
paths:
- ".github/workflows/android-release.yml"
- ".github/workflows/apple-release.yml"
- ".github/workflows/linux-packages.yml"
- ".github/workflows/release-checks.yml"
- ".github/workflows/release.yml"
- ".github/workflows/windows-store.yml"
- "packaging/android/**"
- "packaging/release/**"
- "packaging/version/**"
- "version.properties"
- "Makefile"
permissions:
contents: read
concurrency:
group: release-checks-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
scripts:
name: Validate release scripts
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Run release checks
run: make check-release

459
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,459 @@
name: Release
on:
push:
tags:
- "v*.*.*"
permissions:
contents: read
concurrency:
group: vnidrop-release
cancel-in-progress: false
jobs:
preflight:
name: Verify release tag
if: ${{ vars.RELEASE_PIPELINE_ENABLED == 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 10
outputs:
version: ${{ steps.version.outputs.app }}
android_code: ${{ steps.version.outputs.android_code }}
steps:
- name: Checkout release history
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Verify canonical beta tag on current master
id: version
run: |
set -euo pipefail
packaging/version/resolve-version.sh verify >/dev/null
version="$(packaging/version/resolve-version.sh product)"
channel="$(packaging/version/resolve-version.sh channel)"
master_sha="$(git rev-parse origin/master)"
if [ "$GITHUB_SHA" != "$master_sha" ]; then
echo "Release tags must point at the current master commit" >&2
exit 1
fi
if [ "$channel" != "beta" ]; then
echo "Only beta closed-testing releases are enabled" >&2
exit 1
fi
echo "app=$version" >> "$GITHUB_OUTPUT"
echo "android_code=$(packaging/version/resolve-version.sh android-code)" >> "$GITHUB_OUTPUT"
- name: Refuse an existing GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "GitHub Release $GITHUB_REF_NAME already exists" >&2
exit 1
fi
linux:
name: Linux packages
needs: preflight
uses: ./.github/workflows/linux-packages.yml
windows:
name: Windows Store package
needs: preflight
uses: ./.github/workflows/windows-store.yml
macos:
name: Signed and notarized macOS package
needs: preflight
uses: ./.github/workflows/apple-release.yml
secrets: inherit
android:
name: Signed Android package
needs: preflight
uses: ./.github/workflows/android-release.yml
secrets: inherit
play-closed-testing:
name: Stage Play closed-testing draft
needs:
- preflight
- linux
- windows
- macos
- android
runs-on: ubuntu-24.04
timeout-minutes: 20
environment: play-closed-testing
permissions:
contents: read
id-token: write
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Download signed Android artifacts
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: vnidrop-${{ needs.preflight.outputs.version }}-android-release
path: build/release/android
- name: Validate closed-testing configuration
env:
WORKLOAD_IDENTITY_PROVIDER: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }}
PLAY_SERVICE_ACCOUNT: ${{ vars.GCP_PLAY_SERVICE_ACCOUNT }}
PLAY_PACKAGE_NAME: ${{ vars.PLAY_PACKAGE_NAME }}
PLAY_CLOSED_TRACK: ${{ vars.PLAY_CLOSED_TRACK }}
PLAY_APP_SIGNING_CERT_SHA256: ${{ vars.PLAY_APP_SIGNING_CERT_SHA256 }}
run: |
for name in \
WORKLOAD_IDENTITY_PROVIDER \
PLAY_SERVICE_ACCOUNT \
PLAY_PACKAGE_NAME \
PLAY_CLOSED_TRACK \
PLAY_APP_SIGNING_CERT_SHA256; do
if [ -z "${!name:-}" ]; then
echo "Missing Play closed-testing configuration: $name" >&2
exit 1
fi
done
case "${PLAY_CLOSED_TRACK,,}" in
production|*:production)
echo "Production Play tracks are forbidden" >&2
exit 1
;;
esac
if [ "$PLAY_PACKAGE_NAME" != "com.vnidrop.app" ]; then
echo "Unexpected Play package name: $PLAY_PACKAGE_NAME" >&2
exit 1
fi
- name: Authenticate to Google with GitHub OIDC
id: google-auth
uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3
with:
workload_identity_provider: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ vars.GCP_PLAY_SERVICE_ACCOUNT }}
token_format: access_token
access_token_scopes: https://www.googleapis.com/auth/androidpublisher
- name: Stage AAB and download Play-signed APK
env:
GOOGLE_PLAY_ACCESS_TOKEN: ${{ steps.google-auth.outputs.access_token }}
PLAY_PACKAGE_NAME: ${{ vars.PLAY_PACKAGE_NAME }}
PLAY_CLOSED_TRACK: ${{ vars.PLAY_CLOSED_TRACK }}
PLAY_APP_SIGNING_CERT_SHA256: ${{ vars.PLAY_APP_SIGNING_CERT_SHA256 }}
VERSION: ${{ needs.preflight.outputs.version }}
VERSION_CODE: ${{ needs.preflight.outputs.android_code }}
run: |
set -euo pipefail
shopt -s nullglob
bundles=(build/release/android/*.aab)
if [ "${#bundles[@]}" -ne 1 ]; then
echo "Expected exactly one signed AAB" >&2
exit 1
fi
mkdir -p build/release/play
python3 packaging/android/publish_play.py \
--bundle "${bundles[0]}" \
--package-name "$PLAY_PACKAGE_NAME" \
--track "$PLAY_CLOSED_TRACK" \
--version-code "$VERSION_CODE" \
--release-name "$VERSION" \
--expected-app-certificate "$PLAY_APP_SIGNING_CERT_SHA256" \
--apk-output "build/release/play/VniDrop-${VERSION}-${VERSION_CODE}-play-universal.apk" \
--metadata-output build/release/play/play-release.json
- name: Set up Android SDK verification tools
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
with:
packages: "platform-tools build-tools;36.0.0"
- name: Verify Play-signed universal APK
env:
EXPECTED_CERT_SHA256: ${{ vars.PLAY_APP_SIGNING_CERT_SHA256 }}
VERSION: ${{ needs.preflight.outputs.version }}
VERSION_CODE: ${{ needs.preflight.outputs.android_code }}
run: |
set -euo pipefail
apk="build/release/play/VniDrop-${VERSION}-${VERSION_CODE}-play-universal.apk"
apkanalyzer_path="$(
find "$ANDROID_SDK_ROOT/cmdline-tools" -type f -name apkanalyzer -perm -111 |
sort -r |
head -1
)"
if [ -z "$apkanalyzer_path" ]; then
echo "apkanalyzer was not found" >&2
exit 1
fi
packaging/android/verify-apk-signature.sh \
"$apk" \
"$EXPECTED_CERT_SHA256" \
>/dev/null
if [ "$("$apkanalyzer_path" manifest application-id "$apk")" != "com.vnidrop.app" ]; then
echo "Play APK package name mismatch" >&2
exit 1
fi
if [ "$("$apkanalyzer_path" manifest version-name "$apk")" != "$VERSION" ]; then
echo "Play APK version name mismatch" >&2
exit 1
fi
if [ "$("$apkanalyzer_path" manifest version-code "$apk")" != "$VERSION_CODE" ]; then
echo "Play APK version code mismatch" >&2
exit 1
fi
(
cd build/release/play
sha256sum \
"VniDrop-${VERSION}-${VERSION_CODE}-play-universal.apk" \
play-release.json \
> SHA256SUMS
)
- name: Upload Play-signed APK
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: vnidrop-${{ needs.preflight.outputs.version }}-android-play
path: build/release/play/
if-no-files-found: error
retention-days: 90
compression-level: 0
publish-microsoft-store:
name: Submit Microsoft Store update
needs:
- preflight
- linux
- windows
- macos
- play-closed-testing
runs-on: windows-2025
timeout-minutes: 30
environment: microsoft-store
permissions:
contents: read
steps:
- name: Download Windows Store package
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: vnidrop-${{ needs.preflight.outputs.version }}-windows-store-x64
path: build/release/windows
- name: Validate Microsoft Store configuration
id: store-package
shell: pwsh
env:
AZURE_AD_TENANT_ID: ${{ secrets.AZURE_AD_TENANT_ID }}
AZURE_AD_APPLICATION_CLIENT_ID: ${{ secrets.AZURE_AD_APPLICATION_CLIENT_ID }}
AZURE_AD_APPLICATION_SECRET: ${{ secrets.AZURE_AD_APPLICATION_SECRET }}
SELLER_ID: ${{ secrets.SELLER_ID }}
MICROSOFT_STORE_PRODUCT_ID: ${{ vars.MICROSOFT_STORE_PRODUCT_ID }}
run: |
$configuration = @{
AZURE_AD_TENANT_ID = $env:AZURE_AD_TENANT_ID
AZURE_AD_APPLICATION_CLIENT_ID = $env:AZURE_AD_APPLICATION_CLIENT_ID
AZURE_AD_APPLICATION_SECRET = $env:AZURE_AD_APPLICATION_SECRET
SELLER_ID = $env:SELLER_ID
MICROSOFT_STORE_PRODUCT_ID = $env:MICROSOFT_STORE_PRODUCT_ID
}
foreach ($entry in $configuration.GetEnumerator()) {
if ([string]::IsNullOrWhiteSpace($entry.Value) -or $entry.Value -eq "REPLACE_ME") {
throw "Missing Microsoft Store configuration: $($entry.Key)"
}
}
if ($env:MICROSOFT_STORE_PRODUCT_ID -ne "9NJ5Q0FG7TGL") {
throw "Unexpected Microsoft Store product ID: $env:MICROSOFT_STORE_PRODUCT_ID"
}
$packages = @(
Get-ChildItem build/release/windows -File -Filter *.msixupload -Recurse
)
if ($packages.Count -ne 1) {
throw "Expected exactly one msixupload package, found $($packages.Count)"
}
"path=$($packages[0].FullName)" >> $env:GITHUB_OUTPUT
- name: Set up Microsoft Store Developer CLI
uses: microsoft/microsoft-store-apppublisher@15abd1c50fcc164b19cb240fb04ef3c49bf715a2 # v1.1
with:
version: v0.3.9
- name: Authenticate and verify Store access
shell: pwsh
env:
AZURE_AD_TENANT_ID: ${{ secrets.AZURE_AD_TENANT_ID }}
AZURE_AD_APPLICATION_CLIENT_ID: ${{ secrets.AZURE_AD_APPLICATION_CLIENT_ID }}
AZURE_AD_APPLICATION_SECRET: ${{ secrets.AZURE_AD_APPLICATION_SECRET }}
SELLER_ID: ${{ secrets.SELLER_ID }}
MICROSOFT_STORE_PRODUCT_ID: ${{ vars.MICROSOFT_STORE_PRODUCT_ID }}
run: |
msstore reconfigure `
--tenantId "$env:AZURE_AD_TENANT_ID" `
--sellerId "$env:SELLER_ID" `
--clientId "$env:AZURE_AD_APPLICATION_CLIENT_ID" `
--clientSecret "$env:AZURE_AD_APPLICATION_SECRET"
if ($LASTEXITCODE -ne 0) {
throw "Microsoft Store authentication failed"
}
msstore settings --enableTelemetry false
if ($LASTEXITCODE -ne 0) {
throw "Failed to disable Microsoft Store CLI telemetry"
}
msstore apps get "$env:MICROSOFT_STORE_PRODUCT_ID"
if ($LASTEXITCODE -ne 0) {
throw "The Microsoft Store application is not accessible"
}
- name: Publish package to Microsoft Store
shell: pwsh
env:
MICROSOFT_STORE_PRODUCT_ID: ${{ vars.MICROSOFT_STORE_PRODUCT_ID }}
STORE_PACKAGE: ${{ steps.store-package.outputs.path }}
run: |
msstore publish "$env:STORE_PACKAGE" `
--appId "$env:MICROSOFT_STORE_PRODUCT_ID"
if ($LASTEXITCODE -ne 0) {
throw "Microsoft Store package publication failed"
}
- name: Summarize Store submission
shell: pwsh
env:
VERSION: ${{ needs.preflight.outputs.version }}
MICROSOFT_STORE_PRODUCT_ID: ${{ vars.MICROSOFT_STORE_PRODUCT_ID }}
run: |
"### Microsoft Store submission" >> $env:GITHUB_STEP_SUMMARY
"- App version: $env:VERSION" >> $env:GITHUB_STEP_SUMMARY
"- Product ID: $env:MICROSOFT_STORE_PRODUCT_ID" >> $env:GITHUB_STEP_SUMMARY
"- Package submitted for certification" >> $env:GITHUB_STEP_SUMMARY
publish-github:
name: Publish coordinated GitHub Release
needs:
- preflight
- linux
- windows
- macos
- play-closed-testing
- publish-microsoft-store
runs-on: ubuntu-24.04
timeout-minutes: 20
permissions:
contents: write
id-token: write
attestations: write
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Download Debian package
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: vnidrop-${{ needs.preflight.outputs.version }}-linux-deb-x64
path: build/release/downloads/deb
- name: Download RPM package
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: vnidrop-${{ needs.preflight.outputs.version }}-linux-rpm-x64
path: build/release/downloads/rpm
- name: Download macOS package
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: vnidrop-${{ needs.preflight.outputs.version }}-macos-dmg
path: build/release/downloads/macos
- name: Download Windows Store package
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: vnidrop-${{ needs.preflight.outputs.version }}-windows-store-x64
path: build/release/downloads/windows
- name: Download Play-signed APK
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: vnidrop-${{ needs.preflight.outputs.version }}-android-play
path: build/release/downloads/play
- name: Verify and assemble public release assets
run: packaging/release/assemble-release.sh
- name: Attest release provenance
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4
with:
subject-path: build/release/final/*
- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "$GITHUB_REF_NAME" \
build/release/final/* \
--repo "$GITHUB_REPOSITORY" \
--verify-tag \
--title "VniDrop ${{ needs.preflight.outputs.version }}" \
--generate-notes
update-homebrew:
name: Update Homebrew cask
needs:
- preflight
- macos
- publish-github
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Download macOS package
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: vnidrop-${{ needs.preflight.outputs.version }}-macos-dmg
path: dist
- name: Render Homebrew cask
env:
VERSION: ${{ needs.preflight.outputs.version }}
run: |
set -euo pipefail
sha="$(sha256sum "dist/VniDrop-${VERSION}.dmg" | cut -d' ' -f1)"
sed -e "s/^ version \".*\"/ version \"${VERSION}\"/" \
-e "s/^ sha256 \".*\"/ sha256 \"${sha}\"/" \
packaging/homebrew/vnidrop.rb > /tmp/vnidrop.rb
- name: Push cask to tap
env:
TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
VERSION: ${{ needs.preflight.outputs.version }}
run: |
set -euo pipefail
git clone \
"https://x-access-token:${TAP_TOKEN}@github.com/sudosylabs/homebrew-vnidrop.git" \
tap
mkdir -p tap/Casks
cp /tmp/vnidrop.rb tap/Casks/vnidrop.rb
cd tap
git config user.name "vnidrop-release-bot"
git config user.email "release-bot@users.noreply.github.com"
git add Casks/vnidrop.rb
git commit -m "vnidrop ${VERSION}" || {
echo "Homebrew cask already matches ${VERSION}"
exit 0
}
git push

View File

@@ -6,6 +6,8 @@ on:
- "Cargo.toml" - "Cargo.toml"
- "Cargo.lock" - "Cargo.lock"
- "crates/vnidrop/**" - "crates/vnidrop/**"
- "version.properties"
- "packaging/version/**"
- "Makefile" - "Makefile"
- "config.mk" - "config.mk"
- "make/**" - "make/**"
@@ -19,6 +21,8 @@ on:
- "Cargo.toml" - "Cargo.toml"
- "Cargo.lock" - "Cargo.lock"
- "crates/vnidrop/**" - "crates/vnidrop/**"
- "version.properties"
- "packaging/version/**"
- "Makefile" - "Makefile"
- "config.mk" - "config.mk"
- "make/**" - "make/**"

View File

@@ -4,6 +4,8 @@ on:
pull_request: pull_request:
paths: paths:
- "shared/**" - "shared/**"
- "version.properties"
- "packaging/version/**"
- "crates/vnidrop/**" - "crates/vnidrop/**"
- "Cargo.toml" - "Cargo.toml"
- "Cargo.lock" - "Cargo.lock"
@@ -24,6 +26,8 @@ on:
- master - master
paths: paths:
- "shared/**" - "shared/**"
- "version.properties"
- "packaging/version/**"
- "crates/vnidrop/**" - "crates/vnidrop/**"
- "Cargo.toml" - "Cargo.toml"
- "Cargo.lock" - "Cargo.lock"

View File

@@ -5,6 +5,8 @@ on:
paths: paths:
- ".github/workflows/windows-store.yml" - ".github/workflows/windows-store.yml"
- "packaging/windows/**" - "packaging/windows/**"
- "packaging/version/**"
- "version.properties"
- "assets/windows/**" - "assets/windows/**"
- "desktopApp/**" - "desktopApp/**"
- "shared/**" - "shared/**"
@@ -17,16 +19,8 @@ on:
- "gradle/**" - "gradle/**"
- "gradlew" - "gradlew"
- "gradlew.bat" - "gradlew.bat"
push: workflow_call:
tags:
- "v*.*.*"
workflow_dispatch: workflow_dispatch:
inputs:
version:
description: Release version in MAJOR.MINOR.PATCH form
required: true
default: "1.0.0"
type: string
permissions: permissions:
contents: read contents: read
@@ -77,37 +71,13 @@ jobs:
restore-keys: | restore-keys: |
windows-x64-cargo-1.91.0- windows-x64-cargo-1.91.0-
- name: Resolve Store version - name: Resolve canonical version
id: version id: version
shell: pwsh shell: pwsh
env:
REQUESTED_VERSION: ${{ inputs.version || '1.0.0' }}
run: | run: |
$version = $env:REQUESTED_VERSION $version = .\packaging\version\resolve-version.ps1 -Field Json -VerifyTag | ConvertFrom-Json
if ($env:GITHUB_REF_TYPE -eq "tag") { "app=$($version.productVersion)" >> $env:GITHUB_OUTPUT
if ($env:GITHUB_REF_NAME -notmatch "^v[0-9]+\.[0-9]+\.[0-9]+$") { "package=$($version.windowsPackageVersion)" >> $env:GITHUB_OUTPUT
throw "Store release tags must use vMAJOR.MINOR.PATCH"
}
$version = $env:GITHUB_REF_NAME.Substring(1)
}
if ($version -notmatch "^[0-9]+\.[0-9]+\.[0-9]+$") {
throw "Version must use MAJOR.MINOR.PATCH"
}
$parts = $version.Split(".")
for ($index = 0; $index -lt $parts.Count; $index++) {
$part = $parts[$index]
$number = 0
if (-not [int]::TryParse($part, [ref] $number) -or $number.ToString() -ne $part) {
throw "Version components must be canonical integers"
}
if ($number -lt $(if ($index -eq 0) { 1 } else { 0 }) -or $number -gt 65535) {
throw "Version components must be between 0 and 65535, with a non-zero major"
}
}
"app=$version" >> $env:GITHUB_OUTPUT
"package=$version.0" >> $env:GITHUB_OUTPUT
- name: Test and build release app image - name: Test and build release app image
shell: pwsh shell: pwsh
@@ -115,7 +85,6 @@ jobs:
$arguments = @( $arguments = @(
":shared:jvmTest" ":shared:jvmTest"
":desktopApp:createReleaseDistributable" ":desktopApp:createReleaseDistributable"
"-Pvnidrop.version=${{ steps.version.outputs.app }}"
"-Pvnidrop.desktop.rustVariant=release" "-Pvnidrop.desktop.rustVariant=release"
"-Pvnidrop.diagnostics.included=false" "-Pvnidrop.diagnostics.included=false"
"--no-daemon" "--no-daemon"
@@ -131,7 +100,6 @@ jobs:
shell: pwsh shell: pwsh
run: | run: |
$arguments = @{ $arguments = @{
Version = "${{ steps.version.outputs.app }}"
AppImage = ".\desktopApp\build\compose\binaries\main-release\app\VniDrop" AppImage = ".\desktopApp\build\compose\binaries\main-release\app\VniDrop"
OutputDirectory = ".\build\release\windows" OutputDirectory = ".\build\release\windows"
} }

2
.gitignore vendored
View File

@@ -24,3 +24,5 @@ config.override.mk
# Local design export scratch # Local design export scratch
output/ output/
.screenshots .screenshots
apple/RELEASE-MACOS.md
apple/Generated/*.xcconfig

View File

@@ -12,14 +12,14 @@ include $(ROOT)/make/release.mk
.PHONY: format test check check-rust audit-rust test-rust test-rust-all .PHONY: format test check check-rust audit-rust test-rust test-rust-all
.PHONY: test-rust-transfer test-rust-approval test-rust-lifecycle test-rust-output-sink .PHONY: test-rust-transfer test-rust-approval test-rust-lifecycle test-rust-output-sink
.PHONY: check-shared test-shared test-android-host check-android verify-android-libs build-android run-desktop .PHONY: check-shared test-shared test-android-host check-android verify-android-libs build-android run-desktop
.PHONY: apple-core apple-project open-apple-project open-apple build-apple-macos build-apple-ios check-apple .PHONY: apple-core apple-version-config apple-project open-apple-project open-apple build-apple-macos build-apple-ios check-apple
.PHONY: check-localization localization localization-migrate .PHONY: prepare-release check-version check-release check-localization localization localization-migrate
.PHONY: check-docs run-docs check-diagnostics run-diagnostics diagnostics-db-local diagnostics-db-remote diagnostics-typegen deploy-diagnostics .PHONY: check-docs run-docs check-diagnostics run-diagnostics diagnostics-db-local diagnostics-db-remote diagnostics-typegen deploy-diagnostics
help: ## Show available commands and common configuration variables. help: ## Show available commands and common configuration variables.
@grep -hE '^[A-Za-z0-9_.-]+:.*## ' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*## "} {printf " %-28s %s\n", $$1, $$2}' @grep -hE '^[A-Za-z0-9_.-]+:.*## ' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*## "} {printf " %-28s %s\n", $$1, $$2}'
@printf '\nCommon variables:\n' @printf '\nCommon variables:\n'
@printf ' %-28s %s\n' 'VERSION=x.y.z' 'Package version (default: $(VERSION))' @printf ' %-28s %s\n' 'version.properties' 'Canonical application version ($(VERSION))'
@printf ' %-28s %s\n' 'APPLE_PROFILE=debug|release' 'Rust profile for the Apple XCFramework' @printf ' %-28s %s\n' 'APPLE_PROFILE=debug|release' 'Rust profile for the Apple XCFramework'
@printf ' %-28s %s\n' 'APPLE_CONFIGURATION=...' 'Xcode configuration (default: $(APPLE_CONFIGURATION))' @printf ' %-28s %s\n' 'APPLE_CONFIGURATION=...' 'Xcode configuration (default: $(APPLE_CONFIGURATION))'
@printf ' %-28s %s\n' 'APPLE_DESTINATION=...' 'Optional xcodebuild destination override' @printf ' %-28s %s\n' 'APPLE_DESTINATION=...' 'Optional xcodebuild destination override'
@@ -59,7 +59,26 @@ format: ## Format Rust sources.
test: test-rust test-shared ## Run the main Rust and shared JVM test suites. test: test-rust test-shared ## Run the main Rust and shared JVM test suites.
check: check-rust check-shared check-localization check-docs check-diagnostics ## Run portable pre-PR verification. check: check-version check-rust check-shared check-localization check-docs check-diagnostics ## Run portable pre-PR verification.
prepare-release: ## Update PRODUCT_VERSION and show its derived store versions (RELEASE_VERSION=x.y.z).
@test -n "$(RELEASE_VERSION)" || { printf 'Usage: make prepare-release RELEASE_VERSION=x.y.z\n' >&2; exit 1; }
cd $(ROOT) && packaging/version/prepare-release.sh "$(RELEASE_VERSION)"
check-version: ## Validate the canonical version and its platform mappings.
cd $(ROOT) && packaging/version/test-version.sh
cd $(ROOT) && packaging/version/resolve-version.sh verify
cd $(ROOT) && $(GRADLE) verifyVersion $(GRADLE_FLAGS)
check-release: ## Validate coordinated release scripts and workflow YAML.
cd $(ROOT) && bash -n apple/scripts/notarize.sh apple/scripts/sign-exported-app.sh apple/scripts/tests/test-notarize.sh apple/scripts/tests/test-sign-exported-app.sh packaging/android/build-release.sh packaging/android/verify-apk-signature.sh packaging/android/tests/test_verify_apk_signature.sh packaging/release/assemble-release.sh packaging/release/test-assemble-release.sh packaging/release/test-release-config.sh
cd $(ROOT) && apple/scripts/tests/test-notarize.sh
cd $(ROOT) && apple/scripts/tests/test-sign-exported-app.sh
cd $(ROOT) && packaging/android/tests/test_verify_apk_signature.sh
cd $(ROOT) && packaging/release/test-assemble-release.sh
cd $(ROOT) && packaging/release/test-release-config.sh
cd $(ROOT) && python3 -m unittest discover -s packaging/android/tests -v
cd $(ROOT) && ruby -e 'require "yaml"; ARGV.each { |file| YAML.load_file(file) }' .github/workflows/*.yml
check-rust: ## Run Rust formatting, lint, tests, and documentation checks. check-rust: ## Run Rust formatting, lint, tests, and documentation checks.
cd $(ROOT) && $(CARGO) fmt --all -- --check cd $(ROOT) && $(CARGO) fmt --all -- --check
@@ -113,7 +132,10 @@ apple-core: ## Build the Rust XCFramework and generated Swift bindings.
@test "$(HOST_OS)" = macos || { printf 'Apple builds require macOS.\n' >&2; exit 1; } @test "$(HOST_OS)" = macos || { printf 'Apple builds require macOS.\n' >&2; exit 1; }
cd $(ROOT) && apple/scripts/build-core.sh $(APPLE_PROFILE) cd $(ROOT) && apple/scripts/build-core.sh $(APPLE_PROFILE)
apple-project: apple-core localization ## Generate the native Apple Xcode project. apple-version-config: ## Generate derived Store and Direct Apple build settings.
cd $(ROOT) && packaging/version/generate-apple-xcconfig.sh all
apple-project: apple-core localization apple-version-config ## Generate the native Apple Xcode project.
cd $(ROOT)/apple && $(XCODEGEN) generate cd $(ROOT)/apple && $(XCODEGEN) generate
open-apple-project: apple-project ## Generate and open the native Apple Xcode project. open-apple-project: apple-project ## Generate and open the native Apple Xcode project.
@@ -122,6 +144,12 @@ open-apple-project: apple-project ## Generate and open the native Apple Xcode pr
build-apple-macos: apple-project ## Build the native macOS app (unsigned by default). build-apple-macos: apple-project ## Build the native macOS app (unsigned by default).
cd $(ROOT)/apple && $(XCODEBUILD) -project VniDrop.xcodeproj -scheme VniDrop -configuration $(APPLE_CONFIGURATION) -derivedDataPath "$(APPLE_DERIVED_DATA)" -destination 'platform=macOS' CODE_SIGNING_ALLOWED=$(APPLE_CODE_SIGNING) CODE_SIGNING_REQUIRED=$(APPLE_CODE_SIGNING) build cd $(ROOT)/apple && $(XCODEBUILD) -project VniDrop.xcodeproj -scheme VniDrop -configuration $(APPLE_CONFIGURATION) -derivedDataPath "$(APPLE_DERIVED_DATA)" -destination 'platform=macOS' CODE_SIGNING_ALLOWED=$(APPLE_CODE_SIGNING) CODE_SIGNING_REQUIRED=$(APPLE_CODE_SIGNING) build
build-apple-macos-direct: apple-project ## Build the direct-download macOS target (Sparkle, unsigned) — CI compile check.
cd $(ROOT)/apple && $(XCODEBUILD) -project VniDrop.xcodeproj -scheme VniDropDirect -configuration Release-Direct -derivedDataPath "$(APPLE_DERIVED_DATA)" -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO build
build-apple-dmg: localization ## Build the signed/notarized direct-download .dmg (see apple/RELEASE-MACOS.md for required env).
cd $(ROOT) && apple/scripts/build-dmg.sh
open-apple: build-apple-macos ## Build and launch the native macOS app. open-apple: build-apple-macos ## Build and launch the native macOS app.
@test -d "$(APPLE_DERIVED_DATA)/Build/Products/$(APPLE_CONFIGURATION)/VniDrop.app" || { printf 'Built macOS app was not found.\n' >&2; exit 1; } @test -d "$(APPLE_DERIVED_DATA)/Build/Products/$(APPLE_CONFIGURATION)/VniDrop.app" || { printf 'Built macOS app was not found.\n' >&2; exit 1; }
$(OPEN) "$(APPLE_DERIVED_DATA)/Build/Products/$(APPLE_CONFIGURATION)/VniDrop.app" $(OPEN) "$(APPLE_DERIVED_DATA)/Build/Products/$(APPLE_CONFIGURATION)/VniDrop.app"

View File

@@ -24,7 +24,7 @@ abstract class VerifyVnidropLibrariesTask : DefaultTask() {
archive.getEntry(path)?.size?.takeIf { it > 0L } == null archive.getEntry(path)?.size?.takeIf { it > 0L } == null
} }
check(missing.isEmpty()) { check(missing.isEmpty()) {
"Debug APK has missing or empty VniDrop libraries: ${missing.joinToString()}" "APK has missing or empty VniDrop libraries: ${missing.joinToString()}"
} }
} }
} }
@@ -37,6 +37,22 @@ plugins {
alias(libs.plugins.composeCompiler) alias(libs.plugins.composeCompiler)
} }
val appVersion = rootProject.extra["vnidrop.productVersion"] as String
val androidVersionCode = rootProject.extra["vnidrop.androidVersionCode"] as Int
val releaseKeystorePath = providers.environmentVariable("VNIDROP_ANDROID_KEYSTORE_PATH").orNull
val releaseKeystorePassword = providers.environmentVariable("VNIDROP_ANDROID_KEYSTORE_PASSWORD").orNull
val releaseKeyAlias = providers.environmentVariable("VNIDROP_ANDROID_KEY_ALIAS").orNull
val releaseKeyPassword = providers.environmentVariable("VNIDROP_ANDROID_KEY_PASSWORD").orNull
val releaseSigningValues = listOf(
releaseKeystorePath,
releaseKeystorePassword,
releaseKeyAlias,
releaseKeyPassword,
)
require(releaseSigningValues.all { it == null } || releaseSigningValues.all { it != null }) {
"Android release signing requires the keystore path, keystore password, key alias, and key password together"
}
kotlin { kotlin {
compilerOptions { compilerOptions {
jvmTarget = JvmTarget.JVM_11 jvmTarget = JvmTarget.JVM_11
@@ -55,12 +71,26 @@ android {
namespace = "com.vnidrop.app" namespace = "com.vnidrop.app"
compileSdk = libs.versions.android.compileSdk.get().toInt() compileSdk = libs.versions.android.compileSdk.get().toInt()
signingConfigs {
if (releaseKeystorePath != null) {
create("release") {
val keystoreFile = rootProject.file(releaseKeystorePath)
.also { require(it.isFile) { "Android release keystore was not found" } }
.also { require(it.canRead()) { "Android release keystore is not readable" } }
storeFile = keystoreFile
storePassword = releaseKeystorePassword
keyAlias = releaseKeyAlias
keyPassword = releaseKeyPassword
}
}
}
defaultConfig { defaultConfig {
applicationId = "com.vnidrop.app" applicationId = "com.vnidrop.app"
minSdk = libs.versions.android.minSdk.get().toInt() minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt() targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 1 versionCode = androidVersionCode
versionName = "1.0" versionName = appVersion
} }
packaging { packaging {
resources { resources {
@@ -76,6 +106,7 @@ android {
buildTypes { buildTypes {
getByName("release") { getByName("release") {
isMinifyEnabled = false isMinifyEnabled = false
signingConfig = signingConfigs.findByName("release")
} }
} }
compileOptions { compileOptions {
@@ -87,6 +118,10 @@ android {
jniLibs.srcDir(project(":shared").layout.buildDirectory.dir("intermediates/rust/aarch64-linux-android/debug")) jniLibs.srcDir(project(":shared").layout.buildDirectory.dir("intermediates/rust/aarch64-linux-android/debug"))
jniLibs.srcDir(project(":shared").layout.buildDirectory.dir("intermediates/rust/x86_64-linux-android/debug")) jniLibs.srcDir(project(":shared").layout.buildDirectory.dir("intermediates/rust/x86_64-linux-android/debug"))
} }
getByName("release") {
jniLibs.srcDir(project(":shared").layout.buildDirectory.dir("intermediates/rust/aarch64-linux-android/release"))
jniLibs.srcDir(project(":shared").layout.buildDirectory.dir("intermediates/rust/x86_64-linux-android/release"))
}
} }
} }
@@ -97,6 +132,12 @@ tasks.configureEach {
":shared:copyAndroidAndroidX64Debug", ":shared:copyAndroidAndroidX64Debug",
) )
} }
if (name == "mergeReleaseJniLibFolders" || name == "mergeReleaseNativeLibs") {
dependsOn(
":shared:copyAndroidAndroidArm64Release",
":shared:copyAndroidAndroidX64Release",
)
}
} }
val verifyDebugVnidropLibraries = tasks.register<VerifyVnidropLibrariesTask>("verifyDebugVnidropLibraries") { val verifyDebugVnidropLibraries = tasks.register<VerifyVnidropLibrariesTask>("verifyDebugVnidropLibraries") {

View File

@@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
@@ -38,7 +40,7 @@
<data android:mimeType="application/vnd.vnidrop.transfer"/> <data android:mimeType="application/vnd.vnidrop.transfer"/>
</intent-filter> </intent-filter>
<!-- Fallback: .vnd files often arrive as octet-stream / unknown MIME. --> <!-- Fallback: .vnd files often arrive as octet-stream / unknown MIME. -->
<intent-filter> <intent-filter tools:ignore="AppLinkUrlError">
<action android:name="android.intent.action.VIEW"/> <action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/> <category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/> <category android:name="android.intent.category.BROWSABLE"/>

4
apple/.gitignore vendored
View File

@@ -18,3 +18,7 @@ Local.xcconfig
.swiftpm/ .swiftpm/
DerivedData/ DerivedData/
*.xcuserstate *.xcuserstate
# Direct-download (.dmg) build outputs — apple/scripts/build-dmg.sh
.build-dmg/
dist/

View File

@@ -32,3 +32,18 @@ custom_rules:
regex: '\b(Text|Label|Button|Toggle|Link|NavigationLink|Section|Picker|Stepper|TextField|SecureField|DisclosureGroup|Menu|GroupBox)\("[^"]' regex: '\b(Text|Label|Button|Toggle|Link|NavigationLink|Section|Picker|Stepper|TextField|SecureField|DisclosureGroup|Menu|GroupBox)\("[^"]'
message: "Pass a typed L10n.* accessor (or Text(verbatim:)), not a raw string literal." message: "Pass a typed L10n.* accessor (or Text(verbatim:)), not a raw string literal."
severity: warning severity: warning
raw_alert_message:
name: "Raw NFC/alert message"
# User-facing UIKit/CoreNFC prompts (e.g. NFCReaderSession.alertMessage) must
# be localized, not hardcoded English.
regex: '\balertMessage\s*=\s*"'
message: "Assign a localized value (String(localized: L10n.*)), not a raw string literal."
severity: warning
raw_invitation_error:
name: "Raw InvitationError literal"
# InvitationError.raw is the escape hatch for genuinely dynamic system/core
# messages; a string literal here is a loose user-facing string that belongs
# in a typed InvitationError case mapped to L10n in UserFacingError.swift.
regex: 'InvitationError\.raw\("'
message: "Add a typed InvitationError case + L10n mapping instead of a literal .raw(\"…\")."
severity: warning

View File

@@ -34,12 +34,36 @@ Prerequisites: Xcode, Rust with the Apple targets
make apple-core # Rust core, Swift bindings, and XCFramework make apple-core # Rust core, Swift bindings, and XCFramework
make apple-project # generate apple/VniDrop.xcodeproj make apple-project # generate apple/VniDrop.xcodeproj
make open-apple-project # generate and open the project in Xcode make open-apple-project # generate and open the project in Xcode
make build-apple-macos # unsigned macOS build make build-apple-macos # unsigned macOS build (App Store target)
make open-apple # build and launch the macOS app make open-apple # build and launch the macOS app
make build-apple-ios # unsigned iOS simulator build make build-apple-ios # unsigned iOS simulator app
make check-apple # iOS simulator tests make check-apple # iOS simulator tests
``` ```
`make apple-project` also generates ignored Store and Direct version xcconfig
files. Their `CURRENT_PROJECT_VERSION` values come from the central version
resolver as UTC `YYYYMMDD.HHMM.SS` build identifiers. Regenerate the project
before creating another App Store archive so it receives a fresh build number;
direct DMG builds refresh their own value automatically.
### macOS shipping channels
The macOS app ships through two targets that build identical sources:
- **`VniDrop`** (`Release`) — Mac App Store / TestFlight. Sandboxed, no
self-updater.
- **`VniDropDirect`** (`Release-Direct`) — direct-download `.dmg` on GitHub
Releases + Homebrew cask. Adds the **Sparkle** auto-updater behind the
`DIRECT_DISTRIBUTION` compile flag, so the App Store binary never links Sparkle.
```bash
make build-apple-macos-direct # unsigned compile-check of the direct target
make build-apple-dmg # signed (+ notarized) .dmg
```
Full signing, notarization, appcast, and cask flow: see
[`RELEASE-MACOS.md`](RELEASE-MACOS.md).
Use `APPLE_PROFILE=release` to request a release Rust core, or set Use `APPLE_PROFILE=release` to request a release Rust core, or set
`APPLE_DESTINATION` to override the automatically selected iOS simulator. `APPLE_DESTINATION` to override the automatically selected iOS simulator.
Code signing is disabled for the app and test targets; local and CI builds do Code signing is disabled for the app and test targets; local and CI builds do

View File

@@ -21,13 +21,13 @@ final class UiMessageControllerTests: XCTestCase {
func testErrorSuppressesUserCancellation() { func testErrorSuppressesUserCancellation() {
let c = UiMessageController() let c = UiMessageController()
c.error(InvitationError.message("QR scanning was cancelled")) c.error(InvitationError.cancelled)
XCTAssertNil(c.current) // cancellations are swallowed XCTAssertNil(c.current) // cancellations are swallowed
} }
func testErrorShowsNonCancellation() { func testErrorShowsNonCancellation() {
let c = UiMessageController() let c = UiMessageController()
c.error(InvitationError.message("The transfer was refused")) c.error(InvitationError.raw("The transfer was refused"))
XCTAssertEqual(c.current?.tone, .error) XCTAssertEqual(c.current?.tone, .error)
} }
} }
@@ -36,20 +36,23 @@ final class UiMessageControllerTests: XCTestCase {
final class UserFacingErrorTests: XCTestCase { final class UserFacingErrorTests: XCTestCase {
func testIsUserCancellation() { func testIsUserCancellation() {
XCTAssertTrue(InvitationError.message("NFC reading was cancelled").isUserCancellation) XCTAssertTrue(InvitationError.cancelled.isUserCancellation)
XCTAssertTrue(InvitationError.message("User canceled the picker").isUserCancellation) XCTAssertTrue(InvitationError.raw("User canceled the picker").isUserCancellation)
XCTAssertFalse(InvitationError.message("A database error occurred").isUserCancellation) XCTAssertFalse(InvitationError.raw("A database error occurred").isUserCancellation)
} }
func testToUiTextMapsKnownReasons() { func testToUiTextMapsKnownReasons() {
XCTAssertEqual(InvitationError.message("The transfer was refused").toUiText(), .resource(L10n.Error.permission)) // Typed cases map directly at the UI boundary.
XCTAssertEqual(InvitationError.message("invalid ticket").toUiText(), .resource(L10n.Error.invalidTicket)) XCTAssertEqual(InvitationError.shareEmpty.toUiText(), .resource(L10n.Error.shareEmpty))
XCTAssertEqual(InvitationError.message("Select at least one file to share").toUiText(), .resource(L10n.Error.shareEmpty)) XCTAssertEqual(InvitationError.cameraUnavailable.toUiText(), .resource(L10n.Error.camera))
XCTAssertEqual(InvitationError.message("Camera access is required").toUiText(), .resource(L10n.Error.camera)) XCTAssertEqual(InvitationError.nfcFailed.toUiText(), .resource(L10n.Error.nfc))
// Dynamic `.raw` payloads still fall through the substring hints.
XCTAssertEqual(InvitationError.raw("The transfer was refused").toUiText(), .resource(L10n.Error.permission))
XCTAssertEqual(InvitationError.raw("invalid ticket").toUiText(), .resource(L10n.Error.invalidTicket))
} }
func testToUiTextFallsBackToGeneric() { func testToUiTextFallsBackToGeneric() {
XCTAssertEqual(InvitationError.message("something entirely unexpected").toUiText(), .resource(L10n.Error.generic)) XCTAssertEqual(InvitationError.raw("something entirely unexpected").toUiText(), .resource(L10n.Error.generic))
} }
func testToUiTextMapsTypedTransferFailures() { func testToUiTextMapsTypedTransferFailures() {

View File

@@ -13,6 +13,7 @@ final class AppGraph: ObservableObject {
let filePreviewRepository: FilePreviewRepository let filePreviewRepository: FilePreviewRepository
let approvalCoordinator: ApprovalCoordinator let approvalCoordinator: ApprovalCoordinator
let transferNotificationCoordinator: TransferNotificationCoordinator let transferNotificationCoordinator: TransferNotificationCoordinator
let backgroundActivity: BackgroundActivityController
init(dependencies: AppDependencies, coreRepository: CoreRepository? = nil) { init(dependencies: AppDependencies, coreRepository: CoreRepository? = nil) {
self.dependencies = dependencies self.dependencies = dependencies
@@ -39,6 +40,7 @@ final class AppGraph: ObservableObject {
visibility: visibility, visibility: visibility,
messages: messages messages: messages
) )
self.backgroundActivity = BackgroundActivityController(repository: coreRepository)
AppLogger.info("lifecycle", "graph created", ["platform": dependencies.environment.name]) AppLogger.info("lifecycle", "graph created", ["platform": dependencies.environment.name])
} }

View File

@@ -14,6 +14,10 @@ struct RootView: View {
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
/// Drives the approval sheet; toggled from the pending-approval `onChange` so the
/// presentation can be deferred until the Share/QR sheet has dismissed on macOS.
@State private var showApproval = false
init(dependencies: AppDependencies) { init(dependencies: AppDependencies) {
let graph = AppGraph(dependencies: dependencies) let graph = AppGraph(dependencies: dependencies)
_graph = StateObject(wrappedValue: graph) _graph = StateObject(wrappedValue: graph)
@@ -58,6 +62,7 @@ struct RootView: View {
navigation(windowClass: windowClass) navigation(windowClass: windowClass)
SnackbarHost(controller: messages) SnackbarHost(controller: messages)
ApprovalModalHost( ApprovalModalHost(
isPresented: $showApproval,
state: approvals.state, state: approvals.state,
onAccept: approvals.accept, onAccept: approvals.accept,
onRefuse: approvals.refuse onRefuse: approvals.refuse
@@ -81,22 +86,43 @@ struct RootView: View {
switch phase { switch phase {
case .active: case .active:
graph.visibility.setForeground(true) graph.visibility.setForeground(true)
graph.backgroundActivity.didBecomeForeground()
settingsModel.refreshNotificationPermission() settingsModel.refreshNotificationPermission()
// Reconcile against the durable snapshot: while the window was // Reconcile against the durable snapshot: while the window was
// unfocused/occluded (common on macOS) live events may not have // unfocused/occluded (common on macOS) live events may not have
// rendered, leaving progress/status stale. // rendered, leaving progress/status stale.
Task { _ = await graph.coreRepository.refresh() } Task { _ = await graph.coreRepository.refresh() }
case .background, .inactive: case .background:
graph.visibility.setForeground(false)
// Hold the process open for iOS's grace window so an active
// transfer can finish and notify before suspension.
graph.backgroundActivity.didEnterBackground()
case .inactive:
graph.visibility.setForeground(false) graph.visibility.setForeground(false)
@unknown default: @unknown default:
break break
} }
} }
// A pending approval is a blocking modal; close the sender's detail panel // A pending approval is a blocking modal. Close the sender's detail panel
// (e.g. the Share/QR sheet) so the approval sheet isn't presented under it // (e.g. the Share/QR sheet) first, then present the approval sheet but on
// on macOS. // macOS a sheet presented while another is still dismissing is silently
// dropped, so defer the presentation until that dismissal finishes.
.onChange(of: approvals.state.current?.id) { _, id in .onChange(of: approvals.state.current?.id) { _, id in
if id != nil { sendModel.closeDetailPanel() } guard id != nil else { showApproval = false; return }
let wasShowingSheet = sendModel.state.detailPanel != nil
sendModel.closeDetailPanel()
#if os(macOS)
if wasShowingSheet {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) {
if approvals.state.current != nil { showApproval = true }
}
} else {
showApproval = true
}
#else
_ = wasShowingSheet
showApproval = true
#endif
} }
#if os(macOS) #if os(macOS)
// macOS keeps `scenePhase == .active` even when the app loses focus, so // macOS keeps `scenePhase == .active` even when the app loses focus, so

View File

@@ -8,6 +8,10 @@ private let mainWindowId = "main"
@main @main
struct VniDropApp: App { struct VniDropApp: App {
@StateObject private var externalInvitations = ExternalInvitationController() @StateObject private var externalInvitations = ExternalInvitationController()
#if DIRECT_DISTRIBUTION && os(macOS)
// Sparkle auto-updater, present only in the direct-download (.dmg) build.
@StateObject private var updater = SparkleUpdaterController()
#endif
var body: some Scene { var body: some Scene {
#if os(macOS) #if os(macOS)
@@ -18,6 +22,11 @@ struct VniDropApp: App {
.ignoresSafeArea() .ignoresSafeArea()
.onOpenURL(perform: openInvitation) .onOpenURL(perform: openInvitation)
} }
#if DIRECT_DISTRIBUTION
.commands {
UpdatesCommands(controller: updater)
}
#endif
#else #else
WindowGroup(id: mainWindowId) { WindowGroup(id: mainWindowId) {
RootView(dependencies: makeAppDependencies(externalInvitations: externalInvitations)) RootView(dependencies: makeAppDependencies(externalInvitations: externalInvitations))

View File

@@ -0,0 +1,77 @@
import Combine
import Foundation
#if os(iOS)
import UIKit
#endif
/// Keeps the Rust core alive across the app moving to the background, within the
/// bounds Apple actually allows for a serverless P2P transfer app.
///
/// iOS suspends the whole process (freezing the core's network threads) shortly
/// after the app leaves the foreground. When a transfer or share is active we
/// take a `UIApplication` background-task assertion so iOS grants its finite
/// grace window long enough for an in-flight transfer to finish streaming and
/// for its completion/failure notification to fire. There is no App-Store-legal
/// mechanism to keep serving or receiving *indefinitely* while backgrounded, and
/// `BGTaskScheduler` wake-ups run only opportunistically and cannot detect an
/// incoming peer connection, so they are deliberately not used here.
///
/// macOS does not suspend the process on focus loss, so this is a no-op there and
/// the core keeps running normally.
@MainActor
final class BackgroundActivityController {
private let repository: CoreRepository
init(repository: CoreRepository) {
self.repository = repository
}
#if os(iOS)
private var assertionId: UIBackgroundTaskIdentifier = .invalid
private var idleCancellable: AnyCancellable?
/// The app moved to the background. Hold the process open while there is live
/// work; release as soon as it drains, on return to foreground, or when iOS
/// ends the grace window (whichever comes first).
func didEnterBackground() {
guard assertionId == .invalid, hasActiveWork else { return }
assertionId = UIApplication.shared.beginBackgroundTask(withName: "vnidrop.transfer") { [weak self] in
// Expiration handler: iOS is reclaiming the window; end cleanly to
// avoid the watchdog terminating the app.
self?.endAssertion()
}
// Release the assertion the moment work finishes instead of holding it for
// the full window (battery, and it lets the process suspend sooner). Events
// still deliver on the main actor while the window is open, so the core's
// active counts drop here when a transfer completes.
idleCancellable = repository.statePublisher
.map { ($0.status?.activeTransfers ?? 0) == 0 && ($0.status?.activeShares ?? 0) == 0 }
.removeDuplicates()
.sink { [weak self] idle in
if idle { self?.endAssertion() }
}
}
/// The app returned to the foreground; the process is live again, so drop any
/// held assertion.
func didBecomeForeground() {
endAssertion()
}
private var hasActiveWork: Bool {
let status = repository.state.status
return (status?.activeTransfers ?? 0) > 0 || (status?.activeShares ?? 0) > 0
}
private func endAssertion() {
idleCancellable?.cancel()
idleCancellable = nil
guard assertionId != .invalid else { return }
UIApplication.shared.endBackgroundTask(assertionId)
assertionId = .invalid
}
#else
func didEnterBackground() {}
func didBecomeForeground() {}
#endif
}

View File

@@ -156,7 +156,7 @@ final class CoreRepository: ObservableObject, CoreGateway {
return .failure(CoreNetworkLifecycleError.transitionInProgress) return .failure(CoreNetworkLifecycleError.transitionInProgress)
} }
guard !sources.isEmpty else { guard !sources.isEmpty else {
return .failure(InvitationError.message("Select at least one file to share")) return .failure(InvitationError.shareEmpty)
} }
return await runCore { return await runCore {
let result = try self.requireCore().shareFiles( let result = try self.requireCore().shareFiles(
@@ -353,7 +353,7 @@ final class CoreRepository: ObservableObject, CoreGateway {
private nonisolated func requireCore() throws -> VnidropCore { private nonisolated func requireCore() throws -> VnidropCore {
guard let core = self.core else { guard let core = self.core else {
throw InvitationError.message("Initialize the core first.") throw InvitationError.coreNotInitialized
} }
return core return core
} }

View File

@@ -23,23 +23,42 @@ final class ExternalInvitationController: ObservableObject {
} }
func reportOpenFailure(message: String) { func reportOpenFailure(message: String) {
continuation?.yield(.failure(InvitationError.message(message))) continuation?.yield(.failure(InvitationError.raw(message)))
} }
} }
/// Semantic, UI-agnostic invitation/transfer failures. Cases carry no display
/// text: `Error.toUiText()` (UI layer) maps each case to a localized `L10n` key,
/// so there are no free-form English strings to keep in sync or substring-match.
/// `.raw` is the escape hatch for genuinely dynamic system/core messages (e.g. a
/// `CoreNFC` `localizedDescription` or a picker's failure reason), never shown
/// verbatim it is still routed through `reasonHints`.
enum InvitationError: LocalizedError { enum InvitationError: LocalizedError {
case empty case empty
case tooLarge case tooLarge
case invalidEncoding case invalidEncoding
case message(String) case shareEmpty
case cancelled
case coreNotInitialized
case unsupportedOperation
case noWindowAvailable
case viewControllerUnavailable
case filesystemUnavailable
case invalidInvitationURL
case nfcUnavailable
case nfcFailed
case cameraUnavailable
case qrUnavailable
case bugReportingUnavailable
case selectionFailed
case deleteRecordsFailed
case raw(String)
/// Developer/log-facing only never surfaced to users. Derived from the case
/// so there are no hand-written English blobs; `.raw` passes its payload through.
var errorDescription: String? { var errorDescription: String? {
switch self { if case .raw(let reason) = self { return reason }
case .empty: return "The invitation is empty" return String(describing: self)
case .tooLarge: return "The invitation is too large"
case .invalidEncoding: return "The invitation is not valid text"
case .message(let m): return m
}
} }
} }

View File

@@ -12,6 +12,10 @@ struct PickedShareFile: Equatable, Identifiable, Sendable {
var isTemporaryCopy: Bool = false var isTemporaryCopy: Bool = false
/// When true, `value` is a directory (path or security-scoped folder URL). /// When true, `value` is a directory (path or security-scoped folder URL).
var isDirectory: Bool = false var isDirectory: Bool = false
/// macOS sandbox: a security-scoped bookmark captured at pick time so access to
/// `value` can be re-acquired when the core imports the file (the picker's own
/// scope ends immediately). Nil on iOS (which copies into the container instead).
var securityScopeBookmark: Data? = nil
var id: String { value } var id: String { value }
} }
@@ -48,7 +52,7 @@ extension FileSystemService {
func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false } func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false }
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error> { func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error> {
.failure(InvitationError.message("Revealing the receive folder is not supported")) .failure(InvitationError.unsupportedOperation)
} }
func discardPickedFiles(_ files: [PickedShareFile]) async {} func discardPickedFiles(_ files: [PickedShareFile]) async {}

View File

@@ -5,13 +5,17 @@ import SFSafeSymbols
/// be swiped away. The endpoint id is the trusted identity; display names are /// be swiped away. The endpoint id is the trusted identity; display names are
/// peer-provided. /// peer-provided.
struct ApprovalModalHost: View { struct ApprovalModalHost: View {
/// Driven by the host so presentation can be deferred until any competing sheet
/// (the Share/QR drawer) has finished dismissing macOS silently drops a sheet
/// presented while another is still animating out.
@Binding var isPresented: Bool
let state: ApprovalState let state: ApprovalState
let onAccept: (String) -> Void let onAccept: (String) -> Void
let onRefuse: (String) -> Void let onRefuse: (String) -> Void
var body: some View { var body: some View {
Color.clear Color.clear
.sheet(isPresented: .constant(state.current != nil)) { .sheet(isPresented: $isPresented) {
if let request = state.current { if let request = state.current {
ApprovalSheet(state: state, request: request, onAccept: onAccept, onRefuse: onRefuse) ApprovalSheet(state: state, request: request, onAccept: onAccept, onRefuse: onRefuse)
.interactiveDismissDisabled(true) .interactiveDismissDisabled(true)

View File

@@ -154,7 +154,7 @@ final class SendModel: ObservableObject {
} }
func onFilePickFailed(_ reason: String) { func onFilePickFailed(_ reason: String) {
messages.error(InvitationError.message(reason.isEmpty ? "selection failed" : reason)) messages.error(reason.isEmpty ? InvitationError.selectionFailed : InvitationError.raw(reason))
} }
func clearSelectedSource() { func clearSelectedSource() {

View File

@@ -20,7 +20,7 @@ protocol BugReportService {
/// Offline-safe no-op used until the diagnostics transport is configured. /// Offline-safe no-op used until the diagnostics transport is configured.
struct NoopBugReportService: BugReportService { struct NoopBugReportService: BugReportService {
func submit(_ draft: BugReportDraft, deviceInfo: DeviceInfo?) async -> Result<Void, Error> { func submit(_ draft: BugReportDraft, deviceInfo: DeviceInfo?) async -> Result<Void, Error> {
.failure(InvitationError.message("Bug reporting is not configured")) .failure(InvitationError.bugReportingUnavailable)
} }
func previewLogBytes() async -> Int { 0 } func previewLogBytes() async -> Int { 0 }
} }

View File

@@ -206,7 +206,7 @@ final class SettingsModel: ObservableObject {
} }
func onReceiveFolderPicked(_ folder: ReceiveFolder) { preferences.setReceiveFolder(folder) } func onReceiveFolderPicked(_ folder: ReceiveFolder) { preferences.setReceiveFolder(folder) }
func onReceiveFolderPickFailed(_ reason: String) { messages.error(InvitationError.message(reason)) } func onReceiveFolderPickFailed(_ reason: String) { messages.error(InvitationError.raw(reason)) }
func resetReceiveFolder() { preferences.resetReceiveFolder() } func resetReceiveFolder() { preferences.resetReceiveFolder() }
/// Whether the current receive folder is the platform default (so the reset /// Whether the current receive folder is the platform default (so the reset
@@ -475,7 +475,7 @@ final class SettingsModel: ObservableObject {
loadStorageUsage() loadStorageUsage()
messages.show(UiMessage(text: .resource(L10n.Storage.transfersDeleted), tone: .success)) messages.show(UiMessage(text: .resource(L10n.Storage.transfersDeleted), tone: .success))
} else { } else {
messages.error(InvitationError.message("Could not delete \(failures) transfer records")) messages.error(InvitationError.deleteRecordsFailed)
} }
} }
} }

View File

@@ -24,6 +24,21 @@ struct SettingsScreen: View {
var body: some View { var body: some View {
NavigationStack(path: path) { NavigationStack(path: path) {
Form { Form {
#if os(iOS)
// iOS suspends the app in the background, so serving/receiving
// can't run indefinitely there (unlike macOS). Tell users up
// front so the platform limit doesn't read as a bug.
Section {
VStack(alignment: .leading, spacing: 6) {
Label(String(localized: L10n.Settings.iosBackgroundNoticeTitle), systemSymbol: .moonZzz)
.font(.subheadline.weight(.semibold))
Text(String(localized: L10n.Settings.iosBackgroundNoticeBody))
.font(.footnote)
.foregroundStyle(.secondary)
}
.padding(.vertical, 2)
}
#endif
Section { Section {
NavigationLink(value: SettingsSection.preferences) { NavigationLink(value: SettingsSection.preferences) {
SettingsRow(icon: .personCropCircle, title: String(localized: L10n.Preferences.title), value: model.state.username) SettingsRow(icon: .personCropCircle, title: String(localized: L10n.Preferences.title), value: model.state.username)

View File

@@ -6,7 +6,7 @@ import UIKit
@MainActor @MainActor
func makeAppDependencies(externalInvitations: ExternalInvitationController) -> AppDependencies { func makeAppDependencies(externalInvitations: ExternalInvitationController) -> AppDependencies {
let device = UIDevice.current let device = UIDevice.current
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.1.0" let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
let env = PlatformEnvironment( let env = PlatformEnvironment(
name: "\(device.systemName) \(device.systemVersion)", name: "\(device.systemName) \(device.systemVersion)",
appVersion: version, appVersion: version,

View File

@@ -5,7 +5,7 @@ import AppKit
/// Builds the macOS dependency graph, mirroring `rememberIosAppDependencies`. /// Builds the macOS dependency graph, mirroring `rememberIosAppDependencies`.
@MainActor @MainActor
func makeAppDependencies(externalInvitations: ExternalInvitationController) -> AppDependencies { func makeAppDependencies(externalInvitations: ExternalInvitationController) -> AppDependencies {
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.1.0" let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
let host = Host.current().localizedName ?? "Mac" let host = Host.current().localizedName ?? "Mac"
let env = PlatformEnvironment( let env = PlatformEnvironment(
name: "macOS " + ProcessInfo.processInfo.operatingSystemVersionString, name: "macOS " + ProcessInfo.processInfo.operatingSystemVersionString,

View File

@@ -32,10 +32,10 @@ struct IosFileSystemService: FileSystemService {
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error> { func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error> {
guard canRevealReceiveFolder(folder) else { guard canRevealReceiveFolder(folder) else {
return .failure(InvitationError.message("The receive folder is not VniDrop Documents")) return .failure(InvitationError.filesystemUnavailable)
} }
guard let url = URL(string: "shareddocuments://\(folder.value)") else { guard let url = URL(string: "shareddocuments://\(folder.value)") else {
return .failure(InvitationError.message("The Files location URL is unavailable")) return .failure(InvitationError.filesystemUnavailable)
} }
let opened = await withCheckedContinuation { continuation in let opened = await withCheckedContinuation { continuation in
DispatchQueue.main.async { DispatchQueue.main.async {
@@ -44,7 +44,7 @@ struct IosFileSystemService: FileSystemService {
} }
} }
} }
return opened ? .success(()) : .failure(InvitationError.message("Could not open VniDrop Documents in Files")) return opened ? .success(()) : .failure(InvitationError.filesystemUnavailable)
} }
func discardPickedFiles(_ files: [PickedShareFile]) async { func discardPickedFiles(_ files: [PickedShareFile]) async {
@@ -62,7 +62,7 @@ struct IosFileSystemService: FileSystemService {
accessPolicy: ShareAccessPolicy accessPolicy: ShareAccessPolicy
) async -> Result<Share, Error> { ) async -> Result<Share, Error> {
guard !files.isEmpty else { guard !files.isEmpty else {
return .failure(InvitationError.message("Select at least one file to share")) return .failure(InvitationError.shareEmpty)
} }
let sources = files.map { $0.toIosShareSource() } let sources = files.map { $0.toIosShareSource() }
return await repository.shareSources( return await repository.shareSources(

View File

@@ -42,8 +42,24 @@ struct MacFileSystemService: FileSystemService {
accessPolicy: ShareAccessPolicy accessPolicy: ShareAccessPolicy
) async -> Result<Share, Error> { ) async -> Result<Share, Error> {
guard !files.isEmpty else { guard !files.isEmpty else {
return .failure(InvitationError.message("Select at least one file to share")) return .failure(InvitationError.shareEmpty)
} }
// Re-acquire security-scoped access to every picked source (from the bookmark
// captured at pick time) and hold it across the whole share call. The core
// imports the bytes during shareFiles(), so access only needs to survive that
// call; without this, the import fails with EPERM under the App Store sandbox.
var scopedURLs: [URL] = []
for file in files {
guard let bookmark = file.securityScopeBookmark else { continue }
var stale = false
guard let url = try? URL(
resolvingBookmarkData: bookmark, options: .withSecurityScope,
relativeTo: nil, bookmarkDataIsStale: &stale
), url.startAccessingSecurityScopedResource() else { continue }
scopedURLs.append(url)
}
defer { scopedURLs.forEach { $0.stopAccessingSecurityScopedResource() } }
let sources = files.map { let sources = files.map {
ShareSource(kind: .path, value: $0.value, displayName: $0.displayName, isDirectory: $0.isDirectory) ShareSource(kind: .path, value: $0.value, displayName: $0.displayName, isDirectory: $0.isDirectory)
} }

View File

@@ -107,9 +107,15 @@ enum PickerSupport {
) )
#else #else
let size = isDirectory ? nil : (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize.map { UInt64($0) } let size = isDirectory ? nil : (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize.map { UInt64($0) }
// Capture a security-scoped bookmark while the picker's scope is still held,
// so the core can re-acquire access to open the file at import time (under
// the App Store sandbox). Non-sandboxed builds don't need it but it's harmless.
let bookmark = try? url.bookmarkData(
options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil
)
return PickedShareFile( return PickedShareFile(
value: url.path, displayName: url.lastPathComponent, sizeBytes: size, value: url.path, displayName: url.lastPathComponent, sizeBytes: size,
isTemporaryCopy: false, isDirectory: isDirectory isTemporaryCopy: false, isDirectory: isDirectory, securityScopeBookmark: bookmark
) )
#endif #endif
} }

View File

@@ -29,7 +29,7 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID
picker.delegate = self picker.delegate = self
picker.modalPresentationStyle = .formSheet picker.modalPresentationStyle = .formSheet
guard let presenter = topPresenter() else { guard let presenter = topPresenter() else {
return onResult(.failure(InvitationError.message("Could not find an iOS view controller"))) return onResult(.failure(InvitationError.viewControllerUnavailable))
} }
presenter.present(picker, animated: true) presenter.present(picker, animated: true)
} }
@@ -37,12 +37,12 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID
func scanQrCode(onResult: @escaping (Result<String, Error>) -> Void) { func scanQrCode(onResult: @escaping (Result<String, Error>) -> Void) {
cancel() cancel()
guard let presenter = topPresenter() else { guard let presenter = topPresenter() else {
return onResult(.failure(InvitationError.message("Could not find an iOS view controller"))) return onResult(.failure(InvitationError.viewControllerUnavailable))
} }
ensureCameraAccess { [weak self] granted in ensureCameraAccess { [weak self] granted in
guard let self else { return } guard let self else { return }
guard granted else { guard granted else {
return onResult(.failure(InvitationError.message("Camera access is required to scan QR codes"))) return onResult(.failure(InvitationError.cameraUnavailable))
} }
let scanner = QrScannerViewController { result in let scanner = QrScannerViewController { result in
self.qrController = nil self.qrController = nil
@@ -57,7 +57,7 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID
func readNfcInvitation(onResult: @escaping (Result<String, Error>) -> Void) { func readNfcInvitation(onResult: @escaping (Result<String, Error>) -> Void) {
cancel() cancel()
guard NFCNDEFReaderSession.readingAvailable else { guard NFCNDEFReaderSession.readingAvailable else {
return onResult(.failure(InvitationError.message("NFC reading is unavailable on this device"))) return onResult(.failure(InvitationError.nfcUnavailable))
} }
let reader = InvitationNfcReader { [weak self] result in let reader = InvitationNfcReader { [weak self] result in
self?.nfcReader = nil self?.nfcReader = nil
@@ -80,7 +80,7 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID
let result = documentResult let result = documentResult
documentResult = nil documentResult = nil
result?(Result { result?(Result {
guard let url = urls.first else { throw InvitationError.message("The selected invitation URL was invalid") } guard let url = urls.first else { throw InvitationError.invalidInvitationURL }
let started = url.startAccessingSecurityScopedResource() let started = url.startAccessingSecurityScopedResource()
defer { if started { url.stopAccessingSecurityScopedResource() } } defer { if started { url.stopAccessingSecurityScopedResource() } }
let data = try Data(contentsOf: url) let data = try Data(contentsOf: url)
@@ -157,19 +157,19 @@ final class QrScannerViewController: UIViewController, AVCaptureMetadataOutputOb
} }
func cancelScan() { func cancelScan() {
finish(.failure(InvitationError.message("QR scanning was cancelled"))) finish(.failure(InvitationError.cancelled))
} }
private func configureSession() { private func configureSession() {
guard let device = AVCaptureDevice.default(for: .video), guard let device = AVCaptureDevice.default(for: .video),
let input = try? AVCaptureDeviceInput(device: device), let input = try? AVCaptureDeviceInput(device: device),
session.canAddInput(input) else { session.canAddInput(input) else {
return finish(.failure(InvitationError.message("No camera is available"))) return finish(.failure(InvitationError.cameraUnavailable))
} }
session.addInput(input) session.addInput(input)
let output = AVCaptureMetadataOutput() let output = AVCaptureMetadataOutput()
guard session.canAddOutput(output) else { guard session.canAddOutput(output) else {
return finish(.failure(InvitationError.message("Could not configure the QR scanner"))) return finish(.failure(InvitationError.cameraUnavailable))
} }
session.addOutput(output) session.addOutput(output)
output.setMetadataObjectsDelegate(self, queue: .main) output.setMetadataObjectsDelegate(self, queue: .main)
@@ -220,7 +220,7 @@ final class InvitationNfcReader: NSObject, NFCNDEFReaderSessionDelegate, @unchec
func start() { func start() {
let reader = NFCNDEFReaderSession(delegate: self, queue: .main, invalidateAfterFirstRead: true) let reader = NFCNDEFReaderSession(delegate: self, queue: .main, invalidateAfterFirstRead: true)
reader.alertMessage = "Hold your iPhone near a VniDrop invitation tag" reader.alertMessage = String(localized: L10n.Receive.nfcWaiting)
session = reader session = reader
reader.begin() reader.begin()
} }
@@ -233,7 +233,7 @@ final class InvitationNfcReader: NSObject, NFCNDEFReaderSessionDelegate, @unchec
func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) { func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
if finished { return } if finished { return }
let cancelled = (error as NSError).code == 200 let cancelled = (error as NSError).code == 200
finish(.failure(InvitationError.message(cancelled ? "NFC reading was cancelled" : error.localizedDescription))) finish(.failure(cancelled ? InvitationError.cancelled : InvitationError.raw(error.localizedDescription)))
} }
func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) { func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {
@@ -242,7 +242,7 @@ final class InvitationNfcReader: NSObject, NFCNDEFReaderSessionDelegate, @unchec
.flatMap { $0.records } .flatMap { $0.records }
.compactMap { payloadAsInvitation($0) } .compactMap { payloadAsInvitation($0) }
.first .first
guard let ticket else { throw InvitationError.message("This NFC tag does not contain a VniDrop invitation") } guard let ticket else { throw InvitationError.nfcFailed }
return ticket return ticket
} }
session.invalidate() session.invalidate()

View File

@@ -22,7 +22,7 @@ final class MacReceiveInvitationActions: ReceiveInvitationActions {
} }
panel.begin { response in panel.begin { response in
guard response == .OK, let url = panel.url else { guard response == .OK, let url = panel.url else {
onResult(.failure(InvitationError.message("cancelled"))) onResult(.failure(InvitationError.cancelled))
return return
} }
onResult(Result { onResult(Result {
@@ -33,11 +33,11 @@ final class MacReceiveInvitationActions: ReceiveInvitationActions {
} }
func scanQrCode(onResult: @escaping (Result<String, Error>) -> Void) { func scanQrCode(onResult: @escaping (Result<String, Error>) -> Void) {
onResult(.failure(InvitationError.message("QR scanning is unavailable on macOS"))) onResult(.failure(InvitationError.qrUnavailable))
} }
func readNfcInvitation(onResult: @escaping (Result<String, Error>) -> Void) { func readNfcInvitation(onResult: @escaping (Result<String, Error>) -> Void) {
onResult(.failure(InvitationError.message("NFC is unavailable on macOS"))) onResult(.failure(InvitationError.nfcUnavailable))
} }
func cancel() {} func cancel() {}

View File

@@ -0,0 +1,49 @@
#if DIRECT_DISTRIBUTION && os(macOS)
import Combine
import Sparkle
import SwiftUI
/// Owns the Sparkle updater for the direct-download (.dmg) build.
///
/// Compiled only under `DIRECT_DISTRIBUTION`, so the App Store / TestFlight target
/// (which must not ship a self-updater) never compiles or links Sparkle. The feed
/// URL and public EdDSA key are read from Info.plist (`SUFeedURL`, `SUPublicEDKey`).
@MainActor
final class SparkleUpdaterController: ObservableObject {
private let updaterController: SPUStandardUpdaterController
/// Mirrors `SPUUpdater.canCheckForUpdates` so the menu item can disable itself
/// while a check is already in flight.
@Published private(set) var canCheckForUpdates = false
init() {
// `startingUpdater: true` begins the automatic background check schedule.
updaterController = SPUStandardUpdaterController(
startingUpdater: true,
updaterDelegate: nil,
userDriverDelegate: nil
)
updaterController.updater
.publisher(for: \.canCheckForUpdates)
.assign(to: &$canCheckForUpdates)
}
func checkForUpdates() {
updaterController.checkForUpdates(nil)
}
}
/// Adds a "Check for Updates" item to the application menu (right after the
/// standard "About VniDrop" item), matching the macOS convention.
struct UpdatesCommands: Commands {
@ObservedObject var controller: SparkleUpdaterController
var body: some Commands {
CommandGroup(after: .appInfo) {
Button(String(localized: L10n.Updates.check)) {
controller.checkForUpdates()
}
.disabled(!controller.canCheckForUpdates)
}
}
}
#endif

View File

@@ -36,7 +36,7 @@ final class IosTransferShareActions: NSObject, TransferShareActions {
func writeInvitationToNfc(ticket: String, onResult: @escaping (Result<Void, Error>) -> Void) { func writeInvitationToNfc(ticket: String, onResult: @escaping (Result<Void, Error>) -> Void) {
cancelNfcWrite() cancelNfcWrite()
guard NFCNDEFReaderSession.readingAvailable else { guard NFCNDEFReaderSession.readingAvailable else {
onResult(.failure(InvitationError.message("NFC is unavailable on this device"))) onResult(.failure(InvitationError.nfcUnavailable))
return return
} }
let writer = InvitationNfcWriter(ticket: ticket) { [weak self] result in let writer = InvitationNfcWriter(ticket: ticket) { [weak self] result in
@@ -55,7 +55,7 @@ final class IosTransferShareActions: NSObject, TransferShareActions {
@MainActor @MainActor
private func present(_ controller: UIViewController) throws { private func present(_ controller: UIViewController) throws {
guard let presenter = topPresenter() else { guard let presenter = topPresenter() else {
throw InvitationError.message("Could not find an iOS view controller") throw InvitationError.viewControllerUnavailable
} }
presenter.present(controller, animated: true) presenter.present(controller, animated: true)
} }
@@ -76,7 +76,7 @@ final class InvitationNfcWriter: NSObject, NFCNDEFReaderSessionDelegate, @unchec
func start() { func start() {
let reader = NFCNDEFReaderSession(delegate: self, queue: .main, invalidateAfterFirstRead: false) let reader = NFCNDEFReaderSession(delegate: self, queue: .main, invalidateAfterFirstRead: false)
reader.alertMessage = "Hold your iPhone near a writable NFC tag" reader.alertMessage = String(localized: L10n.Transfer.nfcWaiting)
session = reader session = reader
reader.begin() reader.begin()
} }
@@ -89,14 +89,14 @@ final class InvitationNfcWriter: NSObject, NFCNDEFReaderSessionDelegate, @unchec
func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) { func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
if finished { return } if finished { return }
let cancelled = (error as NSError).code == 200 // readerSessionInvalidationErrorUserCanceled let cancelled = (error as NSError).code == 200 // readerSessionInvalidationErrorUserCanceled
finish(.failure(InvitationError.message(cancelled ? "NFC writing was cancelled" : error.localizedDescription))) finish(.failure(cancelled ? InvitationError.cancelled : InvitationError.raw(error.localizedDescription)))
} }
func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {} func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {}
func readerSession(_ session: NFCNDEFReaderSession, didDetect tags: [NFCNDEFTag]) { func readerSession(_ session: NFCNDEFReaderSession, didDetect tags: [NFCNDEFTag]) {
guard let firstTag = tags.first else { guard let firstTag = tags.first else {
return finish(.failure(InvitationError.message("No NFC tag was detected"))) return finish(.failure(InvitationError.nfcFailed))
} }
// CoreNFC completion handlers run on the session's `.main` queue; these // CoreNFC completion handlers run on the session's `.main` queue; these
// framework values are safe to use there. // framework values are safe to use there.
@@ -109,18 +109,18 @@ final class InvitationNfcWriter: NSObject, NFCNDEFReaderSessionDelegate, @unchec
if let queryError { return self.finish(.failure(queryError)) } if let queryError { return self.finish(.failure(queryError)) }
switch status { switch status {
case .notSupported: case .notSupported:
self.finish(.failure(InvitationError.message("This NFC tag does not support NDEF"))) self.finish(.failure(InvitationError.nfcFailed))
case .readOnly: case .readOnly:
self.finish(.failure(InvitationError.message("This NFC tag is read-only"))) self.finish(.failure(InvitationError.nfcFailed))
default: default:
guard let message = self.invitationMessage() else { guard let message = self.invitationMessage() else {
return self.finish(.failure(InvitationError.message("Could not encode the invitation for NFC"))) return self.finish(.failure(InvitationError.nfcFailed))
} }
tag.writeNDEF(message) { writeError in tag.writeNDEF(message) { writeError in
if let writeError { if let writeError {
self.finish(.failure(writeError)) self.finish(.failure(writeError))
} else { } else {
session.alertMessage = "Invitation written" session.alertMessage = String(localized: L10n.Transfer.nfcWritten)
session.invalidate() session.invalidate()
self.finish(.success(())) self.finish(.success(()))
} }

View File

@@ -17,7 +17,7 @@ final class MacTransferShareActions: TransferShareActions {
panel.allowedContentTypes = [] panel.allowedContentTypes = []
panel.begin { response in panel.begin { response in
guard response == .OK, let url = panel.url else { guard response == .OK, let url = panel.url else {
onResult(.failure(InvitationError.message("cancelled"))) onResult(.failure(InvitationError.cancelled))
return return
} }
onResult(Result { try ticket.write(to: url, atomically: true, encoding: .utf8) }) onResult(Result { try ticket.write(to: url, atomically: true, encoding: .utf8) })
@@ -28,7 +28,7 @@ final class MacTransferShareActions: TransferShareActions {
do { do {
let url = try writeTemporaryInvitation(ticket: ticket, transferName: transferName) let url = try writeTemporaryInvitation(ticket: ticket, transferName: transferName)
guard let view = NSApp.keyWindow?.contentView else { guard let view = NSApp.keyWindow?.contentView else {
onResult(.failure(InvitationError.message("No window available"))) onResult(.failure(InvitationError.noWindowAvailable))
return return
} }
let picker = NSSharingServicePicker(items: [url]) let picker = NSSharingServicePicker(items: [url])
@@ -40,7 +40,7 @@ final class MacTransferShareActions: TransferShareActions {
} }
func writeInvitationToNfc(ticket: String, onResult: @escaping (Result<Void, Error>) -> Void) { func writeInvitationToNfc(ticket: String, onResult: @escaping (Result<Void, Error>) -> Void) {
onResult(.failure(InvitationError.message("NFC is unavailable on macOS"))) onResult(.failure(InvitationError.nfcUnavailable))
} }
func cancelNfcWrite() {} func cancelNfcWrite() {}

View File

@@ -1,65 +1,62 @@
{ {
"features" : [ "fill": {
"refractivity" "linear-gradient": [
],
"fill" : {
"linear-gradient" : [
"extended-gray:1.00000,1.00000", "extended-gray:1.00000,1.00000",
"display-p3:0.55433,0.59923,0.92884,1.00000" "display-p3:0.55433,0.59923,0.92884,1.00000"
] ]
}, },
"groups" : [ "groups": [
{ {
"blend-mode" : "normal", "blend-mode": "normal",
"blur-material" : null, "blur-material": null,
"layers" : [ "layers": [
{ {
"image-name" : "Mask.svg", "image-name": "Mask.svg",
"name" : "Mask" "name": "Mask"
} }
], ],
"lighting" : "individual", "lighting": "individual",
"refractivity" : { "refractivity": {
"depth" : 0.5, "depth": 0.5,
"enabled" : true, "enabled": true,
"strength" : 0 "strength": 0
}, },
"shadow" : { "shadow": {
"kind" : "neutral", "kind": "neutral",
"opacity" : 0.6 "opacity": 0.6
}, },
"specular" : true, "specular": true,
"translucency" : { "translucency": {
"enabled" : true, "enabled": true,
"value" : 0.8 "value": 0.8
} }
}, },
{ {
"layers" : [ "layers": [
{ {
"image-name" : "Drop.svg", "image-name": "Drop.svg",
"name" : "Drop" "name": "Drop"
}, },
{ {
"image-name" : "U.svg", "image-name": "U.svg",
"name" : "U" "name": "U"
} }
], ],
"lighting" : "combined", "lighting": "combined",
"shadow" : { "shadow": {
"kind" : "neutral", "kind": "neutral",
"opacity" : 0.6 "opacity": 0.6
}, },
"translucency" : { "translucency": {
"enabled" : true, "enabled": true,
"value" : 0.4 "value": 0.4
} }
} }
], ],
"supported-platforms" : { "supported-platforms": {
"circles" : [ "circles": [
"watchOS" "watchOS"
], ],
"squares" : "shared" "squares": "shared"
} }
} }

View File

@@ -57,6 +57,20 @@
<string>$(CURRENT_PROJECT_VERSION)</string> <string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationCategoryType</key> <key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string> <string>public.app-category.utilities</string>
<!-- Sparkle auto-update (direct-download .dmg build only). These keys are inert
in the App Store build, which never loads Sparkle (DIRECT_DISTRIBUTION off).
The feed is appcast.xml attached as an asset to each GitHub Release; the
/releases/latest/download/ path always redirects to the newest (non-
prerelease) release's copy, and its <enclosure> points at that same release's
.dmg — no GitHub Pages or repo commits needed. SUPublicEDKey must be the EdDSA
public key printed by Sparkle's `generate_keys` — replace the placeholder
before shipping (see apple/RELEASE-MACOS.md). -->
<key>SUFeedURL</key>
<string>https://github.com/sudosylabs/vnidrop/releases/latest/download/appcast.xml</string>
<key>SUPublicEDKey</key>
<string>/vcOgyrhPi3e58yL8M7hZvDCOgsAKyBsQu/7ChAUk1M=</string>
<key>SUEnableAutomaticChecks</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key> <key>LSSupportsOpeningDocumentsInPlace</key>
<true/> <true/>
<key>NFCReaderUsageDescription</key> <key>NFCReaderUsageDescription</key>

View File

@@ -2,10 +2,12 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<!-- iOS: NFC NDEF reading/writing (NFCNDEFReaderSession requires NDEF). --> <!-- iOS: NFC reader formats. The iOS 26 SDK requires TAG here and rejects
NDEF at App Store upload (error 90778 "NDEF is disallowed"). Our
NFCNDEFReaderSession usage keeps working under the TAG entitlement. -->
<key>com.apple.developer.nfc.readersession.formats</key> <key>com.apple.developer.nfc.readersession.formats</key>
<array> <array>
<string>NDEF</string> <string>TAG</string>
</array> </array>
<!-- macOS App Sandbox: user-selected files for share/receive, and network <!-- macOS App Sandbox: user-selected files for share/receive, and network

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<!-- Entitlements for the direct-download (Developer ID + notarized) macOS build.
Deliberately NOT sandboxed: a sandboxed app signed for Developer ID requires a
provisioning profile, whereas direct-distribution apps run outside the App
Store sandbox by convention. Gatekeeper trust here comes from the hardened
runtime (ENABLE_HARDENED_RUNTIME) plus notarization, not the sandbox. The App
Store target (VniDrop) keeps VniDrop.entitlements with the sandbox enabled.
Networking and user file access need no entitlements once unsandboxed. -->
<dict>
</dict>
</plist>

View File

@@ -5,6 +5,9 @@ import VnidropCore
/// `ui/feedback/UserFacingError.kt`. Never exposes raw `reason=` blobs. /// `ui/feedback/UserFacingError.kt`. Never exposes raw `reason=` blobs.
extension Error { extension Error {
func toUiText() -> UiText { func toUiText() -> UiText {
if let invitation = self as? InvitationError {
return invitation.uiText
}
if let vni = self as? VnidropError { if let vni = self as? VnidropError {
switch vni { switch vni {
case .Ticket: case .Ticket:
@@ -40,6 +43,7 @@ extension Error {
/// True when the user intentionally backed out of a flow. /// True when the user intentionally backed out of a flow.
var isUserCancellation: Bool { var isUserCancellation: Bool {
if let invitation = self as? InvitationError, case .cancelled = invitation { return true }
if let vni = self as? VnidropError, case .Cancelled = vni { return true } if let vni = self as? VnidropError, case .Cancelled = vni { return true }
let haystack = technicalDetail.lowercased() let haystack = technicalDetail.lowercased()
if haystack.isEmpty { if haystack.isEmpty {
@@ -76,6 +80,39 @@ extension Error {
} }
} }
/// Maps each semantic `InvitationError` case to a localized user-facing message.
/// This is the sole `InvitationError` `L10n` boundary: no substring guessing,
/// except for `.raw`, whose dynamic payload still falls through `reasonHints`.
extension InvitationError {
var uiText: UiText {
switch self {
case .empty:
return .resource(L10n.Error.invitationEmpty)
case .tooLarge, .unsupportedOperation, .noWindowAvailable,
.viewControllerUnavailable, .qrUnavailable, .bugReportingUnavailable, .cancelled:
return .resource(L10n.Error.generic)
case .invalidEncoding, .invalidInvitationURL:
return .resource(L10n.Error.invalidTicket)
case .shareEmpty:
return .resource(L10n.Error.shareEmpty)
case .coreNotInitialized:
return .resource(L10n.Error.startingUp)
case .filesystemUnavailable:
return .resource(L10n.Error.filesystem)
case .nfcUnavailable, .nfcFailed:
return .resource(L10n.Error.nfc)
case .cameraUnavailable:
return .resource(L10n.Error.camera)
case .selectionFailed:
return .resource(L10n.Error.selectionFailed)
case .deleteRecordsFailed:
return .resource(L10n.Error.repository)
case .raw(let reason):
return reasonHints(reason) ?? .resource(L10n.Error.generic)
}
}
}
/// Maps a receiver delivery/refusal reason code to a user-facing message, never /// Maps a receiver delivery/refusal reason code to a user-facing message, never
/// surfacing the raw core code (e.g. `destination_exists`). Unknown codes fall back /// surfacing the raw core code (e.g. `destination_exists`). Unknown codes fall back
/// to the substring hints, then a generic message. /// to the substring hints, then a generic message.

View File

@@ -1,9 +1,11 @@
# XcodeGen spec for the native SwiftUI VniDrop app (iOS/iPadOS/macOS). # XcodeGen spec for the native SwiftUI VniDrop app (iOS/iPadOS/macOS).
# Regenerate the project with: xcodegen generate (run from apple/) # Regenerate the project with: xcodegen generate (run from apple/)
# Requires two generated inputs first (both gitignored), before xcodegen: # Requires three generated inputs first (all gitignored), before xcodegen:
# - Rust core: apple/scripts/build-core.sh debug # - Rust core: apple/scripts/build-core.sh debug
# - Localization: (cd localization && bun run src/cli.ts generate) # - Localization: (cd localization && bun run src/cli.ts generate)
# -> VniDrop/Resources/Localizable.xcstrings, VniDrop/Generated/L10n.swift # -> VniDrop/Resources/Localizable.xcstrings, VniDrop/Generated/L10n.swift
# - Versions: packaging/version/generate-apple-xcconfig.sh all
# -> Generated/StoreVersion.xcconfig, Generated/DirectVersion.xcconfig
name: VniDrop name: VniDrop
options: options:
bundleIdPrefix: com.vnidrop bundleIdPrefix: com.vnidrop
@@ -12,6 +14,15 @@ options:
macOS: "15.0" macOS: "15.0"
createIntermediateGroups: true createIntermediateGroups: true
# Build configurations. Declaring `configs` replaces XcodeGen's Debug/Release
# defaults, so both are re-listed here. `Release-Direct` is a release-type config
# used only by the VniDropDirect (notarized DMG + Sparkle) target; the App Store
# `VniDrop` target ships under plain `Release`.
configs:
Debug: debug
Release: release
Release-Direct: release
# Project-wide build settings (applied to every target/config). # Project-wide build settings (applied to every target/config).
settings: settings:
base: base:
@@ -26,27 +37,36 @@ packages:
SFSafeSymbols: SFSafeSymbols:
url: https://github.com/SFSafeSymbols/SFSafeSymbols url: https://github.com/SFSafeSymbols/SFSafeSymbols
from: "5.3.0" from: "5.3.0"
# Sparkle powers in-app auto-updates for the direct-download (.dmg) build only.
# It is linked exclusively by the VniDropDirect target — SwiftPM links products
# per target, not per config, so keeping it off the App Store target is what
# guarantees the store binary never bundles a self-updater (App Store forbids it).
Sparkle:
url: https://github.com/sparkle-project/Sparkle
from: "2.9.4"
targets: # Shared definition for the two shipping app targets. `VniDrop` (App Store /
VniDrop: # TestFlight) and `VniDropDirect` (notarized DMG + Sparkle) build the exact same
# sources; only their destinations, extra dependencies, and the DIRECT_DISTRIBUTION
# compile flag differ (set per target below).
targetTemplates:
AppBase:
type: application type: application
supportedDestinations: [iOS, macOS]
configFiles:
Debug: Signing.xcconfig
Release: Signing.xcconfig
sources: sources:
- path: VniDrop - path: VniDrop
excludes: excludes:
- "Resources/Info.plist" - "Resources/Info.plist"
- "Resources/VniDrop.entitlements" - "Resources/VniDrop.entitlements"
- "Resources/VniDropDirect.entitlements"
- "Resources/**/.DS_Store" - "Resources/**/.DS_Store"
settings: settings:
base: base:
PRODUCT_NAME: VniDrop
PRODUCT_BUNDLE_IDENTIFIER: com.vnidrop.app PRODUCT_BUNDLE_IDENTIFIER: com.vnidrop.app
MARKETING_VERSION: "0.1.0" MARKETING_VERSION: "$(PRODUCT_VERSION)"
CURRENT_PROJECT_VERSION: "3"
GENERATE_INFOPLIST_FILE: NO GENERATE_INFOPLIST_FILE: NO
INFOPLIST_FILE: VniDrop/Resources/Info.plist INFOPLIST_FILE: VniDrop/Resources/Info.plist
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements
# Mirror the Info.plist identity so Xcode's Identity editor shows it too # Mirror the Info.plist identity so Xcode's Identity editor shows it too
# (the editor reads these build settings, not the manual plist). # (the editor reads these build settings, not the manual plist).
INFOPLIST_KEY_CFBundleDisplayName: VniDrop INFOPLIST_KEY_CFBundleDisplayName: VniDrop
@@ -59,12 +79,11 @@ targets:
# AccentColor asset mirrors VniDropColors.brandPurple — keep them in sync. # AccentColor asset mirrors VniDropColors.brandPurple — keep them in sync.
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
configs: configs:
debug:
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements
release: release:
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements
# Produce a dSYM in the archive so symbol upload succeeds. # Produce a dSYM in the archive so symbol upload succeeds.
DEBUG_INFORMATION_FORMAT: dwarf-with-dsym DEBUG_INFORMATION_FORMAT: dwarf-with-dsym
release-direct:
DEBUG_INFORMATION_FORMAT: dwarf-with-dsym
dependencies: dependencies:
- package: VnidropCore - package: VnidropCore
- package: SFSafeSymbols - package: SFSafeSymbols
@@ -87,12 +106,50 @@ targets:
exit 1 exit 1
fi fi
targets:
# App Store / TestFlight target. iOS + macOS, sandboxed, no self-updater.
VniDrop:
templates: [AppBase]
supportedDestinations: [iOS, macOS]
configFiles:
Debug: Generated/StoreVersion.xcconfig
Release: Generated/StoreVersion.xcconfig
Release-Direct: Generated/StoreVersion.xcconfig
# Direct-download macOS target: Developer ID signed, notarized, ships in a .dmg
# and self-updates via Sparkle. DIRECT_DISTRIBUTION gates all Sparkle code so the
# App Store target above never compiles or links it.
VniDropDirect:
templates: [AppBase]
supportedDestinations: [macOS]
configFiles:
Debug: Generated/DirectVersion.xcconfig
Release: Generated/DirectVersion.xcconfig
Release-Direct: Generated/DirectVersion.xcconfig
settings:
base:
SWIFT_ACTIVE_COMPILATION_CONDITIONS: "$(inherited) DIRECT_DISTRIBUTION"
# Notarization requires the hardened runtime.
ENABLE_HARDENED_RUNTIME: YES
# Non-sandboxed entitlements: a sandboxed Developer ID app needs a
# provisioning profile, which direct distribution avoids. (App Store target
# keeps VniDrop.entitlements with the sandbox.)
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDropDirect.entitlements
# The Rust core's macOS slice (vnidrop.xcframework) is arm64-only
# (build-core.sh builds aarch64-apple-darwin only), so the direct build is
# Apple-Silicon-only. Pin ARCHS so the Release-Direct (universal-by-default)
# link doesn't fail looking for x86_64 symbols.
ARCHS: arm64
dependencies:
- package: Sparkle
VniDropTests: VniDropTests:
type: bundle.unit-test type: bundle.unit-test
supportedDestinations: [iOS, macOS] supportedDestinations: [iOS, macOS]
configFiles: configFiles:
Debug: Signing.xcconfig Debug: Signing.xcconfig
Release: Signing.xcconfig Release: Signing.xcconfig
Release-Direct: Signing.xcconfig
sources: sources:
- path: Tests - path: Tests
settings: settings:
@@ -114,3 +171,21 @@ schemes:
config: Debug config: Debug
targets: targets:
- VniDropTests - VniDropTests
# TestFlight ships the Release build; make the Archive/Profile actions explicit
# so Product → Archive can never pick up a Debug configuration.
profile:
config: Release
archive:
config: Release
# Direct-download build: always the Release-Direct config (Sparkle + notarization).
VniDropDirect:
build:
targets:
VniDropDirect: all
run:
config: Release-Direct
profile:
config: Release-Direct
archive:
config: Release-Direct

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<!-- Export options for the direct-download (Developer ID) macOS build consumed by
`xcodebuild -exportArchive` in build-dmg.sh. This produces a Developer
IDsigned, hardened-runtime .app suitable for notarization and distribution
outside the Mac App Store. The App Store build uses a different flow entirely. -->
<dict>
<key>method</key>
<string>developer-id</string>
<key>signingStyle</key>
<string>manual</string>
<!-- Xcode manages the Developer ID Application certificate lookup from the
keychain; the hardened runtime is enabled via ENABLE_HARDENED_RUNTIME in the
VniDropDirect target. -->
<key>teamID</key>
<string>${DEVELOPMENT_TEAM}</string>
</dict>
</plist>

173
apple/scripts/build-dmg.sh Executable file
View File

@@ -0,0 +1,173 @@
#!/usr/bin/env bash
#
# Builds the direct-download macOS artifact: a Developer IDsigned, notarized
# .dmg of the VniDropDirect target (the Sparkle-enabled build). Produces:
# - apple/dist/VniDrop-<version>.dmg (signed + stapled when notarizing)
#
# This is the direct-distribution counterpart to the App Store archive flow; it
# never touches the App Store `VniDrop` target. The Rust crate is not modified.
#
# Usage: apple/scripts/build-dmg.sh
#
# Environment:
# DEVELOPER_ID_APP Codesign identity, e.g. "Developer ID Application: … (TEAMID)".
# Auto-detected from the keychain when unset.
# DEVELOPMENT_TEAM Apple team ID (10 chars). Auto-derived from the identity.
# NOTARY_PROFILE Name of a `xcrun notarytool store-credentials` keychain
# profile. When set, the DMG is notarized and stapled; when
# unset the build still produces a signed DMG and prints the
# pending notarization step (useful before creds exist).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
APPLE_DIR="$REPO_ROOT/apple"
DIST_DIR="$APPLE_DIR/dist"
BUILD_DIR="$APPLE_DIR/.build-dmg"
PROJECT="$APPLE_DIR/VniDrop.xcodeproj"
SCHEME="VniDropDirect"
CONFIG="Release-Direct"
APP_NAME="VniDrop"
VERSION_RESOLVER="$REPO_ROOT/packaging/version/resolve-version.sh"
VERSION_CONFIG_GENERATOR="$REPO_ROOT/packaging/version/generate-apple-xcconfig.sh"
VERSION="$("$VERSION_RESOLVER" product)"
export VNIDROP_BUILD_TIME_UTC="${VNIDROP_BUILD_TIME_UTC:-$(date -u +%Y%m%d%H%M%S)}"
BUILD_NUMBER="$("$VERSION_RESOLVER" apple-direct-build)"
"$VERSION_RESOLVER" verify >/dev/null
BUILD_METADATA="$DIST_DIR/$APP_NAME-$VERSION.build-info.json"
# --- Resolve signing identity ------------------------------------------------
if [ -z "${DEVELOPER_ID_APP:-}" ]; then
DEVELOPER_ID_APP="$(security find-identity -v -p codesigning 2>/dev/null \
| sed -nE 's/.*"(Developer ID Application: [^"]+)".*/\1/p' | head -1)"
fi
if [ -z "${DEVELOPER_ID_APP:-}" ]; then
echo "error: no 'Developer ID Application' identity found in the keychain." >&2
echo " Create one in Xcode ▸ Settings ▸ Accounts, or set DEVELOPER_ID_APP." >&2
exit 1
fi
if [ -z "${DEVELOPMENT_TEAM:-}" ]; then
# The team ID is the 10-char code in the trailing parenthesis of the identity.
DEVELOPMENT_TEAM="$(printf '%s' "$DEVELOPER_ID_APP" | sed -nE 's/.*\(([A-Z0-9]{10})\)$/\1/p')"
fi
echo "==> Direct build v$VERSION (CFBundleVersion $BUILD_NUMBER)"
echo " identity: $DEVELOPER_ID_APP"
echo " team: ${DEVELOPMENT_TEAM:-<unknown>}"
# --- Build core + regenerate project ----------------------------------------
# Release core needs LTO disabled (workspace thin-LTO miscompiles proc-macros).
echo "==> Building Rust core (release)"
CARGO_PROFILE_RELEASE_LTO=false "$SCRIPT_DIR/build-core.sh" release
echo "==> Regenerating Xcode project"
"$VERSION_CONFIG_GENERATOR" all
( cd "$APPLE_DIR" && xcodegen generate >/dev/null )
rm -rf "$BUILD_DIR" && mkdir -p "$BUILD_DIR" "$DIST_DIR"
ARCHIVE="$BUILD_DIR/$APP_NAME.xcarchive"
EXPORT_DIR="$BUILD_DIR/export"
# --- Archive + export (Developer ID) ----------------------------------------
echo "==> Archiving $SCHEME ($CONFIG)"
xcodebuild archive \
-project "$PROJECT" \
-scheme "$SCHEME" \
-configuration "$CONFIG" \
-destination 'generic/platform=macOS' \
-archivePath "$ARCHIVE" \
MARKETING_VERSION="$VERSION" \
DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM" \
CODE_SIGN_STYLE=Manual \
CODE_SIGN_IDENTITY="$DEVELOPER_ID_APP" \
| xcbeautify 2>/dev/null || true
[ -d "$ARCHIVE" ] || { echo "error: archive failed" >&2; exit 1; }
echo "==> Exporting Developer ID app"
EXPORT_OPTS="$BUILD_DIR/ExportOptions.plist"
sed "s/\${DEVELOPMENT_TEAM}/$DEVELOPMENT_TEAM/" \
"$SCRIPT_DIR/ExportOptions-DeveloperID.plist" > "$EXPORT_OPTS"
xcodebuild -exportArchive \
-archivePath "$ARCHIVE" \
-exportPath "$EXPORT_DIR" \
-exportOptionsPlist "$EXPORT_OPTS"
APP="$EXPORT_DIR/$APP_NAME.app"
[ -d "$APP" ] || { echo "error: export failed" >&2; exit 1; }
ACTUAL_VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' \
"$APP/Contents/Info.plist")"
ACTUAL_BUILD="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' \
"$APP/Contents/Info.plist")"
[ "$ACTUAL_VERSION" = "$VERSION" ] || {
echo "error: exported app version $ACTUAL_VERSION does not match $VERSION" >&2
exit 1
}
[ "$ACTUAL_BUILD" = "$BUILD_NUMBER" ] || {
echo "error: exported app build $ACTUAL_BUILD does not match $BUILD_NUMBER" >&2
exit 1
}
echo "==> Enforcing hardened-runtime signature"
"$SCRIPT_DIR/sign-exported-app.sh" \
"$APP" \
"$DEVELOPER_ID_APP" \
"$APPLE_DIR/VniDrop/Resources/VniDropDirect.entitlements"
# --- Build the DMG -----------------------------------------------------------
DMG="$DIST_DIR/$APP_NAME-$VERSION.dmg"
rm -f "$DMG"
STAGING="$BUILD_DIR/dmg-staging"
rm -rf "$STAGING" && mkdir -p "$STAGING"
cp -R "$APP" "$STAGING/"
ln -s /Applications "$STAGING/Applications"
echo "==> Building DMG"
if command -v create-dmg >/dev/null 2>&1; then
create-dmg \
--volname "$APP_NAME" \
--app-drop-link 380 205 \
--icon "$APP_NAME.app" 130 205 \
--window-size 540 380 \
--no-internet-enable \
"$DMG" "$STAGING" >/dev/null || {
# create-dmg exits non-zero if it can't set the fancy layout; fall back.
[ -f "$DMG" ] || hdiutil create -volname "$APP_NAME" -srcfolder "$STAGING" \
-ov -format UDZO "$DMG" >/dev/null
}
else
hdiutil create -volname "$APP_NAME" -srcfolder "$STAGING" \
-ov -format UDZO "$DMG" >/dev/null
fi
echo "==> Signing DMG"
codesign --force --sign "$DEVELOPER_ID_APP" --timestamp "$DMG"
# --- Notarize + staple -------------------------------------------------------
if [ -n "${NOTARY_PROFILE:-}" ]; then
echo "==> Notarizing (profile: $NOTARY_PROFILE)"
NOTARY_LOG="$DIST_DIR/$APP_NAME-$VERSION.notary-log.json"
"$SCRIPT_DIR/notarize.sh" "$DMG" "$NOTARY_PROFILE" "$NOTARY_LOG"
echo "==> Stapling"
xcrun stapler staple "$DMG"
xcrun stapler validate "$DMG"
spctl -a -vvv --type install "$DMG" || true
else
echo "==> NOTARY_PROFILE unset — skipping notarization."
echo " The DMG is signed but NOT notarized; Gatekeeper will block it until"
echo " you run 'xcrun notarytool store-credentials' and re-run with NOTARY_PROFILE set."
fi
SIZE="$(stat -f%z "$DMG")"
jq -n \
--arg productVersion "$VERSION" \
--arg directBuildNumber "$BUILD_NUMBER" \
--arg artifact "$(basename "$DMG")" \
'{
productVersion: $productVersion,
directBuildNumber: $directBuildNumber,
distribution: "direct",
artifact: $artifact
}' > "$BUILD_METADATA"
echo "==> Done."
echo " dmg: $DMG"
echo " version: $VERSION"
echo " build: $BUILD_NUMBER"
echo " size: $SIZE bytes"

View File

@@ -0,0 +1,66 @@
#!/usr/bin/env bash
#
# Generates/updates the Sparkle appcast for the direct-download build. Runs
# Sparkle's `generate_appcast` over the DMGs in apple/dist/, writing:
# - apple/dist/appcast.xml
#
# The <enclosure> URLs point at the matching GitHub Release download assets, and
# each item is signed with the project's EdDSA key (from a key file or the
# keychain). The resulting appcast.xml is uploaded as a release asset; the app's
# SUFeedURL (/releases/latest/download/appcast.xml) always resolves to the newest.
#
# Usage: apple/scripts/generate-appcast.sh
#
# Environment:
# DIST_DIR Folder holding the DMG(s). Default: apple/dist
# SPARKLE_BIN Dir containing generate_appcast. Auto-located when unset.
# SPARKLE_ED_KEY_FILE Path to the EdDSA private key file. When unset,
# generate_appcast reads the key from the login keychain.
# RELEASE_REPO owner/repo for enclosure URLs. Default: sudosylabs/vnidrop
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
APPLE_DIR="$REPO_ROOT/apple"
DIST_DIR="${DIST_DIR:-$APPLE_DIR/dist}"
RELEASE_REPO="${RELEASE_REPO:-sudosylabs/vnidrop}"
VERSION_RESOLVER="$REPO_ROOT/packaging/version/resolve-version.sh"
VERSION="$("$VERSION_RESOLVER" product)"
"$VERSION_RESOLVER" verify >/dev/null
# Enclosure URLs resolve to the specific release's assets.
DOWNLOAD_PREFIX="https://github.com/$RELEASE_REPO/releases/download/v$VERSION"
# --- Locate generate_appcast -------------------------------------------------
find_tool() {
local name="$1"
if [ -n "${SPARKLE_BIN:-}" ] && [ -x "$SPARKLE_BIN/$name" ]; then
printf '%s' "$SPARKLE_BIN/$name"; return 0
fi
if command -v "$name" >/dev/null 2>&1; then command -v "$name"; return 0; fi
# Sparkle SPM artifact bundle lands under DerivedData SourcePackages.
local dd="${APPLE_DERIVED_DATA:-$HOME/Library/Developer/Xcode/DerivedData}"
local hit
hit="$(find "$dd" "$HOME/Library/Caches/org.swift.swiftpm" -type f -name "$name" \
-perm -111 2>/dev/null | head -1 || true)"
[ -n "$hit" ] && { printf '%s' "$hit"; return 0; }
return 1
}
GENERATE_APPCAST="$(find_tool generate_appcast || true)"
if [ -z "$GENERATE_APPCAST" ]; then
echo "error: generate_appcast not found. Set SPARKLE_BIN to Sparkle's bin/ dir" >&2
echo " (download from https://github.com/sparkle-project/Sparkle/releases)." >&2
exit 1
fi
echo "==> Using $GENERATE_APPCAST"
# --- Generate ----------------------------------------------------------------
args=( --download-url-prefix "$DOWNLOAD_PREFIX/" -o "$DIST_DIR/appcast.xml" )
if [ -n "${SPARKLE_ED_KEY_FILE:-}" ]; then
args+=( --ed-key-file "$SPARKLE_ED_KEY_FILE" )
fi
echo "==> Generating appcast (v$VERSION) → $DIST_DIR/appcast.xml"
"$GENERATE_APPCAST" "${args[@]}" "$DIST_DIR"
echo "==> Done. Enclosure prefix: $DOWNLOAD_PREFIX/"

67
apple/scripts/notarize.sh Executable file
View File

@@ -0,0 +1,67 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 3 ]]; then
printf 'Usage: %s <artifact> <keychain-profile> <log-output>\n' "$0" >&2
exit 2
fi
artifact=$1
keychain_profile=$2
log_output=$3
[[ -s $artifact ]] || {
printf 'error: notarization artifact is missing or empty: %s\n' "$artifact" >&2
exit 1
}
[[ -n $keychain_profile ]] || {
printf 'error: notarization keychain profile is empty\n' >&2
exit 1
}
[[ -n $log_output ]] || {
printf 'error: notarization log output path is empty\n' >&2
exit 1
}
rm -f "$log_output"
set +e
response="$(
xcrun notarytool submit "$artifact" \
--keychain-profile "$keychain_profile" \
--wait \
--output-format json
)"
submit_exit=$?
set -e
printf '%s\n' "$response"
submission_id="$(
printf '%s\n' "$response" |
jq -r '.id // empty' 2>/dev/null ||
true
)"
status="$(
printf '%s\n' "$response" |
jq -r '.status // empty' 2>/dev/null ||
true
)"
if [[ $submit_exit -eq 0 && $status == Accepted && -n $submission_id ]]; then
printf 'Notarization accepted (submission %s)\n' "$submission_id"
exit 0
fi
printf 'error: notarization was not accepted (status: %s, submission: %s)\n' \
"${status:-unknown}" "${submission_id:-unknown}" >&2
if [[ -n $submission_id ]]; then
mkdir -p "$(dirname "$log_output")"
if xcrun notarytool log "$submission_id" "$log_output" \
--keychain-profile "$keychain_profile"; then
printf '%s\n' 'Apple notarization log:' >&2
cat "$log_output" >&2
else
printf 'error: could not retrieve the Apple notarization log\n' >&2
fi
fi
exit 1

View File

@@ -0,0 +1,42 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 3 ]]; then
printf 'Usage: %s <app-bundle> <signing-identity> <entitlements>\n' "$0" >&2
exit 2
fi
app=$1
signing_identity=$2
entitlements=$3
[[ -d $app ]] || {
printf 'error: exported app bundle does not exist: %s\n' "$app" >&2
exit 1
}
[[ -n $signing_identity ]] || {
printf 'error: signing identity is empty\n' >&2
exit 1
}
[[ -f $entitlements ]] || {
printf 'error: entitlements file does not exist: %s\n' "$entitlements" >&2
exit 1
}
codesign \
--force \
--sign "$signing_identity" \
--options runtime \
--timestamp \
--entitlements "$entitlements" \
"$app"
codesign --verify --deep --strict --verbose=2 "$app"
signature_details="$(codesign --display --verbose=4 "$app" 2>&1)"
printf '%s\n' "$signature_details"
printf '%s\n' "$signature_details" |
grep -Eq 'flags=.*\(runtime([^)]*)?\)' || {
printf 'error: exported app signature does not enable the hardened runtime\n' >&2
exit 1
}

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
notarize="$script_dir/../notarize.sh"
scratch="$(mktemp -d "${TMPDIR:-/tmp}/vnidrop-notarize-test.XXXXXX")"
trap 'rm -rf "$scratch"' EXIT
mkdir -p "$scratch/bin"
artifact="$scratch/VniDrop.dmg"
calls="$scratch/calls.txt"
log_output="$scratch/notary/notary-log.json"
printf 'dmg\n' > "$artifact"
cat > "$scratch/bin/xcrun" <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$*" >> "$FAKE_NOTARY_CALLS"
if [[ $1 == notarytool && $2 == submit ]]; then
case "${FAKE_NOTARY_MODE:-accepted}" in
accepted)
printf '%s\n' \
'{"id":"11111111-1111-1111-1111-111111111111","status":"Accepted"}'
;;
invalid)
printf '%s\n' \
'{"id":"22222222-2222-2222-2222-222222222222","status":"Invalid"}'
;;
transport-error)
printf '%s\n' 'notary service unavailable' >&2
exit 1
;;
esac
elif [[ $1 == notarytool && $2 == log ]]; then
mkdir -p "$(dirname "$4")"
printf '%s\n' \
'{"status":"Invalid","issues":[{"message":"The signature is invalid."}]}' \
> "$4"
else
printf 'unexpected xcrun invocation: %s\n' "$*" >&2
exit 1
fi
SCRIPT
chmod +x "$scratch/bin/xcrun"
PATH="$scratch/bin:$PATH" \
FAKE_NOTARY_CALLS="$calls" \
FAKE_NOTARY_MODE=accepted \
"$notarize" "$artifact" test-profile "$log_output" >/dev/null
[[ ! -e $log_output ]]
[[ $(grep -c '^notarytool submit ' "$calls") -eq 1 ]]
if grep -q '^notarytool log ' "$calls"; then
printf 'Accepted submissions must not request a rejection log\n' >&2
exit 1
fi
: > "$calls"
if PATH="$scratch/bin:$PATH" \
FAKE_NOTARY_CALLS="$calls" \
FAKE_NOTARY_MODE=invalid \
"$notarize" "$artifact" test-profile "$log_output" >/dev/null 2>&1; then
printf 'Invalid notarization must fail\n' >&2
exit 1
fi
grep -F '"The signature is invalid."' "$log_output" >/dev/null
grep -F \
'notarytool log 22222222-2222-2222-2222-222222222222' \
"$calls" >/dev/null
: > "$calls"
rm -f "$log_output"
if PATH="$scratch/bin:$PATH" \
FAKE_NOTARY_CALLS="$calls" \
FAKE_NOTARY_MODE=transport-error \
"$notarize" "$artifact" test-profile "$log_output" >/dev/null 2>&1; then
printf 'Notary transport errors must fail\n' >&2
exit 1
fi
[[ ! -e $log_output ]]
if grep -q '^notarytool log ' "$calls"; then
printf 'A submission without an ID cannot request a rejection log\n' >&2
exit 1
fi
printf 'Notarization helper tests passed.\n'

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
sign_exported_app="$script_dir/../sign-exported-app.sh"
scratch="$(mktemp -d "${TMPDIR:-/tmp}/vnidrop-codesign-test.XXXXXX")"
trap 'rm -rf "$scratch"' EXIT
mkdir -p "$scratch/bin" "$scratch/VniDrop.app/Contents/MacOS"
app="$scratch/VniDrop.app"
entitlements="$scratch/VniDropDirect.entitlements"
calls="$scratch/calls.txt"
printf '<plist><dict/></plist>\n' > "$entitlements"
printf 'binary\n' > "$app/Contents/MacOS/VniDrop"
cat > "$scratch/bin/codesign" <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$*" >> "$FAKE_CODESIGN_CALLS"
case " $* " in
*" --display "*)
if [[ ${FAKE_CODESIGN_MODE:-runtime} == missing-runtime ]]; then
printf '%s\n' \
'CodeDirectory v=20500 size=123 flags=0x0(none) hashes=1+0 location=embedded' \
>&2
else
printf '%s\n' \
'CodeDirectory v=20500 size=123 flags=0x10000(runtime) hashes=1+0 location=embedded' \
>&2
fi
;;
*" --verify "*)
if [[ ${FAKE_CODESIGN_MODE:-runtime} == verify-error ]]; then
printf '%s\n' 'invalid signature' >&2
exit 1
fi
;;
esac
SCRIPT
chmod +x "$scratch/bin/codesign"
PATH="$scratch/bin:$PATH" \
FAKE_CODESIGN_CALLS="$calls" \
"$sign_exported_app" \
"$app" \
'Developer ID Application: Example (ABCDEFGHIJ)' \
"$entitlements" >/dev/null
grep -F -- \
'--force --sign Developer ID Application: Example (ABCDEFGHIJ) --options runtime --timestamp --entitlements' \
"$calls" >/dev/null
grep -F -- '--verify --deep --strict --verbose=2' "$calls" >/dev/null
grep -F -- '--display --verbose=4' "$calls" >/dev/null
if PATH="$scratch/bin:$PATH" \
FAKE_CODESIGN_CALLS="$calls" \
FAKE_CODESIGN_MODE=missing-runtime \
"$sign_exported_app" \
"$app" \
'Developer ID Application: Example (ABCDEFGHIJ)' \
"$entitlements" >/dev/null 2>&1; then
printf 'A signature without the hardened runtime must fail\n' >&2
exit 1
fi
if PATH="$scratch/bin:$PATH" \
FAKE_CODESIGN_CALLS="$calls" \
FAKE_CODESIGN_MODE=verify-error \
"$sign_exported_app" \
"$app" \
'Developer ID Application: Example (ABCDEFGHIJ)' \
"$entitlements" >/dev/null 2>&1; then
printf 'Signature verification errors must fail\n' >&2
exit 1
fi
printf 'Exported app signing tests passed.\n'

View File

@@ -1,3 +1,5 @@
import java.util.Properties
plugins { plugins {
// this is necessary to avoid the plugins to be loaded multiple times // this is necessary to avoid the plugins to be loaded multiple times
// in each subproject's classloader // in each subproject's classloader
@@ -13,3 +15,67 @@ plugins {
alias(libs.plugins.kotlinJvm) apply false alias(libs.plugins.kotlinJvm) apply false
alias(libs.plugins.kotlinMultiplatform) apply false alias(libs.plugins.kotlinMultiplatform) apply false
} }
val versionFile = layout.projectDirectory.file("version.properties")
val versionProperties = Properties().apply {
versionFile.asFile.inputStream().use(::load)
}
fun requiredVersionProperty(name: String): String =
versionProperties.getProperty(name)?.takeIf { it.isNotBlank() }
?: error("Missing $name in ${versionFile.asFile}")
fun canonicalInteger(name: String, value: String, range: LongRange): Long {
require(value.matches(Regex("0|[1-9][0-9]*"))) {
"$name must be a canonical non-negative integer"
}
val number = value.toLongOrNull()
require(number != null && number in range) {
"$name must be between ${range.first} and ${range.last}"
}
return number
}
val productVersion = requiredVersionProperty("PRODUCT_VERSION")
val productVersionMatch = Regex("(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)")
.matchEntire(productVersion)
?: error("PRODUCT_VERSION must use canonical MAJOR.MINOR.PATCH integers")
val productVersionParts = productVersionMatch.groupValues.drop(1).map(String::toLong)
require(productVersionParts[0] <= 2099 && productVersionParts.drop(1).all { it <= 999 }) {
"PRODUCT_VERSION must use a major no greater than 2099 and minor/patch no greater than 999"
}
val releaseChannel = requiredVersionProperty("RELEASE_CHANNEL")
require(releaseChannel.matches(Regex("[a-z][a-z0-9-]*"))) {
"RELEASE_CHANNEL contains unsupported characters"
}
val androidVersionCode =
(productVersionParts[0] * 1_000_000L + productVersionParts[1] * 1_000L + productVersionParts[2])
.also { require(it in 1L..2_100_000_000L) { "Derived Android version code is out of range" } }
.toInt()
val windowsVersionEpoch = canonicalInteger(
"WINDOWS_VERSION_EPOCH",
requiredVersionProperty("WINDOWS_VERSION_EPOCH"),
1L..65535L,
)
val windowsMajor = productVersionParts[0] + windowsVersionEpoch
require(windowsMajor <= 65535) {
"Derived Windows package major exceeds 65535"
}
val windowsPackageVersion =
"$windowsMajor.${productVersionParts[1]}.${productVersionParts[2]}.0"
extra["vnidrop.productVersion"] = productVersion
extra["vnidrop.releaseChannel"] = releaseChannel
extra["vnidrop.androidVersionCode"] = androidVersionCode
extra["vnidrop.windowsPackageVersion"] = windowsPackageVersion
tasks.register("verifyVersion") {
group = "verification"
description = "Validates the canonical cross-platform application version."
inputs.file(versionFile)
inputs.property("productVersion", productVersion)
inputs.property("releaseChannel", releaseChannel)
inputs.property("androidVersionCode", androidVersionCode)
inputs.property("windowsPackageVersion", windowsPackageVersion)
}

View File

@@ -1,5 +1,5 @@
# Default command configuration. Override locally in the ignored # Default command configuration. Override local tool paths in the ignored
# config.override.mk or on the command line (for example: make package-deb VERSION=1.2.0). # config.override.mk or on the command line.
ifeq ($(OS),Windows_NT) ifeq ($(OS),Windows_NT)
HOST_OS := windows HOST_OS := windows
@@ -24,7 +24,7 @@ XCODEGEN ?= xcodegen
OPEN ?= open OPEN ?= open
POWERSHELL ?= pwsh POWERSHELL ?= pwsh
VERSION ?= $(shell sed -n 's/^vnidrop.version=//p' $(ROOT)/gradle.properties) override VERSION := $(shell $(ROOT)/packaging/version/resolve-version.sh product)
APPLE_PROFILE ?= debug APPLE_PROFILE ?= debug
APPLE_CONFIGURATION ?= Debug APPLE_CONFIGURATION ?= Debug
APPLE_DESTINATION ?= APPLE_DESTINATION ?=

31
crates/vnidrop/build.rs Normal file
View File

@@ -0,0 +1,31 @@
use std::{env, fs, path::PathBuf};
fn main() {
let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap());
let version_file = manifest_dir.join("../../version.properties");
println!("cargo:rerun-if-changed={}", version_file.display());
let contents = fs::read_to_string(&version_file)
.unwrap_or_else(|error| panic!("failed to read {}: {error}", version_file.display()));
let versions: Vec<_> = contents
.lines()
.filter_map(|line| line.strip_prefix("PRODUCT_VERSION="))
.collect();
assert_eq!(
versions.len(),
1,
"{} must contain exactly one PRODUCT_VERSION",
version_file.display()
);
let version = versions[0];
let components: Vec<_> = version.split('.').collect();
assert!(
components.len() == 3
&& components.iter().all(|component| {
component.parse::<u16>().is_ok()
&& (component == &"0" || !component.starts_with('0'))
}),
"PRODUCT_VERSION must use canonical MAJOR.MINOR.PATCH integers"
);
println!("cargo:rustc-env=VNIDROP_PRODUCT_VERSION={version}");
}

View File

@@ -106,7 +106,7 @@ impl HandshakeClient {
transfer_name: metadata.transfer_name.clone(), transfer_name: metadata.transfer_name.clone(),
receiver_name: receiver_name.map(ToOwned::to_owned), receiver_name: receiver_name.map(ToOwned::to_owned),
receiver_device_name: None, receiver_device_name: None,
app_version: env!("CARGO_PKG_VERSION").to_string(), app_version: env!("VNIDROP_PRODUCT_VERSION").to_string(),
}) })
.await .await
} }

View File

@@ -6,19 +6,7 @@ plugins {
alias(libs.plugins.composeCompiler) alias(libs.plugins.composeCompiler)
} }
val appVersion = providers.gradleProperty("vnidrop.version").get() val appVersion = rootProject.extra["vnidrop.productVersion"] as String
val appVersionParts = appVersion.split(".")
require(
appVersionParts.size == 3 &&
appVersionParts.mapIndexed { index, part ->
val number = part.toIntOrNull()
number != null &&
number.toString() == part &&
number in (if (index == 0) 1 else 0)..65535
}.all { it },
) {
"vnidrop.version must be MAJOR.MINOR.PATCH with numeric components from 0 to 65535 and a non-zero major"
}
dependencies { dependencies {
implementation(projects.shared) implementation(projects.shared)

View File

@@ -6,10 +6,6 @@ org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8
org.gradle.configuration-cache=true org.gradle.configuration-cache=true
org.gradle.caching=true org.gradle.caching=true
# Product version used by desktop packaging. Release workflows override this
# from the vMAJOR.MINOR.PATCH tag.
vnidrop.version=1.0.0
#Android #Android
android.builtInKotlin=false android.builtInKotlin=false
android.newDsl=false android.newDsl=false

View File

@@ -3430,6 +3430,40 @@
"ru": "Дополнительно" "ru": "Дополнительно"
} }
}, },
"settings_ios_background_notice_body": {
"context": "Settings overview: explains iOS/iPadOS background limits so users don't think the app is broken. Apple platforms only.",
"targets": [
"apple"
],
"translations": {
"en": "iPhone and iPad limit what apps may do in the background. VniDrop keeps a transfer that's already running alive long enough to finish and notify you after you leave the app, but it can't keep serving or receiving on its own once it's been in the background for a while. For long transfers, keep VniDrop open. On Mac, transfers continue in the background normally.",
"fr": "LiPhone et liPad limitent ce que les apps peuvent faire en arrière-plan. VniDrop maintient un transfert déjà en cours assez longtemps pour le terminer et vous avertir après avoir quitté lapp, mais il ne peut pas continuer à envoyer ou recevoir seul une fois resté en arrière-plan un certain temps. Pour les transferts longs, gardez VniDrop ouvert. Sur Mac, les transferts se poursuivent normalement en arrière-plan.",
"es": "El iPhone y el iPad limitan lo que las apps pueden hacer en segundo plano. VniDrop mantiene una transferencia ya en curso el tiempo suficiente para terminarla y avisarte tras salir de la app, pero no puede seguir enviando o recibiendo por sí solo cuando lleva un rato en segundo plano. Para transferencias largas, mantén VniDrop abierto. En Mac, las transferencias continúan en segundo plano con normalidad.",
"it": "iPhone e iPad limitano ciò che le app possono fare in background. VniDrop mantiene attivo un trasferimento già in corso quanto basta per completarlo e avvisarti dopo che esci dallapp, ma non può continuare a inviare o ricevere da solo dopo un po in background. Per i trasferimenti lunghi, tieni VniDrop aperto. Su Mac i trasferimenti proseguono normalmente in background.",
"de": "iPhone und iPad schränken ein, was Apps im Hintergrund tun dürfen. VniDrop hält eine bereits laufende Übertragung lange genug am Leben, um sie abzuschließen und dich zu benachrichtigen, nachdem du die App verlässt, kann aber nicht von selbst weiter senden oder empfangen, wenn es länger im Hintergrund war. Lass VniDrop bei langen Übertragungen geöffnet. Auf dem Mac laufen Übertragungen im Hintergrund normal weiter.",
"pt": "O iPhone e o iPad limitam o que as apps podem fazer em segundo plano. O VniDrop mantém uma transferência já em curso ativa o tempo suficiente para terminar e notificá-lo depois de sair da app, mas não consegue continuar a enviar ou receber sozinho depois de algum tempo em segundo plano. Para transferências longas, mantenha o VniDrop aberto. No Mac, as transferências continuam normalmente em segundo plano.",
"pl": "iPhone i iPad ograniczają to, co aplikacje mogą robić w tle. VniDrop utrzymuje już trwający transfer wystarczająco długo, aby go dokończyć i powiadomić Cię po opuszczeniu aplikacji, ale nie może samodzielnie wysyłać ani odbierać po dłuższym czasie w tle. Przy długich transferach nie zamykaj VniDrop. Na Macu transfery są kontynuowane w tle normalnie.",
"nl": "iPhone en iPad beperken wat apps op de achtergrond mogen doen. VniDrop houdt een al lopende overdracht lang genoeg actief om deze te voltooien en je te melden nadat je de app verlaat, maar kan niet zelf blijven verzenden of ontvangen als het al een tijd op de achtergrond is. Houd VniDrop open bij lange overdrachten. Op de Mac gaan overdrachten normaal door op de achtergrond.",
"ru": "iPhone и iPad ограничивают действия приложений в фоне. VniDrop удерживает уже идущую передачу достаточно долго, чтобы завершить её и уведомить вас после выхода из приложения, но не может сам продолжать отправку или приём, пробыв некоторое время в фоне. Для долгих передач держите VniDrop открытым. На Mac передачи продолжаются в фоне как обычно."
}
},
"settings_ios_background_notice_title": {
"context": "Settings overview: title of the iOS/iPadOS background-limits notice. Apple platforms only.",
"targets": [
"apple"
],
"translations": {
"en": "Background limits on iPhone & iPad",
"fr": "Limites en arrière-plan sur iPhone et iPad",
"es": "Límites en segundo plano en iPhone y iPad",
"it": "Limiti in background su iPhone e iPad",
"de": "Hintergrund-Grenzen auf iPhone & iPad",
"pt": "Limites em segundo plano no iPhone e iPad",
"pl": "Ograniczenia w tle na iPhonie i iPadzie",
"nl": "Achtergrondlimieten op iPhone en iPad",
"ru": "Ограничения фона на iPhone и iPad"
}
},
"settings_network_title": { "settings_network_title": {
"context": "Settings overview row and Network settings screen title.", "context": "Settings overview row and Network settings screen title.",
"translations": { "translations": {
@@ -4595,6 +4629,23 @@
"ru": "Поделиться" "ru": "Поделиться"
} }
}, },
"updates_check": {
"context": "macOS app menu item that checks for a new version via Sparkle. Direct-download (.dmg) build only; never shown in the App Store build.",
"targets": [
"apple"
],
"translations": {
"en": "Check for Updates…",
"fr": "Rechercher les mises à jour…",
"es": "Buscar actualizaciones…",
"it": "Cerca aggiornamenti…",
"de": "Nach Updates suchen…",
"pt": "Procurar atualizações…",
"pl": "Sprawdź aktualizacje…",
"nl": "Zoeken naar updates…",
"ru": "Проверить наличие обновлений…"
}
},
"value_unavailable": { "value_unavailable": {
"context": "Placeholder shown when a device-info or metadata value can't be read.", "context": "Placeholder shown when a device-info or metadata value can't be read.",
"translations": { "translations": {

View File

@@ -1,11 +1,10 @@
.PHONY: package-deb package-rpm package-msix .PHONY: package-deb package-rpm package-msix
package-deb: ## Build and verify a Debian x64 package (VERSION=x.y.z). package-deb: ## Build and verify a Debian x64 package.
@test "$(HOST_OS)" = linux || { printf 'Debian packaging requires Linux.\n' >&2; exit 1; } @test "$(HOST_OS)" = linux || { printf 'Debian packaging requires Linux.\n' >&2; exit 1; }
@cd $(ROOT); \ @cd $(ROOT); \
version="$$(packaging/linux/resolve-version.sh "$(VERSION)")"; \ version="$$(packaging/linux/resolve-version.sh)"; \
$(GRADLE) :shared:jvmTest :desktopApp:packageReleaseDeb \ $(GRADLE) :shared:jvmTest :desktopApp:packageReleaseDeb \
-Pvnidrop.version="$$version" \
-Pvnidrop.desktop.rustVariant=release \ -Pvnidrop.desktop.rustVariant=release \
-Pvnidrop.diagnostics.included=false \ -Pvnidrop.diagnostics.included=false \
$(GRADLE_RELEASE_FLAGS); \ $(GRADLE_RELEASE_FLAGS); \
@@ -19,12 +18,11 @@ package-deb: ## Build and verify a Debian x64 package (VERSION=x.y.z).
( cd "$$output_directory" && sha256sum "$$output_name" > "$$output_name.sha256" ); \ ( cd "$$output_directory" && sha256sum "$$output_name" > "$$output_name.sha256" ); \
printf 'Package: %s/%s\n' "$$output_directory" "$$output_name" printf 'Package: %s/%s\n' "$$output_directory" "$$output_name"
package-rpm: ## Build and verify an RPM x64 package (VERSION=x.y.z). package-rpm: ## Build and verify an RPM x64 package.
@test "$(HOST_OS)" = linux || { printf 'RPM packaging requires Linux.\n' >&2; exit 1; } @test "$(HOST_OS)" = linux || { printf 'RPM packaging requires Linux.\n' >&2; exit 1; }
@cd $(ROOT); \ @cd $(ROOT); \
version="$$(packaging/linux/resolve-version.sh "$(VERSION)")"; \ version="$$(packaging/linux/resolve-version.sh)"; \
$(GRADLE) :desktopApp:packageReleaseRpm \ $(GRADLE) :desktopApp:packageReleaseRpm \
-Pvnidrop.version="$$version" \
-Pvnidrop.desktop.rustVariant=release \ -Pvnidrop.desktop.rustVariant=release \
-Pvnidrop.diagnostics.included=false \ -Pvnidrop.diagnostics.included=false \
$(GRADLE_RELEASE_FLAGS); \ $(GRADLE_RELEASE_FLAGS); \
@@ -38,14 +36,12 @@ package-rpm: ## Build and verify an RPM x64 package (VERSION=x.y.z).
( cd "$$output_directory" && sha256sum "$$output_name" > "$$output_name.sha256" ); \ ( cd "$$output_directory" && sha256sum "$$output_name" > "$$output_name.sha256" ); \
printf 'Package: %s/%s\n' "$$output_directory" "$$output_name" printf 'Package: %s/%s\n' "$$output_directory" "$$output_name"
package-msix: ## Build and verify an unsigned Windows Store MSIX (VERSION=x.y.z). package-msix: ## Build and verify an unsigned Windows Store MSIX.
@test "$(HOST_OS)" = windows || { printf 'MSIX packaging requires Windows.\n' >&2; exit 1; } @test "$(HOST_OS)" = windows || { printf 'MSIX packaging requires Windows.\n' >&2; exit 1; }
cd $(ROOT) && $(GRADLE) :shared:jvmTest :desktopApp:createReleaseDistributable \ cd $(ROOT) && $(GRADLE) :shared:jvmTest :desktopApp:createReleaseDistributable \
-Pvnidrop.version="$(VERSION)" \
-Pvnidrop.desktop.rustVariant=release \ -Pvnidrop.desktop.rustVariant=release \
-Pvnidrop.diagnostics.included=false \ -Pvnidrop.diagnostics.included=false \
$(GRADLE_RELEASE_FLAGS) $(GRADLE_RELEASE_FLAGS)
cd $(ROOT) && $(POWERSHELL) -NoProfile -File packaging/windows/build-msix.ps1 \ cd $(ROOT) && $(POWERSHELL) -NoProfile -File packaging/windows/build-msix.ps1 \
-Version "$(VERSION)" \
-AppImage desktopApp/build/compose/binaries/main-release/app/VniDrop \ -AppImage desktopApp/build/compose/binaries/main-release/app/VniDrop \
-OutputDirectory build/release/windows -OutputDirectory build/release/windows

View File

@@ -0,0 +1,54 @@
# Android release pipeline
Android releases use two independent credentials:
- the upload keystore signs the APK and AAB;
- a short-lived Google access token publishes the AAB through the Play
Developer API.
The GitHub release workflow expects these encrypted secrets:
- `ANDROID_UPLOAD_KEYSTORE_BASE64`
- `ANDROID_UPLOAD_KEYSTORE_PASSWORD`
- `ANDROID_UPLOAD_KEY_ALIAS`
- `ANDROID_UPLOAD_KEY_PASSWORD`
It also expects this repository variable:
- `ANDROID_UPLOAD_CERT_SHA256`
The protected `play-closed-testing` GitHub Environment supplies:
- `PLAY_APP_SIGNING_CERT_SHA256`
- `GCP_WORKLOAD_IDENTITY_PROVIDER`
- `GCP_PLAY_SERVICE_ACCOUNT`
- `PLAY_PACKAGE_NAME` (`com.vnidrop.app`)
- `PLAY_CLOSED_TRACK` (the existing closed-test track identifier)
The upload and app-signing certificate fingerprints are public identifiers from
Play Console's App signing page. Do not store a private key in a repository
variable.
`packaging/android/build-release.sh` creates an upload-signed AAB and APK,
verifies their canonical version and upload certificate, and writes checksums.
The release workflow uploads only the AAB to Play. It then downloads the
universal APK generated and signed by Play for the public GitHub Release.
Play publishing is deliberately restricted to `draft` releases on
`PLAY_CLOSED_TRACK`. Production promotion is not part of this pipeline.
## One-time setup
1. In Play Console, link a Google Cloud project and grant the deployment
service account permission to manage releases for VniDrop.
2. In Google Cloud, enable the Google Play Android Developer API and configure
a Workload Identity Federation provider that trusts this repository's
GitHub Actions identity. Permit the service account to receive federated
tokens from that provider.
3. Create the `play-closed-testing` GitHub Environment. Add the five variables
listed above and restrict deployment branches/tags to the release policy.
4. Add the four upload-keystore secrets and
`ANDROID_UPLOAD_CERT_SHA256` in the repository settings.
No Google service-account JSON key is stored in GitHub. The workflow exchanges
GitHub's OIDC identity for a short-lived Google access token.

View File

@@ -0,0 +1,145 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/../.." && pwd)"
resolver="$repo_root/packaging/version/resolve-version.sh"
output_dir="$repo_root/build/release/android"
required_apk_libraries=(
"lib/arm64-v8a/libvnidrop.so"
"lib/x86_64/libvnidrop.so"
)
required_aab_libraries=(
"base/lib/arm64-v8a/libvnidrop.so"
"base/lib/x86_64/libvnidrop.so"
)
require_environment() {
local name=$1
[[ -n ${!name:-} ]] || {
printf 'Missing required environment variable: %s\n' "$name" >&2
exit 1
}
}
normalize_fingerprint() {
printf '%s' "$1" | tr -d '[:space:]:' | tr '[:upper:]' '[:lower:]'
}
sha256_file() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | awk '{print $1}'
else
shasum -a 256 "$1" | awk '{print $1}'
fi
}
verify_archive_entries() {
local archive=$1
shift
local entry
local size
for entry in "$@"; do
size="$(
unzip -l "$archive" "$entry" |
awk -v expected="$entry" '$4 == expected {print $1; exit}'
)"
[[ -n $size && $size -gt 0 ]] || {
printf 'Missing or empty Android native library %s in %s\n' \
"$entry" "$archive" >&2
exit 1
}
done
}
for name in \
VNIDROP_ANDROID_KEYSTORE_PATH \
VNIDROP_ANDROID_KEYSTORE_PASSWORD \
VNIDROP_ANDROID_KEY_ALIAS \
VNIDROP_ANDROID_KEY_PASSWORD \
VNIDROP_ANDROID_UPLOAD_CERT_SHA256; do
require_environment "$name"
done
[[ -r $VNIDROP_ANDROID_KEYSTORE_PATH ]] || {
printf 'Android upload keystore is not readable: %s\n' "$VNIDROP_ANDROID_KEYSTORE_PATH" >&2
exit 1
}
version="$("$resolver" product)"
version_code="$("$resolver" android-code)"
"$resolver" verify >/dev/null
cd "$repo_root"
./gradlew \
:androidApp:check \
:androidApp:assembleRelease \
:androidApp:bundleRelease \
-Pvnidrop.diagnostics.included=false \
--no-daemon \
--no-configuration-cache \
--stacktrace
source_apk="$repo_root/androidApp/build/outputs/apk/release/androidApp-release.apk"
source_aab="$repo_root/androidApp/build/outputs/bundle/release/androidApp-release.aab"
metadata="$repo_root/androidApp/build/intermediates/merged_manifests/release/processReleaseManifest/output-metadata.json"
[[ -s $source_apk && -s $source_aab && -s $metadata ]] || {
printf 'Android release outputs are missing or empty\n' >&2
exit 1
}
actual_version="$(jq -r '.elements[0].versionName' "$metadata")"
actual_version_code="$(jq -r '.elements[0].versionCode' "$metadata")"
[[ $actual_version == "$version" && $actual_version_code == "$version_code" ]] || {
printf 'Android artifact version mismatch: expected %s (%s), got %s (%s)\n' \
"$version" "$version_code" "$actual_version" "$actual_version_code" >&2
exit 1
}
jarsigner_report="$(jarsigner -verify "$source_aab" 2>&1)" || {
printf 'AAB signature verification failed:\n%s\n' "$jarsigner_report" >&2
exit 1
}
grep -F 'jar verified.' <<< "$jarsigner_report" >/dev/null || {
printf 'jarsigner did not confirm the AAB signature\n' >&2
exit 1
}
verify_archive_entries "$source_apk" "${required_apk_libraries[@]}"
verify_archive_entries "$source_aab" "${required_aab_libraries[@]}"
actual_fingerprint="$(
"$script_dir/verify-apk-signature.sh" \
"$source_apk" \
"$VNIDROP_ANDROID_UPLOAD_CERT_SHA256"
)"
expected_fingerprint="$(normalize_fingerprint "$VNIDROP_ANDROID_UPLOAD_CERT_SHA256")"
aab_fingerprint="$(
keytool -printcert -jarfile "$source_aab" |
awk -F': ' '/SHA256:/ {print $2; exit}'
)"
aab_fingerprint="$(normalize_fingerprint "$aab_fingerprint")"
[[ $aab_fingerprint == "$expected_fingerprint" ]] || {
printf 'AAB signing certificate mismatch: expected %s, got %s\n' \
"$expected_fingerprint" "$aab_fingerprint" >&2
exit 1
}
mkdir -p "$output_dir"
rm -f \
"$output_dir"/VniDrop-*-upload-signed.apk \
"$output_dir"/VniDrop-*.aab \
"$output_dir"/SHA256SUMS
apk_name="VniDrop-${version}-${version_code}-upload-signed.apk"
aab_name="VniDrop-${version}-${version_code}.aab"
cp "$source_apk" "$output_dir/$apk_name"
cp "$source_aab" "$output_dir/$aab_name"
{
printf '%s %s\n' "$(sha256_file "$output_dir/$apk_name")" "$apk_name"
printf '%s %s\n' "$(sha256_file "$output_dir/$aab_name")" "$aab_name"
} > "$output_dir/SHA256SUMS"
printf 'Created signed Android release artifacts:\n'
printf ' %s\n' "$output_dir/$aab_name"
printf ' %s\n' "$output_dir/$apk_name"
printf ' upload certificate SHA-256: %s\n' "$actual_fingerprint"

424
packaging/android/publish_play.py Executable file
View File

@@ -0,0 +1,424 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import hashlib
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any
API_ROOT = "https://androidpublisher.googleapis.com/androidpublisher/v3"
UPLOAD_ROOT = "https://androidpublisher.googleapis.com/upload/androidpublisher/v3"
RETRYABLE_STATUS = {429, 500, 502, 503, 504}
class PlayApiError(RuntimeError):
def __init__(self, status: int | None, message: str) -> None:
super().__init__(message)
self.status = status
class PlayClient:
def __init__(self, token: str) -> None:
if not token:
raise ValueError("Google Play access token is required")
self.token = token
def request(
self,
method: str,
url: str,
*,
body: bytes | None = None,
content_type: str | None = None,
timeout: int = 180,
attempts: int = 5,
) -> bytes:
headers = {
"Authorization": f"Bearer {self.token}",
"Accept": "application/json",
}
if content_type is not None:
headers["Content-Type"] = content_type
for attempt in range(1, attempts + 1):
request = urllib.request.Request(
url,
data=body,
headers=headers,
method=method,
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
return response.read()
except urllib.error.HTTPError as error:
error_body = error.read().decode("utf-8", errors="replace")
if error.code not in RETRYABLE_STATUS or attempt == attempts:
raise PlayApiError(
error.code,
f"Google Play API returned HTTP {error.code}: {error_body}",
) from error
except urllib.error.URLError as error:
if attempt == attempts:
raise PlayApiError(
None,
f"Google Play API request failed: {error.reason}",
) from error
time.sleep(2 ** (attempt - 1))
raise AssertionError("request retry loop exited unexpectedly")
def request_json(
self,
method: str,
url: str,
*,
value: Any | None = None,
timeout: int = 180,
) -> dict[str, Any]:
body = None
content_type = None
if value is not None:
body = json.dumps(value, separators=(",", ":")).encode()
content_type = "application/json"
response = self.request(
method,
url,
body=body,
content_type=content_type,
timeout=timeout,
)
return json.loads(response) if response else {}
def normalize_fingerprint(value: str) -> str:
return "".join(character for character in value.lower() if character.isalnum())
def validate_closed_track(track: str) -> None:
normalized = track.strip().casefold()
if not normalized:
raise ValueError("Play track is required")
if normalized == "production" or normalized.endswith(":production"):
raise ValueError(
"production tracks are forbidden by this closed-testing pipeline"
)
def find_universal_apk(
response: dict[str, Any],
expected_fingerprint: str,
) -> tuple[str, str] | None:
expected = normalize_fingerprint(expected_fingerprint)
for signing_key in response.get("generatedApks", []):
fingerprint = normalize_fingerprint(
str(signing_key.get("certificateSha256Hash", ""))
)
if fingerprint != expected:
continue
universal = signing_key.get("generatedUniversalApk") or {}
download_id = universal.get("downloadId")
if download_id:
return fingerprint, str(download_id)
return None
def build_track_payload(
track: dict[str, Any],
version_code: int,
release_name: str,
) -> dict[str, Any]:
releases = list(track.get("releases") or [])
expected_code = str(version_code)
if any(
expected_code in [str(code) for code in release.get("versionCodes", [])]
for release in releases
):
raise ValueError(
f"version code {version_code} is already present in track "
f"{track.get('track', '<unknown>')}"
)
releases.append(
{
"name": release_name,
"versionCodes": [expected_code],
"status": "draft",
}
)
return {"track": track["track"], "releases": releases}
def find_track_release(
track: dict[str, Any],
version_code: int,
) -> dict[str, Any] | None:
expected_code = str(version_code)
return next(
(
release
for release in track.get("releases") or []
if expected_code
in [str(code) for code in release.get("versionCodes", [])]
),
None,
)
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def generated_apks_url(package_name: str, version_code: int) -> str:
package = urllib.parse.quote(package_name, safe="")
return f"{API_ROOT}/applications/{package}/generatedApks/{version_code}"
def generated_apk_download_url(
package_name: str,
version_code: int,
download_id: str,
) -> str:
package = urllib.parse.quote(package_name, safe="")
download = urllib.parse.quote(download_id, safe="")
return (
f"{API_ROOT}/applications/{package}/generatedApks/"
f"{version_code}/downloads/{download}:download?alt=media"
)
def get_generated_apks(
client: PlayClient,
package_name: str,
version_code: int,
) -> dict[str, Any] | None:
try:
return client.request_json(
"GET",
generated_apks_url(package_name, version_code),
)
except PlayApiError as error:
if error.status == 404:
return None
raise
def download_universal_apk(
client: PlayClient,
package_name: str,
version_code: int,
expected_fingerprint: str,
output: Path,
*,
attempts: int,
interval_seconds: int,
) -> str:
for attempt in range(1, attempts + 1):
response = get_generated_apks(client, package_name, version_code)
if response is not None:
selected = find_universal_apk(response, expected_fingerprint)
if selected is not None:
fingerprint, download_id = selected
apk = client.request(
"GET",
generated_apk_download_url(
package_name,
version_code,
download_id,
),
)
if apk:
output.parent.mkdir(parents=True, exist_ok=True)
output.write_bytes(apk)
return fingerprint
if attempt < attempts:
time.sleep(interval_seconds)
raise RuntimeError(
"Google Play did not provide a non-empty universal APK signed with the "
f"expected certificate after {attempts} attempts"
)
def publish_bundle(args: argparse.Namespace) -> dict[str, Any]:
validate_closed_track(args.track)
if args.version_code < 1:
raise ValueError("version code must be positive")
if not args.bundle.is_file() or args.bundle.stat().st_size == 0:
raise ValueError(f"AAB is missing or empty: {args.bundle}")
client = PlayClient(args.access_token)
existing = get_generated_apks(client, args.package_name, args.version_code)
if existing is not None:
selected = find_universal_apk(existing, args.expected_app_certificate)
if selected is None:
raise RuntimeError(
"version code already exists in Play, but no universal APK matches "
"the expected app-signing certificate"
)
package = urllib.parse.quote(args.package_name, safe="")
edit = client.request_json(
"POST",
f"{API_ROOT}/applications/{package}/edits",
value={},
)
edit_id = str(edit["id"])
edit_base = f"{API_ROOT}/applications/{package}/edits/{edit_id}"
try:
track_id = urllib.parse.quote(args.track, safe="")
track = client.request_json(
"GET",
f"{edit_base}/tracks/{track_id}",
)
release = find_track_release(track, args.version_code)
if release is None or release.get("status") != "draft":
raise RuntimeError(
"version code already exists in Play but is not a draft on "
f"the configured track {args.track}"
)
finally:
try:
client.request("DELETE", edit_base, attempts=1)
except PlayApiError:
pass
source = "existing"
else:
package = urllib.parse.quote(args.package_name, safe="")
edit = client.request_json(
"POST",
f"{API_ROOT}/applications/{package}/edits",
value={},
)
edit_id = str(edit["id"])
committed = False
edit_base = f"{API_ROOT}/applications/{package}/edits/{edit_id}"
try:
upload_url = (
f"{UPLOAD_ROOT}/applications/{package}/edits/{edit_id}/bundles"
"?uploadType=media"
)
try:
uploaded = json.loads(
client.request(
"POST",
upload_url,
body=args.bundle.read_bytes(),
content_type="application/octet-stream",
attempts=1,
)
)
except PlayApiError:
bundles = client.request_json("GET", f"{edit_base}/bundles")
matches = [
bundle
for bundle in bundles.get("bundles", [])
if int(bundle.get("versionCode", 0)) == args.version_code
]
if len(matches) != 1:
raise
uploaded = matches[0]
uploaded_code = int(uploaded["versionCode"])
if uploaded_code != args.version_code:
raise RuntimeError(
f"Play accepted version code {uploaded_code}, expected "
f"{args.version_code}"
)
track_id = urllib.parse.quote(args.track, safe="")
track_url = f"{edit_base}/tracks/{track_id}"
track = client.request_json("GET", track_url)
payload = build_track_payload(track, args.version_code, args.release_name)
client.request_json("PUT", track_url, value=payload)
commit_url = (
f"{edit_base}:commit"
"?changesInReviewBehavior=ERROR_IF_IN_REVIEW"
)
client.request("POST", commit_url, body=b"")
committed = True
source = "uploaded"
finally:
if not committed:
try:
client.request("DELETE", edit_base, attempts=1)
except PlayApiError:
pass
fingerprint = download_universal_apk(
client,
args.package_name,
args.version_code,
args.expected_app_certificate,
args.apk_output,
attempts=args.poll_attempts,
interval_seconds=args.poll_interval,
)
return {
"packageName": args.package_name,
"track": args.track,
"releaseStatus": "draft",
"releaseName": args.release_name,
"versionCode": args.version_code,
"bundleSha256": sha256_file(args.bundle),
"universalApk": args.apk_output.name,
"universalApkSha256": sha256_file(args.apk_output),
"appSigningCertificateSha256": fingerprint,
"source": source,
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Stage a signed AAB on a closed Play track and download Play's "
"app-signed universal APK"
)
)
parser.add_argument(
"--access-token",
default=os.environ.get("GOOGLE_PLAY_ACCESS_TOKEN"),
)
parser.add_argument("--bundle", type=Path, required=True)
parser.add_argument("--package-name", required=True)
parser.add_argument("--track", required=True)
parser.add_argument("--version-code", type=int, required=True)
parser.add_argument("--release-name", required=True)
parser.add_argument("--expected-app-certificate", required=True)
parser.add_argument("--apk-output", type=Path, required=True)
parser.add_argument("--metadata-output", type=Path, required=True)
parser.add_argument("--poll-attempts", type=int, default=18)
parser.add_argument("--poll-interval", type=int, default=10)
return parser.parse_args()
def main() -> int:
args = parse_args()
try:
metadata = publish_bundle(args)
except (KeyError, ValueError, RuntimeError, PlayApiError) as error:
print(f"Play closed-testing publication failed: {error}", file=sys.stderr)
return 1
args.metadata_output.parent.mkdir(parents=True, exist_ok=True)
args.metadata_output.write_text(
json.dumps(metadata, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
print(
f"Staged {args.release_name} ({args.version_code}) as a draft on "
f"{args.track}"
)
print(f"Downloaded Play-signed APK: {args.apk_output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,164 @@
import importlib.util
import tempfile
import unittest
from pathlib import Path
SCRIPT = Path(__file__).parents[1] / "publish_play.py"
SPEC = importlib.util.spec_from_file_location("publish_play", SCRIPT)
assert SPEC is not None and SPEC.loader is not None
publish_play = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(publish_play)
class PublishPlayTests(unittest.TestCase):
def test_rejects_phone_and_form_factor_production_tracks(self):
for track in ("production", "wear:production", " Production "):
with self.subTest(track=track):
with self.assertRaisesRegex(ValueError, "production"):
publish_play.validate_closed_track(track)
def test_accepts_custom_closed_track(self):
publish_play.validate_closed_track("closed-beta")
def test_normalizes_certificate_fingerprint(self):
self.assertEqual(
publish_play.normalize_fingerprint("AA:bb 01"),
"aabb01",
)
def test_selects_universal_apk_for_expected_signing_key(self):
response = {
"generatedApks": [
{
"certificateSha256Hash": "11:22",
"generatedUniversalApk": {"downloadId": "wrong"},
},
{
"certificateSha256Hash": "AA:BB",
"generatedUniversalApk": {"downloadId": "correct"},
},
]
}
self.assertEqual(
publish_play.find_universal_apk(response, "aa:bb"),
("aabb", "correct"),
)
def test_downloads_generated_apk_as_media(self):
class FakePlayClient:
def __init__(self):
self.download_urls = []
self.media_attempts = 0
def request_json(self, method, url):
self.assert_request(method, url)
return {
"generatedApks": [
{
"certificateSha256Hash": "AA:BB",
"generatedUniversalApk": {
"downloadId": "download/id+=",
},
}
]
}
def request(self, method, url):
self.assert_request(method, url)
self.download_urls.append(url)
if not url.endswith("?alt=media"):
return b""
self.media_attempts += 1
return b"apk" if self.media_attempts == 2 else b""
@staticmethod
def assert_request(method, url):
if method != "GET" or not url.startswith(publish_play.API_ROOT):
raise AssertionError(f"unexpected request: {method} {url}")
client = FakePlayClient()
with tempfile.TemporaryDirectory() as scratch:
output = Path(scratch) / "universal.apk"
fingerprint = publish_play.download_universal_apk(
client,
"com.example app",
2002,
"aa:bb",
output,
attempts=2,
interval_seconds=0,
)
self.assertEqual(fingerprint, "aabb")
self.assertEqual(output.read_bytes(), b"apk")
self.assertEqual(
client.download_urls,
[
(
f"{publish_play.API_ROOT}/applications/com.example%20app/"
"generatedApks/2002/downloads/"
"download%2Fid%2B%3D:download?alt=media"
),
(
f"{publish_play.API_ROOT}/applications/com.example%20app/"
"generatedApks/2002/downloads/"
"download%2Fid%2B%3D:download?alt=media"
),
],
)
def test_track_update_preserves_existing_releases_and_adds_draft(self):
track = {
"track": "closed-beta",
"releases": [
{
"name": "0.1.0",
"versionCodes": ["1"],
"status": "completed",
}
],
}
updated = publish_play.build_track_payload(track, 2, "0.2.0")
self.assertEqual(updated["releases"][0], track["releases"][0])
self.assertEqual(
updated["releases"][1],
{
"name": "0.2.0",
"versionCodes": ["2"],
"status": "draft",
},
)
def test_track_update_rejects_duplicate_version_code(self):
track = {
"track": "closed-beta",
"releases": [{"versionCodes": ["2"], "status": "draft"}],
}
with self.assertRaisesRegex(ValueError, "already present"):
publish_play.build_track_payload(track, 2, "0.2.0")
def test_finds_existing_release_by_version_code(self):
expected = {"versionCodes": ["2"], "status": "draft"}
track = {
"track": "closed-beta",
"releases": [
{"versionCodes": ["1"], "status": "completed"},
expected,
],
}
self.assertIs(
publish_play.find_track_release(track, 2),
expected,
)
def test_returns_none_when_version_is_not_on_track(self):
track = {
"track": "closed-beta",
"releases": [{"versionCodes": ["1"], "status": "completed"}],
}
self.assertIsNone(publish_play.find_track_release(track, 2))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
verifier="$script_dir/../verify-apk-signature.sh"
scratch="$(mktemp -d "${TMPDIR:-/tmp}/vnidrop-apksigner-test.XXXXXX")"
trap 'rm -rf "$scratch"' EXIT
apk="$scratch/app.apk"
fake_apksigner="$scratch/apksigner"
printf 'apk\n' > "$apk"
cat > "$fake_apksigner" <<'SCRIPT'
#!/usr/bin/env bash
case "${FAKE_APKSIGNER_MODE:-success}" in
success)
printf '%s\n' \
'Verifies' \
'Signer #1 certificate SHA-256 digest: AA:BB:CC:DD' >&2
;;
missing)
printf '%s\n' 'Verifies' >&2
;;
failure)
printf '%s\n' 'invalid APK signature' >&2
exit 1
;;
esac
SCRIPT
chmod +x "$fake_apksigner"
actual="$(
APKSIGNER="$fake_apksigner" \
"$verifier" "$apk" "aa bb cc dd"
)"
[[ $actual == aabbccdd ]]
if APKSIGNER="$fake_apksigner" \
"$verifier" "$apk" deadbeef >/dev/null 2>&1; then
printf 'Expected a certificate mismatch to fail\n' >&2
exit 1
fi
if FAKE_APKSIGNER_MODE=missing APKSIGNER="$fake_apksigner" \
"$verifier" "$apk" aabbccdd >/dev/null 2>&1; then
printf 'Expected missing certificate output to fail\n' >&2
exit 1
fi
if FAKE_APKSIGNER_MODE=failure APKSIGNER="$fake_apksigner" \
"$verifier" "$apk" aabbccdd >/dev/null 2>&1; then
printf 'Expected signature verification failure to propagate\n' >&2
exit 1
fi
printf 'APK signature verifier tests passed.\n'

View File

@@ -0,0 +1,86 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 2 ]]; then
printf 'Usage: %s <apk> <expected-certificate-sha256>\n' "$0" >&2
exit 2
fi
apk=$1
expected_fingerprint=$2
build_tools_version=${ANDROID_BUILD_TOOLS_VERSION:-36.0.0}
normalize_fingerprint() {
printf '%s' "$1" |
tr -d '[:space:]:' |
tr '[:upper:]' '[:lower:]'
}
find_apksigner() {
if [[ -n ${APKSIGNER:-} ]]; then
[[ -x $APKSIGNER ]] || {
printf 'Configured apksigner is not executable: %s\n' "$APKSIGNER" >&2
return 1
}
printf '%s\n' "$APKSIGNER"
return
fi
local sdk_root=${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}
if [[ -n $sdk_root ]]; then
local pinned="$sdk_root/build-tools/$build_tools_version/apksigner"
if [[ -x $pinned ]]; then
printf '%s\n' "$pinned"
return
fi
fi
if command -v apksigner >/dev/null 2>&1; then
command -v apksigner
return
fi
printf 'apksigner %s was not found in the Android SDK or PATH\n' \
"$build_tools_version" >&2
return 1
}
[[ -s $apk ]] || {
printf 'APK is missing or empty: %s\n' "$apk" >&2
exit 1
}
apksigner_path="$(find_apksigner)" || exit 1
if ! signature_report="$(
"$apksigner_path" verify --verbose --print-certs "$apk" 2>&1
)"; then
printf 'APK signature verification failed:\n%s\n' "$signature_report" >&2
exit 1
fi
actual_fingerprint="$(
printf '%s\n' "$signature_report" |
awk '
tolower($0) ~ /^signer #1 certificate sha-256 digest:[[:space:]]*/ {
line = $0
sub(/^[^:]*:[[:space:]]*/, "", line)
print line
exit
}
'
)"
[[ -n $actual_fingerprint ]] || {
printf 'Could not read the APK signing certificate fingerprint\n' >&2
exit 1
}
actual_fingerprint="$(normalize_fingerprint "$actual_fingerprint")"
expected_fingerprint="$(normalize_fingerprint "$expected_fingerprint")"
[[ $actual_fingerprint == "$expected_fingerprint" ]] || {
printf 'APK signing certificate mismatch: expected %s, got %s\n' \
"$expected_fingerprint" "$actual_fingerprint" >&2
exit 1
}
printf '%s\n' "$actual_fingerprint"

View File

@@ -0,0 +1,54 @@
# homebrew-vnidrop
Homebrew tap for [VniDrop](https://github.com/sudosylabs/vnidrop) — direct,
private device-to-device file and folder transfer for macOS.
> This repo only holds the Homebrew **cask**. The app itself lives at
> [sudosylabs/vnidrop](https://github.com/sudosylabs/vnidrop). The cask here is
> updated automatically by VniDrop's release pipeline on each tagged release.
## Install
```sh
brew tap sudosylabs/vnidrop
brew install --cask vnidrop
```
Or in one line:
```sh
brew install --cask sudosylabs/vnidrop/vnidrop
```
## Update
VniDrop updates itself in-app via [Sparkle](https://sparkle-project.org), so you
normally don't need to do anything. To update through Homebrew instead:
```sh
brew upgrade --cask vnidrop
```
## Uninstall
```sh
brew uninstall --cask vnidrop
```
Add `--zap` to also remove VniDrop's application support, cache, and preference
files:
```sh
brew uninstall --zap --cask vnidrop
```
## Requirements
- macOS 15 (Sequoia) or later, Apple Silicon.
## What you get
The cask installs the Developer IDsigned, notarized `VniDrop.app` from the
matching [GitHub Release](https://github.com/sudosylabs/vnidrop/releases). App
Store users should install from the Mac App Store instead — that build does not
include the Sparkle self-updater.

View File

@@ -0,0 +1,36 @@
# Homebrew cask for the direct-download (notarized .dmg) macOS build.
#
# This file is the source template. The Apple release workflow substitutes the
# version + sha256 for each release and pushes the result to the tap repo
# (sudosylabs/homebrew-vnidrop, path Casks/vnidrop.rb). Users then install with:
# brew install --cask sudosylabs/vnidrop/vnidrop
#
# `auto_updates true` tells Homebrew that the app updates itself via Sparkle, so
# `brew upgrade` won't fight the in-app updater.
cask "vnidrop" do
version "0.0.0"
sha256 "0000000000000000000000000000000000000000000000000000000000000000"
url "https://github.com/sudosylabs/vnidrop/releases/download/v#{version}/VniDrop-#{version}.dmg",
verified: "github.com/sudosylabs/vnidrop/"
name "VniDrop"
desc "Direct device-to-device file and folder transfer over the network"
homepage "https://github.com/sudosylabs/vnidrop"
livecheck do
url :url
strategy :github_latest
end
auto_updates true
depends_on arch: :arm64
depends_on macos: ">= :sequoia"
app "VniDrop.app"
zap trash: [
"~/Library/Application Support/com.vnidrop.app",
"~/Library/Caches/com.vnidrop.app",
"~/Library/Preferences/com.vnidrop.app.plist",
]
end

View File

@@ -13,9 +13,10 @@ repository should add repository metadata signing and its own update channel.
## GitHub Actions ## GitHub Actions
The Linux packages workflow runs for relevant pull requests, release tags The Linux packages workflow runs for relevant pull requests and manual
matching `vMAJOR.MINOR.PATCH`, and manual dispatches. Each native package is dispatches. The coordinated release workflow also calls it for a canonical
built and validated on its matching distribution family: `vMAJOR.MINOR.PATCH` tag. Each native package is built and validated on its
matching distribution family:
- `.deb` on Ubuntu 22.04 for a conservative glibc baseline - `.deb` on Ubuntu 22.04 for a conservative glibc baseline
- `.rpm` inside Fedora 43 so `jpackage` can discover normal RPM dependencies - `.rpm` inside Fedora 43 so `jpackage` can discover normal RPM dependencies
@@ -23,13 +24,12 @@ built and validated on its matching distribution family:
The shared JVM suite runs inside the Debian build job. Package construction and The shared JVM suite runs inside the Debian build job. Package construction and
payload validation happen in both build jobs, so there is no separate test payload validation happen in both build jobs, so there is no separate test
runner. Pull requests build and verify both packages but do not retain runner. Pull requests build and verify both packages but do not retain
artifacts. Manual runs retain build artifacts for 14 days. A pushed version tag artifacts. Manual and coordinated-release runs retain build artifacts. The
whose commit is on `master` creates the matching GitHub Release with the `.deb`, central release workflow creates the single GitHub Release only after every
`.rpm`, and a combined `SHA256SUMS`. platform build and Play closed-testing stage succeeds.
The existing `v1.0.0` tag predates this workflow and will not run it The legacy `v1.0.0` tag predates canonical versioning and does not define the
retroactively. Use the next version tag after this configuration reaches current product version. New release tags must match `version.properties`.
`master`.
## Install a downloaded package ## Install a downloaded package
@@ -61,11 +61,12 @@ Use JDK 21 and Rust 1.91. Build DEB packages on Debian/Ubuntu with `dpkg` and
`fakeroot`; build RPM packages on Fedora with `rpm-build`. Building an RPM on `fakeroot`; build RPM packages on Fedora with `rpm-build`. Building an RPM on
Ubuntu prevents `jpackage` from discovering normal RPM dependencies. Ubuntu prevents `jpackage` from discovering normal RPM dependencies.
From the repository root on the matching Linux family, run one of: Set the release in `version.properties`. From the repository root on the
matching Linux family, run one of:
```bash ```bash
make package-deb VERSION=1.0.0 make package-deb
make package-rpm VERSION=1.0.0 make package-rpm
``` ```
The Make targets collect the Compose output under `build/release/linux/`, then The Make targets collect the Compose output under `build/release/linux/`, then

View File

@@ -2,38 +2,14 @@
set -euo pipefail set -euo pipefail
version=${1:-1.0.0} script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
resolver="$script_dir/../version/resolve-version.sh"
version="$("$resolver" product)"
"$resolver" verify >/dev/null
if [[ ${GITHUB_REF_TYPE:-} == "tag" ]]; then if [[ -n ${1:-} && $1 != "$version" ]]; then
if [[ ! ${GITHUB_REF_NAME:-} =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then printf 'Version overrides are not supported; version.properties declares %s\n' "$version" >&2
echo "Linux release tags must use vMAJOR.MINOR.PATCH" >&2
exit 1
fi
version=${GITHUB_REF_NAME#v}
fi
if [[ ! $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Version must use MAJOR.MINOR.PATCH" >&2
exit 1 exit 1
fi fi
IFS=. read -r major minor patch <<< "$version"
parts=("$major" "$minor" "$patch")
for index in "${!parts[@]}"; do
part=${parts[$index]}
if [[ $part != "0" && $part == 0* ]]; then
echo "Version components must be canonical integers without leading zeroes" >&2
exit 1
fi
if (( ${#part} > 5 )) || (( 10#$part > 65535 )); then
echo "Version components must be between 0 and 65535" >&2
exit 1
fi
if (( index == 0 && 10#$part == 0 )); then
echo "The major version must be non-zero" >&2
exit 1
fi
done
printf '%s\n' "$version" printf '%s\n' "$version"

View File

@@ -0,0 +1,52 @@
# Coordinated releases
Only `.github/workflows/release.yml` responds to version tags. It verifies that
the tag matches `version.properties` and points at the current `master`, then
calls the native platform workflows in parallel.
The tag workflow runs only when the repository variable
`RELEASE_PIPELINE_ENABLED` is exactly `true`. Leave it unset or set it to
`false` to disable all coordinated releases, including Play uploads, without
disabling release validation on pull requests.
Platform workflows upload private workflow artifacts. After every native build
passes, the release pipeline:
1. stages the signed AAB as a draft on the configured Play closed-test track;
2. submits the unsigned `.msixupload` package to Microsoft Store certification;
3. downloads the universal APK signed by Play;
4. verifies and assembles the public artifacts;
5. generates checksums and GitHub build-provenance attestations;
6. creates exactly one GitHub Release;
7. updates the Homebrew cask.
Public GitHub Release assets are the DEB, RPM, notarized DMG, Sparkle appcast,
Play-signed universal APK, checksum file, and release manifest.
The unsigned Microsoft `.msixupload` and upload-signed Android AAB remain
private workflow artifacts. The protected `microsoft-store` GitHub Environment
supplies the Partner Center credentials and Store product ID used to submit the
Windows package. Microsoft publishes the update after certification; the job
does not change Store listings, pricing, or availability. The Play release
remains a draft on a closed-testing track; this pipeline cannot publish it to
production.
To release, prepare and merge the new product version. Android, Microsoft Store,
and Apple build/package versions are derived automatically:
```bash
make prepare-release RELEASE_VERSION=0.2.1
make check-version
```
Then create and push the matching tag:
```bash
git tag -s v0.2.1 -m "VniDrop 0.2.1"
git push origin v0.2.1
```
The tag must point at the current `origin/master` commit. A failed run creates
no GitHub Release; a rerun safely reuses an already-staged Play draft only when
the version, configured track, draft status, and app-signing certificate all
match.

View File

@@ -0,0 +1,177 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/../.." && pwd)"
resolver="$repo_root/packaging/version/resolve-version.sh"
input_dir="${VNIDROP_RELEASE_INPUT_DIR:-$repo_root/build/release/downloads}"
output_dir="${VNIDROP_RELEASE_OUTPUT_DIR:-$repo_root/build/release/final}"
source_commit="${GITHUB_SHA:-local}"
source_tag="${GITHUB_REF_NAME:-v$("$resolver" product)}"
find_single() {
local directory=$1
local pattern=$2
local label=$3
local matches=()
local match
while IFS= read -r match; do
matches+=("$match")
done < <(find "$directory" -type f -name "$pattern" -print)
[[ ${#matches[@]} == 1 ]] || {
printf 'Expected exactly one %s under %s, found %s\n' \
"$label" "$directory" "${#matches[@]}" >&2
exit 1
}
printf '%s' "${matches[0]}"
}
file_size() {
if stat -c '%s' "$1" >/dev/null 2>&1; then
stat -c '%s' "$1"
else
stat -f '%z' "$1"
fi
}
verify_checksum_file() {
local checksum_file=$1
(
cd "$(dirname "$checksum_file")"
sha256sum --check "$(basename "$checksum_file")"
)
}
version="$("$resolver" product)"
android_code="$("$resolver" android-code)"
windows_package="$("$resolver" windows-package)"
"$resolver" verify >/dev/null
[[ $source_tag == "v$version" ]] || {
printf 'Release tag %s does not match canonical version v%s\n' \
"$source_tag" "$version" >&2
exit 1
}
deb="$(find_single "$input_dir/deb" '*.deb' 'Debian package')"
rpm="$(find_single "$input_dir/rpm" '*.rpm' 'RPM package')"
dmg="$(find_single "$input_dir/macos" '*.dmg' 'macOS DMG')"
appcast="$(find_single "$input_dir/macos" 'appcast.xml' 'Sparkle appcast')"
apple_metadata="$(find_single "$input_dir/macos" '*.build-info.json' 'direct macOS build metadata')"
play_apk="$(find_single "$input_dir/play" '*-play-universal.apk' 'Play-signed APK')"
play_metadata="$(find_single "$input_dir/play" 'play-release.json' 'Play release metadata')"
msix="$(find_single "$input_dir/windows" '*.msix' 'Windows MSIX')"
msixupload="$(find_single "$input_dir/windows" '*.msixupload' 'Windows MSIX upload')"
windows_metadata="$(find_single "$input_dir/windows" '*.build-info.json' 'Windows build metadata')"
[[ $(basename "$deb") == "vnidrop_${version}-1_amd64.deb" ]]
[[ $(basename "$rpm") == "vnidrop-${version}-1.x86_64.rpm" ]]
[[ $(basename "$dmg") == "VniDrop-${version}.dmg" ]]
[[ $(basename "$play_apk") == "VniDrop-${version}-${android_code}-play-universal.apk" ]]
[[ $(basename "$msix") == "VniDrop_${version}_x64.msix" ]]
[[ $(basename "$msixupload") == "VniDrop_${version}_x64.msixupload" ]]
[[ $(jq -r '.productVersion' "$apple_metadata") == "$version" ]]
[[ $(jq -r '.distribution' "$apple_metadata") == direct ]]
[[ $(jq -r '.artifact' "$apple_metadata") == "$(basename "$dmg")" ]]
apple_direct_build="$(jq -r '.directBuildNumber' "$apple_metadata")"
[[ $apple_direct_build =~ ^[1-9][0-9]*(\.[0-9]+){0,2}$ ]] || {
printf 'Invalid direct Apple build number: %s\n' "$apple_direct_build" >&2
exit 1
}
deb_checksum="$(find_single "$input_dir/deb" '*.sha256' 'Debian checksum')"
rpm_checksum="$(find_single "$input_dir/rpm" '*.sha256' 'RPM checksum')"
windows_checksums="$(find_single "$input_dir/windows" 'SHA256SUMS' 'Windows checksums')"
play_checksums="$(find_single "$input_dir/play" 'SHA256SUMS' 'Play APK checksums')"
verify_checksum_file "$deb_checksum"
verify_checksum_file "$rpm_checksum"
verify_checksum_file "$windows_checksums"
verify_checksum_file "$play_checksums"
[[ $(jq -r '.releaseStatus' "$play_metadata") == draft ]]
[[ $(jq -r '.releaseName' "$play_metadata") == "$version" ]]
[[ $(jq -r '.versionCode' "$play_metadata") == "$android_code" ]]
play_track="$(jq -r '.track' "$play_metadata")"
normalized_play_track="$(printf '%s' "$play_track" | tr '[:upper:]' '[:lower:]')"
[[ $normalized_play_track != production && $normalized_play_track != *:production ]]
[[ $(jq -r '.appVersion' "$windows_metadata") == "$version" ]]
[[ $(jq -r '.packageVersion' "$windows_metadata") == "$windows_package" ]]
grep -F "VniDrop-${version}.dmg" "$appcast" >/dev/null
mkdir -p "$output_dir"
[[ -z $(find "$output_dir" -mindepth 1 -maxdepth 1 -print -quit) ]] || {
printf 'Release output directory must be empty: %s\n' "$output_dir" >&2
exit 1
}
cp "$deb" "$rpm" "$dmg" "$appcast" "$play_apk" "$output_dir/"
payloads=(
"$output_dir/$(basename "$deb")"
"$output_dir/$(basename "$rpm")"
"$output_dir/$(basename "$dmg")"
"$output_dir/$(basename "$appcast")"
"$output_dir/$(basename "$play_apk")"
)
files_json="$(
for file in "${payloads[@]}"; do
jq -n \
--arg name "$(basename "$file")" \
--arg sha256 "$(sha256sum "$file" | awk '{print $1}')" \
--argjson bytes "$(file_size "$file")" \
'{name: $name, sha256: $sha256, bytes: $bytes}'
done | jq -s .
)"
jq -n \
--arg productVersion "$version" \
--arg releaseChannel "$("$resolver" channel)" \
--arg tag "$source_tag" \
--arg commit "$source_commit" \
--arg androidVersionCode "$android_code" \
--arg appleDirectBuildNumber "$apple_direct_build" \
--arg windowsPackageVersion "$windows_package" \
--arg windowsMsixUpload "$(basename "$msixupload")" \
--arg windowsMsixUploadSha256 "$(sha256sum "$msixupload" | awk '{print $1}')" \
--arg playTrack "$play_track" \
--arg playBundleSha256 "$(jq -r '.bundleSha256' "$play_metadata")" \
--arg playCertificateSha256 "$(jq -r '.appSigningCertificateSha256' "$play_metadata")" \
--argjson files "$files_json" \
'{
productVersion: $productVersion,
releaseChannel: $releaseChannel,
tag: $tag,
sourceCommit: $commit,
platformVersions: {
androidVersionCode: ($androidVersionCode | tonumber),
appleDirectBuildNumber: $appleDirectBuildNumber,
windowsPackageVersion: $windowsPackageVersion
},
play: {
track: $playTrack,
status: "draft",
bundleSha256: $playBundleSha256,
appSigningCertificateSha256: $playCertificateSha256
},
windowsStore: {
publicReleaseAsset: false,
msixUpload: $windowsMsixUpload,
sha256: $windowsMsixUploadSha256
},
files: $files
}' > "$output_dir/release-manifest.json"
(
cd "$output_dir"
sha256sum \
"$(basename "$deb")" \
"$(basename "$rpm")" \
"$(basename "$dmg")" \
"$(basename "$appcast")" \
"$(basename "$play_apk")" \
release-manifest.json \
> SHA256SUMS
)
printf 'Assembled public release assets in %s\n' "$output_dir"
printf 'Windows Store submission retained as workflow artifact: %s\n' \
"$(basename "$msixupload")"

View File

@@ -0,0 +1,116 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/../.." && pwd)"
fixture_root="$(mktemp -d "${TMPDIR:-/tmp}/vnidrop-release-test.XXXXXX")"
trap 'rm -rf "$fixture_root"' EXIT
input_dir="$fixture_root/input"
output_dir="$fixture_root/output"
mkdir -p \
"$input_dir/deb" \
"$input_dir/rpm" \
"$input_dir/macos" \
"$input_dir/play" \
"$input_dir/windows"
version="$("$repo_root/packaging/version/resolve-version.sh" product)"
android_code="$("$repo_root/packaging/version/resolve-version.sh" android-code)"
windows_package="$("$repo_root/packaging/version/resolve-version.sh" windows-package)"
apple_direct_build=20260728.1432.17
printf 'deb\n' > "$input_dir/deb/vnidrop_${version}-1_amd64.deb"
printf 'rpm\n' > "$input_dir/rpm/vnidrop-${version}-1.x86_64.rpm"
printf 'dmg\n' > "$input_dir/macos/VniDrop-${version}.dmg"
printf '<url>VniDrop-%s.dmg</url>\n' "$version" > "$input_dir/macos/appcast.xml"
printf 'apk\n' > "$input_dir/play/VniDrop-${version}-${android_code}-play-universal.apk"
printf 'msix\n' > "$input_dir/windows/VniDrop_${version}_x64.msix"
printf 'msixupload\n' > "$input_dir/windows/VniDrop_${version}_x64.msixupload"
jq -n \
--arg productVersion "$version" \
--arg directBuildNumber "$apple_direct_build" \
--arg artifact "VniDrop-${version}.dmg" \
'{
productVersion: $productVersion,
directBuildNumber: $directBuildNumber,
distribution: "direct",
artifact: $artifact
}' > "$input_dir/macos/VniDrop-${version}.build-info.json"
jq -n \
--arg releaseName "$version" \
--argjson versionCode "$android_code" \
'{
releaseStatus: "draft",
releaseName: $releaseName,
versionCode: $versionCode,
track: "closed-beta",
bundleSha256: "bundle-sha",
appSigningCertificateSha256: "certificate-sha"
}' > "$input_dir/play/play-release.json"
jq -n \
--arg appVersion "$version" \
--arg packageVersion "$windows_package" \
'{appVersion: $appVersion, packageVersion: $packageVersion}' \
> "$input_dir/windows/VniDrop_${version}_x64.build-info.json"
(
cd "$input_dir/deb"
sha256sum "vnidrop_${version}-1_amd64.deb" \
> "vnidrop_${version}-1_amd64.deb.sha256"
)
(
cd "$input_dir/rpm"
sha256sum "vnidrop-${version}-1.x86_64.rpm" \
> "vnidrop-${version}-1.x86_64.rpm.sha256"
)
(
cd "$input_dir/play"
sha256sum \
"VniDrop-${version}-${android_code}-play-universal.apk" \
play-release.json \
> SHA256SUMS
)
(
cd "$input_dir/windows"
sha256sum \
"VniDrop_${version}_x64.msix" \
"VniDrop_${version}_x64.msixupload" \
"VniDrop_${version}_x64.build-info.json" \
> SHA256SUMS
)
GITHUB_REF_NAME="v$version" \
GITHUB_SHA=fixture-commit \
VNIDROP_RELEASE_INPUT_DIR="$input_dir" \
VNIDROP_RELEASE_OUTPUT_DIR="$output_dir" \
"$script_dir/assemble-release.sh" >/dev/null
expected_public_files=(
"SHA256SUMS"
"VniDrop-${version}-${android_code}-play-universal.apk"
"VniDrop-${version}.dmg"
"appcast.xml"
"release-manifest.json"
"vnidrop-${version}-1.x86_64.rpm"
"vnidrop_${version}-1_amd64.deb"
)
actual_public_files=()
while IFS= read -r file; do
actual_public_files+=("$(basename "$file")")
done < <(find "$output_dir" -maxdepth 1 -type f -print | sort)
[[ ${actual_public_files[*]} == "${expected_public_files[*]}" ]]
[[ $(jq -r '.productVersion' "$output_dir/release-manifest.json") == "$version" ]]
[[ $(jq -r '.platformVersions.appleDirectBuildNumber' \
"$output_dir/release-manifest.json") == "$apple_direct_build" ]]
[[ $(jq -r '.play.status' "$output_dir/release-manifest.json") == draft ]]
[[ $(jq -r '.windowsStore.publicReleaseAsset' "$output_dir/release-manifest.json") == false ]]
(
cd "$output_dir"
sha256sum --check SHA256SUMS >/dev/null
)

View File

@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/../.." && pwd)"
dry_run="$(make -n -C "$repo_root" build-apple-dmg)"
localization_line="$(
printf '%s\n' "$dry_run" |
awk '/bun run generate/ {print NR; exit}'
)"
build_line="$(
printf '%s\n' "$dry_run" |
awk '/apple\/scripts\/build-dmg\.sh/ {print NR; exit}'
)"
[[ -n $localization_line && -n $build_line && $localization_line -lt $build_line ]] || {
printf 'build-apple-dmg must generate localization before building the DMG\n' >&2
exit 1
}
grep -F 'run: make build-apple-dmg' \
"$repo_root/.github/workflows/apple-release.yml" >/dev/null || {
printf 'Apple release workflow must use the generated-input-aware Make target\n' >&2
exit 1
}
store_reconfigure_line="$(
awk '/msstore reconfigure/ {print NR; exit}' \
"$repo_root/.github/workflows/release.yml"
)"
store_settings_line="$(
awk '/msstore settings --enableTelemetry false/ {print NR; exit}' \
"$repo_root/.github/workflows/release.yml"
)"
[[ -n $store_reconfigure_line &&
-n $store_settings_line &&
$store_reconfigure_line -lt $store_settings_line ]] || {
printf 'Microsoft Store CLI credentials must be configured before changing settings\n' >&2
exit 1
}
signing_line="$(
awk '/sign-exported-app\.sh/ {print NR; exit}' \
"$repo_root/apple/scripts/build-dmg.sh"
)"
dmg_line="$(
awk '/echo "==> Building DMG"/ {print NR; exit}' \
"$repo_root/apple/scripts/build-dmg.sh"
)"
[[ -n $signing_line && -n $dmg_line && $signing_line -lt $dmg_line ]] || {
printf 'The exported app must enforce hardened-runtime signing before DMG creation\n' >&2
exit 1
}
printf 'Release configuration tests passed.\n'

View File

@@ -0,0 +1,78 @@
# Application versioning
`version.properties` at the repository root is the single source of truth for
the VniDrop product version and the permanent Windows version epoch. Platform
projects and release workflows derive their versions from it rather than
accepting independent release counters.
Keep it as plain `KEY=VALUE` assignments: the same file is parsed by shell,
PowerShell, Gradle, and Rust. Xcode receives resolver-generated xcconfig files.
The product uses numeric semantic versions. While the app is in beta, feature
releases increment the minor component (`0.2.0`, `0.3.0`) and fixes increment
the patch component (`0.2.1`). Release channels belong in
`RELEASE_CHANNEL`; they are not appended to store version fields.
| Platform | Product version | Platform build/package version |
| --- | --- | --- |
| Android | `PRODUCT_VERSION` | Derived monotonic integer |
| Apple Store | `PRODUCT_VERSION` | Derived UTC `YYYYMMDD.HHMM.SS` |
| Direct macOS | `PRODUCT_VERSION` | Independently derived UTC `YYYYMMDD.HHMM.SS` |
| Linux | `PRODUCT_VERSION` | Native package revision |
| Rust handshake | `PRODUCT_VERSION` | Rust crate version remains independent |
| Microsoft Store | `PRODUCT_VERSION` in the app | Derived MSIX dot-quad |
MSIX requires a non-zero first component and reserves the fourth component for
the Store. Its version is:
```text
(product major + WINDOWS_VERSION_EPOCH).product minor.product patch.0
```
With epoch `1`, product `0.2.0` maps to MSIX `1.2.0.0`, while product `1.0.0`
maps to `2.0.0.0`. Do not change the epoch after publishing.
Android derives its version code as:
```text
product major * 1,000,000 + product minor * 1,000 + product patch
```
For example, `0.2.0` maps to Android code `2000`, `0.2.1` to `2001`, and
`1.0.0` to `1000000`. To keep that mapping unique and within store limits,
the product major may not exceed `2099`, and minor and patch may not exceed
`999`. A rejected store build must use a new patch version rather than
rebuilding a previously uploaded product version.
Apple build numbers are derived at build time by `apple-store-build` and
`apple-direct-build`; they are kept as separate resolver outputs so App Store
and Sparkle releases do not consume each other's cadence. Every changed Windows
Store package must increment the product version because the Store-reserved
fourth component cannot carry a rebuild number.
Apple projects read generated build settings rather than `version.properties`
directly:
```bash
packaging/version/generate-apple-xcconfig.sh all
```
The generated files under `apple/Generated/` are intentionally ignored.
`VNIDROP_BUILD_TIME_UTC=YYYYMMDDHHMMSS` provides a deterministic clock for
tests; distribution builds normally use the current UTC time.
Prepare the next release by changing only the product version:
```bash
make prepare-release RELEASE_VERSION=0.2.1
```
The command refuses non-increasing versions, updates `PRODUCT_VERSION`, and
prints the derived Android and Microsoft Store versions. Then verify:
```bash
make check-version
```
Release tags must exactly match `vPRODUCT_VERSION`. Manual workflow dispatches
also build the committed version and do not accept free-form version inputs.

View File

@@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/../.." && pwd)"
resolver="$script_dir/resolve-version.sh"
output_dir="${VNIDROP_APPLE_XCCONFIG_DIR:-$repo_root/apple/Generated}"
mode="${1:-all}"
case "$mode" in
store|direct|all) ;;
*)
printf 'Usage: %s {store|direct|all}\n' "$0" >&2
exit 1
;;
esac
export VNIDROP_BUILD_TIME_UTC="${VNIDROP_BUILD_TIME_UTC:-$(date -u +%Y%m%d%H%M%S)}"
product_version="$("$resolver" product)"
mkdir -p "$output_dir"
write_config() {
local filename=$1
local build_field=$2
local destination="$output_dir/$filename"
local temporary
local build_number
build_number="$("$resolver" "$build_field")"
temporary="$(mktemp "$output_dir/.${filename}.XXXXXX")"
printf '%s\n' \
'// Generated by packaging/version/generate-apple-xcconfig.sh.' \
'// Regenerate this file instead of editing it.' \
'#include "../Signing.xcconfig"' \
'' \
"PRODUCT_VERSION = $product_version" \
"CURRENT_PROJECT_VERSION = $build_number" \
> "$temporary"
mv "$temporary" "$destination"
}
if [[ $mode == store || $mode == all ]]; then
write_config StoreVersion.xcconfig apple-store-build
fi
if [[ $mode == direct || $mode == all ]]; then
write_config DirectVersion.xcconfig apple-direct-build
fi

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/../.." && pwd)"
resolver="$script_dir/resolve-version.sh"
version_file="${VNIDROP_VERSION_FILE:-$repo_root/version.properties}"
next_version="${1:-}"
fail() {
printf '%s\n' "$*" >&2
exit 1
}
[[ -n $next_version ]] ||
fail "Usage: $0 MAJOR.MINOR.PATCH"
[[ -f $version_file ]] ||
fail "Version file not found: $version_file"
[[ $(grep -c '^PRODUCT_VERSION=' "$version_file") == 1 ]] ||
fail "Expected exactly one PRODUCT_VERSION entry in $version_file"
current_version="$(
VNIDROP_VERSION_FILE="$version_file" "$resolver" product
)"
current_android_code="$(
VNIDROP_VERSION_FILE="$version_file" "$resolver" android-code
)"
temporary="$(mktemp "$(dirname "$version_file")/.version.properties.XXXXXX")"
trap 'rm -f "$temporary"' EXIT
sed "s/^PRODUCT_VERSION=.*/PRODUCT_VERSION=$next_version/" \
"$version_file" > "$temporary"
next_android_code="$(
VNIDROP_VERSION_FILE="$temporary" "$resolver" android-code
)"
next_windows_package="$(
VNIDROP_VERSION_FILE="$temporary" "$resolver" windows-package
)"
VNIDROP_VERSION_FILE="$temporary" "$resolver" verify >/dev/null
(( next_android_code > current_android_code )) ||
fail "New version must be greater than $current_version"
chmod 644 "$temporary"
mv "$temporary" "$version_file"
trap - EXIT
printf 'Prepared VniDrop %s\n' "$next_version"
printf ' Android version code: %s\n' "$next_android_code"
printf ' Microsoft Store package: %s\n' "$next_windows_package"
printf 'Next: make check-version\n'

View File

@@ -0,0 +1,121 @@
[CmdletBinding()]
param(
[ValidateSet("Product", "Channel", "AndroidCode", "AppleStoreBuild", "AppleDirectBuild", "WindowsPackage", "Json", "Verify")]
[string] $Field = "Verify",
[switch] $VerifyTag,
[string] $VersionFile
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
if ([string]::IsNullOrWhiteSpace($VersionFile)) {
$VersionFile = Join-Path $PSScriptRoot "..\..\version.properties"
}
$VersionFile = (Resolve-Path -LiteralPath $VersionFile).Path
function Read-VersionProperty {
param([string] $Name)
$prefix = "$Name="
$matches = @(Get-Content -LiteralPath $VersionFile | Where-Object { $_.StartsWith($prefix) })
if ($matches.Count -ne 1) {
throw "Expected exactly one $Name entry in $VersionFile"
}
return $matches[0].Substring($prefix.Length)
}
function Convert-CanonicalInteger {
param(
[string] $Name,
[string] $Value,
[long] $Minimum,
[long] $Maximum
)
if ($Value -notmatch "^(0|[1-9][0-9]*)$") {
throw "$Name must be a canonical non-negative integer"
}
$number = 0L
if (-not [long]::TryParse($Value, [ref] $number) -or $number -lt $Minimum -or $number -gt $Maximum) {
throw "$Name must be between $Minimum and $Maximum"
}
return $number
}
$productVersion = Read-VersionProperty "PRODUCT_VERSION"
$releaseChannel = Read-VersionProperty "RELEASE_CHANNEL"
$windowsVersionEpochText = Read-VersionProperty "WINDOWS_VERSION_EPOCH"
$buildTimeUtc = $env:VNIDROP_BUILD_TIME_UTC
if ([string]::IsNullOrWhiteSpace($buildTimeUtc)) {
$buildTimeUtc = [DateTime]::UtcNow.ToString(
"yyyyMMddHHmmss",
[Globalization.CultureInfo]::InvariantCulture
)
}
if ($buildTimeUtc -notmatch "^[0-9]{14}$") {
throw "VNIDROP_BUILD_TIME_UTC must use YYYYMMDDHHMMSS"
}
$parsedBuildTime = [DateTime]::MinValue
if (-not [DateTime]::TryParseExact(
$buildTimeUtc,
"yyyyMMddHHmmss",
[Globalization.CultureInfo]::InvariantCulture,
[Globalization.DateTimeStyles]::AssumeUniversal -bor [Globalization.DateTimeStyles]::AdjustToUniversal,
[ref] $parsedBuildTime
)) {
throw "VNIDROP_BUILD_TIME_UTC is not a valid UTC timestamp"
}
$appleBuildNumber = $parsedBuildTime.ToString(
"yyyyMMdd.HHmm.ss",
[Globalization.CultureInfo]::InvariantCulture
)
if ($productVersion -notmatch "^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$") {
throw "PRODUCT_VERSION must use canonical MAJOR.MINOR.PATCH integers"
}
$productParts = $productVersion.Split(".")
$productMajor = Convert-CanonicalInteger "PRODUCT_VERSION major" $productParts[0] 0 2099
$productMinor = Convert-CanonicalInteger "PRODUCT_VERSION minor" $productParts[1] 0 999
$productPatch = Convert-CanonicalInteger "PRODUCT_VERSION patch" $productParts[2] 0 999
if ($releaseChannel -notmatch "^[a-z][a-z0-9-]*$") {
throw "RELEASE_CHANNEL contains unsupported characters"
}
$androidVersionCode = $productMajor * 1000000L + $productMinor * 1000L + $productPatch
if ($androidVersionCode -lt 1 -or $androidVersionCode -gt 2100000000L) {
throw "Derived Android version code must be between 1 and 2100000000"
}
$windowsVersionEpoch = Convert-CanonicalInteger "WINDOWS_VERSION_EPOCH" $windowsVersionEpochText 1 65535
$windowsMajor = $productMajor + $windowsVersionEpoch
if ($windowsMajor -gt 65535) {
throw "Derived Windows package major exceeds 65535"
}
$windowsPackageVersion = "$windowsMajor.$($productParts[1]).$($productParts[2]).0"
if ($VerifyTag -and $env:GITHUB_REF_TYPE -eq "tag" -and $env:GITHUB_REF_NAME -ne "v$productVersion") {
throw "Release tag must be v$productVersion, got $($env:GITHUB_REF_NAME)"
}
$versionInfo = [ordered] @{
productVersion = $productVersion
releaseChannel = $releaseChannel
androidVersionCode = $androidVersionCode
appleStoreBuildNumber = $appleBuildNumber
appleDirectBuildNumber = $appleBuildNumber
windowsPackageVersion = $windowsPackageVersion
}
switch ($Field) {
"Product" { $productVersion }
"Channel" { $releaseChannel }
"AndroidCode" { $androidVersionCode }
"AppleStoreBuild" { $appleBuildNumber }
"AppleDirectBuild" { $appleBuildNumber }
"WindowsPackage" { $windowsPackageVersion }
"Json" { $versionInfo | ConvertTo-Json -Compress }
"Verify" {
"VniDrop $productVersion ($releaseChannel), Android $androidVersionCode, Apple Store $appleBuildNumber, Apple Direct $appleBuildNumber, MSIX $windowsPackageVersion"
}
}

View File

@@ -0,0 +1,136 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/../.." && pwd)"
version_file="${VNIDROP_VERSION_FILE:-$repo_root/version.properties}"
fail() {
printf '%s\n' "$*" >&2
exit 1
}
read_property() {
local key=$1
local matches
matches="$(sed -n "s/^${key}=//p" "$version_file")"
[[ -n "$matches" ]] || fail "Missing $key in $version_file"
[[ $(printf '%s\n' "$matches" | wc -l | tr -d ' ') == 1 ]] ||
fail "Duplicate $key in $version_file"
printf '%s' "$matches"
}
validate_canonical_integer() {
local name=$1
local value=$2
local minimum=$3
local maximum=$4
[[ $value =~ ^(0|[1-9][0-9]*)$ ]] ||
fail "$name must be a canonical non-negative integer"
(( 10#$value >= minimum && 10#$value <= maximum )) ||
fail "$name must be between $minimum and $maximum"
}
product_version="$(read_property PRODUCT_VERSION)"
release_channel="$(read_property RELEASE_CHANNEL)"
windows_version_epoch="$(read_property WINDOWS_VERSION_EPOCH)"
build_time_utc="${VNIDROP_BUILD_TIME_UTC:-$(date -u +%Y%m%d%H%M%S)}"
[[ $product_version =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] ||
fail "PRODUCT_VERSION must use canonical MAJOR.MINOR.PATCH integers"
IFS=. read -r product_major product_minor product_patch <<< "$product_version"
validate_canonical_integer "PRODUCT_VERSION major" "$product_major" 0 2099
validate_canonical_integer "PRODUCT_VERSION minor" "$product_minor" 0 999
validate_canonical_integer "PRODUCT_VERSION patch" "$product_patch" 0 999
[[ $release_channel =~ ^[a-z][a-z0-9-]*$ ]] ||
fail "RELEASE_CHANNEL must start with a lowercase letter and contain only lowercase letters, digits, and hyphens"
validate_canonical_integer "WINDOWS_VERSION_EPOCH" "$windows_version_epoch" 1 65535
[[ $build_time_utc =~ ^[0-9]{14}$ ]] ||
fail "VNIDROP_BUILD_TIME_UTC must use YYYYMMDDHHMMSS"
android_version_code=$((
(10#$product_major * 1000000) +
(10#$product_minor * 1000) +
10#$product_patch
))
(( android_version_code >= 1 && android_version_code <= 2100000000 )) ||
fail "Derived Android version code must be between 1 and 2100000000"
build_month="${build_time_utc:4:2}"
build_day="${build_time_utc:6:2}"
build_hour="${build_time_utc:8:2}"
build_minute="${build_time_utc:10:2}"
build_second="${build_time_utc:12:2}"
build_year="${build_time_utc:0:4}"
(( 10#$build_year >= 1 )) ||
fail "VNIDROP_BUILD_TIME_UTC contains an invalid year"
(( 10#$build_month >= 1 && 10#$build_month <= 12 )) ||
fail "VNIDROP_BUILD_TIME_UTC contains an invalid month"
case $((10#$build_month)) in
2)
max_build_day=28
if (( 10#$build_year % 400 == 0 ||
(10#$build_year % 4 == 0 && 10#$build_year % 100 != 0) )); then
max_build_day=29
fi
;;
4|6|9|11)
max_build_day=30
;;
*)
max_build_day=31
;;
esac
(( 10#$build_day >= 1 && 10#$build_day <= max_build_day )) ||
fail "VNIDROP_BUILD_TIME_UTC contains an invalid day"
(( 10#$build_hour <= 23 && 10#$build_minute <= 59 && 10#$build_second <= 59 )) ||
fail "VNIDROP_BUILD_TIME_UTC contains an invalid time"
apple_build_number="${build_time_utc:0:8}.${build_time_utc:8:4}.${build_time_utc:12:2}"
windows_major=$((10#$product_major + 10#$windows_version_epoch))
(( windows_major <= 65535 )) ||
fail "Derived Windows package major exceeds 65535"
windows_package_version="$windows_major.$product_minor.$product_patch.0"
verify_tag() {
local tag=${1:-${GITHUB_REF_NAME:-}}
if [[ ${GITHUB_REF_TYPE:-} == tag || -n ${1:-} ]]; then
[[ $tag == "v$product_version" ]] ||
fail "Release tag must be v$product_version, got ${tag:-<empty>}"
fi
}
case "${1:-verify}" in
product)
printf '%s\n' "$product_version"
;;
channel)
printf '%s\n' "$release_channel"
;;
android-code)
printf '%s\n' "$android_version_code"
;;
apple-store-build)
printf '%s\n' "$apple_build_number"
;;
apple-direct-build)
printf '%s\n' "$apple_build_number"
;;
windows-package)
printf '%s\n' "$windows_package_version"
;;
verify)
verify_tag
printf 'VniDrop %s (%s), Android %s, Apple Store %s, Apple Direct %s, MSIX %s\n' \
"$product_version" "$release_channel" "$android_version_code" \
"$apple_build_number" "$apple_build_number" "$windows_package_version"
;;
verify-tag)
verify_tag "${2:-}"
;;
*)
fail "Usage: $0 {product|channel|android-code|apple-store-build|apple-direct-build|windows-package|verify|verify-tag [tag]}"
;;
esac

View File

@@ -0,0 +1,88 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
resolver="$script_dir/resolve-version.sh"
prepare_release="$script_dir/prepare-release.sh"
scratch="$(mktemp -d)"
trap 'rm -rf "$scratch"' EXIT
write_version() {
printf '%s\n' \
"PRODUCT_VERSION=$1" \
"RELEASE_CHANNEL=$2" \
"WINDOWS_VERSION_EPOCH=$3" \
> "$scratch/version.properties"
}
resolve() {
VNIDROP_VERSION_FILE="$scratch/version.properties" "$resolver" "$@"
}
expect_failure() {
if "$@" >/dev/null 2>&1; then
printf 'Expected command to fail: %s\n' "$*" >&2
exit 1
fi
}
export VNIDROP_BUILD_TIME_UTC=20260728143217
write_version 0.2.0 beta 1
[[ $(resolve product) == 0.2.0 ]]
[[ $(resolve android-code) == 2000 ]]
[[ $(resolve apple-store-build) == 20260728.1432.17 ]]
[[ $(resolve apple-direct-build) == 20260728.1432.17 ]]
[[ $(resolve windows-package) == 1.2.0.0 ]]
resolve verify-tag v0.2.0
expect_failure resolve verify-tag v1.0.0
write_version 1.0.0 stable 1
[[ $(resolve android-code) == 1000000 ]]
[[ $(resolve windows-package) == 2.0.0.0 ]]
write_version 2099.999.999 stable 1
[[ $(resolve android-code) == 2099999999 ]]
write_version 01.0.0 beta 1
expect_failure resolve verify
write_version 0.0.0 beta 1
expect_failure resolve verify
write_version 2100.0.0 stable 1
expect_failure resolve verify
write_version 0.1000.0 stable 1
expect_failure resolve verify
write_version 0.0.1000 stable 1
expect_failure resolve verify
VNIDROP_BUILD_TIME_UTC=20260728146000 expect_failure resolve verify
VNIDROP_BUILD_TIME_UTC=2026-07-28 expect_failure resolve verify
VNIDROP_BUILD_TIME_UTC=20260229080000 expect_failure resolve verify
write_version 0.2.0 beta 1
config_dir="$scratch/xcconfig"
VNIDROP_VERSION_FILE="$scratch/version.properties" \
VNIDROP_APPLE_XCCONFIG_DIR="$config_dir" \
"$script_dir/generate-apple-xcconfig.sh" all
grep -Fx "PRODUCT_VERSION = 0.2.0" "$config_dir/StoreVersion.xcconfig" >/dev/null
grep -Fx "CURRENT_PROJECT_VERSION = 20260728.1432.17" \
"$config_dir/StoreVersion.xcconfig" >/dev/null
grep -Fx "CURRENT_PROJECT_VERSION = 20260728.1432.17" \
"$config_dir/DirectVersion.xcconfig" >/dev/null
VNIDROP_VERSION_FILE="$scratch/version.properties" \
"$prepare_release" 0.2.1 >/dev/null
[[ $(resolve product) == 0.2.1 ]]
[[ $(resolve android-code) == 2001 ]]
[[ $(resolve windows-package) == 1.2.1.0 ]]
expect_failure env VNIDROP_VERSION_FILE="$scratch/version.properties" \
"$prepare_release" 0.2.1
expect_failure env VNIDROP_VERSION_FILE="$scratch/version.properties" \
"$prepare_release" 0.1.999
printf 'Version resolver tests passed.\n'

View File

@@ -24,15 +24,17 @@ after the first release. The manifest display name uses the exact reserved Store
name; the product's in-app branding and launcher remain `VniDrop`. name; the product's in-app branding and launcher remain `VniDrop`.
The initial package targets Windows Desktop x64, Windows 10 version 2004 The initial package targets Windows Desktop x64, Windows 10 version 2004
(build 19041) or later. The fourth MSIX version component is reserved by the (build 19041) or later. The product version comes from `version.properties`.
Store, so app version 1.2.3 becomes package version 1.2.3.0. Because MSIX requires a non-zero major and reserves the fourth component, the
package version adds `WINDOWS_VERSION_EPOCH` to the product major. With epoch
`1`, product version `0.2.0` becomes package version `1.2.0.0`.
## GitHub Actions ## GitHub Actions
The Windows Store package workflow runs automatically for relevant pull The Windows Store package workflow runs automatically for relevant pull
requests, for release tags matching vMAJOR.MINOR.PATCH, and by manual dispatch. requests and by manual dispatch. The coordinated release workflow also calls
Pull requests build and validate without retaining an artifact. Tags and manual it for a canonical `vMAJOR.MINOR.PATCH` tag. Pull requests build and validate
runs retain: without retaining an artifact. Manual and coordinated-release runs retain:
- VniDrop_VERSION_x64.msix - VniDrop_VERSION_x64.msix
- VniDrop_VERSION_x64.msixupload - VniDrop_VERSION_x64.msixupload
@@ -54,7 +56,8 @@ MakeAppx unpacks the finished package.
Microsoft's current GitHub Actions publishing flow is for updates to an Microsoft's current GitHub Actions publishing flow is for updates to an
already-live free product. For the first release: already-live free product. For the first release:
1. Run this workflow from a release tag or by manual dispatch. 1. Push a canonical release tag to run the coordinated workflow, or run the
Windows package workflow manually.
2. Download the retained artifact. 2. Download the retained artifact.
3. Test that exact build on an interactive Windows VM. Local installation needs 3. Test that exact build on an interactive Windows VM. Local installation needs
an ephemeral development signature trusted only by that VM; this is not a an ephemeral development signature trusted only by that VM; this is not a
@@ -71,25 +74,30 @@ Use this restricted-capability justification in Submission options:
> Rust and JVM libraries and needs normal user-level filesystem and network > Rust and JVM libraries and needs normal user-level filesystem and network
> access to transfer user-selected files directly between devices. > access to transfer user-selected files directly between devices.
After the first release is certified and live, Store publication can be added After the first release is certified and live, the coordinated release
as a separate protected job. Keep its Partner Center credentials in a GitHub workflow submits the generated `.msixupload` from a separate protected job.
Environment, not in this build job: Keep its Partner Center credentials in the `microsoft-store` GitHub
Environment, not in the build job:
- AZURE_AD_TENANT_ID - AZURE_AD_TENANT_ID
- AZURE_AD_APPLICATION_CLIENT_ID - AZURE_AD_APPLICATION_CLIENT_ID
- AZURE_AD_APPLICATION_SECRET - AZURE_AD_APPLICATION_SECRET
- SELLER_ID - SELLER_ID
The Store ID is a non-secret variable. Set `MICROSOFT_STORE_PRODUCT_ID` to `9NJ5Q0FG7TGL` as a non-secret variable in
the same environment. The publishing job validates the product ID,
authenticates with the pinned Microsoft Store Developer CLI, verifies access to
the product, and submits only the package for certification. Existing listings,
pricing, and availability are preserved.
## Manual build on Windows ## Manual build on Windows
From the repository root: From the repository root:
~~~powershell ~~~powershell
.\gradlew.bat :shared:jvmTest :desktopApp:createReleaseDistributable -Pvnidrop.version=1.0.0 -Pvnidrop.desktop.rustVariant=release -Pvnidrop.diagnostics.included=false --no-daemon --no-configuration-cache --stacktrace .\gradlew.bat :shared:jvmTest :desktopApp:createReleaseDistributable -Pvnidrop.desktop.rustVariant=release -Pvnidrop.diagnostics.included=false --no-daemon --no-configuration-cache --stacktrace
.\packaging\windows\build-msix.ps1 -Version 1.0.0 -AppImage .\desktopApp\build\compose\binaries\main-release\app\VniDrop -OutputDirectory .\build\release\windows .\packaging\windows\build-msix.ps1 -AppImage .\desktopApp\build\compose\binaries\main-release\app\VniDrop -OutputDirectory .\build\release\windows
~~~ ~~~
The packaging script requires Windows SDK 10.0.26100.0. It uses MakePri to The packaging script requires Windows SDK 10.0.26100.0. It uses MakePri to

View File

@@ -1,8 +1,5 @@
[CmdletBinding()] [CmdletBinding()]
param( param(
[Parameter(Mandatory)]
[string] $Version,
[Parameter(Mandatory)] [Parameter(Mandatory)]
[string] $AppImage, [string] $AppImage,
@@ -87,16 +84,11 @@ if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) {
throw "MSIX packaging must run on Windows" throw "MSIX packaging must run on Windows"
} }
$versionParts = $Version.Split(".") $versionResolver = Join-Path $PSScriptRoot "..\version\resolve-version.ps1"
Assert-Condition ($versionParts.Count -eq 3) "Version must use MAJOR.MINOR.PATCH" $versionInfoJson = & $versionResolver -Field Json -VerifyTag
for ($index = 0; $index -lt $versionParts.Count; $index++) { $versionInfo = $versionInfoJson | ConvertFrom-Json
$part = $versionParts[$index] $Version = [string] $versionInfo.productVersion
$number = 0 $packageVersion = [string] $versionInfo.windowsPackageVersion
Assert-Condition ([int]::TryParse($part, [ref] $number)) "Version components must be integers"
Assert-Condition ($number.ToString() -eq $part) "Version components must not contain leading zeroes"
Assert-Condition ($number -ge $(if ($index -eq 0) { 1 } else { 0 }) -and $number -le 65535) "Version components must be between 0 and 65535, with a non-zero major"
}
$packageVersion = "$Version.0"
$appImagePath = (Resolve-Path -LiteralPath $AppImage).Path $appImagePath = (Resolve-Path -LiteralPath $AppImage).Path
Assert-Condition (Test-Path -LiteralPath $appImagePath -PathType Container) "App image not found: $AppImage" Assert-Condition (Test-Path -LiteralPath $appImagePath -PathType Container) "App image not found: $AppImage"

View File

@@ -37,7 +37,7 @@ plugins {
alias(libs.plugins.kotlinAtomicfu) alias(libs.plugins.kotlinAtomicfu)
} }
val appVersion = providers.gradleProperty("vnidrop.version").get() val appVersion = rootProject.extra["vnidrop.productVersion"] as String
val desktopRustVariant = providers.gradleProperty("vnidrop.desktop.rustVariant") val desktopRustVariant = providers.gradleProperty("vnidrop.desktop.rustVariant")
.map { value -> .map { value ->
when (value.trim().lowercase()) { when (value.trim().lowercase()) {

View File

@@ -52,7 +52,7 @@ private class AndroidDeviceInfoProvider(
private fun Context.appVersion(): String = runCatching { private fun Context.appVersion(): String = runCatching {
packageManager.getPackageInfo(packageName, 0).versionName packageManager.getPackageInfo(packageName, 0).versionName
}.getOrNull()?.takeIf(String::isNotBlank) ?: "0.1.0" }.getOrNull()?.takeIf(String::isNotBlank) ?: "unknown"
private fun Context.activeNetworkSummary(): String? = runCatching { private fun Context.activeNetworkSummary(): String? = runCatching {
val manager = getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager val manager = getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager

View File

@@ -14,7 +14,7 @@ fun rememberJvmAppDependencies(externalInvitations: ExternalInvitationController
AppDependencies( AppDependencies(
environment = PlatformEnvironment( environment = PlatformEnvironment(
name = "Java ${System.getProperty("java.version")}", name = "Java ${System.getProperty("java.version")}",
appVersion = AppDependencies::class.java.`package`.implementationVersion ?: "0.1.0", appVersion = AppDependencies::class.java.`package`.implementationVersion ?: "unknown",
defaultCoreDataDir = System.getProperty("user.home") + "/.vnidrop", defaultCoreDataDir = System.getProperty("user.home") + "/.vnidrop",
defaultUsername = System.getenv("COMPUTERNAME") ?: System.getenv("HOSTNAME") ?: System.getProperty("user.name") ?: "Receiver", defaultUsername = System.getenv("COMPUTERNAME") ?: System.getenv("HOSTNAME") ?: System.getProperty("user.name") ?: "Receiver",
uiPlatform = uiPlatformForJvm(System.getProperty("os.name")), uiPlatform = uiPlatformForJvm(System.getProperty("os.name")),

View File

@@ -63,10 +63,61 @@ import com.vnidrop.app.ui.platform.LocalUiPlatform
import com.vnidrop.app.ui.shell.AppShell import com.vnidrop.app.ui.shell.AppShell
import com.vnidrop.app.ui.theme.VniDropTheme import com.vnidrop.app.ui.theme.VniDropTheme
import com.vnidrop.app.ui.theme.LocalVniDropColors import com.vnidrop.app.ui.theme.LocalVniDropColors
import kotlinx.coroutines.runBlocking
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFalse import kotlin.test.assertFalse
import kotlin.test.assertTrue import kotlin.test.assertTrue
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.about_is_direct
import vnidrop.shared.generated.resources.about_is_title
import vnidrop.shared.generated.resources.about_isnt_title
import vnidrop.shared.generated.resources.about_privacy_title
import vnidrop.shared.generated.resources.about_tagline
import vnidrop.shared.generated.resources.approval_endpoint_id
import vnidrop.shared.generated.resources.button_approve
import vnidrop.shared.generated.resources.button_choose_files
import vnidrop.shared.generated.resources.button_close
import vnidrop.shared.generated.resources.button_create_new_transfer
import vnidrop.shared.generated.resources.button_download_invitation
import vnidrop.shared.generated.resources.button_open_settings
import vnidrop.shared.generated.resources.button_receive_files
import vnidrop.shared.generated.resources.nav_receive
import vnidrop.shared.generated.resources.nav_send
import vnidrop.shared.generated.resources.notifications_description
import vnidrop.shared.generated.resources.notifications_local_title
import vnidrop.shared.generated.resources.notifications_title
import vnidrop.shared.generated.resources.receive_choose_method_title
import vnidrop.shared.generated.resources.receive_clear_history
import vnidrop.shared.generated.resources.receive_clear_history_description
import vnidrop.shared.generated.resources.receive_clear_history_title
import vnidrop.shared.generated.resources.receive_delete_history_item
import vnidrop.shared.generated.resources.receive_empty_title
import vnidrop.shared.generated.resources.receive_method_file
import vnidrop.shared.generated.resources.receive_new_subtitle
import vnidrop.shared.generated.resources.relay_add_url
import vnidrop.shared.generated.resources.relay_apply
import vnidrop.shared.generated.resources.relay_mode_custom
import vnidrop.shared.generated.resources.relay_strict_warning
import vnidrop.shared.generated.resources.send_access_anyone
import vnidrop.shared.generated.resources.send_choose_file_title
import vnidrop.shared.generated.resources.send_subtitle
import vnidrop.shared.generated.resources.settings_network_title
import vnidrop.shared.generated.resources.settings_subtitle
import vnidrop.shared.generated.resources.snackbar_dismiss
import vnidrop.shared.generated.resources.status_available
import vnidrop.shared.generated.resources.storage_calculating
import vnidrop.shared.generated.resources.storage_clear_transfer_cache
import vnidrop.shared.generated.resources.storage_clear_transfer_cache_description
import vnidrop.shared.generated.resources.storage_delete_transfers
import vnidrop.shared.generated.resources.storage_delete_transfers_description
import vnidrop.shared.generated.resources.storage_received_files
import vnidrop.shared.generated.resources.storage_transfer_data
import vnidrop.shared.generated.resources.transfer_qr_unavailable
import vnidrop.shared.generated.resources.transfer_scan_qr
import vnidrop.shared.generated.resources.transfer_share_title
@OptIn(ExperimentalTestApi::class) @OptIn(ExperimentalTestApi::class)
class FoundationComposeTest { class FoundationComposeTest {
@@ -82,7 +133,7 @@ class FoundationComposeTest {
) )
} }
} }
onNodeWithText("Approve").performClick() onNodeWithText(Res.string.button_approve.value).performClick()
runOnIdle { assertEquals("request", accepted) } runOnIdle { assertEquals("request", accepted) }
} }
@@ -111,8 +162,8 @@ class FoundationComposeTest {
) )
} }
} }
onNodeWithText("Notifications").performClick() onNodeWithText(Res.string.notifications_title.value).performClick()
onNodeWithText("Get notified about transfer activity while VniDrop is in the background.").assertIsDisplayed() onNodeWithText(Res.string.notifications_description.value).assertIsDisplayed()
} }
@Test @Test
@@ -162,15 +213,12 @@ class FoundationComposeTest {
} }
} }
onNodeWithText("Network").performClick() onNodeWithText(Res.string.settings_network_title.value).performClick()
onNodeWithText("Device ID: endpoint-for-allowlist").assertIsDisplayed() onNodeWithText(Res.string.approval_endpoint_id.value("endpoint-for-allowlist")).assertIsDisplayed()
onNodeWithText("Strict custom").performClick() onNodeWithText(Res.string.relay_mode_custom.value).performClick()
onNodeWithText( onNodeWithText(Res.string.relay_strict_warning.value).assertIsDisplayed()
"Strict custom mode will not start unless at least one configured relay is reachable. " + onNodeWithText(Res.string.relay_add_url.value).assertIsDisplayed()
"VniDrop never uses public relays or public discovery in this mode.", onNodeWithText(Res.string.relay_apply.value).performClick()
).assertIsDisplayed()
onNodeWithText("Add relay server").assertIsDisplayed()
onNodeWithText("Apply network settings").performClick()
runOnIdle { assertTrue(applied) } runOnIdle { assertTrue(applied) }
} }
@@ -203,20 +251,14 @@ class FoundationComposeTest {
} }
} }
onNodeWithText("Clear transfer cache").performClick() onNodeWithText(Res.string.storage_clear_transfer_cache.value).performClick()
onNodeWithText( onNodeWithText(Res.string.storage_clear_transfer_cache_description.value).assertIsDisplayed()
"Removes cached transfer content after briefly restarting VniDrop. " +
"Finish ongoing transfers and stop active shares first. Received files and transfer history are not deleted.",
).assertIsDisplayed()
runOnIdle { assertFalse(cacheClearRequested) } runOnIdle { assertFalse(cacheClearRequested) }
onNodeWithTag("confirm-clear-transfer-cache").performClick() onNodeWithTag("confirm-clear-transfer-cache").performClick()
runOnIdle { assertTrue(cacheClearRequested) } runOnIdle { assertTrue(cacheClearRequested) }
onNodeWithText("Delete all transfers").performClick() onNodeWithText(Res.string.storage_delete_transfers.value).performClick()
onNodeWithText( onNodeWithText(Res.string.storage_delete_transfers_description.value).assertIsDisplayed()
"This clears all sent and received transfer records from your history and immediately reclaims unused transfer cache. " +
"Ongoing transfers and received files are not deleted. This cant be undone.",
).assertIsDisplayed()
runOnIdle { assertFalse(deleteRequested) } runOnIdle { assertFalse(deleteRequested) }
onNodeWithTag("confirm-delete-all-transfers").performClick() onNodeWithTag("confirm-delete-all-transfers").performClick()
@@ -260,9 +302,9 @@ class FoundationComposeTest {
} }
} }
onNodeWithText("Received files").assertIsDisplayed() onNodeWithText(Res.string.storage_received_files.value).assertIsDisplayed()
onNodeWithText("Transfer data").assertIsDisplayed() onNodeWithText(Res.string.storage_transfer_data.value).assertIsDisplayed()
onAllNodesWithText("Calculating storage usage…").assertCountEquals(0) onAllNodesWithText(Res.string.storage_calculating.value).assertCountEquals(0)
} }
@Test @Test
@@ -292,14 +334,12 @@ class FoundationComposeTest {
} }
} }
onNodeWithText("Send files directly. Stay in control of who receives them.").assertIsDisplayed() onNodeWithText(Res.string.about_tagline.value).assertIsDisplayed()
onNodeWithText("What VniDrop is").assertIsDisplayed() onNodeWithText(Res.string.about_is_title.value).assertIsDisplayed()
onNodeWithText("What VniDrop isnt").assertIsDisplayed() onNodeWithText(Res.string.about_isnt_title.value).assertIsDisplayed()
onAllNodesWithText("Privacy & security").assertCountEquals(1) onAllNodesWithText(Res.string.about_privacy_title.value).assertCountEquals(1)
onAllNodesWithText("Apache 2.0").assertCountEquals(1) onAllNodesWithText("Apache 2.0").assertCountEquals(1)
val explanationBounds = onNodeWithText( val explanationBounds = onNodeWithText(Res.string.about_is_direct.value).getUnclippedBoundsInRoot()
"A direct device-to-device transfer — your files go straight to the receiver.",
).getUnclippedBoundsInRoot()
assertTrue(explanationBounds.bottom - explanationBounds.top > 32.dp) assertTrue(explanationBounds.bottom - explanationBounds.top > 32.dp)
} }
@@ -328,7 +368,7 @@ class FoundationComposeTest {
) )
} }
} }
onNodeWithText("Allow notifications").performClick() onNodeWithText(Res.string.notifications_local_title.value).performClick()
runOnIdle { assertEquals(true, enabled) } runOnIdle { assertEquals(true, enabled) }
} }
@@ -360,7 +400,7 @@ class FoundationComposeTest {
) )
} }
} }
onNodeWithText("Open Settings").performClick() onNodeWithText(Res.string.button_open_settings.value).performClick()
runOnIdle { assertTrue(opened) } runOnIdle { assertTrue(opened) }
} }
@@ -372,7 +412,7 @@ class FoundationComposeTest {
VniDropTheme(isDarkTheme = false) { VniDropSnackbarHost(controller) } VniDropTheme(isDarkTheme = false) { VniDropSnackbarHost(controller) }
} }
onNodeWithText("Saved successfully").assertIsDisplayed() onNodeWithText("Saved successfully").assertIsDisplayed()
onNodeWithContentDescription("Dismiss").assertIsDisplayed() onNodeWithContentDescription(Res.string.snackbar_dismiss.value).assertIsDisplayed()
} }
@Test @Test
@@ -392,7 +432,7 @@ class FoundationComposeTest {
val messageBottom = onNodeWithText("Notifications are turned off for VniDrop. You can enable them in Settings.") val messageBottom = onNodeWithText("Notifications are turned off for VniDrop. You can enable them in Settings.")
.getUnclippedBoundsInRoot().bottom .getUnclippedBoundsInRoot().bottom
val closeBottom = onNodeWithContentDescription("Dismiss").getUnclippedBoundsInRoot().bottom val closeBottom = onNodeWithContentDescription(Res.string.snackbar_dismiss.value).getUnclippedBoundsInRoot().bottom
val actionTop = onNodeWithText("Open Settings").getUnclippedBoundsInRoot().top val actionTop = onNodeWithText("Open Settings").getUnclippedBoundsInRoot().top
assertTrue(messageBottom <= actionTop) assertTrue(messageBottom <= actionTop)
assertTrue(closeBottom <= actionTop) assertTrue(closeBottom <= actionTop)
@@ -421,7 +461,7 @@ class FoundationComposeTest {
val overlayBottom = onNodeWithTag("snackbar-overlay").getUnclippedBoundsInRoot().bottom val overlayBottom = onNodeWithTag("snackbar-overlay").getUnclippedBoundsInRoot().bottom
val floatingActionTop = onNodeWithTag("floating-action").getUnclippedBoundsInRoot().top val floatingActionTop = onNodeWithTag("floating-action").getUnclippedBoundsInRoot().top
val navigationLabelTop = onNodeWithText("Send").getUnclippedBoundsInRoot().top val navigationLabelTop = onNodeWithText(Res.string.nav_send.value).getUnclippedBoundsInRoot().top
assertTrue(overlayBottom <= floatingActionTop) assertTrue(overlayBottom <= floatingActionTop)
assertTrue(overlayBottom <= navigationLabelTop) assertTrue(overlayBottom <= navigationLabelTop)
} }
@@ -445,7 +485,7 @@ class FoundationComposeTest {
} }
onNodeWithText("VniDrop").assertIsDisplayed() onNodeWithText("VniDrop").assertIsDisplayed()
onNodeWithText("Receive").performClick() onNodeWithText(Res.string.nav_receive.value).performClick()
runOnIdle { assertEquals(AppDestination.Receive, selected) } runOnIdle { assertEquals(AppDestination.Receive, selected) }
} }
@@ -563,9 +603,9 @@ class FoundationComposeTest {
onNodeWithTag("send-empty-icon").assertIsDisplayed() onNodeWithTag("send-empty-icon").assertIsDisplayed()
onNodeWithTag("receive-empty-icon").assertIsDisplayed() onNodeWithTag("receive-empty-icon").assertIsDisplayed()
onAllNodesWithText("Transfers youre sharing from this device.").assertCountEquals(0) onAllNodesWithText(Res.string.send_subtitle.value).assertCountEquals(0)
onAllNodesWithText("Transfers youve received on this device.").assertCountEquals(0) onAllNodesWithText(Res.string.receive_new_subtitle.value).assertCountEquals(0)
onAllNodesWithText("Your name, where transfers are saved, appearance, and notifications.").assertCountEquals(0) onAllNodesWithText(Res.string.settings_subtitle.value).assertCountEquals(0)
} }
@Test @Test
@@ -592,9 +632,9 @@ class FoundationComposeTest {
} }
} }
onNodeWithText("New transfer").performClick() onNodeWithText(Res.string.button_create_new_transfer.value).performClick()
onNodeWithText("Choose what to share").assertIsDisplayed() onNodeWithText(Res.string.send_choose_file_title.value).assertIsDisplayed()
onNodeWithText("Choose files").assertIsDisplayed() onNodeWithText(Res.string.button_choose_files.value).assertIsDisplayed()
} }
@Test @Test
@@ -627,7 +667,7 @@ class FoundationComposeTest {
} }
onNodeWithText("1.5 KB").assertIsDisplayed() onNodeWithText("1.5 KB").assertIsDisplayed()
onNodeWithText("Anyone with this transfer").performClick() onNodeWithText(Res.string.send_access_anyone.value).performClick()
runOnIdle { assertEquals(ShareAccessPolicy.AnyoneWithTransfer, selectedPolicy) } runOnIdle { assertEquals(ShareAccessPolicy.AnyoneWithTransfer, selectedPolicy) }
} }
@@ -656,7 +696,7 @@ class FoundationComposeTest {
} }
val titleBounds = onNodeWithText("Photos").getUnclippedBoundsInRoot() val titleBounds = onNodeWithText("Photos").getUnclippedBoundsInRoot()
val statusBounds = onNodeWithText("Available").getUnclippedBoundsInRoot() val statusBounds = onNodeWithText(Res.string.status_available.value).getUnclippedBoundsInRoot()
assertTrue(statusBounds.left - titleBounds.right <= 12.dp) assertTrue(statusBounds.left - titleBounds.right <= 12.dp)
onNodeWithText("Photos").performClick() onNodeWithText("Photos").performClick()
runOnIdle { assertEquals(9UL, selectedId) } runOnIdle { assertEquals(9UL, selectedId) }
@@ -679,16 +719,16 @@ class FoundationComposeTest {
} }
} }
onNodeWithContentDescription("Share").assertIsDisplayed() onNodeWithContentDescription(Res.string.transfer_share_title.value).assertIsDisplayed()
onAllNodesWithText("Scan with VniDrop to receive this transfer").assertCountEquals(0) onAllNodesWithText(Res.string.transfer_scan_qr.value).assertCountEquals(0)
onNodeWithContentDescription("Share").performClick() onNodeWithContentDescription(Res.string.transfer_share_title.value).performClick()
runOnIdle { assertEquals(com.vnidrop.app.feature.send.TransferDetailPanel.Share, state.value.detailPanel) } runOnIdle { assertEquals(com.vnidrop.app.feature.send.TransferDetailPanel.Share, state.value.detailPanel) }
waitUntil(timeoutMillis = 5_000) { waitUntil(timeoutMillis = 5_000) {
onAllNodesWithText("Scan with VniDrop to receive this transfer").fetchSemanticsNodes().isNotEmpty() onAllNodesWithText(Res.string.transfer_scan_qr.value).fetchSemanticsNodes().isNotEmpty()
} }
onNodeWithText("Scan with VniDrop to receive this transfer").assertIsDisplayed() onNodeWithText(Res.string.transfer_scan_qr.value).assertIsDisplayed()
onNodeWithText("Save .vnd file").assertIsDisplayed() onNodeWithText(Res.string.button_download_invitation.value).assertIsDisplayed()
onNodeWithContentDescription("Close").assertIsDisplayed() onNodeWithContentDescription(Res.string.button_close.value).assertIsDisplayed()
} }
@Test @Test
@@ -710,12 +750,12 @@ class FoundationComposeTest {
} }
} }
onAllNodesWithText("Share").assertCountEquals(0) onAllNodesWithText(Res.string.transfer_share_title.value).assertCountEquals(0)
onAllNodesWithText("Save .vnd file").assertCountEquals(0) onAllNodesWithText(Res.string.button_download_invitation.value).assertCountEquals(0)
runOnIdle { transfer.value = transfer.value.copy(status = TransferStatus.Failed) } runOnIdle { transfer.value = transfer.value.copy(status = TransferStatus.Failed) }
onAllNodesWithText("Share").assertCountEquals(0) onAllNodesWithText(Res.string.transfer_share_title.value).assertCountEquals(0)
onAllNodesWithText("Save .vnd file").assertCountEquals(0) onAllNodesWithText(Res.string.button_download_invitation.value).assertCountEquals(0)
} }
@Test @Test
@@ -737,8 +777,8 @@ class FoundationComposeTest {
} }
} }
onNodeWithText("QR unavailable for this invitation. Use Share or Download instead.").assertIsDisplayed() onNodeWithText(Res.string.transfer_qr_unavailable.value).assertIsDisplayed()
onNodeWithText("Save .vnd file").assertIsDisplayed() onNodeWithText(Res.string.button_download_invitation.value).assertIsDisplayed()
} }
@Test @Test
@@ -774,10 +814,10 @@ class FoundationComposeTest {
} }
} }
onNodeWithText("Nothing received yet").assertIsDisplayed() onNodeWithText(Res.string.receive_empty_title.value).assertIsDisplayed()
onNodeWithText("Start receiving").performClick() onNodeWithText(Res.string.button_receive_files.value).performClick()
onNodeWithText("How would you like to connect?").assertIsDisplayed() onNodeWithText(Res.string.receive_choose_method_title.value).assertIsDisplayed()
onNodeWithText("Open a .vnd invitation").assertIsDisplayed() onNodeWithText(Res.string.receive_method_file.value).assertIsDisplayed()
} }
@Test @Test
@@ -813,11 +853,11 @@ class FoundationComposeTest {
} }
} }
onNodeWithContentDescription("Delete from receive history").assertIsDisplayed() onNodeWithContentDescription(Res.string.receive_delete_history_item.value).assertIsDisplayed()
onNodeWithText("Clear history").performClick() onNodeWithText(Res.string.receive_clear_history.value).performClick()
onNodeWithText("Clear receive history?").assertIsDisplayed() onNodeWithText(Res.string.receive_clear_history_title.value).assertIsDisplayed()
onNodeWithText("Downloaded files will remain on this device.", substring = true).assertIsDisplayed() onNodeWithText(Res.string.receive_clear_history_description.value).assertIsDisplayed()
onNodeWithContentDescription("Close").assertIsDisplayed() onNodeWithContentDescription(Res.string.button_close.value).assertIsDisplayed()
} }
@Test @Test
@@ -883,3 +923,9 @@ class FoundationComposeTest {
updatedAt = 2L, updatedAt = 2L,
) )
} }
private val StringResource.value: String
get() = runBlocking { getString(this@value) }
private fun StringResource.value(vararg formatArgs: Any): String =
runBlocking { getString(this@value, *formatArgs) }

3
version.properties Normal file
View File

@@ -0,0 +1,3 @@
PRODUCT_VERSION=0.2.4
RELEASE_CHANNEL=beta
WINDOWS_VERSION_EPOCH=1