Vollständig portables Linux-ELF bauen
Some checks failed
Tests / test (push) Has been cancelled

This commit is contained in:
2026-10-15 11:23:21 +02:00
parent e799682e10
commit bf34593bbb
10 changed files with 581 additions and 166 deletions

View File

@@ -12,7 +12,7 @@ jobs:
- name: Systempakete installieren
run: |
sudo apt-get update
sudo apt-get install -y pkg-config libx11-dev libxkbcommon-dev libwayland-dev libgl1-mesa-dev
sudo apt-get install -y pkg-config libx11-dev libxkbcommon-dev libwayland-dev libgl1-mesa-dev network-manager curl
- name: Rust installieren
uses: dtolnay/rust-toolchain@stable
with:
@@ -21,10 +21,12 @@ jobs:
run: |
bash -n scripts/*.sh tests/*.sh
bash scripts/build-release.sh
./dist/csr-intake-x86_64.run --check-bundle
./dist/csr-intake-x86_64 --check-bundle
file dist/csr-intake-x86_64 | grep -q 'static-pie linked'
env -i HOME="$RUNNER_TEMP" XDG_CACHE_HOME="$RUNNER_TEMP/cache" PATH=/nonexistent ./dist/csr-intake-x86_64 --check-bundle
- name: Eigenständige Anwendung bereitstellen
uses: actions/upload-artifact@v4
with:
name: csr-intake-x86_64
path: dist/csr-intake-x86_64.run
path: dist/csr-intake-x86_64
if-no-files-found: error

View File

@@ -7,7 +7,7 @@ auch ohne Netzwerk lokal.
## Ablauf in der Werkstatt
1. `csr-intake-x86_64.run` auf dem zu prüfenden Linux-Rechner starten.
1. `csr-intake-x86_64` auf dem zu prüfenden Linux-Rechner starten.
2. Beim ersten Einsatz unter **Einstellungen** das Werkstatt-WLAN verbinden und
**WLAN speichern** aktiviert lassen.
3. Das Gerät wird über DMI-UUID oder Hersteller und Seriennummer erkannt.
@@ -39,27 +39,32 @@ Reparaturen, Berichte und die Synchronisationswarteschlange sind über das Menü
## Anwendung starten
Die Release-Datei enthält Oberfläche und Hardware-Scanner. Sie entpackt geprüfte
Programmdateien in den Benutzer-Cache und speichert Einstellungen, Berichte und die
Offline-Warteschlange standardmäßig unter `~/.local/share/csr-intake`.
Die Release-Datei ist ein statisch gelinktes Linux-ELF. Sie enthält Oberfläche,
Hardware-Scanner, Python-Laufzeit, TLS, SQLite und alle unmittelbar benötigten
Laufzeitbibliotheken. Sie benötigt weder eine installierte Python-Version noch
Shell-, Archiv- oder glibc-Werkzeuge. Geprüfte Programmdateien werden in den
Benutzer-Cache entpackt. Einstellungen, Berichte und die Offline-Warteschlange
liegen standardmäßig unter `~/.local/share/csr-intake`.
```bash
chmod +x csr-intake-x86_64.run
./csr-intake-x86_64.run
chmod +x csr-intake-x86_64
./csr-intake-x86_64
```
Ein anderer Datenordner kann mit `--data /pfad/zum/ordner` gewählt werden:
```bash
./csr-intake-x86_64.run --data "$HOME/csr-intake-daten"
./csr-intake-x86_64.run --version
./csr-intake-x86_64.run --check-bundle
./csr-intake-x86_64 --data "$HOME/csr-intake-daten"
./csr-intake-x86_64 --version
./csr-intake-x86_64 --check-bundle
```
Die Anwendung ist für x86-64 Linux gebaut. Sie benötigt eine grafische Sitzung,
Python 3, SQLite und NetworkManager. Einzelne Diagnosen verwenden Systemwerkzeuge
wie `smartctl`, `stress-ng`, `ffmpeg`, `arecord`, `lsusb` und `xdg-open`. Fehlt ein
Werkzeug, wird der Test als **nicht verfügbar** und nicht als Hardwarefehler bewertet.
Die Anwendung ist für x86-64 Linux ab Kernel 4.4 gebaut. Clientprogramme für WLAN,
HTTP, Audio, Video, SMART und USB werden in den Build übernommen, sofern sie auf dem
Build-Rechner verfügbar sind. Benötigt werden nur eine grafische Linux-Sitzung, der
Kernel und die Systemdienste für die tatsächlich verwendete Hardware, insbesondere
der NetworkManager-Dienst für WLAN. Ein im Build nicht vorhandenes Diagnosewerkzeug
macht nur den betreffenden Test **nicht verfügbar**.
## Konfiguration
@@ -85,13 +90,13 @@ aus und erzeugt anschließend genau ein verteilbares Artefakt:
```bash
bash scripts/build-release.sh
./dist/csr-intake-x86_64.run --check-bundle
./dist/csr-intake-x86_64 --check-bundle
```
Das Ergebnis liegt in `dist/csr-intake-x86_64.run`. `build/release.json` enthält
Das Ergebnis liegt in `dist/csr-intake-x86_64`. `build/release.json` enthält
Build-ID, Versionen und SHA-256-Prüfsummen der eingebetteten Programme. Die CI stellt
bei jedem erfolgreichen Build eine neutrale `.run`-Datei ohne Zugangsdaten als
Artefakt bereit.
bei jedem erfolgreichen Build eine neutrale ELF-Datei ohne Zugangsdaten als Artefakt
bereit.
Für eine werkstattgebundene Datei können `inventory-api-key` und
`inventory-server.crt` vor dem Build in `.bundle-secrets/` abgelegt werden. Der Build
@@ -106,6 +111,7 @@ einen lesbaren Zugangsschlüssel und darf nur wie ein Passwort weitergegeben wer
| --- | --- |
| `gui/` | Bedienoberfläche und geführte Tests |
| `scanner/` | Hardwareerkennung und Messwerte |
| `launcher/` | Statischer ELF-Starter und eingebettete Laufzeit |
| `scripts/` | Build, Synchronisation und lokale Datenwerkzeuge |
| `tests/` | Python- und Rauchtests |
| `config.example/` | Beispiel für die Inventarverbindung |

View File

@@ -1,6 +1,16 @@
use serde::Deserialize;
use std::{path::Path, process::Command};
const BRIDGE: &str = include_str!("../../scripts/inventory-sync.py");
fn python() -> Command {
if let Some(launcher) = std::env::var_os("CSR_RUNTIME_LAUNCHER") {
let mut command = Command::new(launcher);
command.arg("--internal-python");
command
} else {
Command::new("python3")
}
}
#[derive(Clone, Debug, Deserialize)]
pub struct QueueEntry {
pub asset_id: String,
@@ -12,7 +22,7 @@ pub struct QueueEntry {
pub report_path: String,
}
fn call(data: &Path, args: &[&str]) -> Result<String, String> {
let out = Command::new("python3")
let out = python()
.args(["-c", BRIDGE, "--data"])
.arg(data)
.args(args)
@@ -48,7 +58,7 @@ pub struct HardwareIdentity {
pub fn identify(identity: &crate::workflow::Identity) -> Result<HardwareIdentity, String> {
let report = serde_json::json!({"system_uuid": identity.uuid, "serial_number": identity.serial,
"manufacturer": identity.manufacturer});
let output = Command::new("python3")
let output = python()
.args(["-c", BRIDGE, "--identity-json", &report.to_string()])
.output()
.map_err(|e| e.to_string())?;

View File

@@ -86,24 +86,29 @@ struct App {
station_ready: bool,
next_station_attempt: Instant,
}
fn scanner_path() -> PathBuf {
fn scanner_command() -> Command {
if let Some(launcher) = std::env::var_os("CSR_RUNTIME_LAUNCHER") {
let mut command = Command::new(launcher);
command.arg("--internal-scanner");
return command;
}
if let Some(p) = std::env::var_os("CSR_SCANNER") {
return PathBuf::from(p);
return Command::new(p);
}
// Prefer the scanner bundled beside this executable, then a system installation.
if let Ok(exe) = std::env::current_exe() {
if let Some(parent) = exe.parent() {
let sibling = parent.join("pc-intake");
if sibling.exists() {
return sibling;
return Command::new(sibling);
}
}
}
let local = Path::new(env!("CARGO_MANIFEST_DIR")).join("../scanner/target/release/pc-intake");
if local.exists() {
local
Command::new(local)
} else {
PathBuf::from("/usr/local/bin/pc-intake")
Command::new("/usr/local/bin/pc-intake")
}
}
fn setup(data: &Path) -> Result<(), String> {
@@ -337,20 +342,17 @@ impl App {
}
let data = self.data.clone();
let asset = self.inspection.as_ref().unwrap().asset.clone();
let scanner = scanner_path();
let mut scanner = scanner_command();
let (tx, rx) = mpsc::channel();
self.receiver = Some(rx);
self.status = "Hardware scan running / Hardware-Scan läuft …".into();
thread::spawn(move || {
let result = (|| {
let mut command = Command::new("timeout");
command
.args(["--kill-after=5s", "600s"])
.arg(scanner)
scanner
.args(["--asset-id", &asset, "--output"])
.arg(data.join("reports"));
command.arg(if full { "--compile" } else { "--quick" });
let out = command.output().map_err(|e| format!("Scanner: {e}"))?;
scanner.arg(if full { "--compile" } else { "--quick" });
let out = scanner.output().map_err(|e| format!("Scanner: {e}"))?;
if !out.status.success() {
return Err(String::from_utf8_lossy(&out.stderr).into_owned());
}

64
launcher/Cargo.lock generated Normal file
View File

@@ -0,0 +1,64 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "crc32fast"
version = "1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
dependencies = [
"cfg-if",
]
[[package]]
name = "csr-intake-launcher"
version = "2.6.0"
dependencies = [
"flate2",
]
[[package]]
name = "flate2"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"miniz_oxide",
"zlib-rs",
]
[[package]]
name = "miniz_oxide"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "zlib-rs"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"

13
launcher/Cargo.toml Normal file
View File

@@ -0,0 +1,13 @@
[package]
name = "csr-intake-launcher"
version = "2.6.0"
edition = "2021"
[dependencies]
flate2 = "1"
[profile.release]
lto = true
codegen-units = 1
strip = true
panic = "abort"

330
launcher/src/main.rs Normal file
View File

@@ -0,0 +1,330 @@
use flate2::read::GzDecoder;
use std::env;
use std::fs::{self, File};
use std::io::{self, BufReader, Read, Write};
use std::os::unix::fs::PermissionsExt;
use std::os::unix::process::CommandExt;
use std::path::{Component, Path, PathBuf};
use std::process::{self, Command};
const BUILD_ID: &str = env!("CSR_BUILD_ID");
const VERSION: &str = env!("CSR_VERSION");
const PAYLOAD: &[u8] = include_bytes!(env!("CSR_PAYLOAD_FILE"));
const MANIFEST: &str = include_str!(env!("CSR_PAYLOAD_MANIFEST"));
const MAGIC: &[u8; 8] = b"CSRPKG3\0";
fn cache_dir() -> PathBuf {
env::var_os("XDG_CACHE_HOME")
.filter(|v| !v.is_empty())
.map(PathBuf::from)
.or_else(|| env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))
.unwrap_or_else(|| env::temp_dir().join(format!("csr-intake-{}", process::id())))
.join("csr-intake")
.join(BUILD_ID)
}
fn default_data_dir() -> PathBuf {
env::var_os("XDG_DATA_HOME")
.filter(|v| !v.is_empty())
.map(PathBuf::from)
.or_else(|| env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")))
.unwrap_or_else(|| PathBuf::from("."))
.join("csr-intake")
}
fn fnv(path: &Path) -> io::Result<u64> {
let mut input = BufReader::new(File::open(path)?);
let mut hash = 0xcbf29ce484222325u64;
let mut buf = [0u8; 65536];
loop {
let count = input.read(&mut buf)?;
if count == 0 {
break;
}
for byte in &buf[..count] {
hash = (hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3);
}
}
Ok(hash)
}
fn valid(root: &Path) -> bool {
MANIFEST.lines().all(|line| {
let mut fields = line.splitn(4, '\t');
let expected_hash = fields.next().and_then(|v| u64::from_str_radix(v, 16).ok());
let expected_mode = fields.next().and_then(|v| u32::from_str_radix(v, 8).ok());
let expected_size = fields.next().and_then(|v| v.parse::<u64>().ok());
let name = fields.next();
let (Some(hash), Some(mode), Some(size), Some(name)) =
(expected_hash, expected_mode, expected_size, name)
else {
return false;
};
let path = root.join(name);
let Ok(metadata) = fs::metadata(&path) else {
return false;
};
metadata.is_file()
&& metadata.len() == size
&& metadata.permissions().mode() & 0o777 == mode
&& fnv(&path).ok() == Some(hash)
})
}
fn read_u32(reader: &mut impl Read) -> io::Result<u32> {
let mut bytes = [0u8; 4];
reader.read_exact(&mut bytes)?;
Ok(u32::from_le_bytes(bytes))
}
fn read_u64(reader: &mut impl Read) -> io::Result<u64> {
let mut bytes = [0u8; 8];
reader.read_exact(&mut bytes)?;
Ok(u64::from_le_bytes(bytes))
}
fn safe_relative(value: &str) -> io::Result<&Path> {
let path = Path::new(value);
if value.is_empty()
|| path
.components()
.any(|c| !matches!(c, Component::Normal(_)))
{
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"unsafe bundle path",
));
}
Ok(path)
}
fn unpack(destination: &Path) -> io::Result<()> {
let mut reader = GzDecoder::new(PAYLOAD);
let mut magic = [0u8; 8];
reader.read_exact(&mut magic)?;
if &magic != MAGIC {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"invalid bundle header",
));
}
loop {
let name_len = read_u32(&mut reader)?;
if name_len == 0 {
break;
}
if name_len > 4096 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"bundle path too long",
));
}
let mode = read_u32(&mut reader)?;
let size = read_u64(&mut reader)?;
let mut name = vec![0u8; name_len as usize];
reader.read_exact(&mut name)?;
let name = std::str::from_utf8(&name)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "non-UTF-8 bundle path"))?;
let path = destination.join(safe_relative(name)?);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut output = File::create(&path)?;
io::copy(&mut reader.by_ref().take(size), &mut output)?;
output.flush()?;
fs::set_permissions(&path, fs::Permissions::from_mode(mode & 0o777))?;
}
Ok(())
}
fn ensure_bundle() -> io::Result<PathBuf> {
let app = cache_dir();
if !valid(&app) {
if app.exists() {
fs::remove_dir_all(&app)?;
}
let parent = app.parent().unwrap();
fs::create_dir_all(parent)?;
let temp = parent.join(format!(".extract-{BUILD_ID}-{}", process::id()));
if temp.exists() {
fs::remove_dir_all(&temp)?;
}
fs::create_dir(&temp)?;
if let Err(error) = unpack(&temp).and_then(|_| {
if valid(&temp) {
Ok(())
} else {
Err(io::Error::new(
io::ErrorKind::InvalidData,
"bundle checksum mismatch",
))
}
}) {
let _ = fs::remove_dir_all(&temp);
return Err(error);
}
if let Err(error) = fs::rename(&temp, &app) {
if !valid(&app) {
return Err(error);
}
let _ = fs::remove_dir_all(&temp);
}
}
let runtime = app.join("runtime-launcher");
let current = env::current_exe()?;
let replace = fs::metadata(&runtime).map(|m| m.len()).ok()
!= fs::metadata(&current).map(|m| m.len()).ok();
if replace {
let _ = fs::remove_file(&runtime);
if fs::hard_link(&current, &runtime).is_err() {
fs::copy(&current, &runtime)?;
}
fs::set_permissions(&runtime, fs::Permissions::from_mode(0o700))?;
}
Ok(app)
}
fn loader_command(app: &Path, program: &Path) -> Command {
let libraries = app.join("lib");
let mut command = Command::new(app.join("lib/ld-linux-x86-64.so.2"));
command.arg("--library-path").arg(&libraries).arg(program);
command.env("LD_LIBRARY_PATH", &libraries);
command.env(
"PATH",
format!(
"{}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
app.join("bin").display()
),
);
let certificates = app.join("etc/ca-certificates.crt");
if certificates.exists() {
command.env("SSL_CERT_FILE", &certificates);
command.env("CURL_CA_BUNDLE", &certificates);
}
command
}
fn internal(app: &Path, kind: &str, args: impl Iterator<Item = String>) -> ! {
let mut command = if kind == "python" {
let mut command = loader_command(app, &app.join("python/bin/python3"));
command.env("PYTHONHOME", app.join("python"));
command.env(
"PYTHONPATH",
format!(
"{}:{}",
app.join("python/lib/python.zip").display(),
app.join("python/lib/python/lib-dynload").display()
),
);
command
} else {
loader_command(app, &app.join("pc-intake"))
};
command.args(args);
let error = command.exec();
eprintln!("Could not start bundled {kind}: {error}");
process::exit(126)
}
fn seed_config(app: &Path, data: &Path) -> io::Result<()> {
let source = app.join("default-config");
if !source.is_dir() {
return Ok(());
}
let target = data.join("config");
fs::create_dir_all(&target)?;
fs::set_permissions(&target, fs::Permissions::from_mode(0o700))?;
for name in [
"inventory.json",
"inventory-api-key",
"inventory-server.crt",
] {
let destination = target.join(name);
if !destination.exists() {
fs::copy(source.join(name), &destination)?;
fs::set_permissions(destination, fs::Permissions::from_mode(0o600))?;
}
}
Ok(())
}
fn keyboard_layout(data: &Path) -> Option<String> {
let text = fs::read_to_string(data.join("config/workshop.json")).ok()?;
let rest = text
.split_once("\"keyboard_layout\"")?
.1
.split_once(':')?
.1
.trim_start();
let value = rest.strip_prefix('"')?.split_once('"')?.0;
matches!(value, "de" | "us").then(|| value.to_owned())
}
fn fail(context: &str, error: impl std::fmt::Display) -> ! {
eprintln!("CS-R Intake: {context}: {error}");
process::exit(2)
}
fn main() {
let mut args = env::args().skip(1);
let first = args.next();
if first.as_deref() == Some("--version") {
println!("CS-R Intake {VERSION} · {BUILD_ID}");
return;
}
if matches!(
first.as_deref(),
Some("--internal-python" | "--internal-scanner")
) {
let app = env::current_exe()
.ok()
.and_then(|p| p.parent().map(Path::to_owned))
.unwrap_or_else(|| fail("runtime location", "unavailable"));
internal(
&app,
if first.as_deref() == Some("--internal-python") {
"python"
} else {
"scanner"
},
args,
);
}
let app = ensure_bundle().unwrap_or_else(|e| fail("bundle extraction failed", e));
if first.as_deref() == Some("--check-bundle") {
println!("Standalone ELF verified: {BUILD_ID}");
return;
}
let mut gui_args = Vec::new();
if let Some(value) = first {
gui_args.push(value);
}
gui_args.extend(args);
let mut data = default_data_dir();
let mut has_data = false;
let mut index = 0;
while index < gui_args.len() {
if gui_args[index] == "--data" {
has_data = true;
data = gui_args
.get(index + 1)
.map(PathBuf::from)
.unwrap_or_else(|| fail("argument error", "missing directory after --data"));
index += 1;
}
index += 1;
}
seed_config(&app, &data).unwrap_or_else(|e| fail("could not install configuration", e));
if !has_data {
gui_args.extend(["--data".into(), data.to_string_lossy().into_owned()]);
}
if let Some(layout) = keyboard_layout(&data) {
env::set_var("XKB_DEFAULT_LAYOUT", layout);
}
env::set_var("CSR_RUNTIME_LAUNCHER", app.join("runtime-launcher"));
env::set_var("CSR_SCANNER", app.join("pc-intake"));
let mut command = loader_command(&app, &app.join("intake-gui"));
command.args(gui_args);
let error = command.exec();
fail("could not start GUI", error)
}

View File

@@ -3,6 +3,7 @@ set -Eeuo pipefail
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
export CSR_BUILD_ID
CSR_BUILD_ID=$(python3 "$ROOT/scripts/release-manifest.py" --source-id)
cargo fmt --check --manifest-path "$ROOT/launcher/Cargo.toml"
for component in scanner gui; do
cargo fmt --check --manifest-path "$ROOT/$component/Cargo.toml"
cargo clippy --locked --manifest-path "$ROOT/$component/Cargo.toml" --all-targets -- -D warnings

View File

@@ -2,7 +2,7 @@
set -Eeuo pipefail
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
OUT=${1:-$ROOT/dist/csr-intake-x86_64.run}
OUT=${1:-$ROOT/dist/csr-intake-x86_64}
GUI="$ROOT/gui/target/release/intake-gui"
SCANNER="$ROOT/scanner/target/release/pc-intake"
MANIFEST="$ROOT/build/release.json"
@@ -16,7 +16,7 @@ for file in "$GUI" "$SCANNER" "$MANIFEST"; do
done
BUILD_ID=$(python3 "$ROOT/scripts/release-manifest.py" --source-id)
GUI_VERSION=$("$GUI" --version)
GUI_VERSION=$($GUI --version)
grep -qF "$BUILD_ID" <<< "$GUI_VERSION" || {
echo 'Release binaries are stale. Run bash scripts/build-release.sh first.' >&2
exit 2
@@ -24,152 +24,136 @@ grep -qF "$BUILD_ID" <<< "$GUI_VERSION" || {
VERSION=$(sed -E 's/^CS-R Intake ([^ ]+).*/\1/' <<< "$GUI_VERSION")
TMP=$(mktemp -d)
cleanup() {
find "$TMP" -depth -delete 2>/dev/null || true
}
cleanup() { find "$TMP" -depth -delete 2>/dev/null || true; }
trap cleanup EXIT
APP="$TMP/app"
mkdir -p "$APP/bin" "$APP/etc" "$APP/lib" "$APP/python/bin" "$APP/python/lib/python/lib-dynload" "$(dirname "$OUT")"
install -m755 "$GUI" "$APP/intake-gui"
install -m755 "$SCANNER" "$APP/pc-intake"
install -m644 "$MANIFEST" "$APP/release.json"
python3 "$ROOT/scripts/release-manifest.py" --verify-release "$APP"
mkdir -p "$TMP/app" "$(dirname "$OUT")"
install -m755 "$GUI" "$TMP/app/intake-gui"
install -m755 "$SCANNER" "$TMP/app/pc-intake"
install -m644 "$MANIFEST" "$TMP/app/release.json"
python3 "$ROOT/scripts/release-manifest.py" --verify-release "$TMP/app"
PYTHON=$(readlink -f "$(command -v python3)")
PYTHON_STDLIB=$(python3 -c 'import sysconfig; print(sysconfig.get_path("stdlib"))')
install -m755 "$PYTHON" "$APP/python/bin/python3"
cp -a "$PYTHON_STDLIB/lib-dynload/." "$APP/python/lib/python/lib-dynload/"
for tool in nmcli id timeout curl sh; do
source=$(command -v "$tool" 2>/dev/null || true)
[[ -n $source ]] || { echo "Required portable runtime tool is missing: $tool" >&2; exit 2; }
install -m755 "$(readlink -f "$source")" "$APP/bin/$tool"
done
for tool in smartctl stress-ng ffmpeg ffplay arecord aplay speaker-test lsusb xdg-open mpv lspci glmark2 memtester; do
source=$(command -v "$tool" 2>/dev/null || true)
[[ -z $source ]] || install -m755 "$(readlink -f "$source")" "$APP/bin/$tool"
done
for certificates in /etc/ssl/certs/ca-certificates.crt /etc/ca-certificates/extracted/tls-ca-bundle.pem; do
if [[ -f $certificates ]]; then install -m644 "$certificates" "$APP/etc/ca-certificates.crt"; break; fi
done
python3 - "$PYTHON_STDLIB" "$APP/python/lib/python.zip" <<'PY'
from pathlib import Path
import sys, zipfile
source = Path(sys.argv[1])
output = Path(sys.argv[2])
excluded = {'site-packages', '__pycache__', 'test', 'tests', 'idlelib', 'tkinter', 'turtledemo', 'ensurepip', 'venv'}
with zipfile.ZipFile(output, 'w', zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
for path in sorted(source.rglob('*.py')):
relative = path.relative_to(source)
if any(part in excluded for part in relative.parts):
continue
archive.write(path, relative)
PY
python3 - "$APP" <<'PY'
from pathlib import Path
import re, shutil, subprocess, sys
app = Path(sys.argv[1])
lib = app / 'lib'
seeds = [app/'intake-gui', app/'pc-intake', app/'python/bin/python3', *sorted((app/'bin').iterdir()), *sorted((app/'python/lib/python/lib-dynload').glob('*.so'))]
optional = ['libX11.so.6','libXcursor.so.1','libXi.so.6','libXrandr.so.2','libxcb.so.1','libXau.so.6','libXdmcp.so.6','libwayland-client.so.0','libxkbcommon.so.0','libEGL.so.1','libGL.so.1','libGLX.so.0','libOpenGL.so.0','libnss_files.so.2','libnss_dns.so.2','libresolv.so.2']
cache = subprocess.run(['ldconfig','-p'], text=True, check=True, capture_output=True).stdout
for name in optional:
match = re.search(r'^\s*'+re.escape(name)+r'.* => (\S+)$', cache, re.M)
if match: seeds.append(Path(match.group(1)))
queue, seen = list(seeds), set()
while queue:
binary = queue.pop()
try: key = binary.resolve()
except OSError: continue
if key in seen or not key.is_file(): continue
seen.add(key)
result = subprocess.run(['ldd', str(key)], text=True, capture_output=True)
for line in result.stdout.splitlines():
paths = re.findall(r'(/[^\s()]+)', line)
for value in paths:
dependency = Path(value)
if dependency.exists(): queue.append(dependency)
for source in sorted(seen):
if source.is_relative_to(app.resolve()): continue
destination = lib / source.name
if not destination.exists(): shutil.copy2(source, destination)
loader = Path('/usr/lib/ld-linux-x86-64.so.2')
if not loader.exists(): loader = Path('/lib64/ld-linux-x86-64.so.2').resolve()
shutil.copy2(loader, lib/'ld-linux-x86-64.so.2')
PY
contains_credentials=0
if [[ -f $BUNDLE_CONFIG/inventory-api-key && -f $BUNDLE_CONFIG/inventory-server.crt ]]; then
key=$(tr -d '\r\n' < "$BUNDLE_CONFIG/inventory-api-key")
[[ -n $key && $key != *[[:space:]]* ]] || {
echo 'Bundled inventory API key is empty or malformed.' >&2
exit 2
}
[[ -n $key && $key != *[[:space:]]* ]] || { echo 'Bundled API key is malformed.' >&2; exit 2; }
openssl x509 -in "$BUNDLE_CONFIG/inventory-server.crt" -noout -checkend 0 >/dev/null || {
echo 'Bundled inventory certificate is invalid or expired.' >&2
exit 2
echo 'Bundled inventory certificate is invalid or expired.' >&2; exit 2;
}
mkdir -m700 "$TMP/app/default-config"
install -m600 "$BUNDLE_CONFIG/inventory-api-key" "$TMP/app/default-config/inventory-api-key"
install -m600 "$BUNDLE_CONFIG/inventory-server.crt" "$TMP/app/default-config/inventory-server.crt"
printf '%s\n' '{"url":"https://192.168.178.17"}' > "$TMP/app/default-config/inventory.json"
chmod 600 "$TMP/app/default-config/inventory.json"
mkdir -m700 "$APP/default-config"
install -m600 "$BUNDLE_CONFIG/inventory-api-key" "$APP/default-config/inventory-api-key"
install -m600 "$BUNDLE_CONFIG/inventory-server.crt" "$APP/default-config/inventory-server.crt"
printf '%s\n' '{"url":"https://192.168.178.17"}' > "$APP/default-config/inventory.json"
chmod 600 "$APP/default-config/inventory.json"
contains_credentials=1
elif [[ -e $BUNDLE_CONFIG/inventory-api-key || -e $BUNDLE_CONFIG/inventory-server.crt ]]; then
echo 'Both inventory-api-key and inventory-server.crt are required for bundled configuration.' >&2
echo 'Both inventory-api-key and inventory-server.crt are required.' >&2
exit 2
fi
(
cd "$TMP/app"
find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS
)
tar -C "$TMP/app" -czf "$TMP/payload.tar.gz" .
cat > "$TMP/launcher" <<EOF
#!/bin/sh
set -eu
BUILD_ID='$BUILD_ID'
VERSION='$VERSION'
PAYLOAD_LINE=__PAYLOAD_LINE__
env -i \
PYTHONHOME="$APP/python" \
PYTHONPATH="$APP/python/lib/python.zip:$APP/python/lib/python/lib-dynload" \
"$APP/lib/ld-linux-x86-64.so.2" --library-path "$APP/lib" "$APP/python/bin/python3" \
-c 'import argparse, datetime, hashlib, json, pathlib, sqlite3, ssl, urllib.request, uuid; print("Bundled Python OK")'
cache_root=\${XDG_CACHE_HOME:-\${HOME:-\$PWD}/.cache}/csr-intake
app_dir=\$cache_root/\$BUILD_ID
extract_bundle() {
bundle_valid() {
[ -x "\$app_dir/intake-gui" ] && [ -x "\$app_dir/pc-intake" ] &&
(cd "\$app_dir" && sha256sum -c SHA256SUMS >/dev/null 2>&1)
}
if ! bundle_valid; then
if [ -e "\$app_dir" ]; then
find "\$app_dir" -depth -delete 2>/dev/null || true
fi
temp_dir=\$cache_root/.extract-\$BUILD_ID-\$\$
mkdir -p "\$temp_dir"
trap 'find "\$temp_dir" -depth -delete 2>/dev/null || true' EXIT HUP INT TERM
tail -n +"\$PAYLOAD_LINE" "\$0" | gzip -dc | tar -xf - -C "\$temp_dir"
(cd "\$temp_dir" && sha256sum -c SHA256SUMS >/dev/null)
mkdir -p "\$cache_root"
if ! mv -T "\$temp_dir" "\$app_dir" 2>/dev/null; then
find "\$temp_dir" -depth -delete 2>/dev/null || true
fi
trap - EXIT HUP INT TERM
fi
bundle_valid
}
case \${1:-} in
--version)
printf 'CS-R Intake %s · %s\n' "\$VERSION" "\$BUILD_ID"
exit 0
;;
--check-bundle)
extract_bundle
printf 'Standalone bundle verified: %s\n' "\$BUILD_ID"
exit 0
;;
esac
extract_bundle
data_dir=\${XDG_DATA_HOME:-\${HOME:-\$PWD}/.local/share}/csr-intake
expect_data=0
has_data=0
for argument in "\$@"; do
if [ "\$expect_data" -eq 1 ]; then
data_dir=\$argument
expect_data=0
elif [ "\$argument" = '--data' ]; then
has_data=1
expect_data=1
fi
done
[ "\$expect_data" -eq 0 ] || {
echo 'Missing directory after --data.' >&2
exit 2
}
if [ -d "\$app_dir/default-config" ]; then
config_dir=\$data_dir/config
mkdir -p "\$config_dir"
chmod 700 "\$config_dir"
for name in inventory.json inventory-api-key inventory-server.crt; do
target=\$config_dir/\$name
if [ ! -e "\$target" ]; then
install -m600 "\$app_dir/default-config/\$name" "\$target"
fi
done
fi
workshop_config=\$data_dir/config/workshop.json
if [ -f "\$workshop_config" ]; then
keyboard_layout=\$(python3 - "\$workshop_config" <<'PY' 2>/dev/null || true
import json
import sys
layout = json.load(open(sys.argv[1], encoding='utf-8')).get('keyboard_layout', '')
if layout in ('de', 'us'):
print(layout)
python3 - "$APP" "$TMP/payload.gz" "$TMP/payload.manifest" <<'PY'
from pathlib import Path
import gzip, os, struct, sys
root, output, manifest = map(Path, sys.argv[1:])
files = sorted(path for path in root.rglob('*') if path.is_file())
lines=[]
with output.open('wb') as raw, gzip.GzipFile(fileobj=raw, mode='wb', compresslevel=9, mtime=0) as archive:
archive.write(b'CSRPKG3\0')
for path in files:
name = path.relative_to(root).as_posix().encode()
data = path.read_bytes()
mode = path.stat().st_mode & 0o777
archive.write(struct.pack('<IIQ', len(name), mode, len(data)))
archive.write(name); archive.write(data)
digest=0xcbf29ce484222325
for byte in data: digest=((digest ^ byte) * 0x100000001b3) & 0xffffffffffffffff
lines.append(f'{digest:016x}\t{mode:o}\t{len(data)}\t{name.decode()}')
archive.write(struct.pack('<I', 0))
manifest.write_text('\n'.join(lines)+'\n')
PY
)
if [ -n "\$keyboard_layout" ]; then
export XKB_DEFAULT_LAYOUT=\$keyboard_layout
fi
fi
export CSR_SCANNER="\$app_dir/pc-intake"
if [ "\$has_data" -eq 1 ]; then
exec "\$app_dir/intake-gui" "\$@"
fi
exec "\$app_dir/intake-gui" "\$@" --data "\$data_dir"
EOF
export CSR_BUILD_ID="$BUILD_ID" CSR_VERSION="$VERSION"
export CSR_PAYLOAD_FILE="$TMP/payload.gz" CSR_PAYLOAD_MANIFEST="$TMP/payload.manifest"
export RUSTFLAGS="${RUSTFLAGS:-} -C target-feature=+crt-static"
cargo clippy --locked --manifest-path "$ROOT/launcher/Cargo.toml" --all-targets -- -D warnings
cargo build --locked --release --manifest-path "$ROOT/launcher/Cargo.toml"
install -m$([[ $contains_credentials -eq 1 ]] && echo 700 || echo 755) \
"$ROOT/launcher/target/release/csr-intake-launcher" "$OUT"
payload_line=$(( $(wc -l < "$TMP/launcher") + 1 ))
sed -i "s/__PAYLOAD_LINE__/$payload_line/" "$TMP/launcher"
cat "$TMP/launcher" "$TMP/payload.tar.gz" > "$OUT"
if [[ $contains_credentials -eq 1 ]]; then
chmod 700 "$OUT"
else
chmod 755 "$OUT"
file "$OUT" | grep -q 'static-pie linked' || { echo 'Launcher is not statically linked.' >&2; exit 2; }
if ldd "$OUT" 2>&1 | grep -qv 'statically linked\|not a dynamic executable'; then
echo 'Launcher unexpectedly depends on host libraries.' >&2; exit 2
fi
"$OUT" --check-bundle
if [[ $contains_credentials -eq 1 ]]; then
echo "Standalone executable created with inventory configuration: $OUT"
else
echo "Standalone executable created: $OUT"
fi
echo "Standalone ELF created: $OUT"

View File

@@ -19,6 +19,9 @@ def source_id():
base = ROOT / component
paths.extend((base / 'src').rglob('*.rs'))
paths.extend(p for p in [base / 'Cargo.toml', base / 'Cargo.lock', base / 'build.rs'] if p.exists())
launcher = ROOT / 'launcher'
paths.extend((launcher / 'src').rglob('*.rs'))
paths.extend(p for p in [launcher / 'Cargo.toml', launcher / 'Cargo.lock'] if p.exists())
paths.extend(p for p in (ROOT / 'scripts').iterdir() if p.is_file())
h = hashlib.sha256()
for path in sorted(paths):