36 Commits

Author SHA1 Message Date
877083a3ed refactor(diagnostics): remove bug report breadcrumbs 2026-08-02 19:18:18 +02:00
7cb2270d56 ci(apple): add Xcode Cloud post-clone script
Xcode Cloud only checks out the repo, so ci_post_clone.sh installs swiftlint,
xcodegen and bun, downloads the prebuilt core (vnidrop.xcframework + Vnidrop.swift)
from the matching GitHub Release asset, and generates the project via localization,
version/app config codegen and xcodegen. Rust is never built on Xcode Cloud.
2026-08-02 10:29:46 +02:00
eb8498168d fix(shared): avoid java accessor shadowing when reading app.properties
In a Gradle Kotlin DSL script `java` resolves to the Java plugin extension
accessor, so `java.util.Properties` failed script compilation with
"Unresolved reference 'util'", breaking the shared/Linux/Windows KMP jobs.
Import java.util.Properties and use it unqualified, matching the root build.
2026-08-02 10:29:45 +02:00
ac6e837560 docs: describe bug reports instead of telemetry
Update the site privacy policy (no telemetry/analytics, bug-report only, v1.2)
and the README/apple README to reflect that only user-submitted bug reports
remain.
2026-08-02 10:03:29 +02:00
3e18378610 refactor(diagnostics-api): drop telemetry and crash ingestion, keep bug reports
Remove the /v1/events and /v1/crashes routes, their normalizers and storage
paths, and simplify retention to the bugs table. Add a migration dropping the
now-unused event_batches and crashes tables, and regenerate worker types.
2026-08-02 10:03:12 +02:00
b68d338097 refactor(apple): remove diagnostics opt-in toggle, keep bug reports
Drop the Share-diagnostics preference, its Settings toggle and the
DiagnosticsBuildConfig stub. Bug reporting (NoopBugReportService) and the
diagnostics install id used for bug-report correlation are retained.
2026-08-02 10:02:49 +02:00
b8a002a2ad refactor(shared): remove telemetry and crash reporting, keep bug reports
Delete the TelemetryRecorder, CrashReporter, PendingCrashStore and platform
crash hooks along with their models, JSON encoders and the diagnostics opt-in
preference. The DiagnosticsTransport interface is narrowed to sendBugReport, and
DiagnosticsCoordinator now only wires the bug-report service and install id.

Bug reporting, the breadcrumb buffer, log redaction and the diagnostics endpoint
config are kept. Regenerate localization after dropping the diagnostics_* keys.
2026-08-02 10:02:28 +02:00
232fb125d3 feat(config): shared app.properties for app-wide constants
Add a single source of truth (root app.properties) for public app-wide
constants, injected at build time on both platforms instead of hardcoding.

- Apple: generate-appconfig.sh -> Generated/AppConfig.swift (wired into
  `make apple-app-config`), consumed as AppConfig.privacyPolicyURL.
- KMP: generateAppConfig task -> AppConfig.kt (mirrors DiagnosticsBuildConfig),
  consumed as AppConfig.PRIVACY_POLICY_URL.

Replaces the stale hardcoded privacy-policy URL on both sides with
https://vnidrop.sudosy.fr/privacy/.

Also fix the Apple release core build: disable release LTO in build-core.sh
(Cargo forbids lto in a build-override) to avoid the proc-macro
"mis-aligned LINKEDIT string pool" corruption, so release archives are
compact instead of shipping the debug core.

Update the app icon.

Tests: shell test for the generator (escaping, missing/duplicate key),
plus XCTest and jvmTest asserting the generated value matches app.properties.
2026-08-01 19:56:07 +02:00
Hammed Abass
d52ac52cea Merge pull request #40 from sudosylabs/feat/macos-approval-modal-fix
fix(apple): show macOS approval modal + publish prebuilt core bundle
2026-07-31 17:07:40 +02:00
fe97c21c7a fix(apple): keep the snackbar above the approval overlay
The earlier approval-modal fix folded SnackbarHost and the approval modal into a
single OverlayLayer child; nested that way the approval host's full-bleed clear
layer covered the toast, so snackbars stopped appearing.

