@nitra/zebra 8.1.0 → 8.2.1

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.
@@ -131,6 +131,8 @@ public class Zebra {
131
131
  out.write(data, offset, len);
132
132
  }
133
133
  out.flush();
134
+ // Затримка перед закриттям: дає Bluetooth-стеку та принтеру час відправити/прийняти дані (інакше перший друк іноді не виконується, лише другий).
135
+ Thread.sleep(400);
134
136
 
135
137
  Logger.info("Zebra", "ZPL успішно відправлено: " + data.length + " байт");
136
138
  callback.onSuccess(addrTrimmed, data.length);
@@ -23,9 +23,6 @@ import ExternalAccessory
23
23
  /// Невелика затримка між черговими завданнями, щоб дати прошивці час стабілізуватися
24
24
  private let cooldownInterval: TimeInterval = 1.0
25
25
 
26
- /// Перемикач debug-логування
27
- private static let debugLogging = true
28
-
29
26
  /// Один раз реєструємося для нотифікацій EA — потрібно для коректного доступу до аксесуарів при холодному запуску (без Xcode).
30
27
  private static var didRegisterForAccessoryNotifications = false
31
28
  private static let registerLock = NSLock()
@@ -35,13 +32,7 @@ import ExternalAccessory
35
32
  guard !Zebra.didRegisterForAccessoryNotifications else { return }
36
33
  EAAccessoryManager.shared().registerForLocalNotifications()
37
34
  Zebra.didRegisterForAccessoryNotifications = true
38
- log("EAAccessoryManager.registerForLocalNotifications() called")
39
- }
40
-
41
- /// Функція логування з префіксом [Zebra]
42
- private func log(_ items: Any...) {
43
- guard Zebra.debugLogging else { return }
44
- print("[Zebra]", items.map { String(describing: $0) }.joined(separator: " "))
35
+ logDebug("EAAccessoryManager.registerForLocalNotifications() called")
45
36
  }
46
37
 
47
38
  /// Представляє інформацію про підключений аксесуар, яку використовує плагін
@@ -95,7 +86,8 @@ import ExternalAccessory
95
86
  }
96
87
  }
97
88
 
98
- /// Відповідає за запис байтів у OutputStream з таймаутом та звітує через completion
89
+ /// Відповідає за запис байтів у OutputStream з таймаутом та звітує через completion.
90
+ /// Закриття потоків і сесії гарантовано виконується один раз (ідемпотентний close), у т.ч. по таймеру при відсутності подій.
99
91
  private class StreamWriter: NSObject, StreamDelegate {
100
92
  private let output: OutputStream
101
93
  private let data: [UInt8]
@@ -103,12 +95,13 @@ import ExternalAccessory
103
95
  private let timeout: TimeInterval
104
96
  private var totalWritten = 0
105
97
  private var completion: ((Result<PrintResult, ZebraError>) -> Void)?
106
- private weak var owner: Zebra?
107
98
  private var startTime = Date()
108
99
  private let session: EASession
100
+ private var didClose = false
101
+ private let closeLock = NSLock()
102
+ private var timeoutTimer: Timer?
109
103
 
110
- init(owner: Zebra, session: EASession, output: OutputStream, data: [UInt8], address: String, timeout: TimeInterval = 5.0, completion: @escaping (Result<PrintResult, ZebraError>) -> Void) {
111
- self.owner = owner
104
+ init(owner _: Zebra, session: EASession, output: OutputStream, data: [UInt8], address: String, timeout: TimeInterval = 5.0, completion: @escaping (Result<PrintResult, ZebraError>) -> Void) {
112
105
  self.session = session
113
106
  self.output = output
114
107
  self.data = data
@@ -119,6 +112,7 @@ import ExternalAccessory
119
112
  }
120
113
 
121
114
  /// Запускає стріми — обидва (input і output) відкриваються й додаються в RunLoop, як у документації Apple; інакше сесія може не звільнятися коректно.
115
+ /// Таймер гарантує закриття при відсутності подій потоку (наприклад, зависання принтера).
122
116
  func start() {
123
117
  let runLoop = RunLoop.current
124
118
  let mode: RunLoop.Mode = .default
@@ -130,24 +124,39 @@ import ExternalAccessory
130
124
  output.delegate = self
131
125
  output.schedule(in: runLoop, forMode: mode)
132
126
  output.open()
133
- owner?.log("StreamWriter started, bytes=", data.count)
127
+ logDebug("StreamWriter started, bytes= \(self.data.count)")
134
128
  startTime = Date()
129
+ timeoutTimer = Timer.scheduledTimer(withTimeInterval: timeout, repeats: false) { [weak self] _ in
130
+ logDebug("StreamWriter timeout (no events)")
131
+ self?.close(with: .failure(.writeFailed("Таймаут запису")))
132
+ }
135
133
  }
136
134
 
137
- /// Після запису всіх байтів дає час на флаш буфера (iOS може не встигнути відправити на пристрій при миттєвому закритті, особливо при запуску без Xcode).
135
+ /// Після запису всіх байтів дає час на флаш буфера (iOS може не встигнути відправити на пристрій при миттєвому закритті; при першому друці принтер ще "прокидається" — потрібно більше часу).
138
136
  private func flushThenClose() {
139
- let flushDuration: TimeInterval = 0.75
137
+ let flushDuration: TimeInterval = 1.5
140
138
  let deadline = Date().addingTimeInterval(flushDuration)
141
139
  while Date() < deadline {
142
140
  RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.1))
143
141
  }
144
- owner?.log("Flush done, closing stream")
142
+ logDebug("Flush done, closing stream")
145
143
  close(with: .success(PrintResult(address: address)))
146
144
  }
