Skip to content

Commit ad750df

Browse files
authored
Merge pull request #10 from httpsphl/feat/m5-service
M5 (1/3): start at logon as a scheduled task, keep the computer awake
2 parents 7be6789 + 33edf6f commit ad750df

24 files changed

Lines changed: 1207 additions & 24 deletions

‎CLAUDE.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ cargo test --workspace
3131
cargo clippy --workspace --all-targets -- -D warnings
3232
cargo fmt --all --check
3333
cargo run -p botloftd -- serve # com BOTLOFT_HOME de dev (abaixo)
34+
cargo run -p botloftd -- service install # tarefa agendada própria da pasta de dev; `service uninstall` depois
3435
3536
# Tipos TS gerados a partir do Rust
3637
cargo test -p botloft-core export_bindings
@@ -68,6 +69,7 @@ Em dev, use `$env:BOTLOFT_HOME = "$PWD\.dev\home"` (caminho absoluto) no daemon
6869
- Permissão de arquivo é ACL (SID do usuário atual), não modo Unix.
6970
- Estado em `%LOCALAPPDATA%`, nunca `%APPDATA%` (roaming).
7071
- Cuidado com o limite de 260 caracteres em caminhos de workspace.
72+
- Tarefa agendada (spec 14): `RestartOnFailure` não reinicia quando o processo sai com erro, e a repetição de um gatilho de logon só começa no próximo logon. Quem traz o daemon de volta é um gatilho de horário repetido a cada minuto com `IgnoreNew`. Use a API COM do Agendador: a saída do `schtasks.exe` é traduzida.
7173

7274
## Segurança e privacidade
7375

‎Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎crates/botloftd/Cargo.toml‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@ tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
3333
workspace = true
3434

3535
[target."cfg(windows)".dependencies]
36-
windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Threading", "Win32_System_Memory", "Win32_System_JobObjects", "Win32_System_Environment"] }
36+
windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Threading", "Win32_System_Memory", "Win32_System_JobObjects", "Win32_System_Environment", "Win32_System_Power", "Win32_System_Console", "Win32_System_Com", "Win32_System_Ole", "Win32_System_Variant", "Win32_System_TaskScheduler"] }
37+
38+
[target."cfg(windows)".build-dependencies]
39+
embed-resource = "3.0.11"
3740

3841
[dev-dependencies]
3942
base64 = "0.23.1"

‎crates/botloftd/botloftd.manifest‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2+
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
3+
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
4+
<application>
5+
<!-- Windows 10 and 11 -->
6+
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
7+
</application>
8+
</compatibility>
9+
<application xmlns="urn:schemas-microsoft-com:asm.v3">
10+
<windowsSettings>
11+
<!-- Paths past 260 characters, for bots that run npm install (spec 14). -->
12+
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
13+
<!-- No console window when the scheduled task starts the daemon; a
14+
terminal still shares its console (Windows 11 24H2 and later). -->
15+
<consoleAllocationPolicy xmlns="http://schemas.microsoft.com/SMI/2024/WindowsSettings">detached</consoleAllocationPolicy>
16+
</windowsSettings>
17+
</application>
18+
</assembly>

‎crates/botloftd/botloftd.rc‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// The daemon's Windows resources: only its manifest (spec 14).
2+
#define RT_MANIFEST 24
3+
1 RT_MANIFEST "botloftd.manifest"

