mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
Compare commits
48 Commits
31ba3f40b2
...
feat/share
| Author | SHA1 | Date | |
|---|---|---|---|
| 877083a3ed | |||
| 7cb2270d56 | |||
| eb8498168d | |||
| ac6e837560 | |||
| 3e18378610 | |||
| b68d338097 | |||
| b8a002a2ad | |||
| 232fb125d3 | |||
|
|
d52ac52cea | ||
| fe97c21c7a | |||
| c670dda0a9 | |||
| ff391f5502 | |||
| 9079c81409 | |||
| 5424da855e | |||
| 56d19014d4 | |||
| 51bf0abba2 | |||
| e0fb84ccb9 | |||
| 30025a4ebf | |||
| 50e9a6c1cc | |||
| 224a8e0e7a | |||
| efacfab213 | |||
|
|
0ec7618ce8 | ||
| 8b75423b7a | |||
|
|
7236933b76 | ||
| fc732e1b77 | |||
|
|
d097c82f6a | ||
| caaa9a472d | |||
|
|
4ce124da7c | ||
| 94a8b3481b | |||
|
|
6d908d8dc3 | ||
| c6655da7db | |||
| 2d7982bbb9 | |||
|
|
52d4102308 | ||
| 407a0d2d60 | |||
|
|
fc1d27bf45 | ||
| 4730554c2c | |||
| cbb535d998 | |||
| a0ebd7c71b | |||
| cc194f6a7b | |||
| 8de190a36e | |||
| 73bc87d3d1 | |||
| 2166aa9ce4 | |||
| 22b93ce94e | |||
| f3124371ee | |||
| ea2f8b1cc7 | |||
| 9b8d66f97d | |||
| a8a168ffde | |||
| 516c4ace84 |
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*.af filter=lfs diff=lfs merge=lfs -text
|
||||||
141
.github/workflows/android-release.yml
vendored
Normal file
141
.github/workflows/android-release.yml
vendored
Normal 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"
|
||||||
168
.github/workflows/apple-release.yml
vendored
Normal file
168
.github/workflows/apple-release.yml
vendored
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
name: Apple release (macOS DMG)
|
||||||
|
|
||||||
|
# Builds, signs, notarizes, and uploads the direct-download macOS build:
|
||||||
|
# - a Developer ID–signed, 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: Package prebuilt core
|
||||||
|
# build-apple-dmg builds the release Rust core + Swift bindings; bundle them
|
||||||
|
# (xcframework + Vnidrop.swift + checksum) as a release asset so consumers can
|
||||||
|
# skip building the core. See apple/scripts/package-core.sh.
|
||||||
|
run: make package-apple-core
|
||||||
|
|
||||||
|
- 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
|
||||||
|
apple/dist/VnidropCore-*.zip
|
||||||
|
apple/dist/VnidropCore-*.zip.sha256
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
9
.github/workflows/apple.yml
vendored
9
.github/workflows/apple.yml
vendored
@@ -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
|
||||||
|
|||||||
99
.github/workflows/linux-packages.yml
vendored
99
.github/workflows/linux-packages.yml
vendored
@@ -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
53
.github/workflows/release-checks.yml
vendored
Normal 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
459
.github/workflows/release.yml
vendored
Normal 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
|
||||||
4
.github/workflows/rust-core.yml
vendored
4
.github/workflows/rust-core.yml
vendored
@@ -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/**"
|
||||||
|
|||||||
4
.github/workflows/shared-kmp.yml
vendored
4
.github/workflows/shared-kmp.yml
vendored
@@ -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"
|
||||||
|
|||||||
46
.github/workflows/windows-store.yml
vendored
46
.github/workflows/windows-store.yml
vendored
@@ -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
2
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
3
LICENSE
3
LICENSE
@@ -187,7 +187,8 @@
|
|||||||
same "printed page" as the copyright notice for easier
|
same "printed page" as the copyright notice for easier
|
||||||
identification within third-party archives.
|
identification within third-party archives.
|
||||||
|
|
||||||
Copyright [yyyy] [name of copyright owner]
|
Copyright 2026 VniDrop
|
||||||
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
|
|||||||
45
Makefile
45
Makefile
@@ -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-app-config apple-project open-apple-project open-apple build-apple-macos build-apple-ios check-apple package-apple-core
|
||||||
.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,27 @@ 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 apple/scripts/generate-appconfig.sh apple/scripts/tests/test-generate-appconfig.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-generate-appconfig.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 +133,13 @@ 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-app-config: ## Generate AppConfig.swift from the shared app.properties.
|
||||||
|
cd $(ROOT) && apple/scripts/generate-appconfig.sh
|
||||||
|
|
||||||
|
apple-project: apple-core localization apple-version-config apple-app-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 +148,15 @@ 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
|
||||||
|
|
||||||
|
package-apple-core: ## Zip the prebuilt core (xcframework + bindings) + checksum into apple/dist (build the core first).
|
||||||
|
cd $(ROOT) && apple/scripts/package-core.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"
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -127,18 +127,18 @@ people, especially when using **Anyone with this transfer**.
|
|||||||
- Native SwiftUI apps on iOS, iPadOS, and macOS; Compose apps on Android,
|
- Native SwiftUI apps on iOS, iPadOS, and macOS; Compose apps on Android,
|
||||||
Windows, and Linux
|
Windows, and Linux
|
||||||
- Strict custom HTTPS relay profiles with safe apply and rollback
|
- Strict custom HTTPS relay profiles with safe apply and rollback
|
||||||
- Opt-in diagnostics with transfer contents, invitations, and file paths
|
- Optional user-submitted bug reports with transfer contents, invitations, and
|
||||||
excluded
|
file paths excluded
|
||||||
|
|
||||||
## Privacy by design
|
## Privacy by design
|
||||||
|
|
||||||
- **No hosted transfer copy.** VniDrop does not upload file contents to its
|
- **No hosted transfer copy.** VniDrop does not upload file contents to a bug-report
|
||||||
diagnostics service or a VniDrop storage bucket.
|
service or a VniDrop storage bucket.
|
||||||
- **Encrypted in transit.** Iroh connections are authenticated and encrypted
|
- **Encrypted in transit.** Iroh connections are authenticated and encrypted
|
||||||
end to end, including when a relay is needed.
|
end to end, including when a relay is needed.
|
||||||
- **Local control.** Transfer history and sharing state stay on the device.
|
- **Local control.** Transfer history and sharing state stay on the device.
|
||||||
- **Sensitive invitations.** An invitation can grant access, so it is
|
- **Sensitive invitations.** An invitation can grant access, so it is
|
||||||
deliberately excluded from product logs and diagnostics.
|
deliberately excluded from product logs and bug reports.
|
||||||
- **Explicit access.** Approval is required by default, and stopping a share
|
- **Explicit access.** Approval is required by default, and stopping a share
|
||||||
removes access immediately.
|
removes access immediately.
|
||||||
|
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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
app.properties
Normal file
4
app.properties
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Public, app-wide configuration shared by every platform (Apple + KMP).
|
||||||
|
# Plain KEY=VALUE so it is parsed identically by shell, Gradle, and codegen.
|
||||||
|
# Injected into the apps at build time — never hardcode these values in app code.
|
||||||
|
PRIVACY_POLICY_URL=https://vnidrop.sudosy.fr/privacy/
|
||||||
4
apple/.gitignore
vendored
4
apple/.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -90,7 +114,7 @@ The Rust core (iroh network stack) links `SystemConfiguration`, `Security`, and
|
|||||||
Screens mirror the Compose UI in `shared/`. Two deliberate simplifications:
|
Screens mirror the Compose UI in `shared/`. Two deliberate simplifications:
|
||||||
- Empty-state Lottie animations are rendered as SF Symbols (no `lottie-ios`
|
- Empty-state Lottie animations are rendered as SF Symbols (no `lottie-ios`
|
||||||
dependency); swap in `lottie-ios` if exact-parity animation is required.
|
dependency); swap in `lottie-ios` if exact-parity animation is required.
|
||||||
- The full diagnostics/telemetry stack (`diagnostics/*`) is stubbed behind
|
- Bug reporting is stubbed behind `BugReportService` (`NoopBugReportService`) and
|
||||||
`BugReportService` / `DiagnosticsBuildConfig` and lands in a later phase; the UI
|
a real transport lands in a later phase. There is no telemetry or crash
|
||||||
hides the diagnostics toggle when not compiled in.
|
auto-reporting.
|
||||||
```
|
```
|
||||||
|
|||||||
39
apple/Tests/AppConfigTests.swift
Normal file
39
apple/Tests/AppConfigTests.swift
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import VniDrop
|
||||||
|
|
||||||
|
/// Verifies the build-time `AppConfig` (generated from the shared `app.properties`)
|
||||||
|
/// exposes the expected, well-formed values to the app.
|
||||||
|
final class AppConfigTests: XCTestCase {
|
||||||
|
func testPrivacyPolicyURLIsTheExpectedHTTPSEndpoint() {
|
||||||
|
let url = AppConfig.privacyPolicyURL
|
||||||
|
XCTAssertEqual(url.scheme, "https", "Privacy policy URL must be https")
|
||||||
|
XCTAssertEqual(url.absoluteString, "https://vnidrop.sudosy.fr/privacy/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPrivacyPolicyURLMatchesTheSharedConfigFile() throws {
|
||||||
|
// Cross-check the generated constant against the single source of truth so a
|
||||||
|
// broken generator (or drift) is caught, not just a hardcoded copy.
|
||||||
|
let expected = try Self.privacyURLFromAppProperties()
|
||||||
|
XCTAssertEqual(AppConfig.privacyPolicyURL.absoluteString, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads `PRIVACY_POLICY_URL` from the repo's `app.properties` by walking up
|
||||||
|
/// from this source file's location to the repository root.
|
||||||
|
private static func privacyURLFromAppProperties() throws -> String {
|
||||||
|
var dir = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
|
||||||
|
for _ in 0..<8 {
|
||||||
|
let candidate = dir.appendingPathComponent("app.properties")
|
||||||
|
if FileManager.default.fileExists(atPath: candidate.path) {
|
||||||
|
let contents = try String(contentsOf: candidate, encoding: .utf8)
|
||||||
|
for line in contents.split(whereSeparator: \.isNewline) {
|
||||||
|
if line.hasPrefix("PRIVACY_POLICY_URL=") {
|
||||||
|
return String(line.dropFirst("PRIVACY_POLICY_URL=".count))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw XCTSkip("PRIVACY_POLICY_URL missing in \(candidate.path)")
|
||||||
|
}
|
||||||
|
dir.deleteLastPathComponent()
|
||||||
|
}
|
||||||
|
throw XCTSkip("app.properties not found from \(#filePath)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,8 +15,7 @@ final class SettingsModelTests: XCTestCase {
|
|||||||
preferences: preferences,
|
preferences: preferences,
|
||||||
notifications: LocalNotificationService(),
|
notifications: LocalNotificationService(),
|
||||||
messages: UiMessageController(),
|
messages: UiMessageController(),
|
||||||
bugReports: NoopBugReportService(),
|
bugReports: NoopBugReportService()
|
||||||
diagnosticsIncluded: false
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -23,8 +24,7 @@ final class AppGraph: ObservableObject {
|
|||||||
fallback: AppPreferencesDefaults(
|
fallback: AppPreferencesDefaults(
|
||||||
username: dependencies.environment.defaultUsername,
|
username: dependencies.environment.defaultUsername,
|
||||||
receiveFolder: dependencies.fileSystemService.defaultReceiveFolder(),
|
receiveFolder: dependencies.fileSystemService.defaultReceiveFolder(),
|
||||||
themeMode: .system,
|
themeMode: .system
|
||||||
diagnosticsEnabled: false
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.approvalCoordinator = ApprovalCoordinator(
|
self.approvalCoordinator = ApprovalCoordinator(
|
||||||
@@ -39,6 +39,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])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ struct RootView: View {
|
|||||||
@StateObject private var sendModel: SendModel
|
@StateObject private var sendModel: SendModel
|
||||||
@StateObject private var receiveModel: ReceiveModel
|
@StateObject private var receiveModel: ReceiveModel
|
||||||
@StateObject private var settingsModel: SettingsModel
|
@StateObject private var settingsModel: SettingsModel
|
||||||
@ObservedObject private var messages: UiMessageController
|
|
||||||
@ObservedObject private var approvals: ApprovalCoordinator
|
|
||||||
|
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
@@ -46,8 +44,6 @@ struct RootView: View {
|
|||||||
messages: graph.messages,
|
messages: graph.messages,
|
||||||
bugReports: NoopBugReportService()
|
bugReports: NoopBugReportService()
|
||||||
))
|
))
|
||||||
messages = graph.messages
|
|
||||||
approvals = graph.approvalCoordinator
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -56,12 +52,17 @@ struct RootView: View {
|
|||||||
let isDark = resolveDarkTheme(appModel.themeMode, systemDark: systemDark)
|
let isDark = resolveDarkTheme(appModel.themeMode, systemDark: systemDark)
|
||||||
ZStack {
|
ZStack {
|
||||||
navigation(windowClass: windowClass)
|
navigation(windowClass: windowClass)
|
||||||
SnackbarHost(controller: messages)
|
// Observe the coordinator/messages from the *persisted* `graph`
|
||||||
ApprovalModalHost(
|
// StateObject. Deriving them in `init` bound the view to a throwaway
|
||||||
state: approvals.state,
|
// AppGraph rebuilt on every re-init, whose coordinator never receives
|
||||||
onAccept: approvals.accept,
|
// core events — so the approval modal never appeared.
|
||||||
onRefuse: approvals.refuse
|
ApprovalLayer(
|
||||||
|
approvals: graph.approvalCoordinator,
|
||||||
|
sendModel: sendModel
|
||||||
)
|
)
|
||||||
|
// Top-most so the toast is never covered by the approval overlay's
|
||||||
|
// full-bleed clear layer. Observes the live `graph.messages` directly.
|
||||||
|
SnackbarHost(controller: graph.messages)
|
||||||
}
|
}
|
||||||
.overlay {
|
.overlay {
|
||||||
// A small, unobtrusive indicator while the core finishes its async
|
// A small, unobtrusive indicator while the core finishes its async
|
||||||
@@ -81,23 +82,23 @@ 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
|
|
||||||
// (e.g. the Share/QR sheet) so the approval sheet isn't presented under it
|
|
||||||
// on macOS.
|
|
||||||
.onChange(of: approvals.state.current?.id) { _, id in
|
|
||||||
if id != nil { sendModel.closeDetailPanel() }
|
|
||||||
}
|
|
||||||
#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
|
||||||
// drive foreground/background off NSApplication's active state instead —
|
// drive foreground/background off NSApplication's active state instead —
|
||||||
@@ -191,6 +192,66 @@ struct RootView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hosts the approval modal, observing the coordinator passed in from the persisted
|
||||||
|
/// `AppGraph`. Kept as a child view so the `@ObservedObject` subscription is
|
||||||
|
/// established here (in `body`) against the live instance, rather than in
|
||||||
|
/// `RootView.init` against a throwaway graph.
|
||||||
|
private struct ApprovalLayer: View {
|
||||||
|
@ObservedObject var approvals: ApprovalCoordinator
|
||||||
|
let sendModel: SendModel
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
|
||||||
|
/// macOS-only: an approval arrived while a share/QR sheet was still up. We close
|
||||||
|
/// that sheet and present the approval once its dismissal completes (see
|
||||||
|
/// `sendModel.shareSheetsDismissed`), since macOS drops a sheet shown mid-dismissal.
|
||||||
|
@State private var approvalAwaitingSheetDismiss = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ApprovalModalHost(
|
||||||
|
isPresented: $showApproval,
|
||||||
|
state: approvals.state,
|
||||||
|
onAccept: approvals.accept,
|
||||||
|
onRefuse: approvals.refuse
|
||||||
|
)
|
||||||
|
// A pending approval is a blocking modal. Close any open share/QR sheet first
|
||||||
|
// (the detail-view panel *or* the list-level share sheet), then present the
|
||||||
|
// approval sheet: the approval is presented from the app root and neither
|
||||||
|
// platform reliably stacks it over a sheet owned by the Send screen.
|
||||||
|
.onChange(of: approvals.state.current?.id) { _, id in
|
||||||
|
guard id != nil else {
|
||||||
|
showApproval = false
|
||||||
|
approvalAwaitingSheetDismiss = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let wasShowingSheet = sendModel.state.detailPanel != nil
|
||||||
|
|| sendModel.state.shareTargetId != nil
|
||||||
|
sendModel.dismissShareSheets()
|
||||||
|
#if os(macOS)
|
||||||
|
// macOS silently drops a sheet presented while another is still dismissing,
|
||||||
|
// so wait for that sheet's real dismissal completion before presenting.
|
||||||
|
if wasShowingSheet {
|
||||||
|
approvalAwaitingSheetDismiss = true
|
||||||
|
} else {
|
||||||
|
showApproval = true
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
_ = wasShowingSheet
|
||||||
|
showApproval = true
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
#if os(macOS)
|
||||||
|
.onReceive(sendModel.shareSheetsDismissed) { _ in
|
||||||
|
guard approvalAwaitingSheetDismiss else { return }
|
||||||
|
approvalAwaitingSheetDismiss = false
|
||||||
|
if approvals.state.current != nil { showApproval = true }
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A full-window cover with a centered spinner shown while the core is starting.
|
/// A full-window cover with a centered spinner shown while the core is starting.
|
||||||
private struct CoreStartingOverlay: View {
|
private struct CoreStartingOverlay: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -120,7 +120,6 @@ struct AppPreferences: Equatable {
|
|||||||
var username: String
|
var username: String
|
||||||
var receiveFolder: ReceiveFolder
|
var receiveFolder: ReceiveFolder
|
||||||
var themeMode: ThemeMode
|
var themeMode: ThemeMode
|
||||||
var diagnosticsEnabled: Bool
|
|
||||||
var diagnosticsInstallId: String
|
var diagnosticsInstallId: String
|
||||||
var relayConfiguration: RelayConfiguration
|
var relayConfiguration: RelayConfiguration
|
||||||
}
|
}
|
||||||
@@ -129,7 +128,6 @@ struct AppPreferencesDefaults {
|
|||||||
let username: String
|
let username: String
|
||||||
let receiveFolder: ReceiveFolder
|
let receiveFolder: ReceiveFolder
|
||||||
let themeMode: ThemeMode
|
let themeMode: ThemeMode
|
||||||
var diagnosticsEnabled: Bool = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -145,7 +143,6 @@ final class AppPreferencesRepository: ObservableObject {
|
|||||||
static let receiveFolderValue = "receive_folder_value"
|
static let receiveFolderValue = "receive_folder_value"
|
||||||
static let receiveFolderDisplayName = "receive_folder_display_name"
|
static let receiveFolderDisplayName = "receive_folder_display_name"
|
||||||
static let themeMode = "theme_mode"
|
static let themeMode = "theme_mode"
|
||||||
static let diagnosticsEnabled = "diagnostics_enabled"
|
|
||||||
static let diagnosticsInstallId = "diagnostics_install_id"
|
static let diagnosticsInstallId = "diagnostics_install_id"
|
||||||
static let relayConfiguration = "relay_configuration"
|
static let relayConfiguration = "relay_configuration"
|
||||||
}
|
}
|
||||||
@@ -160,13 +157,11 @@ final class AppPreferencesRepository: ObservableObject {
|
|||||||
let username = (defaults.string(forKey: Key.username)).flatMap { $0.isEmpty ? nil : $0 } ?? fallback.username
|
let username = (defaults.string(forKey: Key.username)).flatMap { $0.isEmpty ? nil : $0 } ?? fallback.username
|
||||||
let folder = resolveReceiveFolder(defaults, fallback: fallback.receiveFolder)
|
let folder = resolveReceiveFolder(defaults, fallback: fallback.receiveFolder)
|
||||||
let themeMode = defaults.string(forKey: Key.themeMode).flatMap(ThemeMode.init(rawValue:)) ?? fallback.themeMode
|
let themeMode = defaults.string(forKey: Key.themeMode).flatMap(ThemeMode.init(rawValue:)) ?? fallback.themeMode
|
||||||
let diagnostics = defaults.object(forKey: Key.diagnosticsEnabled) as? Bool ?? fallback.diagnosticsEnabled
|
|
||||||
let installId = defaults.string(forKey: Key.diagnosticsInstallId) ?? ""
|
let installId = defaults.string(forKey: Key.diagnosticsInstallId) ?? ""
|
||||||
return AppPreferences(
|
return AppPreferences(
|
||||||
username: username,
|
username: username,
|
||||||
receiveFolder: folder,
|
receiveFolder: folder,
|
||||||
themeMode: themeMode,
|
themeMode: themeMode,
|
||||||
diagnosticsEnabled: diagnostics,
|
|
||||||
diagnosticsInstallId: installId,
|
diagnosticsInstallId: installId,
|
||||||
relayConfiguration: resolveRelayConfiguration(defaults)
|
relayConfiguration: resolveRelayConfiguration(defaults)
|
||||||
)
|
)
|
||||||
@@ -219,11 +214,6 @@ final class AppPreferencesRepository: ObservableObject {
|
|||||||
reload()
|
reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
func setDiagnosticsEnabled(_ enabled: Bool) {
|
|
||||||
defaults.set(enabled, forKey: Key.diagnosticsEnabled)
|
|
||||||
reload()
|
|
||||||
}
|
|
||||||
|
|
||||||
func setRelayConfiguration(_ configuration: RelayConfiguration) {
|
func setRelayConfiguration(_ configuration: RelayConfiguration) {
|
||||||
guard let encoded = try? JSONEncoder().encode(configuration) else { return }
|
guard let encoded = try? JSONEncoder().encode(configuration) else { return }
|
||||||
defaults.set(encoded, forKey: Key.relayConfiguration)
|
defaults.set(encoded, forKey: Key.relayConfiguration)
|
||||||
|
|||||||
77
apple/VniDrop/Core/BackgroundActivityController.swift
Normal file
77
apple/VniDrop/Core/BackgroundActivityController.swift
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -19,7 +19,19 @@ struct LocalNotification {
|
|||||||
/// Presents notifications even while the app is active. Without a delegate the
|
/// Presents notifications even while the app is active. Without a delegate the
|
||||||
/// system drops the banner when the app is frontmost — very visible on macOS,
|
/// system drops the banner when the app is frontmost — very visible on macOS,
|
||||||
/// where the app window is usually open when a transfer completes.
|
/// where the app window is usually open when a transfer completes.
|
||||||
private final class NotificationPresenter: NSObject, UNUserNotificationCenterDelegate {
|
///
|
||||||
|
/// `@MainActor` is required, not just convenient: these delegate methods are
|
||||||
|
/// `async`, so their continuation resumes at the return point on whatever executor
|
||||||
|
/// they ran on. When the system hands a notification-tap back to UIKit it performs
|
||||||
|
/// state-restoration/snapshot work synchronously on that thread — which asserts
|
||||||
|
/// "Call must be made on main thread" and crashes if the method returned off-main.
|
||||||
|
/// Main-actor isolation guarantees the return happens on the main thread.
|
||||||
|
// `@preconcurrency` on the conformance: these delegate requirements are nonisolated
|
||||||
|
// with non-Sendable UN* parameters, which strict concurrency won't otherwise let a
|
||||||
|
// main actor-isolated type witness. The main-actor isolation is what fixes the
|
||||||
|
// crash (see the type doc above); the attribute inserts the runtime hop.
|
||||||
|
@MainActor
|
||||||
|
private final class NotificationPresenter: NSObject, @preconcurrency UNUserNotificationCenterDelegate {
|
||||||
func userNotificationCenter(
|
func userNotificationCenter(
|
||||||
_ center: UNUserNotificationCenter,
|
_ center: UNUserNotificationCenter,
|
||||||
willPresent notification: UNNotification
|
willPresent notification: UNNotification
|
||||||
@@ -36,7 +48,6 @@ private final class NotificationPresenter: NSObject, UNUserNotificationCenterDel
|
|||||||
didReceive response: UNNotificationResponse
|
didReceive response: UNNotificationResponse
|
||||||
) async {
|
) async {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
await MainActor.run {
|
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
// Reopen/focus the single main window (activation triggers SwiftUI's
|
// Reopen/focus the single main window (activation triggers SwiftUI's
|
||||||
// reopen handling when it was closed).
|
// reopen handling when it was closed).
|
||||||
@@ -44,7 +55,6 @@ private final class NotificationPresenter: NSObject, UNUserNotificationCenterDel
|
|||||||
window.makeKeyAndOrderFront(nil)
|
window.makeKeyAndOrderFront(nil)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ struct SendState: Equatable {
|
|||||||
var selectedTransferId: UInt64?
|
var selectedTransferId: UInt64?
|
||||||
var transferThumbnails: [UInt64: Data] = [:]
|
var transferThumbnails: [UInt64: Data] = [:]
|
||||||
var detailPanel: TransferDetailPanel?
|
var detailPanel: TransferDetailPanel?
|
||||||
|
/// Transfer whose share panel is presented inline from the list context menu
|
||||||
|
/// (distinct from `detailPanel == .share`, which shows it from the detail view).
|
||||||
|
/// Held in the model — not `SendScreen` @State — so the approval flow can dismiss
|
||||||
|
/// it centrally before presenting its modal.
|
||||||
|
var shareTargetId: UInt64?
|
||||||
var receiverHistory: [ReceiverRequestModel] = []
|
var receiverHistory: [ReceiverRequestModel] = []
|
||||||
var isLoadingReceivers = false
|
var isLoadingReceivers = false
|
||||||
var isDeleteConfirmationOpen = false
|
var isDeleteConfirmationOpen = false
|
||||||
@@ -56,6 +61,18 @@ final class SendModel: ObservableObject {
|
|||||||
private let messages: UiMessageController
|
private let messages: UiMessageController
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
/// Fires *after* a share/QR sheet (the detail-view panel or the list-level share
|
||||||
|
/// sheet) has finished animating out. The approval flow waits on this to present
|
||||||
|
/// its modal on macOS, where a sheet shown while another is still dismissing is
|
||||||
|
/// dropped — using the real completion instead of a guessed delay.
|
||||||
|
private let shareSheetsDismissedSubject = PassthroughSubject<Void, Never>()
|
||||||
|
var shareSheetsDismissed: AnyPublisher<Void, Never> {
|
||||||
|
shareSheetsDismissedSubject.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invoked by a share sheet's `onDismiss` completion.
|
||||||
|
func shareSheetDidDismiss() { shareSheetsDismissedSubject.send(()) }
|
||||||
|
|
||||||
init(
|
init(
|
||||||
repository: CoreGateway,
|
repository: CoreGateway,
|
||||||
fileSystemService: FileSystemService,
|
fileSystemService: FileSystemService,
|
||||||
@@ -154,7 +171,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() {
|
||||||
@@ -201,6 +218,17 @@ final class SendModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
func closeDetailPanel() { state.detailPanel = nil }
|
func closeDetailPanel() { state.detailPanel = nil }
|
||||||
|
|
||||||
|
func openShareTarget(_ transferId: UInt64) { state.shareTargetId = transferId }
|
||||||
|
func closeShareTarget() { state.shareTargetId = nil }
|
||||||
|
|
||||||
|
/// Dismisses every share/QR surface at once — the detail-view share panel and the
|
||||||
|
/// list-level share sheet. Used before presenting the receiver-approval modal, so
|
||||||
|
/// no competing sheet is left open (macOS drops a sheet shown over another).
|
||||||
|
func dismissShareSheets() {
|
||||||
|
state.detailPanel = nil
|
||||||
|
state.shareTargetId = nil
|
||||||
|
}
|
||||||
|
|
||||||
func requestDeleteTransfer() { state.isDeleteConfirmationOpen = true }
|
func requestDeleteTransfer() { state.isDeleteConfirmationOpen = true }
|
||||||
func dismissDeleteTransfer() { if !state.isDeleting { state.isDeleteConfirmationOpen = false } }
|
func dismissDeleteTransfer() { if !state.isDeleting { state.isDeleteConfirmationOpen = false } }
|
||||||
|
|
||||||
@@ -257,8 +285,19 @@ final class SendModel: ObservableObject {
|
|||||||
/// Uses the core's `respondReceiverRequest` (no backend change); applies to
|
/// Uses the core's `respondReceiverRequest` (no backend change); applies to
|
||||||
/// receivers that are still pending or accepted.
|
/// receivers that are still pending or accepted.
|
||||||
func cancelReceiver(requestId: String) {
|
func cancelReceiver(requestId: String) {
|
||||||
|
respondToReceiver(requestId: requestId, accepted: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Approves a single pending receiver by responding to its request positively.
|
||||||
|
/// A fallback for when the approval modal didn't surface — the pending receiver
|
||||||
|
/// can still be accepted from its row in the transfer's receivers panel.
|
||||||
|
func acceptReceiver(requestId: String) {
|
||||||
|
respondToReceiver(requestId: requestId, accepted: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func respondToReceiver(requestId: String, accepted: Bool) {
|
||||||
Task {
|
Task {
|
||||||
let result = await repository.respondReceiverRequest(requestId: requestId, accepted: false, reason: nil)
|
let result = await repository.respondReceiverRequest(requestId: requestId, accepted: accepted, reason: nil)
|
||||||
switch result {
|
switch result {
|
||||||
case .success:
|
case .success:
|
||||||
if let transferId = state.selectedTransferId { refreshReceivers(transferId) }
|
if let transferId = state.selectedTransferId { refreshReceivers(transferId) }
|
||||||
|
|||||||
@@ -7,14 +7,18 @@ struct SendScreen: View {
|
|||||||
@ObservedObject var model: SendModel
|
@ObservedObject var model: SendModel
|
||||||
let windowClass: WindowClass
|
let windowClass: WindowClass
|
||||||
|
|
||||||
/// Transfer whose share panel is presented inline from the list context menu.
|
|
||||||
@State private var shareTarget: Transfer?
|
|
||||||
/// Transfer pending an inline (list-level) delete confirmation.
|
/// Transfer pending an inline (list-level) delete confirmation.
|
||||||
@State private var deleteTarget: Transfer?
|
@State private var deleteTarget: Transfer?
|
||||||
|
|
||||||
private var outgoing: [Transfer] {
|
private var outgoing: [Transfer] {
|
||||||
model.coreState.transfers.filter { $0.direction == .send }
|
model.coreState.transfers.filter { $0.direction == .send }
|
||||||
}
|
}
|
||||||
|
/// The transfer whose list-level share sheet is open, resolved from the model's
|
||||||
|
/// `shareTargetId` (kept in the model so the approval flow can dismiss it).
|
||||||
|
private var shareTarget: Transfer? {
|
||||||
|
guard let id = model.state.shareTargetId else { return nil }
|
||||||
|
return outgoing.first { $0.transferId == id }
|
||||||
|
}
|
||||||
private var selectedTransfer: Transfer? {
|
private var selectedTransfer: Transfer? {
|
||||||
guard let id = model.state.selectedTransferId else { return nil }
|
guard let id = model.state.selectedTransferId else { return nil }
|
||||||
return outgoing.first { $0.transferId == id }
|
return outgoing.first { $0.transferId == id }
|
||||||
@@ -50,9 +54,10 @@ struct SendScreen: View {
|
|||||||
// composer drawer on the outer body, so the two don't clash). Opens the
|
// composer drawer on the outer body, so the two don't clash). Opens the
|
||||||
// share panel over the list without navigating into the transfer detail.
|
// share panel over the list without navigating into the transfer detail.
|
||||||
.adaptiveDrawer(
|
.adaptiveDrawer(
|
||||||
isPresented: Binding(get: { shareTarget != nil }, set: { if !$0 { shareTarget = nil } }),
|
isPresented: Binding(get: { shareTarget != nil }, set: { if !$0 { model.closeShareTarget() } }),
|
||||||
windowClass: windowClass,
|
windowClass: windowClass,
|
||||||
onDismiss: { shareTarget = nil }
|
onDismiss: model.closeShareTarget,
|
||||||
|
onDismissed: model.shareSheetDidDismiss
|
||||||
) {
|
) {
|
||||||
if let shareTarget {
|
if let shareTarget {
|
||||||
TransferSharePanel(model: model, transfer: shareTarget)
|
TransferSharePanel(model: model, transfer: shareTarget)
|
||||||
@@ -92,7 +97,8 @@ struct SendScreen: View {
|
|||||||
.adaptiveDrawer(
|
.adaptiveDrawer(
|
||||||
isPresented: Binding(get: { model.state.detailPanel != nil }, set: { _ in }),
|
isPresented: Binding(get: { model.state.detailPanel != nil }, set: { _ in }),
|
||||||
windowClass: windowClass,
|
windowClass: windowClass,
|
||||||
onDismiss: model.closeDetailPanel
|
onDismiss: model.closeDetailPanel,
|
||||||
|
onDismissed: model.shareSheetDidDismiss
|
||||||
) {
|
) {
|
||||||
if let panel = model.state.detailPanel {
|
if let panel = model.state.detailPanel {
|
||||||
DetailPanelContent(model: model, transfer: transfer, panel: panel)
|
DetailPanelContent(model: model, transfer: transfer, panel: panel)
|
||||||
@@ -127,7 +133,7 @@ struct SendScreen: View {
|
|||||||
.contextMenu {
|
.contextMenu {
|
||||||
if transfer.ticket != nil {
|
if transfer.ticket != nil {
|
||||||
Button {
|
Button {
|
||||||
shareTarget = transfer
|
model.openShareTarget(transfer.transferId)
|
||||||
} label: {
|
} label: {
|
||||||
Label(String(localized: L10n.Transfer.shareTitle), systemSymbol: .squareAndArrowUp)
|
Label(String(localized: L10n.Transfer.shareTitle), systemSymbol: .squareAndArrowUp)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,7 +142,8 @@ struct DetailPanelContent: View {
|
|||||||
loading: model.state.isLoadingReceivers,
|
loading: model.state.isLoadingReceivers,
|
||||||
events: model.coreState.events,
|
events: model.coreState.events,
|
||||||
transferTotalSize: transfer.totalSize,
|
transferTotalSize: transfer.totalSize,
|
||||||
onCancel: model.cancelReceiver
|
onCancel: model.cancelReceiver,
|
||||||
|
onAccept: model.acceptReceiver
|
||||||
)
|
)
|
||||||
case .share:
|
case .share:
|
||||||
TransferSharePanel(model: model, transfer: transfer)
|
TransferSharePanel(model: model, transfer: transfer)
|
||||||
@@ -193,6 +194,7 @@ struct ReceiverHistoryPanel: View {
|
|||||||
let events: [CoreEventModel]
|
let events: [CoreEventModel]
|
||||||
let transferTotalSize: UInt64
|
let transferTotalSize: UInt64
|
||||||
let onCancel: (String) -> Void
|
let onCancel: (String) -> Void
|
||||||
|
let onAccept: (String) -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
PanelContainer(title: String(localized: L10n.Transfer.receiversTitle)) {
|
PanelContainer(title: String(localized: L10n.Transfer.receiversTitle)) {
|
||||||
@@ -203,7 +205,12 @@ struct ReceiverHistoryPanel: View {
|
|||||||
} else {
|
} else {
|
||||||
ForEach(Array(receivers.enumerated()), id: \.element.id) { index, receiver in
|
ForEach(Array(receivers.enumerated()), id: \.element.id) { index, receiver in
|
||||||
if index > 0 { Divider().overlay(colors.borderDefault) }
|
if index > 0 { Divider().overlay(colors.borderDefault) }
|
||||||
ReceiverRow(receiver: receiver, sendProgress: sendProgress(for: receiver), onCancel: onCancel)
|
ReceiverRow(
|
||||||
|
receiver: receiver,
|
||||||
|
sendProgress: sendProgress(for: receiver),
|
||||||
|
onCancel: onCancel,
|
||||||
|
onAccept: onAccept
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,6 +231,7 @@ private struct ReceiverRow: View {
|
|||||||
let receiver: ReceiverRequestModel
|
let receiver: ReceiverRequestModel
|
||||||
let sendProgress: TransferProgress?
|
let sendProgress: TransferProgress?
|
||||||
let onCancel: (String) -> Void
|
let onCancel: (String) -> Void
|
||||||
|
let onAccept: (String) -> Void
|
||||||
|
|
||||||
/// Only pending requests can be cancelled per-receiver: the core rejects a
|
/// Only pending requests can be cancelled per-receiver: the core rejects a
|
||||||
/// negative response to an already-accepted request ("...not approved, or it
|
/// negative response to an already-accepted request ("...not approved, or it
|
||||||
@@ -257,6 +265,7 @@ private struct ReceiverRow: View {
|
|||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
if isCancelable {
|
if isCancelable {
|
||||||
|
VStack(alignment: .trailing, spacing: 8) {
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
onCancel(receiver.id)
|
onCancel(receiver.id)
|
||||||
} label: {
|
} label: {
|
||||||
@@ -265,6 +274,18 @@ private struct ReceiverRow: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.borderless)
|
.buttonStyle(.borderless)
|
||||||
.tint(.red)
|
.tint(.red)
|
||||||
|
// Fallback approve action, in case the approval modal didn't surface.
|
||||||
|
Button {
|
||||||
|
onAccept(receiver.id)
|
||||||
|
} label: {
|
||||||
|
Text(String(localized: L10n.Button.approve))
|
||||||
|
.font(VniType.bodySmall).fontWeight(.medium)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 16).padding(.vertical, 7)
|
||||||
|
.background(Color.green, in: Capsule())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|||||||
@@ -20,12 +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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the diagnostics stack is compiled in (mirrors DiagnosticsBuildConfig).
|
|
||||||
enum DiagnosticsBuildConfig {
|
|
||||||
static let included = false
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ struct SettingsState: Equatable {
|
|||||||
var supportsCustomReceiveFolders = true
|
var supportsCustomReceiveFolders = true
|
||||||
var themeMode: ThemeMode = .system
|
var themeMode: ThemeMode = .system
|
||||||
var notificationPermission: NotificationPermission = .notDetermined
|
var notificationPermission: NotificationPermission = .notDetermined
|
||||||
var diagnosticsEnabled = false
|
|
||||||
var relayMode: RelayPreferenceMode = .automatic
|
var relayMode: RelayPreferenceMode = .automatic
|
||||||
var relayURLs: [String] = []
|
var relayURLs: [String] = []
|
||||||
var relayValidationError: RelayConfigurationValidationError?
|
var relayValidationError: RelayConfigurationValidationError?
|
||||||
@@ -76,7 +75,7 @@ struct SettingsState: Equatable {
|
|||||||
&& lhs.supportsCustomReceiveFolders == rhs.supportsCustomReceiveFolders
|
&& lhs.supportsCustomReceiveFolders == rhs.supportsCustomReceiveFolders
|
||||||
&& lhs.themeMode == rhs.themeMode
|
&& lhs.themeMode == rhs.themeMode
|
||||||
&& lhs.notificationPermission == rhs.notificationPermission
|
&& lhs.notificationPermission == rhs.notificationPermission
|
||||||
&& lhs.diagnosticsEnabled == rhs.diagnosticsEnabled && lhs.appVersion == rhs.appVersion
|
&& lhs.appVersion == rhs.appVersion
|
||||||
&& lhs.relayMode == rhs.relayMode && lhs.relayURLs == rhs.relayURLs
|
&& lhs.relayMode == rhs.relayMode && lhs.relayURLs == rhs.relayURLs
|
||||||
&& lhs.relayValidationError == rhs.relayValidationError
|
&& lhs.relayValidationError == rhs.relayValidationError
|
||||||
&& lhs.relayConfigurationIsDirty == rhs.relayConfigurationIsDirty
|
&& lhs.relayConfigurationIsDirty == rhs.relayConfigurationIsDirty
|
||||||
@@ -111,7 +110,6 @@ final class SettingsModel: ObservableObject {
|
|||||||
private let notifications: LocalNotificationService
|
private let notifications: LocalNotificationService
|
||||||
private let messages: UiMessageController
|
private let messages: UiMessageController
|
||||||
private let bugReports: BugReportService
|
private let bugReports: BugReportService
|
||||||
private let diagnosticsIncluded: Bool
|
|
||||||
|
|
||||||
private var usernamePersistTask: Task<Void, Never>?
|
private var usernamePersistTask: Task<Void, Never>?
|
||||||
private var hasLocalUsernameDraft = false
|
private var hasLocalUsernameDraft = false
|
||||||
@@ -126,8 +124,7 @@ final class SettingsModel: ObservableObject {
|
|||||||
preferences: AppPreferencesRepository,
|
preferences: AppPreferencesRepository,
|
||||||
notifications: LocalNotificationService,
|
notifications: LocalNotificationService,
|
||||||
messages: UiMessageController,
|
messages: UiMessageController,
|
||||||
bugReports: BugReportService,
|
bugReports: BugReportService
|
||||||
diagnosticsIncluded: Bool = DiagnosticsBuildConfig.included
|
|
||||||
) {
|
) {
|
||||||
self.environment = environment
|
self.environment = environment
|
||||||
self.deviceInfoProvider = deviceInfoProvider
|
self.deviceInfoProvider = deviceInfoProvider
|
||||||
@@ -137,7 +134,6 @@ final class SettingsModel: ObservableObject {
|
|||||||
self.notifications = notifications
|
self.notifications = notifications
|
||||||
self.messages = messages
|
self.messages = messages
|
||||||
self.bugReports = bugReports
|
self.bugReports = bugReports
|
||||||
self.diagnosticsIncluded = diagnosticsIncluded
|
|
||||||
self.state = SettingsState(
|
self.state = SettingsState(
|
||||||
supportsCustomReceiveFolders: fileSystemService.supportsCustomReceiveFolders,
|
supportsCustomReceiveFolders: fileSystemService.supportsCustomReceiveFolders,
|
||||||
appVersion: environment.appVersion
|
appVersion: environment.appVersion
|
||||||
@@ -151,7 +147,6 @@ final class SettingsModel: ObservableObject {
|
|||||||
self.state.username = self.hasLocalUsernameDraft ? self.state.username : prefs.username
|
self.state.username = self.hasLocalUsernameDraft ? self.state.username : prefs.username
|
||||||
self.state.receiveFolder = folder
|
self.state.receiveFolder = folder
|
||||||
self.state.themeMode = prefs.themeMode
|
self.state.themeMode = prefs.themeMode
|
||||||
self.state.diagnosticsEnabled = prefs.diagnosticsEnabled
|
|
||||||
if !self.hasRelayConfigurationDraft {
|
if !self.hasRelayConfigurationDraft {
|
||||||
self.state.relayMode = prefs.relayConfiguration.mode
|
self.state.relayMode = prefs.relayConfiguration.mode
|
||||||
self.state.relayURLs = prefs.relayConfiguration.relayURLs
|
self.state.relayURLs = prefs.relayConfiguration.relayURLs
|
||||||
@@ -206,7 +201,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
|
||||||
@@ -230,17 +225,6 @@ final class SettingsModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setDiagnosticsEnabled(_ enabled: Bool) {
|
|
||||||
if !diagnosticsIncluded { return }
|
|
||||||
Task {
|
|
||||||
preferences.setDiagnosticsEnabled(enabled)
|
|
||||||
messages.show(UiMessage(
|
|
||||||
text: .resource(enabled ? L10n.Diagnostics.enabledMessage : L10n.Diagnostics.disabledMessage),
|
|
||||||
tone: .success
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Network
|
// MARK: - Network
|
||||||
|
|
||||||
func setRelayMode(_ mode: RelayPreferenceMode) {
|
func setRelayMode(_ mode: RelayPreferenceMode) {
|
||||||
@@ -475,7 +459,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -375,7 +375,7 @@ struct StorageSettings: View {
|
|||||||
struct AboutSettings: View {
|
struct AboutSettings: View {
|
||||||
@ObservedObject var model: SettingsModel
|
@ObservedObject var model: SettingsModel
|
||||||
|
|
||||||
private static let privacyPolicyURL = URL(string: "https://github.com/vnidrop/vnidrop")!
|
private static let privacyPolicyURL = AppConfig.privacyPolicyURL
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Section {
|
Section {
|
||||||
@@ -415,17 +415,6 @@ struct AboutSettings: View {
|
|||||||
Label(String(localized: L10n.About.privacyPolicyLabel), systemSymbol: .handRaised)
|
Label(String(localized: L10n.About.privacyPolicyLabel), systemSymbol: .handRaised)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if DiagnosticsBuildConfig.included {
|
|
||||||
Section {
|
|
||||||
Toggle(isOn: Binding(
|
|
||||||
get: { model.state.diagnosticsEnabled },
|
|
||||||
set: { model.setDiagnosticsEnabled($0) }
|
|
||||||
)) {
|
|
||||||
Text(String(localized: L10n.Diagnostics.title))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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() {}
|
||||||
|
|||||||
49
apple/VniDrop/Platform/SparkleUpdater.swift
Normal file
49
apple/VniDrop/Platform/SparkleUpdater.swift
Normal 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
|
||||||
@@ -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(()))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {}
|
||||||
|
|||||||
8
apple/VniDrop/Resources/AppIcon.icon/Assets/Drop.svg
Normal file
8
apple/VniDrop/Resources/AppIcon.icon/Assets/Drop.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<path d="M520.4,431.72C495.8,464.52 443.32,530.12 420.36,577.68C390.84,636.72 403.96,699.04 446.6,731.84C487.6,758.08 549.92,758.08 592.56,730.2C633.56,700.68 646.68,636.72 620.44,577.68C597.48,530.12 546.64,464.52 520.4,431.72Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(302.875,217.566,-217.566,302.875,404.439,431.72)"><stop offset="0" style="stop-color:rgb(168,85,247);stop-opacity:1"/><stop offset="0.48" style="stop-color:rgb(157,77,244);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(124,42,239);stop-opacity:1"/></linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
17
apple/VniDrop/Resources/AppIcon.icon/Assets/Mask.svg
Normal file
17
apple/VniDrop/Resources/AppIcon.icon/Assets/Mask.svg
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<defs>
|
||||||
|
<mask id="Mask">
|
||||||
|
|
||||||
|
<g transform="matrix(1,-0,-0,1,0,0)"><image id="_Image2" width="1024px" height="1024px" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAABAAAAAQACAAAAABadnRfAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAH6ElEQVR4nO3cv2udVRjA8ZM6CU0HFVTUTK2NWA1IUCdFg24KDkoN+GOwi4IgHfwDnGzVRVB0EQvi4ODYRWILdXMwWsQgOBRbQTvlihA0qX9Blfu+J/c55z6fzx+Q82S43zznvW9bCgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAkNlC9AAwwMLty8vLR25aPLR48EC9n7r752SyPZlc+mlr67dr9X5sywSA3iwcXlt77OZ9PmSy9cPXG5f3+ZAGCABdWXjg5aeXZnXY1sbGuauzOgz4X99fm63ds8/fGP077ycbAF0JuJpvf3Hmwt7sj50NAaArMc/mfjn9yU7IwftOAOhK1MP5K6c//ivo6H0lAHQl7tu5P977YDvs8H0jAHQl8uv5309+NndvBwgAXYn9BJ5/9cfQ8+ur+BoVzLtHN98+GD1DXTYAuhK+g19a/yZ6hJpsADCNpfNvztOHxgZAV8I3gFLK2Rfn5/1gAaArLQSgXD5+IXqEWuZpm4HZuOPcC9Ej1CIAMLUbzpyMHqESAYAB3jk1H7fn+fgtSKOJZwCllFI+PfF39AgVCABdaScA5cvn/okeYTxXABjmmQ/n4M+nAMBAr7wVPcF4c9AwMmnoClBKef396AnGEgC60lYAyvrn0ROMJAB0pbEA7Dz8XfQI4wgAXWksAOXn1b7/myAPAWGEIx/1/TdUAGCM4yeiJxil73yRTmtXgFJ2HtqMHmEEAaAr7QWgbK52/EagKwCMs/Ja9AQj2ADoSoMbQJksX4keYTAbAIy0+G70BMPZAOhKixtAKU98FT3BUAJAV9oMwNa9u9EjDOQKAKMdfTZ6gqFsAHSlzQ2gXFzZix5hGBsAjHfsqegJBrIB0JVGN4Dy7YOtTvbfbABQweqT0RMMIwBQwxvRAwzjCkBXml209+7q8nVAGwDUcGA9eoJBbAB0pdkNoFy8v93Zrs8GAFUcW4meYAgBgDpeih5gCFcAutLwmv3rUsPDXY8NAOq483D0BAMIAFSyFj3AAAIAlTwePcAAngHQlZav2Vdv7e+fBNoAoJJb7oueYHoCALU8Ej3A9AQAarkneoDpCQDUcjR6gOkJANSyHD3A9HwLQFda/haglEOT6AmmZQOAau6OHmBqAgDV9HcHEACo5rboAaYmAFDNYvQAUxMAqEYAIDEBgMQEABITAEhMACCx/j5O/U0MVCMAkJgAQGICAIkJACQmAJCYAEBiAgCJCQAkJgCQmABAYgIAiQkAJCYAkJgAQGICAIkJACQmAJCYAEBiAgCJCQAkJgCQmABAYgIAiQkAJCYAkJgAQGICAIkJACQmAJCYAEBiAgCJCQAkJgCQmABAYgIAiQkAJCYAkJgAQGICAIkJACQmAJCYAEBiAgCJCQAkJgCQmABAYgIAiQkAJCYAkJgAQGICAIkJACQmAJCYAEBiAgCJCQAkJgCQmABAYgIAiQkAJCYAkJgAQGICAIkJACQmAJCYAEBiAgCJCQAkJgCQmABAYgIAiQkAJCYAkJgAQGICAIkJACQmAJCYAEBiAgCJCQAkJgCQmABAYgIAiQkAJCYAkJgAQGICAIkJAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwc/8CNInt3t3vKtwAAAAASUVORK5CYII="/>
|
||||||
|
</g>
|
||||||
|
</mask>
|
||||||
|
</defs>
|
||||||
|
<g mask="url(#Mask)">
|
||||||
|
<path d="M688,148L782,148C832,148 842,171.8 847.48,210L847.48,303.68L688,148Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(110.156,162.233,-162.233,110.156,683.48,144.6)"><stop offset="0" style="stop-color:rgb(242,221,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(192,132,252);stop-opacity:1"/></linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.9 KiB |
8
apple/VniDrop/Resources/AppIcon.icon/Assets/U.svg
Normal file
8
apple/VniDrop/Resources/AppIcon.icon/Assets/U.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<path d="M236.68,148L338.36,148C366.24,148 387.56,170.96 387.56,198.84L387.56,564.56C387.56,597.36 372.8,620.32 372.8,646.56C372.8,725.28 436.76,787.6 522.04,787.6C607.32,787.6 668,725.28 668,646.56C668,620.32 656.52,597.36 656.52,564.56L656.52,198.84C656.52,170.96 677.84,148 705.72,148L781.16,148C817.24,148 846.76,177.52 846.76,213.6L846.76,564.56C846.76,738.4 704.08,879.44 522.04,879.44C340,879.44 194.04,738.4 194.04,564.56L194.04,374.32L220.28,374.32L220.28,305.44C195.68,305.44 176,297.24 176,280.84L176,246.4C176,231.64 187.48,220.16 202.24,220.16L236.68,220.16L236.68,148ZM256.36,239.84C251.44,239.84 248.16,244.76 248.16,249.68L248.16,275.92C248.16,282.48 253.08,285.76 259.64,285.76L282.6,285.76C289.16,285.76 292.44,280.84 292.44,274.28L292.44,251.32C292.44,244.76 287.52,239.84 280.96,239.84L256.36,239.84Z" style="fill:url(#_Linear1);"/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(728.706,668.252,-668.252,728.706,176,148)"><stop offset="0" style="stop-color:rgb(168,85,247);stop-opacity:1"/><stop offset="0.48" style="stop-color:rgb(157,77,244);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(124,42,239);stop-opacity:1"/></linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
57
apple/VniDrop/Resources/AppIcon.icon/icon.json
Normal file
57
apple/VniDrop/Resources/AppIcon.icon/icon.json
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"fill" : {
|
||||||
|
"linear-gradient" : [
|
||||||
|
"extended-gray:1.00000,1.00000",
|
||||||
|
"srgb:0.84942,0.81480,0.95401,1.00000"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"groups" : [
|
||||||
|
{
|
||||||
|
"blend-mode" : "normal",
|
||||||
|
"blur-material" : null,
|
||||||
|
"layers" : [
|
||||||
|
{
|
||||||
|
"image-name" : "Mask.svg",
|
||||||
|
"name" : "Mask"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lighting" : "individual",
|
||||||
|
"shadow" : {
|
||||||
|
"kind" : "neutral",
|
||||||
|
"opacity" : 0.6
|
||||||
|
},
|
||||||
|
"specular" : true,
|
||||||
|
"translucency" : {
|
||||||
|
"enabled" : true,
|
||||||
|
"value" : 0.8
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"layers" : [
|
||||||
|
{
|
||||||
|
"image-name" : "Drop.svg",
|
||||||
|
"name" : "Drop"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"image-name" : "U.svg",
|
||||||
|
"name" : "U"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lighting" : "combined",
|
||||||
|
"shadow" : {
|
||||||
|
"kind" : "layer-color",
|
||||||
|
"opacity" : 0.8
|
||||||
|
},
|
||||||
|
"translucency" : {
|
||||||
|
"enabled" : true,
|
||||||
|
"value" : 0.4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"supported-platforms" : {
|
||||||
|
"circles" : [
|
||||||
|
"watchOS"
|
||||||
|
],
|
||||||
|
"squares" : "shared"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{ "idiom" : "universal", "platform" : "ios", "size" : "1024x1024", "filename" : "app-icon.png" },
|
|
||||||
{ "idiom" : "mac", "scale" : "2x", "size" : "512x512", "filename" : "app-icon.png" }
|
|
||||||
],
|
|
||||||
"info" : { "author" : "xcode", "version" : 1 }
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 49 KiB |
@@ -20,6 +20,8 @@
|
|||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>VniDrop</string>
|
<string>VniDrop</string>
|
||||||
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
<false/>
|
||||||
<!-- macOS: a single instance only; re-launching activates the running app
|
<!-- macOS: a single instance only; re-launching activates the running app
|
||||||
instead of spawning another copy. -->
|
instead of spawning another copy. -->
|
||||||
<key>LSMultipleInstancesProhibited</key>
|
<key>LSMultipleInstancesProhibited</key>
|
||||||
@@ -55,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>
|
||||||
@@ -73,8 +89,6 @@
|
|||||||
<false/>
|
<false/>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>fetch</string>
|
|
||||||
<string>processing</string>
|
|
||||||
<string>remote-notification</string>
|
<string>remote-notification</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UIFileSharingEnabled</key>
|
<key>UIFileSharingEnabled</key>
|
||||||
|
|||||||
@@ -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. -->
|
<!-- 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
|
||||||
|
|||||||
15
apple/VniDrop/Resources/VniDropDirect.entitlements
Normal file
15
apple/VniDrop/Resources/VniDropDirect.entitlements
Normal 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>
|
||||||
@@ -7,11 +7,16 @@ struct AdaptiveDrawer<DrawerContent: View>: ViewModifier {
|
|||||||
@Binding var isPresented: Bool
|
@Binding var isPresented: Bool
|
||||||
let windowClass: WindowClass
|
let windowClass: WindowClass
|
||||||
let onDismiss: () -> Void
|
let onDismiss: () -> Void
|
||||||
|
/// Fired after the sheet's dismissal animation completes (as opposed to
|
||||||
|
/// `onDismiss`, which requests the close). Lets callers serialize a follow-up
|
||||||
|
/// sheet against this one's actual teardown instead of guessing a delay.
|
||||||
|
let onDismissed: (() -> Void)?
|
||||||
@ViewBuilder let drawerContent: () -> DrawerContent
|
@ViewBuilder let drawerContent: () -> DrawerContent
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
content.sheet(
|
content.sheet(
|
||||||
isPresented: Binding(get: { isPresented }, set: { if !$0 { onDismiss() } })
|
isPresented: Binding(get: { isPresented }, set: { if !$0 { onDismiss() } }),
|
||||||
|
onDismiss: onDismissed
|
||||||
) {
|
) {
|
||||||
SheetChrome(onClose: onDismiss) { drawerContent() }
|
SheetChrome(onClose: onDismiss) { drawerContent() }
|
||||||
.modifier(PhoneDetents(enabled: windowClass == .phone))
|
.modifier(PhoneDetents(enabled: windowClass == .phone))
|
||||||
@@ -56,11 +61,12 @@ extension View {
|
|||||||
isPresented: Binding<Bool>,
|
isPresented: Binding<Bool>,
|
||||||
windowClass: WindowClass,
|
windowClass: WindowClass,
|
||||||
onDismiss: @escaping () -> Void,
|
onDismiss: @escaping () -> Void,
|
||||||
|
onDismissed: (() -> Void)? = nil,
|
||||||
@ViewBuilder content: @escaping () -> DrawerContent
|
@ViewBuilder content: @escaping () -> DrawerContent
|
||||||
) -> some View {
|
) -> some View {
|
||||||
modifier(AdaptiveDrawer(
|
modifier(AdaptiveDrawer(
|
||||||
isPresented: isPresented, windowClass: windowClass,
|
isPresented: isPresented, windowClass: windowClass,
|
||||||
onDismiss: onDismiss, drawerContent: content
|
onDismiss: onDismiss, onDismissed: onDismissed, drawerContent: content
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,9 +14,22 @@ 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:
|
||||||
|
# Apple Silicon only. Intel Macs are unsupported (going EOL with macOS 28), and
|
||||||
|
# the Rust core's macOS slice (vnidrop.xcframework) is built arm64-only, so a
|
||||||
|
# universal link would fail looking for x86_64 symbols anyway.
|
||||||
|
ARCHS: arm64
|
||||||
# Strip unreachable code from release binaries.
|
# Strip unreachable code from release binaries.
|
||||||
DEAD_CODE_STRIPPING: YES
|
DEAD_CODE_STRIPPING: YES
|
||||||
# Flag user-facing strings that aren't localized (the app ships 9 languages).
|
# Flag user-facing strings that aren't localized (the app ships 9 languages).
|
||||||
@@ -26,27 +41,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: "1"
|
|
||||||
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,10 +83,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.
|
||||||
|
DEBUG_INFORMATION_FORMAT: dwarf-with-dsym
|
||||||
|
release-direct:
|
||||||
|
DEBUG_INFORMATION_FORMAT: dwarf-with-dsym
|
||||||
dependencies:
|
dependencies:
|
||||||
- package: VnidropCore
|
- package: VnidropCore
|
||||||
- package: SFSafeSymbols
|
- package: SFSafeSymbols
|
||||||
@@ -85,12 +110,45 @@ 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
|
||||||
|
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:
|
||||||
@@ -112,3 +170,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
|
||||||
|
|||||||
19
apple/scripts/ExportOptions-DeveloperID.plist
Normal file
19
apple/scripts/ExportOptions-DeveloperID.plist
Normal 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
|
||||||
|
ID–signed, 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>
|
||||||
@@ -44,6 +44,13 @@ export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-15.0}"
|
|||||||
# This never touches the Rust crate — it only changes how the build is invoked.
|
# This never touches the Rust crate — it only changes how the build is invoked.
|
||||||
export CARGO_PROFILE_DEV_STRIP=none
|
export CARGO_PROFILE_DEV_STRIP=none
|
||||||
|
|
||||||
|
# The workspace `[profile.release] lto = "thin"` corrupts host proc-macro / build
|
||||||
|
# script dylibs when cross-compiling ("mis-aligned LINKEDIT string pool"). Cargo
|
||||||
|
# forbids overriding `lto` per build-override, so disable thin LTO for the whole
|
||||||
|
# release build here — the crate is still fully optimized (opt-level 3, debuginfo
|
||||||
|
# stripped), which is what shrinks the static lib. This never edits the Cargo crate.
|
||||||
|
export CARGO_PROFILE_RELEASE_LTO=false
|
||||||
|
|
||||||
IOS_TARGET="aarch64-apple-ios"
|
IOS_TARGET="aarch64-apple-ios"
|
||||||
SIM_ARM_TARGET="aarch64-apple-ios-sim"
|
SIM_ARM_TARGET="aarch64-apple-ios-sim"
|
||||||
SIM_X64_TARGET="x86_64-apple-ios"
|
SIM_X64_TARGET="x86_64-apple-ios"
|
||||||
|
|||||||
173
apple/scripts/build-dmg.sh
Executable file
173
apple/scripts/build-dmg.sh
Executable file
@@ -0,0 +1,173 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Builds the direct-download macOS artifact: a Developer ID–signed, 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"
|
||||||
66
apple/scripts/generate-appcast.sh
Executable file
66
apple/scripts/generate-appcast.sh
Executable 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/"
|
||||||
44
apple/scripts/generate-appconfig.sh
Executable file
44
apple/scripts/generate-appconfig.sh
Executable file
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Generates apple/VniDrop/Generated/AppConfig.swift from the shared app.properties
|
||||||
|
# so app-wide constants (privacy policy URL, …) have a single source of truth
|
||||||
|
# across Apple and KMP. Regenerate instead of editing the output.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
repo_root="$(cd "$script_dir/../.." && pwd)"
|
||||||
|
config_file="${VNIDROP_APP_PROPERTIES:-$repo_root/app.properties}"
|
||||||
|
output_dir="${VNIDROP_APPLE_GENERATED_DIR:-$repo_root/apple/VniDrop/Generated}"
|
||||||
|
|
||||||
|
read_property() {
|
||||||
|
local key=$1
|
||||||
|
local value
|
||||||
|
value="$(sed -n "s/^${key}=//p" "$config_file")"
|
||||||
|
[[ -n "$value" ]] || { printf 'Missing %s in %s\n' "$key" "$config_file" >&2; exit 1; }
|
||||||
|
[[ $(printf '%s\n' "$value" | wc -l | tr -d ' ') == 1 ]] ||
|
||||||
|
{ printf 'Duplicate %s in %s\n' "$key" "$config_file" >&2; exit 1; }
|
||||||
|
printf '%s' "$value"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Escape for a Swift string literal.
|
||||||
|
swift_escape() {
|
||||||
|
printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'
|
||||||
|
}
|
||||||
|
|
||||||
|
privacy_url="$(read_property PRIVACY_POLICY_URL)"
|
||||||
|
|
||||||
|
mkdir -p "$output_dir"
|
||||||
|
tmp="$(mktemp "$output_dir/.AppConfig.swift.XXXXXX")"
|
||||||
|
cat > "$tmp" <<EOF
|
||||||
|
// Generated by apple/scripts/generate-appconfig.sh from app.properties.
|
||||||
|
// Regenerate this file instead of editing it.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// App-wide constants injected at build time from the shared \`app.properties\`.
|
||||||
|
enum AppConfig {
|
||||||
|
static let privacyPolicyURL = URL(string: "$(swift_escape "$privacy_url")")!
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
mv "$tmp" "$output_dir/AppConfig.swift"
|
||||||
67
apple/scripts/notarize.sh
Executable file
67
apple/scripts/notarize.sh
Executable 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
|
||||||
72
apple/scripts/package-core.sh
Executable file
72
apple/scripts/package-core.sh
Executable file
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Packages the prebuilt Apple core into a single zip + checksum, for attaching to
|
||||||
|
# the GitHub Release. Lets a consumer (e.g. Xcode Cloud) use the compiled core
|
||||||
|
# instead of installing Rust and running build-core.sh. Run AFTER the core exists
|
||||||
|
# (apple/scripts/build-core.sh, or `make apple-core` / `make build-apple-dmg`).
|
||||||
|
#
|
||||||
|
# The bundle carries both build outputs of build-core.sh:
|
||||||
|
# - vnidrop.xcframework (static libs for device/sim/macOS + the FFI module)
|
||||||
|
# - Vnidrop.swift (generated UniFFI bindings — a plain source file, not
|
||||||
|
# part of the xcframework, so it must ship alongside)
|
||||||
|
#
|
||||||
|
# Produces (under apple/dist):
|
||||||
|
# VnidropCore-<version>.zip
|
||||||
|
# VnidropCore-<version>.zip.sha256 (sha256sum(1)/shasum-compatible format)
|
||||||
|
#
|
||||||
|
# Zip layout (root):
|
||||||
|
# vnidrop.xcframework/
|
||||||
|
# Vnidrop.swift
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
APPLE_DIR="$REPO_ROOT/apple"
|
||||||
|
PKG_DIR="$APPLE_DIR/VnidropCore"
|
||||||
|
XCFRAMEWORK="$PKG_DIR/vnidrop.xcframework"
|
||||||
|
BINDINGS="$PKG_DIR/Sources/VnidropCore/Vnidrop.swift"
|
||||||
|
DIST_DIR="$APPLE_DIR/dist"
|
||||||
|
|
||||||
|
VERSION="$("$REPO_ROOT/packaging/version/resolve-version.sh" product)"
|
||||||
|
NAME="VnidropCore-$VERSION"
|
||||||
|
ZIP="$DIST_DIR/$NAME.zip"
|
||||||
|
CHECKSUM="$ZIP.sha256"
|
||||||
|
|
||||||
|
[ -d "$XCFRAMEWORK" ] || {
|
||||||
|
echo "error: missing xcframework: $XCFRAMEWORK" >&2
|
||||||
|
echo " build the core first (apple/scripts/build-core.sh)." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
[ -f "$BINDINGS" ] || {
|
||||||
|
echo "error: missing generated bindings: $BINDINGS" >&2
|
||||||
|
echo " build the core first (apple/scripts/build-core.sh)." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir -p "$DIST_DIR"
|
||||||
|
rm -f "$ZIP" "$CHECKSUM"
|
||||||
|
|
||||||
|
# Stage a clean tree so the zip root holds exactly the two payloads (no absolute
|
||||||
|
# paths or stray parent directories leak into the archive).
|
||||||
|
STAGE="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$STAGE"' EXIT
|
||||||
|
cp -R "$XCFRAMEWORK" "$STAGE/vnidrop.xcframework"
|
||||||
|
cp "$BINDINGS" "$STAGE/Vnidrop.swift"
|
||||||
|
|
||||||
|
# -X drops extra file attributes for a stabler archive across machines.
|
||||||
|
( cd "$STAGE" && zip -q -r -X "$ZIP" vnidrop.xcframework Vnidrop.swift )
|
||||||
|
|
||||||
|
# sha256sum on Linux; shasum -a 256 on macOS. Both emit "<hash> <name>", which
|
||||||
|
# `sha256sum --check` (used by assemble-release.sh) accepts.
|
||||||
|
(
|
||||||
|
cd "$DIST_DIR"
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
sha256sum "$NAME.zip" > "$NAME.zip.sha256"
|
||||||
|
else
|
||||||
|
shasum -a 256 "$NAME.zip" > "$NAME.zip.sha256"
|
||||||
|
fi
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "==> Packaged prebuilt core"
|
||||||
|
echo " zip: $ZIP"
|
||||||
|
echo " checksum: $CHECKSUM"
|
||||||
42
apple/scripts/sign-exported-app.sh
Executable file
42
apple/scripts/sign-exported-app.sh
Executable 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
|
||||||
|
}
|
||||||
59
apple/scripts/tests/test-generate-appconfig.sh
Executable file
59
apple/scripts/tests/test-generate-appconfig.sh
Executable file
@@ -0,0 +1,59 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Tests apple/scripts/generate-appconfig.sh: the shared app.properties is read
|
||||||
|
# correctly, values are emitted as valid escaped Swift, and a missing key fails.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
generator="$script_dir/../generate-appconfig.sh"
|
||||||
|
repo_root="$(cd "$script_dir/../../.." && pwd)"
|
||||||
|
scratch="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$scratch"' EXIT
|
||||||
|
|
||||||
|
# Run the generator against a fixture app.properties, emitting into a temp dir.
|
||||||
|
generate() {
|
||||||
|
VNIDROP_APP_PROPERTIES="$scratch/app.properties" \
|
||||||
|
VNIDROP_APPLE_GENERATED_DIR="$scratch/out" \
|
||||||
|
"$generator"
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_failure() {
|
||||||
|
if "$@" >/dev/null 2>&1; then
|
||||||
|
printf 'Expected command to fail: %s\n' "$*" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_contains() {
|
||||||
|
local file=$1 needle=$2
|
||||||
|
grep -qF "$needle" "$file" ||
|
||||||
|
{ printf 'Expected %s to contain: %s\n' "$file" "$needle" >&2; exit 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
out="$scratch/out/AppConfig.swift"
|
||||||
|
|
||||||
|
# 1. Nominal value is emitted verbatim as a Swift URL literal.
|
||||||
|
printf 'PRIVACY_POLICY_URL=%s\n' 'https://example.test/privacy/' > "$scratch/app.properties"
|
||||||
|
generate
|
||||||
|
assert_contains "$out" 'URL(string: "https://example.test/privacy/")!'
|
||||||
|
assert_contains "$out" 'enum AppConfig'
|
||||||
|
|
||||||
|
# 2. Characters special to a Swift string literal are escaped.
|
||||||
|
printf 'PRIVACY_POLICY_URL=%s\n' 'https://a.test/"q"\z' > "$scratch/app.properties"
|
||||||
|
generate
|
||||||
|
assert_contains "$out" 'URL(string: "https://a.test/\"q\"\\z")!'
|
||||||
|
|
||||||
|
# 3. A missing key fails instead of emitting an empty value.
|
||||||
|
printf 'OTHER_KEY=value\n' > "$scratch/app.properties"
|
||||||
|
expect_failure generate
|
||||||
|
|
||||||
|
# 4. A duplicated key fails.
|
||||||
|
printf 'PRIVACY_POLICY_URL=a\nPRIVACY_POLICY_URL=b\n' > "$scratch/app.properties"
|
||||||
|
expect_failure generate
|
||||||
|
|
||||||
|
# 5. The real committed app.properties produces an https URL.
|
||||||
|
VNIDROP_APPLE_GENERATED_DIR="$scratch/real" "$generator"
|
||||||
|
assert_contains "$scratch/real/AppConfig.swift" 'URL(string: "https://'
|
||||||
|
|
||||||
|
printf 'generate-appconfig tests passed.\n'
|
||||||
87
apple/scripts/tests/test-notarize.sh
Executable file
87
apple/scripts/tests/test-notarize.sh
Executable 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'
|
||||||
78
apple/scripts/tests/test-sign-exported-app.sh
Executable file
78
apple/scripts/tests/test-sign-exported-app.sh
Executable 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'
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
40
ci_scripts/README.md
Normal file
40
ci_scripts/README.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Xcode Cloud CI scripts
|
||||||
|
|
||||||
|
Xcode Cloud runs the scripts in this directory around each build. Only
|
||||||
|
`ci_post_clone.sh` is used today; add `ci_pre_xcodebuild.sh` /
|
||||||
|
`ci_post_xcodebuild.sh` here if later steps are needed.
|
||||||
|
|
||||||
|
## What `ci_post_clone.sh` does
|
||||||
|
|
||||||
|
The Xcode project (`apple/VniDrop.xcodeproj`) and its generated inputs are **not**
|
||||||
|
committed — they are produced by XcodeGen, localization, and the Rust core build.
|
||||||
|
Since Xcode Cloud only checks out the repository, the post-clone script:
|
||||||
|
|
||||||
|
1. installs `swiftlint`, `xcodegen`, and `bun`;
|
||||||
|
2. **downloads the prebuilt core** (`vnidrop.xcframework` + `Vnidrop.swift`) from
|
||||||
|
the matching GitHub Release asset `VnidropCore-<version>.zip` — Xcode Cloud
|
||||||
|
never builds Rust;
|
||||||
|
3. runs localization + version/app config codegen and `xcodegen generate`
|
||||||
|
(equivalent to `make apple-project` without the `apple-core` step).
|
||||||
|
|
||||||
|
The core asset for version `X.Y.Z` must be published on the `vX.Y.Z` release
|
||||||
|
before an Xcode Cloud build for that version runs (see
|
||||||
|
`apple/scripts/package-core.sh` and `.github/workflows/apple-release.yml`).
|
||||||
|
|
||||||
|
### Overrides (env vars, optional)
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| `VNIDROP_CORE_REPO` | `sudosylabs/vnidrop` | Release repository to download the core from |
|
||||||
|
| `VNIDROP_CORE_TAG` | `v<product-version>` | Release tag holding the core asset |
|
||||||
|
|
||||||
|
## Workflow configuration (App Store Connect)
|
||||||
|
|
||||||
|
The workflow itself (product, scheme, triggers, actions) is configured in App
|
||||||
|
Store Connect, not in the repository. Point it at:
|
||||||
|
|
||||||
|
- **Project:** `apple/VniDrop.xcodeproj` (generated by the post-clone script)
|
||||||
|
- **Scheme:** `VniDrop` (App Store / TestFlight target; shared, see `apple/project.yml`)
|
||||||
|
|
||||||
|
Archive actions use the release Rust profile via the published core asset; build
|
||||||
|
and test actions reuse the same prebuilt core.
|
||||||
72
ci_scripts/ci_post_clone.sh
Executable file
72
ci_scripts/ci_post_clone.sh
Executable file
@@ -0,0 +1,72 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Xcode Cloud post-clone step.
|
||||||
|
#
|
||||||
|
# The Apple Xcode project is generated (XcodeGen) and gitignored, and it links a
|
||||||
|
# prebuilt Rust XCFramework plus generated localization/config files. Xcode Cloud
|
||||||
|
# only checks out the repository, so this script:
|
||||||
|
# 1. installs the non-Rust build tooling (swiftlint, xcodegen, bun);
|
||||||
|
# 2. downloads the prebuilt core (vnidrop.xcframework + Vnidrop.swift) from the
|
||||||
|
# matching GitHub Release asset — we never build Rust here;
|
||||||
|
# 3. reproduces `make apple-project` minus the Rust `apple-core` step.
|
||||||
|
#
|
||||||
|
# Xcode Cloud runs this from the `ci_scripts` directory; CI_PRIMARY_REPOSITORY_PATH
|
||||||
|
# points at the checked-out repository root.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="${CI_PRIMARY_REPOSITORY_PATH:-$(cd "$(dirname "$0")/.." && pwd)}"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
echo "==> Installing build tooling (Homebrew)"
|
||||||
|
# swiftlint: enforced by a build phase (fails the build if missing).
|
||||||
|
# xcodegen: generates apple/VniDrop.xcodeproj from apple/project.yml.
|
||||||
|
brew install swiftlint xcodegen
|
||||||
|
|
||||||
|
echo "==> Installing Bun (localization generator)"
|
||||||
|
if ! command -v bun >/dev/null 2>&1; then
|
||||||
|
curl -fsSL https://bun.sh/install | bash
|
||||||
|
fi
|
||||||
|
export BUN_INSTALL="${BUN_INSTALL:-$HOME/.bun}"
|
||||||
|
export PATH="$BUN_INSTALL/bin:$PATH"
|
||||||
|
|
||||||
|
# --- Prebuilt core: download instead of building Rust -------------------------
|
||||||
|
# The Apple core (xcframework + UniFFI bindings) is published as a release asset
|
||||||
|
# by apple/scripts/package-core.sh. See docs at the top of that script.
|
||||||
|
VERSION="$(packaging/version/resolve-version.sh product)"
|
||||||
|
CORE_REPO="${VNIDROP_CORE_REPO:-sudosylabs/vnidrop}"
|
||||||
|
CORE_TAG="${VNIDROP_CORE_TAG:-v$VERSION}"
|
||||||
|
CORE_ZIP="VnidropCore-$VERSION.zip"
|
||||||
|
CORE_BASE_URL="https://github.com/$CORE_REPO/releases/download/$CORE_TAG"
|
||||||
|
|
||||||
|
PKG_DIR="$REPO_ROOT/apple/VnidropCore"
|
||||||
|
DOWNLOAD_DIR="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$DOWNLOAD_DIR"' EXIT
|
||||||
|
|
||||||
|
echo "==> Downloading prebuilt core $CORE_ZIP from $CORE_REPO@$CORE_TAG"
|
||||||
|
curl -fsSL "$CORE_BASE_URL/$CORE_ZIP" -o "$DOWNLOAD_DIR/$CORE_ZIP"
|
||||||
|
curl -fsSL "$CORE_BASE_URL/$CORE_ZIP.sha256" -o "$DOWNLOAD_DIR/$CORE_ZIP.sha256"
|
||||||
|
|
||||||
|
echo "==> Verifying checksum"
|
||||||
|
(
|
||||||
|
cd "$DOWNLOAD_DIR"
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
sha256sum --check "$CORE_ZIP.sha256"
|
||||||
|
else
|
||||||
|
shasum -a 256 --check "$CORE_ZIP.sha256"
|
||||||
|
fi
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "==> Installing core into apple/VnidropCore"
|
||||||
|
unzip -q -o "$DOWNLOAD_DIR/$CORE_ZIP" -d "$DOWNLOAD_DIR/extracted"
|
||||||
|
# Zip root holds: vnidrop.xcframework/ and Vnidrop.swift (see package-core.sh).
|
||||||
|
rm -rf "$PKG_DIR/vnidrop.xcframework"
|
||||||
|
cp -R "$DOWNLOAD_DIR/extracted/vnidrop.xcframework" "$PKG_DIR/vnidrop.xcframework"
|
||||||
|
mkdir -p "$PKG_DIR/Sources/VnidropCore"
|
||||||
|
cp "$DOWNLOAD_DIR/extracted/Vnidrop.swift" "$PKG_DIR/Sources/VnidropCore/Vnidrop.swift"
|
||||||
|
|
||||||
|
# --- Generate the project (everything except the Rust core) -------------------
|
||||||
|
echo "==> Generating localization, version/app config, and the Xcode project"
|
||||||
|
make localization apple-version-config apple-app-config
|
||||||
|
(cd "$REPO_ROOT/apple" && xcodegen generate)
|
||||||
|
|
||||||
|
echo "==> ci_post_clone complete"
|
||||||
@@ -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
31
crates/vnidrop/build.rs
Normal 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}");
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ import type { Metadata } from "next";
|
|||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Privacy policy",
|
title: "Privacy policy",
|
||||||
description:
|
description:
|
||||||
"How VniDrop handles transfers, local app data, optional diagnostics, bug reports, and website visits.",
|
"How VniDrop handles transfers, local app data, optional bug reports, and website visits.",
|
||||||
};
|
};
|
||||||
|
|
||||||
const sections = [
|
const sections = [
|
||||||
["scope", "Scope"],
|
["scope", "Scope"],
|
||||||
["transfers", "Transfers"],
|
["transfers", "Transfers"],
|
||||||
["local-data", "Local data"],
|
["local-data", "Local data"],
|
||||||
["diagnostics", "Diagnostics"],
|
["bug-reports", "Bug reports"],
|
||||||
["website", "Website"],
|
["website", "Website"],
|
||||||
["permissions", "Permissions"],
|
["permissions", "Permissions"],
|
||||||
["providers", "Service providers"],
|
["providers", "Service providers"],
|
||||||
@@ -29,9 +29,9 @@ export default function PrivacyPage() {
|
|||||||
<h1>Privacy Policy</h1>
|
<h1>Privacy Policy</h1>
|
||||||
<p>
|
<p>
|
||||||
This policy explains what moves between devices, what stays local, and what is sent
|
This policy explains what moves between devices, what stays local, and what is sent
|
||||||
only when you choose to share diagnostics or a bug report.
|
only when you choose to submit a bug report.
|
||||||
</p>
|
</p>
|
||||||
<p className="privacy-meta">Effective July 16, 2026 · Version 1.1</p>
|
<p className="privacy-meta">Effective August 2, 2026 · Version 1.2</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ export default function PrivacyPage() {
|
|||||||
<p>
|
<p>
|
||||||
VniDrop has no user accounts and does not upload your transfer to a VniDrop file
|
VniDrop has no user accounts and does not upload your transfer to a VniDrop file
|
||||||
store. Files travel over an authenticated, end-to-end encrypted connection.
|
store. Files travel over an authenticated, end-to-end encrypted connection.
|
||||||
Product diagnostics are opt-in; a bug report is sent only when you submit one.
|
VniDrop has no telemetry or analytics; a bug report is sent only when you submit one.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ export default function PrivacyPage() {
|
|||||||
<h2>Scope and who “VniDrop” means</h2>
|
<h2>Scope and who “VniDrop” means</h2>
|
||||||
<p>
|
<p>
|
||||||
This policy covers the official VniDrop website, the VniDrop applications for
|
This policy covers the official VniDrop website, the VniDrop applications for
|
||||||
Android, iOS, macOS, Windows, and Linux, and the diagnostics service configured by
|
Android, iOS, macOS, Windows, and Linux, and the bug-report service configured by
|
||||||
the official project. For an official release, VniDrop’s data controller is the
|
the official project. For an official release, VniDrop’s data controller is the
|
||||||
individual publisher named in the applicable app-store listing. In this policy,
|
individual publisher named in the applicable app-store listing. In this policy,
|
||||||
“VniDrop,” “we,” and “us” also include the maintainers acting on that publisher’s
|
“VniDrop,” “we,” and “us” also include the maintainers acting on that publisher’s
|
||||||
@@ -72,7 +72,7 @@ export default function PrivacyPage() {
|
|||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
VniDrop is open-source software. A build distributed or operated by someone else
|
VniDrop is open-source software. A build distributed or operated by someone else
|
||||||
may use different networking infrastructure, diagnostics settings, or website
|
may use different networking infrastructure, bug-report settings, or website
|
||||||
hosting. That distributor is responsible for explaining its own practices.
|
hosting. That distributor is responsible for explaining its own practices.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
@@ -117,9 +117,9 @@ export default function PrivacyPage() {
|
|||||||
<ul>
|
<ul>
|
||||||
<li>device identity and networking keys used to establish secure connections;</li>
|
<li>device identity and networking keys used to establish secure connections;</li>
|
||||||
<li>active shares, transfer history, receiver requests, progress, and status;</li>
|
<li>active shares, transfer history, receiver requests, progress, and status;</li>
|
||||||
<li>app preferences, including access and diagnostics choices;</li>
|
<li>app preferences, including access choices;</li>
|
||||||
<li>download destinations and locally managed transfer data; and</li>
|
<li>download destinations and locally managed transfer data; and</li>
|
||||||
<li>an anonymous installation identifier used only for diagnostics correlation.</li>
|
<li>an anonymous installation identifier used only for bug-report correlation.</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>
|
<p>
|
||||||
This information remains until you remove the relevant history, stop or delete a
|
This information remains until you remove the relevant history, stop or delete a
|
||||||
@@ -129,33 +129,27 @@ export default function PrivacyPage() {
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="diagnostics" className="policy-section">
|
<section id="bug-reports" className="policy-section">
|
||||||
<h2>Optional diagnostics and bug reports</h2>
|
<h2>Optional bug reports</h2>
|
||||||
<h3>Automatic product diagnostics</h3>
|
|
||||||
<p>
|
<p>
|
||||||
Official releases indicate in the app settings whether automatic product
|
VniDrop has no automatic telemetry, usage analytics, or crash auto-reporting.
|
||||||
diagnostics are included. When included, automatic usage events and crash reports
|
Nothing is sent to a bug-report service unless you explicitly submit a report.
|
||||||
are disabled until you enable “Share diagnostics.” If enabled, VniDrop may send an
|
|
||||||
anonymous installation ID, app version, platform, sparse event names and properties,
|
|
||||||
crash type and message, a redacted stack trace, timestamps, and recent in-app
|
|
||||||
breadcrumbs. You can turn this off at any time; doing so also removes pending local
|
|
||||||
crash reports.
|
|
||||||
</p>
|
</p>
|
||||||
<h3>User-submitted bug reports</h3>
|
<h3>User-submitted bug reports</h3>
|
||||||
<p>
|
<p>
|
||||||
A bug report is separate from the diagnostics toggle and is sent only when you press
|
A bug report is sent only when you press submit. It can contain what you say
|
||||||
submit. It can contain what you say happened, what you expected, reproduction steps,
|
happened, what you expected, reproduction steps, an optional contact email, app and
|
||||||
an optional contact email, app and platform versions, an anonymous installation ID,
|
platform versions, an anonymous installation ID, device name and model, operating
|
||||||
device name and model, operating system, network and battery information, recent
|
system, network and battery information, and optional recent logs. You can exclude
|
||||||
breadcrumbs, and optional recent logs. You can exclude logs before submitting.
|
logs before submitting.
|
||||||
</p>
|
</p>
|
||||||
<h3>Data deliberately excluded</h3>
|
<h3>Data deliberately excluded</h3>
|
||||||
<p>
|
<p>
|
||||||
Automatic diagnostics are designed to exclude transfer contents, invitations, and
|
Bug reports are designed to exclude transfer contents, invitations, and file paths.
|
||||||
file paths. Before diagnostic text or optional logs are sent, VniDrop applies rules
|
Before optional logs are sent, VniDrop applies rules intended to redact invitation
|
||||||
intended to redact invitation tokens, endpoint identifiers, absolute paths, file and
|
tokens, endpoint identifiers, absolute paths, file and content URIs, and platform
|
||||||
content URIs, and platform document identifiers. No redaction system is perfect, so
|
document identifiers. No redaction system is perfect, so review anything you type
|
||||||
review anything you type into a bug report and avoid including secrets.
|
into a bug report and avoid including secrets.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -223,7 +217,7 @@ export default function PrivacyPage() {
|
|||||||
<dt>Cloudflare</dt>
|
<dt>Cloudflare</dt>
|
||||||
<dd>
|
<dd>
|
||||||
Proxies website requests and provides DNS, security, and abuse controls. When
|
Proxies website requests and provides DNS, security, and abuse controls. When
|
||||||
the optional diagnostics service is configured, it uses Cloudflare Workers, D1,
|
the optional bug-report service is configured, it uses Cloudflare Workers, D1,
|
||||||
and R2.
|
and R2.
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
@@ -300,11 +294,7 @@ export default function PrivacyPage() {
|
|||||||
<td>Until you delete them, clear app data, or uninstall</td>
|
<td>Until you delete them, clear app data, or uninstall</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Pending local crash reports</th>
|
<th scope="row">Server bug reports</th>
|
||||||
<td>Up to 30 days and 20 reports; deleted when diagnostics is disabled</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th scope="row">Server diagnostics and bug reports</th>
|
|
||||||
<td>The current project configuration is 90 days, with scheduled deletion</td>
|
<td>The current project configuration is 90 days, with scheduled deletion</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -317,7 +307,7 @@ export default function PrivacyPage() {
|
|||||||
<p>
|
<p>
|
||||||
Operational backups, provider logs, and deletion backlogs may persist briefly beyond
|
Operational backups, provider logs, and deletion backlogs may persist briefly beyond
|
||||||
the stated period where necessary for security, integrity, or legal obligations. If
|
the stated period where necessary for security, integrity, or legal obligations. If
|
||||||
the production diagnostics retention configuration changes, this policy should be
|
the production bug-report retention configuration changes, this policy should be
|
||||||
updated to match it.
|
updated to match it.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
@@ -325,7 +315,6 @@ export default function PrivacyPage() {
|
|||||||
<section id="choices" className="policy-section">
|
<section id="choices" className="policy-section">
|
||||||
<h2>Your choices and rights</h2>
|
<h2>Your choices and rights</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Enable or disable “Share diagnostics” in VniDrop settings.</li>
|
|
||||||
<li>
|
<li>
|
||||||
Submit a bug report only when you choose, omit contact information, and exclude
|
Submit a bug report only when you choose, omit contact information, and exclude
|
||||||
logs.
|
logs.
|
||||||
@@ -343,7 +332,7 @@ export default function PrivacyPage() {
|
|||||||
<p>
|
<p>
|
||||||
Depending on where you live, privacy law may provide rights to access, correct,
|
Depending on where you live, privacy law may provide rights to access, correct,
|
||||||
delete, restrict, or object to processing of personal information. Because VniDrop
|
delete, restrict, or object to processing of personal information. Because VniDrop
|
||||||
has no account and automatic diagnostics use an anonymous installation ID, we may
|
has no account and bug reports use an anonymous installation ID, we may
|
||||||
not be able to connect a server record to you without additional information. Use
|
not be able to connect a server record to you without additional information. Use
|
||||||
the contact method below and provide only what is needed to locate your submission.
|
the contact method below and provide only what is needed to locate your submission.
|
||||||
</p>
|
</p>
|
||||||
@@ -353,7 +342,7 @@ export default function PrivacyPage() {
|
|||||||
<h2>Security</h2>
|
<h2>Security</h2>
|
||||||
<p>
|
<p>
|
||||||
VniDrop uses authenticated end-to-end encrypted connections, content verification,
|
VniDrop uses authenticated end-to-end encrypted connections, content verification,
|
||||||
deny-by-default share access, bounded diagnostics payloads, redaction, and safe file
|
deny-by-default share access, bounded bug-report payloads, redaction, and safe file
|
||||||
publishing that avoids silently replacing an existing file. No system can guarantee
|
publishing that avoids silently replacing an existing file. No system can guarantee
|
||||||
absolute security. Keep invitations private, verify receiver names, keep your device
|
absolute security. Keep invitations private, verify receiver names, keep your device
|
||||||
updated, and stop sharing when a transfer is finished.
|
updated, and stop sharing when a transfer is finished.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -17,9 +13,8 @@ android.nonTransitiveRClass=true
|
|||||||
android.sourceset.disallowProvider=false
|
android.sourceset.disallowProvider=false
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
|
|
||||||
# VniDrop: compile-time diagnostics/telemetry product surface.
|
# VniDrop: compile-time bug-report delivery surface.
|
||||||
# false → no Share-diagnostics toggle, no telemetry or crash auto-upload stack.
|
# false → user-initiated bug reports fall back to a NoOp transport (never sent).
|
||||||
# Bug report UI remains available (user-initiated).
|
|
||||||
# Enable per build only when endpoint and ingest key are configured:
|
# Enable per build only when endpoint and ingest key are configured:
|
||||||
# ./gradlew … -Pvnidrop.diagnostics.included=true
|
# ./gradlew … -Pvnidrop.diagnostics.included=true
|
||||||
vnidrop.diagnostics.included=false
|
vnidrop.diagnostics.included=false
|
||||||
|
|||||||
@@ -1178,62 +1178,6 @@
|
|||||||
"ru": "Имя устройства"
|
"ru": "Имя устройства"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"diagnostics_description": {
|
|
||||||
"context": "Settings > Diagnostics: explanation of what anonymous diagnostics collect.",
|
|
||||||
"translations": {
|
|
||||||
"en": "Send anonymous crash reports and usage events so we can improve VniDrop. You can turn this off anytime. Invitations, file paths, and transfer contents are never included.",
|
|
||||||
"fr": "Envoyer des rapports de plantage et des événements d’utilisation anonymes pour nous aider à améliorer VniDrop. Vous pouvez désactiver cela à tout moment. Les invitations, chemins de fichiers et contenus de transfert ne sont jamais inclus.",
|
|
||||||
"es": "Enviar informes de fallos y eventos de uso anónimos para ayudarnos a mejorar VniDrop. Puede desactivarlo en cualquier momento. Las invitaciones, las rutas de archivos y el contenido de las transferencias nunca se incluyen.",
|
|
||||||
"it": "Invia report di arresto anomalo ed eventi d’uso anonimi per aiutarci a migliorare VniDrop. Può disattivarlo in qualsiasi momento. Inviti, percorsi dei file e contenuti dei trasferimenti non vengono mai inclusi.",
|
|
||||||
"de": "Anonyme Absturzberichte und Nutzungsereignisse senden, damit wir VniDrop verbessern können. Sie können dies jederzeit deaktivieren. Einladungen, Dateipfade und Übertragungsinhalte werden niemals einbezogen.",
|
|
||||||
"pt": "Enviar relatórios de falhas e eventos de utilização anónimos para nos ajudar a melhorar o VniDrop. Pode desativar isto a qualquer momento. Convites, caminhos de ficheiros e conteúdos das transferências nunca são incluídos.",
|
|
||||||
"pl": "Wysyłaj anonimowe raporty o awariach i zdarzenia użytkowania, aby pomóc nam ulepszać VniDrop. Możesz to wyłączyć w dowolnej chwili. Zaproszenia, ścieżki plików i zawartość transferów nigdy nie są dołączane.",
|
|
||||||
"nl": "Verstuur anonieme crashrapporten en gebruiksgebeurtenissen zodat we VniDrop kunnen verbeteren. U kunt dit op elk moment uitschakelen. Uitnodigingen, bestandspaden en overdrachtsinhoud worden nooit meegestuurd.",
|
|
||||||
"ru": "Отправлять анонимные отчёты о сбоях и события использования, чтобы помочь нам улучшать VniDrop. Вы можете отключить это в любой момент. Приглашения, пути к файлам и содержимое передач никогда не включаются."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"diagnostics_disabled_message": {
|
|
||||||
"context": "Settings > Diagnostics: confirmation shown when diagnostics are turned off.",
|
|
||||||
"translations": {
|
|
||||||
"en": "Diagnostics sharing is off.",
|
|
||||||
"fr": "Le partage des diagnostics est désactivé.",
|
|
||||||
"es": "El uso compartido de diagnósticos está desactivado.",
|
|
||||||
"it": "La condivisione dei dati diagnostici è disattivata.",
|
|
||||||
"de": "Die Freigabe von Diagnosedaten ist deaktiviert.",
|
|
||||||
"pt": "A partilha de diagnósticos está desativada.",
|
|
||||||
"pl": "Udostępnianie diagnostyki jest wyłączone.",
|
|
||||||
"nl": "Het delen van diagnostische gegevens is uitgeschakeld.",
|
|
||||||
"ru": "Передача диагностики отключена."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"diagnostics_enabled_message": {
|
|
||||||
"context": "Settings > Diagnostics: confirmation shown when diagnostics are turned on.",
|
|
||||||
"translations": {
|
|
||||||
"en": "Diagnostics sharing is on.",
|
|
||||||
"fr": "Le partage des diagnostics est activé.",
|
|
||||||
"es": "El uso compartido de diagnósticos está activado.",
|
|
||||||
"it": "La condivisione dei dati diagnostici è attivata.",
|
|
||||||
"de": "Die Freigabe von Diagnosedaten ist aktiviert.",
|
|
||||||
"pt": "A partilha de diagnósticos está ativada.",
|
|
||||||
"pl": "Udostępnianie diagnostyki jest włączone.",
|
|
||||||
"nl": "Het delen van diagnostische gegevens is ingeschakeld.",
|
|
||||||
"ru": "Передача диагностики включена."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"diagnostics_title": {
|
|
||||||
"context": "Settings > Diagnostics: toggle title.",
|
|
||||||
"translations": {
|
|
||||||
"en": "Share diagnostics",
|
|
||||||
"fr": "Partager les diagnostics",
|
|
||||||
"es": "Compartir diagnósticos",
|
|
||||||
"it": "Condividi dati diagnostici",
|
|
||||||
"de": "Diagnosedaten teilen",
|
|
||||||
"pt": "Partilhar diagnósticos",
|
|
||||||
"pl": "Udostępniaj diagnostykę",
|
|
||||||
"nl": "Diagnostische gegevens delen",
|
|
||||||
"ru": "Делиться диагностикой"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"error_camera": {
|
"error_camera": {
|
||||||
"context": "Error: camera permission is needed to scan a QR code.",
|
"context": "Error: camera permission is needed to scan a QR code.",
|
||||||
"translations": {
|
"translations": {
|
||||||
@@ -3430,6 +3374,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": "L’iPhone et l’iPad 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é l’app, 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 dall’app, 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 +4573,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": {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
54
packaging/android/README.md
Normal file
54
packaging/android/README.md
Normal 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.
|
||||||
145
packaging/android/build-release.sh
Executable file
145
packaging/android/build-release.sh
Executable 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
424
packaging/android/publish_play.py
Executable 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())
|
||||||
164
packaging/android/tests/test_publish_play.py
Normal file
164
packaging/android/tests/test_publish_play.py
Normal 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()
|
||||||
57
packaging/android/tests/test_verify_apk_signature.sh
Executable file
57
packaging/android/tests/test_verify_apk_signature.sh
Executable 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'
|
||||||
86
packaging/android/verify-apk-signature.sh
Executable file
86
packaging/android/verify-apk-signature.sh
Executable 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"
|
||||||
7
packaging/apple/.gitignore
vendored
Normal file
7
packaging/apple/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Exported screenshots (large; regenerated from the design source) — not tracked.
|
||||||
|
*.jpg
|
||||||
|
*.jpeg
|
||||||
|
|
||||||
|
# macOS / editor junk
|
||||||
|
.DS_Store
|
||||||
|
*~lock~
|
||||||
3
packaging/apple/AppStore.af
Normal file
3
packaging/apple/AppStore.af
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:2aa9a22a914aa9503f202cf2f2336e9dc236a7aa2cb09fc52a6f1ac61fc90ca7
|
||||||
|
size 10653377
|
||||||
54
packaging/homebrew/tap-README.md
Normal file
54
packaging/homebrew/tap-README.md
Normal 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 ID–signed, 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.
|
||||||
36
packaging/homebrew/vnidrop.rb
Normal file
36
packaging/homebrew/vnidrop.rb
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
exit 1
|
||||||
fi
|
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
|
|
||||||
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"
|
||||||
|
|||||||
52
packaging/release/README.md
Normal file
52
packaging/release/README.md
Normal 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.
|
||||||
183
packaging/release/assemble-release.sh
Executable file
183
packaging/release/assemble-release.sh
Executable file
@@ -0,0 +1,183 @@
|
|||||||
|
#!/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')"
|
||||||
|
apple_core="$(find_single "$input_dir/macos" 'VnidropCore-*.zip' 'Apple prebuilt core bundle')"
|
||||||
|
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 "$apple_core") == "VnidropCore-${version}.zip" ]]
|
||||||
|
[[ $(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')"
|
||||||
|
apple_core_checksum="$(find_single "$input_dir/macos" 'VnidropCore-*.zip.sha256' 'Apple prebuilt core checksum')"
|
||||||
|
verify_checksum_file "$deb_checksum"
|
||||||
|
verify_checksum_file "$rpm_checksum"
|
||||||
|
verify_checksum_file "$windows_checksums"
|
||||||
|
verify_checksum_file "$play_checksums"
|
||||||
|
verify_checksum_file "$apple_core_checksum"
|
||||||
|
|
||||||
|
[[ $(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" "$apple_core" "$output_dir/"
|
||||||
|
|
||||||
|
payloads=(
|
||||||
|
"$output_dir/$(basename "$deb")"
|
||||||
|
"$output_dir/$(basename "$rpm")"
|
||||||
|
"$output_dir/$(basename "$dmg")"
|
||||||
|
"$output_dir/$(basename "$appcast")"
|
||||||
|
"$output_dir/$(basename "$play_apk")"
|
||||||
|
"$output_dir/$(basename "$apple_core")"
|
||||||
|
)
|
||||||
|
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")" \
|
||||||
|
"$(basename "$apple_core")" \
|
||||||
|
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")"
|
||||||
123
packaging/release/test-assemble-release.sh
Executable file
123
packaging/release/test-assemble-release.sh
Executable file
@@ -0,0 +1,123 @@
|
|||||||
|
#!/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 'core\n' > "$input_dir/macos/VnidropCore-${version}.zip"
|
||||||
|
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/macos"
|
||||||
|
sha256sum "VnidropCore-${version}.zip" \
|
||||||
|
> "VnidropCore-${version}.zip.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"
|
||||||
|
"VnidropCore-${version}.zip"
|
||||||
|
"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
|
||||||
|
)
|
||||||
56
packaging/release/test-release-config.sh
Executable file
56
packaging/release/test-release-config.sh
Executable 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'
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user