Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/craft-cloud-desktop/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
//! - Linux: `--tray` is the tray process (no window: [`tray::process`]), which the start-at-login entry runs (an
//! older entry's `--hidden` means the same). The window is a process of its own that ends when closed (Wayland can't
//! hide a window), and starts the tray process if none runs.
//!
//! `--close` ends the window and the tray process that run (an installer's upgrade: also an older version's) and
//! prints which did (`window`, `tray`, a line each); the agent is left alone.
#![cfg_attr(windows, windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]

Expand All @@ -29,6 +32,12 @@ fn main() -> ExitCode {
eprintln!("Craft Cloud can't find your user data folder");
return ExitCode::FAILURE;
};
if has("--close") {
for name in single_instance::close_running(&dir) {
println!("{}", if name == Name::Tray { "tray" } else { "window" });
}
return ExitCode::SUCCESS;
}
let (outside_tx, outside_rx) = std::sync::mpsc::channel::<Event>();
let window: Arc<OnceLock<egui::Context>> = Arc::new(OnceLock::new());
match single_instance::claim(&dir, Name::Window) {
Expand Down
99 changes: 89 additions & 10 deletions apps/craft-cloud-desktop/src/single_instance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,27 @@ impl Name {
pub fn port_file(self) -> String {
format!("{}.port", self.stem())
}

/// Its lock and port files in `dir`.
fn files(self, dir: &Path) -> Files {
Files { lock: dir.join(self.lock_file()), port: dir.join(self.port_file()) }
}

/// Where versions before the folder of its own kept them: loose in the per-user data folder (`parent`).
fn old_files(self, parent: &Path) -> Files {
let stem = match self {
Name::Window => "craft-cloud-app",
Name::Tray => "craft-cloud-tray",
};
Files { lock: parent.join(format!("{stem}.lock")), port: parent.join(format!("{stem}.port")) }
}
}

/// A process kind's lock and port files.
#[derive(Debug, Clone)]
struct Files {
lock: PathBuf,
port: PathBuf,
}

/// What a later start asks the first.
Expand Down Expand Up @@ -82,8 +103,7 @@ pub struct First {

/// Another one's address.
pub struct Running {
dir: PathBuf,
name: Name,
files: Files,
}

/// The folder of the locks and the channels, next to the agent's data folder (the uninstallers remove it).
Expand All @@ -102,31 +122,67 @@ fn folder_beside(data: &Path) -> Option<PathBuf> {
/// Takes the lock of `name` in `dir`, or finds the process that holds it.
pub fn claim(dir: &Path, name: Name) -> std::io::Result<Instance> {
std::fs::create_dir_all(dir)?;
let lock = OpenOptions::new().create(true).truncate(false).write(true).open(dir.join(name.lock_file()))?;
claim_files(name.files(dir))
}

fn claim_files(files: Files) -> std::io::Result<Instance> {
let lock = OpenOptions::new().create(true).truncate(false).write(true).open(&files.lock)?;
match lock.try_lock() {
Ok(()) => {}
Err(std::fs::TryLockError::WouldBlock) => return Ok(Instance::Running(Running { dir: dir.to_owned(), name })),
Err(std::fs::TryLockError::WouldBlock) => return Ok(Instance::Running(Running { files })),
Err(std::fs::TryLockError::Error(e)) => return Err(e),
}
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))?;
let token = token()?;
let port = listener.local_addr()?.port();
// Written whole, then renamed into place, so a later start never reads half of it.
let temp = dir.join(format!("{}.tmp", name.port_file()));
let mut temp = files.port.clone().into_os_string();
temp.push(".tmp");
std::fs::write(&temp, format!("{port} {token}\n"))?;
std::fs::rename(&temp, dir.join(name.port_file()))?;
std::fs::rename(&temp, &files.port)?;
Ok(Instance::First(First { lock, listener, token }))
}

/// Whether a process of `name` runs (holds its lock), without taking it for long.
pub fn is_running(dir: &Path, name: Name) -> bool {
let Ok(lock) = OpenOptions::new().create(true).truncate(false).write(true).open(dir.join(name.lock_file())) else { return false };
holds(&name.files(dir).lock)
}

/// Whether a process holds the lock `lock` (one that isn't there isn't made).
fn holds(lock: &Path) -> bool {
let Ok(lock) = OpenOptions::new().write(true).open(lock) else { return false };
matches!(lock.try_lock(), Err(std::fs::TryLockError::WouldBlock))
}

/// Sends `command` to the running process of `name`, if one answers (within a few seconds).
pub fn send(dir: &Path, name: Name, command: Command) -> std::io::Result<()> {
Running { dir: dir.to_owned(), name }.send(command)
Running { files: name.files(dir) }.send(command)
}

/// Ends the window and the tray process that run for `dir`, also those of a version that kept its files loose in
/// the folder above (an upgrade replaces the programs under them), and waits (a few seconds) for each to let go of its
/// lock. The kinds that were running. The agent is left alone.
pub fn close_running(dir: &Path) -> Vec<Name> {
let parent = dir.parent().unwrap_or(dir);
let mut closed = Vec::new();
for name in [Name::Window, Name::Tray] {
for files in [name.files(dir), name.old_files(parent)] {
if !holds(&files.lock) {
continue;
}
let lock = files.lock.clone();
if (Running { files }).send(Command::Quit).is_ok() {
let deadline = Instant::now() + PATIENCE;
while holds(&lock) && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(50));
}
if !closed.contains(&name) {
closed.push(name);
}
}
}
}
closed
}

