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
7 changes: 6 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ jobs:
release-electron/*.AppImage
release-electron/*.deb
release-electron/*.rpm
release-electron/*.yml
release-electron/*.blockmap
if-no-files-found: error
retention-days: 14

Expand Down Expand Up @@ -137,7 +139,8 @@ jobs:
shell: bash
run: |
set -euo pipefail
ls -lh release-electron/*.AppImage release-electron/*.deb release-electron/*.rpm release-electron/*.flatpak
ls -lh release-electron/*.AppImage release-electron/*.deb release-electron/*.rpm release-electron/*.flatpak release-electron/*.yml
ls -lh release-electron/*.blockmap || echo "No blockmap (differential update disabled)"
- name: Publish GitHub Release assets
uses: softprops/action-gh-release@v2
with:
Expand All @@ -147,3 +150,5 @@ jobs:
release-electron/*.deb
release-electron/*.rpm
release-electron/*.flatpak
release-electron/*.yml
release-electron/*.blockmap
61 changes: 61 additions & 0 deletions docs/UPDATE_FEED.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Liney Electron Update Feed

AppImage auto-update uses `electron-updater` with the GitHub Releases feed
baked into the packaged app. No manual `LINEY_UPDATE_FEED_URL` is needed for
normal installs.

## How it works

- `package.json → build.publish` points at `Cytech-Team/Liney` (`provider: github`).
`electron-builder` writes `release-electron/latest-linux.yml` (+ `.blockmap`)
alongside the AppImage. The `.yml` carries `version`, file `url`, `sha512`
and signature metadata. The updater verifies these before downloading.
- `electron/main.mjs` resolves the feed via `electron/updater-config.mjs`:
- dev (`!app.isPackaged`) → `unconfigured`, reason "disabled in development".
- packaged non-AppImage (deb/rpm/flatpak, no `$APPIMAGE`) → `unconfigured`,
reason "requires AppImage". Those formats update via apt/dnf/flatpak.
- packaged AppImage, no env override → `configured: true`, provider `github`.
- packaged AppImage + `LINEY_UPDATE_FEED_URL` → `configured: true`,
provider `generic`, URL `${feed}/${channel}/` expecting `latest-linux.yml`.
- `autoUpdater.channel` is left at the default (`latest`) on purpose so the
updater looks for `latest-linux.yml`. Setting it to `preview`/`stable` would
make it look for `preview-linux.yml`, which `electron-builder` never emits.

## Preview vs Stable

- Display channel (`preview`/`stable`) comes from `LINEY_UPDATE_CHANNEL`
(default `preview`). It only affects the About UI and `allowPrerelease`.
- `allowPrerelease = channel === "preview"`.
- Preview builds see prereleases + stable releases (newest first from feed).
- Stable builds (`LINEY_UPDATE_CHANNEL=stable`) see stable releases only
(`/releases/latest`).
- Channels stay separate via GitHub stable releases vs prereleases, matching
the About footnote. No extra hosting is required.

## CI requirements

- `release.yml` uploads `*.yml` + `*.blockmap` with the AppImage to the
GitHub Release. Without `latest-linux.yml` in the release assets,
`checkForUpdates()` reports `ERR_UPDATER_CHANNEL_FILE_NOT_FOUND`.
- `scripts/verify-linux-packages.sh` asserts `latest-linux.yml` exists,
contains `version: $VERSION` and a `sha512` entry.
- Renderer (`src/App.tsx` → About → Software Updates) already handles
`idle/checking/available/downloading/downloaded/current/unconfigured/error`
via `liney:updater:event`. No UI change needed.

## Manual test

```sh
# Source build (expect unconfigured, dev reason)
LINEY_DATA_DIR=$(mktemp -d) npm run electron:smoke

# Packaged AppImage without network (expect checking → current/error,
# never "Update feed is not configured")
./release-electron/Liney-Electron-Preview-0.1.4-x86_64.AppImage --appimage-extract-and-run

# Self-hosted generic feed override
LINEY_UPDATE_FEED_URL=https://updates.example.com \
LINEY_UPDATE_CHANNEL=preview \
./release-electron/Liney-Electron-Preview-0.1.4-x86_64.AppImage --appimage-extract-and-run
# Serve preview/latest-linux.yml + AppImage under https://updates.example.com/preview/
```
34 changes: 24 additions & 10 deletions electron/main.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import { fileURLToPath } from "node:url";
import { execFileSync } from "node:child_process";
import { getEditActionCalls, hasNativeEditShortcuts, installNativeEditMenu, installNativeEditShortcuts, resetEditActionCalls } from "./edit-menu.mjs";
import { resolveUpdaterConfig } from "./updater-config.mjs";

protocol.registerSchemesAsPrivileged([
{scheme:"lineyapp",privileges:{standard:true,secure:true,supportFetchAPI:true,stream:true}}
Expand Down Expand Up @@ -66,22 +67,35 @@
let quitting = false;
let tray = null;
let closeToTray = false;
let toneTimer = null;

Check warning on line 70 in electron/main.mjs

View workflow job for this annotation

GitHub Actions / verify

eslint(no-unused-vars)

electron/main.mjs:70:5: Variable 'toneTimer' is declared but never used. Unused variables should start with a '_'.
const updateChannel = process.env.LINEY_UPDATE_CHANNEL === "stable" ? "stable" : "preview";
const updateFeedBase = String(process.env.LINEY_UPDATE_FEED_URL || "").trim().replace(/\/+$/, "");
const updaterConfig = resolveUpdaterConfig({
isPackaged: app.isPackaged,
appImagePath: process.env.APPIMAGE,
feedUrlEnv: process.env.LINEY_UPDATE_FEED_URL,
updateChannelEnv: process.env.LINEY_UPDATE_CHANNEL,
});
const updateChannel = updaterConfig.displayChannel;
let updateState = { status: "idle", channel: updateChannel, version: app.getVersion(), availableVersion: null, releaseNotes: "", percent: 0, error: null };
let updateConfigured = false;
function publishUpdateState(patch) {
updateState = { ...updateState, ...patch, channel: updateChannel, version: app.getVersion() };
broadcast("liney:desktop:event", { name: "liney:updater:event", payload: updateState });
}
function configureUpdater() {
if (!app.isPackaged || !updateFeedBase || process.env.APPIMAGE === undefined) return false;
if (!updaterConfig.configured) return false;
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = false;
autoUpdater.allowPrerelease = updateChannel === "preview";
autoUpdater.channel = updateChannel;
autoUpdater.setFeedURL({ provider: "generic", url: `${updateFeedBase}/${updateChannel}/` });
autoUpdater.allowPrerelease = updaterConfig.allowPrerelease;
// Keep autoUpdater.channel as default (latest) so both GitHub and generic
// feeds resolve `latest-linux.yml`. Setting it to "preview"/"stable" would
// make electron-updater look for `preview-linux.yml` which electron-builder
// never generates. Preview/Stable separation comes from GitHub stable vs
// prerelease tags (allowPrerelease) or from the generic URL subfolder.
if (updaterConfig.provider === "generic" && updaterConfig.url) {
autoUpdater.setFeedURL({ provider: "generic", url: updaterConfig.url });
}
// Default GitHub provider needs no setFeedURL: electron-builder bakes
// `app-update.yml` (owner Cytech-Team, repo Liney) into the packaged app.
autoUpdater.on("checking-for-update", () => publishUpdateState({ status: "checking", error: null }));
autoUpdater.on("update-available", info => {
const releaseNotes = typeof info.releaseNotes === "string" ? info.releaseNotes : Array.isArray(info.releaseNotes) ? info.releaseNotes.map(note => note.note || "").join("\n") : "";
Expand All @@ -92,16 +106,16 @@
toast.show();
}
});
autoUpdater.on("update-not-available", info => publishUpdateState({ status: "current", availableVersion: info.version, releaseNotes: "", percent: 0 }));
autoUpdater.on("update-not-available", () => publishUpdateState({ status: "current", availableVersion: null, releaseNotes: "", percent: 0, error: null }));
autoUpdater.on("download-progress", progress => publishUpdateState({ status: "downloading", percent: Math.max(0, Math.min(100, Math.round(progress.percent || 0))) }));
autoUpdater.on("update-downloaded", info => publishUpdateState({ status: "downloaded", availableVersion: info.version, percent: 100 }));
autoUpdater.on("error", error => publishUpdateState({ status: "error", error: String(error?.message || error).slice(0, 500) }));
autoUpdater.on("update-downloaded", info => publishUpdateState({ status: "downloaded", availableVersion: info.version, percent: 100, error: null }));
autoUpdater.on("error", error => publishUpdateState({ status: "error", availableVersion: null, releaseNotes: "", error: String(error?.message || error).slice(0, 500) }));
updateConfigured = true;
return true;
}
async function checkForUpdates() {
if (!updateConfigured) {
const reason = !app.isPackaged ? "Updates are disabled in development builds" : process.env.APPIMAGE === undefined ? "Automatic updates require the AppImage package" : "Update feed is not configured";
const reason = updaterConfig.reason || "Update feed is not configured";
publishUpdateState({ status: "unconfigured", error: reason });
return updateState;
}
Expand Down Expand Up @@ -321,7 +335,7 @@
autoUpdater.quitAndInstall(true, true);
return true;
case "set_close_to_tray": {
if (Boolean(args.enabled)) {

Check warning on line 338 in electron/main.mjs

View workflow job for this annotation

GitHub Actions / verify

eslint(no-extra-boolean-cast)

electron/main.mjs:338:11: Redundant Boolean call
// Never hide the only window unless Electron successfully created a
// native tray icon. Electron selects the host integration for the
// current OS/desktop; LINEY does not depend on a KDE-specific API.
Expand Down
60 changes: 60 additions & 0 deletions electron/updater-config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Pure update-feed resolution for Liney Electron.
*
* Renderer shows "Update feed is not configured" whenever the main process
* reports `configured: false`. Packaged AppImage builds must resolve to the
* baked-in GitHub Releases feed so auto-update works without manual env vars.
* System packages (deb/rpm/flatpak) update via their package manager.
*/

