@cappitolian/http-local-server-swifter 0.0.22 → 0.0.24

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,19 +6,13 @@ 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
10
- /// the denial is only detectable via Bonjour/mDNS probes.
9
+ /// server.start() will always succeed regardless of permission status.
11
10
  ///
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).
11
+ /// Detection strategy — NWListener with serviceRegistrationUpdateHandler:
12
+ /// The PolicyDenied error for a revoked Local Network permission surfaces
13
+ /// in NWListener.serviceRegistrationUpdateHandler, not in stateUpdateHandler.
14
+ /// A connection handler must also be set or NWListener fails with error 22
15
+ /// before iOS even evaluates the permission.
22
16
  @objc public class LocalNetworkPermission: NSObject {
23
17
 
24
18
  // MARK: - Types
@@ -32,128 +26,112 @@ import UIKit
32
26
  // MARK: - Constants
33
27
 
34
28
  /// 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
29
  private static let bonjourServiceType = "_ssspos._tcp"
38
30
 
39
31
  // MARK: - Private state
40
32
 
41
- private var browser: NWBrowser?
42
- private var udpConnection: NWConnection?
33
+ private var listener: NWListener?
43
34
 
44
35
  // MARK: - Permission check
45
36
 
46
- /// Checks Local Network permission using a dual-probe approach.
37
+ /// Checks Local Network permission by advertising a Bonjour service via NWListener.
47
38
  ///
48
- /// - Parameter completion: Called on the main thread with the result.
49
- /// `.granted` / `.denied` once a probe resolves, or `.unknown` on timeout.
39
+ /// The permission denial for a revoked permission surfaces in
40
+ /// serviceRegistrationUpdateHandler not in stateUpdateHandler.
41
+ /// A dummy connection handler is required to prevent error 22 (Invalid argument).
50
42
  ///
51
- /// - Note: iOS shows the permission dialog only once. On subsequent calls
52
- /// the OS returns the cached decision immediately without showing a dialog.
43
+ /// - Parameter completion: Called on the main thread with the permission status.
53
44
  public func checkPermission(completion: @escaping (PermissionStatus) -> Void) {
54
45
  var completed = false
55
46
 
56
47
  let finish: (PermissionStatus) -> Void = { [weak self] status in
57
48
  guard !completed else { return }
58
49
  completed = true
59
- self?.browser?.cancel()
60
- self?.browser = nil
61
- self?.udpConnection?.cancel()
62
- self?.udpConnection = nil
63
- DispatchQueue.main.async { completion(status) }
50
+ self?.listener?.cancel()
51
+ self?.listener = nil
52
+ DispatchQueue.main.async {
53
+ print("✅ LocalNetworkPermission resolved: \(status)")
54
+ completion(status)
55
+ }
64
56
  }
65
57
 
66
- // Timeout fallback — if neither probe resolves, allow startup.
67
- // Covers: simulator, already-granted permission where iOS skips the callback.
68
- // 6s gives iOS enough time to evaluate a previously-revoked permission,
69
- // which can take longer than a first-time denial.
70
58
  let timeout = DispatchWorkItem {
71
- print("⚠️ LocalNetworkPermission: timeout reached — resolving as .unknown")
59
+ print("⚠️ LocalNetworkPermission: timeout — resolving as .unknown")
72
60
  finish(.unknown)
73
61
  }
74
- DispatchQueue.main.asyncAfter(deadline: .now() + 6.0, execute: timeout)
75
-
76
- // --- Probe 1: NWBrowser (authoritative for permission status) ---
77
- // Uses the app's registered Bonjour type so iOS evaluates the real permission.
78
- // .failed with PolicyDenied/NoAuth = denied. .ready = granted.
79
- let parameters = NWParameters()
80
- parameters.includePeerToPeer = true
81
-
82
- let browser = NWBrowser(
83
- for: .bonjour(type: Self.bonjourServiceType, domain: "local."),
84
- using: parameters
85
- )
86
- self.browser = browser
87
-
88
- browser.stateUpdateHandler = { state in
89
- print("🔍 LocalNetworkPermission NWBrowser state: \(state)")
90
- switch state {
91
- case .ready:
62
+ DispatchQueue.main.asyncAfter(deadline: .now() + 5.0, execute: timeout)
63
+
64
+ let params = NWParameters.tcp
65
+ params.includePeerToPeer = true
66
+
67
+ guard let listener = try? NWListener(using: params) else {
68
+ print("⚠️ LocalNetworkPermission: could not create NWListener")
69
+ timeout.cancel()
70
+ finish(.unknown)
71
+ return
72
+ }
73
+
74
+ listener.service = NWListener.Service(type: Self.bonjourServiceType)
75
+ self.listener = listener
76
+
77
+ // Required: NWListener fails with error 22 (Invalid argument) if no
78
+ // connection handler is set. This dummy handler satisfies the requirement.
79
+ listener.newConnectionHandler = { connection in
80
+ connection.cancel()
81
+ }
82
+
83
+ // This is where iOS reports PolicyDenied for a revoked Local Network permission.
84
+ // stateUpdateHandler only reports generic listener errors, not Bonjour policy errors.
85
+ listener.serviceRegistrationUpdateHandler = { change in
86
+ print("🔍 LocalNetworkPermission serviceRegistration: \(change)")
87
+ switch change {
88
+ case .add:
89
+ // Service registered successfully — permission is granted.
92
90
  timeout.cancel()
93
91
  finish(.granted)
94
- case .failed(let error):
95
- print("🔍 LocalNetworkPermission NWBrowser error code: \((error as NSError).code) — \(error.localizedDescription)")
96
- if Self.isPolicyDenied(error) {
97
- timeout.cancel()
98
- finish(.denied)
99
- } else {
100
- // Non-policy failure (e.g. no Wi-Fi) — let UDP probe or timeout decide
101
- print("⚠️ LocalNetworkPermission: NWBrowser failed (non-policy): \(error)")
102
- }
103
- case .cancelled:
104
- break
105
- default:
92
+
93
+ case .remove:
94
+ // Service was removed. On permission denial this fires immediately
95
+ // after the _NWAdvertiser PolicyDenied error in the system logs.
96
+ // We treat an immediate remove (before .add) as denied.
97
+ timeout.cancel()
98
+ finish(.denied)
99
+
100
+ @unknown default:
106
101
  break
107
102
  }
108
103
  }
109
- browser.start(queue: .main)
110
-
111
- // --- Probe 2: UDP mDNS multicast (dialog trigger + secondary signal) ---
112
- // Sending to 224.0.0.251:5353 triggers the system dialog on first launch.
113
- // Also catches policy denials that surface at the UDP layer.
114
- let host = NWEndpoint.Host("224.0.0.251")
115
- let port = NWEndpoint.Port(rawValue: 5353)!
116
- let conn = NWConnection(host: host, port: port, using: .udp)
117
- self.udpConnection = conn
118
-
119
- conn.stateUpdateHandler = { state in
120
- print("🔍 LocalNetworkPermission UDP state: \(state)")
104
+
105
+ // stateUpdateHandler still needed to catch non-permission listener failures.
106
+ listener.stateUpdateHandler = { state in
107
+ print("🔍 LocalNetworkPermission NWListener state: \(state)")
121
108
  switch state {
122
- case .waiting(let error):
123
- print("🔍 LocalNetworkPermission UDP waiting error code: \((error as NSError).code) — \(error.localizedDescription)")
124
- if Self.isPolicyDenied(error) {
125
- timeout.cancel()
126
- finish(.denied)
127
- }
128
- // Other .waiting errors (no route, offline) are transient — ignore
129
109
  case .failed(let error):
130
- print("🔍 LocalNetworkPermission UDP failed error code: \((error as NSError).code) — \(error.localizedDescription)")
131
- if Self.isPolicyDenied(error) {
132
- timeout.cancel()
133
- finish(.denied)
134
- }
135
- // Non-policy UDP failure — let NWBrowser or timeout decide
110
+ let code = (error as NSError).code
111
+ print("🔍 LocalNetworkPermission NWListener error: \(code) — \(error.localizedDescription)")
112
+ // Only treat explicit policy errors as denied; other failures are unknown.
113
+ timeout.cancel()
114
+ finish(Self.isPolicyDenied(error) ? .denied : .unknown)
115
+
136
116
  case .cancelled:
137
117
  break
118
+
138
119
  default:
139
120
  break
140
- // Note: UDP .ready does NOT confirm permission — we rely on NWBrowser for that.
141
- // A UDP socket can reach .ready even when Bonjour is blocked.
142
121
  }
143
122
  }
144
- conn.start(queue: .main)
123
+
124
+ listener.start(queue: .main)
145
125
  }
146
126
 
147
127
  // MARK: - Recovery
148
128
 
149
129
  /// Opens the app's page in iOS Settings so the user can manually grant Local Network access.
150
130
  ///
151
- /// - Note: This is the only recovery path available after the user denies the permission.
152
- /// - Warning: On iOS 17, the user may need to restart the device after granting
153
- /// the permission for it to take effect. This is a known iOS 17 bug.
131
+ /// - Note: This is the only recovery path after denial iOS cannot re-prompt.
132
+ /// - Warning: On iOS 17, a device restart may be needed after granting the permission.
154
133
  public func openAppSettings() {
155
134
  guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
156
-
157
135
  DispatchQueue.main.async {
158
136
  UIApplication.shared.open(url)
159
137
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cappitolian/http-local-server-swifter",
3
- "version": "0.0.22",
3
+ "version": "0.0.24",
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",