handshake_core/application/
windows.rs1use 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
16static 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 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 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}