mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-12-09 05:05:55 +08:00
Add Mac app release (#865)
This commit is contained in:
parent
4d79706c01
commit
e8c80bf6a7
25 changed files with 891 additions and 33 deletions
142
.github/scripts/app/bootstrap_mac.sh
vendored
Normal file
142
.github/scripts/app/bootstrap_mac.sh
vendored
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
main() {
|
||||
export MAKEFLAGS=-j$(getconf _NPROCESSORS_ONLN)
|
||||
|
||||
wxwidgets_vsn="3.1.5"
|
||||
otp_vsn="24.2"
|
||||
elixir_vsn="1.13.1"
|
||||
|
||||
target=$(target)
|
||||
|
||||
mkdir -p tmp
|
||||
|
||||
if [ ! -d "tmp/wxwidgets-${wxwidgets_vsn}-$target" ]; then
|
||||
build_wxwidgets $wxwidgets_vsn $target
|
||||
fi
|
||||
|
||||
export PATH=$PWD/tmp/wxwidgets-${wxwidgets_vsn}-$target/bin:$PATH
|
||||
echo "wx"
|
||||
file `which wxrc`
|
||||
wx-config --version
|
||||
echo
|
||||
|
||||
openssl_dir=$(brew --prefix openssl@1.1)
|
||||
|
||||
if [ ! -d "tmp/otp-${otp_vsn}-$target" ]; then
|
||||
build_otp $otp_vsn $target $openssl_dir
|
||||
fi
|
||||
|
||||
export PATH=$PWD/tmp/otp-${otp_vsn}-$target/bin:$PATH
|
||||
echo "otp"
|
||||
file `which erlc`
|
||||
erl +V
|
||||
erl -noshell -eval 'ok = crypto:start(), io:format("crypto ok~n"), halt().'
|
||||
erl -noshell -eval '{wx_ref,_,_,_} = wx:new(), io:format("wx ok~n"), halt().'
|
||||
echo
|
||||
|
||||
if [ ! -d "tmp/elixir-${elixir_vsn}" ]; then
|
||||
build_elixir $elixir_vsn
|
||||
fi
|
||||
|
||||
export PATH=$PWD/tmp/elixir-${elixir_vsn}/bin:$PATH
|
||||
echo "elixir"
|
||||
elixir --version
|
||||
|
||||
cat << EOF > tmp/bootstrap_env.sh
|
||||
export PATH=\$PWD/tmp/otp-${otp_vsn}-${target}/bin:\$PATH
|
||||
export PATH=\$PWD/tmp/elixir-${elixir_vsn}/bin:\$PATH
|
||||
EOF
|
||||
}
|
||||
|
||||
build_wxwidgets() {
|
||||
vsn=$1
|
||||
target=$2
|
||||
|
||||
otp_bootstrap_root=$PWD
|
||||
cd tmp
|
||||
|
||||
if [ ! -d wxwidgets-$vsn-src ]; then
|
||||
url=https://github.com/wxWidgets/wxWidgets/releases/download/v$vsn/wxWidgets-$vsn.tar.bz2
|
||||
echo downloading $url
|
||||
curl --fail -LO $url
|
||||
tar -xf wxWidgets-$vsn.tar.bz2
|
||||
mv wxWidgets-$vsn wxwidgets-$vsn-src
|
||||
fi
|
||||
|
||||
cd wxwidgets-$vsn-src
|
||||
./configure \
|
||||
--disable-shared \
|
||||
--prefix=$otp_bootstrap_root/tmp/wxwidgets-$vsn-$target \
|
||||
--with-cocoa \
|
||||
--with-macosx-version-min=10.15 \
|
||||
--with-libjpeg=builtin \
|
||||
--with-libtiff=builtin \
|
||||
--with-libpng=builtin \
|
||||
--with-liblzma=builtin \
|
||||
--with-zlib=builtin \
|
||||
--with-expat=builtin
|
||||
|
||||
make
|
||||
make install
|
||||
cd $otp_bootstrap_root
|
||||
}
|
||||
|
||||
build_otp() {
|
||||
vsn=$1
|
||||
target=$2
|
||||
openssl_dir=$3
|
||||
|
||||
otp_bootstrap_root=$PWD
|
||||
cd tmp
|
||||
curl --fail -LO https://github.com/erlang/otp/releases/download/OTP-${vsn}/otp_src_${vsn}.tar.gz
|
||||
tar -xf otp_src_${vsn}.tar.gz
|
||||
|
||||
cd otp_src_${vsn}
|
||||
|
||||
export ERL_TOP=`pwd`
|
||||
export RELEASE_ROOT=$otp_bootstrap_root/tmp/otp-$vsn-$target
|
||||
|
||||
./otp_build configure \
|
||||
--disable-dynamic-ssl-lib \
|
||||
--with-ssl=$openssl_dir
|
||||
|
||||
./otp_build boot -a
|
||||
./otp_build release -a $RELEASE_ROOT
|
||||
make release_docs DOC_TARGETS=chunks
|
||||
|
||||
cd $RELEASE_ROOT
|
||||
./Install -sasl $PWD
|
||||
./bin/erl -noshell -eval 'io:format("~s", [erlang:system_info(system_version)]), halt().'
|
||||
./bin/erl -noshell -eval 'ok = crypto:start(), halt().'
|
||||
./bin/erl -noshell -eval '{wx_ref,_,_,_} = wx:new(), halt().'
|
||||
cd ../..
|
||||
}
|
||||
|
||||
build_elixir() {
|
||||
vsn=$1
|
||||
otp_release=$(erl -noshell -eval 'io:format("~s", [erlang:system_info(otp_release)]), halt().')
|
||||
|
||||
cd tmp
|
||||
# TODO: On Elixir 1.14, use https://github.com/elixir-lang/elixir/releases/download/v${vsn}/elixir-${vsn}-otp-${otp_release}.zip
|
||||
url=https://repo.hex.pm/builds/elixir/v${vsn}-otp-${otp_release}.zip
|
||||
curl --fail -LO $url
|
||||
mkdir elixir-$vsn
|
||||
unzip v${vsn}-otp-${otp_release}.zip -d elixir-$vsn
|
||||
cd ..
|
||||
}
|
||||
|
||||
target() {
|
||||
os=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
arch=$(uname -m)
|
||||
case $arch in
|
||||
"arm64") arch="aarch64";;
|
||||
*) ;;
|
||||
esac
|
||||
|
||||
echo "$arch-$os"
|
||||
}
|
||||
|
||||
main
|
||||
19
.github/scripts/app/build_mac.sh
vendored
Normal file
19
.github/scripts/app/build_mac.sh
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
#!/bin/bash
|
||||
# Usage:
|
||||
#
|
||||
# $ sh .github/scripts/app/build_mac.sh
|
||||
# $ open _build/app_prod/rel/Livebook.app
|
||||
# $ open livebook://github.com/livebook-dev/livebook/blob/main/test/support/notebooks/basic.livemd
|
||||
# $ open ./test/support/notebooks/basic.livemd
|
||||
set -e
|
||||
|
||||
sh .github/scripts/bootstrap_mac.sh
|
||||
. tmp/bootstrap_env.sh
|
||||
|
||||
# If CODESIGN_IDENITY is set, let's build the .dmg which would also notarize it.
|
||||
# Otherwise, let's build just the .app.
|
||||
if [ -n "$CODESIGN_IDENTITY" ]; then
|
||||
MIX_ENV=prod MIX_TARGET=app mix release mac_app_dmg --overwrite
|
||||
else
|
||||
MIX_ENV=prod MIX_TARGET=app mix release mac_app --overwrite
|
||||
fi
|
||||
4
app_builder/.formatter.exs
Normal file
4
app_builder/.formatter.exs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Used by "mix format"
|
||||
[
|
||||
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
|
||||
]
|
||||
26
app_builder/.gitignore
vendored
Normal file
26
app_builder/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# The directory Mix will write compiled artifacts to.
|
||||
/_build/
|
||||
|
||||
# If you run "mix test --cover", coverage assets end up here.
|
||||
/cover/
|
||||
|
||||
# The directory Mix downloads your dependencies sources to.
|
||||
/deps/
|
||||
|
||||
# Where third-party dependencies like ExDoc output generated docs.
|
||||
/doc/
|
||||
|
||||
# Ignore .fetch files in case you like to edit your project deps locally.
|
||||
/.fetch
|
||||
|
||||
# If the VM crashes, it generates a dump, let's ignore it too.
|
||||
erl_crash.dump
|
||||
|
||||
# Also ignore archive artifacts (built via "mix archive.build").
|
||||
*.ez
|
||||
|
||||
# Ignore package tarball (built via "mix hex.build").
|
||||
app_builder-*.tar
|
||||
|
||||
# Temporary files, for example, from tests.
|
||||
/tmp/
|
||||
1
app_builder/README.md
Normal file
1
app_builder/README.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
# AppBuilder
|
||||
4
app_builder/examples/wx_demo/.formatter.exs
Normal file
4
app_builder/examples/wx_demo/.formatter.exs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Used by "mix format"
|
||||
[
|
||||
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
|
||||
]
|
||||
28
app_builder/examples/wx_demo/.gitignore
vendored
Normal file
28
app_builder/examples/wx_demo/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# The directory Mix will write compiled artifacts to.
|
||||
/_build/
|
||||
|
||||
# If you run "mix test --cover", coverage assets end up here.
|
||||
/cover/
|
||||
|
||||
# The directory Mix downloads your dependencies sources to.
|
||||
/deps/
|
||||
|
||||
# Where third-party dependencies like ExDoc output generated docs.
|
||||
/doc/
|
||||
|
||||
# Ignore .fetch files in case you like to edit your project deps locally.
|
||||
/.fetch
|
||||
|
||||
# If the VM crashes, it generates a dump, let's ignore it too.
|
||||
erl_crash.dump
|
||||
|
||||
# Also ignore archive artifacts (built via "mix archive.build").
|
||||
*.ez
|
||||
|
||||
# Ignore package tarball (built via "mix hex.build").
|
||||
wx_demo-*.tar
|
||||
|
||||
# Temporary files, for example, from tests.
|
||||
/tmp/
|
||||
|
||||
/test.sh
|
||||
1
app_builder/examples/wx_demo/README.md
Normal file
1
app_builder/examples/wx_demo/README.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
# WxDemo
|
||||
104
app_builder/examples/wx_demo/lib/wx_demo.ex
Normal file
104
app_builder/examples/wx_demo/lib/wx_demo.ex
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
defmodule WxDemo.Application do
|
||||
@moduledoc false
|
||||
|
||||
use Application
|
||||
|
||||
@impl true
|
||||
def start(_type, _args) do
|
||||
children = [
|
||||
WxDemo.Window
|
||||
]
|
||||
|
||||
opts = [strategy: :one_for_one, name: WxDemo.Supervisor]
|
||||
Supervisor.start_link(children, opts)
|
||||
end
|
||||
end
|
||||
|
||||
defmodule WxDemo.Window do
|
||||
@moduledoc false
|
||||
|
||||
@behaviour :wx_object
|
||||
|
||||
# https://github.com/erlang/otp/blob/OTP-24.1.2/lib/wx/include/wx.hrl#L1314
|
||||
@wx_id_exit 5006
|
||||
|
||||
def start_link(_) do
|
||||
{:wx_ref, _, _, pid} = :wx_object.start_link(__MODULE__, [], [])
|
||||
{:ok, pid}
|
||||
end
|
||||
|
||||
def child_spec(init_arg) do
|
||||
%{
|
||||
id: __MODULE__,
|
||||
start: {__MODULE__, :start_link, [init_arg]},
|
||||
restart: :transient
|
||||
}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_) do
|
||||
title = "WxDemo"
|
||||
|
||||
wx = :wx.new()
|
||||
frame = :wxFrame.new(wx, -1, title)
|
||||
|
||||
if macOS?() do
|
||||
fixup_macos_menubar(frame, title)
|
||||
end
|
||||
|
||||
:wxFrame.show(frame)
|
||||
:wxFrame.connect(frame, :command_menu_selected)
|
||||
:wxFrame.connect(frame, :close_window, skip: true)
|
||||
:wx.subscribe_events()
|
||||
state = %{frame: frame}
|
||||
{frame, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event({:wx, @wx_id_exit, _, _, _}, state) do
|
||||
:init.stop()
|
||||
{:stop, :normal, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event({:wx, _, _, _, {:wxClose, :close_window}}, state) do
|
||||
:init.stop()
|
||||
{:stop, :normal, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:open_url, url}, state) do
|
||||
:wxMessageDialog.new(state.frame, inspect(url))
|
||||
|> :wxDialog.showModal()
|
||||
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
# ignore other events
|
||||
def handle_info(_event, state) do
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
defp fixup_macos_menubar(frame, title) do
|
||||
menubar = :wxMenuBar.new()
|
||||
# :wxMenuBar.setAutoWindowMenu(false)
|
||||
:wxFrame.setMenuBar(frame, menubar)
|
||||
|
||||
# App Menu
|
||||
menu = :wxMenuBar.oSXGetAppleMenu(menubar)
|
||||
|
||||
# Remove all items except for quit
|
||||
for item <- :wxMenu.getMenuItems(menu) do
|
||||
if :wxMenuItem.getId(item) == @wx_id_exit do
|
||||
:wxMenuItem.setText(item, "Quit #{title}\tCtrl+Q")
|
||||
else
|
||||
:wxMenu.delete(menu, item)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp macOS?() do
|
||||
:os.type() == {:unix, :darwin}
|
||||
end
|
||||
end
|
||||
61
app_builder/examples/wx_demo/mix.exs
Normal file
61
app_builder/examples/wx_demo/mix.exs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
defmodule WxDemo.MixProject do
|
||||
use Mix.Project
|
||||
|
||||
def project do
|
||||
[
|
||||
app: :wx_demo,
|
||||
version: "0.1.0",
|
||||
elixir: "~> 1.13",
|
||||
start_permanent: Mix.env() == :prod,
|
||||
deps: deps(),
|
||||
releases: releases()
|
||||
]
|
||||
end
|
||||
|
||||
def application do
|
||||
[
|
||||
extra_applications: [:wx, :logger],
|
||||
mod: {WxDemo.Application, []}
|
||||
]
|
||||
end
|
||||
|
||||
defp deps do
|
||||
[
|
||||
{:app_builder, path: "../.."}
|
||||
]
|
||||
end
|
||||
|
||||
defp releases do
|
||||
options = [
|
||||
name: "WxDemo",
|
||||
url_schemes: ["wxdemo"]
|
||||
]
|
||||
|
||||
[
|
||||
mac_app: [
|
||||
include_executables_for: [:unix],
|
||||
steps: [:assemble, &AppBuilder.build_mac_app(&1, options)]
|
||||
],
|
||||
mac_app_dmg: [
|
||||
include_executables_for: [:unix],
|
||||
steps: [:assemble, &build_mac_app_dmg(&1, options)]
|
||||
]
|
||||
]
|
||||
end
|
||||
|
||||
defp build_mac_app_dmg(release, options) do
|
||||
options =
|
||||
[
|
||||
codesign: [
|
||||
identity: System.fetch_env!("CODESIGN_IDENTITY")
|
||||
],
|
||||
notarize: [
|
||||
team_id: System.fetch_env!("NOTARIZE_TEAM_ID"),
|
||||
apple_id: System.fetch_env!("NOTARIZE_APPLE_ID"),
|
||||
password: System.fetch_env!("NOTARIZE_PASSWORD")
|
||||
]
|
||||
] ++ options
|
||||
|
||||
AppBuilder.build_mac_app_dmg(release, options)
|
||||
end
|
||||
end
|
||||
1
app_builder/examples/wx_demo/test/test_helper.exs
Normal file
1
app_builder/examples/wx_demo/test/test_helper.exs
Normal file
|
|
@ -0,0 +1 @@
|
|||
ExUnit.start()
|
||||
3
app_builder/examples/wx_demo/test/wx_demo_test.exs
Normal file
3
app_builder/examples/wx_demo/test/wx_demo_test.exs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
defmodule WxDemoTest do
|
||||
use ExUnit.Case, async: true
|
||||
end
|
||||
5
app_builder/lib/app_builder.ex
Normal file
5
app_builder/lib/app_builder.ex
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
defmodule AppBuilder do
|
||||
defdelegate build_mac_app(release, options), to: AppBuilder.MacOS
|
||||
|
||||
defdelegate build_mac_app_dmg(release, options), to: AppBuilder.MacOS
|
||||
end
|
||||
242
app_builder/lib/app_builder/macos.ex
Normal file
242
app_builder/lib/app_builder/macos.ex
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
defmodule AppBuilder.MacOS do
|
||||
@moduledoc false
|
||||
|
||||
import AppBuilder.Utils
|
||||
|
||||
def build_mac_app_dmg(release, options) do
|
||||
{codesign, options} = Keyword.pop(options, :codesign)
|
||||
{notarize, options} = Keyword.pop(options, :notarize)
|
||||
|
||||
release = build_mac_app(release, options)
|
||||
|
||||
app_name = Keyword.fetch!(options, :name)
|
||||
File.rm_rf!("tmp/dmg")
|
||||
File.mkdir_p!("tmp/dmg")
|
||||
File.ln_s!("/Applications", "tmp/dmg/Applications")
|
||||
|
||||
File.cp_r!(
|
||||
Path.join([Mix.Project.build_path(), "rel", "#{app_name}.app"]),
|
||||
"tmp/dmg/#{app_name}.app"
|
||||
)
|
||||
|
||||
to_sign =
|
||||
"tmp/dmg/#{app_name}.app/**"
|
||||
|> Path.wildcard()
|
||||
|> Enum.filter(fn file ->
|
||||
stat = File.lstat!(file)
|
||||
Bitwise.band(0o100, stat.mode) != 0 and stat.type == :regular
|
||||
end)
|
||||
|
||||
to_sign = to_sign ++ ["tmp/dmg/#{app_name}.app"]
|
||||
|
||||
if codesign do
|
||||
entitlements_path = "tmp/entitlements.plist"
|
||||
|
||||
File.write!(entitlements_path, """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
""")
|
||||
|
||||
codesign(to_sign, "--options=runtime --entitlements=#{entitlements_path}", codesign)
|
||||
end
|
||||
|
||||
arch = :erlang.system_info(:system_architecture) |> to_string |> String.split("-") |> hd()
|
||||
vsn = release.version
|
||||
basename = "#{app_name}-#{vsn}-#{arch}.dmg"
|
||||
|
||||
tmp_dmg_path = "tmp/#{app_name}.dmg"
|
||||
dmg_path = "#{Mix.Project.build_path()}/rel/#{basename}"
|
||||
|
||||
File.rm_rf!(tmp_dmg_path)
|
||||
File.rm_rf!(dmg_path)
|
||||
|
||||
cmd!(
|
||||
"hdiutil",
|
||||
~w(create #{tmp_dmg_path} -ov -volname #{app_name}Install -fs HFS+ -srcfolder tmp/dmg)
|
||||
)
|
||||
|
||||
cmd!(
|
||||
"hdiutil",
|
||||
~w(convert #{tmp_dmg_path} -format UDZO -o #{dmg_path})
|
||||
)
|
||||
|
||||
if codesign do
|
||||
codesign([dmg_path], "", codesign)
|
||||
end
|
||||
|
||||
if notarize do
|
||||
notarize(dmg_path, notarize)
|
||||
end
|
||||
|
||||
File.rm!(tmp_dmg_path)
|
||||
release
|
||||
end
|
||||
|
||||
defp codesign(paths, args, options) do
|
||||
identity = Keyword.fetch!(options, :identity)
|
||||
paths = Enum.join(paths, " ")
|
||||
shell!("codesign --force --timestamp --verbose=4 --sign=\"#{identity}\" #{args} #{paths}")
|
||||
end
|
||||
|
||||
defp notarize(path, options) do
|
||||
team_id = Keyword.fetch!(options, :team_id)
|
||||
apple_id = Keyword.fetch!(options, :apple_id)
|
||||
password = Keyword.fetch!(options, :password)
|
||||
|
||||
shell!("""
|
||||
xcrun notarytool submit \
|
||||
--team-id "#{team_id}" \
|
||||
--apple-id "#{apple_id}" \
|
||||
--password "#{password}" \
|
||||
--progress \
|
||||
--wait \
|
||||
#{path}
|
||||
""")
|
||||
end
|
||||
|
||||
def build_mac_app(release, options) do
|
||||
options =
|
||||
Keyword.validate!(options, [
|
||||
:name,
|
||||
:launcher_script,
|
||||
:logo_path,
|
||||
:info_plist,
|
||||
:url_schemes,
|
||||
:document_types
|
||||
])
|
||||
|
||||
app_name = Keyword.fetch!(options, :name)
|
||||
|
||||
app_bundle_path = Path.join([Mix.Project.build_path(), "rel", "#{app_name}.app"])
|
||||
File.rm_rf!(app_bundle_path)
|
||||
File.mkdir_p!(Path.join([app_bundle_path, "Contents", "Resources"]))
|
||||
File.rename!(release.path, Path.join([app_bundle_path, "Contents", "Resources", "rel"]))
|
||||
|
||||
launcher_script = options[:launcher_script] || launcher_script(release.name, app_name)
|
||||
launcher_script_path = Path.join([app_bundle_path, "Contents", "MacOS", app_name])
|
||||
File.mkdir_p!(Path.dirname(launcher_script_path))
|
||||
File.write!(launcher_script_path, launcher_script)
|
||||
File.chmod!(launcher_script_path, 0o700)
|
||||
|
||||
logo_path = options[:logo_path] || Application.app_dir(:wx, "examples/demo/erlang.png")
|
||||
create_logo(app_bundle_path, logo_path)
|
||||
|
||||
info_plist = options[:info_plist] || build_info_plist(options)
|
||||
File.write!(Path.join([app_bundle_path, "Contents", "Info.plist"]), info_plist)
|
||||
|
||||
release
|
||||
end
|
||||
|
||||
defp launcher_script(release_name, app_name) do
|
||||
"""
|
||||
#!/bin/sh
|
||||
set -e
|
||||
root=$(dirname $(dirname "$0"))
|
||||
$root/Resources/rel/bin/#{release_name} start \
|
||||
1>> ~/Library/Logs/#{app_name}.stdout.log \
|
||||
2>> ~/Library/Logs/#{app_name}.stderr.log
|
||||
"""
|
||||
end
|
||||
|
||||
defp build_info_plist(options) do
|
||||
app_name = Keyword.fetch!(options, :name)
|
||||
|
||||
url_schemes =
|
||||
"""
|
||||
\n<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
""" <>
|
||||
for scheme <- options[:url_schemes] || [], into: "" do
|
||||
"""
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>#{app_name}</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>#{scheme}</string>
|
||||
</array>
|
||||
</dict>
|
||||
"""
|
||||
end <>
|
||||
"</array>"
|
||||
|
||||
document_types =
|
||||
"""
|
||||
\n<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
""" <>
|
||||
for type <- options[:document_types] || [], into: "" do
|
||||
"""
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>#{type.name}</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>#{type.role}</string>
|
||||
<key>CFBundleTypeExtensions</key>
|
||||
<array>
|
||||
#{for ext <- type.extensions, do: "<string>#{ext}</string>"}
|
||||
</array>
|
||||
</dict>
|
||||
"""
|
||||
end <>
|
||||
"</array>"
|
||||
|
||||
"""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>#{app_name}</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>#{app_name}</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
<key>CFBundleIconName</key>
|
||||
<string>AppIcon</string>#{url_schemes}#{document_types}
|
||||
</dict>
|
||||
</plist>
|
||||
"""
|
||||
end
|
||||
|
||||
defp create_logo(app_bundle_path, logo_source_path) do
|
||||
logo_dest_path = Path.join([app_bundle_path, "Contents", "Resources", "AppIcon.icns"])
|
||||
|
||||
if Path.extname(logo_source_path) == ".icns" do
|
||||
File.cp!(logo_source_path, logo_dest_path)
|
||||
else
|
||||
logo_dest_tmp_path = "tmp/AppIcon.iconset"
|
||||
File.rm_rf!(logo_dest_tmp_path)
|
||||
File.mkdir_p!(logo_dest_tmp_path)
|
||||
|
||||
sizes = for(i <- [16, 32, 64, 128], j <- [1, 2], do: {i, j}) ++ [{512, 1}]
|
||||
|
||||
for {size, scale} <- sizes do
|
||||
suffix =
|
||||
case scale do
|
||||
1 -> ""
|
||||
2 -> "@2x"
|
||||
end
|
||||
|
||||
size = size * scale
|
||||
out = "#{logo_dest_tmp_path}/icon_#{size}x#{size}#{suffix}.png"
|
||||
cmd!("sips", ~w(-z #{size} #{size} #{logo_source_path} --out #{out}))
|
||||
end
|
||||
|
||||
cmd!("iconutil", ~w(-c icns #{logo_dest_tmp_path} -o #{logo_dest_path}))
|
||||
File.rm_rf!(logo_dest_tmp_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
13
app_builder/lib/app_builder/utils.ex
Normal file
13
app_builder/lib/app_builder/utils.ex
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
defmodule AppBuilder.Utils do
|
||||
@moduledoc false
|
||||
|
||||
def cmd!(bin, args, opts \\ []) do
|
||||
opts = Keyword.put_new(opts, :into, IO.stream())
|
||||
{_, 0} = System.cmd(bin, args, opts)
|
||||
end
|
||||
|
||||
def shell!(command, opts \\ []) do
|
||||
opts = Keyword.put_new(opts, :into, IO.stream())
|
||||
{_, 0} = System.shell(command, opts)
|
||||
end
|
||||
end
|
||||
23
app_builder/mix.exs
Normal file
23
app_builder/mix.exs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
defmodule AppBuilder.MixProject do
|
||||
use Mix.Project
|
||||
|
||||
def project do
|
||||
[
|
||||
app: :app_builder,
|
||||
version: "0.1.0",
|
||||
elixir: "~> 1.13",
|
||||
start_permanent: Mix.env() == :prod,
|
||||
deps: deps()
|
||||
]
|
||||
end
|
||||
|
||||
def application do
|
||||
[
|
||||
extra_applications: [:logger]
|
||||
]
|
||||
end
|
||||
|
||||
defp deps do
|
||||
[]
|
||||
end
|
||||
end
|
||||
3
app_builder/test/app_builder_test.exs
Normal file
3
app_builder/test/app_builder_test.exs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
defmodule AppBuilderTest do
|
||||
use ExUnit.Case, async: true
|
||||
end
|
||||
1
app_builder/test/test_helper.exs
Normal file
1
app_builder/test/test_helper.exs
Normal file
|
|
@ -0,0 +1 @@
|
|||
ExUnit.start()
|
||||
|
|
@ -10,24 +10,25 @@ defmodule Livebook.Application do
|
|||
validate_hostname_resolution!()
|
||||
set_cookie()
|
||||
|
||||
children = [
|
||||
# Start the Telemetry supervisor
|
||||
LivebookWeb.Telemetry,
|
||||
# Start the PubSub system
|
||||
{Phoenix.PubSub, name: Livebook.PubSub},
|
||||
# Start the tracker server on this node
|
||||
{Livebook.Tracker, pubsub_server: Livebook.PubSub},
|
||||
# Start the supervisor dynamically managing sessions
|
||||
{DynamicSupervisor, name: Livebook.SessionSupervisor, strategy: :one_for_one},
|
||||
# Start the server responsible for associating files with sessions
|
||||
Livebook.Session.FileGuard,
|
||||
# Start the Node Pool for managing node names
|
||||
Livebook.Runtime.NodePool,
|
||||
# Start the unique task dependencies
|
||||
Livebook.UniqueTask,
|
||||
# Start the Endpoint (http/https)
|
||||
LivebookWeb.Endpoint
|
||||
]
|
||||
children =
|
||||
[
|
||||
# Start the Telemetry supervisor
|
||||
LivebookWeb.Telemetry,
|
||||
# Start the PubSub system
|
||||
{Phoenix.PubSub, name: Livebook.PubSub},
|
||||
# Start the tracker server on this node
|
||||
{Livebook.Tracker, pubsub_server: Livebook.PubSub},
|
||||
# Start the supervisor dynamically managing sessions
|
||||
{DynamicSupervisor, name: Livebook.SessionSupervisor, strategy: :one_for_one},
|
||||
# Start the server responsible for associating files with sessions
|
||||
Livebook.Session.FileGuard,
|
||||
# Start the Node Pool for managing node names
|
||||
Livebook.Runtime.NodePool,
|
||||
# Start the unique task dependencies
|
||||
Livebook.UniqueTask,
|
||||
# Start the Endpoint (http/https)
|
||||
LivebookWeb.Endpoint
|
||||
] ++ app_specs()
|
||||
|
||||
opts = [strategy: :one_for_one, name: Livebook.Supervisor]
|
||||
|
||||
|
|
@ -157,4 +158,10 @@ defmodule Livebook.Application do
|
|||
defp config_env_var?("LIVEBOOK_" <> _), do: true
|
||||
defp config_env_var?("RELEASE_" <> _), do: true
|
||||
defp config_env_var?(_), do: false
|
||||
|
||||
if Mix.target() == :app do
|
||||
defp app_specs, do: [LivebookApp]
|
||||
else
|
||||
defp app_specs, do: []
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -304,6 +304,20 @@ defmodule Livebook.Utils do
|
|||
"data:#{mime};base64,#{data}"
|
||||
end
|
||||
|
||||
@doc """
|
||||
Opens the given `url` in the browser.
|
||||
"""
|
||||
def browser_open(url) do
|
||||
{cmd, args} =
|
||||
case :os.type() do
|
||||
{:win32, _} -> {"cmd", ["/c", "start", url]}
|
||||
{:unix, :darwin} -> {"open", [url]}
|
||||
{:unix, _} -> {"xdg-open", [url]}
|
||||
end
|
||||
|
||||
System.cmd(cmd, args)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Splits the given string at the last occurrence of `pattern`.
|
||||
|
||||
|
|
|
|||
120
lib/livebook_app.ex
Normal file
120
lib/livebook_app.ex
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
if Mix.target() == :app do
|
||||
defmodule LivebookApp do
|
||||
@moduledoc false
|
||||
|
||||
@behaviour :wx_object
|
||||
|
||||
# https://github.com/erlang/otp/blob/OTP-24.1.2/lib/wx/include/wx.hrl#L1314
|
||||
@wx_id_exit 5006
|
||||
|
||||
def start_link(_) do
|
||||
{:wx_ref, _, _, pid} = :wx_object.start_link(__MODULE__, [], [])
|
||||
{:ok, pid}
|
||||
end
|
||||
|
||||
def child_spec(init_arg) do
|
||||
%{
|
||||
id: __MODULE__,
|
||||
start: {__MODULE__, :start_link, [init_arg]},
|
||||
restart: :transient
|
||||
}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_) do
|
||||
title = "Livebook"
|
||||
|
||||
wx = :wx.new()
|
||||
frame = :wxFrame.new(wx, -1, title, size: {0, 0})
|
||||
|
||||
if macos?() do
|
||||
fixup_macos_menubar(frame, title)
|
||||
end
|
||||
|
||||
:wxFrame.show(frame)
|
||||
:wxFrame.connect(frame, :command_menu_selected)
|
||||
:wxFrame.connect(frame, :close_window, skip: true)
|
||||
:wx.subscribe_events()
|
||||
state = %{frame: frame}
|
||||
|
||||
Livebook.Utils.browser_open(LivebookWeb.Endpoint.access_url())
|
||||
|
||||
{frame, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event({:wx, @wx_id_exit, _, _, _}, state) do
|
||||
:init.stop()
|
||||
{:stop, :normal, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event({:wx, _, _, _, {:wxClose, :close_window}}, state) do
|
||||
:init.stop()
|
||||
{:stop, :normal, state}
|
||||
end
|
||||
|
||||
# TODO: investigate "Universal Links" [1], that is, instead of livebook://foo, we have
|
||||
# https://livebook.dev/foo, which means the link works with and without Livebook.app.
|
||||
#
|
||||
# [1] https://developer.apple.com/documentation/xcode/allowing-apps-and-websites-to-link-to-your-content
|
||||
@impl true
|
||||
def handle_info({:open_url, url}, state) do
|
||||
'livebook://' ++ rest = url
|
||||
import_livebook("https://#{rest}")
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:open_file, path}, state) do
|
||||
import_livebook("file://#{path}")
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
# ignore other events
|
||||
@impl true
|
||||
def handle_info(_event, state) do
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
defp import_livebook(url) do
|
||||
%{
|
||||
URI.parse(LivebookWeb.Endpoint.access_url())
|
||||
| path: "/import"
|
||||
}
|
||||
|> append_query("url=#{URI.encode_www_form(url)}")
|
||||
|> URI.to_string()
|
||||
|> Livebook.Utils.browser_open()
|
||||
end
|
||||
|
||||
defp fixup_macos_menubar(frame, title) do
|
||||
menubar = :wxMenuBar.new()
|
||||
:wxFrame.setMenuBar(frame, menubar)
|
||||
|
||||
# App Menu
|
||||
menu = :wxMenuBar.oSXGetAppleMenu(menubar)
|
||||
|
||||
# Remove all items except for quit
|
||||
for item <- :wxMenu.getMenuItems(menu) do
|
||||
if :wxMenuItem.getId(item) == @wx_id_exit do
|
||||
:wxMenuItem.setText(item, "Quit #{title}\tCtrl+Q")
|
||||
else
|
||||
:wxMenu.delete(menu, item)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp macos?() do
|
||||
:os.type() == {:unix, :darwin}
|
||||
end
|
||||
|
||||
# TODO: On Elixir v1.14, use URI.append_query/2
|
||||
defp append_query(%URI{query: query} = uri, query_to_add) when query in [nil, ""] do
|
||||
%{uri | query: query_to_add}
|
||||
end
|
||||
|
||||
defp append_query(%URI{} = uri, query) do
|
||||
%{uri | query: uri.query <> "&" <> query}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -118,13 +118,13 @@ defmodule LivebookCLI.Server do
|
|||
|
||||
defp open_from_options(base_url, opts) do
|
||||
if opts[:open] do
|
||||
browser_open(base_url)
|
||||
Livebook.Utils.browser_open(base_url)
|
||||
end
|
||||
|
||||
if opts[:open_new] do
|
||||
base_url
|
||||
|> append_path("/explore/notebooks/new")
|
||||
|> browser_open()
|
||||
|> Livebook.Utils.browser_open()
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -213,17 +213,6 @@ defmodule LivebookCLI.Server do
|
|||
|
||||
defp opts_to_config([_opt | opts], config), do: opts_to_config(opts, config)
|
||||
|
||||
defp browser_open(url) do
|
||||
{cmd, args} =
|
||||
case :os.type() do
|
||||
{:win32, _} -> {"cmd", ["/c", "start", url]}
|
||||
{:unix, :darwin} -> {"open", [url]}
|
||||
{:unix, _} -> {"xdg-open", [url]}
|
||||
end
|
||||
|
||||
System.cmd(cmd, args)
|
||||
end
|
||||
|
||||
defp append_path(url, path) do
|
||||
url
|
||||
|> URI.parse()
|
||||
|
|
|
|||
49
mix.exs
49
mix.exs
|
|
@ -24,11 +24,16 @@ defmodule Livebook.MixProject do
|
|||
def application do
|
||||
[
|
||||
mod: {Livebook.Application, []},
|
||||
extra_applications: [:logger, :runtime_tools, :os_mon, :inets, :ssl, :xmerl],
|
||||
extra_applications:
|
||||
[:logger, :runtime_tools, :os_mon, :inets, :ssl, :xmerl] ++
|
||||
extra_applications(Mix.target()),
|
||||
env: Application.get_all_env(:livebook)
|
||||
]
|
||||
end
|
||||
|
||||
defp extra_applications(:app), do: [:wx]
|
||||
defp extra_applications(_), do: []
|
||||
|
||||
defp elixirc_paths(:test), do: ["lib", "test/support"]
|
||||
defp elixirc_paths(_), do: ["lib"]
|
||||
|
||||
|
|
@ -62,7 +67,8 @@ defmodule Livebook.MixProject do
|
|||
{:aws_signature, "~> 0.2.0"},
|
||||
{:phoenix_live_reload, "~> 1.2", only: :dev},
|
||||
{:floki, ">= 0.27.0", only: :test},
|
||||
{:bypass, "~> 2.1", only: :test}
|
||||
{:bypass, "~> 2.1", only: :test},
|
||||
{:app_builder, path: "app_builder", targets: [:app]}
|
||||
]
|
||||
end
|
||||
|
||||
|
|
@ -103,10 +109,49 @@ defmodule Livebook.MixProject do
|
|||
livebook: [
|
||||
include_executables_for: [:unix],
|
||||
include_erts: false
|
||||
],
|
||||
mac_app: [
|
||||
include_executables_for: [:unix],
|
||||
rel_templates_path: "rel/app",
|
||||
steps: [:assemble, &build_mac_app/1]
|
||||
],
|
||||
mac_app_dmg: [
|
||||
include_executables_for: [:unix],
|
||||
rel_templates_path: "rel/app",
|
||||
steps: [:assemble, &build_mac_app_dmg/1]
|
||||
]
|
||||
]
|
||||
end
|
||||
|
||||
@app_options [
|
||||
name: "Livebook",
|
||||
logo_path: "static/images/logo.png",
|
||||
url_schemes: ["livebook"],
|
||||
document_types: [
|
||||
%{name: "LiveMarkdown", role: "Editor", extensions: ["livemd"]}
|
||||
]
|
||||
]
|
||||
|
||||
defp build_mac_app(release) do
|
||||
AppBuilder.build_mac_app(release, @app_options)
|
||||
end
|
||||
|
||||
defp build_mac_app_dmg(release) do
|
||||
options =
|
||||
[
|
||||
codesign: [
|
||||
identity: System.fetch_env!("CODESIGN_IDENTITY")
|
||||
],
|
||||
notarize: [
|
||||
team_id: System.fetch_env!("NOTARIZE_TEAM_ID"),
|
||||
apple_id: System.fetch_env!("NOTARIZE_APPLE_ID"),
|
||||
password: System.fetch_env!("NOTARIZE_PASSWORD")
|
||||
]
|
||||
] ++ @app_options
|
||||
|
||||
AppBuilder.build_mac_app_dmg(release, options)
|
||||
end
|
||||
|
||||
def package do
|
||||
[
|
||||
licenses: ["Apache-2.0"],
|
||||
|
|
|
|||
1
rel/app/env.sh.bat
Normal file
1
rel/app/env.sh.bat
Normal file
|
|
@ -0,0 +1 @@
|
|||
set RELEASE_MODE=interactive
|
||||
1
rel/app/env.sh.eex
Normal file
1
rel/app/env.sh.eex
Normal file
|
|
@ -0,0 +1 @@
|
|||
export RELEASE_MODE=interactive
|
||||
Loading…
Add table
Reference in a new issue