Rebuild Livebook Desktop (#1641)

This commit is contained in:
Wojtek Mach 2023-01-16 21:09:47 +01:00 committed by GitHub
parent 4f6ce86e2b
commit 596df882fc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
104 changed files with 1881 additions and 1539 deletions

View file

@ -1,36 +0,0 @@
#!/bin/bash
set -e pipefail
main() {
export MAKEFLAGS=-j$(getconf _NPROCESSORS_ONLN)
elixir_vsn="${elixir_vsn:-1.14.2}"
mkdir -p tmp/cache
. .github/scripts/app/bootstrap_otp_mac.sh
elixir_dir="$PWD/tmp/cache/elixir-${elixir_vsn}"
if [ ! -d "${elixir_dir}" ]; then
build_elixir $elixir_vsn $elixir_dir
fi
export PATH="${elixir_dir}/bin:$PATH"
echo "checking elixir"
elixir --version
echo "elixir ok"
}
# build_elixir $vsn $dest_dir
build_elixir() {
vsn=$1
dest_dir=$2
otp_release=$(erl -noshell -eval 'io:format("~s", [erlang:system_info(otp_release)]), halt().')
cd tmp
url=https://repo.hex.pm/builds/elixir/v${vsn}-otp-${otp_release}.zip
curl --fail -LO $url
mkdir -p $dest_dir
unzip -q v${vsn}-otp-${otp_release}.zip -d $dest_dir
cd - > /dev/null
}
main

View file

@ -1,103 +0,0 @@
#!/bin/bash
set -e pipefail
main() {
export MAKEFLAGS=-j$(getconf _NPROCESSORS_ONLN)
wxwidgets_repo="${wxwidgets_repo:-wxWidgets/wxWidgets}"
wxwidgets_ref="${wxwidgets_ref:-v3.1.7}"
otp_repo="${otp_repo:-wojtekmach/otp}"
otp_ref="${otp_ref:-wm-WX_MACOS_NON_GUI_APP}"
mkdir -p tmp/cache
openssl_dir=$(brew --prefix openssl@1.1)
otp_dir="$PWD/tmp/cache/${otp_repo}-${otp_ref}"
if [ ! -d $otp_dir ]; then
wxwidgets_dir="$PWD/tmp/cache/${wxwidgets_repo}-${wxwidgets_ref}"
if [ ! -d $wxwidgets_dir ]; then
build_wxwidgets $wxwidgets_repo $wxwidgets_ref $wxwidgets_dir
fi
export PATH="${wxwidgets_dir}/bin:$PATH"
echo "checking wx"
file `which wxrc`
wx-config --version
echo "wx ok"
echo
build_otp $otp_repo $otp_ref $openssl_dir $otp_dir
fi
export PATH="${otp_dir}/bin:$PATH"
echo "checking otp"
cd $otp_dir
./Install -sasl $PWD
erl -noshell -eval 'io:format("root_dir=~p~n", [code:root_dir()]), halt().'
erl -noshell -eval 'io:format("~s", [erlang:system_info(system_version)]), halt().'
erl -noshell -eval 'io:format("~s~n", [erlang:system_info(system_architecture)]), halt().'
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().'
cd - > /dev/null
echo "otp ok"
echo
}
# build_wxwidgets $repo $ref $dest_dir
build_wxwidgets() {
repo=$1
ref=$2
dest_dir=$3
src_dir=tmp/$repo-$ref-src
if [ ! -d $src_dir ]; then
echo cloning $repo $ref
git clone --branch $ref --depth 1 --recursive https://github.com/$repo $src_dir
fi
cd $src_dir
./configure \
--disable-shared \
--prefix=$dest_dir \
--with-cocoa \
--with-macosx-version-min=10.15 \
--disable-sys-libs
make
make install
cd - > /dev/null
}
# build_otp $repo $ref $openssl_dir $dest_dir
build_otp() {
repo=$1
ref=$2
openssl_dir=$3
dest_dir=$4
src_dir=tmp/otp-$repo-$ref-src
if [ ! -d $src_dir ]; then
echo cloning $repo $ref
git clone --branch $ref --depth 1 --recursive https://github.com/$repo $src_dir
fi
export RELEASE_ROOT=$dest_dir
cd $src_dir
export ERL_TOP=`pwd`
export ERLC_USE_SERVER=true
./otp_build configure \
--disable-dynamic-ssl-lib \
--with-ssl=$openssl_dir \
--without-odbc
./otp_build boot -a
./otp_build release -a $RELEASE_ROOT
if [ -z "$skip_docs" ]; then
make release_docs DOC_TARGETS=chunks
fi
cd - > /dev/null
}
main

View file

@ -1,15 +0,0 @@
#!/bin/bash
#
# Usage:
#
# $ sh .github/scripts/app/build_mac.sh
# $ open _build/app_prod/Livebook.app
# $ open livebook://github.com/livebook-dev/livebook/blob/main/test/support/notebooks/basic.livemd
# $ open ./test/support/notebooks/basic.livemd
set -e
. .github/scripts/app/bootstrap_mac.sh
mix local.hex --force --if-missing
mix local.rebar --force --if-missing
MIX_ENV=prod MIX_TARGET=app mix deps.get --only prod
MIX_ENV=prod MIX_TARGET=app mix release app --overwrite

63
.github/scripts/app/build_macos.sh vendored Executable file
View file

@ -0,0 +1,63 @@
#!/bin/bash
#
# Usage:
#
# $ .github/scripts/app/build_macos.sh
# $ open rel/app/macos/.build/LivebookInstall.dmg
# $ open livebook://github.com/livebook-dev/livebook/blob/main/test/support/notebooks/basic.livemd
# $ open ./test/support/notebooks/basic.livemd
#
# Note: This script builds the Mac installer. If you just want to test the Mac app locally, run:
#
# $ cd rel/app/macos && ./run.sh
#
# See rel/app/macos/README.md for more information.
set -euo pipefail
main() {
bootstrap_otp
download_elixir
build_app
}
bootstrap_otp() {
dir=$PWD
cd elixirkit/otp_bootstrap
. ./build_macos_universal.sh $OTP_VERSION "1.1.1s"
cd $dir
}
download_elixir() {
dir=$PWD
elixir_dir=$PWD/_build/elixir-$ELIXIR_VERSION
if [ ! -d $elixir_dir ]; then
otp_release=$(erl -noshell -eval 'io:format("~s", [erlang:system_info(otp_release)]), halt().')
elixir_zip=v${ELIXIR_VERSION}-otp-${otp_release}.zip
url=https://repo.hex.pm/builds/elixir/$elixir_zip
echo downloading $url
curl --fail -LO $url
mkdir -p $elixir_dir
unzip -q $elixir_zip -d $elixir_dir
rm $elixir_zip
fi
export PATH="$elixir_dir/bin:$PATH"
cd $dir
}
build_app() {
mix local.hex --force --if-missing
mix local.rebar --force --if-missing
export MIX_ENV=prod
export MIX_TARGET=app
export ELIXIRKIT_BUILD_ARGS="--configuration release --arch x86_64 --arch arm64"
mix deps.get --only prod
cd rel/app/macos
./build_dmg.sh
}
main

View file

@ -3,12 +3,25 @@
# Usage:
#
# $ sh .github/scripts/app/build_windows.sh
# $ wscript _build/app_prod/Livebook-win/LivebookLauncher.vbs
# $ rel/app/windows/bin/LivebookInstall.exe
# $ start livebook://github.com/livebook-dev/livebook/blob/main/test/support/notebooks/basic.livemd
# $ start ./test/support/notebooks/basic.livemd
#
# Note: This script builds the Windows installer. If you just want to test the Windows app locally, run:
#
# $ cd rel/app/windows && ./run.sh
#
# See rel/app/windows/README.md for more information.
set -e
mix local.hex --force --if-missing
mix local.rebar --force --if-missing
MIX_ENV=prod MIX_TARGET=app mix deps.get --only prod
MIX_ENV=prod MIX_TARGET=app mix release app --overwrite
export MIX_ENV=prod
export MIX_TARGET=app
export ELIXIRKIT_CONFIGURATION=Release
mix deps.get --only prod
cd rel/app/windows
./build_installer.sh

View file

@ -5,7 +5,7 @@ on:
branches:
- main
env:
otp: "25.0"
otp: "25.0.4"
elixir: "1.14.2"
jobs:
main:
@ -101,12 +101,18 @@ jobs:
with:
path: deps
key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}
- name: Cache Bootstrap
- name: Cache Bootstrap OTP
uses: actions/cache@v3
with:
path: tmp
key: ${{ runner.os }}-app-${{ hashFiles('.github/scripts/app/bootstrap_mac.sh') }}
- name: Bootstrap
run: bash .github/scripts/app/bootstrap_mac.sh
path: elixirkit/otp_bootstrap/_build/otp-rel-${{ env.otp }}-openssl-1.1.1s-macos-universal
key: ${{ runner.os }}-bootstrap-${{ env.otp }}-${{ hashFiles('elixirkit/otp_bootstrap/build.sh') }}
- name: Cache Elixir
uses: actions/cache@v3
with:
path: _build/elixir-${{ env.elixir }}
key: ${{ runner.os }}-elixir-${{ env.elixir }}
- name: Build the app
run: bash .github/scripts/app/build_mac.sh
run: .github/scripts/app/build_macos.sh
env:
OTP_VERSION: ${{ env.otp }}
ELIXIR_VERSION: ${{ env.elixir }}

View file

