@cappitolian/http-local-server-swifter 0.0.18 → 0.0.20

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.
@@ -25,45 +25,49 @@ public protocol HttpLocalServerSwifterDelegate: AnyObject {
25
25
  }
26
26
 
27
27
  @objc public func connect(_ call: CAPPluginCall) {
28
- // Trigger the Local Network permission dialog before doing any network work.
29
- // iOS only shows the dialog once; subsequent calls are no-ops.
30
- // We fire it here so the prompt appears at a predictable moment for the user.
31
- networkPermission.requestPermissionIfNeeded()
32
-
33
- // Move execution to a background thread immediately.
34
- DispatchQueue.global(qos: .userInitiated).async { [weak self] in
28
+ // Check Local Network permission first via NWBrowser callback.
29
+ // iOS only shows the system dialog once subsequent calls return the stored decision.
30
+ // We must do this BEFORE starting the server because Swifter uses plain TCP sockets
31
+ // which iOS never blocks — the denial only surfaces through Bonjour/mDNS errors,
32
+ // not through server.start() failures.
33
+ networkPermission.checkPermission { [weak self] status in
35
34
  guard let self = self else { return }
36
35
 
37
- self.disconnect()
38
- let server = HttpServer()
39
- self.webServer = server
40
-
41
- // Use middleware to catch ALL requests and avoid route misses.
42
- server.middleware.append { [weak self] request in
43
- if request.method == "OPTIONS" {
44
- return self?.corsResponse() ?? .raw(204, "No Content", nil, nil)
45
- }
46
- return self?.processRequest(request) ?? .raw(500, "Internal Server Error", nil, nil)
36
+ if status == .denied {
37
+ print("❌ SWIFTER: Local Network permission denied by user")
38
+ call.reject("LOCAL_NETWORK_PERMISSION_DENIED")
39
+ return
47
40
  }
48
41
 
49
- do {
50
- try server.start(self.defaultPort, forceIPv4: true)
51
- let ip = Self.getWiFiAddress() ?? "127.0.0.1"
42
+ // .granted or .unknown — proceed with server startup.
43
+ // .unknown means iOS didn't respond within the timeout (simulator, already granted, etc.)
44
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
45
+ guard let self = self else { return }
46
+
47
+ self.disconnect()
48
+ let server = HttpServer()
49
+ self.webServer = server
50
+
51
+ // Use middleware to catch ALL requests and avoid route misses.
52
+ server.middleware.append { [weak self] request in
53
+ if request.method == "OPTIONS" {
54
+ return self?.corsResponse() ?? .raw(204, "No Content", nil, nil)
55
+ }
56
+ return self?.processRequest(request) ?? .raw(500, "Internal Server Error", nil, nil)
57
+ }
52
58
 
53
- print("🚀 SWIFTER: Server running on http://\(ip):\(self.defaultPort)")
59
+ do {
60
+ try server.start(self.defaultPort, forceIPv4: true)
61
+ let ip = Self.getWiFiAddress() ?? "127.0.0.1"
54
62
 
55
- call.resolve([
56
- "ip": ip,
57
- "port": Int(self.defaultPort)
58
- ])
59
- } catch {
60
- print("❌ SWIFTER ERROR: \(error)")
63
+ print("🚀 SWIFTER: Server running on http://\(ip):\(self.defaultPort)")
61
64
 
62
- // Check if the failure is caused by a denied Local Network permission
63
- // and surface a specific error code so the JS layer can handle it distinctly.
64
- if self.networkPermission.isPermissionDenied(error) {
65
- call.reject("LOCAL_NETWORK_PERMISSION_DENIED")
66
- } else {
65
+ call.resolve([
66
+ "ip": ip,
67
+ "port": Int(self.defaultPort)
68
+ ])
69
+ } catch {
70
+ print("❌ SWIFTER ERROR: \(error)")
67
71
  call.reject("Could not start server")
68
72
  }
69
73
  }
@@ -4,69 +4,113 @@ import UIKit
4
4
 
5
5
  /// Handles Local Network permission lifecycle on iOS.
6
6
  ///
7
- /// iOS does not provide a direct API to query Local Network permission status.
8
- /// The only available approach is:
9
- /// 1. Trigger the system dialog once by starting a NWBrowser with a Bonjour service.
10
- /// 2. Infer denial from DNS/network errors (kDNSServiceErr_PolicyDenied = -65570).
11
- /// 3. Redirect the user to Settings if denied — re-requesting programmatically is not possible.
7
+ /// IMPORTANT: iOS Local Network permission does NOT block TCP sockets.
8
+ /// It only blocks mDNS/Bonjour service discovery. This means Swifter's
9
+ /// server.start() will always succeed regardless of permission status.
10
+ ///
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
18
  @objc public class LocalNetworkPermission: NSObject {
13
19
 
14
- // MARK: - Constants
20
+ // MARK: - Types
21
+
22
+ public enum PermissionStatus {
23
+ case granted
24
+ case denied
25
+ case unknown
26
+ }
27
+
28
+ // MARK: - Private state
15
29
 
16
- /// kDNSServiceErr_PolicyDenied returned by the system when Local Network access is denied.
17
- private static let policyDeniedErrorCode = -65570
30
+ private var connection: NWConnection?
18
31
 
19
- // MARK: - Permission trigger
32
+ // MARK: - Permission check
20
33
 
21
- /// Triggers the Local Network permission dialog by briefly starting a Bonjour browser.
34
+ /// Probes the local network to trigger the permission dialog and detect the result.
22
35
  ///
23
- /// - Important: iOS shows this dialog only once. On subsequent calls it is a no-op.
24
- /// Call this before starting the HTTP server so the dialog appears in a controlled moment,
25
- /// not mid-request when the user least expects it.
26
- public func requestPermissionIfNeeded() {
27
- let parameters = NWParameters()
28
- parameters.includePeerToPeer = true
29
-
30
- let browser = NWBrowser(
31
- for: .bonjour(type: "_http._tcp", domain: "local."),
32
- using: parameters
33
- )
34
-
35
- browser.start(queue: .main)
36
-
37
- // We only need to fire the dialog — cancel shortly after.
38
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
39
- browser.cancel()
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).
38
+ ///
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.
44
+ 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
+ var completed = false
54
+
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
59
+ guard !completed else { return }
60
+ completed = true
61
+ self?.connection?.cancel()
62
+ self?.connection = nil
63
+ DispatchQueue.main.async { completion(.unknown) }
40
64
  }
41
- }
65
+ DispatchQueue.main.asyncAfter(deadline: .now() + 3.0, execute: timeout)
42
66
 
43
- // MARK: - Error detection
67
+ conn.stateUpdateHandler = { [weak self] state in
68
+ guard !completed else { return }
44
69
 
45
- /// Returns `true` if the given error indicates the user denied Local Network permission.
46
- ///
47
- /// - Parameter error: Any `Error` thrown during server start or network operations.
48
- public func isPermissionDenied(_ error: Error) -> Bool {
49
- let nsError = error as NSError
50
- return nsError.code == Self.policyDeniedErrorCode
51
- }
70
+ switch state {
71
+ case .ready:
72
+ // UDP connection reached the network layer permission is granted.
73
+ completed = true
74
+ timeout.cancel()
75
+ self?.connection?.cancel()
76
+ self?.connection = nil
77
+ DispatchQueue.main.async { completion(.granted) }
52
78
 
53
- /// Returns `true` if an error string representation contains known denial markers.
54
- ///
55
- /// Useful when the original `Error` object is unavailable and only a string is at hand
56
- /// (e.g. errors arriving from JavaScript or serialized logs).
57
- public func isPermissionDeniedString(_ errorString: String) -> Bool {
58
- let lower = errorString.lowercased()
59
- return lower.contains("policydenied")
60
- || lower.contains("-65570")
61
- || lower.contains("policy denied")
79
+ case .waiting(let error):
80
+ // .waiting with a policy error = permission denied.
81
+ // Other .waiting errors (no route, offline) are transient keep waiting.
82
+ if Self.isPolicyDenied(error) {
83
+ completed = true
84
+ timeout.cancel()
85
+ self?.connection?.cancel()
86
+ self?.connection = nil
87
+ DispatchQueue.main.async { completion(.denied) }
88
+ }
89
+
90
+ 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
+
98
+ case .cancelled:
99
+ break // Expected — we cancelled it ourselves above
100
+
101
+ default:
102
+ break // .setup, .preparing — keep waiting
103
+ }
104
+ }
105
+
106
+ conn.start(queue: .main)
62
107
  }
63
108
 
64
109
  // MARK: - Recovery
65
110
 
66
111
  /// Opens the app's page in iOS Settings so the user can manually grant Local Network access.
67
112
  ///
68
- /// - Note: This is the only recovery path available. iOS does not allow re-prompting
69
- /// after the user denies the permission.
113
+ /// - Note: This is the only recovery path available after the user denies the permission.
70
114
  /// - Warning: On iOS 17, the user may need to restart the device after granting
71
115
  /// the permission for it to take effect. This is a known iOS 17 bug.
72
116
  public func openAppSettings() {
@@ -76,4 +120,19 @@ import UIKit
76
120
  UIApplication.shared.open(url)
77
121
  }
78
122
  }
123
+
124
+ // MARK: - Private helpers
125
+
126
+ /// Returns true if the NWError indicates a Local Network policy denial.
127
+ ///
128
+ /// Known denial error codes:
129
+ /// - kDNSServiceErr_PolicyDenied = -65570
130
+ /// - kDNSServiceErr_NoAuth = -65555
131
+ private static func isPolicyDenied(_ error: NWError) -> Bool {
132
+ let nsError = error as NSError
133
+ if nsError.code == -65570 || nsError.code == -65555 { return true }
134
+
135
+ let desc = error.localizedDescription.lowercased()
136
+ return desc.contains("policy") || desc.contains("denied") || desc.contains("noauth")
137
+ }
79
138
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cappitolian/http-local-server-swifter",
3
- "version": "0.0.18",
3
+ "version": "0.0.20",
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",