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

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,26 +4,41 @@ 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
+ /// - 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
16
  @objc public class LocalNetworkPermission: NSObject {
13
17
 
14
- // MARK: - Constants
18
+ // MARK: - Types
19
+
20
+ public enum PermissionStatus {
21
+ case granted
22
+ case denied
23
+ case unknown
24
+ }
15
25
 
16
- /// kDNSServiceErr_PolicyDenied returned by the system when Local Network access is denied.
17
- private static let policyDeniedErrorCode = -65570
26
+ // MARK: - Private state
18
27
 
19
- // MARK: - Permission trigger
28
+ private var browser: NWBrowser?
20
29
 
21
- /// Triggers the Local Network permission dialog by briefly starting a Bonjour browser.
30
+ // MARK: - Permission check
31
+
32
+ /// Triggers the Local Network permission dialog and returns the result
33
+ /// asynchronously via the completion handler.
34
+ ///
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.
22
38
  ///
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() {
39
+ /// - Note: iOS shows the dialog only once. On subsequent calls this method
40
+ /// will return the previously stored decision immediately (no dialog shown).
41
+ public func checkPermission(completion: @escaping (PermissionStatus) -> Void) {
27
42
  let parameters = NWParameters()
28
43
  parameters.includePeerToPeer = true
29
44
 
@@ -31,42 +46,62 @@ import UIKit
31
46
  for: .bonjour(type: "_http._tcp", domain: "local."),
32
47
  using: parameters
33
48
  )
49
+ self.browser = browser
34
50
 
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) {
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
39
57
  browser.cancel()
58
+ DispatchQueue.main.async { completion(.unknown) }
40
59
  }
41
- }
60
+ DispatchQueue.main.asyncAfter(deadline: .now() + 3.0, execute: timeout)
42
61
 
43
- // MARK: - Error detection
62
+ browser.stateUpdateHandler = { state in
63
+ guard !completed else { return }
44
64
 
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
- }
65
+ switch state {
66
+ case .ready:
67
+ // Browser started successfully permission is granted
68
+ completed = true
69
+ timeout.cancel()
70
+ browser.cancel()
71
+ DispatchQueue.main.async { completion(.granted) }
52
72
 
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")
73
+ 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)
87
+ }
88
+
89
+ case .cancelled:
90
+ break // Expected when we cancel it ourselves
91
+
92
+ default:
93
+ break // .setup, .waiting — keep waiting
94
+ }
95
+ }
96
+
97
+ browser.start(queue: .main)
62
98
  }
63
99
 
64
100
  // MARK: - Recovery
65
101
 
66
102
  /// Opens the app's page in iOS Settings so the user can manually grant Local Network access.
67
103
  ///
68
- /// - Note: This is the only recovery path available. iOS does not allow re-prompting
69
- /// after the user denies the permission.
104
+ /// - Note: This is the only recovery path available after the user denies the permission.
70
105
  /// - Warning: On iOS 17, the user may need to restart the device after granting
71
106
  /// the permission for it to take effect. This is a known iOS 17 bug.
72
107
  public func openAppSettings() {
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.19",
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",