147
145
 
148
- /// Закриває стріми та викликає completion один раз.
146
+ /// Закриває стріми та викликає completion один раз (ідемпотентно).
149
147
  /// Порядок важливий для коректного звільнення EASession у iOS — інакше повторне відкриття сесії до того ж аксесуара може повертати nil.
150
148
  func close(with result: Result<PrintResult, ZebraError>) {
149
+ closeLock.lock()
150
+ guard !didClose else {
151
+ closeLock.unlock()
152
+ return
153
+ }
154
+ didClose = true
155
+ closeLock.unlock()
156
+
157
+ timeoutTimer?.invalidate()
158
+ timeoutTimer = nil
159
+
151
160
  let runLoop = RunLoop.current
152
161
  let mode: RunLoop.Mode = .default
153
162
  // Даємо RunLoop обробити останні події перед закриттям
@@ -172,26 +181,26 @@ import ExternalAccessory
172
181
  completion(result)
173
182
  }
174
183
  completion = nil
175
- owner?.log("StreamWriter finished with", result)
184
+ logDebug("StreamWriter finished with \(String(describing: result))")
176
185
  }
177
186
 
178
187
  func stream(_ aStream: Stream, handle eventCode: Stream.Event) {
179
188
  // Події input-потоку ігноруємо (потрібен лише для коректного життєвого циклу сесії)
180
189
  guard aStream === output else { return }
181
190
  if Date().timeIntervalSince(startTime) > timeout {
182
- owner?.log("StreamWriter timeout")
191
+ logDebug("StreamWriter timeout")
183
192
  close(with: .failure(.writeFailed("Таймаут запису")))
184
193
  return
185
194
  }
186
195
  switch eventCode {
187
196
  case .openCompleted:
188
197
  // Потік відкрито
189
- owner?.log("Stream open completed")
198
+ logDebug("Stream open completed")
190
199
  case .hasSpaceAvailable:
191
200
  // Є простір для запису, пишемо дані порціями
192
201
  if totalWritten >= data.count {
193
202
  // Усі байти записані — даємо час на флаш буфера перед закриттям (iOS часто не встигає відправити на пристрій)
194
- owner?.log("All bytes written, flushing before close...")
203
+ logDebug("All bytes written, flushing before close...")
195
204
  flushThenClose()
196
205
  return
197
206
  }
@@ -200,7 +209,7 @@ import ExternalAccessory
200
209
  let wrote = output.write(Array(data[totalWritten..<(totalWritten + chunkSize)]), maxLength: chunkSize)
201
210
  if wrote <= 0 {
202
211
  let reason = output.streamError?.localizedDescription ?? "Невідома помилка потоку"
203
- owner?.log("StreamWriter write failed:", reason)
212
+ logDebug("StreamWriter write failed: \(reason)")
204
213
  close(with: .failure(.writeFailed(reason)))
205
214
  return
206
215
  }
@@ -208,11 +217,11 @@ import ExternalAccessory
208
217
  case .errorOccurred:
209
218
  // Сталася помилка потоку
210
219
  let reason = output.streamError?.localizedDescription ?? "Невідома помилка потоку"
211
- owner?.log("Stream error:", reason)
220
+ logDebug("Stream error: \(reason)")
212
221
  close(with: .failure(.writeFailed(reason)))
213
222
  case .endEncountered:
214
223
  // Потік закінчився, перевіряємо чи всі дані записані
215
- owner?.log("Stream end encountered, bytes=", totalWritten)
224
+ logDebug("Stream end encountered, bytes= \(self.totalWritten)")
216
225
  if totalWritten >= data.count {
217
226
  close(with: .success(PrintResult(address: address)))
218
227
  } else {
@@ -239,7 +248,18 @@ import ExternalAccessory
239
248
  /// Повертає список підключених EA аксесуарів у вигляді DeviceInfo, вибираючи серійний номер або ім'я як адресу
240
249
  public func getConnectedAccessories() -> [DeviceInfo] {
241
250
  ensureRegisteredForAccessoryNotifications()
242
- let accessories = EAAccessoryManager.shared().connectedAccessories
251
+ // Якщо аксесуарів немає, чекаємо 0.6 с і повторюємо спробу до 3 разів (щоб дати системі час підхопити нове підключення)
252
+ let maxAttempts = 3
253
+ let retryDelay: TimeInterval = 0.6
254
+ var accessories = EAAccessoryManager.shared().connectedAccessories
255
+ for attempt in 1...maxAttempts {
256
+ if !accessories.isEmpty { break }
257
+ if attempt < maxAttempts {
258
+ logDebug("connectedAccessories attempt: \(attempt)")
259
+ Thread.sleep(forTimeInterval: retryDelay)
260
+ accessories = EAAccessoryManager.shared().connectedAccessories
261
+ }
262
+ }
243
263
  return accessories.compactMap { accessory in
244
264
  let address = accessory.serialNumber.isEmpty ? accessory.name : accessory.serialNumber
245
265
  if address.isEmpty {
@@ -252,14 +272,14 @@ import ExternalAccessory
252
272
  /// Виводить діагностичну інформацію щодо підключених аксесуарів та протоколів у Info.plist
253
273
  public func diagnoseEnvironment() {
254
274
  let accessories = EAAccessoryManager.shared().connectedAccessories
255
- log("Connected accessories count:", accessories.count)
275
+ logDebug("Connected accessories count: \(accessories.count)")
256
276
  for a in accessories {
257
- log("Accessory:", "name=\(a.name)", "serial=\(a.serialNumber)", "protocols=\(a.protocolStrings)")
277
+ logDebug("Accessory: name=\(a.name) serial=\(a.serialNumber) protocols=\(a.protocolStrings)")
258
278
  }
259
279
  if let supported = Bundle.main.object(forInfoDictionaryKey: "UISupportedExternalAccessoryProtocols") as? [String] {
260
- log("Info.plist UISupportedExternalAccessoryProtocols:", supported)
280
+ logDebug("Info.plist UISupportedExternalAccessoryProtocols: \(supported)")
261
281
  } else {
262
- log("Info.plist UISupportedExternalAccessoryProtocols: MISSING or not an array")
282
+ logDebug("Info.plist UISupportedExternalAccessoryProtocols: MISSING or not an array")
263
283
  }
264
284
  }
265
285
 
@@ -270,10 +290,10 @@ import ExternalAccessory
270
290
  for proto in protocolsToTry {
271
291
  for attempt in 1...retries {
272
292
  if let session = EASession(accessory: accessory, forProtocol: proto) {
273
- self.log("EASession open success on attempt", attempt, "protocol:", proto)
293
+ logDebug("EASession open success on attempt \(attempt) protocol: \(proto)")
274
294
  return (session, proto)
275
295
  } else {
276
- self.log("EASession open failed attempt", attempt, "protocol:", proto)
296
+ logDebug("EASession open failed attempt \(attempt) protocol: \(proto)")
277
297
  Thread.sleep(forTimeInterval: delay)
278
298
  }
279
299
  }
@@ -299,18 +319,25 @@ import ExternalAccessory
299
319
  payload += "\r\n"
300
320
  }
301
321
  let zplBytes = Array(payload.utf8)
302
- log("printZpl called with address=\(trimmedAddress)", "zplLength=\(zplBytes.count)")
322
+ logDebug("printZpl called with address=\(trimmedAddress) zplLength=\(zplBytes.count)")
303
323
 
304
324
  DispatchQueue.main.async { [weak self] in
305
325
  guard let self = self else { return }
306
326
  self.ensureRegisteredForAccessoryNotifications()
307
327
  let accessories = EAAccessoryManager.shared().connectedAccessories
308
- self.log("EA connectedAccessories count=", accessories.count)
328
+ logDebug("EA connectedAccessories count= \(accessories.count)")
309
329
 
310
330
  // Шукаємо аксесуар, що відповідає адресі (serialNumber або name)
311
331
  let availableList = self.getConnectedAccessories()
332
+
333
+ if availableList.isEmpty {
334
+ logDebug("No accessories found")
335
+ completion(.failure(.accessoryNotFound(requestedAddress: trimmedAddress, availableAccessories: availableList)))
336
+ return
337
+ }
338
+
312
339
  guard let accessory = accessories.first(where: { self.accessoryMatches($0, address: trimmedAddress) }) else {
313
- self.log("No accessory matched address:", trimmedAddress)
340
+ logDebug("No accessory matched address: \(trimmedAddress)")
314
341
  // У failure передаємо не лише адресу, а й список доступних аксесуарів (назви та адреси) для показу в помилці
315
342
  completion(.failure(.accessoryNotFound(requestedAddress: trimmedAddress, availableAccessories: availableList)))
316
343
  return
@@ -318,30 +345,30 @@ import ExternalAccessory
318
345
 
319
346
  // Обираємо протокол для з’єднання
320
347
  let protocolString = self.preferredProtocol(for: accessory)
321
- self.log("Chosen accessory:", accessory.name, "serial=\(accessory.serialNumber)")
322
- self.log("Selected protocol:", protocolString)
348
+ logDebug("Chosen accessory: \(accessory.name) serial=\(accessory.serialNumber)")
349
+ logDebug("Selected protocol: \(protocolString)")
323
350
  if let supported = Bundle.main.object(forInfoDictionaryKey: "UISupportedExternalAccessoryProtocols") as? [String] {
324
351
  if !supported.contains(protocolString) {
325
- self.log("WARNING: Selected protocol not present in Info.plist UISupportedExternalAccessoryProtocols")
352
+ logDebug("WARNING: Selected protocol not present in Info.plist UISupportedExternalAccessoryProtocols")
326
353
  }
327
354
  } else {
328
- self.log("WARNING: UISupportedExternalAccessoryProtocols missing in Info.plist")
355
+ logDebug("WARNING: UISupportedExternalAccessoryProtocols missing in Info.plist")
329
356
  }
330
357
 
331
- self.log("Attempting to open EASession with retries and fallback protocols...")
358
+ logDebug("Attempting to open EASession with retries and fallback protocols...")
332
359
  Thread.sleep(forTimeInterval: 0.1)
333
360
  let fallback = accessory.protocolStrings
334
361
  // Відкриваємо сесію, пробуємо ретраї і fallback протоколи
335
362
  guard let result = self.openSessionWithRetries(accessory: accessory, preferredProtocol: protocolString, fallbackProtocols: fallback) else {
336
- self.log("EASession failed after retries and fallback")
363
+ logDebug("EASession failed after retries and fallback")
337
364
  completion(.failure(.sessionFailed("EASession повернув nil")))
338
365
  return
339
366
  }
340
367
  let session = result.session
341
368
  let protocolUsed = result.protocolUsed
342
- self.log("Using protocol for session:", protocolUsed)
369
+ logDebug("Using protocol for session: \(protocolUsed)")
343
370
  guard let outputStream = session.outputStream else {
344
- self.log("Session opened but outputStream is nil")
371
+ logDebug("Session opened but outputStream is nil")
345
372
  completion(.failure(.sessionFailed("Відсутній outputStream")))
346
373
  return
347
374
  }
@@ -0,0 +1,11 @@
1
+ import os
2
+
3
+ /// Спільна функція дебаг-логування для модуля Zebra.
4
+ /// У Release не виконується й не обчислює повідомлення (zero cost).
5
+ #if DEBUG
6
+ func logDebug(_ message: @autoclosure @escaping () -> String) {
7
+ Logger(subsystem: "com.nitra.zebra", category: "Zebra").debug("\(message(), privacy: .public)")
8
+ }
9
+ #else
10
+ func logDebug(_ message: @autoclosure @escaping () -> String) {}
11
+ #endif
@@ -36,9 +36,8 @@ public class ZebraPlugin: CAPPlugin, CAPBridgedPlugin {
36
36
  return
37
37
  }
38
38
  Task {
39
- // Зберігаємо адресу в UserDefaults
40
39
  await implementation.setPrinterAddress(address)
41
- // Повертаємо успішний результат
40
+ logDebug("setPrinterAddress: \(address)")
42
41
  await MainActor.run {
43
42
  call.resolve(["address": address])
44
43
  }
@@ -49,13 +48,10 @@ public class ZebraPlugin: CAPPlugin, CAPBridgedPlugin {
49
48
  /// - Parameter call: Виклик без параметрів
50
49
  @objc func getPairedDevices(_ call: CAPPluginCall) {
51
50
  Task {
52
- // Отримуємо список підключених аксесуарів
53
51
  let devices = await implementation.getConnectedAccessories()
54
-
55
- // Перетворюємо масив DeviceInfo в масив словників
52
+ logDebug("getPairedDevices: \(devices.count) device(s)")
56
53
  let list = devices.map { ["address": $0.address, "name": $0.name] }
57
54
  await MainActor.run {
58
- // Повертаємо результат
59
55
  call.resolve(["devices": list])
60
56
  }
61
57
  }
@@ -71,13 +67,15 @@ public class ZebraPlugin: CAPPlugin, CAPBridgedPlugin {
71
67
  return
72
68
  }
73
69
  Task {
70
+ logDebug("print address=\(address) zplLength=\(zpl.utf8.count)")
74
71
  do {
75
72
  _ = try await implementation.printZpl(address: address, zpl: zpl)
73
+ logDebug("print success")
76
74
  await MainActor.run {
77
- // Успішний друк - повертаємо лише success
78
75
  call.resolve(["success": true])
79
76
  }
80
77
  } catch let error as Zebra.ZebraError {
78
+ logDebug("print failed: \(error.localizedDescription)")
81
79
  // Помилка друку - визначаємо код помилки
82
80
  await MainActor.run {
83
81
  let code: String
@@ -110,8 +108,8 @@ public class ZebraPlugin: CAPPlugin, CAPBridgedPlugin {
110
108
  call.reject(error.localizedDescription, code, error)
111
109
  }
112
110
  } catch {
111
+ logDebug("print failed (unknown): \(error.localizedDescription)")
113
112
  await MainActor.run {
114
- // Повертаємо помилку з UNKNOWN
115
113
  call.reject(error.localizedDescription, "UNKNOWN", error)
116
114
  }
117
115
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/zebra",
3
- "version": "8.1.0",
3
+ "version": "8.2.1",
4
4
  "description": "Zebra printer",
5
5
  "keywords": [
6
6
  "capacitor",