mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
Compare commits
20 Commits
6d908d8dc3
...
feat/macos
| Author | SHA1 | Date | |
|---|---|---|---|
| fe97c21c7a | |||
| c670dda0a9 | |||
| ff391f5502 | |||
| 9079c81409 | |||
| 5424da855e | |||
| 56d19014d4 | |||
| 51bf0abba2 | |||
| e0fb84ccb9 | |||
| 30025a4ebf | |||
| 50e9a6c1cc | |||
| 224a8e0e7a | |||
| efacfab213 | |||
|
|
0ec7618ce8 | ||
| 8b75423b7a | |||
|
|
7236933b76 | ||
| fc732e1b77 | |||
|
|
d097c82f6a | ||
| caaa9a472d | |||
|
|
4ce124da7c | ||
| 94a8b3481b |
19
.github/workflows/apple-release.yml
vendored
19
.github/workflows/apple-release.yml
vendored
@@ -132,7 +132,22 @@ jobs:
|
|||||||
echo "SPARKLE_ED_KEY_FILE=$RUNNER_TEMP/sparkle_ed_private_key" >> "$GITHUB_ENV"
|
echo "SPARKLE_ED_KEY_FILE=$RUNNER_TEMP/sparkle_ed_private_key" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Build, sign & notarize DMG
|
- name: Build, sign & notarize DMG
|
||||||
run: apple/scripts/build-dmg.sh
|
run: make build-apple-dmg
|
||||||
|
|
||||||
|
- name: Package prebuilt core
|
||||||
|
# build-apple-dmg builds the release Rust core + Swift bindings; bundle them
|
||||||
|
# (xcframework + Vnidrop.swift + checksum) as a release asset so consumers can
|
||||||
|
# skip building the core. See apple/scripts/package-core.sh.
|
||||||
|
run: make package-apple-core
|
||||||
|
|
||||||
|
- name: Upload notarization diagnostics
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
with:
|
||||||
|
name: vnidrop-${{ steps.version.outputs.app }}-notarization-diagnostics
|
||||||
|
path: apple/dist/*.notary-log.json
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
- name: Generate appcast
|
- name: Generate appcast
|
||||||
env:
|
env:
|
||||||
@@ -147,5 +162,7 @@ jobs:
|
|||||||
apple/dist/VniDrop-*.dmg
|
apple/dist/VniDrop-*.dmg
|
||||||
apple/dist/VniDrop-*.build-info.json
|
apple/dist/VniDrop-*.build-info.json
|
||||||
apple/dist/appcast.xml
|
apple/dist/appcast.xml
|
||||||
|
apple/dist/VnidropCore-*.zip
|
||||||
|
apple/dist/VnidropCore-*.zip.sha256
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
|
|||||||
140
.github/workflows/release.yml
vendored
140
.github/workflows/release.yml
vendored
@@ -185,37 +185,19 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
apk="build/release/play/VniDrop-${VERSION}-${VERSION_CODE}-play-universal.apk"
|
apk="build/release/play/VniDrop-${VERSION}-${VERSION_CODE}-play-universal.apk"
|
||||||
apksigner_path="$(
|
|
||||||
find "$ANDROID_SDK_ROOT/build-tools" -type f -name apksigner -perm -111 |
|
|
||||||
sort -r |
|
|
||||||
head -1
|
|
||||||
)"
|
|
||||||
apkanalyzer_path="$(
|
apkanalyzer_path="$(
|
||||||
find "$ANDROID_SDK_ROOT/cmdline-tools" -type f -name apkanalyzer -perm -111 |
|
find "$ANDROID_SDK_ROOT/cmdline-tools" -type f -name apkanalyzer -perm -111 |
|
||||||
sort -r |
|
sort -r |
|
||||||
head -1
|
head -1
|
||||||
)"
|
)"
|
||||||
if [ -z "$apksigner_path" ] || [ -z "$apkanalyzer_path" ]; then
|
if [ -z "$apkanalyzer_path" ]; then
|
||||||
echo "Android SDK verification tools were not found" >&2
|
echo "apkanalyzer was not found" >&2
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
"$apksigner_path" verify --verbose --print-certs "$apk" \
|
|
||||||
> build/release/play/apksigner-report.txt
|
|
||||||
actual="$(
|
|
||||||
awk -F': ' '/Signer #1 certificate SHA-256 digest:/ {print $2; exit}' \
|
|
||||||
build/release/play/apksigner-report.txt |
|
|
||||||
tr -d '[:space:]:' |
|
|
||||||
tr '[:upper:]' '[:lower:]'
|
|
||||||
)"
|
|
||||||
expected="$(
|
|
||||||
printf '%s' "$EXPECTED_CERT_SHA256" |
|
|
||||||
tr -d '[:space:]:' |
|
|
||||||
tr '[:upper:]' '[:lower:]'
|
|
||||||
)"
|
|
||||||
if [ -z "$actual" ] || [ "$actual" != "$expected" ]; then
|
|
||||||
echo "Play APK signing certificate mismatch" >&2
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
packaging/android/verify-apk-signature.sh \
|
||||||
|
"$apk" \
|
||||||
|
"$EXPECTED_CERT_SHA256" \
|
||||||
|
>/dev/null
|
||||||
if [ "$("$apkanalyzer_path" manifest application-id "$apk")" != "com.vnidrop.app" ]; then
|
if [ "$("$apkanalyzer_path" manifest application-id "$apk")" != "com.vnidrop.app" ]; then
|
||||||
echo "Play APK package name mismatch" >&2
|
echo "Play APK package name mismatch" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -228,7 +210,6 @@ jobs:
|
|||||||
echo "Play APK version code mismatch" >&2
|
echo "Play APK version code mismatch" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
rm build/release/play/apksigner-report.txt
|
|
||||||
(
|
(
|
||||||
cd build/release/play
|
cd build/release/play
|
||||||
sha256sum \
|
sha256sum \
|
||||||
@@ -246,6 +227,114 @@ jobs:
|
|||||||
retention-days: 90
|
retention-days: 90
|
||||||
compression-level: 0
|
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:
|
publish-github:
|
||||||
name: Publish coordinated GitHub Release
|
name: Publish coordinated GitHub Release
|
||||||
needs:
|
needs:
|
||||||
@@ -254,6 +343,7 @@ jobs:
|
|||||||
- windows
|
- windows
|
||||||
- macos
|
- macos
|
||||||
- play-closed-testing
|
- play-closed-testing
|
||||||
|
- publish-microsoft-store
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
19
Makefile
19
Makefile
@@ -12,8 +12,8 @@ include $(ROOT)/make/release.mk
|
|||||||
.PHONY: format test check check-rust audit-rust test-rust test-rust-all
|
.PHONY: format test check check-rust audit-rust test-rust test-rust-all
|
||||||
.PHONY: test-rust-transfer test-rust-approval test-rust-lifecycle test-rust-output-sink
|
.PHONY: test-rust-transfer test-rust-approval test-rust-lifecycle test-rust-output-sink
|
||||||
.PHONY: check-shared test-shared test-android-host check-android verify-android-libs build-android run-desktop
|
.PHONY: check-shared test-shared test-android-host check-android verify-android-libs build-android run-desktop
|
||||||
.PHONY: apple-core apple-version-config apple-project open-apple-project open-apple build-apple-macos build-apple-ios check-apple
|
.PHONY: apple-core apple-version-config apple-project open-apple-project open-apple build-apple-macos build-apple-ios check-apple package-apple-core
|
||||||
.PHONY: check-version check-release check-localization localization localization-migrate
|
.PHONY: prepare-release check-version check-release check-localization localization localization-migrate
|
||||||
.PHONY: check-docs run-docs check-diagnostics run-diagnostics diagnostics-db-local diagnostics-db-remote diagnostics-typegen deploy-diagnostics
|
.PHONY: check-docs run-docs check-diagnostics run-diagnostics diagnostics-db-local diagnostics-db-remote diagnostics-typegen deploy-diagnostics
|
||||||
|
|
||||||
help: ## Show available commands and common configuration variables.
|
help: ## Show available commands and common configuration variables.
|
||||||
@@ -61,14 +61,22 @@ test: test-rust test-shared ## Run the main Rust and shared JVM test suites.
|
|||||||
|
|
||||||
check: check-version 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.
|
check-version: ## Validate the canonical version and its platform mappings.
|
||||||
cd $(ROOT) && packaging/version/test-version.sh
|
cd $(ROOT) && packaging/version/test-version.sh
|
||||||
cd $(ROOT) && packaging/version/resolve-version.sh verify
|
cd $(ROOT) && packaging/version/resolve-version.sh verify
|
||||||
cd $(ROOT) && $(GRADLE) verifyVersion $(GRADLE_FLAGS)
|
cd $(ROOT) && $(GRADLE) verifyVersion $(GRADLE_FLAGS)
|
||||||
|
|
||||||
check-release: ## Validate coordinated release scripts and workflow YAML.
|
check-release: ## Validate coordinated release scripts and workflow YAML.
|
||||||
cd $(ROOT) && bash -n packaging/android/build-release.sh packaging/release/assemble-release.sh packaging/release/test-assemble-release.sh
|
cd $(ROOT) && bash -n apple/scripts/notarize.sh apple/scripts/sign-exported-app.sh apple/scripts/tests/test-notarize.sh apple/scripts/tests/test-sign-exported-app.sh packaging/android/build-release.sh packaging/android/verify-apk-signature.sh packaging/android/tests/test_verify_apk_signature.sh packaging/release/assemble-release.sh packaging/release/test-assemble-release.sh packaging/release/test-release-config.sh
|
||||||
|
cd $(ROOT) && apple/scripts/tests/test-notarize.sh
|
||||||
|
cd $(ROOT) && apple/scripts/tests/test-sign-exported-app.sh
|
||||||
|
cd $(ROOT) && packaging/android/tests/test_verify_apk_signature.sh
|
||||||
cd $(ROOT) && packaging/release/test-assemble-release.sh
|
cd $(ROOT) && packaging/release/test-assemble-release.sh
|
||||||
|
cd $(ROOT) && packaging/release/test-release-config.sh
|
||||||
cd $(ROOT) && python3 -m unittest discover -s packaging/android/tests -v
|
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
|
cd $(ROOT) && ruby -e 'require "yaml"; ARGV.each { |file| YAML.load_file(file) }' .github/workflows/*.yml
|
||||||
|
|
||||||
@@ -139,9 +147,12 @@ build-apple-macos: apple-project ## Build the native macOS app (unsigned by defa
|
|||||||
build-apple-macos-direct: apple-project ## Build the direct-download macOS target (Sparkle, unsigned) — CI compile check.
|
build-apple-macos-direct: apple-project ## Build the direct-download macOS target (Sparkle, unsigned) — CI compile check.
|
||||||
cd $(ROOT)/apple && $(XCODEBUILD) -project VniDrop.xcodeproj -scheme VniDropDirect -configuration Release-Direct -derivedDataPath "$(APPLE_DERIVED_DATA)" -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO build
|
cd $(ROOT)/apple && $(XCODEBUILD) -project VniDrop.xcodeproj -scheme VniDropDirect -configuration Release-Direct -derivedDataPath "$(APPLE_DERIVED_DATA)" -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO build
|
||||||
|
|
||||||
build-apple-dmg: ## Build the signed/notarized direct-download .dmg (see apple/RELEASE-MACOS.md for required env).
|
build-apple-dmg: localization ## Build the signed/notarized direct-download .dmg (see apple/RELEASE-MACOS.md for required env).
|
||||||
cd $(ROOT) && apple/scripts/build-dmg.sh
|
cd $(ROOT) && apple/scripts/build-dmg.sh
|
||||||
|
|
||||||
|
package-apple-core: ## Zip the prebuilt core (xcframework + bindings) + checksum into apple/dist (build the core first).
|
||||||
|
cd $(ROOT) && apple/scripts/package-core.sh
|
||||||
|
|
||||||
open-apple: build-apple-macos ## Build and launch the native macOS app.
|
open-apple: build-apple-macos ## Build and launch the native macOS app.
|
||||||
@test -d "$(APPLE_DERIVED_DATA)/Build/Products/$(APPLE_CONFIGURATION)/VniDrop.app" || { printf 'Built macOS app was not found.\n' >&2; exit 1; }
|
@test -d "$(APPLE_DERIVED_DATA)/Build/Products/$(APPLE_CONFIGURATION)/VniDrop.app" || { printf 'Built macOS app was not found.\n' >&2; exit 1; }
|
||||||
$(OPEN) "$(APPLE_DERIVED_DATA)/Build/Products/$(APPLE_CONFIGURATION)/VniDrop.app"
|
$(OPEN) "$(APPLE_DERIVED_DATA)/Build/Products/$(APPLE_CONFIGURATION)/VniDrop.app"
|
||||||
|
|||||||
@@ -9,15 +9,9 @@ struct RootView: View {
|
|||||||
@StateObject private var sendModel: SendModel
|
@StateObject private var sendModel: SendModel
|
||||||
@StateObject private var receiveModel: ReceiveModel
|
@StateObject private var receiveModel: ReceiveModel
|
||||||
@StateObject private var settingsModel: SettingsModel
|
@StateObject private var settingsModel: SettingsModel
|
||||||
@ObservedObject private var messages: UiMessageController
|
|
||||||
@ObservedObject private var approvals: ApprovalCoordinator
|
|
||||||
|
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
/// Drives the approval sheet; toggled from the pending-approval `onChange` so the
|
|
||||||
/// presentation can be deferred until the Share/QR sheet has dismissed on macOS.
|
|
||||||
@State private var showApproval = false
|
|
||||||
|
|
||||||
init(dependencies: AppDependencies) {
|
init(dependencies: AppDependencies) {
|
||||||
let graph = AppGraph(dependencies: dependencies)
|
let graph = AppGraph(dependencies: dependencies)
|
||||||
_graph = StateObject(wrappedValue: graph)
|
_graph = StateObject(wrappedValue: graph)
|
||||||
@@ -50,8 +44,6 @@ struct RootView: View {
|
|||||||
messages: graph.messages,
|
messages: graph.messages,
|
||||||
bugReports: NoopBugReportService()
|
bugReports: NoopBugReportService()
|
||||||
))
|
))
|
||||||
messages = graph.messages
|
|
||||||
approvals = graph.approvalCoordinator
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -60,13 +52,17 @@ struct RootView: View {
|
|||||||
let isDark = resolveDarkTheme(appModel.themeMode, systemDark: systemDark)
|
let isDark = resolveDarkTheme(appModel.themeMode, systemDark: systemDark)
|
||||||
ZStack {
|
ZStack {
|
||||||
navigation(windowClass: windowClass)
|
navigation(windowClass: windowClass)
|
||||||
SnackbarHost(controller: messages)
|
// Observe the coordinator/messages from the *persisted* `graph`
|
||||||
ApprovalModalHost(
|
// StateObject. Deriving them in `init` bound the view to a throwaway
|
||||||
isPresented: $showApproval,
|
// AppGraph rebuilt on every re-init, whose coordinator never receives
|
||||||
state: approvals.state,
|
// core events — so the approval modal never appeared.
|
||||||
onAccept: approvals.accept,
|
ApprovalLayer(
|
||||||
onRefuse: approvals.refuse
|
approvals: graph.approvalCoordinator,
|
||||||
|
sendModel: sendModel
|
||||||
)
|
)
|
||||||
|
// Top-most so the toast is never covered by the approval overlay's
|
||||||
|
// full-bleed clear layer. Observes the live `graph.messages` directly.
|
||||||
|
SnackbarHost(controller: graph.messages)
|
||||||
}
|
}
|
||||||
.overlay {
|
.overlay {
|
||||||
// A small, unobtrusive indicator while the core finishes its async
|
// A small, unobtrusive indicator while the core finishes its async
|
||||||
@@ -103,27 +99,6 @@ struct RootView: View {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A pending approval is a blocking modal. Close the sender's detail panel
|
|
||||||
// (e.g. the Share/QR sheet) first, then present the approval sheet — but on
|
|
||||||
// macOS a sheet presented while another is still dismissing is silently
|
|
||||||
// dropped, so defer the presentation until that dismissal finishes.
|
|
||||||
.onChange(of: approvals.state.current?.id) { _, id in
|
|
||||||
guard id != nil else { showApproval = false; return }
|
|
||||||
let wasShowingSheet = sendModel.state.detailPanel != nil
|
|
||||||
sendModel.closeDetailPanel()
|
|
||||||
#if os(macOS)
|
|
||||||
if wasShowingSheet {
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) {
|
|
||||||
if approvals.state.current != nil { showApproval = true }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
showApproval = true
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
_ = wasShowingSheet
|
|
||||||
showApproval = true
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
// macOS keeps `scenePhase == .active` even when the app loses focus, so
|
// macOS keeps `scenePhase == .active` even when the app loses focus, so
|
||||||
// drive foreground/background off NSApplication's active state instead —
|
// drive foreground/background off NSApplication's active state instead —
|
||||||
@@ -217,6 +192,66 @@ struct RootView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hosts the approval modal, observing the coordinator passed in from the persisted
|
||||||
|
/// `AppGraph`. Kept as a child view so the `@ObservedObject` subscription is
|
||||||
|
/// established here (in `body`) against the live instance, rather than in
|
||||||
|
/// `RootView.init` against a throwaway graph.
|
||||||
|
private struct ApprovalLayer: View {
|
||||||
|
@ObservedObject var approvals: ApprovalCoordinator
|
||||||
|
let sendModel: SendModel
|
||||||
|
|
||||||
|
/// Drives the approval sheet; toggled from the pending-approval `onChange` so the
|
||||||
|
/// presentation can be deferred until the Share/QR sheet has dismissed on macOS.
|
||||||
|
@State private var showApproval = false
|
||||||
|
|
||||||
|
/// macOS-only: an approval arrived while a share/QR sheet was still up. We close
|
||||||
|
/// that sheet and present the approval once its dismissal completes (see
|
||||||
|
/// `sendModel.shareSheetsDismissed`), since macOS drops a sheet shown mid-dismissal.
|
||||||
|
@State private var approvalAwaitingSheetDismiss = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ApprovalModalHost(
|
||||||
|
isPresented: $showApproval,
|
||||||
|
state: approvals.state,
|
||||||
|
onAccept: approvals.accept,
|
||||||
|
onRefuse: approvals.refuse
|
||||||
|
)
|
||||||
|
// A pending approval is a blocking modal. Close any open share/QR sheet first
|
||||||
|
// (the detail-view panel *or* the list-level share sheet), then present the
|
||||||
|
// approval sheet: the approval is presented from the app root and neither
|
||||||
|
// platform reliably stacks it over a sheet owned by the Send screen.
|
||||||
|
.onChange(of: approvals.state.current?.id) { _, id in
|
||||||
|
guard id != nil else {
|
||||||
|
showApproval = false
|
||||||
|
approvalAwaitingSheetDismiss = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let wasShowingSheet = sendModel.state.detailPanel != nil
|
||||||
|
|| sendModel.state.shareTargetId != nil
|
||||||
|
sendModel.dismissShareSheets()
|
||||||
|
#if os(macOS)
|
||||||
|
// macOS silently drops a sheet presented while another is still dismissing,
|
||||||
|
// so wait for that sheet's real dismissal completion before presenting.
|
||||||
|
if wasShowingSheet {
|
||||||
|
approvalAwaitingSheetDismiss = true
|
||||||
|
} else {
|
||||||
|
showApproval = true
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
_ = wasShowingSheet
|
||||||
|
showApproval = true
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
#if os(macOS)
|
||||||
|
.onReceive(sendModel.shareSheetsDismissed) { _ in
|
||||||
|
guard approvalAwaitingSheetDismiss else { return }
|
||||||
|
approvalAwaitingSheetDismiss = false
|
||||||
|
if approvals.state.current != nil { showApproval = true }
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A full-window cover with a centered spinner shown while the core is starting.
|
/// A full-window cover with a centered spinner shown while the core is starting.
|
||||||
private struct CoreStartingOverlay: View {
|
private struct CoreStartingOverlay: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|||||||
@@ -19,7 +19,19 @@ struct LocalNotification {
|
|||||||
/// Presents notifications even while the app is active. Without a delegate the
|
/// Presents notifications even while the app is active. Without a delegate the
|
||||||
/// system drops the banner when the app is frontmost — very visible on macOS,
|
/// system drops the banner when the app is frontmost — very visible on macOS,
|
||||||
/// where the app window is usually open when a transfer completes.
|
/// where the app window is usually open when a transfer completes.
|
||||||
private final class NotificationPresenter: NSObject, UNUserNotificationCenterDelegate {
|
///
|
||||||
|
/// `@MainActor` is required, not just convenient: these delegate methods are
|
||||||
|
/// `async`, so their continuation resumes at the return point on whatever executor
|
||||||
|
/// they ran on. When the system hands a notification-tap back to UIKit it performs
|
||||||
|
/// state-restoration/snapshot work synchronously on that thread — which asserts
|
||||||
|
/// "Call must be made on main thread" and crashes if the method returned off-main.
|
||||||
|
/// Main-actor isolation guarantees the return happens on the main thread.
|
||||||
|
// `@preconcurrency` on the conformance: these delegate requirements are nonisolated
|
||||||
|
// with non-Sendable UN* parameters, which strict concurrency won't otherwise let a
|
||||||
|
// main actor-isolated type witness. The main-actor isolation is what fixes the
|
||||||
|
// crash (see the type doc above); the attribute inserts the runtime hop.
|
||||||
|
@MainActor
|
||||||
|
private final class NotificationPresenter: NSObject, @preconcurrency UNUserNotificationCenterDelegate {
|
||||||
func userNotificationCenter(
|
func userNotificationCenter(
|
||||||
_ center: UNUserNotificationCenter,
|
_ center: UNUserNotificationCenter,
|
||||||
willPresent notification: UNNotification
|
willPresent notification: UNNotification
|
||||||
@@ -36,7 +48,6 @@ private final class NotificationPresenter: NSObject, UNUserNotificationCenterDel
|
|||||||
didReceive response: UNNotificationResponse
|
didReceive response: UNNotificationResponse
|
||||||
) async {
|
) async {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
await MainActor.run {
|
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
// Reopen/focus the single main window (activation triggers SwiftUI's
|
// Reopen/focus the single main window (activation triggers SwiftUI's
|
||||||
// reopen handling when it was closed).
|
// reopen handling when it was closed).
|
||||||
@@ -44,7 +55,6 @@ private final class NotificationPresenter: NSObject, UNUserNotificationCenterDel
|
|||||||
window.makeKeyAndOrderFront(nil)
|
window.makeKeyAndOrderFront(nil)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ struct SendState: Equatable {
|
|||||||
var selectedTransferId: UInt64?
|
var selectedTransferId: UInt64?
|
||||||
var transferThumbnails: [UInt64: Data] = [:]
|
var transferThumbnails: [UInt64: Data] = [:]
|
||||||
var detailPanel: TransferDetailPanel?
|
var detailPanel: TransferDetailPanel?
|
||||||
|
/// Transfer whose share panel is presented inline from the list context menu
|
||||||
|
/// (distinct from `detailPanel == .share`, which shows it from the detail view).
|
||||||
|
/// Held in the model — not `SendScreen` @State — so the approval flow can dismiss
|
||||||
|
/// it centrally before presenting its modal.
|
||||||
|
var shareTargetId: UInt64?
|
||||||
var receiverHistory: [ReceiverRequestModel] = []
|
var receiverHistory: [ReceiverRequestModel] = []
|
||||||
var isLoadingReceivers = false
|
var isLoadingReceivers = false
|
||||||
var isDeleteConfirmationOpen = false
|
var isDeleteConfirmationOpen = false
|
||||||
@@ -56,6 +61,18 @@ final class SendModel: ObservableObject {
|
|||||||
private let messages: UiMessageController
|
private let messages: UiMessageController
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
/// Fires *after* a share/QR sheet (the detail-view panel or the list-level share
|
||||||
|
/// sheet) has finished animating out. The approval flow waits on this to present
|
||||||
|
/// its modal on macOS, where a sheet shown while another is still dismissing is
|
||||||
|
/// dropped — using the real completion instead of a guessed delay.
|
||||||
|
private let shareSheetsDismissedSubject = PassthroughSubject<Void, Never>()
|
||||||
|
var shareSheetsDismissed: AnyPublisher<Void, Never> {
|
||||||
|
shareSheetsDismissedSubject.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invoked by a share sheet's `onDismiss` completion.
|
||||||
|
func shareSheetDidDismiss() { shareSheetsDismissedSubject.send(()) }
|
||||||
|
|
||||||
init(
|
init(
|
||||||
repository: CoreGateway,
|
repository: CoreGateway,
|
||||||
fileSystemService: FileSystemService,
|
fileSystemService: FileSystemService,
|
||||||
@@ -201,6 +218,17 @@ final class SendModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
func closeDetailPanel() { state.detailPanel = nil }
|
func closeDetailPanel() { state.detailPanel = nil }
|
||||||
|
|
||||||
|
func openShareTarget(_ transferId: UInt64) { state.shareTargetId = transferId }
|
||||||
|
func closeShareTarget() { state.shareTargetId = nil }
|
||||||
|
|
||||||
|
/// Dismisses every share/QR surface at once — the detail-view share panel and the
|
||||||
|
/// list-level share sheet. Used before presenting the receiver-approval modal, so
|
||||||
|
/// no competing sheet is left open (macOS drops a sheet shown over another).
|
||||||
|
func dismissShareSheets() {
|
||||||
|
state.detailPanel = nil
|
||||||
|
state.shareTargetId = nil
|
||||||
|
}
|
||||||
|
|
||||||
func requestDeleteTransfer() { state.isDeleteConfirmationOpen = true }
|
func requestDeleteTransfer() { state.isDeleteConfirmationOpen = true }
|
||||||
func dismissDeleteTransfer() { if !state.isDeleting { state.isDeleteConfirmationOpen = false } }
|
func dismissDeleteTransfer() { if !state.isDeleting { state.isDeleteConfirmationOpen = false } }
|
||||||
|
|
||||||
@@ -257,8 +285,19 @@ final class SendModel: ObservableObject {
|
|||||||
/// Uses the core's `respondReceiverRequest` (no backend change); applies to
|
/// Uses the core's `respondReceiverRequest` (no backend change); applies to
|
||||||
/// receivers that are still pending or accepted.
|
/// receivers that are still pending or accepted.
|
||||||
func cancelReceiver(requestId: String) {
|
func cancelReceiver(requestId: String) {
|
||||||
|
respondToReceiver(requestId: requestId, accepted: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Approves a single pending receiver by responding to its request positively.
|
||||||
|
/// A fallback for when the approval modal didn't surface — the pending receiver
|
||||||
|
/// can still be accepted from its row in the transfer's receivers panel.
|
||||||
|
func acceptReceiver(requestId: String) {
|
||||||
|
respondToReceiver(requestId: requestId, accepted: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func respondToReceiver(requestId: String, accepted: Bool) {
|
||||||
Task {
|
Task {
|
||||||
let result = await repository.respondReceiverRequest(requestId: requestId, accepted: false, reason: nil)
|
let result = await repository.respondReceiverRequest(requestId: requestId, accepted: accepted, reason: nil)
|
||||||
switch result {
|
switch result {
|
||||||
case .success:
|
case .success:
|
||||||
if let transferId = state.selectedTransferId { refreshReceivers(transferId) }
|
if let transferId = state.selectedTransferId { refreshReceivers(transferId) }
|
||||||
|
|||||||
@@ -7,14 +7,18 @@ struct SendScreen: View {
|
|||||||
@ObservedObject var model: SendModel
|
@ObservedObject var model: SendModel
|
||||||
let windowClass: WindowClass
|
let windowClass: WindowClass
|
||||||
|
|
||||||
/// Transfer whose share panel is presented inline from the list context menu.
|
|
||||||
@State private var shareTarget: Transfer?
|
|
||||||
/// Transfer pending an inline (list-level) delete confirmation.
|
/// Transfer pending an inline (list-level) delete confirmation.
|
||||||
@State private var deleteTarget: Transfer?
|
@State private var deleteTarget: Transfer?
|
||||||
|
|
||||||
private var outgoing: [Transfer] {
|
private var outgoing: [Transfer] {
|
||||||
model.coreState.transfers.filter { $0.direction == .send }
|
model.coreState.transfers.filter { $0.direction == .send }
|
||||||
}
|
}
|
||||||
|
/// The transfer whose list-level share sheet is open, resolved from the model's
|
||||||
|
/// `shareTargetId` (kept in the model so the approval flow can dismiss it).
|
||||||
|
private var shareTarget: Transfer? {
|
||||||
|
guard let id = model.state.shareTargetId else { return nil }
|
||||||
|
return outgoing.first { $0.transferId == id }
|
||||||
|
}
|
||||||
private var selectedTransfer: Transfer? {
|
private var selectedTransfer: Transfer? {
|
||||||
guard let id = model.state.selectedTransferId else { return nil }
|
guard let id = model.state.selectedTransferId else { return nil }
|
||||||
return outgoing.first { $0.transferId == id }
|
return outgoing.first { $0.transferId == id }
|
||||||
@@ -50,9 +54,10 @@ struct SendScreen: View {
|
|||||||
// composer drawer on the outer body, so the two don't clash). Opens the
|
// composer drawer on the outer body, so the two don't clash). Opens the
|
||||||
// share panel over the list without navigating into the transfer detail.
|
// share panel over the list without navigating into the transfer detail.
|
||||||
.adaptiveDrawer(
|
.adaptiveDrawer(
|
||||||
isPresented: Binding(get: { shareTarget != nil }, set: { if !$0 { shareTarget = nil } }),
|
isPresented: Binding(get: { shareTarget != nil }, set: { if !$0 { model.closeShareTarget() } }),
|
||||||
windowClass: windowClass,
|
windowClass: windowClass,
|
||||||
onDismiss: { shareTarget = nil }
|
onDismiss: model.closeShareTarget,
|
||||||
|
onDismissed: model.shareSheetDidDismiss
|
||||||
) {
|
) {
|
||||||
if let shareTarget {
|
if let shareTarget {
|
||||||
TransferSharePanel(model: model, transfer: shareTarget)
|
TransferSharePanel(model: model, transfer: shareTarget)
|
||||||
@@ -92,7 +97,8 @@ struct SendScreen: View {
|
|||||||
.adaptiveDrawer(
|
.adaptiveDrawer(
|
||||||
isPresented: Binding(get: { model.state.detailPanel != nil }, set: { _ in }),
|
isPresented: Binding(get: { model.state.detailPanel != nil }, set: { _ in }),
|
||||||
windowClass: windowClass,
|
windowClass: windowClass,
|
||||||
onDismiss: model.closeDetailPanel
|
onDismiss: model.closeDetailPanel,
|
||||||
|
onDismissed: model.shareSheetDidDismiss
|
||||||
) {
|
) {
|
||||||
if let panel = model.state.detailPanel {
|
if let panel = model.state.detailPanel {
|
||||||
DetailPanelContent(model: model, transfer: transfer, panel: panel)
|
DetailPanelContent(model: model, transfer: transfer, panel: panel)
|
||||||
@@ -127,7 +133,7 @@ struct SendScreen: View {
|
|||||||
.contextMenu {
|
.contextMenu {
|
||||||
if transfer.ticket != nil {
|
if transfer.ticket != nil {
|
||||||
Button {
|
Button {
|
||||||
shareTarget = transfer
|
model.openShareTarget(transfer.transferId)
|
||||||
} label: {
|
} label: {
|
||||||
Label(String(localized: L10n.Transfer.shareTitle), systemSymbol: .squareAndArrowUp)
|
Label(String(localized: L10n.Transfer.shareTitle), systemSymbol: .squareAndArrowUp)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,7 +142,8 @@ struct DetailPanelContent: View {
|
|||||||
loading: model.state.isLoadingReceivers,
|
loading: model.state.isLoadingReceivers,
|
||||||
events: model.coreState.events,
|
events: model.coreState.events,
|
||||||
transferTotalSize: transfer.totalSize,
|
transferTotalSize: transfer.totalSize,
|
||||||
onCancel: model.cancelReceiver
|
onCancel: model.cancelReceiver,
|
||||||
|
onAccept: model.acceptReceiver
|
||||||
)
|
)
|
||||||
case .share:
|
case .share:
|
||||||
TransferSharePanel(model: model, transfer: transfer)
|
TransferSharePanel(model: model, transfer: transfer)
|
||||||
@@ -193,6 +194,7 @@ struct ReceiverHistoryPanel: View {
|
|||||||
let events: [CoreEventModel]
|
let events: [CoreEventModel]
|
||||||
let transferTotalSize: UInt64
|
let transferTotalSize: UInt64
|
||||||
let onCancel: (String) -> Void
|
let onCancel: (String) -> Void
|
||||||
|
let onAccept: (String) -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
PanelContainer(title: String(localized: L10n.Transfer.receiversTitle)) {
|
PanelContainer(title: String(localized: L10n.Transfer.receiversTitle)) {
|
||||||
@@ -203,7 +205,12 @@ struct ReceiverHistoryPanel: View {
|
|||||||
} else {
|
} else {
|
||||||
ForEach(Array(receivers.enumerated()), id: \.element.id) { index, receiver in
|
ForEach(Array(receivers.enumerated()), id: \.element.id) { index, receiver in
|
||||||
if index > 0 { Divider().overlay(colors.borderDefault) }
|
if index > 0 { Divider().overlay(colors.borderDefault) }
|
||||||
ReceiverRow(receiver: receiver, sendProgress: sendProgress(for: receiver), onCancel: onCancel)
|
ReceiverRow(
|
||||||
|
receiver: receiver,
|
||||||
|
sendProgress: sendProgress(for: receiver),
|
||||||
|
onCancel: onCancel,
|
||||||
|
onAccept: onAccept
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,6 +231,7 @@ private struct ReceiverRow: View {
|
|||||||
let receiver: ReceiverRequestModel
|
let receiver: ReceiverRequestModel
|
||||||
let sendProgress: TransferProgress?
|
let sendProgress: TransferProgress?
|
||||||
let onCancel: (String) -> Void
|
let onCancel: (String) -> Void
|
||||||
|
let onAccept: (String) -> Void
|
||||||
|
|
||||||
/// Only pending requests can be cancelled per-receiver: the core rejects a
|
/// Only pending requests can be cancelled per-receiver: the core rejects a
|
||||||
/// negative response to an already-accepted request ("...not approved, or it
|
/// negative response to an already-accepted request ("...not approved, or it
|
||||||
@@ -257,6 +265,7 @@ private struct ReceiverRow: View {
|
|||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
if isCancelable {
|
if isCancelable {
|
||||||
|
VStack(alignment: .trailing, spacing: 8) {
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
onCancel(receiver.id)
|
onCancel(receiver.id)
|
||||||
} label: {
|
} label: {
|
||||||
@@ -265,6 +274,18 @@ private struct ReceiverRow: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.borderless)
|
.buttonStyle(.borderless)
|
||||||
.tint(.red)
|
.tint(.red)
|
||||||
|
// Fallback approve action, in case the approval modal didn't surface.
|
||||||
|
Button {
|
||||||
|
onAccept(receiver.id)
|
||||||
|
} label: {
|
||||||
|
Text(String(localized: L10n.Button.approve))
|
||||||
|
.font(VniType.bodySmall).fontWeight(.medium)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 16).padding(.vertical, 7)
|
||||||
|
.background(Color.green, in: Capsule())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|||||||
@@ -7,11 +7,16 @@ struct AdaptiveDrawer<DrawerContent: View>: ViewModifier {
|
|||||||
@Binding var isPresented: Bool
|
@Binding var isPresented: Bool
|
||||||
let windowClass: WindowClass
|
let windowClass: WindowClass
|
||||||
let onDismiss: () -> Void
|
let onDismiss: () -> Void
|
||||||
|
/// Fired after the sheet's dismissal animation completes (as opposed to
|
||||||
|
/// `onDismiss`, which requests the close). Lets callers serialize a follow-up
|
||||||
|
/// sheet against this one's actual teardown instead of guessing a delay.
|
||||||
|
let onDismissed: (() -> Void)?
|
||||||
@ViewBuilder let drawerContent: () -> DrawerContent
|
@ViewBuilder let drawerContent: () -> DrawerContent
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
content.sheet(
|
content.sheet(
|
||||||
isPresented: Binding(get: { isPresented }, set: { if !$0 { onDismiss() } })
|
isPresented: Binding(get: { isPresented }, set: { if !$0 { onDismiss() } }),
|
||||||
|
onDismiss: onDismissed
|
||||||
) {
|
) {
|
||||||
SheetChrome(onClose: onDismiss) { drawerContent() }
|
SheetChrome(onClose: onDismiss) { drawerContent() }
|
||||||
.modifier(PhoneDetents(enabled: windowClass == .phone))
|
.modifier(PhoneDetents(enabled: windowClass == .phone))
|
||||||
@@ -56,11 +61,12 @@ extension View {
|
|||||||
isPresented: Binding<Bool>,
|
isPresented: Binding<Bool>,
|
||||||
windowClass: WindowClass,
|
windowClass: WindowClass,
|
||||||
onDismiss: @escaping () -> Void,
|
onDismiss: @escaping () -> Void,
|
||||||
|
onDismissed: (() -> Void)? = nil,
|
||||||
@ViewBuilder content: @escaping () -> DrawerContent
|
@ViewBuilder content: @escaping () -> DrawerContent
|
||||||
) -> some View {
|
) -> some View {
|
||||||
modifier(AdaptiveDrawer(
|
modifier(AdaptiveDrawer(
|
||||||
isPresented: isPresented, windowClass: windowClass,
|
isPresented: isPresented, windowClass: windowClass,
|
||||||
onDismiss: onDismiss, drawerContent: content
|
onDismiss: onDismiss, onDismissed: onDismissed, drawerContent: content
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ configs:
|
|||||||
# Project-wide build settings (applied to every target/config).
|
# Project-wide build settings (applied to every target/config).
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
|
# Apple Silicon only. Intel Macs are unsupported (going EOL with macOS 28), and
|
||||||
|
# the Rust core's macOS slice (vnidrop.xcframework) is built arm64-only, so a
|
||||||
|
# universal link would fail looking for x86_64 symbols anyway.
|
||||||
|
ARCHS: arm64
|
||||||
# Strip unreachable code from release binaries.
|
# Strip unreachable code from release binaries.
|
||||||
DEAD_CODE_STRIPPING: YES
|
DEAD_CODE_STRIPPING: YES
|
||||||
# Flag user-facing strings that aren't localized (the app ships 9 languages).
|
# Flag user-facing strings that aren't localized (the app ships 9 languages).
|
||||||
@@ -135,11 +139,6 @@ targets:
|
|||||||
# provisioning profile, which direct distribution avoids. (App Store target
|
# provisioning profile, which direct distribution avoids. (App Store target
|
||||||
# keeps VniDrop.entitlements with the sandbox.)
|
# keeps VniDrop.entitlements with the sandbox.)
|
||||||
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDropDirect.entitlements
|
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDropDirect.entitlements
|
||||||
# The Rust core's macOS slice (vnidrop.xcframework) is arm64-only
|
|
||||||
# (build-core.sh builds aarch64-apple-darwin only), so the direct build is
|
|
||||||
# Apple-Silicon-only. Pin ARCHS so the Release-Direct (universal-by-default)
|
|
||||||
# link doesn't fail looking for x86_64 symbols.
|
|
||||||
ARCHS: arm64
|
|
||||||
dependencies:
|
dependencies:
|
||||||
- package: Sparkle
|
- package: Sparkle
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,12 @@ ACTUAL_BUILD="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' \
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
echo "==> Enforcing hardened-runtime signature"
|
||||||
|
"$SCRIPT_DIR/sign-exported-app.sh" \
|
||||||
|
"$APP" \
|
||||||
|
"$DEVELOPER_ID_APP" \
|
||||||
|
"$APPLE_DIR/VniDrop/Resources/VniDropDirect.entitlements"
|
||||||
|
|
||||||
# --- Build the DMG -----------------------------------------------------------
|
# --- Build the DMG -----------------------------------------------------------
|
||||||
DMG="$DIST_DIR/$APP_NAME-$VERSION.dmg"
|
DMG="$DIST_DIR/$APP_NAME-$VERSION.dmg"
|
||||||
rm -f "$DMG"
|
rm -f "$DMG"
|
||||||
@@ -137,7 +143,8 @@ codesign --force --sign "$DEVELOPER_ID_APP" --timestamp "$DMG"
|
|||||||
# --- Notarize + staple -------------------------------------------------------
|
# --- Notarize + staple -------------------------------------------------------
|
||||||
if [ -n "${NOTARY_PROFILE:-}" ]; then
|
if [ -n "${NOTARY_PROFILE:-}" ]; then
|
||||||
echo "==> Notarizing (profile: $NOTARY_PROFILE)"
|
echo "==> Notarizing (profile: $NOTARY_PROFILE)"
|
||||||
xcrun notarytool submit "$DMG" --keychain-profile "$NOTARY_PROFILE" --wait
|
NOTARY_LOG="$DIST_DIR/$APP_NAME-$VERSION.notary-log.json"
|
||||||
|
"$SCRIPT_DIR/notarize.sh" "$DMG" "$NOTARY_PROFILE" "$NOTARY_LOG"
|
||||||
echo "==> Stapling"
|
echo "==> Stapling"
|
||||||
xcrun stapler staple "$DMG"
|
xcrun stapler staple "$DMG"
|
||||||
xcrun stapler validate "$DMG"
|
xcrun stapler validate "$DMG"
|
||||||
|
|||||||
67
apple/scripts/notarize.sh
Executable file
67
apple/scripts/notarize.sh
Executable file
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ $# -ne 3 ]]; then
|
||||||
|
printf 'Usage: %s <artifact> <keychain-profile> <log-output>\n' "$0" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
artifact=$1
|
||||||
|
keychain_profile=$2
|
||||||
|
log_output=$3
|
||||||
|
|
||||||
|
[[ -s $artifact ]] || {
|
||||||
|
printf 'error: notarization artifact is missing or empty: %s\n' "$artifact" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
[[ -n $keychain_profile ]] || {
|
||||||
|
printf 'error: notarization keychain profile is empty\n' >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
[[ -n $log_output ]] || {
|
||||||
|
printf 'error: notarization log output path is empty\n' >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
rm -f "$log_output"
|
||||||
|
set +e
|
||||||
|
response="$(
|
||||||
|
xcrun notarytool submit "$artifact" \
|
||||||
|
--keychain-profile "$keychain_profile" \
|
||||||
|
--wait \
|
||||||
|
--output-format json
|
||||||
|
)"
|
||||||
|
submit_exit=$?
|
||||||
|
set -e
|
||||||
|
printf '%s\n' "$response"
|
||||||
|
|
||||||
|
submission_id="$(
|
||||||
|
printf '%s\n' "$response" |
|
||||||
|
jq -r '.id // empty' 2>/dev/null ||
|
||||||
|
true
|
||||||
|
)"
|
||||||
|
status="$(
|
||||||
|
printf '%s\n' "$response" |
|
||||||
|
jq -r '.status // empty' 2>/dev/null ||
|
||||||
|
true
|
||||||
|
)"
|
||||||
|
|
||||||
|
if [[ $submit_exit -eq 0 && $status == Accepted && -n $submission_id ]]; then
|
||||||
|
printf 'Notarization accepted (submission %s)\n' "$submission_id"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'error: notarization was not accepted (status: %s, submission: %s)\n' \
|
||||||
|
"${status:-unknown}" "${submission_id:-unknown}" >&2
|
||||||
|
if [[ -n $submission_id ]]; then
|
||||||
|
mkdir -p "$(dirname "$log_output")"
|
||||||
|
if xcrun notarytool log "$submission_id" "$log_output" \
|
||||||
|
--keychain-profile "$keychain_profile"; then
|
||||||
|
printf '%s\n' 'Apple notarization log:' >&2
|
||||||
|
cat "$log_output" >&2
|
||||||
|
else
|
||||||
|
printf 'error: could not retrieve the Apple notarization log\n' >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
72
apple/scripts/package-core.sh
Executable file
72
apple/scripts/package-core.sh
Executable file
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Packages the prebuilt Apple core into a single zip + checksum, for attaching to
|
||||||
|
# the GitHub Release. Lets a consumer (e.g. Xcode Cloud) use the compiled core
|
||||||
|
# instead of installing Rust and running build-core.sh. Run AFTER the core exists
|
||||||
|
# (apple/scripts/build-core.sh, or `make apple-core` / `make build-apple-dmg`).
|
||||||
|
#
|
||||||
|
# The bundle carries both build outputs of build-core.sh:
|
||||||
|
# - vnidrop.xcframework (static libs for device/sim/macOS + the FFI module)
|
||||||
|
# - Vnidrop.swift (generated UniFFI bindings — a plain source file, not
|
||||||
|
# part of the xcframework, so it must ship alongside)
|
||||||
|
#
|
||||||
|
# Produces (under apple/dist):
|
||||||
|
# VnidropCore-<version>.zip
|
||||||
|
# VnidropCore-<version>.zip.sha256 (sha256sum(1)/shasum-compatible format)
|
||||||
|
#
|
||||||
|
# Zip layout (root):
|
||||||
|
# vnidrop.xcframework/
|
||||||
|
# Vnidrop.swift
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
APPLE_DIR="$REPO_ROOT/apple"
|
||||||
|
PKG_DIR="$APPLE_DIR/VnidropCore"
|
||||||
|
XCFRAMEWORK="$PKG_DIR/vnidrop.xcframework"
|
||||||
|
BINDINGS="$PKG_DIR/Sources/VnidropCore/Vnidrop.swift"
|
||||||
|
DIST_DIR="$APPLE_DIR/dist"
|
||||||
|
|
||||||
|
VERSION="$("$REPO_ROOT/packaging/version/resolve-version.sh" product)"
|
||||||
|
NAME="VnidropCore-$VERSION"
|
||||||
|
ZIP="$DIST_DIR/$NAME.zip"
|
||||||
|
CHECKSUM="$ZIP.sha256"
|
||||||
|
|
||||||
|
[ -d "$XCFRAMEWORK" ] || {
|
||||||
|
echo "error: missing xcframework: $XCFRAMEWORK" >&2
|
||||||
|
echo " build the core first (apple/scripts/build-core.sh)." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
[ -f "$BINDINGS" ] || {
|
||||||
|
echo "error: missing generated bindings: $BINDINGS" >&2
|
||||||
|
echo " build the core first (apple/scripts/build-core.sh)." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir -p "$DIST_DIR"
|
||||||
|
rm -f "$ZIP" "$CHECKSUM"
|
||||||
|
|
||||||
|
# Stage a clean tree so the zip root holds exactly the two payloads (no absolute
|
||||||
|
# paths or stray parent directories leak into the archive).
|
||||||
|
STAGE="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$STAGE"' EXIT
|
||||||
|
cp -R "$XCFRAMEWORK" "$STAGE/vnidrop.xcframework"
|
||||||
|
cp "$BINDINGS" "$STAGE/Vnidrop.swift"
|
||||||
|
|
||||||
|
# -X drops extra file attributes for a stabler archive across machines.
|
||||||
|
( cd "$STAGE" && zip -q -r -X "$ZIP" vnidrop.xcframework Vnidrop.swift )
|
||||||
|
|
||||||
|
# sha256sum on Linux; shasum -a 256 on macOS. Both emit "<hash> <name>", which
|
||||||
|
# `sha256sum --check` (used by assemble-release.sh) accepts.
|
||||||
|
(
|
||||||
|
cd "$DIST_DIR"
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
sha256sum "$NAME.zip" > "$NAME.zip.sha256"
|
||||||
|
else
|
||||||
|
shasum -a 256 "$NAME.zip" > "$NAME.zip.sha256"
|
||||||
|
fi
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "==> Packaged prebuilt core"
|
||||||
|
echo " zip: $ZIP"
|
||||||
|
echo " checksum: $CHECKSUM"
|
||||||
42
apple/scripts/sign-exported-app.sh
Executable file
42
apple/scripts/sign-exported-app.sh
Executable file
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ $# -ne 3 ]]; then
|
||||||
|
printf 'Usage: %s <app-bundle> <signing-identity> <entitlements>\n' "$0" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
app=$1
|
||||||
|
signing_identity=$2
|
||||||
|
entitlements=$3
|
||||||
|
|
||||||
|
[[ -d $app ]] || {
|
||||||
|
printf 'error: exported app bundle does not exist: %s\n' "$app" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
[[ -n $signing_identity ]] || {
|
||||||
|
printf 'error: signing identity is empty\n' >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
[[ -f $entitlements ]] || {
|
||||||
|
printf 'error: entitlements file does not exist: %s\n' "$entitlements" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
codesign \
|
||||||
|
--force \
|
||||||
|
--sign "$signing_identity" \
|
||||||
|
--options runtime \
|
||||||
|
--timestamp \
|
||||||
|
--entitlements "$entitlements" \
|
||||||
|
"$app"
|
||||||
|
codesign --verify --deep --strict --verbose=2 "$app"
|
||||||
|
|
||||||
|
signature_details="$(codesign --display --verbose=4 "$app" 2>&1)"
|
||||||
|
printf '%s\n' "$signature_details"
|
||||||
|
printf '%s\n' "$signature_details" |
|
||||||
|
grep -Eq 'flags=.*\(runtime([^)]*)?\)' || {
|
||||||
|
printf 'error: exported app signature does not enable the hardened runtime\n' >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
87
apple/scripts/tests/test-notarize.sh
Executable file
87
apple/scripts/tests/test-notarize.sh
Executable file
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
notarize="$script_dir/../notarize.sh"
|
||||||
|
scratch="$(mktemp -d "${TMPDIR:-/tmp}/vnidrop-notarize-test.XXXXXX")"
|
||||||
|
trap 'rm -rf "$scratch"' EXIT
|
||||||
|
|
||||||
|
mkdir -p "$scratch/bin"
|
||||||
|
artifact="$scratch/VniDrop.dmg"
|
||||||
|
calls="$scratch/calls.txt"
|
||||||
|
log_output="$scratch/notary/notary-log.json"
|
||||||
|
printf 'dmg\n' > "$artifact"
|
||||||
|
|
||||||
|
cat > "$scratch/bin/xcrun" <<'SCRIPT'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
printf '%s\n' "$*" >> "$FAKE_NOTARY_CALLS"
|
||||||
|
if [[ $1 == notarytool && $2 == submit ]]; then
|
||||||
|
case "${FAKE_NOTARY_MODE:-accepted}" in
|
||||||
|
accepted)
|
||||||
|
printf '%s\n' \
|
||||||
|
'{"id":"11111111-1111-1111-1111-111111111111","status":"Accepted"}'
|
||||||
|
;;
|
||||||
|
invalid)
|
||||||
|
printf '%s\n' \
|
||||||
|
'{"id":"22222222-2222-2222-2222-222222222222","status":"Invalid"}'
|
||||||
|
;;
|
||||||
|
transport-error)
|
||||||
|
printf '%s\n' 'notary service unavailable' >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
elif [[ $1 == notarytool && $2 == log ]]; then
|
||||||
|
mkdir -p "$(dirname "$4")"
|
||||||
|
printf '%s\n' \
|
||||||
|
'{"status":"Invalid","issues":[{"message":"The signature is invalid."}]}' \
|
||||||
|
> "$4"
|
||||||
|
else
|
||||||
|
printf 'unexpected xcrun invocation: %s\n' "$*" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
SCRIPT
|
||||||
|
chmod +x "$scratch/bin/xcrun"
|
||||||
|
|
||||||
|
PATH="$scratch/bin:$PATH" \
|
||||||
|
FAKE_NOTARY_CALLS="$calls" \
|
||||||
|
FAKE_NOTARY_MODE=accepted \
|
||||||
|
"$notarize" "$artifact" test-profile "$log_output" >/dev/null
|
||||||
|
[[ ! -e $log_output ]]
|
||||||
|
[[ $(grep -c '^notarytool submit ' "$calls") -eq 1 ]]
|
||||||
|
if grep -q '^notarytool log ' "$calls"; then
|
||||||
|
printf 'Accepted submissions must not request a rejection log\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
: > "$calls"
|
||||||
|
if PATH="$scratch/bin:$PATH" \
|
||||||
|
FAKE_NOTARY_CALLS="$calls" \
|
||||||
|
FAKE_NOTARY_MODE=invalid \
|
||||||
|
"$notarize" "$artifact" test-profile "$log_output" >/dev/null 2>&1; then
|
||||||
|
printf 'Invalid notarization must fail\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
grep -F '"The signature is invalid."' "$log_output" >/dev/null
|
||||||
|
grep -F \
|
||||||
|
'notarytool log 22222222-2222-2222-2222-222222222222' \
|
||||||
|
"$calls" >/dev/null
|
||||||
|
|
||||||
|
: > "$calls"
|
||||||
|
rm -f "$log_output"
|
||||||
|
if PATH="$scratch/bin:$PATH" \
|
||||||
|
FAKE_NOTARY_CALLS="$calls" \
|
||||||
|
FAKE_NOTARY_MODE=transport-error \
|
||||||
|
"$notarize" "$artifact" test-profile "$log_output" >/dev/null 2>&1; then
|
||||||
|
printf 'Notary transport errors must fail\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
[[ ! -e $log_output ]]
|
||||||
|
if grep -q '^notarytool log ' "$calls"; then
|
||||||
|
printf 'A submission without an ID cannot request a rejection log\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'Notarization helper tests passed.\n'
|
||||||
78
apple/scripts/tests/test-sign-exported-app.sh
Executable file
78
apple/scripts/tests/test-sign-exported-app.sh
Executable file
@@ -0,0 +1,78 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
sign_exported_app="$script_dir/../sign-exported-app.sh"
|
||||||
|
scratch="$(mktemp -d "${TMPDIR:-/tmp}/vnidrop-codesign-test.XXXXXX")"
|
||||||
|
trap 'rm -rf "$scratch"' EXIT
|
||||||
|
|
||||||
|
mkdir -p "$scratch/bin" "$scratch/VniDrop.app/Contents/MacOS"
|
||||||
|
app="$scratch/VniDrop.app"
|
||||||
|
entitlements="$scratch/VniDropDirect.entitlements"
|
||||||
|
calls="$scratch/calls.txt"
|
||||||
|
printf '<plist><dict/></plist>\n' > "$entitlements"
|
||||||
|
printf 'binary\n' > "$app/Contents/MacOS/VniDrop"
|
||||||
|
|
||||||
|
cat > "$scratch/bin/codesign" <<'SCRIPT'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
printf '%s\n' "$*" >> "$FAKE_CODESIGN_CALLS"
|
||||||
|
case " $* " in
|
||||||
|
*" --display "*)
|
||||||
|
if [[ ${FAKE_CODESIGN_MODE:-runtime} == missing-runtime ]]; then
|
||||||
|
printf '%s\n' \
|
||||||
|
'CodeDirectory v=20500 size=123 flags=0x0(none) hashes=1+0 location=embedded' \
|
||||||
|
>&2
|
||||||
|
else
|
||||||
|
printf '%s\n' \
|
||||||
|
'CodeDirectory v=20500 size=123 flags=0x10000(runtime) hashes=1+0 location=embedded' \
|
||||||
|
>&2
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*" --verify "*)
|
||||||
|
if [[ ${FAKE_CODESIGN_MODE:-runtime} == verify-error ]]; then
|
||||||
|
printf '%s\n' 'invalid signature' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
SCRIPT
|
||||||
|
chmod +x "$scratch/bin/codesign"
|
||||||
|
|
||||||
|
PATH="$scratch/bin:$PATH" \
|
||||||
|
FAKE_CODESIGN_CALLS="$calls" \
|
||||||
|
"$sign_exported_app" \
|
||||||
|
"$app" \
|
||||||
|
'Developer ID Application: Example (ABCDEFGHIJ)' \
|
||||||
|
"$entitlements" >/dev/null
|
||||||
|
grep -F -- \
|
||||||
|
'--force --sign Developer ID Application: Example (ABCDEFGHIJ) --options runtime --timestamp --entitlements' \
|
||||||
|
"$calls" >/dev/null
|
||||||
|
grep -F -- '--verify --deep --strict --verbose=2' "$calls" >/dev/null
|
||||||
|
grep -F -- '--display --verbose=4' "$calls" >/dev/null
|
||||||
|
|
||||||
|
if PATH="$scratch/bin:$PATH" \
|
||||||
|
FAKE_CODESIGN_CALLS="$calls" \
|
||||||
|
FAKE_CODESIGN_MODE=missing-runtime \
|
||||||
|
"$sign_exported_app" \
|
||||||
|
"$app" \
|
||||||
|
'Developer ID Application: Example (ABCDEFGHIJ)' \
|
||||||
|
"$entitlements" >/dev/null 2>&1; then
|
||||||
|
printf 'A signature without the hardened runtime must fail\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if PATH="$scratch/bin:$PATH" \
|
||||||
|
FAKE_CODESIGN_CALLS="$calls" \
|
||||||
|
FAKE_CODESIGN_MODE=verify-error \
|
||||||
|
"$sign_exported_app" \
|
||||||
|
"$app" \
|
||||||
|
'Developer ID Application: Example (ABCDEFGHIJ)' \
|
||||||
|
"$entitlements" >/dev/null 2>&1; then
|
||||||
|
printf 'Signature verification errors must fail\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'Exported app signing tests passed.\n'
|
||||||
@@ -41,19 +41,18 @@ val productVersionMatch = Regex("(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-
|
|||||||
.matchEntire(productVersion)
|
.matchEntire(productVersion)
|
||||||
?: error("PRODUCT_VERSION must use canonical MAJOR.MINOR.PATCH integers")
|
?: error("PRODUCT_VERSION must use canonical MAJOR.MINOR.PATCH integers")
|
||||||
val productVersionParts = productVersionMatch.groupValues.drop(1).map(String::toLong)
|
val productVersionParts = productVersionMatch.groupValues.drop(1).map(String::toLong)
|
||||||
require(productVersionParts[0] <= 65534 && productVersionParts.drop(1).all { it <= 65535 }) {
|
require(productVersionParts[0] <= 2099 && productVersionParts.drop(1).all { it <= 999 }) {
|
||||||
"PRODUCT_VERSION components exceed the supported store ranges"
|
"PRODUCT_VERSION must use a major no greater than 2099 and minor/patch no greater than 999"
|
||||||
}
|
}
|
||||||
|
|
||||||
val releaseChannel = requiredVersionProperty("RELEASE_CHANNEL")
|
val releaseChannel = requiredVersionProperty("RELEASE_CHANNEL")
|
||||||
require(releaseChannel.matches(Regex("[a-z][a-z0-9-]*"))) {
|
require(releaseChannel.matches(Regex("[a-z][a-z0-9-]*"))) {
|
||||||
"RELEASE_CHANNEL contains unsupported characters"
|
"RELEASE_CHANNEL contains unsupported characters"
|
||||||
}
|
}
|
||||||
val androidVersionCode = canonicalInteger(
|
val androidVersionCode =
|
||||||
"ANDROID_VERSION_CODE",
|
(productVersionParts[0] * 1_000_000L + productVersionParts[1] * 1_000L + productVersionParts[2])
|
||||||
requiredVersionProperty("ANDROID_VERSION_CODE"),
|
.also { require(it in 1L..2_100_000_000L) { "Derived Android version code is out of range" } }
|
||||||
1L..2_100_000_000L,
|
.toInt()
|
||||||
).toInt()
|
|
||||||
val windowsVersionEpoch = canonicalInteger(
|
val windowsVersionEpoch = canonicalInteger(
|
||||||
"WINDOWS_VERSION_EPOCH",
|
"WINDOWS_VERSION_EPOCH",
|
||||||
requiredVersionProperty("WINDOWS_VERSION_EPOCH"),
|
requiredVersionProperty("WINDOWS_VERSION_EPOCH"),
|
||||||
|
|||||||
@@ -53,18 +53,6 @@ verify_archive_entries() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
find_apksigner() {
|
|
||||||
if command -v apksigner >/dev/null 2>&1; then
|
|
||||||
command -v apksigner
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
local sdk_root=${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}
|
|
||||||
[[ -n $sdk_root ]] || return 1
|
|
||||||
find "$sdk_root/build-tools" -type f -name apksigner -perm -111 2>/dev/null |
|
|
||||||
sort -r |
|
|
||||||
head -1
|
|
||||||
}
|
|
||||||
|
|
||||||
for name in \
|
for name in \
|
||||||
VNIDROP_ANDROID_KEYSTORE_PATH \
|
VNIDROP_ANDROID_KEYSTORE_PATH \
|
||||||
VNIDROP_ANDROID_KEYSTORE_PASSWORD \
|
VNIDROP_ANDROID_KEYSTORE_PASSWORD \
|
||||||
@@ -119,26 +107,12 @@ grep -F 'jar verified.' <<< "$jarsigner_report" >/dev/null || {
|
|||||||
}
|
}
|
||||||
verify_archive_entries "$source_apk" "${required_apk_libraries[@]}"
|
verify_archive_entries "$source_apk" "${required_apk_libraries[@]}"
|
||||||
verify_archive_entries "$source_aab" "${required_aab_libraries[@]}"
|
verify_archive_entries "$source_aab" "${required_aab_libraries[@]}"
|
||||||
apksigner_path="$(find_apksigner)" || {
|
|
||||||
printf 'apksigner was not found in PATH or the Android SDK\n' >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
signature_report="$("$apksigner_path" verify --verbose --print-certs "$source_apk")"
|
|
||||||
actual_fingerprint="$(
|
actual_fingerprint="$(
|
||||||
printf '%s\n' "$signature_report" |
|
"$script_dir/verify-apk-signature.sh" \
|
||||||
awk -F': ' '/Signer #1 certificate SHA-256 digest:/ {print $2; exit}'
|
"$source_apk" \
|
||||||
|
"$VNIDROP_ANDROID_UPLOAD_CERT_SHA256"
|
||||||
)"
|
)"
|
||||||
[[ -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 "$VNIDROP_ANDROID_UPLOAD_CERT_SHA256")"
|
expected_fingerprint="$(normalize_fingerprint "$VNIDROP_ANDROID_UPLOAD_CERT_SHA256")"
|
||||||
[[ $actual_fingerprint == "$expected_fingerprint" ]] || {
|
|
||||||
printf 'APK signing certificate mismatch: expected %s, got %s\n' \
|
|
||||||
"$expected_fingerprint" "$actual_fingerprint" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
aab_fingerprint="$(
|
aab_fingerprint="$(
|
||||||
keytool -printcert -jarfile "$source_aab" |
|
keytool -printcert -jarfile "$source_aab" |
|
||||||
awk -F': ' '/SHA256:/ {print $2; exit}'
|
awk -F': ' '/SHA256:/ {print $2; exit}'
|
||||||
|
|||||||
@@ -184,6 +184,19 @@ def generated_apks_url(package_name: str, version_code: int) -> str:
|
|||||||
return f"{API_ROOT}/applications/{package}/generatedApks/{version_code}"
|
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(
|
def get_generated_apks(
|
||||||
client: PlayClient,
|
client: PlayClient,
|
||||||
package_name: str,
|
package_name: str,
|
||||||
@@ -216,22 +229,23 @@ def download_universal_apk(
|
|||||||
selected = find_universal_apk(response, expected_fingerprint)
|
selected = find_universal_apk(response, expected_fingerprint)
|
||||||
if selected is not None:
|
if selected is not None:
|
||||||
fingerprint, download_id = selected
|
fingerprint, download_id = selected
|
||||||
package = urllib.parse.quote(package_name, safe="")
|
apk = client.request(
|
||||||
download = urllib.parse.quote(download_id, safe="")
|
"GET",
|
||||||
url = (
|
generated_apk_download_url(
|
||||||
f"{API_ROOT}/applications/{package}/generatedApks/"
|
package_name,
|
||||||
f"{version_code}/downloads/{download}:download"
|
version_code,
|
||||||
|
download_id,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
if apk:
|
||||||
output.parent.mkdir(parents=True, exist_ok=True)
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
output.write_bytes(client.request("GET", url))
|
output.write_bytes(apk)
|
||||||
if output.stat().st_size == 0:
|
|
||||||
raise RuntimeError("Google Play returned an empty universal APK")
|
|
||||||
return fingerprint
|
return fingerprint
|
||||||
if attempt < attempts:
|
if attempt < attempts:
|
||||||
time.sleep(interval_seconds)
|
time.sleep(interval_seconds)
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"Google Play did not provide a universal APK signed with the expected "
|
"Google Play did not provide a non-empty universal APK signed with the "
|
||||||
f"certificate after {attempts} attempts"
|
f"expected certificate after {attempts} attempts"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import importlib.util
|
import importlib.util
|
||||||
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -44,6 +45,69 @@ class PublishPlayTests(unittest.TestCase):
|
|||||||
("aabb", "correct"),
|
("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):
|
def test_track_update_preserves_existing_releases_and_adds_draft(self):
|
||||||
track = {
|
track = {
|
||||||
"track": "closed-beta",
|
"track": "closed-beta",
|
||||||
|
|||||||
57
packaging/android/tests/test_verify_apk_signature.sh
Executable file
57
packaging/android/tests/test_verify_apk_signature.sh
Executable file
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
verifier="$script_dir/../verify-apk-signature.sh"
|
||||||
|
scratch="$(mktemp -d "${TMPDIR:-/tmp}/vnidrop-apksigner-test.XXXXXX")"
|
||||||
|
trap 'rm -rf "$scratch"' EXIT
|
||||||
|
|
||||||
|
apk="$scratch/app.apk"
|
||||||
|
fake_apksigner="$scratch/apksigner"
|
||||||
|
printf 'apk\n' > "$apk"
|
||||||
|
|
||||||
|
cat > "$fake_apksigner" <<'SCRIPT'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
case "${FAKE_APKSIGNER_MODE:-success}" in
|
||||||
|
success)
|
||||||
|
printf '%s\n' \
|
||||||
|
'Verifies' \
|
||||||
|
'Signer #1 certificate SHA-256 digest: AA:BB:CC:DD' >&2
|
||||||
|
;;
|
||||||
|
missing)
|
||||||
|
printf '%s\n' 'Verifies' >&2
|
||||||
|
;;
|
||||||
|
failure)
|
||||||
|
printf '%s\n' 'invalid APK signature' >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
SCRIPT
|
||||||
|
chmod +x "$fake_apksigner"
|
||||||
|
|
||||||
|
actual="$(
|
||||||
|
APKSIGNER="$fake_apksigner" \
|
||||||
|
"$verifier" "$apk" "aa bb cc dd"
|
||||||
|
)"
|
||||||
|
[[ $actual == aabbccdd ]]
|
||||||
|
|
||||||
|
if APKSIGNER="$fake_apksigner" \
|
||||||
|
"$verifier" "$apk" deadbeef >/dev/null 2>&1; then
|
||||||
|
printf 'Expected a certificate mismatch to fail\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if FAKE_APKSIGNER_MODE=missing APKSIGNER="$fake_apksigner" \
|
||||||
|
"$verifier" "$apk" aabbccdd >/dev/null 2>&1; then
|
||||||
|
printf 'Expected missing certificate output to fail\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if FAKE_APKSIGNER_MODE=failure APKSIGNER="$fake_apksigner" \
|
||||||
|
"$verifier" "$apk" aabbccdd >/dev/null 2>&1; then
|
||||||
|
printf 'Expected signature verification failure to propagate\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'APK signature verifier tests passed.\n'
|
||||||
86
packaging/android/verify-apk-signature.sh
Executable file
86
packaging/android/verify-apk-signature.sh
Executable file
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ $# -ne 2 ]]; then
|
||||||
|
printf 'Usage: %s <apk> <expected-certificate-sha256>\n' "$0" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
apk=$1
|
||||||
|
expected_fingerprint=$2
|
||||||
|
build_tools_version=${ANDROID_BUILD_TOOLS_VERSION:-36.0.0}
|
||||||
|
|
||||||
|
normalize_fingerprint() {
|
||||||
|
printf '%s' "$1" |
|
||||||
|
tr -d '[:space:]:' |
|
||||||
|
tr '[:upper:]' '[:lower:]'
|
||||||
|
}
|
||||||
|
|
||||||
|
find_apksigner() {
|
||||||
|
if [[ -n ${APKSIGNER:-} ]]; then
|
||||||
|
[[ -x $APKSIGNER ]] || {
|
||||||
|
printf 'Configured apksigner is not executable: %s\n' "$APKSIGNER" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
printf '%s\n' "$APKSIGNER"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local sdk_root=${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}
|
||||||
|
if [[ -n $sdk_root ]]; then
|
||||||
|
local pinned="$sdk_root/build-tools/$build_tools_version/apksigner"
|
||||||
|
if [[ -x $pinned ]]; then
|
||||||
|
printf '%s\n' "$pinned"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v apksigner >/dev/null 2>&1; then
|
||||||
|
command -v apksigner
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'apksigner %s was not found in the Android SDK or PATH\n' \
|
||||||
|
"$build_tools_version" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ -s $apk ]] || {
|
||||||
|
printf 'APK is missing or empty: %s\n' "$apk" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
apksigner_path="$(find_apksigner)" || exit 1
|
||||||
|
if ! signature_report="$(
|
||||||
|
"$apksigner_path" verify --verbose --print-certs "$apk" 2>&1
|
||||||
|
)"; then
|
||||||
|
printf 'APK signature verification failed:\n%s\n' "$signature_report" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
actual_fingerprint="$(
|
||||||
|
printf '%s\n' "$signature_report" |
|
||||||
|
awk '
|
||||||
|
tolower($0) ~ /^signer #1 certificate sha-256 digest:[[:space:]]*/ {
|
||||||
|
line = $0
|
||||||
|
sub(/^[^:]*:[[:space:]]*/, "", line)
|
||||||
|
print line
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
'
|
||||||
|
)"
|
||||||
|
[[ -n $actual_fingerprint ]] || {
|
||||||
|
printf 'Could not read the APK signing certificate fingerprint\n' >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
actual_fingerprint="$(normalize_fingerprint "$actual_fingerprint")"
|
||||||
|
expected_fingerprint="$(normalize_fingerprint "$expected_fingerprint")"
|
||||||
|
[[ $actual_fingerprint == "$expected_fingerprint" ]] || {
|
||||||
|
printf 'APK signing certificate mismatch: expected %s, got %s\n' \
|
||||||
|
"$expected_fingerprint" "$actual_fingerprint" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
printf '%s\n' "$actual_fingerprint"
|
||||||
@@ -13,27 +13,37 @@ Platform workflows upload private workflow artifacts. After every native build
|
|||||||
passes, the release pipeline:
|
passes, the release pipeline:
|
||||||
|
|
||||||
1. stages the signed AAB as a draft on the configured Play closed-test track;
|
1. stages the signed AAB as a draft on the configured Play closed-test track;
|
||||||
2. downloads the universal APK signed by Play;
|
2. submits the unsigned `.msixupload` package to Microsoft Store certification;
|
||||||
3. verifies and assembles the public artifacts;
|
3. downloads the universal APK signed by Play;
|
||||||
4. generates checksums and GitHub build-provenance attestations;
|
4. verifies and assembles the public artifacts;
|
||||||
5. creates exactly one GitHub Release;
|
5. generates checksums and GitHub build-provenance attestations;
|
||||||
6. updates the Homebrew cask.
|
6. creates exactly one GitHub Release;
|
||||||
|
7. updates the Homebrew cask.
|
||||||
|
|
||||||
Public GitHub Release assets are the DEB, RPM, notarized DMG, Sparkle appcast,
|
Public GitHub Release assets are the DEB, RPM, notarized DMG, Sparkle appcast,
|
||||||
Play-signed universal APK, checksum file, and release manifest.
|
Play-signed universal APK, checksum file, and release manifest.
|
||||||
|
|
||||||
The unsigned Microsoft `.msixupload` and upload-signed Android AAB remain
|
The unsigned Microsoft `.msixupload` and upload-signed Android AAB remain
|
||||||
private workflow artifacts. Partner Center submission stays manual until the
|
private workflow artifacts. The protected `microsoft-store` GitHub Environment
|
||||||
first Microsoft Store release is certified. The Play release remains a draft
|
supplies the Partner Center credentials and Store product ID used to submit the
|
||||||
on a closed-testing track; this pipeline cannot publish to production.
|
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, first update and merge `version.properties`, including a monotonic
|
To release, prepare and merge the new product version. Android, Microsoft Store,
|
||||||
Android version code. Apple Store and Direct build numbers are derived
|
and Apple build/package versions are derived automatically:
|
||||||
independently at build time. Then create and push the matching tag:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git tag -s v0.2.0 -m "VniDrop 0.2.0"
|
make prepare-release RELEASE_VERSION=0.2.1
|
||||||
git push origin v0.2.0
|
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
|
The tag must point at the current `origin/master` commit. A failed run creates
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ rpm="$(find_single "$input_dir/rpm" '*.rpm' 'RPM package')"
|
|||||||
dmg="$(find_single "$input_dir/macos" '*.dmg' 'macOS DMG')"
|
dmg="$(find_single "$input_dir/macos" '*.dmg' 'macOS DMG')"
|
||||||
appcast="$(find_single "$input_dir/macos" 'appcast.xml' 'Sparkle appcast')"
|
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_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_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')"
|
play_metadata="$(find_single "$input_dir/play" 'play-release.json' 'Play release metadata')"
|
||||||
msix="$(find_single "$input_dir/windows" '*.msix' 'Windows MSIX')"
|
msix="$(find_single "$input_dir/windows" '*.msix' 'Windows MSIX')"
|
||||||
@@ -67,6 +68,7 @@ windows_metadata="$(find_single "$input_dir/windows" '*.build-info.json' 'Window
|
|||||||
[[ $(basename "$deb") == "vnidrop_${version}-1_amd64.deb" ]]
|
[[ $(basename "$deb") == "vnidrop_${version}-1_amd64.deb" ]]
|
||||||
[[ $(basename "$rpm") == "vnidrop-${version}-1.x86_64.rpm" ]]
|
[[ $(basename "$rpm") == "vnidrop-${version}-1.x86_64.rpm" ]]
|
||||||
[[ $(basename "$dmg") == "VniDrop-${version}.dmg" ]]
|
[[ $(basename "$dmg") == "VniDrop-${version}.dmg" ]]
|
||||||
|
[[ $(basename "$apple_core") == "VnidropCore-${version}.zip" ]]
|
||||||
[[ $(basename "$play_apk") == "VniDrop-${version}-${android_code}-play-universal.apk" ]]
|
[[ $(basename "$play_apk") == "VniDrop-${version}-${android_code}-play-universal.apk" ]]
|
||||||
[[ $(basename "$msix") == "VniDrop_${version}_x64.msix" ]]
|
[[ $(basename "$msix") == "VniDrop_${version}_x64.msix" ]]
|
||||||
[[ $(basename "$msixupload") == "VniDrop_${version}_x64.msixupload" ]]
|
[[ $(basename "$msixupload") == "VniDrop_${version}_x64.msixupload" ]]
|
||||||
@@ -83,10 +85,12 @@ deb_checksum="$(find_single "$input_dir/deb" '*.sha256' 'Debian checksum')"
|
|||||||
rpm_checksum="$(find_single "$input_dir/rpm" '*.sha256' 'RPM checksum')"
|
rpm_checksum="$(find_single "$input_dir/rpm" '*.sha256' 'RPM checksum')"
|
||||||
windows_checksums="$(find_single "$input_dir/windows" 'SHA256SUMS' 'Windows checksums')"
|
windows_checksums="$(find_single "$input_dir/windows" 'SHA256SUMS' 'Windows checksums')"
|
||||||
play_checksums="$(find_single "$input_dir/play" 'SHA256SUMS' 'Play APK 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 "$deb_checksum"
|
||||||
verify_checksum_file "$rpm_checksum"
|
verify_checksum_file "$rpm_checksum"
|
||||||
verify_checksum_file "$windows_checksums"
|
verify_checksum_file "$windows_checksums"
|
||||||
verify_checksum_file "$play_checksums"
|
verify_checksum_file "$play_checksums"
|
||||||
|
verify_checksum_file "$apple_core_checksum"
|
||||||
|
|
||||||
[[ $(jq -r '.releaseStatus' "$play_metadata") == draft ]]
|
[[ $(jq -r '.releaseStatus' "$play_metadata") == draft ]]
|
||||||
[[ $(jq -r '.releaseName' "$play_metadata") == "$version" ]]
|
[[ $(jq -r '.releaseName' "$play_metadata") == "$version" ]]
|
||||||
@@ -103,7 +107,7 @@ mkdir -p "$output_dir"
|
|||||||
printf 'Release output directory must be empty: %s\n' "$output_dir" >&2
|
printf 'Release output directory must be empty: %s\n' "$output_dir" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
cp "$deb" "$rpm" "$dmg" "$appcast" "$play_apk" "$output_dir/"
|
cp "$deb" "$rpm" "$dmg" "$appcast" "$play_apk" "$apple_core" "$output_dir/"
|
||||||
|
|
||||||
payloads=(
|
payloads=(
|
||||||
"$output_dir/$(basename "$deb")"
|
"$output_dir/$(basename "$deb")"
|
||||||
@@ -111,6 +115,7 @@ payloads=(
|
|||||||
"$output_dir/$(basename "$dmg")"
|
"$output_dir/$(basename "$dmg")"
|
||||||
"$output_dir/$(basename "$appcast")"
|
"$output_dir/$(basename "$appcast")"
|
||||||
"$output_dir/$(basename "$play_apk")"
|
"$output_dir/$(basename "$play_apk")"
|
||||||
|
"$output_dir/$(basename "$apple_core")"
|
||||||
)
|
)
|
||||||
files_json="$(
|
files_json="$(
|
||||||
for file in "${payloads[@]}"; do
|
for file in "${payloads[@]}"; do
|
||||||
@@ -168,6 +173,7 @@ jq -n \
|
|||||||
"$(basename "$dmg")" \
|
"$(basename "$dmg")" \
|
||||||
"$(basename "$appcast")" \
|
"$(basename "$appcast")" \
|
||||||
"$(basename "$play_apk")" \
|
"$(basename "$play_apk")" \
|
||||||
|
"$(basename "$apple_core")" \
|
||||||
release-manifest.json \
|
release-manifest.json \
|
||||||
> SHA256SUMS
|
> SHA256SUMS
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ printf 'deb\n' > "$input_dir/deb/vnidrop_${version}-1_amd64.deb"
|
|||||||
printf 'rpm\n' > "$input_dir/rpm/vnidrop-${version}-1.x86_64.rpm"
|
printf 'rpm\n' > "$input_dir/rpm/vnidrop-${version}-1.x86_64.rpm"
|
||||||
printf 'dmg\n' > "$input_dir/macos/VniDrop-${version}.dmg"
|
printf 'dmg\n' > "$input_dir/macos/VniDrop-${version}.dmg"
|
||||||
printf '<url>VniDrop-%s.dmg</url>\n' "$version" > "$input_dir/macos/appcast.xml"
|
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 'apk\n' > "$input_dir/play/VniDrop-${version}-${android_code}-play-universal.apk"
|
||||||
printf 'msix\n' > "$input_dir/windows/VniDrop_${version}_x64.msix"
|
printf 'msix\n' > "$input_dir/windows/VniDrop_${version}_x64.msix"
|
||||||
printf 'msixupload\n' > "$input_dir/windows/VniDrop_${version}_x64.msixupload"
|
printf 'msixupload\n' > "$input_dir/windows/VniDrop_${version}_x64.msixupload"
|
||||||
@@ -68,6 +69,11 @@ jq -n \
|
|||||||
sha256sum "vnidrop-${version}-1.x86_64.rpm" \
|
sha256sum "vnidrop-${version}-1.x86_64.rpm" \
|
||||||
> "vnidrop-${version}-1.x86_64.rpm.sha256"
|
> "vnidrop-${version}-1.x86_64.rpm.sha256"
|
||||||
)
|
)
|
||||||
|
(
|
||||||
|
cd "$input_dir/macos"
|
||||||
|
sha256sum "VnidropCore-${version}.zip" \
|
||||||
|
> "VnidropCore-${version}.zip.sha256"
|
||||||
|
)
|
||||||
(
|
(
|
||||||
cd "$input_dir/play"
|
cd "$input_dir/play"
|
||||||
sha256sum \
|
sha256sum \
|
||||||
@@ -94,6 +100,7 @@ expected_public_files=(
|
|||||||
"SHA256SUMS"
|
"SHA256SUMS"
|
||||||
"VniDrop-${version}-${android_code}-play-universal.apk"
|
"VniDrop-${version}-${android_code}-play-universal.apk"
|
||||||
"VniDrop-${version}.dmg"
|
"VniDrop-${version}.dmg"
|
||||||
|
"VnidropCore-${version}.zip"
|
||||||
"appcast.xml"
|
"appcast.xml"
|
||||||
"release-manifest.json"
|
"release-manifest.json"
|
||||||
"vnidrop-${version}-1.x86_64.rpm"
|
"vnidrop-${version}-1.x86_64.rpm"
|
||||||
|
|||||||
56
packaging/release/test-release-config.sh
Executable file
56
packaging/release/test-release-config.sh
Executable file
@@ -0,0 +1,56 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
repo_root="$(cd "$script_dir/../.." && pwd)"
|
||||||
|
|
||||||
|
dry_run="$(make -n -C "$repo_root" build-apple-dmg)"
|
||||||
|
localization_line="$(
|
||||||
|
printf '%s\n' "$dry_run" |
|
||||||
|
awk '/bun run generate/ {print NR; exit}'
|
||||||
|
)"
|
||||||
|
build_line="$(
|
||||||
|
printf '%s\n' "$dry_run" |
|
||||||
|
awk '/apple\/scripts\/build-dmg\.sh/ {print NR; exit}'
|
||||||
|
)"
|
||||||
|
[[ -n $localization_line && -n $build_line && $localization_line -lt $build_line ]] || {
|
||||||
|
printf 'build-apple-dmg must generate localization before building the DMG\n' >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
grep -F 'run: make build-apple-dmg' \
|
||||||
|
"$repo_root/.github/workflows/apple-release.yml" >/dev/null || {
|
||||||
|
printf 'Apple release workflow must use the generated-input-aware Make target\n' >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
store_reconfigure_line="$(
|
||||||
|
awk '/msstore reconfigure/ {print NR; exit}' \
|
||||||
|
"$repo_root/.github/workflows/release.yml"
|
||||||
|
)"
|
||||||
|
store_settings_line="$(
|
||||||
|
awk '/msstore settings --enableTelemetry false/ {print NR; exit}' \
|
||||||
|
"$repo_root/.github/workflows/release.yml"
|
||||||
|
)"
|
||||||
|
[[ -n $store_reconfigure_line &&
|
||||||
|
-n $store_settings_line &&
|
||||||
|
$store_reconfigure_line -lt $store_settings_line ]] || {
|
||||||
|
printf 'Microsoft Store CLI credentials must be configured before changing settings\n' >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
signing_line="$(
|
||||||
|
awk '/sign-exported-app\.sh/ {print NR; exit}' \
|
||||||
|
"$repo_root/apple/scripts/build-dmg.sh"
|
||||||
|
)"
|
||||||
|
dmg_line="$(
|
||||||
|
awk '/echo "==> Building DMG"/ {print NR; exit}' \
|
||||||
|
"$repo_root/apple/scripts/build-dmg.sh"
|
||||||
|
)"
|
||||||
|
[[ -n $signing_line && -n $dmg_line && $signing_line -lt $dmg_line ]] || {
|
||||||
|
printf 'The exported app must enforce hardened-runtime signing before DMG creation\n' >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
printf 'Release configuration tests passed.\n'
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
# Application versioning
|
# Application versioning
|
||||||
|
|
||||||
`version.properties` at the repository root is the single source of truth for
|
`version.properties` at the repository root is the single source of truth for
|
||||||
the VniDrop product version and persistent store counters. Platform projects
|
the VniDrop product version and the permanent Windows version epoch. Platform
|
||||||
and release workflows use the version resolver rather than accepting
|
projects and release workflows derive their versions from it rather than
|
||||||
independent version overrides.
|
accepting independent release counters.
|
||||||
|
|
||||||
Keep it as plain `KEY=VALUE` assignments: the same file is parsed by shell,
|
Keep it as plain `KEY=VALUE` assignments: the same file is parsed by shell,
|
||||||
PowerShell, Gradle, and Rust. Xcode receives resolver-generated xcconfig files.
|
PowerShell, Gradle, and Rust. Xcode receives resolver-generated xcconfig files.
|
||||||
@@ -15,7 +15,7 @@ the patch component (`0.2.1`). Release channels belong in
|
|||||||
|
|
||||||
| Platform | Product version | Platform build/package version |
|
| Platform | Product version | Platform build/package version |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Android | `PRODUCT_VERSION` | `ANDROID_VERSION_CODE` |
|
| Android | `PRODUCT_VERSION` | Derived monotonic integer |
|
||||||
| Apple Store | `PRODUCT_VERSION` | Derived UTC `YYYYMMDD.HHMM.SS` |
|
| Apple Store | `PRODUCT_VERSION` | Derived UTC `YYYYMMDD.HHMM.SS` |
|
||||||
| Direct macOS | `PRODUCT_VERSION` | Independently derived UTC `YYYYMMDD.HHMM.SS` |
|
| Direct macOS | `PRODUCT_VERSION` | Independently derived UTC `YYYYMMDD.HHMM.SS` |
|
||||||
| Linux | `PRODUCT_VERSION` | Native package revision |
|
| Linux | `PRODUCT_VERSION` | Native package revision |
|
||||||
@@ -32,12 +32,23 @@ the Store. Its version is:
|
|||||||
With epoch `1`, product `0.2.0` maps to MSIX `1.2.0.0`, while product `1.0.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.
|
maps to `2.0.0.0`. Do not change the epoch after publishing.
|
||||||
|
|
||||||
Every Android upload must increment `ANDROID_VERSION_CODE`. Apple build numbers
|
Android derives its version code as:
|
||||||
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
|
```text
|
||||||
consume each other's cadence. Every changed Windows Store package must
|
product major * 1,000,000 + product minor * 1,000 + product patch
|
||||||
increment the product version because the Store-reserved fourth component
|
```
|
||||||
cannot carry a rebuild number.
|
|
||||||
|
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`
|
Apple projects read generated build settings rather than `version.properties`
|
||||||
directly:
|
directly:
|
||||||
@@ -50,7 +61,14 @@ The generated files under `apple/Generated/` are intentionally ignored.
|
|||||||
`VNIDROP_BUILD_TIME_UTC=YYYYMMDDHHMMSS` provides a deterministic clock for
|
`VNIDROP_BUILD_TIME_UTC=YYYYMMDDHHMMSS` provides a deterministic clock for
|
||||||
tests; distribution builds normally use the current UTC time.
|
tests; distribution builds normally use the current UTC time.
|
||||||
|
|
||||||
Before releasing:
|
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
|
```bash
|
||||||
make check-version
|
make check-version
|
||||||
|
|||||||
53
packaging/version/prepare-release.sh
Executable file
53
packaging/version/prepare-release.sh
Executable file
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
repo_root="$(cd "$script_dir/../.." && pwd)"
|
||||||
|
resolver="$script_dir/resolve-version.sh"
|
||||||
|
version_file="${VNIDROP_VERSION_FILE:-$repo_root/version.properties}"
|
||||||
|
next_version="${1:-}"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf '%s\n' "$*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ -n $next_version ]] ||
|
||||||
|
fail "Usage: $0 MAJOR.MINOR.PATCH"
|
||||||
|
[[ -f $version_file ]] ||
|
||||||
|
fail "Version file not found: $version_file"
|
||||||
|
[[ $(grep -c '^PRODUCT_VERSION=' "$version_file") == 1 ]] ||
|
||||||
|
fail "Expected exactly one PRODUCT_VERSION entry in $version_file"
|
||||||
|
|
||||||
|
current_version="$(
|
||||||
|
VNIDROP_VERSION_FILE="$version_file" "$resolver" product
|
||||||
|
)"
|
||||||
|
current_android_code="$(
|
||||||
|
VNIDROP_VERSION_FILE="$version_file" "$resolver" android-code
|
||||||
|
)"
|
||||||
|
temporary="$(mktemp "$(dirname "$version_file")/.version.properties.XXXXXX")"
|
||||||
|
trap 'rm -f "$temporary"' EXIT
|
||||||
|
|
||||||
|
sed "s/^PRODUCT_VERSION=.*/PRODUCT_VERSION=$next_version/" \
|
||||||
|
"$version_file" > "$temporary"
|
||||||
|
|
||||||
|
next_android_code="$(
|
||||||
|
VNIDROP_VERSION_FILE="$temporary" "$resolver" android-code
|
||||||
|
)"
|
||||||
|
next_windows_package="$(
|
||||||
|
VNIDROP_VERSION_FILE="$temporary" "$resolver" windows-package
|
||||||
|
)"
|
||||||
|
VNIDROP_VERSION_FILE="$temporary" "$resolver" verify >/dev/null
|
||||||
|
|
||||||
|
(( next_android_code > current_android_code )) ||
|
||||||
|
fail "New version must be greater than $current_version"
|
||||||
|
|
||||||
|
chmod 644 "$temporary"
|
||||||
|
mv "$temporary" "$version_file"
|
||||||
|
trap - EXIT
|
||||||
|
|
||||||
|
printf 'Prepared VniDrop %s\n' "$next_version"
|
||||||
|
printf ' Android version code: %s\n' "$next_android_code"
|
||||||
|
printf ' Microsoft Store package: %s\n' "$next_windows_package"
|
||||||
|
printf 'Next: make check-version\n'
|
||||||
@@ -47,7 +47,6 @@ function Convert-CanonicalInteger {
|
|||||||
|
|
||||||
$productVersion = Read-VersionProperty "PRODUCT_VERSION"
|
$productVersion = Read-VersionProperty "PRODUCT_VERSION"
|
||||||
$releaseChannel = Read-VersionProperty "RELEASE_CHANNEL"
|
$releaseChannel = Read-VersionProperty "RELEASE_CHANNEL"
|
||||||
$androidVersionCodeText = Read-VersionProperty "ANDROID_VERSION_CODE"
|
|
||||||
$windowsVersionEpochText = Read-VersionProperty "WINDOWS_VERSION_EPOCH"
|
$windowsVersionEpochText = Read-VersionProperty "WINDOWS_VERSION_EPOCH"
|
||||||
$buildTimeUtc = $env:VNIDROP_BUILD_TIME_UTC
|
$buildTimeUtc = $env:VNIDROP_BUILD_TIME_UTC
|
||||||
if ([string]::IsNullOrWhiteSpace($buildTimeUtc)) {
|
if ([string]::IsNullOrWhiteSpace($buildTimeUtc)) {
|
||||||
@@ -78,13 +77,16 @@ 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"
|
throw "PRODUCT_VERSION must use canonical MAJOR.MINOR.PATCH integers"
|
||||||
}
|
}
|
||||||
$productParts = $productVersion.Split(".")
|
$productParts = $productVersion.Split(".")
|
||||||
$productMajor = Convert-CanonicalInteger "PRODUCT_VERSION major" $productParts[0] 0 65534
|
$productMajor = Convert-CanonicalInteger "PRODUCT_VERSION major" $productParts[0] 0 2099
|
||||||
$null = Convert-CanonicalInteger "PRODUCT_VERSION minor" $productParts[1] 0 65535
|
$productMinor = Convert-CanonicalInteger "PRODUCT_VERSION minor" $productParts[1] 0 999
|
||||||
$null = Convert-CanonicalInteger "PRODUCT_VERSION patch" $productParts[2] 0 65535
|
$productPatch = Convert-CanonicalInteger "PRODUCT_VERSION patch" $productParts[2] 0 999
|
||||||
if ($releaseChannel -notmatch "^[a-z][a-z0-9-]*$") {
|
if ($releaseChannel -notmatch "^[a-z][a-z0-9-]*$") {
|
||||||
throw "RELEASE_CHANNEL contains unsupported characters"
|
throw "RELEASE_CHANNEL contains unsupported characters"
|
||||||
}
|
}
|
||||||
$androidVersionCode = Convert-CanonicalInteger "ANDROID_VERSION_CODE" $androidVersionCodeText 1 2100000000
|
$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
|
$windowsVersionEpoch = Convert-CanonicalInteger "WINDOWS_VERSION_EPOCH" $windowsVersionEpochText 1 65535
|
||||||
$windowsMajor = $productMajor + $windowsVersionEpoch
|
$windowsMajor = $productMajor + $windowsVersionEpoch
|
||||||
if ($windowsMajor -gt 65535) {
|
if ($windowsMajor -gt 65535) {
|
||||||
|
|||||||
@@ -34,23 +34,29 @@ validate_canonical_integer() {
|
|||||||
|
|
||||||
product_version="$(read_property PRODUCT_VERSION)"
|
product_version="$(read_property PRODUCT_VERSION)"
|
||||||
release_channel="$(read_property RELEASE_CHANNEL)"
|
release_channel="$(read_property RELEASE_CHANNEL)"
|
||||||
android_version_code="$(read_property ANDROID_VERSION_CODE)"
|
|
||||||
windows_version_epoch="$(read_property WINDOWS_VERSION_EPOCH)"
|
windows_version_epoch="$(read_property WINDOWS_VERSION_EPOCH)"
|
||||||
build_time_utc="${VNIDROP_BUILD_TIME_UTC:-$(date -u +%Y%m%d%H%M%S)}"
|
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]*)$ ]] ||
|
[[ $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"
|
fail "PRODUCT_VERSION must use canonical MAJOR.MINOR.PATCH integers"
|
||||||
IFS=. read -r product_major product_minor product_patch <<< "$product_version"
|
IFS=. read -r product_major product_minor product_patch <<< "$product_version"
|
||||||
validate_canonical_integer "PRODUCT_VERSION major" "$product_major" 0 65534
|
validate_canonical_integer "PRODUCT_VERSION major" "$product_major" 0 2099
|
||||||
validate_canonical_integer "PRODUCT_VERSION minor" "$product_minor" 0 65535
|
validate_canonical_integer "PRODUCT_VERSION minor" "$product_minor" 0 999
|
||||||
validate_canonical_integer "PRODUCT_VERSION patch" "$product_patch" 0 65535
|
validate_canonical_integer "PRODUCT_VERSION patch" "$product_patch" 0 999
|
||||||
[[ $release_channel =~ ^[a-z][a-z0-9-]*$ ]] ||
|
[[ $release_channel =~ ^[a-z][a-z0-9-]*$ ]] ||
|
||||||
fail "RELEASE_CHANNEL must start with a lowercase letter and contain only lowercase letters, digits, and hyphens"
|
fail "RELEASE_CHANNEL must start with a lowercase letter and contain only lowercase letters, digits, and hyphens"
|
||||||
validate_canonical_integer "ANDROID_VERSION_CODE" "$android_version_code" 1 2100000000
|
|
||||||
validate_canonical_integer "WINDOWS_VERSION_EPOCH" "$windows_version_epoch" 1 65535
|
validate_canonical_integer "WINDOWS_VERSION_EPOCH" "$windows_version_epoch" 1 65535
|
||||||
[[ $build_time_utc =~ ^[0-9]{14}$ ]] ||
|
[[ $build_time_utc =~ ^[0-9]{14}$ ]] ||
|
||||||
fail "VNIDROP_BUILD_TIME_UTC must use YYYYMMDDHHMMSS"
|
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_month="${build_time_utc:4:2}"
|
||||||
build_day="${build_time_utc:6:2}"
|
build_day="${build_time_utc:6:2}"
|
||||||
build_hour="${build_time_utc:8:2}"
|
build_hour="${build_time_utc:8:2}"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ set -euo pipefail
|
|||||||
|
|
||||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
resolver="$script_dir/resolve-version.sh"
|
resolver="$script_dir/resolve-version.sh"
|
||||||
|
prepare_release="$script_dir/prepare-release.sh"
|
||||||
scratch="$(mktemp -d)"
|
scratch="$(mktemp -d)"
|
||||||
trap 'rm -rf "$scratch"' EXIT
|
trap 'rm -rf "$scratch"' EXIT
|
||||||
|
|
||||||
@@ -11,8 +12,7 @@ write_version() {
|
|||||||
printf '%s\n' \
|
printf '%s\n' \
|
||||||
"PRODUCT_VERSION=$1" \
|
"PRODUCT_VERSION=$1" \
|
||||||
"RELEASE_CHANNEL=$2" \
|
"RELEASE_CHANNEL=$2" \
|
||||||
"ANDROID_VERSION_CODE=$3" \
|
"WINDOWS_VERSION_EPOCH=$3" \
|
||||||
"WINDOWS_VERSION_EPOCH=$4" \
|
|
||||||
> "$scratch/version.properties"
|
> "$scratch/version.properties"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,33 +29,44 @@ expect_failure() {
|
|||||||
|
|
||||||
export VNIDROP_BUILD_TIME_UTC=20260728143217
|
export VNIDROP_BUILD_TIME_UTC=20260728143217
|
||||||
|
|
||||||
write_version 0.2.0 beta 2 1
|
write_version 0.2.0 beta 1
|
||||||
[[ $(resolve product) == 0.2.0 ]]
|
[[ $(resolve product) == 0.2.0 ]]
|
||||||
[[ $(resolve android-code) == 2 ]]
|
[[ $(resolve android-code) == 2000 ]]
|
||||||
[[ $(resolve apple-store-build) == 20260728.1432.17 ]]
|
[[ $(resolve apple-store-build) == 20260728.1432.17 ]]
|
||||||
[[ $(resolve apple-direct-build) == 20260728.1432.17 ]]
|
[[ $(resolve apple-direct-build) == 20260728.1432.17 ]]
|
||||||
[[ $(resolve windows-package) == 1.2.0.0 ]]
|
[[ $(resolve windows-package) == 1.2.0.0 ]]
|
||||||
resolve verify-tag v0.2.0
|
resolve verify-tag v0.2.0
|
||||||
expect_failure resolve verify-tag v1.0.0
|
expect_failure resolve verify-tag v1.0.0
|
||||||
|
|
||||||
write_version 1.0.0 stable 42 1
|
write_version 1.0.0 stable 1
|
||||||
|
[[ $(resolve android-code) == 1000000 ]]
|
||||||
[[ $(resolve windows-package) == 2.0.0.0 ]]
|
[[ $(resolve windows-package) == 2.0.0.0 ]]
|
||||||
|
|
||||||
write_version 01.0.0 beta 2 1
|
write_version 2099.999.999 stable 1
|
||||||
|
[[ $(resolve android-code) == 2099999999 ]]
|
||||||
|
|
||||||
|
write_version 01.0.0 beta 1
|
||||||
expect_failure resolve verify
|
expect_failure resolve verify
|
||||||
|
|
||||||
write_version 0.2.0 beta 0 1
|
write_version 0.0.0 beta 1
|
||||||
expect_failure resolve verify
|
expect_failure resolve verify
|
||||||
|
|
||||||
write_version 65535.0.0 stable 2 1
|
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
|
expect_failure resolve verify
|
||||||
|
|
||||||
VNIDROP_BUILD_TIME_UTC=20260728146000 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=2026-07-28 expect_failure resolve verify
|
||||||
VNIDROP_BUILD_TIME_UTC=20260229080000 expect_failure resolve verify
|
VNIDROP_BUILD_TIME_UTC=20260229080000 expect_failure resolve verify
|
||||||
|
|
||||||
write_version 0.2.0 beta 2 1
|
write_version 0.2.0 beta 1
|
||||||
config_dir="$scratch/xcconfig"
|
config_dir="$scratch/xcconfig"
|
||||||
|
VNIDROP_VERSION_FILE="$scratch/version.properties" \
|
||||||
VNIDROP_APPLE_XCCONFIG_DIR="$config_dir" \
|
VNIDROP_APPLE_XCCONFIG_DIR="$config_dir" \
|
||||||
"$script_dir/generate-apple-xcconfig.sh" all
|
"$script_dir/generate-apple-xcconfig.sh" all
|
||||||
grep -Fx "PRODUCT_VERSION = 0.2.0" "$config_dir/StoreVersion.xcconfig" >/dev/null
|
grep -Fx "PRODUCT_VERSION = 0.2.0" "$config_dir/StoreVersion.xcconfig" >/dev/null
|
||||||
@@ -64,4 +75,14 @@ grep -Fx "CURRENT_PROJECT_VERSION = 20260728.1432.17" \
|
|||||||
grep -Fx "CURRENT_PROJECT_VERSION = 20260728.1432.17" \
|
grep -Fx "CURRENT_PROJECT_VERSION = 20260728.1432.17" \
|
||||||
"$config_dir/DirectVersion.xcconfig" >/dev/null
|
"$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'
|
printf 'Version resolver tests passed.\n'
|
||||||
|
|||||||
@@ -74,16 +74,21 @@ Use this restricted-capability justification in Submission options:
|
|||||||
> Rust and JVM libraries and needs normal user-level filesystem and network
|
> Rust and JVM libraries and needs normal user-level filesystem and network
|
||||||
> access to transfer user-selected files directly between devices.
|
> access to transfer user-selected files directly between devices.
|
||||||
|
|
||||||
After the first release is certified and live, Store publication can be added
|
After the first release is certified and live, the coordinated release
|
||||||
as a separate protected job. Keep its Partner Center credentials in a GitHub
|
workflow submits the generated `.msixupload` from a separate protected job.
|
||||||
Environment, not in this build job:
|
Keep its Partner Center credentials in the `microsoft-store` GitHub
|
||||||
|
Environment, not in the build job:
|
||||||
|
|
||||||
- AZURE_AD_TENANT_ID
|
- AZURE_AD_TENANT_ID
|
||||||
- AZURE_AD_APPLICATION_CLIENT_ID
|
- AZURE_AD_APPLICATION_CLIENT_ID
|
||||||
- AZURE_AD_APPLICATION_SECRET
|
- AZURE_AD_APPLICATION_SECRET
|
||||||
- SELLER_ID
|
- SELLER_ID
|
||||||
|
|
||||||
The Store ID is a non-secret variable.
|
Set `MICROSOFT_STORE_PRODUCT_ID` to `9NJ5Q0FG7TGL` as a non-secret variable in
|
||||||
|
the same environment. The publishing job validates the product ID,
|
||||||
|
authenticates with the pinned Microsoft Store Developer CLI, verifies access to
|
||||||
|
the product, and submits only the package for certification. Existing listings,
|
||||||
|
pricing, and availability are preserved.
|
||||||
|
|
||||||
## Manual build on Windows
|
## Manual build on Windows
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
PRODUCT_VERSION=0.2.0
|
PRODUCT_VERSION=0.2.4
|
||||||
RELEASE_CHANNEL=beta
|
RELEASE_CHANNEL=beta
|
||||||
ANDROID_VERSION_CODE=2
|
|
||||||
WINDOWS_VERSION_EPOCH=1
|
WINDOWS_VERSION_EPOCH=1
|
||||||
|
|||||||
Reference in New Issue
Block a user