openclacky 0.9.2 → 0.9.4

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,331 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clacky
4
+ # BlockFont renders text using Unicode box-drawing characters (█ ║ ╗ ╔ ╚ ╝ ═)
5
+ # in the same visual style as the hardcoded OpenClacky logo.
6
+ #
7
+ # Each glyph is 6 lines tall. Characters are joined horizontally with one
8
+ # space column between them.
9
+ module BlockFont
10
+ # Each letter is defined as an array of exactly 6 strings (equal width).
11
+ # rubocop:disable Layout/ExtraSpacing, Layout/SpaceInsideArrayLiteralBrackets
12
+ GLYPHS = {
13
+ "0" => [
14
+ " ██████╗ ",
15
+ "██╔═══██╗",
16
+ "██║██╗██║",
17
+ "██║╚█╔╝██║",
18
+ "╚██████╔╝",
19
+ " ╚═════╝ ",
20
+ ],
21
+ "1" => [
22
+ " ██╗",
23
+ "███║",
24
+ "╚██║",
25
+ " ██║",
26
+ " ██║",
27
+ " ╚═╝",
28
+ ],
29
+ "2" => [
30
+ "██████╗ ",
31
+ "╚════██╗",
32
+ " █████╔╝",
33
+ "██╔═══╝ ",
34
+ "███████╗",
35
+ "╚══════╝",
36
+ ],
37
+ "3" => [
38
+ "██████╗ ",
39
+ "╚════██╗",
40
+ " █████╔╝",
41
+ " ╚═══██╗",
42
+ "██████╔╝",
43
+ "╚═════╝ ",
44
+ ],
45
+ "4" => [
46
+ "██╗ ██╗",
47
+ "██║ ██║",
48
+ "███████║",
49
+ "╚════██║",
50
+ " ██║",
51
+ " ╚═╝",
52
+ ],
53
+ "5" => [
54
+ "███████╗",
55
+ "██╔════╝",
56
+ "███████╗",
57
+ "╚════██║",
58
+ "███████║",
59
+ "╚══════╝",
60
+ ],
61
+ "6" => [
62
+ " ██████╗ ",
63
+ "██╔════╝ ",
64
+ "███████╗ ",
65
+ "██╔═══██╗",
66
+ "╚██████╔╝",
67
+ " ╚═════╝ ",
68
+ ],
69
+ "7" => [
70
+ "███████╗",
71
+ "╚════██║",
72
+ " ██╔╝",
73
+ " ██╔╝ ",
74
+ " ██║ ",
75
+ " ╚═╝ ",
76
+ ],
77
+ "8" => [
78
+ " █████╗ ",
79
+ "██╔══██╗",
80
+ "╚█████╔╝",
81
+ "██╔══██╗",
82
+ "╚█████╔╝",
83
+ " ╚════╝ ",
84
+ ],
85
+ "9" => [
86
+ " █████╗ ",
87
+ "██╔══██╗",
88
+ "╚██████║",
89
+ " ╚═══██║",
90
+ " █████╔╝",
91
+ " ╚════╝ ",
92
+ ],
93
+ " " => [
94
+ " ",
95
+ " ",
96
+ " ",
97
+ " ",
98
+ " ",
99
+ " ",
100
+ ],
101
+ "a" => [
102
+ " ████╗ ",
103
+ "██╔══██╗",
104
+ "███████║",
105
+ "██╔══██║",
106
+ "██║ ██║",
107
+ "╚═╝ ╚═╝",
108
+ ],
109
+ "b" => [
110
+ "██████╗ ",
111
+ "██╔══██╗",
112
+ "██████╔╝",
113
+ "██╔══██╗",
114
+ "██████╔╝",
115
+ "╚═════╝ ",
116
+ ],
117
+ "c" => [
118
+ " ██████╗",
119
+ "██╔════╝",
120
+ "██║ ",
121
+ "██║ ",
122
+ "╚██████╗",
123
+ " ╚═════╝",
124
+ ],
125
+ "d" => [
126
+ "██████╗ ",
127
+ "██╔══██╗",
128
+ "██║ ██║",
129
+ "██║ ██║",
130
+ "██████╔╝",
131
+ "╚═════╝ ",
132
+ ],
133
+ "e" => [
134
+ "███████╗",
135
+ "██╔════╝",
136
+ "█████╗ ",
137
+ "██╔══╝ ",
138
+ "███████╗",
139
+ "╚══════╝",
140
+ ],
141
+ "f" => [
142
+ "███████╗",
143
+ "██╔════╝",
144
+ "█████╗ ",
145
+ "██╔══╝ ",
146
+ "██║ ",
147
+ "╚═╝ ",
148
+ ],
149
+ "g" => [
150
+ " ██████╗ ",
151
+ "██╔════╝ ",
152
+ "██║ ███╗",
153
+ "██║ ██║",
154
+ "╚██████╔╝",
155
+ " ╚═════╝ ",
156
+ ],
157
+ "h" => [
158
+ "██╗ ██╗",
159
+ "██║ ██║",
160
+ "███████║",
161
+ "██╔══██║",
162
+ "██║ ██║",
163
+ "╚═╝ ╚═╝",
164
+ ],
165
+ "i" => [
166
+ "██╗",
167
+ "██║",
168
+ "██║",
169
+ "██║",
170
+ "██║",
171
+ "╚═╝",
172
+ ],
173
+ "j" => [
174
+ " ██╗",
175
+ " ██║",
176
+ " ██║",
177
+ " ██║",
178
+ "███║",
179
+ "╚══╝",
180
+ ],
181
+ "k" => [
182
+ "██╗ ██╗",
183
+ "██║ ██╔╝",
184
+ "█████╔╝ ",
185
+ "██╔═██╗ ",
186
+ "██║ ██╗",
187
+ "╚═╝ ╚═╝",
188
+ ],
189
+ "l" => [
190
+ "██╗ ",
191
+ "██║ ",
192
+ "██║ ",
193
+ "██║ ",
194
+ "███████╗",
195
+ "╚══════╝",
196
+ ],
197
+ "m" => [
198
+ "███╗ ███╗",
199
+ "████╗ ████║",
200
+ "██╔████╔██║",
201
+ "██║╚██╔╝██║",
202
+ "██║ ╚═╝ ██║",
203
+ "╚═╝ ╚═╝",
204
+ ],
205
+ "n" => [
206
+ "███╗ ██╗",
207
+ "████╗ ██║",
208
+ "██╔██╗ ██║",
209
+ "██║╚██╗██║",
210
+ "██║ ╚████║",
211
+ "╚═╝ ╚═══╝",
212
+ ],
213
+ "o" => [
214
+ " ██████╗ ",
215
+ "██╔═══██╗",
216
+ "██║ ██║",
217
+ "██║ ██║",
218
+ "╚██████╔╝",
219
+ " ╚═════╝ ",
220
+ ],
221
+ "p" => [
222
+ "██████╗ ",
223
+ "██╔══██╗",
224
+ "██████╔╝",
225
+ "██╔═══╝ ",
226
+ "██║ ",
227
+ "╚═╝ ",
228
+ ],
229
+ "q" => [
230
+ " ██████╗ ",
231
+ "██╔═══██╗",
232
+ "██║ ██║",
233
+ "██║▄▄ ██║",
234
+ "╚██████╔╝",
235
+ " ╚══▀▀═╝ ",
236
+ ],
237
+ "r" => [
238
+ "██████╗ ",
239
+ "██╔══██╗",
240
+ "██████╔╝",
241
+ "██╔══██╗",
242
+ "██║ ██║",
243
+ "╚═╝ ╚═╝",
244
+ ],
245
+ "s" => [
246
+ "███████╗",
247
+ "██╔════╝",
248
+ "███████╗",
249
+ "╚════██║",
250
+ "███████║",
251
+ "╚══════╝",
252
+ ],
253
+ "t" => [
254
+ "████████╗",
255
+ "╚══██╔══╝",
256
+ " ██║ ",
257
+ " ██║ ",
258
+ " ██║ ",
259
+ " ╚═╝ ",
260
+ ],
261
+ "u" => [
262
+ "██╗ ██╗",
263
+ "██║ ██║",
264
+ "██║ ██║",
265
+ "██║ ██║",
266
+ "╚██████╔╝",
267
+ " ╚═════╝ ",
268
+ ],
269
+ "v" => [
270
+ "██╗ ██╗",
271
+ "██║ ██║",
272
+ "██║ ██║",
273
+ "╚██╗ ██╔╝",
274
+ " ╚████╔╝ ",
275
+ " ╚═══╝ ",
276
+ ],
277
+ "w" => [
278
+ "██╗ ██╗",
279
+ "██║ ██║",
280
+ "██║ █╗ ██║",
281
+ "██║███╗██║",
282
+ "╚███╔███╔╝",
283
+ " ╚══╝╚══╝ ",
284
+ ],
285
+ "x" => [
286
+ "██╗ ██╗",
287
+ "╚██╗██╔╝",
288
+ " ╚███╔╝ ",
289
+ " ██╔██╗ ",
290
+ "██╔╝ ██╗",
291
+ "╚═╝ ╚═╝",
292
+ ],
293
+ "y" => [
294
+ "██╗ ██╗",
295
+ "╚██╗ ██╔╝",
296
+ " ╚████╔╝ ",
297
+ " ╚██╔╝ ",
298
+ " ██║ ",
299
+ " ╚═╝ ",
300
+ ],
301
+ "z" => [
302
+ "███████╗",
303
+ "╚════██║",
304
+ " ██╔╝",
305
+ " ██╔╝ ",
306
+ "███████╗",
307
+ "╚══════╝",
308
+ ],
309
+ }.freeze
310
+ # rubocop:enable Layout/ExtraSpacing, Layout/SpaceInsideArrayLiteralBrackets
311
+
312
+ HEIGHT = 6
313
+ GLYPH_GAP = " "
314
+
315
+ # Render a string as block-font art. Unknown characters fall back to space.
316
+ # Returns a multi-line string (6 lines joined by newlines).
317
+ def self.render(text)
318
+ chars = text.downcase.chars
319
+ glyphs = chars.map { |c| GLYPHS[c] || GLYPHS[" "] }
320
+
321
+ HEIGHT.times.map do |row|
322
+ glyphs.map { |g| g[row] }.join(GLYPH_GAP)
323
+ end.join("\n")
324
+ end
325
+
326
+ # Return the pixel width of the rendered text (longest line length).
327
+ def self.width(text)
328
+ render(text).lines.map { |l| l.chomp.length }.max.to_i
329
+ end
330
+ end
331
+ end
@@ -22,6 +22,9 @@ module Clacky
22
22
  # product_name: "JohnAI Pro"