fn token() -> std::io::Result<String> {
Expand Down Expand Up @@ -180,14 +236,14 @@ impl Running {
match self.try_send(command) {
Ok(()) => return Ok(()),
// The first may be starting: its port file comes a moment after its lock.
Err(_) if Instant::now() < deadline && is_running(&self.dir, self.name) => std::thread::sleep(Duration::from_millis(100)),
Err(_) if Instant::now() < deadline && holds(&self.files.lock) => std::thread::sleep(Duration::from_millis(100)),
Err(e) => return Err(e),
}
}
}

fn try_send(&self, command: Command) -> std::io::Result<()> {
let text = std::fs::read_to_string(self.dir.join(self.name.port_file()))?;
let text = std::fs::read_to_string(&self.files.port)?;
let (port, token) = text.trim().split_once(' ').ok_or_else(|| std::io::Error::other("the port file doesn't read"))?;
let port: u16 = port.parse().map_err(|_| std::io::Error::other("the port file doesn't read"))?;
let mut stream = TcpStream::connect_timeout(&SocketAddr::from((Ipv4Addr::LOCALHOST, port)), Duration::from_secs(1))?;
Expand Down Expand Up @@ -253,6 +309,29 @@ mod tests {
assert!(inside.join("window.lock").is_file() && inside.join("window.port").is_file());
}

/// An upgrade closes what runs: here a tray in the folder and a window of an older version, whose files are loose
/// in the folder above. Each is told to quit; nothing runs where nothing is told.
#[test]
fn close_running_tells_the_window_and_the_tray_new_and_old() {
let parent = tempfile::tempdir().unwrap();
let dir = parent.path().join(FOLDER_NAME);
assert!(close_running(&dir).is_empty(), "nothing runs");
std::fs::create_dir_all(&dir).unwrap();
let (told, heard) = channel();
let Instance::First(tray) = claim(&dir, Name::Tray).unwrap() else { panic!("not first") };
let Instance::First(old_window) = claim_files(Name::Window.old_files(parent.path())).unwrap() else { panic!("not first") };
for (name, first) in [(Name::Tray, tray), (Name::Window, old_window)] {
let told = told.clone();
first.listen(move |command| {
let _ = told.send((name, command));
});
}
assert_eq!(close_running(&dir), [Name::Window, Name::Tray]);
let mut heard: Vec<_> = (0..2).map(|_| heard.recv_timeout(Duration::from_secs(10)).unwrap()).collect();
heard.sort_by_key(|(name, _)| *name == Name::Tray);
assert_eq!(heard, [(Name::Window, Command::Quit), (Name::Tray, Command::Quit)]);
}

#[test]
fn a_wrong_token_shows_nothing() {
let dir = tempfile::tempdir().unwrap();
Expand Down
Loading
Loading