const DEFAULT_OWNER = "Cytech-Team";
const DEFAULT_REPO = "Liney";

export function resolveUpdaterConfig({ isPackaged, appImagePath, feedUrlEnv, updateChannelEnv }) {
const displayChannel = updateChannelEnv === "stable" ? "stable" : "preview";
const allowPrerelease = displayChannel === "preview";

if (!isPackaged) {
return {
configured: false,
displayChannel,
allowPrerelease,
autoUpdaterChannel: null,
reason: "Updates are disabled in development builds",
};
}

if (appImagePath === undefined || appImagePath === null || appImagePath === "") {
return {
configured: false,
displayChannel,
allowPrerelease,
autoUpdaterChannel: null,
reason: "Automatic updates require the AppImage package",
};
}

const feedBase = String(feedUrlEnv || "").trim().replace(/\/+$/, "");
if (feedBase) {
return {
configured: true,
provider: "generic",
url: `${feedBase}/${displayChannel}/`,
displayChannel,
allowPrerelease,
autoUpdaterChannel: null,
reason: null,
};
}

return {
configured: true,
provider: "github",
owner: DEFAULT_OWNER,
repo: DEFAULT_REPO,
displayChannel,
allowPrerelease,
autoUpdaterChannel: null,
reason: null,
};
}
71 changes: 71 additions & 0 deletions electron/updater-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, expect, test } from "bun:test";
import { resolveUpdaterConfig } from "./updater-config.mjs";

