Merge origin/master into feat/apple-typed-resources-and-fixes

Brings in custom relays, relay connection policies, storage cache clearing, and
receiver-failure reporting. Apple-side conflict resolutions:
- CoreRepository: keep CoreDispatcher, adopt master's relay factory + network
  transition guard, drop the now-unused serial queue.
- TransferDetailsView: keep the toolbar-share layout; adopt master's
  invitationPresentation-based QR panel and the new .failed receiver case (typed).
- SettingsModel/SettingsScreen: typed L10n titleKey with master's .network case;
  relay controls and the Free up space / storage redesign coexist.
- Add the missing transfer_receiver_failed localization key.
- Regenerate l10n from the merged strings.json; keep the Apple catalog untracked.
- Drop the notificationsEnabled test assertion (notifications preference was
  intentionally removed on this branch).
This commit is contained in:
2026-07-24 17:48:10 +02:00
80 changed files with 5906 additions and 383 deletions

View File

@@ -26,7 +26,10 @@ final class AppModel: ObservableObject {
AppLogger.info("lifecycle", "app started", ["platform": environment.name])
Task {
let result = await repository.initialize(appDataDir: environment.defaultCoreDataDir)
let result = await repository.initialize(
appDataDir: environment.defaultCoreDataDir,
networkConfiguration: preferences.preferences.relayConfiguration
)
if case .failure(let error) = result { messages.error(error) }
}

View File

@@ -236,6 +236,7 @@ private struct ReceiverRow: View {
let name = receiver.receiverName ?? receiver.receiverDeviceName ?? String(localized: L10n.Transfer.nearbyDevice)
let showLive = sendProgress != nil && receiver.status != .completed
&& receiver.status != .refused && receiver.status != .expired
&& receiver.status != .failed
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 6) {
Text(name).font(VniType.bodyLarge).lineLimit(1)
@@ -277,24 +278,39 @@ struct TransferSharePanel: View {
var body: some View {
PanelContainer(title: String(localized: L10n.Transfer.shareTitle)) {
if let ticket = transfer.ticket {
qrCard(ticket: ticket)
Text(String(localized: L10n.Transfer.scanQr))
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
.frame(maxWidth: .infinity)
switch transfer.invitationPresentation {
case .ready(let ticket):
let qrImage = QRCode.generate(from: ticket)
qrCard(image: qrImage)
if qrImage != nil {
Text(String(localized: L10n.Transfer.scanQr))
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
.frame(maxWidth: .infinity)
}
ShareActionsView(model: model, transfer: transfer, ticket: ticket)
} else {
case .preparing:
Text(String(localized: L10n.Transfer.eventPreparing)).foregroundStyle(colors.foregroundLighter)
case .unavailable:
Text(String(localized: transfer.status == .failed ? L10n.Transfer.eventFailed : L10n.Transfer.eventStopped))
.foregroundStyle(colors.foregroundLighter)
}
}
}
private func qrCard(ticket: String) -> some View {
private func qrCard(image: Image?) -> some View {
ZStack {
if let qr = QRCode.generate(from: ticket) {
qr.interpolation(.none).resizable().scaledToFit().padding(14)
if let image {
image.interpolation(.none).resizable().scaledToFit().padding(14)
} else {
ProgressView()
VStack(spacing: 10) {
Image(systemName: "qrcode")
.font(.system(size: 36, weight: .medium))
Text(LocalizedStringKey("transfer_qr_unavailable"))
.font(VniType.bodySmall)
.multilineTextAlignment(.center)
}
.foregroundStyle(.black.opacity(0.72))
.padding(22)
}
}
.frame(width: 268, height: 268)
@@ -303,6 +319,26 @@ struct TransferSharePanel: View {
}
}
enum TransferInvitationPresentation: Equatable {
case preparing
case ready(String)
case unavailable
}
extension Transfer {
var invitationPresentation: TransferInvitationPresentation {
switch status {
case .importing:
return .preparing
case .sharing:
guard let ticket, !ticket.isEmpty else { return .preparing }
return .ready(ticket)
case .receiving, .done, .failed, .cancelled, .stopped:
return .unavailable
}
}
}
// MARK: - QR generation (CoreImage)
enum QRCode {
@@ -359,6 +395,7 @@ extension ReceiverDeliveryStatus {
case .refused: return L10n.Transfer.receiverRefused
case .expired: return L10n.Transfer.receiverExpired
case .completed: return L10n.Transfer.receiverCompleted
case .failed: return L10n.Transfer.receiverFailed
case .unknown: return L10n.Transfer.receiverUnknown
}
}
@@ -366,7 +403,7 @@ extension ReceiverDeliveryStatus {
func statusColor(_ colors: VniDropColors) -> Color {
switch self {
case .completed: return colors.brandDefault
case .refused, .expired: return colors.destructiveDefault
case .refused, .expired, .failed: return colors.destructiveDefault
default: return colors.foregroundLighter
}
}

View File

@@ -7,6 +7,7 @@ enum SettingsSection: Hashable {
case preferences
case appearance
case notifications
case network
case storage
case about
case bugReport
@@ -17,6 +18,7 @@ enum SettingsSection: Hashable {
case .preferences: return L10n.Preferences.title
case .appearance: return L10n.Appearance.title
case .notifications: return L10n.Notifications.title
case .network: return L10n.Settings.networkTitle
case .storage: return L10n.Storage.title
case .about: return L10n.About.title
case .bugReport: return L10n.About.bugReport
@@ -43,6 +45,14 @@ struct SettingsState: Equatable {
var themeMode: ThemeMode = .system
var notificationPermission: NotificationPermission = .notDetermined
var diagnosticsEnabled = false
var relayMode: RelayPreferenceMode = .automatic
var relayURLs: [String] = []
var relayValidationError: RelayConfigurationValidationError?
var relayConfigurationIsDirty = false
var isApplyingRelayConfiguration = false
var hasActiveNetworkWork = false
var endpointId: String?
var relayApplyErrorKey: String?
var deviceInfo: DeviceInfo?
var appVersion = ""
var isLoadingDeviceInfo = false
@@ -67,6 +77,13 @@ struct SettingsState: Equatable {
&& lhs.themeMode == rhs.themeMode
&& lhs.notificationPermission == rhs.notificationPermission
&& lhs.diagnosticsEnabled == rhs.diagnosticsEnabled && lhs.appVersion == rhs.appVersion
&& lhs.relayMode == rhs.relayMode && lhs.relayURLs == rhs.relayURLs
&& lhs.relayValidationError == rhs.relayValidationError
&& lhs.relayConfigurationIsDirty == rhs.relayConfigurationIsDirty
&& lhs.isApplyingRelayConfiguration == rhs.isApplyingRelayConfiguration
&& lhs.hasActiveNetworkWork == rhs.hasActiveNetworkWork
&& lhs.endpointId == rhs.endpointId
&& lhs.relayApplyErrorKey == rhs.relayApplyErrorKey
&& lhs.isLoadingDeviceInfo == rhs.isLoadingDeviceInfo
&& lhs.bugWhatHappened == rhs.bugWhatHappened && lhs.bugExpected == rhs.bugExpected
&& lhs.bugSteps == rhs.bugSteps && lhs.bugContact == rhs.bugContact
@@ -98,6 +115,7 @@ final class SettingsModel: ObservableObject {
private var usernamePersistTask: Task<Void, Never>?
private var hasLocalUsernameDraft = false
private var hasRelayConfigurationDraft = false
private var cancellables = Set<AnyCancellable>()
init(
@@ -134,10 +152,29 @@ final class SettingsModel: ObservableObject {
self.state.receiveFolder = folder
self.state.themeMode = prefs.themeMode
self.state.diagnosticsEnabled = prefs.diagnosticsEnabled
if !self.hasRelayConfigurationDraft {
self.state.relayMode = prefs.relayConfiguration.mode
self.state.relayURLs = prefs.relayConfiguration.relayURLs
self.state.relayConfigurationIsDirty = false
}
if folder != previousFolder { Task { await self.validateFolder(folder) } }
}
.store(in: &cancellables)
repository.statePublisher
.sink { [weak self] coreState in
guard let self else { return }
let hasActiveWork = (coreState.status?.activeTransfers ?? 0) > 0
|| (coreState.status?.activeShares ?? 0) > 0
|| coreState.transfers.contains(where: { $0.status.isActiveTransfer })
self.state.hasActiveNetworkWork = hasActiveWork
self.state.endpointId = coreState.status?.endpointId
if !hasActiveWork && self.state.relayApplyErrorKey == "relay_apply_active_transfers" {
self.state.relayApplyErrorKey = nil
}
}
.store(in: &cancellables)
refreshNotificationPermission()
loadDeviceInfo()
}
@@ -204,6 +241,119 @@ final class SettingsModel: ObservableObject {
}
}
// MARK: - Network
func setRelayMode(_ mode: RelayPreferenceMode) {
hasRelayConfigurationDraft = true
state.relayMode = mode
if mode.usesCustomRelayURLs && state.relayURLs.isEmpty { state.relayURLs = [""] }
updateRelayConfigurationDraft()
}
func setRelayURL(_ value: String, at index: Int) {
guard state.relayURLs.indices.contains(index) else { return }
hasRelayConfigurationDraft = true
state.relayURLs[index] = value
updateRelayConfigurationDraft()
}
func addRelayURL() {
guard state.relayURLs.count < RelayConfigurationValidator.maximumRelayCount else { return }
hasRelayConfigurationDraft = true
state.relayURLs.append("")
updateRelayConfigurationDraft()
}
func removeRelayURL(at index: Int) {
guard state.relayURLs.indices.contains(index) else { return }
hasRelayConfigurationDraft = true
state.relayURLs.remove(at: index)
if state.relayURLs.isEmpty { state.relayURLs = [""] }
updateRelayConfigurationDraft()
}
func applyRelayConfiguration() {
guard !state.isApplyingRelayConfiguration, state.relayConfigurationIsDirty else { return }
let configuration: RelayConfiguration
do {
configuration = try RelayConfigurationValidator.validate(
mode: state.relayMode,
relayURLs: state.relayURLs,
retainedRelayURLs: preferences.preferences.relayConfiguration.relayURLs
)
} catch let error as RelayConfigurationValidationError {
state.relayValidationError = error
state.relayApplyErrorKey = nil
return
} catch {
return
}
let coreState = repository.state
let hasActiveWork = (coreState.status?.activeTransfers ?? 0) > 0
|| (coreState.status?.activeShares ?? 0) > 0
|| coreState.transfers.contains(where: { $0.status.isActiveTransfer })
guard !hasActiveWork else {
state.hasActiveNetworkWork = true
state.relayApplyErrorKey = "relay_apply_active_transfers"
messages.show(UiMessage(text: .resource("relay_apply_active_transfers"), tone: .warning))
return
}
let previousConfiguration = preferences.preferences.relayConfiguration
state.relayValidationError = nil
state.relayApplyErrorKey = nil
state.isApplyingRelayConfiguration = true
Task {
let applyResult = await repository.initialize(
appDataDir: environment.defaultCoreDataDir,
networkConfiguration: configuration
)
switch applyResult {
case .success:
hasRelayConfigurationDraft = false
preferences.setRelayConfiguration(configuration)
state.isApplyingRelayConfiguration = false
state.relayConfigurationIsDirty = false
messages.show(UiMessage(text: .resource("relay_settings_applied"), tone: .success))
case .failure(let error):
if let lifecycleError = error as? CoreNetworkLifecycleError {
state.isApplyingRelayConfiguration = false
switch lifecycleError {
case .activeNetworkWork:
state.hasActiveNetworkWork = true
state.relayApplyErrorKey = "relay_apply_active_transfers"
case .transitionInProgress:
state.relayApplyErrorKey = "relay_apply_failed"
}
return
}
let rollbackResult = await repository.initialize(
appDataDir: environment.defaultCoreDataDir,
networkConfiguration: previousConfiguration
)
state.isApplyingRelayConfiguration = false
if case .success = rollbackResult {
state.relayApplyErrorKey = "relay_apply_failed"
messages.show(UiMessage(text: .resource("relay_apply_failed"), tone: .error))
} else {
state.relayApplyErrorKey = "relay_restore_failed"
messages.show(UiMessage(text: .resource("relay_restore_failed"), tone: .error))
}
}
}
}
private func updateRelayConfigurationDraft() {
state.relayValidationError = nil
state.relayApplyErrorKey = nil
let saved = preferences.preferences.relayConfiguration
let draftURLs = state.relayMode.usesCustomRelayURLs ? state.relayURLs : saved.relayURLs
state.relayConfigurationIsDirty = saved != RelayConfiguration(mode: state.relayMode, relayURLs: draftURLs)
hasRelayConfigurationDraft = state.relayConfigurationIsDirty
}
func setBugWhatHappened(_ value: String) { state.bugWhatHappened = value }
func setBugExpected(_ value: String) { state.bugExpected = value }
func setBugSteps(_ value: String) { state.bugSteps = value }

View File

@@ -39,6 +39,17 @@ struct SettingsScreen: View {
NavigationLink(value: SettingsSection.storage) {
SettingsRow(icon: .internaldrive, title: String(localized: L10n.Storage.title), value: nil)
}
}
Section(String(localized: L10n.Settings.advancedTitle)) {
NavigationLink(value: SettingsSection.network) {
SettingsRow(
icon: .network,
title: String(localized: L10n.Settings.networkTitle),
value: relayModeLabel(model.state.relayMode)
)
}
}
Section {
NavigationLink(value: SettingsSection.about) {
SettingsRow(icon: .infoCircle, title: String(localized: L10n.About.title), value: nil)
}
@@ -100,6 +111,8 @@ private struct SettingsSectionContent: View {
AppearanceSettings(model: model)
case .notifications:
NotificationSettings(model: model)
case .network:
NetworkSettings(model: model)
case .storage:
StorageSettings(model: model)
case .about:
@@ -110,6 +123,24 @@ private struct SettingsSectionContent: View {
}
}
func relayModeLabel(_ mode: RelayPreferenceMode) -> String {
switch mode {
case .automatic: return String(localized: "relay_mode_automatic")
case .strictCustom: return String(localized: "relay_mode_custom")
case .customWithDirectFallback: return String(localized: "relay_mode_custom_direct_fallback")
case .localOnly: return String(localized: "relay_mode_local_only")
}
}
func relayModeDescriptionKey(_ mode: RelayPreferenceMode) -> String {
switch mode {
case .automatic: return "relay_mode_automatic_description"
case .strictCustom: return "relay_mode_custom_description"
case .customWithDirectFallback: return "relay_mode_custom_direct_fallback_description"
case .localOnly: return "relay_mode_local_only_description"
}
}
struct SettingsRow: View {
let icon: SFSymbol
let title: String

View File

@@ -74,6 +74,175 @@ struct NotificationSettings: View {
}
}
struct NetworkSettings: View {
@ObservedObject var model: SettingsModel
var body: some View {
Section {
Picker(
String(localized: "settings_network_title"),
selection: Binding(get: { model.state.relayMode }, set: { model.setRelayMode($0) })
) {
ForEach(RelayPreferenceMode.allCases, id: \.self) { mode in
Text(relayModeLabel(mode)).tag(mode)
}
}
.pickerStyle(.inline)
.disabled(model.state.isApplyingRelayConfiguration)
} footer: {
Text(LocalizedStringKey(relayModeDescriptionKey(model.state.relayMode)))
}
Section {
Label {
Text(LocalizedStringKey("relay_privacy_description"))
.fixedSize(horizontal: false, vertical: true)
} icon: {
Image(systemName: "lock.shield")
}
.foregroundStyle(.secondary)
}
if let endpointId = model.state.endpointId, !endpointId.isEmpty {
Section {
Text(String(format: String(localized: "approval_endpoint_id"), endpointId))
.font(.footnote.monospaced())
.textSelection(.enabled)
}
}
if model.state.relayMode.usesCustomRelayURLs {
Section {
if model.state.relayMode == .strictCustom {
Label {
Text(LocalizedStringKey("relay_strict_warning"))
.fixedSize(horizontal: false, vertical: true)
} icon: {
Image(systemName: "exclamationmark.shield.fill")
}
.foregroundStyle(.orange)
}
ForEach(Array(model.state.relayURLs.indices), id: \.self) { index in
VStack(alignment: .leading, spacing: 6) {
HStack {
TextField(
"https://relay.example.com",
text: Binding(
get: {
model.state.relayURLs.indices.contains(index)
? model.state.relayURLs[index]
: ""
},
set: { model.setRelayURL($0, at: index) }
)
)
#if os(iOS)
.keyboardType(.URL)
.textInputAutocapitalization(.never)
#endif
.autocorrectionDisabled()
.disabled(model.state.isApplyingRelayConfiguration)
Button(role: .destructive) {
model.removeRelayURL(at: index)
} label: {
Image(systemName: "minus.circle.fill")
}
.buttonStyle(.borderless)
.accessibilityLabel(Text(LocalizedStringKey("relay_remove_url")))
.disabled(model.state.isApplyingRelayConfiguration)
}
if let error = model.state.relayValidationError, error.urlIndex == index {
Text(relayValidationMessage(error))
.font(.caption)
.foregroundStyle(.red)
}
}
}
Button(action: model.addRelayURL) {
Label(String(localized: "relay_add_url"), systemImage: "plus.circle")
}
.disabled(
model.state.relayURLs.count >= RelayConfigurationValidator.maximumRelayCount
|| model.state.isApplyingRelayConfiguration
)
} header: {
Text(LocalizedStringKey("relay_custom_urls_label"))
} footer: {
Text(LocalizedStringKey("relay_custom_urls_help"))
}
}
if let error = model.state.relayValidationError, error.urlIndex == nil {
Section {
Label {
Text(relayValidationMessage(error))
} icon: {
Image(systemName: "exclamationmark.triangle.fill")
}
.foregroundStyle(.red)
}
}
if model.state.hasActiveNetworkWork || model.state.relayApplyErrorKey != nil {
Section {
Label {
Text(LocalizedStringKey(
model.state.hasActiveNetworkWork
? "relay_apply_active_transfers"
: model.state.relayApplyErrorKey ?? "relay_apply_failed"
))
} icon: {
Image(systemName: "exclamationmark.triangle.fill")
}
.foregroundStyle(.red)
}
}
Section {
Button(action: model.applyRelayConfiguration) {
HStack {
Text(LocalizedStringKey(
model.state.isApplyingRelayConfiguration ? "relay_applying" : "relay_apply"
))
if model.state.isApplyingRelayConfiguration {
Spacer()
ProgressView()
}
}
}
.disabled(
!model.state.relayConfigurationIsDirty
|| model.state.isApplyingRelayConfiguration
|| model.state.hasActiveNetworkWork
)
} footer: {
Text(LocalizedStringKey("relay_apply_restart_description"))
}
}
}
private func relayValidationMessage(_ error: RelayConfigurationValidationError) -> String {
switch error {
case .missingURL:
return String(localized: "relay_validation_missing_url")
case .tooManyURLs:
return String(
format: String(localized: "relay_validation_too_many_urls"),
RelayConfigurationValidator.maximumRelayCount
)
case .httpsRequired(let index):
return String(format: String(localized: "relay_validation_https_required"), index + 1)
case .invalidURL(let index):
return String(format: String(localized: "relay_validation_invalid_url"), index + 1)
case .duplicateURL(let index):
return String(format: String(localized: "relay_validation_duplicate_url"), index + 1)
}
}
struct StorageSettings: View {
@ObservedObject var model: SettingsModel
@State private var showDeleteConfirmation = false