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 - name: Systempakete installieren
run: | run: |
sudo apt-get update 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 - name: Rust installieren
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
with: with:
@@ -21,10 +21,12 @@ jobs:
run: | run: |
bash -n scripts/*.sh tests/*.sh bash -n scripts/*.sh tests/*.sh
bash scripts/build-release.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 - name: Eigenständige Anwendung bereitstellen
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: csr-intake-x86_64 name: csr-intake-x86_64
path: dist/csr-intake-x86_64.run path: dist/csr-intake-x86_64
if-no-files-found: error if-no-files-found: error

View File

@@ -7,7 +7,7 @@ auch ohne Netzwerk lokal.
## Ablauf in der Werkstatt ## 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 2. Beim ersten Einsatz unter **Einstellungen** das Werkstatt-WLAN verbinden und
**WLAN speichern** aktiviert lassen. **WLAN speichern** aktiviert lassen.
3. Das Gerät wird über DMI-UUID oder Hersteller und Seriennummer erkannt. 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 ## Anwendung starten
Die Release-Datei enthält Oberfläche und Hardware-Scanner. Sie entpackt geprüfte Die Release-Datei ist ein statisch gelinktes Linux-ELF. Sie enthält Oberfläche,
Programmdateien in den Benutzer-Cache und speichert Einstellungen, Berichte und die Hardware-Scanner, Python-Laufzeit, TLS, SQLite und alle unmittelbar benötigten
Offline-Warteschlange standardmäßig unter `~/.local/share/csr-intake`. 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 ```bash
chmod +x csr-intake-x86_64.run chmod +x csr-intake-x86_64
./csr-intake-x86_64.run ./csr-intake-x86_64
``` ```
Ein anderer Datenordner kann mit `--data /pfad/zum/ordner` gewählt werden: Ein anderer Datenordner kann mit `--data /pfad/zum/ordner` gewählt werden:
```bash ```bash
./csr-intake-x86_64.run --data "$HOME/csr-intake-daten" ./csr-intake-x86_64 --data "$HOME/csr-intake-daten"
./csr-intake-x86_64.run --version ./csr-intake-x86_64 --version
./csr-intake-x86_64.run --check-bundle ./csr-intake-x86_64 --check-bundle
``` ```
Die Anwendung ist für x86-64 Linux gebaut. Sie benötigt eine grafische Sitzung, Die Anwendung ist für x86-64 Linux ab Kernel 4.4 gebaut. Clientprogramme für WLAN,
Python 3, SQLite und NetworkManager. Einzelne Diagnosen verwenden Systemwerkzeuge HTTP, Audio, Video, SMART und USB werden in den Build übernommen, sofern sie auf dem
wie `smartctl`, `stress-ng`, `ffmpeg`, `arecord`, `lsusb` und `xdg-open`. Fehlt ein Build-Rechner verfügbar sind. Benötigt werden nur eine grafische Linux-Sitzung, der
Werkzeug, wird der Test als **nicht verfügbar** und nicht als Hardwarefehler bewertet. 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 ## Konfiguration
@@ -85,13 +90,13 @@ aus und erzeugt anschließend genau ein verteilbares Artefakt:
```bash ```bash
bash scripts/build-release.sh 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 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 bei jedem erfolgreichen Build eine neutrale ELF-Datei ohne Zugangsdaten als Artefakt
Artefakt bereit. bereit.
Für eine werkstattgebundene Datei können `inventory-api-key` und Für eine werkstattgebundene Datei können `inventory-api-key` und
`inventory-server.crt` vor dem Build in `.bundle-secrets/` abgelegt werden. Der Build `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 | | `gui/` | Bedienoberfläche und geführte Tests |
| `scanner/` | Hardwareerkennung und Messwerte | | `scanner/` | Hardwareerkennung und Messwerte |
| `launcher/` | Statischer ELF-Starter und eingebettete Laufzeit |
| `scripts/` | Build, Synchronisation und lokale Datenwerkzeuge | | `scripts/` | Build, Synchronisation und lokale Datenwerkzeuge |
| `tests/` | Python- und Rauchtests | | `tests/` | Python- und Rauchtests |
| `config.example/` | Beispiel für die Inventarverbindung | | `config.example/` | Beispiel für die Inventarverbindung |

View File

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

View File

@@ -86,24 +86,29 @@ struct App {
station_ready: bool, station_ready: bool,
next_station_attempt: Instant, 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") { 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. // Prefer the scanner bundled beside this executable, then a system installation.
if let Ok(exe) = std::env::current_exe() { if let Ok(exe) = std::env::current_exe() {
if let Some(parent) = exe.parent() { if let Some(parent) = exe.parent() {
let sibling = parent.join("pc-intake"); let sibling = parent.join("pc-intake");
if sibling.exists() { if sibling.exists() {
return sibling; return Command::new(sibling);
} }
} }
} }
let local = Path::new(env!("CARGO_MANIFEST_DIR")).join("../scanner/target/release/pc-intake"); let local = Path::new(env!("CARGO_MANIFEST_DIR")).join("../scanner/target/release/pc-intake");
if local.exists() { if local.exists() {
local Command::new(local)
} else { } else {
PathBuf::from("/usr/local/bin/pc-intake") Command::new("/usr/local/bin/pc-intake")
} }
} }
fn setup(data: &Path) -> Result<(), String> { fn setup(data: &Path) -> Result<(), String> {
@@ -337,20 +342,17 @@ impl App {
} }
let data = self.data.clone(); let data = self.data.clone();
let asset = self.inspection.as_ref().unwrap().asset.clone(); let asset = self.inspection.as_ref().unwrap().asset.clone();
let scanner = scanner_path(); let mut scanner = scanner_command();
let (tx, rx) = mpsc::channel(); let (tx, rx) = mpsc::channel();
self.receiver = Some(rx); self.receiver = Some(rx);
self.status = "Hardware scan running / Hardware-Scan läuft …".into(); self.status = "Hardware scan running / Hardware-Scan läuft …".into();
thread::spawn(move || { thread::spawn(move || {
let result = (|| { let result = (|| {
let mut command = Command::new("timeout"); scanner
command
.args(["--kill-after=5s", "600s"])
.arg(scanner)
.args(["--asset-id", &asset, "--output"]) .args(["--asset-id", &asset, "--output"])
.arg(data.join("reports")); .arg(data.join("reports"));
command.arg(if full { "--compile" } else { "--quick" }); scanner.arg(if full { "--compile" } else { "--quick" });
let out = command.output().map_err(|e| format!("Scanner: {e}"))?; let out = scanner.output().map_err(|e| format!("Scanner: {e}"))?;
if !out.status.success() { if !out.status.success() {
return Err(String::from_utf8_lossy(&out.stderr).into_owned()); 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) ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
export CSR_BUILD_ID export CSR_BUILD_ID
CSR_BUILD_ID=$(python3 "$ROOT/scripts/release-manifest.py" --source-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 for component in scanner gui; do
cargo fmt --check --manifest-path "$ROOT/$component/Cargo.toml" cargo fmt --check --manifest-path "$ROOT/$component/Cargo.toml"
cargo clippy --locked --manifest-path "$ROOT/$component/Cargo.toml" --all-targets -- -D warnings cargo clippy --locked --manifest-path "$ROOT/$component/Cargo.toml" --all-targets -- -D warnings

View File

@@ -2,7 +2,7 @@
set -Eeuo pipefail set -Eeuo pipefail
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) 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" GUI="$ROOT/gui/target/release/intake-gui"
SCANNER="$ROOT/scanner/target/release/pc-intake" SCANNER="$ROOT/scanner/target/release/pc-intake"
MANIFEST="$ROOT/build/release.json" MANIFEST="$ROOT/build/release.json"
@@ -16,7 +16,7 @@ for file in "$GUI" "$SCANNER" "$MANIFEST"; do
done done
BUILD_ID=$(python3 "$ROOT/scripts/release-manifest.py" --source-id) BUILD_ID=$(python3 "$ROOT/scripts/release-manifest.py" --source-id)
GUI_VERSION=$("$GUI" --version) GUI_VERSION=$($GUI --version)
grep -qF "$BUILD_ID" <<< "$GUI_VERSION" || { grep -qF "$BUILD_ID" <<< "$GUI_VERSION" || {
echo 'Release binaries are stale. Run bash scripts/build-release.sh first.' >&2 echo 'Release binaries are stale. Run bash scripts/build-release.sh first.' >&2
exit 2 exit 2
@@ -24,152 +24,136 @@ grep -qF "$BUILD_ID" <<< "$GUI_VERSION" || {
VERSION=$(sed -E 's/^CS-R Intake ([^ ]+).*/\1/' <<< "$GUI_VERSION") VERSION=$(sed -E 's/^CS-R Intake ([^ ]+).*/\1/' <<< "$GUI_VERSION")
TMP=$(mktemp -d) TMP=$(mktemp -d)
cleanup() { cleanup() { find "$TMP" -depth -delete 2>/dev/null || true; }
find "$TMP" -depth -delete 2>/dev/null || true
}
trap cleanup EXIT 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")" PYTHON=$(readlink -f "$(command -v python3)")
install -m755 "$GUI" "$TMP/app/intake-gui" PYTHON_STDLIB=$(python3 -c 'import sysconfig; print(sysconfig.get_path("stdlib"))')
install -m755 "$SCANNER" "$TMP/app/pc-intake" install -m755 "$PYTHON" "$APP/python/bin/python3"
install -m644 "$MANIFEST" "$TMP/app/release.json" cp -a "$PYTHON_STDLIB/lib-dynload/." "$APP/python/lib/python/lib-dynload/"
python3 "$ROOT/scripts/release-manifest.py" --verify-release "$TMP/app"
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 contains_credentials=0
if [[ -f $BUNDLE_CONFIG/inventory-api-key && -f $BUNDLE_CONFIG/inventory-server.crt ]]; then if [[ -f $BUNDLE_CONFIG/inventory-api-key && -f $BUNDLE_CONFIG/inventory-server.crt ]]; then
key=$(tr -d '\r\n' < "$BUNDLE_CONFIG/inventory-api-key") key=$(tr -d '\r\n' < "$BUNDLE_CONFIG/inventory-api-key")
[[ -n $key && $key != *[[:space:]]* ]] || { [[ -n $key && $key != *[[:space:]]* ]] || { echo 'Bundled API key is malformed.' >&2; exit 2; }
echo 'Bundled inventory API key is empty or malformed.' >&2
exit 2
}
openssl x509 -in "$BUNDLE_CONFIG/inventory-server.crt" -noout -checkend 0 >/dev/null || { openssl x509 -in "$BUNDLE_CONFIG/inventory-server.crt" -noout -checkend 0 >/dev/null || {
echo 'Bundled inventory certificate is invalid or expired.' >&2 echo 'Bundled inventory certificate is invalid or expired.' >&2; exit 2;
exit 2
} }
mkdir -m700 "$TMP/app/default-config" mkdir -m700 "$APP/default-config"
install -m600 "$BUNDLE_CONFIG/inventory-api-key" "$TMP/app/default-config/inventory-api-key" install -m600 "$BUNDLE_CONFIG/inventory-api-key" "$APP/default-config/inventory-api-key"
install -m600 "$BUNDLE_CONFIG/inventory-server.crt" "$TMP/app/default-config/inventory-server.crt" install -m600 "$BUNDLE_CONFIG/inventory-server.crt" "$APP/default-config/inventory-server.crt"
printf '%s\n' '{"url":"https://192.168.178.17"}' > "$TMP/app/default-config/inventory.json" printf '%s\n' '{"url":"https://192.168.178.17"}' > "$APP/default-config/inventory.json"
chmod 600 "$TMP/app/default-config/inventory.json" chmod 600 "$APP/default-config/inventory.json"
contains_credentials=1 contains_credentials=1
elif [[ -e $BUNDLE_CONFIG/inventory-api-key || -e $BUNDLE_CONFIG/inventory-server.crt ]]; then 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 exit 2
fi 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 env -i \
#!/bin/sh PYTHONHOME="$APP/python" \
set -eu PYTHONPATH="$APP/python/lib/python.zip:$APP/python/lib/python/lib-dynload" \
BUILD_ID='$BUILD_ID' "$APP/lib/ld-linux-x86-64.so.2" --library-path "$APP/lib" "$APP/python/bin/python3" \
VERSION='$VERSION' -c 'import argparse, datetime, hashlib, json, pathlib, sqlite3, ssl, urllib.request, uuid; print("Bundled Python OK")'
PAYLOAD_LINE=__PAYLOAD_LINE__
cache_root=\${XDG_CACHE_HOME:-\${HOME:-\$PWD}/.cache}/csr-intake python3 - "$APP" "$TMP/payload.gz" "$TMP/payload.manifest" <<'PY'
app_dir=\$cache_root/\$BUILD_ID from pathlib import Path
import gzip, os, struct, sys
extract_bundle() { root, output, manifest = map(Path, sys.argv[1:])
bundle_valid() { files = sorted(path for path in root.rglob('*') if path.is_file())
[ -x "\$app_dir/intake-gui" ] && [ -x "\$app_dir/pc-intake" ] && lines=[]
(cd "\$app_dir" && sha256sum -c SHA256SUMS >/dev/null 2>&1) with output.open('wb') as raw, gzip.GzipFile(fileobj=raw, mode='wb', compresslevel=9, mtime=0) as archive:
} archive.write(b'CSRPKG3\0')
if ! bundle_valid; then for path in files:
if [ -e "\$app_dir" ]; then name = path.relative_to(root).as_posix().encode()
find "\$app_dir" -depth -delete 2>/dev/null || true data = path.read_bytes()
fi mode = path.stat().st_mode & 0o777
temp_dir=\$cache_root/.extract-\$BUILD_ID-\$\$ archive.write(struct.pack('<IIQ', len(name), mode, len(data)))
mkdir -p "\$temp_dir" archive.write(name); archive.write(data)
trap 'find "\$temp_dir" -depth -delete 2>/dev/null || true' EXIT HUP INT TERM digest=0xcbf29ce484222325
tail -n +"\$PAYLOAD_LINE" "\$0" | gzip -dc | tar -xf - -C "\$temp_dir" for byte in data: digest=((digest ^ byte) * 0x100000001b3) & 0xffffffffffffffff
(cd "\$temp_dir" && sha256sum -c SHA256SUMS >/dev/null) lines.append(f'{digest:016x}\t{mode:o}\t{len(data)}\t{name.decode()}')
mkdir -p "\$cache_root" archive.write(struct.pack('<I', 0))
if ! mv -T "\$temp_dir" "\$app_dir" 2>/dev/null; then manifest.write_text('\n'.join(lines)+'\n')
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)
PY PY
)
if [ -n "\$keyboard_layout" ]; then
export XKB_DEFAULT_LAYOUT=\$keyboard_layout
fi
fi
export CSR_SCANNER="\$app_dir/pc-intake" export CSR_BUILD_ID="$BUILD_ID" CSR_VERSION="$VERSION"
if [ "\$has_data" -eq 1 ]; then export CSR_PAYLOAD_FILE="$TMP/payload.gz" CSR_PAYLOAD_MANIFEST="$TMP/payload.manifest"
exec "\$app_dir/intake-gui" "\$@" export RUSTFLAGS="${RUSTFLAGS:-} -C target-feature=+crt-static"
fi cargo clippy --locked --manifest-path "$ROOT/launcher/Cargo.toml" --all-targets -- -D warnings
exec "\$app_dir/intake-gui" "\$@" --data "\$data_dir" cargo build --locked --release --manifest-path "$ROOT/launcher/Cargo.toml"
EOF 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 )) file "$OUT" | grep -q 'static-pie linked' || { echo 'Launcher is not statically linked.' >&2; exit 2; }
sed -i "s/__PAYLOAD_LINE__/$payload_line/" "$TMP/launcher" if ldd "$OUT" 2>&1 | grep -qv 'statically linked\|not a dynamic executable'; then
cat "$TMP/launcher" "$TMP/payload.tar.gz" > "$OUT" echo 'Launcher unexpectedly depends on host libraries.' >&2; exit 2
if [[ $contains_credentials -eq 1 ]]; then
chmod 700 "$OUT"
else
chmod 755 "$OUT"
fi fi
"$OUT" --check-bundle "$OUT" --check-bundle
if [[ $contains_credentials -eq 1 ]]; then echo "Standalone ELF created: $OUT"
echo "Standalone executable created with inventory configuration: $OUT"
else
echo "Standalone executable created: $OUT"
fi

View File

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