13 Commits

Author SHA1 Message Date
fe97c21c7a fix(apple): keep the snackbar above the approval overlay
The earlier approval-modal fix folded SnackbarHost and the approval modal into a
single OverlayLayer child; nested that way the approval host's full-bleed clear
layer covered the toast, so snackbars stopped appearing.

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

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

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

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

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

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

While here:
- Present the approval only after any open share/QR sheet has actually finished
  dismissing (macOS can't stack sheets), driven off the sheet's real
  `onDismiss` completion via a new `AdaptiveDrawer.onDismissed` hook and
  `SendModel.shareSheetsDismissed` — no wall-clock delay.
- Move the list-level share-sheet state (`shareTargetId`) into `SendModel` so the
  approval flow can dismiss every share surface centrally.
- Add a fallback: pending receiver rows in the Receivers panel now offer an
  Approve action (`SendModel.acceptReceiver`) alongside Refuse, for the case the
  modal didn't surface.
2026-07-31 11:19:28 +02:00
56d19014d4 chore(release): prepare 0.2.4 2026-07-31 04:59:36 +02:00
51bf0abba2 fix(release): configure Store CLI before settings 2026-07-31 04:51:31 +02:00
e0fb84ccb9 chore(release): prepare 0.2.3 2026-07-30 22:22:11 +02:00
30025a4ebf fix(release): download Play APK media 2026-07-30 22:19:40 +02:00
50e9a6c1cc chore(release): prepare 0.2.2 2026-07-30 21:54:54 +02:00
224a8e0e7a fix(release): enforce Apple hardened runtime 2026-07-30 21:27:34 +02:00
efacfab213 fix(release): expose Apple notarization failures 2026-07-30 21:03:30 +02:00
Hammed Abass
0ec7618ce8 Merge pull request #39 from sudosylabs/feat/release-pipeline-fixes
fix(release): repair Apple and Android builds
2026-07-30 20:30:28 +02:00
22 changed files with 698 additions and 88 deletions

View File

@@ -134,6 +134,21 @@ jobs:
- name: Build, sign & notarize DMG - name: Build, sign & notarize DMG
run: make build-apple-dmg run: make build-apple-dmg
- name: Package prebuilt core
# build-apple-dmg builds the release Rust core + Swift bindings; bundle them
# (xcframework + Vnidrop.swift + checksum) as a release asset so consumers can
# skip building the core. See apple/scripts/package-core.sh.
run: make package-apple-core
- name: Upload notarization diagnostics
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: vnidrop-${{ steps.version.outputs.app }}-notarization-diagnostics
path: apple/dist/*.notary-log.json
if-no-files-found: ignore
retention-days: 14
- name: Generate appcast - name: Generate appcast
env: env:
RELEASE_REPO: ${{ github.repository }} RELEASE_REPO: ${{ github.repository }}
@@ -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

View File

@@ -295,10 +295,6 @@ jobs:
SELLER_ID: ${{ secrets.SELLER_ID }} SELLER_ID: ${{ secrets.SELLER_ID }}
MICROSOFT_STORE_PRODUCT_ID: ${{ vars.MICROSOFT_STORE_PRODUCT_ID }} MICROSOFT_STORE_PRODUCT_ID: ${{ vars.MICROSOFT_STORE_PRODUCT_ID }}
run: | run: |
msstore settings --enableTelemetry false
if ($LASTEXITCODE -ne 0) {
throw "Failed to disable Microsoft Store CLI telemetry"
}
msstore reconfigure ` msstore reconfigure `
--tenantId "$env:AZURE_AD_TENANT_ID" ` --tenantId "$env:AZURE_AD_TENANT_ID" `
--sellerId "$env:SELLER_ID" ` --sellerId "$env:SELLER_ID" `
@@ -307,6 +303,10 @@ jobs:
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
throw "Microsoft Store authentication failed" 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" msstore apps get "$env:MICROSOFT_STORE_PRODUCT_ID"
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
throw "The Microsoft Store application is not accessible" throw "The Microsoft Store application is not accessible"

View File

@@ -12,7 +12,7 @@ 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: prepare-release 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
@@ -71,7 +71,9 @@ check-version: ## Validate the canonical version and its platform mappings.
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/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) && 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/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) && packaging/release/test-release-config.sh
@@ -148,6 +150,9 @@ build-apple-macos-direct: apple-project ## Build the direct-download macOS targe
build-apple-dmg: localization ## 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"

View File

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

View File

@@ -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,14 +48,12 @@ 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). for window in NSApp.windows where window.canBecomeMain {
for window in NSApp.windows where window.canBecomeMain { window.makeKeyAndOrderFront(nil)
window.makeKeyAndOrderFront(nil) break
break
}
} }
#endif #endif
} }

View File

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

View File

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

View File

@@ -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,14 +265,27 @@ private struct ReceiverRow: View {
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
if isCancelable { if isCancelable {
Button(role: .destructive) { VStack(alignment: .trailing, spacing: 8) {
onCancel(receiver.id) Button(role: .destructive) {
} label: { onCancel(receiver.id)
Text(String(localized: L10n.Button.refuse)) } label: {
.font(VniType.bodySmall) Text(String(localized: L10n.Button.refuse))
.font(VniType.bodySmall)
}
.buttonStyle(.borderless)
.tint(.red)
// Fallback approve action, in case the approval modal didn't surface.
Button {
onAccept(receiver.id)
} label: {
Text(String(localized: L10n.Button.approve))
.font(VniType.bodySmall).fontWeight(.medium)
.foregroundStyle(.white)
.padding(.horizontal, 16).padding(.vertical, 7)
.background(Color.green, in: Capsule())
}
.buttonStyle(.plain)
} }
.buttonStyle(.borderless)
.tint(.red)
} }
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)

View File

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

View File

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

View File

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

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

72
apple/scripts/package-core.sh Executable file
View File

@@ -0,0 +1,72 @@
#!/usr/bin/env bash
#
# Packages the prebuilt Apple core into a single zip + checksum, for attaching to
# the GitHub Release. Lets a consumer (e.g. Xcode Cloud) use the compiled core
# instead of installing Rust and running build-core.sh. Run AFTER the core exists
# (apple/scripts/build-core.sh, or `make apple-core` / `make build-apple-dmg`).
#
# The bundle carries both build outputs of build-core.sh:
# - vnidrop.xcframework (static libs for device/sim/macOS + the FFI module)
# - Vnidrop.swift (generated UniFFI bindings — a plain source file, not
# part of the xcframework, so it must ship alongside)
#
# Produces (under apple/dist):
# VnidropCore-<version>.zip
# VnidropCore-<version>.zip.sha256 (sha256sum(1)/shasum-compatible format)
#
# Zip layout (root):
# vnidrop.xcframework/
# Vnidrop.swift
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
APPLE_DIR="$REPO_ROOT/apple"
PKG_DIR="$APPLE_DIR/VnidropCore"
XCFRAMEWORK="$PKG_DIR/vnidrop.xcframework"
BINDINGS="$PKG_DIR/Sources/VnidropCore/Vnidrop.swift"
DIST_DIR="$APPLE_DIR/dist"
VERSION="$("$REPO_ROOT/packaging/version/resolve-version.sh" product)"
NAME="VnidropCore-$VERSION"
ZIP="$DIST_DIR/$NAME.zip"
CHECKSUM="$ZIP.sha256"
[ -d "$XCFRAMEWORK" ] || {
echo "error: missing xcframework: $XCFRAMEWORK" >&2
echo " build the core first (apple/scripts/build-core.sh)." >&2
exit 1
}
[ -f "$BINDINGS" ] || {
echo "error: missing generated bindings: $BINDINGS" >&2
echo " build the core first (apple/scripts/build-core.sh)." >&2
exit 1
}
mkdir -p "$DIST_DIR"
rm -f "$ZIP" "$CHECKSUM"
# Stage a clean tree so the zip root holds exactly the two payloads (no absolute
# paths or stray parent directories leak into the archive).
STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT
cp -R "$XCFRAMEWORK" "$STAGE/vnidrop.xcframework"
cp "$BINDINGS" "$STAGE/Vnidrop.swift"
# -X drops extra file attributes for a stabler archive across machines.
( cd "$STAGE" && zip -q -r -X "$ZIP" vnidrop.xcframework Vnidrop.swift )
# sha256sum on Linux; shasum -a 256 on macOS. Both emit "<hash> <name>", which
# `sha256sum --check` (used by assemble-release.sh) accepts.
(
cd "$DIST_DIR"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$NAME.zip" > "$NAME.zip.sha256"
else
shasum -a 256 "$NAME.zip" > "$NAME.zip.sha256"
fi
)
echo "==> Packaged prebuilt core"
echo " zip: $ZIP"
echo " checksum: $CHECKSUM"

View File

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

View File

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

View File

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

View File

@@ -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,
),
) )
output.parent.mkdir(parents=True, exist_ok=True) if apk:
output.write_bytes(client.request("GET", url)) output.parent.mkdir(parents=True, exist_ok=True)
if output.stat().st_size == 0: output.write_bytes(apk)
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"
) )

View File

@@ -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",

View File

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

View File

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

View File

@@ -25,4 +25,32 @@ grep -F 'run: make build-apple-dmg' \
exit 1 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' printf 'Release configuration tests passed.\n'

View File

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