mirror of
https://github.com/jimeh/build-emacs-for-macos.git
synced 2026-02-19 07:16:39 +00:00
2391 lines
63 KiB
Ruby
Executable File
2391 lines
63 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)
|
|
|
|
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 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.has_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 has_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)
|
|
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'],
|
|
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(
|
|
'--[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.has_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
|