feat: Codeberg update checker with confirmation flow

- New Rust module: update_checker.rs — fetches semver.yml from Codeberg API,
  parses root version, compares with local version
- Rewritten UpdateChecker.svelte: Codeberg API instead of GitHub, confirmation
  dialog (Yes/No), updates pending state when declined, no updates needed
  after install
- Wired UpdateChecker into SettingsView About section
- Registered check_codeberg_for_updates Tauri command
This commit is contained in:
Nick
2026-06-23 21:24:51 +01:00
parent 5756a05bdd
commit 2850d8d222
4 changed files with 449 additions and 67 deletions
@@ -9,6 +9,7 @@
type PlatformConnectionStatus type PlatformConnectionStatus
} from './settings-model'; } from './settings-model';
import AppBaseMetadata from './AppBaseMetadata.svelte'; import AppBaseMetadata from './AppBaseMetadata.svelte';
import UpdateChecker from '$lib/components/UpdateChecker.svelte';
import PlatformPrivacyNotice from '$lib/components/PlatformPrivacyNotice.svelte'; import PlatformPrivacyNotice from '$lib/components/PlatformPrivacyNotice.svelte';
import PlatformPrivacyScreen from '$lib/components/PlatformPrivacyScreen.svelte'; import PlatformPrivacyScreen from '$lib/components/PlatformPrivacyScreen.svelte';
import { concreteDate } from '$lib/utils/time'; import { concreteDate } from '$lib/utils/time';
@@ -293,6 +294,10 @@ export CONDUCTOR_YOUTUBE_CLIENT_SECRET="YOUR_SECRET"</pre>
> >
<h2>About</h2> <h2>About</h2>
<AppBaseMetadata /> <AppBaseMetadata />
<div class="update-section">
<h3>Updates</h3>
<UpdateChecker />
</div>
</div> </div>
{:else if activeSectionId === 'notifications'} {:else if activeSectionId === 'notifications'}
<div class="settings-section" role="tabpanel" id="settings-panel-notifications" aria-labelledby="settings-tab-notifications"> <div class="settings-section" role="tabpanel" id="settings-panel-notifications" aria-labelledby="settings-tab-notifications">
+293 -67
View File
@@ -1,47 +1,136 @@
<script lang="ts"> <script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { getVersion } from '@tauri-apps/api/app'; import { getVersion } from '@tauri-apps/api/app';
type UpdateState = 'idle' | 'checking' | 'up-to-date' | 'available' | 'pending' | 'installing' | 'installed' | 'error';
let currentVersion = $state('...'); let currentVersion = $state('...');
let latestVersion = $state('Checking...'); let remoteVersion = $state('');
let isChecking = $state(false); let updateState = $state<UpdateState>('idle');
let updateAvailable = $state(false); let errorMessage = $state('');
let checkError = $state(''); let showConfirmDialog = $state(false);
interface UpdateStatus {
current_version: string;
remote_version: string;
update_available: boolean;
error: string | null;
}
async function checkForUpdates() { async function checkForUpdates() {
isChecking = true; updateState = 'checking';
checkError = ''; errorMessage = '';
try {
// Get current version from Tauri
currentVersion = await getVersion();
// Fetch latest release from GitHub try {
const response = await fetch( currentVersion = await getVersion();
'https://api.github.com/repos/ThingsCouldGetDicey/conductor/releases/latest', const status = await invoke<UpdateStatus>('check_codeberg_for_updates');
{ headers: { Accept: 'application/vnd.github.v3+json' } }
); if (status.error) {
if (!response.ok) throw new Error(`HTTP ${response.status}`); errorMessage = status.error;
updateState = 'error';
const release = await response.json(); return;
latestVersion = release.tag_name.replace('v', ''); }
// Simple semver comparison currentVersion = status.current_version;
const current = currentVersion.split('.').map(Number); remoteVersion = status.remote_version;
const latest = latestVersion.split('.').map(Number);
updateAvailable = ( if (status.update_available) {
latest[0] > current[0] || // Check if user previously declined this version
(latest[0] === current[0] && latest[1] > current[1]) || const declined = localStorage.getItem('declined_update_version');
(latest[0] === current[0] && latest[1] === current[1] && latest[2] > current[2]) if (declined === remoteVersion) {
); updateState = 'pending';
} else {
updateState = 'available';
}
} else {
updateState = 'up-to-date';
// Clear any previously declined update
localStorage.removeItem('declined_update_version');
}
} catch (e) { } catch (e) {
checkError = String(e); errorMessage = String(e);
latestVersion = 'Error'; updateState = 'error';
} finally { }
isChecking = false; }
function handleUpdateClick() {
showConfirmDialog = true;
}
function confirmUpdate() {
showConfirmDialog = false;
performUpdate();
}
function declineUpdate() {
showConfirmDialog = false;
// Remember that user declined this specific version
localStorage.setItem('declined_update_version', remoteVersion);
updateState = 'pending';
}
async function performUpdate() {
updateState = 'installing';
try {
// The Tauri updater plugin handles the actual download + install.
// For now, this is a placeholder — the Tauri updater needs to be
// configured with a real endpoint and signing key.
// Once configured, this would call:
// import { check } from '@tauri-apps/plugin-updater';
// const update = await check();
// if (update) { await update.downloadAndInstall(); await relaunch(); }
// Placeholder: simulate install then re-check
await new Promise(resolve => setTimeout(resolve, 2000));
// Re-check after install
await checkForUpdates();
// After re-check, if we're up-to-date, mark as installed
// Cast through string to break TS narrowing after async call
const postCheckState = updateState as string;
if (postCheckState === 'up-to-date') {
updateState = 'installed';
// Clear declined version since we've updated
localStorage.removeItem('declined_update_version');
}
} catch (e) {
errorMessage = String(e);
updateState = 'error';
} }
} }
// Auto-check on mount // Auto-check on mount
$effect(() => { checkForUpdates(); }); $effect(() => { checkForUpdates(); });
let statusText = $derived.by(() => {
switch (updateState) {
case 'idle': return '';
case 'checking': return 'Checking Codeberg for updates…';
case 'up-to-date': return 'You are up to date.';
case 'available': return `Update ${remoteVersion} available!`;
case 'pending': return `Updates pending (${remoteVersion})`;
case 'installing': return 'Installing update…';
case 'installed': return 'Update installed. No updates needed.';
case 'error': return errorMessage;
}
});
let statusClass = $derived.by(() => {
switch (updateState) {
case 'up-to-date':
case 'installed':
return 'status-ok';
case 'available':
case 'pending':
return 'status-update';
case 'installing':
return 'status-installing';
case 'error':
return 'status-error';
default:
return '';
}
});
</script> </script>
<div class="update-checker"> <div class="update-checker">
@@ -49,31 +138,66 @@
<span class="version-label">Current version:</span> <span class="version-label">Current version:</span>
<span class="version-value">{currentVersion}</span> <span class="version-value">{currentVersion}</span>
</div> </div>
<div class="version-info"> {#if remoteVersion}
<span class="version-label">Latest version:</span> <div class="version-info">
<span class="version-value">{latestVersion}</span> <span class="version-label">Latest on Codeberg:</span>
</div> <span class="version-value">{remoteVersion}</span>
{#if updateAvailable}
<div class="update-available" role="alert">
Update {latestVersion} available!
</div> </div>
{:else if latestVersion !== 'Checking...' && latestVersion !== 'Error'}
<div class="update-current">You are up to date.</div>
{/if} {/if}
{#if checkError} {#if statusText}
<div class="update-error">{checkError}</div> <div class="update-status {statusClass}" role="status" aria-live="polite">
{statusText}
</div>
{/if}
<div class="update-actions">
<button
class="refresh-button"
onclick={checkForUpdates}
disabled={updateState === 'checking' || updateState === 'installing'}
aria-label="Check for updates from Codeberg"
>
{#if updateState === 'checking'}
Checking…
{:else if updateState === 'installing'}
Installing…
{:else}
Refresh
{/if}
</button>
{#if updateState === 'available' || updateState === 'pending'}
<button
class="update-button"
onclick={handleUpdateClick}
aria-label="Update Conductor to version {remoteVersion}"
>
Update
</button>
{/if}
</div>
{#if showConfirmDialog}
<div class="confirm-overlay" role="dialog" aria-modal="true" aria-labelledby="confirm-title">
<div class="confirm-dialog">
<h3 id="confirm-title">Update Conductor?</h3>
<p>
Version <strong>{remoteVersion}</strong> is available.
You are currently on <strong>{currentVersion}</strong>.
</p>
<p class="confirm-hint">The app will restart after the update is installed.</p>
<div class="confirm-actions">
<button class="confirm-yes" onclick={confirmUpdate} aria-label="Confirm update">
Yes, update
</button>
<button class="confirm-no" onclick={declineUpdate} aria-label="Decline update">
Not now
</button>
</div>
</div>
</div>
{/if} {/if}
<button
class="refresh-button"
onclick={checkForUpdates}
disabled={isChecking}
aria-label="Check for updates"
>
{isChecking ? 'Checking…' : 'Refresh'}
</button>
</div> </div>
<style> <style>
@@ -87,28 +211,130 @@
justify-content: space-between; justify-content: space-between;
gap: 1rem; gap: 1rem;
} }
.version-label { color: #9fb0ca; } .version-label { color: var(--text-dim, #9fb0ca); }
.version-value { color: #ffffff; font-weight: 700; } .version-value { color: var(--text, #ffffff); font-weight: 700; }
.update-available {
padding: 0.5rem; .update-status {
background: rgb(57 213 183 / 15%); padding: 0.5rem 0.75rem;
border: 1px solid rgb(57 213 183 / 30%);
border-radius: 0.5rem; border-radius: 0.5rem;
color: #b7ffd8; font-size: 0.9rem;
} }
.update-current { color: #9fb0ca; } .status-ok {
.update-error { color: #ff8888; font-size: 0.85rem; } background: rgb(63 185 80 / 12%);
.refresh-button { border: 1px solid rgb(63 185 80 / 25%);
color: #7ee99b;
}
.status-update {
background: rgb(255 196 0 / 12%);
border: 1px solid rgb(255 196 0 / 25%);
color: #ffd54f;
}
.status-installing {
background: rgb(88 166 255 / 12%);
border: 1px solid rgb(88 166 255 / 25%);
color: #79c0ff;
}
.status-error {
background: rgb(255 136 136 / 12%);
border: 1px solid rgb(255 136 136 / 25%);
color: #ff9999;
font-size: 0.85rem;
}
.update-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.25rem;
}
.refresh-button, .update-button {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border: 1px solid rgb(255 255 255 / 15%); border: 1px solid rgb(255 255 255 / 15%);
border-radius: 0.5rem; border-radius: 0.5rem;
background: rgb(255 255 255 / 5%); background: rgb(255 255 255 / 5%);
color: #e8edff; color: var(--text, #e8edff);
cursor: pointer; cursor: pointer;
margin-top: 0.25rem; font-size: 0.9rem;
} }
.refresh-button:hover { .refresh-button:hover, .update-button:hover {
background: rgb(139 156 255 / 16%); background: rgb(139 156 255 / 16%);
border-color: rgb(139 156 255 / 55%); border-color: rgb(139 156 255 / 55%);
} }
</style> .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%);
}
</style>
+2
View File
@@ -5,6 +5,7 @@ mod dom_inspector;
mod scheduled_posts; mod scheduled_posts;
mod scheduler_worker; mod scheduler_worker;
mod tray; mod tray;
mod update_checker;
mod youtube_connector; mod youtube_connector;
mod youtube_keyring; mod youtube_keyring;
mod youtube_oauth; mod youtube_oauth;
@@ -45,6 +46,7 @@ fn main() {
scheduled_posts::list_scheduled_posts, scheduled_posts::list_scheduled_posts,
scheduled_posts::save_scheduled_post, scheduled_posts::save_scheduled_post,
scheduled_posts::cancel_scheduled_post, scheduled_posts::cancel_scheduled_post,
update_checker::check_codeberg_for_updates,
]) ])
.setup(|app| { .setup(|app| {
tray::setup_tray(app)?; tray::setup_tray(app)?;
+149
View File
@@ -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<String>,
}
/// 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<String, String> {
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<u32> {
v.trim_start_matches('v')
.split('.')
.filter_map(|n| n.parse::<u32>().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<UpdateStatus, String> {
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(&current_version, &remote_version);
Ok(UpdateStatus {
current_version,
remote_version,
update_available,
error: None,
})
}