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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 39c0c6fedfcedab5ac5602cf96e2f1236c0d09b5a9395e34ed06ad80e8e902d8
4
+ data.tar.gz: f996a7aacd1f21f3925e6132c5b72bdcdee47db201fb8cbc5ebc48a39b83b4a5
5
+ SHA512:
6
+ metadata.gz: 3859dc9e6f2f4687bbf7a11e9a4553e7f72c1eea9493957e752f8d3665935560d4ecdabeda93e7fb0ad9e773aedc807eadcf88048b371e94cc226dd409a1adea
7
+ data.tar.gz: cee4fb7b50e82c92d2e04950f78ab46560801757f7f4b605286a221f516b821277381b9738647fb7bc084101c1ba519e23d02cdf95eb02e5d4bcc9e45b090160
data/Gemfile ADDED
@@ -0,0 +1,10 @@
1
+ source "https://rubygems.org/"
2
+
3
+ %w[
4
+ rice
5
+ qtcore
6
+ qtgui
7
+ qtwidgets
8
+ ].each do |lib|
9
+ gem "ruby-qt6-#{lib}", path: "ruby-qt6-main/#{lib}"
10
+ end
data/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # Depot
2
+
3
+ Depot is a Ruby + Qt6 Linux application installer and desktop integration layer. It presents software as one installable thing while the internals choose the right backend. The current build supports AppImage installation, portable `.deb` extraction, portable `.rpm` extraction, `.flatpakref` installation, and portable tar archive integration through the shared CLI/GUI core.
4
+
5
+ ## Run
6
+
7
+ ```sh
8
+ bundle install
9
+ ./bin/setup-rubyqt6
10
+ ./bin/depot --help
11
+ ./bin/depot-gui
12
+ ```
13
+
14
+ The CLI works without Qt loading. The GUI requires the local RubyQt6 native extensions built by `bin/setup-rubyqt6`.
15
+
16
+ ## Commands
17
+
18
+ ```sh
19
+ ./bin/depot inspect ./App.AppImage
20
+ ./bin/depot install ./App.AppImage
21
+ ./bin/depot inspect ./App.deb
22
+ ./bin/depot install ./App.deb
23
+ ./bin/depot inspect ./App.rpm
24
+ ./bin/depot install ./App.rpm
25
+ ./bin/depot inspect ./App.flatpakref
26
+ ./bin/depot install ./App.flatpakref
27
+ ./bin/depot inspect ./App.tar.gz
28
+ ./bin/depot install ./App.tar.gz
29
+ ./bin/depot list
30
+ ./bin/depot info app
31
+ ./bin/depot uninstall app
32
+ ./bin/depot update app
33
+ ./bin/depot update --all
34
+ ./bin/depot update-source app https://example.com/App.AppImage
35
+ ./bin/depot sandbox app enabled
36
+ ./bin/depot doctor
37
+ ./bin/depot settings
38
+ ```
39
+
40
+ Installed applications are copied under `~/.local/share/depot/apps`, manifests live under `~/.local/share/depot/manifests`, and desktop entries are created under `~/.local/share/applications`.
41
+
42
+ Sample packages used by tests and local backend checks live under `fixtures/`, grouped by format.
43
+
44
+ `.deb` support is intentionally universal-first: Depot parses Debian packages itself, extracts installable files under Depot's user-local app directory, rewrites desktop launchers/icons, and does not require or call `apt`, `dpkg`, `sudo`, or Debian maintainer scripts. Packages that rely on Debian-family dependencies, services, or maintainer-script side effects may still need native distro installation or a future compatibility runtime.
45
+
46
+ `.rpm` support follows the same universal-first model: Depot parses RPM headers for package metadata, requirements, scriptlets, payload format, desktop entries, and icons, then extracts the payload under Depot's user-local app directory through libarchive/`bsdtar`. It does not require or call `rpm`, `dnf`, `zypper`, `sudo`, or RPM scriptlets. Packages that rely on RPM-family dependencies, services, users, policies, or scriptlet side effects may still need native distro installation or a future compatibility runtime.
47
+
48
+ `.flatpakref` support uses Flatpak as the backend instead of unpacking the application itself. Depot parses the ref for transparency, runs `flatpak install --user --from`, records a Depot manifest, creates a small launch wrapper, and calls `flatpak uninstall --user` when removing the app. Flatpak remains responsible for downloads, remotes, runtime dependencies, sandboxing, exported desktop entries, and updates.
49
+
50
+ Tar archive support is also portable-first: Depot inspects `.tar.gz` / `.tgz` archives, extracts them user-locally, reuses or generates desktop launchers, copies icons when possible, and never runs installer scripts from the archive.
51
+
52
+ Updates are enabled by default and can be disabled through settings. Flatpak apps update through `flatpak update --user`; AppImage, `.deb`, `.rpm`, and tar archive installs update by reinstalling from their original source file when that file is still available. You can also attach an HTTPS update URL with `depot update-source APP_ID HTTPS_URL`; Depot streams the download to a temporary file, enforces a size limit, inspects the package before uninstalling anything, and refuses updates that switch package families.
53
+
54
+ Sandboxing is managed globally in Settings and per app from the Installed page. Portable AppImage, `.deb`, `.rpm`, and tar archive installs use a generated Bubblewrap launcher when sandboxing is enabled; Flatpak apps keep using Flatpak's own sandbox and permission model. If `bwrap` is missing, Depot falls back to the normal launcher instead of making the app unlaunchable.
55
+
56
+ `depot doctor` checks required helper tools, Depot data paths, installed manifests, launchers, desktop entries, and original install sources.
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rake/testtask"
4
+
5
+ Rake::TestTask.new do |t|
6
+ t.libs << "test"
7
+ t.pattern = "test/**/*_test.rb"
8
+ end
9
+
10
+ task default: :test
data/bin/depot ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
5
+
6
+ require "depot/cli"
7
+
8
+ exit Depot::CLI.new.run(ARGV)
data/bin/depot-gui ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
5
+
6
+ begin
7
+ require "bundler/setup"
8
+ rescue LoadError
9
+ nil
10
+ end
11
+
12
+ require "depot/gui/app"
13
+
14
+ Depot::GUI::App.run
data/bin/setup-rubyqt6 ADDED
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "fileutils"
5
+ require "etc"
6
+ require "open3"
7
+
8
+ ROOT = File.expand_path("..", __dir__)
9
+ GEMS = %w[rice qtcore qtgui qtwidgets].freeze
10
+ LIB_TARGETS = {
11
+ "rice" => "lib/qt6/rice",
12
+ "qtcore" => "lib/qt6/qtcore",
13
+ "qtgui" => "lib/qt6/qtgui",
14
+ "qtwidgets" => "lib/qt6/qtwidgets"
15
+ }.freeze
16
+ QT_HEADERS = `qmake6 -query QT_INSTALL_HEADERS 2>/dev/null`.strip
17
+ QT_HEADERS = `qmake -query QT_INSTALL_HEADERS 2>/dev/null`.strip if QT_HEADERS.empty?
18
+
19
+ def run(*cmd, chdir:)
20
+ puts "$ #{cmd.join(" ")}"
21
+ env = { "BUNDLE_GEMFILE" => File.join(ROOT, "Gemfile") }
22
+ system(env, *cmd, chdir:) || abort("Command failed: #{cmd.join(" ")}")
23
+ end
24
+
25
+ def mark_build_complete(gem_name)
26
+ script = <<~RUBY
27
+ require "fileutils"
28
+ spec = Gem::Specification.find_all_by_name("ruby-qt6-#{gem_name}").first
29
+ abort("Could not find ruby-qt6-#{gem_name} spec") unless spec
30
+ FileUtils.mkdir_p(File.dirname(spec.gem_build_complete_path))
31
+ FileUtils.touch(spec.gem_build_complete_path)
32
+ RUBY
33
+ run("bundle", "exec", "ruby", "-e", script, chdir: ROOT)
34
+ end
35
+
36
+ def make_args(gem_name)
37
+ flags = ["-DRUBYQT6_BUILD_#{gem_name.upcase}_LIB"]
38
+ flags << "-I#{QT_HEADERS}" unless QT_HEADERS.empty?
39
+ %w[QtCore QtGui QtWidgets].each do |qt_module|
40
+ flags << "-I#{File.join(QT_HEADERS, qt_module)}" unless QT_HEADERS.empty?
41
+ version_dir = Dir[File.join(QT_HEADERS, qt_module, "*.*.*")].first
42
+ next unless version_dir
43
+
44
+ flags << "-I#{version_dir}"
45
+ flags << "-I#{File.join(version_dir, qt_module)}"
46
+ end
47
+
48
+ args = ["cppflags=#{flags.join(" ")}"]
49
+ if gem_name != "rice"
50
+ rice_so = File.join(ROOT, "ruby-qt6-main", "rice", "lib", "qt6", "rice", "rice.so")
51
+ args << "LOCAL_LIBS=#{rice_so}"
52
+ end
53
+ args
54
+ end
55
+
56
+ GEMS.each do |gem_name|
57
+ gem_dir = File.join(ROOT, "ruby-qt6-main", gem_name)
58
+ ext_dir = File.join(gem_dir, "ext", "qt6", gem_name)
59
+ target_dir = File.join(gem_dir, LIB_TARGETS.fetch(gem_name))
60
+ FileUtils.rm_f(File.join(ext_dir, "Makefile"))
61
+ FileUtils.rm_f(Dir.glob(File.join(ext_dir, "*.{o,so}")))
62
+ run("bundle", "exec", "ruby", "extconf.rb", chdir: ext_dir)
63
+ run("make", "-j#{Etc.nprocessors}", *make_args(gem_name), chdir: ext_dir)
64
+ FileUtils.mkdir_p(target_dir)
65
+ Dir.glob(File.join(ext_dir, "*.so")).each do |shared_object|
66
+ FileUtils.cp(shared_object, target_dir)
67
+ puts "installed #{File.basename(shared_object)} -> #{target_dir}"
68
+ end
69
+ mark_build_complete(gem_name)
70
+ end
71
+
72
+ puts "RubyQt6 native extensions are ready."
Binary file
@@ -0,0 +1,10 @@
1
+ [Flatpak Ref]
2
+ Name=org.qbittorrent.qBittorrent
3
+ Branch=stable
4
+ Title=org.qbittorrent.qBittorrent from flathub
5
+ IsRuntime=false
6
+ Url=https://dl.flathub.org/repo/
7
+ SuggestRemoteName=flathub
8
+ GPGKey=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
9
+ RuntimeRepo=https://dl.flathub.org/repo/flathub.flatpakrepo
10
+
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "time"
5
+ require_relative "desktop_entry"
6
+ require_relative "manifest_store"
7
+ require_relative "paths"
8
+ require_relative "result"
9
+ require_relative "sandbox"
10
+
11
+ module Depot
12
+ class AppCustomizer
13
+ ICON_EXTENSIONS = [".png", ".svg", ".xpm"].freeze
14
+
15
+ def initialize(store: ManifestStore.new)
16
+ @store = store
17
+ end
18
+
19
+ def rename(app_id, title)
20
+ manifest = @store.find(app_id)
21
+ return Result.err("No Depot manifest found for #{app_id}.") unless manifest
22
+
23
+ title = title.to_s.strip
24
+ return Result.err("Title cannot be empty.") if title.empty?
25
+
26
+ ensure_defaults(manifest)
27
+ manifest["display_name"] = title
28
+ manifest["customizations"]["display_name"] = title
29
+ persist(manifest)
30
+ end
31
+
32
+ def change_icon(app_id, source_path)
33
+ manifest = @store.find(app_id)
34
+ return Result.err("No Depot manifest found for #{app_id}.") unless manifest
35
+
36
+ source_path = File.expand_path(source_path.to_s)
37
+ return Result.err("Icon file does not exist.") unless File.file?(source_path)
38
+
39
+ ext = File.extname(source_path).downcase
40
+ return Result.err("Choose a PNG, SVG, or XPM icon.") unless ICON_EXTENSIONS.include?(ext)
41
+
42
+ ensure_defaults(manifest)
43
+ remove_custom_icon(manifest)
44
+
45
+ icon_name = "#{manifest.fetch("app_id")}-custom"
46
+ target_dir = File.join(Paths.icon_root, icon_theme_size(source_path, ext), "apps")
47
+ FileUtils.mkdir_p(target_dir)
48
+ target = File.join(target_dir, "#{icon_name}#{ext}")
49
+ FileUtils.cp(source_path, target)
50
+
51
+ manifest["custom_icon"] = {
52
+ "source" => source_path,
53
+ "path" => target,
54
+ "icon_name" => icon_name,
55
+ "set_at" => Time.now.utc.iso8601
56
+ }
57
+ manifest["created_files"] = (manifest["created_files"].to_a + [target]).uniq
58
+ persist(manifest)
59
+ rescue SystemCallError => e
60
+ Result.err("Could not change icon: #{e.message}")
61
+ end
62
+
63
+ def reset(app_id)
64
+ manifest = @store.find(app_id)
65
+ return Result.err("No Depot manifest found for #{app_id}.") unless manifest
66
+
67
+ ensure_defaults(manifest)
68
+ remove_custom_icon(manifest)
69
+ manifest["display_name"] = manifest.fetch("default_display_name")
70
+ manifest.delete("custom_icon")
71
+ manifest["customizations"] = {}
72
+ persist(manifest)
73
+ end
74
+
75
+ private
76
+
77
+ def ensure_defaults(manifest)
78
+ manifest["default_display_name"] ||= manifest.fetch("display_name")
79
+ manifest["default_icon_name"] = default_icon_name(manifest) unless manifest.key?("default_icon_name")
80
+ manifest["customizations"] ||= {}
81
+ end
82
+
83
+ def default_icon_name(manifest)
84
+ manifest["icons"].to_a.any? ? manifest.fetch("app_id") : nil
85
+ end
86
+
87
+ def persist(manifest)
88
+ rewrite_desktop_entry(manifest)
89
+ path = @store.write(manifest)
90
+ Result.ok(manifest.merge("manifest_path" => path))
91
+ rescue SystemCallError => e
92
+ Result.err("Could not update app integration: #{e.message}")
93
+ end
94
+
95
+ def rewrite_desktop_entry(manifest)
96
+ desktop_path = manifest["desktop_entry"]
97
+ return unless desktop_path && !desktop_path.empty?
98
+
99
+ FileUtils.mkdir_p(File.dirname(desktop_path))
100
+ entry = DesktopEntry.new(
101
+ app_id: manifest.fetch("app_id"),
102
+ name: manifest.fetch("display_name"),
103
+ exec_path: Sandbox.launch_path(manifest),
104
+ icon_name: active_icon_name(manifest)
105
+ )
106
+ File.write(desktop_path, entry.contents)
107
+ manifest["created_files"] = (manifest["created_files"].to_a + [desktop_path]).uniq
108
+ end
109
+
110
+ def active_icon_name(manifest)
111
+ custom = manifest["custom_icon"]
112
+ return custom["path"] if custom && custom["path"].to_s != ""
113
+
114
+ manifest["default_icon_name"]
115
+ end
116
+
117
+ def icon_theme_size(path, ext)
118
+ return "scalable" if ext == ".svg"
119
+ return png_size(path) if ext == ".png"
120
+
121
+ "256x256"
122
+ end
123
+
124
+ def png_size(path)
125
+ File.open(path, "rb") do |file|
126
+ header = file.read(24)
127
+ return "256x256" unless header&.start_with?("\x89PNG\r\n\x1A\n".b)
128
+
129
+ width, height = header.byteslice(16, 8).unpack("NN")
130
+ return "256x256" if width.to_i <= 0 || height.to_i <= 0
131
+
132
+ "#{width}x#{height}"
133
+ end
134
+ rescue SystemCallError
135
+ "256x256"
136
+ end
137
+
138
+ def remove_custom_icon(manifest)
139
+ custom = manifest["custom_icon"]
140
+ return unless custom
141
+
142
+ path = custom["path"]
143
+ FileUtils.rm_f(path) if path && custom_icon_path?(path)
144
+ manifest["created_files"] = manifest["created_files"].to_a - [path]
145
+ end
146
+
147
+ def custom_icon_path?(path)
148
+ expanded = File.expand_path(path)
149
+ expanded.start_with?(File.expand_path(Paths.icon_root) + File::SEPARATOR)
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Depot
4
+ module Assets
5
+ module_function
6
+
7
+ def logo_path
8
+ File.expand_path("../../fixtures/assets/download.png", __dir__)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "open3"
5
+ require "shellwords"
6
+ require "tmpdir"
7
+ require "timeout"
8
+ require "time"
9
+ require_relative "../desktop_entry"
10
+ require_relative "../paths"
11
+ require_relative "../result"
12
+ require_relative "../util"
13
+
14
+ module Depot
15
+ module Backends
16
+ class AppImage
17
+ EXTRACTION_TIMEOUT_SECONDS = 25
18
+
19
+ def initialize(store:)
20
+ @store = store
21
+ end
22
+
23
+ def install(inspection, settings: {})
24
+ return Result.err("AppImage backend cannot install #{inspection.format}") unless inspection.appimage?
25
+
26
+ Paths.ensure_base_dirs
27
+
28
+ taken = @store.ids
29
+ app_id = Util.unique_id(Util.slug(inspection.display_name), taken)
30
+ app_dir = File.join(Paths.apps_dir, app_id)
31
+ FileUtils.mkdir_p(app_dir)
32
+
33
+ installed_name = File.basename(inspection.input)
34
+ installed_path = File.join(app_dir, installed_name)
35
+ FileUtils.cp(inspection.input, installed_path)
36
+ FileUtils.chmod(File.stat(installed_path).mode | 0o111, installed_path)
37
+
38
+ metadata = extract_metadata(installed_path, app_id)
39
+ display_name = metadata.fetch("name", inspection.display_name)
40
+ icon_paths = metadata.fetch("icons", [])
41
+ icon_name = icon_paths.any? ? app_id : nil
42
+
43
+ desktop_path = nil
44
+ if settings.fetch("desktop_integration", true)
45
+ desktop_path = File.join(Paths.desktop_entries_dir, "depot-#{app_id}.desktop")
46
+ entry = DesktopEntry.new(app_id:, name: display_name, exec_path: installed_path, icon_name:)
47
+ File.write(desktop_path, entry.contents)
48
+ end
49
+
50
+ manifest = {
51
+ "schema_version" => 1,
52
+ "app_id" => app_id,
53
+ "display_name" => display_name,
54
+ "default_display_name" => display_name,
55
+ "backend" => "appimage",
56
+ "install_source" => File.expand_path(inspection.input),
57
+ "source_sha256" => inspection.sha256,
58
+ "source_size" => inspection.size,
59
+ "installed_executable" => installed_path,
60
+ "desktop_entry" => desktop_path,
61
+ "icons" => icon_paths,
62
+ "default_icon_name" => icon_name,
63
+ "customizations" => {},
64
+ "created_files" => ([installed_path, desktop_path] + icon_paths).compact,
65
+ "created_dirs" => [app_dir],
66
+ "installed_at" => Time.now.utc.iso8601,
67
+ "permissions" => permission_summary(installed_path),
68
+ "sandbox" => {
69
+ "enabled" => false,
70
+ "preference" => settings.fetch("sandbox_preference", "ask")
71
+ },
72
+ "update" => {
73
+ "mechanism" => "manual",
74
+ "source" => File.expand_path(inspection.input)
75
+ },
76
+ "warnings" => inspection.warnings + metadata.fetch("warnings", [])
77
+ }
78
+
79
+ manifest_path = @store.write(manifest)
80
+ Result.ok(manifest.merge("manifest_path" => manifest_path), warnings: manifest["warnings"])
81
+ rescue SystemCallError => e
82
+ Result.err("Install failed: #{e.message}")
83
+ end
84
+
85
+ private
86
+
87
+ def extract_metadata(installed_path, app_id)
88
+ warnings = []
89
+ desktop_name = nil
90
+ icon_paths = []
91
+
92
+ Dir.mktmpdir("depot-appimage-") do |dir|
93
+ stdout, stderr, status = run_extract(installed_path, dir)
94
+ unless status&.success?
95
+ warnings << "Could not extract AppImage metadata; generated a desktop entry from the file name."
96
+ warnings << stderr.strip unless stderr.to_s.strip.empty?
97
+ warnings << stdout.strip unless stdout.to_s.strip.empty?
98
+ return { "warnings" => warnings, "icons" => icon_paths }
99
+ end
100
+
101
+ root = File.join(dir, "squashfs-root")
102
+ desktop = Dir.glob(File.join(root, "**", "*.desktop")).first
103
+ desktop_metadata = parse_desktop_metadata(desktop)
104
+ desktop_name = desktop_metadata["name"]
105
+ icon_paths = install_icons(app_id, find_icons(root, desktop_metadata["icon"]))
106
+ end
107
+
108
+ { "name" => desktop_name, "icons" => icon_paths, "warnings" => warnings }.compact
109
+ rescue Timeout::Error
110
+ { "warnings" => ["Timed out while extracting AppImage metadata."], "icons" => [] }
111
+ rescue SystemCallError => e
112
+ { "warnings" => ["Could not extract AppImage metadata: #{e.message}"], "icons" => [] }
113
+ end
114
+
115
+ def run_extract(installed_path, dir)
116
+ Timeout.timeout(EXTRACTION_TIMEOUT_SECONDS) do
117
+ Open3.capture3({ "APPIMAGE_EXTRACT_AND_RUN" => "1" }, installed_path, "--appimage-extract", chdir: dir)
118
+ end
119
+ end
120
+
121
+ def parse_desktop_metadata(path)
122
+ metadata = {}
123
+ return metadata unless path && File.exist?(path)
124
+
125
+ File.readlines(path, chomp: true).each do |line|
126
+ key, value = line.split("=", 2)
127
+ next unless value
128
+
129
+ value = value.strip
130
+ metadata["name"] = value if key == "Name" && !value.empty?
131
+ metadata["icon"] = value if key == "Icon" && !value.empty?
132
+ end
133
+ metadata
134
+ end
135
+
136
+ def find_icons(root, preferred_icon = nil)
137
+ icons = [".png", ".svg", ".xpm"].flat_map do |ext|
138
+ Dir.glob(File.join(root, "**", "*#{ext}"))
139
+ end
140
+ icons << File.join(root, ".DirIcon")
141
+ preferred = preferred_icon.to_s
142
+ preferred_base = File.basename(preferred, ".*")
143
+
144
+ icons.select { |path| File.file?(path) && File.size(path).positive? }
145
+ .uniq { |path| real_icon_path(path) }
146
+ .sort_by { |path| icon_score(path, preferred_base) }
147
+ .first(8)
148
+ end
149
+
150
+ def install_icons(app_id, icons)
151
+ icons.filter_map do |source|
152
+ ext = icon_extension(source)
153
+ next unless [".png", ".svg", ".xpm"].include?(ext)
154
+
155
+ size = icon_size(source)
156
+ target_dir = File.join(Paths.icon_root, size, "apps")
157
+ FileUtils.mkdir_p(target_dir)
158
+ target = File.join(target_dir, "#{app_id}#{ext}")
159
+ FileUtils.cp(source, target)
160
+ target
161
+ rescue SystemCallError
162
+ nil
163
+ end.uniq
164
+ end
165
+
166
+ def icon_size(path)
167
+ parts = path.split(File::SEPARATOR)
168
+ found = parts.find { |part| part.match?(/\A\d+x\d+\z/) }
169
+ return found if found
170
+
171
+ icon_extension(path) == ".svg" ? "scalable" : "256x256"
172
+ end
173
+
174
+ def icon_score(path, preferred_base)
175
+ base = File.basename(path, ".*")
176
+ ext = icon_extension(path)
177
+ [
178
+ preferred_base.empty? || base != preferred_base ? 1 : 0,
179
+ File.basename(path) == ".DirIcon" ? 0 : 1,
180
+ ext == ".svg" ? 0 : 1,
181
+ -File.size(path)
182
+ ]
183
+ end
184
+
185
+ def icon_extension(path)
186
+ ext = File.extname(path).downcase
187
+ return ext unless ext.empty?
188
+
189
+ File.extname(real_icon_path(path)).downcase
190
+ rescue SystemCallError
191
+ ext
192
+ end
193
+
194
+ def real_icon_path(path)
195
+ File.realpath(path)
196
+ rescue SystemCallError
197
+ path
198
+ end
199
+
200
+ def permission_summary(path)
201
+ {
202
+ "executable" => File.executable?(path),
203
+ "requires_sudo" => false,
204
+ "writes_outside_depot" => false,
205
+ "notes" => ["Portable AppImage installed user-locally."]
206
+ }
207
+ end
208
+ end
209
+ end
210
+ end