Files
banderole/src/executable.rs
T

312 lines
8.7 KiB
Rust

use crate::platform::Platform;
use anyhow::{Context, Result};
use base64::Engine as _;
use std::fs;
use std::io::Write;
use std::path::Path;
use uuid::Uuid;
/// Create a self-extracting executable for the current platform
pub fn create_self_extracting_executable(
out: &Path,
zip_data: Vec<u8>,
_app_name: &str,
) -> Result<()> {
let build_id = Uuid::new_v4();
if Platform::current().is_windows() {
create_windows_executable(out, zip_data, &build_id.to_string())
} else {
create_unix_executable(out, zip_data, &build_id.to_string())
}
}
/// Create a Unix-compatible self-extracting executable
fn create_unix_executable(out: &Path, zip_data: Vec<u8>, build_id: &str) -> Result<()> {
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
let mut file = fs::File::create(out).context("Failed to create output executable")?;
let script = format!(
r#"#!/bin/bash
set -e
if [ -n "$XDG_CACHE_HOME" ]; then
CACHE_DIR="$XDG_CACHE_HOME/banderole"
elif [ -n "$HOME" ]; then
CACHE_DIR="$HOME/.cache/banderole"
else
CACHE_DIR="/tmp/banderole-cache"
fi
APP_DIR="$CACHE_DIR/{build_id}"
READY_FILE="$APP_DIR/.ready"
run_app() {{
cd "$APP_DIR/app" || exit 1
# Find main script from package.json
MAIN_SCRIPT=$("$APP_DIR/node/bin/node" -e "try {{ console.log(require('./package.json').main || 'index.js'); }} catch(e) {{ console.log('index.js'); }}" 2>/dev/null || echo "index.js")
if [ -f "$MAIN_SCRIPT" ]; then
exec "$APP_DIR/node/bin/node" "$MAIN_SCRIPT" "$@"
else
echo "Error: Main script '$MAIN_SCRIPT' not found" >&2
exit 1
fi
}}
if [ -f "$READY_FILE" ] && [ -f "$APP_DIR/app/package.json" ] && [ -x "$APP_DIR/node/bin/node" ]; then
run_app "$@"
fi
mkdir -p "$CACHE_DIR" 2>/dev/null || true
ATTEMPTS=0
MAX_ATTEMPTS=30
while [ $ATTEMPTS -lt $MAX_ATTEMPTS ]; do
if mkdir "$APP_DIR" 2>/dev/null; then
TEMP_ZIP=$(mktemp) || exit 1
trap 'rm -f "$TEMP_ZIP"' EXIT
awk '/^__DATA__$/{{p=1;next}} p{{print}}' "$0" | base64 -d > "$TEMP_ZIP"
if [ ! -s "$TEMP_ZIP" ]; then
echo "Error: Failed to extract bundle data" >&2
rm -rf "$APP_DIR" 2>/dev/null || true
exit 1
fi
if ! unzip -q "$TEMP_ZIP" -d "$APP_DIR" 2>/dev/null; then
echo "Error: Failed to extract bundle" >&2
rm -rf "$APP_DIR" 2>/dev/null || true
exit 1
fi
if [ ! -f "$APP_DIR/app/package.json" ] || [ ! -x "$APP_DIR/node/bin/node" ]; then
echo "Error: Bundle extraction incomplete" >&2
rm -rf "$APP_DIR" 2>/dev/null || true
exit 1
fi
touch "$READY_FILE"
run_app "$@"
else
if [ -f "$READY_FILE" ] && [ -f "$APP_DIR/app/package.json" ] && [ -x "$APP_DIR/node/bin/node" ]; then
run_app "$@"
fi
ATTEMPTS=$((ATTEMPTS + 1))
if [ $ATTEMPTS -lt $MAX_ATTEMPTS ]; then
DELAY=$(awk "BEGIN {{print int(100 * (2 ^ (($ATTEMPTS - 1) / 3)) + 0.5)}}" 2>/dev/null || echo 100)
if [ "$DELAY" -gt 1000 ]; then
DELAY=1000
fi
sleep $(awk "BEGIN {{print $DELAY / 1000}}" 2>/dev/null || echo 0.1)
fi
fi
done
# If we've exhausted retries, exit with error
echo "Error: Failed to extract or run application after $MAX_ATTEMPTS attempts" >&2
exit 1
__DATA__
"#
);
file.write_all(script.as_bytes())?;
let encoded = base64::engine::general_purpose::STANDARD.encode(&zip_data);
file.write_all(encoded.as_bytes())?;
file.write_all(b"\n")?;
#[cfg(unix)]
{
let mut perms = file.metadata()?.permissions();
perms.set_mode(0o755);
fs::set_permissions(out, perms)?;
}
Ok(())
}
/// Create a Windows-compatible self-extracting executable
fn create_windows_executable(out: &Path, zip_data: Vec<u8>, build_id: &str) -> Result<()> {
let bat_path = out.with_extension("bat");
let mut file = fs::File::create(&bat_path).context("Failed to create output batch file")?;
let script = format!(
r#"@echo off
setlocal enabledelayedexpansion
set "CACHE_DIR=%LOCALAPPDATA%\banderole"
set "APP_DIR=!CACHE_DIR!\{build_id}"
set "LOCK_FILE=!APP_DIR!\.lock"
set "READY_FILE=!APP_DIR!\.ready"
set "QUEUE_DIR=!APP_DIR!\.queue"
set "EXTRACTION_PID_FILE=!APP_DIR!\.extraction.pid"
for /f "tokens=2 delims==" %%i in ('wmic process where "processid=%PID%" get processid /value 2^>nul ^| find "ProcessId"') do set "CURRENT_PID=%%i"
if "!CURRENT_PID!"=="" set "CURRENT_PID=%RANDOM%"
:run_app
cd /d "!APP_DIR!\app" || exit /b 1
for /f "delims=" %%i in ('"!APP_DIR!\node\node.exe" -e "try {{ console.log(require('./package.json').main ^|^| 'index.js'); }} catch(e) {{ console.log('index.js'); }}" 2^>nul') do set "MAIN_SCRIPT=%%i"
if "!MAIN_SCRIPT!"=="" set "MAIN_SCRIPT=index.js"
if exist "!MAIN_SCRIPT!" (
"!APP_DIR!\node\node.exe" "!MAIN_SCRIPT!" %*
exit /b !errorlevel!
) else (
echo Error: Main script '!MAIN_SCRIPT!' not found >&2
exit /b 1
)
:cleanup_queue
if defined QUEUE_ENTRY if exist "!QUEUE_ENTRY!" (
del "!QUEUE_ENTRY!" 2>nul
)
exit /b
if exist "!READY_FILE!" if exist "!APP_DIR!\app\package.json" if exist "!APP_DIR!\node\node.exe" (
goto run_app
)
if not exist "!QUEUE_DIR!" mkdir "!QUEUE_DIR!" 2>nul
set "QUEUE_ENTRY=!QUEUE_DIR!\!CURRENT_PID!-%RANDOM%-%TIME:~6,5%.queue"
echo !CURRENT_PID! > "!QUEUE_ENTRY!"
set /a WAIT_COUNT=0
set /a MAX_WAIT=120
:acquire_lock
call :cleanup_stale_locks
if not exist "!LOCK_FILE!" (
mkdir "!LOCK_FILE!" 2>nul
if !errorlevel! equ 0 (
echo !CURRENT_PID! > "!EXTRACTION_PID_FILE!"
goto extract
)
)
:wait_for_ready
if exist "!READY_FILE!" (
if exist "!APP_DIR!\app\package.json" if exist "!APP_DIR!\node\node.exe" (
call :cleanup_queue
goto run_app
)
)
set /a WAIT_COUNT+=1
if !WAIT_COUNT! geq !MAX_WAIT! (
echo Error: Timeout waiting for extraction to complete >&2
call :cleanup_queue
exit /b 1
)
if exist "!EXTRACTION_PID_FILE!" (
timeout /t 1 /nobreak >nul 2>&1
goto wait_for_ready
) else (
timeout /t 1 /nobreak >nul 2>&1
goto acquire_lock
)
:extract
if exist "!READY_FILE!" if exist "!APP_DIR!\app\package.json" if exist "!APP_DIR!\node\node.exe" (
call :cleanup_queue
rmdir "!LOCK_FILE!" 2>nul
del "!EXTRACTION_PID_FILE!" 2>nul
goto run_app
)
if not exist "!CACHE_DIR!" mkdir "!CACHE_DIR!"
if not exist "!APP_DIR!" mkdir "!APP_DIR!"
set "TEMP_ZIP=%TEMP%\banderole-bundle-!CURRENT_PID!-%RANDOM%.zip"
powershell -NoProfile -Command "$content = Get-Content '%~f0' -Raw; $dataStart = $content.IndexOf('__DATA__') + 8; $data = $content.Substring($dataStart).Trim(); [System.IO.File]::WriteAllBytes('!TEMP_ZIP!', [System.Convert]::FromBase64String($data))"
if not exist "!TEMP_ZIP!" (
echo Error: Failed to extract bundle data >&2
call :cleanup_queue
rmdir "!LOCK_FILE!" 2>nul
del "!EXTRACTION_PID_FILE!" 2>nul
exit /b 1
)
powershell -NoProfile -Command "try {{ Expand-Archive -Path '!TEMP_ZIP!' -DestinationPath '!APP_DIR!' -Force }} catch {{ Write-Error $_.Exception.Message; exit 1 }}"
set "EXTRACT_RESULT=!errorlevel!"
del "!TEMP_ZIP!" 2>nul
if !EXTRACT_RESULT! neq 0 (
echo Error: Failed to extract bundle >&2
call :cleanup_queue
rmdir /s /q "!APP_DIR!" 2>nul
rmdir "!LOCK_FILE!" 2>nul
del "!EXTRACTION_PID_FILE!" 2>nul
exit /b 1
)
if not exist "!APP_DIR!\app\package.json" (
echo Error: Bundle extraction incomplete >&2
call :cleanup_queue
rmdir /s /q "!APP_DIR!" 2>nul
rmdir "!LOCK_FILE!" 2>nul
del "!EXTRACTION_PID_FILE!" 2>nul
exit /b 1
)
if not exist "!APP_DIR!\node\node.exe" (
echo Error: Node.js executable not found >&2
call :cleanup_queue
rmdir /s /q "!APP_DIR!" 2>nul
rmdir "!LOCK_FILE!" 2>nul
del "!EXTRACTION_PID_FILE!" 2>nul
exit /b 1
)
echo ready > "!READY_FILE!"
if exist "!QUEUE_DIR!" (
for %%f in ("!QUEUE_DIR!\*.queue") do (
if exist "%%f" del "%%f" 2>nul
)
)
del "!EXTRACTION_PID_FILE!" 2>nul
call :cleanup_queue
rmdir "!LOCK_FILE!" 2>nul
goto run_app
:cleanup_stale_locks
if exist "!LOCK_FILE!" (
for /f %%i in ('powershell -NoProfile -Command "if (Test-Path '!LOCK_FILE!') {{ $age = (Get-Date) - (Get-Item '!LOCK_FILE!').CreationTime; if ($age.TotalSeconds -gt 300) {{ Write-Output 'stale' }} }}"') do (
if "%%i"=="stale" (
rmdir "!LOCK_FILE!" 2>nul
del "!EXTRACTION_PID_FILE!" 2>nul
)
)
)
exit /b
__DATA__
"#
);
file.write_all(script.as_bytes())?;
// Append base64-encoded zip data
let encoded = base64::engine::general_purpose::STANDARD.encode(&zip_data);
file.write_all(encoded.as_bytes())?;
Ok(())
}