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.
- checksums.yaml +7 -0
- data/Gemfile +10 -0
- data/README.md +56 -0
- data/Rakefile +10 -0
- data/bin/depot +8 -0
- data/bin/depot-gui +14 -0
- data/bin/setup-rubyqt6 +72 -0
- data/fixtures/assets/download.png +0 -0
- data/fixtures/flatpakrefs/org.qbittorrent.qBittorrent.flatpakref +10 -0
- data/fixtures/rpms/Modrinth App-0.13.14-1.x86_64.rpm +0 -0
- data/lib/depot/app_customizer.rb +152 -0
- data/lib/depot/assets.rb +11 -0
- data/lib/depot/backends/app_image.rb +210 -0
- data/lib/depot/backends/archive.rb +263 -0
- data/lib/depot/backends/deb.rb +265 -0
- data/lib/depot/backends/flatpak_ref.rb +123 -0
- data/lib/depot/backends/rpm.rb +280 -0
- data/lib/depot/backends/support.rb +39 -0
- data/lib/depot/cli.rb +344 -0
- data/lib/depot/desktop_entry.rb +37 -0
- data/lib/depot/doctor.rb +59 -0
- data/lib/depot/gui/app.rb +23 -0
- data/lib/depot/gui/drop_panel.rb +80 -0
- data/lib/depot/gui/main_window.rb +1196 -0
- data/lib/depot/inspection.rb +52 -0
- data/lib/depot/inspector.rb +387 -0
- data/lib/depot/installer.rb +54 -0
- data/lib/depot/manifest_store.rb +53 -0
- data/lib/depot/packages/archive.rb +188 -0
- data/lib/depot/packages/deb.rb +262 -0
- data/lib/depot/packages/flatpak_ref.rb +90 -0
- data/lib/depot/packages/rpm.rb +301 -0
- data/lib/depot/paths.rb +57 -0
- data/lib/depot/result.rb +13 -0
- data/lib/depot/sandbox.rb +285 -0
- data/lib/depot/settings.rb +43 -0
- data/lib/depot/source_resolver.rb +36 -0
- data/lib/depot/uninstaller.rb +90 -0
- data/lib/depot/update_downloader.rb +136 -0
- data/lib/depot/updater.rb +230 -0
- data/lib/depot/util.rb +33 -0
- data/lib/depot/version.rb +5 -0
- data/lib/depot.rb +21 -0
- metadata +139 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module Depot
|
|
7
|
+
class RpmPackage
|
|
8
|
+
MAGIC = "\xED\xAB\xEE\xDB".b.freeze
|
|
9
|
+
HEADER_MAGIC = "\x8E\xAD\xE8".b.freeze
|
|
10
|
+
HICOLOR_ICON_PATH = %r{/icons/hicolor/([^/]+)/apps/([^/]+)\.(png|svg|xpm)\z}i
|
|
11
|
+
IMAGE_EXT = /\.(png|svg|xpm)\z/i
|
|
12
|
+
SCRIPT_TAGS = {
|
|
13
|
+
1023 => "preinstall",
|
|
14
|
+
1024 => "postinstall",
|
|
15
|
+
1025 => "preuninstall",
|
|
16
|
+
1026 => "postuninstall",
|
|
17
|
+
1065 => "verify",
|
|
18
|
+
1085 => "triggerinstall",
|
|
19
|
+
1086 => "triggeruninstall",
|
|
20
|
+
1087 => "triggerpostuninstall"
|
|
21
|
+
}.freeze
|
|
22
|
+
TAGS = {
|
|
23
|
+
1000 => "Name",
|
|
24
|
+
1001 => "Version",
|
|
25
|
+
1002 => "Release",
|
|
26
|
+
1004 => "Summary",
|
|
27
|
+
1005 => "Description",
|
|
28
|
+
1014 => "License",
|
|
29
|
+
1015 => "Packager",
|
|
30
|
+
1020 => "URL",
|
|
31
|
+
1021 => "OS",
|
|
32
|
+
1022 => "Architecture",
|
|
33
|
+
1049 => "Requires",
|
|
34
|
+
1116 => "DirIndexes",
|
|
35
|
+
1117 => "BaseNames",
|
|
36
|
+
1118 => "DirNames",
|
|
37
|
+
1124 => "PayloadFormat",
|
|
38
|
+
1125 => "PayloadCompressor",
|
|
39
|
+
1126 => "PayloadFlags"
|
|
40
|
+
}.merge(SCRIPT_TAGS.transform_values { |name| "Script:#{name}" }).freeze
|
|
41
|
+
|
|
42
|
+
attr_reader :path
|
|
43
|
+
|
|
44
|
+
def initialize(path)
|
|
45
|
+
@path = path
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def valid?
|
|
49
|
+
rpm_magic? && header_fields.any?
|
|
50
|
+
rescue FormatError
|
|
51
|
+
false
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def header_fields
|
|
55
|
+
@header_fields ||= parse_main_header
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def package_fields
|
|
59
|
+
fields = header_fields
|
|
60
|
+
{
|
|
61
|
+
"Name" => fields["Name"],
|
|
62
|
+
"Version" => fields["Version"],
|
|
63
|
+
"Release" => fields["Release"],
|
|
64
|
+
"Architecture" => fields["Architecture"],
|
|
65
|
+
"Summary" => fields["Summary"],
|
|
66
|
+
"Description" => fields["Description"],
|
|
67
|
+
"License" => fields["License"],
|
|
68
|
+
"Packager" => fields["Packager"],
|
|
69
|
+
"URL" => fields["URL"],
|
|
70
|
+
"PayloadFormat" => fields["PayloadFormat"],
|
|
71
|
+
"PayloadCompressor" => fields["PayloadCompressor"],
|
|
72
|
+
"PayloadFlags" => fields["PayloadFlags"]
|
|
73
|
+
}.compact
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def display_name
|
|
77
|
+
header_fields["Name"] || File.basename(path, ".rpm")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def version_label
|
|
81
|
+
[header_fields["Version"], header_fields["Release"]].compact.join("-")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def requires
|
|
85
|
+
Array(header_fields["Requires"]).reject { |name| name.start_with?("rpmlib(") }.uniq
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def scriptlets
|
|
89
|
+
header_fields.filter_map do |key, value|
|
|
90
|
+
next unless key.start_with?("Script:") && value.to_s.strip != ""
|
|
91
|
+
|
|
92
|
+
key.delete_prefix("Script:")
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def members
|
|
97
|
+
@members ||= list_payload
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def clean_members
|
|
101
|
+
members.map { |entry| clean_entry(entry) }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def desktop_entries
|
|
105
|
+
payload_entries.select { |entry| entry.end_with?(".desktop") }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def primary_desktop_entry
|
|
109
|
+
desktop_entries.min_by { |entry| desktop_score(entry) }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def image_entries
|
|
113
|
+
payload_entries.select { |entry| entry.match?(IMAGE_EXT) }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def icon_entries
|
|
117
|
+
payload_entries.select { |entry| entry.match?(HICOLOR_ICON_PATH) }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def executable_candidates
|
|
121
|
+
payload_entries.reject { |entry| entry.end_with?("/") }
|
|
122
|
+
.select { |entry| executable_name?(entry) }
|
|
123
|
+
.first(24)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def file_entries
|
|
127
|
+
entries = header_file_entries
|
|
128
|
+
entries.empty? ? clean_members : entries
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def header_file_entries
|
|
132
|
+
fields = header_fields
|
|
133
|
+
bases = Array(fields["BaseNames"])
|
|
134
|
+
dirs = Array(fields["DirNames"])
|
|
135
|
+
indexes = Array(fields["DirIndexes"])
|
|
136
|
+
return [] if bases.empty? || dirs.empty? || indexes.empty?
|
|
137
|
+
|
|
138
|
+
bases.each_with_index.map do |base, index|
|
|
139
|
+
dir = dirs[indexes[index].to_i].to_s
|
|
140
|
+
clean_entry(File.join(dir, base))
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def read_entry(entry)
|
|
145
|
+
wanted = clean_entry(entry)
|
|
146
|
+
candidates = [wanted, "./#{wanted}"].uniq
|
|
147
|
+
candidates.each do |candidate|
|
|
148
|
+
stdout, stderr, status = Open3.capture3("bsdtar", "-xOf", path, candidate)
|
|
149
|
+
return stdout if status.success?
|
|
150
|
+
|
|
151
|
+
@last_read_error = stderr.empty? ? stdout : stderr
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
raise FormatError, "Could not read #{entry}: #{@last_read_error}"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def extract_to(destination)
|
|
158
|
+
FileUtils.mkdir_p(destination)
|
|
159
|
+
assert_bsdtar!
|
|
160
|
+
assert_safe_entries!
|
|
161
|
+
stdout, stderr, status = Open3.capture3("bsdtar", "-xf", path, "-C", destination)
|
|
162
|
+
raise FormatError, "Could not extract RPM payload: #{stderr.empty? ? stdout : stderr}" unless status.success?
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
class FormatError < StandardError; end
|
|
166
|
+
|
|
167
|
+
private
|
|
168
|
+
|
|
169
|
+
def rpm_magic?
|
|
170
|
+
File.open(path, "rb") { |file| file.read(4) == MAGIC }
|
|
171
|
+
rescue SystemCallError
|
|
172
|
+
false
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def parse_main_header
|
|
176
|
+
File.open(path, "rb") do |file|
|
|
177
|
+
file.binmode
|
|
178
|
+
raise FormatError, "Not an RPM file" unless file.read(4) == MAGIC
|
|
179
|
+
|
|
180
|
+
file.pos = 96
|
|
181
|
+
read_header(file)
|
|
182
|
+
file.pos += (8 - (file.pos % 8)) % 8
|
|
183
|
+
entries, store = read_header(file)
|
|
184
|
+
fields = {}
|
|
185
|
+
entries.each do |tag, type, offset, count|
|
|
186
|
+
name = TAGS[tag]
|
|
187
|
+
next unless name
|
|
188
|
+
|
|
189
|
+
fields[name] = header_value(type, offset, count, store)
|
|
190
|
+
end
|
|
191
|
+
fields
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def read_header(file)
|
|
196
|
+
magic = file.read(3)
|
|
197
|
+
raise FormatError, "Invalid RPM header" unless magic == HEADER_MAGIC
|
|
198
|
+
|
|
199
|
+
file.read(1)
|
|
200
|
+
file.read(4)
|
|
201
|
+
index_count = file.read(4).unpack1("N")
|
|
202
|
+
store_size = file.read(4).unpack1("N")
|
|
203
|
+
entries = index_count.times.map { file.read(16).unpack("N4") }
|
|
204
|
+
store = file.read(store_size)
|
|
205
|
+
raise FormatError, "Truncated RPM header store" unless store && store.bytesize == store_size
|
|
206
|
+
|
|
207
|
+
[entries, store]
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def header_value(type, offset, count, store)
|
|
211
|
+
case type
|
|
212
|
+
when 2
|
|
213
|
+
store.byteslice(offset, count).unpack("C*")
|
|
214
|
+
when 3
|
|
215
|
+
store.byteslice(offset, count * 2).unpack("n*")
|
|
216
|
+
when 4
|
|
217
|
+
store.byteslice(offset, count * 4).unpack("N*")
|
|
218
|
+
when 5
|
|
219
|
+
store.byteslice(offset, count * 8).unpack("Q>*")
|
|
220
|
+
when 6, 9
|
|
221
|
+
store.byteslice(offset..).split("\0", 2).first.to_s
|
|
222
|
+
when 8
|
|
223
|
+
store.byteslice(offset..).split("\0").first(count)
|
|
224
|
+
when 7
|
|
225
|
+
store.byteslice(offset, count)
|
|
226
|
+
else
|
|
227
|
+
nil
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def list_payload
|
|
232
|
+
assert_bsdtar!
|
|
233
|
+
stdout, stderr, status = Open3.capture3("bsdtar", "-tf", path)
|
|
234
|
+
raise FormatError, "Could not list RPM payload: #{stderr.empty? ? stdout : stderr}" unless status.success?
|
|
235
|
+
|
|
236
|
+
stdout.lines.map(&:chomp).reject(&:empty?)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def assert_bsdtar!
|
|
240
|
+
return if command_available?("bsdtar")
|
|
241
|
+
|
|
242
|
+
raise FormatError, "RPM portable extraction requires bsdtar/libarchive."
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def assert_safe_entries!
|
|
246
|
+
unsafe = payload_entries.find do |entry|
|
|
247
|
+
clean = clean_entry(entry)
|
|
248
|
+
entry.start_with?("/") || clean.split("/").include?("..")
|
|
249
|
+
end
|
|
250
|
+
raise FormatError, "Unsafe path in RPM payload: #{unsafe}" if unsafe
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def payload_entries
|
|
254
|
+
entries = header_file_entries
|
|
255
|
+
entries.empty? ? clean_members : entries
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def clean_entry(entry)
|
|
259
|
+
entry.to_s.sub(%r{\A\./}, "").sub(%r{\A/+}, "")
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def executable_name?(entry)
|
|
263
|
+
base = File.basename(entry)
|
|
264
|
+
return true if entry.include?("/bin/") && !base.include?(".")
|
|
265
|
+
return true if entry.start_with?("opt/") && !base.include?(".")
|
|
266
|
+
|
|
267
|
+
false
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def desktop_score(entry)
|
|
271
|
+
base = File.basename(entry, ".desktop").downcase
|
|
272
|
+
package_name = display_name.to_s.downcase
|
|
273
|
+
[
|
|
274
|
+
clean_entry(entry).match?(%r{/share/applications/[^/]+\.desktop\z}) ? 0 : 1,
|
|
275
|
+
base.include?("url") || base.include?("handler") ? 1 : 0,
|
|
276
|
+
!package_name.empty? && base.include?(package_name) ? 0 : 1,
|
|
277
|
+
clean_entry(entry).length
|
|
278
|
+
]
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def parse_desktop_entry(contents)
|
|
282
|
+
metadata = {}
|
|
283
|
+
in_desktop_entry = false
|
|
284
|
+
contents.each_line(chomp: true) do |line|
|
|
285
|
+
if line.start_with?("[")
|
|
286
|
+
in_desktop_entry = line.strip == "[Desktop Entry]"
|
|
287
|
+
next
|
|
288
|
+
end
|
|
289
|
+
next unless in_desktop_entry
|
|
290
|
+
|
|
291
|
+
key, value = line.split("=", 2)
|
|
292
|
+
metadata[key] = value if value && !metadata.key?(key)
|
|
293
|
+
end
|
|
294
|
+
metadata
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def command_available?(command)
|
|
298
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? { |dir| File.executable?(File.join(dir, command)) }
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
data/lib/depot/paths.rb
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Depot
|
|
6
|
+
module Paths
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def data_home
|
|
10
|
+
ENV.fetch("XDG_DATA_HOME", File.join(Dir.home, ".local", "share"))
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def config_home
|
|
14
|
+
ENV.fetch("XDG_CONFIG_HOME", File.join(Dir.home, ".config"))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def state_home
|
|
18
|
+
ENV.fetch("XDG_STATE_HOME", File.join(Dir.home, ".local", "state"))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def data_dir
|
|
22
|
+
File.join(data_home, "depot")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def config_dir
|
|
26
|
+
File.join(config_home, "depot")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def state_dir
|
|
30
|
+
File.join(state_home, "depot")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def apps_dir
|
|
34
|
+
File.join(data_dir, "apps")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def manifests_dir
|
|
38
|
+
File.join(data_dir, "manifests")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def desktop_entries_dir
|
|
42
|
+
File.join(data_home, "applications")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def icon_root
|
|
46
|
+
File.join(data_home, "icons", "hicolor")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def settings_path
|
|
50
|
+
File.join(config_dir, "settings.json")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def ensure_base_dirs
|
|
54
|
+
FileUtils.mkdir_p([data_dir, config_dir, state_dir, apps_dir, manifests_dir, desktop_entries_dir])
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
data/lib/depot/result.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Depot
|
|
4
|
+
Result = Struct.new(:ok?, :value, :warnings, :error, keyword_init: true) do
|
|
5
|
+
def self.ok(value = nil, warnings: [])
|
|
6
|
+
new(ok?: true, value:, warnings:, error: nil)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def self.err(error, warnings: [])
|
|
10
|
+
new(ok?: false, value: nil, warnings:, error:)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "shellwords"
|
|
5
|
+
require "time"
|
|
6
|
+
require_relative "desktop_entry"
|
|
7
|
+
require_relative "manifest_store"
|
|
8
|
+
require_relative "paths"
|
|
9
|
+
require_relative "result"
|
|
10
|
+
require_relative "settings"
|
|
11
|
+
|
|
12
|
+
module Depot
|
|
13
|
+
module Sandbox
|
|
14
|
+
PORTABLE_BACKENDS = %w[appimage deb-portable rpm-portable archive-portable].freeze
|
|
15
|
+
MODES = %w[inherit enabled disabled].freeze
|
|
16
|
+
PROFILES = %w[relaxed balanced strict].freeze
|
|
17
|
+
HOME_ACCESS = %w[isolated documents full].freeze
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def apply(manifest, settings: Settings.new.load, store: ManifestStore.new)
|
|
22
|
+
normalized = normalize(manifest, settings)
|
|
23
|
+
normalized = write_or_remove_launcher(normalized, settings)
|
|
24
|
+
rewrite_desktop_entry(normalized) if portable?(normalized)
|
|
25
|
+
path = store.write(normalized)
|
|
26
|
+
Result.ok(normalized.merge("manifest_path" => path))
|
|
27
|
+
rescue SystemCallError => e
|
|
28
|
+
Result.err("Could not update sandbox settings: #{e.message}")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def set(app_id, values = {}, store: ManifestStore.new, settings: Settings.new.load)
|
|
32
|
+
manifest = store.find(app_id)
|
|
33
|
+
return Result.err("No installed app found for #{app_id}.") unless manifest
|
|
34
|
+
|
|
35
|
+
sandbox = normalize(manifest, settings).fetch("sandbox", {})
|
|
36
|
+
mode = values["mode"] || values[:mode]
|
|
37
|
+
profile = values["profile"] || values[:profile]
|
|
38
|
+
home_access = values["home_access"] || values[:home_access]
|
|
39
|
+
return Result.err("Sandbox mode must be one of: #{MODES.join(", ")}.") if mode && !MODES.include?(mode.to_s)
|
|
40
|
+
return Result.err("Sandbox profile must be one of: #{PROFILES.join(", ")}.") if profile && !PROFILES.include?(profile.to_s)
|
|
41
|
+
return Result.err("Sandbox home access must be one of: #{HOME_ACCESS.join(", ")}.") if home_access && !HOME_ACCESS.include?(home_access.to_s)
|
|
42
|
+
|
|
43
|
+
sandbox["mode"] = normalize_choice(mode, MODES, sandbox.fetch("mode", "inherit"))
|
|
44
|
+
sandbox["profile"] = normalize_choice(profile, PROFILES, sandbox.fetch("profile", "balanced"))
|
|
45
|
+
sandbox["home_access"] = normalize_choice(home_access, HOME_ACCESS, sandbox.fetch("home_access", "documents"))
|
|
46
|
+
sandbox["network"] = bool_value(values.key?("network") ? values["network"] : values[:network], sandbox.fetch("network", true))
|
|
47
|
+
sandbox["updated_at"] = Time.now.utc.iso8601
|
|
48
|
+
apply(manifest.merge("sandbox" => sandbox), settings:, store:)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def launch_path(manifest, settings: Settings.new.load)
|
|
52
|
+
normalized = normalize(manifest, settings)
|
|
53
|
+
sandbox = normalized.fetch("sandbox", {})
|
|
54
|
+
launcher = sandbox["launcher"]
|
|
55
|
+
return launcher if effective_enabled?(normalized, settings) && launcher.to_s != "" && File.executable?(launcher)
|
|
56
|
+
|
|
57
|
+
normalized["installed_executable"]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def summary(manifest, settings: Settings.new.load)
|
|
61
|
+
normalized = normalize(manifest, settings)
|
|
62
|
+
sandbox = normalized.fetch("sandbox", {})
|
|
63
|
+
if normalized["backend"] == "flatpak"
|
|
64
|
+
return "Flatpak managed"
|
|
65
|
+
end
|
|
66
|
+
unless portable?(normalized)
|
|
67
|
+
return "Unsupported for this backend"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
mode = sandbox.fetch("mode", "inherit")
|
|
71
|
+
state = effective_enabled?(normalized, settings) ? "enabled" : "disabled"
|
|
72
|
+
manager = command_available?("bwrap") ? "Bubblewrap" : "Bubblewrap missing"
|
|
73
|
+
"#{state} (#{mode}, #{sandbox.fetch("profile", "balanced")}, #{sandbox.fetch("home_access", "documents")} home, #{sandbox.fetch("network", true) ? "network" : "no network"}, #{manager})"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def normalize(manifest, settings = Settings.new.load)
|
|
77
|
+
sandbox = manifest.fetch("sandbox", {}).dup
|
|
78
|
+
backend = manifest["backend"]
|
|
79
|
+
if backend == "flatpak"
|
|
80
|
+
sandbox["manager"] = "flatpak"
|
|
81
|
+
sandbox["mode"] ||= "enabled"
|
|
82
|
+
sandbox["enabled"] = true
|
|
83
|
+
return manifest.merge("sandbox" => sandbox)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
sandbox["manager"] = "bubblewrap" if portable?(manifest)
|
|
87
|
+
sandbox["mode"] ||= legacy_mode(sandbox)
|
|
88
|
+
sandbox["profile"] = normalize_choice(sandbox["profile"], PROFILES, settings.fetch("sandbox_profile", "balanced"))
|
|
89
|
+
sandbox["home_access"] = normalize_choice(sandbox["home_access"], HOME_ACCESS, settings.fetch("sandbox_home_access", "documents"))
|
|
90
|
+
sandbox["network"] = bool_value(sandbox.fetch("network", settings.fetch("sandbox_network", true)), true)
|
|
91
|
+
sandbox["enabled"] = effective_enabled?(manifest.merge("sandbox" => sandbox), settings)
|
|
92
|
+
manifest.merge("sandbox" => sandbox)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def effective_enabled?(manifest, settings = Settings.new.load)
|
|
96
|
+
return true if manifest["backend"] == "flatpak"
|
|
97
|
+
return false unless portable?(manifest)
|
|
98
|
+
|
|
99
|
+
mode = manifest.fetch("sandbox", {}).fetch("mode", "inherit")
|
|
100
|
+
return true if mode == "enabled"
|
|
101
|
+
return false if mode == "disabled"
|
|
102
|
+
|
|
103
|
+
%w[prefer-on on enabled].include?(settings.fetch("sandbox_preference", "ask"))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def portable?(manifest)
|
|
107
|
+
PORTABLE_BACKENDS.include?(manifest["backend"])
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def command_available?(command)
|
|
111
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? { |dir| File.executable?(File.join(dir, command)) }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def normalize_choice(value, allowed, fallback)
|
|
115
|
+
value = value.to_s
|
|
116
|
+
allowed.include?(value) ? value : fallback
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def bool_value(value, fallback)
|
|
120
|
+
return value if value == true || value == false
|
|
121
|
+
return true if value.to_s == "true"
|
|
122
|
+
return false if value.to_s == "false"
|
|
123
|
+
|
|
124
|
+
fallback
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def legacy_mode(sandbox)
|
|
128
|
+
return "enabled" if sandbox["enabled"] == true
|
|
129
|
+
|
|
130
|
+
"inherit"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def write_or_remove_launcher(manifest, settings)
|
|
134
|
+
sandbox = manifest.fetch("sandbox", {})
|
|
135
|
+
launcher = sandbox["launcher"]
|
|
136
|
+
if !effective_enabled?(manifest, settings) || !portable?(manifest)
|
|
137
|
+
FileUtils.rm_f(launcher) if safe_launcher_path?(manifest, launcher)
|
|
138
|
+
sandbox.delete("launcher")
|
|
139
|
+
sandbox["enabled"] = false
|
|
140
|
+
return manifest.merge("sandbox" => sandbox)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
app_dir = app_dir_for(manifest)
|
|
144
|
+
return manifest unless app_dir
|
|
145
|
+
|
|
146
|
+
FileUtils.mkdir_p(app_dir)
|
|
147
|
+
FileUtils.mkdir_p(File.join(app_dir, "sandbox-home"))
|
|
148
|
+
launcher = File.join(app_dir, "depot-sandbox-launch")
|
|
149
|
+
File.write(launcher, launcher_script(manifest, app_dir))
|
|
150
|
+
File.chmod(0o755, launcher)
|
|
151
|
+
manifest["created_files"] = (manifest["created_files"].to_a + [launcher]).uniq
|
|
152
|
+
sandbox["launcher"] = launcher
|
|
153
|
+
sandbox["enabled"] = true
|
|
154
|
+
manifest.merge("sandbox" => sandbox)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def app_dir_for(manifest)
|
|
158
|
+
manifest.fetch("created_dirs", []).find { |path| path.to_s.start_with?(Paths.apps_dir) } ||
|
|
159
|
+
safe_parent_app_dir(manifest["installed_executable"])
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def safe_parent_app_dir(path)
|
|
163
|
+
expanded = File.expand_path(path.to_s)
|
|
164
|
+
root = File.expand_path(Paths.apps_dir)
|
|
165
|
+
return nil unless expanded.start_with?(root + File::SEPARATOR)
|
|
166
|
+
|
|
167
|
+
parts = expanded.delete_prefix(root + File::SEPARATOR).split(File::SEPARATOR)
|
|
168
|
+
return nil if parts.empty?
|
|
169
|
+
|
|
170
|
+
File.join(root, parts.first)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def safe_launcher_path?(manifest, path)
|
|
174
|
+
return false if path.to_s.empty?
|
|
175
|
+
|
|
176
|
+
app_dir = app_dir_for(manifest)
|
|
177
|
+
return false unless app_dir
|
|
178
|
+
|
|
179
|
+
File.expand_path(path).start_with?(File.expand_path(app_dir) + File::SEPARATOR)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def launcher_script(manifest, app_dir)
|
|
183
|
+
sandbox = manifest.fetch("sandbox", {})
|
|
184
|
+
real_exec = manifest.fetch("installed_executable")
|
|
185
|
+
home_dir = Dir.home
|
|
186
|
+
sandbox_home = File.join(app_dir, "sandbox-home")
|
|
187
|
+
network = sandbox.fetch("network", true)
|
|
188
|
+
home_access = sandbox.fetch("home_access", "documents")
|
|
189
|
+
|
|
190
|
+
<<~SH
|
|
191
|
+
#!/usr/bin/env bash
|
|
192
|
+
set -e
|
|
193
|
+
|
|
194
|
+
REAL_EXEC=#{Shellwords.escape(real_exec)}
|
|
195
|
+
APP_DIR=#{Shellwords.escape(app_dir)}
|
|
196
|
+
SANDBOX_HOME=#{Shellwords.escape(sandbox_home)}
|
|
197
|
+
HOST_HOME=#{Shellwords.escape(home_dir)}
|
|
198
|
+
|
|
199
|
+
if [ "${DEPOT_DISABLE_SANDBOX:-}" = "1" ] || ! command -v bwrap >/dev/null 2>&1; then
|
|
200
|
+
exec "$REAL_EXEC" "$@"
|
|
201
|
+
fi
|
|
202
|
+
|
|
203
|
+
mkdir -p "$SANDBOX_HOME"
|
|
204
|
+
args=(--die-with-parent --unshare-ipc --unshare-pid --proc /proc --dev /dev --tmpfs /tmp)
|
|
205
|
+
add_ro() { [ -e "$1" ] && args+=(--ro-bind "$1" "$1"); }
|
|
206
|
+
add_rw() { [ -e "$1" ] && args+=(--bind "$1" "$1"); }
|
|
207
|
+
|
|
208
|
+
for path in /usr /bin /sbin /lib /lib64 /etc /opt; do
|
|
209
|
+
add_ro "$path"
|
|
210
|
+
done
|
|
211
|
+
|
|
212
|
+
#{home_setup_script(home_access)}
|
|
213
|
+
if [[ "$APP_DIR" == "$HOST_HOME/"* ]]; then
|
|
214
|
+
app_mount_parent="$SANDBOX_HOME/${APP_DIR#"$HOST_HOME/"}"
|
|
215
|
+
mkdir -p "$(dirname "$app_mount_parent")"
|
|
216
|
+
fi
|
|
217
|
+
args+=(--bind "$APP_DIR" "$APP_DIR")
|
|
218
|
+
args+=(--setenv DEPOT_SANDBOXED 1)
|
|
219
|
+
|
|
220
|
+
if [ -n "${XDG_RUNTIME_DIR:-}" ] && [ -d "$XDG_RUNTIME_DIR" ]; then
|
|
221
|
+
args+=(--bind "$XDG_RUNTIME_DIR" "$XDG_RUNTIME_DIR")
|
|
222
|
+
fi
|
|
223
|
+
if [ -n "${XAUTHORITY:-}" ] && [ -f "$XAUTHORITY" ]; then
|
|
224
|
+
args+=(--ro-bind "$XAUTHORITY" "$XAUTHORITY")
|
|
225
|
+
fi
|
|
226
|
+
if [ -d /tmp/.X11-unix ]; then
|
|
227
|
+
args+=(--ro-bind /tmp/.X11-unix /tmp/.X11-unix)
|
|
228
|
+
fi
|
|
229
|
+
|
|
230
|
+
#{network ? "" : "args+=(--unshare-net)"}
|
|
231
|
+
|
|
232
|
+
args+=(--chdir #{Shellwords.escape(File.dirname(real_exec))})
|
|
233
|
+
exec bwrap "${args[@]}" "$REAL_EXEC" "$@"
|
|
234
|
+
SH
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def home_setup_script(home_access)
|
|
238
|
+
case home_access
|
|
239
|
+
when "full"
|
|
240
|
+
<<~SH.strip
|
|
241
|
+
args+=(--bind "$HOST_HOME" "$HOST_HOME")
|
|
242
|
+
args+=(--setenv HOME "$HOST_HOME")
|
|
243
|
+
SH
|
|
244
|
+
when "documents"
|
|
245
|
+
<<~SH.strip
|
|
246
|
+
args+=(--bind "$SANDBOX_HOME" "$HOST_HOME")
|
|
247
|
+
args+=(--setenv HOME "$HOST_HOME")
|
|
248
|
+
for dir in Desktop Documents Downloads Pictures Music Videos; do
|
|
249
|
+
if [ -d "$HOST_HOME/$dir" ]; then
|
|
250
|
+
mkdir -p "$SANDBOX_HOME/$dir"
|
|
251
|
+
args+=(--bind "$HOST_HOME/$dir" "$HOST_HOME/$dir")
|
|
252
|
+
fi
|
|
253
|
+
done
|
|
254
|
+
SH
|
|
255
|
+
else
|
|
256
|
+
<<~SH.strip
|
|
257
|
+
args+=(--bind "$SANDBOX_HOME" "$HOST_HOME")
|
|
258
|
+
args+=(--setenv HOME "$HOST_HOME")
|
|
259
|
+
SH
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def rewrite_desktop_entry(manifest)
|
|
264
|
+
desktop_path = manifest["desktop_entry"]
|
|
265
|
+
return unless desktop_path && !desktop_path.empty?
|
|
266
|
+
|
|
267
|
+
FileUtils.mkdir_p(File.dirname(desktop_path))
|
|
268
|
+
entry = DesktopEntry.new(
|
|
269
|
+
app_id: manifest.fetch("app_id"),
|
|
270
|
+
name: manifest.fetch("display_name"),
|
|
271
|
+
exec_path: launch_path(manifest),
|
|
272
|
+
icon_name: active_icon_name(manifest)
|
|
273
|
+
)
|
|
274
|
+
File.write(desktop_path, entry.contents)
|
|
275
|
+
manifest["created_files"] = (manifest["created_files"].to_a + [desktop_path]).uniq
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def active_icon_name(manifest)
|
|
279
|
+
custom = manifest["custom_icon"]
|
|
280
|
+
return custom["path"] if custom && custom["path"].to_s != ""
|
|
281
|
+
|
|
282
|
+
manifest["default_icon_name"]
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require_relative "paths"
|
|
6
|
+
|
|
7
|
+
module Depot
|
|
8
|
+
class Settings
|
|
9
|
+
DEFAULTS = {
|
|
10
|
+
"warning_verbosity" => "normal",
|
|
11
|
+
"theme" => "system",
|
|
12
|
+
"default_install_location" => "user",
|
|
13
|
+
"sandbox_preference" => "ask",
|
|
14
|
+
"sandbox_profile" => "balanced",
|
|
15
|
+
"sandbox_home_access" => "documents",
|
|
16
|
+
"sandbox_network" => true,
|
|
17
|
+
"desktop_integration" => true,
|
|
18
|
+
"updates_enabled" => true
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
attr_reader :path
|
|
22
|
+
|
|
23
|
+
def initialize(path = Paths.settings_path)
|
|
24
|
+
@path = path
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def load
|
|
28
|
+
return DEFAULTS.dup unless File.exist?(path)
|
|
29
|
+
|
|
30
|
+
parsed = JSON.parse(File.read(path))
|
|
31
|
+
DEFAULTS.merge(parsed)
|
|
32
|
+
rescue JSON::ParserError
|
|
33
|
+
DEFAULTS.dup
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def save(values)
|
|
37
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
38
|
+
normalized = DEFAULTS.merge(values.transform_keys(&:to_s))
|
|
39
|
+
File.write(path, JSON.pretty_generate(normalized) + "\n")
|
|
40
|
+
normalized
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|