@centimoo/capacitor-tcp-printer 1.0.0
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.
- package/CentimooCapacitorTcpPrinter.podspec +17 -0
- package/Package.swift +28 -0
- package/README.md +181 -0
- package/dist/docs.json +343 -0
- package/dist/esm/definitions.d.ts +144 -0
- package/dist/esm/definitions.js +19 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.js +7 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/web.d.ts +27 -0
- package/dist/esm/web.js +42 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs.js +75 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +78 -0
- package/dist/plugin.js.map +1 -0
- package/ios/Sources/TcpPrinterPlugin/TcpPrinterPlugin.swift +254 -0
- package/package.json +78 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Capacitor
|
|
3
|
+
import Network
|
|
4
|
+
|
|
5
|
+
@objc(TcpPrinter)
|
|
6
|
+
public class TcpPrinter: CAPPlugin, CAPBridgedPlugin {
|
|
7
|
+
|
|
8
|
+
// MARK: - Capacitor Bridge
|
|
9
|
+
|
|
10
|
+
public let identifier = "TcpPrinter"
|
|
11
|
+
public let jsName = "TcpPrinter"
|
|
12
|
+
|
|
13
|
+
public let pluginMethods: [CAPPluginMethod] = [
|
|
14
|
+
CAPPluginMethod(name: "connect", returnType: CAPPluginReturnPromise),
|
|
15
|
+
CAPPluginMethod(name: "send", returnType: CAPPluginReturnPromise),
|
|
16
|
+
CAPPluginMethod(name: "read", returnType: CAPPluginReturnPromise),
|
|
17
|
+
CAPPluginMethod(name: "disconnect", returnType: CAPPluginReturnPromise)
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
// MARK: - Types
|
|
21
|
+
|
|
22
|
+
/// Wraps an NWConnection together with any pending Capacitor call that is
|
|
23
|
+
/// waiting for data (one-shot read model).
|
|
24
|
+
private struct ManagedConnection {
|
|
25
|
+
let connection: NWConnection
|
|
26
|
+
var pendingReadCall: CAPPluginCall?
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// MARK: - Internal State
|
|
30
|
+
|
|
31
|
+
/// All mutable state is accessed exclusively through this serial queue,
|
|
32
|
+
/// eliminating data races on `connections` and `nextClientId`.
|
|
33
|
+
private let queue = DispatchQueue(label: "com.tcpprinter.plugin", qos: .utility)
|
|
34
|
+
|
|
35
|
+
private var connections: [Int: ManagedConnection] = [:]
|
|
36
|
+
private var nextClientId: Int = 0
|
|
37
|
+
|
|
38
|
+
private let debugEnabled = true
|
|
39
|
+
|
|
40
|
+
// MARK: - Logging
|
|
41
|
+
|
|
42
|
+
private func log(_ message: String) {
|
|
43
|
+
if debugEnabled {
|
|
44
|
+
print("🖨 TcpPrinter: \(message)")
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// MARK: - Plugin Load
|
|
49
|
+
|
|
50
|
+
override public func load() {
|
|
51
|
+
log("Plugin loaded")
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// MARK: - Connect
|
|
55
|
+
|
|
56
|
+
@objc public func connect(_ call: CAPPluginCall) {
|
|
57
|
+
guard let host = call.getString("ipAddress"), !host.isEmpty else {
|
|
58
|
+
call.reject("IP address is required")
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let portValue = call.getInt("port") ?? 9100
|
|
63
|
+
guard portValue > 0, portValue <= 65535,
|
|
64
|
+
let port = NWEndpoint.Port(rawValue: UInt16(portValue)) else {
|
|
65
|
+
call.reject("Invalid port: must be 1–65535")
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Optional connect timeout in milliseconds (default 10 s)
|
|
70
|
+
let timeoutMs = call.getInt("timeoutMs") ?? 10_000
|
|
71
|
+
|
|
72
|
+
log("Connecting to \(host):\(portValue) (timeout \(timeoutMs) ms)")
|
|
73
|
+
|
|
74
|
+
let connection = NWConnection(
|
|
75
|
+
host: NWEndpoint.Host(host),
|
|
76
|
+
port: port,
|
|
77
|
+
using: .tcp
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
// Capture clientId safely inside the serial queue
|
|
81
|
+
queue.async { [weak self] in
|
|
82
|
+
guard let self else {
|
|
83
|
+
call.reject("Plugin deallocated")
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let clientId = self.nextClientId
|
|
88
|
+
self.nextClientId += 1
|
|
89
|
+
|
|
90
|
+
// Connect-timeout work item — cancelled if we reach .ready first
|
|
91
|
+
let timeoutItem = DispatchWorkItem { [weak self] in
|
|
92
|
+
self?.log("Connection timed out (client \(clientId))")
|
|
93
|
+
connection.cancel()
|
|
94
|
+
// Only reject if we haven't already resolved
|
|
95
|
+
call.reject("Connection timed out after \(timeoutMs) ms")
|
|
96
|
+
}
|
|
97
|
+
self.queue.asyncAfter(
|
|
98
|
+
deadline: .now() + .milliseconds(timeoutMs),
|
|
99
|
+
execute: timeoutItem
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
connection.stateUpdateHandler = { [weak self] state in
|
|
103
|
+
guard let self else { return }
|
|
104
|
+
|
|
105
|
+
switch state {
|
|
106
|
+
case .ready:
|
|
107
|
+
timeoutItem.cancel()
|
|
108
|
+
self.queue.async {
|
|
109
|
+
// Guard against resolving twice if timeout fired first
|
|
110
|
+
guard !timeoutItem.isCancelled == false else { return }
|
|
111
|
+
self.connections[clientId] = ManagedConnection(connection: connection)
|
|
112
|
+
self.log("Connection ready (client \(clientId))")
|
|
113
|
+
call.resolve(["client": clientId])
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
case .failed(let error):
|
|
117
|
+
timeoutItem.cancel()
|
|
118
|
+
self.log("Connection failed (client \(clientId)): \(error.localizedDescription)")
|
|
119
|
+
self.queue.async {
|
|
120
|
+
self.connections.removeValue(forKey: clientId)
|
|
121
|
+
}
|
|
122
|
+
connection.cancel()
|
|
123
|
+
call.reject("Connection failed: \(error.localizedDescription)")
|
|
124
|
+
|
|
125
|
+
case .cancelled:
|
|
126
|
+
self.log("Connection cancelled (client \(clientId))")
|
|
127
|
+
self.queue.async {
|
|
128
|
+
self.connections.removeValue(forKey: clientId)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
case .waiting(let error):
|
|
132
|
+
// Network temporarily unavailable — log but don't reject yet;
|
|
133
|
+
// NWConnection will retry or eventually move to .failed.
|
|
134
|
+
self.log("Connection waiting (client \(clientId)): \(error.localizedDescription)")
|
|
135
|
+
|
|
136
|
+
default:
|
|
137
|
+
break
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Start the connection on our serial queue so NWConnection callbacks
|
|
142
|
+
// can safely read/write the connections dictionary via queue.async.
|
|
143
|
+
connection.start(queue: self.queue)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// MARK: - Send
|
|
148
|
+
|
|
149
|
+
@objc public func send(_ call: CAPPluginCall) {
|
|
150
|
+
queue.async { [weak self] in
|
|
151
|
+
guard let self else { call.reject("Plugin deallocated"); return }
|
|
152
|
+
|
|
153
|
+
guard let clientId = call.getInt("client"),
|
|
154
|
+
let managed = self.connections[clientId] else {
|
|
155
|
+
call.reject("Invalid or disconnected client")
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
guard let dataArray = call.getArray("data") as? [Any] else {
|
|
160
|
+
call.reject("No data provided")
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Validate every element is in the 0–255 range before sending
|
|
165
|
+
var bytes = [UInt8]()
|
|
166
|
+
bytes.reserveCapacity(dataArray.count)
|
|
167
|
+
for (index, element) in dataArray.enumerated() {
|
|
168
|
+
guard let intVal = element as? Int, intVal >= 0, intVal <= 255 else {
|
|
169
|
+
call.reject("data[\(index)] is out of byte range (0–255)")
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
bytes.append(UInt8(intVal))
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let data = Data(bytes)
|
|
176
|
+
self.log("Sending \(data.count) bytes to client \(clientId)")
|
|
177
|
+
|
|
178
|
+
managed.connection.send(content: data, completion: .contentProcessed { [weak self] error in
|
|
179
|
+
if let error = error {
|
|
180
|
+
self?.log("Send failed (client \(clientId)): \(error.localizedDescription)")
|
|
181
|
+
call.reject("Send failed: \(error.localizedDescription)")
|
|
182
|
+
} else {
|
|
183
|
+
self?.log("Send success (client \(clientId))")
|
|
184
|
+
call.resolve()
|
|
185
|
+
}
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// MARK: - Read
|
|
191
|
+
|
|
192
|
+
/// One-shot receive. The caller must invoke `read` again to receive the
|
|
193
|
+
/// next chunk. Unsolicited data pushed by the printer between explicit
|
|
194
|
+
/// `read` calls is buffered by NWConnection internally.
|
|
195
|
+
@objc public func read(_ call: CAPPluginCall) {
|
|
196
|
+
queue.async { [weak self] in
|
|
197
|
+
guard let self else { call.reject("Plugin deallocated"); return }
|
|
198
|
+
|
|
199
|
+
guard let clientId = call.getInt("client"),
|
|
200
|
+
let managed = self.connections[clientId] else {
|
|
201
|
+
call.reject("Invalid or disconnected client")
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let maxLen = max(1, call.getInt("expectLen") ?? 1024)
|
|
206
|
+
self.log("Reading up to \(maxLen) bytes from client \(clientId)")
|
|
207
|
+
|
|
208
|
+
managed.connection.receive(
|
|
209
|
+
minimumIncompleteLength: 1,
|
|
210
|
+
maximumLength: maxLen
|
|
211
|
+
) { [weak self] data, _, isComplete, error in
|
|
212
|
+
guard let self else { return }
|
|
213
|
+
|
|
214
|
+
if let error = error {
|
|
215
|
+
self.log("Read failed (client \(clientId)): \(error.localizedDescription)")
|
|
216
|
+
call.reject("Read failed: \(error.localizedDescription)")
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
let bytes = data.map { Array($0) } ?? []
|
|
221
|
+
self.log("Received \(bytes.count) bytes (client \(clientId))")
|
|
222
|
+
call.resolve(["result": bytes])
|
|
223
|
+
|
|
224
|
+
if isComplete {
|
|
225
|
+
self.log("Peer closed connection (client \(clientId))")
|
|
226
|
+
self.queue.async {
|
|
227
|
+
self.connections.removeValue(forKey: clientId)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// MARK: - Disconnect
|
|
235
|
+
|
|
236
|
+
@objc public func disconnect(_ call: CAPPluginCall) {
|
|
237
|
+
queue.async { [weak self] in
|
|
238
|
+
guard let self else { call.reject("Plugin deallocated"); return }
|
|
239
|
+
|
|
240
|
+
guard let clientId = call.getInt("client"),
|
|
241
|
+
let managed = self.connections[clientId] else {
|
|
242
|
+
call.reject("Invalid or disconnected client")
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
self.log("Disconnecting client \(clientId)")
|
|
247
|
+
managed.connection.cancel()
|
|
248
|
+
// Removal also happens in the .cancelled state handler, but we do it
|
|
249
|
+
// here immediately so subsequent calls see the client as gone right away.
|
|
250
|
+
self.connections.removeValue(forKey: clientId)
|
|
251
|
+
call.resolve(["client": clientId])
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@centimoo/capacitor-tcp-printer",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Native TCP printer plugin for Capacitor using Apple Network.framework. Enables raw socket printing (ESC/POS) to LAN printers on iOS.",
|
|
5
|
+
"main": "dist/plugin.cjs.js",
|
|
6
|
+
"module": "dist/esm/index.js",
|
|
7
|
+
"types": "dist/esm/index.d.ts",
|
|
8
|
+
"unpkg": "dist/plugin.js",
|
|
9
|
+
"files": [
|
|
10
|
+
"dist/",
|
|
11
|
+
"ios/Sources",
|
|
12
|
+
"ios/Tests",
|
|
13
|
+
"Package.swift",
|
|
14
|
+
"CentimooCapacitorTcpPrinter.podspec"
|
|
15
|
+
],
|
|
16
|
+
"author": "Wail Djenane",
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/DzCorps/capacitor-tcp-printer.git"
|
|
21
|
+
},
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/DzCorps/capacitor-tcp-printer.git/issues"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"capacitor",
|
|
27
|
+
"plugin",
|
|
28
|
+
"native",
|
|
29
|
+
"tcp",
|
|
30
|
+
"printer",
|
|
31
|
+
"escpos",
|
|
32
|
+
"ios",
|
|
33
|
+
"ionic"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"verify": "npm run verify:ios && npm run verify:web",
|
|
37
|
+
"verify:ios": "xcodebuild -scheme CentimooCapacitorTcpPrinter -destination generic/platform=iOS",
|
|
38
|
+
"verify:web": "npm run build",
|
|
39
|
+
"lint": "npm run eslint && npm run prettier -- --check && npm run swiftlint -- lint",
|
|
40
|
+
"fmt": "npm run eslint -- --fix && npm run prettier -- --write && npm run swiftlint -- --fix --format",
|
|
41
|
+
"eslint": "eslint . --ext ts",
|
|
42
|
+
"prettier": "prettier \"**/*.{css,html,ts,js,java}\" --plugin=prettier-plugin-java",
|
|
43
|
+
"swiftlint": "node-swiftlint",
|
|
44
|
+
"docgen": "docgen --api TcpPrinterPlugin --output-readme README.md --output-json dist/docs.json",
|
|
45
|
+
"build": "npm run clean && npm run docgen && tsc && rollup -c rollup.config.mjs",
|
|
46
|
+
"clean": "rimraf ./dist",
|
|
47
|
+
"watch": "tsc --watch",
|
|
48
|
+
"prepublishOnly": "npm run build"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@capacitor/core": "^8.0.0",
|
|
52
|
+
"@capacitor/docgen": "^0.3.1",
|
|
53
|
+
"@capacitor/ios": "^8.0.0",
|
|
54
|
+
"@ionic/eslint-config": "^0.4.0",
|
|
55
|
+
"@ionic/prettier-config": "^4.0.0",
|
|
56
|
+
"@ionic/swiftlint-config": "^2.0.0",
|
|
57
|
+
"eslint": "^8.57.1",
|
|
58
|
+
"prettier": "^3.6.2",
|
|
59
|
+
"prettier-plugin-java": "^2.7.7",
|
|
60
|
+
"rimraf": "^6.1.0",
|
|
61
|
+
"rollup": "^4.53.2",
|
|
62
|
+
"swiftlint": "^2.0.0",
|
|
63
|
+
"typescript": "^5.9.3"
|
|
64
|
+
},
|
|
65
|
+
"peerDependencies": {
|
|
66
|
+
"@capacitor/core": ">=8.0.0"
|
|
67
|
+
},
|
|
68
|
+
"prettier": "@ionic/prettier-config",
|
|
69
|
+
"swiftlint": "@ionic/swiftlint-config",
|
|
70
|
+
"eslintConfig": {
|
|
71
|
+
"extends": "@ionic/eslint-config/recommended"
|
|
72
|
+
},
|
|
73
|
+
"capacitor": {
|
|
74
|
+
"ios": {
|
|
75
|
+
"src": "ios"
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|