Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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*"]
Expand Down Expand Up @@ -72,6 +73,7 @@ jobs:
runs-on: ubuntu-24.04
permissions:
contents: write
actions: write
steps:
- uses: actions/checkout@v4
with:
Expand All @@ -83,13 +85,19 @@ 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/<tag>.md win; otherwise GitHub generates them.
run: |
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}"
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 21 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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");
Expand All @@ -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("")));
}
}
28 changes: 21 additions & 7 deletions src/editor/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(() => {
Expand Down
1 change: 1 addition & 0 deletions src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/tr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/lib/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export type PermissionStatus = "granted" | "denied" | "unsupported";
export const screenPermission = () => invoke<PermissionStatus>("screen_permission");
export const openPermissionSettings = () => invoke<void>("open_permission_settings");
export const restartApp = () => invoke<void>("restart_app");
export const selfUpdateSupported = () => invoke<boolean>("self_update_supported");

export const startCapture = (mode: CaptureMode) => invoke<void>("start_capture", { mode });
export const overlayInfo = () => invoke<OverlayInfo>("overlay_info");
Expand Down
17 changes: 17 additions & 0 deletions src/lib/updates.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
36 changes: 35 additions & 1 deletion src/lib/updates.ts
Original file line number Diff line number Diff line change
@@ -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<UpdateState> {
try {
const update = await check();
Expand All @@ -15,12 +23,38 @@ export async function checkForUpdate(): Promise<UpdateState> {
}
}

/** 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<boolean> {
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;
if (e.event === "Progress") { done += e.data.chunkLength; if (total) onProgress?.(done / total); }
});
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;
};
}
2 changes: 1 addition & 1 deletion src/settings/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ function Updates() {
{state === "none" && <span className="dim">{t.settings.upToDate}</span>}
{state === "error" && <span className="error" title={info}>{t.settings.checkFailed}</span>}
{state === "available" && update && (
<button className="primary" onClick={() => { setState("installing"); installUpdate(update).catch((e) => { setInfo(String(e)); setState("error"); }); }}>
<button className="primary" onClick={() => { setState("installing"); installUpdate(update).then(() => setState("available"), (e) => { setInfo(String(e)); setState("error"); }); }}>
{t.settings.install(info)}
</button>
)}
Expand Down
Loading