23
23
  # logo_url: "https://example.com/logo.png"
24
24
  # support_contact: "support@johnai.com"
25
+ # support_qr_url: "https://example.com/qr.png"
26
+ # theme_color: "#3B82F6"
27
+ # homepage_url: "https://johnai.com"
25
28
  # license_key: "0000002A-00000007-DEADBEEF-CAFEBABE-A1B2C3D4"
26
29
  # license_activated_at: "2025-03-01T00:00:00Z"
27
30
  # license_expires_at: "2026-03-01T00:00:00Z"
@@ -43,7 +46,8 @@ module Clacky
43
46
  attr_reader :brand_name, :license_key, :license_activated_at,
44
47
  :license_expires_at, :license_last_heartbeat, :device_id,
45
48
  :brand_command, :distribution_name, :product_name,
46
- :logo_url, :support_contact, :license_user_id
49
+ :logo_url, :support_contact, :license_user_id,
50
+ :support_qr_url, :theme_color, :homepage_url
47
51
 
48
52
  def initialize(attrs = {})
49
53
  @brand_name = attrs["brand_name"]
@@ -52,6 +56,9 @@ module Clacky
52
56
  @product_name = attrs["product_name"]
53
57
  @logo_url = attrs["logo_url"]
54
58
  @support_contact = attrs["support_contact"]
