Initial: Holzleitner Mail-Client (Rust, Windows-Service)
Polling-Client, der beim Backend die noch nicht versendeten ausgelieferten Belege abfragt (GET /admin/delivered-belegnummern), ERPframe per CLI zum Mailversand anstößt (_SV_MAIL_VERSAND) und die Belege anschließend als versendet markiert (POST /admin/mark-mail-sent). Authentifizierung gegen das Backend per X-Admin-Api-Key. Enthält: Config-Laden (config.json, Vorlage config.example.json), Logging, Windows-Service-Wrapper (install-/uninstall-service.ps1). Nicht im Repo (.gitignore): config.json (Secrets: Admin-Key + ERPframe- Passwort), target/, logs/. config.example.json trägt nur Platzhalter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
89
src/config.rs
Normal file
89
src/config.rs
Normal file
@ -0,0 +1,89 @@
|
||||
//! Konfiguration aus `config.json` (liegt neben der EXE).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Config {
|
||||
/// Basis-URL des Backends, z. B. `http://10.168.10.2:3000` (ohne /admin).
|
||||
pub backend_base_url: String,
|
||||
/// Wert für den Header `X-Admin-Api-Key`.
|
||||
pub admin_api_key: String,
|
||||
|
||||
/// Poll-Intervall in Sekunden (Default 300 = 5 Min).
|
||||
#[serde(default = "d_poll")]
|
||||
pub poll_interval_secs: u64,
|
||||
/// Timeout je HTTP-Request in Sekunden (Default 30).
|
||||
#[serde(default = "d_timeout")]
|
||||
pub request_timeout_secs: u64,
|
||||
/// Timeout für den ERPframe-Prozess in Sekunden; 0 = unbegrenzt warten
|
||||
/// (Default 600 = 10 Min). Verhindert, dass ein hängender ERPframe-Aufruf
|
||||
/// die Schleife dauerhaft blockiert.
|
||||
#[serde(default = "d_erp_timeout")]
|
||||
pub erp_timeout_secs: u64,
|
||||
/// Trennzeichen, mit dem die Belegnummern zu EINEM ERPframe-Argument
|
||||
/// verbunden werden (Default ",").
|
||||
#[serde(default = "d_sep")]
|
||||
pub belegnummer_separator: String,
|
||||
/// Log-Verzeichnis; relativ ⇒ relativ zum EXE-Verzeichnis (Default "logs").
|
||||
#[serde(default = "d_logdir")]
|
||||
pub log_dir: String,
|
||||
|
||||
pub erpframe: ErpframeConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ErpframeConfig {
|
||||
/// Voller Pfad zu ERPFRAME.EXE.
|
||||
pub exe_path: String,
|
||||
/// Erstes Argument (wie im PowerShell-Skript), Default "AUTOSTARTUP".
|
||||
#[serde(default = "d_mode")]
|
||||
pub mode: String,
|
||||
pub app_name: String,
|
||||
pub user: String,
|
||||
pub password: String,
|
||||
/// Das ERPframe-Kommando/Makro, das die Mails versendet.
|
||||
pub command: String,
|
||||
/// Arbeitsverzeichnis; `null` ⇒ EXE-Verzeichnis.
|
||||
#[serde(default)]
|
||||
pub working_dir: Option<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load(path: &Path) -> Result<Self> {
|
||||
let raw = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("Datei lesen: {}", path.display()))?;
|
||||
let cfg: Config = serde_json::from_str(&raw).context("config.json parsen")?;
|
||||
if cfg.backend_base_url.trim().is_empty() {
|
||||
anyhow::bail!("backend_base_url ist leer");
|
||||
}
|
||||
if cfg.admin_api_key.trim().is_empty() {
|
||||
anyhow::bail!("admin_api_key ist leer");
|
||||
}
|
||||
if cfg.erpframe.exe_path.trim().is_empty() {
|
||||
anyhow::bail!("erpframe.exe_path ist leer");
|
||||
}
|
||||
Ok(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
fn d_poll() -> u64 {
|
||||
300
|
||||
}
|
||||
fn d_timeout() -> u64 {
|
||||
30
|
||||
}
|
||||
fn d_erp_timeout() -> u64 {
|
||||
600
|
||||
}
|
||||
fn d_sep() -> String {
|
||||
",".into()
|
||||
}
|
||||
fn d_logdir() -> String {
|
||||
"logs".into()
|
||||
}
|
||||
fn d_mode() -> String {
|
||||
"AUTOSTARTUP".into()
|
||||
}
|
||||
45
src/logging.rs
Normal file
45
src/logging.rs
Normal file
@ -0,0 +1,45 @@
|
||||
//! Schlanker Datei-Logger: schreibt zeitgestempelte Zeilen in ein Tageslog
|
||||
//! (`<dir>/mailclient_YYYY-MM-DD.log`) UND auf stdout — analog zum
|
||||
//! PowerShell-Skript, ohne zusätzliche Logging-Crates.
|
||||
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct Logger {
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Logger {
|
||||
pub fn new(dir: impl Into<PathBuf>) -> std::io::Result<Self> {
|
||||
let dir = dir.into();
|
||||
fs::create_dir_all(&dir)?;
|
||||
Ok(Self { dir })
|
||||
}
|
||||
|
||||
fn write(&self, level: &str, msg: &str) {
|
||||
let now = chrono::Local::now();
|
||||
let line = format!(
|
||||
"[{}] [{}] {}",
|
||||
now.format("%Y-%m-%d %H:%M:%S"),
|
||||
level,
|
||||
msg
|
||||
);
|
||||
println!("{line}");
|
||||
let file = self.dir.join(format!("mailclient_{}.log", now.format("%Y-%m-%d")));
|
||||
if let Ok(mut f) = fs::OpenOptions::new().create(true).append(true).open(&file) {
|
||||
let _ = writeln!(f, "{line}");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn info(&self, msg: &str) {
|
||||
self.write("INFO", msg);
|
||||
}
|
||||
#[allow(dead_code)] // Teil der Logger-API, aktuell nicht genutzt
|
||||
pub fn warn(&self, msg: &str) {
|
||||
self.write("WARN", msg);
|
||||
}
|
||||
pub fn error(&self, msg: &str) {
|
||||
self.write("ERROR", msg);
|
||||
}
|
||||
}
|
||||
307
src/main.rs
Normal file
307
src/main.rs
Normal file
@ -0,0 +1,307 @@
|
||||
//! Holzleitner Mail-Versende-Client (Windows).
|
||||
//!
|
||||
//! Läuft entweder als **Windows-Dienst** (vom SCM gestartet) oder im
|
||||
//! **Konsolenmodus** (interaktiv / zum Testen). In beiden Fällen pollt er alle
|
||||
//! `poll_interval_secs` (Default 300 = 5 Min):
|
||||
//! 1. GET {backend}/admin/delivered-belegnummern (offene, noch nicht
|
||||
//! versendete Belege; Header X-Admin-Api-Key)
|
||||
//! 2. Wenn nicht leer: EIN Aufruf von ERPFRAME.EXE mit allen Belegnummern
|
||||
//! (Format 'V-1','V-2') — ERPframe verschickt die Mails.
|
||||
//! 3. Nur bei ExitCode 0: POST {backend}/admin/mark-mail-sent → markiert die
|
||||
//! Belege server-seitig als versendet (Dedup).
|
||||
//!
|
||||
//! Erst ERPframe, dann markieren ⇒ schlägt ERPframe fehl, bleiben die Belege
|
||||
//! offen und werden beim nächsten Tick erneut versucht.
|
||||
|
||||
mod config;
|
||||
mod logging;
|
||||
#[cfg(windows)]
|
||||
mod service;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::logging::Logger;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DeliveredResponse {
|
||||
#[serde(default)]
|
||||
belegnummern: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct MarkResponse {
|
||||
#[serde(default)]
|
||||
marked: u64,
|
||||
}
|
||||
|
||||
/// Verzeichnis der laufenden EXE. Wichtig, weil ein Dienst mit Arbeits-
|
||||
/// verzeichnis `C:\Windows\System32` startet — config.json/logs werden daher
|
||||
/// IMMER relativ zur EXE aufgelöst, nicht relativ zum cwd.
|
||||
fn exe_dir() -> PathBuf {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(|d| d.to_path_buf()))
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Unter Windows: standardmäßig versuchen, als Dienst zu starten. Wird die
|
||||
// EXE interaktiv gestartet (Doppelklick / Konsole), scheitert der
|
||||
// SCM-Dispatcher (nicht vom SCM gestartet) → Konsolenmodus. `--console`
|
||||
// erzwingt den Konsolenmodus direkt.
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let console = std::env::args().any(|a| a == "--console" || a == "-c");
|
||||
if !console {
|
||||
match service::run() {
|
||||
Ok(()) => return, // lief als Dienst, sauber beendet
|
||||
Err(_e) => { /* interaktiv gestartet → Konsole */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
run_console();
|
||||
}
|
||||
|
||||
/// Konsolenmodus: eigene tokio-Runtime, Stop via Ctrl-C.
|
||||
fn run_console() {
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("tokio runtime");
|
||||
rt.block_on(run_app(async {
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
}));
|
||||
}
|
||||
|
||||
/// Lädt Konfiguration + Logger und fährt die Poll-Schleife, bis `shutdown`
|
||||
/// feuert (Ctrl-C im Konsolenmodus, Stop/Shutdown vom SCM im Dienstmodus).
|
||||
/// Gibt selbst keine Fehler zurück — alles wird geloggt (im Dienst gibt es
|
||||
/// keine Konsole; vor dem Logger greift `fallback_log`).
|
||||
pub(crate) async fn run_app(shutdown: impl std::future::Future<Output = ()>) {
|
||||
let base_dir = exe_dir();
|
||||
|
||||
let cfg = match Config::load(&base_dir.join("config.json")) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
fallback_log(&base_dir, &format!("Konfiguration konnte nicht geladen werden: {e:#}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let log_dir = if Path::new(&cfg.log_dir).is_absolute() {
|
||||
PathBuf::from(&cfg.log_dir)
|
||||
} else {
|
||||
base_dir.join(&cfg.log_dir)
|
||||
};
|
||||
let logger = match Logger::new(&log_dir) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
fallback_log(&base_dir, &format!("Log-Verzeichnis konnte nicht angelegt werden: {e:#}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
logger.info(&format!(
|
||||
"Mailclient gestartet. backend={} intervall={}s erpframe={}",
|
||||
cfg.backend_base_url, cfg.poll_interval_secs, cfg.erpframe.exe_path
|
||||
));
|
||||
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(cfg.request_timeout_secs))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
logger.error(&format!("HTTP-Client konnte nicht gebaut werden: {e:#}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut ticker = tokio::time::interval(Duration::from_secs(cfg.poll_interval_secs));
|
||||
tokio::pin!(shutdown);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = ticker.tick() => {
|
||||
if let Err(e) = run_tick(&cfg, &client, &logger).await {
|
||||
logger.error(&format!("Durchlauf fehlgeschlagen: {e:#}"));
|
||||
}
|
||||
}
|
||||
_ = &mut shutdown => {
|
||||
logger.info("Beende (Stop-Signal).");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Notfall-Logging, wenn der reguläre Logger (noch) nicht steht — schreibt nach
|
||||
/// `<exe-dir>\mailclient-fatal.log`. Im Dienstmodus die einzige Spur, falls
|
||||
/// schon das Laden von config.json scheitert.
|
||||
fn fallback_log(dir: &Path, msg: &str) {
|
||||
eprintln!("{msg}");
|
||||
let file = dir.join("mailclient-fatal.log");
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&file) {
|
||||
use std::io::Write;
|
||||
let _ = writeln!(
|
||||
f,
|
||||
"[{}] {}",
|
||||
chrono::Local::now().format("%Y-%m-%d %H:%M:%S"),
|
||||
msg
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Poll-Durchlauf. Fehler werden zurückgegeben (vom Aufrufer geloggt) und
|
||||
/// brechen die Schleife NICHT ab.
|
||||
async fn run_tick(cfg: &Config, client: &reqwest::Client, logger: &Logger) -> Result<()> {
|
||||
let base = cfg.backend_base_url.trim_end_matches('/');
|
||||
|
||||
// 1) Offene Belege holen (ohne day ⇒ alle offenen).
|
||||
let url = format!("{base}/admin/delivered-belegnummern");
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("X-Admin-Api-Key", &cfg.admin_api_key)
|
||||
.send()
|
||||
.await
|
||||
.context("GET delivered-belegnummern")?;
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow!("delivered-belegnummern HTTP {status}: {body}"));
|
||||
}
|
||||
let data: DeliveredResponse = resp.json().await.context("Antwort parsen")?;
|
||||
let belege = data.belegnummern;
|
||||
|
||||
if belege.is_empty() {
|
||||
logger.info("Keine offenen Belege.");
|
||||
return Ok(());
|
||||
}
|
||||
let liste = belege.join(", ");
|
||||
logger.info(&format!("{} offene Beleg(e) gefunden: {}", belege.len(), liste));
|
||||
|
||||
// 2) ERPframe aufrufen (ein Aufruf, alle Belege). Fehler ⇒ NICHT markieren,
|
||||
// Belege bleiben offen und werden beim nächsten Tick erneut versucht.
|
||||
if let Err(e) = run_erpframe(cfg, logger, &belege).await {
|
||||
logger.error(&format!(
|
||||
"ERPframe-Versand fehlgeschlagen — {} Beleg(e) bleiben offen [{}]: {e:#}",
|
||||
belege.len(),
|
||||
liste
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 3) Server-seitig als versendet markieren.
|
||||
let mark_url = format!("{base}/admin/mark-mail-sent");
|
||||
let result = client
|
||||
.post(&mark_url)
|
||||
.header("X-Admin-Api-Key", &cfg.admin_api_key)
|
||||
.json(&serde_json::json!({ "belegnummern": belege }))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let marked = resp
|
||||
.json::<MarkResponse>()
|
||||
.await
|
||||
.map(|m| m.marked)
|
||||
.unwrap_or(0);
|
||||
// DAS ist die zentrale „erfolgreich versendet"-Zeile:
|
||||
logger.info(&format!(
|
||||
"VERSENDET: {} Beleg(e) verschickt + als versendet markiert: [{}] (frisch markiert: {})",
|
||||
belege.len(),
|
||||
liste,
|
||||
marked
|
||||
));
|
||||
}
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
// Mails sind raus, aber Markierung schlug fehl → Doppelversand-Risiko.
|
||||
logger.error(&format!(
|
||||
"ACHTUNG: ERPframe-Versand OK, aber mark-mail-sent HTTP {status} — Belege [{}] \
|
||||
bleiben offen und werden beim nächsten Tick ERNEUT versendet! Antwort: {body}",
|
||||
liste
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
logger.error(&format!(
|
||||
"ACHTUNG: ERPframe-Versand OK, aber mark-mail-sent nicht erreichbar — Belege [{}] \
|
||||
bleiben offen und werden beim nächsten Tick ERNEUT versendet! Fehler: {e:#}",
|
||||
liste
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Startet ERPFRAME.EXE synchron (ein Aufruf, alle Belege als ein Argument)
|
||||
/// und wartet auf das Ende. Argumente wie im PowerShell-Skript:
|
||||
/// ERPFRAME.EXE <mode> <app_name> <user> <password> <command> <belege>
|
||||
/// Belege im Format 'V-1','V-2' (jede Nr. single-quoted, kommagetrennt).
|
||||
async fn run_erpframe(cfg: &Config, logger: &Logger, belege: &[String]) -> Result<()> {
|
||||
let e = &cfg.erpframe;
|
||||
// Jede Belegnummer in einfache Anführungszeichen, mit dem konfigurierten
|
||||
// Trenner verbunden → 'V-30690288','V-30690290' (Format, das das
|
||||
// ERPframe-Makro erwartet, direkt für SQL `IN (...)` verwendbar).
|
||||
let belege_csv = belege
|
||||
.iter()
|
||||
.map(|b| format!("'{b}'"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(&cfg.belegnummer_separator);
|
||||
|
||||
let mut cmd = tokio::process::Command::new(&e.exe_path);
|
||||
cmd.arg(&e.mode)
|
||||
.arg(&e.app_name)
|
||||
.arg(&e.user)
|
||||
.arg(&e.password)
|
||||
.arg(&e.command)
|
||||
.arg(&belege_csv);
|
||||
|
||||
// Arbeitsverzeichnis: konfiguriert, sonst EXE-Verzeichnis.
|
||||
if let Some(wd) = &e.working_dir {
|
||||
cmd.current_dir(wd);
|
||||
} else if let Some(parent) = Path::new(&e.exe_path).parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
cmd.current_dir(parent);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(&format!(
|
||||
"Starte ERPframe: {} {} \"{}\" \"{}\" \"***\" \"{}\" \"{}\"",
|
||||
e.exe_path, e.mode, e.app_name, e.user, e.command, belege_csv
|
||||
));
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.with_context(|| format!("ERPframe starten: {}", e.exe_path))?;
|
||||
|
||||
let status = if cfg.erp_timeout_secs > 0 {
|
||||
match tokio::time::timeout(Duration::from_secs(cfg.erp_timeout_secs), child.wait()).await {
|
||||
Ok(s) => s.context("ERPframe abwarten")?,
|
||||
Err(_) => {
|
||||
let _ = child.kill().await;
|
||||
return Err(anyhow!(
|
||||
"ERPframe Timeout nach {}s — Prozess beendet",
|
||||
cfg.erp_timeout_secs
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
child.wait().await.context("ERPframe abwarten")?
|
||||
};
|
||||
|
||||
if status.success() {
|
||||
logger.info("ERPframe erfolgreich (ExitCode 0).");
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("ERPframe ExitCode {:?}", status.code()))
|
||||
}
|
||||
}
|
||||
97
src/service.rs
Normal file
97
src/service.rs
Normal file
@ -0,0 +1,97 @@
|
||||
//! Windows-Dienst-Integration (Service Control Manager). Nur unter Windows
|
||||
//! kompiliert (`#[cfg(windows)]` am `mod service` in `main.rs`).
|
||||
//!
|
||||
//! Ablauf: `run()` übergibt den Prozess an den SCM. Der SCM ruft `service_main`
|
||||
//! in einem eigenen Thread; dort registrieren wir einen Control-Handler
|
||||
//! (für Stop/Shutdown), melden „Running", starten eine eigene tokio-Runtime und
|
||||
//! lassen `crate::run_app` laufen, bis der Handler ein Stop-Signal schickt.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use windows_service::service::{
|
||||
ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus, ServiceType,
|
||||
};
|
||||
use windows_service::service_control_handler::{self, ServiceControlHandlerResult};
|
||||
use windows_service::{define_windows_service, service_dispatcher};
|
||||
|
||||
/// Interner Dienst-Name (Schlüssel). **Muss** mit dem `-Name` im
|
||||
/// Install-Skript (`install-service.ps1`) übereinstimmen. Der Anzeigename
|
||||
/// „Holzleitner App Mails" wird separat beim Registrieren gesetzt.
|
||||
const SERVICE_NAME: &str = "HolzleitnerAppMails";
|
||||
const SERVICE_TYPE: ServiceType = ServiceType::OWN_PROCESS;
|
||||
|
||||
define_windows_service!(ffi_service_main, service_main);
|
||||
|
||||
/// Übergibt den Prozess an den SCM. Gibt `Err` zurück, wenn der Prozess NICHT
|
||||
/// vom SCM gestartet wurde (interaktiver Start) — der Aufrufer fällt dann in
|
||||
/// den Konsolenmodus zurück.
|
||||
pub fn run() -> windows_service::Result<()> {
|
||||
service_dispatcher::start(SERVICE_NAME, ffi_service_main)
|
||||
}
|
||||
|
||||
fn service_main(_args: Vec<OsString>) {
|
||||
// Fehler hier landen (falls vor dem Logger) im fallback_log von run_app bzw.
|
||||
// werden vom SCM als nicht-laufend erkannt.
|
||||
let _ = run_service();
|
||||
}
|
||||
|
||||
fn run_service() -> windows_service::Result<()> {
|
||||
// Kanal: Control-Handler -> Loop. Stop/Shutdown sendet ein () hinein.
|
||||
let (shutdown_tx, shutdown_rx) = mpsc::channel::<()>();
|
||||
|
||||
let handler = move |control| -> ServiceControlHandlerResult {
|
||||
match control {
|
||||
ServiceControl::Stop | ServiceControl::Shutdown => {
|
||||
let _ = shutdown_tx.send(());
|
||||
ServiceControlHandlerResult::NoError
|
||||
}
|
||||
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
|
||||
_ => ServiceControlHandlerResult::NotImplemented,
|
||||
}
|
||||
};
|
||||
|
||||
let status_handle = service_control_handler::register(SERVICE_NAME, handler)?;
|
||||
|
||||
// „Running" melden (akzeptiert Stop + System-Shutdown).
|
||||
status_handle.set_service_status(ServiceStatus {
|
||||
service_type: SERVICE_TYPE,
|
||||
current_state: ServiceState::Running,
|
||||
controls_accepted: ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
|
||||
exit_code: ServiceExitCode::Win32(0),
|
||||
checkpoint: 0,
|
||||
wait_hint: Duration::default(),
|
||||
process_id: None,
|
||||
})?;
|
||||
|
||||
// Eigene tokio-Runtime im Dienst-Thread (kein #[tokio::main]).
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("tokio runtime");
|
||||
rt.block_on(async move {
|
||||
// mpsc::recv ist blockierend → in spawn_blocking auslagern und als
|
||||
// Future an run_app übergeben.
|
||||
let shutdown = async move {
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
let _ = shutdown_rx.recv();
|
||||
})
|
||||
.await;
|
||||
};
|
||||
crate::run_app(shutdown).await;
|
||||
});
|
||||
|
||||
// „Stopped" melden.
|
||||
status_handle.set_service_status(ServiceStatus {
|
||||
service_type: SERVICE_TYPE,
|
||||
current_state: ServiceState::Stopped,
|
||||
controls_accepted: ServiceControlAccept::empty(),
|
||||
exit_code: ServiceExitCode::Win32(0),
|
||||
checkpoint: 0,
|
||||
wait_hint: Duration::default(),
|
||||
process_id: None,
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user