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.
@@ -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
- identifier: nil, output:)
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
- identifier: identifier)
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:, identifier: nil)
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>#{name}</string>
78
+ <string>#{escape(display_name)}</string>
60
79
  <key>CFBundleExecutable</key>
61
- <string>#{name}</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>11.0</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('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;')
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
- File.write(File.join(debian_dir, 'control'), <<~CONTROL)
108
- Package: #{@name}
109
- Version: #{@version}
110
- Section: #{@section}
111
- Priority: #{@priority}
112
- Architecture: #{@architecture}
113
- Maintainer: #{@maintainer}
114
- Depends: #{@depends}
115
- Description: #{summary}
116
- #{body.join("\n")}
117
- CONTROL
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', output:)
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:, maintainer:,
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
- # MSI ProductVersion only honours three numeric fields (x.y.z).
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 { |f| f.gsub(/\D/, '').empty? ? '0' : f.gsub(/\D/, '') }.join('.')
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 (e.g. a maintainer with
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('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;').gsub('"', '&quot;')
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="INSTALLDIR" Name="#{name}" />
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
- <ComponentGroupRef Id="Payload" />
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
- # Writes the .wxs next to the output and, when WiX is installed,
77
- # builds a real MSI. Returns the path of whatever was produced.
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
@@ -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 host target, then
5
- # launch the application's main binary) and `crosspack install` (launch the
6
- # package packed for this host). Both resolve the current host target, so
7
- # they take no target argument and are meaningless on any other system.
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 target first (the same stage `crosspack build` runs),
19
- # then launches the first package.executables binary from builds/.
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, host_string: nil, host_os: Crossbuild::Platform.os,
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(target: target.to_s)
37
+ .run(goal: goal)
34
38
 
35
- exe = pkg.executables_for(target).first
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