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.
+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
+
}
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/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.
+20
-20
ein/src/bootstrap.rs
+20
-20
ein/src/bootstrap.rs
···
249
249
250
250
let plist = format!(
251
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
-
"#,
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
272
LAUNCH_AGENT_LABEL = LAUNCH_AGENT_LABEL,
273
273
bin = bin.display(),
274
274
log = log.display(),
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.