Skip to content

Commit 4446803

Browse files
committed
add mac osx bundle
1 parent 0ae8cb7 commit 4446803

File tree

5 files changed

+176
-1
lines changed

5 files changed

+176
-1
lines changed

README.md

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ distribution. It bundles your script, the Ruby interpreter, gems, and native
1111
libraries into a single self-contained artifact that runs without requiring
1212
Ruby to be installed on the target machine.
1313

14-
OCRAN supports three output formats, all cross-platform:
14+
OCRAN supports four output formats, all cross-platform:
1515

1616
* **Self-extracting executable** — bundles everything into a single binary that unpacks and runs transparently, with no Ruby installation required. Produces a `.exe` on Windows, and a native executable on Linux and macOS.
17+
* **macOS app bundle** (`--macosx-bundle`) — wraps the executable in a `.app` bundle for Finder integration, Dock icons, and code signing.
1718
* **Directory** (`--output-dir`) — copies all files into a folder with a ready-to-run launch script (`.sh` on Linux/macOS, `.bat` on Windows).
1819
* **Zip archive** (`--output-zip`) — same as directory output, packed into a `.zip`.
1920

@@ -38,6 +39,7 @@ bundled Ruby on Linux, macOS, or Windows.
3839
## Features
3940

4041
* **Windows/Linux/macOS executable** — self-extracting, self-running executable (primary output)
42+
* **macOS app bundle**`.app` bundle with `Info.plist` for Finder/Dock/code-signing (`--macosx-bundle`)
4143
* **Directory output** — portable directory with a launch script (`--output-dir`)
4244
* **Zip archive output** — portable zip with a launch script (`--output-zip`)
4345
* LZMA compression (optional, default on, for `.exe` only)
@@ -86,6 +88,19 @@ DLLs) into `script.exe`.
8688
Copies all files into `myapp/` and writes a `script.sh` (or `script.bat` on
8789
Windows) launch script.
8890

91+
### Building a macOS app bundle:
92+
93+
ocran --macosx-bundle script.rb
94+
95+
Produces `script.app/` — a standard macOS `.app` bundle containing the
96+
self-extracting executable at `Contents/MacOS/script` and an `Info.plist`.
97+
Open it with `open script.app` or double-click it in Finder.
98+
99+
ocran --macosx-bundle --output MyApp --bundle-id com.example.myapp --icon icon.icns script.rb
100+
101+
Custom name, bundle identifier and icon (must be `.icns` format). The icon is
102+
placed at `Contents/Resources/AppIcon.icns` and referenced in `Info.plist`.
103+
89104
### Building a zip archive:
90105

91106
ocran --output-zip myapp.zip script.rb
@@ -142,6 +157,8 @@ Fine-tuning flags:
142157
* `--output <file>`: Name the generated executable. Defaults to `./<scriptname>.exe` on Windows and `./<scriptname>` on Linux/macOS.
143158
* `--output-dir <dir>`: Output all files to a directory with a launch script instead of building an executable. Works on Linux, macOS, and Windows.
144159
* `--output-zip <file>`: Output a zip archive containing all files and a launch script. Requires `zip` (Linux/macOS) or PowerShell (Windows).
160+
* `--macosx-bundle`: Build a macOS `.app` bundle. Use `--output` to set the bundle name (default: `<scriptname>.app`). (macOS)
161+
* `--bundle-id <id>`: Set the `CFBundleIdentifier` in `Info.plist` (default: `com.example.<appname>`). Used with `--macosx-bundle`.
145162
* `--no-lzma`: Disable LZMA compression (faster build, larger executable).
146163
* `--innosetup <file>`: Use an Inno Setup script (`.iss`) to create a Windows installer.
147164

@@ -312,6 +329,35 @@ Four modes:
312329
If files are missing from the output, try `--gem-all=gemname` first, then
313330
`--gem-full=gemname`. Use `--gem-full` to include everything for all gems.
314331

332+
### Code-signing a macOS app bundle
333+
334+
After building with `--macosx-bundle`, sign the bundle with your Developer ID:
335+
336+
codesign --deep --force --verify --verbose \
337+
--sign "Developer ID Application: Your Name (TEAMID)" \
338+
MyApp.app
339+
340+
Verify the signature:
341+
342+
codesign --verify --deep --strict --verbose=2 MyApp.app
343+
spctl --assess --type execute --verbose MyApp.app
344+
345+
For distribution outside the Mac App Store, notarize with Apple:
346+
347+
# Submit for notarization (requires app-specific password or API key)
348+
xcrun notarytool submit MyApp.app \
349+
--apple-id you@example.com \
350+
--team-id TEAMID \
351+
--password APP_SPECIFIC_PASSWORD \
352+
--wait
353+
354+
# Staple the notarization ticket so it works offline
355+
xcrun stapler staple MyApp.app
356+
357+
Requirements:
358+
* Xcode Command Line Tools (`xcode-select --install`)
359+
* An Apple Developer account with a "Developer ID Application" certificate in Keychain
360+
315361
### Creating a Windows installer
316362

