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
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ members = [
resolver = "2"

[workspace.package]
version = "3.2.0"
version = "3.2.1"
edition = "2021"
authors = ["Manuel Gruber"]
license = "MIT"
Expand Down
2 changes: 1 addition & 1 deletion crates/git-same-app/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Git-Same",
"version": "3.2.0",
"version": "3.2.1",
"identifier": "com.zaai.git-same",
"build": {
"beforeDevCommand": "corepack pnpm dev",
Expand Down
2 changes: 1 addition & 1 deletion crates/git-same-app/ui/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "git-same-app-ui",
"private": true,
"version": "3.2.0",
"version": "3.2.1",
"type": "module",
"packageManager": "pnpm@11.0.9+sha512.34ce82e6780233cf9cad8685029a8f81d2e06196c5a9bad98879f7424940c6817c4e4524fb7d38b8553ceed48b9758b8ebaf1abd3600c232c4c8cf7366086f38",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion crates/git-same-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ tui = ["dep:ratatui", "dep:crossterm"]
release-tools = ["dep:clap_complete", "dep:clap_mangen"]

[dependencies]
git-same-core = { path = "../git-same-core", version = "=3.2.0" }
git-same-core = { path = "../git-same-core", version = "=3.2.1" }
clap = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
Expand Down
18 changes: 17 additions & 1 deletion crates/git-same-cli/src/commands/monitor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,23 @@ fn install_agent(args: &ControlArgs, config_override: bool) -> Result<Report> {
let app_path = args.app_path.as_deref().unwrap_or(Path::new(""));
let retained = args.installer_copy.as_deref().unwrap_or(Path::new(""));
let status = controller.install_for_cask(&staged, app_path, retained)?;
Ok(report(status))
Ok(install_agent_report(status, app_path))
}

/// Homebrew runs the installer before it moves the app into place, so the
/// monitor cannot start yet. Say when it will, rather than the generic
/// "installed but not running", which reads like a failure in `brew` output.
fn install_agent_report(status: MonitorAgentStatus, app_path: &Path) -> Report {
let app_pending = !monitor_agent::source::app_main_executable(app_path).exists();
if status.running || status.state != MonitorAgentState::Stopped || !app_pending {
return report(status);
}
Report {
headline: "Monitor installed; it starts when you open Git-Same or at next login"
.to_string(),
status: Some(status),
data: None,
}
}

/// Persistent Stop on macOS. Elsewhere there is no managed service, so stop
Expand Down
49 changes: 49 additions & 0 deletions crates/git-same-cli/src/commands/monitor_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,52 @@ fn lock_holder_is_starting_until_it_writes_its_own_status() {
.message
.starts_with("Monitor is running"));
}

fn stopped_status() -> MonitorAgentStatus {
MonitorAgentStatus {
installed: true,
state: MonitorAgentState::Stopped,
message: "Monitor is installed but not running".to_string(),
..MonitorAgentStatus::unsupported()
}
}

#[test]
fn cask_install_before_the_app_is_placed_says_when_the_monitor_starts() {
let dir = tempfile::tempdir().unwrap();
let app = dir.path().join("Git-Same.app");

let report = install_agent_report(stopped_status(), &app);

assert_eq!(
report.headline,
"Monitor installed; it starts when you open Git-Same or at next login"
);
}

#[test]
fn cask_install_with_the_app_in_place_keeps_the_status_message() {
let dir = tempfile::tempdir().unwrap();
let app = dir.path().join("Git-Same.app");
let executable = monitor_agent::source::app_main_executable(&app);
std::fs::create_dir_all(executable.parent().unwrap()).unwrap();
std::fs::write(&executable, b"app").unwrap();

let report = install_agent_report(stopped_status(), &app);

assert_eq!(report.headline, "Monitor is installed but not running");
}

#[test]
fn cask_install_with_monitoring_stopped_keeps_the_status_message() {
let dir = tempfile::tempdir().unwrap();
let status = MonitorAgentStatus {
state: MonitorAgentState::Disabled,
message: "Monitoring is stopped".to_string(),
..stopped_status()
};

let report = install_agent_report(status, &dir.path().join("Git-Same.app"));

assert_eq!(report.headline, "Monitoring is stopped");
}
83 changes: 73 additions & 10 deletions crates/git-same-core/src/macos/monitor_agent/controller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -246,10 +246,26 @@ impl Controller {
return Err(MonitorAgentError::ForegroundActive { pid: active.pid });
}
}
let stopped_before = read_monitor_autostart(&self.paths.config).is_ok_and(|on| !on);
let disabled_before = self.launchd().is_disabled(LABEL)?;
set_monitor_autostart(&self.paths.config, true)
.map_err(|e| MonitorAgentError::Configuration(e.to_string()))?;
self.launchd().enable(LABEL)?;
self.bring_up(Intent::Explicit, restart)?;
if let Err(error) = self.bring_up(Intent::Explicit, restart) {
// Nothing exists to run (the app is missing), which only shows
// once the recorded owner is resolved. Put the persistent Stop
// back so a later login does not load a job that cannot start.
if matches!(error, MonitorAgentError::MissingSource(_)) {
if stopped_before {
set_monitor_autostart(&self.paths.config, false)
.map_err(|e| MonitorAgentError::Configuration(e.to_string()))?;
}
if disabled_before {
self.launchd().disable(LABEL)?;
}
}
return Err(error);
}
self.inspect()
}

