crosspack 0.7.1 → 0.8.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 +4 -4
- data/CHANGELOG.md +85 -0
- data/README.md +72 -15
- data/exe/crosspack +37 -19
- data/lib/crossbuild/build_manifest.rb +43 -6
- data/lib/crossbuild/builder.rb +45 -9
- data/lib/crossbuild/matrix.rb +5 -0
- data/lib/crossbuild.rb +7 -6
- data/lib/crosspack/builders/app_dir.rb +43 -12
- data/lib/crosspack/builders/deb.rb +40 -11
- data/lib/crosspack/builders/pkgbuild.rb +7 -6
- data/lib/crosspack/builders/rpm.rb +24 -0
- data/lib/crosspack/builders/wix.rb +120 -16
- data/lib/crosspack/launcher.rb +16 -9
- data/lib/crosspack/package_manifest.rb +8 -1
- data/lib/crosspack/packer.rb +120 -8
- data/lib/crosspack/version.rb +1 -1
- metadata +2 -2
|
@@ -10,9 +10,12 @@ module Crosspack
|
|
|
10
10
|
# as-is.
|
|
11
11
|
class AppDir
|
|
12
12
|
# files: { source_path => basename inside Contents/MacOS }
|
|
13
|
-
# executables: basenames to chmod 0755
|
|
13
|
+
# executables: basenames to chmod 0755 (the first one is the bundle's
|
|
14
|
+
# CFBundleExecutable, falling back to the package name)
|
|
15
|
+
# icon: path to the bundle icon, copied into Contents/Resources
|
|
14
16
|
def self.build(name:, version:, summary:, files:, executables: [],
|
|
15
|
-
|
|
17
|
+
display_name: nil, icon: nil, identifier: nil,
|
|
18
|
+
min_macos: '11.0', output:)
|
|
16
19
|
app_dir = File.join(output, "#{name}.app")
|
|
17
20
|
FileUtils.rm_rf(app_dir)
|
|
18
21
|
macos_dir = File.join(app_dir, 'Contents', 'MacOS')
|
|
@@ -34,9 +37,20 @@ module Crosspack
|
|
|
34
37
|
FileUtils.chmod(executables.include?(dst) ? 0o755 : 0o644, target)
|
|
35
38
|
end
|
|
36
39
|
|
|
40
|
+
icon_file = nil
|
|
41
|
+
if icon
|
|
42
|
+
raise BuildError, "icon: file not found #{icon}" unless File.file?(icon)
|
|
43
|
+
|
|
44
|
+
icon_file = File.basename(icon)
|
|
45
|
+
FileUtils.cp(icon, File.join(resources_dir, icon_file))
|
|
46
|
+
end
|
|
47
|
+
|
|
37
48
|
write_info_plist(File.join(app_dir, 'Contents', 'Info.plist'),
|
|
38
49
|
name: name, version: version, summary: summary,
|
|
39
|
-
|
|
50
|
+
executable: executables.first || name,
|
|
51
|
+
display_name: display_name || name,
|
|
52
|
+
icon_file: icon_file, identifier: identifier,
|
|
53
|
+
min_macos: min_macos)
|
|
40
54
|
|
|
41
55
|
File.write(File.join(output, 'BUILD-DMG.txt'), <<~TXT)
|
|
42
56
|
DMG not built: hdiutil only exists on macOS.
|
|
@@ -46,31 +60,36 @@ module Crosspack
|
|
|
46
60
|
app_dir
|
|
47
61
|
end
|
|
48
62
|
|
|
49
|
-
def self.write_info_plist(path, name:, version:, summary:,
|
|
63
|
+
def self.write_info_plist(path, name:, version:, summary:, executable:,
|
|
64
|
+
display_name:, icon_file: nil, identifier: nil,
|
|
65
|
+
min_macos: '11.0')
|
|
50
66
|
bundle_id = identifier || "org.crosspack.#{name}"
|
|
67
|
+
# Without the extension .icns is assumed; other formats (png) must
|
|
68
|
+
# be named in full.
|
|
69
|
+
icon_ref = icon_file.nil? ? nil : icon_file.sub(/\.icns\z/i, '')
|
|
51
70
|
File.write(path, <<~PLIST)
|
|
52
71
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
53
72
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
54
73
|
<plist version="1.0">
|
|
55
74
|
<dict>
|
|
56
75
|
<key>CFBundleName</key>
|
|
57
|
-
<string>#{name}</string>
|
|
76
|
+
<string>#{escape(name)}</string>
|
|
58
77
|
<key>CFBundleDisplayName</key>
|
|
59
|
-
<string>#{
|
|
78
|
+
<string>#{escape(display_name)}</string>
|
|
60
79
|
<key>CFBundleExecutable</key>
|
|
61
|
-
<string>#{
|
|
80
|
+
<string>#{escape(executable)}</string>
|
|
62
81
|
<key>CFBundleIdentifier</key>
|
|
63
|
-
<string>#{bundle_id}</string>
|
|
82
|
+
<string>#{escape(bundle_id)}</string>
|
|
64
83
|
<key>CFBundlePackageType</key>
|
|
65
84
|
<string>APPL</string>
|
|
66
85
|
<key>CFBundleShortVersionString</key>
|
|
67
|
-
<string>#{version}</string>
|
|
86
|
+
<string>#{escape(version)}</string>
|
|
68
87
|
<key>CFBundleVersion</key>
|
|
69
|
-
<string>#{version}</string>
|
|
88
|
+
<string>#{escape(version)}</string>
|
|
70
89
|
<key>CFBundleInfoDictionaryVersion</key>
|
|
71
90
|
<string>6.0</string>
|
|
72
91
|
<key>LSMinimumSystemVersion</key>
|
|
73
|
-
<string
|
|
92
|
+
<string>#{escape(min_macos)}</string>
|
|
74
93
|
<key>NSPrincipalClass</key>
|
|
75
94
|
<string>NSApplication</string>
|
|
76
95
|
<key>NSSupportsAutomaticTermination</key>
|
|
@@ -78,11 +97,23 @@ module Crosspack
|
|
|
78
97
|
<key>NSSupportsSuddenTermination</key>
|
|
79
98
|
<false/>
|
|
80
99
|
<key>NSMicrophoneUsageDescription</key>
|
|
81
|
-
<string>#{summary}</string>
|
|
100
|
+
<string>#{escape(summary)}</string>
|
|
101
|
+
#{plist_icon_entry(icon_ref)}
|
|
82
102
|
</dict>
|
|
83
103
|
</plist>
|
|
84
104
|
PLIST
|
|
85
105
|
end
|
|
106
|
+
|
|
107
|
+
def self.plist_icon_entry(icon_ref)
|
|
108
|
+
return '' if icon_ref.nil?
|
|
109
|
+
|
|
110
|
+
" <key>CFBundleIconFile</key>\n <string>#{escape(icon_ref)}</string>"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Escapes XML entities so manifest values never break the plist.
|
|
114
|
+
def self.escape(text)
|
|
115
|
+
text.to_s.gsub('&', '&').gsub('<', '<').gsub('>', '>')
|
|
116
|
+
end
|
|
86
117
|
end
|
|
87
118
|
end
|
|
88
119
|
end
|
|
@@ -45,6 +45,7 @@ module Crosspack
|
|
|
45
45
|
stage_files(pkg_root)
|
|
46
46
|
stage_symlinks(pkg_root)
|
|
47
47
|
write_control(File.join(pkg_root, 'DEBIAN'))
|
|
48
|
+
write_maintainer_scripts(File.join(pkg_root, 'DEBIAN'))
|
|
48
49
|
|
|
49
50
|
FileUtils.mkdir_p(File.dirname(@output))
|
|
50
51
|
FileUtils.rm_f(@output)
|
|
@@ -104,17 +105,45 @@ module Crosspack
|
|
|
104
105
|
summary = description_lines.shift || @name
|
|
105
106
|
body = description_lines.map { |l| " #{l}" }
|
|
106
107
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
108
|
+
fields = ["Package: #{@name}", "Version: #{@version}", "Section: #{@section}",
|
|
109
|
+
"Priority: #{@priority}", "Architecture: #{@architecture}",
|
|
110
|
+
"Maintainer: #{@maintainer}"]
|
|
111
|
+
# An empty Depends: field is invalid; omit it instead.
|
|
112
|
+
fields << "Depends: #{@depends}" unless @depends.to_s.strip.empty?
|
|
113
|
+
File.write(File.join(debian_dir, 'control'),
|
|
114
|
+
"#{(fields + ["Description: #{summary}"]).join("\n")}\n#{body.join("\n")}\n")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Desktop integration caches: refresh the application database and the
|
|
118
|
+
# hicolor icon cache after (un)install when the package carries a
|
|
119
|
+
# .desktop file or a themed icon. The tools are guarded — they are not
|
|
120
|
+
# dpkg dependencies, minimal systems may lack them.
|
|
121
|
+
def write_maintainer_scripts(debian_dir)
|
|
122
|
+
commands = desktop_cache_commands
|
|
123
|
+
return if commands.empty?
|
|
124
|
+
|
|
125
|
+
script = "#!/bin/sh\nset -e\n#{commands.join("\n")}\n"
|
|
126
|
+
%w[postinst postrm].each do |script_name|
|
|
127
|
+
path = File.join(debian_dir, script_name)
|
|
128
|
+
File.write(path, script)
|
|
129
|
+
FileUtils.chmod(0o755, path)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def desktop_cache_commands
|
|
134
|
+
commands = @files.values.select { |d| d.end_with?('.desktop') }
|
|
135
|
+
.map { |d| File.dirname(d) }.uniq
|
|
136
|
+
.map do |dir|
|
|
137
|
+
"if command -v update-desktop-database >/dev/null 2>&1; then " \
|
|
138
|
+
"update-desktop-database -q /#{dir} || true; fi"
|
|
139
|
+
end
|
|
140
|
+
commands += @files.values.select { |d| d.include?('/share/icons/') }
|
|
141
|
+
.map { |d| File.dirname(d) }.uniq
|
|
142
|
+
.map do |dir|
|
|
143
|
+
"if command -v gtk-update-icon-cache >/dev/null 2>&1; then " \
|
|
144
|
+
"gtk-update-icon-cache -q -t -f /#{dir} || true; fi"
|
|
145
|
+
end
|
|
146
|
+
commands
|
|
118
147
|
end
|
|
119
148
|
end
|
|
120
149
|
end
|
|
@@ -11,12 +11,13 @@ module Crosspack
|
|
|
11
11
|
class Pkgbuild
|
|
12
12
|
def self.generate(name:, version:, release:, pkgdesc:, depends:, arch:,
|
|
13
13
|
files:, symlinks: {}, executables: [], source:,
|
|
14
|
-
maintainer:, url: '', license: 'Proprietary',
|
|
14
|
+
sha256: nil, maintainer:, url: '', license: 'Proprietary',
|
|
15
|
+
output:)
|
|
15
16
|
content = render(
|
|
16
17
|
name: name, version: version, release: release, pkgdesc: pkgdesc,
|
|
17
18
|
depends: Array(depends), arch: Array(arch), files: files,
|
|
18
19
|
symlinks: symlinks, executables: executables, source: Array(source),
|
|
19
|
-
maintainer: maintainer, url: url, license: license
|
|
20
|
+
sha256: sha256, maintainer: maintainer, url: url, license: license
|
|
20
21
|
)
|
|
21
22
|
FileUtils.mkdir_p(File.dirname(output))
|
|
22
23
|
File.write(output, content)
|
|
@@ -24,8 +25,8 @@ module Crosspack
|
|
|
24
25
|
end
|
|
25
26
|
|
|
26
27
|
def self.render(name:, version:, release:, pkgdesc:, depends:, arch:,
|
|
27
|
-
files:, symlinks:, executables:, source:,
|
|
28
|
-
url:, license:)
|
|
28
|
+
files:, symlinks:, executables:, source:, sha256:,
|
|
29
|
+
maintainer:, url:, license:)
|
|
29
30
|
depends_line = depends.map { |d| "'#{d}'" }.join(' ')
|
|
30
31
|
source_line = source.map { |s| "'#{s}'" }.join(' ')
|
|
31
32
|
srcbase = "$srcdir/$pkgname-$pkgver"
|
|
@@ -49,10 +50,10 @@ module Crosspack
|
|
|
49
50
|
pkgdesc='#{escaped_desc}'
|
|
50
51
|
arch=(#{arch.join(' ')})
|
|
51
52
|
url='#{url}'
|
|
52
|
-
license=(#{license})
|
|
53
|
+
license=('#{license}')
|
|
53
54
|
depends=(#{depends_line})
|
|
54
55
|
source=(#{source_line})
|
|
55
|
-
sha256sums=('SKIP')
|
|
56
|
+
sha256sums=('#{sha256 || 'SKIP'}')
|
|
56
57
|
|
|
57
58
|
package() {
|
|
58
59
|
#{(install_lines + link_lines).join("\n")}
|
|
@@ -29,6 +29,7 @@ module Crosspack
|
|
|
29
29
|
description ||= summary
|
|
30
30
|
requires_line = Array(requires).join(', ')
|
|
31
31
|
entries = spec_entries(files: files, symlinks: symlinks)
|
|
32
|
+
scriptlets = desktop_cache_scriptlets(files)
|
|
32
33
|
|
|
33
34
|
<<~SPEC
|
|
34
35
|
Name: #{name}
|
|
@@ -48,12 +49,35 @@ module Crosspack
|
|
|
48
49
|
%install
|
|
49
50
|
# Files are staged into the buildroot by crosspack.
|
|
50
51
|
|
|
52
|
+
#{scriptlets}
|
|
51
53
|
%files
|
|
52
54
|
%defattr(-,root,root,-)
|
|
53
55
|
#{entries.join("\n")}
|
|
54
56
|
SPEC
|
|
55
57
|
end
|
|
56
58
|
|
|
59
|
+
# %post/%postun: refresh the application database and the hicolor icon
|
|
60
|
+
# cache when the package carries a .desktop file or a themed icon; the
|
|
61
|
+
# tools are guarded (minimal systems may lack them).
|
|
62
|
+
def self.desktop_cache_scriptlets(files)
|
|
63
|
+
commands = files.values.select { |d| d.end_with?('.desktop') }
|
|
64
|
+
.map { |d| File.dirname(d) }.uniq
|
|
65
|
+
.map do |dir|
|
|
66
|
+
"command -v update-desktop-database >/dev/null 2>&1 && " \
|
|
67
|
+
"update-desktop-database -q /#{dir} || :"
|
|
68
|
+
end
|
|
69
|
+
commands += files.values.select { |d| d.include?('/share/icons/') }
|
|
70
|
+
.map { |d| File.dirname(d) }.uniq
|
|
71
|
+
.map do |dir|
|
|
72
|
+
"command -v gtk-update-icon-cache >/dev/null 2>&1 && " \
|
|
73
|
+
"gtk-update-icon-cache -q -t -f /#{dir} || :"
|
|
74
|
+
end
|
|
75
|
+
return '' if commands.empty?
|
|
76
|
+
|
|
77
|
+
body = commands.join("\n")
|
|
78
|
+
"%post\n#{body}\n\n%postun\n#{body}\n\n"
|
|
79
|
+
end
|
|
80
|
+
|
|
57
81
|
# %files entries: %dir for every directory so we never claim ownership
|
|
58
82
|
# of shared paths like /usr or /usr/local.
|
|
59
83
|
def self.spec_entries(files:, symlinks:)
|
|
@@ -12,11 +12,23 @@ module Crosspack
|
|
|
12
12
|
# complete .wxs source plus build instructions — packaging is not blocked
|
|
13
13
|
# by the toolchain.
|
|
14
14
|
class Wix
|
|
15
|
-
#
|
|
15
|
+
# The UI extension providing the standard install wizard dialogs.
|
|
16
|
+
UI_EXTENSION = 'WixToolset.UI.wixext'
|
|
17
|
+
# WiX v6 cannot use the unpinned extension (it pulls the incompatible
|
|
18
|
+
# 7.0.0, WIX6101 wixext7); 7+ resolves a matching version on its own.
|
|
19
|
+
UI_EXTENSION_V6_PIN = "#{UI_EXTENSION}/6.0.1"
|
|
20
|
+
|
|
21
|
+
# MSI ProductVersion honours three numeric fields (x.y.z), each 0..255:
|
|
22
|
+
# a calver year (2026) would trip WIX1148, so it maps to the short
|
|
23
|
+
# form (26) and leading zeros are dropped (2026.09.26 -> 26.9.26).
|
|
16
24
|
def self.msi_version(version)
|
|
17
25
|
fields = version.to_s.split(/[.-]/).first(3)
|
|
18
26
|
fields << '0' while fields.size < 3
|
|
19
|
-
fields.map
|
|
27
|
+
fields.map do |f|
|
|
28
|
+
n = f.to_i
|
|
29
|
+
n %= 100 if n > 255
|
|
30
|
+
n.to_s
|
|
31
|
+
end.join('.')
|
|
20
32
|
end
|
|
21
33
|
|
|
22
34
|
# Deterministic UpgradeCode derived from the package name, so upgrades
|
|
@@ -27,40 +39,75 @@ module Crosspack
|
|
|
27
39
|
"#{hex[0, 8]}-#{hex[8, 4]}-#{hex[12, 4]}-#{hex[16, 4]}-#{hex[20, 12]}"
|
|
28
40
|
end
|
|
29
41
|
|
|
30
|
-
# Escapes XML entities so manifest values
|
|
31
|
-
# "Name <email>") never break the generated .wxs.
|
|
42
|
+
# Escapes XML entities so manifest values never break the generated .wxs.
|
|
32
43
|
def self.escape(text)
|
|
33
44
|
text.to_s.gsub('&', '&').gsub('<', '<').gsub('>', '>').gsub('"', '"')
|
|
34
45
|
end
|
|
35
46
|
|
|
47
|
+
# The deb-style "Name <email>" maintainer must not reach the MSI
|
|
48
|
+
# Manufacturer property: it lands in ARP/Publisher (and an angle-bracket
|
|
49
|
+
# email is invalid in Windows Installer paths), so only the name half
|
|
50
|
+
# is kept.
|
|
51
|
+
def self.sanitize_manufacturer(manufacturer)
|
|
52
|
+
name = manufacturer.to_s.sub(/\s*<[^<>]*>\s*/, '').strip
|
|
53
|
+
name.empty? ? manufacturer.to_s.delete('<>') : name
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Minimal RTF for the WixUI_InstallDir license dialog — it requires
|
|
57
|
+
# WixUILicenseRtf to point at a real .rtf file, else the build fails.
|
|
58
|
+
def self.license_rtf(text)
|
|
59
|
+
escaped = text.to_s.gsub('\\', '\\\\\\\\').gsub('{', '\\{').gsub('}', '\\}')
|
|
60
|
+
lines = escaped.lines.map(&:chomp)
|
|
61
|
+
lines = [''] if lines.empty?
|
|
62
|
+
"{\\rtf1\\ansi\n#{lines.join("\\par\n")}\n}\n"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# shortcuts: nil, or { name: "Display Name", target: "app.exe" } (the
|
|
66
|
+
# payload file inside INSTALLFOLDER the shortcuts launch) — installs
|
|
67
|
+
# Start Menu and Desktop shortcuts. icon: local path to an .ico for
|
|
68
|
+
# the Add/Remove Programs entry (ARPPRODUCTICON), or nil.
|
|
36
69
|
def self.generate_wxs(name:, version:, manufacturer:, summary:, files:,
|
|
37
|
-
arch: 'x64')
|
|
70
|
+
arch: 'x64', shortcuts: nil, icon: nil)
|
|
71
|
+
license_rtf_file = "#{name}-license.rtf"
|
|
38
72
|
name = escape(name)
|
|
39
|
-
manufacturer = escape(manufacturer)
|
|
73
|
+
manufacturer = escape(sanitize_manufacturer(manufacturer))
|
|
40
74
|
summary = escape(summary)
|
|
41
75
|
directory = arch == 'arm64' ? 'ProgramFiles6432Folder' : 'ProgramFiles64Folder'
|
|
42
76
|
components = files.values.each_with_index.map do |dst, i|
|
|
43
|
-
src = files.key(dst).gsub('/', '\\')
|
|
77
|
+
src = escape(files.key(dst).gsub('/', '\\'))
|
|
44
78
|
" <Component Id=\"c#{i}\" Guid=\"*\">\n" \
|
|
45
79
|
" <File Id=\"f#{i}\" Source=\"#{src}\" />\n" \
|
|
46
80
|
' </Component>'
|
|
47
81
|
end.join("\n")
|
|
48
82
|
|
|
83
|
+
# Id must be INSTALLFOLDER: WiX v4+ auto-creates a directory with
|
|
84
|
+
# this exact Id (named "[Manufacturer] [ProductName]") only when the
|
|
85
|
+
# author has not defined one; a different Id makes it add a phantom
|
|
86
|
+
# sibling directory and Manufacturer leaks into install paths.
|
|
87
|
+
shortcut_xml = shortcuts ? shortcuts_fragment(shortcuts) : nil
|
|
88
|
+
feature_refs = ["<ComponentGroupRef Id=\"Payload\" />"]
|
|
89
|
+
feature_refs << '<ComponentGroupRef Id="Shortcuts" />' if shortcuts
|
|
90
|
+
arp_icon = icon ? arp_icon_fragment(icon) : nil
|
|
91
|
+
|
|
49
92
|
<<~WXS
|
|
50
93
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
51
|
-
<!-- Generated by crosspack. Build with: wix build -arch #{arch} -o #{name}.msi #{name}.wxs -->
|
|
52
|
-
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"
|
|
94
|
+
<!-- Generated by crosspack. Build with: wix build -arch #{arch} -ext #{UI_EXTENSION} -o #{name}.msi #{name}.wxs -->
|
|
95
|
+
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"
|
|
96
|
+
xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui">
|
|
53
97
|
<Package Name="#{name}" Manufacturer="#{manufacturer}" Version="#{msi_version(version)}"
|
|
54
98
|
UpgradeCode="{#{upgrade_code(name)}}" Scope="perMachine">
|
|
55
99
|
<SummaryInformation Description="#{summary}" />
|
|
56
100
|
<MajorUpgrade DowngradeErrorMessage="A newer version of #{name} is already installed." />
|
|
57
101
|
|
|
58
102
|
<StandardDirectory Id="#{directory}">
|
|
59
|
-
<Directory Id="
|
|
103
|
+
<Directory Id="INSTALLFOLDER" Name="#{name}" />
|
|
60
104
|
</StandardDirectory>
|
|
61
105
|
|
|
106
|
+
<Property Id="WixUILicenseRtf" Value="#{license_rtf_file}" />
|
|
107
|
+
<ui:WixUI Id="WixUI_InstallDir" InstallDirectory="INSTALLFOLDER" />
|
|
108
|
+
#{arp_icon}
|
|
62
109
|
<Feature Id="Main" Title="#{name}" Level="1">
|
|
63
|
-
|
|
110
|
+
#{feature_refs.map { |r| " #{r}" }.join("\n")}
|
|
64
111
|
</Feature>
|
|
65
112
|
</Package>
|
|
66
113
|
|
|
@@ -69,14 +116,40 @@ module Crosspack
|
|
|
69
116
|
#{components}
|
|
70
117
|
</ComponentGroup>
|
|
71
118
|
</Fragment>
|
|
119
|
+
#{shortcut_xml}
|
|
72
120
|
</Wix>
|
|
73
121
|
WXS
|
|
74
122
|
end
|
|
75
123
|
|
|
76
|
-
#
|
|
77
|
-
#
|
|
124
|
+
# Start Menu + Desktop shortcuts for the main executable. The shortcut
|
|
125
|
+
# component's KeyPath is a marker registry value (a shortcut cannot be
|
|
126
|
+
# a KeyPath itself); the icon comes from the exe.
|
|
127
|
+
def self.shortcuts_fragment(shortcuts)
|
|
128
|
+
name = escape(shortcuts[:name])
|
|
129
|
+
target = escape(shortcuts[:target])
|
|
130
|
+
<<~XML
|
|
131
|
+
<Fragment>
|
|
132
|
+
<ComponentGroup Id="Shortcuts" Directory="INSTALLFOLDER">
|
|
133
|
+
<Component Id="c.shortcuts" Guid="*">
|
|
134
|
+
<Shortcut Id="StartMenuShortcut" Directory="ProgramMenuFolder"
|
|
135
|
+
Name="#{name}" Target="[INSTALLFOLDER]#{target}"
|
|
136
|
+
WorkingDirectory="INSTALLFOLDER" />
|
|
137
|
+
<Shortcut Id="DesktopShortcut" Directory="DesktopFolder"
|
|
138
|
+
Name="#{name}" Target="[INSTALLFOLDER]#{target}"
|
|
139
|
+
WorkingDirectory="INSTALLFOLDER" />
|
|
140
|
+
<RegistryValue Root="HKLM" Key="Software\\#{name}"
|
|
141
|
+
Name="installed" Type="integer" Value="1" KeyPath="yes" />
|
|
142
|
+
</Component>
|
|
143
|
+
</ComponentGroup>
|
|
144
|
+
</Fragment>
|
|
145
|
+
XML
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Writes the .wxs (plus the license .rtf the UI wizard shows) next to
|
|
149
|
+
# the output and, when WiX is installed, builds a real MSI. Returns
|
|
150
|
+
# the path of whatever was produced.
|
|
78
151
|
def self.build(name:, version:, manufacturer:, summary:, files:, output:,
|
|
79
|
-
arch: 'x64')
|
|
152
|
+
arch: 'x64', shortcuts: nil, license: nil, icon: nil)
|
|
80
153
|
arch = { 'x86_64' => 'x64', 'aarch64' => 'arm64' }.fetch(arch, arch)
|
|
81
154
|
missing = files.reject { |src, _dst| File.file?(src) }
|
|
82
155
|
unless missing.empty?
|
|
@@ -92,7 +165,11 @@ module Crosspack
|
|
|
92
165
|
File.write(wxs_path, generate_wxs(name: name, version: version,
|
|
93
166
|
manufacturer: manufacturer,
|
|
94
167
|
summary: summary, files: files,
|
|
95
|
-
arch: arch
|
|
168
|
+
arch: arch, shortcuts: shortcuts,
|
|
169
|
+
icon: icon))
|
|
170
|
+
File.write(File.join(out_dir, "#{name}-license.rtf"),
|
|
171
|
+
license_rtf(license || summary))
|
|
172
|
+
FileUtils.cp(icon, File.join(out_dir, File.basename(icon))) if icon
|
|
96
173
|
|
|
97
174
|
wix = wix_command
|
|
98
175
|
unless wix
|
|
@@ -100,13 +177,17 @@ module Crosspack
|
|
|
100
177
|
MSI not built: WiX toolset not found in PATH.
|
|
101
178
|
Install the .NET SDK and WiX:
|
|
102
179
|
dotnet tool install -g wix
|
|
180
|
+
The install wizard needs the UI extension:
|
|
181
|
+
wix extension add -g #{UI_EXTENSION_V6_PIN} # WiX 6.x (no pin needed for WiX 7+)
|
|
103
182
|
Then build with:
|
|
104
|
-
wix build -arch #{arch} -o #{File.basename(output)} #{name}.wxs
|
|
183
|
+
wix build -arch #{arch} -ext #{UI_EXTENSION} -o #{File.basename(output)} #{name}.wxs
|
|
105
184
|
TXT
|
|
106
185
|
return wxs_path
|
|
107
186
|
end
|
|
108
187
|
|
|
188
|
+
ensure_ui_extension(wix)
|
|
109
189
|
out, status = Open3.capture2e(wix, 'build', '-arch', arch.to_s,
|
|
190
|
+
'-ext', UI_EXTENSION,
|
|
110
191
|
'-o', output, wxs_path)
|
|
111
192
|
unless status.success?
|
|
112
193
|
raise BuildError, "wix build failed to build #{output}:\n#{out}"
|
|
@@ -124,6 +205,29 @@ module Crosspack
|
|
|
124
205
|
rescue Errno::ENOENT
|
|
125
206
|
nil
|
|
126
207
|
end
|
|
208
|
+
|
|
209
|
+
# The Add/Remove Programs entry shows this icon (ARP/Publisher uses
|
|
210
|
+
# the MSI Icons table, not the installed files).
|
|
211
|
+
def self.arp_icon_fragment(icon)
|
|
212
|
+
icon_file = File.basename(icon)
|
|
213
|
+
" <Property Id=\"ARPPRODUCTICON\" Value=\"AppIcon\" />\n" \
|
|
214
|
+
" <Icon Id=\"AppIcon\" SourceFile=\"#{escape(icon_file)}\" />"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# The WixUI dialogs live in the UI extension, so it must be installed
|
|
218
|
+
# before `wix build -ext WixToolset.UI.wixext` can resolve it. A no-op
|
|
219
|
+
# when it is already present.
|
|
220
|
+
def self.ensure_ui_extension(wix = 'wix')
|
|
221
|
+
list, = Open3.capture2e(wix, 'extension', 'list', '-g')
|
|
222
|
+
return if list.include?(UI_EXTENSION)
|
|
223
|
+
|
|
224
|
+
version, = Open3.capture2e(wix, '--version')
|
|
225
|
+
spec = version.to_i >= 7 ? UI_EXTENSION : UI_EXTENSION_V6_PIN
|
|
226
|
+
out, status = Open3.capture2e(wix, 'extension', 'add', '-g', spec)
|
|
227
|
+
return if status.success?
|
|
228
|
+
|
|
229
|
+
raise BuildError, "cannot install the WiX UI extension (#{spec}):\n#{out}"
|
|
230
|
+
end
|
|
127
231
|
end
|
|
128
232
|
end
|
|
129
233
|
end
|
data/lib/crosspack/launcher.rb
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Crosspack
|
|
4
|
-
# Host-only launch commands: `crosspack run` (build the
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
4
|
+
# Host-only launch commands: `crosspack run [goal]` (build the goal's
|
|
5
|
+
# host-buildable entries — default goal: `default` —, then launch the
|
|
6
|
+
# application's main binary) and `crosspack install` (launch the package
|
|
7
|
+
# packed for this host). Both resolve the current host target, so they
|
|
8
|
+
# take no package-target argument and are meaningless on any other system.
|
|
8
9
|
#
|
|
9
10
|
# Commands may be overridden per host in crosspack.yml (run:/install:
|
|
10
11
|
# sections, selectors like the deps: rules of build:); placeholders:
|
|
@@ -15,11 +16,14 @@ module Crosspack
|
|
|
15
16
|
class Launcher
|
|
16
17
|
class Error < BuildError; end
|
|
17
18
|
|
|
18
|
-
# Builds the host
|
|
19
|
-
#
|
|
19
|
+
# Builds the given goal's host-buildable entries first (the same stage
|
|
20
|
+
# `crosspack build <goal>` runs; default goal: `default`), then launches
|
|
21
|
+
# a package.executables binary from builds/ — the one named by the goal
|
|
22
|
+
# when it matches, otherwise the first.
|
|
20
23
|
# Returns the launched process's exit code.
|
|
21
24
|
def self.run(config, root: Dir.pwd, args: [], output_base: nil, version: nil,
|
|
22
|
-
deps: true,
|
|
25
|
+
deps: true, goal: Crossbuild::BuildManifest::DEFAULT_GOAL, host_string: nil,
|
|
26
|
+
host_os: Crossbuild::Platform.os,
|
|
23
27
|
host_arch: Crossbuild::Platform.arch, selectors: Crossbuild::Platform.selectors)
|
|
24
28
|
config = Config.coerce(config, root: root)
|
|
25
29
|
target = host_target('run', host_string, host_arch)
|
|
@@ -30,9 +34,12 @@ module Crosspack
|
|
|
30
34
|
Crossbuild::Builder.new(build_manifest, root: root, output_base: output_base,
|
|
31
35
|
version: version, deps: deps,
|
|
32
36
|
host_os: host_os, host_arch: host_arch)
|
|
33
|
-
.run(
|
|
37
|
+
.run(goal: goal)
|
|
34
38
|
|
|
35
|
-
|
|
39
|
+
# A binary goal (`crosspack run helloworld`) launches that executable;
|
|
40
|
+
# a group goal or the default goal launches the first one.
|
|
41
|
+
executables = pkg.executables_for(target)
|
|
42
|
+
exe = executables.include?(goal) ? goal : executables.first
|
|
36
43
|
if exe.nil?
|
|
37
44
|
raise Error,
|
|
38
45
|
'run: the package: section does not name the binary to launch — ' \
|
|
@@ -14,7 +14,7 @@ module Crosspack
|
|
|
14
14
|
# no .so libraries and vice versa.
|
|
15
15
|
class PackageManifest
|
|
16
16
|
REQUIRED = %w[maintainer description sources prefix payload].freeze
|
|
17
|
-
OPTIONAL = %w[name summary license section lib_dir executables links desktop icon].freeze
|
|
17
|
+
OPTIONAL = %w[name summary license section lib_dir executables links desktop icon min_macos].freeze
|
|
18
18
|
|
|
19
19
|
# Payload selectors may name an os in addition to a family: every distro
|
|
20
20
|
# family packs a linux target, so "linux:" covers deb/rpm/PKGBUILD
|
|
@@ -142,6 +142,12 @@ module Crosspack
|
|
|
142
142
|
@data['icon']
|
|
143
143
|
end
|
|
144
144
|
|
|
145
|
+
# Minimum macOS version for the .app bundle (LSMinimumSystemVersion);
|
|
146
|
+
# defaults to 11.0 in the builder.
|
|
147
|
+
def min_macos
|
|
148
|
+
@data['min_macos']
|
|
149
|
+
end
|
|
150
|
+
|
|
145
151
|
private
|
|
146
152
|
|
|
147
153
|
def validate
|
|
@@ -172,6 +178,7 @@ module Crosspack
|
|
|
172
178
|
validate_string('description', nil)
|
|
173
179
|
validate_string('sources', nil)
|
|
174
180
|
validate_string('prefix', /\A[a-z0-9][a-z0-9+._\/-]*\z/)
|
|
181
|
+
validate_string('min_macos', /\A\d+(\.\d+)*\z/)
|
|
175
182
|
validate_payload
|
|
176
183
|
validate_executables
|
|
177
184
|
validate_links
|