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/// The agent's macOS launchd service label: the `Label` in the app bundle's
34/// embedded `Contents/Library/LaunchAgents/<label>.plist`, what `SMAppService`
35/// registers, and the name `launchctl` addresses (`gui/<uid>/<label>`). Dev
36/// bundles use [`dev_id`] of this, so a dev registration can never collide
37/// with the shipped one.
38///
39/// A launchd label is a *namespace key*, not a TCC identity — deliberately not
40/// [`AGENT_ID`], although the two look related: `org.openlogi.agent` is the
41/// frozen label of the legacy hand-written `~/Library/LaunchAgents` plist
42/// (see `openlogi-agent/src/autostart/macos.rs`), and reusing a legacy label
43/// would make the migration's "is this job ours or the old file's?" question
44/// unanswerable. Once shipped, this value is frozen the same way: renaming it
45/// orphans the registration users already approved in Login Items.
46pub const AGENT_SERVICE_LABEL: &str = "org.openlogi.agent.service";
47
48/// What a dev build appends to every identifier above, so a local build can
49/// never claim a shipped TCC grant and System Settings shows which of the two
50/// installed copies a row belongs to.
51const DEV_SUFFIX: &str = "-dev";
52
53/// `id`'s dev-channel counterpart.
54///
55/// Packaging (`cargo xtask macos`) stamps the result into every `Info.plist`;
56/// the agent matches running GUI processes against it. Defined here so the
57/// identity a dev bundle carries and the identity anything looks for cannot
58/// diverge.
59#[must_use]
60pub fn dev_id(id: &str) -> String {
61 format!("{id}{DEV_SUFFIX}")
62}
63
64/// Whether `id` names a dev build — the inverse of [`dev_id`].
65///
66/// The profile split keys off this: a dev bundle gets its own config directory
67/// and IPC socket, so a false negative points a dev build at the user's real
68/// config. That asymmetry is why the legacy `.dev` spelling is still accepted —
69/// a local bundle built before the rename must not silently claim production
70/// state just because nobody rebuilt it.
71#[must_use]
72pub fn is_dev_id(id: &str) -> bool {
73 strip_dev_suffix(id) != id
74}
75
76/// Whether `id` — a foreground-application identifier, as
77/// [`ForegroundApp::id`](crate::app::ForegroundApp::id) defines one — names one
78/// of OpenLogi's own three processes.
79///
80/// The frontmost-app reader sees the GUI whenever its window is in front, so
81/// without this OpenLogi would offer itself as a target for a per-app profile.
82/// Both identifier shapes are recognised: the bundle-id family above (macOS
83/// bundle ids, and the `WM_CLASS` / `app_id` the GUI advertises on Linux), dev
84/// builds included; and the Windows executable path, matched on its file name.
85/// `packaging/windows/OpenLogi.wxs` carries its own literal copy of those names
86/// (it can't reference Rust) — keep the two in sync.
87#[must_use]
88pub fn is_openlogi_foreground_id(id: &str) -> bool {
89 /// Installed names from `OpenLogi.wxs`, plus the cargo artifact name a dev
90 /// build runs under.
91 const EXECUTABLES: [&str; 4] = [
92 "openlogi.exe",
93 "openlogi-agent.exe",
94 "openlogi-overlay.exe",
95 "openlogi-desktop.exe",
96 ];
97
98 let base = strip_dev_suffix(id);
99 [APP_ID, AGENT_ID, OVERLAY_ID]
100 .iter()
101 .any(|own| base.eq_ignore_ascii_case(own))
102 || id
103 .rsplit(['\\', '/'])
104 .next()
105 .is_some_and(|file| EXECUTABLES.iter().any(|exe| file.eq_ignore_ascii_case(exe)))
106}
107
108/// The dev suffix before it was hyphenated. Recognised, never produced.
109const LEGACY_DEV_SUFFIX: &str = ".dev";
110
111/// `id` with a recognised dev suffix removed, or `id` unchanged.
112fn strip_dev_suffix(id: &str) -> &str {
113 [DEV_SUFFIX, LEGACY_DEV_SUFFIX]
114 .iter()
115 .find(|suffix| ends_with_ignore_ascii_case(id, suffix))
116 .and_then(|suffix| id.get(..id.len() - suffix.len()))
117 .unwrap_or(id)
118}
119
120fn ends_with_ignore_ascii_case(haystack: &str, suffix: &str) -> bool {
121 haystack.len() > suffix.len()
122 && haystack
123 .get(haystack.len() - suffix.len()..)
124 .is_some_and(|tail| tail.eq_ignore_ascii_case(suffix))
125}
126
127/// The release page for a specific version tag (e.g. the running build).
128#[must_use]
129pub fn release_tag_url(version: &str) -> String {
130 format!("{REPO_URL}/releases/tag/v{version}")
131}
132
133/// A GUI action the agent's tray (or any external caller) requests by opening
134/// an `openlogi://<name>` URL. macOS delivers it to the running GUI via an
135/// Apple Event; the GUI parses it back into this enum and dispatches.
136///
137/// The agent builds URLs with [`DeeplinkCommand::to_url`]; the GUI reads them
138/// with [`DeeplinkCommand::parse_url`]. The command names are defined once, in
139/// [`DeeplinkCommand::as_name`], so the two sides cannot disagree.
140#[derive(Clone, Copy, Debug, Eq, PartialEq)]
141pub enum DeeplinkCommand {
142 /// Show / foreground the main window.
143 Show,
144 /// Open the Settings window.
145 OpenSettings,
146 /// Open Settings on the About page.
147 OpenAbout,
148 /// Run a manual update check and open Settings on the Updates page, where
149 /// its status is rendered.
150 CheckForUpdates,
151 /// Quit the GUI.
152 Quit,
153}
154
155impl DeeplinkCommand {
156 /// The URL scheme OpenLogi registers with LaunchServices.
157 pub const SCHEME: &str = "openlogi";
158
159 /// The wire name for this command — the host component of its URL.
160 #[must_use]
161 pub const fn as_name(self) -> &'static str {
162 match self {
163 Self::Show => "show",
164 Self::OpenSettings => "open-settings",
165 Self::OpenAbout => "open-about",
166 Self::CheckForUpdates => "check-for-updates",
167 Self::Quit => "quit",
168 }
169 }
170
171 /// Build the `openlogi://<name>` URL for this command.
172 #[must_use]
173 pub fn to_url(self) -> String {
174 format!("{}://{}", Self::SCHEME, self.as_name())
175 }
176
177 /// Parse a command from its wire name (the part after `openlogi://`).
178 #[must_use]
179 pub fn from_name(name: &str) -> Option<Self> {
180 match name {
181 "show" => Some(Self::Show),
182 "open-settings" => Some(Self::OpenSettings),
183 "open-about" => Some(Self::OpenAbout),
184 "check-for-updates" => Some(Self::CheckForUpdates),
185 "quit" => Some(Self::Quit),
186 _ => None,
187 }
188 }
189
190 /// Parse a full `openlogi://…` URL. The command lives in the URL's host
191 /// component, so any trailing path or query (`openlogi://show/`,
192 /// `openlogi://show?x=1`) is ignored. Returns `None` for a foreign scheme
193 /// or an unknown command.
194 #[must_use]
195 pub fn parse_url(url: &str) -> Option<Self> {
196 let rest = url.strip_prefix(Self::SCHEME)?.strip_prefix("://")?;
197 let name = rest.split(['/', '?']).next().unwrap_or(rest);
198 Self::from_name(name)
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::{
205 AGENT_ID, APP_ID, DeeplinkCommand, OVERLAY_ID, dev_id, is_dev_id, is_openlogi_foreground_id,
206 };
207
208 const ALL: [DeeplinkCommand; 5] = [
209 DeeplinkCommand::Show,
210 DeeplinkCommand::OpenSettings,
211 DeeplinkCommand::OpenAbout,
212 DeeplinkCommand::CheckForUpdates,
213 DeeplinkCommand::Quit,
214 ];
215
216 #[test]
217 fn url_round_trips() {
218 for cmd in ALL {
219 assert_eq!(DeeplinkCommand::parse_url(&cmd.to_url()), Some(cmd));
220 }
221 }
222
223 #[test]
224 fn parse_url_ignores_trailing_path_and_query() {
225 assert_eq!(
226 DeeplinkCommand::parse_url("openlogi://show/"),
227 Some(DeeplinkCommand::Show)
228 );
229 assert_eq!(
230 DeeplinkCommand::parse_url("openlogi://open-settings?from=tray"),
231 Some(DeeplinkCommand::OpenSettings)
232 );
233 }
234
235 #[test]
236 fn parse_url_rejects_foreign_scheme_and_unknown_command() {
237 assert_eq!(DeeplinkCommand::parse_url("https://example.com/show"), None);
238 assert_eq!(DeeplinkCommand::parse_url("openlogi://bogus"), None);
239 assert_eq!(DeeplinkCommand::parse_url("openlogi://"), None);
240 }
241
242 #[test]
243 fn dev_ids_round_trip() {
244 for id in [APP_ID, AGENT_ID, OVERLAY_ID] {
245 assert!(is_dev_id(&dev_id(id)), "{id} suffixed must read as dev");
246 assert!(!is_dev_id(id), "{id} is production");
247 }
248 }
249
250 #[test]
251 fn the_legacy_dotted_suffix_still_reads_as_dev() {
252 // A stale `target/dev` bundle from before the rename must not fall
253 // through to the production config directory and IPC socket.
254 assert!(is_dev_id("org.openlogi.agent.dev"));
255 assert!(is_dev_id("org.openlogi.openlogi.dev"));
256 }
257
258 #[test]
259 fn a_bare_suffix_is_not_a_dev_id() {
260 assert!(!is_dev_id("-dev"));
261 assert!(!is_dev_id(".dev"));
262 assert!(!is_dev_id(""));
263 }
264
265 #[test]
266 fn matching_ignores_case_but_not_position() {
267 assert!(is_dev_id("org.openlogi.agent-DEV"));
268 assert!(!is_dev_id("org.openlogi.dev-agent"));
269 }
270
271 #[test]
272 fn our_own_processes_are_recognised_in_both_identifier_shapes() {
273 for id in [APP_ID, AGENT_ID, OVERLAY_ID] {
274 assert!(is_openlogi_foreground_id(id), "{id}");
275 assert!(is_openlogi_foreground_id(&dev_id(id)), "dev {id}");
276 }
277 // Windows reports a lower-cased executable path; a dev build runs the
278 // cargo artifact out of `target/`.
279 assert!(is_openlogi_foreground_id(
280 r"c:\program files\openlogi\openlogi.exe"
281 ));
282 assert!(is_openlogi_foreground_id(
283 r"c:\program files\openlogi\openlogi-agent.exe"
284 ));
285 assert!(is_openlogi_foreground_id(
286 r"c:\src\openlogi\target\debug\openlogi-desktop.exe"
287 ));
288 }
289
290 #[test]
291 fn a_foreign_app_that_merely_starts_the_same_way_is_not_ours() {
292 assert!(!is_openlogi_foreground_id("org.openlogi.openlogi.helper"));
293 assert!(!is_openlogi_foreground_id(r"c:\apps\openlogic.exe"));
294 assert!(!is_openlogi_foreground_id("com.apple.Safari"));
295 assert!(!is_openlogi_foreground_id(""));
296 }
297}