mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
Compare commits
14 Commits
feat/relea
...
d52ac52cea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d52ac52cea | ||
| fe97c21c7a | |||
| c670dda0a9 | |||
| ff391f5502 | |||
| 9079c81409 | |||
| 5424da855e | |||
| 56d19014d4 | |||
| 51bf0abba2 | |||
| e0fb84ccb9 | |||
| 30025a4ebf | |||
| 50e9a6c1cc | |||
| 224a8e0e7a | |||
| efacfab213 | |||
|
|
0ec7618ce8 |
17
.github/workflows/apple-release.yml
vendored
17
.github/workflows/apple-release.yml
vendored
@@ -134,6 +134,21 @@ jobs:
|
||||
- name: Build, sign & notarize 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
|
||||
env:
|
||||
RELEASE_REPO: ${{ github.repository }}
|
||||
@@ -147,5 +162,7 @@ jobs:
|
||||
apple/dist/VniDrop-*.dmg
|
||||
apple/dist/VniDrop-*.build-info.json
|
||||
apple/dist/appcast.xml
|
||||
apple/dist/VnidropCore-*.zip
|
||||
apple/dist/VnidropCore-*.zip.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@@ -295,10 +295,6 @@ jobs:
|
||||
SELLER_ID: ${{ secrets.SELLER_ID }}
|
||||
MICROSOFT_STORE_PRODUCT_ID: ${{ vars.MICROSOFT_STORE_PRODUCT_ID }}
|
||||
run: |
|
||||
msstore settings --enableTelemetry false
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Failed to disable Microsoft Store CLI telemetry"
|
||||
}
|
||||
msstore reconfigure `
|
||||
--tenantId "$env:AZURE_AD_TENANT_ID" `
|
||||
--sellerId "$env:SELLER_ID" `
|
||||
@@ -307,6 +303,10 @@ jobs:
|
||||
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"
|
||||
|
||||
9
Makefile
9
Makefile
@@ -12,7 +12,7 @@ include $(ROOT)/make/release.mk
|
||||
.PHONY: format test check check-rust audit-rust test-rust test-rust-all
|
||||
.PHONY: test-rust-transfer test-rust-approval test-rust-lifecycle test-rust-output-sink
|
||||
.PHONY: check-shared test-shared test-android-host check-android verify-android-libs build-android run-desktop
|
||||
.PHONY: apple-core apple-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: 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)
|
||||
|
||||
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/release/test-assemble-release.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).
|
||||
cd $(ROOT) && apple/scripts/build-dmg.sh
|
||||
|
||||
package-apple-core: ## Zip the prebuilt core (xcframework + bindings) + checksum into apple/dist (build the core first).
|
||||
cd $(ROOT) && apple/scripts/package-core.sh
|
||||
|
||||
open-apple: build-apple-macos ## Build and launch the native macOS app.
|
||||
@test -d "$(APPLE_DERIVED_DATA)/Build/Products/$(APPLE_CONFIGURATION)/VniDrop.app" || { printf 'Built macOS app was not found.\n' >&2; exit 1; }
|
||||
$(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 receiveModel: ReceiveModel
|
||||
@StateObject private var settingsModel: SettingsModel
|
||||
@ObservedObject private var messages: UiMessageController
|
||||
@ObservedObject private var approvals: ApprovalCoordinator
|
||||
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
/// Drives the approval sheet; toggled from the pending-approval `onChange` so the
|
||||
/// presentation can be deferred until the Share/QR sheet has dismissed on macOS.
|
||||
@State private var showApproval = false
|
||||
|
||||
init(dependencies: AppDependencies) {
|
||||
let graph = AppGraph(dependencies: dependencies)
|
||||
_graph = StateObject(wrappedValue: graph)
|
||||
@@ -50,8 +44,6 @@ struct RootView: View {
|
||||
messages: graph.messages,
|
||||
bugReports: NoopBugReportService()
|
||||
))
|
||||
messages = graph.messages
|
||||
approvals = graph.approvalCoordinator
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -60,13 +52,17 @@ struct RootView: View {
|
||||
let isDark = resolveDarkTheme(appModel.themeMode, systemDark: systemDark)
|
||||
ZStack {
|
||||
navigation(windowClass: windowClass)
|
||||
SnackbarHost(controller: messages)
|
||||
ApprovalModalHost(
|
||||
isPresented: $showApproval,
|
||||
state: approvals.state,
|
||||
onAccept: approvals.accept,
|
||||
onRefuse: approvals.refuse
|
||||
// Observe the coordinator/messages from the *persisted* `graph`
|
||||
// StateObject. Deriving them in `init` bound the view to a throwaway
|
||||
// AppGraph rebuilt on every re-init, whose coordinator never receives
|
||||
// core events — so the approval modal never appeared.
|
||||
ApprovalLayer(
|
||||
approvals: graph.approvalCoordinator,
|
||||
sendModel: sendModel
|
||||
)
|
||||
// Top-most so the toast is never covered by the approval overlay's
|
||||
// full-bleed clear layer. Observes the live `graph.messages` directly.
|
||||
SnackbarHost(controller: graph.messages)
|
||||
}
|
||||
.overlay {
|
||||
// A small, unobtrusive indicator while the core finishes its async
|
||||
@@ -103,27 +99,6 @@ struct RootView: View {
|
||||
break
|
||||
}
|
||||
}
|
||||
// A pending approval is a blocking modal. Close the sender's detail panel
|
||||
// (e.g. the Share/QR sheet) first, then present the approval sheet — but on
|
||||
// macOS a sheet presented while another is still dismissing is silently
|
||||
// dropped, so defer the presentation until that dismissal finishes.
|
||||
.onChange(of: approvals.state.current?.id) { _, id in
|
||||
guard id != nil else { showApproval = false; return }
|
||||
let wasShowingSheet = sendModel.state.detailPanel != nil
|
||||
sendModel.closeDetailPanel()
|
||||
#if os(macOS)
|
||||
if wasShowingSheet {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) {
|
||||
if approvals.state.current != nil { showApproval = true }
|
||||
}
|
||||
} else {
|
||||
showApproval = true
|
||||
}
|
||||
#else
|
||||
_ = wasShowingSheet
|
||||
showApproval = true
|
||||
#endif
|
||||
}
|
||||
#if os(macOS)
|
||||
// macOS keeps `scenePhase == .active` even when the app loses focus, so
|
||||
// drive foreground/background off NSApplication's active state instead —
|
||||
@@ -217,6 +192,66 @@ struct RootView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Hosts the approval modal, observing the coordinator passed in from the persisted
|
||||
/// `AppGraph`. Kept as a child view so the `@ObservedObject` subscription is
|
||||
/// established here (in `body`) against the live instance, rather than in
|
||||
/// `RootView.init` against a throwaway graph.
|
||||
private struct ApprovalLayer: View {
|
||||
@ObservedObject var approvals: ApprovalCoordinator
|
||||
let sendModel: SendModel
|
||||
|
||||
/// Drives the approval sheet; toggled from the pending-approval `onChange` so the
|
||||
/// presentation can be deferred until the Share/QR sheet has dismissed on macOS.
|
||||
@State private var showApproval = false
|
||||
|
||||
/// macOS-only: an approval arrived while a share/QR sheet was still up. We close
|
||||
/// that sheet and present the approval once its dismissal completes (see
|
||||
/// `sendModel.shareSheetsDismissed`), since macOS drops a sheet shown mid-dismissal.
|
||||
@State private var approvalAwaitingSheetDismiss = false
|
||||
|
||||
var body: some View {
|
||||
ApprovalModalHost(
|
||||
isPresented: $showApproval,
|
||||
state: approvals.state,
|
||||
onAccept: approvals.accept,
|
||||
onRefuse: approvals.refuse
|
||||
)
|
||||
// A pending approval is a blocking modal. Close any open share/QR sheet first
|
||||
// (the detail-view panel *or* the list-level share sheet), then present the
|
||||
// approval sheet: the approval is presented from the app root and neither
|
||||
// platform reliably stacks it over a sheet owned by the Send screen.
|
||||
.onChange(of: approvals.state.current?.id) { _, id in
|
||||
guard id != nil else {
|
||||
showApproval = false
|
||||
approvalAwaitingSheetDismiss = false
|
||||
return
|
||||
}
|
||||
let wasShowingSheet = sendModel.state.detailPanel != nil
|
||||
|| sendModel.state.shareTargetId != nil
|
||||
sendModel.dismissShareSheets()
|
||||
#if os(macOS)
|
||||
// macOS silently drops a sheet presented while another is still dismissing,
|
||||
// so wait for that sheet's real dismissal completion before presenting.
|
||||
if wasShowingSheet {
|
||||
approvalAwaitingSheetDismiss = true
|
||||
} else {
|
||||
showApproval = true
|
||||
}
|
||||
#else
|
||||
_ = wasShowingSheet
|
||||
showApproval = true
|
||||
#endif
|
||||
}
|
||||
#if os(macOS)
|
||||
.onReceive(sendModel.shareSheetsDismissed) { _ in
|
||||
guard approvalAwaitingSheetDismiss else { return }
|
||||
approvalAwaitingSheetDismiss = false
|
||||
if approvals.state.current != nil { showApproval = true }
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// A full-window cover with a centered spinner shown while the core is starting.
|
||||
private struct CoreStartingOverlay: View {
|
||||
var body: some View {
|
||||
|
||||
@@ -19,7 +19,19 @@ struct LocalNotification {
|
||||
/// Presents notifications even while the app is active. Without a delegate the
|
||||
/// system drops the banner when the app is frontmost — very visible on macOS,
|
||||
/// where the app window is usually open when a transfer completes.
|
||||
private final class NotificationPresenter: NSObject, UNUserNotificationCenterDelegate {
|
||||
///
|
||||
/// `@MainActor` is required, not just convenient: these delegate methods are
|
||||
/// `async`, so their continuation resumes at the return point on whatever executor
|
||||
/// they ran on. When the system hands a notification-tap back to UIKit it performs
|
||||
/// state-restoration/snapshot work synchronously on that thread — which asserts
|
||||
/// "Call must be made on main thread" and crashes if the method returned off-main.
|
||||
/// Main-actor isolation guarantees the return happens on the main thread.
|
||||
// `@preconcurrency` on the conformance: these delegate requirements are nonisolated
|
||||
// with non-Sendable UN* parameters, which strict concurrency won't otherwise let a
|
||||
// main actor-isolated type witness. The main-actor isolation is what fixes the
|
||||
// crash (see the type doc above); the attribute inserts the runtime hop.
|
||||
@MainActor
|
||||
private final class NotificationPresenter: NSObject, @preconcurrency UNUserNotificationCenterDelegate {
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification
|
||||
@@ -36,14 +48,12 @@ private final class NotificationPresenter: NSObject, UNUserNotificationCenterDel
|
||||
didReceive response: UNNotificationResponse
|
||||
) async {
|
||||
#if os(macOS)
|
||||
await MainActor.run {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
// Reopen/focus the single main window (activation triggers SwiftUI's
|
||||
// reopen handling when it was closed).
|
||||
for window in NSApp.windows where window.canBecomeMain {
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
break
|
||||
}
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
// Reopen/focus the single main window (activation triggers SwiftUI's
|
||||
// reopen handling when it was closed).
|
||||
for window in NSApp.windows where window.canBecomeMain {
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
break
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -25,6 +25,11 @@ struct SendState: Equatable {
|
||||
var selectedTransferId: UInt64?
|
||||
var transferThumbnails: [UInt64: Data] = [:]
|
||||
var detailPanel: TransferDetailPanel?
|
||||
/// Transfer whose share panel is presented inline from the list context menu
|
||||
/// (distinct from `detailPanel == .share`, which shows it from the detail view).
|
||||
/// Held in the model — not `SendScreen` @State — so the approval flow can dismiss
|
||||
/// it centrally before presenting its modal.
|
||||
var shareTargetId: UInt64?
|
||||
var receiverHistory: [ReceiverRequestModel] = []
|
||||
var isLoadingReceivers = false
|
||||
var isDeleteConfirmationOpen = false
|
||||
@@ -56,6 +61,18 @@ final class SendModel: ObservableObject {
|
||||
private let messages: UiMessageController
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
/// Fires *after* a share/QR sheet (the detail-view panel or the list-level share
|
||||
/// sheet) has finished animating out. The approval flow waits on this to present
|
||||
/// its modal on macOS, where a sheet shown while another is still dismissing is
|
||||
/// dropped — using the real completion instead of a guessed delay.
|
||||
private let shareSheetsDismissedSubject = PassthroughSubject<Void, Never>()
|
||||
var shareSheetsDismissed: AnyPublisher<Void, Never> {
|
||||
shareSheetsDismissedSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
/// Invoked by a share sheet's `onDismiss` completion.
|
||||
func shareSheetDidDismiss() { shareSheetsDismissedSubject.send(()) }
|
||||
|
||||
init(
|
||||
repository: CoreGateway,
|
||||
fileSystemService: FileSystemService,
|
||||
@@ -201,6 +218,17 @@ final class SendModel: ObservableObject {
|
||||
}
|
||||
func closeDetailPanel() { state.detailPanel = nil }
|
||||
|
||||
func openShareTarget(_ transferId: UInt64) { state.shareTargetId = transferId }
|
||||
func closeShareTarget() { state.shareTargetId = nil }
|
||||
|
||||
/// Dismisses every share/QR surface at once — the detail-view share panel and the
|
||||
/// list-level share sheet. Used before presenting the receiver-approval modal, so
|
||||
/// no competing sheet is left open (macOS drops a sheet shown over another).
|
||||
func dismissShareSheets() {
|
||||
state.detailPanel = nil
|
||||
state.shareTargetId = nil
|
||||
}
|
||||
|
||||
func requestDeleteTransfer() { state.isDeleteConfirmationOpen = true }
|
||||
func dismissDeleteTransfer() { if !state.isDeleting { state.isDeleteConfirmationOpen = false } }
|
||||
|
||||
@@ -257,8 +285,19 @@ final class SendModel: ObservableObject {
|
||||
/// Uses the core's `respondReceiverRequest` (no backend change); applies to
|
||||
/// receivers that are still pending or accepted.
|
||||
func cancelReceiver(requestId: String) {
|
||||
respondToReceiver(requestId: requestId, accepted: false)
|
||||
}
|
||||
|
||||
/// Approves a single pending receiver by responding to its request positively.
|
||||
/// A fallback for when the approval modal didn't surface — the pending receiver
|
||||
/// can still be accepted from its row in the transfer's receivers panel.
|
||||
func acceptReceiver(requestId: String) {
|
||||
respondToReceiver(requestId: requestId, accepted: true)
|
||||
}
|
||||
|
||||
private func respondToReceiver(requestId: String, accepted: Bool) {
|
||||
Task {
|
||||
let result = await repository.respondReceiverRequest(requestId: requestId, accepted: false, reason: nil)
|
||||
let result = await repository.respondReceiverRequest(requestId: requestId, accepted: accepted, reason: nil)
|
||||
switch result {
|
||||
case .success:
|
||||
if let transferId = state.selectedTransferId { refreshReceivers(transferId) }
|
||||
|
||||
@@ -7,14 +7,18 @@ struct SendScreen: View {
|
||||
@ObservedObject var model: SendModel
|
||||
let windowClass: WindowClass
|
||||
|
||||
/// Transfer whose share panel is presented inline from the list context menu.
|
||||
@State private var shareTarget: Transfer?
|
||||
/// Transfer pending an inline (list-level) delete confirmation.
|
||||
@State private var deleteTarget: Transfer?
|
||||
|
||||
private var outgoing: [Transfer] {
|
||||
model.coreState.transfers.filter { $0.direction == .send }
|
||||
}
|
||||
/// The transfer whose list-level share sheet is open, resolved from the model's
|
||||
/// `shareTargetId` (kept in the model so the approval flow can dismiss it).
|
||||
private var shareTarget: Transfer? {
|
||||
guard let id = model.state.shareTargetId else { return nil }
|
||||
return outgoing.first { $0.transferId == id }
|
||||
}
|
||||
private var selectedTransfer: Transfer? {
|
||||
guard let id = model.state.selectedTransferId else { return nil }
|
||||
return outgoing.first { $0.transferId == id }
|
||||
@@ -50,9 +54,10 @@ struct SendScreen: View {
|
||||
// composer drawer on the outer body, so the two don't clash). Opens the
|
||||
// share panel over the list without navigating into the transfer detail.
|
||||
.adaptiveDrawer(
|
||||
isPresented: Binding(get: { shareTarget != nil }, set: { if !$0 { shareTarget = nil } }),
|
||||
isPresented: Binding(get: { shareTarget != nil }, set: { if !$0 { model.closeShareTarget() } }),
|
||||
windowClass: windowClass,
|
||||
onDismiss: { shareTarget = nil }
|
||||
onDismiss: model.closeShareTarget,
|
||||
onDismissed: model.shareSheetDidDismiss
|
||||
) {
|
||||
if let shareTarget {
|
||||
TransferSharePanel(model: model, transfer: shareTarget)
|
||||
@@ -92,7 +97,8 @@ struct SendScreen: View {
|
||||
.adaptiveDrawer(
|
||||
isPresented: Binding(get: { model.state.detailPanel != nil }, set: { _ in }),
|
||||
windowClass: windowClass,
|
||||
onDismiss: model.closeDetailPanel
|
||||
onDismiss: model.closeDetailPanel,
|
||||
onDismissed: model.shareSheetDidDismiss
|
||||
) {
|
||||
if let panel = model.state.detailPanel {
|
||||
DetailPanelContent(model: model, transfer: transfer, panel: panel)
|
||||
@@ -127,7 +133,7 @@ struct SendScreen: View {
|
||||
.contextMenu {
|
||||
if transfer.ticket != nil {
|
||||
Button {
|
||||
shareTarget = transfer
|
||||
model.openShareTarget(transfer.transferId)
|
||||
} label: {
|
||||
Label(String(localized: L10n.Transfer.shareTitle), systemSymbol: .squareAndArrowUp)
|
||||
}
|
||||
|
||||
@@ -142,7 +142,8 @@ struct DetailPanelContent: View {
|
||||
loading: model.state.isLoadingReceivers,
|
||||
events: model.coreState.events,
|
||||
transferTotalSize: transfer.totalSize,
|
||||
onCancel: model.cancelReceiver
|
||||
onCancel: model.cancelReceiver,
|
||||
onAccept: model.acceptReceiver
|
||||
)
|
||||
case .share:
|
||||
TransferSharePanel(model: model, transfer: transfer)
|
||||
@@ -193,6 +194,7 @@ struct ReceiverHistoryPanel: View {
|
||||
let events: [CoreEventModel]
|
||||
let transferTotalSize: UInt64
|
||||
let onCancel: (String) -> Void
|
||||
let onAccept: (String) -> Void
|
||||
|
||||
var body: some View {
|
||||
PanelContainer(title: String(localized: L10n.Transfer.receiversTitle)) {
|
||||
@@ -203,7 +205,12 @@ struct ReceiverHistoryPanel: View {
|
||||
} else {
|
||||
ForEach(Array(receivers.enumerated()), id: \.element.id) { index, receiver in
|
||||
if index > 0 { Divider().overlay(colors.borderDefault) }
|
||||
ReceiverRow(receiver: receiver, sendProgress: sendProgress(for: receiver), onCancel: onCancel)
|
||||
ReceiverRow(
|
||||
receiver: receiver,
|
||||
sendProgress: sendProgress(for: receiver),
|
||||
onCancel: onCancel,
|
||||
onAccept: onAccept
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,6 +231,7 @@ private struct ReceiverRow: View {
|
||||
let receiver: ReceiverRequestModel
|
||||
let sendProgress: TransferProgress?
|
||||
let onCancel: (String) -> Void
|
||||
let onAccept: (String) -> Void
|
||||
|
||||
/// Only pending requests can be cancelled per-receiver: the core rejects a
|
||||
/// negative response to an already-accepted request ("...not approved, or it
|
||||
@@ -257,14 +265,27 @@ private struct ReceiverRow: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
if isCancelable {
|
||||
Button(role: .destructive) {
|
||||
onCancel(receiver.id)
|
||||
} label: {
|
||||
Text(String(localized: L10n.Button.refuse))
|
||||
.font(VniType.bodySmall)
|
||||
VStack(alignment: .trailing, spacing: 8) {
|
||||
Button(role: .destructive) {
|
||||
onCancel(receiver.id)
|
||||
} label: {
|
||||
Text(String(localized: L10n.Button.refuse))
|
||||
.font(VniType.bodySmall)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.tint(.red)
|
||||
// Fallback approve action, in case the approval modal didn't surface.
|
||||
Button {
|
||||
onAccept(receiver.id)
|
||||
} label: {
|
||||
Text(String(localized: L10n.Button.approve))
|
||||
.font(VniType.bodySmall).fontWeight(.medium)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 16).padding(.vertical, 7)
|
||||
.background(Color.green, in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.tint(.red)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
@@ -7,11 +7,16 @@ struct AdaptiveDrawer<DrawerContent: View>: ViewModifier {
|
||||
@Binding var isPresented: Bool
|
||||
let windowClass: WindowClass
|
||||
let onDismiss: () -> Void
|
||||
/// Fired after the sheet's dismissal animation completes (as opposed to
|
||||
/// `onDismiss`, which requests the close). Lets callers serialize a follow-up
|
||||
/// sheet against this one's actual teardown instead of guessing a delay.
|
||||
let onDismissed: (() -> Void)?
|
||||
@ViewBuilder let drawerContent: () -> DrawerContent
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.sheet(
|
||||
isPresented: Binding(get: { isPresented }, set: { if !$0 { onDismiss() } })
|
||||
isPresented: Binding(get: { isPresented }, set: { if !$0 { onDismiss() } }),
|
||||
onDismiss: onDismissed
|
||||
) {
|
||||
SheetChrome(onClose: onDismiss) { drawerContent() }
|
||||
.modifier(PhoneDetents(enabled: windowClass == .phone))
|
||||
@@ -56,11 +61,12 @@ extension View {
|
||||
isPresented: Binding<Bool>,
|
||||
windowClass: WindowClass,
|
||||
onDismiss: @escaping () -> Void,
|
||||
onDismissed: (() -> Void)? = nil,
|
||||
@ViewBuilder content: @escaping () -> DrawerContent
|
||||
) -> some View {
|
||||
modifier(AdaptiveDrawer(
|
||||
isPresented: isPresented, windowClass: windowClass,
|
||||
onDismiss: onDismiss, drawerContent: content
|
||||
onDismiss: onDismiss, onDismissed: onDismissed, drawerContent: content
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@ configs:
|
||||
# Project-wide build settings (applied to every target/config).
|
||||
settings:
|
||||
base:
|
||||
# Apple Silicon only. Intel Macs are unsupported (going EOL with macOS 28), and
|
||||
# the Rust core's macOS slice (vnidrop.xcframework) is built arm64-only, so a
|
||||
# universal link would fail looking for x86_64 symbols anyway.
|
||||
ARCHS: arm64
|
||||
# Strip unreachable code from release binaries.
|
||||
DEAD_CODE_STRIPPING: YES
|
||||
# Flag user-facing strings that aren't localized (the app ships 9 languages).
|
||||
@@ -135,11 +139,6 @@ targets:
|
||||
# provisioning profile, which direct distribution avoids. (App Store target
|
||||
# keeps VniDrop.entitlements with the sandbox.)
|
||||
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDropDirect.entitlements
|
||||
# The Rust core's macOS slice (vnidrop.xcframework) is arm64-only
|
||||
# (build-core.sh builds aarch64-apple-darwin only), so the direct build is
|
||||
# Apple-Silicon-only. Pin ARCHS so the Release-Direct (universal-by-default)
|
||||
# link doesn't fail looking for x86_64 symbols.
|
||||
ARCHS: arm64
|
||||
dependencies:
|
||||
- package: Sparkle
|
||||
|
||||
|
||||
@@ -105,6 +105,12 @@ ACTUAL_BUILD="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' \
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "==> Enforcing hardened-runtime signature"
|
||||
"$SCRIPT_DIR/sign-exported-app.sh" \
|
||||
"$APP" \
|
||||
"$DEVELOPER_ID_APP" \
|
||||
"$APPLE_DIR/VniDrop/Resources/VniDropDirect.entitlements"
|
||||
|
||||
# --- Build the DMG -----------------------------------------------------------
|
||||
DMG="$DIST_DIR/$APP_NAME-$VERSION.dmg"
|
||||
rm -f "$DMG"
|
||||
@@ -137,7 +143,8 @@ codesign --force --sign "$DEVELOPER_ID_APP" --timestamp "$DMG"
|
||||
# --- Notarize + staple -------------------------------------------------------
|
||||
if [ -n "${NOTARY_PROFILE:-}" ]; then
|
||||
echo "==> Notarizing (profile: $NOTARY_PROFILE)"
|
||||
xcrun notarytool submit "$DMG" --keychain-profile "$NOTARY_PROFILE" --wait
|
||||
NOTARY_LOG="$DIST_DIR/$APP_NAME-$VERSION.notary-log.json"
|
||||
"$SCRIPT_DIR/notarize.sh" "$DMG" "$NOTARY_PROFILE" "$NOTARY_LOG"
|
||||
echo "==> Stapling"
|
||||
xcrun stapler staple "$DMG"
|
||||
xcrun stapler validate "$DMG"
|
||||
|
||||
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'
|
||||
@@ -184,6 +184,19 @@ def generated_apks_url(package_name: str, version_code: int) -> str:
|
||||
return f"{API_ROOT}/applications/{package}/generatedApks/{version_code}"
|
||||
|
||||
|
||||
def generated_apk_download_url(
|
||||
package_name: str,
|
||||
version_code: int,
|
||||
download_id: str,
|
||||
) -> str:
|
||||
package = urllib.parse.quote(package_name, safe="")
|
||||
download = urllib.parse.quote(download_id, safe="")
|
||||
return (
|
||||
f"{API_ROOT}/applications/{package}/generatedApks/"
|
||||
f"{version_code}/downloads/{download}:download?alt=media"
|
||||
)
|
||||
|
||||
|
||||
def get_generated_apks(
|
||||
client: PlayClient,
|
||||
package_name: str,
|
||||
@@ -216,22 +229,23 @@ def download_universal_apk(
|
||||
selected = find_universal_apk(response, expected_fingerprint)
|
||||
if selected is not None:
|
||||
fingerprint, download_id = selected
|
||||
package = urllib.parse.quote(package_name, safe="")
|
||||
download = urllib.parse.quote(download_id, safe="")
|
||||
url = (
|
||||
f"{API_ROOT}/applications/{package}/generatedApks/"
|
||||
f"{version_code}/downloads/{download}:download"
|
||||
apk = client.request(
|
||||
"GET",
|
||||
generated_apk_download_url(
|
||||
package_name,
|
||||
version_code,
|
||||
download_id,
|
||||
),
|
||||
)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_bytes(client.request("GET", url))
|
||||
if output.stat().st_size == 0:
|
||||
raise RuntimeError("Google Play returned an empty universal APK")
|
||||
return fingerprint
|
||||
if apk:
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_bytes(apk)
|
||||
return fingerprint
|
||||
if attempt < attempts:
|
||||
time.sleep(interval_seconds)
|
||||
raise RuntimeError(
|
||||
"Google Play did not provide a universal APK signed with the expected "
|
||||
f"certificate after {attempts} attempts"
|
||||
"Google Play did not provide a non-empty universal APK signed with the "
|
||||
f"expected certificate after {attempts} attempts"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import importlib.util
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
@@ -44,6 +45,69 @@ class PublishPlayTests(unittest.TestCase):
|
||||
("aabb", "correct"),
|
||||
)
|
||||
|
||||
def test_downloads_generated_apk_as_media(self):
|
||||
class FakePlayClient:
|
||||
def __init__(self):
|
||||
self.download_urls = []
|
||||
self.media_attempts = 0
|
||||
|
||||
def request_json(self, method, url):
|
||||
self.assert_request(method, url)
|
||||
return {
|
||||
"generatedApks": [
|
||||
{
|
||||
"certificateSha256Hash": "AA:BB",
|
||||
"generatedUniversalApk": {
|
||||
"downloadId": "download/id+=",
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
def request(self, method, url):
|
||||
self.assert_request(method, url)
|
||||
self.download_urls.append(url)
|
||||
if not url.endswith("?alt=media"):
|
||||
return b""
|
||||
self.media_attempts += 1
|
||||
return b"apk" if self.media_attempts == 2 else b""
|
||||
|
||||
@staticmethod
|
||||
def assert_request(method, url):
|
||||
if method != "GET" or not url.startswith(publish_play.API_ROOT):
|
||||
raise AssertionError(f"unexpected request: {method} {url}")
|
||||
|
||||
client = FakePlayClient()
|
||||
with tempfile.TemporaryDirectory() as scratch:
|
||||
output = Path(scratch) / "universal.apk"
|
||||
fingerprint = publish_play.download_universal_apk(
|
||||
client,
|
||||
"com.example app",
|
||||
2002,
|
||||
"aa:bb",
|
||||
output,
|
||||
attempts=2,
|
||||
interval_seconds=0,
|
||||
)
|
||||
self.assertEqual(fingerprint, "aabb")
|
||||
self.assertEqual(output.read_bytes(), b"apk")
|
||||
|
||||
self.assertEqual(
|
||||
client.download_urls,
|
||||
[
|
||||
(
|
||||
f"{publish_play.API_ROOT}/applications/com.example%20app/"
|
||||
"generatedApks/2002/downloads/"
|
||||
"download%2Fid%2B%3D:download?alt=media"
|
||||
),
|
||||
(
|
||||
f"{publish_play.API_ROOT}/applications/com.example%20app/"
|
||||
"generatedApks/2002/downloads/"
|
||||
"download%2Fid%2B%3D:download?alt=media"
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
def test_track_update_preserves_existing_releases_and_adds_draft(self):
|
||||
track = {
|
||||
"track": "closed-beta",
|
||||
|
||||
@@ -58,6 +58,7 @@ rpm="$(find_single "$input_dir/rpm" '*.rpm' 'RPM package')"
|
||||
dmg="$(find_single "$input_dir/macos" '*.dmg' 'macOS DMG')"
|
||||
appcast="$(find_single "$input_dir/macos" 'appcast.xml' 'Sparkle appcast')"
|
||||
apple_metadata="$(find_single "$input_dir/macos" '*.build-info.json' 'direct macOS build metadata')"
|
||||
apple_core="$(find_single "$input_dir/macos" 'VnidropCore-*.zip' 'Apple prebuilt core bundle')"
|
||||
play_apk="$(find_single "$input_dir/play" '*-play-universal.apk' 'Play-signed APK')"
|
||||
play_metadata="$(find_single "$input_dir/play" 'play-release.json' 'Play release metadata')"
|
||||
msix="$(find_single "$input_dir/windows" '*.msix' 'Windows MSIX')"
|
||||
@@ -67,6 +68,7 @@ windows_metadata="$(find_single "$input_dir/windows" '*.build-info.json' 'Window
|
||||
[[ $(basename "$deb") == "vnidrop_${version}-1_amd64.deb" ]]
|
||||
[[ $(basename "$rpm") == "vnidrop-${version}-1.x86_64.rpm" ]]
|
||||
[[ $(basename "$dmg") == "VniDrop-${version}.dmg" ]]
|
||||
[[ $(basename "$apple_core") == "VnidropCore-${version}.zip" ]]
|
||||
[[ $(basename "$play_apk") == "VniDrop-${version}-${android_code}-play-universal.apk" ]]
|
||||
[[ $(basename "$msix") == "VniDrop_${version}_x64.msix" ]]
|
||||
[[ $(basename "$msixupload") == "VniDrop_${version}_x64.msixupload" ]]
|
||||
@@ -83,10 +85,12 @@ deb_checksum="$(find_single "$input_dir/deb" '*.sha256' 'Debian checksum')"
|
||||
rpm_checksum="$(find_single "$input_dir/rpm" '*.sha256' 'RPM checksum')"
|
||||
windows_checksums="$(find_single "$input_dir/windows" 'SHA256SUMS' 'Windows checksums')"
|
||||
play_checksums="$(find_single "$input_dir/play" 'SHA256SUMS' 'Play APK checksums')"
|
||||
apple_core_checksum="$(find_single "$input_dir/macos" 'VnidropCore-*.zip.sha256' 'Apple prebuilt core checksum')"
|
||||
verify_checksum_file "$deb_checksum"
|
||||
verify_checksum_file "$rpm_checksum"
|
||||
verify_checksum_file "$windows_checksums"
|
||||
verify_checksum_file "$play_checksums"
|
||||
verify_checksum_file "$apple_core_checksum"
|
||||
|
||||
[[ $(jq -r '.releaseStatus' "$play_metadata") == draft ]]
|
||||
[[ $(jq -r '.releaseName' "$play_metadata") == "$version" ]]
|
||||
@@ -103,7 +107,7 @@ mkdir -p "$output_dir"
|
||||
printf 'Release output directory must be empty: %s\n' "$output_dir" >&2
|
||||
exit 1
|
||||
}
|
||||
cp "$deb" "$rpm" "$dmg" "$appcast" "$play_apk" "$output_dir/"
|
||||
cp "$deb" "$rpm" "$dmg" "$appcast" "$play_apk" "$apple_core" "$output_dir/"
|
||||
|
||||
payloads=(
|
||||
"$output_dir/$(basename "$deb")"
|
||||
@@ -111,6 +115,7 @@ payloads=(
|
||||
"$output_dir/$(basename "$dmg")"
|
||||
"$output_dir/$(basename "$appcast")"
|
||||
"$output_dir/$(basename "$play_apk")"
|
||||
"$output_dir/$(basename "$apple_core")"
|
||||
)
|
||||
files_json="$(
|
||||
for file in "${payloads[@]}"; do
|
||||
@@ -168,6 +173,7 @@ jq -n \
|
||||
"$(basename "$dmg")" \
|
||||
"$(basename "$appcast")" \
|
||||
"$(basename "$play_apk")" \
|
||||
"$(basename "$apple_core")" \
|
||||
release-manifest.json \
|
||||
> 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 'dmg\n' > "$input_dir/macos/VniDrop-${version}.dmg"
|
||||
printf '<url>VniDrop-%s.dmg</url>\n' "$version" > "$input_dir/macos/appcast.xml"
|
||||
printf 'core\n' > "$input_dir/macos/VnidropCore-${version}.zip"
|
||||
printf 'apk\n' > "$input_dir/play/VniDrop-${version}-${android_code}-play-universal.apk"
|
||||
printf 'msix\n' > "$input_dir/windows/VniDrop_${version}_x64.msix"
|
||||
printf 'msixupload\n' > "$input_dir/windows/VniDrop_${version}_x64.msixupload"
|
||||
@@ -68,6 +69,11 @@ jq -n \
|
||||
sha256sum "vnidrop-${version}-1.x86_64.rpm" \
|
||||
> "vnidrop-${version}-1.x86_64.rpm.sha256"
|
||||
)
|
||||
(
|
||||
cd "$input_dir/macos"
|
||||
sha256sum "VnidropCore-${version}.zip" \
|
||||
> "VnidropCore-${version}.zip.sha256"
|
||||
)
|
||||
(
|
||||
cd "$input_dir/play"
|
||||
sha256sum \
|
||||
@@ -94,6 +100,7 @@ expected_public_files=(
|
||||
"SHA256SUMS"
|
||||
"VniDrop-${version}-${android_code}-play-universal.apk"
|
||||
"VniDrop-${version}.dmg"
|
||||
"VnidropCore-${version}.zip"
|
||||
"appcast.xml"
|
||||
"release-manifest.json"
|
||||
"vnidrop-${version}-1.x86_64.rpm"
|
||||
|
||||
@@ -25,4 +25,32 @@ grep -F 'run: make build-apple-dmg' \
|
||||
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,3 +1,3 @@
|
||||
PRODUCT_VERSION=0.2.1
|
||||
PRODUCT_VERSION=0.2.4
|
||||
RELEASE_CHANNEL=beta
|
||||
WINDOWS_VERSION_EPOCH=1
|
||||
|
||||
Reference in New Issue
Block a user