nvrsion: Add fix for QR Code Scan Failure in AppStore, Sync, and Tests; explore connection failures over local network; update Info.plist, Models, and SettingsView.

Nucleic-Promote: 1
Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
2026-07-03 01:42:40 -07:00
co-authored by Nucleic
parent cb6976baa4
commit 22d0752329
3 changed files with 62 additions and 1 deletions
+7
View File
@@ -6,6 +6,13 @@
<array>
<string>_nucleic._tcp</string>
</array>
<!-- iOS 14+ gates ALL local-network access — both Bonjour discovery (_nucleic._tcp above)
and a direct NWConnection to the Mac's LAN IP from the pairing QR — behind the
local-network permission, which only prompts if this usage string is present. Without
it the grant never happens and every LAN connection fails silently, so pairing and
reconnect appear to "do nothing." -->
<key>NSLocalNetworkUsageDescription</key>
<string>Nucleic connects to your Mac on the local network to pair and to sync your sessions and approvals.</string>
<key>NucleicChannel</key>
<string>$(NUCLEIC_CHANNEL)</string>
<!-- The short git commit this build came from, shown in the non-release banner to match
@@ -695,7 +695,35 @@ final class RemoteStore: ObservableObject {
}
private func makeChannel(_ endpoint: NWEndpoint) -> NWFrameChannel {
NWFrameChannel(endpoint: endpoint)
let channel = NWFrameChannel(endpoint: endpoint)
// Surface a transport-level failure with an actionable message. Without this, a refused
// connection or a denied local-network permission finished the frame stream and only
// showed up as a generic "handshake closed" — hiding the real (usually fixable) cause.
channel.onFailed = { [weak self] error in
Task { @MainActor in self?.handleTransportFailure(error) }
}
return channel
}
/// Map an `NWConnection` failure to a connectivity state the pairing/onboarding UI can act on.
/// Only meaningful while we're still establishing the link; once `.connected`, a drop is the
/// normal `.closed` → reconnect path's job.
private func handleTransportFailure(_ error: String) {
guard connectivity != .connected else { return }
connectivity = .failed(Self.friendlyTransportError(error))
}
/// Turn a raw `NWError` string into a short, fixable hint. The common onboarding failures are
/// a denied Local Network permission (EPERM / -65555) and the Mac not listening (refused).
private static func friendlyTransportError(_ error: String) -> String {
let lower = error.lowercased()
if lower.contains("denied") || lower.contains("not permitted") || lower.contains("65555") {
return "Can't reach the local network. In Settings ▸ Nucleic, allow Local Network access, then try again."
}
if lower.contains("refused") {
return "Your Mac refused the connection. Check that remote access is still on in Nucleic ▸ Settings."
}
return "Couldn't connect to your Mac — make sure it's on the same Wi‑Fi and remote access is on."
}
private func resolveEndpoint(fingerprint: String?, lanHost: String?, lanPort: UInt16?) -> NWEndpoint? {
@@ -126,6 +126,10 @@ struct PairingIntroView: View {
}
.buttonStyle(.borderedProminent)
// Feedback after a scan: the whole UI is still the intro until pairing completes, so
// without this a failed or in-flight pair looks like "nothing happened."
pairingStatus
VStack(spacing: 6) {
Button { store.enterDemo() } label: {
Label("Explore a demo — no Mac needed", systemImage: "play.circle")
@@ -151,4 +155,26 @@ struct PairingIntroView: View {
}
}
}
/// A status line reflecting an in-flight or failed pairing attempt. Silent in the resting
/// `.unpaired` state (the intro copy already covers that).
@ViewBuilder
private var pairingStatus: some View {
switch store.connectivity {
case .connecting, .reconnecting:
Label("Connecting to your Mac…", systemImage: "arrow.triangle.2.circlepath")
.font(.footnote).foregroundStyle(.secondary)
case .hostOffline:
Label("Can't reach your Mac. Make sure it's on the same Wi‑Fi and remote access is on.",
systemImage: "wifi.exclamationmark")
.font(.footnote).foregroundStyle(.orange)
.multilineTextAlignment(.center).padding(.horizontal, 32)
case .failed(let message):
Label(message, systemImage: "exclamationmark.triangle.fill")
.font(.footnote).foregroundStyle(.red)
.multilineTextAlignment(.center).padding(.horizontal, 32)
case .connected, .unpaired:
EmptyView()
}
}
}