59
+ @support_qr_url = attrs["support_qr_url"]
60
+ @theme_color = attrs["theme_color"]
61
+ @homepage_url = attrs["homepage_url"]
55
62
  @license_key = attrs["license_key"]
56
63
  @license_activated_at = parse_time(attrs["license_activated_at"])
57
64
  @license_expires_at = parse_time(attrs["license_expires_at"])
@@ -159,6 +166,13 @@ module Clacky
159
166
  # Server returns "owner_user_id" for system licenses; plan-based licenses return nil.
160
167
  owner_uid = data["owner_user_id"]
161
168
  @license_user_id = owner_uid.to_s.strip if owner_uid && !owner_uid.to_s.strip.empty?
169
+ # Pin the device_id used in this activation request so that future API calls
170
+ # (e.g. skill_keys, heartbeat) always send the exact same device_id that was
171
+ # recorded in activated_devices on the server side.
172
+ # If the server echoes back device_id in the response, prefer that value;
173
+ # otherwise keep the one we just sent (@device_id is already set above).
174
+ server_device_id = data["device_id"].to_s.strip
175
+ @device_id = server_device_id unless server_device_id.empty?
162
176
  apply_distribution(data["distribution"])
163
177
  save
164
178
  { success: true, message: "License activated successfully!", brand_name: @brand_name,
@@ -179,6 +193,8 @@ module Clacky
179
193
  # Returns the same { success:, message:, brand_name:, data: } shape as activate!
180
194
  def activate_mock!(license_key)
181
195
  @license_key = license_key.strip
196
+ # Pin a stable device_id for this activation. Once set (from a prior load or
197
+ # a previous call), never regenerate — the same rule as activate!.
182
198
  @device_id ||= generate_device_id
183
199
 
184
200
  # Always derive brand_name fresh from the key in mock mode,
@@ -452,6 +468,7 @@ module Clacky
452
468
  # Auto-detect whether the zip has a single root folder to strip.
453
469
  # Uses get_input_stream instead of entry.extract to avoid rubyzip 3.x
454
470
  # path-safety restrictions on absolute destination paths.
471
+ # Uses chunked read + size verification for robustness.
455
472
  Zip::File.open(tmp_zip) do |zip|
456
473
  entries = zip.entries.reject(&:directory?)
457
474
  top_dirs = entries.map { |e| e.name.split("/").first }.uniq
@@ -469,7 +486,22 @@ module Clacky
469
486
 
470
487
  out = File.join(dest_dir, rel_path)
471
488
  FileUtils.mkdir_p(File.dirname(out))
472
- File.open(out, "wb") { |f| f.write(entry.get_input_stream.read) }
489
+
490
+ # Chunked copy with size verification
491
+ written = 0
492
+ File.open(out, "wb") do |f|
493
+ entry.get_input_stream do |input|
494
+ while (chunk = input.read(65536))
495
+ f.write(chunk)
496
+ written += chunk.bytesize
497
+ end
498
+ end
499
+ end
500
+
501
+ # Verify file size matches ZIP entry declaration
502
+ if written != entry.size
503
+ raise "Size mismatch for #{entry.name}: expected #{entry.size}, got #{written}"
504
+ end
473
505
  end
474
506
  end
475
507
 
@@ -708,6 +740,9 @@ module Clacky
708
740
  product_name: @product_name,
709
741
  logo_url: @logo_url,
710
742
  support_contact: @support_contact,
743
+ support_qr_url: @support_qr_url,
744
+ theme_color: @theme_color,
745
+ homepage_url: @homepage_url,
711
746
  branded: branded?,
712
747
  activated: activated?,
713
748
  expired: expired?,
@@ -727,6 +762,9 @@ module Clacky
727
762
  data["product_name"] = @product_name if @product_name
728
763
  data["logo_url"] = @logo_url if @logo_url
729
764
  data["support_contact"] = @support_contact if @support_contact
765
+ data["support_qr_url"] = @support_qr_url if @support_qr_url
766
+ data["theme_color"] = @theme_color if @theme_color
767
+ data["homepage_url"] = @homepage_url if @homepage_url
730
768
  data["license_key"] = @license_key if @license_key
731
769
  data["license_activated_at"] = @license_activated_at.iso8601 if @license_activated_at
732
770
  data["license_expires_at"] = @license_expires_at.iso8601 if @license_expires_at
@@ -756,14 +794,19 @@ module Clacky
756
794
  end
757
795
 
758
796
  # Apply distribution fields from API response.
759
- # Updates name, product_name, logo_url, support_contact from the distribution hash.
797
+ # Updates name, product_name, logo_url, support_contact, support_qr_url,
798
+ # theme_color, and homepage_url from the distribution hash.
760
799
  private def apply_distribution(dist)
761
800
  return unless dist.is_a?(Hash)
762
801
 
763
- @distribution_name = dist["name"] if dist["name"].to_s.strip != ""
802
+ @distribution_name = dist["name"] if dist["name"].to_s.strip != ""
764
803
  @product_name = dist["product_name"] if dist["product_name"].to_s.strip != ""
765
804
  @logo_url = dist["logo_url"] if dist["logo_url"].to_s.strip != ""
766
805
  @support_contact = dist["support_contact"] if dist["support_contact"].to_s.strip != ""
806
+ # New branding fields returned by the API (logo, QR code, theme, homepage)
807
+ @support_qr_url = dist["support_qr_url"] if dist.key?("support_qr_url")
808
+ @theme_color = dist["theme_color"] if dist.key?("theme_color")
809
+ @homepage_url = dist["homepage_url"] if dist.key?("homepage_url")
767
810
  end
768
811
 
769
812
  # Download a remote URL to a local file path.
@@ -844,6 +887,19 @@ module Clacky
844
887
  return cached[:key] if key_valid && within_grace
845
888
  end
846
889
 
890
+ # Guard: @device_id must match the value recorded in activated_devices on the
891
+ # server. If it is nil (e.g. loaded from a brand.yml that predates the
892
+ # device_id field), reload from disk as a last-chance recovery — the file
893
+ # may have been written by a concurrent process or a newer gem version.
894
+ # If still nil after reload, raise an actionable error rather than sending
895
+ # an empty device_id that will always be rejected by the server.
896
+ if @device_id.nil? || @device_id.strip.empty?
897
+ reloaded = BrandConfig.load
898
+ @device_id = reloaded.device_id if reloaded.device_id && !reloaded.device_id.strip.empty?
899
+ end
900
+ raise "Device ID is missing. Please re-activate your license with `clacky license activate`." \
901
+ if @device_id.nil? || @device_id.strip.empty?
902
+
847
903
  # Build signed request payload
848
904
  user_id = parse_user_id_from_key(@license_key)
849
905
  key_hash = Digest::SHA256.hexdigest(@license_key)
@@ -902,7 +958,19 @@ module Clacky
902
958
  hex[0..7].to_i(16)
903
959
  end
904
960
 
905
- # Generate a stable device ID based on system identifiers.
961
+ # Generate a one-time stable device ID based on system identifiers.
962
+ #
963
+ # IMPORTANT: This method MUST only be called once — during the very first
964
+ # activation — via `@device_id ||= generate_device_id`. The result is
965
+ # immediately persisted to brand.yml by `save`. All subsequent calls
966
+ # (heartbeat, skill_keys, etc.) must read @device_id from memory (which was
967
+ # populated by `initialize` from the stored brand.yml), never call this
968
+ # method again.
969
+ #
970
+ # The generated ID is deterministic for the same environment, but can change
971
+ # if the hostname, user, or platform changes (e.g. inside a Docker container
972
+ # with a random hostname). That is why we pin it to disk immediately and
973
+ # never regenerate once saved.
906
974
  private def generate_device_id
907
975
  components = [
908
976
  Socket.gethostname,