Files
Starlight_Launcher/apps/app-frontend/src/components/ui/AppActionBar.vue
T
Disy bc904065c3
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
feat: complete hosted mod sync and launcher interface updates
Add pack sync markers, tagged mod updates, parallel progress, JWT downloads and retry recovery. Include pending onboarding, about scene, compatibility data pack and download fixes.
2026-09-15 19:06:56 +08:00

783 lines
22 KiB
Vue

<template>
<div class="flex gap-2 items-center">
<Dropdown
v-model:shown="notificationCenterShown"
placement="bottom-end"
:triggers="['click']"
:hide-triggers="['click']"
>
<ButtonStyled type="transparent" circular>
<button
v-tooltip="formatMessage(messages.notifications)"
:aria-label="formatMessage(messages.notifications)"
class="relative"
>
<BellIcon />
<span
v-if="hasUnreadNotifications"
class="absolute right-0 top-0 size-2 rounded-full bg-red ring-2 ring-bg-raised"
/>
</button>
</ButtonStyled>
<template #popper>
<div class="w-[22rem] max-w-[calc(100vw-2rem)] p-2">
<div class="mb-2 flex items-center justify-between px-2">
<span class="font-semibold text-contrast">{{
formatMessage(messages.notifications)
}}</span>
<button
v-if="notificationHistory.length"
class="text-xs text-secondary hover:text-contrast"
@click="clearNotificationHistory"
>
{{ formatMessage(messages.clearNotifications) }}
</button>
</div>
<div
v-if="!notificationHistory.length"
class="px-2 py-4 text-center text-sm text-secondary"
>
{{ formatMessage(messages.noNotifications) }}
</div>
<div v-else class="flex max-h-[22rem] flex-col gap-1 overflow-auto">
<div
v-for="item in notificationHistory"
:key="item.key"
class="flex items-start gap-2 rounded-lg p-2 hover:bg-button-bg"
>
<div
class="mt-1 size-2 shrink-0 rounded-full"
:class="notificationDotClass(item.type)"
/>
<button class="min-w-0 flex-1 text-left" @click="openNotification(item)">
<div class="truncate text-sm font-medium text-contrast">{{ item.title }}</div>
<div v-if="item.text" class="line-clamp-2 text-xs text-secondary">
{{ item.text }}
</div>
</button>
<button
v-tooltip="formatMessage(messages.dismissNotification)"
class="shrink-0 text-secondary hover:text-contrast"
@click="dismissNotification(item)"
>
<XIcon class="size-4" />
</button>
</div>
</div>
</div>
</template>
</Dropdown>
<ButtonStyled
v-if="!isDownloadsPage && hasActiveDownloads && !hasVisibleActiveDownloadToasts"
color="brand"
type="transparent"
circular
>
<button v-tooltip="formatMessage(messages.viewActiveDownloads)" @click="goToDownloads">
<DownloadIcon />
</button>
</ButtonStyled>
<div v-if="offline" class="flex items-center gap-1">
<UnplugIcon class="text-secondary" />
<span class="text-sm text-contrast"> {{ formatMessage(messages.offline) }} </span>
</div>
<AppUpdateButton />
<div
class="flex border-solid border-surface-5 text-sm items-center gap-2 py-1.5 px-3 rounded-xl border"
>
<template v-if="selectedProcess">
<OnlineIndicatorIcon />
<div class="text-contrast flex items-center gap-2">
<router-link
v-tooltip="formatMessage(messages.viewInstance)"
:to="`/instance/${encodeURIComponent(selectedProcess.instance.id)}`"
class="hover:underline"
>
{{ selectedProcess.instance.name }}
</router-link>
<Dropdown
v-if="currentProcesses.length > 1"
placement="bottom"
:triggers="['click']"
:hide-triggers="['click']"
@show="showInstances = true"
@hide="showInstances = false"
>
<ButtonStyled type="transparent" circular size="small">
<button
v-tooltip="
showInstances
? formatMessage(messages.hideMoreRunningInstances)
: formatMessage(messages.showMoreRunningInstances)
"
>
<DropdownIcon :class="{ 'rotate-180': !!showInstances }" />
</button>
</ButtonStyled>
<template #popper>
<div class="flex w-[20rem] max-h-[24rem] flex-col gap-2 overflow-auto">
<div
v-for="process in currentProcesses"
:key="process.uuid"
class="flex w-full items-center gap-2 rounded-xl bg-surface-4 p-2 text-sm"
>
<button
v-tooltip.left="
process.uuid === selectedProcess.uuid
? formatMessage(messages.primaryInstance)
: formatMessage(messages.makePrimaryInstance)
"
class="flex flex-grow items-center gap-2"
:class="{
'active:scale-95 transition-transform': process.uuid !== selectedProcess.uuid,
}"
:disabled="process.uuid === selectedProcess.uuid"
@click="selectProcess(process)"
>
<OnlineIndicatorIcon />
<span class="mr-auto text-contrast flex items-center gap-2">
{{ process.instance.name }}
<StarIcon v-if="process.uuid === selectedProcess.uuid" class="text-orange" />
</span>
</button>
<button
v-tooltip="formatMessage(messages.stopInstance)"
class="active:scale-95 flex"
@click.stop="stop(process)"
>
<StopCircleIcon class="text-red size-5" />
</button>
<button
v-tooltip="formatMessage(messages.viewLogs)"
class="active:scale-95 flex"
@click.stop="goToTerminal(process.instance.id)"
>
<TerminalSquareIcon class="text-secondary size-5" />
</button>
</div>
</div>
</template>
</Dropdown>
</div>
<button
v-tooltip="formatMessage(messages.stopInstance)"
class="active:scale-95 flex"
@click="stop(selectedProcess)"
>
<StopCircleIcon class="text-red size-5" />
</button>
<button
v-tooltip="formatMessage(messages.viewLogs)"
class="active:scale-95 flex"
@click="goToTerminal()"
>
<TerminalSquareIcon class="text-secondary size-5" />
</button>
</template>
<template v-else>
<span class="size-2 rounded-full bg-secondary" />
<span class="text-secondary"> {{ formatMessage(messages.noInstancesRunning) }} </span>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import {
BellIcon,
DownloadIcon,
DropdownIcon,
OnlineIndicatorIcon,
StarIcon,
StopCircleIcon,
TerminalSquareIcon,
UnplugIcon,
XIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
defineMessages,
injectNotificationManager,
injectPopupNotificationManager,
type PopupNotification,
type PopupNotificationProgressItem,
useVIntl,
type WebNotification,
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { Dropdown } from 'floating-vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AppUpdateButton from '@/components/ui/app-update-button/index.vue'
import { useInstallJobNotifications } from '@/composables/browse/install-job-notifications'
import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { loading_listener, process_listener } from '@/helpers/events'
import { get_many as getInstances } from '@/helpers/instance'
import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/process'
import type { LoadingBar } from '@/helpers/state'
import { progress_bars_list } from '@/helpers/state'
import type { GameInstance } from '@/helpers/types'
import { downloadBarTypes, injectDownloadManager } from '@/providers/download-manager'
const notificationManager = injectNotificationManager()
const { handleError } = notificationManager
const popupNotificationManager = injectPopupNotificationManager()
const downloadManager = injectDownloadManager()
const { formatMessage } = useVIntl()
type NotificationHistoryItem = {
key: string
createdAt?: number
title: string
text?: string
type?: 'error' | 'warning' | 'success' | 'info' | 'download'
collapsed?: boolean
expand: () => void
dismiss: () => void
}
const notificationHistory = computed<NotificationHistoryItem[]>(() =>
[
...notificationManager.getNotifications().map((item: WebNotification) => ({
key: `web-${item.id}`,
createdAt: item.createdAt,
title: item.title ?? formatMessage(messages.notifications),
text: item.text,
type: item.type,
collapsed: item.collapsed,
expand: () => notificationManager.expandNotification(item.id),
dismiss: () => notificationManager.removeNotification(item.id),
})),
...popupNotificationManager.getNotifications().map((item: PopupNotification) => ({
key: `popup-${item.id}`,
createdAt: item.createdAt,
title: item.title,
text:
item.text ??
(item.progressItems
?.filter((progressItem) => progressItem.text)
.map((progressItem) => `${progressItem.title}: ${progressItem.text}`)
.join('\n') ||
undefined),
type: item.type,
collapsed: item.collapsed,
expand: () => popupNotificationManager.expandNotification(item.id),
dismiss: () => popupNotificationManager.removeNotification(item.id),
})),
].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0)),
)
const hasUnreadNotifications = computed(() =>
notificationHistory.value.some(
(item) => !item.collapsed && ['error', 'warning'].includes(item.type ?? ''),
),
)
function notificationDotClass(type?: NotificationHistoryItem['type']): string {
if (type === 'error') return 'bg-red'
if (type === 'warning') return 'bg-orange'
if (type === 'success') return 'bg-green'
if (type === 'download') return 'bg-green'
return 'bg-blue'
}
function dismissNotification(item: NotificationHistoryItem) {
item.dismiss()
}
async function openNotification(item: NotificationHistoryItem) {
item.expand()
notificationCenterShown.value = false
}
function clearNotificationHistory() {
notificationManager.clearAllNotifications()
popupNotificationManager.clearAllNotifications()
}
const router = useRouter()
const route = useRoute()
const isDownloadsPage = computed(
() => route.path === '/downloads' || route.path.startsWith('/downloads/'),
)
const showInstances = ref(false)
const notificationCenterShown = ref(false)
interface RunningProcess {
uuid: string
instance_id: string
instance: GameInstance
}
interface LoadingEventPayload {
total?: number | null
event: LoadingBar['bar_type']
loader_uuid: string
fraction: number | null
message: string
}
const messages = defineMessages({
offline: {
id: 'app.action-bar.offline',
defaultMessage: 'Offline',
},
viewInstance: {
id: 'app.action-bar.view-instance',
defaultMessage: 'View instance',
},
showMoreRunningInstances: {
id: 'app.action-bar.show-more-running-instances',
defaultMessage: 'Show more running instances',
},
hideMoreRunningInstances: {
id: 'app.action-bar.hide-more-running-instances',
defaultMessage: 'Hide more running instances',
},
primaryInstance: {
id: 'app.action-bar.primary-instance',
defaultMessage: 'Primary instance',
},
makePrimaryInstance: {
id: 'app.action-bar.make-primary-instance',
defaultMessage: 'Make primary instance',
},
stopInstance: {
id: 'app.action-bar.stop-instance',
defaultMessage: 'Stop instance',
},
viewLogs: {
id: 'app.action-bar.view-logs',
defaultMessage: 'View logs',
},
noInstancesRunning: {
id: 'app.action-bar.no-instances-running',
defaultMessage: 'No instances running',
},
notifications: {
id: 'app.action-bar.notifications',
defaultMessage: 'Notifications',
},
clearNotifications: {
id: 'app.action-bar.notifications.clear',
defaultMessage: 'Clear all',
},
noNotifications: {
id: 'app.action-bar.notifications.empty',
defaultMessage: 'No notifications',
},
dismissNotification: {
id: 'app.action-bar.notifications.dismiss',
defaultMessage: 'Dismiss notification',
},
downloadingJava: {
id: 'app.action-bar.downloading-java',
defaultMessage: 'Downloading Java {version}',
},
downloadingModpack: {
id: 'app.downloads.phase.downloading-pack-file',
defaultMessage: 'Downloading modpack',
},
downloads: {
id: 'app.action-bar.downloads',
defaultMessage: 'Downloads',
},
viewActiveDownloads: {
id: 'app.action-bar.view-active-downloads',
defaultMessage: 'View active downloads',
},
exportingModpack: {
id: 'app.action-bar.exporting-modpack',
defaultMessage: 'Exporting modpack',
},
})
const currentProcesses = ref<RunningProcess[]>([])
const selectedProcess = ref<RunningProcess | undefined>()
const refresh = async () => {
const processes = ((await getRunningProcesses().catch((error) => {
handleError(error)
return []
})) ?? []) as Array<{ uuid: string; instance_id: string }>
const instanceIds = processes.map((process) => process.instance_id)
const instances: GameInstance[] = await getInstances(instanceIds).catch((error) => {
handleError(error)
return []
})
currentProcesses.value = processes
.map((process) => {
const instance = instances.find((item) => process.instance_id === item.id)
if (!instance) {
return null
}
return {
...process,
instance,
}
})
.filter((process): process is RunningProcess => process !== null)
if (!selectedProcess.value || !currentProcesses.value.includes(selectedProcess.value)) {
selectedProcess.value = currentProcesses.value[0]
}
}
await refresh()
const { offline } = useNetworkStatus()
const unlistenProcess = await process_listener(async () => {
await refresh()
})
const stop = async (process: RunningProcess) => {
try {
await killProcess(process.uuid).catch(handleError)
} catch (e) {
console.error(e)
}
await refresh()
}
function goToTerminal(instanceId?: string) {
const selectedInstanceId = instanceId ?? selectedProcess.value?.instance.id
if (!selectedInstanceId) {
return
}
router.push(`/instance/${encodeURIComponent(selectedInstanceId)}/logs`)
}
const currentLoadingBars = ref<LoadingBar[]>([])
const currentLoadingBarIconUrls = ref<Record<string, string | null>>({})
const notificationId = ref<string | number | null>(null)
const dismissed = ref(false)
function getLoadingBarKey(loadingBar: LoadingBar): string {
return `${loadingBar.loading_bar_uuid ?? loadingBar.id}`
}
function getLoadingProgress(loadingBar: LoadingBar): number {
if (!loadingBar.total || loadingBar.total <= 0) {
return 0
}
return Math.max(0, Math.min(1, (loadingBar.current ?? 0) / (loadingBar.total ?? 0)))
}
function getLoadingText(loadingBar: LoadingBar): string {
return loadingBar.message ?? ''
}
function getDisplayIconUrl(icon: string | null | undefined): string | null {
if (!icon) {
return null
}
if (/^(https?:|data:|blob:|asset:|tauri:)/.test(icon)) {
return icon
}
return convertFileSrc(icon)
}
function getNotification(): PopupNotification | null {
if (!notificationId.value) {
return null
}
const notification = popupNotificationManager
.getNotifications()
.find((notification) => notification.id === notificationId.value)
return notification ?? null
}
function collapseNotification(): void {
if (!notificationId.value) {
return
}
popupNotificationManager.collapseNotification(notificationId.value)
}
function removeNotification(): void {
if (!notificationId.value) {
return
}
popupNotificationManager.removeNotification(notificationId.value)
notificationId.value = null
}
function buildDownloadItems(): PopupNotificationProgressItem[] {
return [
...installJobNotifications.progressItems.value,
...currentLoadingBars.value.map((bar) => {
const isPackDownload = bar.bar_type?.type === 'pack_download'
return {
id: getLoadingBarKey(bar),
title: bar.title ?? '',
text: getLoadingText(bar),
iconUrl: currentLoadingBarIconUrls.value[getLoadingBarKey(bar)] ?? null,
progress: getLoadingProgress(bar),
waiting: !bar.total || bar.total <= 0,
// Pack downloads report file counts, so prefer count UI over raw percentage.
progressType: isPackDownload ? 'count' : 'percentage',
progressCurrent: bar.current,
progressTotal: bar.total,
}
}),
]
}
const hasVisibleActiveDownloadToasts = computed(() => {
const notification = getNotification()
return !!notification && !notification.collapsed
})
const hasActiveDownloads = computed(
() =>
installJobNotifications.active.value ||
currentLoadingBars.value.some((bar) => downloadBarTypes.has(bar.bar_type?.type ?? '')),
)
const hasDownloadNotificationItems = computed(
() => installJobNotifications.hasItems.value || currentLoadingBars.value.length > 0,
)
function updateNotification(resummon = false): void {
const shouldResummon = resummon && !isDownloadsPage.value
if (shouldResummon) {
dismissed.value = false
}
if (!hasDownloadNotificationItems.value) {
removeNotification()
dismissed.value = false
return
}
if (notificationId.value && !getNotification()) {
notificationId.value = null
dismissed.value = true
}
if (dismissed.value && !shouldResummon) {
return
}
let notif = getNotification()
if (notif?.collapsed && shouldResummon) {
notif.collapsed = false
}
const progressItems = buildDownloadItems()
if (notif) {
notif.title = installJobNotifications.hasItems.value
? installJobNotifications.title.value
: formatMessage(messages.downloads)
notif.text = undefined
notif.progressItems = progressItems
notif.buttons = installJobNotifications.buttons.value
notif.onClick = hasDownloadNotificationItems.value ? goToDownloads : undefined
notif.progress = undefined
notif.waiting = undefined
notif.autoCloseMs =
progressItems.length > 0 && progressItems.every((item) => item.showProgress === false)
? 30 * 1000
: null
if (!notif.collapsed) popupNotificationManager.setNotificationTimer(notif)
} else {
notif = popupNotificationManager.addPopupNotification({
title: installJobNotifications.hasItems.value
? installJobNotifications.title.value
: formatMessage(messages.downloads),
type: 'download',
autoCloseMs: null,
progressItems,
buttons: installJobNotifications.buttons.value,
onClick: hasDownloadNotificationItems.value ? goToDownloads : undefined,
})
notificationId.value = notif.id
if (isDownloadsPage.value) {
popupNotificationManager.collapseNotification(notif.id)
}
if (progressItems.length > 0 && progressItems.every((item) => item.showProgress === false)) {
notif.autoCloseMs = 30 * 1000
popupNotificationManager.setNotificationTimer(notif)
}
}
}
function formatLoadingBars(loadingBar: LoadingBar): LoadingBar {
const formatted = { ...loadingBar }
if (formatted.bar_type?.type === 'java_download') {
formatted.title = formatMessage(messages.downloadingJava, {
version: formatted.bar_type.version,
})
}
if (formatted.bar_type?.type === 'pack_file_download') {
formatted.message = formatMessage(messages.downloadingModpack)
}
if (formatted.bar_type?.instance_id) {
formatted.title = formatted.bar_type.instance_name ?? formatted.bar_type.instance_id
}
if (formatted.bar_type?.type === 'zip_extract') {
formatted.title = formatMessage(messages.exportingModpack)
}
if (formatted.bar_type?.pack_name) {
formatted.title = formatted.bar_type.pack_name
}
return formatted
}
function isVisibleLoadingBar(loadingBar: LoadingBar): boolean {
return (
loadingBar.bar_type?.type !== 'launcher_update' &&
[
'hosted_pack_sync',
'java_download',
'pack_file_download',
'pack_download',
'minecraft_download',
'copy_instance',
'zip_extract',
].includes(loadingBar.bar_type?.type ?? '')
)
}
function applyLoadingEvent(payload: LoadingEventPayload): boolean {
const key = payload.loader_uuid
const index = currentLoadingBars.value.findIndex((bar) => getLoadingBarKey(bar) === key)
if (payload.fraction === null) {
if (index >= 0) {
currentLoadingBars.value.splice(index, 1)
const { [key]: _removedIcon, ...remainingIcons } = currentLoadingBarIconUrls.value
currentLoadingBarIconUrls.value = remainingIcons
}
return false
}
const loadingBar = formatLoadingBars({
loading_bar_uuid: payload.loader_uuid,
message: payload.message,
current: payload.fraction * (payload.total ?? 1),
total: payload.total ?? 1,
bar_type: payload.event,
})
if (!isVisibleLoadingBar(loadingBar)) return false
if (index >= 0) {
currentLoadingBars.value.splice(index, 1, loadingBar)
} else {
currentLoadingBars.value.push(loadingBar)
}
currentLoadingBarIconUrls.value[key] = getDisplayIconUrl(payload.event?.icon)
return index < 0
}
async function refreshLoadingBars() {
const bars: Record<string, LoadingBar> = await progress_bars_list().catch((error) => {
handleError(error)
return {}
})
currentLoadingBars.value = Object.values(bars).map(formatLoadingBars).filter(isVisibleLoadingBar)
const instanceIds = Array.from(
new Set(
currentLoadingBars.value
.map((bar) => bar.bar_type?.instance_id)
.filter((instanceId): instanceId is string => !!instanceId),
),
)
const instances = instanceIds.length
? await getInstances(instanceIds).catch((error) => {
handleError(error)
return []
})
: []
const instanceIconUrls = new Map(
instances.map((instance) => [instance.id, getDisplayIconUrl(instance.icon_path)]),
)
currentLoadingBarIconUrls.value = Object.fromEntries(
currentLoadingBars.value.map((bar) => {
const barIconUrl = getDisplayIconUrl(bar.bar_type?.icon)
const instanceIconUrl = bar.bar_type?.instance_id
? instanceIconUrls.get(bar.bar_type.instance_id)
: null
return [getLoadingBarKey(bar), barIconUrl ?? instanceIconUrl ?? null]
}),
)
currentLoadingBars.value.sort((a, b) => {
const aKey = `${a.loading_bar_uuid ?? a.id ?? ''}`
const bKey = `${b.loading_bar_uuid ?? b.id ?? ''}`
return aKey.localeCompare(bKey)
})
updateNotification()
}
const installJobNotifications = await useInstallJobNotifications({
router,
manager: downloadManager,
handleError,
onChange: updateNotification,
})
await refreshLoadingBars()
let newBarDuringWindow = false
let loadingNotificationTimer: ReturnType<typeof setTimeout> | null = null
const unlistenLoading = await loading_listener((payload: LoadingEventPayload) => {
const isNewBar = applyLoadingEvent(payload)
if (isNewBar) {
newBarDuringWindow = true
}
if (loadingNotificationTimer !== null) {
return
}
loadingNotificationTimer = setTimeout(() => {
loadingNotificationTimer = null
if (newBarDuringWindow) {
newBarDuringWindow = false
if (isDownloadsPage.value) {
updateNotification()
} else {
removeNotification()
updateNotification(true)
}
} else {
updateNotification()
}
}, 250)
})
function goToDownloads() {
router.push('/downloads')
}
watch(
() => route.path,
() => {
if (isDownloadsPage.value) {
collapseNotification()
}
updateNotification()
},
)
function selectProcess(process: RunningProcess) {
selectedProcess.value = process
}
onBeforeUnmount(() => {
if (loadingNotificationTimer !== null) {
clearTimeout(loadingNotificationTimer)
loadingNotificationTimer = null
}
removeNotification()
dismissed.value = false
unlistenProcess()
unlistenLoading()
installJobNotifications.dispose()
})
</script>