Skip to content

Commit 61b4238

Browse files
committed
Fix core-not-running with empty diagnostics error (beta.21)
Happ configs that reference geosite/geoip and balancers were failing EvcoreStartCore while the app showed VPN connected and Last error: —. - Track coreStarted explicitly and always return an error string via IPC - Keep lastError/diagnostics across disconnect so Diagnostics is readable - Best-effort geo download in the extension; strip geosite/geoip rules and flatten balancers to a proxy catch-all when .dat files are missing - Drop localhost DNS entries that break Xray on iOS NE
1 parent d9bc17e commit 61b4238

6 files changed

Lines changed: 268 additions & 122 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ All notable changes to **decoder sec.** are documented here.
1717
- In-app log console
1818
- Signed TestFlight builds
1919

20+
## [0.1.0-beta.21] — 2026-08-12
21+
22+
### Fixed
23+
- **VPN connected / Core running: no / Last error: —** — ошибка ядра больше не теряется при disconnect; явный флаг `coreStarted`; IPC всегда отдаёт текст ошибки
24+
- Конфиги Happ с `geosite:`/`geoip:` и balancer: best-effort скачивание `.dat`, иначе **strip geo-правил** + flatten balancer → catch-all на `proxy`
25+
- DNS: убран `localhost`/`127.0.0.1` из Xray DNS (ломал старт без локального DNS inbound)
26+
- Diagnostics: поле **Geo rules stripped**, geo-статус по конфигу сессии
27+
2028
## [0.1.0-beta.20] — 2026-08-12
2129

2230
### Fixed

