@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
|
-
//
|
|
29
|
-
// iOS only shows the dialog once
|
|
30
|
-
// We
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
59
|
+
do {
|
|
60
|
+
try server.start(self.defaultPort, forceIPv4: true)
|
|
61
|
+
let ip = Self.getWiFiAddress() ?? "127.0.0.1"
|
|
54
62
|
|
|
55
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
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
|
|
8
|
-
///
|
|
9
|
-
///
|
|
10
|
-
///
|
|
11
|
-
///
|
|
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: -
|
|
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
|
-
|
|
17
|
-
private static let policyDeniedErrorCode = -65570
|
|
30
|
+
private var connection: NWConnection?
|
|
18
31
|
|
|
19
|
-
// MARK: - Permission
|
|
32
|
+
// MARK: - Permission check
|
|
20
33
|
|
|
21
|
-
///
|
|
34
|
+
/// Probes the local network to trigger the permission dialog and detect the result.
|
|
22
35
|
///
|
|
23
|
-
///
|
|
24
|
-
///
|
|
25
|
-
///
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
67
|
+
conn.stateUpdateHandler = { [weak self] state in
|
|
68
|
+
guard !completed else { return }
|
|
44
69
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
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.
|
|
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",
|