Skip to main content

openlogi_core/
brand.rs

1//! Brand constants shared across the workspace: the project's public URLs and
2//! the `openlogi://` deep-link command vocabulary.
3//!
4//! Both live here, in the platform-free core crate, so the agent (which *emits*
5//! tray deep links and renders help links) and the GUI (which *parses* the deep
6//! links and renders the same help links) share a single source of truth — the
7//! command names can't drift across the process boundary, and a repo move
8//! touches one file instead of three.
9
10/// The OpenLogi GitHub repository.
11pub const REPO_URL: &str = "https://github.com/AprilNEA/OpenLogi";
12/// The README, used as the in-app "Help" link.
13pub const HELP_URL: &str = "https://github.com/AprilNEA/OpenLogi#readme";
14/// The "latest release" page.
15pub const RELEASES_URL: &str = "https://github.com/AprilNEA/OpenLogi/releases/latest";
16
17/// The application identifier: the Wayland xdg-toplevel `app_id` (and X11
18/// `WM_CLASS`) the GUI advertises, the root of the macOS bundle-id family
19/// (`org.openlogi.agent`, `org.openlogi.openlogi-dev`), and the value the Linux
20/// `.desktop` file pins as `StartupWMClass`. Defined once here so the window the
21/// compositor sees, the launcher that groups it, and the frontmost backend that
22/// self-identifies OpenLogi can never disagree. The `.desktop` file carries its
23/// own literal copy (it can't reference Rust) — keep the two in sync.
24pub const APP_ID: &str = "org.openlogi.openlogi";
25
26/// The always-on agent's bundle identifier — the process that owns the hook and
27/// holds the Accessibility grant, shipped as a nested login item.
28pub const AGENT_ID: &str = "org.openlogi.agent";
29
30/// The Actions Ring overlay's bundle identifier, the second nested login item.
31pub const OVERLAY_ID: &str = "org.openlogi.overlay";
32
33/// What a dev build appends to every identifier above, so a local build can
34/// never claim a shipped TCC grant and System Settings shows which of the two
35/// installed copies a row belongs to.
36const DEV_SUFFIX: &str = "-dev";
37
38/// `id`'s dev-channel counterpart.
39///
40/// Packaging (`cargo xtask macos`) stamps the result into every `Info.plist`;
41/// the agent matches running GUI processes against it. Defined here so the
42/// identity a dev bundle carries and the identity anything looks for cannot
43/// diverge.
44#[must_use]
45pub fn dev_id(id: &str) -> String {
46    format!("{id}{DEV_SUFFIX}")
47}
48
49/// Whether `id` names a dev build — the inverse of [`dev_id`].
50///
51/// The profile split keys off this: a dev bundle gets its own config directory
52/// and IPC socket, so a false negative points a dev build at the user's real
53/// config. That asymmetry is why the legacy `.dev` spelling is still accepted —
54/// a local bundle built before the rename must not silently claim production
55/// state just because nobody rebuilt it.
56#[must_use]
57pub fn is_dev_id(id: &str) -> bool {
58    [DEV_SUFFIX, LEGACY_DEV_SUFFIX]
59        .iter()
60        .any(|suffix| ends_with_ignore_ascii_case(id, suffix))
61}
62
63/// The dev suffix before it was hyphenated. Recognised, never produced.
64const LEGACY_DEV_SUFFIX: &str = ".dev";
65
66fn ends_with_ignore_ascii_case(haystack: &str, suffix: &str) -> bool {
67    haystack.len() > suffix.len()
68        && haystack
69            .get(haystack.len() - suffix.len()..)
70            .is_some_and(|tail| tail.eq_ignore_ascii_case(suffix))
71}
72
73/// The release page for a specific version tag (e.g. the running build).
74#[must_use]
75pub fn release_tag_url(version: &str) -> String {
76    format!("{REPO_URL}/releases/tag/v{version}")
77}
78
79/// A GUI action the agent's tray (or any external caller) requests by opening
80/// an `openlogi://<name>` URL. macOS delivers it to the running GUI via an
81/// Apple Event; the GUI parses it back into this enum and dispatches.
82///
83/// The agent builds URLs with [`DeeplinkCommand::to_url`]; the GUI reads them
84/// with [`DeeplinkCommand::parse_url`]. The command names are defined once, in
85/// [`DeeplinkCommand::as_name`], so the two sides cannot disagree.
86#[derive(Clone, Copy, Debug, Eq, PartialEq)]
87pub enum DeeplinkCommand {
88    /// Show / foreground the main window.
89    Show,
90    /// Open the Settings window.
91    OpenSettings,
92    /// Open Settings on the About page.
93    OpenAbout,
94    /// Run a manual update check and open Settings on the Updates page, where
95    /// its status is rendered.
96    CheckForUpdates,
97    /// Quit the GUI.
98    Quit,
99}
100
101impl DeeplinkCommand {
102    /// The URL scheme OpenLogi registers with LaunchServices.
103    pub const SCHEME: &str = "openlogi";
104
105    /// The wire name for this command — the host component of its URL.
106    #[must_use]
107    pub const fn as_name(self) -> &'static str {
108        match self {
109            Self::Show => "show",
110            Self::OpenSettings => "open-settings",
111            Self::OpenAbout => "open-about",
112            Self::CheckForUpdates => "check-for-updates",
113            Self::Quit => "quit",
114        }
115    }
116
117    /// Build the `openlogi://<name>` URL for this command.
118    #[must_use]
119    pub fn to_url(self) -> String {
120        format!("{}://{}", Self::SCHEME, self.as_name())
121    }
122
123    /// Parse a command from its wire name (the part after `openlogi://`).
124    #[must_use]
125    pub fn from_name(name: &str) -> Option<Self> {
126        match name {
127            "show" => Some(Self::Show),
128            "open-settings" => Some(Self::OpenSettings),
129            "open-about" => Some(Self::OpenAbout),
130            "check-for-updates" => Some(Self::CheckForUpdates),
131            "quit" => Some(Self::Quit),
132            _ => None,
133        }
134    }
135
136    /// Parse a full `openlogi://…` URL. The command lives in the URL's host
137    /// component, so any trailing path or query (`openlogi://show/`,
138    /// `openlogi://show?x=1`) is ignored. Returns `None` for a foreign scheme
139    /// or an unknown command.
140    #[must_use]
141    pub fn parse_url(url: &str) -> Option<Self> {
142        let rest = url.strip_prefix(Self::SCHEME)?.strip_prefix("://")?;
143        let name = rest.split(['/', '?']).next().unwrap_or(rest);
144        Self::from_name(name)
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::{AGENT_ID, APP_ID, DeeplinkCommand, OVERLAY_ID, dev_id, is_dev_id};
151
152    const ALL: [DeeplinkCommand; 5] = [
153        DeeplinkCommand::Show,
154        DeeplinkCommand::OpenSettings,
155        DeeplinkCommand::OpenAbout,
156        DeeplinkCommand::CheckForUpdates,
157        DeeplinkCommand::Quit,
158    ];
159
160    #[test]
161    fn url_round_trips() {
162        for cmd in ALL {
163            assert_eq!(DeeplinkCommand::parse_url(&cmd.to_url()), Some(cmd));
164        }
165    }
166
167    #[test]
168    fn parse_url_ignores_trailing_path_and_query() {
169        assert_eq!(
170            DeeplinkCommand::parse_url("openlogi://show/"),
171            Some(DeeplinkCommand::Show)
172        );
173        assert_eq!(
174            DeeplinkCommand::parse_url("openlogi://open-settings?from=tray"),
175            Some(DeeplinkCommand::OpenSettings)
176        );
177    }
178
179    #[test]
180    fn parse_url_rejects_foreign_scheme_and_unknown_command() {
181        assert_eq!(DeeplinkCommand::parse_url("https://example.com/show"), None);
182        assert_eq!(DeeplinkCommand::parse_url("openlogi://bogus"), None);
183        assert_eq!(DeeplinkCommand::parse_url("openlogi://"), None);
184    }
185
186    #[test]
187    fn dev_ids_round_trip() {
188        for id in [APP_ID, AGENT_ID, OVERLAY_ID] {
189            assert!(is_dev_id(&dev_id(id)), "{id} suffixed must read as dev");
190            assert!(!is_dev_id(id), "{id} is production");
191        }
192    }
193
194    #[test]
195    fn the_legacy_dotted_suffix_still_reads_as_dev() {
196        // A stale `target/dev` bundle from before the rename must not fall
197        // through to the production config directory and IPC socket.
198        assert!(is_dev_id("org.openlogi.agent.dev"));
199        assert!(is_dev_id("org.openlogi.openlogi.dev"));
200    }
201
202    #[test]
203    fn a_bare_suffix_is_not_a_dev_id() {
204        assert!(!is_dev_id("-dev"));
205        assert!(!is_dev_id(".dev"));
206        assert!(!is_dev_id(""));
207    }
208
209    #[test]
210    fn matching_ignores_case_but_not_position() {
211        assert!(is_dev_id("org.openlogi.agent-DEV"));
212        assert!(!is_dev_id("org.openlogi.dev-agent"));
213    }
214}