Files
build-emacs-for-macos/build-emacs-for-macos

2518 lines
67 KiB
Ruby
Executable File

#!/usr/bin/env ruby
# frozen_string_literal: true
require 'English'
require 'date'
require 'erb'
require 'etc'
require 'fileutils'
require 'json'
require 'logger'
require 'net/http'
require 'open3'
require 'optparse'
require 'pathname'
require 'set'
require 'time'
require 'tmpdir'
require 'uri'
require 'yaml'
require 'macho'
class Error < StandardError
end
module Output
class << self
LEVELS = {
debug: Logger::DEBUG,
error: Logger::ERROR,
fatal: Logger::FATAL,
info: Logger::INFO,
unknown: Logger::UNKNOWN,
warn: Logger::WARN
}.freeze
def log_level
LEVELS.key(logger.level)
end
def log_level=(level)
logger.level = LEVELS.fetch(level&.to_sym)
end
def logger
@logger ||=
Logger
.new($stderr)
.tap do |logger|
logger.level = Logger::INFO
logger.formatter =
proc do |severity, _datetime, _progname, msg|
"==> #{severity.upcase}: #{msg}"
end
end
end
end
%i[debug info warn error].each do |severity|
define_method(severity) do |msg, newline: true|
logger.send(severity, format_msg(msg, newline: newline))
end
end
def fatal(msg = nil)
raise Error, msg
end
private
def logger
Output.logger
end
def format_msg(msg, newline: true)
msg = msg.join("\n") if msg.is_a?(Array)
msg = msg.strip
msg = "#{msg}\n" if newline
msg
end
end
module Helpers
def valid_url?(uri)
uri = URI.parse(uri)
(uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)) &&
uri.host.respond_to?(:empty?) && !uri.host.empty?
rescue URI::InvalidURIError
false
end
end
module System
include Output
def run_cmd(*args, output_file: nil)
debug "executing: #{args.join(' ')}"
cmd(*args, output_file: output_file)
end
def cmd(*args, output_file: nil)
if output_file.nil?
return system(*args) || fatal("Exit code: #{$CHILD_STATUS.exitstatus}")
end
# Handle output to both terminal and file
File.open(output_file, 'w') do |file|
Open3.popen3(*args) do |_stdin, stdout, stderr, wait_thread|
stdout_thread = Thread.new do
while (line = stdout.gets)
puts line
file.puts line
file.flush
end
end
stderr_thread = Thread.new do
while (line = stderr.gets)
$stderr.puts line # rubocop:disable Style/StderrPuts
file.puts line
file.flush
end
end
[stdout_thread, stderr_thread, wait_thread].map(&:join)
status = wait_thread.value
return true if status.success?
fatal("Exit code: #{status.exitstatus}")
end
end
end
end
class OS
def self.version
@version ||= OSVersion.new
end
def self.sdk_version
@sdk_version ||= SDKVersion.new
end
def self.arch
@arch ||= `uname -m`.strip
end
end
class AbstractVersion
attr_reader :version
def initialize
@version = load_version.match(
/(?<major>\d+)(?:\.(?<minor>\d+)(?:\.(?<patch>\d+))?)?/
)
end
def load_version
raise NotImplementedError
end
def to_s
@to_s ||= major >= 11 ? major.to_s : "#{major}.#{minor}"
end
def major
@major ||= @version[:major]&.to_i
end
def minor
@minor ||= @version[:minor]&.to_i
end
def patch
@patch ||= @version[:patch]&.to_i
end
end
class OSVersion < AbstractVersion
def load_version
`sw_vers -productVersion`.strip
end
end
class SDKVersion < AbstractVersion
def load_version
ENV.fetch(
'MACOSX_DEPLOYMENT_TARGET',
`xcrun --show-sdk-version 2>/dev/null`.strip
).strip
end
end
class Build
include Output
include System
include Helpers
DEFAULT_GITHUB_REPO = 'emacs-mirror/emacs'
attr_reader :root_dir
attr_reader :source_dir
attr_reader :ref
attr_reader :options
attr_reader :gcc_info
def initialize(root_dir, ref = nil, options = {})
@root_dir = root_dir
@ref = ref || 'master'
@options = options
@gcc_info = GccInfo.new(use_nix: options[:use_nix])
load_plan(options[:plan]) if options[:plan]
end
def build
unless meta[:sha] && meta[:date]
fatal 'Failed to get commit info from GitHub.'
end
tarball = download_tarball(meta[:sha])
@source_dir = extract_tarball(tarball, build_patches)
autogen
detect_native_comp if options[:native_comp].nil?
app = compile_source(@source_dir)
build_dir, app = create_build_dir(app)
handle_native_lisp(app)
if options[:icon_uri] || options[:tahoe_icon_uri] || options[:tahoe_icon_name]
IconEmbedder.new(
app,
icon_uri: options[:icon_uri],
tahoe_icon_uri: options[:tahoe_icon_uri],
tahoe_icon_name: options[:tahoe_icon_name]
).embed
end
CLIHelperEmbedder.new(app).embed
CSourcesEmbedder.new(app, @source_dir).embed
LibEmbedder.new(
app,
[brew_dir, '/nix/store'],
extra_libs,
relink_eln_files: options[:relink_eln]
).embed
GccLibEmbedder.new(app, gcc_info).embed if options[:native_comp]
self_sign_app(app) if options[:self_sign]
archive_build(build_dir) if options[:archive]
end
def print_info
# Force-enable native-comp to ensure all env vars are setup.
options[:native_comp] = true
puts YAML.dump(
{
'os' => OS.version.to_s,
'sdk' => OS.sdk_version.to_s,
'arch' => OS.arch,
'gcc' => {
'root' => gcc_info.root_dir,
'lib' => gcc_info.lib_dir,
'darwin_lib' => gcc_info.darwin_lib_dir,
'target_lib' => gcc_info.target_lib_dir,
'target_darwin_lib' => gcc_info.target_darwin_lib_dir,
'sanitized_target_darwin_lib_dir' =>
gcc_info.sanitized_target_darwin_lib_dir,
'version' => gcc_info.major_version
},
'libgccjit' => {
'root' => gcc_info.libgccjit_root_dir,
'lib' => gcc_info.libgccjit_lib_dir,
'version' => gcc_info.libgccjit_major_version
},
'env' => {
'CC' => compile_env['CC'],
'CFLAGS' => compile_env['CFLAGS']&.split,
'LDFLAGS' => compile_env['LDFLAGS']&.split,
'LIBRARY_PATH' => compile_env['LIBRARY_PATH']&.split(':'),
'PKG_CONFIG_PATH' => compile_env['PKG_CONFIG_PATH']&.split(':'),
'PATH' => compile_env['PATH']&.split(':')
}
}
)
end
def print_preview
puts YAML.dump(
{
'build_name' => build_name,
'emacs' => {
'ref' => meta[:ref],
'sha' => meta[:sha],
'date' => meta[:date]
},
'os_version' => OS.version.to_s,
'sdk_version' => OS.sdk_version.to_s,
'arch' => OS.arch,
'native_comp' => options[:native_comp],
'gcc_version' => gcc_info.major_version,
'libgccjit_version' => gcc_info.libgccjit_major_version
}
)
end
private
def load_plan(filename)
debug "Loading plan from: #{filename}"
plan = YAML.safe_load(File.read(filename), permitted_classes: [:Time])
@ref = plan.dig('source', 'ref')
@meta = {
sha: plan.dig('source', 'commit', 'sha'),
ref: @ref,
date: plan.dig('source', 'commit', 'date')
}
if plan.dig('output', 'directory')
@output_dir = plan.dig('output', 'directory')
end
if plan.dig('output', 'archive')
@archive_filename = plan.dig('output', 'archive')
end
@build_name = plan.dig('build', 'name') if plan.dig('build', 'name')
end
def tarballs_dir
@tarballs_dir ||= File.join(root_dir, 'tarballs')
end
def sources_dir
@sources_dir ||= File.join(root_dir, 'sources')
end
def output_dir
@output_dir ||= options[:output] || File.join(root_dir, 'builds')
end
def github_src_repo
@github_src_repo ||= options[:github_src_repo] || DEFAULT_GITHUB_REPO
end
def use_nix?
!!options[:use_nix]
end
def brew_dir
@brew_dir ||= `brew --prefix`.chomp
end
def extra_libs
return [] if use_nix?
return @extra_libs if @extra_libs
libs = [
File.join(brew_dir, 'opt/expat/lib/libexpat.1.dylib'),
File.join(brew_dir, 'opt/libiconv/lib/libiconv.2.dylib'),
File.join(brew_dir, 'opt/zlib/lib/libz.1.dylib')
]
if options[:native_comp]
libgcc_s =
File.join(
brew_dir,
'lib',
'gcc',
gcc_info.major_version,
'libgcc_s.1.dylib'
)
libs << libgcc_s if File.exist?(libgcc_s)
end
@extra_libs = libs
end
def download_tarball(sha)
FileUtils.mkdir_p(tarballs_dir)
url = "https://github.com/#{github_src_repo}/tarball/#{sha}"
filename = "#{github_src_repo.gsub(/[^a-zA-Z0-9-]+/, '-')}-#{sha[0..6]}.tgz"
target = File.join(tarballs_dir, filename)
if File.exist?(target)
info "#{filename} already exists locally, attempting to use."
return target
end
info 'Downloading tarball from GitHub. This could take a while, ' \
'please be patient.'
args = ['curl', '-L', url, '-o', target]
log_args = args.clone
if options[:github_auth] && ENV['GITHUB_TOKEN']
args =
[args[0]] + ['-H', "Authorization: Token #{ENV['GITHUB_TOKEN']}"] +
args[1..-1]
log_args =
[log_args[0]] + ['-H', '"Authorization: Token $GITHUB_TOKEN"'] +
log_args[1..-1]
end
debug "executing: #{log_args.join(' ')}"
cmd(*args)
target
end
def extract_tarball(filename, patches = [])
FileUtils.mkdir_p(sources_dir)
dirname = File.basename(filename).gsub(/\.\w+$/, '')
target = File.join(sources_dir, dirname)
if File.exist?(target)
info "#{dirname} source tree exists, attempting to use."
return target
end
info 'Extracting tarball...'
result = run_cmd('tar', '-xzf', filename, '-C', sources_dir)
fatal 'Tarball extraction failed.' unless result
patches.each { |patch| apply_patch(patch, target) }
apply_macos_startup_patch(target)
# Keep a copy of src after patches have been applied. This will be used to
# embed C sources into the output Emacs.app bundle.
cmd('cp', '-a', File.join(target, 'src'), File.join(target, 'src.orig'))
target
end
def configure_help
return @configure_help if @configure_help
FileUtils.cd(source_dir) { @configure_help = `./configure --help` }
@configure_help
end
def supports_xwidgets?
@supports_xwidgets ||= !!configure_help.match(/\s+--with-xwidgets\s+/)
end
def supports_tree_sitter?
@supports_tree_sitter ||=
!!configure_help.match(/\s+--with-tree-sitter(\s|=).+/)
end
def supports_native_comp?
@supports_native_comp ||= !native_comp_configure_flag.nil?
end
def native_comp_configure_match
@native_comp_configure_match ||=
configure_help.match(/\s+?(--with-native(?:comp|-compilation))(.+)?\s+?/)
end
def native_comp_configure_flag
return @native_comp_configure_flag if @native_comp_configure_flag
return unless native_comp_configure_match&.[](1)
@native_comp_configure_flag = [
native_comp_configure_match[1],
native_comp_configure_flag_arg
].compact.join('=')
end
def native_comp_configure_flag_arg
return @native_comp_configure_flag_arg if @native_comp_configure_flag_arg
return if native_comp_configure_match&.[](2) != '[=TYPE]'
@native_comp_configure_flag_arg =
(options[:native_full_aot] ? 'aot' : 'yes')
end
def detect_native_comp
info 'Detecting native-comp support...'
options[:native_comp] = supports_native_comp?
info 'Native-comp is: ' \
"#{options[:native_comp] ? 'Supported' : 'Not supported'}"
end
def verify_native_comp
return if supports_native_comp?
fatal 'This emacs source tree does not support native-comp'
end
def autogen
FileUtils.cd(source_dir) do
if File.exist?('configure')
info 'configure script exists, skipping autogen.'
return
end
if File.exist?('autogen/copy_autogen')
run_cmd 'autogen/copy_autogen'
elsif File.exist?('autogen.sh')
run_cmd './autogen.sh'
end
end
end
# rubocop:disable Naming/MethodName,Naming/VariableName
def env_CFLAGS
return @env_CFLAGS if @env_CFLAGS
env = ENV.fetch('CFLAGS', nil)&.split || []
env << '-O2'
if options[:native_comp]
env += [
"-I#{File.join(gcc_info.root_dir, 'include')}",
"-I#{File.join(gcc_info.libgccjit_root_dir, 'include')}"
]
end
env << '-march=native' if options[:native_march]
env << '-mtune=native' if options[:native_mtune]
env << '-fomit-frame-pointer' if options[:fomit_frame_pointer]
if options[:fd_setsize].respond_to?(:>=) && options[:fd_setsize] >= 1024
env += [
"-DFD_SETSIZE=#{options[:fd_setsize]}",
'-DDARWIN_UNLIMITED_SELECT'
]
end
if use_nix? && ENV['NIX_CFLAGS_COMPILE']
env += ENV['NIX_CFLAGS_COMPILE'].split
end
# Group "-isystem <path>" flags together as a single flag. This allows us to
# de-duplicate CFLAGS from NIX_CFLAGS_COMPILE.
new_env = []
isystem_flag = false
env.each do |flag|
if flag.strip == '-isystem'
isystem_flag = true
elsif isystem_flag
new_env << "-isystem #{flag}"
isystem_flag = false
else
new_env << flag
end
end
@env_CFLAGS = new_env.compact.reject(&:empty?).uniq
end
def env_LDFLAGS
return @env_LDFLAGS if @env_LDFLAGS
env = ENV.fetch('LDFLAGS', nil)&.split || []
# Ensure library re-linking and code signing will work after building.
env << '-Wl,-headerpad_max_install_names'
if options[:native_comp]
env += [
"-L#{gcc_info.lib_dir}",
"-L#{gcc_info.darwin_lib_dir}",
"-L#{gcc_info.libgccjit_lib_dir}",
"-I#{File.join(gcc_info.root_dir, 'include')}",
"-I#{File.join(gcc_info.libgccjit_root_dir, 'include')}"
]
end
env += ENV['NIX_LDFLAGS'].split if use_nix? && ENV['NIX_LDFLAGS']
@env_LDFLAGS = env.compact.reject(&:empty?).uniq
end
def env_LIBRARY_PATH
return @env_LIBRARY_PATH if @env_LIBRARY_PATH
env = ENV.fetch('LIBRARY_PATH', nil)&.split || []
if options[:native_comp]
env += [
gcc_info.lib_dir,
gcc_info.darwin_lib_dir,
gcc_info.libgccjit_lib_dir
]
end
env << '/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/lib'
@env_LIBRARY_PATH = env.compact.reject(&:empty?).uniq
end
def env_PKG_CONFIG_PATH
env = ENV.fetch('PKG_CONFIG_PATH', nil)&.split || []
return env if use_nix?
@env_PKG_CONFIG_PATH = (
[
File.join(brew_dir, 'lib/pkgconfig'),
File.join(brew_dir, 'share/pkgconfig'),
File.join(brew_dir, 'opt/expat/lib/pkgconfig'),
File.join(brew_dir, 'opt/libxml2/lib/pkgconfig'),
File.join(brew_dir, 'opt/ncurses/lib/pkgconfig'),
File.join(brew_dir, 'opt/zlib/lib/pkgconfig'),
File.join(
brew_dir,
'Homebrew/Library/Homebrew/os/mac/pkgconfig',
OS.version.to_s
)
] + env
).compact.reject(&:empty?).uniq
end
def env_PATH
env = ENV.fetch('PATH', nil)&.split || []
return env if use_nix?
@env_PATH = (
[
File.join(brew_dir, 'opt/make/libexec/gnubin'),
File.join(brew_dir, 'opt/coreutils/libexec/gnubin'),
File.join(brew_dir, 'opt/gnu-sed/libexec/gnubin'),
File.join(brew_dir, 'bin'),
File.join(brew_dir, 'opt/texinfo/bin')
] + env
).compact.reject(&:empty?).uniq
end
# rubocop:enable Naming/MethodName,Naming/VariableName
def compile_env
return @compile_env if @compile_env
env = {
'CC' => use_nix? ? 'clang' : '/usr/bin/clang',
'PATH' => env_PATH.join(':'),
'PKG_CONFIG_PATH' => env_PKG_CONFIG_PATH.join(':')
}
if options[:native_comp]
env['CFLAGS'] = env_CFLAGS.join(' ')
env['LDFLAGS'] = env_LDFLAGS.join(' ')
env['LIBRARY_PATH'] = env_LIBRARY_PATH.join(':')
end
@compile_env = env
end
def compile_source(source)
target = File.join(source, 'nextstep')
emacs_app = File.join(target, 'Emacs.app')
if File.exist?(emacs_app)
info 'Emacs.app already exists in ' \
"\"#{target.gsub("#{root_dir}/", '')}\", attempting to use."
return emacs_app
end
info 'Compiling from source. This will take a while...'
FileUtils.cd(source) do
if options[:native_comp]
info 'Compiling with native-comp enabled'
verify_native_comp
gcc_info.verify_libgccjit
end
compile_env.each { |k, v| ENV[k] = v }
local_lisp_path = [
ENV.fetch('EMACS_LOCAL_LISP_PATH', '').split(':'),
'/Library/Application Support/Emacs/${version}/site-lisp',
'/Library/Application Support/Emacs/site-lisp',
'/usr/local/share/emacs/site-lisp',
'/opt/homebrew/share/emacs/site-lisp'
].flatten.join(':')
configure_flags = [
'--with-ns',
'--with-modules',
"--enable-locallisppath=#{local_lisp_path}"
]
if options[:xwidgets] && supports_xwidgets?
configure_flags << '--with-xwidgets'
end
if options[:tree_sitter] && supports_tree_sitter?
configure_flags << '--with-tree-sitter'
end
configure_flags << native_comp_configure_flag if options[:native_comp]
configure_flags << '--without-rsvg' if options[:rsvg] == false
configure_flags << '--without-dbus' if options[:dbus] == false
run_cmd(
'./configure', *configure_flags.compact,
output_file: 'configure_output.txt'
)
# Disable aligned_alloc on Mojave and below. See issue:
# https://github.com/daviderestivo/homebrew-emacs-head/issues/15
if OS.sdk_version.major <= 10 && OS.sdk_version.minor <= 14
info 'Force disabling of aligned_alloc on macOS Mojave (10.14.x) ' \
'and earlier'
disable_alligned_alloc
end
make_flags = []
make_flags += ['-j', options[:parallel].to_s] if options[:parallel]
if options[:native_comp]
make_flags << "BYTE_COMPILE_EXTRA_FLAGS=--eval '(setq comp-speed 2)'"
if options[:native_full_aot]
info 'Using native compile full AOT'
# We do not need to supply the full AOT make arg if
# --with-native-compilation=aot configure flag is supported.
unless native_comp_configure_flag_arg
make_flags << 'NATIVE_FULL_AOT=1'
end
ENV.delete('NATIVE_FAST_BOOT')
else
ENV.delete('NATIVE_FULL_AOT')
ENV['NATIVE_FAST_BOOT'] = '1'
end
end
run_cmd 'make', *make_flags.compact
run_cmd 'make', 'install'
end
fatal 'Build failed.' unless File.exist?(emacs_app)
emacs_app
end
def create_build_dir(app)
app_name = File.basename(app)
target_dir = File.join(output_dir, build_name)
if File.exist?(target_dir)
fatal "Output directory #{target_dir} already exists, " \
'please delete it and try again'
end
info "Copying \"#{app_name}\" to: #{target_dir}"
FileUtils.mkdir_p(target_dir)
cmd('cp', '-a', app, target_dir)
options[:dist_include]&.each do |filename|
src = File.join(source_dir, filename)
if File.exist?(src)
info "Copying \"#{filename}\" to: #{target_dir}"
cmd('cp', '-pRL', src, target_dir)
else
info "Warning: #{filename} does not exist in #{source_dir}"
end
end
[target_dir, File.join(target_dir, File.basename(app))]
end
def handle_native_lisp(app)
return unless options[:native_comp]
contents_dir = File.join(app, 'Contents')
FileUtils.cd(contents_dir) do
source =
Dir[
'MacOS/libexec/emacs/**/eln-cache',
'MacOS/lib/emacs/**/native-lisp'
].first
# Skip creation of symlinks if *.eln files are not located in a location
# known to be used by builds which need symlinks and other tweaks.
return if source.nil?
info 'Creating symlinks within Emacs.app needed for native-comp'
if !File.exist?('lisp') && File.exist?('Resources/lisp')
run_cmd('ln', '-s', 'Resources/lisp', 'lisp')
end
# Check for folder name containing two dots (.), as this causes Apple's
# codesign CLI tool to fail signing the Emacs.app bundle, complaining with
# q "bundle format unrecognized" error.
#
# The workaround for now is to rename the folder replacing the dots with
# hyphens (-), and create the native-lisp symlink pointing to the new
# location.
eln_dir = File.dirname(Dir[File.join(source, '**', '*.eln')].first)
if eln_dir.match(%r{/.+\..+\..+/})
base = File.basename(eln_dir)
parent = File.dirname(eln_dir)
until ['.', '/', contents_dir].include?(parent)
if base.match(/\..+\./)
old_name = File.join(parent, base)
new_name = File.join(parent, base.gsub(/\.(.+)\./, '-\\1-'))
info "Renaming: #{old_name} --> #{new_name}"
cmd('mv', old_name, new_name)
end
base = File.basename(parent)
parent = File.dirname(parent)
end
eln_parts =
eln_dir.match(
%r{/(\d+\.\d+\.\d+)/native-lisp/(\d+\.\d+\.\d+-\w+)(?:/.+)?$}i
)
if eln_parts
patch_dump_native_lisp_paths(app, eln_parts[1], eln_parts[2])
end
# Find native-lisp directory again after it has been renamed.
source =
Dir[
'MacOS/libexec/emacs/**/eln-cache',
'MacOS/lib/emacs/**/native-lisp'
].first
if source.nil?
fatal 'Failed to find native-lisp cache directory for ' \
'symlink creation.'
end
end
target = File.basename(source)
run_cmd('ln', '-s', source, target) unless File.exist?(target)
end
end
def patch_dump_native_lisp_paths(app, emacs_version, eln_version)
sanitized_emacs_version = emacs_version.gsub('.', '-')
sanitized_eln_version = eln_version.gsub('.', '-')
contents_dir = File.join(app, 'Contents')
FileUtils.cd(contents_dir) do
filename = Dir['MacOS/Emacs.pdmp', 'MacOS/libexec/Emacs.pdmp'].first
fatal "no Emacs.pdmp file found in #{app}" unless filename
info 'patching Emacs.pdmp to point at new native-lisp paths'
content =
File
.read(filename, mode: 'rb')
.gsub(
"lib/emacs/#{emacs_version}/native-lisp/#{eln_version}/",
"lib/emacs/#{sanitized_emacs_version}/" \
"native-lisp/#{sanitized_eln_version}/"
)
.gsub(
"../native-lisp/#{eln_version}/",
"../native-lisp/#{sanitized_eln_version}/"
)
File.write(filename, content)
end
end
def build_name
return @build_name if @build_name
return @build_name = options[:build_name] if options[:build_name]
metadata =
[
meta[:date]&.strftime('%Y-%m-%d'),
meta[:sha][0..6],
meta[:ref],
"macOS-#{OS.sdk_version}",
OS.arch
].compact.map { |v| v.gsub(/[^\w_-]+/, '-') }
@build_name = "Emacs.#{metadata.join('.')}"
end
def archive_filename
@archive_filename ||= File.join(output_dir, "#{build_name}.tbz")
end
def self_sign_app(app)
cmd('codesign', '--force', '--deep', '-s', '-', app)
end
def archive_build(build_dir)
filename = File.basename(archive_filename)
target_dir = File.dirname(archive_filename)
FileUtils.mkdir_p(target_dir)
build = File.basename(build_dir)
parent_dir = File.dirname(build_dir)
if File.exist?(archive_filename)
info "#{filename} archive exists in " \
"#{target_dir}, skipping archving."
else
info "Creating #{filename} archive in \"#{target_dir}\"..."
FileUtils.cd(parent_dir) do
cmd('tar', '-cjf', archive_filename, build)
if options[:archive_keep] == false
info "Removing \"#{build}\" directory from #{parent_dir}"
FileUtils.rm_rf(build_dir)
end
end
end
end
def disable_alligned_alloc
filename = 'src/config.h'
content =
File
.read(filename)
.gsub('#define HAVE_ALIGNED_ALLOC 1', '#undef HAVE_ALIGNED_ALLOC')
.gsub(
'#define HAVE_DECL_ALIGNED_ALLOC 1',
'#undef HAVE_DECL_ALIGNED_ALLOC'
)
.gsub('#define HAVE_ALLOCA 1', '#undef HAVE_ALLOCA')
.gsub('#define HAVE_ALLOCA_H 1', '#undef HAVE_ALLOCA_H')
File.write(filename, content)
end
MACOS_STARTUP_EL_CONTENT = <<~ELISP
;;; macos-startup.el --- macOS specific startup actions -*- lexical-binding: t -*-
;; Maintainer: Jim Myhrberg <contact@jimeh.me>
;; Keywords: macos, internal
;; Homepage: https://github.com/jimeh/build-emacs-for-macos
;; This file is not part of GNU Emacs.
;;; Commentary:
;; This file contains macOS specific startup actions for self-contained
;; macOS *.app bundles. It enables native-compilation via a bundled
;; libgccjit, and for bundled C-sources to be found for documentation
;; purposes,
;;; Code:
(defun macos-startup--in-app-bundle-p ()
"Check if invoked from a macOS .app bundle."
(and (eq system-type 'darwin)
invocation-directory
(string-match-p ".+\\.app/Contents/MacOS/?$" invocation-directory)))
(defun macos-startup--set-source-directory ()
"Set `source-directory' so that C-sources can be located."
(let* ((src-dir (expand-file-name "../Resources/src" invocation-directory)))
(when (file-directory-p src-dir)
(setq source-directory (file-name-directory src-dir)))))
(defun macos-startup--setup-library-path ()
"Configure LIBRARY_PATH env var for native compilation on macOS.
Ensures LIBRARY_PATH includes paths to the libgccjit and gcc libraries
which are bundled into the .app bundle. This allows native compilation
to work without any external system dependencies aside from Xcode."
(let* ((new-paths
(list (expand-file-name "../Frameworks/gcc/lib" invocation-directory)
(expand-file-name "../Frameworks/gcc/lib/apple-darwin" invocation-directory)
"/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/lib"))
(valid-paths (delq nil (mapcar (lambda (path)
(when (file-directory-p path)
path))
new-paths)))
(existing-paths (split-string (or (getenv "LIBRARY_PATH") "") ":" t))
(unique-paths (delete-dups (append valid-paths existing-paths))))
(when unique-paths
(setenv "LIBRARY_PATH" (mapconcat 'identity unique-paths path-separator)))))
(defun macos-startup--init ()
"Perform macOS specific startup operations."
(when (macos-startup--in-app-bundle-p)
(macos-startup--set-source-directory)
(when (and (fboundp 'native-comp-available-p)
(native-comp-available-p))
(macos-startup--setup-library-path))))
(add-hook 'after-pdump-load-hook #'macos-startup--init)
;;; macos-startup.el ends here
ELISP
def apply_macos_startup_patch(target)
macos_startup_el = File.join(target, 'lisp', 'macos-startup.el')
unless File.exist?(macos_startup_el)
info 'Adding macos-startup.el to lisp sources...'
FileUtils.mkdir_p(File.dirname(macos_startup_el))
File.write(macos_startup_el, MACOS_STARTUP_EL_CONTENT)
end
loadup_el = File.join(target, 'lisp', 'loadup.el')
loadup_content = File.read(loadup_el)
return if loadup_content.include?('(load "macos-startup")')
info 'Patching loadup.el to load macos-startup.el...'
File.write(
loadup_el,
loadup_content.gsub(
'(load "startup")',
"(load \"startup\")\n(load \"macos-startup\")"
)
)
end
def meta
return @meta if @meta
ref_sha = options[:git_sha] || ref
info "Fetching info for git ref: #{ref_sha}"
commit_json = github_api_get("/repos/#{github_src_repo}/commits/#{ref_sha}")
fatal "Failed to get commit info about: #{ref_sha}" if commit_json.nil?
commit = JSON.parse(commit_json)
meta = {
sha: commit['sha'],
date: Time.parse(commit['commit']['committer']['date'])
}
meta[:ref] = ref if ref && ref[0..6] != meta[:sha][0..6]
@meta = meta
end
def github_api_get(uri)
uri = URI.join('https://api.github.com/', uri)
http = Net::HTTP.new(uri.hostname, uri.port)
http.use_ssl = true if uri.scheme == 'https'
request = Net::HTTP::Get.new(uri)
if options[:github_auth] && ENV['GITHUB_TOKEN']
request['Authorization'] = "Token #{ENV['GITHUB_TOKEN']}"
end
response = http.request(request)
return unless response.code == '200'
response.body
end
def effective_version
@effective_version ||=
case ref
when /^emacs-26.*/
26
when /^emacs-27.*/
27
when /^emacs-28.*/
28
when /^emacs-29.*/
29
when /^emacs-30.*/
30
else
31
end
end
def build_patches
p = []
# Enabled by default patches.
if (26..30).include?(effective_version)
p << {
url:
'https://github.com/d12frosted/homebrew-emacs-plus/raw/master/' \
"patches/emacs-#{effective_version}/fix-window-role.patch"
}
end
# The fix-window-role patch was merged into Emacs 31 on 2025-07-31 with
# commit 6e1054a40bf6df1429a2b16fdd0d7652dae4d537. Hence builds for commits
# before then need the patch from the last commit in emacs-plus before it
# was removed.
if effective_version == 31 && meta[:date] < Time.parse('2025-07-31')
p << {
url:
'https://github.com/d12frosted/homebrew-emacs-plus/raw/' \
'3e95d573d5f13aba7808193b66312b38a7c66851/' \
'patches/emacs-31/fix-window-role.patch'
}
end
if (27..31).include?(effective_version)
p << {
url:
'https://github.com/d12frosted/homebrew-emacs-plus/raw/master/' \
"patches/emacs-#{effective_version}/system-appearance.patch"
}
end
if (29..31).include?(effective_version)
p << {
url:
'https://github.com/d12frosted/homebrew-emacs-plus/raw/master/' \
"patches/emacs-#{effective_version}/round-undecorated-frame.patch"
}
end
if effective_version == 27
p << {
url:
'https://github.com/d12frosted/homebrew-emacs-plus/raw/master/' \
"patches/emacs-#{effective_version}/ligatures-freeze-fix.patch"
}
end
if effective_version == 28
p << {
replace: [
'configure.ac',
'grep libgccjit.so\$',
'grep -E \'libgccjit\.(so|dylib)$\''
],
allow_failure: true
}
end
if (28..29).include?(effective_version)
p << {
replace: [
'configure.ac',
'grep -E \'libgccjit\.(so|dylib)$\'',
'grep -E \'libgccjit\.(so|dylib)$\' | tail -1'
],
allow_failure: true
}
end
# Optional patches.
if options[:no_frame_refocus] && (27..31).include?(effective_version)
p << {
url:
'https://github.com/d12frosted/homebrew-emacs-plus/raw/master/' \
"patches/emacs-#{effective_version}/no-frame-refocus-cocoa.patch"
}
end
if options[:no_titlebar] && (27..28).include?(effective_version)
p << {
url:
'https://github.com/d12frosted/homebrew-emacs-plus/raw/master/' \
"patches/emacs-#{effective_version}/no-titlebar.patch"
}
end
if options[:xwidgets] && effective_version == 27
p << {
url:
'https://github.com/d12frosted/homebrew-emacs-plus/raw/master/' \
"patches/emacs-#{effective_version}/xwidgets_webkit_in_cocoa.patch"
}
end
if options[:alpha_background]
if effective_version == 29
p << {
file: File.join(
__dir__, 'patches', 'emacs-29', 'ns-alpha-background.patch'
)
}
elsif (30..31).include?(effective_version)
p << {
url:
"https://github.com/emacs-mirror/emacs/compare/#{meta[:sha]}" \
'...jonrubens:emacs:ns-alpha-background.patch'
}
end
end
# Custom patches.
options[:patches].each do |patch_str|
patch = {}
if valid_url?(patch_str)
patch[:url] = patch_str
elsif File.exist?(patch_str)
patch[:file] = patch_str
else
fatal "Patch file or URL not found: #{patch_str}"
end
p << patch
end
p.uniq
end
def apply_patch(patch, target)
fatal "\"#{target}\" does not exist." unless File.exist?(target)
if patch[:file]
info 'Applying patch...'
FileUtils.cd(target) do
run_cmd('patch', '-f', '-p1', '-l', '-i', patch[:file])
end
elsif patch[:url]
patch_dir = "#{target}/macos_patches"
run_cmd('mkdir', '-p', patch_dir)
patch_file = File.join(patch_dir, 'patch-{num}.diff')
num = 1
while File.exist?(patch_file.gsub('{num}', num.to_s.rjust(3, '0')))
num += 1
end
patch_file = patch_file.gsub('{num}', num.to_s.rjust(3, '0'))
info "Downloading patch: #{patch[:url]}"
run_cmd('curl', '-L#', patch[:url], '-o', patch_file)
real_patch_url = detect_github_symlink_patch(patch[:url], patch_file)
if real_patch_url
FileUtils.rm(patch_file)
apply_patch({ url: real_patch_url }, target)
else
apply_patch({ file: patch_file }, target)
end
elsif patch[:source]
patch_dir = "#{target}/macos_patches"
run_cmd('mkdir', '-p', patch_dir)
patch_file = File.join(patch_dir, 'patch-{num}.diff')
num = 1
while File.exist?(patch_file.gsub('{num}', num.to_s.rjust(3, '0')))
num += 1
end
patch_file = patch_file.gsub('{num}', num.to_s.rjust(3, '0'))
File.write(patch_file, patch[:source])
apply_patch({ file: patch_file }, target)
elsif patch[:replace]
fatal 'Patch replace input error' unless patch[:replace].size == 3
file, before, after = patch[:replace]
info "Applying patch to #{file}..."
filepath = File.join(target, file)
unless File.exist?(filepath)
if patch[:allow_failure]
info "File #{filepath} does not exist, skipping patch."
return
end
fatal "\"#{file}\" does not exist in #{target}"
end
f = File.open(filepath, 'rb')
s = f.read
sub = s.gsub!(before, after)
if sub.nil?
if patch[:allow_failure]
info 'Patch did not apply, skipping.'
return
end
fatal "Replacement failed in #{file}"
end
f.reopen(filepath, 'wb').write(s)
f.close
info "#{file} patched."
end
end
# When downloading raw files from GitHub, if the target file is a symlink, it
# will return the actual target path of the symlink instead of the content of
# the target file. Hence we have to check if the patch file we have downloaded
# contains one and only one line, and if so, assume it's a symlink.
def detect_github_symlink_patch(original_url, patch_file)
lines = []
# read first two lines
File.open(patch_file) do |f|
lines << f.gets
lines << f.gets
end
# if the file contains more than one line of text, it's not a symlink.
return unless lines[1].nil?
symlink_target = lines[0].strip
# Assume patch file content is something along the lines of
# "../emacs-28/fix-window-role.patch", hence we resolve it relative to the
# original url.
info "patch is symlink to #{symlink_target}"
URI.join(original_url, symlink_target).to_s
end
end
class AbstractEmbedder
include Output
include System
attr_reader :app
def initialize(app)
fatal "#{app} does not exist" unless File.exist?(app)
@app = app
end
def relative_path(base, path)
Pathname.new(path).relative_path_from(Pathname.new(base)).to_s
end
def relative_app_path(path)
relative_path(app, path)
end
def invocation_dir
@invocation_dir ||= File.join(app, 'Contents', 'MacOS')
end
def bin
@bin ||= File.join(invocation_dir, 'Emacs')
end
def bin_dir
@bin_dir ||= File.join(invocation_dir, 'bin')
end
def lib_dir
@lib_dir ||= frameworks_dir
end
def frameworks_dir
@frameworks_dir ||= File.join(app, 'Contents', 'Frameworks')
end
def resources_dir
@resources_dir ||= File.join(app, 'Contents', 'Resources')
end
private
def while_writable(file)
mode = File.stat(file).mode
File.chmod(0o775, file)
yield
ensure
File.chmod(mode, file) if File.exist?(file)
end
end
class CLIHelperEmbedder < AbstractEmbedder
def embed
source = File.join(__dir__, 'helper', 'emacs-cli.bash')
target = File.join(bin_dir, 'emacs')
dir = File.dirname(target)
info 'Adding "emacs" CLI helper to Emacs.app'
FileUtils.mkdir_p(dir)
run_cmd('cp', '-pRL', source, target)
run_cmd('chmod', '+w', target)
end
end
class CSourcesEmbedder < AbstractEmbedder
attr_reader :source_dir
def initialize(app, source_dir)
super(app)
@source_dir = source_dir
end
def embed
info 'Bundling C source files into Emacs.app for documentation purposes...'
orig_src_dir = File.join(source_dir, 'src.orig')
src_dir = File.join(source_dir, 'src')
target_dir = File.join(resources_dir, 'src')
debug "Copying Emacs C sources to #{relative_app_path(target_dir)}"
if File.exist?(orig_src_dir)
copy_sources(orig_src_dir, target_dir)
else
copy_sources(
src_dir, target_dir, File.join('**', '*.{awk,c,cc,h,in,m,mk}')
)
end
end
private
def copy_sources(src_dir, target_dir, pattern = File.join('**', '*'))
Dir[File.join(src_dir, pattern)].each do |f|
next if File.directory?(f) ||
File.basename(f).downcase.start_with?('changelog')
rel = relative_path(src_dir, f)
target = File.join(target_dir, rel)
FileUtils.mkdir_p(File.dirname(target))
run_cmd('cp', '-pRL', f, target)
end
end
end
class IconEmbedder < AbstractEmbedder
include Helpers
def initialize(app, icon_uri: nil, tahoe_icon_uri: nil, tahoe_icon_name: nil)
super(app)
@icon_uri = icon_uri
@tahoe_icon_uri = tahoe_icon_uri
@tahoe_icon_name = tahoe_icon_name
end
def embed
handle_icns if present?(@icon_uri)
handle_tahoe if present?(@tahoe_icon_uri)
ensure
cleanup_tmpdir
end
private
def present?(val)
!val.nil? && !val.strip.empty?
end
def handle_icns
source = resolve_source(@icon_uri, '.icns', 'icon.icns')
target = File.join(resources_dir, 'Emacs.icns')
info 'Replacing application icon (Emacs.icns)...'
run_cmd('cp', '-pRL', source, target)
source
end
def handle_tahoe
fatal '--tahoe-icon-name is required with --tahoe-icon-uri' \
unless present?(@tahoe_icon_name)
source = resolve_source(@tahoe_icon_uri, '.car', 'Assets.car')
target = File.join(resources_dir, 'Assets.car')
info 'Placing Tahoe Assets.car into Resources...'
run_cmd('cp', '-pRL', source, target)
set_cf_bundle_icon_name(@tahoe_icon_name)
source
end
def set_cf_bundle_icon_name(name)
info 'Setting CFBundleIconName in Info.plist...'
info_plist = File.join(app, 'Contents', 'Info.plist')
fatal "Info.plist not found: #{info_plist}" unless File.exist?(info_plist)
# Use plutil which adds/replaces the key as needed
run_cmd(
'plutil', '-replace', 'CFBundleIconName', '-string',
name, info_plist
)
end
def resolve_source(uri, expected_ext, download_name)
file_path = if valid_url?(uri)
download_file(uri, download_name)
else
local = File.expand_path(uri)
unless File.exist?(local)
fatal "File does not exist: #{local}"
end
local
end
ext = File.extname(file_path).downcase
fatal "Unexpected file type: #{ext} (expected #{expected_ext})" \
unless ext == expected_ext
file_path
end
def tmpdir
@tmpdir ||= Dir.mktmpdir(%w[emacs-assets .tmp])
end
def download_file(url, name)
path = File.join(tmpdir, name)
info "Downloading asset from: #{url}"
run_cmd('curl', '-L#', url, '-o', path)
path
end
def cleanup_tmpdir
return unless @tmpdir
FileUtils.rm_rf(@tmpdir)
end
end
class LibEmbedder < AbstractEmbedder
attr_reader :lib_sources
attr_reader :extra_libs
attr_reader :relink_eln_files
def initialize(app, sources = [], extra_libs = [], relink_eln_files: true)
super(app)
@lib_sources = sources
@extra_libs = extra_libs
@relink_eln_files = relink_eln_files
end
def embed
info 'Bundling shared libraries into Emacs.app...'
binary = "#{bin}-bin" if File.exist?("#{bin}-bin")
binary ||= bin
FileUtils.cd(File.dirname(app)) do
rpath = File.join(
'@executable_path', relative_path(File.dirname(binary), lib_dir)
)
copy, relink = build_bundle_plan(binary)
extra_libs.each do |lib|
extras_copy, extras_relink = build_bundle_plan(
lib, copy_macho_file: true
)
copy.concat(extras_copy)
relink.concat(extras_relink)
end
if relink_eln_files && eln_files.any?
info "Bundling shared libraries for #{eln_files.size} *.eln files " \
'within Emacs.app'
eln_files.each do |f|
eln_copy, eln_relink = build_bundle_plan(f)
copy.concat(eln_copy)
relink.concat(eln_relink)
end
end
bundle_libs(copy.uniq, relink.uniq)
set_rpath(binary, rpath)
end
end
private
def eln_files
@eln_files ||= Dir[File.join(app, 'Contents', '**', '*.eln')]
end
def set_rpath(macho_file, rpath)
return if rpath.nil? || rpath == ''
mf = MachO.open(macho_file)
return if mf.rpaths.include?(rpath)
debug "Setting rpath for '#{relative_app_path(macho_file)}' to: #{rpath}"
mf.add_rpath(rpath)
while_writable(macho_file) { mf.write! }
end
def resolve_dylib_path(path, loader_path: nil, rpaths: nil)
abs = path.gsub('@executable_path', invocation_dir)
abs = abs.gsub('@loader_path', loader_path) if loader_path
if abs.include?('@rpath')
abs = rpaths.map { |r| abs.gsub('@rpath', r) }
.find { |f| File.exist?(f) }
fatal "Could not resolve path: #{path}" if abs.nil?
end
begin
File.realpath(abs)
rescue Errno::ENOENT
File.expand_path(abs)
end
end
def build_bundle_plan(macho_file, copy_macho_file: false)
macho_file = File.expand_path(macho_file)
loader_path = File.dirname(macho_file)
mf = MachO.open(macho_file)
if macho_file.start_with?(app)
debug 'Calculating bundling instructions for: ' \
"#{relative_app_path(macho_file)}"
else
debug "Calculating bundling instructions for: #{macho_file}"
end
rpaths = mf.rpaths.map do |r|
resolve_dylib_path(r, loader_path: loader_path, rpaths: [loader_path])
end
copy = []
relink = []
relink_target_file = macho_file
if copy_macho_file
macho_basename = File.basename(macho_file)
macho_copy_target = File.join(lib_dir, macho_basename)
relink_target_file = macho_copy_target
copy << {
source: macho_file,
target: macho_copy_target,
dylib_id: File.join('@rpath', macho_basename)
}
end
mf.linked_dylibs.each do |linked_dylib|
debug "-- Processing shared library: #{linked_dylib}"
lib_filepath = resolve_dylib_path(
linked_dylib,
loader_path: loader_path,
rpaths: rpaths + [loader_path]
)
fatal "Could not resolve path for '#{linked_dylib}'" if lib_filepath.nil?
debug "-- -- Resolved to: #{lib_filepath}" if linked_dylib != lib_filepath
# Only bundle libraries from lib_sources.
unless lib_sources.any? { |p| lib_filepath.start_with?(p) }
debug "-- -- Skipping, not from lib_sources: #{lib_sources.join(', ')}"
next
end
unless File.exist?(lib_filepath)
warn "-- -- Skipping, shared library '#{lib_filepath}' does not exist"
next
end
lib_basename = File.basename(lib_filepath)
copy_target = File.join(lib_dir, lib_basename)
new_dylib_id = File.join('@rpath', lib_basename)
copy.push(
source: lib_filepath,
target: copy_target,
dylib_id: new_dylib_id
)
relink.push(
target_file: relink_target_file,
old: linked_dylib,
new: new_dylib_id
)
sub_copy, sub_relink = build_bundle_plan(
lib_filepath, copy_macho_file: true
)
copy.concat(sub_copy)
relink.concat(sub_relink)
end
[copy.uniq, relink.uniq]
end
def bundle_libs(copy, relink)
copy.each do |instruction|
source = instruction[:source]
target = instruction[:target]
dylib_id = instruction[:dylib_id]
next if File.exist?(target)
debug "Copying '#{source}' to: " \
"'#{relative_app_path(target)}' ('#{dylib_id}')"
FileUtils.mkdir_p(File.dirname(target))
cmd('cp', '-pRL', source, target)
next if dylib_id.nil? || dylib_id == ''
while_writable(target) do
file = MachO.open(target)
file.change_dylib_id(dylib_id)
# Remove all rpaths except for @loader_path. Any other rpaths present in
# embedded libraries will potentially cause issues.
rpaths = file.rpaths.reject { |r| r == '@loader_path' }
rpaths.each { |r| file.delete_rpath(r) }
file.write!
end
end
relink_files = relink.group_by { |r| r[:target_file] }
relink_files.each do |target_file, relinks|
debug "Changing linked dylibs in: '#{relative_app_path(target_file)}'"
mf = MachO.open(target_file)
changed = false
grouped = relinks.group_by { |r| r[:old] }
grouped.each do |old_dylib, r|
new_dylib = r.first[:new]
debug "-- Relinking '#{old_dylib}' as: '#{new_dylib}'"
unless mf.linked_dylibs.include?(old_dylib)
warn "-- -- Skipping, not linked: #{old_dylib}"
next
end
mf.change_install_name(old_dylib, new_dylib)
changed = true
end
while_writable(target_file) { mf.write! } if changed
end
end
end
class GccLibEmbedder < AbstractEmbedder
attr_reader :gcc_info
def initialize(app, gcc_info)
super(app)
@gcc_info = gcc_info
end
def embed
if embedded?
info 'libgccjit already embedded in Emacs.app'
return
end
info 'Bundling libgccjit into Emacs.app'
if gcc_info.lib_dir.empty?
fatal "No suitable GCC lib dir found in #{gcc_info.root_dir}"
end
FileUtils.mkdir_p(target_dir)
run_cmd(
'rsync', '-rlptD',
# Exclude lib symlink which points at itself when using nix.
'--exclude', 'lib',
# Exclude gcc directory which holds apple-darwin libs, we copy those
# separately.
'--exclude', 'gcc',
File.join(source_dir, ''), target_dir
)
run_cmd('chmod', '-R', 'u+w', target_dir)
tidy_lib_rpaths(target_dir)
FileUtils.mkdir_p(target_darwin_dir)
run_cmd(
'rsync', '-rlptD',
File.join(source_darwin_dir, ''), target_darwin_dir
)
run_cmd('chmod', '-R', 'u+w', target_darwin_dir)
tidy_lib_rpaths(target_darwin_dir)
FileUtils.rm(Dir[File.join(target_dir, '**', '.DS_Store')], force: true)
return unless target_darwin_dir != sanitized_target_darwin_dir
run_cmd('mv', target_darwin_dir, sanitized_target_darwin_dir)
end
private
# Remove all rpaths from Mach-O library files except for @loader_path.
def tidy_lib_rpaths(directory)
Dir[File.join(directory, '**', '*.{dylib,so}')].each do |file_path|
next if File.symlink?(file_path)
begin
mf = MachO.open(file_path)
rescue MachO::NotAMachOError
next
end
rpaths = mf.rpaths.reject { |r| r == '@loader_path' }
next if rpaths.none?
debug "Tidying up rpaths from: #{relative_app_path(file_path)}"
rpaths.each { |r| mf.delete_rpath(r) }
mf.write!
end
end
def embedded?
Dir[File.join(target_dir, 'libgcc*')].any?
end
def target_dir
File.join(lib_dir, gcc_info.target_lib_dir)
end
def target_darwin_dir
File.join(lib_dir, gcc_info.target_darwin_lib_dir)
end
def sanitized_target_darwin_dir
File.join(lib_dir, gcc_info.sanitized_target_darwin_lib_dir)
end
def source_dir
gcc_info.lib_dir
end
def source_darwin_dir
gcc_info.darwin_lib_dir
end
end
class GccInfo
include Output
include System
def initialize(use_nix: false)
@use_nix = use_nix
end
def use_nix?
@use_nix
end
def root_dir
@root_dir ||=
if use_nix?
libgccjit_root_dir
else
`brew --prefix gcc`.chomp
end
end
def major_version
@major_version ||=
if use_nix?
libgccjit_major_version
else
File.basename(lib_dir)
end
end
def lib_dir
@lib_dir ||=
if use_nix?
File.join(root_dir, 'lib')
else
Dir[File.join(root_dir, 'lib/gcc/*/libgcc*')]
.map { |path| File.dirname(path) }
.select { |path| File.basename(path).match(/^\d+$/) }
.max_by { |path| File.basename(path).to_i }
end
end
def target_lib_dir
File.join('gcc', 'lib')
end
def darwin_lib_dir
return @darwin_lib_dir if @darwin_lib_dir
search_path = File.join(lib_dir, 'gcc/*apple-darwin*/*')
@darwin_lib_dir ||= Dir[search_path].max_by do |path|
vers = []
unless use_nix?
matches = File.basename(File.dirname(path)).match(/darwin(\d+)$/)
vers << matches[1].to_i if matches
end
vers << File.basename(path).split('.').map(&:to_i)
vers.flatten
end
end
def target_darwin_lib_dir
File.join('gcc', 'lib', 'apple-darwin')
end
# Sanitize folder name with full "MAJOR.MINOR.PATCH" version number to just
# the MAJOR version. Apple's codesign CLI tool throws a "bundle format
# unrecognized" error if there are any folders with two dots in their name
# within the Emacs.app application bundle.
def sanitized_target_darwin_lib_dir
@sanitized_target_darwin_lib_dir ||=
File.join(
File.dirname(target_darwin_lib_dir),
File.basename(target_darwin_lib_dir).gsub('.', '_')
)
end
def app_bundle_target_lib_dir
@app_bundle_target_lib_dir ||=
relative_path(
embedder.invocation_dir,
File.join(embedder.lib_dir, target_lib_dir)
)
end
def app_bundle_target_darwin_lib_dir
@app_bundle_target_darwin_lib_dir ||=
relative_path(
embedder.invocation_dir,
File.join(embedder.lib_dir, sanitized_target_darwin_lib_dir)
)
end
def libgccjit_root_dir
@libgccjit_root_dir ||=
if use_nix?
ENV['NIX_LIBGCCJIT_ROOT']&.strip
else
`brew --prefix libgccjit`.chomp
end
end
def libgccjit_major_version
@libgccjit_major_version ||=
if use_nix?
# rubocop:disable Style/SafeNavigationChainLength
ENV['NIX_LIBGCCJIT_VERSION']&.strip&.split('.')&.first
# rubocop:enable Style/SafeNavigationChainLength
else
File.basename(libgccjit_lib_dir.to_s)
end
end
def libgccjit_lib_dir
@libgccjit_lib_dir ||=
if use_nix?
Dir[File.join(libgccjit_root_dir, 'lib/libgccjit*.dylib')]
.map { |path| File.dirname(path) }.first
else
Dir[
File.join(libgccjit_root_dir, 'lib/gcc/*/libgccjit*.dylib'),
File.join(libgccjit_root_dir, 'lib/gcc/*/libgccjit.so*'),
]
.map { |path| File.dirname(path) }
.select { |path| File.basename(path).match(/^\d+$/) }
.max_by { |path| File.basename(path).to_i }
end
end
def verify_libgccjit
fatal 'gcc not installed' unless Dir.exist?(root_dir)
fatal 'libgccjit not installed' unless Dir.exist?(libgccjit_root_dir)
if libgccjit_lib_dir.empty?
fatal "Detected libgccjit (#{libgccjit_root_dir}) does not have any " \
'libgccjit.so* files. Please try reinstalling libgccjit: ' \
'brew reinstall libgccjit'
end
if use_nix?
Dir[File.join(libgccjit_lib_dir, 'libgccjit*.dylib')]
.each { |path| clean_macho_binary(path) }
# No need to verify gcc vs libgccjit for Nix, as we can pull everything we
# need from the libgccjit package. On homebrew we need to pull parts from
# gcc and parts from libgccjit, hence we need to ensure versions match.
return
end
return if major_version == libgccjit_major_version
fatal <<~TEXT
Detected GCC and libgccjit library paths do not belong to the same major
version of GCC. Detected paths:
- #{lib_dir}
- #{libgccjit_lib_dir}
TEXT
end
def get_binding # rubocop:disable Naming/AccessorMethodName
binding
end
private
def embedder
@embedder ||= AbstractEmbedder.new(Dir.mktmpdir(%w[Emacs .app]))
end
def relative_path(base, path)
Pathname.new(path).relative_path_from(Pathname.new(base)).to_s
end
def clean_macho_binary(path)
debug "Checking for duplicate RPATHs in #{path}"
macho_cleaner = MachOCleaner.new(path)
return unless macho_cleaner.duplicate_rpaths?
begin
info "Removing duplicate RPATHs from #{path}"
macho_cleaner.clean!
debug 'Cleaned duplicate RPATHs successfully!'
rescue MachOCleaner::PermissionError => e
warn "Could not remove duplicate RPATHs from #{path}: #{e.message}"
if ENV['USER'] == 'root'
fatal "Could not remove duplicate RPATHs from #{path}: #{e.message}"
else
warn '================================================================='
warn "Attempting to clean duplicate RPATHs from #{path} as root"
warn '================================================================='
run_cmd('sudo', $PROGRAM_NAME, '--clean-macho-binary', path)
end
end
end
end
# MachOCleaner is a class that cleans up a Mach-O file by removing all duplicate
# RPATH load commands. This ensures compatibility with macOS 15.4 and later,
# which refuses to load binaries and shared libraries with duplicate RPATHs.
class MachOCleaner
include Output
include System
class PermissionError < StandardError
def initialize(file, message = nil)
@file = file
super(message || "Insufficient permissions to modify #{file}")
end
attr_reader :file
end
attr_reader :file
def initialize(file_path, backup: true)
@file = file_path
@backup = backup
validate_file!
end
def backup?
@backup
end
# Main cleaning method - removes duplicate RPATH commands
def clean!
duplicate_paths = find_duplicate_rpaths(macho_object)
return if duplicate_paths.empty?
backup_file! if backup?
while_writable(@file) do
duplicate_paths.each do |rpath|
remove_rpath_with_install_name_tool!(rpath)
end
end
end
# Check if file has duplicate RPATH commands
def duplicate_rpaths?
count_duplicate_rpaths(macho_object).positive?
end
# Return total number of RPATH commands
def rpath_count
count_rpaths(macho_object)
end
# Return number of duplicate RPATH commands
def duplicate_rpath_count
count_duplicate_rpaths(macho_object)
end
private
# Validate that the file exists and is readable
def validate_file!
fatal "File does not exist: #{@file}" unless File.exist?(@file)
return if File.readable?(@file)
fatal "File is not readable: #{@file}"
end
# Load and memoize the Mach-O object
def macho_object
return @macho_object if @macho_object
begin
@macho_object = MachO.open(@file)
rescue MachO::MachOError => e
fatal "Not a valid Mach-O file: #{@file} (#{e.message})"
end
unless @macho_object.respond_to?(:rpaths)
fatal "Unsupported Mach-O file type: #{@file}"
end
@macho_object
end
def backup_file!
backup_file = "#{@file}.bak"
if File.exist?(backup_file)
debug "Backup file already exists: #{backup_file}"
return
end
FileUtils.cp(@file, backup_file)
debug "Backed up #{@file} to #{backup_file}"
rescue Errno::EPERM, Errno::EACCES => e
raise PermissionError.new(
backup_file, "Cannot create backup file: #{e.message}"
)
end
# Temporarily make file writable, execute block, then restore permissions
def while_writable(file)
# Check if file is already writable to avoid unnecessary permission changes
if File.writable?(file)
yield
return
end
original_mode = File.stat(file).mode
begin
File.chmod(0o755, file)
rescue Errno::EPERM, Errno::EACCES => e
raise PermissionError.new(
file, "Cannot change file permissions: #{e.message}"
)
end
yield
ensure
if File.exist?(file) && original_mode
begin
File.chmod(original_mode, file)
rescue Errno::EPERM, Errno::EACCES
# Log warning but don't fail - file was already modified successfully
warn "Warning: Could not restore original permissions for #{file}"
end
end
end
# Find duplicate RPATH commands in a Mach-O file
def find_duplicate_rpaths(macho_file)
seen = Set.new
duplicates = []
macho_file.rpaths.each do |rpath|
if seen.include?(rpath)
duplicates << rpath
else
seen.add(rpath)
end
end
duplicates
end
# Remove an RPATH using install_name_tool
def remove_rpath_with_install_name_tool!(rpath)
run_cmd('install_name_tool', '-delete_rpath', rpath, @file)
end
# Count total RPATH commands in a Mach-O file
def count_rpaths(macho_file)
macho_file.rpaths.size
end
# Count duplicate RPATH commands in a Mach-O file
def count_duplicate_rpaths(macho_file)
find_duplicate_rpaths(macho_file).size
end
end
class CLIOptions
include Output
include Helpers
def self.parse(args)
inst = new
inst.parse!(args)
inst.options
end
def parse!(args)
parser.parse!(args)
if options[:tahoe_icon_uri] &&
(
options[:tahoe_icon_name].nil? ||
options[:tahoe_icon_name].strip.empty?
)
fatal '--tahoe-icon-name is required when --tahoe-icon-uri is specified'
end
rescue OptionParser::InvalidOption => e
fatal e.message
end
def options
@options ||= defaults.dup.tap do |o|
o.each { |k, v| o[k] = v.dup if v.is_a?(Array) }
end
end
def defaults
{
work_dir: File.expand_path(__dir__),
native_full_aot: false,
relink_eln: true,
native_march: false,
parallel: Etc.nprocessors,
rsvg: true,
dbus: true,
use_nix: !ENV.fetch('IN_NIX_SHELL', '').empty?,
xwidgets: true,
tree_sitter: true,
fd_setsize: 10_000,
github_src_repo: nil,
github_auth: true,
dist_include: ['COPYING', 'configure_output.txt'],
icon_uri: nil,
tahoe_icon_uri: nil,
tahoe_icon_name: nil,
self_sign: true,
archive: true,
archive_keep: false,
patches: [],
log_level: 'info',
clean_macho_binary: nil
}
end
def parser
@parser ||= OptionParser.new do |opts|
opts.banner = <<~DOC
Usage: ./build-emacs-for-macos [options] <branch/tag/sha>
Branch, tag, and SHA are from the emacs-mirror/emacs/emacs Github repo,
available here: https://github.com/emacs-mirror/emacs
Options:
DOC
opts.on(
'--info',
'Print environment info and detected library paths, then exit'
) { |v| options[:info] = v }
opts.on(
'--preview',
'Print preview details about build and exit.'
) { |v| options[:preview] = v }
opts.on(
'-j',
'--parallel COUNT',
'Compile using COUNT parallel processes ' \
"(detected: #{options[:parallel]})"
) { |v| options[:parallel] = v }
opts.on(
'--git-sha SHA',
'Override detected git SHA of specified ' \
'branch allowing builds of old commits'
) { |v| options[:git_sha] = v }
opts.on(
'--[no-]use-nix',
'Use Nix instead of Homebrew to find dependencies ' \
'(default: enabled if IN_NIX_SHELL is set)'
) { |v| options[:use_nix] = v }
opts.on(
'--[no-]tree-sitter',
'Enable/disable tree-sitter if supported ' \
'(default: enabled)'
) { |v| options[:tree_sitter] = v }
opts.on(
'--[no-]native-comp',
'Enable/disable native-comp ' \
'(default: enabled if supported)'
) { |v| options[:native_comp] = v }
opts.on(
'--optimize',
'Shorthand for --native-march --native-mtune --fomit-frame-pointer ' \
'(default: disabled)'
) do
options[:native_march] = true
options[:native_mtune] = true
options[:fomit_frame_pointer] = true
end
opts.on(
'--[no-]native-march',
'Enable/disable -march=native CFLAG ' \
'(default: disabled)'
) { |v| options[:native_march] = v }
opts.on(
'--[no-]native-mtune',
'Enable/disable -mtune=native CFLAG ' \
'(default: disabled)'
) { |v| options[:native_mtune] = v }
opts.on(
'--[no-]fomit-frame-pointer',
'Enable/disable -fomit-frame-pointer CFLAG ' \
'(default: disabled)'
) { |v| options[:fomit_frame_pointer] = v }
opts.on(
'--[no-]native-full-aot',
'Enable/disable NATIVE_FULL_AOT / Ahead of Time compilation ' \
'(default: disabled)'
) { |v| options[:native_full_aot] = v }
opts.on(
'--[no-]relink-eln-files',
'Enable/disable re-linking shared libraries in bundled *.eln ' \
'files (default: enabled)'
) { |v| options[:relink_eln] = v }
opts.on(
'--[no-]rsvg',
'Enable/disable SVG image support via librsvg ' \
'(default: enabled)'
) { |v| options[:rsvg] = v }
opts.on(
'--[no-]dbus',
'Enable/disable dbus support (default: enabled)'
) { |v| options[:dbus] = v }
opts.on(
'--alpha-background',
'Apply experimental alpha-background patch when building Emacs ' \
'30.x - 31.x (default: disabled)'
) { |v| options[:alpha_background] = v }
opts.on(
'--no-frame-refocus',
'Apply no-frame-refocus patch when building Emacs 27.x - 31.x ' \
'(default: disabled)'
) { options[:no_frame_refocus] = true }
opts.on(
'--no-titlebar',
'Apply no-titlebar patch when building Emacs 27.x - 28.x ' \
'(default: disabled)'
) { options[:no_titlebar] = true }
opts.on(
'--[no-]xwidgets',
'Enable/disable XWidgets when building Emacs 27.x ' \
'(default: disabled)'
) { |v| options[:xwidgets] = v }
opts.on('--[no-]poll', 'Apply poll patch (deprecated)') do
warn '==> WARN: poll patch is deprecated and has no effect.'
end
opts.on('--posix-spawn', 'Apply posix-spawn patch (deprecated)') do
warn '==> WARN: posix-spawn patch is deprecated and has no effect.'
end
opts.on(
'-p=URL', '--patch=URL',
'Specify a custom patch file or URL to apply to the Emacs source ' \
'(can be used multiple times)'
) do |v|
if !valid_url?(v) && !File.exist?(v)
fatal "Patch is not a URL or file: #{v}"
end
options[:patches] << v
end
opts.on(
'--[no-]fd-setsize SIZE',
'Set an file descriptor (max open files) limit (default: 10000)'
) { |v| options[:fd_setsize] = v.respond_to?(:to_i) ? v.to_i : 0 }
opts.on(
'--github-src-repo REPO',
'Specify a GitHub repo to download source tarballs from ' \
'(default: emacs-mirror/emacs)'
) { |v| options[:github_src_repo] = v }
opts.on(
'--[no-]github-auth',
'Make authenticated GitHub API requests if GITHUB_TOKEN ' \
'environment variable is set.' \
'(default: enabled)'
) { |v| options[:github_auth] = v }
opts.on(
'--work-dir DIR',
'Specify a working directory where tarballs, sources, and ' \
'builds will be stored and worked with'
) { |v| options[:work_dir] = v }
opts.on(
'-o DIR',
'--output DIR',
'Output directory for finished builds ' \
'(default: <work-dir>/builds)'
) { |v| options[:output] = v }
opts.on('--build-name NAME', 'Override generated build name') do |v|
options[:build_name] = v
end
opts.on(
'--dist-include x,y,z',
'List of extra files to copy from Emacs source into build ' \
'folder/archive (default: COPYING)'
) { |v| options[:dist_include] = v }
opts.on(
'--icon-uri URI',
'Local path or URL to a .icns file to replace the default app icon'
) { |v| options[:icon_uri] = v }
opts.on(
'--tahoe-icon-uri URI',
'Local path or URL to an Assets.car file for macOS 26 icons. ' \
'Requires --tahoe-icon-name.'
) { |v| options[:tahoe_icon_uri] = v }
opts.on(
'--tahoe-icon-name NAME',
'Name of the icon in Assets.car to set as CFBundleIconName'
) { |v| options[:tahoe_icon_name] = v }
opts.on(
'--[no-]self-sign',
'Enable/disable self-signing of Emacs.app (default: enabled)'
) { |v| options[:self_sign] = v }
opts.on(
'--[no-]archive',
'Enable/disable creating *.tbz archive (default: enabled)'
) { |v| options[:archive] = v }
opts.on(
'--[no-]archive-keep-build-dir',
'Enable/disable keeping source folder for archive ' \
'(default: disabled)'
) { |v| options[:archive_keep] = v }
opts.on(
'--log-level LEVEL',
'Build script log level (default: info)'
) { |v| options[:log_level] = v }
opts.on(
'--plan FILE',
'Follow given plan file, instead of using given git ref/sha'
) { |v| options[:plan] = v }
opts.on(
'--clean-macho-binary FILE',
'Tool to clean duplicate RPATHs from given Mach-O binary.'
) { |v| options[:clean_macho_binary] = v }
end
end
end
if __FILE__ == $PROGRAM_NAME
begin
cli_options = CLIOptions.parse(ARGV)
Output.log_level = cli_options.delete(:log_level)
work_dir = cli_options.delete(:work_dir)
build = Build.new(work_dir, ARGV.shift, cli_options)
if cli_options[:info]
build.print_info
elsif cli_options[:preview]
build.print_preview
elsif cli_options[:clean_macho_binary]
macho_cleaner = MachOCleaner.new(cli_options[:clean_macho_binary])
if macho_cleaner.duplicate_rpaths?
build.info 'Removing duplicate RPATHs from ' \
"#{cli_options[:clean_macho_binary]}..."
macho_cleaner.clean!
build.info 'Cleaned duplicate RPATHs successfully!'
else
build.info 'No duplicate RPATHs found.'
end
else
build.build
end
rescue Error => e
warn "ERROR: #{e.message}"
exit 1
end
end