Merge pull request #40 from sudosylabs/feat/macos-approval-modal-fix

fix(apple): show macOS approval modal + publish prebuilt core bundle
This commit is contained in:
Hammed Abass
2026-07-31 17:07:40 +02:00
committed by GitHub
12 changed files with 281 additions and 69 deletions

View File

@@ -134,6 +134,12 @@ 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 - name: Upload notarization diagnostics
if: failure() if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -156,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

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

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

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

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

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