‎crates/botloftd/build.rs‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
//! Embeds `botloftd.manifest` in the Windows binary (spec 14). It goes in
2+
//! as a resource: the linker's own manifest tool predates
3+
//! `consoleAllocationPolicy` and warns about it on every build.
4+
5+
fn main() {
6+
println!("cargo:rerun-if-changed=botloftd.rc");
7+
println!("cargo:rerun-if-changed=botloftd.manifest");
8+
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows")
9+
&& let Err(err) =
10+
embed_resource::compile("botloftd.rc", embed_resource::NONE).manifest_required()
11+
{
12+
panic!("cannot embed the manifest: {err}");
13+
}
14+
}
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
//! The daemon runs from its own copy in `<home>\bin`, never from the app's
2+
//! folder, so the app's installer can replace its files while the daemon
3+
//! runs (spec 14).
4+
5+
use std::fs;
6+
use std::io;
7+
use std::path::{Path, PathBuf};
8+
9+
pub const EXE_NAME: &str = if cfg!(windows) {
10+
"botloftd.exe"
11+
} else {
12+
"botloftd"
13+
};
14+
const STAGED: &str = "botloftd.new";
15+
const OLD_SUFFIX: &str = ".old";
16+
17+
#[derive(Debug, Clone, PartialEq, Eq)]
18+
pub struct Installed {
19+
pub path: PathBuf,
20+
/// Whether a different binary was put in place.
21+
pub changed: bool,
22+
}
23+
24+
/// Copies `source` to `<bin_dir>\botloftd.exe`, unless it is that file or
25+
/// has the same bytes. A running exe cannot be overwritten but can be
26+
/// renamed, so the old one moves aside and is deleted on a later install.
27+
pub fn install(source: &Path, bin_dir: &Path) -> io::Result<Installed> {
28+
fs::create_dir_all(bin_dir)?;
29+
remove_old(bin_dir);
30+
let target = bin_dir.join(EXE_NAME);
31+
let unchanged = Installed {
32+
path: target.clone(),
33+
changed: false,
34+
};
35+
if is_same_file(source, &target) {
36+
return Ok(unchanged);
37+
}
38+
let bytes = fs::read(source)?;
39+
match fs::read(&target) {
40+
Ok(current) if current == bytes => return Ok(unchanged),
41+
Ok(_) => {}
42+
Err(err) if err.kind() == io::ErrorKind::NotFound => {}
43+
Err(err) => return Err(err),
44+
}
45+
let staged = bin_dir.join(STAGED);
46+
fs::write(&staged, &bytes)?;
47+
if target.exists() {
48+
fs::rename(&target, aside(bin_dir))?;
49+
}
50+
fs::rename(&staged, &target)?;
51+
Ok(Installed {
52+
path: target,
53+
changed: true,
54+
})
55+
}
56+
57+
/// Deletes the installed binary and what was moved aside, as far as
58+
/// nothing still runs them.
59+
pub fn remove(bin_dir: &Path) {
60+
let _ = fs::remove_file(bin_dir.join(EXE_NAME));
61+
let _ = fs::remove_file(bin_dir.join(STAGED));
62+
remove_old(bin_dir);
63+
let _ = fs::remove_dir(bin_dir);
64+
}
65+
66+
fn is_same_file(a: &Path, b: &Path) -> bool {
67+
match (fs::canonicalize(a), fs::canonicalize(b)) {
68+
(Ok(a), Ok(b)) => a == b,
69+
_ => false,
70+
}
71+
}
72+
73+
/// A free `botloftd.<n>.old` name. A previous one may still be running.
74+
fn aside(bin_dir: &Path) -> PathBuf {
75+
(1..)
76+
.map(|n| bin_dir.join(format!("botloftd.{n}{OLD_SUFFIX}")))
77+
.find(|path| !path.exists())
78+
.unwrap_or_else(|| bin_dir.join(format!("botloftd{OLD_SUFFIX}")))
79+
}
80+
81+
fn remove_old(bin_dir: &Path) {
82+
let Ok(entries) = fs::read_dir(bin_dir) else {
83+
return;
84+
};
85+
for entry in entries.flatten() {
86+
if entry.file_name().to_string_lossy().ends_with(OLD_SUFFIX) {
87+
// Still running: it goes next time.
88+
let _ = fs::remove_file(entry.path());
89+
}
90+
}
91+
}
92+
93+
#[cfg(test)]
94+
mod tests {
95+
use super::*;
96+
97+
fn names(dir: &Path) -> Vec<String> {
98+
let mut names: Vec<String> = fs::read_dir(dir)
99+
.expect("read dir")
100+
.map(|entry| {
101+
entry
102+
.expect("entry")
103+
.file_name()
104+
.to_string_lossy()
105+
.into_owned()
106+
})
107+
.collect();
108+
names.sort();
109+
names
110+
}
111+
112+
#[test]
113+
fn copies_once_and_replaces_only_a_different_binary() {
114+
let dir = tempfile::tempdir().expect("tempdir");
115+
let source = dir.path().join("sidecar.exe");
116+
let bin = dir.path().join("bin");
117+
fs::write(&source, b"v1").expect("write");
118+
119+
let first = install(&source, &bin).expect("install");
120+
assert!(first.changed);
121+
assert_eq!(fs::read(&first.path).expect("read"), b"v1");
122+
assert!(!install(&source, &bin).expect("again").changed);
123+
assert!(
124+
!install(&first.path, &bin).expect("itself").changed,
125+
"running install from the installed copy changes nothing"
126+
);
127+
128+
fs::write(&source, b"v2").expect("write");
129+
let second = install(&source, &bin).expect("update");
130+
assert!(second.changed);
131+
assert_eq!(fs::read(&second.path).expect("read"), b"v2");
132+
assert_eq!(names(&bin), ["botloftd.1.old".to_owned(), EXE_NAME.into()]);
133+
134+
fs::write(&source, b"v3").expect("write");
135+
install(&source, &bin).expect("update");
136+
assert_eq!(
137+
names(&bin),
138+
["botloftd.1.old".to_owned(), EXE_NAME.into()],
139+
"the v1 copy went away and v2 moved aside"
140+
);
141+
142+
remove(&bin);
143+
assert!(!bin.exists());
144+
}
145+
146+
/// A running exe is open with share-delete: it can be renamed, not
147+
/// written.
148+
#[cfg(windows)]
149+
#[test]
150+
fn replaces_a_binary_that_is_running() {
151+
use std::os::windows::fs::OpenOptionsExt as _;
152+
const FILE_SHARE_READ: u32 = 1;
153+
const FILE_SHARE_DELETE: u32 = 4;
154+
155+
let dir = tempfile::tempdir().expect("tempdir");
156+
let source = dir.path().join("sidecar.exe");
157+
let bin = dir.path().join("bin");
158+
fs::write(&source, b"v1").expect("write");
159+
let installed = install(&source, &bin).expect("install");
160+
let running = fs::OpenOptions::new()
161+
.read(true)
162+
.share_mode(FILE_SHARE_READ | FILE_SHARE_DELETE)
163+
.open(&installed.path)
164+
.expect("open");
165+
assert!(
166+
fs::write(&installed.path, b"x").is_err(),
167+
"cannot be written"
168+
);
169+
170+
fs::write(&source, b"v2").expect("write");
171+
let updated = install(&source, &bin).expect("update");
172+
assert_eq!(fs::read(&updated.path).expect("read"), b"v2");
173+
drop(running);
174+
}
175+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
//! Asking a daemon on 127.0.0.1 whether it is up, over plain HTTP.
2+
3+
use std::io::{Read as _, Write as _};
4+
use std::net::{Ipv4Addr, SocketAddr, TcpStream};
5+
use std::time::{Duration, Instant};
6+
7+
use serde::Deserialize;
8+
9+
const TIMEOUT: Duration = Duration::from_secs(2);
10+
11+
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
12+
pub struct Health {
13+
pub status: String,
14+
pub version: String,
15+
pub protocol: u32,
16+
}
17+
18+
/// The daemon's `/health`, or `None` if nothing Botloft answers.
19+
pub fn probe(port: u16) -> Option<Health> {
20+
let address = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
21+
let mut stream = TcpStream::connect_timeout(&address, TIMEOUT).ok()?;
22+
stream.set_read_timeout(Some(TIMEOUT)).ok()?;
23+
stream.set_write_timeout(Some(TIMEOUT)).ok()?;
24+
let request = "GET /health HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n";
25+
stream.write_all(request.as_bytes()).ok()?;
26+
let mut response = Vec::new();
27+
stream.read_to_end(&mut response).ok()?;
28+
let response = String::from_utf8_lossy(&response);
29+
let (head, body) = response.split_once("\r\n\r\n")?;
30+
if !head.starts_with("HTTP/1.1 200") {
31+
return None;
32+
}
33+
serde_json::from_str::<Health>(body)
34+
.ok()
35+
.filter(|health| health.status == "ok")
36+
}
37+
38+
/// Polls until `done` holds for the probe, or `limit` passes.
39+
pub fn wait(port: u16, limit: Duration, done: impl Fn(Option<&Health>) -> bool) -> bool {
40+
let started = Instant::now();
41+
loop {
42+
if done(probe(port).as_ref()) {
43+
return true;
44+
}
45+
if started.elapsed() > limit {
46+
return false;
47+
}
48+
std::thread::sleep(Duration::from_millis(250));
49+
}
50+
}

0 commit comments

Comments
 (0)