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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 39c0c6fedfcedab5ac5602cf96e2f1236c0d09b5a9395e34ed06ad80e8e902d8
|
|
4
|
+
data.tar.gz: f996a7aacd1f21f3925e6132c5b72bdcdee47db201fb8cbc5ebc48a39b83b4a5
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 3859dc9e6f2f4687bbf7a11e9a4553e7f72c1eea9493957e752f8d3665935560d4ecdabeda93e7fb0ad9e773aedc807eadcf88048b371e94cc226dd409a1adea
|
|
7
|
+
data.tar.gz: cee4fb7b50e82c92d2e04950f78ab46560801757f7f4b605286a221f516b821277381b9738647fb7bc084101c1ba519e23d02cdf95eb02e5d4bcc9e45b090160
|
data/Gemfile
ADDED
data/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Depot
|
|
2
|
+
|
|
3
|
+
Depot is a Ruby + Qt6 Linux application installer and desktop integration layer. It presents software as one installable thing while the internals choose the right backend. The current build supports AppImage installation, portable `.deb` extraction, portable `.rpm` extraction, `.flatpakref` installation, and portable tar archive integration through the shared CLI/GUI core.
|
|
4
|
+
|
|
5
|
+
## Run
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
bundle install
|
|
9
|
+
./bin/setup-rubyqt6
|
|
10
|
+
./bin/depot --help
|
|
11
|
+
./bin/depot-gui
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The CLI works without Qt loading. The GUI requires the local RubyQt6 native extensions built by `bin/setup-rubyqt6`.
|
|
15
|
+
|
|
16
|
+
## Commands
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
./bin/depot inspect ./App.AppImage
|
|
20
|
+
./bin/depot install ./App.AppImage
|
|
21
|
+
./bin/depot inspect ./App.deb
|
|
22
|
+
./bin/depot install ./App.deb
|
|
23
|
+
./bin/depot inspect ./App.rpm
|
|
24
|
+
./bin/depot install ./App.rpm
|
|
25
|
+
./bin/depot inspect ./App.flatpakref
|
|
26
|
+
./bin/depot install ./App.flatpakref
|
|
27
|
+
./bin/depot inspect ./App.tar.gz
|
|
28
|
+
./bin/depot install ./App.tar.gz
|
|
29
|
+
./bin/depot list
|
|
30
|
+
./bin/depot info app
|
|
31
|
+
./bin/depot uninstall app
|
|
32
|
+
./bin/depot update app
|
|
33
|
+
./bin/depot update --all
|
|
34
|
+
./bin/depot update-source app https://example.com/App.AppImage
|
|
35
|
+
./bin/depot sandbox app enabled
|
|
36
|
+
./bin/depot doctor
|
|
37
|
+
./bin/depot settings
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Installed applications are copied under `~/.local/share/depot/apps`, manifests live under `~/.local/share/depot/manifests`, and desktop entries are created under `~/.local/share/applications`.
|
|
41
|
+
|
|
42
|
+
Sample packages used by tests and local backend checks live under `fixtures/`, grouped by format.
|
|
43
|
+
|
|
44
|
+
`.deb` support is intentionally universal-first: Depot parses Debian packages itself, extracts installable files under Depot's user-local app directory, rewrites desktop launchers/icons, and does not require or call `apt`, `dpkg`, `sudo`, or Debian maintainer scripts. Packages that rely on Debian-family dependencies, services, or maintainer-script side effects may still need native distro installation or a future compatibility runtime.
|
|
45
|
+
|
|
46
|
+
`.rpm` support follows the same universal-first model: Depot parses RPM headers for package metadata, requirements, scriptlets, payload format, desktop entries, and icons, then extracts the payload under Depot's user-local app directory through libarchive/`bsdtar`. It does not require or call `rpm`, `dnf`, `zypper`, `sudo`, or RPM scriptlets. Packages that rely on RPM-family dependencies, services, users, policies, or scriptlet side effects may still need native distro installation or a future compatibility runtime.
|
|
47
|
+
|
|
48
|
+
`.flatpakref` support uses Flatpak as the backend instead of unpacking the application itself. Depot parses the ref for transparency, runs `flatpak install --user --from`, records a Depot manifest, creates a small launch wrapper, and calls `flatpak uninstall --user` when removing the app. Flatpak remains responsible for downloads, remotes, runtime dependencies, sandboxing, exported desktop entries, and updates.
|
|
49
|
+
|
|
50
|
+
Tar archive support is also portable-first: Depot inspects `.tar.gz` / `.tgz` archives, extracts them user-locally, reuses or generates desktop launchers, copies icons when possible, and never runs installer scripts from the archive.
|
|
51
|
+
|
|
52
|
+
Updates are enabled by default and can be disabled through settings. Flatpak apps update through `flatpak update --user`; AppImage, `.deb`, `.rpm`, and tar archive installs update by reinstalling from their original source file when that file is still available. You can also attach an HTTPS update URL with `depot update-source APP_ID HTTPS_URL`; Depot streams the download to a temporary file, enforces a size limit, inspects the package before uninstalling anything, and refuses updates that switch package families.
|
|
53
|
+
|
|
54
|
+
Sandboxing is managed globally in Settings and per app from the Installed page. Portable AppImage, `.deb`, `.rpm`, and tar archive installs use a generated Bubblewrap launcher when sandboxing is enabled; Flatpak apps keep using Flatpak's own sandbox and permission model. If `bwrap` is missing, Depot falls back to the normal launcher instead of making the app unlaunchable.
|
|
55
|
+
|
|
56
|
+
`depot doctor` checks required helper tools, Depot data paths, installed manifests, launchers, desktop entries, and original install sources.
|
data/Rakefile
ADDED
data/bin/depot
ADDED
data/bin/depot-gui
ADDED
data/bin/setup-rubyqt6
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "etc"
|
|
6
|
+
require "open3"
|
|
7
|
+
|
|
8
|
+
ROOT = File.expand_path("..", __dir__)
|
|
9
|
+
GEMS = %w[rice qtcore qtgui qtwidgets].freeze
|
|
10
|
+
LIB_TARGETS = {
|
|
11
|
+
"rice" => "lib/qt6/rice",
|
|
12
|
+
"qtcore" => "lib/qt6/qtcore",
|
|
13
|
+
"qtgui" => "lib/qt6/qtgui",
|
|
14
|
+
"qtwidgets" => "lib/qt6/qtwidgets"
|
|
15
|
+
}.freeze
|
|
16
|
+
QT_HEADERS = `qmake6 -query QT_INSTALL_HEADERS 2>/dev/null`.strip
|
|
17
|
+
QT_HEADERS = `qmake -query QT_INSTALL_HEADERS 2>/dev/null`.strip if QT_HEADERS.empty?
|
|
18
|
+
|
|
19
|
+
def run(*cmd, chdir:)
|
|
20
|
+
puts "$ #{cmd.join(" ")}"
|
|
21
|
+
env = { "BUNDLE_GEMFILE" => File.join(ROOT, "Gemfile") }
|
|
22
|
+
system(env, *cmd, chdir:) || abort("Command failed: #{cmd.join(" ")}")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def mark_build_complete(gem_name)
|
|
26
|
+
script = <<~RUBY
|
|
27
|
+
require "fileutils"
|
|
28
|
+
spec = Gem::Specification.find_all_by_name("ruby-qt6-#{gem_name}").first
|
|
29
|
+
abort("Could not find ruby-qt6-#{gem_name} spec") unless spec
|
|
30
|
+
FileUtils.mkdir_p(File.dirname(spec.gem_build_complete_path))
|
|
31
|
+
FileUtils.touch(spec.gem_build_complete_path)
|
|
32
|
+
RUBY
|
|
33
|
+
run("bundle", "exec", "ruby", "-e", script, chdir: ROOT)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def make_args(gem_name)
|
|
37
|
+
flags = ["-DRUBYQT6_BUILD_#{gem_name.upcase}_LIB"]
|
|
38
|
+
flags << "-I#{QT_HEADERS}" unless QT_HEADERS.empty?
|
|
39
|
+
%w[QtCore QtGui QtWidgets].each do |qt_module|
|
|
40
|
+
flags << "-I#{File.join(QT_HEADERS, qt_module)}" unless QT_HEADERS.empty?
|
|
41
|
+
version_dir = Dir[File.join(QT_HEADERS, qt_module, "*.*.*")].first
|
|
42
|
+
next unless version_dir
|
|
43
|
+
|
|
44
|
+
flags << "-I#{version_dir}"
|
|
45
|
+
flags << "-I#{File.join(version_dir, qt_module)}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
args = ["cppflags=#{flags.join(" ")}"]
|
|
49
|
+
if gem_name != "rice"
|
|
50
|
+
rice_so = File.join(ROOT, "ruby-qt6-main", "rice", "lib", "qt6", "rice", "rice.so")
|
|
51
|
+
args << "LOCAL_LIBS=#{rice_so}"
|
|
52
|
+
end
|
|
53
|
+
args
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
GEMS.each do |gem_name|
|
|
57
|
+
gem_dir = File.join(ROOT, "ruby-qt6-main", gem_name)
|
|
58
|
+
ext_dir = File.join(gem_dir, "ext", "qt6", gem_name)
|
|
59
|
+
target_dir = File.join(gem_dir, LIB_TARGETS.fetch(gem_name))
|
|
60
|
+
FileUtils.rm_f(File.join(ext_dir, "Makefile"))
|
|
61
|
+
FileUtils.rm_f(Dir.glob(File.join(ext_dir, "*.{o,so}")))
|
|
62
|
+
run("bundle", "exec", "ruby", "extconf.rb", chdir: ext_dir)
|
|
63
|
+
run("make", "-j#{Etc.nprocessors}", *make_args(gem_name), chdir: ext_dir)
|
|
64
|
+
FileUtils.mkdir_p(target_dir)
|
|
65
|
+
Dir.glob(File.join(ext_dir, "*.so")).each do |shared_object|
|
|
66
|
+
FileUtils.cp(shared_object, target_dir)
|
|
67
|
+
puts "installed #{File.basename(shared_object)} -> #{target_dir}"
|
|
68
|
+
end
|
|
69
|
+
mark_build_complete(gem_name)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
puts "RubyQt6 native extensions are ready."
|
|
Binary file
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
[Flatpak Ref]
|
|
2
|
+
Name=org.qbittorrent.qBittorrent
|
|
3
|
+
Branch=stable
|
|
4
|
+
Title=org.qbittorrent.qBittorrent from flathub
|
|
5
|
+
IsRuntime=false
|
|
6
|
+
Url=https://dl.flathub.org/repo/
|
|
7
|
+
SuggestRemoteName=flathub
|
|
8
|
+
GPGKey=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
|
|
9
|
+
RuntimeRepo=https://dl.flathub.org/repo/flathub.flatpakrepo
|
|
10
|
+
|
|
Binary file
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "time"
|
|
5
|
+
require_relative "desktop_entry"
|
|
6
|
+
require_relative "manifest_store"
|
|
7
|
+
require_relative "paths"
|
|
8
|
+
require_relative "result"
|
|
9
|
+
require_relative "sandbox"
|
|
10
|
+
|
|
11
|
+
module Depot
|
|
12
|
+
class AppCustomizer
|
|
13
|
+
ICON_EXTENSIONS = [".png", ".svg", ".xpm"].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(store: ManifestStore.new)
|
|
16
|
+
@store = store
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def rename(app_id, title)
|
|
20
|
+
manifest = @store.find(app_id)
|
|
21
|
+
return Result.err("No Depot manifest found for #{app_id}.") unless manifest
|
|
22
|
+
|
|
23
|
+
title = title.to_s.strip
|
|
24
|
+
return Result.err("Title cannot be empty.") if title.empty?
|
|
25
|
+
|
|
26
|
+
ensure_defaults(manifest)
|
|
27
|
+
manifest["display_name"] = title
|
|
28
|
+
manifest["customizations"]["display_name"] = title
|
|
29
|
+
persist(manifest)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def change_icon(app_id, source_path)
|
|
33
|
+
manifest = @store.find(app_id)
|
|
34
|
+
return Result.err("No Depot manifest found for #{app_id}.") unless manifest
|
|
35
|
+
|
|
36
|
+
source_path = File.expand_path(source_path.to_s)
|
|
37
|
+
return Result.err("Icon file does not exist.") unless File.file?(source_path)
|
|
38
|
+
|
|
39
|
+
ext = File.extname(source_path).downcase
|
|
40
|
+
return Result.err("Choose a PNG, SVG, or XPM icon.") unless ICON_EXTENSIONS.include?(ext)
|
|
41
|
+
|
|
42
|
+
ensure_defaults(manifest)
|
|
43
|
+
remove_custom_icon(manifest)
|
|
44
|
+
|
|
45
|
+
icon_name = "#{manifest.fetch("app_id")}-custom"
|
|
46
|
+
target_dir = File.join(Paths.icon_root, icon_theme_size(source_path, ext), "apps")
|
|
47
|
+
FileUtils.mkdir_p(target_dir)
|
|
48
|
+
target = File.join(target_dir, "#{icon_name}#{ext}")
|
|
49
|
+
FileUtils.cp(source_path, target)
|
|
50
|
+
|
|
51
|
+
manifest["custom_icon"] = {
|
|
52
|
+
"source" => source_path,
|
|
53
|
+
"path" => target,
|
|
54
|
+
"icon_name" => icon_name,
|
|
55
|
+
"set_at" => Time.now.utc.iso8601
|
|
56
|
+
}
|
|
57
|
+
manifest["created_files"] = (manifest["created_files"].to_a + [target]).uniq
|
|
58
|
+
persist(manifest)
|
|
59
|
+
rescue SystemCallError => e
|
|
60
|
+
Result.err("Could not change icon: #{e.message}")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def reset(app_id)
|
|
64
|
+
manifest = @store.find(app_id)
|
|
65
|
+
return Result.err("No Depot manifest found for #{app_id}.") unless manifest
|
|
66
|
+
|
|
67
|
+
ensure_defaults(manifest)
|
|
68
|
+
remove_custom_icon(manifest)
|
|
69
|
+
manifest["display_name"] = manifest.fetch("default_display_name")
|
|
70
|
+
manifest.delete("custom_icon")
|
|
71
|
+
manifest["customizations"] = {}
|
|
72
|
+
persist(manifest)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def ensure_defaults(manifest)
|
|
78
|
+
manifest["default_display_name"] ||= manifest.fetch("display_name")
|
|
79
|
+
manifest["default_icon_name"] = default_icon_name(manifest) unless manifest.key?("default_icon_name")
|
|
80
|
+
manifest["customizations"] ||= {}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def default_icon_name(manifest)
|
|
84
|
+
manifest["icons"].to_a.any? ? manifest.fetch("app_id") : nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def persist(manifest)
|
|
88
|
+
rewrite_desktop_entry(manifest)
|
|
89
|
+
path = @store.write(manifest)
|
|
90
|
+
Result.ok(manifest.merge("manifest_path" => path))
|
|
91
|
+
rescue SystemCallError => e
|
|
92
|
+
Result.err("Could not update app integration: #{e.message}")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def rewrite_desktop_entry(manifest)
|
|
96
|
+
desktop_path = manifest["desktop_entry"]
|
|
97
|
+
return unless desktop_path && !desktop_path.empty?
|
|
98
|
+
|
|
99
|
+
FileUtils.mkdir_p(File.dirname(desktop_path))
|
|
100
|
+
entry = DesktopEntry.new(
|
|
101
|
+
app_id: manifest.fetch("app_id"),
|
|
102
|
+
name: manifest.fetch("display_name"),
|
|
103
|
+
exec_path: Sandbox.launch_path(manifest),
|
|
104
|
+
icon_name: active_icon_name(manifest)
|
|
105
|
+
)
|
|
106
|
+
File.write(desktop_path, entry.contents)
|
|
107
|
+
manifest["created_files"] = (manifest["created_files"].to_a + [desktop_path]).uniq
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def active_icon_name(manifest)
|
|
111
|
+
custom = manifest["custom_icon"]
|
|
112
|
+
return custom["path"] if custom && custom["path"].to_s != ""
|
|
113
|
+
|
|
114
|
+
manifest["default_icon_name"]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def icon_theme_size(path, ext)
|
|
118
|
+
return "scalable" if ext == ".svg"
|
|
119
|
+
return png_size(path) if ext == ".png"
|
|
120
|
+
|
|
121
|
+
"256x256"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def png_size(path)
|
|
125
|
+
File.open(path, "rb") do |file|
|
|
126
|
+
header = file.read(24)
|
|
127
|
+
return "256x256" unless header&.start_with?("\x89PNG\r\n\x1A\n".b)
|
|
128
|
+
|
|
129
|
+
width, height = header.byteslice(16, 8).unpack("NN")
|
|
130
|
+
return "256x256" if width.to_i <= 0 || height.to_i <= 0
|
|
131
|
+
|
|
132
|
+
"#{width}x#{height}"
|
|
133
|
+
end
|
|
134
|
+
rescue SystemCallError
|
|
135
|
+
"256x256"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def remove_custom_icon(manifest)
|
|
139
|
+
custom = manifest["custom_icon"]
|
|
140
|
+
return unless custom
|
|
141
|
+
|
|
142
|
+
path = custom["path"]
|
|
143
|
+
FileUtils.rm_f(path) if path && custom_icon_path?(path)
|
|
144
|
+
manifest["created_files"] = manifest["created_files"].to_a - [path]
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def custom_icon_path?(path)
|
|
148
|
+
expanded = File.expand_path(path)
|
|
149
|
+
expanded.start_with?(File.expand_path(Paths.icon_root) + File::SEPARATOR)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
data/lib/depot/assets.rb
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "shellwords"
|
|
6
|
+
require "tmpdir"
|
|
7
|
+
require "timeout"
|
|
8
|
+
require "time"
|
|
9
|
+
require_relative "../desktop_entry"
|
|
10
|
+
require_relative "../paths"
|
|
11
|
+
require_relative "../result"
|
|
12
|
+
require_relative "../util"
|
|
13
|
+
|
|
14
|
+
module Depot
|
|
15
|
+
module Backends
|
|
16
|
+
class AppImage
|
|
17
|
+
EXTRACTION_TIMEOUT_SECONDS = 25
|
|
18
|
+
|
|
19
|
+
def initialize(store:)
|
|
20
|
+
@store = store
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def install(inspection, settings: {})
|
|
24
|
+
return Result.err("AppImage backend cannot install #{inspection.format}") unless inspection.appimage?
|
|
25
|
+
|
|
26
|
+
Paths.ensure_base_dirs
|
|
27
|
+
|
|
28
|
+
taken = @store.ids
|
|
29
|
+
app_id = Util.unique_id(Util.slug(inspection.display_name), taken)
|
|
30
|
+
app_dir = File.join(Paths.apps_dir, app_id)
|
|
31
|
+
FileUtils.mkdir_p(app_dir)
|
|
32
|
+
|
|
33
|
+
installed_name = File.basename(inspection.input)
|
|
34
|
+
installed_path = File.join(app_dir, installed_name)
|
|
35
|
+
FileUtils.cp(inspection.input, installed_path)
|
|
36
|
+
FileUtils.chmod(File.stat(installed_path).mode | 0o111, installed_path)
|
|
37
|
+
|
|
38
|
+
metadata = extract_metadata(installed_path, app_id)
|
|
39
|
+
display_name = metadata.fetch("name", inspection.display_name)
|
|
40
|
+
icon_paths = metadata.fetch("icons", [])
|
|
41
|
+
icon_name = icon_paths.any? ? app_id : nil
|
|
42
|
+
|
|
43
|
+
desktop_path = nil
|
|
44
|
+
if settings.fetch("desktop_integration", true)
|
|
45
|
+
desktop_path = File.join(Paths.desktop_entries_dir, "depot-#{app_id}.desktop")
|
|
46
|
+
entry = DesktopEntry.new(app_id:, name: display_name, exec_path: installed_path, icon_name:)
|
|
47
|
+
File.write(desktop_path, entry.contents)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
manifest = {
|
|
51
|
+
"schema_version" => 1,
|
|
52
|
+
"app_id" => app_id,
|
|
53
|
+
"display_name" => display_name,
|
|
54
|
+
"default_display_name" => display_name,
|
|
55
|
+
"backend" => "appimage",
|
|
56
|
+
"install_source" => File.expand_path(inspection.input),
|
|
57
|
+
"source_sha256" => inspection.sha256,
|
|
58
|
+
"source_size" => inspection.size,
|
|
59
|
+
"installed_executable" => installed_path,
|
|
60
|
+
"desktop_entry" => desktop_path,
|
|
61
|
+
"icons" => icon_paths,
|
|
62
|
+
"default_icon_name" => icon_name,
|
|
63
|
+
"customizations" => {},
|
|
64
|
+
"created_files" => ([installed_path, desktop_path] + icon_paths).compact,
|
|
65
|
+
"created_dirs" => [app_dir],
|
|
66
|
+
"installed_at" => Time.now.utc.iso8601,
|
|
67
|
+
"permissions" => permission_summary(installed_path),
|
|
68
|
+
"sandbox" => {
|
|
69
|
+
"enabled" => false,
|
|
70
|
+
"preference" => settings.fetch("sandbox_preference", "ask")
|
|
71
|
+
},
|
|
72
|
+
"update" => {
|
|
73
|
+
"mechanism" => "manual",
|
|
74
|
+
"source" => File.expand_path(inspection.input)
|
|
75
|
+
},
|
|
76
|
+
"warnings" => inspection.warnings + metadata.fetch("warnings", [])
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
manifest_path = @store.write(manifest)
|
|
80
|
+
Result.ok(manifest.merge("manifest_path" => manifest_path), warnings: manifest["warnings"])
|
|
81
|
+
rescue SystemCallError => e
|
|
82
|
+
Result.err("Install failed: #{e.message}")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def extract_metadata(installed_path, app_id)
|
|
88
|
+
warnings = []
|
|
89
|
+
desktop_name = nil
|
|
90
|
+
icon_paths = []
|
|
91
|
+
|
|
92
|
+
Dir.mktmpdir("depot-appimage-") do |dir|
|
|
93
|
+
stdout, stderr, status = run_extract(installed_path, dir)
|
|
94
|
+
unless status&.success?
|
|
95
|
+
warnings << "Could not extract AppImage metadata; generated a desktop entry from the file name."
|
|
96
|
+
warnings << stderr.strip unless stderr.to_s.strip.empty?
|
|
97
|
+
warnings << stdout.strip unless stdout.to_s.strip.empty?
|
|
98
|
+
return { "warnings" => warnings, "icons" => icon_paths }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
root = File.join(dir, "squashfs-root")
|
|
102
|
+
desktop = Dir.glob(File.join(root, "**", "*.desktop")).first
|
|
103
|
+
desktop_metadata = parse_desktop_metadata(desktop)
|
|
104
|
+
desktop_name = desktop_metadata["name"]
|
|
105
|
+
icon_paths = install_icons(app_id, find_icons(root, desktop_metadata["icon"]))
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
{ "name" => desktop_name, "icons" => icon_paths, "warnings" => warnings }.compact
|
|
109
|
+
rescue Timeout::Error
|
|
110
|
+
{ "warnings" => ["Timed out while extracting AppImage metadata."], "icons" => [] }
|
|
111
|
+
rescue SystemCallError => e
|
|
112
|
+
{ "warnings" => ["Could not extract AppImage metadata: #{e.message}"], "icons" => [] }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def run_extract(installed_path, dir)
|
|
116
|
+
Timeout.timeout(EXTRACTION_TIMEOUT_SECONDS) do
|
|
117
|
+
Open3.capture3({ "APPIMAGE_EXTRACT_AND_RUN" => "1" }, installed_path, "--appimage-extract", chdir: dir)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def parse_desktop_metadata(path)
|
|
122
|
+
metadata = {}
|
|
123
|
+
return metadata unless path && File.exist?(path)
|
|
124
|
+
|
|
125
|
+
File.readlines(path, chomp: true).each do |line|
|
|
126
|
+
key, value = line.split("=", 2)
|
|
127
|
+
next unless value
|
|
128
|
+
|
|
129
|
+
value = value.strip
|
|
130
|
+
metadata["name"] = value if key == "Name" && !value.empty?
|
|
131
|
+
metadata["icon"] = value if key == "Icon" && !value.empty?
|
|
132
|
+
end
|
|
133
|
+
metadata
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def find_icons(root, preferred_icon = nil)
|
|
137
|
+
icons = [".png", ".svg", ".xpm"].flat_map do |ext|
|
|
138
|
+
Dir.glob(File.join(root, "**", "*#{ext}"))
|
|
139
|
+
end
|
|
140
|
+
icons << File.join(root, ".DirIcon")
|
|
141
|
+
preferred = preferred_icon.to_s
|
|
142
|
+
preferred_base = File.basename(preferred, ".*")
|
|
143
|
+
|
|
144
|
+
icons.select { |path| File.file?(path) && File.size(path).positive? }
|
|
145
|
+
.uniq { |path| real_icon_path(path) }
|
|
146
|
+
.sort_by { |path| icon_score(path, preferred_base) }
|
|
147
|
+
.first(8)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def install_icons(app_id, icons)
|
|
151
|
+
icons.filter_map do |source|
|
|
152
|
+
ext = icon_extension(source)
|
|
153
|
+
next unless [".png", ".svg", ".xpm"].include?(ext)
|
|
154
|
+
|
|
155
|
+
size = icon_size(source)
|
|
156
|
+
target_dir = File.join(Paths.icon_root, size, "apps")
|
|
157
|
+
FileUtils.mkdir_p(target_dir)
|
|
158
|
+
target = File.join(target_dir, "#{app_id}#{ext}")
|
|
159
|
+
FileUtils.cp(source, target)
|
|
160
|
+
target
|
|
161
|
+
rescue SystemCallError
|
|
162
|
+
nil
|
|
163
|
+
end.uniq
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def icon_size(path)
|
|
167
|
+
parts = path.split(File::SEPARATOR)
|
|
168
|
+
found = parts.find { |part| part.match?(/\A\d+x\d+\z/) }
|
|
169
|
+
return found if found
|
|
170
|
+
|
|
171
|
+
icon_extension(path) == ".svg" ? "scalable" : "256x256"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def icon_score(path, preferred_base)
|
|
175
|
+
base = File.basename(path, ".*")
|
|
176
|
+
ext = icon_extension(path)
|
|
177
|
+
[
|
|
178
|
+
preferred_base.empty? || base != preferred_base ? 1 : 0,
|
|
179
|
+
File.basename(path) == ".DirIcon" ? 0 : 1,
|
|
180
|
+
ext == ".svg" ? 0 : 1,
|
|
181
|
+
-File.size(path)
|
|
182
|
+
]
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def icon_extension(path)
|
|
186
|
+
ext = File.extname(path).downcase
|
|
187
|
+
return ext unless ext.empty?
|
|
188
|
+
|
|
189
|
+
File.extname(real_icon_path(path)).downcase
|
|
190
|
+
rescue SystemCallError
|
|
191
|
+
ext
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def real_icon_path(path)
|
|
195
|
+
File.realpath(path)
|
|
196
|
+
rescue SystemCallError
|
|
197
|
+
path
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def permission_summary(path)
|
|
201
|
+
{
|
|
202
|
+
"executable" => File.executable?(path),
|
|
203
|
+
"requires_sudo" => false,
|
|
204
|
+
"writes_outside_depot" => false,
|
|
205
|
+
"notes" => ["Portable AppImage installed user-locally."]
|
|
206
|
+
}
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|