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,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "open3"
5
+
6
+ module Depot
7
+ class ArchivePackage
8
+ DESKTOP_PATH = %r{\A(?:\./)?(.+/)?[^/]+\.desktop\z}
9
+ HICOLOR_ICON_PATH = %r{/icons/hicolor/([^/]+)/apps/([^/]+)\.(png|svg|xpm)\z}i
10
+ IMAGE_EXT = /\.(png|svg|xpm)\z/i
11
+ SCRIPT_EXT = /\.(sh|bash|run|pl|py|rb)\z/i
12
+ SOURCE_MARKERS = %w[configure Makefile CMakeLists.txt meson.build setup.py package.json Cargo.toml go.mod].freeze
13
+
14
+ attr_reader :path
15
+
16
+ def initialize(path)
17
+ @path = path
18
+ end
19
+
20
+ def valid?
21
+ tar_name? && !members.empty?
22
+ rescue FormatError
23
+ false
24
+ end
25
+
26
+ def format
27
+ case File.basename(path)
28
+ when /\.t(?:ar\.)?gz\z/i, /\.tgz\z/i then "tar.gz"
29
+ when /\.tar\.xz\z/i, /\.txz\z/i then "tar.xz"
30
+ when /\.tar\.zst\z/i, /\.tzst\z/i then "tar.zst"
31
+ else "archive"
32
+ end
33
+ end
34
+
35
+ def display_name
36
+ root = common_root
37
+ return titleize(root) if root && root != "."
38
+
39
+ titleize(File.basename(path).sub(/\.(tar\.gz|tgz|tar\.xz|txz|tar\.zst|tzst)\z/i, ""))
40
+ end
41
+
42
+ def members
43
+ @members ||= list_archive
44
+ end
45
+
46
+ def clean_members
47
+ members.map { |entry| clean_entry(entry) }
48
+ end
49
+
50
+ def desktop_entries
51
+ clean_members.select { |entry| entry.end_with?(".desktop") }
52
+ end
53
+
54
+ def primary_desktop_entry
55
+ desktop_entries.min_by { |entry| desktop_score(entry) }
56
+ end
57
+
58
+ def image_entries
59
+ clean_members.select { |entry| entry.match?(IMAGE_EXT) }
60
+ end
61
+
62
+ def icon_entries
63
+ clean_members.select { |entry| entry.match?(HICOLOR_ICON_PATH) }
64
+ end
65
+
66
+ def executable_candidates
67
+ clean_members.reject { |entry| entry.end_with?("/") }
68
+ .select { |entry| executable_name?(entry) }
69
+ .first(24)
70
+ end
71
+
72
+ def script_entries
73
+ clean_members.reject { |entry| entry.end_with?("/") }
74
+ .select { |entry| File.basename(entry).match?(SCRIPT_EXT) || File.basename(entry).match?(/\Ainstall/i) }
75
+ .first(24)
76
+ end
77
+
78
+ def source_markers
79
+ clean_members.map { |entry| File.basename(entry) }
80
+ .select { |name| SOURCE_MARKERS.include?(name) }
81
+ .uniq
82
+ end
83
+
84
+ def common_root
85
+ roots = clean_members.reject(&:empty?).map { |entry| entry.split("/", 2).first }.uniq
86
+ roots.length == 1 ? roots.first : nil
87
+ end
88
+
89
+ def read_entry(entry)
90
+ wanted = clean_entry(entry)
91
+ candidate = members.find { |member| clean_entry(member) == wanted }
92
+ raise FormatError, "Could not find #{entry} in archive" unless candidate
93
+
94
+ stdout, stderr, status = Open3.capture3("tar", *tar_options, "-xOf", path, candidate)
95
+ raise FormatError, "Could not read #{entry}: #{stderr.empty? ? stdout : stderr}" unless status.success?
96
+
97
+ stdout
98
+ end
99
+
100
+ def extract_to(destination)
101
+ FileUtils.mkdir_p(destination)
102
+ assert_safe_entries!
103
+ stdout, stderr, status = Open3.capture3("tar", *tar_options, "-xf", path, "-C", destination)
104
+ raise FormatError, "Could not extract archive: #{stderr.empty? ? stdout : stderr}" unless status.success?
105
+ end
106
+
107
+ class FormatError < StandardError; end
108
+
109
+ private
110
+
111
+ def tar_name?
112
+ File.basename(path).match?(/\.(tar\.gz|tgz|tar\.xz|txz|tar\.zst|tzst)\z/i)
113
+ end
114
+
115
+ def list_archive
116
+ stdout, stderr, status = Open3.capture3("tar", *tar_options, "-tf", path)
117
+ raise FormatError, "Could not list archive: #{stderr.empty? ? stdout : stderr}" unless status.success?
118
+
119
+ stdout.lines.map(&:chomp).reject(&:empty?)
120
+ end
121
+
122
+ def tar_options
123
+ case File.basename(path)
124
+ when /\.(tar\.gz|tgz)\z/i then ["-z"]
125
+ when /\.(tar\.xz|txz)\z/i then ["-J"]
126
+ when /\.(tar\.zst|tzst)\z/i then ["--zstd"]
127
+ else []
128
+ end
129
+ end
130
+
131
+ def assert_safe_entries!
132
+ unsafe = members.find do |entry|
133
+ clean = clean_entry(entry)
134
+ entry.start_with?("/") || clean.split("/").include?("..")
135
+ end
136
+ raise FormatError, "Unsafe path in archive: #{unsafe}" if unsafe
137
+ end
138
+
139
+ def clean_entry(entry)
140
+ entry.to_s.sub(%r{\A\./}, "")
141
+ end
142
+
143
+ def executable_name?(entry)
144
+ base = File.basename(entry)
145
+ return false if SOURCE_MARKERS.include?(base)
146
+ return false if base.match?(/\A(install|setup|configure)\b/i)
147
+ return true if base == "AppRun"
148
+ return true if entry.include?("/bin/") && !base.include?(".")
149
+ return true if base.match?(SCRIPT_EXT)
150
+
151
+ !base.include?(".") && !entry.include?("/share/") && !entry.include?("/doc/")
152
+ end
153
+
154
+ def desktop_score(entry)
155
+ metadata = parse_desktop_entry(read_entry(entry))
156
+ [
157
+ metadata["NoDisplay"].to_s.downcase == "true" ? 1 : 0,
158
+ metadata["Hidden"].to_s.downcase == "true" ? 1 : 0,
159
+ metadata["Type"] == "Application" ? 0 : 1,
160
+ clean_entry(entry).match?(%r{/share/applications/[^/]+\.desktop\z}) ? 0 : 1,
161
+ metadata["Name"].to_s.downcase.include?("url handler") ? 1 : 0,
162
+ clean_entry(entry).length
163
+ ]
164
+ rescue FormatError
165
+ [9, clean_entry(entry).length]
166
+ end
167
+
168
+ def parse_desktop_entry(contents)
169
+ metadata = {}
170
+ in_desktop_entry = false
171
+ contents.each_line(chomp: true) do |line|
172
+ if line.start_with?("[")
173
+ in_desktop_entry = line.strip == "[Desktop Entry]"
174
+ next
175
+ end
176
+ next unless in_desktop_entry
177
+
178
+ key, value = line.split("=", 2)
179
+ metadata[key] = value if value && !metadata.key?(key)
180
+ end
181
+ metadata
182
+ end
183
+
184
+ def titleize(value)
185
+ value.to_s.gsub(/[_-]+/, " ").split.map(&:capitalize).join(" ")
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "open3"
5
+ require "shellwords"
6
+ require "tempfile"
7
+
8
+ module Depot
9
+ class DebPackage
10
+ SCRIPT_NAMES = %w[preinst postinst prerm postrm config].freeze
11
+ DESKTOP_PATH = %r{\A(?:\./)?usr/share/applications/[^/]+\.desktop\z}
12
+ ICON_PATH = %r{/icons/hicolor/([^/]+)/apps/([^/]+)\.(png|svg|xpm)\z}i
13
+
14
+ attr_reader :path
15
+
16
+ def initialize(path)
17
+ @path = path
18
+ end
19
+
20
+ def valid?
21
+ debian_binary && control_archive_name && data_archive_name
22
+ rescue FormatError
23
+ false
24
+ end
25
+
26
+ def debian_binary
27
+ member("debian-binary")&.strip
28
+ end
29
+
30
+ def control_fields
31
+ @control_fields ||= parse_control(read_control_file("control").to_s)
32
+ end
33
+
34
+ def control_members
35
+ @control_members ||= list_tar(control_archive_name)
36
+ end
37
+
38
+ def data_members
39
+ @data_members ||= list_tar(data_archive_name)
40
+ end
41
+
42
+ def maintainer_scripts
43
+ control_members.map { |entry| clean_entry(entry) }
44
+ .select { |entry| SCRIPT_NAMES.include?(File.basename(entry)) }
45
+ .uniq
46
+ end
47
+
48
+ def desktop_entries
49
+ data_members.map { |entry| clean_entry(entry) }
50
+ .select { |entry| entry.end_with?(".desktop") }
51
+ end
52
+
53
+ def primary_desktop_entry
54
+ desktop_entries.min_by { |entry| desktop_score(entry) }
55
+ end
56
+
57
+ def icon_entries
58
+ data_members.map { |entry| clean_entry(entry) }
59
+ .select { |entry| entry.match?(ICON_PATH) }
60
+ end
61
+
62
+ def image_entries
63
+ data_members.map { |entry| clean_entry(entry) }
64
+ .select { |entry| entry.match?(/\.(png|svg|xpm)\z/i) }
65
+ end
66
+
67
+ def executable_entries
68
+ data_members.map { |entry| clean_entry(entry) }
69
+ .select { |entry| entry.start_with?("usr/bin/", "usr/local/bin/", "opt/") }
70
+ .reject { |entry| entry.end_with?("/") }
71
+ end
72
+
73
+ def control_archive_name
74
+ @control_archive_name ||= archive_name("control.tar")
75
+ end
76
+
77
+ def data_archive_name
78
+ @data_archive_name ||= archive_name("data.tar")
79
+ end
80
+
81
+ def ar_members
82
+ @ar_members ||= parse_ar.keys
83
+ end
84
+
85
+ def read_data_entry(entry)
86
+ read_tar_entry(data_archive_name, entry)
87
+ end
88
+
89
+ def extract_data_to(destination)
90
+ FileUtils.mkdir_p(destination)
91
+ assert_safe_entries!(data_members)
92
+ with_member_file(data_archive_name) do |archive|
93
+ stdout, stderr, status = Open3.capture3(
94
+ "tar",
95
+ *tar_options(data_archive_name),
96
+ "-xf",
97
+ archive,
98
+ "-C",
99
+ destination
100
+ )
101
+ raise FormatError, "Could not extract data archive: #{stderr.empty? ? stdout : stderr}" unless status.success?
102
+ end
103
+ end
104
+
105
+ private
106
+
107
+ class FormatError < StandardError; end
108
+
109
+ def member(name)
110
+ parse_ar[normalize_ar_name(name)]
111
+ end
112
+
113
+ def archive_name(prefix)
114
+ ar_members.find { |name| name.start_with?(prefix) }
115
+ end
116
+
117
+ def read_control_file(name)
118
+ read_tar_entry(control_archive_name, name)
119
+ end
120
+
121
+ def read_tar_entry(archive_name, entry)
122
+ wanted = clean_entry(entry)
123
+ candidate = list_tar(archive_name).find { |member| clean_entry(member) == wanted }
124
+ raise FormatError, "Could not find #{entry} in #{archive_name}" unless candidate
125
+
126
+ with_member_file(archive_name) do |archive|
127
+ stdout, stderr, status = Open3.capture3(
128
+ "tar",
129
+ *tar_options(archive_name),
130
+ "-xOf",
131
+ archive,
132
+ candidate
133
+ )
134
+ raise FormatError, "Could not read #{entry}: #{stderr.empty? ? stdout : stderr}" unless status.success?
135
+
136
+ stdout
137
+ end
138
+ end
139
+
140
+ def list_tar(archive_name)
141
+ with_member_file(archive_name) do |archive|
142
+ stdout, stderr, status = Open3.capture3("tar", *tar_options(archive_name), "-tf", archive)
143
+ raise FormatError, "Could not list #{archive_name}: #{stderr.empty? ? stdout : stderr}" unless status.success?
144
+
145
+ stdout.lines.map(&:chomp).reject(&:empty?)
146
+ end
147
+ end
148
+
149
+ def with_member_file(name)
150
+ data = member(name)
151
+ raise FormatError, "Missing #{name}" unless data
152
+
153
+ file = Tempfile.new(["depot-deb-", File.extname(name)])
154
+ file.binmode
155
+ file.write(data)
156
+ file.close
157
+ yield file.path
158
+ ensure
159
+ file&.unlink
160
+ end
161
+
162
+ def tar_options(name)
163
+ case name
164
+ when /\.tar\.xz\z/ then ["-J"]
165
+ when /\.tar\.gz\z/ then ["-z"]
166
+ when /\.tar\.bz2\z/ then ["-j"]
167
+ when /\.tar\.zst\z/ then ["--zstd"]
168
+ else []
169
+ end
170
+ end
171
+
172
+ def parse_control(text)
173
+ fields = {}
174
+ current = nil
175
+ text.each_line(chomp: true) do |line|
176
+ if line.start_with?(" ", "\t")
177
+ fields[current] = [fields[current], line.sub(/\A[ \t]/, "")].compact.join("\n") if current
178
+ next
179
+ end
180
+
181
+ key, value = line.split(":", 2)
182
+ next unless value
183
+
184
+ current = key
185
+ fields[key] = value.strip
186
+ end
187
+ fields
188
+ end
189
+
190
+ def assert_safe_entries!(entries)
191
+ unsafe = entries.find do |entry|
192
+ clean = clean_entry(entry)
193
+ entry.start_with?("/") || clean.split("/").include?("..")
194
+ end
195
+ raise FormatError, "Unsafe path in data archive: #{unsafe}" if unsafe
196
+ end
197
+
198
+ def clean_entry(entry)
199
+ entry.to_s.sub(%r{\A\./}, "")
200
+ end
201
+
202
+ def desktop_score(entry)
203
+ metadata = parse_desktop_entry(read_data_entry(entry))
204
+ [
205
+ metadata["NoDisplay"].to_s.downcase == "true" ? 1 : 0,
206
+ metadata["Hidden"].to_s.downcase == "true" ? 1 : 0,
207
+ metadata["Type"] == "Application" ? 0 : 1,
208
+ clean_entry(entry).match?(DESKTOP_PATH) ? 0 : 1,
209
+ metadata["Name"].to_s.downcase.include?("url handler") ? 1 : 0,
210
+ File.basename(entry).include?("-url-handler") ? 1 : 0,
211
+ clean_entry(entry).length
212
+ ]
213
+ rescue FormatError
214
+ [9, clean_entry(entry).length]
215
+ end
216
+
217
+ def parse_desktop_entry(contents)
218
+ metadata = {}
219
+ in_desktop_entry = false
220
+ contents.each_line(chomp: true) do |line|
221
+ if line.start_with?("[")
222
+ in_desktop_entry = line.strip == "[Desktop Entry]"
223
+ next
224
+ end
225
+ next unless in_desktop_entry
226
+
227
+ key, value = line.split("=", 2)
228
+ metadata[key] = value if value && !metadata.key?(key)
229
+ end
230
+ metadata
231
+ end
232
+
233
+ def parse_ar
234
+ @parse_ar ||= begin
235
+ File.open(path, "rb") do |file|
236
+ raise FormatError, "Not an ar archive" unless file.read(8) == "!<arch>\n"
237
+
238
+ members = {}
239
+ until file.eof?
240
+ header = file.read(60)
241
+ break if header.nil? || header.empty?
242
+ raise FormatError, "Truncated ar header" unless header.bytesize == 60
243
+ raise FormatError, "Invalid ar member header" unless header.byteslice(58, 2) == "`\n"
244
+
245
+ name = normalize_ar_name(header.byteslice(0, 16))
246
+ size = header.byteslice(48, 10).to_s.strip.to_i
247
+ data = file.read(size)
248
+ raise FormatError, "Truncated ar member #{name}" unless data && data.bytesize == size
249
+
250
+ file.read(1) if size.odd?
251
+ members[name] = data
252
+ end
253
+ members
254
+ end
255
+ end
256
+ end
257
+
258
+ def normalize_ar_name(name)
259
+ name.to_s.strip.sub(%r{/\z}, "")
260
+ end
261
+ end
262
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Depot
4
+ class FlatpakRef
5
+ GROUP = "Flatpak Ref"
6
+ BOOLEAN_KEYS = %w[IsRuntime].freeze
7
+
8
+ attr_reader :path
9
+
10
+ def initialize(path)
11
+ @path = path
12
+ end
13
+
14
+ def valid?
15
+ fields.fetch("Name", "").to_s != "" && fields.fetch("Url", "").to_s != ""
16
+ rescue FormatError
17
+ false
18
+ end
19
+
20
+ def fields
21
+ @fields ||= parse
22
+ end
23
+
24
+ def name
25
+ fields["Name"]
26
+ end
27
+
28
+ def branch
29
+ fields["Branch"] || "master"
30
+ end
31
+
32
+ def title
33
+ fields["Title"]
34
+ end
35
+
36
+ def display_name
37
+ title.to_s.sub(/\s+from\s+\S+\z/i, "").then { |value| value.empty? ? name : value }
38
+ end
39
+
40
+ def runtime?
41
+ fields["IsRuntime"] == true
42
+ end
43
+
44
+ def remote_name
45
+ fields["SuggestRemoteName"]
46
+ end
47
+
48
+ def url
49
+ fields["Url"]
50
+ end
51
+
52
+ def runtime_repo
53
+ fields["RuntimeRepo"]
54
+ end
55
+
56
+ def gpg_key?
57
+ fields["GPGKey"].to_s.strip != ""
58
+ end
59
+
60
+ class FormatError < StandardError; end
61
+
62
+ private
63
+
64
+ def parse
65
+ current_group = nil
66
+ parsed = {}
67
+ File.foreach(path, chomp: true) do |line|
68
+ stripped = line.strip
69
+ next if stripped.empty? || stripped.start_with?("#", ";")
70
+
71
+ if stripped.start_with?("[") && stripped.end_with?("]")
72
+ current_group = stripped[1...-1]
73
+ next
74
+ end
75
+ next unless current_group == GROUP
76
+
77
+ key, value = stripped.split("=", 2)
78
+ next unless key && value
79
+
80
+ parsed[key] = BOOLEAN_KEYS.include?(key) ? value.downcase == "true" : value
81
+ end
82
+
83
+ raise FormatError, "Missing [#{GROUP}] group" if parsed.empty?
84
+
85
+ parsed
86
+ rescue SystemCallError => e
87
+ raise FormatError, e.message
88
+ end
89
+ end
90
+ end