Skip to main content

openlogi_core/
brand.rs

1//! Brand constants shared across the workspace: the project's public URLs,
2//! the `openlogi://` deep-link command vocabulary, the three processes' bundle
3//! identifiers, and the app bundle's layout — where the helpers live and what
4//! they are called on disk.
5//!
6//! All of it lives here, in the platform-free core crate, so the agent (which
7//! *emits* tray deep links and finds the overlay), the GUI (which *parses* the
8//! deep links and finds the agent), and the packaging tooling share a single
9//! source of truth: the command names can't drift across the process boundary,
10//! a helper is looked up at runtime by the name packaging gave it, and a rename
11//! touches one file.
12
13use std::path::Path;
14
15/// The OpenLogi GitHub repository.
16pub const REPO_URL: &str = "https://github.com/AprilNEA/OpenLogi";
17/// The README, used as the in-app "Help" link.
18pub const HELP_URL: &str = "https://github.com/AprilNEA/OpenLogi#readme";
19/// The "latest release" page.
20pub const RELEASES_URL: &str = "https://github.com/AprilNEA/OpenLogi/releases/latest";
21
22/// The application identifier: the Wayland xdg-toplevel `app_id` (and X11
23/// `WM_CLASS`) the GUI advertises, the root of the macOS bundle-id family
24/// (`org.openlogi.agent`, `org.openlogi.openlogi-dev`), and the value the Linux
25/// `.desktop` file pins as `StartupWMClass`. Defined once here so the window the
26/// compositor sees, the launcher that groups it, and the frontmost backend that
27/// self-identifies OpenLogi can never disagree. The `.desktop` file carries its
28/// own literal copy (it can't reference Rust) — keep the two in sync.
29pub const APP_ID: &str = "org.openlogi.openlogi";
30
31/// The always-on agent's bundle identifier — the process that owns the hook and
32/// holds the Accessibility grant, shipped as a nested login item.
33pub const AGENT_ID: &str = "org.openlogi.agent";
34
35/// The Actions Ring overlay's bundle identifier, the second nested login item.
36pub const OVERLAY_ID: &str = "org.openlogi.overlay";
37
38/// The agent's macOS launchd service label: the `Label` in the app bundle's
39/// embedded `Contents/Library/LaunchAgents/<label>.plist`, what `SMAppService`
40/// registers, and the name `launchctl` addresses (`gui/<uid>/<label>`). Dev
41/// bundles use [`dev_id`] of this, so a dev registration can never collide
42/// with the shipped one.
43///
44/// A launchd label is a *namespace key*, not a TCC identity — deliberately not
45/// [`AGENT_ID`], although the two look related: `org.openlogi.agent` is the
46/// frozen label of the legacy hand-written `~/Library/LaunchAgents` plist
47/// (see `openlogi-agent/src/autostart/macos.rs`), and reusing a legacy label
48/// would make the migration's "is this job ours or the old file's?" question
49/// unanswerable. Once shipped, this value is frozen the same way: renaming it
50/// orphans the registration users already approved in Login Items.
51pub const AGENT_SERVICE_LABEL: &str = "org.openlogi.agent.service";
52
53/// What a dev build appends to every identifier above, so a local build can
54/// never claim a shipped TCC grant and System Settings shows which of the two
55/// installed copies a row belongs to.
56const DEV_SUFFIX: &str = "-dev";
57
58/// `id`'s dev-channel counterpart.
59///
60/// Packaging (`cargo xtask macos`) stamps the result into every `Info.plist`;
61/// the agent matches running GUI processes against it. Defined here so the
62/// identity a dev bundle carries and the identity anything looks for cannot
63/// diverge.
64#[must_use]
65pub fn dev_id(id: &str) -> String {
66    format!("{id}{DEV_SUFFIX}")
67}
68
69/// The app's display name: the outer bundle's `CFBundleName` and
70/// `CFBundleDisplayName`, and the root the helpers' names are formed from.
71pub const APP_NAME: &str = "OpenLogi";
72
73/// The GUI's executable, as cargo builds it and as the macOS bundle and the
74/// Linux packages ship it. The helpers' executables are [`Helper::executable`].
75pub const GUI_EXECUTABLE: &str = "openlogi-desktop";
76
77/// The CLI's executable — the command users type, so it never changes. The
78/// Windows installer ships the GUI under the same name in another case
79/// (`OpenLogi.exe`), which the case-insensitive match below covers.
80pub const CLI_EXECUTABLE: &str = "openlogi";
81
82/// `name`'s dev-channel counterpart, the way [`dev_id`] is for identifiers:
83/// what System Settings shows for a local build's bundle, and the directory a
84/// dev helper lives in.
85#[must_use]
86pub fn dev_name(name: &str) -> String {
87    format!("{name} Dev")
88}
89
90/// Where the app bundle nests its login-item helpers, relative to its root.
91pub const LOGIN_ITEMS_DIR: &str = "Contents/Library/LoginItems";
92
93/// Where the app bundle carries the agent's launchd service plist, relative
94/// to its root.
95pub const LAUNCH_AGENTS_DIR: &str = "Contents/Library/LaunchAgents";
96
97/// The nested login-item helpers the app bundle embeds.
98///
99/// Each helper's directory is named exactly like its display name, because
100/// macOS privacy panes fall back to a bundle's filename whenever its metadata
101/// is stale — a spelling that differs from the display name, or a dev helper
102/// named like the shipped one, renders as a row nobody can trust. Packaging
103/// writes these names and both the app and the agent look the helpers up by
104/// them at runtime, so they are defined once here.
105#[derive(Clone, Copy, Debug, PartialEq, Eq)]
106pub enum Helper {
107    /// The always-on agent: the process that owns the hook and holds the
108    /// Accessibility grant.
109    Agent,
110    /// The Actions Ring renderer.
111    Overlay,
112}
113
114impl Helper {
115    /// The helper's bundle identifier.
116    #[must_use]
117    pub const fn bundle_id(self) -> &'static str {
118        match self {
119            Self::Agent => AGENT_ID,
120            Self::Overlay => OVERLAY_ID,
121        }
122    }
123
124    /// The shipped display name — and so the shipped bundle directory's name.
125    #[must_use]
126    pub const fn display_name(self) -> &'static str {
127        match self {
128            Self::Agent => "OpenLogi Agent",
129            Self::Overlay => "OpenLogi Overlay",
130        }
131    }
132
133    /// The helper's executable: how cargo names the binary, and how the
134    /// process shows up in a process list.
135    #[must_use]
136    pub const fn executable(self) -> &'static str {
137        match self {
138            Self::Agent => "openlogi-agent",
139            Self::Overlay => "openlogi-overlay",
140        }
141    }
142
143    /// The shipped helper's bundle, relative to the app bundle's root.
144    #[must_use]
145    pub fn bundle_dir(self) -> String {
146        format!("{LOGIN_ITEMS_DIR}/{}.app", self.display_name())
147    }
148
149    /// A dev build's helper bundle, relative to the app bundle's root.
150    #[must_use]
151    pub fn dev_bundle_dir(self) -> String {
152        format!("{LOGIN_ITEMS_DIR}/{}.app", dev_name(self.display_name()))
153    }
154
155    /// The helper's executable inside `bundle_dir`, one of [`Self::bundle_dir`]
156    /// or [`Self::dev_bundle_dir`].
157    #[must_use]
158    pub fn executable_in(self, bundle_dir: &str) -> String {
159        format!("{bundle_dir}/Contents/MacOS/{}", self.executable())
160    }
161
162    /// The bundle name this helper shipped under before it took its display
163    /// name. History, so spelled out rather than derived: a future rename must
164    /// not quietly stop old installs from being found.
165    const fn legacy_bundle_name(self) -> &'static str {
166        match self {
167            Self::Agent => "OpenLogiAgent",
168            Self::Overlay => "OpenLogiOverlay",
169        }
170    }
171
172    /// Every path, relative to the app bundle's root, at which this helper's
173    /// executable has ever shipped, newest layout first: the dev-suffixed
174    /// name, the shipped name, and the pre-rename name for bundles built
175    /// before the helpers took their display names.
176    #[must_use]
177    pub fn executable_candidates(self) -> [String; 3] {
178        let legacy = format!("{LOGIN_ITEMS_DIR}/{}.app", self.legacy_bundle_name());
179        [
180            self.executable_in(&self.dev_bundle_dir()),
181            self.executable_in(&self.bundle_dir()),
182            self.executable_in(&legacy),
183        ]
184    }
185}
186
187/// The `.app` root of a packaged helper binary — `…/Foo.app/Contents/MacOS/foo`
188/// gives `…/Foo.app` — and `None` for a bare binary such as a cargo build
189/// output.
190#[must_use]
191pub fn helper_bundle_root(executable: &Path) -> Option<&Path> {
192    let bundle = executable.ancestors().nth(3)?;
193    (bundle.extension()? == "app").then_some(bundle)
194}
195
196/// Whether `id` names a dev build — the inverse of [`dev_id`].
197///
198/// The profile split keys off this: a dev bundle gets its own config directory
199/// and IPC socket, so a false negative points a dev build at the user's real
200/// config. That asymmetry is why the legacy `.dev` spelling is still accepted —
201/// a local bundle built before the rename must not silently claim production
202/// state just because nobody rebuilt it.
203#[must_use]
204pub fn is_dev_id(id: &str) -> bool {
205    strip_dev_suffix(id) != id
206}
207
208/// Whether `id` — a foreground-application identifier, as
209/// [`ForegroundApp::id`](crate::app::ForegroundApp::id) defines one — names one
210/// of OpenLogi's own three processes.
211///
212/// The frontmost-app reader sees the GUI whenever its window is in front, so
213/// without this OpenLogi would offer itself as a target for a per-app profile.
214/// Both identifier shapes are recognised: the bundle-id family above (macOS
215/// bundle ids, and the `WM_CLASS` / `app_id` the GUI advertises on Linux), dev
216/// builds included; and the Windows executable path, matched on its file name
217/// — the installed names from `packaging/windows/OpenLogi.wxs` (which carries
218/// its own literal copy, since it can't reference Rust — keep the two in sync)
219/// plus the cargo artifact name a dev build's GUI runs under.
220#[must_use]
221pub fn is_openlogi_foreground_id(id: &str) -> bool {
222    const WINDOWS_SUFFIX: &str = ".exe";
223    let executables = [
224        CLI_EXECUTABLE,
225        GUI_EXECUTABLE,
226        Helper::Agent.executable(),
227        Helper::Overlay.executable(),
228    ];
229
230    let base = strip_dev_suffix(id);
231    [APP_ID, AGENT_ID, OVERLAY_ID]
232        .iter()
233        .any(|own| base.eq_ignore_ascii_case(own))
234        || id
235            .rsplit(['\\', '/'])
236            .next()
237            .filter(|file| ends_with_ignore_ascii_case(file, WINDOWS_SUFFIX))
238            .and_then(|file| file.get(..file.len() - WINDOWS_SUFFIX.len()))
239            .is_some_and(|stem| executables.iter().any(|exe| stem.eq_ignore_ascii_case(exe)))
240}
241
242/// The dev suffix before it was hyphenated. Recognised, never produced.
243const LEGACY_DEV_SUFFIX: &str = ".dev";
244
245/// `id` with a recognised dev suffix removed, or `id` unchanged.
246fn strip_dev_suffix(id: &str) -> &str {
247    [DEV_SUFFIX, LEGACY_DEV_SUFFIX]
248        .iter()
249        .find(|suffix| ends_with_ignore_ascii_case(id, suffix))
250        .and_then(|suffix| id.get(..id.len() - suffix.len()))
251        .unwrap_or(id)
252}
253
254fn ends_with_ignore_ascii_case(haystack: &str, suffix: &str) -> bool {
255    haystack.len() > suffix.len()
256        && haystack
257            .get(haystack.len() - suffix.len()..)
258            .is_some_and(|tail| tail.eq_ignore_ascii_case(suffix))
259}
260
261/// The release page for a specific version tag (e.g. the running build).
262#[must_use]
263pub fn release_tag_url(version: &str) -> String {
264    format!("{REPO_URL}/releases/tag/v{version}")
265}
266
267/// A GUI action the agent's tray (or any external caller) requests by opening
268/// an `openlogi://<name>` URL. macOS delivers it to the running GUI via an
269/// Apple Event; the GUI parses it back into this enum and dispatches.
270///
271/// The agent builds URLs with [`DeeplinkCommand::to_url`]; the GUI reads them
272/// with [`DeeplinkCommand::parse_url`]. The command names are defined once, in
273/// [`DeeplinkCommand::as_name`], so the two sides cannot disagree.
274#[derive(Clone, Copy, Debug, Eq, PartialEq)]
275pub enum DeeplinkCommand {
276    /// Show / foreground the main window.
277    Show,
278    /// Open the Settings window.
279    OpenSettings,
280    /// Open Settings on the About page.
281    OpenAbout,
282    /// Run a manual update check and open Settings on the Updates page, where
283    /// its status is rendered.
284    CheckForUpdates,
285    /// Quit the GUI.
286    Quit,
287}
288
289impl DeeplinkCommand {
290    /// The URL scheme OpenLogi registers with LaunchServices.
291    pub const SCHEME: &str = "openlogi";
292
293    /// The wire name for this command — the host component of its URL.
294    #[must_use]
295    pub const fn as_name(self) -> &'static str {
296        match self {
297            Self::Show => "show",
298            Self::OpenSettings => "open-settings",
299            Self::OpenAbout => "open-about",
300            Self::CheckForUpdates => "check-for-updates",
301            Self::Quit => "quit",
302        }
303    }
304
305    /// Build the `openlogi://<name>` URL for this command.
306    #[must_use]
307    pub fn to_url(self) -> String {
308        format!("{}://{}", Self::SCHEME, self.as_name())
309    }
310
311    /// Parse a command from its wire name (the part after `openlogi://`).
312    #[must_use]
313    pub fn from_name(name: &str) -> Option<Self> {
314        match name {
315            "show" => Some(Self::Show),
316            "open-settings" => Some(Self::OpenSettings),
317            "open-about" => Some(Self::OpenAbout),
318            "check-for-updates" => Some(Self::CheckForUpdates),
319            "quit" => Some(Self::Quit),
320            _ => None,
321        }
322    }
323
324    /// Parse a full `openlogi://…` URL. The command lives in the URL's host
325    /// component, so any trailing path or query (`openlogi://show/`,
326    /// `openlogi://show?x=1`) is ignored. Returns `None` for a foreign scheme
327    /// or an unknown command.
328    #[must_use]
329    pub fn parse_url(url: &str) -> Option<Self> {
330        let rest = url.strip_prefix(Self::SCHEME)?.strip_prefix("://")?;
331        let name = rest.split(['/', '?']).next().unwrap_or(rest);
332        Self::from_name(name)
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use std::path::Path;
339
340    use super::{
341        AGENT_ID, APP_ID, DeeplinkCommand, Helper, OVERLAY_ID, dev_id, helper_bundle_root,
342        is_dev_id, is_openlogi_foreground_id,
343    };
344
345    const ALL: [DeeplinkCommand; 5] = [
346        DeeplinkCommand::Show,
347        DeeplinkCommand::OpenSettings,
348        DeeplinkCommand::OpenAbout,
349        DeeplinkCommand::CheckForUpdates,
350        DeeplinkCommand::Quit,
351    ];
352
353    #[test]
354    fn url_round_trips() {
355        for cmd in ALL {
356            assert_eq!(DeeplinkCommand::parse_url(&cmd.to_url()), Some(cmd));
357        }
358    }
359
360    #[test]
361    fn parse_url_ignores_trailing_path_and_query() {
362        assert_eq!(
363            DeeplinkCommand::parse_url("openlogi://show/"),
364            Some(DeeplinkCommand::Show)
365        );
366        assert_eq!(
367            DeeplinkCommand::parse_url("openlogi://open-settings?from=tray"),
368            Some(DeeplinkCommand::OpenSettings)
369        );
370    }
371
372    #[test]
373    fn parse_url_rejects_foreign_scheme_and_unknown_command() {
374        assert_eq!(DeeplinkCommand::parse_url("https://example.com/show"), None);
375        assert_eq!(DeeplinkCommand::parse_url("openlogi://bogus"), None);
376        assert_eq!(DeeplinkCommand::parse_url("openlogi://"), None);
377    }
378
379    #[test]
380    fn dev_ids_round_trip() {
381        for id in [APP_ID, AGENT_ID, OVERLAY_ID] {
382            assert!(is_dev_id(&dev_id(id)), "{id} suffixed must read as dev");
383            assert!(!is_dev_id(id), "{id} is production");
384        }
385    }
386
387    #[test]
388    fn helper_bundles_are_named_after_their_display_names() {
389        assert_eq!(
390            Helper::Agent.bundle_dir(),
391            "Contents/Library/LoginItems/OpenLogi Agent.app"
392        );
393        assert_eq!(
394            Helper::Overlay.dev_bundle_dir(),
395            "Contents/Library/LoginItems/OpenLogi Overlay Dev.app"
396        );
397        // Bundles built before the helpers took their display names are
398        // still found, after the current layouts.
399        assert_eq!(
400            Helper::Agent.executable_candidates()[2],
401            "Contents/Library/LoginItems/OpenLogiAgent.app/Contents/MacOS/openlogi-agent"
402        );
403    }
404
405    #[test]
406    fn a_helper_bundle_root_is_three_levels_above_its_executable() {
407        let packaged = Path::new(
408            "/Applications/OpenLogi.app/Contents/Library/LoginItems/OpenLogi Agent.app/Contents/MacOS/openlogi-agent",
409        );
410        assert_eq!(
411            helper_bundle_root(packaged),
412            Some(Path::new(
413                "/Applications/OpenLogi.app/Contents/Library/LoginItems/OpenLogi Agent.app"
414            ))
415        );
416        let dev = Path::new(
417            "/Users/me/OpenLogi/target/dev/OpenLogi.app/Contents/Library/LoginItems/OpenLogi Agent Dev.app/Contents/MacOS/openlogi-agent",
418        );
419        assert_eq!(
420            helper_bundle_root(dev),
421            Some(Path::new(
422                "/Users/me/OpenLogi/target/dev/OpenLogi.app/Contents/Library/LoginItems/OpenLogi Agent Dev.app"
423            ))
424        );
425        assert_eq!(
426            helper_bundle_root(Path::new("target/debug/openlogi-agent")),
427            None
428        );
429    }
430
431    #[test]
432    fn the_legacy_dotted_suffix_still_reads_as_dev() {
433        // A stale `target/dev` bundle from before the rename must not fall
434        // through to the production config directory and IPC socket.
435        assert!(is_dev_id("org.openlogi.agent.dev"));
436        assert!(is_dev_id("org.openlogi.openlogi.dev"));
437    }
438
439    #[test]
440    fn a_bare_suffix_is_not_a_dev_id() {
441        assert!(!is_dev_id("-dev"));
442        assert!(!is_dev_id(".dev"));
443        assert!(!is_dev_id(""));
444    }
445
446    #[test]
447    fn matching_ignores_case_but_not_position() {
448        assert!(is_dev_id("org.openlogi.agent-DEV"));
449        assert!(!is_dev_id("org.openlogi.dev-agent"));
450    }
451
452    #[test]
453    fn our_own_processes_are_recognised_in_both_identifier_shapes() {
454        for id in [APP_ID, AGENT_ID, OVERLAY_ID] {
455            assert!(is_openlogi_foreground_id(id), "{id}");
456            assert!(is_openlogi_foreground_id(&dev_id(id)), "dev {id}");
457        }
458        // Windows reports a lower-cased executable path; a dev build runs the
459        // cargo artifact out of `target/`.
460        assert!(is_openlogi_foreground_id(
461            r"c:\program files\openlogi\openlogi.exe"
462        ));
463        assert!(is_openlogi_foreground_id(
464            r"c:\program files\openlogi\openlogi-agent.exe"
465        ));
466        assert!(is_openlogi_foreground_id(
467            r"c:\src\openlogi\target\debug\openlogi-desktop.exe"
468        ));
469    }
470
471    #[test]
472    fn a_foreign_app_that_merely_starts_the_same_way_is_not_ours() {
473        assert!(!is_openlogi_foreground_id("org.openlogi.openlogi.helper"));
474        assert!(!is_openlogi_foreground_id(r"c:\apps\openlogic.exe"));
475        assert!(!is_openlogi_foreground_id("com.apple.Safari"));
476        assert!(!is_openlogi_foreground_id(""));
477    }
478}