@cappitolian/http-local-server-swifter 0.0.20 → 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,15 +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
- /// - Send a UDP probe to the mDNS multicast address (224.0.0.251:5353).
13
- /// - This triggers the system permission dialog without requiring any
14
- /// NSBonjourServices entry in Info.plist.
15
- /// - NWBrowser was avoided because it requires the browsed service type
16
- /// to be declared in NSBonjourServices, causing false NoAuth(-65555)
17
- /// failures even when the permission is actually granted.
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).
18
22
  @objc public class LocalNetworkPermission: NSObject {
19
23
 
20
24
  // MARK: - Types
@@ -25,84 +29,108 @@ import UIKit
25
29
  case unknown
26
30
  }
27
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
+
28
39
  // MARK: - Private state
29
40
 
30
- private var connection: NWConnection?
41
+ private var browser: NWBrowser?
42
+ private var udpConnection: NWConnection?
31
43
 
32
44
  // MARK: - Permission check
33
45
 
34
- /// Probes the local network to trigger the permission dialog and detect the result.
46
+ /// Checks Local Network permission using a dual-probe approach.
35
47
  ///
36
- /// Sends a UDP packet to the mDNS multicast address. iOS intercepts this at the
37
- /// network layer and either allows it (granted) or blocks it with a policy error (denied).
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
- /// - Parameter completion: Called on the main thread with the permission status.
40
- /// `.granted` / `.denied` once iOS responds, or `.unknown` on timeout
41
- /// (simulator, permission already granted and cached by the OS, etc.).
42
- ///
43
- /// - Note: iOS shows the dialog only once. Subsequent calls return the cached decision.
51
+ /// - Note: iOS shows the permission dialog only once. On subsequent calls
52
+ /// the OS returns the cached decision immediately without showing a dialog.
44
53
  public func checkPermission(completion: @escaping (PermissionStatus) -> Void) {
45
- // mDNS multicast address — probing this triggers the Local Network permission dialog.
46
- // This approach does NOT require NSBonjourServices in Info.plist.
47
- let host = NWEndpoint.Host("224.0.0.251")
48
- let port = NWEndpoint.Port(rawValue: 5353)!
49
-
50
- let conn = NWConnection(host: host, port: port, using: .udp)
51
- self.connection = conn
52
-
53
54
  var completed = false
54
55
 
55
- // Timeout fallback treat unresolved state as unknown and allow server startup.
56
- // This covers: simulator, permission already granted (OS may skip the callback),
57
- // and edge cases where iOS doesn't fire stateUpdateHandler promptly.
58
- let timeout = DispatchWorkItem { [weak self] in
56
+ let finish: (PermissionStatus) -> Void = { [weak self] status in
59
57
  guard !completed else { return }
60
58
  completed = true
61
- self?.connection?.cancel()
62
- self?.connection = nil
63
- DispatchQueue.main.async { completion(.unknown) }
59
+ self?.browser?.cancel()
60
+ self?.browser = nil
61
+ self?.udpConnection?.cancel()
62
+ self?.udpConnection = nil
63
+ DispatchQueue.main.async { completion(status) }
64
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) }
65
69
  DispatchQueue.main.asyncAfter(deadline: .now() + 3.0, execute: timeout)
66
70
 
67
- conn.stateUpdateHandler = { [weak self] state in
68
- guard !completed else { return }
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
69
76
 
77
+ let browser = NWBrowser(
78
+ for: .bonjour(type: Self.bonjourServiceType, domain: "local."),
79
+ using: parameters
80
+ )
81
+ self.browser = browser
82
+
83
+ browser.stateUpdateHandler = { state in
70
84
  switch state {
71
85
  case .ready:
72
- // UDP connection reached the network layer — permission is granted.
73
- completed = true
74
86
  timeout.cancel()
75
- self?.connection?.cancel()
76
- self?.connection = nil
77
- DispatchQueue.main.async { completion(.granted) }
87
+ 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
+
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
78
111
 
112
+ conn.stateUpdateHandler = { state in
113
+ switch state {
79
114
  case .waiting(let error):
80
- // .waiting with a policy error = permission denied.
81
- // Other .waiting errors (no route, offline) are transient — keep waiting.
82
115
  if Self.isPolicyDenied(error) {
83
- completed = true
84
116
  timeout.cancel()
85
- self?.connection?.cancel()
86
- self?.connection = nil
87
- DispatchQueue.main.async { completion(.denied) }
117
+ finish(.denied)
88
118
  }
89
-
119
+ // Other .waiting errors (no route, offline) are transient — ignore
90
120
  case .failed(let error):
91
- let status: PermissionStatus = Self.isPolicyDenied(error) ? .denied : .unknown
92
- completed = true
93
- timeout.cancel()
94
- self?.connection?.cancel()
95
- self?.connection = nil
96
- DispatchQueue.main.async { completion(status) }
97
-
121
+ if Self.isPolicyDenied(error) {
122
+ timeout.cancel()
123
+ finish(.denied)
124
+ }
125
+ // Non-policy UDP failure — let NWBrowser or timeout decide
98
126
  case .cancelled:
99
- break // Expected — we cancelled it ourselves above
100
-
127
+ break
101
128
  default:
102
- break // .setup, .preparing — keep waiting
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.
103
132
  }
104
133
  }
105
-
106
134
  conn.start(queue: .main)
107
135
  }
108
136
 
@@ -125,9 +153,9 @@ import UIKit
125
153
 
126
154
  /// Returns true if the NWError indicates a Local Network policy denial.
127
155
  ///
128
- /// Known denial error codes:
129
- /// - kDNSServiceErr_PolicyDenied = -65570
130
- /// - kDNSServiceErr_NoAuth = -65555
156
+ /// Known denial codes:
157
+ /// kDNSServiceErr_PolicyDenied = -65570
158
+ /// kDNSServiceErr_NoAuth = -65555
131
159
  private static func isPolicyDenied(_ error: NWError) -> Bool {
132
160
  let nsError = error as NSError
133
161
  if nsError.code == -65570 || nsError.code == -65555 { return true }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cappitolian/http-local-server-swifter",
3
- "version": "0.0.20",
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",