mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-06 10:49:56 +02:00
Compare commits
34 Commits
fc1d27bf45
...
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 |
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"
|
||||||
124
.github/workflows/apple-release.yml
vendored
124
.github/workflows/apple-release.yml
vendored
@@ -1,27 +1,20 @@
|
|||||||
name: Apple release (macOS DMG)
|
name: Apple release (macOS DMG)
|
||||||
|
|
||||||
# Builds, signs, notarizes, and publishes the direct-download macOS build:
|
# Builds, signs, notarizes, and uploads the direct-download macOS build:
|
||||||
# - a Developer ID–signed, notarized VniDrop-<version>.dmg,
|
# - a Developer ID–signed, notarized VniDrop-<version>.dmg,
|
||||||
# - a Sparkle appcast.xml (both attached to the GitHub Release), and
|
# - a Sparkle appcast.xml.
|
||||||
# - an updated Homebrew cask pushed to the sudosylabs/homebrew-vnidrop tap.
|
#
|
||||||
|
# The central release workflow publishes these artifacts and updates Homebrew.
|
||||||
#
|
#
|
||||||
# The App Store / TestFlight build is NOT produced here — that goes through Xcode
|
# The App Store / TestFlight build is NOT produced here — that goes through Xcode
|
||||||
# Organizer / App Store Connect. This workflow only covers direct distribution.
|
# Organizer / App Store Connect. This workflow only covers direct distribution.
|
||||||
#
|
#
|
||||||
# Trigger: push a tag vMAJOR.MINOR.PATCH (must point at a commit on master), or
|
# Called by the central tag-release workflow, or run manually to validate the
|
||||||
# run manually with an explicit version (produces artifacts, no Release).
|
# signed/notarized direct-download artifact.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_call:
|
||||||
tags:
|
|
||||||
- "v*.*.*"
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: Release version in MAJOR.MINOR.PATCH form
|
|
||||||
required: true
|
|
||||||
default: "0.1.0"
|
|
||||||
type: string
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -39,8 +32,6 @@ jobs:
|
|||||||
name: Build & notarize DMG
|
name: Build & notarize DMG
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
outputs:
|
outputs:
|
||||||
version: ${{ steps.version.outputs.app }}
|
version: ${{ steps.version.outputs.app }}
|
||||||
steps:
|
steps:
|
||||||
@@ -58,17 +49,11 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Resolve version
|
- name: Resolve canonical version
|
||||||
id: version
|
id: version
|
||||||
env:
|
|
||||||
REQUESTED_VERSION: ${{ inputs.version || '' }}
|
|
||||||
run: |
|
run: |
|
||||||
if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
|
packaging/version/resolve-version.sh verify >/dev/null
|
||||||
version="${GITHUB_REF_NAME#v}"
|
version="$(packaging/version/resolve-version.sh product)"
|
||||||
else
|
|
||||||
version="$REQUESTED_VERSION"
|
|
||||||
fi
|
|
||||||
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "bad version '$version'" >&2; exit 1; }
|
|
||||||
echo "app=$version" >> "$GITHUB_OUTPUT"
|
echo "app=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Select Xcode
|
- name: Select Xcode
|
||||||
@@ -94,7 +79,7 @@ jobs:
|
|||||||
run: brew install xcodegen swiftlint create-dmg
|
run: brew install xcodegen swiftlint create-dmg
|
||||||
|
|
||||||
- name: Install Bun
|
- name: Install Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||||
|
|
||||||
- name: Download Sparkle tools
|
- name: Download Sparkle tools
|
||||||
# generate_appcast + sign_update ship in the Sparkle release tarball.
|
# generate_appcast + sign_update ship in the Sparkle release tarball.
|
||||||
@@ -147,12 +132,27 @@ jobs:
|
|||||||
echo "SPARKLE_ED_KEY_FILE=$RUNNER_TEMP/sparkle_ed_private_key" >> "$GITHUB_ENV"
|
echo "SPARKLE_ED_KEY_FILE=$RUNNER_TEMP/sparkle_ed_private_key" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Build, sign & notarize DMG
|
- name: Build, sign & notarize DMG
|
||||||
run: apple/scripts/build-dmg.sh "${{ steps.version.outputs.app }}"
|
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
|
- name: Generate appcast
|
||||||
env:
|
env:
|
||||||
RELEASE_REPO: ${{ github.repository }}
|
RELEASE_REPO: ${{ github.repository }}
|
||||||
run: apple/scripts/generate-appcast.sh "${{ steps.version.outputs.app }}"
|
run: apple/scripts/generate-appcast.sh
|
||||||
|
|
||||||
- name: Upload artifacts
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
@@ -160,71 +160,9 @@ jobs:
|
|||||||
name: vnidrop-${{ steps.version.outputs.app }}-macos-dmg
|
name: vnidrop-${{ steps.version.outputs.app }}-macos-dmg
|
||||||
path: |
|
path: |
|
||||||
apple/dist/VniDrop-*.dmg
|
apple/dist/VniDrop-*.dmg
|
||||||
|
apple/dist/VniDrop-*.build-info.json
|
||||||
apple/dist/appcast.xml
|
apple/dist/appcast.xml
|
||||||
|
apple/dist/VnidropCore-*.zip
|
||||||
|
apple/dist/VnidropCore-*.zip.sha256
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
|
|
||||||
- name: Publish GitHub Release
|
|
||||||
if: github.event_name == 'push' && github.ref_type == 'tag'
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
GH_REPO: ${{ github.repository }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
tag="$GITHUB_REF_NAME"
|
|
||||||
version="${tag#v}"
|
|
||||||
if gh release view "$tag" >/dev/null 2>&1; then
|
|
||||||
echo "Release $tag already exists; refusing to replace assets" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
gh release create "$tag" \
|
|
||||||
"apple/dist/VniDrop-${version}.dmg" \
|
|
||||||
"apple/dist/appcast.xml" \
|
|
||||||
--verify-tag \
|
|
||||||
--title "VniDrop $version" \
|
|
||||||
--generate-notes
|
|
||||||
|
|
||||||
update-cask:
|
|
||||||
name: Update Homebrew cask
|
|
||||||
needs: build
|
|
||||||
if: github.event_name == 'push' && github.ref_type == 'tag'
|
|
||||||
runs-on: ubuntu-22.04
|
|
||||||
timeout-minutes: 15
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Download DMG artifact
|
|
||||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
|
||||||
with:
|
|
||||||
name: vnidrop-${{ needs.build.outputs.version }}-macos-dmg
|
|
||||||
path: dist
|
|
||||||
|
|
||||||
- name: Render cask
|
|
||||||
env:
|
|
||||||
VERSION: ${{ needs.build.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
|
|
||||||
echo "Rendered cask:"; cat /tmp/vnidrop.rb
|
|
||||||
|
|
||||||
- name: Push to tap
|
|
||||||
env:
|
|
||||||
TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
|
||||||
VERSION: ${{ needs.build.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 "no cask changes"; exit 0; }
|
|
||||||
git push
|
|
||||||
|
|||||||
4
.github/workflows/apple.yml
vendored
4
.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"
|
||||||
|
|||||||
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"
|
||||||
}
|
}
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -25,3 +25,4 @@ config.override.mk
|
|||||||
output/
|
output/
|
||||||
.screenshots
|
.screenshots
|
||||||
apple/RELEASE-MACOS.md
|
apple/RELEASE-MACOS.md
|
||||||
|
apple/Generated/*.xcconfig
|
||||||
|
|||||||
43
Makefile
43
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.
|
||||||
@@ -125,8 +151,11 @@ build-apple-macos: apple-project ## Build the native macOS app (unsigned by defa
|
|||||||
build-apple-macos-direct: apple-project ## Build the direct-download macOS target (Sparkle, unsigned) — CI compile check.
|
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
|
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: ## Build the signed/notarized direct-download .dmg (see apple/RELEASE-MACOS.md for required env).
|
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 $(VERSION)
|
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; }
|
||||||
|
|||||||
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/
|
||||||
@@ -40,6 +40,12 @@ 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
|
### macOS shipping channels
|
||||||
|
|
||||||
The macOS app ships through two targets that build identical sources:
|
The macOS app ships through two targets that build identical sources:
|
||||||
@@ -52,7 +58,7 @@ The macOS app ships through two targets that build identical sources:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
make build-apple-macos-direct # unsigned compile-check of the direct target
|
make build-apple-macos-direct # unsigned compile-check of the direct target
|
||||||
make build-apple-dmg VERSION=x.y.z # signed (+ notarized) .dmg
|
make build-apple-dmg # signed (+ notarized) .dmg
|
||||||
```
|
```
|
||||||
|
|
||||||
Full signing, notarization, appcast, and cask flow: see
|
Full signing, notarization, appcast, and cask flow: see
|
||||||
@@ -108,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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,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(
|
||||||
|
|||||||
@@ -9,15 +9,9 @@ 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
|
||||||
|
|
||||||
/// Drives the approval sheet; toggled from the pending-approval `onChange` so the
|
|
||||||
/// presentation can be deferred until the Share/QR sheet has dismissed on macOS.
|
|
||||||
@State private var showApproval = false
|
|
||||||
|
|
||||||
init(dependencies: AppDependencies) {
|
init(dependencies: AppDependencies) {
|
||||||
let graph = AppGraph(dependencies: dependencies)
|
let graph = AppGraph(dependencies: dependencies)
|
||||||
_graph = StateObject(wrappedValue: graph)
|
_graph = StateObject(wrappedValue: graph)
|
||||||
@@ -50,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 {
|
||||||
@@ -60,13 +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
|
||||||
isPresented: $showApproval,
|
// AppGraph rebuilt on every re-init, whose coordinator never receives
|
||||||
state: approvals.state,
|
// core events — so the approval modal never appeared.
|
||||||
onAccept: approvals.accept,
|
ApprovalLayer(
|
||||||
onRefuse: approvals.refuse
|
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
|
||||||
@@ -103,27 +99,6 @@ struct RootView: View {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A pending approval is a blocking modal. Close the sender's detail panel
|
|
||||||
// (e.g. the Share/QR sheet) first, then present the approval sheet — but on
|
|
||||||
// macOS a sheet presented while another is still dismissing is silently
|
|
||||||
// dropped, so defer the presentation until that dismissal finishes.
|
|
||||||
.onChange(of: approvals.state.current?.id) { _, id in
|
|
||||||
guard id != nil else { showApproval = false; return }
|
|
||||||
let wasShowingSheet = sendModel.state.detailPanel != nil
|
|
||||||
sendModel.closeDetailPanel()
|
|
||||||
#if os(macOS)
|
|
||||||
if wasShowingSheet {
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) {
|
|
||||||
if approvals.state.current != nil { showApproval = true }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
showApproval = true
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
_ = wasShowingSheet
|
|
||||||
showApproval = true
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
// macOS keeps `scenePhase == .active` even when the app loses focus, so
|
// macOS keeps `scenePhase == .active` even when the app loses focus, so
|
||||||
// drive foreground/background off NSApplication's active state instead —
|
// drive foreground/background off NSApplication's active state instead —
|
||||||
@@ -217,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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -24,8 +24,3 @@ struct NoopBugReportService: BugReportService {
|
|||||||
}
|
}
|
||||||
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
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"fill" : {
|
"fill" : {
|
||||||
"linear-gradient" : [
|
"linear-gradient" : [
|
||||||
"extended-gray:1.00000,1.00000",
|
"extended-gray:1.00000,1.00000",
|
||||||
"display-p3:0.55433,0.59923,0.92884,1.00000"
|
"srgb:0.84942,0.81480,0.95401,1.00000"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"groups" : [
|
"groups" : [
|
||||||
@@ -16,11 +16,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"lighting" : "individual",
|
"lighting" : "individual",
|
||||||
"refractivity": {
|
|
||||||
"depth": 0.5,
|
|
||||||
"enabled": true,
|
|
||||||
"strength": 0
|
|
||||||
},
|
|
||||||
"shadow" : {
|
"shadow" : {
|
||||||
"kind" : "neutral",
|
"kind" : "neutral",
|
||||||
"opacity" : 0.6
|
"opacity" : 0.6
|
||||||
@@ -44,8 +39,8 @@
|
|||||||
],
|
],
|
||||||
"lighting" : "combined",
|
"lighting" : "combined",
|
||||||
"shadow" : {
|
"shadow" : {
|
||||||
"kind": "neutral",
|
"kind" : "layer-color",
|
||||||
"opacity": 0.6
|
"opacity" : 0.8
|
||||||
},
|
},
|
||||||
"translucency" : {
|
"translucency" : {
|
||||||
"enabled" : true,
|
"enabled" : true,
|
||||||
|
|||||||
@@ -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
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -24,6 +26,10 @@ configs:
|
|||||||
# 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).
|
||||||
@@ -50,10 +56,6 @@ packages:
|
|||||||
targetTemplates:
|
targetTemplates:
|
||||||
AppBase:
|
AppBase:
|
||||||
type: application
|
type: application
|
||||||
configFiles:
|
|
||||||
Debug: Signing.xcconfig
|
|
||||||
Release: Signing.xcconfig
|
|
||||||
Release-Direct: Signing.xcconfig
|
|
||||||
sources:
|
sources:
|
||||||
- path: VniDrop
|
- path: VniDrop
|
||||||
excludes:
|
excludes:
|
||||||
@@ -65,11 +67,7 @@ targetTemplates:
|
|||||||
base:
|
base:
|
||||||
PRODUCT_NAME: VniDrop
|
PRODUCT_NAME: VniDrop
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: com.vnidrop.app
|
PRODUCT_BUNDLE_IDENTIFIER: com.vnidrop.app
|
||||||
MARKETING_VERSION: "0.1.0"
|
MARKETING_VERSION: "$(PRODUCT_VERSION)"
|
||||||
# Placeholder only — the real CFBundleVersion is stamped at build time as a
|
|
||||||
# UTC YYMMDD.HHMM timestamp by the "Stamp build number" phase below, so every
|
|
||||||
# build is monotonic and self-describing (shown as "MARKETING_VERSION (build)").
|
|
||||||
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
|
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements
|
||||||
@@ -111,29 +109,16 @@ targetTemplates:
|
|||||||
echo "error: SwiftLint not installed — run 'brew install swiftlint'"
|
echo "error: SwiftLint not installed — run 'brew install swiftlint'"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
postBuildScripts:
|
|
||||||
# Stamp CFBundleVersion as a UTC YYMMDD.HHMM timestamp into the built
|
|
||||||
# Info.plist before code signing. Runs for every build (Xcode GUI archive and
|
|
||||||
# CLI alike), so both the App Store and direct-download channels get a
|
|
||||||
# monotonic, meaningful build id. CI/reproducible builds can pin it via the
|
|
||||||
# VNIDROP_BUILD env var. MARKETING_VERSION stays the human X.Y.Z version.
|
|
||||||
- name: Stamp build number (UTC timestamp)
|
|
||||||
basedOnDependencyAnalysis: false
|
|
||||||
script: |
|
|
||||||
build="${VNIDROP_BUILD:-$(date -u +%y%m%d.%H%M)}"
|
|
||||||
plist="${TARGET_BUILD_DIR}/${INFOPLIST_PATH}"
|
|
||||||
if [ -f "$plist" ]; then
|
|
||||||
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $build" "$plist"
|
|
||||||
echo "Stamped CFBundleVersion = $build"
|
|
||||||
else
|
|
||||||
echo "warning: Info.plist not found at $plist; CFBundleVersion not stamped"
|
|
||||||
fi
|
|
||||||
|
|
||||||
targets:
|
targets:
|
||||||
# App Store / TestFlight target. iOS + macOS, sandboxed, no self-updater.
|
# App Store / TestFlight target. iOS + macOS, sandboxed, no self-updater.
|
||||||
VniDrop:
|
VniDrop:
|
||||||
templates: [AppBase]
|
templates: [AppBase]
|
||||||
supportedDestinations: [iOS, macOS]
|
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
|
# 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
|
# and self-updates via Sparkle. DIRECT_DISTRIBUTION gates all Sparkle code so the
|
||||||
@@ -141,6 +126,10 @@ targets:
|
|||||||
VniDropDirect:
|
VniDropDirect:
|
||||||
templates: [AppBase]
|
templates: [AppBase]
|
||||||
supportedDestinations: [macOS]
|
supportedDestinations: [macOS]
|
||||||
|
configFiles:
|
||||||
|
Debug: Generated/DirectVersion.xcconfig
|
||||||
|
Release: Generated/DirectVersion.xcconfig
|
||||||
|
Release-Direct: Generated/DirectVersion.xcconfig
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS: "$(inherited) DIRECT_DISTRIBUTION"
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS: "$(inherited) DIRECT_DISTRIBUTION"
|
||||||
@@ -150,11 +139,6 @@ targets:
|
|||||||
# provisioning profile, which direct distribution avoids. (App Store target
|
# provisioning profile, which direct distribution avoids. (App Store target
|
||||||
# keeps VniDrop.entitlements with the sandbox.)
|
# keeps VniDrop.entitlements with the sandbox.)
|
||||||
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDropDirect.entitlements
|
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDropDirect.entitlements
|
||||||
# The Rust core's macOS slice (vnidrop.xcframework) is arm64-only
|
|
||||||
# (build-core.sh builds aarch64-apple-darwin only), so the direct build is
|
|
||||||
# Apple-Silicon-only. Pin ARCHS so the Release-Direct (universal-by-default)
|
|
||||||
# link doesn't fail looking for x86_64 symbols.
|
|
||||||
ARCHS: arm64
|
|
||||||
dependencies:
|
dependencies:
|
||||||
- package: Sparkle
|
- package: Sparkle
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -7,8 +7,7 @@
|
|||||||
# This is the direct-distribution counterpart to the App Store archive flow; it
|
# 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.
|
# never touches the App Store `VniDrop` target. The Rust crate is not modified.
|
||||||
#
|
#
|
||||||
# Usage: apple/scripts/build-dmg.sh [version]
|
# Usage: apple/scripts/build-dmg.sh
|
||||||
# version MAJOR.MINOR.PATCH; defaults to MARKETING_VERSION / the git tag.
|
|
||||||
#
|
#
|
||||||
# Environment:
|
# Environment:
|
||||||
# DEVELOPER_ID_APP Codesign identity, e.g. "Developer ID Application: … (TEAMID)".
|
# DEVELOPER_ID_APP Codesign identity, e.g. "Developer ID Application: … (TEAMID)".
|
||||||
@@ -29,29 +28,14 @@ PROJECT="$APPLE_DIR/VniDrop.xcodeproj"
|
|||||||
SCHEME="VniDropDirect"
|
SCHEME="VniDropDirect"
|
||||||
CONFIG="Release-Direct"
|
CONFIG="Release-Direct"
|
||||||
APP_NAME="VniDrop"
|
APP_NAME="VniDrop"
|
||||||
|
VERSION_RESOLVER="$REPO_ROOT/packaging/version/resolve-version.sh"
|
||||||
|
VERSION_CONFIG_GENERATOR="$REPO_ROOT/packaging/version/generate-apple-xcconfig.sh"
|
||||||
|
|
||||||
# --- Resolve version (arg > git tag > project MARKETING_VERSION) -------------
|
VERSION="$("$VERSION_RESOLVER" product)"
|
||||||
resolve_version() {
|
export VNIDROP_BUILD_TIME_UTC="${VNIDROP_BUILD_TIME_UTC:-$(date -u +%Y%m%d%H%M%S)}"
|
||||||
local v="${1:-}"
|
BUILD_NUMBER="$("$VERSION_RESOLVER" apple-direct-build)"
|
||||||
if [ -z "$v" ] && [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
|
"$VERSION_RESOLVER" verify >/dev/null
|
||||||
v="${GITHUB_REF_NAME#v}"
|
BUILD_METADATA="$DIST_DIR/$APP_NAME-$VERSION.build-info.json"
|
||||||
fi
|
|
||||||
if [ -z "$v" ]; then
|
|
||||||
v="$(sed -nE 's/.*MARKETING_VERSION: "([0-9.]+)".*/\1/p' "$APPLE_DIR/project.yml" | head -1)"
|
|
||||||
fi
|
|
||||||
if [[ ! "$v" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
|
||||||
echo "version must be MAJOR.MINOR.PATCH (got '$v')" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
printf '%s' "$v"
|
|
||||||
}
|
|
||||||
VERSION="$(resolve_version "${1:-}")"
|
|
||||||
|
|
||||||
# CFBundleVersion is a UTC YYMMDD.HHMM timestamp stamped by the target's
|
|
||||||
# "Stamp build number" build phase. Pin it here (one value for the whole archive)
|
|
||||||
# so the app, DMG, and appcast all agree; Sparkle compares it to order updates.
|
|
||||||
BUILD_NUMBER="$(date -u +%y%m%d.%H%M)"
|
|
||||||
export VNIDROP_BUILD="$BUILD_NUMBER"
|
|
||||||
|
|
||||||
# --- Resolve signing identity ------------------------------------------------
|
# --- Resolve signing identity ------------------------------------------------
|
||||||
if [ -z "${DEVELOPER_ID_APP:-}" ]; then
|
if [ -z "${DEVELOPER_ID_APP:-}" ]; then
|
||||||
@@ -76,6 +60,7 @@ echo " team: ${DEVELOPMENT_TEAM:-<unknown>}"
|
|||||||
echo "==> Building Rust core (release)"
|
echo "==> Building Rust core (release)"
|
||||||
CARGO_PROFILE_RELEASE_LTO=false "$SCRIPT_DIR/build-core.sh" release
|
CARGO_PROFILE_RELEASE_LTO=false "$SCRIPT_DIR/build-core.sh" release
|
||||||
echo "==> Regenerating Xcode project"
|
echo "==> Regenerating Xcode project"
|
||||||
|
"$VERSION_CONFIG_GENERATOR" all
|
||||||
( cd "$APPLE_DIR" && xcodegen generate >/dev/null )
|
( cd "$APPLE_DIR" && xcodegen generate >/dev/null )
|
||||||
|
|
||||||
rm -rf "$BUILD_DIR" && mkdir -p "$BUILD_DIR" "$DIST_DIR"
|
rm -rf "$BUILD_DIR" && mkdir -p "$BUILD_DIR" "$DIST_DIR"
|
||||||
@@ -107,6 +92,24 @@ xcodebuild -exportArchive \
|
|||||||
-exportOptionsPlist "$EXPORT_OPTS"
|
-exportOptionsPlist "$EXPORT_OPTS"
|
||||||
APP="$EXPORT_DIR/$APP_NAME.app"
|
APP="$EXPORT_DIR/$APP_NAME.app"
|
||||||
[ -d "$APP" ] || { echo "error: export failed" >&2; exit 1; }
|
[ -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 -----------------------------------------------------------
|
# --- Build the DMG -----------------------------------------------------------
|
||||||
DMG="$DIST_DIR/$APP_NAME-$VERSION.dmg"
|
DMG="$DIST_DIR/$APP_NAME-$VERSION.dmg"
|
||||||
@@ -140,7 +143,8 @@ codesign --force --sign "$DEVELOPER_ID_APP" --timestamp "$DMG"
|
|||||||
# --- Notarize + staple -------------------------------------------------------
|
# --- Notarize + staple -------------------------------------------------------
|
||||||
if [ -n "${NOTARY_PROFILE:-}" ]; then
|
if [ -n "${NOTARY_PROFILE:-}" ]; then
|
||||||
echo "==> Notarizing (profile: $NOTARY_PROFILE)"
|
echo "==> Notarizing (profile: $NOTARY_PROFILE)"
|
||||||
xcrun notarytool submit "$DMG" --keychain-profile "$NOTARY_PROFILE" --wait
|
NOTARY_LOG="$DIST_DIR/$APP_NAME-$VERSION.notary-log.json"
|
||||||
|
"$SCRIPT_DIR/notarize.sh" "$DMG" "$NOTARY_PROFILE" "$NOTARY_LOG"
|
||||||
echo "==> Stapling"
|
echo "==> Stapling"
|
||||||
xcrun stapler staple "$DMG"
|
xcrun stapler staple "$DMG"
|
||||||
xcrun stapler validate "$DMG"
|
xcrun stapler validate "$DMG"
|
||||||
@@ -152,7 +156,18 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
SIZE="$(stat -f%z "$DMG")"
|
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 "==> Done."
|
||||||
echo " dmg: $DMG"
|
echo " dmg: $DMG"
|
||||||
echo " version: $VERSION"
|
echo " version: $VERSION"
|
||||||
|
echo " build: $BUILD_NUMBER"
|
||||||
echo " size: $SIZE bytes"
|
echo " size: $SIZE bytes"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
# keychain). The resulting appcast.xml is uploaded as a release asset; the app's
|
# 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.
|
# SUFeedURL (/releases/latest/download/appcast.xml) always resolves to the newest.
|
||||||
#
|
#
|
||||||
# Usage: apple/scripts/generate-appcast.sh [version]
|
# Usage: apple/scripts/generate-appcast.sh
|
||||||
#
|
#
|
||||||
# Environment:
|
# Environment:
|
||||||
# DIST_DIR Folder holding the DMG(s). Default: apple/dist
|
# DIST_DIR Folder holding the DMG(s). Default: apple/dist
|
||||||
@@ -24,12 +24,10 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
|||||||
APPLE_DIR="$REPO_ROOT/apple"
|
APPLE_DIR="$REPO_ROOT/apple"
|
||||||
DIST_DIR="${DIST_DIR:-$APPLE_DIR/dist}"
|
DIST_DIR="${DIST_DIR:-$APPLE_DIR/dist}"
|
||||||
RELEASE_REPO="${RELEASE_REPO:-sudosylabs/vnidrop}"
|
RELEASE_REPO="${RELEASE_REPO:-sudosylabs/vnidrop}"
|
||||||
|
VERSION_RESOLVER="$REPO_ROOT/packaging/version/resolve-version.sh"
|
||||||
|
|
||||||
VERSION="${1:-}"
|
VERSION="$("$VERSION_RESOLVER" product)"
|
||||||
if [ -z "$VERSION" ] && [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
|
"$VERSION_RESOLVER" verify >/dev/null
|
||||||
VERSION="${GITHUB_REF_NAME#v}"
|
|
||||||
fi
|
|
||||||
[ -n "$VERSION" ] || { echo "error: version required (arg or tag)" >&2; exit 1; }
|
|
||||||
|
|
||||||
# Enclosure URLs resolve to the specific release's assets.
|
# Enclosure URLs resolve to the specific release's assets.
|
||||||
DOWNLOAD_PREFIX="https://github.com/$RELEASE_REPO/releases/download/v$VERSION"
|
DOWNLOAD_PREFIX="https://github.com/$RELEASE_REPO/releases/download/v$VERSION"
|
||||||
|
|||||||
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": {
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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'
|
||||||
78
packaging/version/README.md
Normal file
78
packaging/version/README.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Application versioning
|
||||||
|
|
||||||
|
`version.properties` at the repository root is the single source of truth for
|
||||||
|
the VniDrop product version and the permanent Windows version epoch. Platform
|
||||||
|
projects and release workflows derive their versions from it rather than
|
||||||
|
accepting independent release counters.
|
||||||
|
|
||||||
|
Keep it as plain `KEY=VALUE` assignments: the same file is parsed by shell,
|
||||||
|
PowerShell, Gradle, and Rust. Xcode receives resolver-generated xcconfig files.
|
||||||
|
|
||||||
|
The product uses numeric semantic versions. While the app is in beta, feature
|
||||||
|
releases increment the minor component (`0.2.0`, `0.3.0`) and fixes increment
|
||||||
|
the patch component (`0.2.1`). Release channels belong in
|
||||||
|
`RELEASE_CHANNEL`; they are not appended to store version fields.
|
||||||
|
|
||||||
|
| Platform | Product version | Platform build/package version |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Android | `PRODUCT_VERSION` | Derived monotonic integer |
|
||||||
|
| Apple Store | `PRODUCT_VERSION` | Derived UTC `YYYYMMDD.HHMM.SS` |
|
||||||
|
| Direct macOS | `PRODUCT_VERSION` | Independently derived UTC `YYYYMMDD.HHMM.SS` |
|
||||||
|
| Linux | `PRODUCT_VERSION` | Native package revision |
|
||||||
|
| Rust handshake | `PRODUCT_VERSION` | Rust crate version remains independent |
|
||||||
|
| Microsoft Store | `PRODUCT_VERSION` in the app | Derived MSIX dot-quad |
|
||||||
|
|
||||||
|
MSIX requires a non-zero first component and reserves the fourth component for
|
||||||
|
the Store. Its version is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
(product major + WINDOWS_VERSION_EPOCH).product minor.product patch.0
|
||||||
|
```
|
||||||
|
|
||||||
|
With epoch `1`, product `0.2.0` maps to MSIX `1.2.0.0`, while product `1.0.0`
|
||||||
|
maps to `2.0.0.0`. Do not change the epoch after publishing.
|
||||||
|
|
||||||
|
Android derives its version code as:
|
||||||
|
|
||||||
|
```text
|
||||||
|
product major * 1,000,000 + product minor * 1,000 + product patch
|
||||||
|
```
|
||||||
|
|
||||||
|
For example, `0.2.0` maps to Android code `2000`, `0.2.1` to `2001`, and
|
||||||
|
`1.0.0` to `1000000`. To keep that mapping unique and within store limits,
|
||||||
|
the product major may not exceed `2099`, and minor and patch may not exceed
|
||||||
|
`999`. A rejected store build must use a new patch version rather than
|
||||||
|
rebuilding a previously uploaded product version.
|
||||||
|
|
||||||
|
Apple build numbers are derived at build time by `apple-store-build` and
|
||||||
|
`apple-direct-build`; they are kept as separate resolver outputs so App Store
|
||||||
|
and Sparkle releases do not consume each other's cadence. Every changed Windows
|
||||||
|
Store package must increment the product version because the Store-reserved
|
||||||
|
fourth component cannot carry a rebuild number.
|
||||||
|
|
||||||
|
Apple projects read generated build settings rather than `version.properties`
|
||||||
|
directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
packaging/version/generate-apple-xcconfig.sh all
|
||||||
|
```
|
||||||
|
|
||||||
|
The generated files under `apple/Generated/` are intentionally ignored.
|
||||||
|
`VNIDROP_BUILD_TIME_UTC=YYYYMMDDHHMMSS` provides a deterministic clock for
|
||||||
|
tests; distribution builds normally use the current UTC time.
|
||||||
|
|
||||||
|
Prepare the next release by changing only the product version:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make prepare-release RELEASE_VERSION=0.2.1
|
||||||
|
```
|
||||||
|
|
||||||
|
The command refuses non-increasing versions, updates `PRODUCT_VERSION`, and
|
||||||
|
prints the derived Android and Microsoft Store versions. Then verify:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make check-version
|
||||||
|
```
|
||||||
|
|
||||||
|
Release tags must exactly match `vPRODUCT_VERSION`. Manual workflow dispatches
|
||||||
|
also build the committed version and do not accept free-form version inputs.
|
||||||
48
packaging/version/generate-apple-xcconfig.sh
Executable file
48
packaging/version/generate-apple-xcconfig.sh
Executable file
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
repo_root="$(cd "$script_dir/../.." && pwd)"
|
||||||
|
resolver="$script_dir/resolve-version.sh"
|
||||||
|
output_dir="${VNIDROP_APPLE_XCCONFIG_DIR:-$repo_root/apple/Generated}"
|
||||||
|
mode="${1:-all}"
|
||||||
|
|
||||||
|
case "$mode" in
|
||||||
|
store|direct|all) ;;
|
||||||
|
*)
|
||||||
|
printf 'Usage: %s {store|direct|all}\n' "$0" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
export VNIDROP_BUILD_TIME_UTC="${VNIDROP_BUILD_TIME_UTC:-$(date -u +%Y%m%d%H%M%S)}"
|
||||||
|
product_version="$("$resolver" product)"
|
||||||
|
mkdir -p "$output_dir"
|
||||||
|
|
||||||
|
write_config() {
|
||||||
|
local filename=$1
|
||||||
|
local build_field=$2
|
||||||
|
local destination="$output_dir/$filename"
|
||||||
|
local temporary
|
||||||
|
local build_number
|
||||||
|
|
||||||
|
build_number="$("$resolver" "$build_field")"
|
||||||
|
temporary="$(mktemp "$output_dir/.${filename}.XXXXXX")"
|
||||||
|
printf '%s\n' \
|
||||||
|
'// Generated by packaging/version/generate-apple-xcconfig.sh.' \
|
||||||
|
'// Regenerate this file instead of editing it.' \
|
||||||
|
'#include "../Signing.xcconfig"' \
|
||||||
|
'' \
|
||||||
|
"PRODUCT_VERSION = $product_version" \
|
||||||
|
"CURRENT_PROJECT_VERSION = $build_number" \
|
||||||
|
> "$temporary"
|
||||||
|
mv "$temporary" "$destination"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ $mode == store || $mode == all ]]; then
|
||||||
|
write_config StoreVersion.xcconfig apple-store-build
|
||||||
|
fi
|
||||||
|
if [[ $mode == direct || $mode == all ]]; then
|
||||||
|
write_config DirectVersion.xcconfig apple-direct-build
|
||||||
|
fi
|
||||||
53
packaging/version/prepare-release.sh
Executable file
53
packaging/version/prepare-release.sh
Executable file
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
repo_root="$(cd "$script_dir/../.." && pwd)"
|
||||||
|
resolver="$script_dir/resolve-version.sh"
|
||||||
|
version_file="${VNIDROP_VERSION_FILE:-$repo_root/version.properties}"
|
||||||
|
next_version="${1:-}"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf '%s\n' "$*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ -n $next_version ]] ||
|
||||||
|
fail "Usage: $0 MAJOR.MINOR.PATCH"
|
||||||
|
[[ -f $version_file ]] ||
|
||||||
|
fail "Version file not found: $version_file"
|
||||||
|
[[ $(grep -c '^PRODUCT_VERSION=' "$version_file") == 1 ]] ||
|
||||||
|
fail "Expected exactly one PRODUCT_VERSION entry in $version_file"
|
||||||
|
|
||||||
|
current_version="$(
|
||||||
|
VNIDROP_VERSION_FILE="$version_file" "$resolver" product
|
||||||
|
)"
|
||||||
|
current_android_code="$(
|
||||||
|
VNIDROP_VERSION_FILE="$version_file" "$resolver" android-code
|
||||||
|
)"
|
||||||
|
temporary="$(mktemp "$(dirname "$version_file")/.version.properties.XXXXXX")"
|
||||||
|
trap 'rm -f "$temporary"' EXIT
|
||||||
|
|
||||||
|
sed "s/^PRODUCT_VERSION=.*/PRODUCT_VERSION=$next_version/" \
|
||||||
|
"$version_file" > "$temporary"
|
||||||
|
|
||||||
|
next_android_code="$(
|
||||||
|
VNIDROP_VERSION_FILE="$temporary" "$resolver" android-code
|
||||||
|
)"
|
||||||
|
next_windows_package="$(
|
||||||
|
VNIDROP_VERSION_FILE="$temporary" "$resolver" windows-package
|
||||||
|
)"
|
||||||
|
VNIDROP_VERSION_FILE="$temporary" "$resolver" verify >/dev/null
|
||||||
|
|
||||||
|
(( next_android_code > current_android_code )) ||
|
||||||
|
fail "New version must be greater than $current_version"
|
||||||
|
|
||||||
|
chmod 644 "$temporary"
|
||||||
|
mv "$temporary" "$version_file"
|
||||||
|
trap - EXIT
|
||||||
|
|
||||||
|
printf 'Prepared VniDrop %s\n' "$next_version"
|
||||||
|
printf ' Android version code: %s\n' "$next_android_code"
|
||||||
|
printf ' Microsoft Store package: %s\n' "$next_windows_package"
|
||||||
|
printf 'Next: make check-version\n'
|
||||||
121
packaging/version/resolve-version.ps1
Normal file
121
packaging/version/resolve-version.ps1
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[ValidateSet("Product", "Channel", "AndroidCode", "AppleStoreBuild", "AppleDirectBuild", "WindowsPackage", "Json", "Verify")]
|
||||||
|
[string] $Field = "Verify",
|
||||||
|
|
||||||
|
[switch] $VerifyTag,
|
||||||
|
|
||||||
|
[string] $VersionFile
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($VersionFile)) {
|
||||||
|
$VersionFile = Join-Path $PSScriptRoot "..\..\version.properties"
|
||||||
|
}
|
||||||
|
$VersionFile = (Resolve-Path -LiteralPath $VersionFile).Path
|
||||||
|
|
||||||
|
function Read-VersionProperty {
|
||||||
|
param([string] $Name)
|
||||||
|
|
||||||
|
$prefix = "$Name="
|
||||||
|
$matches = @(Get-Content -LiteralPath $VersionFile | Where-Object { $_.StartsWith($prefix) })
|
||||||
|
if ($matches.Count -ne 1) {
|
||||||
|
throw "Expected exactly one $Name entry in $VersionFile"
|
||||||
|
}
|
||||||
|
return $matches[0].Substring($prefix.Length)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Convert-CanonicalInteger {
|
||||||
|
param(
|
||||||
|
[string] $Name,
|
||||||
|
[string] $Value,
|
||||||
|
[long] $Minimum,
|
||||||
|
[long] $Maximum
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($Value -notmatch "^(0|[1-9][0-9]*)$") {
|
||||||
|
throw "$Name must be a canonical non-negative integer"
|
||||||
|
}
|
||||||
|
$number = 0L
|
||||||
|
if (-not [long]::TryParse($Value, [ref] $number) -or $number -lt $Minimum -or $number -gt $Maximum) {
|
||||||
|
throw "$Name must be between $Minimum and $Maximum"
|
||||||
|
}
|
||||||
|
return $number
|
||||||
|
}
|
||||||
|
|
||||||
|
$productVersion = Read-VersionProperty "PRODUCT_VERSION"
|
||||||
|
$releaseChannel = Read-VersionProperty "RELEASE_CHANNEL"
|
||||||
|
$windowsVersionEpochText = Read-VersionProperty "WINDOWS_VERSION_EPOCH"
|
||||||
|
$buildTimeUtc = $env:VNIDROP_BUILD_TIME_UTC
|
||||||
|
if ([string]::IsNullOrWhiteSpace($buildTimeUtc)) {
|
||||||
|
$buildTimeUtc = [DateTime]::UtcNow.ToString(
|
||||||
|
"yyyyMMddHHmmss",
|
||||||
|
[Globalization.CultureInfo]::InvariantCulture
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if ($buildTimeUtc -notmatch "^[0-9]{14}$") {
|
||||||
|
throw "VNIDROP_BUILD_TIME_UTC must use YYYYMMDDHHMMSS"
|
||||||
|
}
|
||||||
|
$parsedBuildTime = [DateTime]::MinValue
|
||||||
|
if (-not [DateTime]::TryParseExact(
|
||||||
|
$buildTimeUtc,
|
||||||
|
"yyyyMMddHHmmss",
|
||||||
|
[Globalization.CultureInfo]::InvariantCulture,
|
||||||
|
[Globalization.DateTimeStyles]::AssumeUniversal -bor [Globalization.DateTimeStyles]::AdjustToUniversal,
|
||||||
|
[ref] $parsedBuildTime
|
||||||
|
)) {
|
||||||
|
throw "VNIDROP_BUILD_TIME_UTC is not a valid UTC timestamp"
|
||||||
|
}
|
||||||
|
$appleBuildNumber = $parsedBuildTime.ToString(
|
||||||
|
"yyyyMMdd.HHmm.ss",
|
||||||
|
[Globalization.CultureInfo]::InvariantCulture
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($productVersion -notmatch "^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$") {
|
||||||
|
throw "PRODUCT_VERSION must use canonical MAJOR.MINOR.PATCH integers"
|
||||||
|
}
|
||||||
|
$productParts = $productVersion.Split(".")
|
||||||
|
$productMajor = Convert-CanonicalInteger "PRODUCT_VERSION major" $productParts[0] 0 2099
|
||||||
|
$productMinor = Convert-CanonicalInteger "PRODUCT_VERSION minor" $productParts[1] 0 999
|
||||||
|
$productPatch = Convert-CanonicalInteger "PRODUCT_VERSION patch" $productParts[2] 0 999
|
||||||
|
if ($releaseChannel -notmatch "^[a-z][a-z0-9-]*$") {
|
||||||
|
throw "RELEASE_CHANNEL contains unsupported characters"
|
||||||
|
}
|
||||||
|
$androidVersionCode = $productMajor * 1000000L + $productMinor * 1000L + $productPatch
|
||||||
|
if ($androidVersionCode -lt 1 -or $androidVersionCode -gt 2100000000L) {
|
||||||
|
throw "Derived Android version code must be between 1 and 2100000000"
|
||||||
|
}
|
||||||
|
$windowsVersionEpoch = Convert-CanonicalInteger "WINDOWS_VERSION_EPOCH" $windowsVersionEpochText 1 65535
|
||||||
|
$windowsMajor = $productMajor + $windowsVersionEpoch
|
||||||
|
if ($windowsMajor -gt 65535) {
|
||||||
|
throw "Derived Windows package major exceeds 65535"
|
||||||
|
}
|
||||||
|
$windowsPackageVersion = "$windowsMajor.$($productParts[1]).$($productParts[2]).0"
|
||||||
|
|
||||||
|
if ($VerifyTag -and $env:GITHUB_REF_TYPE -eq "tag" -and $env:GITHUB_REF_NAME -ne "v$productVersion") {
|
||||||
|
throw "Release tag must be v$productVersion, got $($env:GITHUB_REF_NAME)"
|
||||||
|
}
|
||||||
|
|
||||||
|
$versionInfo = [ordered] @{
|
||||||
|
productVersion = $productVersion
|
||||||
|
releaseChannel = $releaseChannel
|
||||||
|
androidVersionCode = $androidVersionCode
|
||||||
|
appleStoreBuildNumber = $appleBuildNumber
|
||||||
|
appleDirectBuildNumber = $appleBuildNumber
|
||||||
|
windowsPackageVersion = $windowsPackageVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ($Field) {
|
||||||
|
"Product" { $productVersion }
|
||||||
|
"Channel" { $releaseChannel }
|
||||||
|
"AndroidCode" { $androidVersionCode }
|
||||||
|
"AppleStoreBuild" { $appleBuildNumber }
|
||||||
|
"AppleDirectBuild" { $appleBuildNumber }
|
||||||
|
"WindowsPackage" { $windowsPackageVersion }
|
||||||
|
"Json" { $versionInfo | ConvertTo-Json -Compress }
|
||||||
|
"Verify" {
|
||||||
|
"VniDrop $productVersion ($releaseChannel), Android $androidVersionCode, Apple Store $appleBuildNumber, Apple Direct $appleBuildNumber, MSIX $windowsPackageVersion"
|
||||||
|
}
|
||||||
|
}
|
||||||
136
packaging/version/resolve-version.sh
Executable file
136
packaging/version/resolve-version.sh
Executable file
@@ -0,0 +1,136 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
repo_root="$(cd "$script_dir/../.." && pwd)"
|
||||||
|
version_file="${VNIDROP_VERSION_FILE:-$repo_root/version.properties}"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf '%s\n' "$*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
read_property() {
|
||||||
|
local key=$1
|
||||||
|
local matches
|
||||||
|
matches="$(sed -n "s/^${key}=//p" "$version_file")"
|
||||||
|
[[ -n "$matches" ]] || fail "Missing $key in $version_file"
|
||||||
|
[[ $(printf '%s\n' "$matches" | wc -l | tr -d ' ') == 1 ]] ||
|
||||||
|
fail "Duplicate $key in $version_file"
|
||||||
|
printf '%s' "$matches"
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_canonical_integer() {
|
||||||
|
local name=$1
|
||||||
|
local value=$2
|
||||||
|
local minimum=$3
|
||||||
|
local maximum=$4
|
||||||
|
[[ $value =~ ^(0|[1-9][0-9]*)$ ]] ||
|
||||||
|
fail "$name must be a canonical non-negative integer"
|
||||||
|
(( 10#$value >= minimum && 10#$value <= maximum )) ||
|
||||||
|
fail "$name must be between $minimum and $maximum"
|
||||||
|
}
|
||||||
|
|
||||||
|
product_version="$(read_property PRODUCT_VERSION)"
|
||||||
|
release_channel="$(read_property RELEASE_CHANNEL)"
|
||||||
|
windows_version_epoch="$(read_property WINDOWS_VERSION_EPOCH)"
|
||||||
|
build_time_utc="${VNIDROP_BUILD_TIME_UTC:-$(date -u +%Y%m%d%H%M%S)}"
|
||||||
|
|
||||||
|
[[ $product_version =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] ||
|
||||||
|
fail "PRODUCT_VERSION must use canonical MAJOR.MINOR.PATCH integers"
|
||||||
|
IFS=. read -r product_major product_minor product_patch <<< "$product_version"
|
||||||
|
validate_canonical_integer "PRODUCT_VERSION major" "$product_major" 0 2099
|
||||||
|
validate_canonical_integer "PRODUCT_VERSION minor" "$product_minor" 0 999
|
||||||
|
validate_canonical_integer "PRODUCT_VERSION patch" "$product_patch" 0 999
|
||||||
|
[[ $release_channel =~ ^[a-z][a-z0-9-]*$ ]] ||
|
||||||
|
fail "RELEASE_CHANNEL must start with a lowercase letter and contain only lowercase letters, digits, and hyphens"
|
||||||
|
validate_canonical_integer "WINDOWS_VERSION_EPOCH" "$windows_version_epoch" 1 65535
|
||||||
|
[[ $build_time_utc =~ ^[0-9]{14}$ ]] ||
|
||||||
|
fail "VNIDROP_BUILD_TIME_UTC must use YYYYMMDDHHMMSS"
|
||||||
|
|
||||||
|
android_version_code=$((
|
||||||
|
(10#$product_major * 1000000) +
|
||||||
|
(10#$product_minor * 1000) +
|
||||||
|
10#$product_patch
|
||||||
|
))
|
||||||
|
(( android_version_code >= 1 && android_version_code <= 2100000000 )) ||
|
||||||
|
fail "Derived Android version code must be between 1 and 2100000000"
|
||||||
|
|
||||||
|
build_month="${build_time_utc:4:2}"
|
||||||
|
build_day="${build_time_utc:6:2}"
|
||||||
|
build_hour="${build_time_utc:8:2}"
|
||||||
|
build_minute="${build_time_utc:10:2}"
|
||||||
|
build_second="${build_time_utc:12:2}"
|
||||||
|
build_year="${build_time_utc:0:4}"
|
||||||
|
(( 10#$build_year >= 1 )) ||
|
||||||
|
fail "VNIDROP_BUILD_TIME_UTC contains an invalid year"
|
||||||
|
(( 10#$build_month >= 1 && 10#$build_month <= 12 )) ||
|
||||||
|
fail "VNIDROP_BUILD_TIME_UTC contains an invalid month"
|
||||||
|
|
||||||
|
case $((10#$build_month)) in
|
||||||
|
2)
|
||||||
|
max_build_day=28
|
||||||
|
if (( 10#$build_year % 400 == 0 ||
|
||||||
|
(10#$build_year % 4 == 0 && 10#$build_year % 100 != 0) )); then
|
||||||
|
max_build_day=29
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
4|6|9|11)
|
||||||
|
max_build_day=30
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
max_build_day=31
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
(( 10#$build_day >= 1 && 10#$build_day <= max_build_day )) ||
|
||||||
|
fail "VNIDROP_BUILD_TIME_UTC contains an invalid day"
|
||||||
|
(( 10#$build_hour <= 23 && 10#$build_minute <= 59 && 10#$build_second <= 59 )) ||
|
||||||
|
fail "VNIDROP_BUILD_TIME_UTC contains an invalid time"
|
||||||
|
apple_build_number="${build_time_utc:0:8}.${build_time_utc:8:4}.${build_time_utc:12:2}"
|
||||||
|
|
||||||
|
windows_major=$((10#$product_major + 10#$windows_version_epoch))
|
||||||
|
(( windows_major <= 65535 )) ||
|
||||||
|
fail "Derived Windows package major exceeds 65535"
|
||||||
|
windows_package_version="$windows_major.$product_minor.$product_patch.0"
|
||||||
|
|
||||||
|
verify_tag() {
|
||||||
|
local tag=${1:-${GITHUB_REF_NAME:-}}
|
||||||
|
if [[ ${GITHUB_REF_TYPE:-} == tag || -n ${1:-} ]]; then
|
||||||
|
[[ $tag == "v$product_version" ]] ||
|
||||||
|
fail "Release tag must be v$product_version, got ${tag:-<empty>}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-verify}" in
|
||||||
|
product)
|
||||||
|
printf '%s\n' "$product_version"
|
||||||
|
;;
|
||||||
|
channel)
|
||||||
|
printf '%s\n' "$release_channel"
|
||||||
|
;;
|
||||||
|
android-code)
|
||||||
|
printf '%s\n' "$android_version_code"
|
||||||
|
;;
|
||||||
|
apple-store-build)
|
||||||
|
printf '%s\n' "$apple_build_number"
|
||||||
|
;;
|
||||||
|
apple-direct-build)
|
||||||
|
printf '%s\n' "$apple_build_number"
|
||||||
|
;;
|
||||||
|
windows-package)
|
||||||
|
printf '%s\n' "$windows_package_version"
|
||||||
|
;;
|
||||||
|
verify)
|
||||||
|
verify_tag
|
||||||
|
printf 'VniDrop %s (%s), Android %s, Apple Store %s, Apple Direct %s, MSIX %s\n' \
|
||||||
|
"$product_version" "$release_channel" "$android_version_code" \
|
||||||
|
"$apple_build_number" "$apple_build_number" "$windows_package_version"
|
||||||
|
;;
|
||||||
|
verify-tag)
|
||||||
|
verify_tag "${2:-}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
fail "Usage: $0 {product|channel|android-code|apple-store-build|apple-direct-build|windows-package|verify|verify-tag [tag]}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
88
packaging/version/test-version.sh
Executable file
88
packaging/version/test-version.sh
Executable file
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
resolver="$script_dir/resolve-version.sh"
|
||||||
|
prepare_release="$script_dir/prepare-release.sh"
|
||||||
|
scratch="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$scratch"' EXIT
|
||||||
|
|
||||||
|
write_version() {
|
||||||
|
printf '%s\n' \
|
||||||
|
"PRODUCT_VERSION=$1" \
|
||||||
|
"RELEASE_CHANNEL=$2" \
|
||||||
|
"WINDOWS_VERSION_EPOCH=$3" \
|
||||||
|
> "$scratch/version.properties"
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve() {
|
||||||
|
VNIDROP_VERSION_FILE="$scratch/version.properties" "$resolver" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_failure() {
|
||||||
|
if "$@" >/dev/null 2>&1; then
|
||||||
|
printf 'Expected command to fail: %s\n' "$*" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
export VNIDROP_BUILD_TIME_UTC=20260728143217
|
||||||
|
|
||||||
|
write_version 0.2.0 beta 1
|
||||||
|
[[ $(resolve product) == 0.2.0 ]]
|
||||||
|
[[ $(resolve android-code) == 2000 ]]
|
||||||
|
[[ $(resolve apple-store-build) == 20260728.1432.17 ]]
|
||||||
|
[[ $(resolve apple-direct-build) == 20260728.1432.17 ]]
|
||||||
|
[[ $(resolve windows-package) == 1.2.0.0 ]]
|
||||||
|
resolve verify-tag v0.2.0
|
||||||
|
expect_failure resolve verify-tag v1.0.0
|
||||||
|
|
||||||
|
write_version 1.0.0 stable 1
|
||||||
|
[[ $(resolve android-code) == 1000000 ]]
|
||||||
|
[[ $(resolve windows-package) == 2.0.0.0 ]]
|
||||||
|
|
||||||
|
write_version 2099.999.999 stable 1
|
||||||
|
[[ $(resolve android-code) == 2099999999 ]]
|
||||||
|
|
||||||
|
write_version 01.0.0 beta 1
|
||||||
|
expect_failure resolve verify
|
||||||
|
|
||||||
|
write_version 0.0.0 beta 1
|
||||||
|
expect_failure resolve verify
|
||||||
|
|
||||||
|
write_version 2100.0.0 stable 1
|
||||||
|
expect_failure resolve verify
|
||||||
|
|
||||||
|
write_version 0.1000.0 stable 1
|
||||||
|
expect_failure resolve verify
|
||||||
|
|
||||||
|
write_version 0.0.1000 stable 1
|
||||||
|
expect_failure resolve verify
|
||||||
|
|
||||||
|
VNIDROP_BUILD_TIME_UTC=20260728146000 expect_failure resolve verify
|
||||||
|
VNIDROP_BUILD_TIME_UTC=2026-07-28 expect_failure resolve verify
|
||||||
|
VNIDROP_BUILD_TIME_UTC=20260229080000 expect_failure resolve verify
|
||||||
|
|
||||||
|
write_version 0.2.0 beta 1
|
||||||
|
config_dir="$scratch/xcconfig"
|
||||||
|
VNIDROP_VERSION_FILE="$scratch/version.properties" \
|
||||||
|
VNIDROP_APPLE_XCCONFIG_DIR="$config_dir" \
|
||||||
|
"$script_dir/generate-apple-xcconfig.sh" all
|
||||||
|
grep -Fx "PRODUCT_VERSION = 0.2.0" "$config_dir/StoreVersion.xcconfig" >/dev/null
|
||||||
|
grep -Fx "CURRENT_PROJECT_VERSION = 20260728.1432.17" \
|
||||||
|
"$config_dir/StoreVersion.xcconfig" >/dev/null
|
||||||
|
grep -Fx "CURRENT_PROJECT_VERSION = 20260728.1432.17" \
|
||||||
|
"$config_dir/DirectVersion.xcconfig" >/dev/null
|
||||||
|
|
||||||
|
VNIDROP_VERSION_FILE="$scratch/version.properties" \
|
||||||
|
"$prepare_release" 0.2.1 >/dev/null
|
||||||
|
[[ $(resolve product) == 0.2.1 ]]
|
||||||
|
[[ $(resolve android-code) == 2001 ]]
|
||||||
|
[[ $(resolve windows-package) == 1.2.1.0 ]]
|
||||||
|
expect_failure env VNIDROP_VERSION_FILE="$scratch/version.properties" \
|
||||||
|
"$prepare_release" 0.2.1
|
||||||
|
expect_failure env VNIDROP_VERSION_FILE="$scratch/version.properties" \
|
||||||
|
"$prepare_release" 0.1.999
|
||||||
|
|
||||||
|
printf 'Version resolver tests passed.\n'
|
||||||
@@ -24,15 +24,17 @@ after the first release. The manifest display name uses the exact reserved Store
|
|||||||
name; the product's in-app branding and launcher remain `VniDrop`.
|
name; the product's in-app branding and launcher remain `VniDrop`.
|
||||||
|
|
||||||
The initial package targets Windows Desktop x64, Windows 10 version 2004
|
The initial package targets Windows Desktop x64, Windows 10 version 2004
|
||||||
(build 19041) or later. The fourth MSIX version component is reserved by the
|
(build 19041) or later. The product version comes from `version.properties`.
|
||||||
Store, so app version 1.2.3 becomes package version 1.2.3.0.
|
Because MSIX requires a non-zero major and reserves the fourth component, the
|
||||||
|
package version adds `WINDOWS_VERSION_EPOCH` to the product major. With epoch
|
||||||
|
`1`, product version `0.2.0` becomes package version `1.2.0.0`.
|
||||||
|
|
||||||
## GitHub Actions
|
## GitHub Actions
|
||||||
|
|
||||||
The Windows Store package workflow runs automatically for relevant pull
|
The Windows Store package workflow runs automatically for relevant pull
|
||||||
requests, for release tags matching vMAJOR.MINOR.PATCH, and by manual dispatch.
|
requests and by manual dispatch. The coordinated release workflow also calls
|
||||||
Pull requests build and validate without retaining an artifact. Tags and manual
|
it for a canonical `vMAJOR.MINOR.PATCH` tag. Pull requests build and validate
|
||||||
runs retain:
|
without retaining an artifact. Manual and coordinated-release runs retain:
|
||||||
|
|
||||||
- VniDrop_VERSION_x64.msix
|
- VniDrop_VERSION_x64.msix
|
||||||
- VniDrop_VERSION_x64.msixupload
|
- VniDrop_VERSION_x64.msixupload
|
||||||
@@ -54,7 +56,8 @@ MakeAppx unpacks the finished package.
|
|||||||
Microsoft's current GitHub Actions publishing flow is for updates to an
|
Microsoft's current GitHub Actions publishing flow is for updates to an
|
||||||
already-live free product. For the first release:
|
already-live free product. For the first release:
|
||||||
|
|
||||||
1. Run this workflow from a release tag or by manual dispatch.
|
1. Push a canonical release tag to run the coordinated workflow, or run the
|
||||||
|
Windows package workflow manually.
|
||||||
2. Download the retained artifact.
|
2. Download the retained artifact.
|
||||||
3. Test that exact build on an interactive Windows VM. Local installation needs
|
3. Test that exact build on an interactive Windows VM. Local installation needs
|
||||||
an ephemeral development signature trusted only by that VM; this is not a
|
an ephemeral development signature trusted only by that VM; this is not a
|
||||||
@@ -71,25 +74,30 @@ Use this restricted-capability justification in Submission options:
|
|||||||
> Rust and JVM libraries and needs normal user-level filesystem and network
|
> Rust and JVM libraries and needs normal user-level filesystem and network
|
||||||
> access to transfer user-selected files directly between devices.
|
> access to transfer user-selected files directly between devices.
|
||||||
|
|
||||||
After the first release is certified and live, Store publication can be added
|
After the first release is certified and live, the coordinated release
|
||||||
as a separate protected job. Keep its Partner Center credentials in a GitHub
|
workflow submits the generated `.msixupload` from a separate protected job.
|
||||||
Environment, not in this build job:
|
Keep its Partner Center credentials in the `microsoft-store` GitHub
|
||||||
|
Environment, not in the build job:
|
||||||
|
|
||||||
- AZURE_AD_TENANT_ID
|
- AZURE_AD_TENANT_ID
|
||||||
- AZURE_AD_APPLICATION_CLIENT_ID
|
- AZURE_AD_APPLICATION_CLIENT_ID
|
||||||
- AZURE_AD_APPLICATION_SECRET
|
- AZURE_AD_APPLICATION_SECRET
|
||||||
- SELLER_ID
|
- SELLER_ID
|
||||||
|
|
||||||
The Store ID is a non-secret variable.
|
Set `MICROSOFT_STORE_PRODUCT_ID` to `9NJ5Q0FG7TGL` as a non-secret variable in
|
||||||
|
the same environment. The publishing job validates the product ID,
|
||||||
|
authenticates with the pinned Microsoft Store Developer CLI, verifies access to
|
||||||
|
the product, and submits only the package for certification. Existing listings,
|
||||||
|
pricing, and availability are preserved.
|
||||||
|
|
||||||
## Manual build on Windows
|
## Manual build on Windows
|
||||||
|
|
||||||
From the repository root:
|
From the repository root:
|
||||||
|
|
||||||
~~~powershell
|
~~~powershell
|
||||||
.\gradlew.bat :shared:jvmTest :desktopApp:createReleaseDistributable -Pvnidrop.version=1.0.0 -Pvnidrop.desktop.rustVariant=release -Pvnidrop.diagnostics.included=false --no-daemon --no-configuration-cache --stacktrace
|
.\gradlew.bat :shared:jvmTest :desktopApp:createReleaseDistributable -Pvnidrop.desktop.rustVariant=release -Pvnidrop.diagnostics.included=false --no-daemon --no-configuration-cache --stacktrace
|
||||||
|
|
||||||
.\packaging\windows\build-msix.ps1 -Version 1.0.0 -AppImage .\desktopApp\build\compose\binaries\main-release\app\VniDrop -OutputDirectory .\build\release\windows
|
.\packaging\windows\build-msix.ps1 -AppImage .\desktopApp\build\compose\binaries\main-release\app\VniDrop -OutputDirectory .\build\release\windows
|
||||||
~~~
|
~~~
|
||||||
|
|
||||||
The packaging script requires Windows SDK 10.0.26100.0. It uses MakePri to
|
The packaging script requires Windows SDK 10.0.26100.0. It uses MakePri to
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
[CmdletBinding()]
|
[CmdletBinding()]
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
|
||||||
[string] $Version,
|
|
||||||
|
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
[string] $AppImage,
|
[string] $AppImage,
|
||||||
|
|
||||||
@@ -87,16 +84,11 @@ if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) {
|
|||||||
throw "MSIX packaging must run on Windows"
|
throw "MSIX packaging must run on Windows"
|
||||||
}
|
}
|
||||||
|
|
||||||
$versionParts = $Version.Split(".")
|
$versionResolver = Join-Path $PSScriptRoot "..\version\resolve-version.ps1"
|
||||||
Assert-Condition ($versionParts.Count -eq 3) "Version must use MAJOR.MINOR.PATCH"
|
$versionInfoJson = & $versionResolver -Field Json -VerifyTag
|
||||||
for ($index = 0; $index -lt $versionParts.Count; $index++) {
|
$versionInfo = $versionInfoJson | ConvertFrom-Json
|
||||||
$part = $versionParts[$index]
|
$Version = [string] $versionInfo.productVersion
|
||||||
$number = 0
|
$packageVersion = [string] $versionInfo.windowsPackageVersion
|
||||||
Assert-Condition ([int]::TryParse($part, [ref] $number)) "Version components must be integers"
|
|
||||||
Assert-Condition ($number.ToString() -eq $part) "Version components must not contain leading zeroes"
|
|
||||||
Assert-Condition ($number -ge $(if ($index -eq 0) { 1 } else { 0 }) -and $number -le 65535) "Version components must be between 0 and 65535, with a non-zero major"
|
|
||||||
}
|
|
||||||
$packageVersion = "$Version.0"
|
|
||||||
|
|
||||||
$appImagePath = (Resolve-Path -LiteralPath $AppImage).Path
|
$appImagePath = (Resolve-Path -LiteralPath $AppImage).Path
|
||||||
Assert-Condition (Test-Path -LiteralPath $appImagePath -PathType Container) "App image not found: $AppImage"
|
Assert-Condition (Test-Path -LiteralPath $appImagePath -PathType Container) "App image not found: $AppImage"
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
# VniDrop diagnostics API
|
# VniDrop diagnostics API
|
||||||
|
|
||||||
Cloudflare Worker for ingesting batched telemetry, crash reports, and user-submitted
|
Cloudflare Worker for ingesting user-submitted bug reports. D1 stores searchable
|
||||||
bug reports. D1 stores searchable metadata; R2 stores larger stack traces and logs.
|
metadata; R2 stores the larger attached logs.
|
||||||
|
|
||||||
The service is designed for modest traffic and low operating cost:
|
The service is designed for modest traffic and low operating cost:
|
||||||
|
|
||||||
- one D1 row is written per telemetry batch, not per event;
|
- one D1 row is written per bug report;
|
||||||
- crash stacks and bug logs are stored in R2 instead of D1;
|
- bug logs are stored in R2 instead of D1;
|
||||||
- request and batch limits reject oversized work before storage writes;
|
- request limits reject oversized work before storage writes;
|
||||||
- an hourly scheduled cleanup and an R2 lifecycle rule enforce retention;
|
- an hourly scheduled cleanup and an R2 lifecycle rule enforce retention;
|
||||||
- no Queue, Durable Object, or KV resources are required.
|
- no Queue, Durable Object, or KV resources are required.
|
||||||
|
|
||||||
@@ -35,15 +35,13 @@ X-VniDrop-Install-Id: <anonymous install UUID>
|
|||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| `GET` | `/live` | process liveness; does not touch storage |
|
| `GET` | `/live` | process liveness; does not touch storage |
|
||||||
| `GET` | `/health` | authenticated readiness; checks required configuration and the D1 schema |
|
| `GET` | `/health` | authenticated readiness; checks required configuration and the D1 schema |
|
||||||
| `POST` | `/v1/events` | `{ batchId, installId, appVersion?, platform?, events: [...] }` |
|
|
||||||
| `POST` | `/v1/crashes` | app crash payload |
|
|
||||||
| `POST` | `/v1/bugs` | app bug-report payload |
|
| `POST` | `/v1/bugs` | app bug-report payload |
|
||||||
|
|
||||||
Batch and report IDs are client-generated UUIDs. A client must reuse the same ID
|
Report IDs are client-generated UUIDs. A client must reuse the same ID when
|
||||||
when retrying so D1 can acknowledge the request without storing it twice.
|
retrying so D1 can acknowledge the request without storing it twice.
|
||||||
|
|
||||||
Accepted reports return `202`. Defaults are a 262,144-byte request limit and at
|
Accepted reports return `202`. The default is a 262,144-byte request limit.
|
||||||
most 50 events per batch. Cloudflare rate-limit bindings allow 30 requests per
|
Cloudflare rate-limit bindings allow 30 requests per
|
||||||
installation and 120 requests per source, per ingest route, per minute. Source
|
installation and 120 requests per source, per ingest route, per minute. Source
|
||||||
limits run before shared-key verification so rejected traffic is bounded too.
|
limits run before shared-key verification so rejected traffic is bounded too.
|
||||||
These counters are eventually consistent and local to a Cloudflare location, so
|
These counters are eventually consistent and local to a Cloudflare location, so
|
||||||
@@ -153,11 +151,11 @@ migrations to the isolated local database assigned to each test file.
|
|||||||
`RETENTION_DAYS` defaults to 90. The `17 * * * *` cron trigger runs cleanup at
|
`RETENTION_DAYS` defaults to 90. The `17 * * * *` cron trigger runs cleanup at
|
||||||
17 minutes past every hour. Cleanup works in bounded batches: it deletes each
|
17 minutes past every hour. Cleanup works in bounded batches: it deletes each
|
||||||
expired report's referenced R2 object before deleting that exact D1 row. The R2
|
expired report's referenced R2 object before deleting that exact D1 row. The R2
|
||||||
lifecycle rule is an independent backstop for stack and log objects, including
|
lifecycle rule is an independent backstop for log objects, including objects left
|
||||||
objects left behind by a partial ingest failure. Each scheduled run can remove
|
behind by a partial ingest failure. Each scheduled run can remove 7,200 bug rows
|
||||||
8,000 event batches and 7,200 rows from each report table while staying below
|
while staying below D1's per-invocation query ceiling. Later hourly runs continue
|
||||||
D1's per-invocation query ceiling. Later hourly runs continue any backlog.
|
any backlog.
|
||||||
Reaching the cap emits a structured warning with the remaining expired-row counts;
|
Reaching the cap emits a structured warning with the remaining expired-row count;
|
||||||
alert on that warning because
|
alert on that warning because
|
||||||
retention is necessarily best-effort during sustained distributed abuse.
|
retention is necessarily best-effort during sustained distributed abuse.
|
||||||
|
|
||||||
@@ -179,23 +177,20 @@ vnidrop.diagnostics.ingestKey=<same value as INGEST_KEY>
|
|||||||
|
|
||||||
Both the endpoint and key are required. When both are empty the app uses its
|
Both the endpoint and key are required. When both are empty the app uses its
|
||||||
offline-safe no-op transport; configuring only one fails the Gradle build.
|
offline-safe no-op transport; configuring only one fails the Gradle build.
|
||||||
`vnidrop.diagnostics.included=false` disables
|
`vnidrop.diagnostics.included=false` routes bug reports to that no-op transport
|
||||||
automatic telemetry and crash upload, but a configured endpoint can still accept
|
(never sent); a configured endpoint accepts an explicit user-submitted bug report.
|
||||||
an explicit user-submitted bug report. Treat the app-side key as an abuse-control
|
Treat the app-side key as an abuse-control token with the limitations described
|
||||||
token with the limitations described above.
|
above.
|
||||||
|
|
||||||
## Reading reports
|
## Reading reports
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx wrangler d1 execute vnidrop-diagnostics --remote \
|
|
||||||
--command "SELECT id, exception_type, platform, occurred_at FROM crashes ORDER BY occurred_at DESC LIMIT 20"
|
|
||||||
|
|
||||||
npx wrangler d1 execute vnidrop-diagnostics --remote \
|
npx wrangler d1 execute vnidrop-diagnostics --remote \
|
||||||
--command "SELECT id, what_happened, status, occurred_at FROM bugs WHERE status = 'open' ORDER BY occurred_at DESC LIMIT 20"
|
--command "SELECT id, what_happened, status, occurred_at FROM bugs WHERE status = 'open' ORDER BY occurred_at DESC LIMIT 20"
|
||||||
```
|
```
|
||||||
|
|
||||||
R2 object keys use `crashes/<id>/<attempt-id>/stack.txt` and
|
R2 object keys use `bugs/<id>/<attempt-id>/logs.txt`. The unique attempt segment
|
||||||
`bugs/<id>/<attempt-id>/logs.txt`. The unique attempt segment prevents a retry
|
prevents a retry from overwriting an already accepted object before D1 detects
|
||||||
from overwriting an already accepted object before D1 detects the duplicate.
|
the duplicate.
|
||||||
There is no public administration endpoint; inspect reports through authenticated
|
There is no public administration endpoint; inspect reports through authenticated
|
||||||
Cloudflare tools or a future Access-protected dashboard.
|
Cloudflare tools or a future Access-protected dashboard.
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Telemetry and crash auto-reporting were removed from the app; only user-initiated
|
||||||
|
-- bug reports remain. Drop the now-unused ingestion tables and their indexes.
|
||||||
|
DROP INDEX IF EXISTS idx_event_batches_received;
|
||||||
|
DROP INDEX IF EXISTS idx_event_batches_install;
|
||||||
|
DROP TABLE IF EXISTS event_batches;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_crashes_received;
|
||||||
|
DROP INDEX IF EXISTS idx_crashes_fingerprint;
|
||||||
|
DROP INDEX IF EXISTS idx_crashes_install;
|
||||||
|
DROP TABLE IF EXISTS crashes;
|
||||||
@@ -1,20 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
normalizeBug,
|
normalizeBug,
|
||||||
normalizeCrash,
|
|
||||||
normalizeEvents,
|
|
||||||
readJsonObject,
|
readJsonObject,
|
||||||
} from "./input";
|
} from "./input";
|
||||||
import {
|
import {
|
||||||
type DiagnosticsEnv,
|
type DiagnosticsEnv,
|
||||||
runRetention,
|
runRetention,
|
||||||
storeBug,
|
storeBug,
|
||||||
storeCrash,
|
|
||||||
storeEvents,
|
|
||||||
} from "./storage";
|
} from "./storage";
|
||||||
|
|
||||||
const DEFAULT_MAX_BODY_BYTES = 262_144;
|
const DEFAULT_MAX_BODY_BYTES = 262_144;
|
||||||
const HARD_MAX_BODY_BYTES = 1_048_576;
|
const HARD_MAX_BODY_BYTES = 1_048_576;
|
||||||
const DEFAULT_MAX_EVENTS = 50;
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
async fetch(request: Request, env: DiagnosticsEnv, _ctx: ExecutionContext): Promise<Response> {
|
async fetch(request: Request, env: DiagnosticsEnv, _ctx: ExecutionContext): Promise<Response> {
|
||||||
@@ -72,41 +67,6 @@ export default {
|
|||||||
if (!parsed.ok) return json({ error: parsed.error }, parsed.status, requestId);
|
if (!parsed.ok) return json({ error: parsed.error }, parsed.status, requestId);
|
||||||
|
|
||||||
switch (url.pathname) {
|
switch (url.pathname) {
|
||||||
case "/v1/events": {
|
|
||||||
const maxEvents = boundedPositiveInt(env.MAX_EVENTS_PER_BATCH, DEFAULT_MAX_EVENTS, 1, 100);
|
|
||||||
const normalized = normalizeEvents(parsed.value, maxEvents);
|
|
||||||
if (!normalized.ok) {
|
|
||||||
return json({ error: normalized.error }, normalized.status, requestId);
|
|
||||||
}
|
|
||||||
const result = await storeEvents(normalized.value, env);
|
|
||||||
return json(
|
|
||||||
{
|
|
||||||
ok: true,
|
|
||||||
id: result.id,
|
|
||||||
stored: result.stored,
|
|
||||||
duplicate: result.duplicate,
|
|
||||||
},
|
|
||||||
202,
|
|
||||||
requestId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
case "/v1/crashes": {
|
|
||||||
const normalized = normalizeCrash(parsed.value);
|
|
||||||
if (!normalized.ok) {
|
|
||||||
return json({ error: normalized.error }, normalized.status, requestId);
|
|
||||||
}
|
|
||||||
const result = await storeCrash(normalized.value, env);
|
|
||||||
return json(
|
|
||||||
{
|
|
||||||
ok: true,
|
|
||||||
id: result.id,
|
|
||||||
fingerprint: result.fingerprint,
|
|
||||||
duplicate: result.duplicate,
|
|
||||||
},
|
|
||||||
202,
|
|
||||||
requestId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
case "/v1/bugs": {
|
case "/v1/bugs": {
|
||||||
const normalized = normalizeBug(parsed.value);
|
const normalized = normalizeBug(parsed.value);
|
||||||
if (!normalized.ok) {
|
if (!normalized.ok) {
|
||||||
@@ -159,12 +119,6 @@ async function readiness(env: DiagnosticsEnv, requestId: string): Promise<Respon
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await env.DB.batch([
|
await env.DB.batch([
|
||||||
env.DB.prepare(
|
|
||||||
"SELECT id, received_at, install_id, payload_json FROM event_batches LIMIT 1",
|
|
||||||
),
|
|
||||||
env.DB.prepare(
|
|
||||||
"SELECT id, occurred_at, stack_r2_key, breadcrumbs_json FROM crashes LIMIT 1",
|
|
||||||
),
|
|
||||||
env.DB.prepare(
|
env.DB.prepare(
|
||||||
"SELECT id, occurred_at, logs_r2_key, device_json FROM bugs LIMIT 1",
|
"SELECT id, occurred_at, logs_r2_key, device_json FROM bugs LIMIT 1",
|
||||||
),
|
),
|
||||||
@@ -216,8 +170,8 @@ async function installRateLimited(
|
|||||||
return !result.success;
|
return !result.success;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isIngestPath(path: string): path is "/v1/events" | "/v1/crashes" | "/v1/bugs" {
|
function isIngestPath(path: string): path is "/v1/bugs" {
|
||||||
return path === "/v1/events" || path === "/v1/crashes" || path === "/v1/bugs";
|
return path === "/v1/bugs";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function timingSafeEqual(provided: string, expected: string): Promise<boolean> {
|
async function timingSafeEqual(provided: string, expected: string): Promise<boolean> {
|
||||||
|
|||||||
@@ -8,21 +8,6 @@ export type InputFailure = {
|
|||||||
|
|
||||||
export type InputResult<T> = { ok: true; value: T } | InputFailure;
|
export type InputResult<T> = { ok: true; value: T } | InputFailure;
|
||||||
|
|
||||||
export type NormalizedProperties = Record<string, string>;
|
|
||||||
|
|
||||||
export interface NormalizedEvent {
|
|
||||||
name: string;
|
|
||||||
timestampMillis: number;
|
|
||||||
properties: NormalizedProperties;
|
|
||||||
schemaVersion: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NormalizedBreadcrumb {
|
|
||||||
name: string;
|
|
||||||
timestampMillis: number;
|
|
||||||
properties: NormalizedProperties;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NormalizedDevice {
|
export interface NormalizedDevice {
|
||||||
deviceName: string;
|
deviceName: string;
|
||||||
deviceModel: string;
|
deviceModel: string;
|
||||||
@@ -31,28 +16,6 @@ export interface NormalizedDevice {
|
|||||||
batteryLevel: string;
|
batteryLevel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NormalizedEventsPayload {
|
|
||||||
batchId: string;
|
|
||||||
installId: string;
|
|
||||||
appVersion: string;
|
|
||||||
platform: string;
|
|
||||||
events: NormalizedEvent[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NormalizedCrashPayload {
|
|
||||||
id: string;
|
|
||||||
installId: string;
|
|
||||||
appVersion: string;
|
|
||||||
platform: string;
|
|
||||||
exceptionType: string;
|
|
||||||
exceptionMessage: string;
|
|
||||||
stackTrace: string;
|
|
||||||
occurredAt: number;
|
|
||||||
diagnosticsEnabledAtCapture: boolean;
|
|
||||||
breadcrumbs: NormalizedBreadcrumb[];
|
|
||||||
schemaVersion: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NormalizedBugPayload {
|
export interface NormalizedBugPayload {
|
||||||
id: string;
|
id: string;
|
||||||
installId: string;
|
installId: string;
|
||||||
@@ -65,16 +28,12 @@ export interface NormalizedBugPayload {
|
|||||||
contact: string;
|
contact: string;
|
||||||
logs: string;
|
logs: string;
|
||||||
device: NormalizedDevice;
|
device: NormalizedDevice;
|
||||||
breadcrumbs: NormalizedBreadcrumb[];
|
|
||||||
schemaVersion: 1;
|
schemaVersion: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MAX_LOG_BYTES = 192 * 1024;
|
export const MAX_LOG_BYTES = 192 * 1024;
|
||||||
export const MAX_BREADCRUMBS_JSON_BYTES = 16_000;
|
|
||||||
export const MAX_DEVICE_JSON_BYTES = 4_000;
|
export const MAX_DEVICE_JSON_BYTES = 4_000;
|
||||||
|
|
||||||
const MAX_PROPERTIES = 12;
|
|
||||||
const MAX_BREADCRUMBS = 40;
|
|
||||||
const MISSING = Symbol("missing");
|
const MISSING = Symbol("missing");
|
||||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
const UTF8_ENCODER = new TextEncoder();
|
const UTF8_ENCODER = new TextEncoder();
|
||||||
@@ -164,119 +123,6 @@ export async function readJsonObject(
|
|||||||
return success(parsed);
|
return success(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeEvents(
|
|
||||||
body: JsonObject,
|
|
||||||
maxEvents = 50,
|
|
||||||
): InputResult<NormalizedEventsPayload> {
|
|
||||||
if (!isPlainObject(body)) return failure(400, "invalid_body");
|
|
||||||
if (!Number.isSafeInteger(maxEvents) || maxEvents <= 0) {
|
|
||||||
throw new RangeError("maxEvents must be a positive safe integer");
|
|
||||||
}
|
|
||||||
|
|
||||||
const batchId = idField(body, ["batchId", "batch_id"], "invalid_batch_id");
|
|
||||||
if (!batchId.ok) return batchId;
|
|
||||||
const installId = installIdField(body);
|
|
||||||
if (!installId.ok) return installId;
|
|
||||||
const appVersion = stringField(body, ["appVersion", "app_version"], 40, "invalid_app_version");
|
|
||||||
if (!appVersion.ok) return appVersion;
|
|
||||||
const platform = stringField(body, ["platform"], 40, "invalid_platform");
|
|
||||||
if (!platform.ok) return platform;
|
|
||||||
|
|
||||||
const batchSchema = schemaVersion(body);
|
|
||||||
if (!batchSchema.ok) return batchSchema;
|
|
||||||
const rawEvents = pick(body, ["events"]);
|
|
||||||
if (!Array.isArray(rawEvents)) return failure(400, "invalid_events");
|
|
||||||
if (rawEvents.length === 0) return failure(400, "empty_batch");
|
|
||||||
if (rawEvents.length > maxEvents) return failure(400, "batch_too_large");
|
|
||||||
|
|
||||||
const events: NormalizedEvent[] = [];
|
|
||||||
for (const rawEvent of rawEvents) {
|
|
||||||
const event = normalizeEvent(rawEvent);
|
|
||||||
if (!event.ok) return event;
|
|
||||||
events.push(event.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return success({
|
|
||||||
batchId: batchId.value,
|
|
||||||
installId: installId.value,
|
|
||||||
appVersion: appVersion.value,
|
|
||||||
platform: platform.value,
|
|
||||||
events,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeCrash(body: JsonObject): InputResult<NormalizedCrashPayload> {
|
|
||||||
if (!isPlainObject(body)) return failure(400, "invalid_body");
|
|
||||||
|
|
||||||
const id = idField(body, ["id"], "invalid_id");
|
|
||||||
if (!id.ok) return id;
|
|
||||||
const installId = installIdField(body);
|
|
||||||
if (!installId.ok) return installId;
|
|
||||||
const appVersion = stringField(body, ["appVersion", "app_version"], 40, "invalid_app_version");
|
|
||||||
if (!appVersion.ok) return appVersion;
|
|
||||||
const platform = stringField(body, ["platform"], 40, "invalid_platform");
|
|
||||||
if (!platform.ok) return platform;
|
|
||||||
const exceptionType = stringField(
|
|
||||||
body,
|
|
||||||
["exceptionType", "exception_type"],
|
|
||||||
120,
|
|
||||||
"invalid_exception_type",
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
if (!exceptionType.ok) return exceptionType;
|
|
||||||
const exceptionMessage = stringField(
|
|
||||||
body,
|
|
||||||
["exceptionMessage", "exception_message"],
|
|
||||||
2_000,
|
|
||||||
"invalid_exception_message",
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
if (!exceptionMessage.ok) return exceptionMessage;
|
|
||||||
const stackTrace = stringField(
|
|
||||||
body,
|
|
||||||
["stackTrace", "stack_trace"],
|
|
||||||
32_000,
|
|
||||||
"invalid_stack_trace",
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
if (!stackTrace.ok) return stackTrace;
|
|
||||||
const occurredAt = timestampField(
|
|
||||||
body,
|
|
||||||
["timestampMillis", "timestamp_millis", "occurredAt", "occurred_at"],
|
|
||||||
);
|
|
||||||
if (!occurredAt.ok) return occurredAt;
|
|
||||||
const diagnosticsEnabled = booleanField(
|
|
||||||
body,
|
|
||||||
[
|
|
||||||
"diagnosticsEnabledAtCapture",
|
|
||||||
"diagnostics_enabled_at_capture",
|
|
||||||
"diagnostics_enabled",
|
|
||||||
],
|
|
||||||
"invalid_diagnostics_enabled",
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
if (!diagnosticsEnabled.ok) return diagnosticsEnabled;
|
|
||||||
const version = schemaVersion(body);
|
|
||||||
if (!version.ok) return version;
|
|
||||||
const breadcrumbs = normalizeBreadcrumbs(pick(body, ["breadcrumbs"]));
|
|
||||||
if (!breadcrumbs.ok) return breadcrumbs;
|
|
||||||
|
|
||||||
return success({
|
|
||||||
id: id.value,
|
|
||||||
installId: installId.value,
|
|
||||||
appVersion: appVersion.value,
|
|
||||||
platform: platform.value,
|
|
||||||
exceptionType: exceptionType.value,
|
|
||||||
exceptionMessage: exceptionMessage.value,
|
|
||||||
stackTrace: stackTrace.value,
|
|
||||||
occurredAt: occurredAt.value,
|
|
||||||
diagnosticsEnabledAtCapture: diagnosticsEnabled.value,
|
|
||||||
breadcrumbs: breadcrumbs.value,
|
|
||||||
schemaVersion: version.value,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeBug(body: JsonObject): InputResult<NormalizedBugPayload> {
|
export function normalizeBug(body: JsonObject): InputResult<NormalizedBugPayload> {
|
||||||
if (!isPlainObject(body)) return failure(400, "invalid_body");
|
if (!isPlainObject(body)) return failure(400, "invalid_body");
|
||||||
|
|
||||||
@@ -319,8 +165,6 @@ export function normalizeBug(body: JsonObject): InputResult<NormalizedBugPayload
|
|||||||
if (!logs.ok) return logs;
|
if (!logs.ok) return logs;
|
||||||
const device = normalizeDevice(pick(body, ["device"]));
|
const device = normalizeDevice(pick(body, ["device"]));
|
||||||
if (!device.ok) return device;
|
if (!device.ok) return device;
|
||||||
const breadcrumbs = normalizeBreadcrumbs(pick(body, ["breadcrumbs"]));
|
|
||||||
if (!breadcrumbs.ok) return breadcrumbs;
|
|
||||||
const version = schemaVersion(body);
|
const version = schemaVersion(body);
|
||||||
if (!version.ok) return version;
|
if (!version.ok) return version;
|
||||||
|
|
||||||
@@ -336,80 +180,10 @@ export function normalizeBug(body: JsonObject): InputResult<NormalizedBugPayload
|
|||||||
contact: contact.value,
|
contact: contact.value,
|
||||||
logs: includeLogs.value === true ? logs.value : "",
|
logs: includeLogs.value === true ? logs.value : "",
|
||||||
device: device.value,
|
device: device.value,
|
||||||
breadcrumbs: breadcrumbs.value,
|
|
||||||
schemaVersion: version.value,
|
schemaVersion: version.value,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeEvent(raw: unknown): InputResult<NormalizedEvent> {
|
|
||||||
if (!isPlainObject(raw)) return failure(400, "invalid_event");
|
|
||||||
const name = stringField(raw, ["name"], 64, "invalid_event", true, true);
|
|
||||||
if (!name.ok) return name;
|
|
||||||
const timestamp = timestampField(raw, ["timestampMillis", "timestamp_millis", "ts"]);
|
|
||||||
if (!timestamp.ok) return failure(400, "invalid_event");
|
|
||||||
const properties = normalizeProperties(pick(raw, ["properties", "props"]), "invalid_event");
|
|
||||||
if (!properties.ok) return properties;
|
|
||||||
const version = schemaVersion(raw);
|
|
||||||
if (!version.ok) return version;
|
|
||||||
return success({
|
|
||||||
name: name.value,
|
|
||||||
timestampMillis: timestamp.value,
|
|
||||||
properties: properties.value,
|
|
||||||
schemaVersion: version.value,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeBreadcrumbs(raw: unknown | typeof MISSING): InputResult<NormalizedBreadcrumb[]> {
|
|
||||||
if (raw === MISSING) return success([]);
|
|
||||||
if (!Array.isArray(raw)) return failure(400, "invalid_breadcrumbs");
|
|
||||||
|
|
||||||
const breadcrumbs: NormalizedBreadcrumb[] = [];
|
|
||||||
for (const item of raw.slice(0, MAX_BREADCRUMBS)) {
|
|
||||||
if (!isPlainObject(item)) return failure(400, "invalid_breadcrumbs");
|
|
||||||
const name = stringField(item, ["name"], 64, "invalid_breadcrumbs", true, true);
|
|
||||||
if (!name.ok) return name;
|
|
||||||
const timestamp = timestampField(item, ["timestampMillis", "timestamp_millis", "ts"]);
|
|
||||||
if (!timestamp.ok) return failure(400, "invalid_breadcrumbs");
|
|
||||||
const properties = normalizeProperties(
|
|
||||||
pick(item, ["properties", "props"]),
|
|
||||||
"invalid_breadcrumbs",
|
|
||||||
);
|
|
||||||
if (!properties.ok) return properties;
|
|
||||||
|
|
||||||
breadcrumbs.push({
|
|
||||||
name: name.value,
|
|
||||||
timestampMillis: timestamp.value,
|
|
||||||
properties: properties.value,
|
|
||||||
});
|
|
||||||
if (jsonBytes(breadcrumbs) > MAX_BREADCRUMBS_JSON_BYTES) {
|
|
||||||
breadcrumbs.pop();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return success(breadcrumbs);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeProperties(
|
|
||||||
raw: unknown | typeof MISSING,
|
|
||||||
error: string,
|
|
||||||
): InputResult<NormalizedProperties> {
|
|
||||||
if (raw === MISSING) return success({});
|
|
||||||
if (!isPlainObject(raw)) return failure(400, error);
|
|
||||||
|
|
||||||
const entries: Array<[string, string]> = [];
|
|
||||||
const normalizedKeys = new Set<string>();
|
|
||||||
for (const [key, value] of Object.entries(raw).slice(0, MAX_PROPERTIES)) {
|
|
||||||
if (typeof value !== "string") return failure(400, error);
|
|
||||||
const normalizedKey = truncateUtf8(key, 40);
|
|
||||||
if (normalizedKey.length === 0 || normalizedKeys.has(normalizedKey)) {
|
|
||||||
return failure(400, error);
|
|
||||||
}
|
|
||||||
normalizedKeys.add(normalizedKey);
|
|
||||||
entries.push([normalizedKey, truncateUtf8(value, 128)]);
|
|
||||||
}
|
|
||||||
return success(Object.fromEntries(entries));
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDevice(raw: unknown | typeof MISSING): InputResult<NormalizedDevice> {
|
function normalizeDevice(raw: unknown | typeof MISSING): InputResult<NormalizedDevice> {
|
||||||
if (raw === MISSING) raw = {};
|
if (raw === MISSING) raw = {};
|
||||||
if (!isPlainObject(raw)) return failure(400, "invalid_device");
|
if (!isPlainObject(raw)) return failure(400, "invalid_device");
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import type {
|
import type { NormalizedBugPayload } from "./input";
|
||||||
NormalizedBugPayload,
|
|
||||||
NormalizedCrashPayload,
|
|
||||||
NormalizedEventsPayload,
|
|
||||||
} from "./input";
|
|
||||||
|
|
||||||
export type DiagnosticsEnv = Cloudflare.Env & {
|
export type DiagnosticsEnv = Cloudflare.Env & {
|
||||||
INGEST_KEY?: string;
|
INGEST_KEY?: string;
|
||||||
AE?: AnalyticsEngineDataset;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface StoreResult {
|
export interface StoreResult {
|
||||||
@@ -15,130 +10,6 @@ export interface StoreResult {
|
|||||||
stored: number;
|
stored: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function storeEvents(
|
|
||||||
payload: NormalizedEventsPayload,
|
|
||||||
env: DiagnosticsEnv,
|
|
||||||
): Promise<StoreResult> {
|
|
||||||
const result = await env.DB.prepare(
|
|
||||||
`INSERT INTO event_batches (id, received_at, install_id, app_version, platform, event_count, payload_json)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(id) DO NOTHING`,
|
|
||||||
)
|
|
||||||
.bind(
|
|
||||||
payload.batchId,
|
|
||||||
Date.now(),
|
|
||||||
payload.installId,
|
|
||||||
payload.appVersion,
|
|
||||||
payload.platform,
|
|
||||||
payload.events.length,
|
|
||||||
JSON.stringify(payload.events),
|
|
||||||
)
|
|
||||||
.run();
|
|
||||||
const duplicate = result.meta.changes === 0;
|
|
||||||
if (!duplicate && env.AE) {
|
|
||||||
try {
|
|
||||||
for (const event of payload.events) {
|
|
||||||
env.AE.writeDataPoint({
|
|
||||||
blobs: [
|
|
||||||
event.name,
|
|
||||||
payload.platform,
|
|
||||||
payload.appVersion,
|
|
||||||
payload.installId,
|
|
||||||
JSON.stringify(event.properties),
|
|
||||||
payload.batchId,
|
|
||||||
],
|
|
||||||
doubles: [event.timestampMillis, event.schemaVersion],
|
|
||||||
indexes: [payload.installId],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// D1 remains the durable source of truth if the optional analytics index is unavailable.
|
|
||||||
console.error(
|
|
||||||
JSON.stringify({
|
|
||||||
message: "failed to index diagnostics event batch",
|
|
||||||
batchId: payload.batchId,
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: payload.batchId,
|
|
||||||
duplicate,
|
|
||||||
stored: duplicate ? 0 : payload.events.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function storeCrash(
|
|
||||||
payload: NormalizedCrashPayload,
|
|
||||||
env: DiagnosticsEnv,
|
|
||||||
): Promise<StoreResult & { fingerprint: string }> {
|
|
||||||
const database = env.DB.withSession("first-primary");
|
|
||||||
const existing = await database
|
|
||||||
.prepare("SELECT fingerprint FROM crashes WHERE id = ?")
|
|
||||||
.bind(payload.id)
|
|
||||||
.first<{ fingerprint: string }>();
|
|
||||||
if (existing) {
|
|
||||||
return { id: payload.id, duplicate: true, stored: 0, fingerprint: existing.fingerprint };
|
|
||||||
}
|
|
||||||
|
|
||||||
const fingerprint = await crashFingerprint(payload.exceptionType, payload.stackTrace);
|
|
||||||
const stackKey = payload.stackTrace
|
|
||||||
? `crashes/${payload.id}/${crypto.randomUUID()}/stack.txt`
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (stackKey) {
|
|
||||||
await env.BLOBS.put(stackKey, payload.stackTrace, {
|
|
||||||
httpMetadata: { contentType: "text/plain; charset=utf-8" },
|
|
||||||
customMetadata: { installId: payload.installId, fingerprint },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await database
|
|
||||||
.prepare(
|
|
||||||
`INSERT INTO crashes (
|
|
||||||
id, received_at, occurred_at, install_id, app_version, platform,
|
|
||||||
exception_type, exception_message, fingerprint, diagnostics_enabled,
|
|
||||||
stack_r2_key, breadcrumbs_json, schema_version
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(id) DO NOTHING`,
|
|
||||||
)
|
|
||||||
.bind(
|
|
||||||
payload.id,
|
|
||||||
Date.now(),
|
|
||||||
payload.occurredAt,
|
|
||||||
payload.installId,
|
|
||||||
payload.appVersion,
|
|
||||||
payload.platform,
|
|
||||||
payload.exceptionType,
|
|
||||||
payload.exceptionMessage,
|
|
||||||
fingerprint,
|
|
||||||
payload.diagnosticsEnabledAtCapture ? 1 : 0,
|
|
||||||
stackKey,
|
|
||||||
JSON.stringify(payload.breadcrumbs),
|
|
||||||
payload.schemaVersion,
|
|
||||||
)
|
|
||||||
.run();
|
|
||||||
const duplicate = result.meta.changes === 0;
|
|
||||||
if (duplicate) {
|
|
||||||
const stored = await database
|
|
||||||
.prepare("SELECT fingerprint FROM crashes WHERE id = ?")
|
|
||||||
.bind(payload.id)
|
|
||||||
.first<{ fingerprint: string }>();
|
|
||||||
if (!stored) throw new Error("duplicate crash row was not readable");
|
|
||||||
if (stackKey) await deleteAttemptBlob(env, stackKey);
|
|
||||||
return { id: payload.id, duplicate: true, stored: 0, fingerprint: stored.fingerprint };
|
|
||||||
}
|
|
||||||
return { id: payload.id, duplicate: false, stored: 1, fingerprint };
|
|
||||||
} catch (error) {
|
|
||||||
if (stackKey) {
|
|
||||||
await deleteAttemptBlob(env, stackKey);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function storeBug(
|
export async function storeBug(
|
||||||
payload: NormalizedBugPayload,
|
payload: NormalizedBugPayload,
|
||||||
env: DiagnosticsEnv,
|
env: DiagnosticsEnv,
|
||||||
@@ -164,8 +35,8 @@ export async function storeBug(
|
|||||||
`INSERT INTO bugs (
|
`INSERT INTO bugs (
|
||||||
id, received_at, occurred_at, install_id, app_version, platform,
|
id, received_at, occurred_at, install_id, app_version, platform,
|
||||||
what_happened, expected, steps, contact, logs_r2_key,
|
what_happened, expected, steps, contact, logs_r2_key,
|
||||||
device_json, breadcrumbs_json, status, schema_version
|
device_json, status, schema_version
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?)
|
||||||
ON CONFLICT(id) DO NOTHING`,
|
ON CONFLICT(id) DO NOTHING`,
|
||||||
)
|
)
|
||||||
.bind(
|
.bind(
|
||||||
@@ -181,7 +52,6 @@ export async function storeBug(
|
|||||||
payload.contact,
|
payload.contact,
|
||||||
logsKey,
|
logsKey,
|
||||||
JSON.stringify(payload.device),
|
JSON.stringify(payload.device),
|
||||||
JSON.stringify(payload.breadcrumbs),
|
|
||||||
payload.schemaVersion,
|
payload.schemaVersion,
|
||||||
)
|
)
|
||||||
.run();
|
.run();
|
||||||
@@ -201,26 +71,22 @@ export async function storeBug(
|
|||||||
export async function runRetention(env: DiagnosticsEnv): Promise<void> {
|
export async function runRetention(env: DiagnosticsEnv): Promise<void> {
|
||||||
const retentionDays = boundedPositiveInt(env.RETENTION_DAYS, 90, 1, 3_650);
|
const retentionDays = boundedPositiveInt(env.RETENTION_DAYS, 90, 1, 3_650);
|
||||||
const cutoff = Date.now() - retentionDays * 86_400_000;
|
const cutoff = Date.now() - retentionDays * 86_400_000;
|
||||||
// Eight full passes plus the backlog check use at most 43 of D1's 50 queries per invocation.
|
// Eight passes plus the backlog check stay well within D1's 50 queries per invocation.
|
||||||
for (let pass = 0; pass < 8; pass += 1) {
|
for (let pass = 0; pass < 8; pass += 1) {
|
||||||
const hasFullBatch = await runRetentionPass(env, cutoff);
|
const hasFullBatch = await runRetentionPass(env, cutoff);
|
||||||
if (!hasFullBatch) return;
|
if (!hasFullBatch) return;
|
||||||
}
|
}
|
||||||
const [events, crashes, bugs] = await env.DB.batch<{ count: number }>([
|
const bugs = await env.DB.prepare(
|
||||||
env.DB.prepare("SELECT COUNT(*) AS count FROM event_batches WHERE received_at < ?").bind(
|
"SELECT COUNT(*) AS count FROM bugs WHERE received_at < ?",
|
||||||
cutoff,
|
)
|
||||||
),
|
.bind(cutoff)
|
||||||
env.DB.prepare("SELECT COUNT(*) AS count FROM crashes WHERE received_at < ?").bind(cutoff),
|
.first<{ count: number }>();
|
||||||
env.DB.prepare("SELECT COUNT(*) AS count FROM bugs WHERE received_at < ?").bind(cutoff),
|
|
||||||
]);
|
|
||||||
console.warn(
|
console.warn(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
message: "diagnostics retention reached its per-run pass limit",
|
message: "diagnostics retention reached its per-run pass limit",
|
||||||
cutoff,
|
cutoff,
|
||||||
backlog: {
|
backlog: {
|
||||||
eventBatches: events.results[0]?.count ?? 0,
|
bugs: bugs?.count ?? 0,
|
||||||
crashes: crashes.results[0]?.count ?? 0,
|
|
||||||
bugs: bugs.results[0]?.count ?? 0,
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -228,28 +94,17 @@ export async function runRetention(env: DiagnosticsEnv): Promise<void> {
|
|||||||
|
|
||||||
async function runRetentionPass(env: DiagnosticsEnv, cutoff: number): Promise<boolean> {
|
async function runRetentionPass(env: DiagnosticsEnv, cutoff: number): Promise<boolean> {
|
||||||
const reportBatchSize = 900;
|
const reportBatchSize = 900;
|
||||||
const eventBatchSize = 1_000;
|
const bugs = await expiredBlobRows(env.DB, "bugs", "logs_r2_key", cutoff, reportBatchSize);
|
||||||
const [crashes, bugs] = await Promise.all([
|
|
||||||
expiredBlobRows(env.DB, "crashes", "stack_r2_key", cutoff, reportBatchSize),
|
|
||||||
expiredBlobRows(env.DB, "bugs", "logs_r2_key", cutoff, reportBatchSize),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const blobKeys = [...crashes, ...bugs]
|
const blobKeys = bugs
|
||||||
.map((row) => row.blobKey)
|
.map((row) => row.blobKey)
|
||||||
.filter((key): key is string => key !== null);
|
.filter((key): key is string => key !== null);
|
||||||
for (let offset = 0; offset < blobKeys.length; offset += 1_000) {
|
for (let offset = 0; offset < blobKeys.length; offset += 1_000) {
|
||||||
await env.BLOBS.delete(blobKeys.slice(offset, offset + 1_000));
|
await env.BLOBS.delete(blobKeys.slice(offset, offset + 1_000));
|
||||||
}
|
}
|
||||||
|
|
||||||
const statements = [retentionStatement(env.DB, "event_batches", cutoff, eventBatchSize)];
|
if (bugs.length > 0) await deleteRowsById(env.DB, "bugs", bugs).run();
|
||||||
if (crashes.length > 0) statements.push(deleteRowsById(env.DB, "crashes", crashes));
|
return bugs.length === reportBatchSize;
|
||||||
if (bugs.length > 0) statements.push(deleteRowsById(env.DB, "bugs", bugs));
|
|
||||||
const [eventsResult] = await env.DB.batch(statements);
|
|
||||||
return (
|
|
||||||
eventsResult.meta.changes === eventBatchSize ||
|
|
||||||
crashes.length === reportBatchSize ||
|
|
||||||
bugs.length === reportBatchSize
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExpiredBlobRow {
|
interface ExpiredBlobRow {
|
||||||
@@ -259,8 +114,8 @@ interface ExpiredBlobRow {
|
|||||||
|
|
||||||
async function expiredBlobRows(
|
async function expiredBlobRows(
|
||||||
database: D1Database,
|
database: D1Database,
|
||||||
table: "crashes" | "bugs",
|
table: "bugs",
|
||||||
column: "stack_r2_key" | "logs_r2_key",
|
column: "logs_r2_key",
|
||||||
cutoff: number,
|
cutoff: number,
|
||||||
batchSize: number,
|
batchSize: number,
|
||||||
): Promise<ExpiredBlobRow[]> {
|
): Promise<ExpiredBlobRow[]> {
|
||||||
@@ -277,25 +132,9 @@ async function expiredBlobRows(
|
|||||||
return result.results;
|
return result.results;
|
||||||
}
|
}
|
||||||
|
|
||||||
function retentionStatement(
|
|
||||||
database: D1Database,
|
|
||||||
table: "event_batches" | "crashes" | "bugs",
|
|
||||||
cutoff: number,
|
|
||||||
batchSize: number,
|
|
||||||
): D1PreparedStatement {
|
|
||||||
return database
|
|
||||||
.prepare(
|
|
||||||
`DELETE FROM ${table}
|
|
||||||
WHERE rowid IN (
|
|
||||||
SELECT rowid FROM ${table} WHERE received_at < ? ORDER BY received_at LIMIT ?
|
|
||||||
)`,
|
|
||||||
)
|
|
||||||
.bind(cutoff, batchSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteRowsById(
|
function deleteRowsById(
|
||||||
database: D1Database,
|
database: D1Database,
|
||||||
table: "crashes" | "bugs",
|
table: "bugs",
|
||||||
rows: ExpiredBlobRow[],
|
rows: ExpiredBlobRow[],
|
||||||
): D1PreparedStatement {
|
): D1PreparedStatement {
|
||||||
return database
|
return database
|
||||||
@@ -303,18 +142,6 @@ function deleteRowsById(
|
|||||||
.bind(JSON.stringify(rows.map((row) => row.id)));
|
.bind(JSON.stringify(rows.map((row) => row.id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function crashFingerprint(exceptionType: string, stackTrace: string): Promise<string> {
|
|
||||||
const topFrames = stackTrace
|
|
||||||
.split("\n")
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.slice(0, 4)
|
|
||||||
.join("\n");
|
|
||||||
const bytes = new TextEncoder().encode(`${exceptionType}\n${topFrames}`);
|
|
||||||
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
|
|
||||||
return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteAttemptBlob(env: DiagnosticsEnv, key: string): Promise<void> {
|
async function deleteAttemptBlob(env: DiagnosticsEnv, key: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await env.BLOBS.delete(key);
|
await env.BLOBS.delete(key);
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
MAX_BREADCRUMBS_JSON_BYTES,
|
|
||||||
MAX_DEVICE_JSON_BYTES,
|
MAX_DEVICE_JSON_BYTES,
|
||||||
MAX_LOG_BYTES,
|
MAX_LOG_BYTES,
|
||||||
normalizeBug,
|
normalizeBug,
|
||||||
normalizeCrash,
|
|
||||||
normalizeEvents,
|
|
||||||
readJsonObject,
|
readJsonObject,
|
||||||
} from "../src/input";
|
} from "../src/input";
|
||||||
|
|
||||||
@@ -57,7 +54,7 @@ describe("readJsonObject", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("requires application/json with a UTF-8 charset", async () => {
|
it("requires application/json with a UTF-8 charset", async () => {
|
||||||
const missing = new Request("https://example.test/v1/events", {
|
const missing = new Request("https://example.test/v1/bugs", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: "{}",
|
body: "{}",
|
||||||
});
|
});
|
||||||
@@ -108,18 +105,6 @@ describe("readJsonObject", () => {
|
|||||||
|
|
||||||
describe("normalizers", () => {
|
describe("normalizers", () => {
|
||||||
it("preserves false booleans and rejects their string representation", () => {
|
it("preserves false booleans and rejects their string representation", () => {
|
||||||
const crash = crashPayload(false);
|
|
||||||
const normalizedCrash = normalizeCrash(crash);
|
|
||||||
expect(normalizedCrash.ok).toBe(true);
|
|
||||||
if (normalizedCrash.ok) {
|
|
||||||
expect(normalizedCrash.value.diagnosticsEnabledAtCapture).toBe(false);
|
|
||||||
}
|
|
||||||
expect(normalizeCrash(crashPayload("false"))).toEqual({
|
|
||||||
ok: false,
|
|
||||||
status: 400,
|
|
||||||
error: "invalid_diagnostics_enabled",
|
|
||||||
});
|
|
||||||
|
|
||||||
const bug = bugPayload({ include_logs: false, logs: "discard me" });
|
const bug = bugPayload({ include_logs: false, logs: "discard me" });
|
||||||
const normalizedBug = normalizeBug(bug);
|
const normalizedBug = normalizeBug(bug);
|
||||||
expect(normalizedBug.ok).toBe(true);
|
expect(normalizedBug.ok).toBe(true);
|
||||||
@@ -133,20 +118,11 @@ describe("normalizers", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps logs, breadcrumbs, and device JSON within valid byte budgets", () => {
|
it("keeps logs and device JSON within valid byte budgets", () => {
|
||||||
const properties = Object.fromEntries(
|
|
||||||
Array.from({ length: 12 }, (_, index) => [`key-${index}-${"\u0000".repeat(40)}`, "\u0000".repeat(128)]),
|
|
||||||
);
|
|
||||||
const breadcrumbs = Array.from({ length: 40 }, (_, index) => ({
|
|
||||||
name: `crumb-${index}`,
|
|
||||||
timestamp_millis: index,
|
|
||||||
properties,
|
|
||||||
}));
|
|
||||||
const result = normalizeBug(
|
const result = normalizeBug(
|
||||||
bugPayload({
|
bugPayload({
|
||||||
include_logs: true,
|
include_logs: true,
|
||||||
logs: "😀".repeat(60_000),
|
logs: "😀".repeat(60_000),
|
||||||
breadcrumbs,
|
|
||||||
device: {
|
device: {
|
||||||
device_name: "\u0000".repeat(200),
|
device_name: "\u0000".repeat(200),
|
||||||
device_model: "\u0000".repeat(200),
|
device_model: "\u0000".repeat(200),
|
||||||
@@ -159,54 +135,38 @@ describe("normalizers", () => {
|
|||||||
|
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
if (!result.ok) return;
|
if (!result.ok) return;
|
||||||
const breadcrumbsJson = JSON.stringify(result.value.breadcrumbs);
|
|
||||||
const deviceJson = JSON.stringify(result.value.device);
|
const deviceJson = JSON.stringify(result.value.device);
|
||||||
expect(ENCODER.encode(result.value.logs).byteLength).toBe(MAX_LOG_BYTES);
|
expect(ENCODER.encode(result.value.logs).byteLength).toBe(MAX_LOG_BYTES);
|
||||||
expect(ENCODER.encode(breadcrumbsJson).byteLength).toBeLessThanOrEqual(
|
|
||||||
MAX_BREADCRUMBS_JSON_BYTES,
|
|
||||||
);
|
|
||||||
expect(ENCODER.encode(deviceJson).byteLength).toBeLessThanOrEqual(MAX_DEVICE_JSON_BYTES);
|
expect(ENCODER.encode(deviceJson).byteLength).toBeLessThanOrEqual(MAX_DEVICE_JSON_BYTES);
|
||||||
expect(JSON.parse(breadcrumbsJson)).toEqual(result.value.breadcrumbs);
|
|
||||||
expect(JSON.parse(deviceJson)).toEqual(result.value.device);
|
expect(JSON.parse(deviceJson)).toEqual(result.value.device);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("requires stable report IDs and validates supplied IDs and schema versions", () => {
|
it("requires stable report IDs and validates supplied IDs and schema versions", () => {
|
||||||
const result = normalizeEvents({
|
const missingId = normalizeBug(bugPayload({ id: undefined }));
|
||||||
events: [{ name: "opened", ts: 1, schema_version: 1 }],
|
expect(missingId).toEqual({ ok: false, status: 400, error: "invalid_id" });
|
||||||
});
|
|
||||||
expect(result).toEqual({ ok: false, status: 400, error: "invalid_batch_id" });
|
|
||||||
const legacyInstall = normalizeEvents({
|
|
||||||
batch_id: ID,
|
|
||||||
install_id: "legacy-test-install",
|
|
||||||
events: [{ name: "opened", ts: 1 }],
|
|
||||||
});
|
|
||||||
expect(legacyInstall.ok && legacyInstall.value.installId).toBe("legacy-test-install");
|
|
||||||
const missingInstall = normalizeEvents({
|
|
||||||
batch_id: ID,
|
|
||||||
events: [{ name: "opened", ts: 1 }],
|
|
||||||
});
|
|
||||||
expect(missingInstall.ok && missingInstall.value.installId).toBe("unknown");
|
|
||||||
expect(
|
|
||||||
normalizeEvents({
|
|
||||||
batch_id: ID,
|
|
||||||
install_id: "bad\u0000install",
|
|
||||||
events: [{ name: "opened", ts: 1 }],
|
|
||||||
}),
|
|
||||||
).toEqual({ ok: false, status: 400, error: "invalid_install_id" });
|
|
||||||
|
|
||||||
expect(
|
const legacyInstall = normalizeBug(bugPayload({ install_id: "legacy-test-install" }));
|
||||||
normalizeEvents({
|
expect(legacyInstall.ok && legacyInstall.value.installId).toBe("legacy-test-install");
|
||||||
batch_id: "not-a-uuid",
|
|
||||||
events: [{ name: "opened", timestamp_millis: 1 }],
|
const missingInstall = normalizeBug(bugPayload({ install_id: undefined }));
|
||||||
}),
|
expect(missingInstall.ok && missingInstall.value.installId).toBe("unknown");
|
||||||
).toEqual({ ok: false, status: 400, error: "invalid_batch_id" });
|
|
||||||
expect(
|
expect(normalizeBug(bugPayload({ install_id: "bad\u0000install" }))).toEqual({
|
||||||
normalizeEvents({
|
ok: false,
|
||||||
batch_id: ID,
|
status: 400,
|
||||||
install_id: INSTALL_ID,
|
error: "invalid_install_id",
|
||||||
events: [{ name: "opened", timestamp_millis: 1, schema_version: 2 }],
|
});
|
||||||
}),
|
|
||||||
).toEqual({ ok: false, status: 400, error: "unsupported_schema_version" });
|
expect(normalizeBug(bugPayload({ id: "not-a-uuid" }))).toEqual({
|
||||||
|
ok: false,
|
||||||
|
status: 400,
|
||||||
|
error: "invalid_id",
|
||||||
|
});
|
||||||
|
expect(normalizeBug(bugPayload({ schema_version: 2 }))).toEqual({
|
||||||
|
ok: false,
|
||||||
|
status: 400,
|
||||||
|
error: "unsupported_schema_version",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -215,7 +175,7 @@ function chunkedJsonRequest(
|
|||||||
contentType = "application/json; charset=utf-8",
|
contentType = "application/json; charset=utf-8",
|
||||||
contentLength?: string,
|
contentLength?: string,
|
||||||
): Request {
|
): Request {
|
||||||
return new Request("https://example.test/v1/events", {
|
return new Request("https://example.test/v1/bugs", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"content-type": contentType,
|
"content-type": contentType,
|
||||||
@@ -230,24 +190,8 @@ function chunkedJsonRequest(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function crashPayload(diagnosticsEnabled: unknown): Record<string, unknown> {
|
|
||||||
return {
|
|
||||||
id: ID,
|
|
||||||
install_id: INSTALL_ID,
|
|
||||||
app_version: "1.0",
|
|
||||||
platform: "test",
|
|
||||||
exception_type: "ExampleError",
|
|
||||||
exception_message: "message",
|
|
||||||
stack_trace: "stack",
|
|
||||||
occurred_at: 1,
|
|
||||||
diagnostics_enabled: diagnosticsEnabled,
|
|
||||||
schema_version: 1,
|
|
||||||
breadcrumbs: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function bugPayload(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
function bugPayload(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||||
return {
|
const payload: Record<string, unknown> = {
|
||||||
id: ID,
|
id: ID,
|
||||||
install_id: INSTALL_ID,
|
install_id: INSTALL_ID,
|
||||||
app_version: "1.0",
|
app_version: "1.0",
|
||||||
@@ -259,8 +203,12 @@ function bugPayload(overrides: Record<string, unknown> = {}): Record<string, unk
|
|||||||
contact: "",
|
contact: "",
|
||||||
logs: "",
|
logs: "",
|
||||||
device: {},
|
device: {},
|
||||||
breadcrumbs: [],
|
|
||||||
schema_version: 1,
|
schema_version: 1,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
|
// An explicit `undefined` override omits the key entirely (simulating a missing field).
|
||||||
|
for (const key of Object.keys(overrides)) {
|
||||||
|
if (overrides[key] === undefined) delete payload[key];
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,8 @@ import { env, exports } from "cloudflare:workers";
|
|||||||
import { createExecutionContext } from "cloudflare:test";
|
import { createExecutionContext } from "cloudflare:test";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import worker from "../src/index";
|
import worker from "../src/index";
|
||||||
import type {
|
import type { NormalizedBugPayload } from "../src/input";
|
||||||
NormalizedBugPayload,
|
import { type DiagnosticsEnv, runRetention, storeBug } from "../src/storage";
|
||||||
NormalizedCrashPayload,
|
|
||||||
NormalizedEventsPayload,
|
|
||||||
} from "../src/input";
|
|
||||||
import {
|
|
||||||
type DiagnosticsEnv,
|
|
||||||
runRetention,
|
|
||||||
storeBug,
|
|
||||||
storeCrash,
|
|
||||||
storeEvents,
|
|
||||||
} from "../src/storage";
|
|
||||||
|
|
||||||
const INSTALL_ID = "10000000-0000-4000-8000-000000000000";
|
const INSTALL_ID = "10000000-0000-4000-8000-000000000000";
|
||||||
|
|
||||||
@@ -35,7 +25,7 @@ describe("diagnostics Worker", () => {
|
|||||||
expect(unknown.status).toBe(404);
|
expect(unknown.status).toBe(404);
|
||||||
|
|
||||||
const unauthorized = await exports.default.fetch(
|
const unauthorized = await exports.default.fetch(
|
||||||
jsonRequest("/v1/events", eventPayload(uuid(1)), "wrong-key"),
|
jsonRequest("/v1/bugs", bugPayload(uuid(1), "logs"), "wrong-key"),
|
||||||
);
|
);
|
||||||
expect(unauthorized.status).toBe(401);
|
expect(unauthorized.status).toBe(401);
|
||||||
expect(await unauthorized.json()).toEqual({ error: "unauthorized" });
|
expect(await unauthorized.json()).toEqual({ error: "unauthorized" });
|
||||||
@@ -44,7 +34,7 @@ describe("diagnostics Worker", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const preflight = await exports.default.fetch(
|
const preflight = await exports.default.fetch(
|
||||||
new Request("https://diagnostics.test/v1/events", { method: "OPTIONS" }),
|
new Request("https://diagnostics.test/v1/bugs", { method: "OPTIONS" }),
|
||||||
);
|
);
|
||||||
expect(preflight.status).toBe(204);
|
expect(preflight.status).toBe(204);
|
||||||
expect(preflight.headers.get("access-control-allow-origin")).toBeNull();
|
expect(preflight.headers.get("access-control-allow-origin")).toBeNull();
|
||||||
@@ -68,7 +58,7 @@ describe("diagnostics Worker", () => {
|
|||||||
const context = createExecutionContext();
|
const context = createExecutionContext();
|
||||||
|
|
||||||
const response = await worker.fetch(
|
const response = await worker.fetch(
|
||||||
jsonRequest("/v1/events", eventPayload(uuid(3)), "wrong-key", "198.51.100.3"),
|
jsonRequest("/v1/bugs", bugPayload(uuid(3), "logs"), "wrong-key", "198.51.100.3"),
|
||||||
limitedEnv,
|
limitedEnv,
|
||||||
context,
|
context,
|
||||||
);
|
);
|
||||||
@@ -80,7 +70,7 @@ describe("diagnostics Worker", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns structured errors for invalid bodies and asynchronous storage failures", async () => {
|
it("returns structured errors for invalid bodies and asynchronous storage failures", async () => {
|
||||||
const invalid = await exports.default.fetch(jsonRequest("/v1/events", null));
|
const invalid = await exports.default.fetch(jsonRequest("/v1/bugs", null));
|
||||||
expect(invalid.status).toBe(400);
|
expect(invalid.status).toBe(400);
|
||||||
expect(await invalid.json()).toEqual({ error: "invalid_body" });
|
expect(await invalid.json()).toEqual({ error: "invalid_body" });
|
||||||
|
|
||||||
@@ -92,6 +82,8 @@ describe("diagnostics Worker", () => {
|
|||||||
};
|
};
|
||||||
const rejectingDatabase = {
|
const rejectingDatabase = {
|
||||||
prepare: () => statement,
|
prepare: () => statement,
|
||||||
|
batch: async () => Promise.reject(rejection),
|
||||||
|
withSession: () => ({ prepare: () => statement }),
|
||||||
} as unknown as D1Database;
|
} as unknown as D1Database;
|
||||||
const rejectingEnv: DiagnosticsEnv = { ...env, DB: rejectingDatabase };
|
const rejectingEnv: DiagnosticsEnv = { ...env, DB: rejectingDatabase };
|
||||||
|
|
||||||
@@ -106,7 +98,7 @@ describe("diagnostics Worker", () => {
|
|||||||
|
|
||||||
const ingestContext = createExecutionContext();
|
const ingestContext = createExecutionContext();
|
||||||
const failedIngest = await worker.fetch(
|
const failedIngest = await worker.fetch(
|
||||||
jsonRequest("/v1/events", eventPayload(uuid(2)), env.INGEST_KEY, "198.51.100.2"),
|
jsonRequest("/v1/bugs", bugPayload(uuid(2), "logs"), env.INGEST_KEY, "198.51.100.2"),
|
||||||
rejectingEnv,
|
rejectingEnv,
|
||||||
ingestContext,
|
ingestContext,
|
||||||
);
|
);
|
||||||
@@ -114,101 +106,6 @@ describe("diagnostics Worker", () => {
|
|||||||
expect(await failedIngest.json()).toEqual({ error: "internal" });
|
expect(await failedIngest.json()).toEqual({ error: "internal" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("deduplicates event batches using the client batch ID", async () => {
|
|
||||||
const id = uuid(10);
|
|
||||||
const first = await exports.default.fetch(jsonRequest("/v1/events", eventPayload(id)));
|
|
||||||
const second = await exports.default.fetch(jsonRequest("/v1/events", eventPayload(id)));
|
|
||||||
|
|
||||||
expect(first.status).toBe(202);
|
|
||||||
expect(await first.json()).toMatchObject({
|
|
||||||
ok: true,
|
|
||||||
id,
|
|
||||||
stored: 1,
|
|
||||||
duplicate: false,
|
|
||||||
});
|
|
||||||
expect(second.status).toBe(202);
|
|
||||||
expect(await second.json()).toMatchObject({
|
|
||||||
ok: true,
|
|
||||||
id,
|
|
||||||
stored: 0,
|
|
||||||
duplicate: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const row = await env.DB.prepare(
|
|
||||||
"SELECT event_count AS eventCount, payload_json AS payloadJson FROM event_batches WHERE id = ?",
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.first<{ eventCount: number; payloadJson: string }>();
|
|
||||||
expect(row?.eventCount).toBe(1);
|
|
||||||
expect(JSON.parse(row?.payloadJson ?? "null")).toEqual([
|
|
||||||
{
|
|
||||||
name: "app_open",
|
|
||||||
timestampMillis: 1,
|
|
||||||
properties: { screen: "home" },
|
|
||||||
schemaVersion: 1,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps D1 idempotency when the optional analytics index is enabled", async () => {
|
|
||||||
const points: AnalyticsEngineDataPoint[] = [];
|
|
||||||
const analytics = {
|
|
||||||
writeDataPoint: (point: AnalyticsEngineDataPoint) => points.push(point),
|
|
||||||
} as AnalyticsEngineDataset;
|
|
||||||
const analyticsEnv: DiagnosticsEnv = { ...env, AE: analytics };
|
|
||||||
const payload: NormalizedEventsPayload = {
|
|
||||||
batchId: uuid(11),
|
|
||||||
installId: INSTALL_ID,
|
|
||||||
appVersion: "1.0",
|
|
||||||
platform: "test",
|
|
||||||
events: [
|
|
||||||
{
|
|
||||||
name: "indexed",
|
|
||||||
timestampMillis: 1,
|
|
||||||
properties: {},
|
|
||||||
schemaVersion: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(await storeEvents(payload, analyticsEnv)).toMatchObject({ duplicate: false, stored: 1 });
|
|
||||||
expect(await storeEvents(payload, analyticsEnv)).toMatchObject({ duplicate: true, stored: 0 });
|
|
||||||
expect(points).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the accepted crash blob when a duplicate request arrives", async () => {
|
|
||||||
const id = uuid(20);
|
|
||||||
const first = await exports.default.fetch(
|
|
||||||
jsonRequest("/v1/crashes", crashPayload(id, "first stack")),
|
|
||||||
);
|
|
||||||
const second = await exports.default.fetch(
|
|
||||||
jsonRequest("/v1/crashes", crashPayload(id, "second stack")),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(first.status).toBe(202);
|
|
||||||
const firstBody = await first.json<{ fingerprint: string }>();
|
|
||||||
expect(firstBody).toMatchObject({ ok: true, id, duplicate: false });
|
|
||||||
expect(second.status).toBe(202);
|
|
||||||
const secondBody = await second.json<{ fingerprint: string }>();
|
|
||||||
expect(secondBody).toMatchObject({ ok: true, id, duplicate: true });
|
|
||||||
|
|
||||||
const row = await env.DB.prepare(
|
|
||||||
`SELECT stack_r2_key AS stackKey, breadcrumbs_json AS breadcrumbsJson,
|
|
||||||
fingerprint
|
|
||||||
FROM crashes WHERE id = ?`,
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.first<{ stackKey: string; breadcrumbsJson: string; fingerprint: string }>();
|
|
||||||
expect(row?.stackKey).toMatch(new RegExp(`^crashes/${id}/[0-9a-f-]+/stack\\.txt$`));
|
|
||||||
expect(firstBody.fingerprint).toBe(row?.fingerprint);
|
|
||||||
expect(secondBody.fingerprint).toBe(row?.fingerprint);
|
|
||||||
expect(JSON.parse(row?.breadcrumbsJson ?? "null")).toEqual([]);
|
|
||||||
expect(await (await env.BLOBS.get(row?.stackKey ?? "missing"))?.text()).toBe("first stack");
|
|
||||||
|
|
||||||
const objects = await env.BLOBS.list({ prefix: `crashes/${id}/` });
|
|
||||||
expect(objects.objects.map((object) => object.key)).toEqual([row?.stackKey]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stores bug metadata as JSON and cleans the duplicate upload attempt", async () => {
|
it("stores bug metadata as JSON and cleans the duplicate upload attempt", async () => {
|
||||||
const id = uuid(30);
|
const id = uuid(30);
|
||||||
const payload = bugPayload(id, "first logs");
|
const payload = bugPayload(id, "first logs");
|
||||||
@@ -224,7 +121,7 @@ describe("diagnostics Worker", () => {
|
|||||||
|
|
||||||
const row = await env.DB.prepare(
|
const row = await env.DB.prepare(
|
||||||
`SELECT occurred_at AS occurredAt, logs_r2_key AS logsKey,
|
`SELECT occurred_at AS occurredAt, logs_r2_key AS logsKey,
|
||||||
device_json AS deviceJson, breadcrumbs_json AS breadcrumbsJson
|
device_json AS deviceJson
|
||||||
FROM bugs WHERE id = ?`,
|
FROM bugs WHERE id = ?`,
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
@@ -232,7 +129,6 @@ describe("diagnostics Worker", () => {
|
|||||||
occurredAt: number;
|
occurredAt: number;
|
||||||
logsKey: string;
|
logsKey: string;
|
||||||
deviceJson: string;
|
deviceJson: string;
|
||||||
breadcrumbsJson: string;
|
|
||||||
}>();
|
}>();
|
||||||
expect(row?.occurredAt).toBe(3);
|
expect(row?.occurredAt).toBe(3);
|
||||||
expect(JSON.parse(row?.deviceJson ?? "null")).toEqual({
|
expect(JSON.parse(row?.deviceJson ?? "null")).toEqual({
|
||||||
@@ -242,9 +138,6 @@ describe("diagnostics Worker", () => {
|
|||||||
network: "offline",
|
network: "offline",
|
||||||
batteryLevel: "90%",
|
batteryLevel: "90%",
|
||||||
});
|
});
|
||||||
expect(JSON.parse(row?.breadcrumbsJson ?? "null")).toEqual([
|
|
||||||
{ name: "opened", timestampMillis: 2, properties: {} },
|
|
||||||
]);
|
|
||||||
expect(await (await env.BLOBS.get(row?.logsKey ?? "missing"))?.text()).toBe("first logs");
|
expect(await (await env.BLOBS.get(row?.logsKey ?? "missing"))?.text()).toBe("first logs");
|
||||||
|
|
||||||
const objects = await env.BLOBS.list({ prefix: `bugs/${id}/` });
|
const objects = await env.BLOBS.list({ prefix: `bugs/${id}/` });
|
||||||
@@ -252,9 +145,7 @@ describe("diagnostics Worker", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("acknowledges known report IDs without touching an unavailable blob store", async () => {
|
it("acknowledges known report IDs without touching an unavailable blob store", async () => {
|
||||||
const crash = normalizedCrash(uuid(31), "accepted stack");
|
|
||||||
const bug = normalizedBug(uuid(32), "accepted logs");
|
const bug = normalizedBug(uuid(32), "accepted logs");
|
||||||
const firstCrash = await storeCrash(crash, env);
|
|
||||||
await storeBug(bug, env);
|
await storeBug(bug, env);
|
||||||
let blobWrites = 0;
|
let blobWrites = 0;
|
||||||
const unavailableBlobs = {
|
const unavailableBlobs = {
|
||||||
@@ -265,14 +156,6 @@ describe("diagnostics Worker", () => {
|
|||||||
} as unknown as R2Bucket;
|
} as unknown as R2Bucket;
|
||||||
const unavailableEnv: DiagnosticsEnv = { ...env, BLOBS: unavailableBlobs };
|
const unavailableEnv: DiagnosticsEnv = { ...env, BLOBS: unavailableBlobs };
|
||||||
|
|
||||||
await expect(
|
|
||||||
storeCrash({ ...crash, stackTrace: "retry stack" }, unavailableEnv),
|
|
||||||
).resolves.toEqual({
|
|
||||||
id: crash.id,
|
|
||||||
duplicate: true,
|
|
||||||
stored: 0,
|
|
||||||
fingerprint: firstCrash.fingerprint,
|
|
||||||
});
|
|
||||||
await expect(
|
await expect(
|
||||||
storeBug({ ...bug, logs: "retry logs" }, unavailableEnv),
|
storeBug({ ...bug, logs: "retry logs" }, unavailableEnv),
|
||||||
).resolves.toEqual({ id: bug.id, duplicate: true, stored: 0 });
|
).resolves.toEqual({ id: bug.id, duplicate: true, stored: 0 });
|
||||||
@@ -286,88 +169,56 @@ describe("diagnostics Worker", () => {
|
|||||||
async () => Promise.reject(rejection),
|
async () => Promise.reject(rejection),
|
||||||
);
|
);
|
||||||
const rejectingEnv: DiagnosticsEnv = { ...env, DB: rejectingDatabase };
|
const rejectingEnv: DiagnosticsEnv = { ...env, DB: rejectingDatabase };
|
||||||
const crashId = uuid(33);
|
|
||||||
const bugId = uuid(34);
|
const bugId = uuid(34);
|
||||||
|
|
||||||
const crashResponse = await worker.fetch(
|
|
||||||
jsonRequest("/v1/crashes", crashPayload(crashId, "orphan candidate"), env.INGEST_KEY, "198.51.100.33"),
|
|
||||||
rejectingEnv,
|
|
||||||
createExecutionContext(),
|
|
||||||
);
|
|
||||||
const bugResponse = await worker.fetch(
|
const bugResponse = await worker.fetch(
|
||||||
jsonRequest("/v1/bugs", bugPayload(bugId, "orphan candidate"), env.INGEST_KEY, "198.51.100.34"),
|
jsonRequest("/v1/bugs", bugPayload(bugId, "orphan candidate"), env.INGEST_KEY, "198.51.100.34"),
|
||||||
rejectingEnv,
|
rejectingEnv,
|
||||||
createExecutionContext(),
|
createExecutionContext(),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(crashResponse.status).toBe(500);
|
|
||||||
expect(await crashResponse.json()).toEqual({ error: "internal" });
|
|
||||||
expect(bugResponse.status).toBe(500);
|
expect(bugResponse.status).toBe(500);
|
||||||
expect(await bugResponse.json()).toEqual({ error: "internal" });
|
expect(await bugResponse.json()).toEqual({ error: "internal" });
|
||||||
expect((await env.BLOBS.list({ prefix: `crashes/${crashId}/` })).objects).toEqual([]);
|
|
||||||
expect((await env.BLOBS.list({ prefix: `bugs/${bugId}/` })).objects).toEqual([]);
|
expect((await env.BLOBS.list({ prefix: `bugs/${bugId}/` })).objects).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("removes expired rows and their exact R2 objects while preserving current data", async () => {
|
it("removes expired rows and their exact R2 objects while preserving current data", async () => {
|
||||||
const oldEventId = uuid(40);
|
|
||||||
const oldCrashId = uuid(41);
|
|
||||||
const oldBugId = uuid(42);
|
const oldBugId = uuid(42);
|
||||||
const currentEventId = uuid(43);
|
const currentBugId = uuid(43);
|
||||||
const oldCrashKey = `crashes/${oldCrashId}/retention/stack.txt`;
|
|
||||||
const oldBugKey = `bugs/${oldBugId}/retention/logs.txt`;
|
const oldBugKey = `bugs/${oldBugId}/retention/logs.txt`;
|
||||||
const oldReceivedAt = Date.now() - 100 * 86_400_000;
|
const oldReceivedAt = Date.now() - 100 * 86_400_000;
|
||||||
|
|
||||||
await Promise.all([
|
await env.BLOBS.put(oldBugKey, "expired logs");
|
||||||
env.BLOBS.put(oldCrashKey, "expired crash"),
|
|
||||||
env.BLOBS.put(oldBugKey, "expired logs"),
|
|
||||||
]);
|
|
||||||
await env.DB.batch([
|
await env.DB.batch([
|
||||||
env.DB.prepare(
|
|
||||||
`INSERT INTO event_batches
|
|
||||||
(id, received_at, install_id, app_version, platform, event_count, payload_json)
|
|
||||||
VALUES (?, ?, ?, '', '', 1, '[]')`,
|
|
||||||
).bind(oldEventId, oldReceivedAt, INSTALL_ID),
|
|
||||||
env.DB.prepare(
|
|
||||||
`INSERT INTO event_batches
|
|
||||||
(id, received_at, install_id, app_version, platform, event_count, payload_json)
|
|
||||||
VALUES (?, ?, ?, '', '', 1, '[]')`,
|
|
||||||
).bind(currentEventId, Date.now(), INSTALL_ID),
|
|
||||||
env.DB.prepare(
|
|
||||||
`INSERT INTO crashes
|
|
||||||
(id, received_at, occurred_at, install_id, app_version, platform,
|
|
||||||
exception_type, exception_message, fingerprint, diagnostics_enabled,
|
|
||||||
stack_r2_key, breadcrumbs_json, schema_version)
|
|
||||||
VALUES (?, ?, ?, ?, '', '', 'Error', '', 'fingerprint', 1, ?, '[]', 1)`,
|
|
||||||
).bind(oldCrashId, oldReceivedAt, oldReceivedAt, INSTALL_ID, oldCrashKey),
|
|
||||||
env.DB.prepare(
|
env.DB.prepare(
|
||||||
`INSERT INTO bugs
|
`INSERT INTO bugs
|
||||||
(id, received_at, occurred_at, install_id, app_version, platform,
|
(id, received_at, occurred_at, install_id, app_version, platform,
|
||||||
what_happened, expected, steps, contact, logs_r2_key,
|
what_happened, expected, steps, contact, logs_r2_key,
|
||||||
device_json, breadcrumbs_json, status, schema_version)
|
device_json, status, schema_version)
|
||||||
VALUES (?, ?, ?, ?, '', '', 'failed', 'worked', '', '', ?, '{}', '[]', 'open', 1)`,
|
VALUES (?, ?, ?, ?, '', '', 'failed', 'worked', '', '', ?, '{}', 'open', 1)`,
|
||||||
).bind(oldBugId, oldReceivedAt, oldReceivedAt, INSTALL_ID, oldBugKey),
|
).bind(oldBugId, oldReceivedAt, oldReceivedAt, INSTALL_ID, oldBugKey),
|
||||||
|
env.DB.prepare(
|
||||||
|
`INSERT INTO bugs
|
||||||
|
(id, received_at, occurred_at, install_id, app_version, platform,
|
||||||
|
what_happened, expected, steps, contact, logs_r2_key,
|
||||||
|
device_json, status, schema_version)
|
||||||
|
VALUES (?, ?, ?, ?, '', '', 'failed', 'worked', '', '', NULL, '{}', 'open', 1)`,
|
||||||
|
).bind(currentBugId, Date.now(), Date.now(), INSTALL_ID),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await runRetention(env);
|
await runRetention(env);
|
||||||
|
|
||||||
for (const [table, id] of [
|
expect(
|
||||||
["event_batches", oldEventId],
|
await env.DB.prepare("SELECT id FROM bugs WHERE id = ?").bind(oldBugId).first(),
|
||||||
["crashes", oldCrashId],
|
).toBeNull();
|
||||||
["bugs", oldBugId],
|
|
||||||
] as const) {
|
|
||||||
const row = await env.DB.prepare(`SELECT id FROM ${table} WHERE id = ?`).bind(id).first();
|
|
||||||
expect(row).toBeNull();
|
|
||||||
}
|
|
||||||
expect(await env.BLOBS.head(oldCrashKey)).toBeNull();
|
|
||||||
expect(await env.BLOBS.head(oldBugKey)).toBeNull();
|
expect(await env.BLOBS.head(oldBugKey)).toBeNull();
|
||||||
expect(
|
expect(
|
||||||
await env.DB.prepare("SELECT id FROM event_batches WHERE id = ?").bind(currentEventId).first(),
|
await env.DB.prepare("SELECT id FROM bugs WHERE id = ?").bind(currentBugId).first(),
|
||||||
).not.toBeNull();
|
).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("bounds a full retention run below the D1 per-invocation query limit", async () => {
|
it("bounds a full retention run below the D1 per-invocation query limit", async () => {
|
||||||
let queryCount = 0;
|
let queryCount = 0;
|
||||||
let batchCalls = 0;
|
|
||||||
const blobDeleteBatchSizes: number[] = [];
|
const blobDeleteBatchSizes: number[] = [];
|
||||||
const rows = Array.from({ length: 900 }, (_, index) => ({
|
const rows = Array.from({ length: 900 }, (_, index) => ({
|
||||||
id: `expired-${index}`,
|
id: `expired-${index}`,
|
||||||
@@ -381,17 +232,17 @@ describe("diagnostics Worker", () => {
|
|||||||
queryCount += 1;
|
queryCount += 1;
|
||||||
return d1Result(rows, 0);
|
return d1Result(rows, 0);
|
||||||
},
|
},
|
||||||
|
run: async () => {
|
||||||
|
queryCount += 1;
|
||||||
|
return d1Result([], rows.length);
|
||||||
|
},
|
||||||
|
first: async () => {
|
||||||
|
queryCount += 1;
|
||||||
|
return { count: rows.length };
|
||||||
|
},
|
||||||
};
|
};
|
||||||
return statement;
|
return statement;
|
||||||
},
|
},
|
||||||
batch: async (statements: D1PreparedStatement[]) => {
|
|
||||||
batchCalls += 1;
|
|
||||||
queryCount += statements.length;
|
|
||||||
if (batchCalls === 9) {
|
|
||||||
return statements.map(() => d1Result([{ count: 1 }], 0));
|
|
||||||
}
|
|
||||||
return statements.map((_, index) => d1Result([], index === 0 ? 1_000 : 900));
|
|
||||||
},
|
|
||||||
} as unknown as D1Database;
|
} as unknown as D1Database;
|
||||||
const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
const blobs = {
|
const blobs = {
|
||||||
@@ -406,9 +257,10 @@ describe("diagnostics Worker", () => {
|
|||||||
warning.mockRestore();
|
warning.mockRestore();
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(queryCount).toBe(43);
|
// Eight passes (one SELECT + one DELETE each) plus the final backlog SELECT.
|
||||||
expect(blobDeleteBatchSizes).toHaveLength(16);
|
expect(queryCount).toBe(17);
|
||||||
expect(Math.max(...blobDeleteBatchSizes)).toBe(1_000);
|
expect(blobDeleteBatchSizes).toHaveLength(8);
|
||||||
|
expect(Math.max(...blobDeleteBatchSizes)).toBe(900);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("converges an expired report backlog across bounded retention runs", async () => {
|
it("converges an expired report backlog across bounded retention runs", async () => {
|
||||||
@@ -424,13 +276,13 @@ describe("diagnostics Worker", () => {
|
|||||||
CROSS JOIN digits AS ones
|
CROSS JOIN digits AS ones
|
||||||
WHERE thousands.value * 1000 + hundreds.value * 100 + tens.value * 10 + ones.value < 7201
|
WHERE thousands.value * 1000 + hundreds.value * 100 + tens.value * 10 + ones.value < 7201
|
||||||
)
|
)
|
||||||
INSERT INTO crashes (
|
INSERT INTO bugs (
|
||||||
id, received_at, occurred_at, install_id, app_version, platform,
|
id, received_at, occurred_at, install_id, app_version, platform,
|
||||||
exception_type, exception_message, fingerprint, diagnostics_enabled,
|
what_happened, expected, steps, contact, logs_r2_key,
|
||||||
stack_r2_key, breadcrumbs_json, schema_version
|
device_json, status, schema_version
|
||||||
)
|
)
|
||||||
SELECT 'retention-backlog-' || printf('%04d', value), ?, ?, ?, '', '',
|
SELECT 'retention-backlog-' || printf('%04d', value), ?, ?, ?, '', '',
|
||||||
'Error', '', 'fingerprint-' || value, 0, NULL, '[]', 1
|
'failed', 'worked', '', '', NULL, '{}', 'open', 1
|
||||||
FROM sequence`,
|
FROM sequence`,
|
||||||
)
|
)
|
||||||
.bind(oldReceivedAt, oldReceivedAt, INSTALL_ID)
|
.bind(oldReceivedAt, oldReceivedAt, INSTALL_ID)
|
||||||
@@ -440,13 +292,13 @@ describe("diagnostics Worker", () => {
|
|||||||
try {
|
try {
|
||||||
await runRetention(env);
|
await runRetention(env);
|
||||||
const afterFirstRun = await env.DB.prepare(
|
const afterFirstRun = await env.DB.prepare(
|
||||||
"SELECT COUNT(*) AS count FROM crashes WHERE id LIKE 'retention-backlog-%'",
|
"SELECT COUNT(*) AS count FROM bugs WHERE id LIKE 'retention-backlog-%'",
|
||||||
).first<{ count: number }>();
|
).first<{ count: number }>();
|
||||||
expect(afterFirstRun?.count).toBe(1);
|
expect(afterFirstRun?.count).toBe(1);
|
||||||
|
|
||||||
await runRetention(env);
|
await runRetention(env);
|
||||||
const afterSecondRun = await env.DB.prepare(
|
const afterSecondRun = await env.DB.prepare(
|
||||||
"SELECT COUNT(*) AS count FROM crashes WHERE id LIKE 'retention-backlog-%'",
|
"SELECT COUNT(*) AS count FROM bugs WHERE id LIKE 'retention-backlog-%'",
|
||||||
).first<{ count: number }>();
|
).first<{ count: number }>();
|
||||||
expect(afterSecondRun?.count).toBe(0);
|
expect(afterSecondRun?.count).toBe(0);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -482,39 +334,6 @@ function healthRequest(key = env.INGEST_KEY, source = "198.51.100.1"): Request {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function eventPayload(batchId: string): Record<string, unknown> {
|
|
||||||
return {
|
|
||||||
batchId,
|
|
||||||
installId: INSTALL_ID,
|
|
||||||
appVersion: "1.0",
|
|
||||||
platform: "test",
|
|
||||||
events: [
|
|
||||||
{
|
|
||||||
name: "app_open",
|
|
||||||
timestampMillis: 1,
|
|
||||||
properties: { screen: "home" },
|
|
||||||
schemaVersion: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function crashPayload(id: string, stackTrace: string): Record<string, unknown> {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
installId: INSTALL_ID,
|
|
||||||
appVersion: "1.0",
|
|
||||||
platform: "test",
|
|
||||||
exceptionType: "TestError",
|
|
||||||
exceptionMessage: "failed",
|
|
||||||
stackTrace,
|
|
||||||
timestampMillis: 2,
|
|
||||||
diagnosticsEnabledAtCapture: true,
|
|
||||||
breadcrumbs: [],
|
|
||||||
schemaVersion: 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function bugPayload(id: string, logs: string): Record<string, unknown> {
|
function bugPayload(id: string, logs: string): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -535,23 +354,6 @@ function bugPayload(id: string, logs: string): Record<string, unknown> {
|
|||||||
network: "offline",
|
network: "offline",
|
||||||
batteryLevel: "90%",
|
batteryLevel: "90%",
|
||||||
},
|
},
|
||||||
breadcrumbs: [{ name: "opened", timestampMillis: 2, properties: {} }],
|
|
||||||
schemaVersion: 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizedCrash(id: string, stackTrace: string): NormalizedCrashPayload {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
installId: INSTALL_ID,
|
|
||||||
appVersion: "1.0",
|
|
||||||
platform: "test",
|
|
||||||
exceptionType: "TestError",
|
|
||||||
exceptionMessage: "failed",
|
|
||||||
stackTrace,
|
|
||||||
occurredAt: 2,
|
|
||||||
diagnosticsEnabledAtCapture: true,
|
|
||||||
breadcrumbs: [],
|
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -575,7 +377,6 @@ function normalizedBug(id: string, logs: string): NormalizedBugPayload {
|
|||||||
network: "offline",
|
network: "offline",
|
||||||
batteryLevel: "90%",
|
batteryLevel: "90%",
|
||||||
},
|
},
|
||||||
breadcrumbs: [],
|
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
// Generated by Wrangler by running `wrangler types` (hash: e2953336d40e96c4b5125a5de01f7cac)
|
// Generated by Wrangler by running `wrangler types` (hash: 9c27cfd9219ba0d4efc153db78cad4c3)
|
||||||
// Runtime types generated with workerd@1.20260708.1 2026-07-14 nodejs_compat
|
// Runtime types generated with workerd@1.20260708.1 2026-07-14 nodejs_compat
|
||||||
interface __BaseEnv_Env {
|
interface __BaseEnv_Env {
|
||||||
BLOBS: R2Bucket;
|
BLOBS: R2Bucket;
|
||||||
@@ -7,7 +7,6 @@ interface __BaseEnv_Env {
|
|||||||
INSTALL_RATE_LIMITER: RateLimit;
|
INSTALL_RATE_LIMITER: RateLimit;
|
||||||
SOURCE_RATE_LIMITER: RateLimit;
|
SOURCE_RATE_LIMITER: RateLimit;
|
||||||
MAX_BODY_BYTES: "262144";
|
MAX_BODY_BYTES: "262144";
|
||||||
MAX_EVENTS_PER_BATCH: "50";
|
|
||||||
RETENTION_DAYS: "90";
|
RETENTION_DAYS: "90";
|
||||||
}
|
}
|
||||||
declare namespace Cloudflare {
|
declare namespace Cloudflare {
|
||||||
@@ -21,7 +20,7 @@ type StringifyValues<EnvType extends Record<string, unknown>> = {
|
|||||||
[Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;
|
[Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;
|
||||||
};
|
};
|
||||||
declare namespace NodeJS {
|
declare namespace NodeJS {
|
||||||
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "MAX_BODY_BYTES" | "MAX_EVENTS_PER_BATCH" | "RETENTION_DAYS">> {}
|
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "MAX_BODY_BYTES" | "RETENTION_DAYS">> {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Begin runtime types
|
// Begin runtime types
|
||||||
|
|||||||
@@ -47,13 +47,8 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
// Optional: bind Analytics Engine as `AE` when event volume justifies it.
|
|
||||||
// "analytics_engine_datasets": [
|
|
||||||
// { "binding": "AE", "dataset": "vnidrop_events" },
|
|
||||||
// ],
|
|
||||||
"vars": {
|
"vars": {
|
||||||
"MAX_BODY_BYTES": "262144",
|
"MAX_BODY_BYTES": "262144",
|
||||||
"MAX_EVENTS_PER_BATCH": "50",
|
|
||||||
"RETENTION_DAYS": "90",
|
"RETENTION_DAYS": "90",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import org.gradle.api.tasks.PathSensitivity
|
|||||||
import org.gradle.api.tasks.TaskAction
|
import org.gradle.api.tasks.TaskAction
|
||||||
import org.gradle.jvm.tasks.Jar
|
import org.gradle.jvm.tasks.Jar
|
||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
abstract class VerifyHostCargoTaskSelection : DefaultTask() {
|
abstract class VerifyHostCargoTaskSelection : DefaultTask() {
|
||||||
@get:Input
|
@get:Input
|
||||||
@@ -37,7 +38,7 @@ plugins {
|
|||||||
alias(libs.plugins.kotlinAtomicfu)
|
alias(libs.plugins.kotlinAtomicfu)
|
||||||
}
|
}
|
||||||
|
|
||||||
val appVersion = providers.gradleProperty("vnidrop.version").get()
|
val appVersion = rootProject.extra["vnidrop.productVersion"] as String
|
||||||
val desktopRustVariant = providers.gradleProperty("vnidrop.desktop.rustVariant")
|
val desktopRustVariant = providers.gradleProperty("vnidrop.desktop.rustVariant")
|
||||||
.map { value ->
|
.map { value ->
|
||||||
when (value.trim().lowercase()) {
|
when (value.trim().lowercase()) {
|
||||||
@@ -49,7 +50,7 @@ val desktopRustVariant = providers.gradleProperty("vnidrop.desktop.rustVariant")
|
|||||||
.orElse(Variant.Debug)
|
.orElse(Variant.Debug)
|
||||||
|
|
||||||
// Compile-time switches (gradle.properties or -P…).
|
// Compile-time switches (gradle.properties or -P…).
|
||||||
// included=false: no Share-diagnostics toggle, no telemetry/crash auto-upload stack.
|
// included=false: user-initiated bug reports use a NoOp transport (never sent).
|
||||||
// endpoint/key both empty: transport is NoOp (safe default until Cloudflare is deployed).
|
// endpoint/key both empty: transport is NoOp (safe default until Cloudflare is deployed).
|
||||||
val diagnosticsIncluded: Boolean =
|
val diagnosticsIncluded: Boolean =
|
||||||
(findProperty("vnidrop.diagnostics.included") as String?)?.toBooleanStrictOrNull() ?: false
|
(findProperty("vnidrop.diagnostics.included") as String?)?.toBooleanStrictOrNull() ?: false
|
||||||
@@ -110,6 +111,55 @@ val generateDiagnosticsBuildConfig by tasks.registering {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// App-wide public constants (privacy policy URL, …) from the shared app.properties,
|
||||||
|
// so Apple and KMP read one source of truth instead of hardcoding values.
|
||||||
|
val appProperties = Properties().apply {
|
||||||
|
rootProject.file("app.properties").inputStream().use(::load)
|
||||||
|
}
|
||||||
|
val privacyPolicyUrl: String = appProperties.getProperty("PRIVACY_POLICY_URL")?.trim().orEmpty()
|
||||||
|
check(privacyPolicyUrl.isNotEmpty()) { "PRIVACY_POLICY_URL must be set in app.properties" }
|
||||||
|
|
||||||
|
val appConfigDir = layout.buildDirectory.dir("generated/appconfig/commonMain/kotlin")
|
||||||
|
val generateAppConfig by tasks.registering {
|
||||||
|
group = "build"
|
||||||
|
description = "Generates AppConfig from the shared app.properties"
|
||||||
|
val outputDir = appConfigDir
|
||||||
|
val privacy = privacyPolicyUrl
|
||||||
|
inputs.property("PRIVACY_POLICY_URL", privacy)
|
||||||
|
outputs.dir(outputDir)
|
||||||
|
doLast {
|
||||||
|
val packageDir = outputDir.get().asFile.resolve("com/vnidrop/app")
|
||||||
|
packageDir.mkdirs()
|
||||||
|
fun esc(value: String): String = buildString {
|
||||||
|
for (ch in value) {
|
||||||
|
when (ch) {
|
||||||
|
'\\' -> append("\\\\")
|
||||||
|
'"' -> append("\\\"")
|
||||||
|
'\n' -> append("\\n")
|
||||||
|
'\r' -> append("\\r")
|
||||||
|
'\t' -> append("\\t")
|
||||||
|
'$' -> append("\\$")
|
||||||
|
else -> append(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
packageDir.resolve("AppConfig.kt").writeText(
|
||||||
|
"""
|
||||||
|
|package com.vnidrop.app
|
||||||
|
|
|
||||||
|
|/**
|
||||||
|
| * Generated by shared/build.gradle.kts from the shared app.properties.
|
||||||
|
| * Single source of truth for app-wide public constants (also used by Apple).
|
||||||
|
| */
|
||||||
|
|object AppConfig {
|
||||||
|
| const val PRIVACY_POLICY_URL: String = "${esc(privacy)}"
|
||||||
|
|}
|
||||||
|
|
|
||||||
|
""".trimMargin(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
androidTarget {
|
androidTarget {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
@@ -122,6 +172,7 @@ kotlin {
|
|||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain {
|
commonMain {
|
||||||
kotlin.srcDir(files(diagnosticsBuildConfigDir).builtBy(generateDiagnosticsBuildConfig))
|
kotlin.srcDir(files(diagnosticsBuildConfigDir).builtBy(generateDiagnosticsBuildConfig))
|
||||||
|
kotlin.srcDir(files(appConfigDir).builtBy(generateAppConfig))
|
||||||
}
|
}
|
||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ private class AndroidDeviceInfoProvider(
|
|||||||
|
|
||||||
private fun Context.appVersion(): String = runCatching {
|
private fun Context.appVersion(): String = runCatching {
|
||||||
packageManager.getPackageInfo(packageName, 0).versionName
|
packageManager.getPackageInfo(packageName, 0).versionName
|
||||||
}.getOrNull()?.takeIf(String::isNotBlank) ?: "0.1.0"
|
}.getOrNull()?.takeIf(String::isNotBlank) ?: "unknown"
|
||||||
|
|
||||||
private fun Context.activeNetworkSummary(): String? = runCatching {
|
private fun Context.activeNetworkSummary(): String? = runCatching {
|
||||||
val manager = getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
val manager = getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
package com.vnidrop.app.diagnostics
|
|
||||||
|
|
||||||
import java.io.File
|
|
||||||
import java.nio.charset.StandardCharsets
|
|
||||||
|
|
||||||
actual fun createPendingCrashStore(appDataDir: String): PendingCrashStore =
|
|
||||||
AndroidPendingCrashStore(appDataDir)
|
|
||||||
|
|
||||||
private class AndroidPendingCrashStore(
|
|
||||||
appDataDir: String,
|
|
||||||
) : PendingCrashStore {
|
|
||||||
private val directory = File(appDataDir, "diagnostics/crashes")
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
override fun write(report: CrashReport) {
|
|
||||||
if (!isValidDiagnosticId(report.id)) return
|
|
||||||
directory.mkdirs()
|
|
||||||
val target = File(directory, "${report.id}.crash")
|
|
||||||
val temporary = File(directory, ".${report.id}.tmp")
|
|
||||||
val payload = CrashReportCodec.encode(report)
|
|
||||||
temporary.writeText(payload, StandardCharsets.UTF_8)
|
|
||||||
if (!temporary.renameTo(target)) {
|
|
||||||
target.writeText(payload, StandardCharsets.UTF_8)
|
|
||||||
temporary.delete()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
override fun list(): List<CrashReport> {
|
|
||||||
if (!directory.isDirectory) return emptyList()
|
|
||||||
return directory
|
|
||||||
.listFiles { file -> file.isFile && file.name.endsWith(".crash") }
|
|
||||||
.orEmpty()
|
|
||||||
.sortedByDescending { it.lastModified() }
|
|
||||||
.mapNotNull { file ->
|
|
||||||
runCatching { CrashReportCodec.decode(file.readText(StandardCharsets.UTF_8)) }.getOrNull()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
override fun delete(id: String) {
|
|
||||||
if (!isValidDiagnosticId(id)) return
|
|
||||||
File(directory, "$id.crash").delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
override fun prune(olderThanTimestampMillis: Long, maxCount: Int) {
|
|
||||||
require(maxCount > 0) { "maxCount must be positive" }
|
|
||||||
if (!directory.isDirectory) return
|
|
||||||
directory.listFiles { file -> file.isFile && file.name.endsWith(".tmp") }
|
|
||||||
.orEmpty()
|
|
||||||
.forEach(File::delete)
|
|
||||||
val reports = directory
|
|
||||||
.listFiles { file -> file.isFile && file.name.endsWith(".crash") }
|
|
||||||
.orEmpty()
|
|
||||||
.mapNotNull { file ->
|
|
||||||
val report = runCatching {
|
|
||||||
CrashReportCodec.decode(file.readText(StandardCharsets.UTF_8))
|
|
||||||
}.getOrNull()
|
|
||||||
if (report == null) {
|
|
||||||
file.delete()
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
file to report
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.sortedByDescending { (_, report) -> report.timestampMillis }
|
|
||||||
reports.forEachIndexed { index, (file, report) ->
|
|
||||||
if (index >= maxCount || report.timestampMillis < olderThanTimestampMillis) file.delete()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package com.vnidrop.app.diagnostics
|
|
||||||
|
|
||||||
actual fun installPlatformCrashHook(onCrash: (Throwable) -> Unit) {
|
|
||||||
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
|
||||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
|
||||||
runCatching { onCrash(throwable) }
|
|
||||||
previous?.uncaughtException(thread, throwable)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -80,10 +80,6 @@
|
|||||||
<string name="button_write_nfc">Auf NFC-Tag schreiben</string>
|
<string name="button_write_nfc">Auf NFC-Tag schreiben</string>
|
||||||
<string name="device_model_title">Gerätemodell</string>
|
<string name="device_model_title">Gerätemodell</string>
|
||||||
<string name="device_name_title">Gerätename</string>
|
<string name="device_name_title">Gerätename</string>
|
||||||
<string name="diagnostics_description">Anonyme Absturzberichte und Nutzungsereignisse senden, damit wir VniDrop verbessern können. Sie können dies jederzeit deaktivieren. Einladungen, Dateipfade und Übertragungsinhalte werden niemals einbezogen.</string>
|
|
||||||
<string name="diagnostics_disabled_message">Die Freigabe von Diagnosedaten ist deaktiviert.</string>
|
|
||||||
<string name="diagnostics_enabled_message">Die Freigabe von Diagnosedaten ist aktiviert.</string>
|
|
||||||
<string name="diagnostics_title">Diagnosedaten teilen</string>
|
|
||||||
<string name="error_camera">Für das Scannen eines QR-Codes ist Kamerazugriff erforderlich.</string>
|
<string name="error_camera">Für das Scannen eines QR-Codes ist Kamerazugriff erforderlich.</string>
|
||||||
<string name="error_device_info">Geräteinformationen konnten nicht geladen werden.</string>
|
<string name="error_device_info">Geräteinformationen konnten nicht geladen werden.</string>
|
||||||
<string name="error_destination_exists">Am Ziel ist bereits eine Datei mit demselben Namen vorhanden. Wählen Sie einen anderen Ordner oder entfernen Sie die vorhandene Datei.</string>
|
<string name="error_destination_exists">Am Ziel ist bereits eine Datei mit demselben Namen vorhanden. Wählen Sie einen anderen Ordner oder entfernen Sie die vorhandene Datei.</string>
|
||||||
|
|||||||
@@ -80,10 +80,6 @@
|
|||||||
<string name="button_write_nfc">Escribir en etiqueta NFC</string>
|
<string name="button_write_nfc">Escribir en etiqueta NFC</string>
|
||||||
<string name="device_model_title">Modelo del dispositivo</string>
|
<string name="device_model_title">Modelo del dispositivo</string>
|
||||||
<string name="device_name_title">Nombre del dispositivo</string>
|
<string name="device_name_title">Nombre del dispositivo</string>
|
||||||
<string name="diagnostics_description">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.</string>
|
|
||||||
<string name="diagnostics_disabled_message">El uso compartido de diagnósticos está desactivado.</string>
|
|
||||||
<string name="diagnostics_enabled_message">El uso compartido de diagnósticos está activado.</string>
|
|
||||||
<string name="diagnostics_title">Compartir diagnósticos</string>
|
|
||||||
<string name="error_camera">Se necesita acceso a la cámara para escanear un código QR.</string>
|
<string name="error_camera">Se necesita acceso a la cámara para escanear un código QR.</string>
|
||||||
<string name="error_device_info">No se pudo cargar la información del dispositivo.</string>
|
<string name="error_device_info">No se pudo cargar la información del dispositivo.</string>
|
||||||
<string name="error_destination_exists">Ya existe un archivo con el mismo nombre en el destino. Elija otra carpeta o elimine el archivo existente.</string>
|
<string name="error_destination_exists">Ya existe un archivo con el mismo nombre en el destino. Elija otra carpeta o elimine el archivo existente.</string>
|
||||||
|
|||||||
@@ -80,10 +80,6 @@
|
|||||||
<string name="button_write_nfc">Écrire sur un tag NFC</string>
|
<string name="button_write_nfc">Écrire sur un tag NFC</string>
|
||||||
<string name="device_model_title">Modèle de l’appareil</string>
|
<string name="device_model_title">Modèle de l’appareil</string>
|
||||||
<string name="device_name_title">Nom de l’appareil</string>
|
<string name="device_name_title">Nom de l’appareil</string>
|
||||||
<string name="diagnostics_description">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.</string>
|
|
||||||
<string name="diagnostics_disabled_message">Le partage des diagnostics est désactivé.</string>
|
|
||||||
<string name="diagnostics_enabled_message">Le partage des diagnostics est activé.</string>
|
|
||||||
<string name="diagnostics_title">Partager les diagnostics</string>
|
|
||||||
<string name="error_camera">L’accès à la caméra est nécessaire pour scanner un QR code.</string>
|
<string name="error_camera">L’accès à la caméra est nécessaire pour scanner un QR code.</string>
|
||||||
<string name="error_device_info">Impossible de charger les informations de l’appareil.</string>
|
<string name="error_device_info">Impossible de charger les informations de l’appareil.</string>
|
||||||
<string name="error_destination_exists">Un fichier portant le même nom existe déjà dans la destination. Choisissez un autre dossier ou supprimez le fichier existant.</string>
|
<string name="error_destination_exists">Un fichier portant le même nom existe déjà dans la destination. Choisissez un autre dossier ou supprimez le fichier existant.</string>
|
||||||
|
|||||||
@@ -80,10 +80,6 @@
|
|||||||
<string name="button_write_nfc">Scrivi su tag NFC</string>
|
<string name="button_write_nfc">Scrivi su tag NFC</string>
|
||||||
<string name="device_model_title">Modello del dispositivo</string>
|
<string name="device_model_title">Modello del dispositivo</string>
|
||||||
<string name="device_name_title">Nome del dispositivo</string>
|
<string name="device_name_title">Nome del dispositivo</string>
|
||||||
<string name="diagnostics_description">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.</string>
|
|
||||||
<string name="diagnostics_disabled_message">La condivisione dei dati diagnostici è disattivata.</string>
|
|
||||||
<string name="diagnostics_enabled_message">La condivisione dei dati diagnostici è attivata.</string>
|
|
||||||
<string name="diagnostics_title">Condividi dati diagnostici</string>
|
|
||||||
<string name="error_camera">Per scansionare un codice QR è necessario l’accesso alla fotocamera.</string>
|
<string name="error_camera">Per scansionare un codice QR è necessario l’accesso alla fotocamera.</string>
|
||||||
<string name="error_device_info">Impossibile caricare le informazioni sul dispositivo.</string>
|
<string name="error_device_info">Impossibile caricare le informazioni sul dispositivo.</string>
|
||||||
<string name="error_destination_exists">Nella destinazione esiste già un file con lo stesso nome. Scelga un’altra cartella o rimuova il file esistente.</string>
|
<string name="error_destination_exists">Nella destinazione esiste già un file con lo stesso nome. Scelga un’altra cartella o rimuova il file esistente.</string>
|
||||||
|
|||||||
@@ -80,10 +80,6 @@
|
|||||||
<string name="button_write_nfc">Naar NFC-tag schrijven</string>
|
<string name="button_write_nfc">Naar NFC-tag schrijven</string>
|
||||||
<string name="device_model_title">Apparaatmodel</string>
|
<string name="device_model_title">Apparaatmodel</string>
|
||||||
<string name="device_name_title">Apparaatnaam</string>
|
<string name="device_name_title">Apparaatnaam</string>
|
||||||
<string name="diagnostics_description">Verstuur anonieme crashrapporten en gebruiksgebeurtenissen zodat we VniDrop kunnen verbeteren. U kunt dit op elk moment uitschakelen. Uitnodigingen, bestandspaden en overdrachtsinhoud worden nooit meegestuurd.</string>
|
|
||||||
<string name="diagnostics_disabled_message">Het delen van diagnostische gegevens is uitgeschakeld.</string>
|
|
||||||
<string name="diagnostics_enabled_message">Het delen van diagnostische gegevens is ingeschakeld.</string>
|
|
||||||
<string name="diagnostics_title">Diagnostische gegevens delen</string>
|
|
||||||
<string name="error_camera">Voor het scannen van een QR-code is toegang tot de camera vereist.</string>
|
<string name="error_camera">Voor het scannen van een QR-code is toegang tot de camera vereist.</string>
|
||||||
<string name="error_device_info">Apparaatgegevens konden niet worden geladen.</string>
|
<string name="error_device_info">Apparaatgegevens konden niet worden geladen.</string>
|
||||||
<string name="error_destination_exists">Er staat al een bestand met dezelfde naam in de doelmap. Kies een andere map of verwijder het bestaande bestand.</string>
|
<string name="error_destination_exists">Er staat al een bestand met dezelfde naam in de doelmap. Kies een andere map of verwijder het bestaande bestand.</string>
|
||||||
|
|||||||
@@ -80,10 +80,6 @@
|
|||||||
<string name="button_write_nfc">Zapisz na tagu NFC</string>
|
<string name="button_write_nfc">Zapisz na tagu NFC</string>
|
||||||
<string name="device_model_title">Model urządzenia</string>
|
<string name="device_model_title">Model urządzenia</string>
|
||||||
<string name="device_name_title">Nazwa urządzenia</string>
|
<string name="device_name_title">Nazwa urządzenia</string>
|
||||||
<string name="diagnostics_description">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.</string>
|
|
||||||
<string name="diagnostics_disabled_message">Udostępnianie diagnostyki jest wyłączone.</string>
|
|
||||||
<string name="diagnostics_enabled_message">Udostępnianie diagnostyki jest włączone.</string>
|
|
||||||
<string name="diagnostics_title">Udostępniaj diagnostykę</string>
|
|
||||||
<string name="error_camera">Do zeskanowania kodu QR wymagany jest dostęp do aparatu.</string>
|
<string name="error_camera">Do zeskanowania kodu QR wymagany jest dostęp do aparatu.</string>
|
||||||
<string name="error_device_info">Nie udało się wczytać informacji o urządzeniu.</string>
|
<string name="error_device_info">Nie udało się wczytać informacji o urządzeniu.</string>
|
||||||
<string name="error_destination_exists">W miejscu docelowym istnieje już plik o tej samej nazwie. Wybierz inny folder lub usuń istniejący plik.</string>
|
<string name="error_destination_exists">W miejscu docelowym istnieje już plik o tej samej nazwie. Wybierz inny folder lub usuń istniejący plik.</string>
|
||||||
|
|||||||
@@ -80,10 +80,6 @@
|
|||||||
<string name="button_write_nfc">Escrever em etiqueta NFC</string>
|
<string name="button_write_nfc">Escrever em etiqueta NFC</string>
|
||||||
<string name="device_model_title">Modelo do dispositivo</string>
|
<string name="device_model_title">Modelo do dispositivo</string>
|
||||||
<string name="device_name_title">Nome do dispositivo</string>
|
<string name="device_name_title">Nome do dispositivo</string>
|
||||||
<string name="diagnostics_description">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.</string>
|
|
||||||
<string name="diagnostics_disabled_message">A partilha de diagnósticos está desativada.</string>
|
|
||||||
<string name="diagnostics_enabled_message">A partilha de diagnósticos está ativada.</string>
|
|
||||||
<string name="diagnostics_title">Partilhar diagnósticos</string>
|
|
||||||
<string name="error_camera">É necessário acesso à câmara para ler um código QR.</string>
|
<string name="error_camera">É necessário acesso à câmara para ler um código QR.</string>
|
||||||
<string name="error_device_info">Não foi possível carregar as informações do dispositivo.</string>
|
<string name="error_device_info">Não foi possível carregar as informações do dispositivo.</string>
|
||||||
<string name="error_destination_exists">Já existe um ficheiro com o mesmo nome no destino. Escolha outra pasta ou remova o ficheiro existente.</string>
|
<string name="error_destination_exists">Já existe um ficheiro com o mesmo nome no destino. Escolha outra pasta ou remova o ficheiro existente.</string>
|
||||||
|
|||||||
@@ -80,10 +80,6 @@
|
|||||||
<string name="button_write_nfc">Записать на NFC-метку</string>
|
<string name="button_write_nfc">Записать на NFC-метку</string>
|
||||||
<string name="device_model_title">Модель устройства</string>
|
<string name="device_model_title">Модель устройства</string>
|
||||||
<string name="device_name_title">Имя устройства</string>
|
<string name="device_name_title">Имя устройства</string>
|
||||||
<string name="diagnostics_description">Отправлять анонимные отчёты о сбоях и события использования, чтобы помочь нам улучшать VniDrop. Вы можете отключить это в любой момент. Приглашения, пути к файлам и содержимое передач никогда не включаются.</string>
|
|
||||||
<string name="diagnostics_disabled_message">Передача диагностики отключена.</string>
|
|
||||||
<string name="diagnostics_enabled_message">Передача диагностики включена.</string>
|
|
||||||
<string name="diagnostics_title">Делиться диагностикой</string>
|
|
||||||
<string name="error_camera">Для сканирования QR-кода требуется доступ к камере.</string>
|
<string name="error_camera">Для сканирования QR-кода требуется доступ к камере.</string>
|
||||||
<string name="error_device_info">Не удалось загрузить сведения об устройстве.</string>
|
<string name="error_device_info">Не удалось загрузить сведения об устройстве.</string>
|
||||||
<string name="error_destination_exists">В папке назначения уже есть файл с таким именем. Выберите другую папку или удалите существующий файл.</string>
|
<string name="error_destination_exists">В папке назначения уже есть файл с таким именем. Выберите другую папку или удалите существующий файл.</string>
|
||||||
|
|||||||
@@ -80,10 +80,6 @@
|
|||||||
<string name="button_write_nfc">Write to NFC tag</string>
|
<string name="button_write_nfc">Write to NFC tag</string>
|
||||||
<string name="device_model_title">Device model</string>
|
<string name="device_model_title">Device model</string>
|
||||||
<string name="device_name_title">Device name</string>
|
<string name="device_name_title">Device name</string>
|
||||||
<string name="diagnostics_description">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.</string>
|
|
||||||
<string name="diagnostics_disabled_message">Diagnostics sharing is off.</string>
|
|
||||||
<string name="diagnostics_enabled_message">Diagnostics sharing is on.</string>
|
|
||||||
<string name="diagnostics_title">Share diagnostics</string>
|
|
||||||
<string name="error_camera">Camera access is required to scan a QR code.</string>
|
<string name="error_camera">Camera access is required to scan a QR code.</string>
|
||||||
<string name="error_device_info">Could not load device information.</string>
|
<string name="error_device_info">Could not load device information.</string>
|
||||||
<string name="error_destination_exists">A file with the same name already exists in the destination. Choose another folder or remove the existing file.</string>
|
<string name="error_destination_exists">A file with the same name already exists in the destination. Choose another folder or remove the existing file.</string>
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ fun App(
|
|||||||
graph.coreRepository,
|
graph.coreRepository,
|
||||||
graph.preferencesRepository,
|
graph.preferencesRepository,
|
||||||
graph.messages,
|
graph.messages,
|
||||||
graph.diagnostics,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val sendViewModel = viewModel {
|
val sendViewModel = viewModel {
|
||||||
@@ -105,7 +104,6 @@ fun App(
|
|||||||
dependencies.localNotificationService,
|
dependencies.localNotificationService,
|
||||||
graph.messages,
|
graph.messages,
|
||||||
graph.diagnostics.bugReports,
|
graph.diagnostics.bugReports,
|
||||||
graph.diagnostics,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val appState by appViewModel.state.collectAsStateWithLifecycle()
|
val appState by appViewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|||||||
@@ -19,9 +19,6 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.flow.drop
|
|
||||||
import kotlinx.coroutines.flow.filter
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
class AppGraph(
|
class AppGraph(
|
||||||
val dependencies: AppDependencies,
|
val dependencies: AppDependencies,
|
||||||
@@ -40,11 +37,9 @@ class AppGraph(
|
|||||||
receiveFolder = dependencies.fileSystemService.defaultReceiveFolder(),
|
receiveFolder = dependencies.fileSystemService.defaultReceiveFolder(),
|
||||||
themeMode = ThemeMode.System,
|
themeMode = ThemeMode.System,
|
||||||
notificationsEnabled = false,
|
notificationsEnabled = false,
|
||||||
diagnosticsEnabled = false,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val diagnostics = DiagnosticsCoordinator.create(
|
val diagnostics = DiagnosticsCoordinator.create(
|
||||||
appDataDir = dependencies.environment.defaultCoreDataDir,
|
|
||||||
appVersion = dependencies.environment.appVersion,
|
appVersion = dependencies.environment.appVersion,
|
||||||
platform = dependencies.environment.name,
|
platform = dependencies.environment.name,
|
||||||
preferencesRepository = preferencesRepository,
|
preferencesRepository = preferencesRepository,
|
||||||
@@ -75,12 +70,6 @@ class AppGraph(
|
|||||||
init {
|
init {
|
||||||
AppLogger.initialize(dependencies.environment.defaultCoreDataDir)
|
AppLogger.initialize(dependencies.environment.defaultCoreDataDir)
|
||||||
diagnostics.start()
|
diagnostics.start()
|
||||||
applicationScope.launch {
|
|
||||||
visibility.isForeground
|
|
||||||
.drop(1)
|
|
||||||
.filter { isForeground -> !isForeground }
|
|
||||||
.collect { diagnostics.telemetry.flush() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun close() {
|
fun close() {
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
package com.vnidrop.app.diagnostics
|
|
||||||
|
|
||||||
import com.vnidrop.app.logging.platformNowMillis
|
|
||||||
import kotlin.concurrent.atomics.AtomicReference
|
|
||||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fixed-size ring of high-level app breadcrumbs for crash / bug context.
|
|
||||||
* Always in-memory only; never auto-uploaded without policy + transport.
|
|
||||||
*
|
|
||||||
* Updates are best-effort under concurrency; losing a breadcrumb is preferable
|
|
||||||
* to blocking a dying process on a lock.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalAtomicApi::class)
|
|
||||||
class BreadcrumbBuffer(
|
|
||||||
private val capacity: Int = DefaultCapacity,
|
|
||||||
) {
|
|
||||||
init {
|
|
||||||
require(capacity > 0) { "capacity must be positive" }
|
|
||||||
}
|
|
||||||
|
|
||||||
private val items = AtomicReference<List<Breadcrumb>>(emptyList())
|
|
||||||
|
|
||||||
fun add(name: String, properties: Map<String, String> = emptyMap(), timestampMillis: Long = platformNowMillis()) {
|
|
||||||
val sanitizedName = sanitizeDiagnosticName(name)
|
|
||||||
if (sanitizedName.isBlank()) return
|
|
||||||
val crumb = Breadcrumb(
|
|
||||||
name = sanitizedName,
|
|
||||||
timestampMillis = timestampMillis,
|
|
||||||
properties = sanitizeDiagnosticProperties(properties),
|
|
||||||
)
|
|
||||||
while (true) {
|
|
||||||
val current = items.load()
|
|
||||||
if (items.compareAndSet(current, (current + crumb).takeLast(capacity))) return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun snapshot(): List<Breadcrumb> = items.load()
|
|
||||||
|
|
||||||
fun clear() {
|
|
||||||
items.store(emptyList())
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val DefaultCapacity = 40
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,7 +21,6 @@ data class BugReportDraft(
|
|||||||
class BugReportService(
|
class BugReportService(
|
||||||
private val preferencesRepository: PreferencesRepository,
|
private val preferencesRepository: PreferencesRepository,
|
||||||
private val transport: DiagnosticsTransport,
|
private val transport: DiagnosticsTransport,
|
||||||
private val breadcrumbs: BreadcrumbBuffer,
|
|
||||||
private val appVersion: String,
|
private val appVersion: String,
|
||||||
private val platform: String,
|
private val platform: String,
|
||||||
private val logReader: () -> String = {
|
private val logReader: () -> String = {
|
||||||
@@ -53,7 +52,6 @@ class BugReportService(
|
|||||||
network = deviceInfo?.network?.takeUtf8Bytes(96),
|
network = deviceInfo?.network?.takeUtf8Bytes(96),
|
||||||
batteryLevel = deviceInfo?.batteryLevel?.takeUtf8Bytes(64),
|
batteryLevel = deviceInfo?.batteryLevel?.takeUtf8Bytes(64),
|
||||||
),
|
),
|
||||||
breadcrumbs = breadcrumbs.snapshot(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user