use std::fs::{self, File}; use std::io::Read; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use clap::Parser; use flate2::Compression; use flate2::write::GzEncoder; use sha2::{Digest, Sha256}; use zip::write::SimpleFileOptions; const KIT_DIR: &str = "install-kit-awindows-20260427-211240"; const README: &str = r#"ActivityWatch DetMir Windows Install Kit Includes: - windows/* (deploy scripts, collectors, common module, configs/examples) - ansible/* (Windows and AW server playbooks, examples, inventory, tasks) - aw-server/* (server installer, health orchestrator, RU patch loader, host groups, default settings) - scripts/* (install-kit rebuild/validation, quality gates, browser/web smoke checks) Source: - Local project snapshot at build time. Customer-specific deployment configs, inventories, passwords, tokens, domains, IP addresses and runtime snapshots are intentionally excluded from this public install-kit. "#; const ANSIBLE_FILES: &[&str] = &[ "ansible/README.md", "ansible/deploy_aw_pfsense_poller.yml", "ansible/deploy_aw_server.yml", "ansible/deploy_aw_windows.yml", "ansible/group_vars/all.example.yml", "ansible/group_vars/pfsense-poller.example.yml", "ansible/group_vars/proxmox-matrix.example.yml", "ansible/group_vars/proxmox.example.yml", "ansible/group_vars/windows.example.yml", "ansible/install_full_stack.yml", "ansible/inventory.example.ini", "ansible/provision_proxmox_ct_and_deploy_aw.yml", "ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml", "ansible/tasks/provision_ct_and_deploy_aw.yml", ]; const AW_SERVER_FILES: &[&str] = &[ "aw-server/activitywatch-server.service", "aw-server/apply_webui_ru_patch.sh", "aw-server/aw-host-groups.json", "aw-server/aw-ru-patch.js", "aw-server/aw-rus-healthd.service", "aw-server/aw-rus-healthd.timer", "aw-server/aw-browser-smoke.service", "aw-server/aw-browser-smoke.timer", "aw-server/aw-slo-monitor.service", "aw-server/aw-slo-monitor.timer", "aw-server/aw-server.env.example", "aw-server/aw-sw-cleanup.js", "aw-server/aw-worktime-api.service", "aw-server/aw-worktime-prewarm.service", "aw-server/aw-worktime-prewarm.timer", "aw-server/aw-worktime-panel.js", "aw-server/install_aw_server.sh", "aw-server/settings/classes-worktime.json", "aw-server/settings/views-default.json", ]; const WINDOWS_FILES: &[&str] = &[ "windows/ActivityWatch.Windows.Common.psd1", "windows/ActivityWatch.Windows.Common.psm1", "windows/browser-domains-native-collector.ps1", "windows/deploy-domain-users.ps1", "windows/deploy-ensemble.ps1", "windows/deploy-single-user.ps1", "windows/AWatchRusCollectorGuardService.cs", "windows/aw-collector-guard.ps1", "windows/install-collector-guard-service.ps1", "windows/dlp-endpoint-signals-collector.ps1", "windows/dlp-policy.example.json", "windows/dlp-policy.native-cross-os.example.json", "windows/email-outbound-collector.ps1", "windows/hardening-recovery.ps1", "windows/migrate-awatch-rus-paths.ps1", "windows/validate-deployment.ps1", "windows/web-category-rules.example.json", "windows/worktime-session-collector.ps1", ]; const SCRIPTS_FILES: &[&str] = &[ "scripts/aw-webui-browser-smoke.mjs", "scripts/aw-webui-browser-smoke.sh", "scripts/check_install_kit_vs_repo.sh", "scripts/quality-gate.sh", "scripts/rebuild_install_kit.sh", "scripts/validate_install_kit.sh", "scripts/verify_innosetup_installer.sh", ]; #[derive(Debug, Parser)] #[command(about = "Rebuild ActivityWatch-Russian Windows install-kit directory and archives")] struct Cli { #[arg(long, default_value = ".")] root: PathBuf, } fn main() { let code = match run() { Ok(()) => 0, Err(err) => { eprintln!("{err:#}"); 1 } }; std::process::exit(code); } fn run() -> Result<()> { let cli = Cli::parse(); let root = cli .root .canonicalize() .with_context(|| format!("canonicalize root {}", cli.root.display()))?; rebuild(&root) } fn rebuild(root: &Path) -> Result<()> { let kit = root.join(KIT_DIR); for name in ["ansible", "aw-server", "windows", "scripts"] { remove_if_exists(&kit.join(name))?; } remove_server_config_dirs(&kit)?; for rel in ANSIBLE_FILES .iter() .chain(AW_SERVER_FILES) .chain(WINDOWS_FILES) .chain(SCRIPTS_FILES) { copy_file(root, &kit, rel)?; } write_file_replace(&kit.join("README-INSTALL-KIT.txt"), README.as_bytes()) .with_context(|| format!("write {}", kit.join("README-INSTALL-KIT.txt").display()))?; write_manifest(root, &kit)?; write_zip(root, &kit)?; write_tar(root, &kit)?; Ok(()) } fn remove_server_config_dirs(kit: &Path) -> Result<()> { if !kit.is_dir() { return Ok(()); } for entry in fs::read_dir(kit).with_context(|| format!("read dir {}", kit.display()))? { let entry = entry.with_context(|| format!("read dir entry {}", kit.display()))?; let path = entry.path(); if path .file_name() .and_then(|name| name.to_str()) .is_some_and(|name| name.starts_with("server-configs-")) { remove_if_exists(&path)?; } } Ok(()) } fn copy_file(root: &Path, kit: &Path, rel: &str) -> Result<()> { let src = root.join(rel); let dest = kit.join(rel); fs::create_dir_all(dest.parent().context("destination parent")?) .with_context(|| format!("create parent for {}", dest.display()))?; let bytes = fs::read(&src).with_context(|| format!("read {}", src.display()))?; write_file_replace(&dest, &bytes) .with_context(|| format!("copy {} to {}", src.display(), dest.display()))?; Ok(()) } fn write_file_replace(path: &Path, bytes: &[u8]) -> Result<()> { remove_if_exists(path)?; fs::write(path, bytes).with_context(|| format!("write {}", path.display()))?; Ok(()) } fn remove_if_exists(path: &Path) -> Result<()> { if !path.exists() { return Ok(()); } let meta = fs::symlink_metadata(path).with_context(|| format!("stat {}", path.display()))?; if meta.is_dir() { fs::remove_dir_all(path).with_context(|| format!("remove dir {}", path.display()))?; } else { fs::remove_file(path).with_context(|| format!("remove file {}", path.display()))?; } Ok(()) } fn write_manifest(root: &Path, kit: &Path) -> Result<()> { let mut files = Vec::new(); collect_files(kit, &mut files)?; files.retain(|path| path.file_name().and_then(|name| name.to_str()) != Some("MANIFEST.txt")); files.sort(); let manifest = kit.join("MANIFEST.txt"); let mut out = String::new(); for path in files { let digest = sha256_file(&path)?; let rel = path .strip_prefix(root) .with_context(|| format!("strip root prefix from {}", path.display()))?; out.push_str(&format!("{digest} {}\n", slash_path(rel))); } write_file_replace(&manifest, out.as_bytes())?; Ok(()) } fn write_zip(root: &Path, kit: &Path) -> Result<()> { let archive_path = root.join(format!("{KIT_DIR}.zip")); remove_if_exists(&archive_path)?; let file = File::create(&archive_path) .with_context(|| format!("create {}", archive_path.display()))?; let mut zip = zip::ZipWriter::new(file); let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); let mut files = Vec::new(); collect_files(kit, &mut files)?; files.sort(); for path in files { let rel = path .strip_prefix(root) .with_context(|| format!("strip root prefix from {}", path.display()))?; let name = slash_path(rel); zip.start_file(name, options).context("start zip file")?; let mut input = File::open(&path).with_context(|| format!("open {}", path.display()))?; std::io::copy(&mut input, &mut zip) .with_context(|| format!("write zip {}", path.display()))?; } zip.finish().context("finish zip")?; Ok(()) } fn write_tar(root: &Path, kit: &Path) -> Result<()> { let archive_path = root.join(format!("{KIT_DIR}.tar.gz")); remove_if_exists(&archive_path)?; let file = File::create(&archive_path) .with_context(|| format!("create {}", archive_path.display()))?; let encoder = GzEncoder::new(file, Compression::default()); let mut builder = tar::Builder::new(encoder); let mut files = Vec::new(); collect_files(kit, &mut files)?; files.sort(); for path in files { builder .append_path_with_name( &path, slash_path( path.strip_prefix(root) .with_context(|| format!("strip root prefix from {}", path.display()))?, ), ) .with_context(|| format!("append tar {}", path.display()))?; } builder.finish().context("finish tar")?; Ok(()) } fn collect_files(path: &Path, out: &mut Vec) -> Result<()> { for entry in fs::read_dir(path).with_context(|| format!("read dir {}", path.display()))? { let entry = entry.with_context(|| format!("read dir entry {}", path.display()))?; let entry_path = entry.path(); let file_type = entry .file_type() .with_context(|| format!("read file type {}", entry_path.display()))?; if file_type.is_dir() { collect_files(&entry_path, out)?; } else if (file_type.is_file() || file_type.is_symlink()) && entry_path.is_file() { out.push(entry_path); } } Ok(()) } fn sha256_file(path: &Path) -> Result { let mut file = File::open(path).with_context(|| format!("open {}", path.display()))?; let mut hasher = Sha256::new(); let mut buf = [0_u8; 64 * 1024]; loop { let read = file .read(&mut buf) .with_context(|| format!("read {}", path.display()))?; if read == 0 { break; } hasher.update(&buf[..read]); } Ok(format!("{:x}", hasher.finalize())) } fn slash_path(path: &Path) -> String { path.components() .map(|component| component.as_os_str().to_string_lossy()) .collect::>() .join("/") } #[cfg(test)] mod tests { use std::fs; use tempfile::tempdir; use super::remove_server_config_dirs; #[test] fn removes_server_config_dirs_only() { let tmp = tempdir().unwrap(); let kit = tmp.path(); fs::create_dir_all(kit.join("server-configs-x")).unwrap(); fs::create_dir_all(kit.join("windows")).unwrap(); remove_server_config_dirs(kit).unwrap(); assert!(!kit.join("server-configs-x").exists()); assert!(kit.join("windows").exists()); } }