Universal launch

The development of cross-platform applications has long become commonplace and no longer causes much excitement, but what about… universal launch?

Imagine an executable file that runs independently on Windows, Linux, FreeBSD and MacOS without modification or reassembly.

Moreover, it is launched in the user sense:

by clicking, by pressing Enter – since a regular program is launched in each specific OS.

Without additional actions with the environment, without calling commands, without any launch parameters – without anything.

All screenshots are running the same application

All screenshots are running the same application

Help for those not involved

Regular programs are compiled for a specific OS, or require a special environment to run and operate, which is most often installed separately.

What I will show below in this article is “Proof of Concept” (PoC), proof of what to do single launch for four completely different and technically incompatible platforms Maybealthough not very simple.

Demonstration video of dragging an application from Windows 10 to MacOS and launching it immediately:

How it works

The starting script is packed together with the main part into one file, the script is at the beginning of the file, and the archive with the application is at the end.

The technology itself is enough famousbut I was able to develop it a little further by implementing a universal boot part.

An application in Java was used as the main part, but it is technically possible to implement similar logic with any other language.

The JAR file into which a regular Java application is packaged is a ZIP archive, the format feature of which is to read from the end of the file. But the starting script is executed from the beginning of the file.

It is this separation that allows you to launch “yourself” without unpacking and changes:

self=`(readlink -f $0)`
java -jar $self 
exit

Windows problem

Microsoft often follows its own special path in its products, which it then imposes on others. Their command processor was no exception.

For a long time I was not sure about the possibility of creating a universal script for both Windows Batch (the same cmd.exe) and bash.

But strangely enough the solution found.

The prototype of the script, which works both in Windows and in bash, looks like this:

rem(){ :;};rem '
@goto b
';echo Starting Demo..;
:b
@echo off
@echo Starting Demo...

It works by crossing syntax from two worlds:

for Windows Batch rem – a line skip function, i.e. everything that starts with the word rem is completely skipped.

But for bash rem() this is defining an empty function and calling it immediately with a multiline:

rem '
@goto b
';

Therefore bash will skip this block.

But the Windows Batch command interpreter will jump to the label:

@goto b

into a block where the full-fledged script code for it alone begins:

:b
@echo off
@echo Starting Demo...

This is how we miraculously get a single launch point for both worlds. And no magic.

Environment Definition

To run an application in Java, you need installed runtime – JRE, which may not be present on the machine at all, or the installed version may be outdated.

Therefore, to complete the picture, a check for the presence of a local installation was added, checking its version and automatically downloading the JRE for Windows and Linux platforms – if nothing worthwhile was found.

For Linux, the type of architecture is also taken into account, but without exotic ones – only 32 or 64 bits. The general operating logic for Linux, FreeBSD and MacOS looks like this:

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

First we look java as a command accessible from the environment.

If not found, we check for the presence of the variable JAVA_HOME in the environment, since this variable usually specifies the full path to the JDK.

Next we check the version of the found 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"
}

The essence of the logic above is to get a single number corresponding to the major version of the found JRE:

8 for Java 1.8, 9 for Java 9, and so on.

The resulting version number is then checked:

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

If the found JRE is too old, we try to download from the network and unpack the required version. But first, let’s check for restart to see if there is already a downloaded version:

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

This is what determining the architecture type and matching part of the file name with the downloaded JRE looks like:

# 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

Please note that a 32bit Linux system will call itself i686and the name of the 32bit JRE will be i586 – this is how it happened historically.

Unfortunately, there are no binary assemblies in the form of a downloadable archive for FreeBSD and MacOS, so I had to do this:

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

The OS type and architecture are then substituted into the full download link:

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"

Where does the JRE come from?

Actually, Oracle does not allow you to download JRE releases automatically, so kind and good people (for tests) posted ready-made binary assemblies of OpenJDK and JRE in the form of Maven dependencies, here here.

This is the now outdated 1.8 version of the JRE, which I took specifically for the widest possible coverage of different environments and environments – somehow the 1.8 branch still remains the most compatible with the realities of operation.

Below I will describe the logic of the script, this is how downloading and unpacking occurs:

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

In theory, you should also separately check the return codes when creating a directory and unpacking – but for PoC I think it would be overkill.