‎DecoderSec/Core/TunnelManager.swift‎

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ struct TunnelDiagnosticsSnapshot: Equatable {
1919
var geoNeedsGeosite: Bool
2020
var geoHasGeoip: Bool
2121
var geoHasGeosite: Bool
22+
var geoStripped: Bool
2223

2324
static let empty = TunnelDiagnosticsSnapshot(
2425
coreRunning: false,
@@ -28,7 +29,8 @@ struct TunnelDiagnosticsSnapshot: Equatable {
2829
geoNeedsGeoip: false,
2930
geoNeedsGeosite: false,
3031
geoHasGeoip: false,
31-
geoHasGeosite: false
32+
geoHasGeosite: false,
33+
geoStripped: false
3234
)
3335
}
3436

@@ -82,7 +84,8 @@ final class TunnelManager: ObservableObject {
8284
if on {
8385
didConnect = false
8486
coreRunning = false
85-
tunnelDiagnostics = .empty
87+
lastError = nil
88+
// Keep previous diagnostics until the new session reports.
8689
let m = try await ensureManager(configuration: configuration)
8790
let payload = TunnelConfigPayload(
8891
configContent: configuration.content,
@@ -95,9 +98,7 @@ final class TunnelManager: ObservableObject {
9598
} else {
9699
pendingReconnect = false
97100
coreRunning = false
98-
tunnelDiagnostics = .empty
99101
try await disableTunnel()
100-
lastError = nil
101102
}
102103
} catch {
103104
lastError = Self.userFacingError(error)
@@ -147,12 +148,14 @@ final class TunnelManager: ObservableObject {
147148
queryTunnelMessage(type: "diagnostics", retries: retries) { [weak self] json in
148149
guard let self else { return }
149150
let running = json["running"] as? Bool ?? false
150-
let error = json["error"] as? String
151+
let error = (json["error"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
151152
self.coreRunning = running
152153
if let error, !error.isEmpty {
153154
self.lastError = error
154155
} else if running {
155156
self.lastError = nil
157+
} else if self.lastError == nil {
158+
self.lastError = "Core is not running."
156159
}
157160
self.tunnelDiagnostics = TunnelDiagnosticsSnapshot(
158161
coreRunning: running,
@@ -162,10 +165,17 @@ final class TunnelManager: ObservableObject {
162165
geoNeedsGeoip: json["geoNeedsGeoip"] as? Bool ?? false,
163166
geoNeedsGeosite: json["geoNeedsGeosite"] as? Bool ?? false,
164167
geoHasGeoip: json["geoHasGeoip"] as? Bool ?? false,
165-
geoHasGeosite: json["geoHasGeosite"] as? Bool ?? false
168+
geoHasGeosite: json["geoHasGeosite"] as? Bool ?? false,
169+
geoStripped: json["geoStripped"] as? Bool ?? false
166170
)
171+
// Keep the tunnel up briefly when core failed so Diagnostics can show the error.
172+
// User can disconnect manually; we only auto-stop after a short delay.
167173
if self.status == .connected, !running {
168-
Task { try? await self.disableTunnel() }
174+
Task {
175+
try? await Task.sleep(nanoseconds: 8_000_000_000)
176+
guard self.status == .connected, !self.coreRunning else { return }
177+
try? await self.disableTunnel()
178+
}
169179
}
170180
}
171181
}
@@ -233,10 +243,10 @@ final class TunnelManager: ObservableObject {
233243
self.trackConnectFailures(previous: previous, current: connection.status)
234244
if connection.status == .connected {
235245
self.didConnect = true
236-
self.refreshCoreStatus(retries: 3)
246+
self.refreshCoreStatus(retries: 5)
237247
} else {
238248
self.coreRunning = false
239-
self.tunnelDiagnostics = .empty
249+
// Do not wipe tunnelDiagnostics / lastError on disconnect — user needs them.
240250
if (connection.status == .disconnected || connection.status == .invalid)
241251
&& self.pendingReconnect {
242252
self.pendingReconnect = false

‎DecoderSec/Features/Settings/DiagnosticsView.swift‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,15 @@ struct DiagnosticsView: View {
1717
}
1818

1919
Section("Geo resources") {
20-
row("Needs geoip.dat", geoRow(tunnel.tunnelDiagnostics.geoNeedsGeoip, tunnel.tunnelDiagnostics.geoHasGeoip))
21-
row("Needs geosite.dat", geoRow(tunnel.tunnelDiagnostics.geoNeedsGeosite, tunnel.tunnelDiagnostics.geoHasGeosite))
20+
row("Needs geoip.dat", geoRow(
21+
tunnel.tunnelDiagnostics.geoNeedsGeoip || (snapshot?.usesGeoRules == true),
22+
tunnel.tunnelDiagnostics.geoHasGeoip
23+
))
24+
row("Needs geosite.dat", geoRow(
25+
tunnel.tunnelDiagnostics.geoNeedsGeosite || (snapshot?.usesGeoRules == true),
26+
tunnel.tunnelDiagnostics.geoHasGeosite
27+
))
28+
row("Geo rules stripped", tunnel.tunnelDiagnostics.geoStripped ? "yes" : "no")
2229
if let path = tunnel.tunnelDiagnostics.resourcesPath {
2330
row("Extension resources path", path)
2431
}

‎DecoderSecTunnel/PacketTunnelProvider.swift‎

Lines changed: 72 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,8 @@
22
// PacketTunnelProvider.swift
33
// DecoderSec
44
//
5-
// Receives the full config from the app via providerConfiguration / start options.
6-
// No App Groups, no Core Data.
7-
//
8-
// Startup follows NodePassProject/Everywhere: synchronous EvcoreStartCore in the
9-
// setTunnelNetworkSettings callback, completionHandler(nil) even when the core
10-
// fails (error surfaced via handleAppMessage / diagnostics IPC).
5+
// Startup follows NodePassProject/Everywhere: synchronous EvcoreStartCore,
6+
// completionHandler(nil) even when the core fails so IPC can report coreError.
117
//
128

139
import EverywhereCore
@@ -18,8 +14,12 @@ import NetworkExtension
1814
final class PacketTunnelProvider: NEPacketTunnelProvider {
1915
private static let tunnelMTU = 1500
2016

17+
private var coreStarted = false
2118
private var coreError: String?
2219
private var resourcesPathError: String?
20+
private var lastConfigContent: String?
21+
private var lastCoreType: CoreType = .xray
22+
private var geoStripped = false
2323

2424
private var pathMonitor: NWPathMonitor?
2525
private let pathMonitorQueue = DispatchQueue(label: "com.decodersec.app.pathMonitor", qos: .utility)
@@ -28,8 +28,11 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
2828
private static let pathDebounceInterval: DispatchTimeInterval = .milliseconds(1000)
2929

3030
override func startTunnel(options: [String: NSObject]?, completionHandler: @escaping (Error?) -> Void) {
31+
coreStarted = false
3132
coreError = nil
3233
resourcesPathError = nil
34+
lastConfigContent = nil
35+
geoStripped = false
3336

3437
let providerConfig = (protocolConfiguration as? NETunnelProviderProtocol)?.providerConfiguration
3538

@@ -40,14 +43,14 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
4043
return
4144
}
4245

46+
lastCoreType = payload.coreType
47+
lastConfigContent = payload.configContent
48+
4349
let configContent: String
4450
do {
45-
configContent = try ConfigNormalizer.normalize(
46-
payload.configContent,
47-
for: payload.coreType,
48-
useZashboard: payload.useZashboard
49-
)
51+
configContent = try prepareConfig(payload)
5052
} catch {
53+
coreError = error.localizedDescription
5154
completionHandler(error)
5255
return
5356
}
@@ -61,16 +64,18 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
6164
return
6265
}
6366
if let error {
67+
self.coreError = error.localizedDescription
6468
completionHandler(error)
6569
return
6670
}
6771

6872
let fd = TunnelFD.lookup(for: self.packetFlow)
6973
if fd < 0 {
74+
self.coreError = "could not obtain TUN file descriptor"
7075
completionHandler(NSError(
7176
domain: "DecoderSec",
7277
code: -1,
73-
userInfo: [NSLocalizedDescriptionKey: "could not obtain TUN file descriptor"]
78+
userInfo: [NSLocalizedDescriptionKey: self.coreError!]
7479
))
7580
return
7681
}
@@ -83,21 +88,60 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
8388
}
8489

8590
var coreErr: NSError?
86-
guard EvcoreStartCore(payload.coreType.rawValue, configContent, Int(fd), Self.tunnelMTU, &coreErr) else {
87-
self.coreError = Self.describeCoreError(coreErr)
88-
NSLog("DecoderSec: EvcoreStartCore failed: \(self.coreError ?? "unknown")")
91+
let started = EvcoreStartCore(
92+
payload.coreType.rawValue,
93+
configContent,
94+
Int(fd),
95+
Self.tunnelMTU,
96+
&coreErr
97+
)
98+
99+
guard started else {
100+
var message = Self.describeCoreError(coreErr)
101+
if self.geoStripped {
102+
message += " (geo rules were stripped — missing .dat in extension)"
103+
}
104+
self.coreStarted = false
105+
self.coreError = message
106+
NSLog("DecoderSec: EvcoreStartCore failed: \(message)")
89107
// Keep NE alive so the app can read coreError via IPC (Everywhere pattern).
90108
completionHandler(nil)
91109
return
92110
}
93111

112+
self.coreStarted = true
113+
self.coreError = nil
94114
self.startPathMonitor()
95115
completionHandler(nil)
96116
}
97117
}
98118

119+
private func prepareConfig(_ payload: TunnelConfigPayload) throws -> String {
120+
if payload.coreType == .xray {
121+
let dir = EVCore.resourcesURL(for: .xray)
122+
GeoResourceBootstrap.tryEnsurePresent(forConfig: payload.configContent, in: dir)
123+
let status = GeoResourceBootstrap.status(forConfig: payload.configContent, in: dir)
124+
let stripGeo = !status.isReady
125+
geoStripped = stripGeo
126+
if stripGeo, let missing = status.missingSummary {
127+
NSLog("DecoderSec: missing geo files (\(missing)) — stripping geosite/geoip rules")
128+
}
129+
return try XrayNormalizer.normalize(
130+
payload.configContent,
131+
useZashboard: payload.useZashboard,
132+
stripGeoRules: stripGeo
133+
)
134+
}
135+
return try ConfigNormalizer.normalize(
136+
payload.configContent,
137+
for: payload.coreType,
138+
useZashboard: payload.useZashboard
139+
)
140+
}
141+
99142
override func stopTunnel(with _: NEProviderStopReason, completionHandler: @escaping () -> Void) {
100143
stopPathMonitor()
144+
coreStarted = false
101145

102146
let lock = NSLock()
103147
var didComplete = false
@@ -147,15 +191,21 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
147191
}
148192

149193
private func diagnosticsPayload() -> [String: Any] {
150-
var payload: [String: Any] = ["running": coreError == nil]
151-
if let err = coreError { payload["error"] = err }
152-
payload["resourcesPath"] = EVCore.resourcesURL(for: .xray).path
194+
var payload: [String: Any] = [
195+
"running": coreStarted,
196+
"geoStripped": geoStripped,
197+
]
198+
if let err = coreError, !err.isEmpty {
199+
payload["error"] = err
200+
} else if !coreStarted {
201+
payload["error"] = "Core is not running."
202+
}
203+
payload["resourcesPath"] = EVCore.resourcesURL(for: lastCoreType).path
153204
if let resourcesPathError {
154205
payload["resourcesPathError"] = resourcesPathError
155206
}
156207

157-
let providerConfig = (protocolConfiguration as? NETunnelProviderProtocol)?.providerConfiguration
158-
if let config = providerConfig?[TunnelConfigPayload.configContentKey] as? String {
208+
if let config = lastConfigContent {
159209
let geo = GeoResourceBootstrap.status(forConfig: config, in: EVCore.resourcesURL(for: .xray))
160210
payload["geoNeedsGeoip"] = geo.needsGeoip
161211
payload["geoNeedsGeosite"] = geo.needsGeosite
@@ -171,9 +221,8 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
171221

172222
private static func describeCoreError(_ error: NSError?) -> String {
173223
guard let error else { return "Core failed to start." }
174-
if !error.localizedDescription.isEmpty {
175-
return error.localizedDescription
176-
}
224+
let text = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
225+
if !text.isEmpty { return text }
177226
return "Core failed to start (domain \(error.domain), code \(error.code))."
178227
}
179228

0 commit comments

Comments
 (0)