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

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,14 @@ 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.
11
+ /// Detection strategy — NWListener with Bonjour advertising:
12
+ /// NWBrowser returns .ready even when permission is revoked (iOS bug).
13
+ /// NWListener advertising a Bonjour service correctly fails with PolicyDenied
14
+ /// when Local Network permission is OFF, including previously-revoked cases.
19
15
  ///
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
+ /// The service type MUST match NSBonjourServices in Info.plist exactly.
22
17
  @objc public class LocalNetworkPermission: NSObject {
23
18
 
24
19
  // MARK: - Types
@@ -32,116 +27,97 @@ import UIKit
32
27
  // MARK: - Constants
33
28
 
34
29
  /// 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
30
  private static let bonjourServiceType = "_ssspos._tcp"
38
31
 
39
32
  // MARK: - Private state
40
33
 
41
- private var browser: NWBrowser?
42
- private var udpConnection: NWConnection?
34
+ private var listener: NWListener?
43
35
 
44
36
  // MARK: - Permission check
45
37
 
46
- /// Checks Local Network permission using a dual-probe approach.
38
+ /// Checks Local Network permission by advertising a Bonjour service via NWListener.
47
39
  ///
48
- /// - Parameter completion: Called on the main thread with the result.
49
- /// `.granted` / `.denied` once a probe resolves, or `.unknown` on timeout.
40
+ /// NWListener is the only iOS API that correctly detects a revoked Local Network
41
+ /// permission. NWBrowser incorrectly returns .ready in that case (iOS bug).
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.
44
+ /// `.granted` / `.denied` once iOS responds, or `.unknown` on timeout.
45
+ ///
46
+ /// - Note: iOS shows the system dialog only on the first call. Subsequent calls
47
+ /// return the cached decision immediately without prompting the user.
53
48
  public func checkPermission(completion: @escaping (PermissionStatus) -> Void) {
54
49
  var completed = false
55
50
 
56
51
  let finish: (PermissionStatus) -> Void = { [weak self] status in
57
52
  guard !completed else { return }
58
53
  completed = true
59
- self?.browser?.cancel()
60
- self?.browser = nil
61
- self?.udpConnection?.cancel()
62
- self?.udpConnection = nil
63
- DispatchQueue.main.async { completion(status) }
54
+ self?.listener?.cancel()
55
+ self?.listener = nil
56
+ DispatchQueue.main.async {
57
+ print("✅ LocalNetworkPermission resolved: \(status)")
58
+ completion(status)
59
+ }
64
60
  }
65
61
 
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.
62
+ // Timeout fallback — allow startup if iOS never calls the state handler.
63
+ // Covers: simulator, and rare edge cases where the OS skips the callback.
70
64
  let timeout = DispatchWorkItem {
71
- print("⚠️ LocalNetworkPermission: timeout reached — resolving as .unknown")
65
+ print("⚠️ LocalNetworkPermission: timeout — resolving as .unknown")
72
66
  finish(.unknown)
73
67
  }
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)")
68
+ DispatchQueue.main.asyncAfter(deadline: .now() + 5.0, execute: timeout)
69
+
70
+ // Create a TCP listener and advertise it as a Bonjour service.
71
+ // iOS evaluates the Local Network permission at advertisement time,
72
+ // not at socket bind time — which is why server.start() always succeeds.
73
+ let params = NWParameters.tcp
74
+ params.includePeerToPeer = true
75
+
76
+ guard let listener = try? NWListener(using: params) else {
77
+ print("⚠️ LocalNetworkPermission: could not create NWListener — resolving as .unknown")
78
+ timeout.cancel()
79
+ finish(.unknown)
80
+ return
81
+ }
82
+
83
+ // Advertising this service type triggers the system dialog on first launch
84
+ // and also immediately returns PolicyDenied when permission is revoked.
85
+ listener.service = NWListener.Service(type: Self.bonjourServiceType)
86
+ self.listener = listener
87
+
88
+ listener.stateUpdateHandler = { state in
89
+ print("🔍 LocalNetworkPermission NWListener state: \(state)")
90
90
  switch state {
91
91
  case .ready:
92
+ // Bonjour advertisement succeeded — permission is granted.
92
93
  timeout.cancel()
93
94
  finish(.granted)
95
+
94
96
  case .failed(let error):
95
- print("🔍 LocalNetworkPermission NWBrowser error code: \((error as NSError).code) — \(error.localizedDescription)")
97
+ let code = (error as NSError).code
98
+ print("🔍 LocalNetworkPermission NWListener error code: \(code) — \(error.localizedDescription)")
99
+
96
100
  if Self.isPolicyDenied(error) {
101
+ // PolicyDenied or NoAuth — user has denied Local Network permission.
97
102
  timeout.cancel()
98
103
  finish(.denied)
99
104
  } 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:
106
- break
107
- }
108
- }
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)")
121
- 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) {
105
+ // Port conflict or other non-permission error treat as unknown
106
+ // and allow the server to start rather than blocking the user.
107
+ print("⚠️ LocalNetworkPermission: NWListener failed (non-policy): \(error)")
125
108
  timeout.cancel()
126
- finish(.denied)
109
+ finish(.unknown)
127
110
  }
128
- // Other .waiting errors (no route, offline) are transient — ignore
129
- 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
111
+
136
112
  case .cancelled:
137
- break
113
+ break // Expected — we cancelled it ourselves in finish()
114
+
138
115
  default:
139
- 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.
116
+ break // .setup, .waiting — keep waiting
142
117
  }
143
118
  }
144
- conn.start(queue: .main)
119
+
120
+ listener.start(queue: .main)
145
121
  }
146
122
 
147
123
  // MARK: - Recovery
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.23",
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",