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,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
@@ -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
@@ -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