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,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "time"
5
+ require_relative "../packages/archive"
6
+ require_relative "../desktop_entry"
7
+ require_relative "../paths"
8
+ require_relative "../result"
9
+ require_relative "../util"
10
+ require_relative "support"
11
+
12
+ module Depot
13
+ module Backends
14
+ class Archive
15
+ include Support
16
+
17
+ def initialize(store:)
18
+ @store = store
19
+ end
20
+
21
+ def install(inspection, settings: {})
22
+ return Result.err("Archive backend cannot install #{inspection.format}") unless inspection.archive?
23
+
24
+ Paths.ensure_base_dirs
25
+ package = ArchivePackage.new(inspection.input)
26
+ return Result.err("Unsupported or invalid archive.") unless package.valid?
27
+
28
+ app_id = Util.unique_id(Util.slug(inspection.display_name), @store.ids)
29
+ app_dir = File.join(Paths.apps_dir, app_id)
30
+ root_dir = File.join(app_dir, "root")
31
+ FileUtils.mkdir_p(app_dir)
32
+
33
+ package.extract_to(root_dir)
34
+
35
+ desktop_source = package.primary_desktop_entry
36
+ display_name = desktop_name(package, desktop_source) || package.display_name
37
+ preferred_icon = desktop_source && package.read_entry(desktop_source)[/^Icon=(.+)$/, 1]&.strip
38
+ icon_name = install_icons(package, root_dir, app_id, preferred_icon)
39
+ executable = executable_for(package, root_dir, desktop_source)
40
+
41
+ desktop_path = nil
42
+ if settings.fetch("desktop_integration", true) && executable
43
+ desktop_path = File.join(Paths.desktop_entries_dir, "depot-#{app_id}.desktop")
44
+ contents = desktop_source ? rewrite_desktop(package.read_entry(desktop_source), root_dir, app_id, display_name, icon_name) : generated_desktop(app_id, display_name, executable, icon_name)
45
+ File.write(desktop_path, contents)
46
+ end
47
+
48
+ warnings = inspection.warnings + portable_warnings(package, executable, desktop_source)
49
+ manifest = {
50
+ "schema_version" => 1,
51
+ "app_id" => app_id,
52
+ "display_name" => display_name,
53
+ "default_display_name" => display_name,
54
+ "backend" => "archive-portable",
55
+ "install_source" => File.expand_path(inspection.input),
56
+ "source_sha256" => inspection.sha256,
57
+ "source_size" => inspection.size,
58
+ "installed_executable" => executable,
59
+ "desktop_entry" => desktop_path,
60
+ "icons" => icon_paths(app_id),
61
+ "default_icon_name" => icon_name,
62
+ "created_files" => [desktop_path].compact + icon_paths(app_id),
63
+ "created_dirs" => [app_dir],
64
+ "installed_at" => Time.now.utc.iso8601,
65
+ "archive" => {
66
+ "format" => package.format,
67
+ "root" => package.common_root,
68
+ "desktop_source" => desktop_source
69
+ },
70
+ "portable_root" => root_dir,
71
+ "permissions" => {
72
+ "executable" => executable ? File.executable?(executable) : false,
73
+ "requires_sudo" => false,
74
+ "writes_outside_depot" => false,
75
+ "notes" => ["Archive extracted user-locally. Installer scripts were not executed."]
76
+ },
77
+ "sandbox" => {
78
+ "enabled" => false,
79
+ "preference" => settings.fetch("sandbox_preference", "ask")
80
+ },
81
+ "update" => {
82
+ "mechanism" => "manual",
83
+ "source" => File.expand_path(inspection.input)
84
+ },
85
+ "warnings" => warnings
86
+ }
87
+
88
+ manifest_path = @store.write(manifest)
89
+ refresh_desktop_caches if settings.fetch("desktop_integration", true)
90
+ Result.ok(manifest.merge("manifest_path" => manifest_path), warnings:)
91
+ rescue ArchivePackage::FormatError, SystemCallError => e
92
+ Result.err("Archive install failed: #{e.message}")
93
+ end
94
+
95
+ private
96
+
97
+ def desktop_name(package, entry)
98
+ return nil unless entry
99
+
100
+ line = package.read_entry(entry).lines.find { |candidate| candidate.start_with?("Name=") }
101
+ line&.split("=", 2)&.last&.strip
102
+ rescue ArchivePackage::FormatError
103
+ nil
104
+ end
105
+
106
+ def executable_for(package, root_dir, desktop_entry)
107
+ exec_line = desktop_entry && package.read_entry(desktop_entry).lines.find { |line| line.start_with?("Exec=") }
108
+ command = exec_line&.split("=", 2)&.last.to_s.strip
109
+ rewritten = rewrite_exec_value(command, root_dir)
110
+ first = first_exec_token(rewritten)
111
+ return first if first && File.exist?(first)
112
+
113
+ candidates = package.executable_candidates.map { |entry| File.join(root_dir, entry) }
114
+ candidates.find { |path| File.file?(path) && File.executable?(path) } ||
115
+ candidates.find { |path| File.file?(path) }
116
+ rescue ArchivePackage::FormatError
117
+ nil
118
+ end
119
+
120
+ def generated_desktop(app_id, display_name, executable, icon_name)
121
+ DesktopEntry.new(app_id:, name: display_name, exec_path: executable, icon_name:).contents
122
+ end
123
+
124
+ def rewrite_desktop(contents, root_dir, app_id, display_name, icon_name)
125
+ seen_depot_id = false
126
+ current_group = nil
127
+ lines = []
128
+ contents.lines.each do |line|
129
+ if line.start_with?("[")
130
+ lines << "X-Depot-AppID=#{app_id}\n" if current_group == "[Desktop Entry]" && !seen_depot_id
131
+ current_group = line.strip
132
+ end
133
+
134
+ lines << if line.start_with?("Name=") && current_group == "[Desktop Entry]"
135
+ "Name=#{display_name}\n"
136
+ elsif line.match?(/\AExec=/)
137
+ "Exec=#{rewrite_exec_value(line.split("=", 2).last.strip, root_dir)}\n"
138
+ elsif line.match?(/\ATryExec=/)
139
+ "TryExec=#{rewrite_exec_value(line.split("=", 2).last.strip, root_dir)}\n"
140
+ elsif line.start_with?("Icon=")
141
+ icon_name ? "Icon=#{icon_name}\n" : line
142
+ elsif line.start_with?("X-Depot-AppID=")
143
+ seen_depot_id = true
144
+ "X-Depot-AppID=#{app_id}\n"
145
+ else
146
+ line
147
+ end
148
+ end
149
+ lines << "X-Depot-AppID=#{app_id}\n" if current_group == "[Desktop Entry]" && !seen_depot_id
150
+ lines.join
151
+ end
152
+
153
+ def rewrite_exec_value(value, root_dir)
154
+ token, rest = split_exec(value)
155
+ return value if token.to_s.empty?
156
+
157
+ mapped = if token.start_with?("/")
158
+ File.join(root_dir, token.delete_prefix("/"))
159
+ elsif token.include?("/")
160
+ File.join(root_dir, token)
161
+ else
162
+ candidate = find_executable(root_dir, token)
163
+ candidate || token
164
+ end
165
+ "#{Util.desktop_exec_quote(mapped)}#{rest}"
166
+ end
167
+
168
+ def split_exec(value)
169
+ if value.start_with?('"')
170
+ closing = value.index('"', 1)
171
+ return [value[1...closing], value[(closing + 1)..].to_s] if closing
172
+ end
173
+ token, rest = value.split(/\s+/, 2)
174
+ [token, rest ? " #{rest}" : ""]
175
+ end
176
+
177
+ def first_exec_token(value)
178
+ token, = split_exec(value)
179
+ token
180
+ end
181
+
182
+ def find_executable(root_dir, token)
183
+ Dir.glob(File.join(root_dir, "**", token)).find { |path| File.file?(path) && File.executable?(path) }
184
+ end
185
+
186
+ def install_icons(package, root_dir, app_id, preferred)
187
+ icons = package.icon_entries
188
+ icons = fallback_icon_entries(package, preferred) if icons.empty?
189
+ icons.sort_by { |entry| icon_score(entry, preferred) }.first(8).each do |entry|
190
+ ext = File.extname(entry).downcase
191
+ next unless [".png", ".svg", ".xpm"].include?(ext)
192
+
193
+ source = File.join(root_dir, entry)
194
+ next unless File.file?(source)
195
+
196
+ target_dir = File.join(Paths.icon_root, icon_theme_size(entry, source), "apps")
197
+ FileUtils.mkdir_p(target_dir)
198
+ FileUtils.cp(source, File.join(target_dir, "#{app_id}#{ext}"))
199
+ end
200
+ icon_paths(app_id).any? ? app_id : nil
201
+ end
202
+
203
+ def fallback_icon_entries(package, preferred)
204
+ preferred_base = File.basename(preferred.to_s, ".*")
205
+ candidates = package.image_entries
206
+ scored = candidates.select do |entry|
207
+ base = File.basename(entry, ".*")
208
+ entry.match?(/(^|\/)(icon|logo|app)[^\/]*\.(png|svg|xpm)\z/i) ||
209
+ (!preferred_base.empty? && (base == preferred_base || entry.downcase.include?(preferred_base.downcase)))
210
+ end
211
+ scored.empty? ? candidates : scored
212
+ end
213
+
214
+ def icon_score(entry, preferred)
215
+ base = File.basename(entry, ".*")
216
+ [
217
+ preferred.to_s.empty? || base != preferred ? 1 : 0,
218
+ entry.include?("/256x256/") ? 0 : 1,
219
+ entry.include?("/512x512/") ? 0 : 1,
220
+ entry.match?(/(^|\/)(icon|logo|app)[^\/]*\.(png|svg|xpm)\z/i) ? 0 : 1,
221
+ -entry.length
222
+ ]
223
+ end
224
+
225
+ def icon_theme_size(entry, source)
226
+ match = entry.match(ArchivePackage::HICOLOR_ICON_PATH)
227
+ return match[1] if match
228
+ return "scalable" if File.extname(entry).downcase == ".svg"
229
+ return png_size(source) if File.extname(entry).downcase == ".png"
230
+
231
+ "256x256"
232
+ end
233
+
234
+ def png_size(path)
235
+ File.open(path, "rb") do |file|
236
+ header = file.read(24)
237
+ return "256x256" unless header&.start_with?("\x89PNG\r\n\x1A\n".b)
238
+
239
+ width, height = header.byteslice(16, 8).unpack("NN")
240
+ return "256x256" if width.to_i <= 0 || height.to_i <= 0
241
+
242
+ "#{width}x#{height}"
243
+ end
244
+ rescue SystemCallError
245
+ "256x256"
246
+ end
247
+
248
+ def icon_paths(app_id)
249
+ Dir.glob(File.join(Paths.icon_root, "*", "apps", "#{app_id}.{png,svg,xpm}"), File::FNM_EXTGLOB)
250
+ end
251
+
252
+ def portable_warnings(package, executable, desktop_source)
253
+ warnings = ["Depot installed this archive in portable extraction mode and did not run installer scripts."]
254
+ warnings << "No desktop launcher was found; Depot generated one." unless desktop_source
255
+ warnings << "No executable could be confidently selected." unless executable
256
+ warnings << "Installer-like scripts were found and were not executed: #{package.script_entries.first(6).join(", ")}." unless package.script_entries.empty?
257
+ warnings << "Source/build markers were found: #{package.source_markers.join(", ")}." unless package.source_markers.empty?
258
+ warnings
259
+ end
260
+
261
+ end
262
+ end
263
+ end
@@ -0,0 +1,265 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "time"
5
+ require_relative "../packages/deb"
6
+ require_relative "../paths"
7
+ require_relative "../result"
8
+ require_relative "../util"
9
+ require_relative "support"
10
+
11
+ module Depot
12
+ module Backends
13
+ class Deb
14
+ include Support
15
+
16
+ def initialize(store:)
17
+ @store = store
18
+ end
19
+
20
+ def install(inspection, settings: {})
21
+ return Result.err("Deb backend cannot install #{inspection.format}") unless inspection.deb?
22
+
23
+ Paths.ensure_base_dirs
24
+ package = DebPackage.new(inspection.input)
25
+ return Result.err("Invalid Debian package.") unless package.valid?
26
+
27
+ fields = package.control_fields
28
+ app_id = Util.unique_id(Util.slug("#{fields["Package"] || inspection.display_name}-#{fields["Version"]}"), @store.ids)
29
+ app_dir = File.join(Paths.apps_dir, app_id)
30
+ root_dir = File.join(app_dir, "root")
31
+ FileUtils.mkdir_p(app_dir)
32
+
33
+ package.extract_data_to(root_dir)
34
+
35
+ desktop_source = package.primary_desktop_entry
36
+ display_name = desktop_name(package, desktop_source) || fields["Package"] || inspection.display_name
37
+ preferred_icon = desktop_source && package.read_data_entry(desktop_source)[/^Icon=(.+)$/, 1]&.strip
38
+ icon_name = install_icons(package, root_dir, app_id, desktop_source, preferred_icon)
39
+ executable = executable_for(package, root_dir, desktop_source)
40
+
41
+ desktop_path = nil
42
+ if settings.fetch("desktop_integration", true) && desktop_source
43
+ desktop_path = File.join(Paths.desktop_entries_dir, "depot-#{app_id}.desktop")
44
+ File.write(desktop_path, rewrite_desktop(package.read_data_entry(desktop_source), root_dir, app_id, display_name, icon_name))
45
+ end
46
+
47
+ manifest = {
48
+ "schema_version" => 1,
49
+ "app_id" => app_id,
50
+ "display_name" => display_name,
51
+ "default_display_name" => display_name,
52
+ "backend" => "deb-portable",
53
+ "install_source" => File.expand_path(inspection.input),
54
+ "source_sha256" => inspection.sha256,
55
+ "source_size" => inspection.size,
56
+ "installed_executable" => executable,
57
+ "desktop_entry" => desktop_path,
58
+ "icons" => installed_icon_paths(app_id, preferred_icon),
59
+ "default_icon_name" => icon_name,
60
+ "created_files" => [desktop_path].compact + installed_icon_paths(app_id, preferred_icon),
61
+ "created_dirs" => [app_dir],
62
+ "installed_at" => Time.now.utc.iso8601,
63
+ "package" => fields.slice("Package", "Version", "Architecture", "Maintainer", "Depends", "Homepage"),
64
+ "desktop_source" => desktop_source,
65
+ "portable_root" => root_dir,
66
+ "permissions" => {
67
+ "executable" => executable ? File.executable?(executable) : false,
68
+ "requires_sudo" => false,
69
+ "writes_outside_depot" => false,
70
+ "notes" => ["Debian package extracted user-locally. Maintainer scripts were not executed."]
71
+ },
72
+ "sandbox" => {
73
+ "enabled" => false,
74
+ "preference" => settings.fetch("sandbox_preference", "ask")
75
+ },
76
+ "update" => {
77
+ "mechanism" => "manual",
78
+ "source" => File.expand_path(inspection.input)
79
+ },
80
+ "warnings" => inspection.warnings + portable_warnings(package)
81
+ }
82
+
83
+ manifest_path = @store.write(manifest)
84
+ refresh_desktop_caches if settings.fetch("desktop_integration", true)
85
+ Result.ok(manifest.merge("manifest_path" => manifest_path), warnings: manifest["warnings"])
86
+ rescue DebPackage::FormatError, SystemCallError => e
87
+ Result.err("Deb install failed: #{e.message}")
88
+ end
89
+
90
+ private
91
+
92
+ def desktop_name(package, entry)
93
+ return nil unless entry
94
+
95
+ line = package.read_data_entry(entry).lines.find { |candidate| candidate.start_with?("Name=") }
96
+ line&.split("=", 2)&.last&.strip
97
+ rescue DebPackage::FormatError
98
+ nil
99
+ end
100
+
101
+ def executable_for(package, root_dir, desktop_entry)
102
+ exec_line = desktop_entry && package.read_data_entry(desktop_entry).lines.find { |line| line.start_with?("Exec=") }
103
+ command = exec_line&.split("=", 2)&.last.to_s.strip
104
+ rewritten = rewrite_exec_value(command, root_dir)
105
+ first = first_exec_token(rewritten)
106
+ return first if first && File.exist?(first)
107
+
108
+ package.executable_entries.map { |entry| File.join(root_dir, entry) }.find { |path| File.file?(path) && File.executable?(path) }
109
+ rescue DebPackage::FormatError
110
+ nil
111
+ end
112
+
113
+ def rewrite_desktop(contents, root_dir, app_id, display_name, icon_name)
114
+ seen_depot_id = false
115
+ current_group = nil
116
+ lines = []
117
+ contents.lines.each do |line|
118
+ if line.start_with?("[")
119
+ lines << "X-Depot-AppID=#{app_id}\n" if current_group == "[Desktop Entry]" && !seen_depot_id
120
+ current_group = line.strip
121
+ end
122
+
123
+ lines << if line.start_with?("Name=") && current_group == "[Desktop Entry]"
124
+ "Name=#{display_name}\n"
125
+ elsif line.match?(/\AExec=/)
126
+ "Exec=#{rewrite_exec_value(line.split("=", 2).last.strip, root_dir)}\n"
127
+ elsif line.match?(/\ATryExec=/)
128
+ "TryExec=#{rewrite_exec_value(line.split("=", 2).last.strip, root_dir)}\n"
129
+ elsif line.start_with?("Icon=")
130
+ icon_name ? "Icon=#{icon_name}\n" : line
131
+ elsif line.start_with?("X-Depot-AppID=")
132
+ seen_depot_id = true
133
+ "X-Depot-AppID=#{app_id}\n"
134
+ else
135
+ line
136
+ end
137
+ end
138
+ lines << "X-Depot-AppID=#{app_id}\n" if current_group == "[Desktop Entry]" && !seen_depot_id
139
+ lines.join
140
+ end
141
+
142
+ def rewrite_exec_value(value, root_dir)
143
+ token, rest = split_exec(value)
144
+ return value if token.to_s.empty?
145
+
146
+ mapped = if token.start_with?("/")
147
+ File.join(root_dir, token.delete_prefix("/"))
148
+ elsif token.include?("/")
149
+ File.join(root_dir, token)
150
+ else
151
+ candidate = File.join(root_dir, "usr", "bin", token)
152
+ File.exist?(candidate) ? candidate : token
153
+ end
154
+ "#{Util.desktop_exec_quote(mapped)}#{rest}"
155
+ end
156
+
157
+ def split_exec(value)
158
+ if value.start_with?('"')
159
+ closing = value.index('"', 1)
160
+ return [value[1...closing], value[(closing + 1)..].to_s] if closing
161
+ end
162
+ token, rest = value.split(/\s+/, 2)
163
+ [token, rest ? " #{rest}" : ""]
164
+ end
165
+
166
+ def first_exec_token(value)
167
+ token, = split_exec(value)
168
+ token
169
+ end
170
+
171
+ def install_icons(package, root_dir, app_id, desktop_source, preferred)
172
+ icons = package.icon_entries
173
+ icons = fallback_icon_entries(package, preferred) if icons.empty?
174
+ icons = icons.sort_by { |entry| icon_score(entry, preferred) }.first(8)
175
+ icons.each do |entry|
176
+ size = icon_theme_size(entry, File.join(root_dir, entry))
177
+ ext = File.extname(entry).delete_prefix(".").downcase
178
+ source = File.join(root_dir, entry)
179
+ next unless File.file?(source)
180
+
181
+ target_dir = File.join(Paths.icon_root, size, "apps")
182
+ FileUtils.mkdir_p(target_dir)
183
+ icon_install_names(app_id, preferred).each do |name|
184
+ FileUtils.cp(source, File.join(target_dir, "#{name}.#{ext}"))
185
+ end
186
+ end
187
+ icon_paths(app_id).any? ? app_id : nil
188
+ rescue DebPackage::FormatError
189
+ nil
190
+ end
191
+
192
+ def icon_paths(app_id)
193
+ Dir.glob(File.join(Paths.icon_root, "*", "apps", "#{app_id}.{png,svg,xpm}"), File::FNM_EXTGLOB)
194
+ end
195
+
196
+ def installed_icon_paths(app_id, preferred)
197
+ icon_install_names(app_id, preferred).flat_map do |name|
198
+ Dir.glob(File.join(Paths.icon_root, "*", "apps", "#{name}.{png,svg,xpm}"), File::FNM_EXTGLOB)
199
+ end.uniq
200
+ end
201
+
202
+ def icon_install_names(app_id, preferred)
203
+ names = [app_id]
204
+ preferred = preferred.to_s
205
+ names << preferred if preferred.match?(/\A[a-zA-Z0-9_.-]+\z/)
206
+ names.uniq
207
+ end
208
+
209
+ def icon_score(entry, preferred)
210
+ base = File.basename(entry, ".*")
211
+ [
212
+ preferred.to_s.empty? || base != preferred ? 1 : 0,
213
+ entry.include?("/resources/app/resources/linux/code.") ? 0 : 1,
214
+ entry.include?("/resources/linux/code.") ? 0 : 1,
215
+ entry.include?("/256x256/") ? 0 : 1,
216
+ entry.include?("/512x512/") ? 0 : 1,
217
+ entry.match?(/(^|\/)(icon|logo|code|cursor)[^\/]*\.(png|svg|xpm)\z/i) ? 0 : 1,
218
+ -entry.length
219
+ ]
220
+ end
221
+
222
+ def fallback_icon_entries(package, preferred)
223
+ preferred_base = File.basename(preferred.to_s, ".*")
224
+ candidates = package.image_entries
225
+ scored = candidates.select do |entry|
226
+ base = File.basename(entry, ".*")
227
+ entry.match?(/(^|\/)(icon|logo|code|cursor)[^\/]*\.(png|svg|xpm)\z/i) ||
228
+ (!preferred_base.empty? && (base == preferred_base || entry.downcase.include?(preferred_base.downcase)))
229
+ end
230
+ scored.empty? ? candidates : scored
231
+ end
232
+
233
+ def icon_theme_size(entry, source)
234
+ match = entry.match(DebPackage::ICON_PATH)
235
+ return match[1] if match
236
+ return "scalable" if File.extname(entry).downcase == ".svg"
237
+ return png_size(source) if File.extname(entry).downcase == ".png"
238
+
239
+ "256x256"
240
+ end
241
+
242
+ def png_size(path)
243
+ File.open(path, "rb") do |file|
244
+ header = file.read(24)
245
+ return "256x256" unless header&.start_with?("\x89PNG\r\n\x1A\n".b)
246
+
247
+ width, height = header.byteslice(16, 8).unpack("NN")
248
+ return "256x256" if width.to_i <= 0 || height.to_i <= 0
249
+
250
+ "#{width}x#{height}"
251
+ end
252
+ rescue SystemCallError
253
+ "256x256"
254
+ end
255
+
256
+ def portable_warnings(package)
257
+ warnings = [
258
+ "Depot installed this package in portable extraction mode and did not use apt, dpkg, sudo, or root maintainer scripts."
259
+ ]
260
+ warnings << "No desktop launcher was found in the package." if package.primary_desktop_entry.nil?
261
+ warnings
262
+ end
263
+ end
264
+ end
265
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "open3"
5
+ require "time"
6
+ require_relative "../packages/flatpak_ref"
7
+ require_relative "../paths"
8
+ require_relative "../result"
9
+ require_relative "../util"
10
+ require_relative "support"
11
+
12
+ module Depot
13
+ module Backends
14
+ class FlatpakRefBackend
15
+ include Support
16
+
17
+ def initialize(store:)
18
+ @store = store
19
+ end
20
+
21
+ def install(inspection, settings: {})
22
+ return Result.err("Flatpak backend cannot install #{inspection.format}") unless inspection.flatpakref?
23
+ return Result.err("Flatpak is not installed on this system.") unless command_available?("flatpak")
24
+
25
+ Paths.ensure_base_dirs
26
+ ref = FlatpakRef.new(inspection.input)
27
+ return Result.err("Invalid Flatpak reference.") unless ref.valid?
28
+ return Result.err("Depot only installs Flatpak application refs right now.") if ref.runtime?
29
+
30
+ app_id = Util.unique_id(Util.slug(ref.name), @store.ids)
31
+ app_dir = File.join(Paths.apps_dir, app_id)
32
+ FileUtils.mkdir_p(app_dir)
33
+
34
+ stdout, stderr, status = Open3.capture3(
35
+ "flatpak", "install", "--user", "--noninteractive", "-y", "--or-update", "--from", inspection.input
36
+ )
37
+ unless status.success?
38
+ message = [stderr, stdout].map(&:to_s).find { |text| text.strip != "" } || "flatpak install failed"
39
+ return Result.err("Flatpak install failed: #{message.strip}")
40
+ end
41
+
42
+ launcher = write_launcher(app_dir, ref.name)
43
+ desktop_entry = exported_desktop_entry(ref.name)
44
+ display_name = desktop_entry ? desktop_name(File.read(desktop_entry)) : ref.display_name
45
+ warnings = inspection.warnings + ["Flatpak handled the download, verification, sandbox, desktop integration, and runtime dependencies."]
46
+ manifest = {
47
+ "schema_version" => 1,
48
+ "app_id" => app_id,
49
+ "display_name" => display_name,
50
+ "default_display_name" => display_name,
51
+ "backend" => "flatpak",
52
+ "install_source" => File.expand_path(inspection.input),
53
+ "source_sha256" => inspection.sha256,
54
+ "source_size" => inspection.size,
55
+ "installed_executable" => launcher,
56
+ "desktop_entry" => desktop_entry,
57
+ "icons" => [],
58
+ "created_files" => [launcher],
59
+ "created_dirs" => [app_dir],
60
+ "installed_at" => Time.now.utc.iso8601,
61
+ "package" => ref.fields,
62
+ "flatpak" => {
63
+ "app_id" => ref.name,
64
+ "branch" => ref.branch,
65
+ "remote" => ref.remote_name,
66
+ "origin" => flatpak_info(ref.name, "--show-origin"),
67
+ "ref" => flatpak_info(ref.name, "--show-ref")
68
+ },
69
+ "permissions" => {
70
+ "executable" => true,
71
+ "requires_sudo" => false,
72
+ "writes_outside_depot" => false,
73
+ "notes" => ["Flatpak manages this app, its sandbox, remotes, runtimes, and exported desktop integration."]
74
+ },
75
+ "sandbox" => {
76
+ "enabled" => true,
77
+ "manager" => "flatpak",
78
+ "preference" => settings.fetch("sandbox_preference", "ask")
79
+ },
80
+ "update" => {
81
+ "mechanism" => "flatpak",
82
+ "source" => ref.url
83
+ },
84
+ "warnings" => warnings
85
+ }
86
+
87
+ manifest_path = @store.write(manifest)
88
+ Result.ok(manifest.merge("manifest_path" => manifest_path), warnings:)
89
+ rescue FlatpakRef::FormatError, SystemCallError => e
90
+ Result.err("Flatpak install failed: #{e.message}")
91
+ end
92
+
93
+ private
94
+
95
+ def write_launcher(app_dir, flatpak_id)
96
+ launcher = File.join(app_dir, "run")
97
+ write_shell_launcher(launcher, "flatpak", "run", "--user", flatpak_id)
98
+ end
99
+
100
+ def exported_desktop_entry(flatpak_id)
101
+ candidates = [
102
+ File.join(Paths.data_home, "flatpak", "exports", "share", "applications", "#{flatpak_id}.desktop"),
103
+ File.join("/var", "lib", "flatpak", "exports", "share", "applications", "#{flatpak_id}.desktop")
104
+ ]
105
+ candidates.find { |path| File.file?(path) }
106
+ end
107
+
108
+ def desktop_name(contents)
109
+ desktop_name_from(contents) || "Flatpak App"
110
+ rescue SystemCallError
111
+ "Flatpak App"
112
+ end
113
+
114
+ def flatpak_info(flatpak_id, flag)
115
+ stdout, = Open3.capture2("flatpak", "info", "--user", flag, flatpak_id)
116
+ stdout.strip.empty? ? nil : stdout.strip
117
+ rescue SystemCallError
118
+ nil
119
+ end
120
+
121
+ end
122
+ end
123
+ end