depot-linux 0.1.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.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +10 -0
  3. data/README.md +56 -0
  4. data/Rakefile +10 -0
  5. data/bin/depot +8 -0
  6. data/bin/depot-gui +14 -0
  7. data/bin/setup-rubyqt6 +72 -0
  8. data/fixtures/assets/download.png +0 -0
  9. data/fixtures/flatpakrefs/org.qbittorrent.qBittorrent.flatpakref +10 -0
  10. data/fixtures/rpms/Modrinth App-0.13.14-1.x86_64.rpm +0 -0
  11. data/lib/depot/app_customizer.rb +152 -0
  12. data/lib/depot/assets.rb +11 -0
  13. data/lib/depot/backends/app_image.rb +210 -0
  14. data/lib/depot/backends/archive.rb +263 -0
  15. data/lib/depot/backends/deb.rb +265 -0
  16. data/lib/depot/backends/flatpak_ref.rb +123 -0
  17. data/lib/depot/backends/rpm.rb +280 -0
  18. data/lib/depot/backends/support.rb +39 -0
  19. data/lib/depot/cli.rb +344 -0
  20. data/lib/depot/desktop_entry.rb +37 -0
  21. data/lib/depot/doctor.rb +59 -0
  22. data/lib/depot/gui/app.rb +23 -0
  23. data/lib/depot/gui/drop_panel.rb +80 -0
  24. data/lib/depot/gui/main_window.rb +1196 -0
  25. data/lib/depot/inspection.rb +52 -0
  26. data/lib/depot/inspector.rb +387 -0
  27. data/lib/depot/installer.rb +54 -0
  28. data/lib/depot/manifest_store.rb +53 -0
  29. data/lib/depot/packages/archive.rb +188 -0
  30. data/lib/depot/packages/deb.rb +262 -0
  31. data/lib/depot/packages/flatpak_ref.rb +90 -0
  32. data/lib/depot/packages/rpm.rb +301 -0
  33. data/lib/depot/paths.rb +57 -0
  34. data/lib/depot/result.rb +13 -0
  35. data/lib/depot/sandbox.rb +285 -0
  36. data/lib/depot/settings.rb +43 -0
  37. data/lib/depot/source_resolver.rb +36 -0
  38. data/lib/depot/uninstaller.rb +90 -0
  39. data/lib/depot/update_downloader.rb +136 -0
  40. data/lib/depot/updater.rb +230 -0
  41. data/lib/depot/util.rb +33 -0
  42. data/lib/depot/version.rb +5 -0
  43. data/lib/depot.rb +21 -0
  44. metadata +139 -0
