use std::path::{Path, PathBuf};
#[cfg(target_os = "macos")]
use super::wrapper::log_wrapper_event;
#[cfg(target_os = "macos")]
use super::{
first_run_marker_path, is_first_run_at, legacy_migration_marker_path,
mark_first_run_complete_at,
};
#[allow(dead_code)]
const LAUNCH_AT_LOGIN_LABEL: &str = "org.freenet.Freenet";
#[allow(dead_code)]
const LEGACY_SERVICE_LAUNCHD_LABEL: &str = "org.freenet.node";
#[allow(dead_code)]
fn launch_agent_plist_path() -> Option<PathBuf> {
launch_agent_plist_path_for(LAUNCH_AT_LOGIN_LABEL)
}
#[allow(dead_code)]
fn launch_agent_plist_path_for(label: &str) -> Option<PathBuf> {
dirs::home_dir().map(|h| {
h.join("Library")
.join("LaunchAgents")
.join(format!("{label}.plist"))
})
}
#[allow(dead_code)]
pub(super) fn is_launch_at_login_enabled_at(plist: &Path) -> bool {
plist.exists()
}
#[allow(dead_code)]
pub(crate) fn macos_app_bundle_path(exe: &Path) -> Option<PathBuf> {
for ancestor in exe.ancestors() {
if ancestor
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.ends_with(".app"))
{
return Some(ancestor.to_path_buf());
}
}
None
}
#[allow(dead_code)]
fn macos_app_bundle_wrapper(bundle: &Path) -> PathBuf {
bundle.join("Contents").join("MacOS").join("Freenet")
}
#[allow(dead_code)]
pub(super) fn launch_agent_program_arguments(exe: &Path) -> Vec<String> {
match macos_app_bundle_path(exe) {
Some(bundle) => vec![
macos_app_bundle_wrapper(&bundle)
.to_string_lossy()
.into_owned(),
],
None => vec![
exe.to_string_lossy().into_owned(),
"service".to_string(),
"run-wrapper".to_string(),
],
}
}
fn xml_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
c => out.push(c),
}
}
out
}
#[allow(dead_code)]
pub(super) fn launch_agent_plist_contents(program_arguments: &[String]) -> String {
let mut args_xml = String::new();
for arg in program_arguments {
args_xml.push_str(" <string>");
args_xml.push_str(&xml_escape(arg));
args_xml.push_str("</string>\n");
}
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>{LAUNCH_AT_LOGIN_LABEL}</string>
<key>ProgramArguments</key>
<array>
{args_xml} </array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<false/>
<key>ProcessType</key>
<string>Interactive</string>
</dict>
</plist>
"#
)
}
#[allow(dead_code)]
pub(super) fn write_launch_agent_plist_at(
plist: &Path,
program_arguments: &[String],
) -> std::io::Result<()> {
if let Some(parent) = plist.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(plist, launch_agent_plist_contents(program_arguments))
}
#[allow(dead_code)]
pub(super) fn remove_launch_agent_plist_at(plist: &Path) -> std::io::Result<()> {
match std::fs::remove_file(plist) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
#[allow(dead_code)]
pub(super) fn launch_agent_plist_has_expected_leader(plist: &Path, expected_leader: &str) -> bool {
let escaped = format!("<string>{}</string>", xml_escape(expected_leader));
match std::fs::read_to_string(plist) {
Ok(contents) => contents.contains(&escaped),
Err(_) => false,
}
}
#[cfg(target_os = "macos")]
fn launchctl_bootstrap(plist: &Path) {
let uid = unsafe { libc::getuid() };
let target = format!("gui/{uid}");
match std::process::Command::new("launchctl")
.args(["bootstrap", &target])
.arg(plist)
.output()
{
Ok(o) if !o.status.success() => {
tracing::warn!(
"launchctl bootstrap {} returned {}: {}",
plist.display(),
o.status,
String::from_utf8_lossy(&o.stderr).trim()
);
}
Err(e) => {
tracing::warn!(
"launchctl bootstrap {} failed to spawn: {}",
plist.display(),
e
);
}
_ => {}
}
}
#[cfg(target_os = "macos")]
fn launchctl_bootout(plist: &Path) {
let uid = unsafe { libc::getuid() };
let target = format!("gui/{uid}");
match std::process::Command::new("launchctl")
.args(["bootout", &target])
.arg(plist)
.output()
{
Ok(o) if !o.status.success() => {
tracing::debug!(
"launchctl bootout {} returned {}: {}",
plist.display(),
o.status,
String::from_utf8_lossy(&o.stderr).trim()
);
}
Err(e) => {
tracing::warn!(
"launchctl bootout {} failed to spawn: {}",
plist.display(),
e
);
}
_ => {}
}
}
#[cfg(not(target_os = "macos"))]
#[allow(dead_code)]
fn launchctl_bootstrap(_plist: &Path) {}
#[cfg(not(target_os = "macos"))]
#[allow(dead_code)]
fn launchctl_bootout(_plist: &Path) {}
#[allow(dead_code)]
pub(crate) fn enable_launch_at_login(executable: &Path) -> std::io::Result<()> {
let Some(plist) = launch_agent_plist_path() else {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"could not resolve home directory for launch agent path",
));
};
let args = launch_agent_program_arguments(executable);
if plist.exists() {
launchctl_bootout(&plist);
}
write_launch_agent_plist_at(&plist, &args)?;
launchctl_bootstrap(&plist);
Ok(())
}
#[allow(dead_code)]
pub(crate) fn disable_launch_at_login() -> std::io::Result<()> {
let Some(plist) = launch_agent_plist_path() else {
return Ok(());
};
launchctl_bootout(&plist);
remove_launch_agent_plist_at(&plist)
}
#[allow(dead_code)]
pub(crate) fn is_launch_at_login_enabled() -> bool {
launch_agent_plist_path()
.map(|p| is_launch_at_login_enabled_at(&p))
.unwrap_or(false)
}
#[derive(Debug, PartialEq, Eq)]
#[allow(dead_code)]
pub(super) enum FirstRunLaunchAtLoginAction {
Register,
AlreadyEnabled,
NotFirstRun,
}
#[allow(dead_code)]
pub(super) fn first_run_launch_at_login_action(
is_first_run: bool,
already_enabled: bool,
) -> FirstRunLaunchAtLoginAction {
if !is_first_run {
FirstRunLaunchAtLoginAction::NotFirstRun
} else if already_enabled {
FirstRunLaunchAtLoginAction::AlreadyEnabled
} else {
FirstRunLaunchAtLoginAction::Register
}
}
#[derive(Debug, PartialEq, Eq)]
#[allow(dead_code)]
pub(crate) enum ToggleLaunchAtLoginOutcome {
Enable,
Disable,
}
#[allow(dead_code)]
pub(crate) fn toggle_launch_at_login_outcome(
currently_enabled: bool,
) -> ToggleLaunchAtLoginOutcome {
if currently_enabled {
ToggleLaunchAtLoginOutcome::Disable
} else {
ToggleLaunchAtLoginOutcome::Enable
}
}
#[allow(dead_code)]
pub(super) fn refresh_launch_at_login_plist_if_stale() {
if !is_launch_at_login_enabled() {
return;
}
let Some(plist) = launch_agent_plist_path() else {
return;
};
let Ok(exe) = std::env::current_exe() else {
return;
};
let args = launch_agent_program_arguments(&exe);
let Some(leader) = args.first() else {
return;
};
if launch_agent_plist_has_expected_leader(&plist, leader) {
return;
}
tracing::info!(
"Refreshing stale Launch at Login plist (was pointing elsewhere, now {})",
leader
);
if let Err(e) = enable_launch_at_login(&exe) {
tracing::warn!("Failed to refresh Launch at Login plist: {}", e);
}
}
#[allow(dead_code)]
pub(super) fn legacy_launchd_agent_present() -> bool {
launch_agent_plist_path_for(LEGACY_SERVICE_LAUNCHD_LABEL)
.map(|p| p.exists())
.unwrap_or(false)
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[allow(dead_code)]
pub(super) enum LegacyMigrationStep {
BootOutAndRemovePlist(PathBuf),
RemoveLegacyWrapperScript(PathBuf),
RemoveLegacyBinary(PathBuf),
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[allow(dead_code)]
pub(super) enum LegacyMigrationPlan {
NoMigration,
Migrate(Vec<LegacyMigrationStep>),
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[allow(dead_code)]
pub(super) struct ResolvedLegacyInstall {
pub wrapper_script: Option<PathBuf>,
pub binaries: Vec<PathBuf>,
}
#[allow(dead_code)]
pub(super) fn legacy_install_migration_plan(
migration_already_done: bool,
legacy_plist: Option<(PathBuf, bool)>,
resolved: &ResolvedLegacyInstall,
) -> LegacyMigrationPlan {
if migration_already_done {
return LegacyMigrationPlan::NoMigration;
}
let Some((plist_path, plist_exists)) = legacy_plist else {
return LegacyMigrationPlan::NoMigration;
};
if !plist_exists {
return LegacyMigrationPlan::NoMigration;
}
let mut steps = Vec::with_capacity(2 + resolved.binaries.len());
steps.push(LegacyMigrationStep::BootOutAndRemovePlist(plist_path));
if let Some(wrapper) = &resolved.wrapper_script {
steps.push(LegacyMigrationStep::RemoveLegacyWrapperScript(
wrapper.clone(),
));
}
for bin in &resolved.binaries {
steps.push(LegacyMigrationStep::RemoveLegacyBinary(bin.clone()));
}
LegacyMigrationPlan::Migrate(steps)
}
#[allow(dead_code)]
pub(super) fn legacy_program_path_from_plist(plist_xml: &str) -> Option<PathBuf> {
let after_key = plist_xml.split("<key>ProgramArguments</key>").nth(1)?;
let array = after_key.split("<array>").nth(1)?;
let array = array.split("</array>").next()?;
let start = array.find("<string>")? + "<string>".len();
let end = array[start..].find("</string>")? + start;
let raw = array[start..end].trim();
if raw.is_empty() {
return None;
}
Some(PathBuf::from(xml_unescape(raw)))
}
#[allow(dead_code)]
pub(super) fn legacy_binary_path_from_wrapper(wrapper_sh: &str) -> Option<PathBuf> {
for line in wrapper_sh.lines() {
let trimmed = line.trim_start();
if !trimmed.starts_with('"') {
continue;
}
let rest = &trimmed[1..];
let Some(close) = rest.find('"') else {
continue;
};
let path = &rest[..close];
let after = rest[close + 1..].trim_start();
if after.starts_with("network") && !path.is_empty() {
return Some(PathBuf::from(path));
}
}
None
}
const LEGACY_WRAPPER_SCRIPT_NAME: &str = "freenet-service-wrapper.sh";
const LEGACY_WRAPPER_SIGNATURE: &str = "# Freenet service wrapper for auto-update support.";
#[allow(dead_code)]
pub(super) fn is_legacy_freenet_wrapper(basename: Option<&str>, contents: &str) -> bool {
basename == Some(LEGACY_WRAPPER_SCRIPT_NAME) && contents.contains(LEGACY_WRAPPER_SIGNATURE)
}
#[allow(dead_code)]
fn xml_unescape(s: &str) -> String {
s.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace("&", "&")
}
#[cfg(target_os = "macos")]
fn run_legacy_install_migration(log_dir: &Path, plan: &LegacyMigrationPlan) -> bool {
let LegacyMigrationPlan::Migrate(steps) = plan else {
return false;
};
log_wrapper_event(
log_dir,
"Legacy install.sh install detected on first DMG launch; migrating to \
the DMG-managed launch agent (issue #3943).",
);
for step in steps {
match step {
LegacyMigrationStep::BootOutAndRemovePlist(plist) => {
launchctl_bootout(plist);
match remove_launch_agent_plist_at(plist) {
Ok(()) => log_wrapper_event(
log_dir,
&format!("Migration: removed legacy launch agent {}", plist.display()),
),
Err(e) => log_wrapper_event(
log_dir,
&format!(
"Migration: could not remove legacy launch agent {} ({e}). \
Please remove it manually: rm {}",
plist.display(),
plist.display()
),
),
}
}
LegacyMigrationStep::RemoveLegacyWrapperScript(wrapper) => {
remove_legacy_file(log_dir, "legacy wrapper script", wrapper);
}
LegacyMigrationStep::RemoveLegacyBinary(bin) => {
remove_legacy_file(log_dir, "legacy CLI binary", bin);
}
}
}
true
}
#[cfg(target_os = "macos")]
fn remove_legacy_file(log_dir: &Path, kind: &str, path: &Path) {
match std::fs::remove_file(path) {
Ok(()) => log_wrapper_event(
log_dir,
&format!("Migration: removed {kind} {}", path.display()),
),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
}
Err(e) => log_wrapper_event(
log_dir,
&format!(
"Migration: could not remove {kind} {} ({e}). \
If it persists, remove it manually: sudo rm {}",
path.display(),
path.display()
),
),
}
}
#[cfg(target_os = "macos")]
fn resolve_legacy_install(legacy_plist: &Path) -> ResolvedLegacyInstall {
let Ok(plist_xml) = std::fs::read_to_string(legacy_plist) else {
return ResolvedLegacyInstall::default();
};
let Some(wrapper_path) = legacy_program_path_from_plist(&plist_xml) else {
return ResolvedLegacyInstall::default();
};
let Ok(wrapper_sh) = std::fs::read_to_string(&wrapper_path) else {
return ResolvedLegacyInstall::default();
};
let basename = wrapper_path.file_name().and_then(|n| n.to_str());
if !is_legacy_freenet_wrapper(basename, &wrapper_sh) {
return ResolvedLegacyInstall::default();
}
let mut binaries = Vec::new();
if let Some(bin) = legacy_binary_path_from_wrapper(&wrapper_sh) {
if bin.file_name().and_then(|n| n.to_str()) == Some("freenet") && bin.exists() {
if let Some(dir) = bin.parent() {
let fdev = dir.join("fdev");
if fdev.exists() {
binaries.push(fdev);
}
}
binaries.push(bin);
}
}
ResolvedLegacyInstall {
wrapper_script: wrapper_path.exists().then_some(wrapper_path),
binaries,
}
}
#[cfg(target_os = "macos")]
pub(super) fn macos_launch_at_login_startup(log_dir: &Path) {
let Some(marker) = first_run_marker_path() else {
return;
};
let is_first_run = is_first_run_at(&marker);
let migration_done = legacy_migration_marker_path()
.map(|m| m.exists())
.unwrap_or(false);
let legacy_plist_path = launch_agent_plist_path_for(LEGACY_SERVICE_LAUNCHD_LABEL);
let legacy_present = legacy_plist_path
.as_ref()
.map(|p| p.exists())
.unwrap_or(false);
let resolved = legacy_plist_path
.as_ref()
.filter(|_| !migration_done && legacy_present)
.map(|p| resolve_legacy_install(p))
.unwrap_or_default();
let legacy_plist = legacy_plist_path.map(|p| (p, legacy_present));
let plan = legacy_install_migration_plan(migration_done, legacy_plist, &resolved);
let migrated = run_legacy_install_migration(log_dir, &plan);
let legacy_present = if migrated {
legacy_launchd_agent_present()
} else {
legacy_present
};
let migrated_off_legacy = migrated && !legacy_present;
if migrated_off_legacy && !is_launch_at_login_enabled() {
match std::env::current_exe() {
Ok(exe) => match enable_launch_at_login(&exe) {
Ok(()) => log_wrapper_event(
log_dir,
"Migration: registered replacement Launch at Login agent",
),
Err(e) => log_wrapper_event(
log_dir,
&format!(
"Migration: replacement Launch at Login registration failed ({e}). \
Enable it from the Freenet tray menu if you want auto-start on login."
),
),
},
Err(e) => log_wrapper_event(
log_dir,
&format!("Migration: could not resolve current exe for replacement agent: {e}"),
),
}
}
if migrated && !legacy_present {
if let Some(m) = legacy_migration_marker_path() {
if let Err(e) = mark_first_run_complete_at(&m) {
log_wrapper_event(
log_dir,
&format!(
"Migration: failed to write migration marker {}: {e}",
m.display()
),
);
}
}
}
if legacy_present {
log_wrapper_event(
log_dir,
"Legacy launchd agent ~/Library/LaunchAgents/org.freenet.node.plist \
detected. Freenet is already configured to auto-start via that \
agent; the new DMG install will NOT create a duplicate agent. \
To clean up the legacy one when convenient: launchctl bootout \
gui/$UID ~/Library/LaunchAgents/org.freenet.node.plist && rm \
~/Library/LaunchAgents/org.freenet.node.plist",
);
}
let already_enabled = is_launch_at_login_enabled() || legacy_present;
match first_run_launch_at_login_action(is_first_run, already_enabled) {
FirstRunLaunchAtLoginAction::Register => match std::env::current_exe() {
Ok(exe) => match enable_launch_at_login(&exe) {
Ok(()) => log_wrapper_event(log_dir, "First-run: registered Launch at Login agent"),
Err(e) => log_wrapper_event(
log_dir,
&format!("First-run Launch at Login registration failed: {e}"),
),
},
Err(e) => log_wrapper_event(
log_dir,
&format!("First-run Launch at Login: could not resolve current exe: {e}"),
),
},
FirstRunLaunchAtLoginAction::AlreadyEnabled | FirstRunLaunchAtLoginAction::NotFirstRun => {
if is_launch_at_login_enabled() {
refresh_launch_at_login_plist_if_stale();
}
}
}
}
#[cfg(not(target_os = "macos"))]
#[allow(dead_code)]
pub(super) fn macos_launch_at_login_startup(_log_dir: &Path) {}