@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.
@@ -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
+ }