mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
feat(desktop): bundled renderer, modules and instance accounts (#3254)
This commit is contained in:
1 parent
c748c8af4e
commit
7c5fa2180a
1275 files changed
+215074
-81246
No files matched your search
@@ -0,0 +1,510 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
const {ipcRenderer} = require('electron');
|
||||
|
||||
const SPLASH_STATE_CHANNEL = 'desktop-splash:state';
|
||||
const SPLASH_READY_CHANNEL = 'desktop-splash:ready';
|
||||
const SPLASH_RETRY_CHANNEL = 'desktop-splash:retry-now';
|
||||
const SPLASH_QUIT_CHANNEL = 'desktop-splash:quit';
|
||||
const SPLASH_OPEN_DOWNLOAD_CHANNEL = 'desktop-splash:open-download';
|
||||
const SPLASH_NETWORK_ONLINE_CHANNEL = 'desktop-splash:network-online';
|
||||
|
||||
const SPLASH_MOUNT_ID = 'splash-mount';
|
||||
const SPLASH_MARK_SIZE = 88;
|
||||
const SPLASH_MARK_SOURCE = './symbol.svg';
|
||||
const COUNTDOWN_INTERVAL_MS = 1000;
|
||||
const SPLASH_BRANDING_WAIT_MS = 400;
|
||||
const SPLASH_FONT_FACES = Object.freeze(['400 12px "Fluxer Sans"', '500 16px "Fluxer Sans"']);
|
||||
const NON_BREAKING_SPACE = '\u00a0';
|
||||
const SPLASH_ACTION_LABEL_MAX_LENGTH = 48;
|
||||
const SPLASH_MESSAGE_MAX_LENGTH = 160;
|
||||
const SPLASH_VERSION_LABEL_MAX_LENGTH = 64;
|
||||
const SPLASH_OPTION_LABEL_MAX_LENGTH = 48;
|
||||
const SPLASH_OPTION_BUTTON_LABEL_MAX_LENGTH = 24;
|
||||
const SPLASH_OPTION_MAX_COUNT = 8;
|
||||
const SPLASH_OPTION_VALUE_PATTERN = /^[a-z0-9_]{1,32}$/;
|
||||
|
||||
const SplashLayout = Object.freeze({
|
||||
SPLASH: 'splash',
|
||||
MANUAL_UPDATE: 'manual-update',
|
||||
});
|
||||
|
||||
const SplashStatus = Object.freeze({
|
||||
CHECKING_FOR_UPDATES: 'checking-for-updates',
|
||||
DOWNLOADING_UPDATES: 'downloading-updates',
|
||||
INSTALLING_UPDATES: 'installing-updates',
|
||||
VERIFYING: 'verifying',
|
||||
UPDATE_FAILURE: 'update-failure',
|
||||
SHELL_UPDATE_DOWNLOADING: 'shell-update-downloading',
|
||||
SHELL_UPDATE_RESTARTING: 'shell-update-restarting',
|
||||
BLOCKED_UPDATE_REQUIRED: 'blocked-update-required',
|
||||
BLOCKED_UPDATE_UNREACHABLE: 'blocked-update-unreachable',
|
||||
BLOCKED_SECURITY_UPDATE_REQUIRED: 'blocked-security-update-required',
|
||||
BLOCKED_SECURITY_UPDATE_MANAGED: 'blocked-security-update-managed',
|
||||
BLOCKED_SHELL_UPDATE: 'blocked-shell-update',
|
||||
BLOCKED_SHELL_UPDATE_MANAGED: 'blocked-shell-update-managed',
|
||||
BLOCKED_UNSUPPORTED_BUILD: 'blocked-unsupported-build',
|
||||
LAUNCHING: 'launching',
|
||||
UNREACHABLE_LAUNCH: 'unreachable-launch',
|
||||
WAITING_LOCAL_NETWORK: 'waiting-local-network',
|
||||
});
|
||||
|
||||
const KNOWN_LAYOUTS = new Set(Object.values(SplashLayout));
|
||||
const KNOWN_STATUSES = new Set(Object.values(SplashStatus));
|
||||
const PROGRESS_STATUSES = new Set([
|
||||
SplashStatus.DOWNLOADING_UPDATES,
|
||||
SplashStatus.INSTALLING_UPDATES,
|
||||
SplashStatus.SHELL_UPDATE_DOWNLOADING,
|
||||
]);
|
||||
const ACTION_CHANNELS = new Map([
|
||||
['retry', SPLASH_RETRY_CHANNEL],
|
||||
['download', SPLASH_OPEN_DOWNLOAD_CHANNEL],
|
||||
['quit', SPLASH_QUIT_CHANNEL],
|
||||
]);
|
||||
const SPLASH_FALLBACK_ACTION = Object.freeze({kind: 'quit', label: 'Quit'});
|
||||
|
||||
let splashState = null;
|
||||
let renderedLayout = null;
|
||||
let renderedManualSignature = null;
|
||||
let countdownTimer = null;
|
||||
let selectedOption = null;
|
||||
|
||||
function toCount(value) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
|
||||
return Math.max(0, Math.trunc(value));
|
||||
}
|
||||
|
||||
function toText(value, maxLength) {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) return null;
|
||||
return trimmed.slice(0, maxLength);
|
||||
}
|
||||
|
||||
function normalizeAction(value) {
|
||||
if (value == null || typeof value !== 'object') return null;
|
||||
if (typeof value.kind !== 'string' || !ACTION_CHANNELS.has(value.kind)) return null;
|
||||
const label = toText(value.label, SPLASH_ACTION_LABEL_MAX_LENGTH);
|
||||
if (label == null) return null;
|
||||
return {kind: value.kind, label};
|
||||
}
|
||||
|
||||
function normalizeOption(value) {
|
||||
if (value == null || typeof value !== 'object') return null;
|
||||
if (typeof value.value !== 'string' || !SPLASH_OPTION_VALUE_PATTERN.test(value.value)) return null;
|
||||
if (typeof value.kind !== 'string' || !ACTION_CHANNELS.has(value.kind)) return null;
|
||||
const label = toText(value.label, SPLASH_OPTION_LABEL_MAX_LENGTH);
|
||||
if (label == null) return null;
|
||||
const buttonLabel = toText(value.buttonLabel, SPLASH_OPTION_BUTTON_LABEL_MAX_LENGTH);
|
||||
if (buttonLabel == null) return null;
|
||||
return {value: value.value, label, buttonLabel, kind: value.kind};
|
||||
}
|
||||
|
||||
function normalizeOptions(value) {
|
||||
if (!Array.isArray(value)) return null;
|
||||
const accepted = [];
|
||||
for (const entry of value.slice(0, SPLASH_OPTION_MAX_COUNT)) {
|
||||
const option = normalizeOption(entry);
|
||||
if (option != null) {
|
||||
accepted.push(option);
|
||||
}
|
||||
}
|
||||
return accepted.length === 0 ? null : accepted;
|
||||
}
|
||||
|
||||
function withManualUpdateAffordance(state) {
|
||||
if (state.layout !== SplashLayout.MANUAL_UPDATE) return state;
|
||||
if (state.options != null || state.action != null) return state;
|
||||
return {...state, action: SPLASH_FALLBACK_ACTION};
|
||||
}
|
||||
|
||||
function normalizeState(payload) {
|
||||
if (payload == null || typeof payload !== 'object') {
|
||||
return {
|
||||
layout: SplashLayout.SPLASH,
|
||||
status: SplashStatus.CHECKING_FOR_UPDATES,
|
||||
requiredSecurityUpdate: false,
|
||||
current: null,
|
||||
total: null,
|
||||
progress: null,
|
||||
seconds: null,
|
||||
action: null,
|
||||
message: null,
|
||||
versionLabel: null,
|
||||
options: null,
|
||||
};
|
||||
}
|
||||
return withManualUpdateAffordance({
|
||||
layout: KNOWN_LAYOUTS.has(payload.layout) ? payload.layout : SplashLayout.SPLASH,
|
||||
status: KNOWN_STATUSES.has(payload.status) ? payload.status : SplashStatus.CHECKING_FOR_UPDATES,
|
||||
requiredSecurityUpdate: payload.requiredSecurityUpdate === true,
|
||||
current: toCount(payload.current),
|
||||
total: toCount(payload.total),
|
||||
progress: typeof payload.progress === 'number' && Number.isFinite(payload.progress) ? payload.progress : null,
|
||||
seconds: toCount(payload.seconds),
|
||||
action: normalizeAction(payload.action),
|
||||
message: toText(payload.message, SPLASH_MESSAGE_MAX_LENGTH),
|
||||
versionLabel: toText(payload.versionLabel, SPLASH_VERSION_LABEL_MAX_LENGTH),
|
||||
options: normalizeOptions(payload.options),
|
||||
});
|
||||
}
|
||||
|
||||
function getStatusText(state) {
|
||||
switch (state.status) {
|
||||
case SplashStatus.DOWNLOADING_UPDATES:
|
||||
return state.requiredSecurityUpdate
|
||||
? `Downloading required security update ${state.current ?? 0} of ${state.total ?? 0}…`
|
||||
: `Downloading update ${state.current ?? 0} of ${state.total ?? 0}…`;
|
||||
case SplashStatus.INSTALLING_UPDATES:
|
||||
return state.requiredSecurityUpdate
|
||||
? `Installing required security update ${state.current ?? 0} of ${state.total ?? 0}…`
|
||||
: `Installing update ${state.current ?? 0} of ${state.total ?? 0}…`;
|
||||
case SplashStatus.VERIFYING:
|
||||
return state.requiredSecurityUpdate ? 'Verifying required security update…' : 'Verifying files…';
|
||||
case SplashStatus.UPDATE_FAILURE:
|
||||
return state.requiredSecurityUpdate
|
||||
? `Required security update failed. Retrying in ${state.seconds ?? 0} sec…`
|
||||
: `Update failed. Retrying in ${state.seconds ?? 0} sec…`;
|
||||
case SplashStatus.SHELL_UPDATE_DOWNLOADING:
|
||||
return state.requiredSecurityUpdate ? 'Downloading required security update…' : 'Updating Fluxer…';
|
||||
case SplashStatus.SHELL_UPDATE_RESTARTING:
|
||||
return state.requiredSecurityUpdate
|
||||
? 'Restarting to finish the required security update…'
|
||||
: 'Restarting to finish the update…';
|
||||
case SplashStatus.BLOCKED_UPDATE_REQUIRED:
|
||||
return state.requiredSecurityUpdate
|
||||
? 'A required security update is needed to continue'
|
||||
: 'Update required to continue';
|
||||
case SplashStatus.BLOCKED_UPDATE_UNREACHABLE:
|
||||
return "Can't reach the Fluxer update server";
|
||||
case SplashStatus.BLOCKED_SECURITY_UPDATE_REQUIRED:
|
||||
return 'A required security update is needed to continue';
|
||||
case SplashStatus.BLOCKED_SECURITY_UPDATE_MANAGED:
|
||||
return 'Install the required security update through your package manager';
|
||||
case SplashStatus.BLOCKED_SHELL_UPDATE:
|
||||
return state.requiredSecurityUpdate
|
||||
? 'A required security update is needed to continue'
|
||||
: 'A new version of Fluxer is required';
|
||||
case SplashStatus.BLOCKED_SHELL_UPDATE_MANAGED:
|
||||
return 'Update Fluxer through your package manager';
|
||||
case SplashStatus.BLOCKED_UNSUPPORTED_BUILD:
|
||||
return 'This installation is incomplete. Reinstall Fluxer';
|
||||
case SplashStatus.LAUNCHING:
|
||||
return 'Starting…';
|
||||
case SplashStatus.UNREACHABLE_LAUNCH:
|
||||
return 'Starting offline…';
|
||||
case SplashStatus.WAITING_LOCAL_NETWORK:
|
||||
return 'Waiting for local network permission…';
|
||||
default:
|
||||
return state.requiredSecurityUpdate ? 'Checking for required security update…' : 'Checking for updates…';
|
||||
}
|
||||
}
|
||||
|
||||
function shouldShowProgress(state) {
|
||||
return state.progress !== null && PROGRESS_STATUSES.has(state.status);
|
||||
}
|
||||
|
||||
function toProgressPercent(progress) {
|
||||
if (progress === null) return 0;
|
||||
return Math.min(100, Math.max(0, progress));
|
||||
}
|
||||
|
||||
function createNode(tagName, className) {
|
||||
const node = document.createElement(tagName);
|
||||
if (className != null) {
|
||||
node.className = className;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function createMark(className) {
|
||||
const mark = createNode('img', className);
|
||||
mark.setAttribute('src', SPLASH_MARK_SOURCE);
|
||||
mark.setAttribute('alt', '');
|
||||
mark.setAttribute('width', String(SPLASH_MARK_SIZE));
|
||||
mark.setAttribute('height', String(SPLASH_MARK_SIZE));
|
||||
return mark;
|
||||
}
|
||||
|
||||
function createProgress(percent) {
|
||||
const progress = createNode('div', 'progress');
|
||||
const bar = createNode('div', 'progress-bar');
|
||||
const complete = createNode('div', 'complete');
|
||||
complete.style.width = `${percent}%`;
|
||||
bar.appendChild(complete);
|
||||
progress.appendChild(bar);
|
||||
return progress;
|
||||
}
|
||||
|
||||
function createProgressPlaceholder() {
|
||||
const placeholder = createNode('div', 'progress-placeholder');
|
||||
placeholder.textContent = NON_BREAKING_SPACE;
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
function sendSplashAction(kind, value) {
|
||||
const channel = ACTION_CHANNELS.get(kind);
|
||||
if (channel == null) return;
|
||||
if (value === undefined) {
|
||||
ipcRenderer.send(channel);
|
||||
return;
|
||||
}
|
||||
ipcRenderer.send(channel, value);
|
||||
}
|
||||
|
||||
function onSplashActionClick() {
|
||||
if (splashState == null || splashState.action == null) return;
|
||||
sendSplashAction(splashState.action.kind, undefined);
|
||||
}
|
||||
|
||||
function buildSplashLayout(mount) {
|
||||
const splash = createNode('div');
|
||||
splash.id = 'splash';
|
||||
const inner = createNode('div', 'splash-inner');
|
||||
const media = createNode('div', 'splash-media');
|
||||
media.appendChild(createMark('splash-mark'));
|
||||
const text = createNode('div', 'splash-text');
|
||||
text.appendChild(createNode('span', 'splash-status'));
|
||||
text.appendChild(createNode('span', 'splash-detail'));
|
||||
text.appendChild(createProgressPlaceholder());
|
||||
text.appendChild(createNode('div', 'splash-action-slot'));
|
||||
inner.appendChild(media);
|
||||
inner.appendChild(text);
|
||||
splash.appendChild(inner);
|
||||
mount.replaceChildren(splash);
|
||||
}
|
||||
|
||||
function reconcileProgress(mount, state) {
|
||||
const existing = mount.querySelector('.progress, .progress-placeholder');
|
||||
if (existing == null) return;
|
||||
if (shouldShowProgress(state)) {
|
||||
const percent = toProgressPercent(state.progress);
|
||||
const complete = existing.querySelector('.complete');
|
||||
if (complete != null) {
|
||||
complete.style.width = `${percent}%`;
|
||||
return;
|
||||
}
|
||||
existing.replaceWith(createProgress(percent));
|
||||
return;
|
||||
}
|
||||
if (existing.classList.contains('progress-placeholder')) return;
|
||||
existing.replaceWith(createProgressPlaceholder());
|
||||
}
|
||||
|
||||
function reconcileAction(mount, state) {
|
||||
const slot = mount.querySelector('.splash-action-slot');
|
||||
if (slot == null) return;
|
||||
const existing = slot.querySelector('.splash-action');
|
||||
if (state.action == null) {
|
||||
if (existing != null) {
|
||||
slot.replaceChildren();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (existing == null) {
|
||||
const button = createNode('button', 'splash-action');
|
||||
button.textContent = state.action.label;
|
||||
button.addEventListener('click', onSplashActionClick);
|
||||
slot.appendChild(button);
|
||||
return;
|
||||
}
|
||||
existing.textContent = state.action.label;
|
||||
}
|
||||
|
||||
function findSelectedOption() {
|
||||
if (splashState == null || splashState.options == null) return null;
|
||||
return splashState.options.find((option) => option.value === selectedOption) ?? null;
|
||||
}
|
||||
|
||||
function manualUpdateSignature(state) {
|
||||
return JSON.stringify([state.action, state.options]);
|
||||
}
|
||||
|
||||
function patchManualUpdateText(mount, state) {
|
||||
const message = mount.querySelector('.dl-update-message');
|
||||
if (message != null) {
|
||||
message.textContent = state.message ?? '';
|
||||
}
|
||||
const version = mount.querySelector('.dl-version-message');
|
||||
if (version != null) {
|
||||
version.textContent = state.versionLabel ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
function buildManualUpdateLayout(mount, state) {
|
||||
const splash = createNode('div');
|
||||
splash.id = 'splash';
|
||||
const inner = createNode('div', 'splash-inner-dl');
|
||||
const markBox = createNode('div', 'splash-mark-dl');
|
||||
markBox.appendChild(createMark(null));
|
||||
const message = createNode('div', 'dl-update-message');
|
||||
message.textContent = state.message ?? '';
|
||||
const version = createNode('div', 'dl-version-message');
|
||||
version.textContent = state.versionLabel ?? '';
|
||||
inner.appendChild(markBox);
|
||||
inner.appendChild(message);
|
||||
inner.appendChild(buildManualUpdateControl(state));
|
||||
inner.appendChild(version);
|
||||
splash.appendChild(inner);
|
||||
mount.replaceChildren(splash);
|
||||
}
|
||||
|
||||
function buildManualUpdateControl(state) {
|
||||
return state.options == null ? buildManualUpdateAction(state) : buildManualUpdatePicker(state);
|
||||
}
|
||||
|
||||
function buildManualUpdateAction(state) {
|
||||
const frame = createNode('div', 'dl-action-frame');
|
||||
const button = createNode('button', 'dl-button');
|
||||
button.id = 'dl-button';
|
||||
button.textContent = state.action.label;
|
||||
button.addEventListener('click', onSplashActionClick);
|
||||
frame.appendChild(button);
|
||||
return frame;
|
||||
}
|
||||
|
||||
function buildManualUpdatePicker(state) {
|
||||
const frame = createNode('div', 'dl-select-frame');
|
||||
const selectBox = createNode('div', 'dl-select');
|
||||
const select = createNode('select');
|
||||
select.id = 'dl-select-input';
|
||||
for (const option of state.options) {
|
||||
const node = createNode('option');
|
||||
node.value = option.value;
|
||||
node.textContent = option.label;
|
||||
select.appendChild(node);
|
||||
}
|
||||
selectBox.appendChild(select);
|
||||
const button = createNode('button', 'dl-button');
|
||||
button.id = 'dl-button';
|
||||
if (findSelectedOption() == null) {
|
||||
selectedOption = state.options[0].value;
|
||||
}
|
||||
const initial = findSelectedOption();
|
||||
select.value = selectedOption;
|
||||
button.textContent = initial == null ? '' : initial.buttonLabel;
|
||||
select.addEventListener('change', () => {
|
||||
selectedOption = select.value;
|
||||
const option = findSelectedOption();
|
||||
button.textContent = option == null ? '' : option.buttonLabel;
|
||||
});
|
||||
button.addEventListener('click', () => {
|
||||
const option = findSelectedOption();
|
||||
if (option == null) return;
|
||||
sendSplashAction(option.kind, option.value);
|
||||
});
|
||||
frame.appendChild(selectBox);
|
||||
frame.appendChild(button);
|
||||
return frame;
|
||||
}
|
||||
|
||||
function render() {
|
||||
const mount = document.getElementById(SPLASH_MOUNT_ID);
|
||||
if (mount == null) return;
|
||||
const state = splashState ?? normalizeState(null);
|
||||
if (state.layout === SplashLayout.MANUAL_UPDATE) {
|
||||
const signature = manualUpdateSignature(state);
|
||||
if (renderedLayout !== SplashLayout.MANUAL_UPDATE || renderedManualSignature !== signature) {
|
||||
renderedLayout = SplashLayout.MANUAL_UPDATE;
|
||||
renderedManualSignature = signature;
|
||||
buildManualUpdateLayout(mount, state);
|
||||
return;
|
||||
}
|
||||
patchManualUpdateText(mount, state);
|
||||
return;
|
||||
}
|
||||
if (renderedLayout !== SplashLayout.SPLASH) {
|
||||
renderedLayout = SplashLayout.SPLASH;
|
||||
renderedManualSignature = null;
|
||||
buildSplashLayout(mount);
|
||||
}
|
||||
const statusNode = mount.querySelector('.splash-status');
|
||||
if (statusNode != null) {
|
||||
statusNode.textContent = getStatusText(state);
|
||||
}
|
||||
const detailNode = mount.querySelector('.splash-detail');
|
||||
if (detailNode != null) {
|
||||
detailNode.textContent = state.message ?? '';
|
||||
}
|
||||
reconcileProgress(mount, state);
|
||||
reconcileAction(mount, state);
|
||||
}
|
||||
|
||||
function hasCountdown(state) {
|
||||
return state.layout === SplashLayout.SPLASH && state.seconds != null && state.seconds > 0;
|
||||
}
|
||||
|
||||
function stopCountdown() {
|
||||
if (countdownTimer == null) return;
|
||||
clearInterval(countdownTimer);
|
||||
countdownTimer = null;
|
||||
}
|
||||
|
||||
function tickCountdown() {
|
||||
if (splashState == null || !hasCountdown(splashState)) {
|
||||
stopCountdown();
|
||||
return;
|
||||
}
|
||||
splashState = {...splashState, seconds: splashState.seconds - 1};
|
||||
if (!hasCountdown(splashState)) {
|
||||
stopCountdown();
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
function syncCountdown(state) {
|
||||
stopCountdown();
|
||||
if (!hasCountdown(state)) return;
|
||||
countdownTimer = setInterval(tickCountdown, COUNTDOWN_INTERVAL_MS);
|
||||
}
|
||||
|
||||
ipcRenderer.on(SPLASH_STATE_CHANNEL, (_event, payload) => {
|
||||
splashState = normalizeState(payload);
|
||||
syncCountdown(splashState);
|
||||
render();
|
||||
});
|
||||
|
||||
function waitForBranding(mount) {
|
||||
const pending = [];
|
||||
const mark = mount.querySelector('.splash-mark');
|
||||
if (mark != null && typeof mark.decode === 'function') {
|
||||
pending.push(mark.decode().catch(() => undefined));
|
||||
}
|
||||
const fonts = document.fonts;
|
||||
if (fonts != null && typeof fonts.load === 'function') {
|
||||
for (const face of SPLASH_FONT_FACES) {
|
||||
pending.push(fonts.load(face).catch(() => undefined));
|
||||
}
|
||||
}
|
||||
return Promise.race([
|
||||
Promise.all(pending),
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, SPLASH_BRANDING_WAIT_MS);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
function afterNextPaint(callback) {
|
||||
window.requestAnimationFrame(() => {
|
||||
window.requestAnimationFrame(callback);
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
render();
|
||||
const mount = document.getElementById(SPLASH_MOUNT_ID);
|
||||
const branded = mount == null ? Promise.resolve() : waitForBranding(mount);
|
||||
void branded.then(() => {
|
||||
afterNextPaint(() => {
|
||||
ipcRenderer.send(SPLASH_READY_CHANNEL);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('online', () => {
|
||||
ipcRenderer.send(SPLASH_NETWORK_ONLINE_CHANNEL);
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
stopCountdown();
|
||||
});
|
||||
Reference in new issue
Block a user