@cappitolian/http-local-server-swifter 0.0.21 → 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,106 +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
- 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.
74
- let parameters = NWParameters()
75
- parameters.includePeerToPeer = true
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.
64
+ let timeout = DispatchWorkItem {
65
+ print("⚠️ LocalNetworkPermission: timeout resolving as .unknown")
66
+ finish(.unknown)
67
+ }
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
+ }
76
82
 
77
- let browser = NWBrowser(
78
- for: .bonjour(type: Self.bonjourServiceType, domain: "local."),
79
- using: parameters
80
- )
81
- self.browser = browser
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
82
87
 
83
- browser.stateUpdateHandler = { state in
88
+ listener.stateUpdateHandler = { state in
89
+ print("🔍 LocalNetworkPermission NWListener state: \(state)")
84
90
  switch state {
85
91
  case .ready:
92
+ // Bonjour advertisement succeeded — permission is granted.
86
93
  timeout.cancel()
87
94
  finish(.granted)
88
- case .failed(let error):
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)")
95
- }
96
- case .cancelled:
97
- break
98
- default:
99
- break
100
- }
101
- }
102
- browser.start(queue: .main)
103
95
 
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
96
+ case .failed(let error):
97
+ let code = (error as NSError).code
98
+ print("🔍 LocalNetworkPermission NWListener error code: \(code) \(error.localizedDescription)")
111
99
 
112
- conn.stateUpdateHandler = { state in
113
- switch state {
114
- case .waiting(let error):
115
100
  if Self.isPolicyDenied(error) {
101
+ // PolicyDenied or NoAuth — user has denied Local Network permission.
116
102
  timeout.cancel()
117
103
  finish(.denied)
118
- }
119
- // Other .waiting errors (no route, offline) are transient ignore
120
- case .failed(let error):
121
- if Self.isPolicyDenied(error) {
104
+ } else {
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)")
122
108
  timeout.cancel()
123
- finish(.denied)
109
+ finish(.unknown)
124
110
  }
125
- // Non-policy UDP failure — let NWBrowser or timeout decide
111
+
126
112
  case .cancelled:
127
- break
113
+ break // Expected — we cancelled it ourselves in finish()
114
+
128
115
  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.
116
+ break // .setup, .waiting — keep waiting
132
117
  }
133
118
  }
134
- conn.start(queue: .main)
119
+
120
+ listener.start(queue: .main)
135
121
  }
136
122
 
137
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.21",
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",