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:
@@ -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"</pre>
|
||||
>
|
||||
<h2>About</h2>
|
||||
<AppBaseMetadata />
|
||||
<div class="update-section">
|
||||
<h3>Updates</h3>
|
||||
<UpdateChecker />
|
||||
</div>
|
||||
</div>
|
||||
{:else if activeSectionId === 'notifications'}
|
||||
<div class="settings-section" role="tabpanel" id="settings-panel-notifications" aria-labelledby="settings-tab-notifications">
|
||||
|
||||
@@ -1,47 +1,136 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
|
||||
type UpdateState = 'idle' | 'checking' | 'up-to-date' | 'available' | 'pending' | 'installing' | 'installed' | 'error';
|
||||
|
||||
let currentVersion = $state('...');
|
||||
let latestVersion = $state('Checking...');
|
||||
let isChecking = $state(false);
|
||||
let updateAvailable = $state(false);
|
||||
let checkError = $state('');
|
||||
let remoteVersion = $state('');
|
||||
let updateState = $state<UpdateState>('idle');
|
||||
let errorMessage = $state('');
|
||||
let showConfirmDialog = $state(false);
|
||||
|
||||
interface UpdateStatus {
|
||||
current_version: string;
|
||||
remote_version: string;
|
||||
update_available: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
async function checkForUpdates() {
|
||||
isChecking = true;
|
||||
checkError = '';
|
||||
try {
|
||||
// Get current version from Tauri
|
||||
currentVersion = await getVersion();
|
||||
updateState = 'checking';
|
||||
errorMessage = '';
|
||||
|
||||
// Fetch latest release from GitHub
|
||||
const response = await fetch(
|
||||
'https://api.github.com/repos/ThingsCouldGetDicey/conductor/releases/latest',
|
||||
{ headers: { Accept: 'application/vnd.github.v3+json' } }
|
||||
);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const release = await response.json();
|
||||
latestVersion = release.tag_name.replace('v', '');
|
||||
|
||||
// Simple semver comparison
|
||||
const current = currentVersion.split('.').map(Number);
|
||||
const latest = latestVersion.split('.').map(Number);
|
||||
updateAvailable = (
|
||||
latest[0] > current[0] ||
|
||||
(latest[0] === current[0] && latest[1] > current[1]) ||
|
||||
(latest[0] === current[0] && latest[1] === current[1] && latest[2] > current[2])
|
||||
);
|
||||
try {
|
||||
currentVersion = await getVersion();
|
||||
const status = await invoke<UpdateStatus>('check_codeberg_for_updates');
|
||||
|
||||
if (status.error) {
|
||||
errorMessage = status.error;
|
||||
updateState = 'error';
|
||||
return;
|
||||
}
|
||||
|
||||
currentVersion = status.current_version;
|
||||
remoteVersion = status.remote_version;
|
||||
|
||||
if (status.update_available) {
|
||||
// Check if user previously declined this version
|
||||
const declined = localStorage.getItem('declined_update_version');
|
||||
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) {
|
||||
checkError = String(e);
|
||||
latestVersion = 'Error';
|
||||
} finally {
|
||||
isChecking = false;
|
||||
errorMessage = String(e);
|
||||
updateState = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
$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>
|
||||
|
||||
<div class="update-checker">
|
||||
@@ -49,31 +138,66 @@
|
||||
<span class="version-label">Current version:</span>
|
||||
<span class="version-value">{currentVersion}</span>
|
||||
</div>
|
||||
<div class="version-info">
|
||||
<span class="version-label">Latest version:</span>
|
||||
<span class="version-value">{latestVersion}</span>
|
||||
</div>
|
||||
|
||||
{#if updateAvailable}
|
||||
<div class="update-available" role="alert">
|
||||
Update {latestVersion} available!
|
||||
{#if remoteVersion}
|
||||
<div class="version-info">
|
||||
<span class="version-label">Latest on Codeberg:</span>
|
||||
<span class="version-value">{remoteVersion}</span>
|
||||
</div>
|
||||
{:else if latestVersion !== 'Checking...' && latestVersion !== 'Error'}
|
||||
<div class="update-current">You are up to date.</div>
|
||||
{/if}
|
||||
|
||||
{#if checkError}
|
||||
<div class="update-error">{checkError}</div>
|
||||
|
||||
{#if statusText}
|
||||
<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}
|
||||
|
||||
<button
|
||||
class="refresh-button"
|
||||
onclick={checkForUpdates}
|
||||
disabled={isChecking}
|
||||
aria-label="Check for updates"
|
||||
>
|
||||
{isChecking ? 'Checking…' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -87,28 +211,130 @@
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
.version-label { color: #9fb0ca; }
|
||||
.version-value { color: #ffffff; font-weight: 700; }
|
||||
.update-available {
|
||||
padding: 0.5rem;
|
||||
background: rgb(57 213 183 / 15%);
|
||||
border: 1px solid rgb(57 213 183 / 30%);
|
||||
.version-label { color: var(--text-dim, #9fb0ca); }
|
||||
.version-value { color: var(--text, #ffffff); font-weight: 700; }
|
||||
|
||||
.update-status {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
color: #b7ffd8;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.update-current { color: #9fb0ca; }
|
||||
.update-error { color: #ff8888; font-size: 0.85rem; }
|
||||
.refresh-button {
|
||||
.status-ok {
|
||||
background: rgb(63 185 80 / 12%);
|
||||
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;
|
||||
border: 1px solid rgb(255 255 255 / 15%);
|
||||
border-radius: 0.5rem;
|
||||
background: rgb(255 255 255 / 5%);
|
||||
color: #e8edff;
|
||||
color: var(--text, #e8edff);
|
||||
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%);
|
||||
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>
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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(¤t_version, &remote_version);
|
||||
|
||||
Ok(UpdateStatus {
|
||||
current_version,
|
||||
remote_version,
|
||||
update_available,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user