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
|
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">
|
||||||
|
|||||||
@@ -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 {
|
try {
|
||||||
// Get current version from Tauri
|
|
||||||
currentVersion = await getVersion();
|
currentVersion = await getVersion();
|
||||||
|
const status = await invoke<UpdateStatus>('check_codeberg_for_updates');
|
||||||
|
|
||||||
// Fetch latest release from GitHub
|
if (status.error) {
|
||||||
const response = await fetch(
|
errorMessage = status.error;
|
||||||
'https://api.github.com/repos/ThingsCouldGetDicey/conductor/releases/latest',
|
updateState = 'error';
|
||||||
{ headers: { Accept: 'application/vnd.github.v3+json' } }
|
return;
|
||||||
);
|
}
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
||||||
|
|
||||||
const release = await response.json();
|
currentVersion = status.current_version;
|
||||||
latestVersion = release.tag_name.replace('v', '');
|
remoteVersion = status.remote_version;
|
||||||
|
|
||||||
// Simple semver comparison
|
if (status.update_available) {
|
||||||
const current = currentVersion.split('.').map(Number);
|
// Check if user previously declined this version
|
||||||
const latest = latestVersion.split('.').map(Number);
|
const declined = localStorage.getItem('declined_update_version');
|
||||||
updateAvailable = (
|
if (declined === remoteVersion) {
|
||||||
latest[0] > current[0] ||
|
updateState = 'pending';
|
||||||
(latest[0] === current[0] && latest[1] > current[1]) ||
|
} else {
|
||||||
(latest[0] === current[0] && latest[1] === current[1] && latest[2] > current[2])
|
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>
|
||||||
|
<span class="version-value">{remoteVersion}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#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>
|
</div>
|
||||||
|
|
||||||
{#if updateAvailable}
|
{#if showConfirmDialog}
|
||||||
<div class="update-available" role="alert">
|
<div class="confirm-overlay" role="dialog" aria-modal="true" aria-labelledby="confirm-title">
|
||||||
Update {latestVersion} available!
|
<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>
|
</div>
|
||||||
{:else if latestVersion !== 'Checking...' && latestVersion !== 'Error'}
|
|
||||||
<div class="update-current">You are up to date.</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if checkError}
|
|
||||||
<div class="update-error">{checkError}</div>
|
|
||||||
{/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%);
|
||||||
}
|
}
|
||||||
|
.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>
|
</style>
|
||||||
@@ -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)?;
|
||||||
|
|||||||
@@ -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