feat(dashboard): add generic desktop app updater bridge (#5424)

* feat(dashboard): add generic desktop app updater bridge

* fix(dashboard): address updater bridge review feedback

* fix(dashboard): unify updater bridge types and error logging

* fix(dashboard): consolidate updater bridge typings
This commit is contained in:
エイカク
2026-02-25 10:01:13 +09:00
committed by GitHub
parent c24de24ca4
commit 5530a2260a
4 changed files with 149 additions and 74 deletions
@@ -58,6 +58,18 @@
"guideStep2": "Install it and restart AstrBot.",
"guideStep3": "If you use Docker, prefer the image update path."
},
"desktopApp": {
"title": "Update Desktop App",
"message": "Check and upgrade the AstrBot desktop application.",
"currentVersion": "Current version: ",
"latestVersion": "Latest version: ",
"checking": "Checking desktop app updates...",
"hasNewVersion": "A new version is available. Click confirm to upgrade.",
"isLatest": "Already on the latest version",
"installing": "Downloading and installing update. The app will restart automatically...",
"checkFailed": "Failed to check updates. Please try again later.",
"installFailed": "Upgrade failed. Please try again later."
},
"dashboardUpdate": {
"title": "Update Dashboard to Latest Version Only",
"currentVersion": "Current Version",
@@ -58,6 +58,18 @@
"guideStep2": "完成安装后重启 AstrBot。",
"guideStep3": "如果你使用 Docker,请优先使用镜像更新方式。"
},
"desktopApp": {
"title": "更新桌面应用",
"message": "将检查并升级 AstrBot 桌面端程序。",
"currentVersion": "当前版本:",
"latestVersion": "最新版本:",
"checking": "正在检查桌面应用更新...",
"hasNewVersion": "发现新版本,可点击确认升级。",
"isLatest": "已经是最新版本",
"installing": "正在下载并安装更新,完成后将自动重启应用...",
"checkFailed": "检查更新失败,请稍后重试。",
"installFailed": "升级失败,请稍后重试。"
},
"dashboardUpdate": {
"title": "单独更新管理面板到最新版本",
"currentVersion": "当前版本",
@@ -50,24 +50,27 @@ let installLoading = ref(false);
const isDesktopReleaseMode = ref(
typeof window !== 'undefined' && !!window.astrbotDesktop?.isDesktop
);
const redirectConfirmDialog = ref(false);
const pendingRedirectUrl = ref('');
const resolvingReleaseTarget = ref(false);
const DEFAULT_ASTRBOT_RELEASE_BASE_URL = 'https://github.com/AstrBotDevs/AstrBot/releases';
const resolveReleaseBaseUrl = () => {
const raw = import.meta.env.VITE_ASTRBOT_RELEASE_BASE_URL;
// Keep upstream default on AstrBot releases; desktop distributors can override via env injection.
const normalized = raw?.trim()?.replace(/\/+$/, '') || '';
const withoutLatestSuffix = normalized.replace(/\/latest$/i, '');
return withoutLatestSuffix || DEFAULT_ASTRBOT_RELEASE_BASE_URL;
};
const releaseBaseUrl = resolveReleaseBaseUrl();
const getReleaseUrlByTag = (tag: string | null | undefined) => {
const normalizedTag = (tag || '').trim();
if (!normalizedTag || normalizedTag.toLowerCase() === 'latest') {
return `${releaseBaseUrl}/latest`;
const desktopUpdateDialog = ref(false);
const desktopUpdateChecking = ref(false);
const desktopUpdateInstalling = ref(false);
const desktopUpdateHasNewVersion = ref(false);
const desktopUpdateCurrentVersion = ref('-');
const desktopUpdateLatestVersion = ref('-');
const desktopUpdateStatus = ref('');
const getAppUpdaterBridge = (): AstrBotAppUpdaterBridge | null => {
if (typeof window === 'undefined') {
return null;
}
return `${releaseBaseUrl}/tag/${normalizedTag}`;
const bridge = window.astrbotAppUpdater;
if (
bridge &&
typeof bridge.checkForAppUpdate === 'function' &&
typeof bridge.installAppUpdate === 'function'
) {
return bridge;
}
return null;
};
const getSelectedGitHubProxy = () => {
@@ -89,16 +92,6 @@ const releasesHeader = computed(() => [
{ title: t('core.header.updateDialog.table.sourceUrl'), key: 'zipball_url' },
{ title: t('core.header.updateDialog.table.actions'), key: 'switch' }
]);
const latestReleaseTag = computed(() => {
const firstRelease = (releases.value as any[])?.[0];
if (firstRelease?.tag_name) {
return firstRelease.tag_name as string;
}
return hasNewVersion.value
? t('core.header.updateDialog.redirectConfirm.latestLabel')
: (botCurrVersion.value || '-');
});
// Form validation
const formValid = ref(true);
const passwordRules = computed(() => [
@@ -126,47 +119,88 @@ const accountEditStatus = ref({
message: ''
});
const open = (link: string) => {
window.open(link, '_blank');
};
function requestExternalRedirect(link: string) {
pendingRedirectUrl.value = link;
redirectConfirmDialog.value = true;
function cancelDesktopUpdate() {
if (desktopUpdateInstalling.value) {
return;
}
desktopUpdateDialog.value = false;
}
function cancelExternalRedirect() {
redirectConfirmDialog.value = false;
pendingRedirectUrl.value = '';
}
async function openDesktopUpdateDialog() {
desktopUpdateDialog.value = true;
desktopUpdateChecking.value = true;
desktopUpdateInstalling.value = false;
desktopUpdateHasNewVersion.value = false;
desktopUpdateCurrentVersion.value = '-';
desktopUpdateLatestVersion.value = '-';
desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.checking');
function confirmExternalRedirect() {
const targetUrl = pendingRedirectUrl.value;
cancelExternalRedirect();
if (targetUrl) {
open(targetUrl);
const bridge = getAppUpdaterBridge();
if (!bridge) {
desktopUpdateChecking.value = false;
desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.checkFailed');
return;
}
try {
const result = await bridge.checkForAppUpdate();
if (!result?.ok) {
desktopUpdateCurrentVersion.value = result?.currentVersion || '-';
desktopUpdateLatestVersion.value =
result?.latestVersion || result?.currentVersion || '-';
desktopUpdateStatus.value =
result?.reason || t('core.header.updateDialog.desktopApp.checkFailed');
return;
}
desktopUpdateCurrentVersion.value = result.currentVersion || '-';
desktopUpdateLatestVersion.value =
result.latestVersion || result.currentVersion || '-';
desktopUpdateHasNewVersion.value = !!result.hasUpdate;
desktopUpdateStatus.value = result.hasUpdate
? t('core.header.updateDialog.desktopApp.hasNewVersion')
: t('core.header.updateDialog.desktopApp.isLatest');
} catch (error) {
console.error(error);
desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.checkFailed');
} finally {
desktopUpdateChecking.value = false;
}
}
const getReleaseUrlForDesktop = () => {
const firstRelease = (releases.value as any[])?.[0];
if (firstRelease?.tag_name) {
return getReleaseUrlByTag(firstRelease.tag_name as string);
async function confirmDesktopUpdate() {
if (!desktopUpdateHasNewVersion.value || desktopUpdateInstalling.value) {
return;
}
if (hasNewVersion.value) return getReleaseUrlByTag('latest');
const tag = botCurrVersion.value?.startsWith('v') ? botCurrVersion.value : 'latest';
return getReleaseUrlByTag(tag);
};
const bridge = getAppUpdaterBridge();
if (!bridge) {
desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.installFailed');
return;
}
desktopUpdateInstalling.value = true;
desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.installing');
try {
const result = await bridge.installAppUpdate();
if (result?.ok) {
desktopUpdateDialog.value = false;
return;
}
desktopUpdateStatus.value =
result?.reason || t('core.header.updateDialog.desktopApp.installFailed');
} catch (error) {
console.error(error);
desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.installFailed');
} finally {
desktopUpdateInstalling.value = false;
}
}
function handleUpdateClick() {
if (isDesktopReleaseMode.value) {
requestExternalRedirect('');
resolvingReleaseTarget.value = true;
checkUpdate();
void getReleases().finally(() => {
pendingRedirectUrl.value = getReleaseUrlForDesktop() || getReleaseUrlByTag('latest');
resolvingReleaseTarget.value = false;
});
void openDesktopUpdateDialog();
return;
}
checkUpdate();
@@ -680,40 +714,38 @@ onMounted(async () => {
</v-card>
</v-dialog>
<v-dialog v-model="redirectConfirmDialog" max-width="460">
<v-dialog v-model="desktopUpdateDialog" max-width="460">
<v-card>
<v-card-title class="text-h3 pa-4 pl-6 pb-0">
{{ t('core.header.updateDialog.redirectConfirm.title') }}
{{ t('core.header.updateDialog.desktopApp.title') }}
</v-card-title>
<v-card-text>
<div class="mb-3">
{{ t('core.header.updateDialog.redirectConfirm.message') }}
{{ t('core.header.updateDialog.desktopApp.message') }}
</div>
<v-alert type="info" variant="tonal" density="compact">
<div>
{{ t('core.header.updateDialog.redirectConfirm.targetVersion') }}
<strong v-if="!resolvingReleaseTarget">{{ latestReleaseTag }}</strong>
<v-progress-circular v-else indeterminate size="16" width="2" class="ml-1" />
{{ t('core.header.updateDialog.desktopApp.currentVersion') }}
<strong>{{ desktopUpdateCurrentVersion }}</strong>
</div>
<div class="text-caption">
{{ t('core.header.updateDialog.redirectConfirm.currentVersion') }}
{{ botCurrVersion || '-' }}
<div>
{{ t('core.header.updateDialog.desktopApp.latestVersion') }}
<strong v-if="!desktopUpdateChecking">{{ desktopUpdateLatestVersion }}</strong>
<v-progress-circular v-else indeterminate size="16" width="2" class="ml-1" />
</div>
</v-alert>
<div class="text-caption mt-3">
<div>{{ t('core.header.updateDialog.redirectConfirm.guideTitle') }}</div>
<div>1. {{ t('core.header.updateDialog.redirectConfirm.guideStep1') }}</div>
<div>2. {{ t('core.header.updateDialog.redirectConfirm.guideStep2') }}</div>
<div>3. {{ t('core.header.updateDialog.redirectConfirm.guideStep3') }}</div>
{{ desktopUpdateStatus }}
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" variant="text" @click="cancelExternalRedirect">
<v-btn color="grey" variant="text" @click="cancelDesktopUpdate" :disabled="desktopUpdateInstalling">
{{ t('core.common.dialog.cancelButton') }}
</v-btn>
<v-btn color="primary" variant="flat" @click="confirmExternalRedirect"
:loading="resolvingReleaseTarget" :disabled="resolvingReleaseTarget || !pendingRedirectUrl">
<v-btn color="primary" variant="flat" @click="confirmDesktopUpdate"
:loading="desktopUpdateInstalling"
:disabled="desktopUpdateChecking || desktopUpdateInstalling || !desktopUpdateHasNewVersion">
{{ t('core.common.dialog.confirmButton') }}
</v-btn>
</v-card-actions>
@@ -1,7 +1,26 @@
export {};
declare global {
interface AstrBotDesktopAppUpdateCheckResult {
ok: boolean;
reason?: string | null;
currentVersion?: string;
latestVersion?: string | null;
hasUpdate: boolean;
}
interface AstrBotDesktopAppUpdateResult {
ok: boolean;
reason?: string | null;
}
interface AstrBotAppUpdaterBridge {
checkForAppUpdate: () => Promise<AstrBotDesktopAppUpdateCheckResult>;
installAppUpdate: () => Promise<AstrBotDesktopAppUpdateResult>;
}
interface Window {
astrbotAppUpdater?: AstrBotAppUpdaterBridge;
astrbotDesktop?: {
isDesktop: boolean;
isDesktopRuntime: () => Promise<boolean>;