Expand Down Expand Up @@ -314,21 +330,58 @@ impl Controller {
}
}

// Re-read: an `Install` above may have just changed the owner.
let program = self.program(InstallRecord::load(&self.paths.install_record)?.as_ref());
if !program_placed(&program) {
return match intent {
// The app's next launch or the next login starts it.
Intent::Automatic => Ok(()),
Intent::Explicit => Err(MonitorAgentError::MissingSource(format!(
Comment thread
manuelgruber marked this conversation as resolved.
"'{}' does not exist; reinstall or move Git-Same.app back",
program.display()
))),
};
}
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
if !launchd.gui_session_available()? {
// The plist is in place; launchd starts it at the next login.
return Ok(());
}
let service = launchd.service(LABEL)?;
if !service.loaded {
launchd.bootstrap(LABEL, &self.paths.launch_agent)?;
} else if service.pid.is_none() {
self.reload_service()?;
} else if restart {
launchd.kickstart(LABEL, true)?;
} else if service.pid.is_none() {
launchd.kickstart(LABEL, false)?;
}
self.confirm_started()
}

/// Starts a loaded job that has no process. A fresh bootstrap rather than
/// `kickstart`: a job launchd parked with `EX_CONFIG` (loaded at login
/// while the app was missing) never starts from `kickstart`, which blocks
/// instead. `RunAtLoad` makes the bootstrap start it either way.
fn reload_service(&self) -> Result<()> {
let launchd = self.launchd();
launchd.bootout(LABEL)?;
// Removal can finish after bootout returns. Bootstrapping into that
// window fails with "Operation already in progress", which the
// wrapper would read as success because the old job is still loaded.
let mut waited = Duration::ZERO;
while launchd.service(LABEL)?.loaded {
if waited >= EXIT_WAIT {
return Err(MonitorAgentError::Launchd {
operation: "bootout".to_string(),
code: None,
detail: format!("{LABEL} was still loaded after bootout"),
});
}
self.system.sleep(POLL);
waited += POLL;
}
launchd.bootstrap(LABEL, &self.paths.launch_agent)
}
Comment thread
manuelgruber marked this conversation as resolved.

/// launchd accepted the service and reports a process. Does not wait for
/// repository scanning: a long first scan is `starting`, not a failure.
fn confirm_started(&self) -> Result<()> {
Expand Down Expand Up @@ -449,16 +502,17 @@ impl Controller {
}
}
self.wait_for_managed_exit()?;
let rendered = self.render_plist(
&staged.source.program(&self.paths.helper),
Some(staged.source.owner_kind),
)?;
let program = staged.source.program(&self.paths.helper);
let rendered = self.render_plist(&program, Some(staged.source.owner_kind))?;
self.installer().activate(staged, &rendered)?;
let foreground_active = !matches!(
self.system.monitor_state(&self.paths.ipc),
RuntimeMonitorState::Stopped
);
if start_if_possible && gui && !foreground_active {
// A cask install runs before Homebrew places the app, so the bundle
// executable is usually still missing here; the installation is
// committed and the app's next launch or the next login starts it.
if start_if_possible && gui && !foreground_active && program_placed(&program) {
launchd.bootstrap(LABEL, &self.paths.launch_agent)?;
self.confirm_started()?;
}
Expand Down Expand Up @@ -730,7 +784,7 @@ impl Controller {

let source = source::cask_source(staged_executable, final_app_path);
if self.cask_install_is_current(&source)? {
if enabled {
if enabled && program_placed(&source.source_binary) {
self.bring_up_installed()?;
}
} else {
Expand Down Expand Up @@ -776,7 +830,7 @@ impl Controller {
if !service.loaded {
launchd.bootstrap(LABEL, &self.paths.launch_agent)?;
} else if service.pid.is_none() {
launchd.kickstart(LABEL, false)?;
self.reload_service()?;
}
self.confirm_started()
}
Expand Down Expand Up @@ -863,6 +917,15 @@ fn program_installed(program: &Path, record: Option<&InstallRecord>) -> bool {
}
}

/// Whether launchd may be asked to start `program`. launchd parks a job whose
/// program is missing at load (`EX_CONFIG`) and never retries it, even once
/// the file appears, so nothing bootstraps the monitor before its executable
/// is on disk. During a cask install it is not: Homebrew runs the installer
/// before it moves the app into place.
fn program_placed(program: &Path) -> bool {
is_executable(program)
}

/// Whether the installed program still matches what was recorded. Used to
/// decide whether an installation needs repairing; see
/// [`program_installed`] for why an absent in-place program is not damage.
Expand Down
Loading
Loading