use std::path::{Path, PathBuf};
use std::time::Duration;
use chrono::{DateTime, TimeZone, Utc};
use crate::daemon::opencode_plugin_seen;
use crate::error::{
ERR_OPENCODE_HOOK_UNREACHABLE, ERR_OPENCODE_SURFACE_UNDETERMINED,
ERR_OPENCODE_VERSION_UNSUPPORTED,
};
use crate::hooks::cline::SurfaceState;
use crate::hooks::hook_files::{self, Existing};
use crate::hooks::opencode::{self, Installed, Located};
pub const AGENT: &str = "opencode";
const VERSION_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CliReport {
pub state: SurfaceState,
pub version: Option<String>,
pub supported: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DesktopReport {
pub state: SurfaceState,
pub version: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PluginFileState {
Present,
Absent,
Foreign,
}
impl PluginFileState {
pub fn as_str(self) -> &'static str {
match self {
Self::Present => "present",
Self::Absent => "absent",
Self::Foreign => "foreign",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Reachability {
Proven,
Pending,
Failed,
}
impl Reachability {
pub fn as_str(self) -> &'static str {
match self {
Self::Proven => "proven",
Self::Pending => "pending",
Self::Failed => "failed",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpencodeAttestation {
pub as_of: DateTime<Utc>,
pub config_root: Option<(PathBuf, Located)>,
pub cli: CliReport,
pub desktop: DesktopReport,
pub plugin_file: PluginFileState,
pub plugin_seen: bool,
pub last_seen: Option<DateTime<Utc>>,
pub surfaces_seen: Vec<String>,
pub other_plugins: Vec<PathBuf>,
pub managed: Vec<PathBuf>,
pub reachability: Reachability,
}
pub struct Inputs {
pub openlatch_dir: PathBuf,
pub root: Option<(PathBuf, Located)>,
pub installed: Installed,
pub cli_version: Option<Result<String, String>>,
pub desktop_version: Option<String>,
pub managed_roots: Vec<PathBuf>,
pub now: DateTime<Utc>,
}
pub fn probe(openlatch_dir: &Path) -> OpencodeAttestation {
let installed = opencode::installed();
let cli_version = installed.cli.as_deref().map(run_version);
let desktop_version = installed.desktop.as_deref().and_then(desktop_version);
from_inputs(Inputs {
openlatch_dir: openlatch_dir.to_path_buf(),
root: opencode::root_located(),
installed,
cli_version,
desktop_version,
managed_roots: managed_roots(),
now: Utc::now(),
})
}
pub fn from_inputs(inputs: Inputs) -> OpencodeAttestation {
let cli = match (&inputs.installed.cli, &inputs.cli_version) {
(None, _) => CliReport {
state: if inputs.root.is_some() {
SurfaceState::Absent
} else {
SurfaceState::Undetermined
},
version: None,
supported: None,
},
(Some(_), Some(Ok(text))) => match opencode::parse_version(text) {
Some(version) => CliReport {
state: SurfaceState::Present,
version: Some(format!("{}.{}.{}", version.0, version.1, version.2)),
supported: Some(opencode::version_supported(version)),
},
None => CliReport {
state: SurfaceState::Undetermined,
version: None,
supported: None,
},
},
(Some(_), _) => CliReport {
state: SurfaceState::Undetermined,
version: None,
supported: None,
},
};
let desktop = DesktopReport {
state: match (&inputs.installed.desktop, &inputs.root) {
(Some(_), _) => SurfaceState::Present,
(None, Some(_)) => SurfaceState::Absent,
(None, None) => SurfaceState::Undetermined,
},
version: inputs.desktop_version.clone(),
};
let plugin_path = inputs
.root
.as_ref()
.map(|(root, _)| opencode::plugin_path(root));
let plugin_file = match plugin_path.as_deref().map(hook_files::whatever_is_there) {
Some(Existing::Ours) => PluginFileState::Present,
Some(Existing::Theirs) => PluginFileState::Foreign,
_ => PluginFileState::Absent,
};
let written_ms = plugin_path
.as_deref()
.filter(|_| plugin_file == PluginFileState::Present)
.and_then(opencode_plugin_seen::mtime_ms);
let seen = opencode_plugin_seen::read_seen(&inputs.openlatch_dir);
let plugin_seen =
written_ms.is_some_and(|written| opencode_plugin_seen::seen_since(&seen, written));
let last_seen =
opencode_plugin_seen::last_seen(&seen).and_then(|ms| Utc.timestamp_millis_opt(ms).single());
let installed_at = written_ms.and_then(|ms| Utc.timestamp_millis_opt(ms).single());
let other_plugins = match opencode_plugin_seen::reported_plugins(&inputs.openlatch_dir) {
Some(reported) => reported_other_plugins(&reported, plugin_path.as_deref()),
None => inputs
.root
.as_ref()
.map(|(root, _)| other_plugins(root, plugin_path.as_deref()))
.unwrap_or_default(),
};
OpencodeAttestation {
as_of: inputs.now,
config_root: inputs.root.clone(),
cli,
desktop,
plugin_file,
plugin_seen,
last_seen,
surfaces_seen: seen.keys().cloned().collect(),
other_plugins,
managed: inputs
.managed_roots
.into_iter()
.filter(|p| p.exists())
.collect(),
reachability: reachability(&inputs.openlatch_dir, installed_at),
}
}
fn run_version(binary: &Path) -> Result<String, String> {
run_version_within(binary, VERSION_TIMEOUT)
}
fn run_version_within(binary: &Path, timeout: Duration) -> Result<String, String> {
use std::io::BufRead;
let mut child = std::process::Command::new(binary)
.arg("--version")
.env("OPENCODE_DISABLE_AUTOUPDATE", "1")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
.map_err(|e| e.to_string())?;
let deadline = std::time::Instant::now() + timeout;
let (tx, rx) = std::sync::mpsc::channel();
if let Some(stdout) = child.stdout.take() {
std::thread::spawn(move || {
let mut line = String::new();
let _ = std::io::BufReader::new(stdout).read_line(&mut line);
let _ = tx.send(line);
});
}
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) if std::time::Instant::now() < deadline => {
std::thread::sleep(Duration::from_millis(25));
}
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
return Err("`opencode --version` did not answer in time".to_string());
}
Err(e) => return Err(e.to_string()),
}
};
if !status.success() {
return Err(format!("`opencode --version` exited with {status}"));
}
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
rx.recv_timeout(remaining)
.map_err(|_| "`opencode --version` did not answer in time".to_string())
}
fn desktop_version(location: &Path) -> Option<String> {
let plist = std::fs::read_to_string(location.join("Contents").join("Info.plist")).ok()?;
let after_key = plist
.split("<key>CFBundleShortVersionString</key>")
.nth(1)?;
let value = after_key
.split("<string>")
.nth(1)?
.split("</string>")
.next()?;
let value = value.trim();
(!value.is_empty()).then(|| value.to_string())
}
fn managed_roots() -> Vec<PathBuf> {
opencode::managed_dir().into_iter().collect()
}
fn reported_other_plugins(reported: &[String], ours: Option<&Path>) -> Vec<PathBuf> {
let canonical = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
let ours = ours.map(canonical);
reported
.iter()
.map(|entry| file_url_path(entry).unwrap_or_else(|| PathBuf::from(entry)))
.filter(|path| ours.as_deref() != Some(canonical(path).as_path()))
.collect()
}
fn file_url_path(entry: &str) -> Option<PathBuf> {
let rest = entry.strip_prefix("file://")?;
let rest = match rest.as_bytes() {
[b'/', drive, b':', ..] if drive.is_ascii_alphabetic() && cfg!(windows) => &rest[1..],
_ => rest,
};
let bytes = rest.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok();
if let Some(byte) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
out.push(byte);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
Some(PathBuf::from(String::from_utf8_lossy(&out).into_owned()))
}
fn other_plugins(root: &Path, ours: Option<&Path>) -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = ["plugin", opencode::PLUGINS_DIR_NAME]
.iter()
.filter_map(|dir| std::fs::read_dir(root.join(dir)).ok())
.flatten()
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| matches!(path.extension().and_then(|e| e.to_str()), Some("js" | "ts")))
.filter(|path| Some(path.as_path()) != ours)
.collect();
out.sort();
out
}
fn reachability(openlatch_dir: &Path, installed_at: Option<DateTime<Utc>>) -> Reachability {
let since = |at: &DateTime<Utc>| installed_at.is_none_or(|installed| *at >= installed);
if crate::daemon::agent_liveness::read(openlatch_dir)
.get(AGENT)
.is_some_and(since)
{
return Reachability::Proven;
}
if fallback_holds_event(&openlatch_dir.join("logs").join("fallback.jsonl"), &since) {
return Reachability::Failed;
}
Reachability::Pending
}
fn fallback_holds_event(path: &Path, since: &dyn Fn(&DateTime<Utc>) -> bool) -> bool {
use std::io::BufRead;
let Ok(file) = std::fs::File::open(path) else {
return false;
};
std::io::BufReader::new(file)
.lines()
.map_while(Result::ok)
.filter(|line| line.contains(AGENT))
.filter_map(|line| serde_json::from_str::<serde_json::Value>(&line).ok())
.filter(|event| event["source"].as_str() == Some(AGENT))
.filter(|event| event["data"]["opencode_hook"].as_str() != Some("plugin_loaded"))
.any(|event| {
event["time"]
.as_str()
.and_then(|t| DateTime::parse_from_rfc3339(t).ok())
.is_some_and(|at| since(&at.with_timezone(&Utc)))
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AbsentSurface {
pub surface: &'static str,
pub state: SurfaceState,
pub code: Option<&'static str>,
pub remedy: String,
}
impl OpencodeAttestation {
pub fn surfaces_absent(&self) -> Vec<AbsentSurface> {
[("cli", self.cli.state), ("desktop", self.desktop.state)]
.into_iter()
.filter(|(_, state)| *state != SurfaceState::Present)
.map(|(surface, state)| match state {
SurfaceState::Undetermined => AbsentSurface {
surface,
state,
code: Some(ERR_OPENCODE_SURFACE_UNDETERMINED),
remedy: format!(
"Start OpenCode once on the {surface} surface, then rerun `openlatch doctor`."
),
},
_ => AbsentSurface {
surface,
state,
code: None,
remedy: format!(
"Nothing to do unless you use the OpenCode {surface}; install it, then rerun `openlatch init --agent opencode`."
),
},
})
.collect()
}
pub fn to_json(&self) -> serde_json::Value {
use serde_json::json;
json!({
"as_of": self.as_of.to_rfc3339(),
"config_root": self.config_root.as_ref().map(|(path, located)| json!({
"path": crate::core::path_compat::display_path(path),
"located": located.as_str(),
})),
"cli": {
"state": self.cli.state.as_str(),
"version": self.cli.version,
"supported": self.cli.supported,
},
"desktop": {
"state": self.desktop.state.as_str(),
"version": self.desktop.version,
},
"plugin_file": {
"state": self.plugin_file.as_str(),
"ours": self.plugin_file == PluginFileState::Present,
},
"plugin_seen": {
"state": if self.plugin_seen { "seen" } else { "pending" },
"last_seen": self.last_seen.map(|t| t.to_rfc3339()),
"surfaces": self.surfaces_seen,
},
"other_plugins": self.other_plugins.iter().map(|path| json!({
"name": path.file_name().map(|n| n.to_string_lossy().into_owned()),
"path": crate::core::path_compat::display_path(path),
})).collect::<Vec<_>>(),
"managed": self.managed.iter().map(|p| crate::core::path_compat::display_path(p)).collect::<Vec<_>>(),
"reachability": self.reachability.as_str(),
"surfaces_absent": self.surfaces_absent().iter().map(|s| json!({
"surface": s.surface,
"state": s.state.as_str(),
"code": s.code,
"remedy": s.remedy,
})).collect::<Vec<_>>(),
})
}
pub fn summary(&self) -> String {
let cli = match (&self.cli.state, &self.cli.version) {
(SurfaceState::Present, Some(v)) => format!("CLI {v}"),
(state, _) => format!("CLI {}", state.as_str()),
};
let desktop = match (&self.desktop.state, &self.desktop.version) {
(SurfaceState::Present, Some(v)) => format!("Desktop {v}"),
(state, _) => format!("Desktop {}", state.as_str()),
};
format!(
"OpenCode: {cli}, {desktop}; plugin {} ({})",
self.plugin_file.as_str(),
if self.plugin_seen { "seen" } else { "pending" }
)
}
pub fn human_lines(&self) -> Vec<String> {
let mut lines = Vec::new();
if let Some((path, located)) = &self.config_root {
lines.push(format!(
"Config root: {} ({})",
crate::core::path_compat::display_path(path),
located.as_str()
));
}
if let Some(at) = self.last_seen {
lines.push(format!(
"Plugin last loaded: {} ({})",
at.to_rfc3339(),
self.surfaces_seen.join(", ")
));
}
for path in &self.other_plugins {
lines.push(format!(
"Other plugin: {}",
crate::core::path_compat::display_path(path)
));
}
for path in &self.managed {
lines.push(format!(
"Managed config (read only): {}",
crate::core::path_compat::display_path(path)
));
}
lines.push(format!("Reachability: {}", self.reachability.as_str()));
lines
}
pub fn version_unsupported(&self) -> Option<(&'static str, String)> {
(self.cli.supported == Some(false)).then(|| {
(
ERR_OPENCODE_VERSION_UNSUPPORTED,
format!(
"OpenCode {} is below the supported floor 1.17.0. Run `opencode upgrade`.",
self.cli.version.as_deref().unwrap_or("unknown")
),
)
})
}
pub fn unreachable(&self) -> Option<(&'static str, String)> {
(self.reachability == Reachability::Failed).then(|| {
let likely = if cfg!(windows) {
"on Windows, check that `openlatch-hook.exe` exists under the OpenLatch `bin` folder and is not blocked"
} else {
"check that the daemon is running and that `openlatch-hook` exists under the OpenLatch `bin` folder"
};
(
ERR_OPENCODE_HOOK_UNREACHABLE,
format!(
"The OpenLatch plugin ran the hook but it could not reach the daemon; {likely}. Run `openlatch doctor --fix`, then restart OpenCode."
),
)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
fn script(dir: &Path, name: &str, body: &str) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
let path = dir.join(name);
std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
path
}
#[cfg(unix)]
#[test]
fn the_version_probe_is_bounded_when_a_descendant_holds_stdout() {
let dir = tempfile::tempdir().unwrap();
let answers = script(dir.path(), "answers", "(sleep 30) &\necho 1.18.35");
let started = std::time::Instant::now();
let out = run_version_within(&answers, Duration::from_secs(3)).expect("version");
assert_eq!(out.trim(), "1.18.35");
assert!(started.elapsed() < Duration::from_secs(3));
let silent = script(dir.path(), "silent", "(sleep 30) &\nexit 0");
let started = std::time::Instant::now();
assert!(run_version_within(&silent, Duration::from_millis(500)).is_err());
assert!(started.elapsed() < Duration::from_secs(3));
}
struct Host {
_dir: tempfile::TempDir,
ol: PathBuf,
root: PathBuf,
}
fn host() -> Host {
let dir = tempfile::tempdir().expect("tempdir");
let ol = dir.path().join("openlatch");
let root = dir.path().join("config").join("opencode");
std::fs::create_dir_all(&ol).unwrap();
std::fs::create_dir_all(&root).unwrap();
Host {
ol,
root,
_dir: dir,
}
}
fn inputs(host: &Host, cli_version: Option<Result<String, String>>) -> Inputs {
Inputs {
openlatch_dir: host.ol.clone(),
root: Some((host.root.clone(), Located::Xdg)),
installed: Installed {
cli: cli_version
.as_ref()
.map(|_| PathBuf::from("/usr/local/bin/opencode")),
desktop: None,
},
cli_version,
desktop_version: None,
managed_roots: vec![],
now: Utc::now(),
}
}
#[test]
fn other_plugins_follow_the_load_report_then_the_folders() {
let host = host();
let plugins = opencode::plugins_dir(&host.root);
crate::hooks::opencode_plugin::install(&plugins, &host.ol).expect("install");
std::fs::write(plugins.join("global.js"), "export default async () => ({})").unwrap();
let before = from_inputs(inputs(&host, None));
assert_eq!(before.other_plugins, vec![plugins.join("global.js")]);
let ours = format!("file://{}", opencode::plugin_path(&host.root).display());
let repo = std::env::temp_dir().join("repo with space/.opencode/plugins/repo.ts");
let repo_url = format!("file://{}", repo.display()).replace(' ', "%20");
crate::daemon::opencode_plugin_seen::record_report(
&host.ol,
"cli",
1,
Some(&["opencode-foo@1.0.0".to_string(), repo_url, ours]),
)
.unwrap();
let after = from_inputs(inputs(&host, None));
assert_eq!(
after.other_plugins,
vec![PathBuf::from("opencode-foo@1.0.0"), repo]
);
}
#[test]
fn pending_until_a_load_report_follows_the_write_then_seen() {
let host = host();
crate::hooks::opencode_plugin::install(&opencode::plugins_dir(&host.root), &host.ol)
.expect("install");
let a = from_inputs(inputs(&host, Some(Ok("1.18.35\n".into()))));
assert_eq!(a.plugin_file, PluginFileState::Present);
assert!(!a.plugin_seen);
let json = a.to_json();
assert_eq!(json["plugin_seen"]["state"], "pending");
assert_eq!(json["plugin_file"]["ours"], true);
assert_eq!(json["cli"]["version"], "1.18.35");
assert_eq!(json["cli"]["supported"], true);
assert_eq!(json["config_root"]["located"], "xdg");
assert_eq!(json["reachability"], "pending");
let later = Utc::now().timestamp_millis() + 1000;
opencode_plugin_seen::record(&host.ol, "cli", later).unwrap();
let a = from_inputs(inputs(&host, Some(Ok("1.18.35".into()))));
assert!(a.plugin_seen);
assert_eq!(a.to_json()["plugin_seen"]["state"], "seen");
assert_eq!(
a.to_json()["plugin_seen"]["surfaces"],
serde_json::json!(["cli"])
);
}
#[test]
fn every_c5_field_but_the_relay_pair_is_rendered() {
let host = host();
let json = from_inputs(inputs(&host, Some(Ok("1.18.35".into())))).to_json();
for field in [
"as_of",
"config_root",
"cli",
"desktop",
"plugin_file",
"plugin_seen",
"other_plugins",
"reachability",
"surfaces_absent",
] {
assert!(json.get(field).is_some(), "{field} missing");
}
assert!(json.get("relay").is_none(), "P2's field");
assert!(json.get("bypass_suspected").is_none(), "P2's field");
assert_eq!(json["plugin_file"]["state"], "absent");
}
#[test]
fn a_cli_below_the_floor_is_unsupported() {
let host = host();
let a = from_inputs(inputs(&host, Some(Ok("opencode 1.16.2".into()))));
assert_eq!(a.cli.supported, Some(false));
let (code, remedy) = a.version_unsupported().expect("finding");
assert_eq!(code, ERR_OPENCODE_VERSION_UNSUPPORTED);
assert!(remedy.contains("opencode upgrade"));
assert!(from_inputs(inputs(&host, Some(Ok("1.17.0".into()))))
.version_unsupported()
.is_none());
}
#[test]
fn an_unanswering_cli_and_an_unresolved_root_are_undetermined() {
let host = host();
let a = from_inputs(inputs(&host, Some(Err("timed out".into()))));
assert_eq!(a.cli.state, SurfaceState::Undetermined);
let absent = a.surfaces_absent();
let cli = absent.iter().find(|s| s.surface == "cli").expect("cli");
assert_eq!(cli.code, Some(ERR_OPENCODE_SURFACE_UNDETERMINED));
let desktop = absent
.iter()
.find(|s| s.surface == "desktop")
.expect("desktop");
assert_eq!(desktop.state, SurfaceState::Absent);
assert_eq!(desktop.code, None);
let mut no_root = inputs(&host, None);
no_root.root = None;
let a = from_inputs(no_root);
assert_eq!(a.cli.state, SurfaceState::Undetermined);
assert_eq!(a.desktop.state, SurfaceState::Undetermined);
}
#[test]
fn a_foreign_file_and_other_plugins_are_named() {
let host = host();
let plugins = opencode::plugins_dir(&host.root);
std::fs::create_dir_all(&plugins).unwrap();
std::fs::write(
plugins.join("openlatch.js"),
"export default async () => ({})\n",
)
.unwrap();
std::fs::write(plugins.join("other.ts"), "x").unwrap();
std::fs::write(plugins.join("notes.md"), "x").unwrap();
std::fs::create_dir_all(host.root.join("plugin")).unwrap();
std::fs::write(host.root.join("plugin").join("legacy.js"), "x").unwrap();
let a = from_inputs(inputs(&host, None));
assert_eq!(a.plugin_file, PluginFileState::Foreign);
assert_eq!(a.to_json()["plugin_file"]["ours"], false);
let names: Vec<_> = a
.other_plugins
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
.collect();
assert_eq!(names, ["legacy.js", "other.ts"]);
}
#[test]
fn reachability_is_proven_by_a_live_event_and_failed_by_fallback_only() {
let host = host();
crate::hooks::opencode_plugin::install(&opencode::plugins_dir(&host.root), &host.ol)
.expect("install");
let logs = host.ol.join("logs");
std::fs::create_dir_all(&logs).unwrap();
let future = (Utc::now() + chrono::Duration::minutes(5)).to_rfc3339();
std::fs::write(
logs.join("fallback.jsonl"),
format!(
"{}\n",
serde_json::json!({"source": "opencode", "time": future, "data": {"opencode_hook": "plugin_loaded"}})
),
)
.unwrap();
assert_eq!(
from_inputs(inputs(&host, None)).reachability,
Reachability::Pending
);
std::fs::write(
logs.join("fallback.jsonl"),
format!(
"{}\n",
serde_json::json!({"source": "opencode", "time": future, "data": {"opencode_hook": "tool.execute.before"}})
),
)
.unwrap();
let a = from_inputs(inputs(&host, None));
assert_eq!(a.reachability, Reachability::Failed);
assert_eq!(
a.unreachable().map(|(c, _)| c),
Some(ERR_OPENCODE_HOOK_UNREACHABLE)
);
std::fs::write(
host.ol.join(crate::daemon::agent_liveness::FILE_NAME),
serde_json::json!({"opencode": future}).to_string(),
)
.unwrap();
assert_eq!(
from_inputs(inputs(&host, None)).reachability,
Reachability::Proven
);
}
#[test]
fn the_desktop_version_is_read_from_the_bundle_metadata() {
let dir = tempfile::tempdir().unwrap();
let app = dir.path().join("OpenCode.app");
std::fs::create_dir_all(app.join("Contents")).unwrap();
std::fs::write(
app.join("Contents").join("Info.plist"),
"<plist><dict><key>CFBundleName</key><string>OpenCode</string>\
<key>CFBundleShortVersionString</key>\n<string>1.18.35</string></dict></plist>",
)
.unwrap();
assert_eq!(desktop_version(&app).as_deref(), Some("1.18.35"));
assert_eq!(desktop_version(dir.path()), None);
}
}