@ -252,6 +252,28 @@ MIX_ENV=prod mix escript.build
./livebook server
```
### Livebook Desktop
For macOS, run:
```shell
# Test macOS app locally
(cd rel/app/macos && ./run.sh)
# Build macOS installer
.github/scripts/app/build_macos.sh
```
For Windows, run:
```shell
# Test Windows app locally
(cd rel/app/windows && ./run.sh)
# Build Windows installer
.github/scripts/app/build_windows.sh
```
## Sponsors
Livebook development is sponsored by:

View file

@ -1 +0,0 @@
# AppBundler

View file

@ -1 +0,0 @@
# Demo

View file

@ -1 +0,0 @@
An example file to test "open file" feature.

View file

@ -1,95 +0,0 @@
defmodule Demo.Application do
@moduledoc false
use Application
@impl true
def start(_type, _args) do
children = [
Demo.Window
]
opts = [strategy: :one_for_one, name: Demo.Supervisor]
Supervisor.start_link(children, opts)
end
end
defmodule Demo.Window do
@moduledoc false
use GenServer, restart: :transient
def start_link(arg) do
GenServer.start_link(__MODULE__, arg, name: __MODULE__)
end
# https://github.com/erlang/otp/blob/OTP-24.1.2/lib/wx/include/wx.hrl#L1314
@wx_id_exit 5006
@wx_id_osx_hide 5250
@impl true
def init(_) do
AppBundler.init()
app_name = "Demo"
os = AppBundler.os()
wx = :wx.new()
frame = :wxFrame.new(wx, -1, app_name, size: {400, 400})
if os == :macos do
fixup_macos_menubar(frame, app_name)
end
:wxFrame.show(frame)
:wxFrame.connect(frame, :command_menu_selected, skip: true)
:wxFrame.connect(frame, :close_window, skip: true)
{:ok, %{frame: frame}}
end
@impl true
def handle_info({:wx, @wx_id_exit, _, _, _}, state) do
:init.stop()
{:stop, :shutdown, state}
end
@impl true
def handle_info({:wx, _, _, _, {:wxClose, :close_window}}, state) do
:init.stop()
{:stop, :shutdown, state}
end
# ignore other menu events
@impl true
def handle_info({:wx, _, _, _, {:wxCommand, :command_menu_selected, _, _, _}}, state) do
{:noreply, state}
end
@impl true
def handle_info(event, state) do
show_dialog(state, inspect(event))
{:noreply, state}
end
# Helpers
defp show_dialog(state, data) do
:wxMessageDialog.new(state.frame, data)
|> :wxDialog.showModal()
end
# 1. WxeApp attaches event handler to "Quit" menu item that does nothing (to not accidentally bring
# down the VM). Let's create a fresh menu bar without that caveat.
# 2. Fix app name
defp fixup_macos_menubar(frame, app_name) do
menubar = :wxMenuBar.new()
:wxFrame.setMenuBar(frame, menubar)
menu = :wxMenuBar.oSXGetAppleMenu(menubar)
menu
|> :wxMenu.findItem(@wx_id_osx_hide)
|> :wxMenuItem.setItemLabel("Hide #{app_name}\tCtrl+H")
menu
|> :wxMenu.findItem(@wx_id_exit)
|> :wxMenuItem.setItemLabel("Quit #{app_name}\tCtrl+Q")
end
end

View file

@ -1,71 +0,0 @@
defmodule Demo.MixProject do
use Mix.Project
def project do
[
app: :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: {Demo.Application, []}
]
end
defp deps do
[
{:app_bundler, path: ".."}
]
end
defp releases do
macos_notarization = macos_notarization()
[
app: [
steps: [
:assemble,
&AppBundler.bundle/1
],
app: [
name: "Demo",
url_schemes: ["wxdemo"],
document_types: [
[
name: "Demo",
extensions: ["wxdemo"],
macos: [
role: "Editor"
]
]
],
macos: [
build_dmg: macos_notarization != nil,
notarization: macos_notarization
],
windows: [
build_installer: true
]
]
]
]
end
defp macos_notarization do
identity = System.get_env("NOTARIZE_IDENTITY")
team_id = System.get_env("NOTARIZE_TEAM_ID")
apple_id = System.get_env("NOTARIZE_APPLE_ID")
password = System.get_env("NOTARIZE_PASSWORD")
if identity && team_id && apple_id && password do
[identity: identity, team_id: team_id, apple_id: apple_id, password: password]
end
end
end

View file

@ -1,132 +0,0 @@
defmodule AppBundler do
def bundle(release) do
options = validate_options(release.options[:app] || [])
case os() do
:macos ->
AppBundler.MacOS.bundle(release, options)
:windows ->
AppBundler.Windows.bundle(release, options)
end
end
def os do
case :os.type() do
{:unix, :darwin} -> :macos
{:win32, _} -> :windows
end
end
def init do
{:ok, _} = Registry.register(AppBundler.Registry, "app_event_subscribers", [])
if input = System.get_env("APP_BUILDER_INPUT") do
__rpc__(input)
end
end
def __rpc__ do
IO.read(:line)
|> String.trim()
|> __rpc__()
end
def __rpc__("open_app") do
dispatch(:open_app)
end
def __rpc__("open_url:" <> url) do
dispatch({:open_url, url})
end
def __rpc__("open_file:" <> path) do
path =
if os() == :windows do
String.replace(path, "\\", "/")
else
path
end
dispatch({:open_file, path})
end
defp dispatch(message) do
Registry.dispatch(AppBundler.Registry, "app_event_subscribers", fn entries ->
for {pid, _} <- entries, do: send(pid, message)
end)
end
defp validate_options(options) do
os = os()
root_allowed_options = %{
all: [
:name,
:icon_path,
url_schemes: [],
document_types: [],
additional_paths: []
],
macos: [
app_type: :regular,
build_dmg: false,
notarization: nil
],
windows: [
:server,
build_installer: false
]
}
document_type_allowed_options = %{
all: [
:name,
:extensions,
:icon_path
],
macos: [
:role
],
windows: []
}
options
|> validate_options(root_allowed_options, os)
|> Keyword.put_new_lazy(:name, &default_name/0)
|> Keyword.update!(:document_types, fn document_types ->
Enum.map(document_types, fn options ->
validate_options(options, document_type_allowed_options, os)
end)
end)
end
defp default_name do
Mix.Project.config()[:app] |> to_string |> Macro.camelize()
end
defp validate_options(options, allowed, os) do
{macos_options, options} = Keyword.pop(options, :macos, [])
{windows_options, options} = Keyword.pop(options, :windows, [])
options_per_os = %{
macos: macos_options,
windows: windows_options
}
options = Keyword.validate!(options, allowed.all)
options_for_os = Map.fetch!(options_per_os, os)
allowed_without_defaults =
for option <- allowed.all do
case option do
atom when is_atom(atom) -> atom
{atom, _default} when is_atom(atom) -> atom
end
end
allowed_for_os = allowed_without_defaults ++ Map.fetch!(allowed, os)
os_options = Keyword.validate!(options_for_os, allowed_for_os)
Keyword.merge(options, os_options)
end
end

View file

@ -1,13 +0,0 @@
defmodule AppBundler.Application do
@moduledoc false
use Application
def start(_type, _args) do
children = [
{Registry, keys: :duplicate, name: AppBundler.Registry}
]
Supervisor.start_link(children, strategy: :one_for_one, name: AppBundler.Supervisor)
end
end

View file

@ -1,189 +0,0 @@
defmodule AppBundler.MacOS do
@moduledoc false
import AppBundler.Utils
@templates_path "#{__ENV__.file}/../../templates"
def bundle(release, options) do
app_name = options[:name]
app_path = "#{Mix.Project.build_path()}/#{app_name}.app"
File.rm_rf!(app_path)
tmp_dir = "#{Mix.Project.build_path()}/tmp"
contents_path = "#{app_path}/Contents"
resources_path = "#{contents_path}/Resources"
copy_dir(release.path, "#{resources_path}/rel")
launcher_eex_path = Path.expand("#{@templates_path}/macos/Launcher.swift.eex")
launcher_src_path = "#{tmp_dir}/Launcher.swift"
launcher_x86_64_path = "#{tmp_dir}/#{app_name}Launcher-x86_64"
launcher_aarch64_path = "#{tmp_dir}/#{app_name}Launcher-aarch64"
launcher_bin_path = "#{contents_path}/MacOS/#{app_name}Launcher"
copy_template(launcher_eex_path, launcher_src_path, release: release, app_options: options)
File.mkdir!("#{contents_path}/MacOS")
log(:green, :creating, launcher_x86_64_path)
cmd!("swiftc", [
"-warnings-as-errors",
"-target",
"x86_64-apple-macosx10.15",
"-o",
launcher_x86_64_path,
launcher_src_path
])
log(:green, :creating, launcher_aarch64_path)
cmd!("swiftc", [
"-warnings-as-errors",
"-target",
"arm64-apple-macosx12",
"-o",
launcher_aarch64_path,
launcher_src_path
])
log(:green, :creating, Path.relative_to_cwd(launcher_bin_path))
cmd!("lipo", [
launcher_x86_64_path,
launcher_aarch64_path,
"-create",
"-output",
launcher_bin_path
])
icon_path =
Keyword.get(options, :icon_path, Application.app_dir(:wx, "examples/demo/erlang.png"))
dest_path = "#{resources_path}/AppIcon.icns"
create_icon(icon_path, dest_path)
for type <- Keyword.fetch!(options, :document_types) do
if src_path = Keyword.get(type, :icon_path, icon_path) do
dest_path = "#{resources_path}/#{type[:name]}Icon.icns"
create_icon(src_path, dest_path)
end
end
copy_template(
Path.expand("#{@templates_path}/macos/Info.plist.eex"),
"#{contents_path}/Info.plist",
release: release,
app_options: options
)
if options[:build_dmg] do
build_dmg(release, options)
end
release
end
defp build_dmg(release, options) do
app_name = Keyword.fetch!(options, :name)
notarization = Keyword.fetch!(options, :notarization)
dmg_dir = "#{Mix.Project.build_path()}/dmg"
app_dir = "#{dmg_dir}/#{app_name}.app"
tmp_dir = "#{Mix.Project.build_path()}/tmp"
File.rm_rf!(dmg_dir)
File.mkdir_p!(dmg_dir)
File.ln_s!("/Applications", "#{dmg_dir}/Applications")
File.cp_r!(
"#{Mix.Project.build_path()}/#{app_name}.app",
app_dir
)
to_sign =
"find #{app_dir} -perm +111 -type f -exec sh -c \"file {} | grep --silent Mach-O\" \\; -print"
|> shell!(into: "")
|> String.split("\n", trim: true)
to_sign = to_sign ++ [app_dir]
entitlements_eex_path = "#{Path.expand(@templates_path)}/macos/Entitlements.plist.eex"
entitlements_plist_path = "#{tmp_dir}/Entitlements.plist"
copy_template(entitlements_eex_path, entitlements_plist_path,
release: release,
app_options: options
)
log(:green, "signing", Path.relative_to_cwd(app_dir))
codesign(to_sign, "--options=runtime --entitlements=#{entitlements_plist_path}", notarization)
dmg_path = "#{Mix.Project.build_path()}/#{app_name}Install.dmg"
log(:green, "creating", Path.relative_to_cwd(dmg_path))
cmd!(
"hdiutil",
~w(create #{dmg_path} -ov -volname #{app_name}Install -fs HFS+ -srcfolder #{dmg_dir})
)
log(:green, "notarizing", Path.relative_to_cwd(dmg_path))
notarize(dmg_path, notarization)
release
end
defp codesign(paths, extra_flags, options) do
identity = Keyword.fetch!(options, :identity)
paths = Enum.join(paths, " ")
flags = "--force --timestamp --verbose=4 --sign=\"#{identity}\" #{extra_flags}"
shell!("codesign #{flags} #{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
defp create_icon(src_path, dest_path) do
log(:green, :creating, Path.relative_to_cwd(dest_path))
src_path = normalize_icon_path(src_path)
if Path.extname(src_path) == ".icns" do
File.cp!(src_path, dest_path)
else
name = Path.basename(dest_path, ".icns")
dest_tmp_path = "tmp/#{name}.iconset"
File.rm_rf!(dest_tmp_path)
File.mkdir_p!(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 = "#{dest_tmp_path}/icon_#{size}x#{size}#{suffix}.png"
cmd!("sips", ~w(-z #{size} #{size} #{src_path} --out #{out}), into: "")
end
cmd!("iconutil", ~w(-c icns #{dest_tmp_path} -o #{dest_path}))
File.rm_rf!(dest_tmp_path)
end
end
end

View file

@ -1,118 +0,0 @@
defmodule AppBundler.Utils do
@moduledoc false
require Logger
def cmd!(bin, args, opts \\ []) do
opts = Keyword.put_new(opts, :into, IO.stream())
{_, status} = System.cmd(bin, args, opts)
if status != 0 do
raise "command exited with #{status}"
end
end
def shell!(command, opts \\ []) do
opts = Keyword.put_new(opts, :into, IO.stream())
{output, 0} = System.shell(command, opts)
output
end
def ensure_executable(url) do
ensure_executable(url, :no_verify)
end
def ensure_executable(url, expected_sha256) do
tmp_dir = Path.join(System.tmp_dir!(), Path.basename(url, Path.extname(url)))
path = Path.join(tmp_dir, Path.basename(url))
if File.exists?(path) do
verify(File.read!(path), expected_sha256)
else
File.mkdir_p!(tmp_dir)
body = download_and_verify(url, expected_sha256)
File.write!(path, body)
if Path.extname(path) == ".zip" do
{:ok, _} = :zip.extract(String.to_charlist(path), cwd: tmp_dir)
else
File.chmod!(path, 0o600)
end
end
path
end
def ensure_executable(url, expected_sha256, basename) do
path = ensure_executable(url, expected_sha256)
Path.join(Path.dirname(path), basename)
end
defp download_and_verify(url, expected_sha256) do
url
|> download_unverified()
|> verify(expected_sha256)
end
defp download_unverified(url) do
Logger.debug("downloading #{url}")
http_options = [ssl: [verify: :verify_none]]
options = []
headers = []
request = {url, headers}
{:ok, {{_, 200, _}, _, body}} = :httpc.request(:get, request, http_options, options)
body
end
defp verify(data, :no_verify) do
data
end
defp verify(data, expected_sha256) do
actual_sha256 = :crypto.hash(:sha256, data) |> Base.encode16(case: :lower)
if expected_sha256 == actual_sha256 do
data
else
raise """
sha256 mismatch
expected: #{expected_sha256}
got: #{actual_sha256}\
"""
end
end
def normalize_icon_path(path) when is_binary(path) do
path
end
def normalize_icon_path(path_per_os) when is_list(path_per_os) do
Keyword.fetch!(path_per_os, AppBundler.os())
end
def copy_dir(from, to, options \\ []) do
File.mkdir_p!(Path.dirname(to))
log(:green, "creating", Path.relative_to_cwd(to), options)
File.cp_r!(from, to)
end
def copy_file(source, target, options \\ []) do
create_file(target, File.read!(source), options)
end
def copy_template(source, target, assigns, options \\ []) do
create_file(target, EEx.eval_file(source, assigns: assigns, trim: true), options)
end
def create_file(path, contents, options \\ []) when is_binary(path) do
log(:green, :creating, Path.relative_to_cwd(path), options)
File.mkdir_p!(Path.dirname(path))
File.write!(path, contents)
end
def log(color, command, message, options \\ []) do
unless options[:quiet] do
Mix.shell().info([color, "* #{command} ", :reset, message])
end
end
end

View file

@ -1,144 +0,0 @@
defmodule AppBundler.Windows do
@moduledoc false
import AppBundler.Utils
@templates_path "#{__ENV__.file}/../../templates"
def bundle(release, options) do
{:ok, _} = Application.ensure_all_started(:ssl)
{:ok, _} = Application.ensure_all_started(:inets)
log(:green, :killing, "epmd.exe")
System.cmd("taskkill.exe", ~w(/F /IM epmd.exe))
app_name = options[:name]
app_path = "#{Mix.Project.build_path()}/#{app_name}-win"
File.rm_rf!(app_path)
copy_dir(release.path, "#{app_path}/rel")
manifest_eex_path = Path.expand("#{@templates_path}/windows/Manifest.xml.eex")
manifest_xml_path = "#{app_path}/Manifest.xml"
copy_template(manifest_eex_path, manifest_xml_path, release: release)
[erl_exe | _] = Path.wildcard("#{app_path}/**/erl.exe")
log(:green, :updating, Path.relative_to_cwd(erl_exe))
Mix.Task.run("pe.update", ["--set-manifest", manifest_xml_path, erl_exe])
vcredist_path = ensure_vcredistx64()
copy_file(vcredist_path, "#{app_path}/vcredist_x64.exe")
icon_path = options[:icon_path]
if icon_path do
create_icon(icon_path, "#{app_path}/AppIcon.ico")
end
tmp_dir = "#{Mix.Project.build_path()}/tmp"
File.mkdir_p!(tmp_dir)
launcher_eex_path = Path.expand("#{@templates_path}/windows/Launcher.vb.eex")
launcher_src_path = "#{tmp_dir}/Launcher.vb"
launcher_bin_path = "#{app_path}/#{app_name}Launcher.exe"
copy_template(launcher_eex_path, launcher_src_path, release: release, app_options: options)
File.mkdir!("#{app_path}/Logs")
args = [
path(launcher_src_path),
"/out:" <> path(launcher_bin_path),
"/nologo",
"/target:winexe",
"/win32manifest:" <> path(manifest_xml_path)
]
extra_args =
if icon_path do
["/win32icon:" <> path("#{app_path}/AppIcon.ico")]
else
[]
end
vbc_path = ensure_vbc()
cmd!(vbc_path, args ++ extra_args)
for type <- Keyword.fetch!(options, :document_types) do
if src_path = Keyword.get(type, :icon_path, icon_path) do
dest_path = "#{app_path}/#{type[:name]}Icon.ico"
create_icon(src_path, dest_path)
end
end
if Keyword.fetch!(options, :build_installer) do
installer_eex_path = Path.expand("#{@templates_path}/windows/Installer.nsi.eex")
installer_nsi_path = "#{app_path}/Installer.nsi"
copy_template(installer_eex_path, installer_nsi_path, release: release, app_options: options)
makensis_path = ensure_makensis()
log(:green, "creating", Path.relative_to_cwd("#{app_path}/#{app_name}Install.exe"))
cmd!(makensis_path, [installer_nsi_path])
end
release
end
defp path(path), do: String.replace(path, "/", "\\")
def handle_event(module, input)
def handle_event(module, "open_url:" <> url) do
module.open_url(url)
end
def handle_event(module, "open_file:" <> path) do
module.open_file(String.replace(path, "\\", "/"))
end
defp ensure_vcredistx64 do
url = "https://aka.ms/vs/17/release/vc_redist.x64.exe"
AppBundler.Utils.ensure_executable(url)
end
defp ensure_makensis do
url = "https://downloads.sourceforge.net/project/nsis/NSIS%203/3.08/nsis-3.08.zip"
sha256 = "1bb9fc85ee5b220d3869325dbb9d191dfe6537070f641c30fbb275c97051fd0c"
AppBundler.Utils.ensure_executable(url, sha256, "nsis-3.08/makensis.exe")
end
defp ensure_magick do
System.find_executable("magick.exe") ||
raise "couldn't find magick.exe in PATH to automatically convert images to .ico"
end
def ensure_vbc do
case System.shell("dir %WINDIR%\\Microsoft.NET\\Framework64\\vbc.exe /s/b") do
{paths, 0} ->
paths |> String.split("\r\n", trim: true) |> List.last()
{_, 1} ->
raise "cannot find vbc.exe. You need to install Visual Studio."
end
end
defp create_icon(src_path, dest_path) do
log(:green, "creating", Path.relative_to_cwd(dest_path))
src_path = normalize_icon_path(src_path)
if Path.extname(src_path) == ".ico" do
File.cp!(src_path, dest_path)
else
magick_path = ensure_magick()
sizes = [16, 32, 48, 64, 128]
for i <- sizes do
cmd!(magick_path, [src_path, "-resize", "#{i}x#{i}", sized_path(dest_path, i)])
end
sized_paths = Enum.map(sizes, &sized_path(dest_path, &1))
cmd!(magick_path, sized_paths ++ [dest_path])
end
end
defp sized_path(path, size) do
String.replace_trailing(path, ".ico", ".#{size}.ico")
end
end

View file

@ -1,70 +0,0 @@
<?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>CFBundleExecutable</key>
<string><%= @app_options[:name] %>Launcher</string>
<key>CFBundleName</key>
<string><%= @app_options[:name] %></string>
<key>CFBundleDisplayName</key>
<string><%= @app_options[:name] %></string>
<key>CFBundleShortVersionString</key>
<string><%= @release.version %></string>
<key>CFBundleVersion</key>
<string><%= @release.version %></string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundleIconName</key>
<string>AppIcon</string>
<%= if schemes = @app_options[:url_schemes] do %>
<key>CFBundleURLTypes</key>
<array>
<%= for scheme <- schemes do %>
<dict>
<key>CFBundleURLName</key>
<string><%= @app_options[:name] %></string>
<key>CFBundleURLSchemes</key>
<array>
<string><%= scheme %></string>
</array>
</dict>
<% end %>
</array>
<% end %>
<%= if types = @app_options[:document_types] do %>
<key>CFBundleDocumentTypes</key>
<array>
<%= for type <- types 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>
<% end %>
</array>
<%= if type[:icon_path] do %>
<key>CFBundleTypeIconFile</key>
<string><%= type[:name] %>Icon</string>
<% end %>
</dict>
<% end %>
</array>
<% end %>
<%= if @app_options[:app_type] == :agent do %>
<key>LSUIElement</key>
<true/>
<% end %>
<key>LSRequiresNativeExecution</key>
<true/>
</dict>
</plist>

View file

@ -1,134 +0,0 @@
<%
additional_paths =
Enum.map_join(@app_options[:additional_paths], ":", fn path ->
if String.starts_with?(path, "/") do
path
else
"\\(resourcePath)/#{path}"
end
end)
%>import Cocoa
class AppDelegate: NSObject, NSApplicationDelegate {
var releaseTask: Process!
var isRunning = false
var initialInput = "open_app"
func applicationDidFinishLaunching(_ aNotification: Notification) {
if !isRunning {
releaseTask = startRelease(initialInput)
isRunning = true
}
}
func applicationWillTerminate(_ n: Notification) {
if (releaseTask.isRunning == true) {
log("terminating release task")
releaseTask.terminate()
}
}
func application(_ app: NSApplication, open urls: [URL]) {
for url in urls {
var input : String
if url.isFileURL {
input = "open_file:\(url.path)"
} else {
input = "open_url:\(url)"
}
if isRunning {
rpc(input)
} else {
initialInput = input
}
}
}
}
func startRelease(_ input : String) -> Process {
let task = buildReleaseTask()
task.environment!["APP_BUILDER_INPUT"] = input
task.arguments = ["start"]
task.terminationHandler = {(t: Process) in
if t.terminationStatus == 0 {
log("release exited with: \(t.terminationStatus)")
} else {
runAlert(messageText: "\(appName) exited with error status \(t.terminationStatus).")
}
NSApp.terminate(nil)
}
try! task.run()
log("release pid: \(task.processIdentifier)")
DispatchQueue.global(qos: .userInteractive).async {
task.waitUntilExit()
}
return task
}
func rpc(_ event: String) {
let input = Pipe()
let task = buildReleaseTask()
task.standardInput = input
input.fileHandleForWriting.write("\(event)\n".data(using: .utf8)!)
task.arguments = ["rpc", "AppBundler.__rpc__()"]
try! task.run()
task.waitUntilExit()
if task.terminationStatus != 0 {
runAlert(messageText: "Something went wrong")
}
}
func buildReleaseTask() -> Process {
let task = Process()
task.launchPath = Bundle.main.path(forResource: "rel/bin/<%= @release.name %>", ofType: "")!
task.environment = ProcessInfo.processInfo.environment
<%= if additional_paths != "" do %>
let resourcePath = Bundle.main.resourcePath ?? ""
_ = resourcePath
let additionalPaths = "<%= additional_paths %>"
let path = task.environment!["PATH"] ?? ""
task.environment!["PATH"] = "\(additionalPaths):\(path)"
<% end %>
task.standardOutput = logFile
task.standardError = logFile
return task
}
func runAlert(messageText: String) {
DispatchQueue.main.sync {
let alert = NSAlert()
alert.alertStyle = .critical
alert.messageText = messageText
alert.informativeText = "Logs available at: \(logPath)"
alert.runModal()
}
}
func log(_ line: String) {
logFile.write("[\(appName)Launcher] \(line)\n".data(using: .utf8)!)
}
let fm = FileManager.default
let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as! String
let home = NSHomeDirectory()
let logPath = "\(home)/Library/Logs/\(appName).log"
if !fm.fileExists(atPath: logPath) { fm.createFile(atPath: logPath, contents: Data()) }
let logFile = FileHandle(forUpdatingAtPath: logPath)!
logFile.seekToEndOfFile()
let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
app.run()

View file

@ -1,90 +0,0 @@
<%
app_name = Keyword.fetch!(@app_options, :name)
%>
!include "MUI2.nsh"
;--------------------------------
;General
Name "<%= app_name %>"
ManifestDPIAware true
OutFile "<%= app_name %>Install.exe"
Unicode True
InstallDir "$LOCALAPPDATA\<%= app_name %>"
; Need admin for registering URL scheme
RequestExecutionLevel admin
;--------------------------------
;Interface Settings
!define MUI_ABORTWARNING
;--------------------------------
;Pages
;!insertmacro MUI_PAGE_COMPONENTS
<%= if @app_options[:icon_path] do %>
!define MUI_ICON "AppIcon.ico"
<% end %>
!insertmacro MUI_PAGE_DIRECTORY
!insertmacro MUI_PAGE_INSTFILES
!insertmacro MUI_UNPAGE_CONFIRM
!insertmacro MUI_UNPAGE_INSTFILES
;--------------------------------
;Languages
!insertmacro MUI_LANGUAGE "English"
;--------------------------------
;Installer Sections
Section "Install"
SetOutPath "$INSTDIR"
File vcredist_x64.exe
ExecWait '"$INSTDIR\vcredist_x64.exe" /install /quiet /norestart'
File /r rel rel
File "<%= app_name %>Launcher.exe"
<%= if @app_options[:icon_path] do %>
File "AppIcon.ico"
<% end %>
CreateDirectory "$INSTDIR\Logs"
WriteUninstaller "$INSTDIR\<%= app_name %>Uninstall.exe"
<%= for type <- Keyword.fetch!(@app_options, :document_types) do %>
<%= for ext <- type[:extensions] do %>
WriteRegStr HKCR ".<%= ext %>" "" "<%= app_name %>.<%= type[:name] %>"
<% end %>
WriteRegStr HKCR "<%= app_name %>.<%= type[:name] %>" "" "<%= type[:name] %>"
<%= if type[:icon_path] || @app_options[:icon_path] do %>
File "<%= type[:name] %>Icon.ico"
WriteRegStr HKCR "<%= app_name %>.<%= type[:name] %>\DefaultIcon" "" "$INSTDIR\<%= type[:name] %>Icon.ico"
<% end %>
WriteRegStr HKCR "<%= app_name %>.<%= type[:name] %>\shell\open\command" "" '"$INSTDIR\<%= app_name %>Launcher.exe" "open_file:%1"'
<% end %>
<%= for url_scheme <- Keyword.fetch!(@app_options, :url_schemes) do %>
DetailPrint "Register <%= url_scheme %> URL Handler"
DeleteRegKey HKCR "<%= url_scheme %>"
WriteRegStr HKCR "<%= url_scheme %>" "" "<%= url_scheme %> Protocol"
WriteRegStr HKCR "<%= url_scheme %>" "URL Protocol" ""
WriteRegStr HKCR "<%= url_scheme %>\shell" "" ""
WriteRegStr HKCR "<%= url_scheme %>\shell\open" "" ""
WriteRegStr HKCR "<%= url_scheme %>\shell\open\command" "" '"$INSTDIR\<%= app_name %>Launcher.exe" "open_url:%1"'
<% end %>
SectionEnd
Section "Desktop Shortcut"
CreateShortCut "$DESKTOP\<%= app_name %>.lnk" "$INSTDIR\<%= app_name %>Launcher.exe" "" <%= if @app_options[:icon_path] do %> "$INSTDIR\AppIcon.ico" <% end %>
SectionEnd
Section "Uninstall"
Delete "$DESKTOP\<%= app_name %>.lnk"
; TODO: stop epmd if it was started
RMDir /r "$INSTDIR"
SectionEnd

View file

@ -1,61 +0,0 @@
<%
app_name = Keyword.fetch!(@app_options, :name)
additional_paths =
for path <- Keyword.fetch!(@app_options, :additional_paths), into: "" do
"root & \"\\" <> String.replace(path, "/", "\\") <> ";\" & "
end
%>
Imports System
Module Launcher
Sub Main(args As String())
Dim root = My.Application.Info.DirectoryPath
Dim script = root & "\rel\bin\<%= @release.name %>.bat"
Dim input as String
If args.count > 0 Then
input = args(0)
Else
input = "open_app"
End If
<%= if additional_paths != "" do %>
Environment.SetEnvironmentVariable("PATH", <%= additional_paths%>Environment.GetEnvironmentVariable("PATH"))
<% end %>
' try release rpc, if release is down, this will fail but that's ok.
Dim rpcProc = new System.Diagnostics.Process()
rpcProc.StartInfo.FileName = "cmd.exe"
rpcProc.StartInfo.Arguments = "/c echo " & input & " | """ & script & """ rpc ""AppBundler.__rpc__()"""
rpcProc.StartInfo.UseShellExecute = false
rpcProc.StartInfo.CreateNoWindow = true
rpcProc.Start()
rpcProc.WaitForExit()
' rpc failed which usually means the release is down, let's start it
If rpcProc.ExitCode <> 0 Then
Environment.SetEnvironmentVariable("APP_BUILDER_INPUT", input)
Dim startProc = new System.Diagnostics.Process()
startProc.StartInfo.FileName = "cmd.exe"
startProc.StartInfo.Arguments = "/c """ & script & """ start"
startProc.StartInfo.UseShellExecute = false
startProc.StartInfo.CreateNoWindow = true
startProc.StartInfo.RedirectStandardError = true
startProc.StartInfo.StandardErrorEncoding = System.Text.Encoding.UTF8
startProc.Start()
Dim errorMessage = startProc.StandardError.ReadToEnd()
startProc.WaitForExit()
If startProc.ExitCode <> 0 Then
MsgBox(
"<%= app_name %> exited with error code " & startProc.ExitCode & "." & vbCrLf & errorMessage,
MsgBoxStyle.Critical
)
End If
End If
End Sub
End Module

View file

@ -1,33 +0,0 @@
defmodule AppBundler.MixProject do
use Mix.Project
def project do
[
app: :app_bundler,
version: "0.1.0",
elixir: "~> 1.13",
start_permanent: Mix.env() == :prod,
deps: deps(),
# Suppress warnings
xref: [
exclude: [
:wx
]
]
]
end
def application do
[
mod: {AppBundler.Application, []},
extra_applications: [:logger, :eex, :inets, :ssl, :crypto]
]
end
defp deps do
[
{:libpe, "~> 1.0"}
]
end
end

View file

@ -1,3 +0,0 @@
%{
"libpe": {:hex, :libpe, "1.1.2", "16337b414c690e0ee9c49fe917b059622f001c399303102b98900c05c229cd9a", [:mix], [], "hexpm", "31df0639fafb603b20078c8db9596c8984f35a151c64ec2e483d9136ff9f428c"},
}

View file

@ -20,7 +20,7 @@ erl_crash.dump
*.ez
# Ignore package tarball (built via "mix hex.build").
app_bundler-*.tar
elixirkit-*.tar
# Temporary files, for example, from tests.
/tmp/

1
elixirkit/README.md Normal file
View file

@ -0,0 +1 @@
# ElixirKit

View file

@ -20,9 +20,7 @@ erl_crash.dump
*.ez
# Ignore package tarball (built via "mix hex.build").
wx_demo-*.tar
demo-*.tar
# Temporary files, for example, from tests.
/tmp/
/test.sh

11
elixirkit/demo/README.md Normal file
View file

@ -0,0 +1,11 @@
# Demo
This is an example ElixirKit application.
See:
* [Elixir Server](lib/demo.ex)
* [Swift Client](rel/swift)
* [AppKit Client](rel/appkit)
* [C# Client](rel/dotnet)
* [Windows Forms Client](rel/winforms)

View file

@ -0,0 +1,61 @@
defmodule Demo.Application do
@moduledoc false
use Application
@impl true
def start(_type, _args) do
children = [
Demo.Server
]
opts = [strategy: :one_for_one, name: Demo.Supervisor]
Supervisor.start_link(children, opts)
end
end
defmodule Demo.Server do
use GenServer
def start_link(arg) do
GenServer.start_link(__MODULE__, arg, name: __MODULE__)
end
@impl true
def init(_) do
Process.flag(:trap_exit, true)
{:ok, server_pid} = ElixirKit.start()
log("init")
Task.start(fn ->
for i <- 5..1//-1 do
log("Stopping in #{i}...")
Process.sleep(1000)
end
System.stop()
end)
{:ok, %{server_pid: server_pid}}
end
@impl true
def handle_info({:event, "log", message}, state) do
log(message)
{:noreply, state}
end
@impl true
def handle_info({:EXIT, pid, :shutdown}, state) when pid == state.server_pid do
{:noreply, state}
end
@impl true
def terminate(_reason, _state) do
log("Stopping...")
end
defp log(message) do
IO.puts(["[server] ", message])
end
end

26
elixirkit/demo/mix.exs Normal file
View file

@ -0,0 +1,26 @@
defmodule Demo.MixProject do
use Mix.Project
def project do
[
app: :demo,
version: "0.1.0",
elixir: "~> 1.14",
start_permanent: Mix.env() == :prod,
deps: deps()
]
end
def application do
[
extra_applications: [:logger],
mod: {Demo.Application, []}
]
end
defp deps do
[
{:elixirkit, path: ".."}
]
end
end

8
elixirkit/demo/rel/appkit/.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

View file

@ -0,0 +1,19 @@
// swift-tools-version: 5.5
import PackageDescription
let package = Package(
name: "Demo",
platforms: [
.macOS(.v11)
],
dependencies: [
.package(name: "ElixirKit", path: "../../../elixirkit_swift")
],
targets: [
.executableTarget(
name: "Demo",
dependencies: ["ElixirKit"]
)
]
)

View file

@ -0,0 +1,9 @@
# Demo
Run the app, just the executable:
$ ./run.sh
Run the app bundle:
$ ./run_app.sh

View file

@ -0,0 +1,62 @@
import AppKit
import ElixirKit
@main
public struct Demo {
public static func main() {
let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
app.run()
}
}
class AppDelegate: NSObject, NSApplicationDelegate {
private var window : NSWindow!
func applicationDidFinishLaunching(_ aNotification: Notification) {
ElixirKit.API.start(
name: "demo",
terminationHandler: { _ in
NSApp.terminate(nil)
}
)
ElixirKit.API.publish("log", "Hello from AppKit!")
let menuItemOne = NSMenuItem()
menuItemOne.submenu = NSMenu(title: "Demo")
menuItemOne.submenu?.items = [
NSMenuItem(title: "Quit Demo", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
]
let menu = NSMenu()
menu.items = [menuItemOne]
NSApp.mainMenu = menu
window = NSWindow(contentRect: NSMakeRect(0, -1000, 200, 200),
styleMask: [.titled, .closable],
backing: .buffered,
defer: true)
window.orderFrontRegardless()
window.title = "Demo"
let button = NSButton(title: "Press me!", target: self, action: #selector(pressMe))
window.contentView!.subviews.append(button)
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
}
func applicationShouldTerminateAfterLastWindowClosed(_ app: NSApplication) -> Bool {
return true
}
func applicationWillTerminate(_ aNotification: Notification) {
ElixirKit.API.stop()
}
@objc
func pressMe() {
ElixirKit.API.publish("log", "button pressed!")
}
}

View file

@ -0,0 +1,7 @@
#!/bin/sh
set -euo pipefail
swift build
target_dir=`swift build --show-bin-path`
(cd ../.. && mix release --overwrite --path=$target_dir/rel)
$target_dir/Demo

View file

@ -0,0 +1,8 @@
#!/bin/sh
set -euo pipefail
export ELIXIRKIT_APP_NAME=Demo
export ELIXIRKIT_PROJECT_DIR=$PWD/../..
. ../../../../elixirkit/elixirkit_swift/Scripts/build_macos_app.sh
open -W --stdout `tty` --stderr `tty` .build/Demo.app

2
elixirkit/demo/rel/dotnet/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/bin
/obj

View file

@ -0,0 +1,11 @@
using ElixirKit;
class Demo {
public static void Main()
{
var api = new ElixirKit.API();
api.Start(name: "demo");
api.Publish("log", "Hello from C#!");
api.WaitForExit();
}
}

View file

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../../elixirkit_dotnet/ElixirKit.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,5 @@
# Demo
Run the app:
$ ./run.sh

View file

@ -0,0 +1,7 @@
#!/bin/sh
set -euo pipefail
dotnet build
target_dir="$PWD/bin/Debug/net6.0"
(cd ../.. && mix release --overwrite --path=${target_dir}/rel)
dotnet run --no-build

8
elixirkit/demo/rel/swift/.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

View file

@ -0,0 +1,19 @@
// swift-tools-version: 5.5
import PackageDescription
let package = Package(
name: "Demo",
platforms: [
.macOS(.v11)
],
dependencies: [
.package(name: "ElixirKit", path: "../../../elixirkit_swift")
],
targets: [
.executableTarget(
name: "Demo",
dependencies: ["ElixirKit"]
)
]
)

View file

@ -0,0 +1,5 @@
# Demo
Run the app:
$ ./run.sh

View file

@ -0,0 +1,18 @@
import Foundation
import ElixirKit
@main
struct Demo {
public static func main() {
ElixirKit.API.start(name: "demo")
// Capture ctrl+c
signal(SIGINT) { signal in
ElixirKit.API.stop()
exit(signal)
}
ElixirKit.API.publish("log", "Hello from Swift!")
ElixirKit.API.waitUntilExit()
}
}

View file

@ -0,0 +1,7 @@
#!/bin/sh
set -euo pipefail
swift build
target_dir=`swift build --show-bin-path`
(cd ../.. && mix release --overwrite --path=$target_dir/rel)
$target_dir/demo

View file

@ -0,0 +1,2 @@
/bin
/obj

View file

@ -0,0 +1,99 @@
namespace Demo;
static class DemoMain
{
[STAThread]
static void Main()
{
var api = new ElixirKit.API(id: "com.example.Demo");
if (api.MainInstance)
{
api.Start(name: "demo", exited: (exitCode) =>
{
Application.Exit();
});
Application.ApplicationExit += (sender, args) =>
{
api.Stop();
};
api.Publish("log", "Hello from Windows Forms!");
ApplicationConfiguration.Initialize();
Application.Run(new DemoForm(api));
}
else
{
api.Publish("log", "Hello from another instance!");
}
}
}
class DemoForm : Form
{
ElixirKit.API api;
public DemoForm(ElixirKit.API api)
{
this.api = api;
InitializeComponent();
}
private void form_Load(object? sender, EventArgs e)
{
}
private void button_Click(object? sender, EventArgs e)
{
api.Publish("log", "button pressed!");
}
// WinForms boilerplate below.
private System.ComponentModel.IContainer? components = null;
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
private void InitializeComponent()
{
this.components = new System.ComponentModel.Container();
this.button = new System.Windows.Forms.Button();
this.SuspendLayout();
//
// button
//
this.button.Location = new System.Drawing.Point(20, 20);
this.button.Name = "button";
this.button.Size = new System.Drawing.Size(200, 100);
this.button.TabIndex = 0;
this.button.Text = "Press me!";
this.button.UseVisualStyleBackColor = true;
this.button.Click += new System.EventHandler(this.button_Click);
//
// form
//
this.AutoScaleDimensions = new System.Drawing.SizeF(12F, 25F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(800, 450);
this.Controls.Add(this.button);
this.Name = "form";
this.Text = "Demo";
this.Load += new System.EventHandler(this.form_Load);
this.ResumeLayout(false);
}
#endregion
private System.Windows.Forms.Button button;
}

View file

@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType Condition="'$(Configuration)' == 'Debug'">Exe</OutputType>
<OutputType Condition="'$(Configuration)' == 'Release'">WinExe</OutputType>
<TargetFramework>net6.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../../elixirkit_dotnet/ElixirKit.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,5 @@
# Demo
Run the app:
$ ./run.sh

View file

@ -0,0 +1,7 @@
#!/bin/sh
set -euo pipefail
dotnet build
target_dir="$PWD/bin/Debug/net6.0-windows"
(cd ../.. && mix release --overwrite --path=${target_dir}/rel)
dotnet run --no-build

2
elixirkit/elixirkit_dotnet/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/bin
/obj

View file

@ -0,0 +1,247 @@
using System;
using System.Text;
using System.Diagnostics;
using System.IO.Pipes;
using System.IO;
using System.Threading.Tasks;
using System.Threading;
using System.Net;
using System.Net.Sockets;
namespace ElixirKit;
public static class API
{
private static Release? release;
private static Mutex? mutex;
private static string? id;
private static bool mainInstance = true;
// On Windows we need to manually handle the app being launched multiple times.
// It can be opened directly or via its associated file types and URL schemes.
// This function checks if the current app is the "main instance".
public static bool IsMainInstance(String id)
{
if (mutex != null)
{
throw new Exception("IsMainInstance can only be called once");
}
API.id = id;
mutex = new Mutex(true, id, out mainInstance);
return mainInstance;
}
static void ensureMainInstance()
{
if (!mainInstance)
{
throw new Exception("Not on main instance");
}
}
public static bool HasExited {
get {
ensureMainInstance();
return release!.HasExited;
}
}
public static void Start(string name, ExitHandler? exited = null, string? logPath = null)
{
ensureMainInstance();
release = new Release(name, exited, logPath);
if (mutex != null)
{
var t = new Task(() => {
while (true) {
var line = PipeReadLine();
if (line != null)
{
release!.Send(line);
}
}
});
t.Start();
}
}
public static int Stop()
{
ensureMainInstance();
return release!.Stop();
}
public static int WaitForExit()
{
ensureMainInstance();
return release!.WaitForExit();
}
public static void Publish(string name, string data)
{
if (mainInstance)
{
release!.Publish(name, data);
}
else
{
var message = Release.EncodeEventMessage(name, data);
PipeWriteLine(message);
}
}
private static string? PipeReadLine()
{
using var pipe = new NamedPipeServerStream(id!);
pipe.WaitForConnection();
using var reader = new StreamReader(pipe);
var line = reader.ReadLine()!;
pipe.Disconnect();
return line;
}
private static void PipeWriteLine(string line)
{
using var pipe = new NamedPipeClientStream(id!);
pipe.Connect();
using var writer = new StreamWriter(pipe);
writer.WriteLine(line);
}
}
public delegate void ExitHandler(int ExitCode);
internal class Release
{
Process startProcess;
NetworkStream stream;
TcpListener listener;
TcpClient client;
internal bool HasExited {
get {
return startProcess.HasExited;
}
}
public Release(string name, ExitHandler? exited = null, string? logPath = null)
{
var endpoint = new IPEndPoint(IPAddress.Loopback, 0);
listener = new(endpoint);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
startProcess = new Process()
{
StartInfo = new ProcessStartInfo()
{
FileName = relScript(name),
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
}
};
startProcess.StartInfo.Arguments = "start";
startProcess.StartInfo.EnvironmentVariables.Add("ELIXIRKIT_PORT", $"{port}");
if (exited != null)
{
startProcess.EnableRaisingEvents = true;
startProcess.Exited += (sender, args) =>
{
exited(startProcess.ExitCode);
};
}
startProcess.OutputDataReceived += (sender, e) =>
{
if (!String.IsNullOrEmpty(e.Data)) { Console.WriteLine(e.Data); }
};
startProcess.ErrorDataReceived += (sender, e) =>
{
if (!String.IsNullOrEmpty(e.Data)) { Console.Error.WriteLine(e.Data); }
};
if (logPath != null)
{
var logWriter = File.AppendText(logPath);
logWriter.AutoFlush = true;
startProcess.OutputDataReceived += (sender, e) =>
{
if (!String.IsNullOrEmpty(e.Data)) { logWriter.WriteLine(e.Data); }
};
startProcess.ErrorDataReceived += (sender, e) =>
{
if (!String.IsNullOrEmpty(e.Data)) { logWriter.WriteLine(e.Data); }
};
}
startProcess.Start();
startProcess.BeginOutputReadLine();
startProcess.BeginErrorReadLine();
client = listener.AcceptTcpClient();
stream = client.GetStream();
}
internal static string EncodeEventMessage(string name, string data)
{
var bytes = System.Text.Encoding.UTF8.GetBytes(data);
var encoded = System.Convert.ToBase64String(bytes);
return $"event:{name}:{encoded}";
}
public void Publish(string name, string data) {
Send(EncodeEventMessage(name, data));
}
internal void Send(string message)
{
var bytes = Encoding.UTF8.GetBytes(message + "\n");
stream.Write(bytes, 0, bytes.Length);
}
public int Stop()
{
if (HasExited)
{
return startProcess!.ExitCode;
}
client.Close();
listener.Stop();
return WaitForExit();
}
public int WaitForExit()
{
startProcess!.WaitForExit();
return startProcess!.ExitCode;
}
private string relScript(string name)
{
var exe = Process.GetCurrentProcess().MainModule!.FileName;
var dir = Path.GetDirectoryName(exe)!;
if (Path.GetExtension(exe) == ".exe")
{
return Path.Combine(dir, "rel", "bin", name + ".bat");
}
else
{
return Path.Combine(dir, "rel", "bin", name);
}
}
}

View file

@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net46</TargetFramework>
<LangVersion>10.0</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

8
elixirkit/elixirkit_swift/.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

View file

@ -0,0 +1,27 @@
// swift-tools-version: 5.5
import PackageDescription
let package = Package(
name: "ElixirKit",
platforms: [
.macOS(.v11)
],
products: [
.library(
name: "ElixirKit",
targets: ["ElixirKit"]
),
],
dependencies: [],
targets: [
.target(
name: "ElixirKit",
dependencies: []
),
.testTarget(
name: "ElixirKitTests",
dependencies: ["ElixirKit"]
)
]
)

View file

@ -0,0 +1 @@
# ElixirKit

View file

@ -0,0 +1,33 @@
#!/bin/sh
set -euo pipefail
app_name=$ELIXIRKIT_APP_NAME
release_name="${ELIXIRKIT_RELEASE_NAME:-}"
app_dir=$PWD/.build/${app_name}.app
build_args="${ELIXIRKIT_BUILD_ARGS:-}"
rm -rf $app_dir
swift build $build_args
target_dir=`swift build --show-bin-path $build_args`
rel_dir=$app_dir/Contents/Resources/rel
mkdir -p $app_dir/Contents/{MacOS,Resources}
if [ -f Info.plist ]; then
cp Info.plist $app_dir/Contents/Info.plist
fi
cp $target_dir/$app_name $app_dir/Contents/MacOS/$app_name
if [ -d Resources ]; then
for i in Resources/*; do
cp $i $app_dir/Contents/Resources/
done
fi
(
cd $ELIXIRKIT_PROJECT_DIR
mix release $release_name --overwrite --path=$rel_dir
)
/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister -f $app_dir

View file

@ -0,0 +1,39 @@
#!/bin/sh
set -euo pipefail
app_name=$ELIXIRKIT_APP_NAME
app_dir=$PWD/.build/${app_name}.app
identity="${ELIXIRKIT_CODESIGN_IDENTITY:-}"
team_id="${ELIXIRKIT_NOTARY_TEAM_ID:-}"
apple_id="${ELIXIRKIT_NOTARY_APPLE_ID:-}"
password="${ELIXIRKIT_NOTARY_PASSWORD:-}"
if [ -n "$identity" ]; then
files=`find $app_dir -perm +111 -type f -exec sh -c "file {} | grep --silent Mach-O" \; -print`
files="$files $app_dir/Contents/MacOS/$app_name"
codesign --sign="$identity" --options=runtime --entitlements=App.entitlements --force --timestamp --verbose=2 $files
else
echo "[warning] skipping codesign. Please set ELIXIRKIT_CODESIGN_IDENTITY environment variable"
fi
dmg_path=$PWD/.build/${app_name}Install.dmg
dmg_dir=$PWD/.build/dmg
rm -rf $dmg_dir
mkdir $dmg_dir
ln -s /Applications $dmg_dir
cp -r $app_dir $dmg_dir/
hdiutil create $dmg_path -ov -volname ${app_name}Install -fs HFS+ -srcfolder $dmg_dir
if [ -n "$team_id" ]; then
xcrun notarytool submit \
--team-id "${team_id}" \
--apple-id "${apple_id}" \
--password "${password}" \
--progress \
--wait \
$dmg_path
else
echo "[warning] skipping notarization. Please set ELIXIRKIT_NOTARY_{TEAM_ID,APPLE_ID,PASSWORD} environment variables"
fi

View file

@ -0,0 +1,179 @@
import Foundation
import Network
public class API {
static var process: Process?
private static var release: Release?
public static var isRunning: Bool {
get {
release != nil && release!.isRunning;
}
}
public static func start(name: String, logPath: String? = nil, terminationHandler: ((Process) -> Void)? = nil) {
release = Release(name: name, logPath: logPath, terminationHandler: terminationHandler)
}
public static func publish(_ name: String, _ data: String) {
release!.publish(name, data)
}
public static func stop() {
release!.stop();
}
public static func waitUntilExit() {
release!.waitUntilExit();
}
}
private class Release {
let listener: NWListener
let startProcess: Process
let semaphore = DispatchSemaphore(value: 0)
var logHandle: FileHandle?
var connection: NWConnection?
var isRunning: Bool {
get {
startProcess.isRunning
}
}
init(name: String, logPath: String? = nil, terminationHandler: ((Process) -> Void)? = nil) {
listener = try! NWListener(using: .tcp, on: .any)
let bundle = Bundle.main
var rootDir = "";
if bundle.bundlePath.hasSuffix(".app") {
rootDir = "\(bundle.bundlePath)/Contents/Resources"
}
else {
rootDir = bundle.bundlePath
}
startProcess = Process()
if logPath != nil {
let logPath = logPath!
let fm = FileManager.default
if !fm.fileExists(atPath: logPath) { fm.createFile(atPath: logPath, contents: Data()) }
logHandle = FileHandle(forUpdatingAtPath: logPath)!
logHandle!.seekToEndOfFile()
let stdout = Pipe()
let stderr = Pipe()
startProcess.standardOutput = stdout
startProcess.standardError = stderr
let stdouth = stdout.fileHandleForReading
let stderrh = stderr.fileHandleForReading
stdouth.waitForDataInBackgroundAndNotify()
stderrh.waitForDataInBackgroundAndNotify()
NotificationCenter.default.addObserver(
self,
selector: #selector(receiveStdout(n:)),
name: NSNotification.Name.NSFileHandleDataAvailable,
object: stdouth
)
NotificationCenter.default.addObserver(
self,
selector: #selector(receiveStderr(n:)),
name: NSNotification.Name.NSFileHandleDataAvailable,
object: stderrh
)
}
startProcess.launchPath = "\(rootDir)/rel/bin/\(name)"
startProcess.arguments = ["start"]
startProcess.terminationHandler = terminationHandler
listener.stateUpdateHandler = stateDidChange(to:)
listener.newConnectionHandler = didAccept(connection:)
listener.start(queue: .global())
let timeout = DispatchTime.now() + DispatchTimeInterval.seconds(5)
if semaphore.wait(timeout: timeout) == .timedOut {
fatalError("waited for connection for more than 5s")
}
}
func stateDidChange(to state: NWListener.State) {
switch state {
case .ready:
start(port: listener.port!.rawValue.description)
case .failed(let error):
print("Server error: \(error.localizedDescription)")
exit(EXIT_FAILURE)
default:
break
}
}
func start(port: String) {
var env = ProcessInfo.processInfo.environment
env["ELIXIRKIT_PORT"] = port
startProcess.environment = env
try! startProcess.run()
}
func didAccept(connection: NWConnection) {
self.connection = connection
self.connection!.start(queue: .main)
semaphore.signal()
}
func send(_ string: String) {
connection!.send(
content: (string + "\n").data(using: .utf8),
completion: .contentProcessed { error in
if error != nil {
print(error!)
}
}
)
}
@objc
func receiveStdout(n: NSNotification) {
let h = n.object as! FileHandle
let data = h.availableData
if !data.isEmpty {
FileHandle.standardOutput.write(data)
logHandle!.write(data)
h.waitForDataInBackgroundAndNotify()
}
}
@objc
func receiveStderr(n: NSNotification) {
let h = n.object as! FileHandle
let data = h.availableData
if !data.isEmpty {
logHandle!.write(data)
FileHandle.standardError.write(data)
h.waitForDataInBackgroundAndNotify()
}
}
public func publish(_ name: String, _ data: String) {
let encoded = data.data(using: .utf8)!.base64EncodedString()
let message = "event:\(name):\(encoded)"
send(message)
}
public func stop() {
connection!.cancel()
listener.cancel()
waitUntilExit()
}
public func waitUntilExit() {
startProcess.waitUntilExit()
}
}

View file

@ -0,0 +1,8 @@
import XCTest
@testable import ElixirKit
final class ElixirKitTests: XCTestCase {
func testExample() throws {
XCTAssertEqual(1 + 2, 3)
}
}

View file

@ -0,0 +1,5 @@
defmodule ElixirKit do
def start do
Supervisor.start_child(ElixirKit.Supervisor, {ElixirKit.Server, self()})
end
end

View file

@ -0,0 +1,12 @@
defmodule ElixirKit.Application do
@moduledoc false
use Application
@impl true
def start(_type, _args) do
children = []
opts = [strategy: :one_for_one, name: ElixirKit.Supervisor]
Supervisor.start_link(children, opts)
end
end

View file

@ -0,0 +1,30 @@
defmodule ElixirKit.Server do
@moduledoc false
use GenServer
def start_link(arg) do
GenServer.start_link(__MODULE__, arg, name: __MODULE__)
end
@impl true
def init(pid) do
port = System.fetch_env!("ELIXIRKIT_PORT") |> String.to_integer()
{:ok, socket} = :gen_tcp.connect('localhost', port, mode: :binary, packet: :line)
{:ok, %{pid: pid, socket: socket}}
end
@impl true
def handle_info({:tcp, socket, "event:" <> rest}, state) when socket == state.socket do
[name, data] = rest |> String.trim_trailing() |> String.split(":")
data = Base.decode64!(data)
send(state.pid, {:event, name, data})
{:noreply, state}
end
@impl true
def handle_info({:tcp_closed, socket}, state) when socket == state.socket do
System.stop()
{:stop, :shutdown, state}
end
end

39
elixirkit/mix.exs Normal file
View file

@ -0,0 +1,39 @@
defmodule ElixirKit.MixProject do
use Mix.Project
def project do
[
app: :elixirkit,
version: "0.1.0",
elixir: "~> 1.14",
start_permanent: Mix.env() == :prod,
package: package(),
deps: deps()
]
end
def application do
[
extra_applications: [:logger],
mod: {ElixirKit.Application, []}
]
end
def package do
[
files: [
"lib",
"elixirkit_swift/Package.swift",
"elixirkit_swift/Sources",
"elixirkit_dotnet/ElixirKit.csproj",
"elixirkit_dotnet/ElixirKit.cs",
"mix.exs",
"README.md"
]
]
end
defp deps do
[]
end
end

1
elixirkit/otp_bootstrap/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/_build

128
elixirkit/otp_bootstrap/build.sh Executable file
View file

@ -0,0 +1,128 @@
#!/bin/sh
set -euo pipefail
if [ $# -ne 3 ]; then
cat <<EOF
Usage:
build.sh otp_version openssl_version target
Set BUILD_DOCS=1 to build doc chunks.
EOF
exit 1
fi
otp_version=$1
openssl_version=$2
target=$3
build_docs=${BUILD_DOCS:-}
# Common build flags
export MAKEFLAGS=-j8
cflags="-Os -fno-common -mmacosx-version-min=11.0"
case "$target" in
macos-aarch64)
arch="arm64"
;;
macos-x86_64)
arch="x86_64"
;;
*)
echo "bad target $target"
exit 1
esac
build_dir=$PWD/_build
openssl_src_dir=$build_dir/openssl-src-$openssl_version
openssl_rel_dir=$build_dir/openssl-rel-$openssl_version-$target
otp_src_dir=$build_dir/otp-src-$otp_version
otp_rel_dir=$build_dir/otp-rel-$otp_version-openssl-$openssl_version-$target
echo "Building OpenSSL $openssl_version..."
if [ -d $openssl_src_dir ]; then
echo "$openssl_src_dir already exists"
else
url=https://github.com/openssl/openssl
ref=OpenSSL_`echo $openssl_version | tr '.' '_'`
git clone --depth 1 $url --branch $ref $openssl_src_dir
fi
if [ -d $openssl_rel_dir ]; then
echo "$openssl_rel_dir already exists"
else
(
cd $openssl_src_dir
./Configure "darwin64-$arch-cc" --prefix=$openssl_rel_dir $cflags
make clean
make
make install_sw
)
fi
echo "Building OTP $otp_version..."
if [ -d $otp_src_dir ]; then
echo "$otp_src_dir already exists"
else
url=https://github.com/erlang/otp
ref=OTP-$otp_version
git clone --depth 1 $url --branch $ref $otp_src_dir
fi
if [ -d $otp_rel_dir ]; then
echo "$otp_rel_dir already exists"
else
(
cd $otp_src_dir
git clean -dfx
export ERL_TOP=$PWD
export ERLC_USE_SERVER=true
xcrun="xcrun -sdk macosx"
sysroot=`$xcrun --show-sdk-path`
./configure --enable-bootstrap-only
./otp_build configure \
--build=`erts/autoconf/config.guess` \
--host="$arch-apple-darwin" \
--with-ssl=$openssl_rel_dir \
--disable-dynamic-ssl-lib \
--without-{javac,odbc,wx,observer,debugger,et} \
erl_xcomp_sysroot=$sysroot \
CC="$xcrun cc -arch $arch" \
CFLAGS="$cflags" \
CXX="$xcrun c++ -arch $arch" \
CXXFLAGS="$cflags" \
LD="$xcrun ld" \
LDFLAGS="-lc++" \
RANLIB="$xcrun ranlib"
./otp_build boot -a
./otp_build release -a $otp_rel_dir
if [ "$build_docs" = "1" ]; then
make release_docs DOC_TARGETS=chunks RELEASE_ROOT=$otp_rel_dir
fi
cd $otp_rel_dir
./Install -cross -sasl $PWD
)
fi
echo "Checking OTP..."
if [[ `uname -m` = "arm64" && "$target" = *"x86_64" ]]; then
echo "skip"
exit 0
fi
if [[ `uname -m` = "x86_64" && "$target" = *"aarch64" ]]; then
echo "skip"
exit 0
fi
export PATH=$otp_rel_dir/bin:$PATH
erl -noshell -eval 'io:format("root_dir=~p~n", [code:root_dir()]), halt().'
erl -noshell -eval 'io:format("~s", [erlang:system_info(system_version)]), halt().'
erl -noshell -eval 'io:format("~s~n", [erlang:system_info(system_architecture)]), halt().'
erl -noshell -eval 'ok = crypto:start(), io:format("crypto ok~n"), halt().'

View file

@ -0,0 +1,45 @@
#!/bin/sh
set -euo pipefail
if [ $# -ne 2 ]; then
cat <<EOF
Usage:
build_macos_universal.sh otp_version openssl_version
EOF
exit 1
fi
otp_version=$1
openssl_version=$2
echo "\nBuilding macos-universal..."
otp_rel_dir=$PWD/_build/otp-rel-$otp_version-openssl-$openssl_version-macos-universal
if [ -d $otp_rel_dir ]; then
echo "$otp_rel_dir already exists"
else
echo "Building macos-aarch64..."
BUILD_DOCS=1 ./build.sh $otp_version $openssl_version macos-aarch64
echo "\nBuilding macos-x86_64..."
./build.sh $otp_version $openssl_version macos-x86_64
dir1=$PWD/_build/otp-rel-$otp_version-openssl-$openssl_version-macos-aarch64
dir2=$PWD/_build/otp-rel-$otp_version-openssl-$openssl_version-macos-x86_64
cp -R $dir1 $otp_rel_dir
cd $otp_rel_dir
for i in `find . -perm +111 -type f -exec sh -c "file {} | grep --silent 'Mach-O'" \; -print`; do
echo lipo $otp_rel_dir/$i
lipo $i $dir2/$i -create -output $i.universal
mv $i.universal $i
done
cd -
fi
export PATH=$otp_rel_dir/bin:$PATH
echo "checking OTP"
erl -noshell -eval 'io:format("root_dir=~p~n", [code:root_dir()]), halt().'
erl -noshell -eval 'io:format("~s", [erlang:system_info(system_version)]), halt().'
erl -noshell -eval 'io:format("~s~n", [erlang:system_info(system_architecture)]), halt().'
erl -noshell -eval 'ok = crypto:start(), io:format("crypto ok~n"), halt().'

View file

@ -1,3 +1,3 @@
defmodule AppBundlerTest do
defmodule ElixirKitTest do
use ExUnit.Case, async: true
end

View file

@ -1,179 +1,41 @@
if Mix.target() == :app do
defmodule LivebookApp do
@moduledoc false
@name __MODULE__
@wxID_OPEN 5000
@wxID_EXIT 5006
@wxBITMAP_TYPE_PNG 15
use GenServer
def start_link(arg) do
GenServer.start_link(__MODULE__, arg, name: @name)
GenServer.start_link(__MODULE__, arg, name: __MODULE__)
end
taskbar_icon_path = "rel/app/icon.png"
@external_resource taskbar_icon_path
@taskbar_icon File.read!(taskbar_icon_path)
@impl true
def init(_) do
AppBundler.init()
os = AppBundler.os()
:wx.new()
# TODO: instead of embedding the icon and copying to tmp, copy the file known location.
# It's a bit tricky because it needs to support all the ways of running the app:
# 1. MIX_TARGET=app mix phx.server
# 2. mix app
# 3. mix release app
taskbar_icon_path = Path.join(System.tmp_dir!(), "icon.png")
File.write!(taskbar_icon_path, @taskbar_icon)
icon = :wxIcon.new(taskbar_icon_path, type: @wxBITMAP_TYPE_PNG)
menu_items = [
{"Open Browser", key: "ctrl+o", id: @wxID_OPEN},
{"Quit", key: "ctrl+q", id: @wxID_EXIT}
]
taskbar = WxUtils.taskbar("Livebook", icon, menu_items)
if os == :windows do
:wxTaskBarIcon.connect(taskbar, :taskbar_left_down,
callback: fn _, _ ->
open_browser()
end
)
end
{:ok, _} = ElixirKit.start()
{:ok, nil}
end
@impl true
def handle_info(:open_app, state) do
open_browser()
def handle_info({:event, "open", url}, state) do
open(url)
{:noreply, state}
end
@impl true
def handle_info({:open_file, path}, state) do
defp open("") do
open(LivebookWeb.Endpoint.access_url())
end
defp open("file://" <> path) do
path
|> Livebook.Utils.notebook_open_url()
|> open_browser()
{:noreply, state}
|> open()
end
@impl true
def handle_info({:open_url, "livebook://" <> rest}, state) do
defp open("livebook://" <> rest) do
"https://#{rest}"
|> Livebook.Utils.notebook_import_url()
|> open_browser()
{:noreply, state}
|> open()
end
@impl true
def handle_info({:wx, @wxID_EXIT, _, _, _}, _state) do
System.stop(0)
end
@impl true
def handle_info({:wx, @wxID_OPEN, _, _, _}, state) do
open_browser()
{:noreply, state}
end
if Mix.env() == :dev do
@impl true
def handle_info(event, state) do
IO.inspect(event)
{:noreply, state}
end
end
defp open_browser(url \\ LivebookWeb.Endpoint.access_url()) do
defp open(url) do
Livebook.Utils.browser_open(url)
end
end
defmodule WxUtils do
@moduledoc false
@wxID_ANY -1
def taskbar(title, icon, menu_items) do
pid = self()
os = AppBundler.os()
options = if os == :macos, do: [iconType: 1], else: []
# skip keyboard shortcuts
menu_items =
for item <- menu_items do
{title, options} = item
options = Keyword.delete(options, :key)
{title, options}
end
taskbar =
:wxTaskBarIcon.new(
[
createPopupMenu: fn ->
menu = menu(menu_items)
# For some reason, on macOS the menu event must be handled in another process
# but on Windows it must be either the same process OR we use the callback.
case os do
:macos ->
env = :wx.get_env()
Task.start_link(fn ->
:wx.set_env(env)
:wxMenu.connect(menu, :command_menu_selected)
receive do
message ->
send(pid, message)
end
end)
:windows ->
:ok =
:wxMenu.connect(menu, :command_menu_selected,
callback: fn wx, _ ->
send(pid, wx)
end
)
end
menu
end
] ++ options
)
:wxTaskBarIcon.setIcon(taskbar, icon, tooltip: title)
taskbar
end
def menu(items) do
menu = :wxMenu.new()
Enum.each(items, fn
{title, options} ->
id = Keyword.get(options, :id, @wxID_ANY)
title =
case Keyword.fetch(options, :key) do
{:ok, key} ->
title <> "\t" <> key
:error ->
title
end
:wxMenu.append(menu, id, title)
end)
menu
end
end
end

60
mix.exs
View file

@ -30,16 +30,11 @@ defmodule Livebook.MixProject do
def application do
[
mod: {Livebook.Application, []},
extra_applications:
[:logger, :runtime_tools, :os_mon, :inets, :ssl, :xmerl] ++
extra_applications(Mix.target()),
extra_applications: [:logger, :runtime_tools, :os_mon, :inets, :ssl, :xmerl],
env: Application.get_all_env(:livebook)
]
end
defp extra_applications(:app), do: [:wx]
defp extra_applications(_), do: []
defp elixirc_paths(:test), do: elixirc_paths(:dev) ++ ["test/support"]
defp elixirc_paths(_), do: ["lib", "proto/lib"]
@ -111,7 +106,7 @@ defmodule Livebook.MixProject do
]
end
defp target_deps(:app), do: [{:app_bundler, path: "app_bundler"}]
defp target_deps(:app), do: [{:elixirkit, path: "elixirkit"}]
defp target_deps(_), do: []
@lock (with {:ok, contents} <- File.read("mix.lock"),
@ -134,14 +129,6 @@ defmodule Livebook.MixProject do
## Releases
defp releases do
macos_notarization = macos_notarization()
additional_paths = [
"rel/vendor/otp/erts-#{:erlang.system_info(:version)}/bin",
"rel/vendor/otp/bin",
"rel/vendor/elixir/bin"
]
[
livebook: [
include_executables_for: [:unix],
@ -155,53 +142,12 @@ defmodule Livebook.MixProject do
steps: [
:assemble,
&remove_cookie/1,
&standalone_erlang_elixir/1,
&AppBundler.bundle/1
],
app: [
name: "Livebook",
url_schemes: ["livebook"],
document_types: [
[
name: "LiveMarkdown",
extensions: ["livemd"],
macos: [
icon_path: "rel/app/icon.png",
role: "Editor"
],
windows: [
icon_path: "rel/app/icon.ico"
]
]
],
macos: [
app_type: :agent,
icon_path: "rel/app/icon-macos.png",
build_dmg: macos_notarization != nil,
notarization: macos_notarization,
additional_paths: additional_paths ++ ["/usr/local/bin"]
],
windows: [
icon_path: "rel/app/icon.ico",
build_installer: true,
additional_paths: additional_paths
]
&standalone_erlang_elixir/1
]
]
]
end
defp macos_notarization do
identity = System.get_env("NOTARIZE_IDENTITY")
team_id = System.get_env("NOTARIZE_TEAM_ID")
apple_id = System.get_env("NOTARIZE_APPLE_ID")
password = System.get_env("NOTARIZE_PASSWORD")
if identity && team_id && apple_id && password do
[identity: identity, team_id: team_id, apple_id: apple_id, password: password]
end
end
defp remove_cookie(release) do
File.rm!(Path.join(release.path, "releases/COOKIE"))
release

View file

@ -9,10 +9,8 @@
"decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"},
"earmark_parser": {:hex, :earmark_parser, "1.4.29", "149d50dcb3a93d9f3d6f3ecf18c918fb5a2d3c001b5d3305c926cddfbd33355b", [:mix], [], "hexpm", "4902af1b3eb139016aed210888748db8070b8125c2342ce3dcae4f38dcc63503"},
"ecto": {:hex, :ecto, "3.9.4", "3ee68e25dbe0c36f980f1ba5dd41ee0d3eb0873bccae8aeaf1a2647242bffa35", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "de5f988c142a3aa4ec18b85a4ec34a2390b65b24f02385c1144252ff6ff8ee75"},
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
"floki": {:hex, :floki, "0.34.0", "002d0cc194b48794d74711731db004fafeb328fe676976f160685262d43706a8", [:mix], [], "hexpm", "9c3a9f43f40dde00332a589bd9d389b90c1f518aef500364d00636acc5ebc99c"},
"hpax": {:hex, :hpax, "0.1.1", "2396c313683ada39e98c20a75a82911592b47e5c24391363343bde74f82396ca", [:mix], [], "hexpm", "0ae7d5a0b04a8a60caf7a39fcf3ec476f35cc2cc16c05abea730d3ce6ac6c826"},
"html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"},
"jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"},
"mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"},
"mint": {:hex, :mint, "1.4.2", "50330223429a6e1260b2ca5415f69b0ab086141bc76dc2fbf34d7c389a6675b2", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "ce75a5bbcc59b4d7d8d70f8b2fc284b1751ffb35c7b6a6302b5192f8ab4ddd80"},

View file

@ -4,8 +4,9 @@ set MIX_ARCHIVES=!RELEASE_ROOT!\vendor\archives
set MIX_REBAR3=!RELEASE_ROOT!\vendor\rebar3
set LIVEBOOK_SHUTDOWN_ENABLED=true
set LIVEBOOK_PORT=0
set PATH=!RELEASE_ROOT!\vendor\otp\erts-<%= @release.erts_version%>\bin;!RELEASE_ROOT!\vendor\otp\bin;!RELEASE_ROOT!\vendor\elixir\bin;!PATH!
set cookie_path="!RELEASE_ROOT!\releases\COOKIE"
set cookie_path=!RELEASE_ROOT!\releases\COOKIE
if not exist %cookie_path% (
for /f "skip=1" %%X in ('wmic os get localdatetime') do if not defined TIMESTAMP set TIMESTAMP=%%X
echo cookie-!TIMESTAMP:~0,11!-!RANDOM! > %cookie_path%

View file

@ -5,6 +5,7 @@ export MIX_REBAR3="${RELEASE_ROOT}/vendor/rebar3"
export WX_MACOS_NON_GUI_APP=1
export LIVEBOOK_SHUTDOWN_ENABLED=true
export LIVEBOOK_PORT=0
export PATH="$RELEASE_ROOT/vendor/otp/erts-<%= @release.erts_version%>/bin:$RELEASE_ROOT/vendor/otp/bin:$RELEASE_ROOT/vendor/elixir/bin:$PATH"
cookie_path="${RELEASE_ROOT}/releases/COOKIE"
if [ ! -f $cookie_path ]; then

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

8
rel/app/macos/.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

42
rel/app/macos/Info.plist Normal file
View file

@ -0,0 +1,42 @@
<?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>CFBundleName</key>
<string>Livebook</string>
<key>CFBundleIdentifier</key>
<string>dev.livebook.Livebook</string>
<key>CFBundleVersion</key>
<string>0.8.0</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>LSUIElement</key>
<true/>
<key>LSRequiresNativeExecution</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>Livebook</string>
<key>CFBundleURLSchemes</key>
<array>
<string>livebook</string>
</array>
</dict>
</array>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>LiveMarkdown</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleTypeExtensions</key>
<array>
<string>livemd</string>
</array>
</dict>
</array>
</dict>
</plist>

View file

@ -0,0 +1,19 @@
// swift-tools-version: 5.5
import PackageDescription
let package = Package(
name: "Livebook",
platforms: [
.macOS(.v11)
],
dependencies: [
.package(name: "ElixirKit", path: "../../../elixirkit/elixirkit_swift")
],
targets: [
.executableTarget(
name: "Livebook",
dependencies: ["ElixirKit"]
)
]
)

5
rel/app/macos/README.md Normal file
View file

@ -0,0 +1,5 @@
# Livebook for macOS
Run the app:
$ ./run.sh

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -0,0 +1,88 @@
import AppKit
import ElixirKit
@main
public struct Livebook {
public static func main() {
let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
app.run()
}
}
class AppDelegate: NSObject, NSApplicationDelegate {
private var statusItem: NSStatusItem!
private var logPath: String!
private var launchedByOpenURL = false
private var initialURLs: [URL] = []
func applicationDidFinishLaunching(_ aNotification: Notification) {
logPath = "\(NSHomeDirectory())/Library/Logs/Livebook.log"
ElixirKit.API.start(name: "app", logPath: logPath) { process in
if process.terminationStatus != 0 {
DispatchQueue.main.sync {
let alert = NSAlert()
alert.alertStyle = .critical
alert.messageText = "Livebook exited with error status \(process.terminationStatus)"
alert.addButton(withTitle: "Dismiss")
alert.addButton(withTitle: "View Logs")
switch alert.runModal() {
case .alertSecondButtonReturn:
self.viewLogs()
default:
()
}
}
}
NSApp.terminate(nil)
}
if (self.initialURLs == []) {
ElixirKit.API.publish("open", "")
}
else {
for url in self.initialURLs {
ElixirKit.API.publish("open", url.absoluteString)
}
}
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
let button = statusItem.button!
button.image = NSImage(named: "MenuBarIcon")
let menu = NSMenu()
menu.items = [
NSMenuItem(title: "Open", action: #selector(open), keyEquivalent: "o"),
NSMenuItem(title: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
]
statusItem.menu = menu
}
func applicationWillTerminate(_ aNotification: Notification) {
ElixirKit.API.stop()
}
func application(_ app: NSApplication, open urls: [URL]) {
if !ElixirKit.API.isRunning {
initialURLs = urls
return
}
for url in urls {
ElixirKit.API.publish("open", url.absoluteString)
}
}
@objc
func open() {
ElixirKit.API.publish("open", "")
}
@objc
func viewLogs() {
NSWorkspace.shared.open(NSURL.fileURL(withPath: logPath))
}
}

10
rel/app/macos/build_app.sh Executable file
View file

@ -0,0 +1,10 @@
#!/bin/sh
set -euo pipefail
export MIX_TARGET=app
export MIX_ENV=prod
export ELIXIRKIT_APP_NAME=Livebook
export ELIXIRKIT_PROJECT_DIR=$PWD/../../..
export ELIXIRKIT_RELEASE_NAME=app
. ../../../elixirkit/elixirkit_swift/Scripts/build_macos_app.sh

5
rel/app/macos/build_dmg.sh Executable file
View file

@ -0,0 +1,5 @@
#!/bin/sh
set -euo pipefail
. `dirname $0`/build_app.sh
. ../../../elixirkit/elixirkit_swift/Scripts/build_macos_dmg.sh

5
rel/app/macos/run.sh Executable file
View file

@ -0,0 +1,5 @@
#!/bin/sh
set -euo pipefail
. `dirname $0`/build_app.sh
open -W --stdout `tty` --stderr `tty` $app_dir

View file

@ -79,11 +79,11 @@ defmodule Standalone do
download_elixir_at_destination(standalone_destination, elixir_version)
filenames =
case AppBundler.os() do
:macos ->
case :os.type() do
{:unix, :darwin} ->
["elixir", "elixirc", "mix", "iex"]
:windows ->
{:win32, _} ->
["elixir.bat", "elixirc.bat", "mix.bat", "iex.bat"]
end

2
rel/app/windows/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/bin
/obj

View file

@ -0,0 +1,99 @@
!include "MUI2.nsh"
!include "WinVer.nsh"
Name "Livebook"
ManifestDPIAware true
OutFile "bin\LivebookInstall.exe"
Unicode True
; Install to user home so we have permission to write COOKIE, crash dumps, etc
InstallDir "$LOCALAPPDATA\Livebook"
; Need admin for registering URL scheme
RequestExecutionLevel admin
!define MUI_ABORTWARNING
!define MUI_ICON "Resources\AppIcon.ico"
!insertmacro MUI_PAGE_DIRECTORY
!insertmacro MUI_PAGE_INSTFILES
!insertmacro MUI_UNPAGE_CONFIRM
!insertmacro MUI_UNPAGE_INSTFILES
!insertmacro MUI_LANGUAGE "English"
Function .onInit
${IfNot} ${AtLeastWin10}
MessageBox mb_iconStop "Livebook requires Windows 10+"
Abort
${EndIf}
FunctionEnd
Section "Install"
SetOutPath "$INSTDIR"
File "bin\vc_redist.x64.exe"
ExecWait '"$INSTDIR\vc_redist.x64.exe" /install /quiet /norestart'
Delete "$INSTDIR\vc_redist.x64.exe"
File /a /r "bin\Livebook-Release\"
CreateDirectory "$INSTDIR\Logs"
WriteUninstaller "$INSTDIR\LivebookUninstall.exe"
SectionEnd
Section "Desktop Shortcut"
CreateShortCut "$DESKTOP\Livebook.lnk" "$INSTDIR\Livebook.exe" ""
SectionEnd
Section "Check"
DetailPrint "Checking Erlang..."
; we use otp\erts-:vsn\bin\erl.exe instead of otp\bin\erl.exe because the latter for some reason
; hardcoded the path: c:\otp\erts-:vsn\bin\erlexec.dll. The Elixir releases uses the former
; anyway.
nsExec::ExecToLog '"$INSTDIR\rel\vendor\otp\erts-${ERTS_VERSION}\bin\erl.exe" -noinput -eval "erlang:display(ok), halt()."'
Pop $0
${If} $0 != 0
MessageBox mb_iconStop "Checking Erlang failed: $0. Please click 'Show details' and report an issue."
Abort
${EndIf}
DetailPrint "Checking Distributed Erlang..."
nsExec::ExecToLog '"$INSTDIR\rel\vendor\otp\erts-${ERTS_VERSION}\bin\erl.exe" -sname "livebook-install-test" -noinput -eval "erlang:display(ok), halt()."'
Pop $0
${If} $0 != 0
MessageBox mb_iconStop "Checking Distributed Erlang failed: $0. Please click 'Show details' and report an issue."
Abort
${EndIf}
SectionEnd
Section "Install Handlers"
DetailPrint "Registering .livemd File Handler"
DeleteRegKey HKCR ".livemd"
WriteRegStr HKCR ".livemd" "" "Livebook.LiveMarkdown"
DeleteRegKey HKCR "Livebook.LiveMarkdown"
WriteRegStr HKCR "Livebook.LiveMarkdown" "" "LiveMarkdown"
WriteRegStr HKCR "Livebook.LiveMarkdown\DefaultIcon" "" "$INSTDIR\Livebook.exe,1"
WriteRegStr HKCR "Livebook.LiveMarkdown\shell\open\command" "" '"$INSTDIR\Livebook.exe" "open:%1"'
DetailPrint "Registering livebook URL Handler"
DeleteRegKey HKCR "livebook"
WriteRegStr HKCR "livebook" "" "Livebook URL Protocol"
WriteRegStr HKCR "livebook" "URL Protocol" ""
WriteRegStr HKCR "livebook\shell" "" ""
WriteRegStr HKCR "livebook\shell\open" "" ""
WriteRegStr HKCR "livebook\shell\open\command" "" '"$INSTDIR\Livebook.exe" "open:%1"'
SectionEnd
Section "Uninstall"
DeleteRegKey HKCR ".livemd"
DeleteRegKey HKCR "Livebook.LiveMarkdown"
DeleteRegKey HKCR "livebook"
DetailPrint "Terminating Livebook..."
ExecWait "taskkill /f /t /im Livebook.exe"
ExecWait "taskkill /f /t /im epmd.exe"
Sleep 1000
Delete "$DESKTOP\Livebook.lnk"
RMDir /r "$INSTDIR"
SectionEnd

118
rel/app/windows/Livebook.cs Normal file
View file

@ -0,0 +1,118 @@
using System;
using System.Drawing;
using System.IO;
using System.Windows.Forms;
#nullable enable
namespace Livebook;
static class LivebookMain
{
[STAThread]
static void Main(string[] args)
{
var prefix = "open:";
var url = "";
if (args.Length == 1 && args[0].StartsWith(prefix))
{
var uri = new System.Uri(args[0].Remove(0, prefix.Length));
url = uri.AbsoluteUri;
}
if (ElixirKit.API.IsMainInstance("dev.livebook.Livebook"))
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new LivebookApp(url));
var code = ElixirKit.API.Stop();
if (code == 0) { return; }
var message = $"Livebook exited with exit code {code}.\r\nLogs available at: {getLogPath()}";
MessageBox.Show(new Form() { TopMost = true }, message, "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
else
{
ElixirKit.API.Publish("open", url);
}
}
internal static string getLogPath()
{
return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Livebook",
"Logs",
"Livebook.log"
);
}
}
class DummyForm : Form {}
class LivebookApp : ApplicationContext
{
private NotifyIcon notifyIcon;
public LivebookApp(string url)
{
ThreadExit += threadExit;
ContextMenuStrip menu = new ContextMenuStrip();
menu.Items.Add("Open", null, openClicked);
menu.Items.Add("Quit", null, quitClicked);
notifyIcon = new NotifyIcon()
{
Text = "Livebook",
Visible = true,
Icon = Icon.ExtractAssociatedIcon(Application.ExecutablePath)!,
ContextMenuStrip = menu
};
notifyIcon.Click += notifyIconClicked;
var logPath = LivebookMain.getLogPath();
Directory.CreateDirectory(Path.GetDirectoryName(logPath)!);
ElixirKit.API.Start(
name: "app",
logPath: logPath,
exited: (exitCode) =>
{
Application.Exit();
}
);
ElixirKit.API.Publish("open", url);
}
private void threadExit(object? sender, EventArgs e)
{
notifyIcon.Visible = false;
}
private void notifyIconClicked(object? sender, EventArgs e)
{
MouseEventArgs mouseEventArgs = (MouseEventArgs)e;
if (mouseEventArgs.Button == MouseButtons.Left)
{
open();
}
}
private void openClicked(object? sender, EventArgs e)
{
open();
}
private void quitClicked(object? sender, EventArgs e)
{
notifyIcon.Visible = false;
Application.Exit();
}
private void open() {
ElixirKit.API.Publish("open", "");
}
}

View file

@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType Condition="'$(Configuration)' == 'Debug'">Exe</OutputType>
<OutputType Condition="'$(Configuration)' == 'Release'">WinExe</OutputType>
<OutDir>bin/Livebook-$(Configuration)</OutDir>
<PublishDir>bin/Livebook-$(Configuration)</PublishDir>
<TargetFramework>net4.6-windows</TargetFramework>
<LangVersion>10.0</LangVersion>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>false</SelfContained>
<UseWindowsForms>true</UseWindowsForms>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
<ApplicationIcon>Resources/AppIcon.ico</ApplicationIcon>
<ApplicationManifest>App.manifest</ApplicationManifest>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../../elixirkit/elixirkit_dotnet/ElixirKit.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,5 @@
# Livebook for Windows
Run the app:
$ ./run.sh

View file

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Some files were not shown because too many files have changed in this diff Show more