diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2b1a5c3..b7803f1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,6 +27,8 @@ jobs: - name: Build release run: cargo build --release + env: + TERM_RELEASE_VERSION: ${{ github.ref_name }} - name: Install librsvg (for icon rendering) run: brew install librsvg diff --git a/Cargo.lock b/Cargo.lock index 887c35f..86e41c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1969,7 +1969,7 @@ dependencies = [ [[package]] name = "term" -version = "0.1.0" +version = "1.3.1" dependencies = [ "bytemuck", "fontdue", diff --git a/Cargo.toml b/Cargo.toml index db83337..aebfeca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "term" -version = "0.1.0" +version = "1.3.1" edition = "2024" license = "MIT" diff --git a/README.md b/README.md index abb92fd..950fea8 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ Automated coverage includes inline unit tests, subprocess integration tests for - **Catppuccin Mocha** color theme throughout - **True-color support** — ANSI 8/16, 256-color, and 24-bit RGB - **Multiple tabs** — Cmd+T/W to open/close, Cmd+[/] or Cmd+1–9 to navigate, drag to reorder +- **Native self-updates** — `Term -> Check for Updates...` compares the running build against the latest GitHub release and installs it in place when a newer app bundle is available - **Native background controls** — `Appearance -> Background...` opens the macOS color panel with an alpha slider for live background color/opacity changes - **Scrollback** — 10,000-line buffer; scroll with mouse wheel, Cmd+Up/Down, Cmd+Home/End - **Clipboard** — Cmd+C copies selection (text), Cmd+V pastes (text or image path); OSC 52 supported diff --git a/install.sh b/install.sh index 3c6b86f..5f0a5fa 100755 --- a/install.sh +++ b/install.sh @@ -6,14 +6,23 @@ REPO_DIR="$(cd "$(dirname "$0")" && pwd)" APP_NAME="Term" APP_BUNDLE="/Applications/$APP_NAME.app" BIN_DIR="$REPO_DIR/target/release" +VERSION="${TERM_RELEASE_VERSION:-}" + +if [ -z "$VERSION" ]; then + if GIT_TAG=$(git -C "$REPO_DIR" describe --tags --exact-match 2>/dev/null); then + VERSION="${GIT_TAG#v}" + else + VERSION=$(grep '^version = ' "$REPO_DIR/Cargo.toml" | head -1 | sed 's/.*"\(.*\)".*/\1/') + fi +fi # Clean up the iconset temp dir on exit (success or failure). _ICONSET_TMP="" trap '[[ -n "$_ICONSET_TMP" ]] && rm -rf "$_ICONSET_TMP"' EXIT -echo "→ Building (release)…" +echo "→ Building (release ${VERSION})…" cd "$REPO_DIR" -cargo build --release +TERM_RELEASE_VERSION="$VERSION" cargo build --release echo "→ Creating app bundle at $APP_BUNDLE" mkdir -p "$APP_BUNDLE/Contents/MacOS" @@ -75,7 +84,7 @@ APPLESCRIPT fi # Info.plist — minimum viable for macOS to treat this as an app -cat > "$APP_BUNDLE/Contents/Info.plist" <<'PLIST' +cat > "$APP_BUNDLE/Contents/Info.plist" < @@ -84,7 +93,8 @@ cat > "$APP_BUNDLE/Contents/Info.plist" <<'PLIST' CFBundleName Term CFBundleDisplayName Term CFBundleIdentifier com.local.term - CFBundleVersion 1.0 + CFBundleShortVersionString ${VERSION} + CFBundleVersion ${VERSION} CFBundleExecutable term CFBundlePackageType APPL CFBundleIconFile AppIcon diff --git a/src/main.rs b/src/main.rs index 9695962..6566ad2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod config; mod platform; mod renderer; mod terminal; +mod updater; use std::collections::HashMap; use std::io::{Read, Write}; @@ -21,12 +22,15 @@ use completion::Engine; use config::{BackgroundAppearance, DEFAULT_BACKGROUND_APPEARANCE, WINDOW_HEIGHT, WINDOW_WIDTH}; use renderer::{PaneView, Renderer}; use terminal::Terminal; +use updater::UpdateCheck; #[derive(Debug)] enum AppEvent { PtyData { pane_id: u64, data: Vec }, PtyExit { pane_id: u64 }, NewTab, + CheckForUpdates, + UpdateCheckFinished(Result), ShowBackgroundAppearancePanel, BackgroundAppearanceChanged(BackgroundAppearance), } @@ -1069,6 +1073,7 @@ struct App { proxy: EventLoopProxy, tabbing_id: String, background: BackgroundAppearance, + checking_for_updates: bool, } impl App { @@ -1081,6 +1086,7 @@ impl App { proxy, tabbing_id: format!("term-{}", std::process::id()), background: DEFAULT_BACKGROUND_APPEARANCE, + checking_for_updates: false, } } @@ -1098,6 +1104,63 @@ impl App { id } + fn begin_update_check(&mut self) { + if self.checking_for_updates { + return; + } + self.checking_for_updates = true; + #[cfg(target_os = "macos")] + platform::set_update_menu_checking(true); + + let proxy = self.proxy.clone(); + std::thread::spawn(move || { + let _ = proxy.send_event(AppEvent::UpdateCheckFinished(updater::check_for_updates())); + }); + } + + fn finish_update_check(&mut self, result: Result) { + self.checking_for_updates = false; + #[cfg(target_os = "macos")] + platform::set_update_menu_checking(false); + + #[cfg(target_os = "macos")] + match result { + Ok(UpdateCheck::NoUpdateNeeded { + current_version, + latest_version, + comparison, + }) => { + let current = updater::display_version(¤t_version); + let latest = updater::display_version(&latest_version); + let message = if comparison.is_gt() { + format!("{current} is newer than the latest GitHub release ({latest}).") + } else { + format!("Term {current} is already up to date.") + }; + platform::show_info_alert("You're Up to Date", &message); + } + Ok(UpdateCheck::UpdateAvailable { + current_version, + release, + }) => { + let current = updater::display_version(¤t_version); + let latest = updater::display_version(&release.version); + if platform::confirm_update_install(¤t, &latest) { + match updater::spawn_background_update(&release) { + Ok(()) => platform::show_info_alert( + "Updating Term", + &format!( + "Downloading {latest} now. Term will relaunch when the update is ready." + ), + ), + Err(err) => platform::show_error_alert("Update Failed", &err), + } + } + } + Err(err) => platform::show_error_alert("Update Failed", &err), + } + } + fn create_pane( &mut self, window_id: WindowId, @@ -1523,6 +1586,10 @@ impl ApplicationHandler for App { let _ = proxy.send_event(AppEvent::NewTab); }); let proxy = self.proxy.clone(); + platform::set_check_for_updates_callback(move || { + let _ = proxy.send_event(AppEvent::CheckForUpdates); + }); + let proxy = self.proxy.clone(); platform::set_show_background_panel_callback(move || { let _ = proxy.send_event(AppEvent::ShowBackgroundAppearancePanel); }); @@ -1530,6 +1597,7 @@ impl ApplicationHandler for App { platform::set_background_changed_callback(move |background| { let _ = proxy.send_event(AppEvent::BackgroundAppearanceChanged(background)); }); + platform::install_update_menu_item(); platform::install_background_menu_item(); } @@ -2056,6 +2124,12 @@ impl ApplicationHandler for App { AppEvent::NewTab => { self.open_tab(event_loop); } + AppEvent::CheckForUpdates => { + self.begin_update_check(); + } + AppEvent::UpdateCheckFinished(result) => { + self.finish_update_check(result); + } AppEvent::ShowBackgroundAppearancePanel => { #[cfg(target_os = "macos")] platform::show_background_panel(self.background); diff --git a/src/platform.rs b/src/platform.rs index 24bc19f..6d37655 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -5,6 +5,7 @@ use std::sync::OnceLock; static NEW_TAB_CB: OnceLock> = OnceLock::new(); static SHOW_BACKGROUND_PANEL_CB: OnceLock> = OnceLock::new(); +static CHECK_FOR_UPDATES_CB: OnceLock> = OnceLock::new(); static BACKGROUND_CHANGED_CB: OnceLock> = OnceLock::new(); @@ -18,6 +19,10 @@ pub fn set_show_background_panel_callback(f: impl Fn() + Send + Sync + 'static) let _ = SHOW_BACKGROUND_PANEL_CB.set(Box::new(f)); } +pub fn set_check_for_updates_callback(f: impl Fn() + Send + Sync + 'static) { + let _ = CHECK_FOR_UPDATES_CB.set(Box::new(f)); +} + pub fn set_background_changed_callback(f: impl Fn(BackgroundAppearance) + Send + Sync + 'static) { let _ = BACKGROUND_CHANGED_CB.set(Box::new(f)); } @@ -42,6 +47,16 @@ extern "C" fn show_background_panel_action( } } +extern "C" fn check_for_updates_action( + _this: *mut objc2::runtime::AnyObject, + _cmd: objc2::runtime::Sel, + _sender: *mut objc2::runtime::AnyObject, +) { + if let Some(cb) = CHECK_FOR_UPDATES_CB.get() { + cb(); + } +} + extern "C" fn background_changed_action( _this: *mut objc2::runtime::AnyObject, _cmd: objc2::runtime::Sel, @@ -71,6 +86,11 @@ fn action_target_class() -> Option<&'static objc2::runtime::AnyClass> { show_background_panel_action as extern "C" fn(*mut AnyObject, objc2::runtime::Sel, *mut AnyObject), ); + builder.add_method( + sel!(checkForUpdates:), + check_for_updates_action + as extern "C" fn(*mut AnyObject, objc2::runtime::Sel, *mut AnyObject), + ); builder.add_method( sel!(changeColor:), background_changed_action @@ -160,6 +180,48 @@ fn find_menu_item( None } +#[cfg(target_os = "macos")] +fn app_menu_submenu( + main_menu: *mut objc2::runtime::AnyObject, +) -> Option<*mut objc2::runtime::AnyObject> { + use objc2::{msg_send, runtime::AnyObject}; + if main_menu.is_null() { + return None; + } + unsafe { + let count: usize = msg_send![main_menu, numberOfItems]; + if count == 0 { + return None; + } + let app_item: *mut AnyObject = msg_send![main_menu, itemAtIndex: 0usize]; + if app_item.is_null() { + return None; + } + let submenu: *mut AnyObject = msg_send![app_item, submenu]; + (!submenu.is_null()).then_some(submenu) + } +} + +#[cfg(target_os = "macos")] +fn main_menu() -> Option<*mut objc2::runtime::AnyObject> { + use objc2::{class, msg_send, runtime::AnyObject}; + unsafe { + let app: *mut AnyObject = msg_send![class!(NSApplication), sharedApplication]; + if app.is_null() { + return None; + } + let main_menu: *mut AnyObject = msg_send![app, mainMenu]; + (!main_menu.is_null()).then_some(main_menu) + } +} + +#[cfg(target_os = "macos")] +fn update_menu_item() -> Option<*mut objc2::runtime::AnyObject> { + let submenu = app_menu_submenu(main_menu()?)?; + find_menu_item(submenu, "Check for Updates...") + .or_else(|| find_menu_item(submenu, "Checking for Updates...")) +} + #[cfg(target_os = "macos")] fn panel_background(sender: *mut objc2::runtime::AnyObject) -> Option { use objc2::{class, msg_send, runtime::AnyObject}; @@ -232,17 +294,54 @@ pub fn configure_window_background(ns_view: *mut std::ffi::c_void) { } #[cfg(target_os = "macos")] -pub fn install_background_menu_item() { +pub fn install_update_menu_item() { use objc2::{class, msg_send, runtime::AnyObject, sel}; unsafe { - let app: *mut AnyObject = msg_send![class!(NSApplication), sharedApplication]; - if app.is_null() { + let Some(main_menu) = main_menu() else { + return; + }; + let Some(submenu) = app_menu_submenu(main_menu) else { + return; + }; + if update_menu_item().is_some() { return; } - let main_menu: *mut AnyObject = msg_send![app, mainMenu]; - if main_menu.is_null() { + + let title = match ns_string("Check for Updates...") { + Some(s) => s, + None => return, + }; + let empty = match ns_string("") { + Some(s) => s, + None => return, + }; + let item: *mut AnyObject = msg_send![class!(NSMenuItem), alloc]; + let item: *mut AnyObject = msg_send![ + item, + initWithTitle: title + action: sel!(checkForUpdates:) + keyEquivalent: empty + ]; + if item.is_null() { return; } + let Some(target) = shared_action_target() else { + return; + }; + let _: () = msg_send![item, setTarget: target]; + let count: usize = msg_send![submenu, numberOfItems]; + let insert_at = count.min(1usize); + let _: () = msg_send![submenu, insertItem: item atIndex: insert_at]; + } +} + +#[cfg(target_os = "macos")] +pub fn install_background_menu_item() { + use objc2::{class, msg_send, runtime::AnyObject, sel}; + unsafe { + let Some(main_menu) = main_menu() else { + return; + }; let appearance_title = match ns_string("Appearance") { Some(s) => s, @@ -336,6 +435,74 @@ pub fn show_background_panel(background: BackgroundAppearance) { } } +#[cfg(target_os = "macos")] +pub fn set_update_menu_checking(checking: bool) { + use objc2::msg_send; + let Some(item) = update_menu_item() else { + return; + }; + let Some(title) = ns_string(if checking { + "Checking for Updates..." + } else { + "Check for Updates..." + }) else { + return; + }; + unsafe { + let _: () = msg_send![item, setTitle: title]; + let _: () = msg_send![item, setEnabled: !checking]; + } +} + +#[cfg(target_os = "macos")] +fn run_alert(title: &str, message: &str, second_button: Option<&str>) -> Option { + use objc2::{class, msg_send, runtime::AnyObject}; + unsafe { + let app: *mut AnyObject = msg_send![class!(NSApplication), sharedApplication]; + if !app.is_null() { + let _: () = msg_send![app, activateIgnoringOtherApps: true]; + } + + let alert: *mut AnyObject = msg_send![class!(NSAlert), alloc]; + let alert: *mut AnyObject = msg_send![alert, init]; + if alert.is_null() { + return None; + } + + let title = ns_string(title)?; + let message = ns_string(message)?; + let ok = ns_string("OK")?; + let _: () = msg_send![alert, setMessageText: title]; + let _: () = msg_send![alert, setInformativeText: message]; + let _: *mut AnyObject = msg_send![alert, addButtonWithTitle: ok]; + + if let Some(second_button) = second_button { + let second = ns_string(second_button)?; + let _: *mut AnyObject = msg_send![alert, addButtonWithTitle: second]; + } + + Some(msg_send![alert, runModal]) + } +} + +#[cfg(target_os = "macos")] +pub fn show_info_alert(title: &str, message: &str) { + let _ = run_alert(title, message, None); +} + +#[cfg(target_os = "macos")] +pub fn show_error_alert(title: &str, message: &str) { + let _ = run_alert(title, message, None); +} + +#[cfg(target_os = "macos")] +pub fn confirm_update_install(current_version: &str, latest_version: &str) -> bool { + let message = format!( + "A newer Term release is available.\n\nCurrent: {current_version}\nLatest: {latest_version}\n\nTerm will download the update in the background, then quit and relaunch when it is ready." + ); + run_alert("Update Available", &message, Some("Later")) == Some(1000) +} + // ── Clipboard ────────────────────────────────────────────────────────────────── /// Read the macOS clipboard and return PNG-encoded image bytes, or `None` if diff --git a/src/updater.rs b/src/updater.rs new file mode 100644 index 0000000..4474c94 --- /dev/null +++ b/src/updater.rs @@ -0,0 +1,363 @@ +use serde_json::Value; +use std::cmp::Ordering; +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +const RELEASE_API_URL: &str = "https://api.github.com/repos/cartermp/term/releases/latest"; +const RELEASE_ASSET_NAME: &str = "Term.app.zip"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReleaseInfo { + pub tag_name: String, + pub version: String, + pub zip_url: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UpdateCheck { + NoUpdateNeeded { + current_version: String, + latest_version: String, + comparison: Ordering, + }, + UpdateAvailable { + current_version: String, + release: ReleaseInfo, + }, +} + +pub fn current_version() -> String { + bundle_version_from_current_exe().unwrap_or_else(compiled_version) +} + +pub fn display_version(version: &str) -> String { + let normalized = normalize_version(version); + if normalized.is_empty() { + "unknown".to_string() + } else { + format!("v{normalized}") + } +} + +pub fn check_for_updates() -> Result { + let current_version = current_version(); + let release = fetch_latest_release()?; + let comparison = compare_versions(¤t_version, &release.version); + if comparison == Ordering::Less { + Ok(UpdateCheck::UpdateAvailable { + current_version, + release, + }) + } else { + Ok(UpdateCheck::NoUpdateNeeded { + current_version, + latest_version: release.version, + comparison, + }) + } +} + +pub fn spawn_background_update(release: &ReleaseInfo) -> Result<(), String> { + let app_bundle = current_app_bundle()?; + let work_dir = std::env::temp_dir().join(format!( + "term-update-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&work_dir) + .map_err(|e| format!("failed to create updater workspace: {e}"))?; + + let script_path = work_dir.join("install-update.sh"); + let log_path = work_dir.join("update.log"); + std::fs::write(&script_path, build_update_script()) + .map_err(|e| format!("failed to write updater script: {e}"))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&script_path) + .map_err(|e| format!("failed to inspect updater script: {e}"))? + .permissions(); + perms.set_mode(0o700); + std::fs::set_permissions(&script_path, perms) + .map_err(|e| format!("failed to mark updater script executable: {e}"))?; + } + + Command::new("/bin/bash") + .arg(&script_path) + .arg(std::process::id().to_string()) + .arg(&app_bundle) + .arg(&release.tag_name) + .arg(&release.zip_url) + .arg(&log_path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("failed to launch updater helper: {e}"))?; + + Ok(()) +} + +fn compiled_version() -> String { + option_env!("TERM_RELEASE_VERSION") + .filter(|v| !v.trim().is_empty()) + .unwrap_or(env!("CARGO_PKG_VERSION")) + .trim() + .to_string() +} + +fn fetch_latest_release() -> Result { + let output = Command::new("/usr/bin/curl") + .args([ + "-fsSL", + "-H", + "Accept: application/vnd.github+json", + "-H", + "User-Agent: term-updater", + RELEASE_API_URL, + ]) + .output() + .map_err(|e| format!("failed to fetch latest release metadata: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(if stderr.is_empty() { + format!("latest release lookup failed with status {}", output.status) + } else { + format!("latest release lookup failed: {stderr}") + }); + } + + parse_latest_release_json(&String::from_utf8_lossy(&output.stdout)) +} + +fn parse_latest_release_json(json: &str) -> Result { + let value: Value = + serde_json::from_str(json).map_err(|e| format!("invalid GitHub release payload: {e}"))?; + let tag_name = value + .get("tag_name") + .and_then(Value::as_str) + .ok_or_else(|| "latest release payload is missing tag_name".to_string())?; + let zip_url = value + .get("assets") + .and_then(Value::as_array) + .and_then(|assets| { + assets.iter().find_map(|asset| { + (asset.get("name").and_then(Value::as_str) == Some(RELEASE_ASSET_NAME)) + .then(|| { + asset + .get("browser_download_url") + .and_then(Value::as_str) + .map(str::to_string) + }) + .flatten() + }) + }) + .ok_or_else(|| format!("latest release does not include {RELEASE_ASSET_NAME}"))?; + + Ok(ReleaseInfo { + tag_name: tag_name.to_string(), + version: normalize_version(tag_name), + zip_url, + }) +} + +fn current_app_bundle() -> Result { + let exe = + std::env::current_exe().map_err(|e| format!("failed to locate running executable: {e}"))?; + app_bundle_from_exe_path(&exe).ok_or_else(|| { + format!( + "self-update only works when Term is running from a .app bundle (current executable: {}).", + exe.display() + ) + }) +} + +fn bundle_version_from_current_exe() -> Option { + let exe = std::env::current_exe().ok()?; + let app_bundle = app_bundle_from_exe_path(&exe)?; + let plist = std::fs::read_to_string(app_bundle.join("Contents/Info.plist")).ok()?; + plist_string_value(&plist, "CFBundleShortVersionString") + .or_else(|| plist_string_value(&plist, "CFBundleVersion")) + .map(|value| normalize_version(&value)) + .filter(|value| !value.is_empty()) +} + +fn app_bundle_from_exe_path(path: &Path) -> Option { + let macos_dir = path.parent()?; + if macos_dir.file_name() != Some(OsStr::new("MacOS")) { + return None; + } + let contents_dir = macos_dir.parent()?; + if contents_dir.file_name() != Some(OsStr::new("Contents")) { + return None; + } + let app_bundle = contents_dir.parent()?; + (app_bundle.extension() == Some(OsStr::new("app"))).then(|| app_bundle.to_path_buf()) +} + +fn plist_string_value(plist: &str, key: &str) -> Option { + let key_marker = format!("{key}"); + let key_idx = plist.find(&key_marker)?; + let tail = &plist[key_idx + key_marker.len()..]; + let string_start = tail.find("")?; + let tail = &tail[string_start + "".len()..]; + let string_end = tail.find("")?; + Some(tail[..string_end].trim().to_string()) +} + +fn normalize_version(version: &str) -> String { + let base = version.trim().trim_start_matches(['v', 'V']); + let base = base + .split_once('+') + .map(|(prefix, _)| prefix) + .unwrap_or(base); + let base = base + .split_once('-') + .map(|(prefix, _)| prefix) + .unwrap_or(base); + base.trim().to_string() +} + +fn compare_versions(a: &str, b: &str) -> Ordering { + let a_norm = normalize_version(a); + let b_norm = normalize_version(b); + match (parse_version_parts(&a_norm), parse_version_parts(&b_norm)) { + (Some(a_parts), Some(b_parts)) => compare_version_parts(&a_parts, &b_parts), + _ => a_norm.cmp(&b_norm), + } +} + +fn parse_version_parts(version: &str) -> Option> { + let mut parts = Vec::new(); + for piece in version.split('.') { + if piece.is_empty() { + return None; + } + parts.push(piece.parse().ok()?); + } + Some(parts) +} + +fn compare_version_parts(a: &[u64], b: &[u64]) -> Ordering { + let len = a.len().max(b.len()); + for idx in 0..len { + let a_part = a.get(idx).copied().unwrap_or(0); + let b_part = b.get(idx).copied().unwrap_or(0); + match a_part.cmp(&b_part) { + Ordering::Equal => {} + other => return other, + } + } + Ordering::Equal +} + +fn build_update_script() -> &'static str { + r#"#!/bin/bash +set -euo pipefail + +PID="$1" +APP_DST="$2" +VERSION="$3" +ZIP_URL="$4" +LOG_PATH="$5" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TMP="$(mktemp -d)" + +cleanup() { + rm -rf "$TMP" "$SCRIPT_DIR" +} +trap cleanup EXIT + +report_failure() { + /usr/bin/osascript - "$LOG_PATH" <<'APPLESCRIPT' >/dev/null 2>&1 || true +on run argv + display alert "Term update failed" message ("See " & item 1 of argv & " for details.") as critical +end run +APPLESCRIPT +} + +{ + echo "Downloading Term ${VERSION}..." + /usr/bin/curl -fL --silent --show-error "$ZIP_URL" -o "$TMP/Term.app.zip" + /usr/bin/ditto -x -k "$TMP/Term.app.zip" "$TMP" + if [ ! -d "$TMP/Term.app" ]; then + echo "error: downloaded archive did not contain Term.app" + exit 1 + fi + /usr/bin/xattr -cr "$TMP/Term.app" || true + + if /bin/kill -0 "$PID" 2>/dev/null; then + /bin/kill "$PID" 2>/dev/null || true + fi + while /bin/kill -0 "$PID" 2>/dev/null; do + /bin/sleep 0.2 + done + + /bin/rm -rf "$APP_DST" + /usr/bin/ditto "$TMP/Term.app" "$APP_DST" + /System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister \ + -f "$APP_DST" 2>/dev/null || true + /usr/bin/open -a "$APP_DST" +} >>"$LOG_PATH" 2>&1 || report_failure +"# +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_latest_release_asset_url() { + let release = parse_latest_release_json( + r#"{ + "tag_name": "v1.3.1", + "assets": [ + {"name": "install.sh", "browser_download_url": "https://example/install.sh"}, + {"name": "Term.app.zip", "browser_download_url": "https://example/Term.app.zip"} + ] + }"#, + ) + .unwrap(); + + assert_eq!(release.tag_name, "v1.3.1"); + assert_eq!(release.version, "1.3.1"); + assert_eq!(release.zip_url, "https://example/Term.app.zip"); + } + + #[test] + fn compares_semverish_versions_numerically() { + assert_eq!(compare_versions("1.3.1", "v1.3.1"), Ordering::Equal); + assert_eq!(compare_versions("1.3.2", "1.3.1"), Ordering::Greater); + assert_eq!(compare_versions("1.3", "1.3.0"), Ordering::Equal); + assert_eq!(compare_versions("1.10.0", "1.9.9"), Ordering::Greater); + } + + #[test] + fn finds_app_bundle_from_executable_path() { + let exe = Path::new("/Applications/Term.app/Contents/MacOS/term"); + assert_eq!( + app_bundle_from_exe_path(exe), + Some(PathBuf::from("/Applications/Term.app")) + ); + } + + #[test] + fn extracts_bundle_version_from_plist_xml() { + let plist = r#" + + CFBundleNameTerm + CFBundleShortVersionString1.3.1 + + "#; + assert_eq!( + plist_string_value(plist, "CFBundleShortVersionString").as_deref(), + Some("1.3.1") + ); + } +}