317363
To make your application start faster or keep files between runs, use

lib/ocran/direction.rb

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,63 @@ def build_zip(path)
551551
say "Finished building #{path} (#{File.size(path)} bytes)"
552552
end
553553

554+
def build_macosx_bundle(bundle_path)
555+
require_relative "stub_builder"
556+
require "fileutils"
557+
558+
bundle_path = Pathname(bundle_path)
559+
app_name = bundle_path.basename.sub_ext("").to_s
560+
contents_dir = bundle_path / "Contents"
561+
macos_dir = contents_dir / "MacOS"
562+
resources_dir = contents_dir / "Resources"
563+
564+
FileUtils.mkdir_p(macos_dir.to_s)
565+
566+
executable_path = macos_dir / app_name
567+
say "Building app bundle #{bundle_path}"
568+
569+
StubBuilder.new(executable_path,
570+
chdir_before: @option.chdir_before?,
571+
debug_extract: @option.enable_debug_extract?,
572+
debug_mode: @option.enable_debug_mode?,
573+
enable_compression: @option.enable_compression?,
574+
gui_mode: false,
575+
icon_path: nil,
576+
&to_proc) => builder
577+
578+
if @option.icon_filename
579+
FileUtils.mkdir_p(resources_dir.to_s)
580+
icon_dest = resources_dir / "AppIcon#{@option.icon_filename.extname}"
581+
FileUtils.cp(@option.icon_filename.to_s, icon_dest.to_s)
582+
end
583+
584+
bundle_id = @option.bundle_identifier || "com.example.#{app_name}"
585+
icon_entry = @option.icon_filename ? " <key>CFBundleIconFile</key>\n <string>AppIcon</string>\n" : ""
586+
587+
File.write(contents_dir / "Info.plist", <<~PLIST)
588+
<?xml version="1.0" encoding="UTF-8"?>
589+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
590+
<plist version="1.0">
591+
<dict>
592+
<key>CFBundleName</key>
593+
<string>#{app_name}</string>
594+
<key>CFBundleDisplayName</key>
595+
<string>#{app_name}</string>
596+
<key>CFBundleIdentifier</key>
597+
<string>#{bundle_id}</string>
598+
<key>CFBundleVersion</key>
599+
<string>1.0</string>
600+
<key>CFBundlePackageType</key>
601+
<string>APPL</string>
602+
<key>CFBundleExecutable</key>
603+
<string>#{app_name}</string>
604+
#{icon_entry}</dict>
605+
</plist>
606+
PLIST
607+
608+
say "Finished building #{bundle_path} (#{builder.data_size} bytes decompressed)"
609+
end
610+
554611
def build_stab_exe
555612
require_relative "stub_builder"
556613

lib/ocran/option.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ def initialize
1212
:add_all_encoding? => true,
1313
:argv => [],
1414
:auto_detect_dlls? => true,
15+
:bundle_identifier => nil,
16+
:macosx_bundle => nil,
17+
:macosx_bundle? => false,
1518
:chdir_before? => false,
1619
:enable_compression? => true,
1720
:enable_debug_extract? => false,
@@ -87,6 +90,8 @@ def usage
8790
--output <file> Name the exe to generate. Defaults to ./<scriptname>.exe.
8891
--output-dir <dir> Output all files to a directory with a launch script instead of an exe.
8992
--output-zip <file> Output a zip archive containing all files and a launch script.
93+
--macosx-bundle Build a macOS .app bundle. Use --output to name it (default: <scriptname>.app).
94+
--bundle-id <id> Bundle identifier for the macOS app bundle (default: com.example.<appname>).
9095
--no-lzma Disable LZMA compression of the executable.
9196
--innosetup <file> Use given Inno Setup script (.iss) to create an installer.
9297
@@ -120,6 +125,10 @@ def parse(argv)
120125
when "--output-zip"
121126
path = argv.shift
122127
@options[:output_zip] = Pathname.new(path).expand_path if path
128+
when "--macosx-bundle"
129+
@options[:macosx_bundle?] = true
130+
when "--bundle-id"
131+
@options[:bundle_identifier] = argv.shift
123132
when "--dll"
124133
path = argv.shift
125134
@options[:extra_dlls] << path if path
@@ -210,6 +219,11 @@ def parse(argv)
210219
executable.basename.sub_ext(ext).expand_path
211220
end
212221

