@cappitolian/http-local-server-swifter 0.0.19 → 0.0.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -6,13 +6,19 @@ import UIKit
|
|
|
6
6
|
///
|
|
7
7
|
/// IMPORTANT: iOS Local Network permission does NOT block TCP sockets.
|
|
8
8
|
/// It only blocks mDNS/Bonjour service discovery. This means Swifter's
|
|
9
|
-
/// server.start() will always succeed regardless of permission status
|
|
9
|
+
/// server.start() will always succeed regardless of permission status —
|
|
10
|
+
/// the denial is only detectable via Bonjour/mDNS probes.
|
|
10
11
|
///
|
|
11
|
-
/// Detection strategy:
|
|
12
|
-
///
|
|
13
|
-
///
|
|
14
|
-
///
|
|
15
|
-
///
|
|
12
|
+
/// Detection strategy (dual-probe):
|
|
13
|
+
/// 1. NWBrowser with the app's registered Bonjour service type (_ssspos._tcp).
|
|
14
|
+
/// This is the authoritative source — if Bonjour is denied, the local
|
|
15
|
+
/// network permission is denied. The service type MUST match NSBonjourServices
|
|
16
|
+
/// in Info.plist, otherwise iOS returns NoAuth even when permission is granted.
|
|
17
|
+
/// 2. UDP probe to 224.0.0.251:5353 (mDNS multicast) as a secondary signal
|
|
18
|
+
/// to trigger the system dialog on first launch.
|
|
19
|
+
///
|
|
20
|
+
/// The first probe that resolves wins. If both time out, we assume .unknown
|
|
21
|
+
/// and allow the server to start (covers simulator and edge cases).
|
|
16
22
|
@objc public class LocalNetworkPermission: NSObject {
|
|
17
23
|
|
|
18
24
|
// MARK: - Types
|
|
@@ -23,78 +29,109 @@ import UIKit
|
|
|
23
29
|
case unknown
|
|
24
30
|
}
|
|
25
31
|
|
|
32
|
+
// MARK: - Constants
|
|
33
|
+
|
|
34
|
+
/// Must match the NSBonjourServices entry in Info.plist exactly.
|
|
35
|
+
/// NWBrowser will return NoAuth(-65555) for any type NOT listed there,
|
|
36
|
+
/// even when the user has granted Local Network permission.
|
|
37
|
+
private static let bonjourServiceType = "_ssspos._tcp"
|
|
38
|
+
|
|
26
39
|
// MARK: - Private state
|
|
27
40
|
|
|
28
41
|
private var browser: NWBrowser?
|
|
42
|
+
private var udpConnection: NWConnection?
|
|
29
43
|
|
|
30
44
|
// MARK: - Permission check
|
|
31
45
|
|
|
32
|
-
///
|
|
33
|
-
/// asynchronously via the completion handler.
|
|
46
|
+
/// Checks Local Network permission using a dual-probe approach.
|
|
34
47
|
///
|
|
35
|
-
/// - Parameter completion: Called on the main thread with the
|
|
36
|
-
///
|
|
37
|
-
/// or `.unknown` if the result could not be determined within the timeout.
|
|
48
|
+
/// - Parameter completion: Called on the main thread with the result.
|
|
49
|
+
/// `.granted` / `.denied` once a probe resolves, or `.unknown` on timeout.
|
|
38
50
|
///
|
|
39
|
-
/// - Note: iOS shows the dialog only once. On subsequent calls
|
|
40
|
-
///
|
|
51
|
+
/// - Note: iOS shows the permission dialog only once. On subsequent calls
|
|
52
|
+
/// the OS returns the cached decision immediately without showing a dialog.
|
|
41
53
|
public func checkPermission(completion: @escaping (PermissionStatus) -> Void) {
|
|
54
|
+
var completed = false
|
|
55
|
+
|
|
56
|
+
let finish: (PermissionStatus) -> Void = { [weak self] status in
|
|
57
|
+
guard !completed else { return }
|
|
58
|
+
completed = true
|
|
59
|
+
self?.browser?.cancel()
|
|
60
|
+
self?.browser = nil
|
|
61
|
+
self?.udpConnection?.cancel()
|
|
62
|
+
self?.udpConnection = nil
|
|
63
|
+
DispatchQueue.main.async { completion(status) }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Timeout fallback — if neither probe resolves, allow startup.
|
|
67
|
+
// Covers: simulator, already-granted permission where iOS skips the callback.
|
|
68
|
+
let timeout = DispatchWorkItem { finish(.unknown) }
|
|
69
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0, execute: timeout)
|
|
70
|
+
|
|
71
|
+
// --- Probe 1: NWBrowser (authoritative for permission status) ---
|
|
72
|
+
// Uses the app's registered Bonjour type so iOS evaluates the real permission.
|
|
73
|
+
// .failed with PolicyDenied/NoAuth = denied. .ready = granted.
|
|
42
74
|
let parameters = NWParameters()
|
|
43
75
|
parameters.includePeerToPeer = true
|
|
44
76
|
|
|
45
77
|
let browser = NWBrowser(
|
|
46
|
-
for: .bonjour(type:
|
|
78
|
+
for: .bonjour(type: Self.bonjourServiceType, domain: "local."),
|
|
47
79
|
using: parameters
|
|
48
80
|
)
|
|
49
81
|
self.browser = browser
|
|
50
82
|
|
|
51
|
-
// Timeout fallback — if iOS doesn't respond in 3 seconds, assume unknown
|
|
52
|
-
// (e.g. on simulator or when the user has already granted permission)
|
|
53
|
-
var completed = false
|
|
54
|
-
let timeout = DispatchWorkItem {
|
|
55
|
-
guard !completed else { return }
|
|
56
|
-
completed = true
|
|
57
|
-
browser.cancel()
|
|
58
|
-
DispatchQueue.main.async { completion(.unknown) }
|
|
59
|
-
}
|
|
60
|
-
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0, execute: timeout)
|
|
61
|
-
|
|
62
83
|
browser.stateUpdateHandler = { state in
|
|
63
|
-
guard !completed else { return }
|
|
64
|
-
|
|
65
84
|
switch state {
|
|
66
85
|
case .ready:
|
|
67
|
-
// Browser started successfully — permission is granted
|
|
68
|
-
completed = true
|
|
69
86
|
timeout.cancel()
|
|
70
|
-
|
|
71
|
-
DispatchQueue.main.async { completion(.granted) }
|
|
72
|
-
|
|
87
|
+
finish(.granted)
|
|
73
88
|
case .failed(let error):
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|| error.localizedDescription.lowercased().contains("policy")
|
|
81
|
-
|
|
82
|
-
completed = true
|
|
83
|
-
timeout.cancel()
|
|
84
|
-
browser.cancel()
|
|
85
|
-
DispatchQueue.main.async {
|
|
86
|
-
completion(isDenied ? .denied : .unknown)
|
|
89
|
+
if Self.isPolicyDenied(error) {
|
|
90
|
+
timeout.cancel()
|
|
91
|
+
finish(.denied)
|
|
92
|
+
} else {
|
|
93
|
+
// Non-policy failure (e.g. no Wi-Fi) — let UDP probe or timeout decide
|
|
94
|
+
print("⚠️ LocalNetworkPermission: NWBrowser failed (non-policy): \(error)")
|
|
87
95
|
}
|
|
88
|
-
|
|
89
96
|
case .cancelled:
|
|
90
|
-
break
|
|
91
|
-
|
|
97
|
+
break
|
|
92
98
|
default:
|
|
93
|
-
break
|
|
99
|
+
break
|
|
94
100
|
}
|
|
95
101
|
}
|
|
96
|
-
|
|
97
102
|
browser.start(queue: .main)
|
|
103
|
+
|
|
104
|
+
// --- Probe 2: UDP mDNS multicast (dialog trigger + secondary signal) ---
|
|
105
|
+
// Sending to 224.0.0.251:5353 triggers the system dialog on first launch.
|
|
106
|
+
// Also catches policy denials that surface at the UDP layer.
|
|
107
|
+
let host = NWEndpoint.Host("224.0.0.251")
|
|
108
|
+
let port = NWEndpoint.Port(rawValue: 5353)!
|
|
109
|
+
let conn = NWConnection(host: host, port: port, using: .udp)
|
|
110
|
+
self.udpConnection = conn
|
|
111
|
+
|
|
112
|
+
conn.stateUpdateHandler = { state in
|
|
113
|
+
switch state {
|
|
114
|
+
case .waiting(let error):
|
|
115
|
+
if Self.isPolicyDenied(error) {
|
|
116
|
+
timeout.cancel()
|
|
117
|
+
finish(.denied)
|
|
118
|
+
}
|
|
119
|
+
// Other .waiting errors (no route, offline) are transient — ignore
|
|
120
|
+
case .failed(let error):
|
|
121
|
+
if Self.isPolicyDenied(error) {
|
|
122
|
+
timeout.cancel()
|
|
123
|
+
finish(.denied)
|
|
124
|
+
}
|
|
125
|
+
// Non-policy UDP failure — let NWBrowser or timeout decide
|
|
126
|
+
case .cancelled:
|
|
127
|
+
break
|
|
128
|
+
default:
|
|
129
|
+
break
|
|
130
|
+
// Note: UDP .ready does NOT confirm permission — we rely on NWBrowser for that.
|
|
131
|
+
// A UDP socket can reach .ready even when Bonjour is blocked.
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
conn.start(queue: .main)
|
|
98
135
|
}
|
|
99
136
|
|
|
100
137
|
// MARK: - Recovery
|
|
@@ -111,4 +148,19 @@ import UIKit
|
|
|
111
148
|
UIApplication.shared.open(url)
|
|
112
149
|
}
|
|
113
150
|
}
|
|
151
|
+
|
|
152
|
+
// MARK: - Private helpers
|
|
153
|
+
|
|
154
|
+
/// Returns true if the NWError indicates a Local Network policy denial.
|
|
155
|
+
///
|
|
156
|
+
/// Known denial codes:
|
|
157
|
+
/// kDNSServiceErr_PolicyDenied = -65570
|
|
158
|
+
/// kDNSServiceErr_NoAuth = -65555
|
|
159
|
+
private static func isPolicyDenied(_ error: NWError) -> Bool {
|
|
160
|
+
let nsError = error as NSError
|
|
161
|
+
if nsError.code == -65570 || nsError.code == -65555 { return true }
|
|
162
|
+
|
|
163
|
+
let desc = error.localizedDescription.lowercased()
|
|
164
|
+
return desc.contains("policy") || desc.contains("denied") || desc.contains("noauth")
|
|
165
|
+
}
|
|
114
166
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cappitolian/http-local-server-swifter",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.21",
|
|
4
4
|
"description": "Runs a local HTTP server on your device, accessible over LAN. Supports connect, disconnect, GET, and POST methods with IP and port discovery.",
|
|
5
5
|
"main": "dist/plugin.cjs.js",
|
|
6
6
|
"module": "dist/esm/index.js",
|