Skip to main content

handshake_core/application/
windows.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::{Arc, Mutex, OnceLock};
4
5use super::{Application, ApplicationError, DEFAULT_EVENT_CAPACITY, database_path};
6use crate::config::Config;
7use crate::folder::Folders;
8use crate::store::Store;
9use crate::terminal::TranscriptRecorder;
10
11struct WindowStorage {
12    recorder: Arc<TranscriptRecorder>,
13    windows: usize,
14}
15
16/// The registry owns the recorder until the last runtime has stopped its processes. Creation and
17/// final shutdown share the lock, so a new window never races the previous recorder's owner lock.
18static STORAGE: OnceLock<Mutex<HashMap<PathBuf, WindowStorage>>> = OnceLock::new();
19
20pub(super) struct WindowStorageLease {
21    path: PathBuf,
22}
23
24impl Drop for WindowStorageLease {
25    fn drop(&mut self) {
26        let mut storage = STORAGE
27            .get_or_init(Mutex::default)
28            .lock()
29            .unwrap_or_else(std::sync::PoisonError::into_inner);
30        if let Some(entry) = storage.get_mut(&self.path) {
31            entry.windows -= 1;
32            if entry.windows == 0 {
33                entry.recorder.shutdown();
34                storage.remove(&self.path);
35            }
36        }
37    }
38}
39
40impl Application {
41    /// Creates an independent window runtime sharing durable history with the other windows.
42    /// Only the first runtime recovers interrupted work and restores a previously open folder.
43    ///
44    /// # Errors
45    /// Returns an error if storage, recovery, or runtime initialization fails.
46    pub fn new_window(data_directory: &Path) -> Result<Self, ApplicationError> {
47        Config::prepare_user()?;
48        std::fs::create_dir_all(data_directory).map_err(|source| {
49            crate::store::StoreError::CreateDirectory {
50                path: data_directory.into(),
51                source,
52            }
53        })?;
54        let path = std::fs::canonicalize(data_directory).map_err(|source| {
55            crate::store::StoreError::ResolveDirectory {
56                path: data_directory.into(),
57                source,
58            }
59        })?;
60        let mut storage = STORAGE
61            .get_or_init(Mutex::default)
62            .lock()
63            .map_err(|_| ApplicationError::Poisoned)?;
64        let restore = !storage.contains_key(&path);
65        let transcripts = match storage.get(&path) {
66            Some(entry) => Arc::clone(&entry.recorder),
67            None => Arc::new(TranscriptRecorder::open(&path.join("transcripts"))?),
68        };
69        let mut store = Store::open(&database_path(data_directory))?;
70        if restore {
71            store.recover_interrupted_work()?;
72        }
73        let folders = Folders::for_window(store, restore)?;
74        let config = Config::load_user();
75        let mut application = Self::with_folders(
76            folders,
77            config,
78            DEFAULT_EVENT_CAPACITY,
79            Arc::clone(&transcripts),
80            true,
81            Some(data_directory),
82        )?;
83        storage
84            .entry(path.clone())
85            .or_insert_with(|| WindowStorage {
86                recorder: transcripts,
87                windows: 0,
88            })
89            .windows += 1;
90        application.window_storage = Some(WindowStorageLease { path });
91        Ok(application)
92    }
93
94    /// Returns every folder that should reopen after app shutdown, including unavailable folders.
95    ///
96    /// # Errors
97    /// Returns an error if the database or runtime state cannot be read.
98    pub fn restorable_folders(&self) -> Result<Vec<PathBuf>, ApplicationError> {
99        Ok(self.lock_inner()?.folders.restorable_paths()?)
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use crate::{
107        Command, CommandDisposition, RequestId, TerminalSize, TerminalStatus, TranscriptRead,
108        WorkflowKind,
109    };
110    use std::thread;
111    use std::time::{Duration, Instant};
112
113    #[test]
114    fn a_new_window_waits_for_final_recording_shutdown() {
115        let data = tempfile::tempdir().unwrap();
116        let folder = tempfile::tempdir().unwrap();
117        let app = Application::new_window(data.path()).unwrap();
118        open(&app, folder.path());
119        let id = terminal(&app, folder.path());
120        let stalled = app.terminal_output.stall_recording_worker(id);
121        let key = std::fs::canonicalize(data.path()).unwrap();
122        let recorder = Arc::clone(
123            &STORAGE
124                .get()
125                .unwrap()
126                .lock()
127                .unwrap()
128                .get(&key)
129                .unwrap()
130                .recorder,
131        );
132        let dropping = thread::spawn(move || drop(app));
133        let deadline = Instant::now() + Duration::from_secs(5);
134        while !recorder.is_shutting_down() {
135            assert!(
136                Instant::now() < deadline,
137                "shutdown never acquired storage ownership"
138            );
139            thread::yield_now();
140        }
141        let path = data.path().to_owned();
142        let (sender, receiver) = std::sync::mpsc::channel();
143        let opening = thread::spawn(move || {
144            let app = Application::new_window(&path);
145            sender
146                .send(
147                    app.as_ref()
148                        .map(|app| app.snapshot().unwrap().folders.open_folder.clone())
149                        .map_err(ToString::to_string),
150                )
151                .unwrap();
152        });
153        assert!(matches!(
154            receiver.recv_timeout(Duration::from_millis(25)),
155            Err(std::sync::mpsc::RecvTimeoutError::Timeout)
156        ));
157        drop(stalled);
158        dropping.join().unwrap();
159        assert_eq!(
160            receiver
161                .recv_timeout(Duration::from_secs(5))
162                .unwrap()
163                .unwrap()
164                .as_deref(),
165            Some(folder.path())
166        );
167        opening.join().unwrap();
168    }
169
170    #[test]
171    fn an_additional_missing_restored_window_owns_and_closes_its_entry() {
172        let data = tempfile::tempdir().unwrap();
173        let missing = tempfile::tempdir().unwrap();
174        let missing_path = missing.path().to_owned();
175        let available = tempfile::tempdir().unwrap();
176        let first = Application::new_window(data.path()).unwrap();
177        open(&first, &missing_path);
178        let second = Application::new_window(data.path()).unwrap();
179        open(&second, available.path());
180        drop(first);
181        drop(second);
182        drop(missing);
183        let primary = Application::new_window(data.path()).unwrap();
184        let restored = Application::new_window(data.path()).unwrap();
185        assert_eq!(
186            restored
187                .handle_command(
188                    RequestId(1),
189                    Command::RestoreFolder {
190                        path: missing_path.clone()
191                    }
192                )
193                .unwrap()
194                .disposition,
195            CommandDisposition::Accepted
196        );
197        assert_eq!(
198            restored
199                .snapshot()
200                .unwrap()
201                .folders
202                .unavailable_folder
203                .unwrap()
204                .path,
205            missing_path
206        );
207        restored
208            .handle_command(RequestId(2), Command::CloseFolder)
209            .unwrap();
210        assert_eq!(primary.restorable_folders().unwrap(), [available.path()]);
211    }
212
213    fn open(app: &Application, path: &Path) {
214        assert_eq!(
215            app.handle_command(RequestId(1), Command::OpenFolder { path: path.into() })
216                .unwrap()
217                .disposition,
218            CommandDisposition::Accepted
219        );
220    }
221
222    fn terminal(app: &Application, path: &Path) -> crate::TerminalId {
223        app.handle_command(
224            RequestId(2),
225            Command::CreateWorkflow {
226                folder: path.into(),
227                session_id: None,
228                kind: WorkflowKind::Terminal,
229                roles: vec![],
230                size: TerminalSize {
231                    rows: 24,
232                    columns: 80,
233                    pixel_width: 800,
234                    pixel_height: 480,
235                },
236            },
237        )
238        .unwrap();
239        app.snapshot()
240            .unwrap()
241            .workflows
242            .workflows
243            .last()
244            .unwrap()
245            .terminal_ids()[0]
246    }
247
248    #[test]
249    fn windows_share_history_without_recovering_or_stopping_live_work() {
250        let data = tempfile::tempdir().unwrap();
251        let a = tempfile::tempdir().unwrap();
252        let b = tempfile::tempdir().unwrap();
253        let first = Application::new_window(data.path()).unwrap();
254        open(&first, a.path());
255        let first_id = terminal(&first, a.path());
256        let first_workflow = first.snapshot().unwrap().workflows.workflows[0].clone();
257        let second = Application::new_window(data.path()).unwrap();
258        assert!(second.snapshot().unwrap().folders.open_folder.is_none());
259        open(&second, b.path());
260        let second_id = terminal(&second, b.path());
261        assert_ne!(first_id, second_id);
262        assert_eq!(
263            first.snapshot().unwrap().workflows.workflows[0],
264            first_workflow
265        );
266        assert_eq!(first.restorable_folders().unwrap(), [b.path(), a.path()]);
267        assert!(
268            first
269                .snapshot()
270                .unwrap()
271                .terminals
272                .iter()
273                .all(|t| t.status == TerminalStatus::Running)
274        );
275
276        second
277            .handle_command(RequestId(3), Command::CloseFolder)
278            .unwrap();
279        drop(second);
280        assert_eq!(first.restorable_folders().unwrap(), [a.path()]);
281        first
282            .write_terminal_input(first_id, b"printf surviving\\n\n")
283            .unwrap();
284        let deadline = Instant::now() + Duration::from_secs(5);
285        loop {
286            let TranscriptRead::Output(page) =
287                first.read_terminal_transcript(first_id, 0, 4096).unwrap()
288            else {
289                panic!("output expected")
290            };
291            if String::from_utf8_lossy(&page.bytes).contains("surviving") {
292                break;
293            }
294            assert!(
295                Instant::now() < deadline,
296                "the remaining window stopped recording"
297            );
298            thread::sleep(Duration::from_millis(10));
299        }
300        drop(first);
301        let relaunched = Application::new_window(data.path()).unwrap();
302        assert_eq!(
303            relaunched
304                .snapshot()
305                .unwrap()
306                .folders
307                .open_folder
308                .as_deref(),
309            Some(a.path())
310        );
311        assert_eq!(relaunched.restorable_folders().unwrap(), [a.path()]);
312    }
313
314    #[test]
315    fn all_open_folders_restore_and_open_folders_survive_the_recent_limit() {
316        let data = tempfile::tempdir().unwrap();
317        let folders = (0..12)
318            .map(|_| tempfile::tempdir().unwrap())
319            .collect::<Vec<_>>();
320        let mut windows = Vec::new();
321        for folder in &folders {
322            let app = Application::new_window(data.path()).unwrap();
323            open(&app, folder.path());
324            windows.push(app);
325        }
326        assert_eq!(windows[0].restorable_folders().unwrap().len(), 12);
327        assert_eq!(
328            windows[11].snapshot().unwrap().folders.recent_folders.len(),
329            10
330        );
331        drop(windows);
332        let app = Application::new_window(data.path()).unwrap();
333        assert_eq!(app.restorable_folders().unwrap().len(), 12);
334        assert_eq!(
335            app.snapshot().unwrap().folders.open_folder.as_deref(),
336            Some(folders[11].path())
337        );
338    }
339
340    #[test]
341    fn forgetting_another_windows_recent_folder_keeps_its_restore_entry() {
342        let data = tempfile::tempdir().unwrap();
343        let folder = tempfile::tempdir().unwrap();
344        let first = Application::new_window(data.path()).unwrap();
345        open(&first, folder.path());
346        let second = Application::new_window(data.path()).unwrap();
347        std::fs::remove_dir_all(folder.path()).unwrap();
348        second
349            .handle_command(
350                RequestId(1),
351                Command::RemoveRecentFolder {
352                    path: folder.path().into(),
353                },
354            )
355            .unwrap();
356        assert_eq!(
357            second.snapshot().unwrap().folders.recent_folders,
358            Vec::new()
359        );
360        assert_eq!(second.restorable_folders().unwrap(), [folder.path()]);
361        std::fs::create_dir_all(folder.path()).unwrap();
362        drop(first);
363        drop(second);
364        let relaunched = Application::new_window(data.path()).unwrap();
365        assert_eq!(
366            relaunched
367                .snapshot()
368                .unwrap()
369                .folders
370                .open_folder
371                .as_deref(),
372            Some(folder.path())
373        );
374    }
375
376    #[test]
377    fn replacing_an_unavailable_restored_folder_clears_its_restore_entry() {
378        let data = tempfile::tempdir().unwrap();
379        let missing = tempfile::tempdir().unwrap();
380        let path = missing.path().to_owned();
381        let replacement = tempfile::tempdir().unwrap();
382        let first = Application::new_window(data.path()).unwrap();
383        open(&first, &path);
384        drop(first);
385        drop(missing);
386        let restored = Application::new_window(data.path()).unwrap();
387        assert_eq!(
388            restored
389                .snapshot()
390                .unwrap()
391                .folders
392                .unavailable_folder
393                .unwrap()
394                .path,
395            path
396        );
397        let rejected_path = data.path().join("also-missing");
398        assert!(matches!(
399            restored
400                .handle_command(
401                    RequestId(4),
402                    Command::OpenFolder {
403                        path: rejected_path
404                    }
405                )
406                .unwrap()
407                .disposition,
408            CommandDisposition::Rejected { .. }
409        ));
410        open(&restored, replacement.path());
411        assert_eq!(restored.restorable_folders().unwrap(), [replacement.path()]);
412    }
413}