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}