From ceccfcda7115d4b21c101290ffcb5d409925c347 Mon Sep 17 00:00:00 2001 From: cdricms <36056008+cdricms@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:33:58 +0200 Subject: [PATCH] feat(apple): transfer controls, progress fixes, and project config - Sender progress: aggregate only in-flight receivers so the bar clears on completion and is order-independent ("Sending to N") - Add Stop sharing and per-receiver Refuse (pending requests) on the sender - Fix macOS: raise approval modal above the Share sheet; drive foreground state off NSApplication so background notifications fire - Persist app identity (display name, category) and signing team via Info.plist / project.yml / gitignored Local.xcconfig --- apple/.gitignore | 3 + apple/Signing.xcconfig | 9 ++ apple/VniDrop/App/RootView.swift | 19 ++++ apple/VniDrop/Core/TransferProgress.swift | 56 +++++++++-- apple/VniDrop/Features/Send/SendModel.swift | 31 ++++++ apple/VniDrop/Features/Send/SendScreen.swift | 4 +- .../Features/Send/TransferDetailsView.swift | 80 +++++++++++++--- apple/VniDrop/Resources/Info.plist | 94 +++++++++++-------- apple/VniDrop/Resources/Localizable.xcstrings | 30 ++++++ apple/VniDrop/UI/Components/Components.swift | 13 ++- apple/project.yml | 7 ++ 11 files changed, 278 insertions(+), 68 deletions(-) create mode 100644 apple/Signing.xcconfig diff --git a/apple/.gitignore b/apple/.gitignore index c970db8..3910689 100644 --- a/apple/.gitignore +++ b/apple/.gitignore @@ -6,6 +6,9 @@ VnidropCore/Sources/VnidropCore/Vnidrop.swift # Generated by XcodeGen from project.yml VniDrop.xcodeproj/ +# Per-developer signing (team id); Signing.xcconfig optionally includes it +Local.xcconfig + # SwiftPM / Xcode .build/ .swiftpm/ diff --git a/apple/Signing.xcconfig b/apple/Signing.xcconfig new file mode 100644 index 0000000..39d058f --- /dev/null +++ b/apple/Signing.xcconfig @@ -0,0 +1,9 @@ +// Committed signing config. Contains no secrets. +// +// Per-developer signing (e.g. DEVELOPMENT_TEAM) goes in Local.xcconfig, which is +// gitignored. The optional include below means the build still works for anyone +// who doesn't have a Local.xcconfig — Xcode automatic signing fills in their team. +// +// To persist your team across `xcodegen generate`, create apple/Local.xcconfig: +// DEVELOPMENT_TEAM = XXXXXXXXXX +#include? "Local.xcconfig" diff --git a/apple/VniDrop/App/RootView.swift b/apple/VniDrop/App/RootView.swift index 375b439..d2f67b4 100644 --- a/apple/VniDrop/App/RootView.swift +++ b/apple/VniDrop/App/RootView.swift @@ -82,6 +82,25 @@ struct RootView: View { break } } + // A pending approval is a blocking modal; close the sender's detail panel + // (e.g. the Share/QR sheet) so the approval sheet isn't presented under it + // on macOS. + .onChange(of: approvals.state.current?.id) { id in + if id != nil { sendModel.closeDetailPanel() } + } + #if os(macOS) + // macOS keeps `scenePhase == .active` even when the app loses focus, so + // drive foreground/background off NSApplication's active state instead — + // otherwise notifications (only posted when unfocused) never fire. + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didResignActiveNotification)) { _ in + graph.visibility.setForeground(false) + } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + graph.visibility.setForeground(true) + settingsModel.refreshNotificationPermission() + Task { _ = await graph.coreRepository.refresh() } + } + #endif } /// iOS uses a bottom tab bar; macOS uses a native source-list sidebar so each diff --git a/apple/VniDrop/Core/TransferProgress.swift b/apple/VniDrop/Core/TransferProgress.swift index f3426a4..e23dde1 100644 --- a/apple/VniDrop/Core/TransferProgress.swift +++ b/apple/VniDrop/Core/TransferProgress.swift @@ -23,6 +23,9 @@ struct TransferProgress: Equatable { let labelKey: String let progress: Double? var detail: String? = nil + /// Pre-resolved label that overrides `labelKey` when set (e.g. "Sending to 2", + /// which needs a runtime count). + var label: String? = nil } func statusLabelKey(_ status: TransferStatus) -> String { @@ -107,8 +110,11 @@ func progressForReceiver( ) } -/// Best send-side progress for a transfer (any receiver). -func activeSendProgress( +/// Send-side progress summary for a transfer's list row. Aggregates only the +/// receivers that are *currently downloading* (in-flight), so the bar disappears +/// once every receiver has completed/aborted, and its value is deterministic +/// (order-independent) rather than "whichever receiver's event arrived last". +func sendProgressSummary( events: [CoreEventModel], transferId: UInt64, totalSizeHint: UInt64? = nil @@ -118,23 +124,53 @@ func activeSendProgress( .compactMap { findString($0.dataJson, key: "endpoint_id") } .reduce(into: [String]()) { acc, id in if !acc.contains(id) { acc.append(id) } } + // No per-endpoint attribution: fall back to the single connection-scoped + // stream, hidden once it completes/aborts. if endpointIds.isEmpty { let relevant = events.filter { $0.transferId == transferId && $0.direction == "send" && $0.phase == "transfer" - && ["started", "progress"].contains($0.kind) + && ["started", "progress", "completed", "aborted"].contains($0.kind) } - guard let first = relevant.first else { return nil } + guard let latest = relevant.first else { return nil } + if latest.kind == "completed" || latest.kind == "aborted" { return nil } + let live = relevant.filter { ["started", "progress"].contains($0.kind) } return TransferProgress( - transferId: transferId, phase: "transfer", kind: first.kind, + transferId: transferId, phase: "transfer", kind: latest.kind, labelKey: "progress_sending", - progress: aggregateReceiverProgress(events: relevant, totalSizeHint: totalSizeHint), - detail: progressDetail(first) + progress: aggregateReceiverProgress(events: live, totalSizeHint: totalSizeHint), + detail: progressDetail(latest) ) } - let all = endpointIds.compactMap { - progressForReceiver(events: events, transferId: transferId, remoteEndpointId: $0, totalSizeHint: totalSizeHint) + + // Keep only receivers still in flight (latest state started/progress). + var activeCount = 0 + var fractions: [Double] = [] + var lastActive: TransferProgress? + for id in endpointIds { + guard let p = progressForReceiver( + events: events, transferId: transferId, remoteEndpointId: id, totalSizeHint: totalSizeHint + ) else { continue } + guard p.kind == "progress" || p.kind == "started" else { continue } + activeCount += 1 + lastActive = p + if let fraction = p.progress { fractions.append(fraction) } } - return all.first { $0.kind == "progress" || $0.kind == "started" } ?? all.first + if activeCount == 0 { return nil } + + // Same total per receiver, so the byte-summed progress is the mean of the + // per-receiver fractions. + let combined = fractions.isEmpty ? nil : fractions.reduce(0, +) / Double(fractions.count) + if activeCount == 1 { + return TransferProgress( + transferId: transferId, phase: "transfer", kind: "progress", + labelKey: "progress_sending", progress: combined, detail: lastActive?.detail + ) + } + return TransferProgress( + transferId: transferId, phase: "transfer", kind: "progress", + labelKey: "progress_sending", progress: combined, + label: String(format: String(localized: "progress_sending_to_count"), activeCount) + ) } func formatBytes(_ size: UInt64) -> String { diff --git a/apple/VniDrop/Features/Send/SendModel.swift b/apple/VniDrop/Features/Send/SendModel.swift index d4a0ac6..4ca7872 100644 --- a/apple/VniDrop/Features/Send/SendModel.swift +++ b/apple/VniDrop/Features/Send/SendModel.swift @@ -205,6 +205,37 @@ final class SendModel: ObservableObject { } } + /// Cancels/refuses a single receiver by responding to its request negatively. + /// Uses the core's `respondReceiverRequest` (no backend change); applies to + /// receivers that are still pending or accepted. + func cancelReceiver(requestId: String) { + Task { + let result = await repository.respondReceiverRequest(requestId: requestId, accepted: false, reason: nil) + switch result { + case .success: + if let transferId = state.selectedTransferId { refreshReceivers(transferId) } + _ = await repository.refresh() + case .failure(let error): + messages.error(error) + } + } + } + + /// Stops an active outgoing share (interrupts any in-flight receivers). The + /// transfer stays in history as "Stopped". Uses the core's `cancelTransfer`. + func stopSharing(transferId: UInt64) { + Task { + let result = await repository.cancel(transferId: transferId) + switch result { + case .success: + _ = await repository.refresh() + messages.tryShow(UiMessage(text: .resource("transfer_event_stopped"), tone: .info)) + case .failure(let error): + messages.error(error) + } + } + } + // MARK: - Invitation results / share func onInvitationResult(_ action: InvitationAction, _ result: Result) { diff --git a/apple/VniDrop/Features/Send/SendScreen.swift b/apple/VniDrop/Features/Send/SendScreen.swift index bbc367b..cc8669b 100644 --- a/apple/VniDrop/Features/Send/SendScreen.swift +++ b/apple/VniDrop/Features/Send/SendScreen.swift @@ -116,7 +116,7 @@ struct SendScreen: View { private func progress(for transfer: Transfer) -> TransferProgress? { switch transfer.status { case .importing: return progressForTransfer(events: model.coreState.events, transferId: transfer.transferId) - case .sharing: return activeSendProgress(events: model.coreState.events, transferId: transfer.transferId, totalSizeHint: transfer.totalSize) + case .sharing: return sendProgressSummary(events: model.coreState.events, transferId: transfer.transferId, totalSizeHint: transfer.totalSize) default: return nil } } @@ -142,7 +142,7 @@ private struct TransferListItem: View { Text("\(formatBytes(transfer.totalSize)) · \(accessPolicyLabel(transfer.accessPolicy))") .font(.caption).foregroundStyle(.secondary).lineLimit(1) if let progress, transfer.status == .importing || transfer.status == .sharing { - ProgressRow(labelKey: progress.labelKey, progress: progress.progress, detail: progress.detail) + ProgressRow(labelKey: progress.labelKey, progress: progress.progress, detail: progress.detail, labelText: progress.label) .padding(.top, 2) } } diff --git a/apple/VniDrop/Features/Send/TransferDetailsView.swift b/apple/VniDrop/Features/Send/TransferDetailsView.swift index 5ad48e2..652bb72 100644 --- a/apple/VniDrop/Features/Send/TransferDetailsView.swift +++ b/apple/VniDrop/Features/Send/TransferDetailsView.swift @@ -7,6 +7,11 @@ struct TransferDetailsView: View { @ObservedObject var model: SendModel let transfer: Transfer let events: [CoreEventModel] + @State private var showStopConfirmation = false + + private var isActiveShare: Bool { + transfer.status == .sharing || transfer.status == .importing + } private var pendingReceivers: Int { model.state.receiverHistory.filter { $0.status == .requested || $0.status == .accepted }.count @@ -45,6 +50,16 @@ struct TransferDetailsView: View { onTap: model.openShare ) } + + if isActiveShare { + Section { + Button(role: .destructive) { + showStopConfirmation = true + } label: { + Label(String(localized: "send_stop_sharing"), systemImage: "stop.circle") + } + } + } } .formStyle(.grouped) .navigationTitle(Text(LocalizedStringKey("send_transfer_details_title"))) @@ -58,6 +73,18 @@ struct TransferDetailsView: View { } } } + .confirmationDialog( + Text(LocalizedStringKey("send_stop_sharing")), + isPresented: $showStopConfirmation, + titleVisibility: .visible + ) { + Button(String(localized: "send_stop_sharing"), role: .destructive) { + model.stopSharing(transferId: transfer.transferId) + } + Button(String(localized: "button_cancel"), role: .cancel) {} + } message: { + Text(LocalizedStringKey("send_stop_sharing_description")) + } } } @@ -113,7 +140,8 @@ struct DetailPanelContent: View { receivers: model.state.receiverHistory, loading: model.state.isLoadingReceivers, events: model.coreState.events, - transferTotalSize: transfer.totalSize + transferTotalSize: transfer.totalSize, + onCancel: model.cancelReceiver ) case .share: TransferSharePanel(model: model, transfer: transfer) @@ -163,6 +191,7 @@ struct ReceiverHistoryPanel: View { let loading: Bool let events: [CoreEventModel] let transferTotalSize: UInt64 + let onCancel: (String) -> Void var body: some View { PanelContainer(title: String(localized: "transfer_receivers_title")) { @@ -173,7 +202,7 @@ 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)) + ReceiverRow(receiver: receiver, sendProgress: sendProgress(for: receiver), onCancel: onCancel) } } } @@ -193,25 +222,46 @@ private struct ReceiverRow: View { @Environment(\.vniColors) private var colors let receiver: ReceiverRequestModel let sendProgress: TransferProgress? + let onCancel: (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 + /// was refused"). Interrupting an in-flight receiver needs Stop sharing. + private var isCancelable: Bool { + receiver.status == .requested + } var body: some View { let name = receiver.receiverName ?? receiver.receiverDeviceName ?? String(localized: "transfer_nearby_device") let showLive = sendProgress != nil && receiver.status != .completed && receiver.status != .refused && receiver.status != .expired - VStack(alignment: .leading, spacing: 6) { - Text(name).font(VniType.bodyLarge).lineLimit(1) - if let deviceName = receiver.receiverDeviceName, deviceName != name { - Text(deviceName).font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter) + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 6) { + Text(name).font(VniType.bodyLarge).lineLimit(1) + if let deviceName = receiver.receiverDeviceName, deviceName != name { + Text(deviceName).font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter) + } + if showLive, let sendProgress { + ProgressRow(labelKey: sendProgress.labelKey, progress: sendProgress.progress, detail: sendProgress.detail, labelText: sendProgress.label) + } else { + Text(LocalizedStringKey(receiver.status.statusTextKey)) + .font(VniType.bodySmall).fontWeight(.medium) + .foregroundStyle(receiver.status.statusColor(colors)) + } + if let reason = receiver.reason, !reason.isEmpty { + Text(reason).font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter) + } } - if showLive, let sendProgress { - ProgressRow(labelKey: sendProgress.labelKey, progress: sendProgress.progress, detail: sendProgress.detail) - } else { - Text(LocalizedStringKey(receiver.status.statusTextKey)) - .font(VniType.bodySmall).fontWeight(.medium) - .foregroundStyle(receiver.status.statusColor(colors)) - } - if let reason = receiver.reason, !reason.isEmpty { - Text(reason).font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter) + .frame(maxWidth: .infinity, alignment: .leading) + if isCancelable { + Button(role: .destructive) { + onCancel(receiver.id) + } label: { + Text(LocalizedStringKey("button_refuse")) + .font(VniType.bodySmall) + } + .buttonStyle(.borderless) + .tint(.red) } } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/apple/VniDrop/Resources/Info.plist b/apple/VniDrop/Resources/Info.plist index 40840a7..28ca33d 100644 --- a/apple/VniDrop/Resources/Info.plist +++ b/apple/VniDrop/Resources/Info.plist @@ -2,26 +2,12 @@ - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - $(MARKETING_VERSION) - CFBundleVersion - $(CURRENT_PROJECT_VERSION) CADisableMinimumFrameDurationOnPhone - UILaunchScreen - + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + VniDrop CFBundleDocumentTypes @@ -37,6 +23,56 @@ + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSApplicationCategoryType + public.app-category.utilities + LSSupportsOpeningDocumentsInPlace + + NFCReaderUsageDescription + VniDrop uses NFC to read transfer invitation tags. + NSBonjourServices + + + + NSCameraUsageDescription + VniDrop uses the camera to scan transfer QR codes. + NSLocalNetworkUsageDescription + VniDrop needs local network access to send to other local devices if needed. + UIBackgroundModes + + fetch + processing + remote-notification + + UIFileSharingEnabled + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + UIInterfaceOrientationPortrait + + UIViewControllerBasedStatusBarAppearance + UTExportedTypeDeclarations @@ -59,27 +95,5 @@ - LSSupportsOpeningDocumentsInPlace - - UIFileSharingEnabled - - UIBackgroundModes - - fetch - processing - remote-notification - - UIViewControllerBasedStatusBarAppearance - - NSCameraUsageDescription - VniDrop uses the camera to scan transfer QR codes. - NFCReaderUsageDescription - VniDrop uses NFC to read transfer invitation tags. - NSLocalNetworkUsageDescription - VniDrop needs local network access to send to other local devices if needed. - NSBonjourServices - - - diff --git a/apple/VniDrop/Resources/Localizable.xcstrings b/apple/VniDrop/Resources/Localizable.xcstrings index 8c413c3..0387094 100644 --- a/apple/VniDrop/Resources/Localizable.xcstrings +++ b/apple/VniDrop/Resources/Localizable.xcstrings @@ -1185,6 +1185,16 @@ } } }, + "progress_sending_to_count" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sending to %lld" + } + } + } + }, "progress_share_ready" : { "localizations" : { "en" : { @@ -1627,6 +1637,26 @@ } } }, + "send_stop_sharing" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stop sharing" + } + } + } + }, + "send_stop_sharing_description" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This stops the transfer and interrupts anyone currently downloading it. It stays in your history as Stopped." + } + } + } + }, "send_title" : { "localizations" : { "en" : { diff --git a/apple/VniDrop/UI/Components/Components.swift b/apple/VniDrop/UI/Components/Components.swift index 7671bd4..cb83110 100644 --- a/apple/VniDrop/UI/Components/Components.swift +++ b/apple/VniDrop/UI/Components/Components.swift @@ -33,11 +33,13 @@ struct ProgressRow: View { let labelKey: String let progress: Double? var detail: String? = nil + /// Pre-resolved label; when set it overrides `labelKey`. + var labelText: String? = nil var body: some View { VStack(alignment: .leading, spacing: 4) { HStack { - Text(LocalizedStringKey(labelKey)).font(.subheadline).lineLimit(1) + label.font(.subheadline).lineLimit(1) Spacer() if let progress { Text("\(Int(progress * 100))%").font(.caption).foregroundStyle(.secondary) @@ -54,6 +56,15 @@ struct ProgressRow: View { } .frame(maxWidth: .infinity) } + + @ViewBuilder + private var label: some View { + if let labelText { + Text(labelText) + } else { + Text(LocalizedStringKey(labelKey)) + } + } } // MARK: - Field diff --git a/apple/project.yml b/apple/project.yml index a718b34..b0de8fc 100644 --- a/apple/project.yml +++ b/apple/project.yml @@ -17,6 +17,9 @@ targets: VniDrop: type: application supportedDestinations: [iOS, macOS] + configFiles: + Debug: Signing.xcconfig + Release: Signing.xcconfig sources: - path: VniDrop excludes: @@ -30,6 +33,10 @@ targets: CURRENT_PROJECT_VERSION: "1" GENERATE_INFOPLIST_FILE: NO INFOPLIST_FILE: VniDrop/Resources/Info.plist + # Mirror the Info.plist identity so Xcode's Identity editor shows it too + # (the editor reads these build settings, not the manual plist). + INFOPLIST_KEY_CFBundleDisplayName: VniDrop + INFOPLIST_KEY_LSApplicationCategoryType: public.app-category.utilities SWIFT_VERSION: "5.9" ENABLE_USER_SCRIPT_SANDBOXING: NO ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon