@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
- /// - Trigger the system dialog by starting a NWBrowser.
13
- /// - Listen to the browser's state change callback to detect denial.
14
- /// - Surface the result via a completion handler so the plugin can
15
- /// reject the Capacitor call with LOCAL_NETWORK_PERMISSION_DENIED.
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
- /// Triggers the Local Network permission dialog and returns the result
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 permission status.
36
- /// Will be called with `.granted` or `.denied` once iOS responds,
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 this method
40
- /// will return the previously stored decision immediately (no dialog shown).
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: "_http._tcp", domain: "local."),
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
- browser.cancel()
71
- DispatchQueue.main.async { completion(.granted) }
72
-
87
+ finish(.granted)
73
88
  case .failed(let error):
74
- // Check for policy denial errors:
75
- // kDNSServiceErr_PolicyDenied = -65570
76
- // kDNSServiceErr_NoAuth = -65555
77
- let nsError = error as NSError
78
- let isDenied = nsError.code == -65570
79
- || nsError.code == -65555
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 // Expected when we cancel it ourselves
91
-
97
+ break
92
98
  default:
93
- break // .setup, .waiting — keep waiting
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.19",
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",