222+
if @options[:macosx_bundle?]
223+
bundle_base = output_override || script.basename
224+
@options[:macosx_bundle] = Pathname(bundle_base).sub_ext(".app").expand_path
225+
end
226+
213227
@options[:use_inno_setup?] = !!inno_setup_script
214228

215229
@options[:verbose?] &&= !quiet?
@@ -233,6 +247,10 @@ def parse(argv)
233247
if output_dir && output_zip
234248
raise "--output-dir and --output-zip cannot be used together"
235249
end
250+
251+
if macosx_bundle && (output_dir || output_zip || inno_setup_script)
252+
raise "--macosx-bundle cannot be combined with --output-dir, --output-zip, or --innosetup"
253+
end
236254
end
237255

238256
def add_all_core? = @options[__method__]
@@ -241,6 +259,10 @@ def add_all_encoding? = @options[__method__]
241259

242260
def argv = @options[__method__]
243261

262+
def bundle_identifier = @options[__method__]
263+
264+
def macosx_bundle = @options[__method__]
265+
244266
def auto_detect_dlls? = @options[__method__]
245267

246268
def chdir_before? = @options[__method__]

lib/ocran/runner.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ def build
9999
else
100100
raise "Inno Setup is only supported on Windows"
101101
end
102+
elsif @option.macosx_bundle
103+
direction.build_macosx_bundle(@option.macosx_bundle)
102104
elsif @option.output_dir
103105
direction.build_output_dir(@option.output_dir)
104106
elsif @option.output_zip

test/test_ocra.rb

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1132,4 +1132,52 @@ def test_openssl_https_cacert
11321132
end
11331133
end
11341134
end
1135+
1136+
# Tests that --macosx-bundle produces a valid .app bundle structure and
1137+
# that the executable inside it runs correctly.
1138+
def test_macosx_bundle
1139+
skip "macOS app bundle test is macOS-only" unless RUBY_PLATFORM.include?("darwin")
1140+
with_fixture 'helloworld' do
1141+
assert system("ruby", ocran, "helloworld.rb", "--macosx-bundle", *DefaultArgs)
1142+
1143+
bundle = "helloworld.app"
1144+
assert Dir.exist?(bundle), "Expected #{bundle} directory to exist"
1145+
assert File.exist?(File.join(bundle, "Contents", "Info.plist")), "Expected Info.plist"
1146+
1147+
exe = File.join(bundle, "Contents", "MacOS", "helloworld")
1148+
assert File.exist?(exe), "Expected executable at Contents/MacOS/helloworld"
1149+
assert File.executable?(exe), "Expected Contents/MacOS/helloworld to be executable"
1150+
1151+
pristine_env exe do
1152+
assert system(exe)
1153+
end
1154+
end
1155+
end
1156+
1157+
# Tests --macosx-bundle with a custom name, bundle-id, and icon.
1158+
def test_macosx_bundle_custom
1159+
skip "macOS app bundle test is macOS-only" unless RUBY_PLATFORM.include?("darwin")
1160+
with_fixture 'helloworld' do
1161+
# Create a minimal placeholder .icns file (not a real icon, just tests the copy)
1162+
File.write("test.icns", "placeholder")
1163+
1164+
assert system("ruby", ocran, "helloworld.rb",
1165+
"--macosx-bundle",
1166+
"--output", "MyApp",
1167+
"--bundle-id", "com.example.myapp",
1168+
"--icon", "test.icns",
1169+
*DefaultArgs)
1170+
1171+
bundle = "MyApp.app"
1172+
assert Dir.exist?(bundle)
1173+
1174+
plist = File.read(File.join(bundle, "Contents", "Info.plist"))
1175+
assert plist.include?("com.example.myapp"), "Expected bundle identifier in Info.plist"
1176+
assert plist.include?("MyApp"), "Expected app name in Info.plist"
1177+
assert plist.include?("CFBundleIconFile"), "Expected icon entry in Info.plist"
1178+
1179+
assert File.exist?(File.join(bundle, "Contents", "Resources", "AppIcon.icns"))
1180+
assert File.exist?(File.join(bundle, "Contents", "MacOS", "MyApp"))
1181+
end
1182+
end
11351183
end

0 commit comments

Comments
 (0)