Split them: rename OverlayLayer to ApprovalLayer (approval modal only) and hoist
SnackbarHost to a top-most direct child of the root ZStack, observing the live
`graph.messages` directly. The toast now renders above the overlay again.
2026-07-31 12:46:12 +02:00
c670dda0a9 fix(apple): run the notification delegate on the main actor (iOS crash)
Tapping an approval notification while the app was backgrounded crashed on iOS
with "Call must be made on main thread". The UNUserNotificationCenterDelegate
methods are `async` and nonisolated, so their continuation resumes off the main
thread at the return point — where UIKit synchronously runs state-restoration /
snapshot work, tripping the main-thread assertion. (The empty iOS `didReceive`
body didn't matter; even an empty async method returns off-main.)

Isolate NotificationPresenter to `@MainActor` so the delegate returns on the main
thread. `@preconcurrency` on the UNUserNotificationCenterDelegate conformance is
required because those requirements are nonisolated with non-Sendable UN*
parameters, which strict concurrency won't otherwise let a main-actor type
witness. The macOS branch's now-redundant `await MainActor.run { … }` is dropped.
2026-07-31 12:46:05 +02:00
ff391f5502 build(apple): publish prebuilt core bundle in release assets
Bundle the compiled Apple core — vnidrop.xcframework plus the generated UniFFI
bindings (Vnidrop.swift, a source file that lives outside the xcframework) — into
VnidropCore-<version>.zip with a checksum, and attach it to the GitHub Release.
This lets a consumer (e.g. Xcode Cloud, later) use the prebuilt core instead of
installing Rust and running build-core.sh.

No duplicate builds: the release job compiles the core once (build-apple-dmg ->
build-core.sh release), links it into the signed DMG, and package-core.sh only
zips that same output. Package.swift is unchanged (still binaryTarget(path:)).

- apple/scripts/package-core.sh: stage xcframework + Vnidrop.swift and zip them
  with a sha256sum/shasum-compatible checksum sidecar (macOS-native).
- Makefile: package-apple-core target.
- apple-release.yml: run package-apple-core after the DMG and upload the zip +
  checksum in the macOS artifact.
- assemble-release.sh: verify the core zip's checksum, copy it into the final
  assets, and list it in release-manifest.json + SHA256SUMS (+ fixture update).
2026-07-31 11:40:34 +02:00
9079c81409 build(apple): pin ARCHS to arm64 project-wide
The Rust core's macOS slice (vnidrop.xcframework) is built aarch64-apple-darwin
only, so every target is Apple-Silicon-only — not just the Release-Direct build.
Hoist ARCHS: arm64 from the VniDropDirect target into the project-wide base
settings so no configuration attempts a universal link that would fail looking
for x86_64 symbols. Intel Macs are unsupported (EOL with macOS 28).
2026-07-31 11:20:36 +02:00
5424da855e fix(apple): show receiver-approval modal on macOS release builds
The approval modal never appeared for a macOS sender: the receiver request
reached the core and even fired its notification, but the modal stayed hidden.

Root cause was observation, not presentation. `RootView` derived `approvals`
and `messages` as `@ObservedObject` in `init` from a freshly built `AppGraph`.
`init` runs on every view re-creation and each run makes a throwaway graph, so
those observed objects were repointed to a dead `ApprovalCoordinator` that never
receives core events — while the persisted `@StateObject graph` (and the models
wired to it) kept the live one. Debug happened not to re-init the view, so it
stayed on the live instance; release re-inits it, exposing the bug.

Move the snackbar + approval modal into an `OverlayLayer` child view that takes
the coordinator/messages as `@ObservedObject` and is constructed in `body` from
the persisted `graph`, so the subscription is always against the live instances.

While here:
- Present the approval only after any open share/QR sheet has actually finished
  dismissing (macOS can't stack sheets), driven off the sheet's real
  `onDismiss` completion via a new `AdaptiveDrawer.onDismissed` hook and
  `SendModel.shareSheetsDismissed` — no wall-clock delay.
- Move the list-level share-sheet state (`shareTargetId`) into `SendModel` so the
  approval flow can dismiss every share surface centrally.
- Add a fallback: pending receiver rows in the Receivers panel now offer an
  Approve action (`SendModel.acceptReceiver`) alongside Refuse, for the case the
  modal didn't surface.
2026-07-31 11:19:28 +02:00
56d19014d4 chore(release): prepare 0.2.4 2026-07-31 04:59:36 +02:00
51bf0abba2 fix(release): configure Store CLI before settings 2026-07-31 04:51:31 +02:00
e0fb84ccb9 chore(release): prepare 0.2.3 2026-07-30 22:22:11 +02:00
30025a4ebf fix(release): download Play APK media 2026-07-30 22:19:40 +02:00
50e9a6c1cc chore(release): prepare 0.2.2 2026-07-30 21:54:54 +02:00
224a8e0e7a fix(release): enforce Apple hardened runtime 2026-07-30 21:27:34 +02:00
efacfab213 fix(release): expose Apple notarization failures 2026-07-30 21:03:30 +02:00
Hammed Abass
0ec7618ce8 Merge pull request #39 from sudosylabs/feat/release-pipeline-fixes
fix(release): repair Apple and Android builds
2026-07-30 20:30:28 +02:00
8b75423b7a fix(release): repair Apple and Android builds 2026-07-30 20:26:27 +02:00
Hammed Abass
7236933b76 Merge pull request #38 from sudosylabs/feat/release-0.2.1
chore(release): prepare 0.2.1
2026-07-30 19:43:19 +02:00
fc732e1b77 chore(release): prepare 0.2.1 2026-07-30 19:27:57 +02:00
Hammed Abass
d097c82f6a Merge pull request #37 from sudosylabs/feat/microsoft-store-publishing
ci: automate Microsoft Store updates
2026-07-30 19:25:15 +02:00
caaa9a472d ci: automate Microsoft Store updates 2026-07-30 19:12:50 +02:00
Hammed Abass
4ce124da7c Merge pull request #36 from sudosylabs/feat/automated-release-versions
feat(release): automate derived store versions
2026-07-30 17:58:18 +02:00
94a8b3481b feat(release): automate derived store versions 2026-07-30 17:40:00 +02:00
Hammed Abass
6d908d8dc3 Merge pull request #35 from sudosylabs/feat/release-pipeline
ci: add coordinated cross-platform release pipeline
2026-07-28 11:58:18 +02:00
c6655da7db refactor(version): derive Apple build numbers 2026-07-28 11:27:45 +02:00
2d7982bbb9 ci: add coordinated release pipeline 2026-07-28 11:09:53 +02:00
Hammed Abass
52d4102308 Merge pull request #34 from sudosylabs/feat/unified-versioning
feat(release): unify cross-platform versioning
2026-07-28 08:45:02 +02:00
407a0d2d60 feat(release): unify cross-platform versioning 2026-07-28 08:22:58 +02:00
Hammed Abass
fc1d27bf45 Merge pull request #33 from sudosylabs/feat/release-test-flight
feat(apple): stable iOS/macOS release + macOS direct-download channel
2026-07-27 16:25:55 +02:00
31ba3f40b2 test(shared): resolve UI copy from resources 2026-07-25 19:46:34 +02:00
125 changed files with 4170 additions and 3576 deletions

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

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

View File

@@ -1,27 +1,20 @@
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 IDsigned, notarized VniDrop-<version>.dmg,
# - a Sparkle appcast.xml (both attached to the GitHub Release), and
# - an updated Homebrew cask pushed to the sudosylabs/homebrew-vnidrop tap.
# - a Sparkle appcast.xml.
#
# The central release workflow publishes these artifacts and updates Homebrew.
#
# The App Store / TestFlight build is NOT produced here — that goes through Xcode
# Organizer / App Store Connect. This workflow only covers direct distribution.
#
# Trigger: push a tag vMAJOR.MINOR.PATCH (must point at a commit on master), or
# run manually with an explicit version (produces artifacts, no Release).
# Called by the central tag-release workflow, or run manually to validate the
# signed/notarized direct-download artifact.
on:
push:
tags:
- "v*.*.*"
workflow_call:
workflow_dispatch:
inputs:
version:
description: Release version in MAJOR.MINOR.PATCH form
required: true
default: "0.1.0"
type: string
permissions:
contents: read
@@ -39,8 +32,6 @@ jobs:
name: Build & notarize DMG
runs-on: macos-latest
timeout-minutes: 90
permissions:
contents: write
outputs:
version: ${{ steps.version.outputs.app }}
steps:
@@ -58,17 +49,11 @@ jobs:
exit 1
fi
- name: Resolve version
- name: Resolve canonical version
id: version
env:
REQUESTED_VERSION: ${{ inputs.version || '' }}
run: |
if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
version="${GITHUB_REF_NAME#v}"
else
version="$REQUESTED_VERSION"
fi
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "bad version '$version'" >&2; exit 1; }
packaging/version/resolve-version.sh verify >/dev/null
version="$(packaging/version/resolve-version.sh product)"
echo "app=$version" >> "$GITHUB_OUTPUT"
- name: Select Xcode
@@ -94,7 +79,7 @@ jobs:
run: brew install xcodegen swiftlint create-dmg
- name: Install Bun
uses: oven-sh/setup-bun@v2
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- name: Download Sparkle tools
# 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"
- 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
env:
RELEASE_REPO: ${{ github.repository }}
run: apple/scripts/generate-appcast.sh "${{ steps.version.outputs.app }}"
run: apple/scripts/generate-appcast.sh
- name: Upload artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -160,71 +160,9 @@ jobs:
name: vnidrop-${{ steps.version.outputs.app }}-macos-dmg
path: |
apple/dist/VniDrop-*.dmg
apple/dist/VniDrop-*.build-info.json
apple/dist/appcast.xml
apple/dist/VnidropCore-*.zip
apple/dist/VnidropCore-*.zip.sha256
if-no-files-found: error
retention-days: 14
- 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

View File

@@ -4,6 +4,8 @@ on:
pull_request:
paths:
- "apple/**"
- "version.properties"
- "packaging/version/**"
- "crates/vnidrop/**"
- "crates/uniffi-bindgen/**"
- "Cargo.toml"
@@ -18,6 +20,8 @@ on:
- master
paths:
- "apple/**"
- "version.properties"
- "packaging/version/**"
- "crates/vnidrop/**"
- "crates/uniffi-bindgen/**"
- "Cargo.toml"

View File

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

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

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

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

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

View File

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

View File

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

View File

@@ -5,6 +5,8 @@ on:
paths:
- ".github/workflows/windows-store.yml"
- "packaging/windows/**"
- "packaging/version/**"
- "version.properties"
- "assets/windows/**"
- "desktopApp/**"
- "shared/**"
@@ -17,16 +19,8 @@ on:
- "gradle/**"
- "gradlew"
- "gradlew.bat"
push:
tags:
- "v*.*.*"
workflow_call:
workflow_dispatch:
inputs:
version:
description: Release version in MAJOR.MINOR.PATCH form
required: true
default: "1.0.0"
type: string
permissions:
contents: read
@@ -77,37 +71,13 @@ jobs:
restore-keys: |
windows-x64-cargo-1.91.0-
- name: Resolve Store version
- name: Resolve canonical version
id: version
shell: pwsh
env:
REQUESTED_VERSION: ${{ inputs.version || '1.0.0' }}
run: |
$version = $env:REQUESTED_VERSION
if ($env:GITHUB_REF_TYPE -eq "tag") {
if ($env:GITHUB_REF_NAME -notmatch "^v[0-9]+\.[0-9]+\.[0-9]+$") {
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
$version = .\packaging\version\resolve-version.ps1 -Field Json -VerifyTag | ConvertFrom-Json
"app=$($version.productVersion)" >> $env:GITHUB_OUTPUT
"package=$($version.windowsPackageVersion)" >> $env:GITHUB_OUTPUT
- name: Test and build release app image
shell: pwsh
@@ -115,7 +85,6 @@ jobs:
$arguments = @(
":shared:jvmTest"
":desktopApp:createReleaseDistributable"
"-Pvnidrop.version=${{ steps.version.outputs.app }}"
"-Pvnidrop.desktop.rustVariant=release"
"-Pvnidrop.diagnostics.included=false"
"--no-daemon"
@@ -131,7 +100,6 @@ jobs:
shell: pwsh
run: |
$arguments = @{
Version = "${{ steps.version.outputs.app }}"
AppImage = ".\desktopApp\build\compose\binaries\main-release\app\VniDrop"
OutputDirectory = ".\build\release\windows"
}

1
.gitignore vendored
View File

@@ -25,3 +25,4 @@ config.override.mk
output/
.screenshots
apple/RELEASE-MACOS.md
apple/Generated/*.xcconfig

View File

@@ -12,14 +12,14 @@ include $(ROOT)/make/release.mk
.PHONY: format test check check-rust audit-rust test-rust test-rust-all
.PHONY: 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: apple-core apple-project open-apple-project open-apple build-apple-macos build-apple-ios check-apple
.PHONY: check-localization localization localization-migrate
.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: 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
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}'
@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_CONFIGURATION=...' 'Xcode configuration (default: $(APPLE_CONFIGURATION))'
@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.
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.
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; }
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
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.
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).
cd $(ROOT) && apple/scripts/build-dmg.sh $(VERSION)
build-apple-dmg: localization ## Build the signed/notarized direct-download .dmg (see apple/RELEASE-MACOS.md for required env).
cd $(ROOT) && apple/scripts/build-dmg.sh
package-apple-core: ## Zip the prebuilt core (xcframework + bindings) + checksum into apple/dist (build the core first).
cd $(ROOT) && apple/scripts/package-core.sh
open-apple: build-apple-macos ## Build and launch the native macOS app.
@test -d "$(APPLE_DERIVED_DATA)/Build/Products/$(APPLE_CONFIGURATION)/VniDrop.app" || { printf 'Built macOS app was not found.\n' >&2; exit 1; }

View File

@@ -127,18 +127,18 @@ people, especially when using **Anyone with this transfer**.
- Native SwiftUI apps on iOS, iPadOS, and macOS; Compose apps on Android,
Windows, and Linux
- Strict custom HTTPS relay profiles with safe apply and rollback
- Opt-in diagnostics with transfer contents, invitations, and file paths
excluded
- Optional user-submitted bug reports with transfer contents, invitations, and
file paths excluded
## Privacy by design
- **No hosted transfer copy.** VniDrop does not upload file contents to its
diagnostics service or a VniDrop storage bucket.
- **No hosted transfer copy.** VniDrop does not upload file contents to a bug-report
service or a VniDrop storage bucket.
- **Encrypted in transit.** Iroh connections are authenticated and encrypted
end to end, including when a relay is needed.
- **Local control.** Transfer history and sharing state stay on the device.
- **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
removes access immediately.

View File

@@ -24,7 +24,7 @@ abstract class VerifyVnidropLibrariesTask : DefaultTask() {
archive.getEntry(path)?.size?.takeIf { it > 0L } == null
}
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)
}
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 {
compilerOptions {
jvmTarget = JvmTarget.JVM_11
@@ -55,12 +71,26 @@ android {
namespace = "com.vnidrop.app"
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 {
applicationId = "com.vnidrop.app"
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 1
versionName = "1.0"
versionCode = androidVersionCode
versionName = appVersion
}
packaging {
resources {
@@ -76,6 +106,7 @@ android {
buildTypes {
getByName("release") {
isMinifyEnabled = false
signingConfig = signingConfigs.findByName("release")
}
}
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/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",
)
}
if (name == "mergeReleaseJniLibFolders" || name == "mergeReleaseNativeLibs") {
dependsOn(
":shared:copyAndroidAndroidArm64Release",
":shared:copyAndroidAndroidX64Release",
)
}
}
val verifyDebugVnidropLibraries = tasks.register<VerifyVnidropLibrariesTask>("verifyDebugVnidropLibraries") {

View File

@@ -1,5 +1,7 @@
<?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.ACCESS_NETWORK_STATE"/>
@@ -38,7 +40,7 @@
<data android:mimeType="application/vnd.vnidrop.transfer"/>
</intent-filter>
<!-- Fallback: .vnd files often arrive as octet-stream / unknown MIME. -->
<intent-filter>
<intent-filter tools:ignore="AppLinkUrlError">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>

4
app.properties Normal file
View 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/

View File

@@ -40,6 +40,12 @@ make build-apple-ios # unsigned iOS simulator app
make check-apple # iOS simulator tests
```
`make apple-project` also generates ignored Store and Direct version xcconfig
files. Their `CURRENT_PROJECT_VERSION` values come from the central version
resolver as UTC `YYYYMMDD.HHMM.SS` build identifiers. Regenerate the project
before creating another App Store archive so it receives a fresh build number;
direct DMG builds refresh their own value automatically.
### macOS shipping channels
The macOS app ships through two targets that build identical sources:
@@ -52,7 +58,7 @@ The macOS app ships through two targets that build identical sources:
```bash
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
@@ -108,7 +114,7 @@ The Rust core (iroh network stack) links `SystemConfiguration`, `Security`, and
Screens mirror the Compose UI in `shared/`. Two deliberate simplifications:
- Empty-state Lottie animations are rendered as SF Symbols (no `lottie-ios`
dependency); swap in `lottie-ios` if exact-parity animation is required.
- The full diagnostics/telemetry stack (`diagnostics/*`) is stubbed behind
`BugReportService` / `DiagnosticsBuildConfig` and lands in a later phase; the UI
hides the diagnostics toggle when not compiled in.
- Bug reporting is stubbed behind `BugReportService` (`NoopBugReportService`) and
a real transport lands in a later phase. There is no telemetry or crash
auto-reporting.
```

View 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)")
}
}

View File

@@ -15,8 +15,7 @@ final class SettingsModelTests: XCTestCase {
preferences: preferences,
notifications: LocalNotificationService(),
messages: UiMessageController(),
bugReports: NoopBugReportService(),
diagnosticsIncluded: false
bugReports: NoopBugReportService()
)
}

View File

@@ -24,8 +24,7 @@ final class AppGraph: ObservableObject {
fallback: AppPreferencesDefaults(
username: dependencies.environment.defaultUsername,
receiveFolder: dependencies.fileSystemService.defaultReceiveFolder(),
themeMode: .system,
diagnosticsEnabled: false
themeMode: .system
)
)
self.approvalCoordinator = ApprovalCoordinator(

View File

@@ -9,15 +9,9 @@ struct RootView: View {
@StateObject private var sendModel: SendModel
@StateObject private var receiveModel: ReceiveModel
@StateObject private var settingsModel: SettingsModel
@ObservedObject private var messages: UiMessageController
@ObservedObject private var approvals: ApprovalCoordinator
@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) {
let graph = AppGraph(dependencies: dependencies)
_graph = StateObject(wrappedValue: graph)
@@ -50,8 +44,6 @@ struct RootView: View {
messages: graph.messages,
bugReports: NoopBugReportService()
))
messages = graph.messages
approvals = graph.approvalCoordinator
}
var body: some View {
@@ -60,13 +52,17 @@ struct RootView: View {
let isDark = resolveDarkTheme(appModel.themeMode, systemDark: systemDark)
ZStack {
navigation(windowClass: windowClass)
SnackbarHost(controller: messages)
ApprovalModalHost(
isPresented: $showApproval,
state: approvals.state,
onAccept: approvals.accept,
onRefuse: approvals.refuse
// Observe the coordinator/messages from the *persisted* `graph`
// StateObject. Deriving them in `init` bound the view to a throwaway
// AppGraph rebuilt on every re-init, whose coordinator never receives
// core events so the approval modal never appeared.
ApprovalLayer(
approvals: graph.approvalCoordinator,
sendModel: sendModel
)
// Top-most so the toast is never covered by the approval overlay's
// full-bleed clear layer. Observes the live `graph.messages` directly.
SnackbarHost(controller: graph.messages)
}
.overlay {
// A small, unobtrusive indicator while the core finishes its async
@@ -103,27 +99,6 @@ struct RootView: View {
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)
// macOS keeps `scenePhase == .active` even when the app loses focus, so
// 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.
private struct CoreStartingOverlay: View {
var body: some View {

View File

@@ -120,7 +120,6 @@ struct AppPreferences: Equatable {
var username: String
var receiveFolder: ReceiveFolder
var themeMode: ThemeMode
var diagnosticsEnabled: Bool
var diagnosticsInstallId: String
var relayConfiguration: RelayConfiguration
}
@@ -129,7 +128,6 @@ struct AppPreferencesDefaults {
let username: String
let receiveFolder: ReceiveFolder
let themeMode: ThemeMode
var diagnosticsEnabled: Bool = false
}
@MainActor
@@ -145,7 +143,6 @@ final class AppPreferencesRepository: ObservableObject {
static let receiveFolderValue = "receive_folder_value"
static let receiveFolderDisplayName = "receive_folder_display_name"
static let themeMode = "theme_mode"
static let diagnosticsEnabled = "diagnostics_enabled"
static let diagnosticsInstallId = "diagnostics_install_id"
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 folder = resolveReceiveFolder(defaults, fallback: fallback.receiveFolder)
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) ?? ""
return AppPreferences(
username: username,
receiveFolder: folder,
themeMode: themeMode,
diagnosticsEnabled: diagnostics,
diagnosticsInstallId: installId,
relayConfiguration: resolveRelayConfiguration(defaults)
)
@@ -219,11 +214,6 @@ final class AppPreferencesRepository: ObservableObject {
reload()
}
func setDiagnosticsEnabled(_ enabled: Bool) {
defaults.set(enabled, forKey: Key.diagnosticsEnabled)
reload()
}
func setRelayConfiguration(_ configuration: RelayConfiguration) {
guard let encoded = try? JSONEncoder().encode(configuration) else { return }
defaults.set(encoded, forKey: Key.relayConfiguration)

View File

@@ -19,7 +19,19 @@ struct LocalNotification {
/// 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,
/// 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(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification
@@ -36,14 +48,12 @@ private final class NotificationPresenter: NSObject, UNUserNotificationCenterDel
didReceive response: UNNotificationResponse
) async {
#if os(macOS)
await MainActor.run {
NSApp.activate(ignoringOtherApps: true)
// Reopen/focus the single main window (activation triggers SwiftUI's
// reopen handling when it was closed).
for window in NSApp.windows where window.canBecomeMain {
window.makeKeyAndOrderFront(nil)
break
}
NSApp.activate(ignoringOtherApps: true)
// Reopen/focus the single main window (activation triggers SwiftUI's
// reopen handling when it was closed).
for window in NSApp.windows where window.canBecomeMain {
window.makeKeyAndOrderFront(nil)
break
}
#endif
}

View File

@@ -25,6 +25,11 @@ struct SendState: Equatable {
var selectedTransferId: UInt64?
var transferThumbnails: [UInt64: Data] = [:]
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 isLoadingReceivers = false
var isDeleteConfirmationOpen = false
@@ -56,6 +61,18 @@ final class SendModel: ObservableObject {
private let messages: UiMessageController
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(
repository: CoreGateway,
fileSystemService: FileSystemService,
@@ -201,6 +218,17 @@ final class SendModel: ObservableObject {
}
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 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
/// receivers that are still pending or accepted.
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 {
let result = await repository.respondReceiverRequest(requestId: requestId, accepted: false, reason: nil)
let result = await repository.respondReceiverRequest(requestId: requestId, accepted: accepted, reason: nil)
switch result {
case .success:
if let transferId = state.selectedTransferId { refreshReceivers(transferId) }

View File

@@ -7,14 +7,18 @@ struct SendScreen: View {
@ObservedObject var model: SendModel
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.
@State private var deleteTarget: Transfer?
private var outgoing: [Transfer] {
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? {
guard let id = model.state.selectedTransferId else { return nil }
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
// share panel over the list without navigating into the transfer detail.
.adaptiveDrawer(
isPresented: Binding(get: { shareTarget != nil }, set: { if !$0 { shareTarget = nil } }),
isPresented: Binding(get: { shareTarget != nil }, set: { if !$0 { model.closeShareTarget() } }),
windowClass: windowClass,
onDismiss: { shareTarget = nil }
onDismiss: model.closeShareTarget,
onDismissed: model.shareSheetDidDismiss
) {
if let shareTarget {
TransferSharePanel(model: model, transfer: shareTarget)
@@ -92,7 +97,8 @@ struct SendScreen: View {
.adaptiveDrawer(
isPresented: Binding(get: { model.state.detailPanel != nil }, set: { _ in }),
windowClass: windowClass,
onDismiss: model.closeDetailPanel
onDismiss: model.closeDetailPanel,
onDismissed: model.shareSheetDidDismiss
) {
if let panel = model.state.detailPanel {
DetailPanelContent(model: model, transfer: transfer, panel: panel)
@@ -127,7 +133,7 @@ struct SendScreen: View {
.contextMenu {
if transfer.ticket != nil {
Button {
shareTarget = transfer
model.openShareTarget(transfer.transferId)
} label: {
Label(String(localized: L10n.Transfer.shareTitle), systemSymbol: .squareAndArrowUp)
}

View File

@@ -142,7 +142,8 @@ struct DetailPanelContent: View {
loading: model.state.isLoadingReceivers,
events: model.coreState.events,
transferTotalSize: transfer.totalSize,
onCancel: model.cancelReceiver
onCancel: model.cancelReceiver,
onAccept: model.acceptReceiver
)
case .share:
TransferSharePanel(model: model, transfer: transfer)
@@ -193,6 +194,7 @@ struct ReceiverHistoryPanel: View {
let events: [CoreEventModel]
let transferTotalSize: UInt64
let onCancel: (String) -> Void
let onAccept: (String) -> Void
var body: some View {
PanelContainer(title: String(localized: L10n.Transfer.receiversTitle)) {
@@ -203,7 +205,12 @@ struct ReceiverHistoryPanel: View {
} else {
ForEach(Array(receivers.enumerated()), id: \.element.id) { index, receiver in
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 sendProgress: TransferProgress?
let onCancel: (String) -> Void
let onAccept: (String) -> Void
/// Only pending requests can be cancelled per-receiver: the core rejects a
/// negative response to an already-accepted request ("...not approved, or it
@@ -257,14 +265,27 @@ private struct ReceiverRow: View {
}
.frame(maxWidth: .infinity, alignment: .leading)
if isCancelable {
Button(role: .destructive) {
onCancel(receiver.id)
} label: {
Text(String(localized: L10n.Button.refuse))
.font(VniType.bodySmall)
VStack(alignment: .trailing, spacing: 8) {
Button(role: .destructive) {
onCancel(receiver.id)
} label: {
Text(String(localized: L10n.Button.refuse))
.font(VniType.bodySmall)
}
.buttonStyle(.borderless)
.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)
}
.buttonStyle(.borderless)
.tint(.red)
}
}
.frame(maxWidth: .infinity, alignment: .leading)

View File

@@ -24,8 +24,3 @@ struct NoopBugReportService: BugReportService {
}
func previewLogBytes() async -> Int { 0 }
}
/// Whether the diagnostics stack is compiled in (mirrors DiagnosticsBuildConfig).
enum DiagnosticsBuildConfig {
static let included = false
}

View File

@@ -44,7 +44,6 @@ struct SettingsState: Equatable {
var supportsCustomReceiveFolders = true
var themeMode: ThemeMode = .system
var notificationPermission: NotificationPermission = .notDetermined
var diagnosticsEnabled = false
var relayMode: RelayPreferenceMode = .automatic
var relayURLs: [String] = []
var relayValidationError: RelayConfigurationValidationError?
@@ -76,7 +75,7 @@ struct SettingsState: Equatable {
&& lhs.supportsCustomReceiveFolders == rhs.supportsCustomReceiveFolders
&& lhs.themeMode == rhs.themeMode
&& 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.relayValidationError == rhs.relayValidationError
&& lhs.relayConfigurationIsDirty == rhs.relayConfigurationIsDirty
@@ -111,7 +110,6 @@ final class SettingsModel: ObservableObject {
private let notifications: LocalNotificationService
private let messages: UiMessageController
private let bugReports: BugReportService
private let diagnosticsIncluded: Bool
private var usernamePersistTask: Task<Void, Never>?
private var hasLocalUsernameDraft = false
@@ -126,8 +124,7 @@ final class SettingsModel: ObservableObject {
preferences: AppPreferencesRepository,
notifications: LocalNotificationService,
messages: UiMessageController,
bugReports: BugReportService,
diagnosticsIncluded: Bool = DiagnosticsBuildConfig.included
bugReports: BugReportService
) {
self.environment = environment
self.deviceInfoProvider = deviceInfoProvider
@@ -137,7 +134,6 @@ final class SettingsModel: ObservableObject {
self.notifications = notifications
self.messages = messages
self.bugReports = bugReports
self.diagnosticsIncluded = diagnosticsIncluded
self.state = SettingsState(
supportsCustomReceiveFolders: fileSystemService.supportsCustomReceiveFolders,
appVersion: environment.appVersion
@@ -151,7 +147,6 @@ final class SettingsModel: ObservableObject {
self.state.username = self.hasLocalUsernameDraft ? self.state.username : prefs.username
self.state.receiveFolder = folder
self.state.themeMode = prefs.themeMode
self.state.diagnosticsEnabled = prefs.diagnosticsEnabled
if !self.hasRelayConfigurationDraft {
self.state.relayMode = prefs.relayConfiguration.mode
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
func setRelayMode(_ mode: RelayPreferenceMode) {

View File

@@ -375,7 +375,7 @@ struct StorageSettings: View {
struct AboutSettings: View {
@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 {
Section {
@@ -415,17 +415,6 @@ struct AboutSettings: View {
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))
}
}
}
}
}

View File

@@ -6,7 +6,7 @@ import UIKit
@MainActor
func makeAppDependencies(externalInvitations: ExternalInvitationController) -> AppDependencies {
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(
name: "\(device.systemName) \(device.systemVersion)",
appVersion: version,

View File

@@ -5,7 +5,7 @@ import AppKit
/// Builds the macOS dependency graph, mirroring `rememberIosAppDependencies`.
@MainActor
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 env = PlatformEnvironment(
name: "macOS " + ProcessInfo.processInfo.operatingSystemVersionString,

View File

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

View File

@@ -7,11 +7,16 @@ struct AdaptiveDrawer<DrawerContent: View>: ViewModifier {
@Binding var isPresented: Bool
let windowClass: WindowClass
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
func body(content: Content) -> some View {
content.sheet(
isPresented: Binding(get: { isPresented }, set: { if !$0 { onDismiss() } })
isPresented: Binding(get: { isPresented }, set: { if !$0 { onDismiss() } }),
onDismiss: onDismissed
) {
SheetChrome(onClose: onDismiss) { drawerContent() }
.modifier(PhoneDetents(enabled: windowClass == .phone))
@@ -56,11 +61,12 @@ extension View {
isPresented: Binding<Bool>,
windowClass: WindowClass,
onDismiss: @escaping () -> Void,
onDismissed: (() -> Void)? = nil,
@ViewBuilder content: @escaping () -> DrawerContent
) -> some View {
modifier(AdaptiveDrawer(
isPresented: isPresented, windowClass: windowClass,
onDismiss: onDismiss, drawerContent: content
onDismiss: onDismiss, onDismissed: onDismissed, drawerContent: content
))
}
}

View File

@@ -1,9 +1,11 @@
# XcodeGen spec for the native SwiftUI VniDrop app (iOS/iPadOS/macOS).
# 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
# - Localization: (cd localization && bun run src/cli.ts generate)
# -> VniDrop/Resources/Localizable.xcstrings, VniDrop/Generated/L10n.swift
# - Versions: packaging/version/generate-apple-xcconfig.sh all
# -> Generated/StoreVersion.xcconfig, Generated/DirectVersion.xcconfig
name: VniDrop
options:
bundleIdPrefix: com.vnidrop
@@ -24,6 +26,10 @@ configs:
# Project-wide build settings (applied to every target/config).
settings:
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.
DEAD_CODE_STRIPPING: YES
# Flag user-facing strings that aren't localized (the app ships 9 languages).
@@ -50,10 +56,6 @@ packages:
targetTemplates:
AppBase:
type: application
configFiles:
Debug: Signing.xcconfig
Release: Signing.xcconfig
Release-Direct: Signing.xcconfig
sources:
- path: VniDrop
excludes:
@@ -65,11 +67,7 @@ targetTemplates:
base:
PRODUCT_NAME: VniDrop
PRODUCT_BUNDLE_IDENTIFIER: com.vnidrop.app
MARKETING_VERSION: "0.1.0"
# 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"
MARKETING_VERSION: "$(PRODUCT_VERSION)"
GENERATE_INFOPLIST_FILE: NO
INFOPLIST_FILE: VniDrop/Resources/Info.plist
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements
@@ -111,29 +109,16 @@ targetTemplates:
echo "error: SwiftLint not installed — run 'brew install swiftlint'"
exit 1
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:
# App Store / TestFlight target. iOS + macOS, sandboxed, no self-updater.
VniDrop:
templates: [AppBase]
supportedDestinations: [iOS, macOS]
configFiles:
Debug: Generated/StoreVersion.xcconfig
Release: Generated/StoreVersion.xcconfig
Release-Direct: Generated/StoreVersion.xcconfig
# Direct-download macOS target: Developer ID signed, notarized, ships in a .dmg
# and self-updates via Sparkle. DIRECT_DISTRIBUTION gates all Sparkle code so the
@@ -141,6 +126,10 @@ targets:
VniDropDirect:
templates: [AppBase]
supportedDestinations: [macOS]
configFiles:
Debug: Generated/DirectVersion.xcconfig
Release: Generated/DirectVersion.xcconfig
Release-Direct: Generated/DirectVersion.xcconfig
settings:
base:
SWIFT_ACTIVE_COMPILATION_CONDITIONS: "$(inherited) DIRECT_DISTRIBUTION"
@@ -150,11 +139,6 @@ targets:
# provisioning profile, which direct distribution avoids. (App Store target
# keeps VniDrop.entitlements with the sandbox.)
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDropDirect.entitlements
# The Rust core's macOS slice (vnidrop.xcframework) is arm64-only
# (build-core.sh builds aarch64-apple-darwin only), so the direct build is
# Apple-Silicon-only. Pin ARCHS so the Release-Direct (universal-by-default)
# link doesn't fail looking for x86_64 symbols.
ARCHS: arm64
dependencies:
- package: Sparkle

View File

@@ -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.
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"
SIM_ARM_TARGET="aarch64-apple-ios-sim"
SIM_X64_TARGET="x86_64-apple-ios"

View File

@@ -7,8 +7,7 @@
# This is the direct-distribution counterpart to the App Store archive flow; it
# never touches the App Store `VniDrop` target. The Rust crate is not modified.
#
# Usage: apple/scripts/build-dmg.sh [version]
# version MAJOR.MINOR.PATCH; defaults to MARKETING_VERSION / the git tag.
# Usage: apple/scripts/build-dmg.sh
#
# Environment:
# DEVELOPER_ID_APP Codesign identity, e.g. "Developer ID Application: … (TEAMID)".
@@ -29,29 +28,14 @@ PROJECT="$APPLE_DIR/VniDrop.xcodeproj"
SCHEME="VniDropDirect"
CONFIG="Release-Direct"
APP_NAME="VniDrop"
VERSION_RESOLVER="$REPO_ROOT/packaging/version/resolve-version.sh"
VERSION_CONFIG_GENERATOR="$REPO_ROOT/packaging/version/generate-apple-xcconfig.sh"
# --- Resolve version (arg > git tag > project MARKETING_VERSION) -------------
resolve_version() {
local v="${1:-}"
if [ -z "$v" ] && [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
v="${GITHUB_REF_NAME#v}"
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"
VERSION="$("$VERSION_RESOLVER" product)"
export VNIDROP_BUILD_TIME_UTC="${VNIDROP_BUILD_TIME_UTC:-$(date -u +%Y%m%d%H%M%S)}"
BUILD_NUMBER="$("$VERSION_RESOLVER" apple-direct-build)"
"$VERSION_RESOLVER" verify >/dev/null
BUILD_METADATA="$DIST_DIR/$APP_NAME-$VERSION.build-info.json"
# --- Resolve signing identity ------------------------------------------------
if [ -z "${DEVELOPER_ID_APP:-}" ]; then
@@ -76,6 +60,7 @@ echo " team: ${DEVELOPMENT_TEAM:-<unknown>}"
echo "==> Building Rust core (release)"
CARGO_PROFILE_RELEASE_LTO=false "$SCRIPT_DIR/build-core.sh" release
echo "==> Regenerating Xcode project"
"$VERSION_CONFIG_GENERATOR" all
( cd "$APPLE_DIR" && xcodegen generate >/dev/null )
rm -rf "$BUILD_DIR" && mkdir -p "$BUILD_DIR" "$DIST_DIR"
@@ -107,6 +92,24 @@ xcodebuild -exportArchive \
-exportOptionsPlist "$EXPORT_OPTS"
APP="$EXPORT_DIR/$APP_NAME.app"
[ -d "$APP" ] || { echo "error: export failed" >&2; exit 1; }
ACTUAL_VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' \
"$APP/Contents/Info.plist")"
ACTUAL_BUILD="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' \
"$APP/Contents/Info.plist")"
[ "$ACTUAL_VERSION" = "$VERSION" ] || {
echo "error: exported app version $ACTUAL_VERSION does not match $VERSION" >&2
exit 1
}
[ "$ACTUAL_BUILD" = "$BUILD_NUMBER" ] || {
echo "error: exported app build $ACTUAL_BUILD does not match $BUILD_NUMBER" >&2
exit 1
}
echo "==> Enforcing hardened-runtime signature"
"$SCRIPT_DIR/sign-exported-app.sh" \
"$APP" \
"$DEVELOPER_ID_APP" \
"$APPLE_DIR/VniDrop/Resources/VniDropDirect.entitlements"
# --- Build the DMG -----------------------------------------------------------
DMG="$DIST_DIR/$APP_NAME-$VERSION.dmg"
@@ -140,7 +143,8 @@ codesign --force --sign "$DEVELOPER_ID_APP" --timestamp "$DMG"
# --- Notarize + staple -------------------------------------------------------
if [ -n "${NOTARY_PROFILE:-}" ]; then
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"
xcrun stapler staple "$DMG"
xcrun stapler validate "$DMG"
@@ -152,7 +156,18 @@ else
fi
SIZE="$(stat -f%z "$DMG")"
jq -n \
--arg productVersion "$VERSION" \
--arg directBuildNumber "$BUILD_NUMBER" \
--arg artifact "$(basename "$DMG")" \
'{
productVersion: $productVersion,
directBuildNumber: $directBuildNumber,
distribution: "direct",
artifact: $artifact
}' > "$BUILD_METADATA"
echo "==> Done."
echo " dmg: $DMG"
echo " version: $VERSION"
echo " build: $BUILD_NUMBER"
echo " size: $SIZE bytes"

View File

@@ -9,7 +9,7 @@
# keychain). The resulting appcast.xml is uploaded as a release asset; the app's
# SUFeedURL (/releases/latest/download/appcast.xml) always resolves to the newest.
#
# Usage: apple/scripts/generate-appcast.sh [version]
# Usage: apple/scripts/generate-appcast.sh
#
# Environment:
# 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"
DIST_DIR="${DIST_DIR:-$APPLE_DIR/dist}"
RELEASE_REPO="${RELEASE_REPO:-sudosylabs/vnidrop}"
VERSION_RESOLVER="$REPO_ROOT/packaging/version/resolve-version.sh"
VERSION="${1:-}"
if [ -z "$VERSION" ] && [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
VERSION="${GITHUB_REF_NAME#v}"
fi
[ -n "$VERSION" ] || { echo "error: version required (arg or tag)" >&2; exit 1; }
VERSION="$("$VERSION_RESOLVER" product)"
"$VERSION_RESOLVER" verify >/dev/null
# Enclosure URLs resolve to the specific release's assets.
DOWNLOAD_PREFIX="https://github.com/$RELEASE_REPO/releases/download/v$VERSION"

View 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
View File

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

72
apple/scripts/package-core.sh Executable file
View 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"

View File

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

View File

@@ -0,0 +1,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'

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
import java.util.Properties
plugins {
// this is necessary to avoid the plugins to be loaded multiple times
// in each subproject's classloader
@@ -13,3 +15,67 @@ plugins {
alias(libs.plugins.kotlinJvm) 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
View 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
View 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"

View File

@@ -1,5 +1,5 @@
# Default command configuration. Override locally in the ignored
# config.override.mk or on the command line (for example: make package-deb VERSION=1.2.0).
# Default command configuration. Override local tool paths in the ignored
# config.override.mk or on the command line.
ifeq ($(OS),Windows_NT)
HOST_OS := windows
@@ -24,7 +24,7 @@ XCODEGEN ?= xcodegen
OPEN ?= open
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_CONFIGURATION ?= Debug
APPLE_DESTINATION ?=

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

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

View File

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

View File

@@ -6,19 +6,7 @@ plugins {
alias(libs.plugins.composeCompiler)
}
val appVersion = providers.gradleProperty("vnidrop.version").get()
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"
}
val appVersion = rootProject.extra["vnidrop.productVersion"] as String
dependencies {
implementation(projects.shared)

View File

@@ -3,14 +3,14 @@ import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Privacy policy",
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 = [
["scope", "Scope"],
["transfers", "Transfers"],
["local-data", "Local data"],
["diagnostics", "Diagnostics"],
["bug-reports", "Bug reports"],
["website", "Website"],
["permissions", "Permissions"],
["providers", "Service providers"],
@@ -29,9 +29,9 @@ export default function PrivacyPage() {
<h1>Privacy Policy</h1>
<p>
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 className="privacy-meta">Effective July 16, 2026 · Version 1.1</p>
<p className="privacy-meta">Effective August 2, 2026 · Version 1.2</p>
</div>
</section>
@@ -56,7 +56,7 @@ export default function PrivacyPage() {
<p>
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.
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>
</div>
@@ -64,7 +64,7 @@ export default function PrivacyPage() {
<h2>Scope and who VniDrop means</h2>
<p>
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, VniDrops data controller is the
individual publisher named in the applicable app-store listing. In this policy,
VniDrop, we, and us also include the maintainers acting on that publishers
@@ -72,7 +72,7 @@ export default function PrivacyPage() {
</p>
<p>
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.
</p>
</section>
@@ -117,9 +117,9 @@ export default function PrivacyPage() {
<ul>
<li>device identity and networking keys used to establish secure connections;</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>an anonymous installation identifier used only for diagnostics correlation.</li>
<li>an anonymous installation identifier used only for bug-report correlation.</li>
</ul>
<p>
This information remains until you remove the relevant history, stop or delete a
@@ -129,33 +129,27 @@ export default function PrivacyPage() {
</p>
</section>
<section id="diagnostics" className="policy-section">
<h2>Optional diagnostics and bug reports</h2>
<h3>Automatic product diagnostics</h3>
<section id="bug-reports" className="policy-section">
<h2>Optional bug reports</h2>
<p>
Official releases indicate in the app settings whether automatic product
diagnostics are included. When included, automatic usage events and crash reports
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.
VniDrop has no automatic telemetry, usage analytics, or crash auto-reporting.
Nothing is sent to a bug-report service unless you explicitly submit a report.
</p>
<h3>User-submitted bug reports</h3>
<p>
A bug report is separate from the diagnostics toggle and is sent only when you press
submit. It can contain what you say happened, what you expected, reproduction steps,
an optional contact email, app and platform versions, an anonymous installation ID,
device name and model, operating system, network and battery information, recent
breadcrumbs, and optional recent logs. You can exclude logs before submitting.
A bug report is sent only when you press submit. It can contain what you say
happened, what you expected, reproduction steps, an optional contact email, app and
platform versions, an anonymous installation ID, device name and model, operating
system, network and battery information, and optional recent logs. You can exclude
logs before submitting.
</p>
<h3>Data deliberately excluded</h3>
<p>
Automatic diagnostics are designed to exclude transfer contents, invitations, and
file paths. Before diagnostic text or optional logs are sent, VniDrop applies rules
intended to redact invitation tokens, endpoint identifiers, absolute paths, file and
content URIs, and platform document identifiers. No redaction system is perfect, so
review anything you type into a bug report and avoid including secrets.
Bug reports are designed to exclude transfer contents, invitations, and file paths.
Before optional logs are sent, VniDrop applies rules intended to redact invitation
tokens, endpoint identifiers, absolute paths, file and content URIs, and platform
document identifiers. No redaction system is perfect, so review anything you type
into a bug report and avoid including secrets.
</p>
</section>
@@ -223,7 +217,7 @@ export default function PrivacyPage() {
<dt>Cloudflare</dt>
<dd>
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.
</dd>
</div>
@@ -300,11 +294,7 @@ export default function PrivacyPage() {
<td>Until you delete them, clear app data, or uninstall</td>
</tr>
<tr>
<th scope="row">Pending local crash 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>
<th scope="row">Server bug reports</th>
<td>The current project configuration is 90 days, with scheduled deletion</td>
</tr>
<tr>
@@ -317,7 +307,7 @@ export default function PrivacyPage() {
<p>
Operational backups, provider logs, and deletion backlogs may persist briefly beyond
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.
</p>
</section>
@@ -325,7 +315,6 @@ export default function PrivacyPage() {
<section id="choices" className="policy-section">
<h2>Your choices and rights</h2>
<ul>
<li>Enable or disable Share diagnostics in VniDrop settings.</li>
<li>
Submit a bug report only when you choose, omit contact information, and exclude
logs.
@@ -343,7 +332,7 @@ export default function PrivacyPage() {
<p>
Depending on where you live, privacy law may provide rights to access, correct,
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
the contact method below and provide only what is needed to locate your submission.
</p>
@@ -353,7 +342,7 @@ export default function PrivacyPage() {
<h2>Security</h2>
<p>
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
absolute security. Keep invitations private, verify receiver names, keep your device
updated, and stop sharing when a transfer is finished.

View File

@@ -6,10 +6,6 @@ org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8
org.gradle.configuration-cache=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.builtInKotlin=false
android.newDsl=false
@@ -17,9 +13,8 @@ android.nonTransitiveRClass=true
android.sourceset.disallowProvider=false
android.useAndroidX=true
# VniDrop: compile-time diagnostics/telemetry product surface.
# false → no Share-diagnostics toggle, no telemetry or crash auto-upload stack.
# Bug report UI remains available (user-initiated).
# VniDrop: compile-time bug-report delivery surface.
# false → user-initiated bug reports fall back to a NoOp transport (never sent).
# Enable per build only when endpoint and ingest key are configured:
# ./gradlew … -Pvnidrop.diagnostics.included=true
vnidrop.diagnostics.included=false

View File

@@ -1178,62 +1178,6 @@
"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 dutilisation 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 duso 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": {
"context": "Error: camera permission is needed to scan a QR code.",
"translations": {

View File

@@ -1,11 +1,10 @@
.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; }
@cd $(ROOT); \
version="$$(packaging/linux/resolve-version.sh "$(VERSION)")"; \
version="$$(packaging/linux/resolve-version.sh)"; \
$(GRADLE) :shared:jvmTest :desktopApp:packageReleaseDeb \
-Pvnidrop.version="$$version" \
-Pvnidrop.desktop.rustVariant=release \
-Pvnidrop.diagnostics.included=false \
$(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" ); \
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; }
@cd $(ROOT); \
version="$$(packaging/linux/resolve-version.sh "$(VERSION)")"; \
version="$$(packaging/linux/resolve-version.sh)"; \
$(GRADLE) :desktopApp:packageReleaseRpm \
-Pvnidrop.version="$$version" \
-Pvnidrop.desktop.rustVariant=release \
-Pvnidrop.diagnostics.included=false \
$(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" ); \
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; }
cd $(ROOT) && $(GRADLE) :shared:jvmTest :desktopApp:createReleaseDistributable \
-Pvnidrop.version="$(VERSION)" \
-Pvnidrop.desktop.rustVariant=release \
-Pvnidrop.diagnostics.included=false \
$(GRADLE_RELEASE_FLAGS)
cd $(ROOT) && $(POWERSHELL) -NoProfile -File packaging/windows/build-msix.ps1 \
-Version "$(VERSION)" \
-AppImage desktopApp/build/compose/binaries/main-release/app/VniDrop \
-OutputDirectory build/release/windows

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -13,9 +13,10 @@ repository should add repository metadata signing and its own update channel.
## GitHub Actions
The Linux packages workflow runs for relevant pull requests, release tags
matching `vMAJOR.MINOR.PATCH`, and manual dispatches. Each native package is
built and validated on its matching distribution family:
The Linux packages workflow runs for relevant pull requests and manual
dispatches. The coordinated release workflow also calls it for a canonical
`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
- `.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
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
artifacts. Manual runs retain build artifacts for 14 days. A pushed version tag
whose commit is on `master` creates the matching GitHub Release with the `.deb`,
`.rpm`, and a combined `SHA256SUMS`.
artifacts. Manual and coordinated-release runs retain build artifacts. The
central release workflow creates the single GitHub Release only after every
platform build and Play closed-testing stage succeeds.
The existing `v1.0.0` tag predates this workflow and will not run it
retroactively. Use the next version tag after this configuration reaches
`master`.
The legacy `v1.0.0` tag predates canonical versioning and does not define the
current product version. New release tags must match `version.properties`.
## 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
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
make package-deb VERSION=1.0.0
make package-rpm VERSION=1.0.0
make package-deb
make package-rpm
```
The Make targets collect the Compose output under `build/release/linux/`, then

View File

@@ -2,38 +2,14 @@
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 [[ ! ${GITHUB_REF_NAME:-} =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Linux release tags must use vMAJOR.MINOR.PATCH" >&2
exit 1
fi
version=${GITHUB_REF_NAME#v}
fi
if [[ ! $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Version must use MAJOR.MINOR.PATCH" >&2
if [[ -n ${1:-} && $1 != "$version" ]]; then
printf 'Version overrides are not supported; version.properties declares %s\n' "$version" >&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"

View File

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

View File

@@ -0,0 +1,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")"

View 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
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,15 +24,17 @@ after the first release. The manifest display name uses the exact reserved Store
name; the product's in-app branding and launcher remain `VniDrop`.
The initial package targets Windows Desktop x64, Windows 10 version 2004
(build 19041) or later. The fourth MSIX version component is reserved by the
Store, so app version 1.2.3 becomes package version 1.2.3.0.
(build 19041) or later. The product version comes from `version.properties`.
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
The Windows Store package workflow runs automatically for relevant pull
requests, for release tags matching vMAJOR.MINOR.PATCH, and by manual dispatch.
Pull requests build and validate without retaining an artifact. Tags and manual
runs retain:
requests and by manual dispatch. The coordinated release workflow also calls
it for a canonical `vMAJOR.MINOR.PATCH` tag. Pull requests build and validate
without retaining an artifact. Manual and coordinated-release runs retain:
- VniDrop_VERSION_x64.msix
- 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
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.
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
@@ -71,25 +74,30 @@ Use this restricted-capability justification in Submission options:
> Rust and JVM libraries and needs normal user-level filesystem and network
> access to transfer user-selected files directly between devices.
After the first release is certified and live, Store publication can be added
as a separate protected job. Keep its Partner Center credentials in a GitHub
Environment, not in this build job:
After the first release is certified and live, the coordinated release
workflow submits the generated `.msixupload` from a separate protected job.
Keep its Partner Center credentials in the `microsoft-store` GitHub
Environment, not in the build job:
- AZURE_AD_TENANT_ID
- AZURE_AD_APPLICATION_CLIENT_ID
- AZURE_AD_APPLICATION_SECRET
- 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
From the repository root:
~~~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

View File

@@ -1,8 +1,5 @@
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string] $Version,
[Parameter(Mandatory)]
[string] $AppImage,
@@ -87,16 +84,11 @@ if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) {
throw "MSIX packaging must run on Windows"
}
$versionParts = $Version.Split(".")
Assert-Condition ($versionParts.Count -eq 3) "Version must use MAJOR.MINOR.PATCH"
for ($index = 0; $index -lt $versionParts.Count; $index++) {
$part = $versionParts[$index]
$number = 0
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"
$versionResolver = Join-Path $PSScriptRoot "..\version\resolve-version.ps1"
$versionInfoJson = & $versionResolver -Field Json -VerifyTag
$versionInfo = $versionInfoJson | ConvertFrom-Json
$Version = [string] $versionInfo.productVersion
$packageVersion = [string] $versionInfo.windowsPackageVersion
$appImagePath = (Resolve-Path -LiteralPath $AppImage).Path
Assert-Condition (Test-Path -LiteralPath $appImagePath -PathType Container) "App image not found: $AppImage"

View File

@@ -1,13 +1,13 @@
# VniDrop diagnostics API
Cloudflare Worker for ingesting batched telemetry, crash reports, and user-submitted
bug reports. D1 stores searchable metadata; R2 stores larger stack traces and logs.
Cloudflare Worker for ingesting user-submitted bug reports. D1 stores searchable
metadata; R2 stores the larger attached logs.
The service is designed for modest traffic and low operating cost:
- one D1 row is written per telemetry batch, not per event;
- crash stacks and bug logs are stored in R2 instead of D1;
- request and batch limits reject oversized work before storage writes;
- one D1 row is written per bug report;
- bug logs are stored in R2 instead of D1;
- request limits reject oversized work before storage writes;
- an hourly scheduled cleanup and an R2 lifecycle rule enforce retention;
- 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` | `/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 |
Batch and report IDs are client-generated UUIDs. A client must reuse the same ID
when retrying so D1 can acknowledge the request without storing it twice.
Report IDs are client-generated UUIDs. A client must reuse the same ID when
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
most 50 events per batch. Cloudflare rate-limit bindings allow 30 requests per
Accepted reports return `202`. The default is a 262,144-byte request limit.
Cloudflare rate-limit bindings allow 30 requests per
installation and 120 requests per source, per ingest route, per minute. Source
limits run before shared-key verification so rejected traffic is bounded too.
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
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
lifecycle rule is an independent backstop for stack and log objects, including
objects left behind by a partial ingest failure. Each scheduled run can remove
8,000 event batches and 7,200 rows from each report table while staying below
D1's per-invocation query ceiling. Later hourly runs continue any backlog.
Reaching the cap emits a structured warning with the remaining expired-row counts;
lifecycle rule is an independent backstop for log objects, including objects left
behind by a partial ingest failure. Each scheduled run can remove 7,200 bug rows
while staying below D1's per-invocation query ceiling. Later hourly runs continue
any backlog.
Reaching the cap emits a structured warning with the remaining expired-row count;
alert on that warning because
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
offline-safe no-op transport; configuring only one fails the Gradle build.
`vnidrop.diagnostics.included=false` disables
automatic telemetry and crash upload, but a configured endpoint can still accept
an explicit user-submitted bug report. Treat the app-side key as an abuse-control
token with the limitations described above.
`vnidrop.diagnostics.included=false` routes bug reports to that no-op transport
(never sent); a configured endpoint accepts an explicit user-submitted bug report.
Treat the app-side key as an abuse-control token with the limitations described
above.
## Reading reports
```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 \
--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
`bugs/<id>/<attempt-id>/logs.txt`. The unique attempt segment prevents a retry
from overwriting an already accepted object before D1 detects the duplicate.
R2 object keys use `bugs/<id>/<attempt-id>/logs.txt`. The unique attempt segment
prevents a retry from overwriting an already accepted object before D1 detects
the duplicate.
There is no public administration endpoint; inspect reports through authenticated
Cloudflare tools or a future Access-protected dashboard.

View File

@@ -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;

View File

@@ -1,20 +1,15 @@
import {
normalizeBug,
normalizeCrash,
normalizeEvents,
readJsonObject,
} from "./input";
import {
type DiagnosticsEnv,
runRetention,
storeBug,
storeCrash,
storeEvents,
} from "./storage";
const DEFAULT_MAX_BODY_BYTES = 262_144;
const HARD_MAX_BODY_BYTES = 1_048_576;
const DEFAULT_MAX_EVENTS = 50;
export default {
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);
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": {
const normalized = normalizeBug(parsed.value);
if (!normalized.ok) {
@@ -159,12 +119,6 @@ async function readiness(env: DiagnosticsEnv, requestId: string): Promise<Respon
}
try {
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(
"SELECT id, occurred_at, logs_r2_key, device_json FROM bugs LIMIT 1",
),
@@ -216,8 +170,8 @@ async function installRateLimited(
return !result.success;
}
function isIngestPath(path: string): path is "/v1/events" | "/v1/crashes" | "/v1/bugs" {
return path === "/v1/events" || path === "/v1/crashes" || path === "/v1/bugs";
function isIngestPath(path: string): path is "/v1/bugs" {
return path === "/v1/bugs";
}
async function timingSafeEqual(provided: string, expected: string): Promise<boolean> {

View File

@@ -8,21 +8,6 @@ export type 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 {
deviceName: string;
deviceModel: string;
@@ -31,28 +16,6 @@ export interface NormalizedDevice {
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 {
id: string;
installId: string;
@@ -65,16 +28,12 @@ export interface NormalizedBugPayload {
contact: string;
logs: string;
device: NormalizedDevice;
breadcrumbs: NormalizedBreadcrumb[];
schemaVersion: 1;
}
export const MAX_LOG_BYTES = 192 * 1024;
export const MAX_BREADCRUMBS_JSON_BYTES = 16_000;
export const MAX_DEVICE_JSON_BYTES = 4_000;
const MAX_PROPERTIES = 12;
const MAX_BREADCRUMBS = 40;
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 UTF8_ENCODER = new TextEncoder();
@@ -164,119 +123,6 @@ export async function readJsonObject(
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> {
if (!isPlainObject(body)) return failure(400, "invalid_body");
@@ -319,8 +165,6 @@ export function normalizeBug(body: JsonObject): InputResult<NormalizedBugPayload
if (!logs.ok) return logs;
const device = normalizeDevice(pick(body, ["device"]));
if (!device.ok) return device;
const breadcrumbs = normalizeBreadcrumbs(pick(body, ["breadcrumbs"]));
if (!breadcrumbs.ok) return breadcrumbs;
const version = schemaVersion(body);
if (!version.ok) return version;
@@ -336,80 +180,10 @@ export function normalizeBug(body: JsonObject): InputResult<NormalizedBugPayload
contact: contact.value,
logs: includeLogs.value === true ? logs.value : "",
device: device.value,
breadcrumbs: breadcrumbs.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> {
if (raw === MISSING) raw = {};
if (!isPlainObject(raw)) return failure(400, "invalid_device");

View File

@@ -1,12 +1,7 @@
import type {
NormalizedBugPayload,
NormalizedCrashPayload,
NormalizedEventsPayload,
} from "./input";
import type { NormalizedBugPayload } from "./input";
export type DiagnosticsEnv = Cloudflare.Env & {
INGEST_KEY?: string;
AE?: AnalyticsEngineDataset;
};
export interface StoreResult {
@@ -15,130 +10,6 @@ export interface StoreResult {
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(
payload: NormalizedBugPayload,
env: DiagnosticsEnv,
@@ -161,12 +32,12 @@ export async function storeBug(
try {
const result = await database
.prepare(
`INSERT INTO bugs (
id, received_at, occurred_at, install_id, app_version, platform,
what_happened, expected, steps, contact, logs_r2_key,
device_json, breadcrumbs_json, status, schema_version
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?)
ON CONFLICT(id) DO NOTHING`,
`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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?)
ON CONFLICT(id) DO NOTHING`,
)
.bind(
payload.id,
@@ -181,7 +52,6 @@ export async function storeBug(
payload.contact,
logsKey,
JSON.stringify(payload.device),
JSON.stringify(payload.breadcrumbs),
payload.schemaVersion,
)
.run();
@@ -201,26 +71,22 @@ export async function storeBug(
export async function runRetention(env: DiagnosticsEnv): Promise<void> {
const retentionDays = boundedPositiveInt(env.RETENTION_DAYS, 90, 1, 3_650);
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) {
const hasFullBatch = await runRetentionPass(env, cutoff);
if (!hasFullBatch) return;
}
const [events, crashes, bugs] = await env.DB.batch<{ count: number }>([
env.DB.prepare("SELECT COUNT(*) AS count FROM event_batches WHERE received_at < ?").bind(
cutoff,
),
env.DB.prepare("SELECT COUNT(*) AS count FROM crashes WHERE received_at < ?").bind(cutoff),
env.DB.prepare("SELECT COUNT(*) AS count FROM bugs WHERE received_at < ?").bind(cutoff),
]);
const bugs = await env.DB.prepare(
"SELECT COUNT(*) AS count FROM bugs WHERE received_at < ?",
)
.bind(cutoff)
.first<{ count: number }>();
console.warn(
JSON.stringify({
message: "diagnostics retention reached its per-run pass limit",
cutoff,
backlog: {
eventBatches: events.results[0]?.count ?? 0,
crashes: crashes.results[0]?.count ?? 0,
bugs: bugs.results[0]?.count ?? 0,
bugs: bugs?.count ?? 0,
},
}),
);
@@ -228,28 +94,17 @@ export async function runRetention(env: DiagnosticsEnv): Promise<void> {
async function runRetentionPass(env: DiagnosticsEnv, cutoff: number): Promise<boolean> {
const reportBatchSize = 900;
const eventBatchSize = 1_000;
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 bugs = await expiredBlobRows(env.DB, "bugs", "logs_r2_key", cutoff, reportBatchSize);
const blobKeys = [...crashes, ...bugs]
const blobKeys = bugs
.map((row) => row.blobKey)
.filter((key): key is string => key !== null);
for (let offset = 0; offset < blobKeys.length; offset += 1_000) {
await env.BLOBS.delete(blobKeys.slice(offset, offset + 1_000));
}
const statements = [retentionStatement(env.DB, "event_batches", cutoff, eventBatchSize)];
if (crashes.length > 0) statements.push(deleteRowsById(env.DB, "crashes", crashes));
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
);
if (bugs.length > 0) await deleteRowsById(env.DB, "bugs", bugs).run();
return bugs.length === reportBatchSize;
}
interface ExpiredBlobRow {
@@ -259,8 +114,8 @@ interface ExpiredBlobRow {
async function expiredBlobRows(
database: D1Database,
table: "crashes" | "bugs",
column: "stack_r2_key" | "logs_r2_key",
table: "bugs",
column: "logs_r2_key",
cutoff: number,
batchSize: number,
): Promise<ExpiredBlobRow[]> {
@@ -277,25 +132,9 @@ async function expiredBlobRows(
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(
database: D1Database,
table: "crashes" | "bugs",
table: "bugs",
rows: ExpiredBlobRow[],
): D1PreparedStatement {
return database
@@ -303,18 +142,6 @@ function deleteRowsById(
.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> {
try {
await env.BLOBS.delete(key);

View File

@@ -1,11 +1,8 @@
import { describe, expect, it } from "vitest";
import {
MAX_BREADCRUMBS_JSON_BYTES,
MAX_DEVICE_JSON_BYTES,
MAX_LOG_BYTES,
normalizeBug,
normalizeCrash,
normalizeEvents,
readJsonObject,
} from "../src/input";
@@ -57,7 +54,7 @@ describe("readJsonObject", () => {
});
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",
body: "{}",
});
@@ -108,18 +105,6 @@ describe("readJsonObject", () => {
describe("normalizers", () => {
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 normalizedBug = normalizeBug(bug);
expect(normalizedBug.ok).toBe(true);
@@ -133,20 +118,11 @@ describe("normalizers", () => {
});
});
it("keeps logs, breadcrumbs, 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,
}));
it("keeps logs and device JSON within valid byte budgets", () => {
const result = normalizeBug(
bugPayload({
include_logs: true,
logs: "😀".repeat(60_000),
breadcrumbs,
device: {
device_name: "\u0000".repeat(200),
device_model: "\u0000".repeat(200),
@@ -159,54 +135,38 @@ describe("normalizers", () => {
expect(result.ok).toBe(true);
if (!result.ok) return;
const breadcrumbsJson = JSON.stringify(result.value.breadcrumbs);
const deviceJson = JSON.stringify(result.value.device);
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(JSON.parse(breadcrumbsJson)).toEqual(result.value.breadcrumbs);
expect(JSON.parse(deviceJson)).toEqual(result.value.device);
});
it("requires stable report IDs and validates supplied IDs and schema versions", () => {
const result = normalizeEvents({
events: [{ name: "opened", ts: 1, schema_version: 1 }],
});
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" });
const missingId = normalizeBug(bugPayload({ id: undefined }));
expect(missingId).toEqual({ ok: false, status: 400, error: "invalid_id" });
expect(
normalizeEvents({
batch_id: "not-a-uuid",
events: [{ name: "opened", timestamp_millis: 1 }],
}),
).toEqual({ ok: false, status: 400, error: "invalid_batch_id" });
expect(
normalizeEvents({
batch_id: ID,
install_id: INSTALL_ID,
events: [{ name: "opened", timestamp_millis: 1, schema_version: 2 }],
}),
).toEqual({ ok: false, status: 400, error: "unsupported_schema_version" });
const legacyInstall = normalizeBug(bugPayload({ install_id: "legacy-test-install" }));
expect(legacyInstall.ok && legacyInstall.value.installId).toBe("legacy-test-install");
const missingInstall = normalizeBug(bugPayload({ install_id: undefined }));
expect(missingInstall.ok && missingInstall.value.installId).toBe("unknown");
expect(normalizeBug(bugPayload({ install_id: "bad\u0000install" }))).toEqual({
ok: false,
status: 400,
error: "invalid_install_id",
});
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",
contentLength?: string,
): Request {
return new Request("https://example.test/v1/events", {
return new Request("https://example.test/v1/bugs", {
method: "POST",
headers: {
"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> {
return {
const payload: Record<string, unknown> = {
id: ID,
install_id: INSTALL_ID,
app_version: "1.0",
@@ -259,8 +203,12 @@ function bugPayload(overrides: Record<string, unknown> = {}): Record<string, unk
contact: "",
logs: "",
device: {},
breadcrumbs: [],
schema_version: 1,
...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;
}

View File

@@ -2,18 +2,8 @@ import { env, exports } from "cloudflare:workers";
import { createExecutionContext } from "cloudflare:test";
import { describe, expect, it, vi } from "vitest";
import worker from "../src/index";
import type {
NormalizedBugPayload,
NormalizedCrashPayload,
NormalizedEventsPayload,
} from "../src/input";
import {
type DiagnosticsEnv,
runRetention,
storeBug,
storeCrash,
storeEvents,
} from "../src/storage";
import type { NormalizedBugPayload } from "../src/input";
import { type DiagnosticsEnv, runRetention, storeBug } from "../src/storage";
const INSTALL_ID = "10000000-0000-4000-8000-000000000000";
@@ -35,7 +25,7 @@ describe("diagnostics Worker", () => {
expect(unknown.status).toBe(404);
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(await unauthorized.json()).toEqual({ error: "unauthorized" });
@@ -44,7 +34,7 @@ describe("diagnostics Worker", () => {
);
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.headers.get("access-control-allow-origin")).toBeNull();
@@ -68,7 +58,7 @@ describe("diagnostics Worker", () => {
const context = createExecutionContext();
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,
context,
);
@@ -80,7 +70,7 @@ describe("diagnostics Worker", () => {
});
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(await invalid.json()).toEqual({ error: "invalid_body" });
@@ -92,6 +82,8 @@ describe("diagnostics Worker", () => {
};
const rejectingDatabase = {
prepare: () => statement,
batch: async () => Promise.reject(rejection),
withSession: () => ({ prepare: () => statement }),
} as unknown as D1Database;
const rejectingEnv: DiagnosticsEnv = { ...env, DB: rejectingDatabase };
@@ -106,7 +98,7 @@ describe("diagnostics Worker", () => {
const ingestContext = createExecutionContext();
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,
ingestContext,
);
@@ -114,101 +106,6 @@ describe("diagnostics Worker", () => {
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 () => {
const id = uuid(30);
const payload = bugPayload(id, "first logs");
@@ -224,7 +121,7 @@ describe("diagnostics Worker", () => {
const row = await env.DB.prepare(
`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 = ?`,
)
.bind(id)
@@ -232,7 +129,6 @@ describe("diagnostics Worker", () => {
occurredAt: number;
logsKey: string;
deviceJson: string;
breadcrumbsJson: string;
}>();
expect(row?.occurredAt).toBe(3);
expect(JSON.parse(row?.deviceJson ?? "null")).toEqual({
@@ -242,9 +138,6 @@ describe("diagnostics Worker", () => {
network: "offline",
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");
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 () => {
const crash = normalizedCrash(uuid(31), "accepted stack");
const bug = normalizedBug(uuid(32), "accepted logs");
const firstCrash = await storeCrash(crash, env);
await storeBug(bug, env);
let blobWrites = 0;
const unavailableBlobs = {
@@ -265,14 +156,6 @@ describe("diagnostics Worker", () => {
} as unknown as R2Bucket;
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(
storeBug({ ...bug, logs: "retry logs" }, unavailableEnv),
).resolves.toEqual({ id: bug.id, duplicate: true, stored: 0 });
@@ -286,88 +169,56 @@ describe("diagnostics Worker", () => {
async () => Promise.reject(rejection),
);
const rejectingEnv: DiagnosticsEnv = { ...env, DB: rejectingDatabase };
const crashId = uuid(33);
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(
jsonRequest("/v1/bugs", bugPayload(bugId, "orphan candidate"), env.INGEST_KEY, "198.51.100.34"),
rejectingEnv,
createExecutionContext(),
);
expect(crashResponse.status).toBe(500);
expect(await crashResponse.json()).toEqual({ error: "internal" });
expect(bugResponse.status).toBe(500);
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([]);
});
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 currentEventId = uuid(43);
const oldCrashKey = `crashes/${oldCrashId}/retention/stack.txt`;
const currentBugId = uuid(43);
const oldBugKey = `bugs/${oldBugId}/retention/logs.txt`;
const oldReceivedAt = Date.now() - 100 * 86_400_000;
await Promise.all([
env.BLOBS.put(oldCrashKey, "expired crash"),
env.BLOBS.put(oldBugKey, "expired logs"),
]);
await env.BLOBS.put(oldBugKey, "expired logs");
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(
`INSERT INTO bugs
(id, received_at, occurred_at, install_id, app_version, platform,
what_happened, expected, steps, contact, logs_r2_key,
device_json, breadcrumbs_json, status, schema_version)
VALUES (?, ?, ?, ?, '', '', 'failed', 'worked', '', '', ?, '{}', '[]', 'open', 1)`,
device_json, status, schema_version)
VALUES (?, ?, ?, ?, '', '', 'failed', 'worked', '', '', ?, '{}', 'open', 1)`,
).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);
for (const [table, id] of [
["event_batches", oldEventId],
["crashes", oldCrashId],
["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.DB.prepare("SELECT id FROM bugs WHERE id = ?").bind(oldBugId).first(),
).toBeNull();
expect(await env.BLOBS.head(oldBugKey)).toBeNull();
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();
});
it("bounds a full retention run below the D1 per-invocation query limit", async () => {
let queryCount = 0;
let batchCalls = 0;
const blobDeleteBatchSizes: number[] = [];
const rows = Array.from({ length: 900 }, (_, index) => ({
id: `expired-${index}`,
@@ -381,17 +232,17 @@ describe("diagnostics Worker", () => {
queryCount += 1;
return d1Result(rows, 0);
},
run: async () => {
queryCount += 1;
return d1Result([], rows.length);
},
first: async () => {
queryCount += 1;
return { count: rows.length };
},
};
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;
const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined);
const blobs = {
@@ -406,9 +257,10 @@ describe("diagnostics Worker", () => {
warning.mockRestore();
}
expect(queryCount).toBe(43);
expect(blobDeleteBatchSizes).toHaveLength(16);
expect(Math.max(...blobDeleteBatchSizes)).toBe(1_000);
// Eight passes (one SELECT + one DELETE each) plus the final backlog SELECT.
expect(queryCount).toBe(17);
expect(blobDeleteBatchSizes).toHaveLength(8);
expect(Math.max(...blobDeleteBatchSizes)).toBe(900);
});
it("converges an expired report backlog across bounded retention runs", async () => {
@@ -424,13 +276,13 @@ describe("diagnostics Worker", () => {
CROSS JOIN digits AS ones
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,
exception_type, exception_message, fingerprint, diagnostics_enabled,
stack_r2_key, breadcrumbs_json, schema_version
what_happened, expected, steps, contact, logs_r2_key,
device_json, status, schema_version
)
SELECT 'retention-backlog-' || printf('%04d', value), ?, ?, ?, '', '',
'Error', '', 'fingerprint-' || value, 0, NULL, '[]', 1
'failed', 'worked', '', '', NULL, '{}', 'open', 1
FROM sequence`,
)
.bind(oldReceivedAt, oldReceivedAt, INSTALL_ID)
@@ -440,13 +292,13 @@ describe("diagnostics Worker", () => {
try {
await runRetention(env);
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 }>();
expect(afterFirstRun?.count).toBe(1);
await runRetention(env);
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 }>();
expect(afterSecondRun?.count).toBe(0);
} 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> {
return {
id,
@@ -535,23 +354,6 @@ function bugPayload(id: string, logs: string): Record<string, unknown> {
network: "offline",
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,
};
}
@@ -575,7 +377,6 @@ function normalizedBug(id: string, logs: string): NormalizedBugPayload {
network: "offline",
batteryLevel: "90%",
},
breadcrumbs: [],
schemaVersion: 1,
};
}

View File

@@ -1,5 +1,5 @@
/* 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
interface __BaseEnv_Env {
BLOBS: R2Bucket;
@@ -7,7 +7,6 @@ interface __BaseEnv_Env {
INSTALL_RATE_LIMITER: RateLimit;
SOURCE_RATE_LIMITER: RateLimit;
MAX_BODY_BYTES: "262144";
MAX_EVENTS_PER_BATCH: "50";
RETENTION_DAYS: "90";
}
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;
};
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

View File

@@ -47,13 +47,8 @@
},
},
],
// Optional: bind Analytics Engine as `AE` when event volume justifies it.
// "analytics_engine_datasets": [
// { "binding": "AE", "dataset": "vnidrop_events" },
// ],
"vars": {
"MAX_BODY_BYTES": "262144",
"MAX_EVENTS_PER_BATCH": "50",
"RETENTION_DAYS": "90",
},
}

View File

@@ -14,6 +14,7 @@ import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction
import org.gradle.jvm.tasks.Jar
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.util.Properties
abstract class VerifyHostCargoTaskSelection : DefaultTask() {
@get:Input
@@ -37,7 +38,7 @@ plugins {
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")
.map { value ->
when (value.trim().lowercase()) {
@@ -49,7 +50,7 @@ val desktopRustVariant = providers.gradleProperty("vnidrop.desktop.rustVariant")
.orElse(Variant.Debug)
// 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).
val diagnosticsIncluded: Boolean =
(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 {
androidTarget {
compilerOptions {
@@ -122,6 +172,7 @@ kotlin {
sourceSets {
commonMain {
kotlin.srcDir(files(diagnosticsBuildConfigDir).builtBy(generateDiagnosticsBuildConfig))
kotlin.srcDir(files(appConfigDir).builtBy(generateAppConfig))
}
androidMain.dependencies {
implementation(libs.androidx.activity.compose)

View File

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

View File

@@ -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()
}
}
}

View File

@@ -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)
}
}

View File

@@ -80,10 +80,6 @@
<string name="button_write_nfc">Auf NFC-Tag schreiben</string>
<string name="device_model_title">Gerätemodell</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_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>

View File

@@ -80,10 +80,6 @@
<string name="button_write_nfc">Escribir en etiqueta NFC</string>
<string name="device_model_title">Modelo 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_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>

View File

@@ -80,10 +80,6 @@
<string name="button_write_nfc">Écrire sur un tag NFC</string>
<string name="device_model_title">Modèle de lappareil</string>
<string name="device_name_title">Nom de lappareil</string>
<string name="diagnostics_description">Envoyer des rapports de plantage et des événements dutilisation 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">Laccès à la caméra est nécessaire pour scanner un QR code.</string>
<string name="error_device_info">Impossible de charger les informations de lappareil.</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>

View File

@@ -80,10 +80,6 @@
<string name="button_write_nfc">Scrivi su tag NFC</string>
<string name="device_model_title">Modello del dispositivo</string>
<string name="device_name_title">Nome del dispositivo</string>
<string name="diagnostics_description">Invia report di arresto anomalo ed eventi duso 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 laccesso alla fotocamera.</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 unaltra cartella o rimuova il file esistente.</string>

View File

@@ -80,10 +80,6 @@
<string name="button_write_nfc">Naar NFC-tag schrijven</string>
<string name="device_model_title">Apparaatmodel</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_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>

View File

@@ -80,10 +80,6 @@
<string name="button_write_nfc">Zapisz na tagu NFC</string>
<string name="device_model_title">Model 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_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>

View File

@@ -80,10 +80,6 @@
<string name="button_write_nfc">Escrever em etiqueta NFC</string>
<string name="device_model_title">Modelo 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_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>

View File

@@ -80,10 +80,6 @@
<string name="button_write_nfc">Записать на NFC-метку</string>
<string name="device_model_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_device_info">Не удалось загрузить сведения об устройстве.</string>
<string name="error_destination_exists">В папке назначения уже есть файл с таким именем. Выберите другую папку или удалите существующий файл.</string>

View File

@@ -80,10 +80,6 @@
<string name="button_write_nfc">Write to NFC tag</string>
<string name="device_model_title">Device model</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_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>

View File

@@ -80,7 +80,6 @@ fun App(
graph.coreRepository,
graph.preferencesRepository,
graph.messages,
graph.diagnostics,
)
}
val sendViewModel = viewModel {
@@ -105,7 +104,6 @@ fun App(
dependencies.localNotificationService,
graph.messages,
graph.diagnostics.bugReports,
graph.diagnostics,
)
}
val appState by appViewModel.state.collectAsStateWithLifecycle()

View File

@@ -19,9 +19,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
class AppGraph(
val dependencies: AppDependencies,
@@ -40,11 +37,9 @@ class AppGraph(
receiveFolder = dependencies.fileSystemService.defaultReceiveFolder(),
themeMode = ThemeMode.System,
notificationsEnabled = false,
diagnosticsEnabled = false,
),
)
val diagnostics = DiagnosticsCoordinator.create(
appDataDir = dependencies.environment.defaultCoreDataDir,
appVersion = dependencies.environment.appVersion,
platform = dependencies.environment.name,
preferencesRepository = preferencesRepository,
@@ -75,12 +70,6 @@ class AppGraph(
init {
AppLogger.initialize(dependencies.environment.defaultCoreDataDir)
diagnostics.start()
applicationScope.launch {
visibility.isForeground
.drop(1)
.filter { isForeground -> !isForeground }
.collect { diagnostics.telemetry.flush() }
}
}
fun close() {

View File

@@ -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
}
}

View File

@@ -21,7 +21,6 @@ data class BugReportDraft(
class BugReportService(
private val preferencesRepository: PreferencesRepository,
private val transport: DiagnosticsTransport,
private val breadcrumbs: BreadcrumbBuffer,
private val appVersion: String,
private val platform: String,
private val logReader: () -> String = {
@@ -53,7 +52,6 @@ class BugReportService(
network = deviceInfo?.network?.takeUtf8Bytes(96),
batteryLevel = deviceInfo?.batteryLevel?.takeUtf8Bytes(64),
),
breadcrumbs = breadcrumbs.snapshot(),
)
}

Some files were not shown because too many files have changed in this diff Show More