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
This commit is contained in:
2026-07-19 12:33:58 +02:00
parent ea376a382b
commit ceccfcda71
11 changed files with 278 additions and 68 deletions

3
apple/.gitignore vendored
View File

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

9
apple/Signing.xcconfig Normal file
View File

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

View File

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

View File

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

View File

@@ -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<Void, Error>) {

View File

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

View File

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

View File

@@ -2,26 +2,12 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UILaunchScreen</key>
<dict/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>VniDrop</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
@@ -37,6 +23,56 @@
</array>
</dict>
</array>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>NFCReaderUsageDescription</key>
<string>VniDrop uses NFC to read transfer invitation tags.</string>
<key>NSBonjourServices</key>
<array>
<string></string>
</array>
<key>NSCameraUsageDescription</key>
<string>VniDrop uses the camera to scan transfer QR codes.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>VniDrop needs local network access to send to other local devices if needed.</string>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>processing</string>
<string>remote-notification</string>
</array>
<key>UIFileSharingEnabled</key>
<true/>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
@@ -59,27 +95,5 @@
</dict>
</dict>
</array>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>UIFileSharingEnabled</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>processing</string>
<string>remote-notification</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>VniDrop uses the camera to scan transfer QR codes.</string>
<key>NFCReaderUsageDescription</key>
<string>VniDrop uses NFC to read transfer invitation tags.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>VniDrop needs local network access to send to other local devices if needed.</string>
<key>NSBonjourServices</key>
<array>
<string></string>
</array>
</dict>
</plist>

View File

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

View File

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

View File

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