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}