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

578 lines
15 KiB
Ruby
Executable File

#!/usr/bin/env ruby
# frozen_string_literal: true
require 'date'
require 'etc'
require 'fileutils'
require 'json'
require 'net/http'
require 'optparse'
require 'pathname'
require 'uri'
def err(msg = nil)
warn("ERROR: #{msg}") if msg
exit 1
end
class Build
DOWNLOAD_URL = 'https://github.com/emacs-mirror/emacs/tarball/%s'
LATEST_URL = 'https://api.github.com/repos/emacs-mirror/emacs/commits/%s'
NATIVE_COMP_REF_REGEXP = %r{^feature/native-comp}.freeze
attr_reader :root_dir
attr_reader :ref
attr_reader :options
def initialize(root_dir, ref = 'master', options = {})
@root_dir = root_dir
@ref = ref
@options = options
end
def build
unless meta[:sha] && meta[:date]
err 'Failed to get commit info from GitHub API.'
end
tarball = download_tarball(meta[:sha])
source = extract_tarball(tarball, patches(options))
app = compile_source(source)
LibEmbedder.new(app, brew_dir, os.version, extra_libs).embed
archive_app(app)
end
private
def tarball_dir
@tarball_dir ||= File.join(root_dir, 'tarballs')
end
def source_dir
@source_dir ||= File.join(root_dir, 'sources')
end
def build_dir
@build_dir ||= File.join(root_dir, 'builds')
end
def brew_dir
@brew_dir ||= `brew --prefix`.chomp
end
def gcc_dir
@gcc_dir ||= `brew --prefix gcc`.chomp
end
def extra_libs
@extra_libs ||= [
"#{brew_dir}/opt/expat/lib/libexpat.1.dylib",
"#{brew_dir}/opt/libiconv/lib/libiconv.2.dylib",
"#{brew_dir}/opt/zlib/lib/libz.1.dylib"
]
end
def download_tarball(sha)
FileUtils.mkdir_p(tarball_dir)
url = (DOWNLOAD_URL % sha)
filename = "emacs-mirror-emacs-#{sha[0..6]}.tgz"
target = File.join(tarball_dir, filename)
if File.exist?(target)
puts "INFO: #{filename} already exists locally, attempting to use."
return target
end
puts 'Downloading tarball from GitHub. This could take a while, ' \
'please be patient.'
result = run_cmd('curl', '-L', url, '-o', target)
err 'Download failed.' unless result
target
end
def extract_tarball(filename, patches = [])
FileUtils.mkdir_p(source_dir)
dirname = File.basename(filename).gsub(/\.\w+$/, '')
target = File.join(source_dir, dirname)
if File.exist?(target)
puts "\nINFO: #{dirname} source tree exists, attempting to use."
return target
end
puts 'Extracting tarball...'
result = run_cmd('tar', '-xzf', filename, '-C', source_dir)
err 'Tarball extraction failed.' unless result
patches.each { |patch| apply_patch(patch, target) }
target
end
def configure_help
@configure_help ||= `./configure --help`
end
def supports_native_comp?
@supports_native_comp ||= configure_help.match?(/\s+--with-nativecomp\s+/)
end
def supports_xwidgets?
@supports_xwidgets ||= configure_help.match?(/\s+--with-xwidgets\s+/)
end
def detect_native_comp
print 'Detecting native-comp support: '
options[:native_comp] = supports_native_comp?
puts options[:native_comp] ? 'Supported' : 'Not supported'
end
def verify_native_comp
return if supports_native_comp?
err 'This emacs source tree does not support native-comp'
end
def verify_libgccjit
err 'gcc not installed' unless Dir.exist?(gcc_dir)
return if Dir["#{gcc_dir}/lib/**/libgccjit.so*"].any?
err "Detected GCC (#{gcc_dir}) does not have libgccjit. Ensure patched " \
'gcc brew formula has been installed via ./install-patched-gcc'
end
def compile_source(source)
target = "#{source}/nextstep"
emacs_app = "#{target}/Emacs.app"
if File.exist?("#{target}/Emacs.app")
puts 'INFO: Emacs.app already exists in ' \
"\"#{target.gsub(root_dir + '/', '')}\", attempting to use."
return emacs_app
end
puts 'Compiling from source. This will take a while...'
FileUtils.cd(source) do
if File.exist?('autogen/copy_autogen')
run_cmd 'autogen/copy_autogen'
elsif File.exist?('autogen.sh')
run_cmd './autogen.sh'
end
detect_native_comp if options[:native_comp].nil?
if options[:native_comp]
puts 'Compiling with native-comp enabled'
verify_native_comp
verify_libgccjit
ENV['NATIVE_FAST_BOOT'] = '1' if options[:native_fast_boot]
ENV['CFLAGS'] = [
"-I#{gcc_dir}/include",
'-O2',
'-march=native'
].compact.join(' ')
ENV['LDFLAGS'] = [
"-L#{gcc_dir}/lib/gcc/10",
"-I#{gcc_dir}/include"
].compact.join(' ')
ENV['LIBRARY_PATH'] = [
"#{gcc_dir}/lib/gcc/10",
ENV['LIBRARY_PATH']
].compact.join(':')
end
ENV['CC'] = 'clang'
ENV['PKG_CONFIG_PATH'] = [
"#{brew_dir}/lib/pkgconfig",
"#{brew_dir}/share/pkgconfig",
"#{brew_dir}/opt/expat/lib/pkgconfig",
"#{brew_dir}/opt/libxml2/lib/pkgconfig",
"#{brew_dir}/opt/ncurses/lib/pkgconfig",
"#{brew_dir}/opt/zlib/lib/pkgconfig",
"#{brew_dir}/Homebrew/Library/Homebrew/os/mac/pkgconfig/#{os.version}",
ENV['PKG_CONFIG_PATH']
].compact.join(':')
ENV['PATH'] = [
"#{brew_dir}/opt/gnu-sed/libexec/gnubin",
"#{brew_dir}/bin",
"#{brew_dir}/opt/texinfo/bin",
ENV['PATH']
].compact.join(':')
configure_flags = [
'--with-ns',
'--with-modules',
'--enable-locallisppath=' \
'/Library/Application Support/Emacs/${version}/site-lisp:' \
'/Library/Application Support/Emacs/site-lisp'
]
configure_flags << '--with-xwidgets' if supports_xwidgets?
configure_flags << '--with-nativecomp' if options[:native_comp]
make_flags = []
make_flags += ['-j', options[:parallel].to_s] if options[:parallel]
run_cmd './configure', *configure_flags
# Disable aligned_alloc on Mojave and below. See issue:
# https://github.com/daviderestivo/homebrew-emacs-head/issues/15
if os.major <= 10 && os.minor <= 14
puts 'Force disabling of aligned_alloc on macOS <= Mojave (10.14.x)'
disable_alligned_alloc
end
if options[:native_comp]
make_flags << "BYTE_COMPILE_EXTRA_FLAGS=--eval '(setq comp-speed 2)'"
end
run_cmd 'make', *make_flags
run_cmd 'make', 'install'
end
err 'Build failed.' unless File.exist?(emacs_app)
if options[:native_comp]
FileUtils.cd(File.join(emacs_app, 'Contents')) do
FileUtils.ln_s('Resources/lisp', 'lisp')
dir = Dir['MacOS/libexec/emacs/**/eln-cache'].first
FileUtils.ln_s(dir, 'eln-cache')
end
end
emacs_app
end
def archive_app(app)
FileUtils.mkdir_p(build_dir)
metadata = [
ref.gsub(/\W/, '-'),
meta[:date],
meta[:sha][0..6],
"macOS-#{os.version}",
arch
]
filename = "Emacs.app-[#{metadata.join('][')}].tbz"
target = "#{build_dir}/#{filename}"
app_base = File.basename(app)
app_dir = File.dirname(app)
if !File.exist?(target)
puts "\nCreating #{filename} archive in \"#{build_dir}\"..."
FileUtils.cd(app_dir) { system('tar', '-cjf', target, app_base) }
else
puts "\nINFO: #{filename} archive exists in " \
"#{build_dir.gsub(root_dir + '/', '')}, skipping archving."
end
end
def os
@os ||= begin
ver = `sw_vers -productVersion`.chomp
.sub(/^(\d+\.\d+\.\d)+/, '\1')
.split('.')
.map(&:to_i)
OpenStruct.new(
'version' => "#{ver[0]}.#{ver[1]}",
'major' => ver[0],
'minor' => ver[1],
'patch' => ver[2]
)
end
end
def arch
@arch = `uname -m`.strip
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.open(filename, 'w') { |f| f.write(content) }
end
def meta
return @meta if @meta
url = format(LATEST_URL, (options[:git_sha] || ref))
commit = JSON.parse(http_get(url))
@meta = {
sha: commit['sha'],
date: Date.parse(commit['commit']['committer']['date'])
}
end
def http_get(url)
Net::HTTP.get(URI.parse(url))
end
def run_cmd(*args)
puts '==> ' + args.join(' ')
system(*args) || exit($CHILD_STATUS.exitstatus)
end
def effective_version
@effective_version ||= begin
case ref
when /^emacs-27.*/
'emacs-27'
when /^emacs-28.*/, NATIVE_COMP_REF_REGEXP, 'master'
'emacs-28'
end
end
end
def patches(opts = {})
p = []
if effective_version
if opts[:xwidgets] && effective_version == 'emacs-27'
p << {
url: 'https://github.com/d12frosted/homebrew-emacs-plus/raw/master/' \
"patches/#{effective_version}/xwidgets_webkit_in_cocoa.patch"
}
end
p << {
url: 'https://github.com/d12frosted/homebrew-emacs-plus/raw/master/' \
"patches/#{effective_version}/fix-window-role.patch"
}
p << {
url: 'https://github.com/d12frosted/homebrew-emacs-plus/raw/master/' \
"patches/#{effective_version}/system-appearance.patch"
}
end
p
end
def apply_patch(patch, target)
err "\"#{target}\" does not exist." unless File.exist?(target)
if patch[:url]
run_cmd('mkdir', '-p', "#{target}/patches")
patch_file = "#{target}/patches/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'))
puts "Downloading patch: #{patch[:url]}"
run_cmd('curl', '-L#', patch[:url], '-o', patch_file)
puts 'Applying patch...'
FileUtils.cd(target) { run_cmd('patch', '-f', '-p1', '-i', patch_file) }
elsif patch[:replace]
err 'Patch replace input error' unless patch[:replace].size == 3
file, before, after = patch[:replace]
filepath = File.join(target, file)
err "\"#{file}\" does not exist in #{target}" unless File.exist?(filepath)
f = File.open(filepath, 'rb')
s = f.read
sub = s.gsub!(before, after)
err "Replacement filed in #{file}" if sub.nil?
f.reopen(filepath, 'wb').write(s)
f.close
end
end
end
class LibEmbedder
attr_reader :app
attr_reader :lib_source
attr_reader :macos_version
attr_reader :extra_libs
def initialize(app, lib_source, macos_version, extra_libs = [])
err "#{app} does not exist" unless File.exist?(app)
@app = app
@lib_source = lib_source
@macos_version = macos_version
@extra_libs = extra_libs
end
def embed
puts 'Embedding libraries into Emacs.app'
FileUtils.cd(File.dirname(app)) do
copy_libs(bin)
copy_extra_libs(extra_libs, bin) if extra_libs.any?
self_ref_libs(bin)
end
end
private
def arch
@arch = `uname -m`.strip
end
def bin
"#{app}/Contents/MacOS/Emacs"
end
def lib_dir
"#{app}/Contents/MacOS/lib-#{arch}-#{macos_version}"
end
def copy_libs(exe, rel_path = nil)
exe_file = File.basename(exe)
rel_path ||= Pathname.new(lib_dir).relative_path_from(
File.dirname(exe)
).to_s
`otool -L "#{exe}"`.split("\n")[1..-1].each do |line|
match = line.match(%r{^\s+(.+/(lib[^/ ]+))\s})
next unless match && match[1].start_with?(lib_source)
while_writable(exe) do
if match[2] == exe_file
system('install_name_tool', '-id',
"@executable_path/#{rel_path}/#{match[2]}", exe)
else
system('install_name_tool', '-change', match[1],
"@executable_path/#{rel_path}/#{match[2]}", exe)
end
end
next if match[2] == exe_file || File.exist?("#{lib_dir}/#{match[2]}")
FileUtils.mkdir_p(lib_dir)
FileUtils.cp(match[1], lib_dir)
copy_libs("#{lib_dir}/#{match[2]}", rel_path)
end
end
def copy_extra_libs(extra_libs, exe, rel_path = nil)
rel_path ||= Pathname.new(lib_dir).relative_path_from(
File.dirname(exe)
).to_s
extra_libs.each do |lib|
lib_file = File.basename(lib)
target = "#{lib_dir}/#{lib_file}"
unless File.exist?(target)
FileUtils.mkdir_p(lib_dir)
FileUtils.cp(lib, lib_dir)
end
while_writable(target) do
system('install_name_tool', '-id',
"@executable_path/#{rel_path}/#{lib_file}", target)
end
copy_libs(target, rel_path)
end
end
def self_ref_libs(exe)
rel_path = Pathname.new(lib_dir).relative_path_from(File.dirname(exe)).to_s
lib_paths ||= Dir.glob("#{lib_dir}/*")
libs = lib_paths.map { |f| File.basename(f) }
([exe] + lib_paths).each do |bin_path|
`otool -L "#{bin_path}"`.split("\n")[1..-1].each do |line|
match = line.match(%r{^\s+(.+/(lib[^/ ]+))\s})
next unless match
next if match[1].start_with?('@executable_path/')
next unless libs.include?(match[2])
while_writable(bin_path) do
system(
'install_name_tool', '-change', match[1],
"@executable_path/#{rel_path}/#{match[2]}",
bin_path
)
end
end
end
end
def while_writable(file)
mode = File.stat(file).mode
File.chmod(0o775, file)
yield
File.chmod(mode, file)
end
end
if __FILE__ == $PROGRAM_NAME
cli_options = {
native_fast_boot: true,
parallel: Etc.nprocessors,
xwidgets: true
}
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('-j', '--parallel COUNT',
'Compile using COUNT parallel processes ' \
"(detected: #{cli_options[:parallel]})") do |v|
cli_options[:parallel] = v
end
opts.on('--git-sha SHA', 'Override detected git SHA of specified branch ' \
'allowing builds of old commits') do |v|
cli_options[:git_sha] = v
end
opts.on('--[no-]xwidgets',
'Enable/disable XWidgets ' \
'(default: enabled)') do |v|
cli_options[:xwidgets] = v
end
opts.on('--[no-]native-comp',
'Enable/disable native-comp ' \
'(default: enabled if supported)') do |v|
cli_options[:native_comp] = v
end
opts.on('--[no-]native-fast-boot',
'Enable/disable NATIVE_FAST_BOOT ' \
'(default: enabled if native-comp supported)') do |v|
cli_options[:native_fast_boot] = v
end
end.parse!
Build.new(File.expand_path(__dir__), ARGV.shift, cli_options).build
end