use std::path::Path;
pub const REPO_URL: &str = "https://github.com/AprilNEA/OpenLogi";
pub const HELP_URL: &str = "https://github.com/AprilNEA/OpenLogi#readme";
pub const RELEASES_URL: &str = "https://github.com/AprilNEA/OpenLogi/releases/latest";
pub const APP_ID: &str = "org.openlogi.openlogi";
pub const AGENT_ID: &str = "org.openlogi.agent";
pub const OVERLAY_ID: &str = "org.openlogi.overlay";
pub const AGENT_SERVICE_LABEL: &str = "org.openlogi.agent.service";
const DEV_SUFFIX: &str = "-dev";
#[must_use]
pub fn dev_id(id: &str) -> String {
format!("{id}{DEV_SUFFIX}")
}
pub const APP_NAME: &str = "OpenLogi";
pub const GUI_EXECUTABLE: &str = "openlogi-desktop";
pub const CLI_EXECUTABLE: &str = "openlogi";
#[must_use]
pub fn dev_name(name: &str) -> String {
format!("{name} Dev")
}
pub const LOGIN_ITEMS_DIR: &str = "Contents/Library/LoginItems";
pub const LAUNCH_AGENTS_DIR: &str = "Contents/Library/LaunchAgents";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Helper {
Agent,
Overlay,
}
impl Helper {
#[must_use]
pub const fn bundle_id(self) -> &'static str {
match self {
Self::Agent => AGENT_ID,
Self::Overlay => OVERLAY_ID,
}
}
#[must_use]
pub const fn display_name(self) -> &'static str {
match self {
Self::Agent => "OpenLogi Agent",
Self::Overlay => "OpenLogi Overlay",
}
}
#[must_use]
pub const fn executable(self) -> &'static str {
match self {
Self::Agent => "openlogi-agent",
Self::Overlay => "openlogi-overlay",
}
}
#[must_use]
pub fn bundle_dir(self) -> String {
format!("{LOGIN_ITEMS_DIR}/{}.app", self.display_name())
}
#[must_use]
pub fn dev_bundle_dir(self) -> String {
format!("{LOGIN_ITEMS_DIR}/{}.app", dev_name(self.display_name()))
}
#[must_use]
pub fn executable_in(self, bundle_dir: &str) -> String {
format!("{bundle_dir}/Contents/MacOS/{}", self.executable())
}
const fn legacy_bundle_name(self) -> &'static str {
match self {
Self::Agent => "OpenLogiAgent",
Self::Overlay => "OpenLogiOverlay",
}
}
#[must_use]
pub fn executable_candidates(self) -> [String; 3] {
let legacy = format!("{LOGIN_ITEMS_DIR}/{}.app", self.legacy_bundle_name());
[
self.executable_in(&self.dev_bundle_dir()),
self.executable_in(&self.bundle_dir()),
self.executable_in(&legacy),
]
}
}
#[must_use]
pub fn helper_bundle_root(executable: &Path) -> Option<&Path> {
let bundle = executable.ancestors().nth(3)?;
(bundle.extension()? == "app").then_some(bundle)
}
#[must_use]
pub fn is_dev_id(id: &str) -> bool {
strip_dev_suffix(id) != id
}
#[must_use]
pub fn is_openlogi_foreground_id(id: &str) -> bool {
const WINDOWS_SUFFIX: &str = ".exe";
let executables = [
CLI_EXECUTABLE,
GUI_EXECUTABLE,
Helper::Agent.executable(),
Helper::Overlay.executable(),
];
let base = strip_dev_suffix(id);
[APP_ID, AGENT_ID, OVERLAY_ID]
.iter()
.any(|own| base.eq_ignore_ascii_case(own))
|| id
.rsplit(['\\', '/'])
.next()
.filter(|file| ends_with_ignore_ascii_case(file, WINDOWS_SUFFIX))
.and_then(|file| file.get(..file.len() - WINDOWS_SUFFIX.len()))
.is_some_and(|stem| executables.iter().any(|exe| stem.eq_ignore_ascii_case(exe)))
}
const LEGACY_DEV_SUFFIX: &str = ".dev";
fn strip_dev_suffix(id: &str) -> &str {
[DEV_SUFFIX, LEGACY_DEV_SUFFIX]
.iter()
.find(|suffix| ends_with_ignore_ascii_case(id, suffix))
.and_then(|suffix| id.get(..id.len() - suffix.len()))
.unwrap_or(id)
}
fn ends_with_ignore_ascii_case(haystack: &str, suffix: &str) -> bool {
haystack.len() > suffix.len()
&& haystack
.get(haystack.len() - suffix.len()..)
.is_some_and(|tail| tail.eq_ignore_ascii_case(suffix))
}
#[must_use]
pub fn release_tag_url(version: &str) -> String {
format!("{REPO_URL}/releases/tag/v{version}")
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DeeplinkCommand {
Show,
OpenSettings,
OpenAbout,
CheckForUpdates,
Quit,
}
impl DeeplinkCommand {
pub const SCHEME: &str = "openlogi";
#[must_use]
pub const fn as_name(self) -> &'static str {
match self {
Self::Show => "show",
Self::OpenSettings => "open-settings",
Self::OpenAbout => "open-about",
Self::CheckForUpdates => "check-for-updates",
Self::Quit => "quit",
}
}
#[must_use]
pub fn to_url(self) -> String {
format!("{}://{}", Self::SCHEME, self.as_name())
}
#[must_use]
pub fn from_name(name: &str) -> Option<Self> {
match name {
"show" => Some(Self::Show),
"open-settings" => Some(Self::OpenSettings),
"open-about" => Some(Self::OpenAbout),
"check-for-updates" => Some(Self::CheckForUpdates),
"quit" => Some(Self::Quit),
_ => None,
}
}
#[must_use]
pub fn parse_url(url: &str) -> Option<Self> {
let rest = url.strip_prefix(Self::SCHEME)?.strip_prefix("://")?;
let name = rest.split(['/', '?']).next().unwrap_or(rest);
Self::from_name(name)
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::{
AGENT_ID, APP_ID, DeeplinkCommand, Helper, OVERLAY_ID, dev_id, helper_bundle_root,
is_dev_id, is_openlogi_foreground_id,
};
const ALL: [DeeplinkCommand; 5] = [
DeeplinkCommand::Show,
DeeplinkCommand::OpenSettings,
DeeplinkCommand::OpenAbout,
DeeplinkCommand::CheckForUpdates,
DeeplinkCommand::Quit,
];
#[test]
fn url_round_trips() {
for cmd in ALL {
assert_eq!(DeeplinkCommand::parse_url(&cmd.to_url()), Some(cmd));
}
}
#[test]
fn parse_url_ignores_trailing_path_and_query() {
assert_eq!(
DeeplinkCommand::parse_url("openlogi://show/"),
Some(DeeplinkCommand::Show)
);
assert_eq!(
DeeplinkCommand::parse_url("openlogi://open-settings?from=tray"),
Some(DeeplinkCommand::OpenSettings)
);
}
#[test]
fn parse_url_rejects_foreign_scheme_and_unknown_command() {
assert_eq!(DeeplinkCommand::parse_url("https://example.com/show"), None);
assert_eq!(DeeplinkCommand::parse_url("openlogi://bogus"), None);
assert_eq!(DeeplinkCommand::parse_url("openlogi://"), None);
}
#[test]
fn dev_ids_round_trip() {
for id in [APP_ID, AGENT_ID, OVERLAY_ID] {
assert!(is_dev_id(&dev_id(id)), "{id} suffixed must read as dev");
assert!(!is_dev_id(id), "{id} is production");
}
}
#[test]
fn helper_bundles_are_named_after_their_display_names() {
assert_eq!(
Helper::Agent.bundle_dir(),
"Contents/Library/LoginItems/OpenLogi Agent.app"
);
assert_eq!(
Helper::Overlay.dev_bundle_dir(),
"Contents/Library/LoginItems/OpenLogi Overlay Dev.app"
);
assert_eq!(
Helper::Agent.executable_candidates()[2],
"Contents/Library/LoginItems/OpenLogiAgent.app/Contents/MacOS/openlogi-agent"
);
}
#[test]
fn a_helper_bundle_root_is_three_levels_above_its_executable() {
let packaged = Path::new(
"/Applications/OpenLogi.app/Contents/Library/LoginItems/OpenLogi Agent.app/Contents/MacOS/openlogi-agent",
);
assert_eq!(
helper_bundle_root(packaged),
Some(Path::new(
"/Applications/OpenLogi.app/Contents/Library/LoginItems/OpenLogi Agent.app"
))
);
let dev = Path::new(
"/Users/me/OpenLogi/target/dev/OpenLogi.app/Contents/Library/LoginItems/OpenLogi Agent Dev.app/Contents/MacOS/openlogi-agent",
);
assert_eq!(
helper_bundle_root(dev),
Some(Path::new(
"/Users/me/OpenLogi/target/dev/OpenLogi.app/Contents/Library/LoginItems/OpenLogi Agent Dev.app"
))
);
assert_eq!(
helper_bundle_root(Path::new("target/debug/openlogi-agent")),
None
);
}
#[test]
fn the_legacy_dotted_suffix_still_reads_as_dev() {
assert!(is_dev_id("org.openlogi.agent.dev"));
assert!(is_dev_id("org.openlogi.openlogi.dev"));
}
#[test]
fn a_bare_suffix_is_not_a_dev_id() {
assert!(!is_dev_id("-dev"));
assert!(!is_dev_id(".dev"));
assert!(!is_dev_id(""));
}
#[test]
fn matching_ignores_case_but_not_position() {
assert!(is_dev_id("org.openlogi.agent-DEV"));
assert!(!is_dev_id("org.openlogi.dev-agent"));
}
#[test]
fn our_own_processes_are_recognised_in_both_identifier_shapes() {
for id in [APP_ID, AGENT_ID, OVERLAY_ID] {
assert!(is_openlogi_foreground_id(id), "{id}");
assert!(is_openlogi_foreground_id(&dev_id(id)), "dev {id}");
}
assert!(is_openlogi_foreground_id(
r"c:\program files\openlogi\openlogi.exe"
));
assert!(is_openlogi_foreground_id(
r"c:\program files\openlogi\openlogi-agent.exe"
));
assert!(is_openlogi_foreground_id(
r"c:\src\openlogi\target\debug\openlogi-desktop.exe"
));
}
#[test]
fn a_foreign_app_that_merely_starts_the_same_way_is_not_ours() {
assert!(!is_openlogi_foreground_id("org.openlogi.openlogi.helper"));
assert!(!is_openlogi_foreground_id(r"c:\apps\openlogic.exe"));
assert!(!is_openlogi_foreground_id("com.apple.Safari"));
assert!(!is_openlogi_foreground_id(""));
}
}