diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml new file mode 100644 index 0000000..2a588fd --- /dev/null +++ b/.github/workflows/beta.yml @@ -0,0 +1,128 @@ +name: Beta +run-name: "OpenVCS Beta • Run #${{ github.run_number }} • Beta@${{ steps.meta.outputs.short_sha }}" + +on: + push: + branches: [ Beta ] + workflow_dispatch: + +permissions: + contents: write + actions: write + +env: + TARGET_REF: Beta + RUSTC_WRAPPER: sccache + SCCACHE_GHA_ENABLED: ${{ vars.SSCCACHE_GHA_ENABLED }} + SCCACHE_CACHE_SIZE: ${{ vars.SSCCACHE_SIZE }} + +jobs: + beta: + name: Build & publish Beta + strategy: + fail-fast: false + matrix: + include: + - platform: ubuntu-24.04 + args: '' + - platform: windows-latest + args: '' + runs-on: ${{ matrix.platform }} + steps: + - name: Checkout target ref + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ env.TARGET_REF }} + fetch-depth: 0 + submodules: recursive + lfs: true + + - name: Compute metadata (date, short SHA) + id: meta + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const date = new Date().toISOString().slice(0, 10); + const short = context.sha.substring(0, 7); + core.setOutput('short_sha', short); + core.setOutput('date', date); + + - name: Setup Node + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: '24' + cache: 'npm' + cache-dependency-path: Frontend/package-lock.json + + - name: Install frontend deps + working-directory: Frontend + run: npm ci + + - name: Build frontend + working-directory: Frontend + run: npm run build + + - name: Install Rust (stable) + uses: dtolnay/rust-toolchain@5d458579430fc14a04a08a1e7d3694f545e91ce6 # stable + with: + components: rustfmt, clippy + + - name: Setup sccache + uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9 + + - name: Install Linux deps + if: matrix.platform == 'ubuntu-24.04' + run: | + set -euxo pipefail + sudo apt-get update + sudo apt-get install -y libappindicator3-dev librsvg2-dev patchelf + sudo apt-get install -y libwebkit2gtk-4.1-dev || sudo apt-get install -y libwebkit2gtk-4.0-dev + + - name: Rust cache + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + cache-on-failure: true + + - name: Cargo fmt (check) + run: cargo fmt --all -- --check + + - name: Cargo clippy + run: cargo clippy --all-targets -- -D warnings + + - name: Remove existing 'openvcs-beta' release & tag + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const tag = 'openvcs-beta'; + const releases = await github.paginate(github.rest.repos.listReleases, { owner, repo, per_page: 100 }); + const rel = releases.find(r => r.tag_name === tag); + if (rel) await github.rest.repos.deleteRelease({ owner, repo, release_id: rel.id }); + try { + await github.rest.git.deleteRef({ owner, repo, ref: `tags/${tag}` }); + } catch (e) { + if (e.status !== 422) throw e; + } + + - name: Build and publish Beta prerelease + uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # action-v0.6.2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + FRONTEND_SKIP_BUILD: '1' + OPENVCS_UPDATE_CHANNEL: beta + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_PRIVATE_KEY_PASSWORD }} + with: + projectPath: Backend + tagName: openvcs-beta + releaseName: "OpenVCS Beta ${{ steps.meta.outputs.date }} (${{ env.TARGET_REF }}@${{ steps.meta.outputs.short_sha }})" + releaseBody: | + Beta build from `${{ env.TARGET_REF }}`. + Date (UTC): ${{ steps.meta.outputs.date }} + Commit: ${{ github.sha }} (${{ env.TARGET_REF }}@${{ steps.meta.outputs.short_sha }}) + Runner: ${{ runner.os }} • Run #${{ github.run_number }} + releaseDraft: true + prerelease: true + args: ${{ matrix.args }} + diff --git a/Backend/build.rs b/Backend/build.rs index 2281c8d..21372f0 100644 --- a/Backend/build.rs +++ b/Backend/build.rs @@ -134,22 +134,30 @@ fn main() { // Compute channel based on environment; default to stable let chan = env::var("OPENVCS_UPDATE_CHANNEL").unwrap_or_else(|_| "stable".into()); - // Locations - let stable = serde_json::Value::String( - "https://github.com/Jordonbc/OpenVCS/releases/latest/download/latest.json".into(), - ); - let nightly = serde_json::Value::String( - "https://github.com/Jordonbc/OpenVCS/releases/download/openvcs-nightly/latest.json".into(), - ); + // Repository URL (can be overridden via env var for forks) + let repo = + env::var("OPENVCS_REPO").unwrap_or_else(|_| "https://github.com/Jordonbc/OpenVCS".into()); + + // Build update URLs from repository + let stable = + serde_json::Value::String(format!("{}/releases/latest/download/latest.json", repo)); + let beta = serde_json::Value::String(format!( + "{}/releases/download/openvcs-beta/latest.json", + repo + )); + let nightly = serde_json::Value::String(format!( + "{}/releases/download/openvcs-nightly/latest.json", + repo + )); // Navigate: plugins.updater.endpoints if let Some(plugins) = json.get_mut("plugins") { if let Some(updater) = plugins.get_mut("updater") { let endpoints = match chan.as_str() { + // Beta: check beta first, then stable + "beta" => serde_json::Value::Array(vec![beta.clone(), stable.clone()]), // Nightly: check nightly first, then stable - "nightly" | "beta" => { - serde_json::Value::Array(vec![nightly.clone(), stable.clone()]) - } + "nightly" => serde_json::Value::Array(vec![nightly.clone(), stable.clone()]), // Stable: stable only _ => serde_json::Value::Array(vec![stable.clone()]), }; @@ -200,6 +208,7 @@ fn main() { println!("cargo:rerun-if-env-changed=OPENVCS_UPDATE_CHANNEL"); println!("cargo:rerun-if-env-changed=OPENVCS_FLATPAK"); println!("cargo:rerun-if-env-changed=OPENVCS_OFFICIAL_RELEASE"); + println!("cargo:rerun-if-env-changed=OPENVCS_REPO"); // Export a GIT_DESCRIBE string for About dialog and diagnostics let describe = Command::new("git") @@ -257,10 +266,18 @@ fn main() { pkg_version.clone() } else { let branch_ident = sanitize_semver_ident(&branch); - format!( - "{pkg_version}+git.{branch_ident}.{hash}{}", + let channel_suffix = match chan.as_str() { + "beta" => "-beta", + "nightly" => "-nightly", + _ => "", + }; + let suffix = format!( + "+git.{}{}{}", + branch_ident, + hash, if dirty { ".dirty" } else { "" } - ) + ); + format!("{}{}{}", pkg_version, channel_suffix, suffix) }; println!("cargo:rustc-env=OPENVCS_VERSION={}", version); diff --git a/Backend/src/lib.rs b/Backend/src/lib.rs index 694f9b4..475422d 100644 --- a/Backend/src/lib.rs +++ b/Backend/src/lib.rs @@ -327,6 +327,7 @@ fn build_invoke_handler( tauri_commands::ssh_key_candidates, tauri_commands::ssh_add_key, tauri_commands::updater_install_now, + tauri_commands::get_update_status, tauri_commands::open_repo_dotfile, tauri_commands::open_docs, tauri_commands::open_output_log_window, diff --git a/Backend/src/tauri_commands/updater.rs b/Backend/src/tauri_commands/updater.rs index 1b5dfaa..021058c 100644 --- a/Backend/src/tauri_commands/updater.rs +++ b/Backend/src/tauri_commands/updater.rs @@ -1,10 +1,66 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later use log::{debug, error, info, trace}; +use serde::Serialize; use tauri::{Emitter, Manager, Runtime, Window}; use tauri_plugin_updater::UpdaterExt; +/// Response payload for update status check. +#[derive(Serialize)] +pub struct UpdateStatus { + pub available: bool, + pub version: Option, + pub current_version: Option, + pub body: Option, + pub date: Option, +} + +#[tauri::command] +/// Checks for available updates and returns detailed status. +/// +/// # Parameters +/// - `window`: Calling window handle. +/// +/// # Returns +/// - [`UpdateStatus`] with version info if update available, or {available: false}. +pub async fn get_update_status(window: Window) -> Result { + let app = window.app_handle(); + let updater = app.updater().map_err(|e| { + error!("get_update_status: failed to get updater: {}", e); + e.to_string() + })?; + + match updater.check().await { + Ok(Some(update)) => { + let date_str = update.date.map(|d| d.to_string()); + let status = UpdateStatus { + available: true, + version: Some(update.version.clone()), + current_version: Some(update.current_version.clone()), + body: update.body.clone(), + date: date_str, + }; + debug!( + "get_update_status: update available: {} -> {}", + update.current_version, update.version + ); + Ok(status) + } + Ok(None) => Ok(UpdateStatus { + available: false, + version: None, + current_version: None, + body: None, + date: None, + }), + Err(e) => { + error!("get_update_status: check failed: {}", e); + Err(e.to_string()) + } + } +} + #[tauri::command] /// Downloads and installs an available application update. /// diff --git a/Frontend/src/modals/settings.html b/Frontend/src/modals/settings.html index 8584563..7c2e8f3 100644 --- a/Frontend/src/modals/settings.html +++ b/Frontend/src/modals/settings.html @@ -68,6 +68,7 @@

