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"
>
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 {latestVersion} available!
+ {#if remoteVersion}
+
+ Latest on Codeberg:
+ {remoteVersion}
- {:else if latestVersion !== 'Checking...' && latestVersion !== 'Error'}
-
You are up to date.
{/if}
-
- {#if checkError}
-
{checkError}
+
+ {#if statusText}
+
+ {statusText}
+
+ {/if}
+
+
+
+
+ {#if updateState === 'available' || updateState === 'pending'}
+
+ {/if}
+
+
+ {#if showConfirmDialog}
+
+
+
Update Conductor?
+
+ Version {remoteVersion} is available.
+ You are currently on {currentVersion}.
+
+
The app will restart after the update is installed.
+
+
+
+
+
+
{/if}
-
-
+ .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,
+ })
+}