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,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Depot
4
+ Inspection = Struct.new(
5
+ :input,
6
+ :format,
7
+ :confidence,
8
+ :display_name,
9
+ :sha256,
10
+ :size,
11
+ :executable,
12
+ :metadata,
13
+ :warnings,
14
+ :risks,
15
+ keyword_init: true
16
+ ) do
17
+ def appimage?
18
+ format == "appimage"
19
+ end
20
+
21
+ def deb?
22
+ format == "deb"
23
+ end
24
+
25
+ def archive?
26
+ %w[tar.gz tar.xz tar.zst].include?(format)
27
+ end
28
+
29
+ def rpm?
30
+ format == "rpm"
31
+ end
32
+
33
+ def flatpakref?
34
+ format == "flatpakref"
35
+ end
36
+
37
+ def to_h
38
+ {
39
+ "input" => input,
40
+ "format" => format,
41
+ "confidence" => confidence,
42
+ "display_name" => display_name,
43
+ "sha256" => sha256,
44
+ "size" => size,
45
+ "executable" => executable,
46
+ "metadata" => metadata,
47
+ "warnings" => warnings,
48
+ "risks" => risks
49
+ }
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,387 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require_relative "packages/archive"
5
+ require_relative "packages/deb"
6
+ require_relative "packages/flatpak_ref"
7
+ require_relative "inspection"
8
+ require_relative "packages/rpm"
9
+ require_relative "result"
10
+ require_relative "util"
11
+
12
+ module Depot
13
+ class Inspector
14
+ APPIMAGE_EXT = /\.appimage\z/i
15
+ DEB_EXT = /\.deb\z/i
16
+ RPM_EXT = /\.rpm\z/i
17
+ FLATPAKREF_EXT = /\.flatpakref\z/i
18
+ ARCHIVE_EXT = /\.(tar\.gz|tgz|tar\.xz|txz|tar\.zst|tzst)\z/i
19
+ ELF_MAGIC = "\x7FELF".b.freeze
20
+
21
+ def self.inspect(input, checksum: true)
22
+ new.inspect(input, checksum:)
23
+ end
24
+
25
+ def inspect(input, checksum: true)
26
+ source = input.to_s
27
+ uri = parse_uri(source)
28
+ return inspect_url(uri) if uri&.absolute?
29
+ return Result.err("Path does not exist: #{source}") unless File.exist?(source)
30
+ return Result.err("Input is not a regular file: #{source}") unless File.file?(source)
31
+
32
+ Result.ok(file_inspection(source, checksum:))
33
+ end
34
+
35
+ private
36
+
37
+ def inspect_url(uri)
38
+ warnings = ["URL installs are planned; this build installs local files through available backends."]
39
+ inspection = Inspection.new(
40
+ input: uri.to_s,
41
+ format: format_from_name(uri.path),
42
+ confidence: "low",
43
+ display_name: File.basename(uri.path),
44
+ sha256: nil,
45
+ size: nil,
46
+ executable: false,
47
+ metadata: {},
48
+ warnings:,
49
+ risks: ["Depot cannot verify remote content until it is downloaded."]
50
+ )
51
+ Result.ok(inspection, warnings:)
52
+ end
53
+
54
+ def file_inspection(path, checksum: true)
55
+ extension_match = path.match?(APPIMAGE_EXT)
56
+ deb_extension_match = path.match?(DEB_EXT)
57
+ rpm_extension_match = path.match?(RPM_EXT)
58
+ flatpakref_extension_match = path.match?(FLATPAKREF_EXT)
59
+ archive_extension_match = path.match?(ARCHIVE_EXT)
60
+ elf = elf?(path)
61
+ deb_package = deb_extension_match ? DebPackage.new(path) : nil
62
+ deb_valid = deb_package&.valid?
63
+ rpm_package = rpm_extension_match ? RpmPackage.new(path) : nil
64
+ rpm_valid = rpm_package&.valid?
65
+ flatpak_ref = flatpakref_extension_match ? FlatpakRef.new(path) : nil
66
+ flatpakref_valid = flatpak_ref&.valid?
67
+ archive_package = archive_extension_match ? ArchivePackage.new(path) : nil
68
+ archive_valid = archive_package&.valid?
69
+ warnings = []
70
+ risks = []
71
+ metadata = {}
72
+ display_name = if archive_valid
73
+ archive_package.display_name
74
+ elsif rpm_valid
75
+ rpm_package.display_name
76
+ elsif flatpakref_valid
77
+ flatpak_ref.display_name
78
+ elsif rpm_extension_match
79
+ File.basename(path, ".rpm")
80
+ elsif flatpakref_extension_match
81
+ File.basename(path, ".flatpakref")
82
+ elsif archive_extension_match
83
+ File.basename(path).sub(/\.(tar\.gz|tgz|tar\.xz|txz|tar\.zst|tzst)\z/i, "")
84
+ else
85
+ File.basename(path, File.extname(path))
86
+ end
87
+
88
+ format = if extension_match && elf
89
+ "appimage"
90
+ elsif extension_match
91
+ warnings << "File name looks like an AppImage, but the ELF header was not found."
92
+ "appimage"
93
+ elsif deb_valid
94
+ "deb"
95
+ elsif deb_extension_match
96
+ warnings << "File name looks like a Debian package, but the archive structure was not recognized."
97
+ "deb"
98
+ elsif rpm_valid
99
+ "rpm"
100
+ elsif rpm_extension_match
101
+ warnings << "File name looks like an RPM package, but the RPM header was not recognized."
102
+ "rpm"
103
+ elsif flatpakref_valid
104
+ "flatpakref"
105
+ elsif flatpakref_extension_match
106
+ warnings << "File name looks like a Flatpak reference, but the file structure was not recognized."
107
+ "flatpakref"
108
+ elsif archive_valid
109
+ archive_package.format
110
+ elsif archive_extension_match
111
+ warnings << "File name looks like a tar archive, but the archive could not be read."
112
+ archive_package&.format || "tar.gz"
113
+ elsif elf
114
+ warnings << "File is an ELF executable, but does not use the .AppImage extension."
115
+ "elf"
116
+ else
117
+ "unknown"
118
+ end
119
+
120
+ confidence = if extension_match && elf
121
+ "high"
122
+ elsif deb_valid
123
+ "high"
124
+ elsif archive_valid
125
+ "high"
126
+ elsif rpm_valid
127
+ "high"
128
+ elsif flatpakref_valid
129
+ "high"
130
+ elsif extension_match || deb_extension_match || rpm_extension_match || flatpakref_extension_match || archive_extension_match || elf
131
+ "medium"
132
+ else
133
+ "low"
134
+ end
135
+
136
+ risks << "Installing may execute the AppImage runtime to extract desktop metadata." if format == "appimage"
137
+ if format == "deb"
138
+ deb_metadata = deb_valid ? deb_metadata(deb_package) : {}
139
+ metadata.merge!(deb_metadata)
140
+ warnings.concat(deb_warnings(deb_metadata))
141
+ risks.concat(deb_risks(deb_metadata))
142
+ end
143
+ if archive_format?(format)
144
+ archive_metadata = archive_valid ? archive_metadata(archive_package) : {}
145
+ metadata.merge!(archive_metadata)
146
+ warnings.concat(archive_warnings(archive_metadata))
147
+ risks.concat(archive_risks(archive_metadata))
148
+ end
149
+ if format == "rpm"
150
+ rpm_metadata = rpm_valid ? rpm_metadata(rpm_package) : {}
151
+ metadata.merge!(rpm_metadata)
152
+ warnings.concat(rpm_warnings(rpm_metadata))
153
+ risks.concat(rpm_risks(rpm_metadata))
154
+ end
155
+ if format == "flatpakref"
156
+ flatpakref_metadata = flatpakref_valid ? flatpakref_metadata(flatpak_ref) : {}
157
+ metadata.merge!(flatpakref_metadata)
158
+ warnings.concat(flatpakref_warnings(flatpakref_metadata))
159
+ risks.concat(flatpakref_risks(flatpakref_metadata))
160
+ end
161
+ risks << "This format does not have an installer backend yet." unless %w[appimage deb rpm flatpakref tar.gz tar.xz tar.zst].include?(format)
162
+
163
+ Inspection.new(
164
+ input: path,
165
+ format:,
166
+ confidence:,
167
+ display_name:,
168
+ sha256: checksum ? Util.sha256(path) : nil,
169
+ size: File.size(path),
170
+ executable: File.executable?(path),
171
+ metadata: {
172
+ "extension_appimage" => extension_match,
173
+ "extension_deb" => deb_extension_match,
174
+ "extension_rpm" => rpm_extension_match,
175
+ "extension_flatpakref" => flatpakref_extension_match,
176
+ "extension_archive" => archive_extension_match,
177
+ "elf" => elf
178
+ }.merge(metadata),
179
+ warnings:,
180
+ risks:
181
+ )
182
+ end
183
+
184
+ def elf?(path)
185
+ File.open(path, "rb") { |file| file.read(4) == ELF_MAGIC }
186
+ rescue SystemCallError
187
+ false
188
+ end
189
+
190
+ def format_from_name(name)
191
+ return "appimage" if name.match?(APPIMAGE_EXT)
192
+ return "deb" if name.match?(DEB_EXT)
193
+ return "rpm" if name.match?(RPM_EXT)
194
+ return "flatpakref" if name.match?(FLATPAKREF_EXT)
195
+ return ArchivePackage.new(name).format if name.match?(ARCHIVE_EXT)
196
+
197
+ "unknown"
198
+ end
199
+
200
+ def archive_format?(format)
201
+ %w[tar.gz tar.xz tar.zst].include?(format)
202
+ end
203
+
204
+ def deb_metadata(package)
205
+ fields = package.control_fields
206
+ {
207
+ "debian_binary" => package.debian_binary,
208
+ "ar_members" => package.ar_members,
209
+ "control_archive" => package.control_archive_name,
210
+ "data_archive" => package.data_archive_name,
211
+ "package" => fields["Package"],
212
+ "version" => fields["Version"],
213
+ "architecture" => fields["Architecture"],
214
+ "maintainer" => fields["Maintainer"],
215
+ "description" => fields["Description"],
216
+ "depends" => fields["Depends"],
217
+ "homepage" => fields["Homepage"],
218
+ "maintainer_scripts" => package.maintainer_scripts,
219
+ "desktop_entries" => package.desktop_entries,
220
+ "primary_desktop_entry" => package.primary_desktop_entry,
221
+ "icon_count" => package.icon_entries.size,
222
+ "executable_candidates" => package.executable_entries.first(12),
223
+ "data_entry_count" => package.data_members.size
224
+ }
225
+ rescue DebPackage::FormatError => e
226
+ { "deb_error" => e.message }
227
+ end
228
+
229
+ def deb_warnings(metadata)
230
+ warnings = [
231
+ "This is a Debian package. Debian packages are usually designed for Debian-based distributions and may not behave correctly on every Linux distribution."
232
+ ]
233
+ scripts = metadata.fetch("maintainer_scripts", [])
234
+ warnings << "Maintainer scripts are present and will not be executed in Depot portable mode: #{scripts.join(", ")}." unless scripts.empty?
235
+ dependencies = dependency_names(metadata["depends"])
236
+ unless dependencies.empty?
237
+ warnings << "Dependencies are declared and are not automatically installed in portable mode: #{dependency_summary(dependencies)}."
238
+ end
239
+ warnings
240
+ end
241
+
242
+ def deb_risks(metadata)
243
+ risks = [
244
+ "Depot installs Debian packages by portable extraction, not by registering them with apt or dpkg.",
245
+ "Some Debian packages assume system paths, services, users, or libraries that may not exist outside Debian-family systems."
246
+ ]
247
+ risks << "No desktop launcher was found; Depot may not be able to integrate this package cleanly." unless metadata["primary_desktop_entry"]
248
+ risks
249
+ end
250
+
251
+ def dependency_names(depends)
252
+ depends.to_s.split(",").map do |dependency|
253
+ dependency.split("|").first.to_s.strip.sub(/\s*\(.+\)\z/, "")
254
+ end.reject(&:empty?).uniq
255
+ end
256
+
257
+ def dependency_summary(dependencies)
258
+ shown = dependencies.first(6).join(", ")
259
+ extra = dependencies.length - 6
260
+ extra.positive? ? "#{dependencies.length} dependencies, including #{shown}, and #{extra} more" : shown
261
+ end
262
+
263
+ def archive_metadata(package)
264
+ {
265
+ "archive_format" => package.format,
266
+ "archive_root" => package.common_root,
267
+ "desktop_entries" => package.desktop_entries,
268
+ "primary_desktop_entry" => package.primary_desktop_entry,
269
+ "icon_count" => package.image_entries.size,
270
+ "executable_candidates" => package.executable_candidates,
271
+ "script_entries" => package.script_entries,
272
+ "source_markers" => package.source_markers,
273
+ "data_entry_count" => package.members.size
274
+ }
275
+ rescue ArchivePackage::FormatError => e
276
+ { "archive_error" => e.message }
277
+ end
278
+
279
+ def archive_warnings(metadata)
280
+ warnings = ["This archive is not a formal Linux package. Depot will infer the app layout and will not run installer scripts."]
281
+ scripts = metadata.fetch("script_entries", [])
282
+ markers = metadata.fetch("source_markers", [])
283
+ warnings << "Installer-like scripts were found and will not be executed: #{scripts.first(6).join(", ")}." unless scripts.empty?
284
+ warnings << "Source/build markers were found: #{markers.join(", ")}." unless markers.empty?
285
+ warnings
286
+ end
287
+
288
+ def archive_risks(metadata)
289
+ risks = ["Tar archives can contain arbitrary layouts, so Depot uses portable extraction and desktop inference."]
290
+ risks << "No desktop launcher was found; Depot will generate one if it can identify an executable." unless metadata["primary_desktop_entry"]
291
+ risks << "No executable candidate was found; install may not be launchable." if metadata.fetch("executable_candidates", []).empty?
292
+ risks
293
+ end
294
+
295
+ def rpm_metadata(package)
296
+ fields = package.package_fields
297
+ {
298
+ "package" => fields["Name"],
299
+ "version" => fields["Version"],
300
+ "release" => fields["Release"],
301
+ "architecture" => fields["Architecture"],
302
+ "summary" => fields["Summary"],
303
+ "description" => fields["Description"],
304
+ "license" => fields["License"],
305
+ "url" => fields["URL"],
306
+ "payload_format" => fields["PayloadFormat"],
307
+ "payload_compressor" => fields["PayloadCompressor"],
308
+ "requires" => package.requires,
309
+ "scriptlets" => package.scriptlets,
310
+ "desktop_entries" => package.desktop_entries,
311
+ "primary_desktop_entry" => package.primary_desktop_entry,
312
+ "icon_count" => package.icon_entries.size,
313
+ "executable_candidates" => package.executable_candidates,
314
+ "data_entry_count" => package.file_entries.size
315
+ }
316
+ rescue RpmPackage::FormatError => e
317
+ { "rpm_error" => e.message }
318
+ end
319
+
320
+ def rpm_warnings(metadata)
321
+ warnings = [
322
+ "This is an RPM package. RPM packages are usually designed for RPM-based distributions and may not behave correctly on every Linux distribution."
323
+ ]
324
+ scriptlets = metadata.fetch("scriptlets", [])
325
+ warnings << "RPM scriptlets are present and will not be executed in Depot portable mode: #{scriptlets.join(", ")}." unless scriptlets.empty?
326
+ requirements = rpm_requirement_names(metadata["requires"])
327
+ unless requirements.empty?
328
+ warnings << "RPM requirements are declared and are not automatically installed in portable mode: #{dependency_summary(requirements)}."
329
+ end
330
+ warnings
331
+ end
332
+
333
+ def rpm_risks(metadata)
334
+ risks = [
335
+ "Depot installs RPM packages by portable extraction, not by registering them with rpm, dnf, or zypper.",
336
+ "Some RPM packages assume system paths, services, users, or libraries that may not exist outside RPM-family systems."
337
+ ]
338
+ risks << "No desktop launcher was found; Depot may not be able to integrate this package cleanly." unless metadata["primary_desktop_entry"]
339
+ risks
340
+ end
341
+
342
+ def rpm_requirement_names(requires)
343
+ Array(requires).map do |requirement|
344
+ requirement.to_s.sub(/\s*\(.+\)\z/, "")
345
+ end.reject { |name| name.empty? || name.start_with?("rpmlib(") }.uniq
346
+ end
347
+
348
+ def flatpakref_metadata(ref)
349
+ {
350
+ "name" => ref.name,
351
+ "title" => ref.title,
352
+ "branch" => ref.branch,
353
+ "url" => ref.url,
354
+ "suggest_remote_name" => ref.remote_name,
355
+ "is_runtime" => ref.runtime?,
356
+ "runtime_repo" => ref.runtime_repo,
357
+ "gpg_key_present" => ref.gpg_key?,
358
+ "fields" => ref.fields
359
+ }
360
+ rescue FlatpakRef::FormatError => e
361
+ { "flatpakref_error" => e.message }
362
+ end
363
+
364
+ def flatpakref_warnings(metadata)
365
+ warnings = ["This Flatpak reference will be installed through the system Flatpak tool into the user Flatpak installation."]
366
+ warnings << "This ref points to a runtime; Depot currently focuses on Flatpak application refs." if metadata["is_runtime"]
367
+ warnings << "No GPG key is embedded in this ref." unless metadata["gpg_key_present"]
368
+ warnings
369
+ end
370
+
371
+ def flatpakref_risks(metadata)
372
+ risks = [
373
+ "Flatpak may download the application, runtime dependencies, and remote metadata from #{metadata["url"] || "the configured remote"}.",
374
+ "Flatpak manages sandboxing, updates, exported launchers, and uninstall behavior for this app."
375
+ ]
376
+ risks << "A runtime repo may be added or used: #{metadata["runtime_repo"]}." if metadata["runtime_repo"]
377
+ risks
378
+ end
379
+
380
+ def parse_uri(value)
381
+ uri = URI.parse(value)
382
+ uri if uri.scheme && uri.host
383
+ rescue URI::InvalidURIError
384
+ nil
385
+ end
386
+ end
387
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "backends/app_image"
5
+ require_relative "backends/archive"
6
+ require_relative "backends/deb"
7
+ require_relative "backends/flatpak_ref"
8
+ require_relative "backends/rpm"
9
+ require_relative "inspector"
10
+ require_relative "manifest_store"
11
+ require_relative "result"
12
+ require_relative "sandbox"
13
+ require_relative "settings"
14
+
15
+ module Depot
16
+ class Installer
17
+ def self.install(input, options = {})
18
+ new.install(input, options)
19
+ end
20
+
21
+ def initialize(store: ManifestStore.new, settings: Settings.new)
22
+ @store = store
23
+ @settings = settings
24
+ end
25
+
26
+ def install(input, options = {})
27
+ inspection_result = Inspector.inspect(input)
28
+ return inspection_result unless inspection_result.ok?
29
+
30
+ inspection = inspection_result.value
31
+ merged_settings = @settings.load.merge(options.fetch(:settings, {}))
32
+ result = case inspection.format
33
+ when "appimage"
34
+ Backends::AppImage.new(store: @store).install(inspection, settings: merged_settings)
35
+ when "deb"
36
+ Backends::Deb.new(store: @store).install(inspection, settings: merged_settings)
37
+ when "tar.gz", "tar.xz", "tar.zst"
38
+ Backends::Archive.new(store: @store).install(inspection, settings: merged_settings)
39
+ when "rpm"
40
+ Backends::Rpm.new(store: @store).install(inspection, settings: merged_settings)
41
+ when "flatpakref"
42
+ Backends::FlatpakRefBackend.new(store: @store).install(inspection, settings: merged_settings)
43
+ else
44
+ Result.err("No installer backend is available for detected format: #{inspection.format}")
45
+ end
46
+ return result unless result.ok?
47
+
48
+ sandboxed = Sandbox.apply(result.value, settings: merged_settings, store: @store)
49
+ return sandboxed unless sandboxed.ok?
50
+
51
+ Result.ok(sandboxed.value, warnings: result.warnings)
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require_relative "paths"
6
+
7
+ module Depot
8
+ class ManifestStore
9
+ attr_reader :dir
10
+
11
+ def initialize(dir = Paths.manifests_dir)
12
+ @dir = dir
13
+ end
14
+
15
+ def all
16
+ Dir.glob(File.join(dir, "*.json")).sort.filter_map { |path| read_file(path) }
17
+ end
18
+
19
+ def ids
20
+ all.map { |manifest| manifest.fetch("app_id") }
21
+ end
22
+
23
+ def find(app_id)
24
+ path = manifest_path(app_id)
25
+ return nil unless File.exist?(path)
26
+
27
+ read_file(path)
28
+ end
29
+
30
+ def write(manifest)
31
+ FileUtils.mkdir_p(dir)
32
+ path = manifest_path(manifest.fetch("app_id"))
33
+ File.write(path, JSON.pretty_generate(manifest) + "\n")
34
+ path
35
+ end
36
+
37
+ def delete(app_id)
38
+ FileUtils.rm_f(manifest_path(app_id))
39
+ end
40
+
41
+ def manifest_path(app_id)
42
+ File.join(dir, "#{app_id}.json")
43
+ end
44
+
45
+ private
46
+
47
+ def read_file(path)
48
+ JSON.parse(File.read(path))
49
+ rescue JSON::ParserError
50
+ nil
51
+ end
52
+ end
53
+ end