Settings

diff --git a/Frontend/src/scripts/features/settings.ts b/Frontend/src/scripts/features/settings.ts index 5e94508..ac8b5e8 100644 --- a/Frontend/src/scripts/features/settings.ts +++ b/Frontend/src/scripts/features/settings.ts @@ -790,7 +790,7 @@ function collectSettingsFromForm(root: HTMLElement): GlobalSettings { theme_pack: themePack || DEFAULT_LIGHT_THEME_ID, language: get('#set-language')?.value, default_backend: (get('#set-default-backend')?.value || 'git') as any, - update_channel: (() => { const v = get('#set-update-channel')?.value; return v === 'beta' ? 'nightly' : v; })(), + update_channel: get('#set-update-channel')?.value || 'stable', reopen_last_repos: !!get('#set-reopen-last')?.checked, checks_on_launch: !!get('#set-checks-on-launch')?.checked, }; @@ -927,8 +927,7 @@ export async function loadSettingsIntoForm(root?: HTMLElement) { const elLang = get('#set-language'); if (elLang) elLang.value = toKebab(cfg.general?.language); await refreshDefaultBackendOptions(m, cfg); const elChan = get('#set-update-channel'); if (elChan) { - const v = toKebab(cfg.general?.update_channel); - elChan.value = (v === 'beta') ? 'nightly' : v; + elChan.value = toKebab(cfg.general?.update_channel); } const elReo = get('#set-reopen-last'); if (elReo) elReo.checked = !!cfg.general?.reopen_last_repos; const elChk = get('#set-checks-on-launch'); if (elChk) elChk.checked = !!cfg.general?.checks_on_launch; diff --git a/Frontend/src/scripts/features/update.ts b/Frontend/src/scripts/features/update.ts index 9a1f69b..cf99e72 100644 --- a/Frontend/src/scripts/features/update.ts +++ b/Frontend/src/scripts/features/update.ts @@ -4,6 +4,14 @@ import { TAURI } from '../lib/tauri'; import { openModal, closeModal } from '../ui/modals'; import { notify } from '../lib/notify'; +interface UpdateStatus { + available: boolean; + version: string | null; + current_version: string | null; + body: string | null; + date: string | null; +} + export function wireUpdate() { const modal = document.getElementById('update-modal') as HTMLElement | null; if (!modal || (modal as any).__wired) return; @@ -26,68 +34,24 @@ export function wireUpdate() { export async function showUpdateDialog(_data: any) { try { if (!TAURI.has) return; - const cfg = await TAURI.invoke('get_global_settings'); - const about = await TAURI.invoke('about_info'); - const channel = String(cfg?.general?.update_channel || 'stable'); - const current = String(about?.version || '').trim(); - - const fetchJson = async (url: string) => { - const r = await fetch(url, { cache: 'no-store' }); return r.ok ? r.json() : null; - }; - - const stable = await fetchJson('https://api.github.com/repos/Jordonbc/OpenVCS/releases/latest'); - const nightly = await fetchJson('https://api.github.com/repos/Jordonbc/OpenVCS/releases/tags/openvcs-nightly'); - - const norm = (v: string) => String(v || '').replace(/^v/i, '').trim(); - const stableTag = norm(stable?.tag_name || stable?.name || ''); - const nightlyTag = norm(nightly?.tag_name || nightly?.name || ''); - - const base = (v: string) => norm(v).split('+', 1)[0]; - const currentBase = base(current); - const newerThanCurrent = (v: string) => Boolean(v) && v !== '' && currentBase !== base(v); - let show = false; - let pick = null as any; + const status = await TAURI.invoke('get_update_status'); - if (channel === 'stable') { - if (newerThanCurrent(stableTag)) { show = true; pick = stable; } - } else { - // Nightly: pick the most recent by published_at timestamp and ensure it's newer than current - const sDate = Date.parse(String(stable?.published_at || stable?.created_at || '')) || 0; - const nDate = Date.parse(String(nightly?.published_at || nightly?.created_at || '')) || 0; - pick = (nDate > sDate ? nightly : stable) || nightly || stable; - const pickTag = norm(pick?.tag_name || pick?.name || ''); - show = newerThanCurrent(pickTag); + if (!status.available) { + notify('Already up to date'); + return; } - if (!show || !pick) { notify('Already up to date'); return; } - openModal('update-modal'); const modal = document.getElementById('update-modal') as HTMLElement | null; if (!modal) return; const verEl = modal.querySelector('#update-version'); const notesEl = modal.querySelector('#update-notes'); - const v = pick?.tag_name || pick?.name || ''; - const body = String(pick?.body || '').trim(); + const v = status.version || ''; + const body = String(status.body || '').trim(); if (verEl) verEl.textContent = v ? `Version ${v}` : 'Update available'; if (notesEl) (notesEl as HTMLElement).textContent = body || '(No changelog provided)'; } catch { notify('Update check failed'); } } - -function inferString(obj: any, keys: string[]): string | undefined { - if (!obj || typeof obj !== 'object') return undefined; - for (const k of keys) { - const v = obj[k as any]; - if (typeof v === 'string' && v.trim()) return v; - } - // nested common containers - if (obj.update && typeof obj.update === 'object') { - const v = inferString(obj.update, keys); if (v) return v; - } - if (obj.manifest && typeof obj.manifest === 'object') { - const v = inferString(obj.manifest, keys); if (v) return v; - } - return undefined; -}