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