Разработка кроссплатформенных приложений давно стала обыденностью и уже не вызывает особых восторгов, но как насчет.. универсального запуска?

Представьте себе исполняемый файл, который без изменений и пересборки самостоятельно запускается в ОС Windows, Linux, FreeBSD и MacOS.

Причем запускается в пользовательском смысле:

по клику, по нажатию Enter — так как запускается обычная программа в каждой конкретной ОС.

Без дополнительных действий с окружением, без вызова команд, без каких‑то параметров запуска — без всего.

На всех скриншотах запущено одно и то же приложение

Справка для непричастных

Обычные программы компилируются под какую‑то конкретную ОС, либо требуют специальное окружение для запуска и работы, которое чаще всего устанавливается отдельно.

То что я покажу ниже в этой статье — «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%

Важные моменты:

  1. Глаза вам не врут: curl и tar теперь действительно есть в стандартной поставке Windows 10 и выше, на самом деле аж с 2017 года.

  2. Используем одну универсальную 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

Мы небольшая команда ветеранов ИТ‑индустрии, создаем и дорабатываем самое разнообразное программное обеспечение, наш софт автоматизирует бизнес‑процессы на трех континентах, в самых разных отраслях и условиях.

Оживляем давно умершеечиним никогда не работавшее и создаем невозможное — затем рассказываем об этом в своих статьях.