Skip to main content

hyprcorrect_core/
runtime.rs

1//! Runtime coordination between the daemon and the prefs subprocess.
2//!
3//! Both write/read a PID file at the platform's runtime location
4//! (`$XDG_RUNTIME_DIR/hyprcorrect.pid` on Linux, `$TMPDIR/...` on
5//! macOS) so the prefs window can target SIGHUP at the daemon
6//! specifically — `pkill -x hyprcorrect` would catch both processes
7//! since they share a binary name.
8
9use std::fs;
10use std::path::PathBuf;
11
12/// An error reading or writing the daemon PID file.
13#[derive(Debug, thiserror::Error)]
14pub enum PidError {
15    #[error("pid file I/O: {0}")]
16    Io(String),
17    #[error("pid file content is not a number: {0}")]
18    Parse(String),
19}
20
21/// Path to the daemon PID file. Falls back to the OS temp dir when
22/// `$XDG_RUNTIME_DIR` is unset (macOS, restricted environments).
23pub fn pid_path() -> PathBuf {
24    runtime_dir().join("hyprcorrect.pid")
25}
26
27/// Path to the trigger-action file. The hyprctl bind writes "word",
28/// "sentence", or "review" here before signaling the daemon; the
29/// daemon reads it on `SIGUSR1` to know which action fired. The
30/// review subprocess also writes "review-apply" / "review-cancel"
31/// here when it closes, so the daemon knows what to do with the
32/// pending request file.
33pub fn action_path() -> PathBuf {
34    runtime_dir().join("hyprcorrect.action")
35}
36
37/// Path to the chord-capture Unix socket. The prefs window connects
38/// here and writes `capture\n` to ask the daemon to deliver the
39/// next non-modifier key press (with full modifier mask, including
40/// Super) as a chord string. The socket exists because egui-winit
41/// on Linux discards Super from `Modifiers`, so the prefs UI cannot
42/// record SUPER-containing chords on its own.
43pub fn chord_socket_path() -> PathBuf {
44    runtime_dir().join("hyprcorrect-chord.sock")
45}
46
47/// Path to the review-request file. The daemon writes the original
48/// sentence + the proposed correction + trailing whitespace + the
49/// originating window's address here when the review chord fires;
50/// the review subprocess reads it to populate the popup, then
51/// updates the same path with its decision on exit so the daemon's
52/// apply handler can finish the job.
53pub fn review_path() -> PathBuf {
54    runtime_dir().join("hyprcorrect.review")
55}
56
57/// Ranked alternative spellings for one corrected word, for the review
58/// popup's per-field suggestion dropdown. `options` is best-first and
59/// the first entry is normally the applied correction; the popup drops
60/// whatever matches the field's current text and shows the rest.
61#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
62pub struct WordSuggestions {
63    /// The corrected word these options belong to.
64    pub word: String,
65    /// Candidate replacements, best first.
66    pub options: Vec<String>,
67}
68
69/// A pending review request — what the user typed, what the smart
70/// provider suggested, and where to emit the result.
71#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
72pub struct ReviewRequest {
73    /// The sentence as it sits in the focused window's buffer.
74    pub original: String,
75    /// The smart provider's proposed correction.
76    pub corrected: String,
77    /// Whitespace between the sentence's right edge and the caret —
78    /// preserved so the emit lands with the user's spacing intact.
79    pub trailing: String,
80    /// How many characters of `original` sit BEFORE the caret —
81    /// determines the BackSpace count when the apply path emits.
82    #[serde(default)]
83    pub chars_before_caret: usize,
84    /// How many characters of `original` sit AFTER the caret —
85    /// determines the Delete count when the apply path emits.
86    /// Zero for the common case where the caret is at the end of
87    /// (or in trailing whitespace after) the sentence.
88    #[serde(default)]
89    pub chars_after_caret: usize,
90    /// Hyprland address of the window the request originated from —
91    /// the daemon uses it to update that window's buffer when the
92    /// user accepts.
93    pub window_address: String,
94    /// Ranked backup suggestions for each changed word, ordered by the
95    /// word's position in `corrected` so it lines up with the popup's
96    /// editable fields. Empty when no provider offered alternatives.
97    #[serde(default)]
98    pub suggestions: Vec<WordSuggestions>,
99    /// `true` while the daemon is still computing the correction (e.g.
100    /// an in-flight LLM call). The popup is spawned immediately in this
101    /// state — showing the original text and a "Checking…" line — and
102    /// re-reads the request until the daemon writes the finished one
103    /// with `pending: false`.
104    #[serde(default)]
105    pub pending: bool,
106    /// Logical width (points) of the monitor the source window sits on,
107    /// so the popup can grow with the sentence up to half the screen.
108    /// Zero when unknown — the popup then falls back to a fixed cap.
109    #[serde(default)]
110    pub screen_width: f32,
111    /// Usable logical height (points) of that monitor — the full height
112    /// minus any reserved areas (e.g. a top waybar). Lets the popup grow
113    /// to fit its content up to the screen without ever sliding under the
114    /// bar. Zero when unknown — the popup then falls back to a fixed cap.
115    #[serde(default)]
116    pub screen_height: f32,
117    /// Whether the daemon has an LLM provider configured. The popup shows
118    /// its "Ask LLM" escalation button only when this is true.
119    #[serde(default)]
120    pub llm_available: bool,
121    /// Whether the LLM produced the `corrected` text shown. When `true`
122    /// the popup hides the "Ask LLM" button — the result is already the
123    /// LLM's, so there's nothing to escalate. Keyed on the provider that
124    /// actually produced the correction, so an LLM miss that fell back to
125    /// LanguageTool/Spellbook still offers the button.
126    #[serde(default)]
127    pub from_llm: bool,
128}
129
130/// Write a fresh review request to disk. Overwrites any pending one.
131///
132/// # Errors
133///
134/// I/O errors are surfaced; the daemon logs and skips the spawn if
135/// this fails, so a half-written file doesn't trip up the popup.
136pub fn write_review_request(req: &ReviewRequest) -> Result<(), PidError> {
137    let json = serde_json::to_string(req).map_err(|e| PidError::Io(e.to_string()))?;
138    let path = review_path();
139    if let Some(parent) = path.parent() {
140        fs::create_dir_all(parent).map_err(|e| PidError::Io(e.to_string()))?;
141    }
142    fs::write(&path, json).map_err(|e| PidError::Io(e.to_string()))
143}
144
145/// Read the pending review request, or `None` if no file exists.
146///
147/// # Errors
148///
149/// See [`PidError`].
150pub fn read_review_request() -> Result<Option<ReviewRequest>, PidError> {
151    match fs::read_to_string(review_path()) {
152        Ok(text) => serde_json::from_str(&text)
153            .map(Some)
154            .map_err(|e| PidError::Parse(e.to_string())),
155        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
156        Err(e) => Err(PidError::Io(e.to_string())),
157    }
158}
159
160/// Remove the review-request file (idempotent).
161pub fn clear_review() {
162    let _ = fs::remove_file(review_path());
163}
164
165/// Read the trigger-action file, returning the trimmed contents. An
166/// empty string is returned if the file is missing or unreadable —
167/// callers treat that as "default action" (fix-last-word).
168pub fn read_action() -> String {
169    std::fs::read_to_string(action_path())
170        .map(|s| s.trim().to_string())
171        .unwrap_or_default()
172}
173
174fn runtime_dir() -> PathBuf {
175    std::env::var_os("XDG_RUNTIME_DIR")
176        .map(PathBuf::from)
177        .unwrap_or_else(std::env::temp_dir)
178}
179
180/// Write the current process's PID to the daemon PID file.
181///
182/// # Errors
183///
184/// Returns [`PidError::Io`] if the file can't be written.
185pub fn write_self_pid() -> Result<(), PidError> {
186    let path = pid_path();
187    if let Some(parent) = path.parent() {
188        fs::create_dir_all(parent).map_err(|e| PidError::Io(e.to_string()))?;
189    }
190    fs::write(&path, std::process::id().to_string()).map_err(|e| PidError::Io(e.to_string()))
191}
192
193/// Remove the daemon PID file (idempotent — missing file is OK). The
194/// action file is removed alongside it since the two have the same
195/// lifecycle: both are owned by the running daemon.
196pub fn clear_pid() {
197    let _ = fs::remove_file(pid_path());
198    let _ = fs::remove_file(action_path());
199}
200
201/// Read the daemon's PID from the file. Returns `Ok(None)` if no file
202/// exists (no daemon running).
203///
204/// # Errors
205///
206/// See [`PidError`].
207pub fn read_daemon_pid() -> Result<Option<i32>, PidError> {
208    match fs::read_to_string(pid_path()) {
209        Ok(text) => text
210            .trim()
211            .parse::<i32>()
212            .map(Some)
213            .map_err(|e| PidError::Parse(e.to_string())),
214        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
215        Err(e) => Err(PidError::Io(e.to_string())),
216    }
217}