@@ -0,0 +1,1196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Depot
6
+ module GUI
7
+ class InstalledAppsTable < RubyQt6::Bando::QTableWidget
8
+ def initialize(owner)
9
+ super(0, 4)
10
+ @owner = owner
11
+ end
12
+
13
+ def context_menu_event(event)
14
+ with_event_errors do
15
+ item = item_at(event.pos)
16
+ return unless item
17
+
18
+ set_current_item(item)
19
+
20
+ menu = QMenu.new("", self)
21
+ view = menu.add_action("View info")
22
+ rename = menu.add_action("Change title...")
23
+ icon = menu.add_action("Change icon...")
24
+ reset = menu.add_action("Reset properties")
25
+ sandbox = menu.add_action("Sandbox...")
26
+ menu.add_separator
27
+ reinstall = menu.add_action("Reinstall")
28
+ launch = menu.add_action("Launch")
29
+ uninstall = menu.add_action("Uninstall")
30
+
31
+ case menu.exec(event.global_pos)&.text.to_s
32
+ when view.text.to_s
33
+ @owner.info_selected
34
+ when rename.text.to_s
35
+ @owner.change_title_selected
36
+ when icon.text.to_s
37
+ @owner.change_icon_selected
38
+ when reset.text.to_s
39
+ @owner.reset_selected
40
+ when sandbox.text.to_s
41
+ @owner.sandbox_selected
42
+ when reinstall.text.to_s
43
+ @owner.reinstall_selected
44
+ when launch.text.to_s
45
+ @owner.launch_selected
46
+ when uninstall.text.to_s
47
+ @owner.uninstall_selected
48
+ end
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def with_event_errors
55
+ yield
56
+ rescue StandardError => e
57
+ @owner.send(:unexpected_error, e)
58
+ end
59
+ end
60
+
61
+ class SandboxDialog < RubyQt6::Bando::QDialog
62
+ attr_reader :mode_combo, :profile_combo, :home_combo, :network_check
63
+
64
+ def initialize(parent, manifest, settings)
65
+ super(parent)
66
+ @manifest = Sandbox.normalize(manifest, settings)
67
+ sandbox = @manifest.fetch("sandbox", {})
68
+
69
+ set_window_title("Sandbox")
70
+ set_modal(true)
71
+ resize(430, 260)
72
+
73
+ layout = QVBoxLayout.new
74
+ title = QLabel.new("Sandbox #{manifest.fetch("display_name")}")
75
+ title.set_style_sheet("font-size: 18px; font-weight: 700;")
76
+ layout.add_widget(title)
77
+
78
+ note = QLabel.new(description_text)
79
+ note.set_word_wrap(true)
80
+ note.set_object_name("depotInstallSubtitle")
81
+ layout.add_widget(note)
82
+
83
+ form = QFormLayout.new
84
+ @mode_combo = QComboBox.new
85
+ %w[inherit enabled disabled].each { |value| @mode_combo.add_item(value) }
86
+ @profile_combo = QComboBox.new
87
+ %w[relaxed balanced strict].each { |value| @profile_combo.add_item(value) }
88
+ @home_combo = QComboBox.new
89
+ %w[isolated documents full].each { |value| @home_combo.add_item(value) }
90
+ @network_check = QCheckBox.new("Allow network access")
91
+
92
+ set_combo(@mode_combo, sandbox.fetch("mode", "inherit"))
93
+ set_combo(@profile_combo, sandbox.fetch("profile", "balanced"))
94
+ set_combo(@home_combo, sandbox.fetch("home_access", "documents"))
95
+ @network_check.set_checked(sandbox.fetch("network", true))
96
+
97
+ form.add_row(QString.new("Mode"), @mode_combo)
98
+ form.add_row(QString.new("Profile"), @profile_combo)
99
+ form.add_row(QString.new("Home access"), @home_combo)
100
+ form.add_row(QString.new("Network"), @network_check)
101
+ layout.add_layout(form)
102
+
103
+ buttons = QHBoxLayout.new
104
+ save = QPushButton.new("Save")
105
+ cancel = QPushButton.new("Cancel")
106
+ save.clicked.connect(self, :accept)
107
+ cancel.clicked.connect(self, :reject)
108
+ buttons.add_stretch
109
+ buttons.add_widget(save)
110
+ buttons.add_widget(cancel)
111
+ layout.add_layout(buttons)
112
+ set_layout(layout)
113
+ end
114
+
115
+ def values
116
+ {
117
+ "mode" => @mode_combo.current_text.to_s,
118
+ "profile" => @profile_combo.current_text.to_s,
119
+ "home_access" => @home_combo.current_text.to_s,
120
+ "network" => @network_check.checked?
121
+ }
122
+ end
123
+
124
+ private
125
+
126
+ def description_text
127
+ return "Flatpak already manages this app's sandbox. Depot shows that state here, but Bubblewrap settings do not apply." if @manifest["backend"] == "flatpak"
128
+
129
+ "Bubblewrap runs this app through a generated Depot launcher. If Bubblewrap is missing, Depot falls back to the normal launcher so the app still opens."
130
+ end
131
+
132
+ def set_combo(combo, value)
133
+ index = combo.find_text(value)
134
+ combo.set_current_index(index) if index && index >= 0
135
+ end
136
+ end
137
+
138
+ class MainWindow < RubyQt6::Bando::QMainWindow
139
+ q_object do
140
+ slot "browse_file()"
141
+ slot "inspect_current()"
142
+ slot "install_current()"
143
+ slot "refresh_updates()"
144
+ slot "set_update_source_selected()"
145
+ slot "update_selected()"
146
+ slot "update_all()"
147
+ slot "refresh_installed()"
148
+ slot "launch_selected()"
149
+ slot "info_selected()"
150
+ slot "change_title_selected()"
151
+ slot "change_icon_selected()"
152
+ slot "reset_selected()"
153
+ slot "sandbox_selected()"
154
+ slot "reinstall_selected()"
155
+ slot "uninstall_selected()"
156
+ slot "save_settings()"
157
+ slot "change_page(int)"
158
+ end
159
+
160
+ def initialize
161
+ super()
162
+ @store = ManifestStore.new
163
+ @customizer = AppCustomizer.new(store: @store)
164
+ @settings = Settings.new
165
+ @current_path = nil
166
+ @current_inspection = nil
167
+
168
+ set_window_title("Depot")
169
+ resize(980, 660)
170
+ set_window_icon(QIcon.new(Assets.logo_path)) if File.exist?(Assets.logo_path)
171
+
172
+ build_ui
173
+ load_settings_controls
174
+ apply_theme(@settings.load.fetch("theme", "system"))
175
+ refresh_installed
176
+ end
177
+
178
+ def browse_file
179
+ file = QFileDialog.get_open_file_name(
180
+ self,
181
+ "Choose Software",
182
+ Dir.home,
183
+ "Software packages (*.AppImage *.appimage *.deb *.rpm *.flatpakref *.tar.gz *.tgz *.tar.xz *.txz *.tar.zst *.tzst *.zip);;All files (*)"
184
+ )
185
+ load_input(file) if file && !file.empty?
186
+ end
187
+
188
+ def inspect_current
189
+ return warn_dialog("Choose software first.") unless @current_path
190
+
191
+ result = Inspector.inspect(@current_path, checksum: false)
192
+ unless result.ok?
193
+ @current_inspection = nil
194
+ @detected_label.set_text("Could not inspect this file")
195
+ @detected_label.set_visible(true)
196
+ @summary.set_plain_text(result.error)
197
+ @install_button.set_enabled(false)
198
+ return
199
+ end
200
+
201
+ @current_inspection = result.value
202
+ @detected_label.set_text(detected_summary(@current_inspection))
203
+ @detected_label.set_visible(true)
204
+ @summary.set_plain_text(summary_for(@current_inspection))
205
+ @install_button.set_enabled(installable_inspection?(@current_inspection))
206
+ end
207
+
208
+ def install_current
209
+ return warn_dialog("Choose software first.") unless @current_path
210
+
211
+ inspect_current unless @current_inspection
212
+ return warn_dialog("No installer backend is available for this format yet.") unless installable_inspection?(@current_inspection)
213
+
214
+ answer = QMessageBox.question(
215
+ self,
216
+ "Install Software",
217
+ install_prompt(@current_inspection),
218
+ QMessageBox::Yes | QMessageBox::No
219
+ )
220
+ return unless answer == QMessageBox::Yes
221
+
222
+ result = with_install_progress("Installing #{install_progress_name(@current_inspection)}...\n\nThis may take a while.") do
223
+ Installer.install(@current_path, settings: @settings.load)
224
+ end
225
+ if result.ok?
226
+ manifest = result.value
227
+ QMessageBox.information(self, "Installed", "Installed #{manifest.fetch("display_name")} as #{manifest.fetch("app_id")}.")
228
+ @current_path = nil
229
+ @current_inspection = nil
230
+ @path_edit.set_text("")
231
+ @detected_label.set_text("")
232
+ @detected_label.set_visible(false)
233
+ @summary.set_plain_text("")
234
+ @install_button.set_enabled(false)
235
+ refresh_installed
236
+ @sidebar.set_current_row(1)
237
+ else
238
+ warn_dialog(result.error)
239
+ end
240
+ end
241
+
242
+ def refresh_installed
243
+ @apps_table.set_row_count(0)
244
+ @store.all.each_with_index do |manifest, row|
245
+ @apps_table.insert_row(row)
246
+ @apps_table.set_item(row, 0, QTableWidgetItem.new(manifest.fetch("app_id")))
247
+ @apps_table.set_item(row, 1, QTableWidgetItem.new(manifest.fetch("display_name")))
248
+ @apps_table.set_item(row, 2, QTableWidgetItem.new(manifest.fetch("backend")))
249
+ @apps_table.set_item(row, 3, QTableWidgetItem.new(manifest.fetch("installed_at", "")))
250
+ end
251
+ @apps_table.resize_columns_to_contents
252
+ end
253
+
254
+ def launch_selected
255
+ manifest = selected_manifest
256
+ return warn_dialog("Select an installed app first.") unless manifest
257
+
258
+ executable = Sandbox.launch_path(manifest, @settings.load)
259
+ Process.spawn(executable, pgroup: true)
260
+ rescue SystemCallError => e
261
+ warn_dialog("Could not launch app: #{e.message}")
262
+ end
263
+
264
+ def info_selected
265
+ manifest = selected_manifest
266
+ return warn_dialog("Select an installed app first.") unless manifest
267
+
268
+ custom = manifest.fetch("customizations", {})
269
+ custom_icon = manifest["custom_icon"]
270
+ QMessageBox.information(
271
+ self,
272
+ manifest.fetch("display_name"),
273
+ [
274
+ "App ID: #{manifest.fetch("app_id")}",
275
+ "Name: #{manifest.fetch("display_name")}",
276
+ "Default name: #{manifest["default_display_name"] || manifest.fetch("display_name")}",
277
+ "Backend: #{manifest.fetch("backend")}",
278
+ "Executable: #{manifest.fetch("installed_executable")}",
279
+ "Desktop entry: #{manifest["desktop_entry"] || "none"}",
280
+ "Icon: #{active_icon_summary(manifest)}",
281
+ "Sandbox: #{Sandbox.summary(manifest, @settings.load)}",
282
+ "Custom title: #{custom["display_name"] ? "yes" : "no"}",
283
+ "Custom icon: #{custom_icon ? custom_icon["path"] : "no"}",
284
+ "Source: #{manifest.fetch("install_source")}",
285
+ "Update source: #{manifest.dig("update", "source") || "none"}",
286
+ "Installed: #{manifest.fetch("installed_at", "unknown")}"
287
+ ].join("\n")
288
+ )
289
+ end
290
+
291
+ def change_title_selected
292
+ manifest = selected_manifest
293
+ return warn_dialog("Select an installed app first.") unless manifest
294
+
295
+ ok = QBool.new
296
+ title = QInputDialog.get_text(
297
+ self,
298
+ QString.new("Change Title"),
299
+ QString.new("Application title:"),
300
+ QLineEdit::Normal,
301
+ QString.new(manifest.fetch("display_name")),
302
+ ok
303
+ )
304
+ return unless ok.ok? && title
305
+
306
+ result = @customizer.rename(manifest.fetch("app_id"), title.to_s)
307
+ if result.ok?
308
+ refresh_installed
309
+ status_bar.show_message(QString.new("Updated #{result.value.fetch("display_name")}"))
310
+ else
311
+ warn_dialog(result.error)
312
+ end
313
+ rescue StandardError => e
314
+ unexpected_error(e)
315
+ end
316
+
317
+ def change_icon_selected
318
+ manifest = selected_manifest
319
+ return warn_dialog("Select an installed app first.") unless manifest
320
+
321
+ file = QFileDialog.get_open_file_name(
322
+ self,
323
+ "Choose Icon",
324
+ Dir.home,
325
+ "Icons (*.png *.svg *.xpm);;All files (*)"
326
+ )
327
+ return unless file && !file.empty?
328
+
329
+ result = @customizer.change_icon(manifest.fetch("app_id"), file)
330
+ if result.ok?
331
+ refresh_installed
332
+ status_bar.show_message(QString.new("Updated icon for #{result.value.fetch("display_name")}"))
333
+ else
334
+ warn_dialog(result.error)
335
+ end
336
+ end
337
+
338
+ def sandbox_selected
339
+ manifest = selected_manifest
340
+ return warn_dialog("Select an installed app first.") unless manifest
341
+
342
+ if manifest["backend"] == "flatpak"
343
+ QMessageBox.information(self, "Sandbox", "Flatpak manages sandboxing for #{manifest.fetch("display_name")}.\n\nDepot will show Flatpak permissions in a later permission editor.")
344
+ return
345
+ end
346
+ return warn_dialog("Depot sandboxing is available for AppImage, portable Debian, portable RPM, and portable archive apps.") unless Sandbox.portable?(manifest)
347
+
348
+ dialog = SandboxDialog.new(self, manifest, @settings.load)
349
+ return unless dialog.exec == QDialog::Accepted
350
+
351
+ result = Sandbox.set(manifest.fetch("app_id"), dialog.values, store: @store, settings: @settings.load)
352
+ if result.ok?
353
+ refresh_installed
354
+ status_bar.show_message(QString.new("Updated sandbox for #{manifest.fetch("display_name")}"))
355
+ else
356
+ warn_dialog(result.error)
357
+ end
358
+ rescue StandardError => e
359
+ unexpected_error(e)
360
+ end
361
+
362
+ def reset_selected
363
+ manifest = selected_manifest
364
+ return warn_dialog("Select an installed app first.") unless manifest
365
+
366
+ answer = QMessageBox.question(
367
+ self,
368
+ "Reset Properties",
369
+ "Reset #{manifest.fetch("display_name")} to its original title and icon?",
370
+ QMessageBox::Yes | QMessageBox::No
371
+ )
372
+ return unless answer == QMessageBox::Yes
373
+
374
+ result = @customizer.reset(manifest.fetch("app_id"))
375
+ if result.ok?
376
+ refresh_installed
377
+ status_bar.show_message(QString.new("Reset properties for #{result.value.fetch("display_name")}"))
378
+ else
379
+ warn_dialog(result.error)
380
+ end
381
+ end
382
+
383
+ def uninstall_selected
384
+ manifest = selected_manifest
385
+ return warn_dialog("Select an installed app first.") unless manifest
386
+
387
+ answer = QMessageBox.question(
388
+ self,
389
+ "Uninstall",
390
+ "Remove #{manifest.fetch("display_name")} and its Depot-created desktop integration?",
391
+ QMessageBox::Yes | QMessageBox::No
392
+ )
393
+ return unless answer == QMessageBox::Yes
394
+
395
+ result = Uninstaller.uninstall(manifest.fetch("app_id"))
396
+ if result.ok?
397
+ QMessageBox.information(self, "Uninstalled", "Removed #{manifest.fetch("display_name")}.")
398
+ refresh_installed
399
+ else
400
+ warn_dialog(result.error)
401
+ end
402
+ end
403
+
404
+ def reinstall_selected
405
+ manifest = selected_manifest
406
+ return warn_dialog("Select an installed app first.") unless manifest
407
+
408
+ source = manifest["install_source"]
409
+ return warn_dialog("Depot does not know the original installer path for this app.") if source.to_s.empty?
410
+ source = resolved_install_source(source)
411
+ return warn_dialog("Original installer is missing: #{manifest["install_source"]}") unless source
412
+
413
+ answer = QMessageBox.question(
414
+ self,
415
+ "Reinstall",
416
+ "Reinstall #{manifest.fetch("display_name")} from its original installer?\n\nDepot will remove and recreate its managed files, desktop entry, icons, and manifest.",
417
+ QMessageBox::Yes | QMessageBox::No
418
+ )
419
+ return unless answer == QMessageBox::Yes
420
+
421
+ uninstall = nil
422
+ install = nil
423
+ with_install_progress("Reinstalling #{manifest.fetch("display_name")}...\n\nThis may take a while.") do
424
+ uninstall = Uninstaller.uninstall(manifest.fetch("app_id"))
425
+ install = Installer.install(source, settings: @settings.load) if uninstall.ok?
426
+ end
427
+ return warn_dialog(uninstall.error) unless uninstall.ok?
428
+
429
+ if install.ok?
430
+ refreshed = install.value
431
+ QMessageBox.information(self, "Reinstalled", "Reinstalled #{refreshed.fetch("display_name")} as #{refreshed.fetch("app_id")}.")
432
+ refresh_installed
433
+ else
434
+ warn_dialog("Depot removed the old install, but reinstall failed: #{install.error}")
435
+ refresh_installed
436
+ end
437
+ rescue StandardError => e
438
+ unexpected_error(e)
439
+ end
440
+
441
+ def refresh_updates
442
+ return unless @updates_table
443
+
444
+ enabled = @settings.load.fetch("updates_enabled", true)
445
+ @updates_status.set_text(enabled ? "Updates are enabled." : "Updates are disabled in Settings.")
446
+ @updates_table.set_row_count(0)
447
+ Updater.new(store: @store, settings: @settings).records.each_with_index do |record, row|
448
+ @updates_table.insert_row(row)
449
+ @updates_table.set_item(row, 0, QTableWidgetItem.new(record.fetch("app_id")))
450
+ @updates_table.set_item(row, 1, QTableWidgetItem.new(record.fetch("display_name")))
451
+ @updates_table.set_item(row, 2, QTableWidgetItem.new(record.fetch("method")))
452
+ @updates_table.set_item(row, 3, QTableWidgetItem.new(record.fetch("status")))
453
+ @updates_table.set_item(row, 4, QTableWidgetItem.new(record.fetch("last_updated_at").to_s))
454
+ end
455
+ @updates_table.resize_columns_to_contents
456
+ end
457
+
458
+ def set_update_source_selected
459
+ app_id = selected_update_app_id
460
+ return warn_dialog("Select an app first.") unless app_id
461
+
462
+ manifest = @store.find(app_id)
463
+ current = manifest&.dig("update", "source").to_s
464
+ ok = QBool.new
465
+ url = QInputDialog.get_text(
466
+ self,
467
+ QString.new("Update Source"),
468
+ QString.new("HTTPS update URL:"),
469
+ QLineEdit::Normal,
470
+ QString.new(current.start_with?("https://") ? current : ""),
471
+ ok
472
+ )
473
+ return unless ok.ok? && url
474
+
475
+ result = Updater.new(store: @store, settings: @settings).set_source(app_id, url.to_s.strip)
476
+ if result.ok?
477
+ refresh_updates
478
+ status_bar.show_message(QString.new("Updated source for #{app_id}"))
479
+ else
480
+ warn_dialog(result.error)
481
+ end
482
+ rescue StandardError => e
483
+ unexpected_error(e)
484
+ end
485
+
486
+ def update_selected
487
+ return warn_dialog("Updates are disabled in Settings.") unless @settings.load.fetch("updates_enabled", true)
488
+
489
+ app_id = selected_update_app_id
490
+ return warn_dialog("Select an app to update first.") unless app_id
491
+
492
+ result = with_install_progress("Updating #{app_id}...\n\nThis may take a while.") do
493
+ Updater.new(store: @store, settings: @settings).update(app_id)
494
+ end
495
+ if result.ok?
496
+ QMessageBox.information(self, "Updated", "Updated #{app_id}.")
497
+ refresh_updates
498
+ refresh_installed
499
+ else
500
+ warn_dialog(result.error)
501
+ end
502
+ end
503
+
504
+ def update_all
505
+ return warn_dialog("Updates are disabled in Settings.") unless @settings.load.fetch("updates_enabled", true)
506
+
507
+ answer = QMessageBox.question(
508
+ self,
509
+ "Update All",
510
+ "Update every app that Depot knows how to update?",
511
+ QMessageBox::Yes | QMessageBox::No
512
+ )
513
+ return unless answer == QMessageBox::Yes
514
+
515
+ result = with_install_progress("Updating apps...\n\nThis may take a while.") do
516
+ Updater.new(store: @store, settings: @settings).update_all
517
+ end
518
+ if result.ok?
519
+ QMessageBox.information(self, "Updates", "Finished updating apps.")
520
+ else
521
+ warn_dialog(([result.error] + result.warnings).join("\n"))
522
+ end
523
+ refresh_updates
524
+ refresh_installed
525
+ end
526
+
527
+ def save_settings
528
+ values = {
529
+ "warning_verbosity" => @warning_combo.current_text,
530
+ "theme" => @theme_combo.current_text,
531
+ "default_install_location" => "user",
532
+ "sandbox_preference" => @sandbox_combo.current_text,
533
+ "sandbox_profile" => @sandbox_profile_combo.current_text,
534
+ "sandbox_home_access" => @sandbox_home_combo.current_text,
535
+ "sandbox_network" => @sandbox_network_check.checked?,
536
+ "desktop_integration" => @desktop_check.checked?,
537
+ "updates_enabled" => @updates_check.checked?
538
+ }
539
+ @settings.save(values)
540
+ apply_theme(values.fetch("theme"))
541
+ QMessageBox.information(self, "Settings", "Depot settings were saved.")
542
+ end
543
+
544
+ def change_page(index)
545
+ @pages.set_current_index(index)
546
+ refresh_installed if index == 1
547
+ refresh_updates if index == 2
548
+ end
549
+
550
+ def load_input(path)
551
+ @current_path = path
552
+ @path_edit.set_text(path)
553
+ inspect_current
554
+ end
555
+
556
+ private
557
+
558
+ def build_ui
559
+ central = QWidget.new
560
+ root = QHBoxLayout.new
561
+
562
+ @sidebar = QListWidget.new
563
+ @sidebar.set_fixed_width(170)
564
+ @sidebar.set_object_name("depotSidebar")
565
+ %w[Install Installed Updates Settings About].each { |name| @sidebar.add_item(name) }
566
+ @sidebar.current_row_changed.connect(self, :change_page)
567
+
568
+ @pages = QStackedWidget.new
569
+ @pages.add_widget(build_install_page)
570
+ @pages.add_widget(build_installed_page)
571
+ @pages.add_widget(build_updates_page)
572
+ @pages.add_widget(build_settings_page)
573
+ @pages.add_widget(build_about_page)
574
+
575
+ root.add_widget(@sidebar)
576
+ root.add_widget(@pages, 1)
577
+ central.set_layout(root)
578
+ set_central_widget(central)
579
+ status_bar.show_message(QString.new("Ready"))
580
+ @sidebar.set_current_row(0)
581
+ end
582
+
583
+ def build_install_page
584
+ page = QWidget.new
585
+ layout = QVBoxLayout.new
586
+
587
+ logo_path = Assets.logo_path
588
+ if File.exist?(logo_path)
589
+ logo = QLabel.new
590
+ pixmap = QPixmap.new(logo_path)
591
+ logo.set_pixmap(pixmap.scaled(96, 96, Qt::KeepAspectRatio, Qt::SmoothTransformation))
592
+ logo.set_alignment(Qt::AlignCenter)
593
+ layout.add_widget(logo)
594
+ end
595
+
596
+ title = QLabel.new("Install software")
597
+ title.set_style_sheet("font-size: 26px; font-weight: 800;")
598
+ title.set_alignment(Qt::AlignCenter)
599
+ layout.add_widget(title)
600
+
601
+ subtitle = QLabel.new("Select any Linux programs you'd like to install.")
602
+ subtitle.set_object_name("depotInstallSubtitle")
603
+ subtitle.set_alignment(Qt::AlignCenter)
604
+ layout.add_widget(subtitle)
605
+
606
+ drop = DropPanel.new { |path| load_input(path) }
607
+ layout.add_widget(drop)
608
+
609
+ row = QHBoxLayout.new
610
+ @path_edit = QLineEdit.new
611
+ @path_edit.set_placeholder_text("Path to package or installer")
612
+ browse = QPushButton.new("Choose File")
613
+ browse.clicked.connect(self, :browse_file)
614
+ inspect = QPushButton.new("Inspect")
615
+ inspect.clicked.connect(self, :inspect_current)
616
+ row.add_widget(@path_edit, 1)
617
+ row.add_widget(browse)
618
+ row.add_widget(inspect)
619
+ layout.add_layout(row)
620
+
621
+ @detected_label = QLabel.new("")
622
+ @detected_label.set_object_name("depotDetectedLabel")
623
+ @detected_label.set_word_wrap(true)
624
+ @detected_label.set_visible(false)
625
+ layout.add_widget(@detected_label)
626
+
627
+ @summary = QTextEdit.new
628
+ @summary.set_read_only(true)
629
+ @summary.set_minimum_height(220)
630
+ layout.add_widget(@summary, 1)
631
+
632
+ @install_button = QPushButton.new("Install Software")
633
+ @install_button.set_enabled(false)
634
+ @install_button.clicked.connect(self, :install_current)
635
+ layout.add_widget(@install_button)
636
+
637
+ page.set_layout(layout)
638
+ page
639
+ end
640
+
641
+ def build_installed_page
642
+ page = QWidget.new
643
+ layout = QVBoxLayout.new
644
+
645
+ title = QLabel.new("Installed Apps")
646
+ title.set_style_sheet("font-size: 24px; font-weight: 700;")
647
+ layout.add_widget(title)
648
+
649
+ @apps_table = InstalledAppsTable.new(self)
650
+ @apps_table.set_horizontal_header_labels(QStringList.new << "ID" << "Name" << "Backend" << "Installed")
651
+ @apps_table.set_selection_behavior(QAbstractItemView::SelectRows)
652
+ @apps_table.set_selection_mode(QAbstractItemView::SingleSelection)
653
+ layout.add_widget(@apps_table, 1)
654
+
655
+ buttons = QHBoxLayout.new
656
+ refresh = QPushButton.new("Refresh")
657
+ refresh.clicked.connect(self, :refresh_installed)
658
+ launch = QPushButton.new("Launch")
659
+ launch.clicked.connect(self, :launch_selected)
660
+ info = QPushButton.new("Info")
661
+ info.clicked.connect(self, :info_selected)
662
+ rename = QPushButton.new("Rename")
663
+ rename.clicked.connect(self, :change_title_selected)
664
+ icon = QPushButton.new("Icon")
665
+ icon.clicked.connect(self, :change_icon_selected)
666
+ sandbox = QPushButton.new("Sandbox")
667
+ sandbox.clicked.connect(self, :sandbox_selected)
668
+ reset = QPushButton.new("Reset Properties")
669
+ reset.clicked.connect(self, :reset_selected)
670
+ reinstall = QPushButton.new("Reinstall")
671
+ reinstall.clicked.connect(self, :reinstall_selected)
672
+ uninstall = QPushButton.new("Uninstall")
673
+ uninstall.clicked.connect(self, :uninstall_selected)
674
+ buttons.add_widget(refresh)
675
+ buttons.add_stretch
676
+ buttons.add_widget(launch)
677
+ buttons.add_widget(info)
678
+ buttons.add_widget(rename)
679
+ buttons.add_widget(icon)
680
+ buttons.add_widget(sandbox)
681
+ buttons.add_widget(reset)
682
+ buttons.add_widget(reinstall)
683
+ buttons.add_widget(uninstall)
684
+ layout.add_layout(buttons)
685
+
686
+ page.set_layout(layout)
687
+ page
688
+ end
689
+
690
+ def build_updates_page
691
+ page = QWidget.new
692
+ layout = QVBoxLayout.new
693
+
694
+ title = QLabel.new("Updates")
695
+ title.set_style_sheet("font-size: 24px; font-weight: 700;")
696
+ layout.add_widget(title)
697
+
698
+ @updates_status = QLabel.new("Updates are enabled.")
699
+ @updates_status.set_object_name("depotInstallSubtitle")
700
+ layout.add_widget(@updates_status)
701
+
702
+ @updates_table = QTableWidget.new(0, 5)
703
+ @updates_table.set_horizontal_header_labels(QStringList.new << "ID" << "Name" << "Method" << "Status" << "Last Updated")
704
+ @updates_table.set_selection_behavior(QAbstractItemView::SelectRows)
705
+ @updates_table.set_selection_mode(QAbstractItemView::SingleSelection)
706
+ layout.add_widget(@updates_table, 1)
707
+
708
+ buttons = QHBoxLayout.new
709
+ refresh = QPushButton.new("Refresh")
710
+ refresh.clicked.connect(self, :refresh_updates)
711
+ set_url = QPushButton.new("Set URL")
712
+ set_url.clicked.connect(self, :set_update_source_selected)
713
+ selected = QPushButton.new("Update Selected")
714
+ selected.clicked.connect(self, :update_selected)
715
+ all = QPushButton.new("Update All")
716
+ all.clicked.connect(self, :update_all)
717
+ buttons.add_widget(refresh)
718
+ buttons.add_stretch
719
+ buttons.add_widget(set_url)
720
+ buttons.add_widget(selected)
721
+ buttons.add_widget(all)
722
+ layout.add_layout(buttons)
723
+
724
+ page.set_layout(layout)
725
+ page
726
+ end
727
+
728
+ def build_settings_page
729
+ page = QWidget.new
730
+ layout = QVBoxLayout.new
731
+
732
+ title = QLabel.new("Settings")
733
+ title.set_style_sheet("font-size: 24px; font-weight: 700;")
734
+ layout.add_widget(title)
735
+
736
+ form = QFormLayout.new
737
+ @warning_combo = QComboBox.new
738
+ %w[quiet normal detailed].each { |value| @warning_combo.add_item(value) }
739
+ @theme_combo = QComboBox.new
740
+ %w[system light dark].each { |value| @theme_combo.add_item(value) }
741
+ @sandbox_combo = QComboBox.new
742
+ %w[ask prefer-off prefer-on].each { |value| @sandbox_combo.add_item(value) }
743
+ @sandbox_profile_combo = QComboBox.new
744
+ %w[relaxed balanced strict].each { |value| @sandbox_profile_combo.add_item(value) }
745
+ @sandbox_home_combo = QComboBox.new
746
+ %w[isolated documents full].each { |value| @sandbox_home_combo.add_item(value) }
747
+ @sandbox_network_check = QCheckBox.new("Allow network access in new app sandboxes")
748
+ @desktop_check = QCheckBox.new("Create desktop launchers and icons")
749
+ @updates_check = QCheckBox.new("Enable updates")
750
+
751
+ form.add_row(QString.new("Warnings"), @warning_combo)
752
+ form.add_row(QString.new("Theme"), @theme_combo)
753
+ form.add_row(QString.new("Sandbox default"), @sandbox_combo)
754
+ form.add_row(QString.new("Sandbox profile"), @sandbox_profile_combo)
755
+ form.add_row(QString.new("Sandbox home"), @sandbox_home_combo)
756
+ form.add_row(QString.new("Sandbox network"), @sandbox_network_check)
757
+ form.add_row(QString.new("Desktop integration"), @desktop_check)
758
+ form.add_row(QString.new("Updates"), @updates_check)
759
+ layout.add_layout(form)
760
+
761
+ save = QPushButton.new("Save Settings")
762
+ save.clicked.connect(self, :save_settings)
763
+ layout.add_widget(save)
764
+ layout.add_stretch
765
+
766
+ page.set_layout(layout)
767
+ page
768
+ end
769
+
770
+ def build_about_page
771
+ page = QWidget.new
772
+ layout = QVBoxLayout.new
773
+ logo_path = Assets.logo_path
774
+ if File.exist?(logo_path)
775
+ logo = QLabel.new
776
+ pixmap = QPixmap.new(logo_path)
777
+ logo.set_pixmap(pixmap.scaled(164, 164, Qt::KeepAspectRatio, Qt::SmoothTransformation))
778
+ logo.set_alignment(Qt::AlignCenter)
779
+ layout.add_widget(logo)
780
+ end
781
+
782
+ text = QLabel.new("Depot\nUniversal Linux application installer and desktop integration layer.\n\nThis build installs software through Depot manifests, desktop launchers, icons, settings, and clean uninstall tracking. Additional backends plug into the same flow.")
783
+ text.set_alignment(Qt::AlignCenter)
784
+ text.set_word_wrap(true)
785
+ layout.add_widget(text)
786
+ layout.add_stretch
787
+ page.set_layout(layout)
788
+ page
789
+ end
790
+
791
+ def load_settings_controls
792
+ values = @settings.load
793
+ set_combo(@warning_combo, values.fetch("warning_verbosity", "normal"))
794
+ set_combo(@theme_combo, values.fetch("theme", "system"))
795
+ set_combo(@sandbox_combo, values.fetch("sandbox_preference", "ask"))
796
+ set_combo(@sandbox_profile_combo, values.fetch("sandbox_profile", "balanced"))
797
+ set_combo(@sandbox_home_combo, values.fetch("sandbox_home_access", "documents"))
798
+ @sandbox_network_check.set_checked(values.fetch("sandbox_network", true))
799
+ @desktop_check.set_checked(values.fetch("desktop_integration", true))
800
+ @updates_check.set_checked(values.fetch("updates_enabled", true))
801
+ end
802
+
803
+ def set_combo(combo, value)
804
+ index = combo.find_text(value)
805
+ combo.set_current_index(index) if index && index >= 0
806
+ end
807
+
808
+ def apply_theme(theme)
809
+ set_style_sheet(base_stylesheet(theme))
810
+ end
811
+
812
+ def base_stylesheet(theme)
813
+ palette = case theme
814
+ when "dark"
815
+ {
816
+ bg: "#17191d", panel: "#20242a", field: "#242932", text: "#f2f4f8",
817
+ muted: "#aeb6c2", border: "#3b424d", accent: "#2f6fed", accent_hover: "#3f7cff"
818
+ }
819
+ when "light"
820
+ {
821
+ bg: "#f5f6f8", panel: "#ffffff", field: "#ffffff", text: "#17191d",
822
+ muted: "#5f6875", border: "#cfd5dd", accent: "#245fd6", accent_hover: "#1f55bf"
823
+ }
824
+ else
825
+ {
826
+ bg: "#f5f6f8", panel: "#ffffff", field: "#ffffff", text: "#17191d",
827
+ muted: "#5f6875", border: "#cfd5dd", accent: "#245fd6", accent_hover: "#1f55bf"
828
+ }
829
+ end
830
+
831
+ <<~CSS
832
+ QMainWindow, QWidget {
833
+ background: #{palette.fetch(:bg)};
834
+ color: #{palette.fetch(:text)};
835
+ font-size: 14px;
836
+ }
837
+ QLabel#depotDropTitle {
838
+ font-size: 22px;
839
+ font-weight: 700;
840
+ }
841
+ QLabel#depotDropHint {
842
+ color: #{palette.fetch(:muted)};
843
+ }
844
+ QLabel#depotInstallSubtitle {
845
+ color: #{palette.fetch(:muted)};
846
+ font-size: 14px;
847
+ }
848
+ QLabel#depotDetectedLabel {
849
+ background: #{palette.fetch(:panel)};
850
+ color: #{palette.fetch(:text)};
851
+ border: 1px solid #{palette.fetch(:border)};
852
+ border-radius: 6px;
853
+ padding: 10px 12px;
854
+ font-weight: 700;
855
+ }
856
+ QFrame#depotDropPanel {
857
+ background: #{palette.fetch(:panel)};
858
+ border: 2px dashed #{palette.fetch(:border)};
859
+ border-radius: 8px;
860
+ }
861
+ QFrame#depotDropPanel[dragActive="true"] {
862
+ border-color: #{palette.fetch(:accent)};
863
+ background: #{palette.fetch(:field)};
864
+ }
865
+ QListWidget#depotSidebar, QTextEdit, QLineEdit, QTableWidget, QComboBox {
866
+ background: #{palette.fetch(:field)};
867
+ color: #{palette.fetch(:text)};
868
+ border: 1px solid #{palette.fetch(:border)};
869
+ border-radius: 6px;
870
+ }
871
+ QListWidget#depotSidebar::item {
872
+ min-height: 38px;
873
+ padding-left: 12px;
874
+ border-radius: 4px;
875
+ }
876
+ QListWidget#depotSidebar::item:selected {
877
+ background: #{palette.fetch(:accent)};
878
+ color: white;
879
+ }
880
+ QPushButton {
881
+ background: #{palette.fetch(:accent)};
882
+ color: white;
883
+ padding: 8px 14px;
884
+ border: 0;
885
+ border-radius: 6px;
886
+ }
887
+ QPushButton:hover {
888
+ background: #{palette.fetch(:accent_hover)};
889
+ }
890
+ QPushButton:disabled {
891
+ background: #{palette.fetch(:border)};
892
+ color: #{palette.fetch(:muted)};
893
+ }
894
+ CSS
895
+ end
896
+
897
+ def summary_for(inspection)
898
+ lines = [
899
+ "Package",
900
+ " Name: #{inspection.display_name}",
901
+ " Format: #{inspection.format} (#{inspection.confidence})",
902
+ " Size: #{format_bytes(inspection.size)}",
903
+ " SHA-256: #{inspection.sha256 || "calculated during install"}",
904
+ " Executable file: #{inspection.executable ? "yes" : "no"}"
905
+ ]
906
+ if inspection.deb?
907
+ dependencies = dependency_names(inspection.metadata["depends"])
908
+ scripts = inspection.metadata.fetch("maintainer_scripts", [])
909
+ desktops = inspection.metadata.fetch("desktop_entries", [])
910
+ lines << ""
911
+ lines << "Debian package:"
912
+ lines << " Package: #{inspection.metadata["package"] || "unknown"}"
913
+ lines << " Version: #{inspection.metadata["version"] || "unknown"}"
914
+ lines << " Architecture: #{inspection.metadata["architecture"] || "unknown"}"
915
+ lines << " Dependencies: #{dependencies.empty? ? "none declared" : dependency_summary(dependencies)}"
916
+ lines << " Maintainer scripts: #{scripts.empty? ? "none" : scripts.join(", ")}"
917
+ lines << " Desktop entries: #{desktops.empty? ? "none" : "#{desktops.length} found; using #{inspection.metadata["primary_desktop_entry"]}"}"
918
+ lines << " Portable install: extracts into Depot; does not run apt, dpkg, sudo, or maintainer scripts"
919
+ elsif inspection.archive?
920
+ scripts = inspection.metadata.fetch("script_entries", [])
921
+ markers = inspection.metadata.fetch("source_markers", [])
922
+ executables = inspection.metadata.fetch("executable_candidates", [])
923
+ desktops = inspection.metadata.fetch("desktop_entries", [])
924
+ lines << ""
925
+ lines << "Portable archive:"
926
+ lines << " Archive root: #{inspection.metadata["archive_root"] || "mixed"}"
927
+ lines << " Executable candidates: #{executables.empty? ? "none" : executables.first(6).join(", ")}"
928
+ lines << " Desktop entries: #{desktops.empty? ? "none; Depot will generate one if possible" : "#{desktops.length} found; using #{inspection.metadata["primary_desktop_entry"]}"}"
929
+ lines << " Icons/images: #{inspection.metadata.fetch("icon_count", 0)} found"
930
+ lines << " Installer-like scripts: #{scripts.empty? ? "none" : scripts.first(6).join(", ")}"
931
+ lines << " Source/build markers: #{markers.empty? ? "none" : markers.join(", ")}"
932
+ lines << " Portable install: extracts into Depot; does not run scripts"
933
+ elsif inspection.rpm?
934
+ requirements = rpm_requirement_names(inspection.metadata["requires"])
935
+ scriptlets = inspection.metadata.fetch("scriptlets", [])
936
+ desktops = inspection.metadata.fetch("desktop_entries", [])
937
+ lines << ""
938
+ lines << "RPM package:"
939
+ lines << " Package: #{inspection.metadata["package"] || "unknown"}"
940
+ lines << " Version: #{rpm_version_label(inspection.metadata)}"
941
+ lines << " Architecture: #{inspection.metadata["architecture"] || "unknown"}"
942
+ lines << " Payload: #{inspection.metadata["payload_format"] || "unknown"} / #{inspection.metadata["payload_compressor"] || "unknown"}"
943
+ lines << " Requirements: #{requirements.empty? ? "none declared" : dependency_summary(requirements)}"
944
+ lines << " Scriptlets: #{scriptlets.empty? ? "none" : scriptlets.join(", ")}"
945
+ lines << " Desktop entries: #{desktops.empty? ? "none" : "#{desktops.length} found; using #{inspection.metadata["primary_desktop_entry"]}"}"
946
+ lines << " Portable install: extracts into Depot; does not run rpm, dnf, zypper, sudo, or scriptlets"
947
+ elsif inspection.flatpakref?
948
+ lines << ""
949
+ lines << "Flatpak reference:"
950
+ lines << " Flatpak ID: #{inspection.metadata["name"] || "unknown"}"
951
+ lines << " Branch: #{inspection.metadata["branch"] || "master"}"
952
+ lines << " Remote: #{inspection.metadata["suggest_remote_name"] || "none"}"
953
+ lines << " URL: #{inspection.metadata["url"] || "unknown"}"
954
+ lines << " Runtime ref: #{inspection.metadata["is_runtime"] ? "yes" : "no"}"
955
+ lines << " Embedded GPG key: #{inspection.metadata["gpg_key_present"] ? "yes" : "no"}"
956
+ lines << " Install mode: Flatpak handles download, runtime dependencies, sandboxing, and desktop integration"
957
+ end
958
+ lines << ""
959
+ lines << "Warnings:"
960
+ lines.concat(list_or_none(inspection.warnings))
961
+ lines << ""
962
+ lines << "Risks:"
963
+ lines.concat(list_or_none(inspection.risks))
964
+ lines.join("\n")
965
+ end
966
+
967
+ def list_or_none(items)
968
+ return [" none"] if items.empty?
969
+
970
+ items.map { |item| " - #{item}" }
971
+ end
972
+
973
+ def selected_manifest
974
+ row = @apps_table.current_row
975
+ return nil if row.nil? || row.negative?
976
+
977
+ item = @apps_table.item(row, 0)
978
+ return nil unless item
979
+
980
+ @store.find(item.text)
981
+ end
982
+
983
+ def selected_update_app_id
984
+ row = @updates_table.current_row
985
+ return nil if row.nil? || row.negative?
986
+
987
+ item = @updates_table.item(row, 0)
988
+ item&.text
989
+ end
990
+
991
+ def resolved_install_source(source)
992
+ SourceResolver.resolve(source)
993
+ end
994
+
995
+ def installable_inspection?(inspection)
996
+ inspection&.appimage? || inspection&.deb? || inspection&.archive? || inspection&.rpm? || inspection&.flatpakref?
997
+ end
998
+
999
+ def install_prompt(inspection)
1000
+ base_actions = [
1001
+ "Record a Depot manifest",
1002
+ "Prepare desktop integration",
1003
+ "Track uninstall behavior"
1004
+ ]
1005
+ return prompt_message(inspection, base_actions.unshift("Copy the app into your user application folder"), []) unless inspection.deb? || inspection.archive? || inspection.rpm? || inspection.flatpakref?
1006
+
1007
+ if inspection.archive?
1008
+ scripts = inspection.metadata.fetch("script_entries", [])
1009
+ markers = inspection.metadata.fetch("source_markers", [])
1010
+ actions = ["Extract the archive user-locally", "Infer launcher/icon integration", "Record uninstall tracking"]
1011
+ notes = ["Installer scripts are not run"]
1012
+ notes << "Scripts found: #{scripts.first(6).join(", ")}" unless scripts.empty?
1013
+ notes << "Source/build markers found: #{markers.join(", ")}" unless markers.empty?
1014
+ return prompt_message(inspection, actions, notes)
1015
+ end
1016
+
1017
+ if inspection.rpm?
1018
+ requirements = rpm_requirement_names(inspection.metadata["requires"])
1019
+ scriptlets = inspection.metadata.fetch("scriptlets", [])
1020
+ actions = ["Extract the RPM payload user-locally", "Rewrite launchers/icons for Depot", "Record uninstall tracking"]
1021
+ notes = ["No rpm, dnf, zypper, sudo, or scriptlets"]
1022
+ notes << "Requirements are not installed automatically: #{dependency_summary(requirements)}" unless requirements.empty?
1023
+ notes << "Scriptlets will not be run: #{scriptlets.join(", ")}" unless scriptlets.empty?
1024
+ return prompt_message(inspection, actions, notes)
1025
+ end
1026
+
1027
+ if inspection.flatpakref?
1028
+ actions = ["Install through Flatpak in user mode", "Track the app in Depot", "Use Flatpak for launch/uninstall"]
1029
+ notes = [
1030
+ "Flatpak ID: #{inspection.metadata["name"] || inspection.display_name}",
1031
+ "Remote: #{inspection.metadata["suggest_remote_name"] || "none"}",
1032
+ "Flatpak manages sandboxing and runtime dependencies"
1033
+ ]
1034
+ return prompt_message(inspection, actions, notes)
1035
+ end
1036
+
1037
+ dependencies = dependency_names(inspection.metadata["depends"])
1038
+ scripts = inspection.metadata.fetch("maintainer_scripts", [])
1039
+ actions = ["Extract the Debian payload user-locally", "Rewrite launchers/icons for Depot", "Record uninstall tracking"]
1040
+ notes = ["No apt, dpkg, sudo, or maintainer scripts"]
1041
+ notes << "Dependencies are not installed automatically: #{dependency_summary(dependencies)}" unless dependencies.empty?
1042
+ notes << "Scripts will not be run: #{scripts.join(", ")}" unless scripts.empty?
1043
+ prompt_message(inspection, actions, notes)
1044
+ end
1045
+
1046
+ def prompt_message(inspection, actions, notes)
1047
+ [
1048
+ "#{inspection.display_name}",
1049
+ "#{inspection.format} install",
1050
+ "",
1051
+ "What Depot will do:",
1052
+ *actions.map { |action| " - #{action}" },
1053
+ ("Notes:" unless notes.empty?),
1054
+ *notes.map { |note| " - #{note}" },
1055
+ "",
1056
+ "Install #{inspection.metadata["package"] || inspection.metadata["name"] || inspection.display_name}?"
1057
+ ].compact.join("\n")
1058
+ end
1059
+
1060
+ def detected_summary(inspection)
1061
+ backend = case inspection.format
1062
+ when "flatpakref" then "Flatpak"
1063
+ when "deb" then "Debian portable"
1064
+ when "rpm" then "RPM portable"
1065
+ when "appimage" then "AppImage"
1066
+ when "tar.gz", "tar.xz", "tar.zst" then "Portable archive"
1067
+ else inspection.format
1068
+ end
1069
+ "#{backend} detected: #{inspection.display_name} - #{format_bytes(inspection.size)}"
1070
+ end
1071
+
1072
+ def format_bytes(size)
1073
+ return "unknown" unless size
1074
+
1075
+ units = %w[B KB MB GB]
1076
+ value = size.to_f
1077
+ unit = units.shift
1078
+ while value >= 1024 && units.any?
1079
+ value /= 1024
1080
+ unit = units.shift
1081
+ end
1082
+ value >= 10 || unit == "B" ? "#{value.round} #{unit}" : "#{value.round(1)} #{unit}"
1083
+ end
1084
+
1085
+ def with_install_progress(message)
1086
+ dialog = QMessageBox.new(
1087
+ QMessageBox::Information,
1088
+ QString.new("Depot"),
1089
+ QString.new(message),
1090
+ QMessageBox::NoButton.to_qflags,
1091
+ self
1092
+ )
1093
+ dialog.set_option(QMessageBox::DontUseNativeDialog, true)
1094
+ dialog.set_window_title(QString.new("Depot"))
1095
+ dialog.set_modal(true)
1096
+ dialog.set_window_modality(Qt::ApplicationModal)
1097
+ dialog.set_style_sheet(install_progress_message_style)
1098
+ dialog.set_fixed_size(460, 150)
1099
+ dialog.show
1100
+ QApplication.process_events
1101
+
1102
+ result = nil
1103
+ error = nil
1104
+ done = false
1105
+ worker = Thread.new do
1106
+ begin
1107
+ result = yield
1108
+ rescue StandardError => e
1109
+ error = e
1110
+ ensure
1111
+ done = true
1112
+ end
1113
+ end
1114
+
1115
+ until done
1116
+ QApplication.process_events
1117
+ sleep 0.05
1118
+ end
1119
+ worker.join
1120
+ raise error if error
1121
+
1122
+ result
1123
+ ensure
1124
+ if dialog
1125
+ dialog.close
1126
+ QApplication.process_events
1127
+ end
1128
+ end
1129
+
1130
+ def install_progress_name(inspection)
1131
+ inspection&.metadata&.fetch("package", nil) || inspection&.display_name || "software"
1132
+ end
1133
+
1134
+ def install_progress_message_style
1135
+ <<~CSS
1136
+ QMessageBox {
1137
+ background-color: #ffffff;
1138
+ color: #15171a;
1139
+ border: 1px solid #cfd5dd;
1140
+ }
1141
+ QMessageBox QLabel {
1142
+ background-color: #ffffff;
1143
+ color: #15171a;
1144
+ font-size: 15px;
1145
+ font-weight: 700;
1146
+ padding: 10px;
1147
+ }
1148
+ QMessageBox QLabel#qt_msgbox_label {
1149
+ min-width: 340px;
1150
+ }
1151
+ QMessageBox QPushButton {
1152
+ background-color: #245fd6;
1153
+ color: #ffffff;
1154
+ }
1155
+ CSS
1156
+ end
1157
+
1158
+ def dependency_names(depends)
1159
+ depends.to_s.split(",").map do |dependency|
1160
+ dependency.split("|").first.to_s.strip.sub(/\s*\(.+\)\z/, "")
1161
+ end.reject(&:empty?).uniq
1162
+ end
1163
+
1164
+ def dependency_summary(dependencies)
1165
+ shown = dependencies.first(6).join(", ")
1166
+ extra = dependencies.length - 6
1167
+ extra.positive? ? "#{dependencies.length} dependencies, including #{shown}, and #{extra} more" : shown
1168
+ end
1169
+
1170
+ def rpm_requirement_names(requires)
1171
+ Array(requires).map do |requirement|
1172
+ requirement.to_s.sub(/\s*\(.+\)\z/, "")
1173
+ end.reject { |name| name.empty? || name.start_with?("rpmlib(") }.uniq
1174
+ end
1175
+
1176
+ def rpm_version_label(metadata)
1177
+ [metadata["version"], metadata["release"]].compact.join("-").then { |value| value.empty? ? "unknown" : value }
1178
+ end
1179
+
1180
+ def active_icon_summary(manifest)
1181
+ custom = manifest["custom_icon"]
1182
+ return custom["path"] if custom && custom["path"].to_s != ""
1183
+
1184
+ manifest["default_icon_name"] || (manifest["icons"].to_a.any? ? manifest.fetch("app_id") : "none")
1185
+ end
1186
+
1187
+ def warn_dialog(message)
1188
+ QMessageBox.warning(self, "Depot", message)
1189
+ end
1190
+
1191
+ def unexpected_error(error)
1192
+ warn_dialog("Depot hit an interface error: #{error.message}")
1193
+ end
1194
+ end
1195
+ end
1196
+ end