Part of Windows

Now we will analyze in detail the part of the script responsible for launching in Windows, it starts from this place:

:b
@echo off
@echo Starting Demo...
:: self script name
set SELF_SCRIPT=%0

First of all, we save the full path to ourselves %0 into a variable SELF_SCRIPTsince it can be overwritten later.

Next, we determine the path to the unpacked JRE, which will be stored in the current user’s home folder:

:: 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)

If the binary javaw.exe exists – we assume that the JRE has already been downloaded and use it.

Pay attention to the peculiarity of the JRE on Windows, in the form of a separate binary for graphical applications – javaw.exe

If the downloaded JRE is not there, we try to find it in the environment; if found, we try to determine the version:

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
  )
)

We believe that if in the directory with the JRE there is javaw.exe then there is definitely java.exesince both binaries are necessarily present in assemblies for Windows.

And the general logic of the code above coincides with the bash version – get the major digit of the JRE version for subsequent checking:

if %JAVA_VERSION% LSS 8 (goto :DownloadJava) else (goto :RunJava)

If the found JRE is older than 1.8 (1.5, 1.4, and so on), we assume that it is not supported and try to download the required one from the network.

This is what downloading and unpacking the JRE looks like:

: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%

Important points:

  1. Your eyes don't lie to you: curl And tar now it really is included in the standard distribution of Windows 10 and higher, in fact since 2017.

  2. We use one universal 32-bit version of the JRE, without taking into account the architecture, since in the Windows environment there is no compatibility problem and running 32-bit applications on a 64-bit architecture.

The launch code looks like this:

: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

Here we need some clarification about how referential logic works and labels. Team goto makes a transition to the script location marked with the label:

goto :EOF

will go here, bypassing all the rest of the code:

:EOF
exit

And if there is no label, then execution will continue sequentially, so after:

set JRE=%UNPACKED_JRE_DIR%\jre\bin\javaw.exe

will be executed:

echo Using JRE %JAVA_VERSION% from %JRE%

And further until the end.

MacOS and readlink

It turned out that the implementation readlink on MacOS do not support key -f so I had to add my implementation directly to the script:

# 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"
}

This function is used to calculate its own full path, taking into account links and relative parts.

Shell by default

On MacOS starting with Catalina, it is used by default zshin FreeBSD – ksh and on most Linuxes – bash.

The Unix bootloader code in this project is written for bash. To automatically restart a script through bash if the user runs it with a different interpreter, use the following code:

if [ -z "$BASH" ]; then 
echo "0. Current shell is not bash. Trying to re-run with bash.." 
exec bash $0
exit
fi

Test project

The entire project is posted on Github here here.

Keep in mind that the two parts of the shell script – for Windows Batch and bash – have different setting line endings!

This turned out to be mandatory to run on MacOS.

Test application on Swingwhich displays the environment environment when run, is notable for its build cycle – I used BeanShell plugin in order to implement the packaging logic in the form of an inline script in 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>

Any JDK older than version 1.8 is built:

mvn clean package

You can use external Apache Maven, or build from any development environment. Final binary all.cmd will be in the directory target.

Epilogue

Of course, I didn’t discover America – this has been known for a long time and has long been used in practice. Here here there is a huge article with implementation examples in different languages, here — a project for creating cross-platform self-extracting archives for different types of Unix, which uses a similar idea.

However, I have not yet seen a complete assembly into a ready-made solution, with cross-platform “Windows‑Mac‑Linux‑BSD” for one binary.

The practical application of such technology is very possible and makes practical sense, since the need to generate several different assemblies for different OSes disappears.

But of course, much more work will be needed to optimize the starting scripts.

PS

Unfortunately, it is impossible to put a normal icon on such a file without external influence on the environment, so in all operating systems such a universal binary will (at best) have a script icon.

This is a censored version of my article from last year, rephrased original which is available on our blog.

The article was also published on LOREcausing a lively discussion there, after which I slightly changed the terminology used in the article.

0x08 Software

We are a small team of IT industry veterans, we create and develop a wide variety of software; our software automates business processes on three continents, in a wide variety of industries and conditions.

Bringing it to life long dead, fixing something that never worked and create impossible — then we talk about it in our articles.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *