Разработка кроссплатформенных приложений давно стала обыденностью и уже не вызывает особых восторгов, но как насчет.. универсального запуска?
Представьте себе исполняемый файл, который без изменений и пересборки самостоятельно запускается в ОС Windows, Linux, FreeBSD и MacOS.
Причем запускается в пользовательском смысле:
по клику, по нажатию Enter — так как запускается обычная программа в каждой конкретной ОС.
Без дополнительных действий с окружением, без вызова команд, без каких‑то параметров запуска — без всего.
![На всех скриншотах запущено одно и то же приложение На всех скриншотах запущено одно и то же приложение](https://habrastorage.org/getpro/habr/upload_files/656/3d6/a85/6563d6a85c410775dd4ff76e9b11a52d.jpg)
Справка для непричастных
Обычные программы компилируются под какую‑то конкретную ОС, либо требуют специальное окружение для запуска и работы, которое чаще всего устанавливается отдельно.
То что я покажу ниже в этой статье — «Proof of Concept» (PoC), доказательство что сделать единый запуск под четыре абсолютно разные и несовместимые платформы технически возможно, хотя и не очень просто.
Демонстрационное видео с перетаскиванием приложения с Windows 10 в MacOS и немедленным запуском:
Как это работает
Стартовый скрипт упаковывается вместе с основной частью в один файл, cкрипт — в начало файла, архив с приложением — в конце.
Сама по себе технология достаточно известная, но у меня получилось развить ее несколько дальше, реализовав универсальную загрузочную часть.
В качестве основной части было использовано приложение на Java, но технически возможно реализовать похожую логику и с любым другим языком.
JAR‑файл, в который упаковывается обычное Java‑приложение является ZIP‑архивом, особенностью формата которого является чтение с конца файла. А вот стартовый скрипт выполняется с начала файла.
Именно такое разделение и позволяет сделать запуск «себя самого» без распаковки и изменений:
self=`(readlink -f $0)`
java -jar $self
exit
Проблема Windows
Microsoft в своих продуктах часто идет своим особым путем, который затем навязывает окружающим. Их командный процессор не стал исключением.
Долгое время я не был уверен в возможности создания универсального скрипта как для Windows Batch (тот самый cmd.exe) так и для bash.
Но как ни странно решение нашлось.
Прототип скрипта, который отрабатывает и в Windows и в bash выглядит вот так:
rem(){ :;};rem '
@goto b
';echo Starting Demo..;
:b
@echo off
@echo Starting Demo...
Работает это за счет пересечения синтаксиса из двух миров:
для Windows Batch rem — функция пропуска строки, те все что начинается со слова rem им полностью пропускается.
А вот для bash rem() это определение пустой функции и ее немедленный вызов с мультистрокой:
rem '
@goto b
';
Поэтому bash этот блок пропустит.
Зато командный интерпретатор Windows Batch сделает переход по метке:
@goto b
в блок, где начинается уже полноценный код скрипта для него одного:
:b
@echo off
@echo Starting Demo...
Вот таким чудным образом получается единая точка запуска для обоих миров. И никакой магии.
Определение окружения
Чтобы запустить приложение на Java нужен установленный рантайм — JRE, которого на машине может и не быть совсем, либо установленная версия может оказаться устаревшей.
Поэтому для полноты картины, была добавлена проверка наличия локальной установки, сверка ее версии и автоматическое скачивание JRE для Windows и Linux платформ — если ничего путного не нашлось.
Для Linux учитывается еще и тип архитектуры, но без экзотики — только 32 или 64бита. Общая логика работы для Linux, FreeBSD и MacOS выглядит так:
echo "1. Searching for Java JRE.."
if type -p java; then
echo "1.1 Found Java executable in PATH"
_JRE=java
elif [[ -n $JAVA_HOME ]] && [[ -x "$JAVA_HOME/bin/java" ]]; then
echo "1.2 Found Java executable in JAVA_HOME"
_JRE="$JAVA_HOME/bin/java"
else
echo "1.3 no JRE found"
fi
v="$(jdk_version)"
echo "2. Detected Java version: $v"
if [[ $v -lt 8 ]]
then
echo "2.1 Found unsupported version: $v"
try_download_java
echo "2.2 Using JRE: $_JRE"
fi
self=`(readlink -f $0)`
$_JRE -jar $self
exit
Сначала мы ищем java
в виде команды, доступной из окружения.
Если не нашли — проверяем наличие переменной JAVA_HOME
в окружении, поскольку в этой переменной обычно указывается полный путь до JDK.
Дальше проверяем версию найденного JRE:
# returns the JDK version.
# 8 for 1.8.0_nn, 9 for 9-ea etc, and "no_java" for undetected
jdk_version() {
local result
local java_cmd
if [[ -n $(type -p java) ]]
then
java_cmd=java
elif [[ (-n "$JAVA_HOME") && (-x "$JAVA_HOME/bin/java") ]]
then
java_cmd="$JAVA_HOME/bin/java"
fi
local IFS=#x27;\n'
# remove \r for Cygwin
local lines=$("$java_cmd" -Xms32M -Xmx32M -version 2>&1 | tr '\r' '\n')
if [[ -z $java_cmd ]]
then
result=no_java
else
for line in $lines; do
if [[ (-z $result) && ($line = *"version \""*) ]]
then
local ver=$(echo $line | sed -e 's/.*version "\(.*\)"\(.*\)/\1/; 1q')
# on macOS, sed doesn't support '?'
if [[ $ver = "1."* ]]
then
result=$(echo $ver | sed -e 's/1\.\([0-9]*\)\(.*\)/\1/; 1q')
else
result=$(echo $ver | sed -e 's/\([0-9]*\)\(.*\)/\1/; 1q')
fi
fi
done
fi
echo "$result"
}
Суть логики выше в том чтобы получить одно число, соответствующее мажорной версии найденной JRE:
8 — для Java 1.8, 9 — для Java 9 и так далее.
Затем проверяется полученное число версии:
v="$(jdk_version)"
echo "2. Detected Java version: $v"
if [[ $v -lt 8 ]]
then
echo "2.1 Found unsupported version: $v"
try_download_java
echo "2.2 Using JRE: $_JRE"
fi
Если найденная JRE слишком старая — пытаемся скачать из сети и распаковать нужную версию. Но прежде проверяем на повторный запуск — есть ли уже скачанная версия:
UNPACKED_JRE=~/.jre/jre
if [[ -f "$UNPACKED_JRE/bin/java" ]]; then
echo "3.1 Found unpacked JRE"
_JRE="$UNPACKED_JRE/bin/java"
return 0
fi
Вот так выглядит определение типа архитектуры и сопоставление части имени файла со скачиваемым JRE:
# Detect the platform (similar to $OSTYPE)
OS="`uname`"
ARCH="`uname -m`"
# select correct path segments based on CPU architecture and OS
case $ARCH in
'x86_64')
ARCH='x64'
;;
'i686')
ARCH='i586'
;;
*)
exit_error "Unsupported for automatic download"
;;
esac
Обратите внимание что 32битная система с Linux будет называть себя
i686
, а в названии 32битной JRE будетi586
— так сложилось исторически.
К сожалению бинарных сборок в виде скачиваемого архива для FreeBSD и MacOS нет, поэтому пришлось сделать вот так:
case $OS incase $OS in
'Linux')
OS='linux'
;;
*)
exit_error "Unsupported for automatic download"
;;
esac
'Linux')
OS='linux'
;;
*)
exit_error "Unsupported for automatic download"
;;
esac
Тип ОС и архитектуры затем подставляются в полную ссылку для скачивания:
echo "3.2 Downloading for OS: $OS and arch: $ARCH"
URL="https://../../jvm/com/oracle/jre/1.8.121/jre-1.8.121-$OS-$ARCH.zip"
echo "Full url: $URL"
Откуда берется JRE
Вообще‑то компания Oracle не дает скачивать релизы JRE в автоматическом режиме, поэтому добрые и хорошие люди (для тестов) выложили готовые бинарные сборки OpenJDK и JRE в виде зависимостей Maven, вот тут.
Это ныне устаревшая 1.8 версия JRE, которую я взял специально для максимально широкого покрытия разных сред и окружений — каким‑то образом ветка 1.8 до сих пор остается самой совместимой с реалиями эксплуатации.
Ниже опишу логику работы скрипта, вот так происходит скачивание и распаковка:
echo "Full url: $URL"
CODE=$(curl -L -w '%{http_code}' -o /tmp/jre.zip -C - $URL)
if [[ "$CODE" =~ ^2 ]]; then
# Server returned 2xx response
mkdir -p ~/.jre
unzip /tmp/jre.zip -d ~/.jre/
_JRE="$UNPACKED_JRE/bin/java"
return 0
elif [[ "$CODE" = 404 ]]; then
exit_error "3.3 Unable to download JRE from $URL"
else
exit_error "3.4 ERROR: server returned HTTP code $CODE"
fi
По идее нужно еще отдельно проверять возвращаемые коды при создании каталога и распаковке — но для PoC думаю будет перебор.
Часть Windows
Теперь детально разберем часть скрипта, отвечающего за запуск в Windows, начинается она с этого места:
:b
@echo off
@echo Starting Demo...
:: self script name
set SELF_SCRIPT=%0
Первым делом сохраняем полный путь до себя самого %0
в переменную SELF_SCRIPT
, поскольку дальше он может быть перезаписан.
Дальше определяем путь до распакованной JRE, которая будет храниться в домашней папке текущего пользователя:
:: path to unpacked JRE
set UNPACKED_JRE_DIR=%UserProfile%\.jre
:: path to unpacked JRE binary
set UNPACKED_JRE=%UNPACKED_JRE_DIR%\jre\bin\javaw.exe
IF exist %UNPACKED_JRE% (goto :RunJavaUnpacked)
Если бинарник javaw.exe
существует — считаем что JRE уже был скачан и используем его.
Обратите внимание на особенность работы JRE в Windows, в виде отдельного бинарника для графических приложений — javaw.exe
Если скачанного JRE нет — пытаемся найти в окружении, если нашли — пытаемся определить версию:
where javaw 2>NUL
if "%ERRORLEVEL%"=="0" (call :JavaFound) else (call :NoJava)
goto :EOF
:JavaFound
set JRE=javaw
echo Java found in PATH, checking version..
set JAVA_VERSION=0
for /f "tokens=3" %%g in ('java -version 2^>^&1 ^| findstr /i "version"') do (
set JAVA_VERSION=%%g
)
set JAVA_VERSION=%JAVA_VERSION:"=%
for /f "delims=.-_ tokens=1-2" %%v in ("%JAVA_VERSION%") do (
if /I "%%v" EQU "1" (
set JAVA_VERSION=%%w
) else (
set JAVA_VERSION=%%v
)
)
Считаем, что если в каталоге с JRE есть javaw.exe
то обязательно есть и java.exe
, поскольку в сборках для Windows оба бинарника обязательно присутствуют.
А общая логика кода выше совпадает с версией для bash — получить мажорную цифру версии JRE для последующей проверки:
if %JAVA_VERSION% LSS 8 (goto :DownloadJava) else (goto :RunJava)
Если найденная JRE старше 1.8 (1.5, 1.4 и так далее) — считаем что она не поддерживается и пытаемся скачать нужную из сети.
Вот так выглядит скачивание и распаковка JRE:
:DownloadJava
echo JRE not found in PATH, trying to download..
WHERE curl
IF %ERRORLEVEL% NEQ 0 (call :ExitError "curl wasn't found in PATH, cannot download JRE")
WHERE tar
IF %ERRORLEVEL% NEQ 0 (call :ExitError "tar wasn't found in PATH, cannot download JRE")
curl.exe -o %TEMP%\jre.zip -C - https://nexus.nuiton.org/nexus/content/repositories/jvm/com/oracle/jre/1.8.121/jre-1.8.121-windows-i586.zip
IF not exist %UNPACKED_JRE_DIR% (mkdir %UNPACKED_JRE_DIR%)
tar -xf %TEMP%\jre.zip -C %UNPACKED_JRE_DIR%
Важные моменты:
Глаза вам не врут: curl и tar теперь действительно есть в стандартной поставке Windows 10 и выше, на самом деле аж с 2017 года.
Используем одну универсальную 32х битную версию JRE, без учета архитектуры, поскольку в Windows-окружении нет проблемы совместимости и запуска 32х-битных приложений на 64-битной архитектуре.
Код запуска выглядит вот так:
:RunJavaUnpacked
set JRE=%UNPACKED_JRE_DIR%\jre\bin\javaw.exe
:RunJava
echo Using JRE %JAVA_VERSION% from %JRE%
start %JRE% -jar %SELF_SCRIPT%
goto :EOF
:ExitError
echo Found Error: %0
pause
:EOF
exit
Тут необходимо пояснение о работе ссылочной логики и метках. Команда goto
делает переход в место скрипта отмеченное меткой:
goto :EOF
перейдет вот сюда, минуя весь остальной код:
:EOF
exit
А если метки нет то выполнение продолжится последовательно, поэтому после:
set JRE=%UNPACKED_JRE_DIR%\jre\bin\javaw.exe
выполнится:
echo Using JRE %JAVA_VERSION% from %JRE%
И дальше до конца.
MacOS и readlink
Оказалось что реализация readlink на в MacOS не поддерживает ключ -f
, поэтому пришлось добавлять свою реализацию прямо в скрипт:
# Return the canonicalized path, wworks on OS-X like 'readlink -f' on Linux
function get_realpath {
[ "." = "${1}" ] && n=${PWD} || n=${1}; while nn=$( readlink -n "$n" ); do n=$nn; done; echo "$n"
}
Эта функция используется для вычисления собственного полного пути, с учетом ссылок и относительных частей.
Шелл по-умолчанию
В MacOS начиная с Catalina по‑умолчанию используется zsh, в FreeBSD — ksh а в большинстве линуксов — bash.
Код загрузчика для юниксов в этом проекте написан для bash. Чтобы автоматически перезапустить скрипт через bash, если пользователь запускает другим интерпретатором, используется вот такой код:
if [ -z "$BASH" ]; then
echo "0. Current shell is not bash. Trying to re-run with bash.."
exec bash $0
exit
fi
Тестовый проект
Весь проект выложен на Github вот тут.
Имейте ввиду что две части шелл‑скрипта — для Windows Batch и bash имеют разную настройку окончания строк!
Это оказалось обязательным для запуска на MacOS.
Тестовое приложение на Swing, которое при запуске отображает окружение среды, примечательно циклом сборки — я использовал BeanShell plugin, для того чтобы реализовать логику упаковки в виде inline‑скрипта на Java:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.Ox08.experiments</groupId>
<artifactId>full-cross</artifactId>
<version>1.0-RELEASE</version>
<name>0x08 Experiments: Full Cross Application</name>
<packaging>jar</packaging>
<url>https://teletype.in/@alex0x08</url>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<exec.mainClass>com.ox08.demos.fullcross.FullCross</exec.mainClass>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<archive>
<manifest>
<mainClass>com.ox08.demos.fullcross.FullCross</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
<plugin>
<groupId>com.github.genthaler</groupId>
<artifactId>beanshell-maven-plugin</artifactId>
<version>1.4</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>run</goal>
</goals>
</execution>
</executions>
<configuration>
<quiet>true</quiet>
<script>
<![CDATA[
import java.io.*;
// function should be defined before actual call
// this just appends source binary to target
void copy(File src,OutputStream fout) {
FileInputStream fin = null;
try {
fin =new FileInputStream(src);
byte[] b = new byte[1024];
int noOfBytes = 0;
while( (noOfBytes = fin.read(b)) != -1 )
{ fout.write(b, 0, noOfBytes); }
} catch (Exception e) {
e.printStackTrace();
} finally {
fout.flush();
if (fin!=null) { fin.close(); }
}
}
// current project folder
String projectDir = System.getProperty("maven.multiModuleProjectDirectory");
// target combined binary
File target = new File(projectDir+"/target/all.cmd");
if (target.exists()) {
target.delete();
}
// shell bootloader
File fboot = new File(projectDir+"/src/main/cmd/boot.cmd");
// jar file with application
File fjar = new File(projectDir+"/target/full-cross-1.0-RELEASE.jar");
// open write stream to target combined binary
FileOutputStream fout = new FileOutputStream(target);
// write bootloader
copy(fboot,fout);
// write jar
copy(fjar,fout);
fout.close();
target.setExecutable(true);
]]>
</script>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-install-plugin</artifactId>
<version>3.1.1</version>
<configuration>
<skip>true</skip>
</configuration>
</plugin>
</plugins>
</build>
</project>
Собирается любой JDK cтарше 1.8 версии:
mvn clean package
Можно использовать внешний Apache Maven, либо собрать из любой среды разработки. Итоговый бинарник all.cmd
будет находиться в каталоге target.
Эпилог
Америку я разумеется не открыл — такое известно давно и давно используется на практике. Вот тут находится огромная статья с примерами реализации на разных языках, тут — проект для создания кросс‑платформенных самораспаковывающихся архивов под разные типы Unix, в котором используется похожая идея.
Тем не менее, полную сборку в готовое решение, с кроссплатформенностью «Windows‑Mac‑Linux‑BSD» для одного бинарника я еще не видел.
Практическое применение такой технологии очень даже возможно и имеет практический смысл, поскольку исчезает необходимость генерации нескольких разных сборок под разные ОС.
Но разумеется понадобится куда больше работы по оптимизации стартовых скриптов.
P.S.
Нормальную иконку на такой файл к сожалению не поставить без внешнего воздействия на среду, поэтому во всех ОС такой универсальный бинарник будет (в лучшем случае) иметь иконку скрипта.
Это отцезурированная версия моей прошлогодней статьи, отбитый оригинал которой доступен в нашем блоге.
Также статья публиковалась на ЛОРе, вызвав там живейшее обсуждение, после которого я немного поменял используемую в статье терминологию.
0x08 Software
Мы небольшая команда ветеранов ИТ‑индустрии, создаем и дорабатываем самое разнообразное программное обеспечение, наш софт автоматизирует бизнес‑процессы на трех континентах, в самых разных отраслях и условиях.
Оживляем давно умершее, чиним никогда не работавшее и создаем невозможное — затем рассказываем об этом в своих статьях.