diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7e3c6a6..792659c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,9 @@ name: Release # Push a tag like v0.2.0. Every platform is built, signed and audited first; only if -# all of them pass are the installers, updater signatures and latest.json published -# as a DRAFT release of this repository (review it, then press Publish). +# all of them pass are the installers, updater signatures and latest.json published as +# a release of this repository. Installed apps pick it up through latest.json, and the +# Packages workflow then updates Homebrew, Scoop and winget. on: push: tags: ["v*"] @@ -72,6 +73,7 @@ jobs: runs-on: ubuntu-24.04 permissions: contents: write + actions: write steps: - uses: actions/checkout@v4 with: @@ -83,7 +85,7 @@ jobs: run: node scripts/latest-json.mjs artifacts "${GITHUB_REF_NAME}" out - name: Audit exactly what is published run: node scripts/audit-release.mjs out - - name: Create draft release + - name: Publish release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Hand-written notes in .github/release-notes/.md win; otherwise GitHub generates them. @@ -91,5 +93,11 @@ jobs: notes=".github/release-notes/${GITHUB_REF_NAME}.md" if [ -f "$notes" ]; then set -- --notes-file "$notes"; else set -- --generate-notes; fi gh release create "${GITHUB_REF_NAME}" out/* \ - --repo "${GITHUB_REPOSITORY}" --draft "$@" \ + --repo "${GITHUB_REPOSITORY}" --latest "$@" \ --title "Lumela ${GITHUB_REF_NAME}" + # A release created with GITHUB_TOKEN does not fire `release: published`, so start + # the package updates explicitly (workflow_dispatch is allowed from GITHUB_TOKEN). + - name: Update Homebrew, Scoop and winget + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh workflow run packages.yml --repo "${GITHUB_REPOSITORY}" --ref main -f tag="${GITHUB_REF_NAME}" diff --git a/README.md b/README.md index 2c18297..0a650a6 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ scoop install https://raw.githubusercontent.com/tw4/lumela/main/packaging/scoop/ winget (`tw4.Lumela`) is under review in [microsoft/winget-pkgs#476618](https://github.com/microsoft/winget-pkgs/pull/476618). -Lumela updates itself, and every update is signature-checked before it is installed. +Lumela checks for new versions in the background and offers a one-click update: it downloads, checks the signature, installs and restarts, so there is nothing to uninstall or download again. On Linux this works with the AppImage; with the `.deb` the update button opens the release page. > [!NOTE] > Installers are not code-signed yet, so the operating system warns on first launch. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 86d74fc..68175ae 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -392,6 +392,17 @@ fn restart_app(app: AppHandle) { app.restart(); } +/// Whether the built-in updater can replace this install. On Linux it can only swap an +/// AppImage (which sets `APPIMAGE`); `.deb`/`.rpm` installs are updated by reinstalling. +fn self_update_supported_for(os: &str, appimage: Option<&str>) -> bool { + os != "linux" || appimage.is_some_and(|p| !p.is_empty()) +} + +#[tauri::command] +fn self_update_supported() -> bool { + self_update_supported_for(std::env::consts::OS, std::env::var("APPIMAGE").ok().as_deref()) +} + #[tauri::command] fn copy_text(text: String) -> Result<(), String> { arboard::Clipboard::new().and_then(|mut c| c.set_text(text)).map_err(|e| e.to_string()) @@ -738,6 +749,7 @@ pub fn run() { screen_permission, open_permission_settings, restart_app, + self_update_supported, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); @@ -753,4 +765,13 @@ mod tests { assert_eq!(urlencoding_decode("plain"), "plain"); assert_eq!(urlencoding_decode("bad%zz%4"), "bad%zz%4"); } + + #[test] + fn only_appimage_self_updates_on_linux() { + assert!(self_update_supported_for("windows", None)); + assert!(self_update_supported_for("macos", None)); + assert!(self_update_supported_for("linux", Some("/home/u/Lumela.AppImage"))); + assert!(!self_update_supported_for("linux", None)); + assert!(!self_update_supported_for("linux", Some(""))); + } } diff --git a/src/editor/Editor.tsx b/src/editor/Editor.tsx index 1506349..59f5a23 100644 --- a/src/editor/Editor.tsx +++ b/src/editor/Editor.tsx @@ -25,7 +25,7 @@ interface SavedState { beautify?: import("../lib/beautify").BeautifySettings; an import { BackgroundImage, render, Source, Watermark } from "../lib/render"; import { startDrag } from "@crabnebula/tauri-plugin-drag"; import { tempDir, join } from "@tauri-apps/api/path"; -import { checkForUpdate, installUpdate } from "../lib/updates"; +import { canSelfUpdate, checkForUpdate, installUpdate, oncePerVersion, UPDATE_CHECK_INTERVAL_MS } from "../lib/updates"; import { useStore } from "../lib/store"; import AnnotationLayer, { Tool } from "./AnnotationLayer"; import Panel from "./Panel"; @@ -112,16 +112,30 @@ export default function Editor() { const [showHistory, setShowHistory] = useState(false); const [showPermission, setShowPermission] = useState(false); - // Look for an update once per launch (silently when there is none or offline). + // Look for an update at launch and every few hours after (the app lives in the tray), + // silently when there is none or offline, and tell about each version only once. useEffect(() => { if (import.meta.env.DEV) return; - checkForUpdate().then((s) => { - if (s.kind !== "available") return; + const isNew = oncePerVersion(); + const look = async () => { + const s = await checkForUpdate(); + if (s.kind !== "available" || !isNew(s.update.version)) return; + const self = await canSelfUpdate(); + const t = getT().toast; setToast({ - text: getT().toast.updateReady(s.update.version), - action: { label: getT().toast.updateInstall, run: () => { setToast({ text: getT().toast.updateDownloading }); installUpdate(s.update).catch((e) => notify(String(e), true)); } }, + text: t.updateReady(s.update.version), + action: { + label: self ? t.updateInstall : t.updateDownload, + run: () => { + if (self) setToast({ text: t.updateDownloading }); + installUpdate(s.update).catch((e) => notify(String(e), true)); + }, + }, }); - }); + }; + look(); + const timer = window.setInterval(look, UPDATE_CHECK_INTERVAL_MS); + return () => window.clearInterval(timer); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 152f040..142c43c 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -68,6 +68,7 @@ export const en = { updateReady: (v: string) => `New version ready: ${v}`, updateInstall: "Update and restart", updateDownloading: "Downloading update…", + updateDownload: "Download", }, panel: { presets: "Presets", diff --git a/src/i18n/tr.ts b/src/i18n/tr.ts index 7d6dfc2..a9fc68d 100644 --- a/src/i18n/tr.ts +++ b/src/i18n/tr.ts @@ -69,6 +69,7 @@ export const tr: Dict = { updateReady: (v) => `Yeni sürüm hazır: ${v}`, updateInstall: "Güncelle ve yeniden başlat", updateDownloading: "Güncelleme indiriliyor…", + updateDownload: "İndir", }, panel: { presets: "Hazır ayarlar", diff --git a/src/lib/ipc.ts b/src/lib/ipc.ts index 9940543..e7dbba9 100644 --- a/src/lib/ipc.ts +++ b/src/lib/ipc.ts @@ -28,6 +28,7 @@ export type PermissionStatus = "granted" | "denied" | "unsupported"; export const screenPermission = () => invoke("screen_permission"); export const openPermissionSettings = () => invoke("open_permission_settings"); export const restartApp = () => invoke("restart_app"); +export const selfUpdateSupported = () => invoke("self_update_supported"); export const startCapture = (mode: CaptureMode) => invoke("start_capture", { mode }); export const overlayInfo = () => invoke("overlay_info"); diff --git a/src/lib/updates.test.ts b/src/lib/updates.test.ts new file mode 100644 index 0000000..5a5c8b4 --- /dev/null +++ b/src/lib/updates.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { oncePerVersion, UPDATE_CHECK_INTERVAL_MS } from "./updates"; + +describe("oncePerVersion", () => { + it("tells about a version once and about the next one again", () => { + const isNew = oncePerVersion(); + expect(isNew("0.1.2")).toBe(true); + expect(isNew("0.1.2")).toBe(false); + expect(isNew("0.1.3")).toBe(true); + expect(isNew("0.1.3")).toBe(false); + }); +}); + +it("checks again within a day", () => { + expect(UPDATE_CHECK_INTERVAL_MS).toBeGreaterThan(0); + expect(UPDATE_CHECK_INTERVAL_MS).toBeLessThanOrEqual(24 * 60 * 60 * 1000); +}); diff --git a/src/lib/updates.ts b/src/lib/updates.ts index d451f9d..c5c1ebe 100644 --- a/src/lib/updates.ts +++ b/src/lib/updates.ts @@ -1,11 +1,19 @@ +import { openUrl } from "@tauri-apps/plugin-opener"; import { relaunch } from "@tauri-apps/plugin-process"; import { check, Update } from "@tauri-apps/plugin-updater"; +import { selfUpdateSupported } from "./ipc"; export type UpdateState = | { kind: "none" } | { kind: "available"; update: Update } | { kind: "error"; message: string }; +/** Where people download a version by hand (e.g. a `.deb` install on Linux). */ +export const RELEASES_URL = "https://github.com/tw4/lumela/releases/latest"; + +/** The app usually lives in the tray for days, so look again every few hours. */ +export const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; + export async function checkForUpdate(): Promise { try { const update = await check(); @@ -15,8 +23,24 @@ export async function checkForUpdate(): Promise { } } -/** Downloads, installs and restarts. `onProgress` gets 0–1 when the size is known. */ +/** Whether this install can replace itself (false for `.deb`/`.rpm` on Linux). */ +export async function canSelfUpdate(): Promise { + try { + return await selfUpdateSupported(); + } catch { + return true; + } +} + +/** + * Downloads, installs and restarts. Installs that cannot replace themselves open the + * release page instead. `onProgress` gets 0–1 when the size is known. + */ export async function installUpdate(update: Update, onProgress?: (p: number) => void) { + if (!(await canSelfUpdate())) { + await openUrl(RELEASES_URL); + return; + } let total = 0, done = 0; await update.downloadAndInstall((e) => { if (e.event === "Started") total = e.data.contentLength ?? 0; @@ -24,3 +48,13 @@ export async function installUpdate(update: Update, onProgress?: (p: number) => }); await relaunch(); } + +/** Tells about each new version once, however often the check runs. */ +export function oncePerVersion() { + let last: string | null = null; + return (version: string) => { + if (version === last) return false; + last = version; + return true; + }; +} diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 33aa719..8e45637 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -160,7 +160,7 @@ function Updates() { {state === "none" && {t.settings.upToDate}} {state === "error" && {t.settings.checkFailed}} {state === "available" && update && ( - )}