mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-26 23:01:22 +00:00
fix(settings): enable Linux nightly AppImage auto-update in System settings
Fixes the Settings → System "App updates" panel so it actually works on
Linux, and adds Nightly as a selectable channel — previously missing
entirely, so Linux self-update couldn't be reached from this UI at all.
- The channel dropdown no longer gets permanently disabled the moment
the desktop bridge reports 'unsupported', which is the normal state
whenever the channel isn't Nightly on Linux. It stays enabled so the
user can switch to Nightly, the only way out of that state.
- Shows live download progress ("Downloading update… N%") and an
explicit button state machine (Check → grayed out while busy →
Restart now once staged), instead of a frozen "Checking…" during the
~1.5GB background download.
- Renders every status update from the triggering action's own return
value (checkNow()/setChannel()'s result) rather than a separate
follow-up getStatus() call, which can race against other state
changes and show a stale result even after a real success.
- setupAppUpdates() no longer re-syncs the channel to the backend on
every Settings-panel re-render — only once per page load — so a
redundant sync can no longer stomp an in-flight download's state.
- Routes update-flow events into the existing diagnostics.js
console-capture + contribute() snapshot API, so the user's existing
"Export Diagnostics" button now captures the full update decision
trace end to end — no new UI or log file. This diagnostic tracing is
what actually root-caused the bugs above, from real on-device
captures rather than guesswork.
Companion PR in feedBack-desktop (the underlying update engine).
Verified end-to-end on a Steam Deck: channel switch → check → live
download progress → restart button → relaunch onto the new build,
confirmed via a real Export Diagnostics capture showing a clean,
fully-accounted-for trace.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
1c077c9ab7
commit
c991a92c47
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
@ -63,11 +63,18 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# npm ci runs third-party postinstall scripts; don't leave the token in
|
||||
# git config for them (this job never pushes).
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Rebuild Tailwind CSS
|
||||
run: bash scripts/build-tailwind.sh
|
||||
|
||||
|
||||
965
package-lock.json
generated
965
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -14,6 +14,7 @@
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-import-x": "^4.17.1"
|
||||
"eslint-plugin-import-x": "^4.17.1",
|
||||
"tailwindcss": "^3.4.19"
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,15 +7,26 @@
|
||||
#
|
||||
# Pin to Tailwind 3.x so the input/config syntax matches what was
|
||||
# already shipped via the Play CDN (Tailwind 4 has breaking changes).
|
||||
#
|
||||
# Run this from a checkout with NO untracked plugin directories present (a
|
||||
# `git worktree add --detach` of this branch is the safest way). The content
|
||||
# glob (tailwind.config.js) scans `./plugins/**` on disk regardless of
|
||||
# .gitignore — a dev machine with private/out-of-tree plugins checked out
|
||||
# locally (e.g. audio_engine, plugin_manager) will silently bake their classes
|
||||
# into the committed CSS, which CI's clean checkout can never reproduce and
|
||||
# will permanently fail the tailwind-fresh gate.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
# Pin to the exact version used to generate the committed CSS — committed
|
||||
# artifacts must rebuild byte-stable for diff-friendly maintenance. The
|
||||
# pinned version is the one that produced the current static/tailwind.min.css
|
||||
# (visible in its top-of-file header comment); bump deliberately when you
|
||||
# want to track upstream Tailwind 3.x updates, and regenerate the CSS in
|
||||
# the same commit.
|
||||
exec npx -y tailwindcss@3.4.19 \
|
||||
# Byte-stable rebuilds require the exact same resolved dependency tree, not
|
||||
# just the same top-level tailwindcss version: `npx -y tailwindcss@x.y.z`
|
||||
# installs into a scratch npx cache and lets npm re-resolve transitive deps
|
||||
# (postcss, cssnano, autoprefixer) to whatever's current on the registry at
|
||||
# invocation time — those drift independently of the pinned version and
|
||||
# silently produced non-reproducible output between two machines. tailwindcss
|
||||
# is now a pinned devDependency (package.json/package-lock.json); `npm ci`
|
||||
# before this script (both here and in CI) is what actually makes the output
|
||||
# reproducible.
|
||||
exec npx tailwindcss \
|
||||
-c tailwind.config.js \
|
||||
-i static/_tailwind.src.css \
|
||||
-o static/tailwind.min.css \
|
||||
|
||||
@ -209,9 +209,16 @@ export function setupWindowOptions() {
|
||||
}
|
||||
}
|
||||
|
||||
export const APP_UPDATE_CHANNELS = ['stable', 'rc', 'beta', 'alpha'];
|
||||
export const APP_UPDATE_CHANNELS = ['stable', 'rc', 'beta', 'alpha', 'nightly'];
|
||||
|
||||
export let _appUpdatesWired = false;
|
||||
// Poll handle for the active-download watcher (module-scoped so re-running
|
||||
// setupAppUpdates on a panel re-render never stacks a second poll).
|
||||
let _appUpdatePollTimer = null;
|
||||
// Last channel main actually acknowledged (initial sync or a successful
|
||||
// user switch). Used to revert the dropdown/localStorage if a switch fails,
|
||||
// so the UI/persisted state can never end up ahead of the real updater state.
|
||||
let _appUpdateAckedChannel = null;
|
||||
|
||||
export function setupAppUpdates() {
|
||||
const block = document.getElementById('app-updates-block');
|
||||
@ -244,13 +251,29 @@ export function setupAppUpdates() {
|
||||
try { storedRaw = localStorage.getItem('feedBack-update-channel') || localStorage.getItem('slopsmith-update-channel'); } catch (_) { /* fall through */ }
|
||||
const stored = APP_UPDATE_CHANNELS.includes(storedRaw) ? storedRaw : 'stable';
|
||||
channelSelect.value = stored;
|
||||
_appUpdateAckedChannel = stored;
|
||||
|
||||
const isLinux = window.feedBackDesktop?.platform === 'linux';
|
||||
// Diagnostic: every entry into this function, with whether the one-time
|
||||
// sync gate has already fired. _appUpdatesWired is a MODULE-level `let`,
|
||||
// so it only resets to false on a genuine fresh evaluation of this
|
||||
// script (a real page reload/navigation) — not on loadSettings() simply
|
||||
// being called again within the same page. A second "wired=false" in one
|
||||
// exported log is direct proof of a reload; a series of "wired=true"
|
||||
// entries proves it's just repeated Settings-panel visits (harmless).
|
||||
console.log('[update-diag] setupAppUpdates() entered', JSON.stringify({ wired: _appUpdatesWired, stored }));
|
||||
|
||||
function showLinuxFallback(message) {
|
||||
// Deliberately leaves channelSelect ENABLED: on Linux "unsupported"
|
||||
// usually just means "the channel isn't Nightly yet", and the dropdown
|
||||
// is the only way to switch to Nightly. Disabling it would trap the
|
||||
// user on whatever channel they booted with. Only the check button and
|
||||
// the note reflect the unsupported state.
|
||||
if (linuxNote) linuxNote.classList.remove('hidden');
|
||||
channelSelect.disabled = true;
|
||||
checkBtn.disabled = true;
|
||||
// Reset the button out of any leftover "Restart now" state (e.g. an
|
||||
// update was staged on nightly, then the user switched channels).
|
||||
checkBtn.textContent = 'Check for updates';
|
||||
checkBtn.dataset.mode = 'check';
|
||||
statusEl.textContent = message || 'Auto-update is not available on this platform.';
|
||||
}
|
||||
|
||||
@ -262,61 +285,169 @@ export function setupAppUpdates() {
|
||||
} catch (_) { return 'never'; }
|
||||
}
|
||||
|
||||
// Render one status object. Always keeps the current version + channel
|
||||
// visible and appends what's happening, so the download progress never
|
||||
// obscures which build you're on.
|
||||
function renderFrom(s, extra) {
|
||||
// Diagnostic trace: log the raw status object on EVERY render
|
||||
// (unconditionally, before any branching) — auto-captured by
|
||||
// diagnostics.js's console wrap into the exportable ring buffer, so
|
||||
// "Export Diagnostics" in this same Settings → System panel captures
|
||||
// exactly what the app saw and decided, not just what the UI showed.
|
||||
console.log('[update-diag] renderFrom', JSON.stringify(s), extra ? `extra=${extra}` : '');
|
||||
if (!s) { statusEl.textContent = extra || 'Updater status unavailable.'; return; }
|
||||
if (s.status === 'unsupported' || s.platform === 'linux') {
|
||||
showLinuxFallback('Auto-update requires the AppImage build on the Nightly channel.');
|
||||
return;
|
||||
}
|
||||
// Healthy for the current channel — clear any "unsupported" UI left
|
||||
// over from a prior channel selection.
|
||||
if (linuxNote) linuxNote.classList.add('hidden');
|
||||
const base = `Version ${s.currentVersion || '?'} · ${s.channel || channelSelect.value}`;
|
||||
// The button is a little state machine driven by the status: grayed
|
||||
// out while a check/download is in flight, flipped to "Restart now"
|
||||
// (dataset.mode='restart', consumed by the click handler) once an
|
||||
// update is staged, and back to a plain check button otherwise.
|
||||
let action;
|
||||
let btnLabel = 'Check for updates';
|
||||
let btnMode = 'check';
|
||||
let btnDisabled = false;
|
||||
// Switching channels mid-check/download abandons the in-flight
|
||||
// operation (a redundant setChannel() bumps main's checkGeneration),
|
||||
// so lock the selector while one is active. Left enabled for every
|
||||
// other status, including the Linux "unsupported" fallback above,
|
||||
// where it's the only way to switch onto Nightly.
|
||||
channelSelect.disabled = s.status === 'checking' || s.status === 'downloading';
|
||||
switch (s.status) {
|
||||
case 'checking':
|
||||
action = 'checking for updates…';
|
||||
btnDisabled = true;
|
||||
break;
|
||||
case 'downloading': {
|
||||
const pct = typeof s.percent === 'number' ? s.percent : null;
|
||||
action = pct === null ? 'update available — downloading…' : `downloading update… ${pct}%`;
|
||||
btnDisabled = true;
|
||||
break;
|
||||
}
|
||||
case 'downloaded':
|
||||
action = 'update ready';
|
||||
if (typeof updateApi.apply === 'function') {
|
||||
btnLabel = 'Restart now';
|
||||
btnMode = 'restart';
|
||||
} else {
|
||||
// Older bridge without apply(): fall back to text-only.
|
||||
action = 'update ready — restart to apply';
|
||||
}
|
||||
break;
|
||||
case 'error':
|
||||
action = s.message ? `update error: ${s.message}` : 'update check failed';
|
||||
break;
|
||||
case 'idle':
|
||||
default:
|
||||
action = `up to date · last checked ${fmtTimestamp(s.lastChecked)}`;
|
||||
break;
|
||||
}
|
||||
checkBtn.textContent = btnLabel;
|
||||
checkBtn.dataset.mode = btnMode;
|
||||
checkBtn.disabled = btnDisabled;
|
||||
const line = `${base} · ${action}`;
|
||||
statusEl.textContent = extra ? `${extra} · ${line}` : line;
|
||||
|
||||
// Live structured snapshot (overwrites, not a log) via the existing
|
||||
// diagnostics contribute() API — 'audio_engine' is feedBack-desktop's
|
||||
// own registered plugin id, so the server's diagnostics export won't
|
||||
// filter it out. Always current, no scrolling through console history
|
||||
// needed to answer "what does the app think is going on right now."
|
||||
try {
|
||||
window.feedBack?.diagnostics?.contribute('audio_engine', {
|
||||
update: {
|
||||
channel: s.channel || channelSelect.value,
|
||||
status: s.status,
|
||||
currentVersion: s.currentVersion ?? null,
|
||||
lastChecked: s.lastChecked ?? null,
|
||||
percent: typeof s.percent === 'number' ? s.percent : null,
|
||||
message: s.message ?? null,
|
||||
rendered: line,
|
||||
ts: Date.now(),
|
||||
},
|
||||
});
|
||||
} catch (_) { /* diagnostics.js not loaded — never let this break rendering */ }
|
||||
|
||||
// A download runs in the background (the check returns immediately), so
|
||||
// poll for the terminal state rather than relying solely on a one-shot
|
||||
// "downloaded" event that could be missed or arrive out of order.
|
||||
if (s.status === 'downloading' || s.status === 'checking') pollWhileBusy();
|
||||
}
|
||||
|
||||
function renderStatus(extra) {
|
||||
try {
|
||||
// Wrap in Promise.resolve so a future getStatus() that returns
|
||||
// synchronously won't blow up on .then().
|
||||
void Promise.resolve(updateApi.getStatus()).then((s) => {
|
||||
if (!s) { statusEl.textContent = extra || 'Updater status unavailable.'; return; }
|
||||
if (s.status === 'unsupported' || s.platform === 'linux') {
|
||||
showLinuxFallback('Auto-update is not available on Linux.');
|
||||
return;
|
||||
}
|
||||
if (s.status === 'error') {
|
||||
const errMsg = s.message ? `Update error: ${s.message}` : 'Update check failed.';
|
||||
statusEl.textContent = extra ? `${extra} · ${errMsg}` : errMsg;
|
||||
return;
|
||||
}
|
||||
const parts = [
|
||||
`Version ${s.currentVersion || '?'}`,
|
||||
`channel ${s.channel || channelSelect.value}`,
|
||||
`last checked ${fmtTimestamp(s.lastChecked)}`,
|
||||
];
|
||||
statusEl.textContent = extra ? `${extra} · ${parts.join(' · ')}` : parts.join(' · ');
|
||||
}).catch((e) => {
|
||||
console.warn('[updater] getStatus failed:', e);
|
||||
statusEl.textContent = extra || 'Failed to read updater status.';
|
||||
});
|
||||
void Promise.resolve(updateApi.getStatus())
|
||||
.then((s) => renderFrom(s, extra))
|
||||
.catch((e) => {
|
||||
console.warn('[updater] getStatus failed:', e);
|
||||
statusEl.textContent = extra || 'Failed to read updater status.';
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[updater] getStatus threw:', e);
|
||||
statusEl.textContent = extra || 'Failed to read updater status.';
|
||||
}
|
||||
}
|
||||
|
||||
if (isLinux) {
|
||||
showLinuxFallback('Auto-update is not available on Linux.');
|
||||
// Keep main informed of the persisted channel even on Linux so
|
||||
// cross-platform reasoning about the channel stays consistent.
|
||||
// setChannel() may return a Promise — chain .catch() so a rejected
|
||||
// promise doesn't surface as an unhandled rejection.
|
||||
try {
|
||||
void Promise.resolve(updateApi.setChannel(stored)).catch((e) => {
|
||||
console.warn('[updater] setChannel(linux) failed:', e);
|
||||
// While a download (or check) is active, re-read the authoritative status
|
||||
// every ~1.5s and stop once it settles (downloaded / idle / error). This is
|
||||
// what guarantees the panel leaves "downloading… 100%" and lands on "update
|
||||
// ready" (or surfaces a swap error) even if the completion event is lost.
|
||||
function pollWhileBusy() {
|
||||
if (_appUpdatePollTimer) return;
|
||||
_appUpdatePollTimer = setInterval(() => {
|
||||
void Promise.resolve(updateApi.getStatus()).then((s) => {
|
||||
renderFrom(s);
|
||||
const st = s && s.status;
|
||||
if (st !== 'downloading' && st !== 'checking') {
|
||||
clearInterval(_appUpdatePollTimer);
|
||||
_appUpdatePollTimer = null;
|
||||
}
|
||||
}).catch(() => {
|
||||
clearInterval(_appUpdatePollTimer);
|
||||
_appUpdatePollTimer = null;
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[updater] setChannel(linux) threw:', e);
|
||||
}
|
||||
return;
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
// Inform main of the persisted channel on each load. setChannel() on
|
||||
// main is idempotent when the channel already matches.
|
||||
try {
|
||||
void Promise.resolve(updateApi.setChannel(stored)).catch((e) => {
|
||||
console.warn('[updater] setChannel(initial) failed:', e);
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[updater] setChannel(initial) threw:', e);
|
||||
// Inform main of the persisted channel — but ONLY the first time this page
|
||||
// wires up, not on every loadSettings() re-render. This used to run
|
||||
// unconditionally on every call and was caught (via Export Diagnostics)
|
||||
// stomping an in-flight check/download: a redundant setChannel() call
|
||||
// mid-download bumps main's checkGeneration and resets progress state,
|
||||
// so the download silently loses its ability to report completion even
|
||||
// though the file swap itself still happens in the background. Once
|
||||
// wired, the channel select's own 'change' handler is the only thing
|
||||
// that needs to tell main about a channel switch.
|
||||
if (!_appUpdatesWired) {
|
||||
try {
|
||||
// Render from THIS call's own result (same reasoning as the check
|
||||
// button and the 'change' handler below), not just catch its
|
||||
// errors. The unconditional renderStatus() at the bottom of this
|
||||
// function fires a SEPARATE getStatus() round-trip immediately
|
||||
// after — if that resolves before main has processed this
|
||||
// setChannel() (e.g. main is still on its 'stable' boot default),
|
||||
// the UI would render 'unsupported' and — since this call's own
|
||||
// eventual success was never rendered — get stuck there
|
||||
// permanently, even once main correctly switches channel a moment
|
||||
// later. Rendering here too means whichever of the two calls
|
||||
// resolves LAST wins and shows the true state, regardless of
|
||||
// which order they land in.
|
||||
void Promise.resolve(updateApi.setChannel(stored)).then((result) => {
|
||||
_appUpdateAckedChannel = stored;
|
||||
renderFrom(result);
|
||||
}).catch((e) => {
|
||||
console.warn('[updater] setChannel(initial) failed:', e);
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[updater] setChannel(initial) threw:', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!_appUpdatesWired) {
|
||||
@ -326,56 +457,82 @@ export function setupAppUpdates() {
|
||||
channelSelect.addEventListener('change', async () => {
|
||||
const val = channelSelect.value;
|
||||
if (!APP_UPDATE_CHANNELS.includes(val)) return;
|
||||
try { localStorage.setItem('feedBack-update-channel', val); localStorage.removeItem('slopsmith-update-channel'); } catch (_) {}
|
||||
console.log('[update-diag] user switched channel to', val);
|
||||
try {
|
||||
// Await setChannel so the status line reflects what actually
|
||||
// happened — rendering "Channel set" unconditionally would
|
||||
// mislead users when the IPC rejects.
|
||||
await Promise.resolve(updateApi.setChannel(val));
|
||||
renderStatus(`Channel set to ${val}.`);
|
||||
// Render from setChannel()'s own return value (same reasoning
|
||||
// as the check button: it's computed synchronously at the
|
||||
// moment of the switch, so it can't be stale, unlike a
|
||||
// follow-up getStatus() call).
|
||||
const result = await Promise.resolve(updateApi.setChannel(val));
|
||||
// Only persist once main has actually acknowledged the switch —
|
||||
// a failed setChannel() must never leave localStorage (or the
|
||||
// dropdown) ahead of what main is really using.
|
||||
_appUpdateAckedChannel = val;
|
||||
try { localStorage.setItem('feedBack-update-channel', val); localStorage.removeItem('slopsmith-update-channel'); } catch (_) {}
|
||||
renderFrom(result, `Channel set to ${val}.`);
|
||||
} catch (e) {
|
||||
console.warn('[updater] setChannel failed:', e);
|
||||
channelSelect.value = _appUpdateAckedChannel ?? 'stable';
|
||||
renderStatus(`Failed to set channel to ${val}: ${e?.message || e}`);
|
||||
}
|
||||
});
|
||||
|
||||
checkBtn.addEventListener('click', async () => {
|
||||
// In restart mode (set by renderFrom once an update is staged) the
|
||||
// button applies the update instead of checking again.
|
||||
if (checkBtn.dataset.mode === 'restart') {
|
||||
console.log('[update-diag] user clicked Restart now');
|
||||
checkBtn.disabled = true;
|
||||
checkBtn.textContent = 'Restarting…';
|
||||
try {
|
||||
const r = await updateApi.apply();
|
||||
if (r?.status === 'error') {
|
||||
console.warn('[updater] apply returned error:', r.message || 'unknown');
|
||||
renderFrom(r, 'Restart failed.');
|
||||
}
|
||||
// On success the app quits + relaunches — nothing to render.
|
||||
} catch (e) {
|
||||
console.warn('[updater] apply failed:', e);
|
||||
statusEl.textContent = `Restart failed: ${e?.message || e}`;
|
||||
checkBtn.textContent = 'Restart now';
|
||||
checkBtn.disabled = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
console.log('[update-diag] user clicked Check for updates');
|
||||
checkBtn.disabled = true;
|
||||
statusEl.textContent = 'Checking for updates…';
|
||||
let reEnableBtn = true;
|
||||
let result;
|
||||
try {
|
||||
const result = await updateApi.checkNow();
|
||||
const status = result?.status || 'unknown';
|
||||
let msg;
|
||||
switch (status) {
|
||||
case 'idle':
|
||||
msg = "You're on the newest version in this channel.";
|
||||
break;
|
||||
case 'downloading':
|
||||
msg = 'Update available — downloading…';
|
||||
break;
|
||||
case 'downloaded':
|
||||
msg = 'Update downloaded — restart to apply.';
|
||||
break;
|
||||
case 'unsupported':
|
||||
reEnableBtn = false;
|
||||
showLinuxFallback('Auto-update is not available on Linux.');
|
||||
return;
|
||||
case 'error':
|
||||
msg = `Update check failed${result?.message ? `: ${result.message}` : '.'}`;
|
||||
break;
|
||||
default:
|
||||
msg = `Update check returned: ${status}`;
|
||||
}
|
||||
renderStatus(msg);
|
||||
// The Linux check returns immediately (any download runs in the
|
||||
// background).
|
||||
result = await updateApi.checkNow();
|
||||
} catch (e) {
|
||||
console.warn('[updater] checkNow failed:', e);
|
||||
statusEl.textContent = `Update check failed: ${e?.message || e}`;
|
||||
} finally {
|
||||
if (reEnableBtn) checkBtn.disabled = false;
|
||||
checkBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
// Render straight from checkNow()'s own return value rather than a
|
||||
// follow-up getStatus() call. checkNow() computes that value
|
||||
// synchronously at the moment it decides the outcome, so it can't
|
||||
// be stale; a separate getStatus() round-trip right after it can
|
||||
// race with anything that resets state in between (a concurrent
|
||||
// channel switch, another in-flight check settling) and show a
|
||||
// blanked "up to date · last checked never" even though this check
|
||||
// just succeeded.
|
||||
renderFrom(result);
|
||||
});
|
||||
|
||||
// Main-process events (checkNow/download decisions in update-manager.ts)
|
||||
// are invisible to this page's console — forward them into it so a
|
||||
// single "Export Diagnostics" click captures both sides of the story.
|
||||
if (typeof updateApi.onDiag === 'function') {
|
||||
updateApi.onDiag((payload) => {
|
||||
console.log('[update-diag:main]', payload?.message, payload?.data ? JSON.stringify(payload.data) : '');
|
||||
});
|
||||
}
|
||||
|
||||
_appUpdatesWired = true;
|
||||
}
|
||||
|
||||
|
||||
2
static/tailwind.min.css
vendored
2
static/tailwind.min.css
vendored
File diff suppressed because one or more lines are too long
@ -741,6 +741,7 @@
|
||||
<option value="rc">Release candidate</option>
|
||||
<option value="beta">Beta</option>
|
||||
<option value="alpha">Alpha</option>
|
||||
<option value="nightly">Nightly</option>
|
||||
</select>
|
||||
<button id="app-update-check-now"
|
||||
class="bg-accent hover:bg-accent-light px-4 py-2.5 rounded-xl text-sm font-medium text-white transition disabled:opacity-50">
|
||||
@ -749,8 +750,8 @@
|
||||
</div>
|
||||
<p id="app-update-status" class="text-xs text-gray-500 fb-srow-wide">Loading updater status…</p>
|
||||
<p id="app-update-linux-note" class="hidden text-xs text-yellow-300 fb-srow-wide">
|
||||
Auto-update is not available on Linux —
|
||||
<a href="https://github.com/got-feedback/feedback-desktop/releases" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">download new versions from GitHub Releases</a>.
|
||||
Auto-update on Linux only works for the AppImage build on the Nightly channel —
|
||||
<a href="https://github.com/got-feedBack/feedBack-desktop/releases" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">download other versions from GitHub Releases</a>.
|
||||
</p>
|
||||
</div>
|
||||
<!-- Window options — desktop-only; setupWindowOptions() unhides. -->
|
||||
|
||||
Loading…
Reference in New Issue
Block a user