diff --git a/frontend/src/app-base/SettingsView.svelte b/frontend/src/app-base/SettingsView.svelte index a7a761a..d5d8fb7 100644 --- a/frontend/src/app-base/SettingsView.svelte +++ b/frontend/src/app-base/SettingsView.svelte @@ -9,6 +9,7 @@ type PlatformConnectionStatus } from './settings-model'; import AppBaseMetadata from './AppBaseMetadata.svelte'; + import UpdateChecker from '$lib/components/UpdateChecker.svelte'; import PlatformPrivacyNotice from '$lib/components/PlatformPrivacyNotice.svelte'; import PlatformPrivacyScreen from '$lib/components/PlatformPrivacyScreen.svelte'; import { concreteDate } from '$lib/utils/time'; @@ -293,6 +294,10 @@ export CONDUCTOR_YOUTUBE_CLIENT_SECRET="YOUR_SECRET" >

About

+
+

Updates

+ +
{:else if activeSectionId === 'notifications'}
diff --git a/frontend/src/lib/components/UpdateChecker.svelte b/frontend/src/lib/components/UpdateChecker.svelte index f8a585b..8155166 100644 --- a/frontend/src/lib/components/UpdateChecker.svelte +++ b/frontend/src/lib/components/UpdateChecker.svelte @@ -1,47 +1,136 @@
@@ -49,31 +138,66 @@ Current version: {currentVersion}
-
- Latest version: - {latestVersion} -
- - {#if updateAvailable} - + .update-button { + background: rgb(63 185 80 / 15%); + border-color: rgb(63 185 80 / 35%); + color: #7ee99b; + font-weight: 600; + } + .update-button:hover { + background: rgb(63 185 80 / 25%); + border-color: rgb(63 185 80 / 50%); + } + .refresh-button:disabled, .update-button:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .confirm-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgb(0 0 0 / 60%); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + } + .confirm-dialog { + background: var(--surface, #161b22); + border: 1px solid var(--border, #30363d); + border-radius: 0.75rem; + padding: 1.5rem; + max-width: 400px; + box-shadow: 0 8px 32px rgb(0 0 0 / 40%); + } + .confirm-dialog h3 { + margin: 0 0 0.75rem; + color: var(--text, #ffffff); + font-size: 1.1rem; + } + .confirm-dialog p { + margin: 0.3rem 0; + color: var(--text-dim, #9fb0ca); + font-size: 0.9rem; + } + .confirm-hint { + font-size: 0.8rem !important; + color: var(--text-dim, #8b949e); + } + .confirm-actions { + display: flex; + gap: 0.75rem; + margin-top: 1.25rem; + justify-content: flex-end; + } + .confirm-yes, .confirm-no { + padding: 0.5rem 1.25rem; + border-radius: 0.5rem; + border: 1px solid rgb(255 255 255 / 15%); + cursor: pointer; + font-size: 0.9rem; + } + .confirm-yes { + background: rgb(63 185 80 / 20%); + border-color: rgb(63 185 80 / 40%); + color: #7ee99b; + font-weight: 600; + } + .confirm-yes:hover { + background: rgb(63 185 80 / 30%); + } + .confirm-no { + background: rgb(255 255 255 / 5%); + color: var(--text-dim, #9fb0ca); + } + .confirm-no:hover { + background: rgb(255 255 255 / 10%); + } + \ No newline at end of file diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index bcb5a45..8ac3dc8 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -5,6 +5,7 @@ mod dom_inspector; mod scheduled_posts; mod scheduler_worker; mod tray; +mod update_checker; mod youtube_connector; mod youtube_keyring; mod youtube_oauth; @@ -45,6 +46,7 @@ fn main() { scheduled_posts::list_scheduled_posts, scheduled_posts::save_scheduled_post, scheduled_posts::cancel_scheduled_post, + update_checker::check_codeberg_for_updates, ]) .setup(|app| { tray::setup_tray(app)?; diff --git a/src-tauri/src/update_checker.rs b/src-tauri/src/update_checker.rs new file mode 100644 index 0000000..7240a11 --- /dev/null +++ b/src-tauri/src/update_checker.rs @@ -0,0 +1,149 @@ +/// Update checker — fetches the latest version from Codeberg +/// and returns it to the frontend for comparison. +/// +/// The version is read from `semver.yml` in the repo root, +/// which is the canonical source of truth for Conductor's version. +use serde::{Deserialize, Serialize}; +use std::env; + +const CODEBERG_API_BASE: &str = "https://codeberg.org/api/v1"; +const CODEBERG_REPO: &str = "thingscouldgetdicey/conductor"; +const SEMVER_FILE_PATH: &str = "semver.yml"; + +#[derive(Debug, Serialize, Deserialize)] +pub struct UpdateStatus { + /// The version running locally (from Tauri's getVersion()) + pub current_version: String, + /// The latest version on Codeberg (from semver.yml) + pub remote_version: String, + /// Whether an update is available + pub update_available: bool, + /// Error message if the check failed + pub error: Option, +} + +/// Fetch the root version from semver.yml content. +/// The root section looks like: +/// ``` +/// root: +/// version: 0.7.2 +/// ``` +fn parse_root_version(yaml_content: &str) -> Result { + let mut in_root = false; + for line in yaml_content.lines() { + let trimmed = line.trim(); + if trimmed == "root:" { + in_root = true; + continue; + } + if in_root { + // If we hit another top-level key (no indentation), we've left root + if !line.starts_with(' ') && !line.starts_with('\t') && !trimmed.is_empty() { + in_root = false; + continue; + } + if trimmed.starts_with("version:") { + let version = trimmed + .strip_prefix("version:") + .unwrap_or("") + .trim() + .trim_matches('"') + .trim_matches('\''); + return Ok(version.to_string()); + } + } + } + Err("Could not find root version in semver.yml".to_string()) +} + +/// Compare two semver strings (e.g., "0.7.2" vs "0.7.3"). +/// Returns true if remote > current. +fn is_newer(current: &str, remote: &str) -> bool { + let parse = |v: &str| -> Vec { + v.trim_start_matches('v') + .split('.') + .filter_map(|n| n.parse::().ok()) + .collect() + }; + let c = parse(current); + let r = parse(remote); + for i in 0..c.len().max(r.len()) { + let cv = c.get(i).unwrap_or(&0); + let rv = r.get(i).unwrap_or(&0); + if rv > cv { + return true; + } + if rv < cv { + return false; + } + } + false +} + +/// Tauri command: check for updates from Codeberg. +/// Reads the Codeberg API token from the CODEBERG_API_KEY env var. +/// If the repo is public, the token is optional. +#[tauri::command] +pub async fn check_codeberg_for_updates( + app_handle: tauri::AppHandle, +) -> Result { + let current_version = app_handle.package_info().version.to_string(); + + // Build the API URL for fetching semver.yml + let url = format!( + "{}/repos/{}/contents/{}", + CODEBERG_API_BASE, CODEBERG_REPO, SEMVER_FILE_PATH + ); + + let client = reqwest::Client::new(); + let mut request = client.get(&url).header("Accept", "application/json"); + + // Add auth token if available (needed for private repos) + if let Ok(token) = env::var("CODEBERG_API_KEY") { + request = request.header("Authorization", format!("token {}", token)); + } + + let response = request + .send() + .await + .map_err(|e| format!("Failed to connect to Codeberg: {}", e))?; + + if !response.status().is_success() { + return Ok(UpdateStatus { + current_version, + remote_version: String::new(), + update_available: false, + error: Some(format!("Codeberg API returned HTTP {}", response.status())), + }); + } + + let body: serde_json::Value = response + .json() + .await + .map_err(|e| format!("Failed to parse Codeberg response: {}", e))?; + + // The API returns base64-encoded content + let content_b64 = body + .get("content") + .and_then(|v| v.as_str()) + .ok_or("No content field in Codeberg response")?; + + use base64::Engine; + let content_bytes = base64::engine::general_purpose::STANDARD + .decode(content_b64.trim()) + .map_err(|e| format!("Failed to decode base64: {}", e))?; + + let yaml_content = + String::from_utf8(content_bytes).map_err(|e| format!("Failed to decode UTF-8: {}", e))?; + + let remote_version = parse_root_version(&yaml_content)?; + + let update_available = is_newer(¤t_version, &remote_version); + + Ok(UpdateStatus { + current_version, + remote_version, + update_available, + error: None, + }) +}