diff --git a/.github/workflows/theseus-build.yml b/.github/workflows/theseus-build.yml index 46bb693b8c..36547186e3 100644 --- a/.github/workflows/theseus-build.yml +++ b/.github/workflows/theseus-build.yml @@ -27,12 +27,15 @@ on: options: - prod - staging + - prod-with-staging-archon default: prod required: false jobs: build: name: Build + env: + VITE_STRIPE_PUBLISHABLE_KEY: pk_live_51JbFxJJygY5LJFfKLVVldb10HlLt24p421OWRsTOWc5sXYFOnFUXWieSc6HD3PHo25ktx8db1WcHr36XGFvZFVUz00V9ixrCs5 strategy: fail-fast: false matrix: diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index 041209b551..d37b0b8225 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -5,6 +5,7 @@ import { nodeAuthState, PanelVersionFeature, TauriModrinthClient, + VerboseLoggingFeature, } from '@modrinth/api-client' import { ArrowBigUpDashIcon, @@ -25,7 +26,7 @@ import { RefreshCwIcon, RestoreIcon, RightArrowIcon, - ServerIcon, + ServerStackIcon, SettingsIcon, UserIcon, WorldIcon, @@ -86,6 +87,7 @@ import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue' import RunningAppBar from '@/components/ui/RunningAppBar.vue' import SplashScreen from '@/components/ui/SplashScreen.vue' import { useCheckDisableMouseover } from '@/composables/macCssFix.js' +import { config } from '@/config' import { hide_ads_window, init_ads_window, show_ads_window } from '@/helpers/ads.js' import { debugAnalytics, initAnalytics, trackEvent } from '@/helpers/analytics' import { check_reachable } from '@/helpers/auth.js' @@ -133,6 +135,8 @@ const { addPopupNotification } = popupNotificationManager const tauriApiClient = new TauriModrinthClient({ userAgent: `modrinth/theseus/${getVersion()} (support@modrinth.com)`, + labrinthBaseUrl: config.labrinthBaseUrl, + archonBaseUrl: config.archonBaseUrl, features: [ new NodeAuthFeature({ getAuth: () => nodeAuthState.getAuth?.() ?? null, @@ -146,12 +150,14 @@ const tauriApiClient = new TauriModrinthClient({ token: async () => (await getCreds())?.session, }), new PanelVersionFeature(), + new VerboseLoggingFeature(), ], }) provideModrinthClient(tauriApiClient) providePageContext({ hierarchicalSidebarAvailable: ref(true), showAds: ref(false), + openExternalUrl: (url) => openUrl(url), }) provideModalBehavior({ noblur: computed(() => !themeStore.advancedRendering), @@ -409,17 +415,30 @@ const handleClose = async () => { } const router = useRouter() +const route = useRoute() + +const loading = useLoading() +loading.setEnabled(false) +loading.startLoading() + +let suspensePending = false + +router.beforeEach(() => { + suspensePending = false + loading.startLoading() +}) router.afterEach((to, from, failure) => { trackEvent('PageView', { path: to.path, fromPath: from.path, failed: failure, }) + setTimeout(() => { + if (!suspensePending) { + loading.stopLoading() + } + }, 100) }) -const route = useRoute() - -const loading = useLoading() -loading.setEnabled(false) const error = useError() const errorModal = ref() @@ -1010,6 +1029,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload) - +
@@ -1195,7 +1216,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
@@ -1297,8 +1326,8 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
- - + + - - - - - diff --git a/apps/app-frontend/src/components/ui/URLConfirmModal.vue b/apps/app-frontend/src/components/ui/URLConfirmModal.vue index aa7917a5df..0ec7cd0bce 100644 --- a/apps/app-frontend/src/components/ui/URLConfirmModal.vue +++ b/apps/app-frontend/src/components/ui/URLConfirmModal.vue @@ -1,11 +1,9 @@ diff --git a/apps/app-frontend/src/pages/instance/Worlds.vue b/apps/app-frontend/src/pages/instance/Worlds.vue index e8b79f4380..861eba0188 100644 --- a/apps/app-frontend/src/pages/instance/Worlds.vue +++ b/apps/app-frontend/src/pages/instance/Worlds.vue @@ -357,9 +357,7 @@ const MAX_LINUX_REFRESHES = 3 const isLinux = platform() === 'linux' const linuxRefreshCount = ref(0) -const protocolVersion = ref( - await get_profile_protocol_version(instance.value.path), -) +const protocolVersion = ref(null) const managedServerName = ref(null) const managedServerAddress = ref(null) @@ -424,22 +422,27 @@ watch( { immediate: true }, ) -const unlistenProfile = await profile_listener(async (e: ProfileEvent) => { - if (e.profile_path_id !== instance.value.path) return +const [unlistenProfile, , resolvedProtocolVersion, resolvedGameVersions] = await Promise.all([ + profile_listener(async (e: ProfileEvent) => { + if (e.profile_path_id !== instance.value.path) return - console.info(`Handling profile event '${e.event}' for profile: ${e.profile_path_id}`) + console.info(`Handling profile event '${e.event}' for profile: ${e.profile_path_id}`) - if (e.event === 'servers_updated') { - if (isLinux && linuxRefreshCount.value >= MAX_LINUX_REFRESHES) return - if (isLinux) linuxRefreshCount.value++ + if (e.event === 'servers_updated') { + if (isLinux && linuxRefreshCount.value >= MAX_LINUX_REFRESHES) return + if (isLinux) linuxRefreshCount.value++ - await refreshAllWorlds() - } + await refreshAllWorlds() + } - await handleDefaultProfileUpdateEvent(worlds.value, instance.value.path, e) -}) + await handleDefaultProfileUpdateEvent(worlds.value, instance.value.path, e) + }), + refreshAllWorlds(), + get_profile_protocol_version(instance.value.path).catch(() => null), + get_game_versions().catch(() => [] as GameVersion[]), +]) -await refreshAllWorlds() +protocolVersion.value = resolvedProtocolVersion async function refreshServer(address: string) { if (!serverData.value[address]) { @@ -589,7 +592,7 @@ function worldsMatch(world: World, other: World | undefined) { return false } -const gameVersions = ref(await get_game_versions().catch(() => [])) +const gameVersions = ref(resolvedGameVersions) const supportsServerQuickPlay = computed(() => hasServerQuickPlaySupport(gameVersions.value, instance.value.game_version), ) diff --git a/apps/app-frontend/src/providers/setup/auth.ts b/apps/app-frontend/src/providers/setup/auth.ts index bf522c59a3..2de8c0acfa 100644 --- a/apps/app-frontend/src/providers/setup/auth.ts +++ b/apps/app-frontend/src/providers/setup/auth.ts @@ -1,6 +1,6 @@ import type { Labrinth } from '@modrinth/api-client' import { type AuthProvider, provideAuth } from '@modrinth/ui' -import { type Ref, ref, watchEffect } from 'vue' +import { computed, type Ref, ref, watchEffect } from 'vue' type AppCredentials = { session?: string | null @@ -13,10 +13,12 @@ export function setupAuthProvider( ) { const sessionToken = ref(null) const user = ref(null) + const isReady = computed(() => credentials.value !== undefined) const authProvider: AuthProvider = { session_token: sessionToken, user, + isReady, requestSignIn, } diff --git a/apps/app-frontend/src/providers/setup/server-install-content.ts b/apps/app-frontend/src/providers/setup/server-install-content.ts new file mode 100644 index 0000000000..35919d9d73 --- /dev/null +++ b/apps/app-frontend/src/providers/setup/server-install-content.ts @@ -0,0 +1,393 @@ +import type { Archon, Labrinth } from '@modrinth/api-client' +import { + createContext, + type CreationFlowContextValue, + injectModrinthClient, + injectNotificationManager, +} from '@modrinth/ui' +import { computed, type ComputedRef, nextTick, type Ref, ref, watch } from 'vue' +import { useRoute, useRouter } from 'vue-router' + +type ServerFlowFrom = 'onboarding' | 'reset-server' +type ServerInstallableType = 'modpack' | 'mod' | 'plugin' | 'datapack' + +type InstallableSearchResult = Labrinth.Search.v3.ResultSearchProject & { + installing?: boolean + installed?: boolean +} + +interface ServerModpackSelectionRequest { + projectId: string + versionId: string + name: string + iconUrl?: string +} + +interface ServerSetupModalHandle { + show: () => void | Promise + hide: () => void + ctx?: CreationFlowContextValue | null +} + +export interface ServerInstallContentContext { + serverIdQuery: ComputedRef + worldIdQuery: ComputedRef + browseFrom: ComputedRef + serverFlowFrom: ComputedRef + isFromWorlds: ComputedRef + isServerContext: ComputedRef + isSetupServerContext: ComputedRef + effectiveServerWorldId: ComputedRef + serverContextServerData: Ref + serverContentProjectIds: Ref> + serverBackUrl: ComputedRef + serverBackLabel: ComputedRef + serverBrowseHeading: ComputedRef + initServerContext: () => Promise + watchServerContextChanges: () => void + searchServerModpacks: ( + query: string, + limit?: number, + ) => Promise + getServerProjectVersions: (projectId: string) => Promise<{ id: string }[]> + enforceSetupModpackRoute: (currentProjectType: string | undefined) => void + installProjectToServer: (project: InstallableSearchResult) => Promise + onServerFlowBack: () => void + handleServerModpackFlowCreate: (config: CreationFlowContextValue) => Promise + markServerProjectInstalled: (id: string) => void +} + +export const [injectServerInstallContent, provideServerInstallContent] = + createContext('Browse', 'serverInstallContent') + +function readQueryString(value: unknown): string | null { + if (Array.isArray(value)) return value[0] ?? null + return typeof value === 'string' && value.length > 0 ? value : null +} + +export function createServerInstallContent(opts: { + serverSetupModalRef: Ref +}) { + const { serverSetupModalRef } = opts + const route = useRoute() + const router = useRouter() + const client = injectModrinthClient() + const { handleError } = injectNotificationManager() + + const serverIdQuery = computed(() => readQueryString(route.query.sid)) + const worldIdQuery = computed(() => readQueryString(route.query.wid)) + const browseFrom = computed(() => readQueryString(route.query.from)) + const serverFlowFrom = computed(() => + browseFrom.value === 'onboarding' || browseFrom.value === 'reset-server' + ? browseFrom.value + : null, + ) + + const isFromWorlds = computed(() => browseFrom.value === 'worlds') + const isServerContext = computed(() => !!serverIdQuery.value) + const isSetupServerContext = computed(() => !!serverIdQuery.value && !!serverFlowFrom.value) + + const serverContextWorldId = ref(worldIdQuery.value) + const serverContextServerData = ref(null) + const serverContentProjectIds = ref>(new Set()) + const effectiveServerWorldId = computed(() => worldIdQuery.value ?? serverContextWorldId.value) + + const serverBackUrl = computed(() => { + const sid = serverIdQuery.value + if (!sid) return '/hosting/manage' + if (serverFlowFrom.value === 'onboarding') { + return `/hosting/manage/${sid}?resumeModal=setup-type` + } + if (serverFlowFrom.value === 'reset-server') { + return `/hosting/manage/${sid}?openSettings=installation` + } + return `/hosting/manage/${sid}/content` + }) + const serverBackLabel = computed(() => { + if (serverFlowFrom.value === 'onboarding') return 'Back to setup' + if (serverFlowFrom.value === 'reset-server') return 'Cancel reset' + return 'Back to server' + }) + const serverBrowseHeading = computed(() => { + if (serverFlowFrom.value === 'reset-server') { + return 'Select modpack to install after reset' + } + return 'Install content to server' + }) + + async function resolveServerContextWorldId(serverId: string) { + try { + const server = await client.archon.servers_v1.get(serverId) + const activeWorld = server.worlds.find((world) => world.is_active) + return activeWorld?.id ?? server.worlds[0]?.id ?? null + } catch (err) { + handleError(err as Error) + return null + } + } + + async function refreshServerInstalledContent(serverId: string, worldId: string) { + try { + const content = await client.archon.content_v1.getAddons(serverId, worldId) + const ids = new Set( + (content.addons ?? []) + .map((addon) => addon.project_id) + .filter((projectId): projectId is string => !!projectId), + ) + serverContentProjectIds.value = ids + } catch (err) { + handleError(err as Error) + } + } + + async function initServerContext() { + const sid = serverIdQuery.value + if (!sid) return + + try { + serverContextServerData.value = await client.archon.servers_v0.get(sid) + } catch (err) { + handleError(err as Error) + } + + let resolvedWorldId = effectiveServerWorldId.value + if (!resolvedWorldId) { + resolvedWorldId = await resolveServerContextWorldId(sid) + if (resolvedWorldId) { + serverContextWorldId.value = resolvedWorldId + } + } + + if (resolvedWorldId) { + await refreshServerInstalledContent(sid, resolvedWorldId) + } + } + + function watchServerContextChanges() { + watch([serverIdQuery, effectiveServerWorldId], async ([sid, wid], [prevSid, prevWid]) => { + if (!sid) { + serverContextServerData.value = null + serverContentProjectIds.value = new Set() + return + } + + if (sid !== prevSid) { + serverContentProjectIds.value = new Set() + try { + serverContextServerData.value = await client.archon.servers_v0.get(sid) + } catch (err) { + handleError(err as Error) + } + } + + if (wid && (sid !== prevSid || wid !== prevWid)) { + await refreshServerInstalledContent(sid, wid) + } + }) + } + + function normalizeLoader(loader: string) { + return loader.toLowerCase().replaceAll('_', '').replaceAll('-', '').replaceAll(' ', '') + } + + function getCompatibleLoaders(loader: string) { + const normalized = normalizeLoader(loader) + if (!normalized) return new Set() + if (normalized === 'paper' || normalized === 'purpur' || normalized === 'spigot') { + return new Set(['paper', 'purpur', 'spigot', 'bukkit']) + } + if (normalized === 'neoforge' || normalized === 'neo') { + return new Set(['neoforge', 'neo']) + } + return new Set([normalized]) + } + + function enforceSetupModpackRoute(currentProjectType: string | undefined) { + if (!isSetupServerContext.value || currentProjectType === 'modpack') return + router.replace({ + path: '/browse/modpack', + query: route.query, + }) + } + + async function searchServerModpacks(query: string, limit: number = 10) { + return client.labrinth.projects_v2.search({ + query: query || undefined, + new_filters: + 'project_types = "modpack" AND (client_side = "optional" OR client_side = "required") AND server_side = "required"', + limit, + }) + } + + async function getServerProjectVersions(projectId: string) { + const versions = await client.labrinth.versions_v3.getProjectVersions(projectId) + return versions.map((version) => ({ id: version.id })) + } + + async function openServerModpackInstallFlow(request: ServerModpackSelectionRequest) { + if (!serverIdQuery.value || !effectiveServerWorldId.value) { + throw new Error('Missing server context') + } + + const modalInstance = serverSetupModalRef.value + if (!modalInstance) return + + modalInstance.show() + await nextTick() + + const ctx = modalInstance.ctx + if (!ctx) return + + ctx.setupType.value = 'modpack' + ctx.modpackSelection.value = { + projectId: request.projectId, + versionId: request.versionId, + name: request.name, + iconUrl: request.iconUrl, + } + ctx.modal.value?.setStage('final-config') + } + + function getCurrentServerInstallType(): ServerInstallableType { + const raw = Array.isArray(route.params.projectType) + ? route.params.projectType[0] + : route.params.projectType + if (raw === 'modpack' || raw === 'mod' || raw === 'plugin' || raw === 'datapack') { + return raw + } + throw new Error('This content type cannot be installed to a server from browse.') + } + + async function installProjectToServer(project: InstallableSearchResult) { + const contentType = getCurrentServerInstallType() + const sid = serverIdQuery.value + const wid = effectiveServerWorldId.value + if (!sid || !wid) { + throw new Error('No server world is available for install.') + } + + if (contentType === 'modpack') { + const versions = await client.labrinth.versions_v2.getProjectVersions(project.project_id, { + include_changelog: false, + }) + const versionId = versions[0]?.id ?? project.version_id + if (!versionId) { + throw new Error('No version found for this modpack') + } + + await openServerModpackInstallFlow({ + projectId: project.project_id, + versionId, + name: project.name, + iconUrl: project.icon_url ?? undefined, + }) + return false + } + + const versions = await client.labrinth.versions_v2.getProjectVersions(project.project_id, { + include_changelog: false, + }) + const serverLoader = (serverContextServerData.value?.loader ?? '').toLowerCase() + const serverGameVersion = (serverContextServerData.value?.mc_version ?? '').trim() + const compatibleLoaders = getCompatibleLoaders(serverLoader) + + const hasGameVersionMatch = (version: Labrinth.Versions.v2.Version) => + !serverGameVersion || version.game_versions.includes(serverGameVersion) + const hasLoaderMatch = (version: Labrinth.Versions.v2.Version) => { + if (contentType === 'datapack') return true + if (compatibleLoaders.size === 0) return true + return version.loaders.some((loader) => compatibleLoaders.has(normalizeLoader(loader))) + } + + let matchingVersion = versions.find( + (version) => hasGameVersionMatch(version) && hasLoaderMatch(version), + ) + if (!matchingVersion) { + matchingVersion = versions.find((version) => hasLoaderMatch(version)) + } + if (!matchingVersion) { + matchingVersion = versions.find((version) => hasGameVersionMatch(version)) + } + if (!matchingVersion) { + matchingVersion = versions[0] + } + if (!matchingVersion) { + throw new Error('No installable version was found for this project.') + } + + await client.archon.content_v1.addAddon(sid, wid, { + project_id: matchingVersion.project_id, + version_id: matchingVersion.id, + }) + + serverContentProjectIds.value = new Set([...serverContentProjectIds.value, project.project_id]) + return true + } + + function onServerFlowBack() { + serverSetupModalRef.value?.hide() + } + + async function handleServerModpackFlowCreate(config: CreationFlowContextValue) { + const sid = serverIdQuery.value + const wid = effectiveServerWorldId.value + if (!sid || !wid || !config.modpackSelection.value) { + config.loading.value = false + return + } + + try { + await client.archon.content_v1.installContent(sid, wid, { + content_variant: 'modpack', + spec: { + platform: 'modrinth', + project_id: config.modpackSelection.value.projectId, + version_id: config.modpackSelection.value.versionId, + }, + soft_override: false, + properties: config.buildProperties(), + } satisfies Archon.Content.v1.InstallWorldContent) + serverSetupModalRef.value?.hide() + + if (serverFlowFrom.value === 'onboarding') { + await client.archon.servers_v1.endIntro(sid) + await router.push(`/hosting/manage/${sid}/content`) + return + } + + await router.push(`/hosting/manage/${sid}?openSettings=installation`) + } catch (err) { + handleError(err as Error) + config.loading.value = false + } + } + + function markServerProjectInstalled(id: string) { + serverContentProjectIds.value = new Set([...serverContentProjectIds.value, id]) + } + + return { + serverIdQuery, + worldIdQuery, + browseFrom, + serverFlowFrom, + isFromWorlds, + isServerContext, + isSetupServerContext, + effectiveServerWorldId, + serverContextServerData, + serverContentProjectIds, + serverBackUrl, + serverBackLabel, + serverBrowseHeading, + initServerContext, + watchServerContextChanges, + searchServerModpacks, + getServerProjectVersions, + enforceSetupModpackRoute, + installProjectToServer, + onServerFlowBack, + handleServerModpackFlowCreate, + markServerProjectInstalled, + } +} diff --git a/apps/app-frontend/src/routes.js b/apps/app-frontend/src/routes.js index 2748853d58..b80137915f 100644 --- a/apps/app-frontend/src/routes.js +++ b/apps/app-frontend/src/routes.js @@ -43,26 +43,35 @@ export default new createRouter({ children: [ { path: '', - redirect: (to) => { - const rawId = Array.isArray(to.params.id) ? to.params.id[0] : to.params.id - if (!rawId) return '/hosting/manage' - return `/hosting/manage/${encodeURIComponent(rawId)}/content` + name: 'ServerManageOverview', + component: Hosting.Overview, + meta: { + breadcrumb: [{ name: '?Server' }], }, }, { path: 'content', name: 'ServerManageContent', component: Hosting.Content, + meta: { + breadcrumb: [{ name: '?Server' }], + }, }, { path: 'files', name: 'ServerManageFiles', component: Hosting.Files, + meta: { + breadcrumb: [{ name: '?Server' }], + }, }, { path: 'backups', name: 'ServerManageBackups', component: Hosting.Backups, + meta: { + breadcrumb: [{ name: '?Server' }], + }, }, ], }, @@ -118,6 +127,13 @@ export default new createRouter({ }, ], }, + { + path: '/:projectType(mod|plugin|datapack|resourcepack|shader|modpack)/:id/:rest(.*)*', + redirect: (to) => { + const rest = to.params.rest ? `/${[].concat(to.params.rest).join('/')}` : '' + return `/project/${to.params.id}${rest}${to.hash}` + }, + }, { path: '/project/:id', name: 'Project', @@ -232,7 +248,8 @@ export default new createRouter({ ], linkActiveClass: 'router-link-active', linkExactActiveClass: 'router-link-exact-active', - scrollBehavior() { + scrollBehavior(to, from) { + if (to.path === from.path) return // Sometimes Vue's scroll behavior is not working as expected, so we need to manually scroll to top (especially on Linux) document.querySelector('.app-viewport')?.scrollTo(0, 0) return { diff --git a/apps/app-frontend/src/store/theme.ts b/apps/app-frontend/src/store/theme.ts index 605596b110..dbdd5a1196 100644 --- a/apps/app-frontend/src/store/theme.ts +++ b/apps/app-frontend/src/store/theme.ts @@ -5,7 +5,6 @@ export const DEFAULT_FEATURE_FLAGS = { page_path: false, worlds_tab: false, worlds_in_home: true, - servers_in_app: false, server_project_qa: false, i18n_debug: false, } diff --git a/apps/app-frontend/tsconfig.app.json b/apps/app-frontend/tsconfig.app.json index f723e2026f..504558d95f 100644 --- a/apps/app-frontend/tsconfig.app.json +++ b/apps/app-frontend/tsconfig.app.json @@ -3,7 +3,7 @@ "target": "ES2020", "useDefineForClassFields": true, "module": "ESNext", - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "lib": ["ES2021", "DOM", "DOM.Iterable"], "skipLibCheck": true, "moduleResolution": "bundler", diff --git a/apps/app-frontend/vite.config.ts b/apps/app-frontend/vite.config.ts index 618068cb24..f55d00c81f 100644 --- a/apps/app-frontend/vite.config.ts +++ b/apps/app-frontend/vite.config.ts @@ -1,4 +1,5 @@ import vue from '@vitejs/plugin-vue' +import { existsSync, readFileSync } from 'fs' import { resolve } from 'path' import { defineConfig } from 'vite' import svgLoader from 'vite-svg-loader' @@ -6,6 +7,23 @@ import svgLoader from 'vite-svg-loader' import tauriConf from '../app/tauri.conf.json' const projectRootDir = resolve(__dirname) +const appLibEnvDir = resolve(projectRootDir, '../../packages/app-lib') + +// Load .env from app-lib manually instead of using Vite's envDir, which would auto-load .env.local and override values +const envFilePath = resolve(appLibEnvDir, '.env') +if (existsSync(envFilePath)) { + for (const line of readFileSync(envFilePath, 'utf-8').split('\n')) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) continue + const eqIndex = trimmed.indexOf('=') + if (eqIndex === -1) continue + const key = trimmed.slice(0, eqIndex) + const value = trimmed.slice(eqIndex + 1) + if (!(key in process.env)) { + process.env[key] = value + } + } +} // https://vitejs.dev/config/ export default defineConfig({ @@ -68,7 +86,7 @@ export default defineConfig({ }, // to make use of `TAURI_ENV_DEBUG` and other env variables // https://v2.tauri.app/reference/environment-variables/#tauri-cli-hook-commands - envPrefix: ['VITE_', 'TAURI_'], + envPrefix: ['VITE_', 'TAURI_', 'MODRINTH_'], build: { rolldownOptions: { onwarn(warning, defaultHandler) { diff --git a/apps/app/build.rs b/apps/app/build.rs index 358975ed1f..769d7db702 100644 --- a/apps/app/build.rs +++ b/apps/app/build.rs @@ -89,6 +89,8 @@ fn main() { "logs_delete_logs", "logs_delete_logs_by_filename", "logs_get_latest_log_cursor", + "logs_get_live_log_buffer", + "logs_clear_live_log_buffer", ]) .default_permission( DefaultPermissionRule::AllowAllCommands, diff --git a/apps/app/capabilities/plugins.json b/apps/app/capabilities/plugins.json index f1a45bf27d..ac090260b5 100644 --- a/apps/app/capabilities/plugins.json +++ b/apps/app/capabilities/plugins.json @@ -22,7 +22,12 @@ { "identifier": "http:default", - "allow": [{ "url": "https://modrinth.com/*" }, { "url": "https://*.modrinth.com/*" }] + "allow": [ + { "url": "https://modrinth.com/*" }, + { "url": "https://*.modrinth.com/*" }, + { "url": "https://*.nodes.modrinth.com/*" }, + { "url": "https://api.mclo.gs/*" } + ] }, "dialog:allow-save", diff --git a/apps/app/src/api/logs.rs b/apps/app/src/api/logs.rs index c5097a1c95..cf68519e14 100644 --- a/apps/app/src/api/logs.rs +++ b/apps/app/src/api/logs.rs @@ -21,6 +21,8 @@ pub fn init() -> tauri::plugin::TauriPlugin { logs_delete_logs, logs_delete_logs_by_filename, logs_get_latest_log_cursor, + logs_get_live_log_buffer, + logs_clear_live_log_buffer, ]) .build() } @@ -83,3 +85,18 @@ pub async fn logs_get_latest_log_cursor( ) -> Result { Ok(logs::get_latest_log_cursor(profile_path, cursor).await?) } + +/// Get all buffered live log lines for a profile +#[tauri::command] +pub async fn logs_get_live_log_buffer( + profile_path: &str, +) -> Result { + Ok(logs::get_live_log_buffer(profile_path).await?) +} + +/// Clear the live log buffer for a profile +#[tauri::command] +pub async fn logs_clear_live_log_buffer(profile_path: &str) -> Result<()> { + logs::clear_live_log_buffer(profile_path); + Ok(()) +} diff --git a/apps/app/src/main.rs b/apps/app/src/main.rs index 03a8227ac6..a78c260101 100644 --- a/apps/app/src/main.rs +++ b/apps/app/src/main.rs @@ -7,6 +7,7 @@ use native_dialog::{DialogBuilder, MessageLevel}; use std::env; use tauri::{Listener, Manager}; +use tauri_plugin_fs::FsExt; use theseus::prelude::*; mod api; @@ -35,6 +36,8 @@ async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> { .allow_directory(state.directories.caches_dir(), true)?; app.asset_protocol_scope() .allow_directory(state.directories.caches_dir().join("icons"), true)?; + app.fs_scope() + .allow_directory(state.directories.profiles_dir(), true)?; Ok(()) } diff --git a/apps/app/tauri.conf.json b/apps/app/tauri.conf.json index bc06fa6405..06d1d350ae 100644 --- a/apps/app/tauri.conf.json +++ b/apps/app/tauri.conf.json @@ -87,7 +87,7 @@ "capabilities": ["ads", "core", "plugins"], "csp": { "default-src": "'self' customprotocol: asset:", - "connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.posthog.com https://posthog.modrinth.com https://*.sentry.io https://api.mclo.gs http://textures.minecraft.net https://textures.minecraft.net https://js.stripe.com https://*.stripe.com wss://*.stripe.com wss://*.nodes.modrinth.com 'self' data: blob:", + "connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.nodes.modrinth.com https://*.posthog.com https://posthog.modrinth.com https://*.sentry.io https://api.mclo.gs http://textures.minecraft.net https://textures.minecraft.net https://js.stripe.com https://*.stripe.com wss://*.stripe.com wss://*.nodes.modrinth.com wss://*.ts.net 'self' data: blob:", "font-src": ["https://cdn-raw.modrinth.com/fonts/"], "img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:", "style-src": "'unsafe-inline' 'self'", diff --git a/apps/frontend/src/components/ui/servers/ModrinthServersIcon.vue b/apps/frontend/src/components/brand/ModrinthServersIcon.vue similarity index 100% rename from apps/frontend/src/components/ui/servers/ModrinthServersIcon.vue rename to apps/frontend/src/components/brand/ModrinthServersIcon.vue diff --git a/apps/frontend/src/components/ui/servers/notice/AssignNoticeModal.vue b/apps/frontend/src/components/ui/admin/AssignNoticeModal.vue similarity index 100% rename from apps/frontend/src/components/ui/servers/notice/AssignNoticeModal.vue rename to apps/frontend/src/components/ui/admin/AssignNoticeModal.vue diff --git a/apps/frontend/src/components/ui/servers/LoaderSelector.vue b/apps/frontend/src/components/ui/servers/LoaderSelector.vue deleted file mode 100644 index 0998a9ced1..0000000000 --- a/apps/frontend/src/components/ui/servers/LoaderSelector.vue +++ /dev/null @@ -1,96 +0,0 @@ - - - diff --git a/apps/frontend/src/components/ui/servers/LoaderSelectorCard.vue b/apps/frontend/src/components/ui/servers/LoaderSelectorCard.vue deleted file mode 100644 index 0017f1076e..0000000000 --- a/apps/frontend/src/components/ui/servers/LoaderSelectorCard.vue +++ /dev/null @@ -1,68 +0,0 @@ - - - diff --git a/apps/frontend/src/components/ui/servers/LogLine.vue b/apps/frontend/src/components/ui/servers/LogLine.vue deleted file mode 100644 index 07df871a33..0000000000 --- a/apps/frontend/src/components/ui/servers/LogLine.vue +++ /dev/null @@ -1,91 +0,0 @@ - - - - - diff --git a/apps/frontend/src/components/ui/servers/PanelServerActionButton.vue b/apps/frontend/src/components/ui/servers/PanelServerActionButton.vue deleted file mode 100644 index 3022a7b06a..0000000000 --- a/apps/frontend/src/components/ui/servers/PanelServerActionButton.vue +++ /dev/null @@ -1,276 +0,0 @@ - - - diff --git a/apps/frontend/src/components/ui/servers/PanelServerStatus.vue b/apps/frontend/src/components/ui/servers/PanelServerStatus.vue deleted file mode 100644 index f5b42fa0e1..0000000000 --- a/apps/frontend/src/components/ui/servers/PanelServerStatus.vue +++ /dev/null @@ -1,75 +0,0 @@ - - - diff --git a/apps/frontend/src/components/ui/servers/PanelSpinner.vue b/apps/frontend/src/components/ui/servers/PanelSpinner.vue deleted file mode 100644 index c2c7f55eab..0000000000 --- a/apps/frontend/src/components/ui/servers/PanelSpinner.vue +++ /dev/null @@ -1,22 +0,0 @@ - diff --git a/apps/frontend/src/components/ui/servers/PanelTerminal.vue b/apps/frontend/src/components/ui/servers/PanelTerminal.vue deleted file mode 100644 index 950a2dc9ea..0000000000 --- a/apps/frontend/src/components/ui/servers/PanelTerminal.vue +++ /dev/null @@ -1,1414 +0,0 @@ - - - - - diff --git a/apps/frontend/src/components/ui/servers/PlatformChangeModpackVersionModal.vue b/apps/frontend/src/components/ui/servers/PlatformChangeModpackVersionModal.vue deleted file mode 100644 index e7b0c74ed7..0000000000 --- a/apps/frontend/src/components/ui/servers/PlatformChangeModpackVersionModal.vue +++ /dev/null @@ -1,163 +0,0 @@ - - - diff --git a/apps/frontend/src/components/ui/servers/PlatformVersionSelectModal.vue b/apps/frontend/src/components/ui/servers/PlatformVersionSelectModal.vue deleted file mode 100644 index f4ca68f7c0..0000000000 --- a/apps/frontend/src/components/ui/servers/PlatformVersionSelectModal.vue +++ /dev/null @@ -1,538 +0,0 @@ - - - diff --git a/apps/frontend/src/components/ui/servers/SaveBanner.vue b/apps/frontend/src/components/ui/servers/SaveBanner.vue deleted file mode 100644 index a43e4dd1f1..0000000000 --- a/apps/frontend/src/components/ui/servers/SaveBanner.vue +++ /dev/null @@ -1,17 +0,0 @@ - - - diff --git a/apps/frontend/src/components/ui/servers/ServerSidebar.vue b/apps/frontend/src/components/ui/servers/ServerSidebar.vue deleted file mode 100644 index 1b455df0c1..0000000000 --- a/apps/frontend/src/components/ui/servers/ServerSidebar.vue +++ /dev/null @@ -1,52 +0,0 @@ - - - diff --git a/apps/frontend/src/components/ui/servers/ServerStats.vue b/apps/frontend/src/components/ui/servers/ServerStats.vue deleted file mode 100644 index a16aa63226..0000000000 --- a/apps/frontend/src/components/ui/servers/ServerStats.vue +++ /dev/null @@ -1,256 +0,0 @@ - - - - - diff --git a/apps/frontend/src/components/ui/servers/TeleportOverflowMenu.vue b/apps/frontend/src/components/ui/servers/TeleportOverflowMenu.vue deleted file mode 100644 index 4a8f774134..0000000000 --- a/apps/frontend/src/components/ui/servers/TeleportOverflowMenu.vue +++ /dev/null @@ -1,438 +0,0 @@ - - - diff --git a/apps/frontend/src/components/ui/servers/icons/ChevronDownIcon.vue b/apps/frontend/src/components/ui/servers/icons/ChevronDownIcon.vue deleted file mode 100644 index 783d18a402..0000000000 --- a/apps/frontend/src/components/ui/servers/icons/ChevronDownIcon.vue +++ /dev/null @@ -1,16 +0,0 @@ - diff --git a/apps/frontend/src/components/ui/servers/icons/ChevronUpIcon.vue b/apps/frontend/src/components/ui/servers/icons/ChevronUpIcon.vue deleted file mode 100644 index da6cf408ab..0000000000 --- a/apps/frontend/src/components/ui/servers/icons/ChevronUpIcon.vue +++ /dev/null @@ -1,16 +0,0 @@ - diff --git a/apps/frontend/src/components/ui/servers/icons/CodeFileIcon.vue b/apps/frontend/src/components/ui/servers/icons/CodeFileIcon.vue deleted file mode 100644 index 42022a33ce..0000000000 --- a/apps/frontend/src/components/ui/servers/icons/CodeFileIcon.vue +++ /dev/null @@ -1,18 +0,0 @@ - diff --git a/apps/frontend/src/components/ui/servers/icons/CogFolderIcon.vue b/apps/frontend/src/components/ui/servers/icons/CogFolderIcon.vue deleted file mode 100644 index cc8fc1bc66..0000000000 --- a/apps/frontend/src/components/ui/servers/icons/CogFolderIcon.vue +++ /dev/null @@ -1,26 +0,0 @@ - diff --git a/apps/frontend/src/components/ui/servers/icons/EarthIcon.vue b/apps/frontend/src/components/ui/servers/icons/EarthIcon.vue deleted file mode 100644 index e96f944bcc..0000000000 --- a/apps/frontend/src/components/ui/servers/icons/EarthIcon.vue +++ /dev/null @@ -1,20 +0,0 @@ - diff --git a/apps/frontend/src/components/ui/servers/icons/FullscreenIcon.vue b/apps/frontend/src/components/ui/servers/icons/FullscreenIcon.vue deleted file mode 100644 index 383912d2b8..0000000000 --- a/apps/frontend/src/components/ui/servers/icons/FullscreenIcon.vue +++ /dev/null @@ -1,19 +0,0 @@ - diff --git a/apps/frontend/src/components/ui/servers/icons/ImageFileIcon.vue b/apps/frontend/src/components/ui/servers/icons/ImageFileIcon.vue deleted file mode 100644 index 6aa81c2779..0000000000 --- a/apps/frontend/src/components/ui/servers/icons/ImageFileIcon.vue +++ /dev/null @@ -1,18 +0,0 @@ - diff --git a/apps/frontend/src/components/ui/servers/icons/LoaderIcon.vue b/apps/frontend/src/components/ui/servers/icons/LoaderIcon.vue deleted file mode 100644 index 090bb945bd..0000000000 --- a/apps/frontend/src/components/ui/servers/icons/LoaderIcon.vue +++ /dev/null @@ -1,15 +0,0 @@ - - - diff --git a/apps/frontend/src/components/ui/servers/icons/LoadingIcon.vue b/apps/frontend/src/components/ui/servers/icons/LoadingIcon.vue deleted file mode 100644 index 9e9a8ad3fa..0000000000 --- a/apps/frontend/src/components/ui/servers/icons/LoadingIcon.vue +++ /dev/null @@ -1,9 +0,0 @@ - diff --git a/apps/frontend/src/components/ui/servers/icons/MinimizeIcon.vue.vue b/apps/frontend/src/components/ui/servers/icons/MinimizeIcon.vue.vue deleted file mode 100644 index 27b0fcad21..0000000000 --- a/apps/frontend/src/components/ui/servers/icons/MinimizeIcon.vue.vue +++ /dev/null @@ -1,19 +0,0 @@ - diff --git a/apps/frontend/src/components/ui/servers/icons/PanelErrorIcon.vue b/apps/frontend/src/components/ui/servers/icons/PanelErrorIcon.vue deleted file mode 100644 index 2ecad74dce..0000000000 --- a/apps/frontend/src/components/ui/servers/icons/PanelErrorIcon.vue +++ /dev/null @@ -1,16 +0,0 @@ - diff --git a/apps/frontend/src/components/ui/servers/icons/SlashIcon.vue b/apps/frontend/src/components/ui/servers/icons/SlashIcon.vue deleted file mode 100644 index 7f6e62fca2..0000000000 --- a/apps/frontend/src/components/ui/servers/icons/SlashIcon.vue +++ /dev/null @@ -1,10 +0,0 @@ - diff --git a/apps/frontend/src/components/ui/servers/icons/TextFileIcon.vue b/apps/frontend/src/components/ui/servers/icons/TextFileIcon.vue deleted file mode 100644 index 99bcee1ac1..0000000000 --- a/apps/frontend/src/components/ui/servers/icons/TextFileIcon.vue +++ /dev/null @@ -1,20 +0,0 @@ - diff --git a/apps/frontend/src/components/ui/servers/icons/Timer.vue b/apps/frontend/src/components/ui/servers/icons/Timer.vue deleted file mode 100644 index e1ead004b1..0000000000 --- a/apps/frontend/src/components/ui/servers/icons/Timer.vue +++ /dev/null @@ -1,17 +0,0 @@ - diff --git a/apps/frontend/src/components/ui/servers/notice/NoticeDashboardItem.vue b/apps/frontend/src/components/ui/servers/notice/NoticeDashboardItem.vue deleted file mode 100644 index 2208808456..0000000000 --- a/apps/frontend/src/components/ui/servers/notice/NoticeDashboardItem.vue +++ /dev/null @@ -1,127 +0,0 @@ - - diff --git a/apps/frontend/src/components/ui/thread/ThreadView.vue b/apps/frontend/src/components/ui/thread/ThreadView.vue index 608f0f1f10..dc9bcaa6c4 100644 --- a/apps/frontend/src/components/ui/thread/ThreadView.vue +++ b/apps/frontend/src/components/ui/thread/ThreadView.vue @@ -74,7 +74,7 @@ diff --git a/apps/frontend/src/pages/hosting/manage/[id]/index.vue b/apps/frontend/src/pages/hosting/manage/[id]/index.vue index 46e19ea119..9b4ae555cc 100644 --- a/apps/frontend/src/pages/hosting/manage/[id]/index.vue +++ b/apps/frontend/src/pages/hosting/manage/[id]/index.vue @@ -1,728 +1,13 @@ - - + + diff --git a/apps/frontend/src/pages/hosting/manage/[id]/options.vue b/apps/frontend/src/pages/hosting/manage/[id]/options.vue deleted file mode 100644 index 6fa28b857f..0000000000 --- a/apps/frontend/src/pages/hosting/manage/[id]/options.vue +++ /dev/null @@ -1,56 +0,0 @@ - - diff --git a/apps/frontend/src/pages/hosting/manage/[id]/options/advanced.vue b/apps/frontend/src/pages/hosting/manage/[id]/options/advanced.vue deleted file mode 100644 index b730006fff..0000000000 --- a/apps/frontend/src/pages/hosting/manage/[id]/options/advanced.vue +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/apps/frontend/src/pages/hosting/manage/[id]/options/billing.vue b/apps/frontend/src/pages/hosting/manage/[id]/options/billing.vue deleted file mode 100644 index 6e470aaafc..0000000000 --- a/apps/frontend/src/pages/hosting/manage/[id]/options/billing.vue +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/apps/frontend/src/pages/hosting/manage/[id]/options/index.vue b/apps/frontend/src/pages/hosting/manage/[id]/options/index.vue deleted file mode 100644 index 311bee0a9c..0000000000 --- a/apps/frontend/src/pages/hosting/manage/[id]/options/index.vue +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/apps/frontend/src/pages/hosting/manage/[id]/options/loader.vue b/apps/frontend/src/pages/hosting/manage/[id]/options/loader.vue deleted file mode 100644 index e3a54224ea..0000000000 --- a/apps/frontend/src/pages/hosting/manage/[id]/options/loader.vue +++ /dev/null @@ -1,16 +0,0 @@ - - - diff --git a/apps/frontend/src/pages/hosting/manage/[id]/options/network.vue b/apps/frontend/src/pages/hosting/manage/[id]/options/network.vue deleted file mode 100644 index a63867e288..0000000000 --- a/apps/frontend/src/pages/hosting/manage/[id]/options/network.vue +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/apps/frontend/src/pages/hosting/manage/[id]/options/properties.vue b/apps/frontend/src/pages/hosting/manage/[id]/options/properties.vue deleted file mode 100644 index 48c1fb8a6d..0000000000 --- a/apps/frontend/src/pages/hosting/manage/[id]/options/properties.vue +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/apps/frontend/src/pages/hosting/manage/index.vue b/apps/frontend/src/pages/hosting/manage/index.vue index 0cd0a74a32..3ea3fb216b 100644 --- a/apps/frontend/src/pages/hosting/manage/index.vue +++ b/apps/frontend/src/pages/hosting/manage/index.vue @@ -8,7 +8,7 @@ definePageMeta({ }) useHead({ - title: 'Servers - Modrinth', + title: 'Hosting - Modrinth', }) const config = useRuntimeConfig() diff --git a/apps/frontend/src/pages/settings/billing/index.vue b/apps/frontend/src/pages/settings/billing/index.vue index 0c6df65c7c..5a9cdd8dbc 100644 --- a/apps/frontend/src/pages/settings/billing/index.vue +++ b/apps/frontend/src/pages/settings/billing/index.vue @@ -718,7 +718,7 @@ import { useQuery, useQueryClient } from '@tanstack/vue-query' import { useIntervalFn } from '@vueuse/core' import { computed, ref, watch } from 'vue' -import ModrinthServersIcon from '~/components/ui/servers/ModrinthServersIcon.vue' +import ModrinthServersIcon from '~/components/brand/ModrinthServersIcon.vue' import ServersUpgradeModalWrapper from '~/components/ui/servers/ServersUpgradeModalWrapper.vue' import { products } from '~/generated/state.json' @@ -836,6 +836,14 @@ const messages = defineMessages({ id: 'settings.billing.interval.year', defaultMessage: 'year', }, + intervalQuarter: { + id: 'settings.billing.interval.quarter', + defaultMessage: 'quarter', + }, + intervalQuarterly: { + id: 'settings.billing.interval.quarterly.adjective', + defaultMessage: 'quarterly', + }, intervalMonthly: { id: 'settings.billing.interval.monthly', defaultMessage: 'monthly', @@ -1016,15 +1024,20 @@ const messages = defineMessages({ }) function getIntervalNounLabel(interval) { + console.log(interval) return interval === 'yearly' ? formatMessage(messages.intervalYear) - : formatMessage(messages.intervalMonth) + : interval === 'quarterly' + ? formatMessage(messages.intervalQuarter) + : formatMessage(messages.intervalMonth) } function getIntervalAdjectiveLabel(interval) { return interval === 'yearly' ? formatMessage(messages.intervalYearly) - : formatMessage(messages.intervalMonthly) + : interval === 'quarterly' + ? formatMessage(messages.intervalQuarterly) + : formatMessage(messages.intervalMonthly) } const queryClient = useQueryClient() diff --git a/apps/frontend/src/providers/setup/auth.ts b/apps/frontend/src/providers/setup/auth.ts index e19d1a3c88..cb15021c44 100644 --- a/apps/frontend/src/providers/setup/auth.ts +++ b/apps/frontend/src/providers/setup/auth.ts @@ -13,6 +13,7 @@ export function setupAuthProvider(auth: Awaited>) { const authProvider: AuthProvider = { session_token: sessionToken, user, + isReady: ref(true), requestSignIn: async (redirectPath: string) => { await router.push({ path: '/auth/sign-in', diff --git a/apps/frontend/src/providers/setup/page-context.ts b/apps/frontend/src/providers/setup/page-context.ts index dd5d0ef5ac..919a146c62 100644 --- a/apps/frontend/src/providers/setup/page-context.ts +++ b/apps/frontend/src/providers/setup/page-context.ts @@ -7,6 +7,7 @@ export function setupPageContextProvider() { providePageContext({ hierarchicalSidebarAvailable: ref(false), showAds: ref(false), + openExternalUrl: (url) => window.open(url, '_blank'), }) provideModalBehavior({ noblur: computed(() => !(cosmetics.value?.advancedRendering ?? true)), diff --git a/apps/frontend/src/store/console.ts b/apps/frontend/src/store/console.ts index 5e9d0169ad..fb72b37c86 100644 --- a/apps/frontend/src/store/console.ts +++ b/apps/frontend/src/store/console.ts @@ -1,164 +1 @@ -import { createGlobalState } from '@vueuse/core' -import { type Ref, shallowRef } from 'vue' - -/** - * Maximum number of console output lines to store - * @type {number} - */ -const maxLines = 10000 -const batchTimeout = 300 // ms -const initialBatchSize = 256 - -/** - * Provides a global console output state management system - * Allows adding, storing, and clearing console output with a maximum line limit - * - * @returns {Object} Console state management methods and reactive state - * @property {Ref} consoleOutput - Reactive array of console output lines - * @property {function(string): void} addConsoleOutput - Method to add a new console output line - * @property {function(): void} clear - Method to clear all console output - */ -export const useModrinthServersConsole = createGlobalState(() => { - /** - * Reactive array storing console output lines - * @type {Ref} - */ - const output: Ref = shallowRef([]) - const searchQuery: Ref = shallowRef('') - const filteredOutput: Ref = shallowRef([]) - let searchRegex: RegExp | null = null - - let lineBuffer: string[] = [] - let batchTimer: NodeJS.Timeout | null = null - let isProcessingInitialBatch = false - - let refilterTimer: NodeJS.Timeout | null = null - const refilterTimeout = 100 // ms - - const updateFilter = () => { - if (!searchQuery.value) { - filteredOutput.value = [] - return - } - - if (!searchRegex) { - searchRegex = new RegExp(searchQuery.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i') - } - - filteredOutput.value = output.value.filter((line) => searchRegex?.test(line) ?? false) - } - - const scheduleRefilter = () => { - if (refilterTimer) clearTimeout(refilterTimer) - refilterTimer = setTimeout(updateFilter, refilterTimeout) - } - - const flushBuffer = () => { - if (lineBuffer.length === 0) return - - const processedLines = lineBuffer.flatMap((line) => line.split('\n').filter(Boolean)) - - if (isProcessingInitialBatch && processedLines.length >= initialBatchSize) { - isProcessingInitialBatch = false - output.value = processedLines.slice(-maxLines) - } else { - const newOutput = [...output.value, ...processedLines] - output.value = newOutput.slice(-maxLines) - } - - lineBuffer = [] - batchTimer = null - - if (searchQuery.value) { - scheduleRefilter() - } - } - - /** - * Adds a new output line to the console output - * Automatically removes the oldest line if max output is exceeded - * - * @param {string} line - The console output line to add - */ - const addLine = (line: string): void => { - lineBuffer.push(line) - - if (!batchTimer) { - batchTimer = setTimeout(flushBuffer, batchTimeout) - } - } - - /** - * Adds multiple output lines to the console output - * Automatically removes the oldest lines if max output is exceeded - * - * @param {string[]} lines - The console output lines to add - * @returns {void} - */ - const addLines = (lines: string[]): void => { - if (output.value.length === 0 && lines.length >= initialBatchSize) { - isProcessingInitialBatch = true - lineBuffer = lines - flushBuffer() - return - } - - lineBuffer.push(...lines) - - if (!batchTimer) { - batchTimer = setTimeout(flushBuffer, batchTimeout) - } - } - - /** - * Sets the search query and filters the output based on the query - * - * @param {string} query - The search query - */ - const setSearchQuery = (query: string): void => { - searchQuery.value = query - searchRegex = null - updateFilter() - } - - /** - * Clears all console output lines - */ - const clear = (): void => { - output.value = [] - filteredOutput.value = [] - searchQuery.value = '' - lineBuffer = [] - isProcessingInitialBatch = false - if (batchTimer) { - clearTimeout(batchTimer) - batchTimer = null - } - if (refilterTimer) { - clearTimeout(refilterTimer) - refilterTimer = null - } - searchRegex = null - } - - /** - * Finds the index of a line in the main output - * - * @param {string} line - The line to find - * @returns {number} The index of the line, or -1 if not found - */ - const findLineIndex = (line: string): number => { - return output.value.findIndex((l) => l === line) - } - - return { - output, - searchQuery, - filteredOutput, - addLine, - addLines, - setSearchQuery, - clear, - findLineIndex, - } -}) +export { useModrinthServersConsole } from '@modrinth/ui' diff --git a/packages/api-client/CLAUDE.md b/packages/api-client/CLAUDE.md index 12752dc2fb..015556ee67 100644 --- a/packages/api-client/CLAUDE.md +++ b/packages/api-client/CLAUDE.md @@ -47,6 +47,22 @@ client.iso3166.data This structure is derived at runtime from the flat `MODULE_REGISTRY` in `modules/index.ts` via `buildModuleStructure()`, and the TypeScript types are inferred automatically via `InferredClientModules`. +## Critical: Always use `this.client.request()` + +API modules **must** use `this.client.request()` (or `.upload`) for all HTTP calls — never `$fetch`, `fetch`, or any other HTTP library directly. The request method routes through the platform-specific implementation (Nuxt `$fetch`, Tauri HTTP plugin, etc.) and the feature middleware chain (auth, retry, circuit breaker). Using `$fetch` directly bypasses the platform layer and will fail in Tauri (CORS/sandboxing). The only exception is the `ISO3166Module` which is explicitly node-only. + +For external APIs (non-Modrinth), pass the full base URL as the `api` field and set `skipAuth: true`: + +```ts +this.client.request('/endpoint', { + api: 'https://external-api.com', + version: 1, + method: 'POST', + body: { data }, + skipAuth: true, +}) +``` + ## Usage The client is provided to the component tree via DI (see the `dependency-injection` skill). Each app creates a platform-specific client and provides it at the root: diff --git a/packages/api-client/src/modules/archon/types.ts b/packages/api-client/src/modules/archon/types.ts index 9c0c315f8e..9af5472ce2 100644 --- a/packages/api-client/src/modules/archon/types.ts +++ b/packages/api-client/src/modules/archon/types.ts @@ -499,6 +499,16 @@ export namespace Archon { message: string } + export type WSLog4jEvent = { + event: 'log4j' + logger_name?: string + level?: string + thread_name?: string + timestamp_millis?: number + message?: string + throwable?: string + } + export type WSStatsEvent = { event: 'stats' cpu_percent: number @@ -644,6 +654,7 @@ export namespace Archon { export type WSEvent = | WSBackupProgressEvent | WSLogEvent + | WSLog4jEvent | WSStatsEvent | WSPowerStateEvent | WSStateEvent diff --git a/packages/api-client/src/modules/index.ts b/packages/api-client/src/modules/index.ts index 3312b7388e..a98f7ce35f 100644 --- a/packages/api-client/src/modules/index.ts +++ b/packages/api-client/src/modules/index.ts @@ -36,6 +36,8 @@ import { LabrinthTechReviewInternalModule } from './labrinth/tech-review/interna import { LabrinthThreadsV3Module } from './labrinth/threads/v3' import { LabrinthUsersV2Module } from './labrinth/users/v2' import { LauncherMetaManifestV0Module } from './launcher-meta/v0' +import { MclogsInsightsV1Module } from './mclogs/insights/v1' +import { MclogsLogsV1Module } from './mclogs/logs/v1' import { PaperVersionsV3Module } from './paper/v3' import { PurpurVersionsV2Module } from './purpur/v2' @@ -58,6 +60,8 @@ export const MODULE_REGISTRY = { archon_servers_v0: ArchonServersV0Module, archon_servers_v1: ArchonServersV1Module, iso3166_data: ISO3166Module, + mclogs_insights_v1: MclogsInsightsV1Module, + mclogs_logs_v1: MclogsLogsV1Module, launchermeta_manifest_v0: LauncherMetaManifestV0Module, kyros_content_v1: KyrosContentV1Module, kyros_files_v0: KyrosFilesV0Module, diff --git a/packages/api-client/src/modules/kyros/files/v0.ts b/packages/api-client/src/modules/kyros/files/v0.ts index 4712c08379..fb8f4d19b7 100644 --- a/packages/api-client/src/modules/kyros/files/v0.ts +++ b/packages/api-client/src/modules/kyros/files/v0.ts @@ -1,12 +1,19 @@ import { AbstractModule } from '../../../core/abstract-module' import type { UploadHandle, UploadProgress } from '../../../types/upload' +import type { Archon } from '../../archon/types' import type { Kyros } from '../types' +type NodeFsAuth = Pick + export class KyrosFilesV0Module extends AbstractModule { public getModuleID(): string { return 'kyros_files_v0' } + private getNodeBaseUrl(auth: NodeFsAuth): string { + return `https://${auth.url.replace(/\/modrinth\/v\d+\/fs\/?$/, '')}` + } + /** * List directory contents with pagination * @@ -62,6 +69,24 @@ export class KyrosFilesV0Module extends AbstractModule { }) } + /** + * Download a file using explicit filesystem auth credentials. + * + * @param auth - Filesystem auth (url + token) from Archon + * @param path - File path (e.g., "/server-icon.png") + * @returns Promise resolving to file Blob + */ + public async downloadFileWithAuth(auth: NodeFsAuth, path: string): Promise { + return this.client.request('/fs/download', { + api: this.getNodeBaseUrl(auth), + version: 'modrinth/v0', + method: 'GET', + params: { path }, + headers: { Authorization: `Bearer ${auth.token}` }, + skipAuth: true, + }) + } + /** * Upload a file to a server's filesystem with progress tracking * @@ -89,6 +114,36 @@ export class KyrosFilesV0Module extends AbstractModule { }) } + /** + * Upload a file using explicit filesystem auth credentials. + * + * @param auth - Filesystem auth (url + token) from Archon + * @param path - Destination path (e.g., "/server-icon.png") + * @param file - File to upload + * @param options - Optional progress callback and feature overrides + * @returns UploadHandle with promise, onProgress, and cancel + */ + public uploadFileWithAuth( + auth: NodeFsAuth, + path: string, + file: File | Blob, + options?: { + onProgress?: (progress: UploadProgress) => void + retry?: boolean | number + }, + ): UploadHandle { + return this.client.upload('/fs/create', { + api: this.getNodeBaseUrl(auth), + version: 'modrinth/v0', + file, + params: { path, type: 'file' }, + headers: { Authorization: `Bearer ${auth.token}` }, + onProgress: options?.onProgress, + retry: options?.retry, + skipAuth: true, + }) + } + /** * Update file contents * @@ -152,6 +207,28 @@ export class KyrosFilesV0Module extends AbstractModule { }) } + /** + * Delete a file or folder using explicit filesystem auth credentials. + * + * @param auth - Filesystem auth (url + token) from Archon + * @param path - Path to delete + * @param recursive - If true, delete directory contents recursively + */ + public async deleteFileOrFolderWithAuth( + auth: NodeFsAuth, + path: string, + recursive: boolean, + ): Promise { + return this.client.request('/fs/delete', { + api: this.getNodeBaseUrl(auth), + version: 'modrinth/v0', + method: 'DELETE', + params: { path, recursive }, + headers: { Authorization: `Bearer ${auth.token}` }, + skipAuth: true, + }) + } + /** * Extract an archive file (zip, tar, etc.) * diff --git a/packages/api-client/src/modules/mclogs/insights/v1.ts b/packages/api-client/src/modules/mclogs/insights/v1.ts new file mode 100644 index 0000000000..61fb18df4d --- /dev/null +++ b/packages/api-client/src/modules/mclogs/insights/v1.ts @@ -0,0 +1,18 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { Mclogs } from '../types' + +export class MclogsInsightsV1Module extends AbstractModule { + public getModuleID(): string { + return 'mclogs_insights_v1' + } + + public async analyse(content: string): Promise { + return this.client.request('/analyse', { + api: 'https://api.mclo.gs', + version: '1', + method: 'POST', + body: { content }, + skipAuth: true, + }) + } +} diff --git a/packages/api-client/src/modules/mclogs/logs/v1.ts b/packages/api-client/src/modules/mclogs/logs/v1.ts new file mode 100644 index 0000000000..c703cf9100 --- /dev/null +++ b/packages/api-client/src/modules/mclogs/logs/v1.ts @@ -0,0 +1,18 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { Mclogs } from '../types' + +export class MclogsLogsV1Module extends AbstractModule { + public getModuleID(): string { + return 'mclogs_logs_v1' + } + + public async create(content: string): Promise { + return this.client.request('/log', { + api: 'https://api.mclo.gs', + version: '1', + method: 'POST', + body: { content }, + skipAuth: true, + }) + } +} diff --git a/packages/api-client/src/modules/mclogs/types.ts b/packages/api-client/src/modules/mclogs/types.ts new file mode 100644 index 0000000000..2894827b52 --- /dev/null +++ b/packages/api-client/src/modules/mclogs/types.ts @@ -0,0 +1,63 @@ +export namespace Mclogs { + export namespace Insights { + export namespace v1 { + export type LogEntry = { + level: number + time: string | null + prefix: string + lines: Array<{ number: number; content: string }> + } + + export type Solution = { + message: string + } + + export type Problem = { + message: string + counter: number + entry: LogEntry + solutions: Solution[] + } + + export type Information = { + message: string + counter: number + label: string + value: string + entry: LogEntry + } + + export type Analysis = { + problems: Problem[] + information: Information[] + } + + export type InsightsResponse = { + id: string + name: string + type: string + version: string + title: string + analysis: Analysis + } + } + } + + export namespace Logs { + export namespace v1 { + export type CreateResponse = { + success: boolean + id: string + source: string | null + created: number + expires: number + size: number + lines: number + errors: number + url: string + raw: string + token: string + } + } + } +} diff --git a/packages/api-client/src/modules/types.ts b/packages/api-client/src/modules/types.ts index 3baa5cf661..d86e87723d 100644 --- a/packages/api-client/src/modules/types.ts +++ b/packages/api-client/src/modules/types.ts @@ -3,5 +3,6 @@ export * from './iso3166/types' export * from './kyros/types' export * from './labrinth/types' export * from './launcher-meta/types' +export * from './mclogs/types' export * from './paper/types' export * from './purpur/types' diff --git a/packages/api-client/src/platform/tauri.ts b/packages/api-client/src/platform/tauri.ts index 7e57fc2a53..6b3c2ed616 100644 --- a/packages/api-client/src/platform/tauri.ts +++ b/packages/api-client/src/platform/tauri.ts @@ -103,11 +103,38 @@ export class TauriModrinthClient extends XHRUploadClient { throw error } + // Handle binary downloads (e.g. kyros fs files) before JSON parsing. + const contentType = response.headers.get('content-type')?.toLowerCase() ?? '' + if (fullUrl.includes('/fs/download')) { + return (await response.blob()) as T + } + if ( + contentType.startsWith('image/') || + contentType.startsWith('audio/') || + contentType.startsWith('video/') || + contentType.includes('application/octet-stream') + ) { + return (await response.blob()) as T + } + + if (response.status === 204 || response.status === 205) { + return undefined as T + } + + if (contentType.includes('application/json') || contentType.includes('+json')) { + return (await response.json()) as T + } + const text = await response.text() if (!text) { return undefined as T } - return JSON.parse(text) as T + + try { + return JSON.parse(text) as T + } catch { + return text as T + } } catch (error) { throw this.normalizeError(error) } diff --git a/packages/app-lib/.env.prod b/packages/app-lib/.env.prod index a073e5c964..94f2c2d34e 100644 --- a/packages/app-lib/.env.prod +++ b/packages/app-lib/.env.prod @@ -1,5 +1,6 @@ MODRINTH_URL=https://modrinth.com/ MODRINTH_API_BASE_URL=https://api.modrinth.com/ +MODRINTH_ARCHON_BASE_URL=https://archon.modrinth.com/ MODRINTH_API_URL=https://api.modrinth.com/v2/ MODRINTH_API_URL_V3=https://api.modrinth.com/v3/ MODRINTH_SOCKET_URL=wss://api.modrinth.com/ diff --git a/packages/app-lib/.env.prod-with-staging-archon b/packages/app-lib/.env.prod-with-staging-archon new file mode 100644 index 0000000000..b2d549c0e1 --- /dev/null +++ b/packages/app-lib/.env.prod-with-staging-archon @@ -0,0 +1,12 @@ +MODRINTH_URL=https://modrinth.com/ +MODRINTH_API_BASE_URL=https://api.modrinth.com/ +MODRINTH_ARCHON_BASE_URL=https://staging-archon.modrinth.com/ +MODRINTH_API_URL=https://api.modrinth.com/v2/ +MODRINTH_API_URL_V3=https://api.modrinth.com/v3/ +MODRINTH_SOCKET_URL=wss://api.modrinth.com/ +MODRINTH_LAUNCHER_META_URL=https://launcher-meta.modrinth.com/ + +# SQLite database file used by sqlx for type checking. Uncomment this to a valid path +# in your system and run `cargo sqlx database setup` to generate an empty database that +# can be used for developing the app DB schema +#DATABASE_URL=sqlite:///tmp/Modrinth/code/packages/app-lib/.sqlx/generated/state.db diff --git a/packages/app-lib/.env.staging b/packages/app-lib/.env.staging index d68aa0b08f..0094b73e5a 100644 --- a/packages/app-lib/.env.staging +++ b/packages/app-lib/.env.staging @@ -1,5 +1,6 @@ MODRINTH_URL=https://staging.modrinth.com/ MODRINTH_API_BASE_URL=https://staging-api.modrinth.com/ +MODRINTH_ARCHON_BASE_URL=https://staging-archon.modrinth.com/ MODRINTH_API_URL=https://staging-api.modrinth.com/v2/ MODRINTH_API_URL_V3=https://staging-api.modrinth.com/v3/ MODRINTH_SOCKET_URL=wss://staging-api.modrinth.com/ diff --git a/packages/app-lib/src/api/logs.rs b/packages/app-lib/src/api/logs.rs index 265d9bcb45..c174977ed7 100644 --- a/packages/app-lib/src/api/logs.rs +++ b/packages/app-lib/src/api/logs.rs @@ -299,6 +299,26 @@ pub async fn delete_logs_by_filename( Ok(()) } +#[tracing::instrument] +pub async fn get_live_log_buffer( + profile_path: &str, +) -> crate::Result { + let state = State::get().await?; + let lines = crate::state::get_log_buffer(profile_path); + let joined = lines.join("\n"); + + let credentials = Credentials::get_all(&state.pool) + .await? + .into_iter() + .map(|x| x.1) + .collect::>(); + Ok(CensoredString::censor(joined, &credentials)) +} + +pub fn clear_live_log_buffer(profile_path: &str) { + crate::state::remove_log_buffer(profile_path); +} + #[tracing::instrument] pub async fn get_latest_log_cursor( profile_path: &str, diff --git a/packages/app-lib/src/event/mod.rs b/packages/app-lib/src/event/mod.rs index a65a7270d2..f570d78a9a 100644 --- a/packages/app-lib/src/event/mod.rs +++ b/packages/app-lib/src/event/mod.rs @@ -270,6 +270,29 @@ pub enum FriendPayload { StatusSync, } +#[cfg(feature = "tauri")] +pub use self::log_types::*; + +#[cfg(feature = "tauri")] +mod log_types { + use crate::state::Log4jEvent; + use serde::Serialize; + + #[derive(Serialize, Clone)] + #[serde(tag = "type", rename_all = "snake_case")] + pub enum LogEvent { + Log4j(Log4jEvent), + Legacy { message: String }, + } + + #[derive(Serialize, Clone)] + pub struct LogPayload { + pub profile_path_id: String, + #[serde(flatten)] + pub event: LogEvent, + } +} + #[derive(Debug, thiserror::Error)] pub enum EventError { #[error("Event state was not properly initialized")] diff --git a/packages/app-lib/src/state/process.rs b/packages/app-lib/src/state/process.rs index bfcb70cf1d..fb6f0da317 100644 --- a/packages/app-lib/src/state/process.rs +++ b/packages/app-lib/src/state/process.rs @@ -1,4 +1,6 @@ use crate::event::emit::{emit_process, emit_profile}; +#[cfg(feature = "tauri")] +use crate::event::{LogEvent, LogPayload}; use crate::event::{ProcessPayloadType, ProfilePayloadType}; use crate::profile; use crate::util::io::IOError; @@ -9,17 +11,76 @@ use quick_xml::Reader; use quick_xml::events::Event; use serde::Deserialize; use serde::Serialize; +use std::collections::VecDeque; use std::fmt::Debug; use std::fs::OpenOptions; use std::io::Write; use std::path::{Path, PathBuf}; use std::process::ExitStatus; +use std::sync::LazyLock; +#[cfg(feature = "tauri")] +use tauri::Emitter; use tempfile::TempDir; use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::{Child, Command}; use uuid::Uuid; const LAUNCHER_LOG_PATH: &str = "launcher_log.txt"; +const LOG_BUFFER_CAPACITY: usize = 50_000; + +struct LogRingBuffer { + lines: VecDeque, +} + +impl LogRingBuffer { + fn new() -> Self { + Self { + lines: VecDeque::new(), + } + } + + fn push(&mut self, line: String) { + if self.lines.len() >= LOG_BUFFER_CAPACITY { + self.lines.pop_front(); + } + self.lines.push_back(line); + } + + fn get_all(&self) -> Vec { + self.lines.iter().cloned().collect() + } + + fn clear(&mut self) { + self.lines.clear(); + } +} + +static LOG_BUFFERS: LazyLock> = + LazyLock::new(DashMap::new); + +pub fn push_log_line(profile_path: &str, line: String) { + LOG_BUFFERS + .entry(profile_path.to_string()) + .or_insert_with(LogRingBuffer::new) + .push(line); +} + +pub fn get_log_buffer(profile_path: &str) -> Vec { + LOG_BUFFERS + .get(profile_path) + .map(|buf| buf.get_all()) + .unwrap_or_default() +} + +pub fn clear_log_buffer(profile_path: &str) { + if let Some(mut buf) = LOG_BUFFERS.get_mut(profile_path) { + buf.clear(); + } +} + +pub fn remove_log_buffer(profile_path: &str) { + LOG_BUFFERS.remove(profile_path); +} pub struct ProcessManager { processes: DashMap, @@ -91,6 +152,8 @@ impl ProcessManager { let log_path = logs_folder.join(LAUNCHER_LOG_PATH); + clear_log_buffer(profile_path); + { let mut log_file = OpenOptions::new() .write(true) @@ -222,13 +285,14 @@ struct Process { rpc_server: RpcServer, } -#[derive(Debug, Default)] -struct Log4jEvent { - timestamp: Option, - logger: Option, - level: Option, - thread: Option, - message: Option, +#[derive(Debug, Default, Serialize, Clone)] +pub struct Log4jEvent { + pub timestamp_millis: Option, + pub logger_name: Option, + pub level: Option, + pub thread_name: Option, + pub message: Option, + pub throwable: Option, } impl Process { @@ -285,17 +349,19 @@ impl Process { match key.as_str() { "logger" => { - current_event.logger = Some(value) + current_event.logger_name = + Some(value) } "level" => { current_event.level = Some(value) } "thread" => { - current_event.thread = Some(value) + current_event.thread_name = + Some(value) } "timestamp" => { - current_event.timestamp = - Some(value) + current_event.timestamp_millis = + value.parse::().ok() } _ => {} } @@ -321,39 +387,17 @@ impl Process { } b"log4j:Throwable" => { in_throwable = false; - // Process and write the log entry - let thread = current_event - .thread - .as_deref() - .unwrap_or(""); - let level = current_event - .level - .as_deref() - .unwrap_or(""); - let logger = current_event - .logger - .as_deref() - .unwrap_or(""); - - if let Some(message) = ¤t_event.message { - let formatted_time = - Process::format_timestamp( - current_event.timestamp.as_deref(), - ); - let formatted_log = format!( - "{} [{}] [{}{}]: {}\n", - formatted_time, - thread, - if !logger.is_empty() { - format!("{logger}/") - } else { - String::new() - }, - level, - message.trim() - ); - - // Write the log message + current_event.throwable = + if current_content.is_empty() { + None + } else { + Some(current_content.clone()) + }; + + // Write log entry + throwable to file + if let Some(formatted_log) = + Self::format_log4j_entry(¤t_event) + { if let Err(e) = Process::append_to_log_file( &log_path, &formatted_log, @@ -364,12 +408,11 @@ impl Process { ); } - // Write the throwable if present - if !current_content.is_empty() + if let Some(ref throwable) = + current_event.throwable && let Err(e) = Process::append_to_log_file( - &log_path, - ¤t_content, + &log_path, throwable, ) { tracing::error!( @@ -378,68 +421,55 @@ impl Process { ); } } + + Self::emit_log4j_event( + profile_path, + ¤t_event, + ); } b"log4j:Event" => { in_event = false; // If no throwable was present, write the log entry at the end of the event if current_event.message.is_some() - && !in_throwable + && current_event.throwable.is_none() { - let thread = current_event - .thread - .as_deref() - .unwrap_or(""); - let level = current_event - .level - .as_deref() - .unwrap_or(""); - let logger = current_event - .logger - .as_deref() - .unwrap_or(""); - let message = current_event - .message - .as_deref() - .unwrap_or("") - .trim(); - - let formatted_time = - Process::format_timestamp( - current_event.timestamp.as_deref(), - ); - let formatted_log = format!( - "{} [{}] [{}{}]: {}\n", - formatted_time, - thread, - if !logger.is_empty() { - format!("{logger}/") - } else { - String::new() - }, - level, - message - ); - - // Write the log message - if let Err(e) = Process::append_to_log_file( - &log_path, - &formatted_log, - ) { + if let Some(formatted_log) = + Self::format_log4j_entry(¤t_event) + && let Err(e) = + Process::append_to_log_file( + &log_path, + &formatted_log, + ) + { tracing::error!( "Failed to write to log file: {}", e ); } - if let Some(timestamp) = - current_event.timestamp.as_deref() - && let Err(e) = Self::maybe_handle_server_join_logging( + if let Some(timestamp_millis) = + current_event.timestamp_millis + { + let timestamp = + timestamp_millis.to_string(); + let message = current_event + .message + .as_deref() + .unwrap_or("") + .trim(); + if let Err(e) = Self::maybe_handle_server_join_logging( profile_path, - timestamp, - message + ×tamp, + message, ).await { tracing::error!("Failed to handle server join logging: {e}"); } + } + + Self::emit_log4j_event( + profile_path, + ¤t_event, + ); } } _ => {} @@ -454,15 +484,17 @@ impl Process { && !e.inplace_trim_end() && !e.inplace_trim_start() && let Ok(text) = e.xml_content() - && let Err(e) = Process::append_to_log_file( + { + if let Err(e) = Process::append_to_log_file( &log_path, &format!("{text}\n"), - ) - { - tracing::error!( - "Failed to write to log file: {}", - e - ); + ) { + tracing::error!( + "Failed to write to log file: {}", + e + ); + } + Self::emit_legacy_log(profile_path, &text); } } Ok(Event::CData(e)) => { @@ -489,6 +521,7 @@ impl Process { if let Err(e) = Self::append_to_log_file(&log_path, &line) { tracing::warn!("Failed to write to log file: {}", e); } + Self::emit_legacy_log(profile_path, line.trim_ascii_end()); if let Err(e) = Self::maybe_handle_old_server_join_logging( profile_path, line.trim_ascii_end(), @@ -506,30 +539,98 @@ impl Process { } } - fn format_timestamp(timestamp: Option<&str>) -> String { - if let Some(timestamp_str) = timestamp { - if let Ok(timestamp_val) = timestamp_str.parse::() { - let datetime_utc = if timestamp_val > i32::MAX as i64 { - let secs = timestamp_val / 1000; - let nsecs = ((timestamp_val % 1000) * 1_000_000) as u32; - - chrono::DateTime::::from_timestamp(secs, nsecs) - .unwrap_or_default() - } else { - chrono::DateTime::::from_timestamp_secs(timestamp_val) - .unwrap_or_default() - }; - - let datetime_local = datetime_utc.with_timezone(&chrono::Local); - format!("[{}]", datetime_local.format("%H:%M:%S")) + fn format_timestamp(timestamp_millis: Option) -> String { + if let Some(timestamp_val) = timestamp_millis { + let datetime_utc = if timestamp_val > i32::MAX as i64 { + let secs = timestamp_val / 1000; + let nsecs = ((timestamp_val % 1000) * 1_000_000) as u32; + + chrono::DateTime::::from_timestamp(secs, nsecs) + .unwrap_or_default() } else { - "[??:??:??]".to_string() - } + chrono::DateTime::::from_timestamp_secs(timestamp_val) + .unwrap_or_default() + }; + + let datetime_local = datetime_utc.with_timezone(&chrono::Local); + format!("[{}]", datetime_local.format("%H:%M:%S")) } else { "[??:??:??]".to_string() } } + fn format_log4j_entry(event: &Log4jEvent) -> Option { + let message = event.message.as_ref()?; + let thread = event.thread_name.as_deref().unwrap_or(""); + let level = event.level.as_deref().unwrap_or(""); + let logger = event.logger_name.as_deref().unwrap_or(""); + let formatted_time = Self::format_timestamp(event.timestamp_millis); + + Some(format!( + "{} [{}] [{}{}]: {}\n", + formatted_time, + thread, + if !logger.is_empty() { + format!("{logger}/") + } else { + String::new() + }, + level, + message.trim() + )) + } + + fn emit_log4j_event(profile_path: &str, event: &Log4jEvent) { + if let Some(formatted) = Self::format_log4j_entry(event) { + push_log_line(profile_path, formatted.trim_end().to_string()); + } + if let Some(ref throwable) = event.throwable { + for line in throwable.lines().filter(|l| !l.is_empty()) { + push_log_line(profile_path, line.to_string()); + } + } + + #[cfg(feature = "tauri")] + { + if let Ok(event_state) = crate::EventState::get() { + let _ = event_state.app.emit( + "log", + LogPayload { + profile_path_id: profile_path.to_string(), + event: LogEvent::Log4j(event.clone()), + }, + ); + } + } + #[cfg(not(feature = "tauri"))] + { + let _ = (profile_path, event); + } + } + + fn emit_legacy_log(profile_path: &str, message: &str) { + push_log_line(profile_path, message.to_string()); + + #[cfg(feature = "tauri")] + { + if let Ok(event_state) = crate::EventState::get() { + let _ = event_state.app.emit( + "log", + LogPayload { + profile_path_id: profile_path.to_string(), + event: LogEvent::Legacy { + message: message.to_string(), + }, + }, + ); + } + } + #[cfg(not(feature = "tauri"))] + { + let _ = (profile_path, message); + } + } + fn append_to_log_file( path: impl AsRef, line: &str, diff --git a/packages/assets/generated-icons.ts b/packages/assets/generated-icons.ts index ff66853f3d..7e815d7931 100644 --- a/packages/assets/generated-icons.ts +++ b/packages/assets/generated-icons.ts @@ -3,8 +3,6 @@ import type { FunctionalComponent, SVGAttributes } from 'vue' -export type IconComponent = FunctionalComponent - import _AffiliateIcon from './icons/affiliate.svg?component' import _AlignLeftIcon from './icons/align-left.svg?component' import _ArchiveIcon from './icons/archive.svg?component' @@ -202,6 +200,7 @@ import _SearchIcon from './icons/search.svg?component' import _SendIcon from './icons/send.svg?component' import _ServerIcon from './icons/server.svg?component' import _ServerPlusIcon from './icons/server-plus.svg?component' +import _ServerStackIcon from './icons/server-stack.svg?component' import _SettingsIcon from './icons/settings.svg?component' import _Settings2Icon from './icons/settings-2.svg?component' import _ShareIcon from './icons/share.svg?component' @@ -386,12 +385,15 @@ import _VersionIcon from './icons/version.svg?component' import _WikiIcon from './icons/wiki.svg?component' import _WindowIcon from './icons/window.svg?component' import _WorldIcon from './icons/world.svg?component' +import _WrapTextIcon from './icons/wrap-text.svg?component' import _WrenchIcon from './icons/wrench.svg?component' import _XIcon from './icons/x.svg?component' import _XCircleIcon from './icons/x-circle.svg?component' import _ZoomInIcon from './icons/zoom-in.svg?component' import _ZoomOutIcon from './icons/zoom-out.svg?component' +export type IconComponent = FunctionalComponent + export const AffiliateIcon = _AffiliateIcon export const AlignLeftIcon = _AlignLeftIcon export const ArchiveIcon = _ArchiveIcon @@ -589,6 +591,7 @@ export const SearchIcon = _SearchIcon export const SendIcon = _SendIcon export const ServerIcon = _ServerIcon export const ServerPlusIcon = _ServerPlusIcon +export const ServerStackIcon = _ServerStackIcon export const SettingsIcon = _SettingsIcon export const Settings2Icon = _Settings2Icon export const ShareIcon = _ShareIcon @@ -773,6 +776,7 @@ export const VersionIcon = _VersionIcon export const WikiIcon = _WikiIcon export const WindowIcon = _WindowIcon export const WorldIcon = _WorldIcon +export const WrapTextIcon = _WrapTextIcon export const WrenchIcon = _WrenchIcon export const XIcon = _XIcon export const XCircleIcon = _XCircleIcon diff --git a/packages/assets/icons/server-stack.svg b/packages/assets/icons/server-stack.svg new file mode 100644 index 0000000000..81f8f6d565 --- /dev/null +++ b/packages/assets/icons/server-stack.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/packages/assets/icons/server.svg b/packages/assets/icons/server.svg index 54e05379db..a09b0fee4b 100644 --- a/packages/assets/icons/server.svg +++ b/packages/assets/icons/server.svg @@ -1 +1,18 @@ - + + + + + + + diff --git a/packages/assets/icons/wrap-text.svg b/packages/assets/icons/wrap-text.svg new file mode 100644 index 0000000000..ed9eb6b325 --- /dev/null +++ b/packages/assets/icons/wrap-text.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/packages/ui/CLAUDE.md b/packages/ui/CLAUDE.md index b5000aeed4..db030d9b54 100644 --- a/packages/ui/CLAUDE.md +++ b/packages/ui/CLAUDE.md @@ -80,3 +80,17 @@ Key providers exported from this package: - `provideModrinthClient` / `injectModrinthClient` — API client - `provideNotificationManager` / `injectNotificationManager` — Notifications + +## Vue Template Rules + +### Multi-statement event handlers + +Never use newline-separated statements in Vue template event handlers like `@click`. Vue's template compiler cannot parse multi-line expressions separated only by newlines. Always use semicolons on a single line: + +```vue + +@click=" foo = true $emit('bar') " + + +@click="foo = true; $emit('bar')" +``` diff --git a/packages/ui/package.json b/packages/ui/package.json index 2132684af4..5529a0c682 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -54,6 +54,7 @@ "@codemirror/language": "^6.9.3", "@codemirror/state": "^6.3.2", "@codemirror/view": "^6.22.1", + "@intercom/messenger-js-sdk": "^0.0.14", "@modrinth/api-client": "workspace:*", "@modrinth/assets": "workspace:*", "@modrinth/blog": "workspace:*", @@ -62,6 +63,7 @@ "@tresjs/cientos": "^4.3.0", "@tresjs/core": "^4.3.4", "@tresjs/post-processing": "^2.4.0", + "@types/dompurify": "^3.0.5", "@types/markdown-it": "^14.1.1", "@types/three": "^0.172.0", "@vintl/how-ago": "^3.0.1", @@ -72,6 +74,7 @@ "ace-builds": "^1.43.5", "apexcharts": "^4.0.0", "dayjs": "^1.11.10", + "dompurify": "^3.1.7", "es-toolkit": "^1.44.0", "floating-vue": "^5.2.2", "fuse.js": "^6.6.2", @@ -87,7 +90,7 @@ "vue-select": "4.0.0-beta.6", "vue-typed-virtual-list": "^1.0.10", "vue3-ace-editor": "^2.2.4", - "vue3-apexcharts": "^1.4.4", + "vue3-apexcharts": "^1.5.2", "xss": "^1.0.14" }, "web-types": "../../web-types.json" diff --git a/packages/ui/src/components/base/BaseTerminal.vue b/packages/ui/src/components/base/BaseTerminal.vue index 2f30e0c8ef..ac4378ae9e 100644 --- a/packages/ui/src/components/base/BaseTerminal.vue +++ b/packages/ui/src/components/base/BaseTerminal.vue @@ -1,12 +1,14 @@