describe("resolveUpdaterConfig", () => {
test("disables updates in development builds", () => {
const result = resolveUpdaterConfig({
isPackaged: false,
appImagePath: undefined,
feedUrlEnv: "",
updateChannelEnv: "",
});
expect(result.configured).toBe(false);
expect(result.reason).toMatch(/development/i);
});

test("requires AppImage for packaged system packages", () => {
const result = resolveUpdaterConfig({
isPackaged: true,
appImagePath: undefined,
feedUrlEnv: "https://updates.example.com",
updateChannelEnv: "preview",
});
expect(result.configured).toBe(false);
expect(result.reason).toMatch(/AppImage/i);
});

test("uses baked-in GitHub feed for packaged AppImage without custom env", () => {
const result = resolveUpdaterConfig({
isPackaged: true,
appImagePath: "/tmp/Liney.AppImage",
feedUrlEnv: "",
updateChannelEnv: "",
});
expect(result.configured).toBe(true);
expect(result.provider).toBe("github");
expect(result.owner).toBe("Cytech-Team");
expect(result.repo).toBe("Liney");
expect(result.displayChannel).toBe("preview");
expect(result.allowPrerelease).toBe(true);
// Must use default latest-linux.yml so GitHub prerelease filtering works.
// Setting channel to "preview"/"stable" would look for preview-linux.yml
// which electron-builder never generates.
expect(result.autoUpdaterChannel).toBeNull();
});

test("stable channel disables prerelease", () => {
const result = resolveUpdaterConfig({
isPackaged: true,
appImagePath: "/tmp/Liney.AppImage",
feedUrlEnv: "",
updateChannelEnv: "stable",
});
expect(result.configured).toBe(true);
expect(result.displayChannel).toBe("stable");
expect(result.allowPrerelease).toBe(false);
expect(result.autoUpdaterChannel).toBeNull();
});

test("custom generic feed keeps channel in URL and default yml name", () => {
const result = resolveUpdaterConfig({
isPackaged: true,
appImagePath: "/tmp/Liney.AppImage",
feedUrlEnv: "https://updates.example.com///",
updateChannelEnv: "preview",
});
expect(result.configured).toBe(true);
expect(result.provider).toBe("generic");
expect(result.url).toBe("https://updates.example.com/preview/");
expect(result.autoUpdaterChannel).toBeNull();
});
});
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,11 @@
"rpm": {
"artifactName": "Liney-Electron-Preview-${version}-${arch}.rpm"
},
"publish": null,
"publish": {
"provider": "github",
"owner": "Cytech-Team",
"repo": "Liney"
},
"asarUnpack": [
"electron/dist/engine.mjs",
"electron/native/**",
Expand Down
9 changes: 9 additions & 0 deletions scripts/verify-linux-packages.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ done
PACKAGE_ROOT="$(mktemp -d)"
trap 'rm -rf "$PACKAGE_ROOT"' EXIT

# Update feed metadata for AppImage auto-update (GitHub Releases).
# electron-builder writes latest-linux.yml alongside the AppImage when
# build.publish points at Cytech-Team/Liney. The updater verifies SHA-512
# and signature from this file before downloading.
YML="$OUTPUT_DIR/latest-linux.yml"
test -s "$YML"
grep -Fq "version: $VERSION" "$YML"
grep -Fq "sha512" "$YML"

# Validate package metadata without relying on a host RPM database.
[[ "$(dpkg-deb --field "$DEB" Package)" == "liney" ]]
[[ "$(dpkg-deb --field "$DEB" Version)" == "$VERSION" ]]
Expand Down
6 changes: 3 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6336,12 +6336,12 @@ function AboutSettings() {
return <div className="settings-scroll">
<div className="settings-placeholder"><div className="placeholder-orb"><Info size={25}/></div><h3>Liney Electron Preview</h3><p>Unofficial LINE client สำหรับ Linux · React + Electron · LINEJS Worker · SQLite history</p><span className="settings-status">Version {update.version} · {update.channel.toUpperCase()} channel</span></div>
<SettingsGroup title="Software Updates">
<div className="setting-row"><div><strong>{statusText[update.status] || t("สถานะการอัปเดต")}</strong><span>{update.availableVersion ? t("เวอร์ชันใหม่ {{version}}", { version: update.availableVersion }) : t("ตรวจสอบเวอร์ชันใหม่จาก Cytech Team")}</span></div><div className="setting-control"><button type="button" className="settings-action-button" disabled={busy || update.status === "checking" || update.status === "downloading"} onClick={() => void run("check_for_updates")}><RefreshCw size={14}/> {t("ตรวจสอบ")}</button></div></div>
<div className="setting-row"><div><strong>{statusText[update.status] || t("สถานะการอัปเดต")}</strong><span>{update.status === "current" ? t("ไม่พบเวอร์ชันใหม่") : update.availableVersion ? t("เวอร์ชันใหม่ {{version}}", { version: update.availableVersion }) : t("ตรวจสอบเวอร์ชันใหม่จาก Cytech Team")}</span></div><div className="setting-control"><button type="button" className="settings-action-button" disabled={busy || update.status === "checking" || update.status === "downloading"} onClick={() => void run("check_for_updates")}><RefreshCw size={14}/> {t("ตรวจสอบ")}</button></div></div>
{update.status === "available" && update.availableVersion !== deferredVersion && <div className="setting-row"><div><strong>{t("พร้อมอัปเดต")}</strong><span>{t("ดาวน์โหลดได้โดยไม่ปิดแอป และจะติดตั้งเมื่อยืนยัน")}</span></div><div className="setting-control"><button type="button" className="settings-action-button" onClick={() => setDeferredVersion(update.availableVersion || null)}>{t("ไว้ภายหลัง")}</button><button type="button" className="settings-action-button primary" disabled={busy} onClick={() => void run("download_update")}>{t("ดาวน์โหลด")}</button></div></div>}
{update.status === "downloading" && <div className="setting-row"><div><strong>Download progress</strong><span>{update.percent ?? 0}%</span></div><div className="setting-control"><progress max="100" value={update.percent ?? 0}/></div></div>}
{update.status === "downloaded" && <div className="setting-row"><div><strong>{t("พร้อมติดตั้ง")}</strong><span>{t("ไฟล์อัปเดตผ่านการตรวจสอบความถูกต้องแล้ว")}</span></div><div className="setting-control"><button type="button" className="settings-action-button primary" onClick={() => void run("install_update")}>Restart & Update</button></div></div>}
{update.releaseNotes && <div className="update-release-notes"><strong>Release notes</strong><p>{update.releaseNotes}</p></div>}
{update.error && <p className="update-error">{update.error}</p>}
{(update.status === "available" || update.status === "downloaded") && update.releaseNotes && <div className="update-release-notes"><strong>Release notes</strong><p>{update.releaseNotes}</p></div>}
{(update.status === "error" || update.status === "unconfigured") && update.error && <p className="update-error">{update.error}</p>}
<p className="update-footnote">{t("ช่องทาง Preview และ Stable แยกจากกัน · ตรวจสอบลายเซ็นและ SHA-512 ตาม metadata ของแพ็กเกจ · ระบบอัปเดตอัตโนมัติรองรับ AppImage เท่านั้น")}</p>
</SettingsGroup>
</div>;
Expand Down
1 change: 1 addition & 0 deletions src/lib/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@
"กำลังดาวน์โหลด {{percent}}%": "Downloading {{percent}}%",
"ดาวน์โหลดและตรวจสอบไฟล์แล้ว": "Downloaded and verified",
"ใช้เวอร์ชันล่าสุดแล้ว": "You're up to date",
"ไม่พบเวอร์ชันใหม่": "No new version found",
"ยังไม่ได้ตั้งค่า Update Feed": "Update feed isn't configured",
"ตรวจสอบการอัปเดตไม่สำเร็จ": "Couldn't check for updates",
"สถานะการอัปเดต": "Update status",
Expand Down Expand Up @@ -523,7 +524,7 @@

const LanguageContext = createContext<AppLanguage>("th");

export function translateText(

Check warning on line 527 in src/lib/i18n.tsx

View workflow job for this annotation

GitHub Actions / verify

react(only-export-components)

src/lib/i18n.tsx:527:17: Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components.
language: AppLanguage,
source: string,
values: TranslationValues = {},
Expand Down
Loading