CLAUDE.md
CLAUDE.md
This file has not been changed.
Cargo.lock
Cargo.lock
This file has not been changed.
Cargo.toml
Cargo.toml
This file has not been changed.
README.md
README.md
This file has not been changed.
ein/Cargo.toml
ein/Cargo.toml
This file has not been changed.
ein/src/app.rs
ein/src/app.rs
This file has not been changed.
ein/src/bootstrap.rs
ein/src/bootstrap.rs
ein/src/config.rs
ein/src/config.rs
This file has not been changed.
ein/src/connection.rs
ein/src/connection.rs
This file has not been changed.
ein/src/input.rs
ein/src/input.rs
This file has not been changed.
ein/src/lib.rs
ein/src/lib.rs
This file has not been changed.
ein/src/main.rs
ein/src/main.rs
This file has not been changed.
ein/src/render.rs
ein/src/render.rs
This file has not been changed.
-368
crates/ein-tui/src/bootstrap.rs
-368
crates/ein-tui/src/bootstrap.rs
···
1
-
// SPDX-License-Identifier: Apache-2.0
2
-
// Copyright 2026 Mason Stallmo
3
-
4
-
//! Bootstrap logic: downloads `eind` on first run and registers it as a
5
-
//! system service (macOS LaunchAgent or Linux systemd user service).
6
-
7
-
// These items are only called from the #[cfg(not(debug_assertions))] block in
8
-
// lib.rs, so they appear unused in debug builds. That's intentional.
9
-
#![cfg_attr(debug_assertions, allow(dead_code))]
10
-
11
-
use anyhow::{Context, Result};
12
-
use std::{
13
-
io,
14
-
os::unix::fs::PermissionsExt,
15
-
path::{Path, PathBuf},
16
-
};
17
-
use tar::Archive;
18
-
use tokio::{fs, process::Command, task};
19
-
use xz2::read::XzDecoder;
20
-
21
-
const GITHUB_REPO: &str = "mstallmo/ein";
22
-
23
-
/// Path where `ein` installs the server binary: `~/.ein/bin/eind`.
24
-
pub fn server_bin_path() -> PathBuf {
25
-
dirs::home_dir()
26
-
.expect("home directory not found")
27
-
.join(".ein")
28
-
.join("bin")
29
-
.join("eind")
30
-
}
31
-
32
-
/// Compile-time target triple used to select the right GitHub release asset.
33
-
pub fn target_triple() -> &'static str {
34
-
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
35
-
return "aarch64-apple-darwin";
36
-
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
37
-
return "x86_64-apple-darwin";
38
-
#[cfg(all(target_os = "linux", target_arch = "aarch64"))]
39
-
return "aarch64-unknown-linux-gnu";
40
-
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
41
-
return "x86_64-unknown-linux-gnu";
42
-
#[allow(unreachable_code)]
43
-
""
44
-
}
45
-
46
-
/// Downloads the `eind` binary for the current platform from GitHub
47
-
/// releases and writes it to `~/.ein/bin/eind` with executable permissions.
48
-
pub async fn download_server(version: &str) -> Result<()> {
49
-
let ver = version.trim_start_matches('v');
50
-
let tag = format!("v{ver}");
51
-
let triple = target_triple();
52
-
// cargo-dist names archives as "{package}-{triple}.tar.xz" (no version in filename).
53
-
let archive_name = format!("eind-{triple}.tar.xz");
54
-
let url = format!("https://github.com/{GITHUB_REPO}/releases/download/{tag}/{archive_name}");
55
-
56
-
let dest = server_bin_path();
57
-
fs::create_dir_all(dest.parent().unwrap())
58
-
.await
59
-
.context("failed to create ~/.ein/bin")?;
60
-
61
-
println!("Downloading {url}...");
62
-
63
-
let response = reqwest::get(&url)
64
-
.await
65
-
.with_context(|| format!("failed to fetch {url}"))?;
66
-
67
-
if !response.status().is_success() {
68
-
anyhow::bail!("download failed: HTTP {}", response.status());
69
-
}
70
-
71
-
let bytes = response
72
-
.bytes()
73
-
.await
74
-
.context("failed to read response body")?;
75
-
76
-
let dest_clone = dest.clone();
77
-
task::spawn_blocking(move || extract_server(&bytes, &dest_clone))
78
-
.await
79
-
.context("extraction task panicked")??;
80
-
81
-
// Make the binary executable.
82
-
let mut perms = fs::metadata(&dest).await?.permissions();
83
-
perms.set_mode(0o755);
84
-
fs::set_permissions(&dest, perms).await?;
85
-
86
-
println!("eind installed to {}", dest.display());
87
-
Ok(())
88
-
}
89
-
90
-
/// Extracts the `eind` binary from a tar.xz archive into `dest`.
91
-
fn extract_server(bytes: &[u8], dest: &Path) -> Result<()> {
92
-
let xz = XzDecoder::new(io::Cursor::new(bytes));
93
-
let mut archive = Archive::new(xz);
94
-
95
-
for entry in archive
96
-
.entries()
97
-
.context("failed to read archive entries")?
98
-
{
99
-
let mut entry = entry.context("corrupt archive entry")?;
100
-
let entry_path = entry.path().context("entry has no path")?;
101
-
102
-
// The archive contains exactly one file: the `eind` binary.
103
-
// Accept it regardless of any leading directory component.
104
-
let file_name = entry_path
105
-
.file_name()
106
-
.and_then(|n| n.to_str())
107
-
.unwrap_or("");
108
-
109
-
if file_name == "eind" {
110
-
let mut file = std::fs::File::create(dest)
111
-
.with_context(|| format!("failed to create {}", dest.display()))?;
112
-
io::copy(&mut entry, &mut file).context("failed to write eind")?;
113
-
return Ok(());
114
-
}
115
-
}
116
-
117
-
anyhow::bail!("eind binary not found in archive")
118
-
}
119
-
120
-
// ---------------------------------------------------------------------------
121
-
// Service registration
122
-
// ---------------------------------------------------------------------------
123
-
124
-
/// Ensures `eind` is registered as a system service.
125
-
///
126
-
/// On macOS, installs a LaunchAgent plist and loads it.
127
-
/// On Linux, writes a systemd user unit and enables it.
128
-
/// On other platforms, does nothing (the TUI's retry loop handles reconnects).
129
-
pub async fn ensure_service_installed() -> Result<()> {
130
-
#[cfg(target_os = "macos")]
131
-
return ensure_launchagent_installed().await;
132
-
133
-
#[cfg(target_os = "linux")]
134
-
return ensure_systemd_installed().await;
135
-
136
-
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
137
-
Ok(())
138
-
}
139
-
140
-
// ---------------------------------------------------------------------------
141
-
// Uninstall
142
-
// ---------------------------------------------------------------------------
143
-
144
-
/// Stops and removes the `eind` service and binary installed by
145
-
/// [`ensure_service_installed`] and [`download_server`].
146
-
///
147
-
/// Returns a list of completed step descriptions for display in the TUI.
148
-
/// User config and session data in `~/.ein/` are left intact.
149
-
pub async fn uninstall() -> Result<Vec<String>> {
150
-
let mut steps: Vec<String> = Vec::new();
151
-
#[cfg(target_os = "macos")]
152
-
uninstall_launchagent(&mut steps).await?;
153
-
#[cfg(target_os = "linux")]
154
-
uninstall_systemd(&mut steps).await?;
155
-
remove_server_binary(&mut steps).await?;
156
-
Ok(steps)
157
-
}
158
-
159
-
async fn remove_server_binary(steps: &mut Vec<String>) -> Result<()> {
160
-
let path = server_bin_path();
161
-
if path.exists() {
162
-
fs::remove_file(&path)
163
-
.await
164
-
.with_context(|| format!("failed to remove {}", path.display()))?;
165
-
steps.push(format!("Removed {}", path.display()));
166
-
}
167
-
Ok(())
168
-
}
169
-
170
-
#[cfg(target_os = "macos")]
171
-
async fn uninstall_launchagent(steps: &mut Vec<String>) -> Result<()> {
172
-
let plist = launchagent_plist_path();
173
-
// Ignore errors โ the service may already be stopped/unloaded.
174
-
let _ = Command::new("launchctl")
175
-
.args(["unload", plist.to_str().unwrap_or("")])
176
-
.output()
177
-
.await;
178
-
if plist.exists() {
179
-
fs::remove_file(&plist)
180
-
.await
181
-
.with_context(|| format!("failed to remove {}", plist.display()))?;
182
-
steps.push(format!("Removed LaunchAgent ({})", LAUNCH_AGENT_LABEL));
183
-
}
184
-
Ok(())
185
-
}
186
-
187
-
#[cfg(target_os = "linux")]
188
-
async fn uninstall_systemd(steps: &mut Vec<String>) -> Result<()> {
189
-
let unit = systemd_unit_path();
190
-
let _ = Command::new("systemctl")
191
-
.args(["--user", "stop", SYSTEMD_SERVICE_NAME])
192
-
.output()
193
-
.await;
194
-
let _ = Command::new("systemctl")
195
-
.args(["--user", "disable", SYSTEMD_SERVICE_NAME])
196
-
.output()
197
-
.await;
198
-
if unit.exists() {
199
-
fs::remove_file(&unit)
200
-
.await
201
-
.with_context(|| format!("failed to remove {}", unit.display()))?;
202
-
steps.push(format!(
203
-
"Removed systemd user service ({})",
204
-
SYSTEMD_SERVICE_NAME
205
-
));
206
-
}
207
-
let _ = Command::new("systemctl")
208
-
.args(["--user", "daemon-reload"])
209
-
.output()
210
-
.await;
211
-
Ok(())
212
-
}
213
-
214
-
// ---------------------------------------------------------------------------
215
-
// macOS LaunchAgent
216
-
// ---------------------------------------------------------------------------
217
-
218
-
#[cfg(target_os = "macos")]
219
-
const LAUNCH_AGENT_LABEL: &str = "com.ein.server";
220
-
221
-
#[cfg(target_os = "macos")]
222
-
fn launchagent_plist_path() -> PathBuf {
223
-
dirs::home_dir()
224
-
.expect("home directory not found")
225
-
.join("Library")
226
-
.join("LaunchAgents")
227
-
.join(format!("{LAUNCH_AGENT_LABEL}.plist"))
228
-
}
229
-
230
-
#[cfg(target_os = "macos")]
231
-
async fn ensure_launchagent_installed() -> Result<()> {
232
-
// Check if already loaded.
233
-
let status = Command::new("launchctl")
234
-
.args(["list", LAUNCH_AGENT_LABEL])
235
-
.output()
236
-
.await
237
-
.context("launchctl not found")?;
238
-
239
-
if status.status.success() {
240
-
return Ok(()); // Already running.
241
-
}
242
-
243
-
let plist_path = launchagent_plist_path();
244
-
let bin = server_bin_path();
245
-
let log = dirs::home_dir()
246
-
.expect("home directory not found")
247
-
.join(".ein")
248
-
.join("server.log");
249
-
250
-
let plist = format!(
251
-
r#"<?xml version="1.0" encoding="UTF-8"?>
252
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
253
-
<plist version="1.0">
254
-
<dict>
255
-
<key>Label</key>
256
-
<string>{LAUNCH_AGENT_LABEL}</string>
257
-
<key>ProgramArguments</key>
258
-
<array>
259
-
<string>{bin}</string>
260
-
</array>
261
-
<key>RunAtLoad</key>
262
-
<true/>
263
-
<key>KeepAlive</key>
264
-
<true/>
265
-
<key>StandardOutPath</key>
266
-
<string>{log}</string>
267
-
<key>StandardErrorPath</key>
268
-
<string>{log}</string>
269
-
</dict>
270
-
</plist>
271
-
"#,
272
-
LAUNCH_AGENT_LABEL = LAUNCH_AGENT_LABEL,
273
-
bin = bin.display(),
274
-
log = log.display(),
275
-
);
276
-
277
-
fs::create_dir_all(plist_path.parent().unwrap())
278
-
.await
279
-
.context("failed to create LaunchAgents directory")?;
280
-
fs::write(&plist_path, plist)
281
-
.await
282
-
.context("failed to write plist")?;
283
-
284
-
let output = Command::new("launchctl")
285
-
.args(["load", plist_path.to_str().unwrap()])
286
-
.output()
287
-
.await
288
-
.context("failed to run launchctl load")?;
289
-
290
-
if !output.status.success() {
291
-
let stderr = String::from_utf8_lossy(&output.stderr);
292
-
anyhow::bail!("launchctl load failed: {stderr}");
293
-
}
294
-
295
-
println!("eind registered as LaunchAgent ({LAUNCH_AGENT_LABEL})");
296
-
Ok(())
297
-
}
298
-
299
-
// ---------------------------------------------------------------------------
300
-
// Linux systemd user service
301
-
// ---------------------------------------------------------------------------
302
-
303
-
#[cfg(target_os = "linux")]
304
-
const SYSTEMD_SERVICE_NAME: &str = "eind";
305
-
306
-
#[cfg(target_os = "linux")]
307
-
fn systemd_unit_path() -> PathBuf {
308
-
dirs::home_dir()
309
-
.expect("home directory not found")
310
-
.join(".config")
311
-
.join("systemd")
312
-
.join("user")
313
-
.join(format!("{SYSTEMD_SERVICE_NAME}.service"))
314
-
}
315
-
316
-
#[cfg(target_os = "linux")]
317
-
async fn ensure_systemd_installed() -> Result<()> {
318
-
// Check if already enabled.
319
-
let status = Command::new("systemctl")
320
-
.args(["--user", "is-enabled", SYSTEMD_SERVICE_NAME])
321
-
.output()
322
-
.await
323
-
.context("systemctl not found")?;
324
-
325
-
if status.status.success() {
326
-
return Ok(()); // Already enabled.
327
-
}
328
-
329
-
let unit_path = systemd_unit_path();
330
-
let bin = server_bin_path();
331
-
332
-
let unit = format!(
333
-
"[Unit]\nDescription=Ein server\n\n[Service]\nExecStart={bin}\nRestart=always\n\n[Install]\nWantedBy=default.target\n",
334
-
bin = bin.display(),
335
-
);
336
-
337
-
fs::create_dir_all(unit_path.parent().unwrap())
338
-
.await
339
-
.context("failed to create systemd user directory")?;
340
-
fs::write(&unit_path, unit)
341
-
.await
342
-
.context("failed to write systemd unit")?;
343
-
344
-
let reload = Command::new("systemctl")
345
-
.args(["--user", "daemon-reload"])
346
-
.output()
347
-
.await
348
-
.context("failed to run systemctl daemon-reload")?;
349
-
350
-
if !reload.status.success() {
351
-
let stderr = String::from_utf8_lossy(&reload.stderr);
352
-
anyhow::bail!("systemctl daemon-reload failed: {stderr}");
353
-
}
354
-
355
-
let enable = Command::new("systemctl")
356
-
.args(["--user", "enable", "--now", SYSTEMD_SERVICE_NAME])
357
-
.output()
358
-
.await
359
-
.context("failed to run systemctl enable")?;
360
-
361
-
if !enable.status.success() {
362
-
let stderr = String::from_utf8_lossy(&enable.stderr);
363
-
anyhow::bail!("systemctl enable failed: {stderr}");
364
-
}
365
-
366
-
println!("eind registered as systemd user service ({SYSTEMD_SERVICE_NAME})");
367
-
Ok(())
368
-
}
History
7 rounds
0 comments
mstallmo.com
submitted
#6
1 commit
expand
collapse
Rename
ein-tui to ein. Move from crates/ directory to top level project directory.
merge conflicts detected
expand
collapse
expand
collapse
- CLAUDE.md:4
- Cargo.lock:970
- Cargo.toml:1
- README.md:4
expand 0 comments
mstallmo.com
submitted
#5
1 commit
expand
collapse
Rename
ein-tui to ein. Move from crates/ directory to top level project directory.
expand 0 comments
mstallmo.com
submitted
#4
1 commit
expand
collapse
Rename
ein-tui to ein. Move from crates/ directory to top level project directory.
expand 0 comments
mstallmo.com
submitted
#3
1 commit
expand
collapse
Rename
ein-tui to ein. Move from crates/ directory to top level project directory.
expand 0 comments
mstallmo.com
submitted
#2
1 commit
expand
collapse
Rename
ein-tui to ein. Move from crates/ directory to top level project directory.
expand 0 comments
mstallmo.com
submitted
#1
1 commit
expand
collapse
Rename
ein-tui to ein. Move from crates/ directory to top level project directory.
expand 0 comments
mstallmo.com
submitted
#0
1 commit
expand
collapse
Rename
ein-tui to ein. Move from crates/ directory to top level project directory.