feat: log viewer

This commit is contained in:
LynithDev 2025-08-14 22:14:56 +02:00
parent 843db8f995
commit 6d4e0fa850
22 changed files with 541 additions and 109 deletions

32
Cargo.lock generated
View file

@ -626,6 +626,17 @@ dependencies = [
"alloc-stdlib",
]
[[package]]
name = "bstr"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4"
dependencies = [
"memchr",
"regex-automata 0.4.9",
"serde",
]
[[package]]
name = "bumpalo"
version = "3.16.0"
@ -3022,6 +3033,15 @@ dependencies = [
"minimal-lexical",
]
[[package]]
name = "normpath"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8911957c4b1549ac0dc74e30db9c8b0e66ddcd6d7acc33098f4c63a64a6d7ed"
dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "ntapi"
version = "0.4.1"
@ -3429,6 +3449,7 @@ dependencies = [
"onelauncher_gamemode",
"onelauncher_macro",
"onelauncher_migration",
"opener",
"p256",
"paste",
"rand 0.8.5",
@ -3545,6 +3566,17 @@ dependencies = [
"pathdiff",
]
[[package]]
name = "opener"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "771b9704f8cd8b424ec747a320b30b47517a6966ba2c7da90047c16f4a962223"
dependencies = [
"bstr",
"normpath",
"windows-sys 0.59.0",
]
[[package]]
name = "option-ext"
version = "0.2.0"

View file

@ -99,10 +99,6 @@ uuid = { version = "1.16", features = [
] }
# network
# async-tungstenite = { version = "0.28", features = [
# "tokio-runtime",
# "tokio-rustls-webpki-roots"
# ] }
reqwest = { version = "0.12", default-features = false, features = [
"json",
"stream",
@ -174,6 +170,7 @@ merge = { version = "=0.2.0" }
sysinfo = { version = "=0.34.2", features = [ "serde" ] }
thiserror = { version = "2" }
whoami = { version = "=1.6.0" }
opener = { version = "=0.8.2" }
# macro
paste = { version = "1" }

View file

@ -1,5 +1,5 @@
use onelauncher_core::api::proxy::ProxyTauri;
use onelauncher_core::api::tauri::{TauriLauncherApi, TauriLauncherEventApi};
use onelauncher_core::api::tauri::TauRPCLauncherExt;
use onelauncher_core::error::LauncherResult;
use onelauncher_core::store::proxy::ProxyState;
use onelauncher_core::store::semaphore::SemaphoreStore;
@ -56,12 +56,11 @@ async fn initialize_tauri(builder: tauri::Builder<tauri::Wry>) -> LauncherResult
let router = taurpc::Router::new()
.export_config(
specta_typescript::Typescript::default()
.bigint(specta_typescript::BigIntExportBehavior::BigInt)
.bigint(specta_typescript::BigIntExportBehavior::Number)
.formatter(ext::specta::formatter),
)
.merge(api::commands::OneClientApiImpl.into_handler())
.merge(onelauncher_core::api::tauri::TauriLauncherApiImpl.into_handler())
.merge(onelauncher_core::api::tauri::TauriLauncherEventApiImpl.into_handler());
.use_launcher_api();
let builder = builder
.plugin(tauri_plugin_single_instance::init(|app, argv, cwd| {

View file

@ -21,11 +21,11 @@
"@tanstack/react-router": "catalog:",
"@tanstack/react-router-devtools": "catalog:",
"@tanstack/react-table": "catalog:",
"@tanstack/react-virtual": "catalog:",
"@tanstack/router-plugin": "catalog:",
"@tauri-apps/api": "catalog:",
"@tauri-apps/plugin-clipboard-manager": "catalog:",
"@tauri-apps/plugin-dialog": "catalog:",
"@tauri-apps/plugin-shell": "catalog:",
"@untitled-theme/icons-react": "catalog:",
"motion": "catalog:",
"overlayscrollbars": "catalog:",

View file

@ -6,7 +6,7 @@ type TAURI_CHANNEL<T> = (response: T) => void
export type ClusterError = { type: "InvalidVersion"; data: string } | { type: "MismatchedVersion"; data: string } | { type: "MismatchedLoader"; data: string } | { type: "MissingJavaVersion"; data: string } | { type: "ClusterDownloading"; data: string } | { type: "ClusterAlreadyRunning"; data: string }
export type ClusterModel = { id: bigint; folder_name: string; stage: ClusterStage; created_at: string; group_id: bigint | null; name: string; mc_version: string; mc_loader: GameLoader; mc_loader_version: string | null; last_played: string | null; overall_played: bigint | null; icon_url: Icon | null; setting_profile_name: string | null; linked_modpack_hash: string | null }
export type ClusterModel = { id: number; folder_name: string; stage: ClusterStage; created_at: string; group_id: number | null; name: string; mc_version: string; mc_loader: GameLoader; mc_loader_version: string | null; last_played: string | null; overall_played: number | null; icon_url: Icon | null; setting_profile_name: string | null; linked_modpack_hash: string | null }
export type ClusterStage = "notready" | "downloading" | "repairing" | "ready"
@ -42,24 +42,24 @@ export type IngressType = { Download: { file_name: string } } | "JavaPrepare" |
export type JavaError = { type: "ParseVersion"; data: string } | { type: "Execute"; data: string } | { type: "MissingJava"; data: string }
export type LauncherError = { type: "DirError"; data: DirectoryError } | { type: "IOError"; data: IOError } | { type: "IngressError"; data: IngressError } | { type: "JavaError"; data: JavaError } | { type: "CryptoError"; data: CryptoError } | { type: "DiscordError"; data: DiscordError } | { type: "MetadataError"; data: MetadataError } | { type: "ClusterError"; data: ClusterError } | { type: "MinecraftAuthError"; data: MinecraftAuthError } | { type: "ProcessError"; data: ProcessError } | { type: "PackageError"; data: PackageError } | { type: "DaoError"; data: DaoError } | { type: "SerdeError"; data: string } | { type: "AnyhowError"; data: string } | { type: "DbError"; data: string } | { type: "ReqwestError"; data: string } | { type: "InterpulseError"; data: string } | { type: "RegexError"; data: string } | { type: "SemaphoreError"; data: string } | { type: "UrlError"; data: string } | { type: "TauriError"; data: string }
export type LauncherError = { type: "DirError"; data: DirectoryError } | { type: "IOError"; data: IOError } | { type: "IngressError"; data: IngressError } | { type: "JavaError"; data: JavaError } | { type: "CryptoError"; data: CryptoError } | { type: "DiscordError"; data: DiscordError } | { type: "MetadataError"; data: MetadataError } | { type: "ClusterError"; data: ClusterError } | { type: "MinecraftAuthError"; data: MinecraftAuthError } | { type: "ProcessError"; data: ProcessError } | { type: "PackageError"; data: PackageError } | { type: "DaoError"; data: DaoError } | { type: "SerdeError"; data: string } | { type: "AnyhowError"; data: string } | { type: "DbError"; data: string } | { type: "ReqwestError"; data: string } | { type: "InterpulseError"; data: string } | { type: "RegexError"; data: string } | { type: "SemaphoreError"; data: string } | { type: "UrlError"; data: string } | { type: "OpenerError"; data: string } | { type: "TauriError"; data: string }
export type ManagedPackage = { id: string; slug: string; provider: Provider; package_type: PackageType; name: string; short_desc: string; body: ManagedPackageBody;
/**
* Won't have all versions for some providers, like CurseForge.
* Try making a request to get the versions if needed
*/
version_ids: string[]; mc_versions: string[]; loaders: GameLoader[]; icon_url: string | null; created: string; updated: string; client: PackageSide; server: PackageSide; categories: PackageCategories; license: PackageLicense | null; author: PackageAuthor; links: PackageLinks; status: PackageStatus; downloads: bigint; gallery: PackageGallery[] }
version_ids: string[]; mc_versions: string[]; loaders: GameLoader[]; icon_url: string | null; created: string; updated: string; client: PackageSide; server: PackageSide; categories: PackageCategories; license: PackageLicense | null; author: PackageAuthor; links: PackageLinks; status: PackageStatus; downloads: number; gallery: PackageGallery[] }
export type ManagedPackageBody = { Url: string } | { Raw: string }
export type ManagedUser = { id: string; username: string; url?: string | null; avatar_url?: string | null; bio?: string | null; is_organization_user?: boolean; role?: string | null }
export type ManagedVersion = { version_id: string; project_id: string; display_name: string; display_version: string; changelog: string | null; dependencies: ManagedVersionDependency[]; mc_versions: string[]; release_type: PackageReleaseType; loaders: GameLoader[]; published: string; downloads: bigint; files: ManagedVersionFile[] }
export type ManagedVersion = { version_id: string; project_id: string; display_name: string; display_version: string; changelog: string | null; dependencies: ManagedVersionDependency[]; mc_versions: string[]; release_type: PackageReleaseType; loaders: GameLoader[]; published: string; downloads: number; files: ManagedVersionFile[] }
export type ManagedVersionDependency = { version_id: string | null; project_id: string | null; file_name: string | null; dependency_type: PackageDependencyType }
export type ManagedVersionFile = { sha1: string; url: string; file_name: string; primary: boolean; size: bigint }
export type ManagedVersionFile = { sha1: string; url: string; file_name: string; primary: boolean; size: number }
export type MessageLevel = "Info" | "Warn" | "Error"
@ -139,9 +139,9 @@ export type PackageStatus = "active" | "abandoned"
export type PackageType = "mod" | "resourcepack" | "shader" | "datapack" | "modpack"
export type Paginated<T> = { total: bigint; offset: bigint; limit: bigint; items: T[] }
export type Paginated<T> = { total: number; offset: number; limit: number; items: T[] }
export type Process = { pid: number; started_at: string; cluster_id: bigint; post_hook: string | null; account_id: string }
export type Process = { pid: number; started_at: string; cluster_id: number; post_hook: string | null; account_id: string }
export type ProcessError = { type: "HookUnsuccessful"; data: string } | { type: "NoPid"; data: string }
@ -153,19 +153,19 @@ export type Provider = "Modrinth" | "CurseForge" | "SkyClient"
export type Resolution = { width: number; height: number }
export type SearchQuery = { query: string | null; offset: bigint | null; limit: bigint | null; sort: Sort | null; filters: Filters | null }
export type SearchQuery = { query: string | null; offset: number | null; limit: number | null; sort: Sort | null; filters: Filters | null }
export type SearchResult = { project_id: string; package_type: PackageType; slug: string; author: string; title: string; description: string; categories: PackageCategories; loaders: GameLoader[]; mc_versions: string[]; downloads: bigint; icon_url: string; date_created: string; date_modified: string; latest_version: string; license: string | null; client_side: PackageSide; server_side: PackageSide;
export type SearchResult = { project_id: string; package_type: PackageType; slug: string; author: string; title: string; description: string; categories: PackageCategories; loaders: GameLoader[]; mc_versions: string[]; downloads: number; icon_url: string; date_created: string; date_modified: string; latest_version: string; license: string | null; client_side: PackageSide; server_side: PackageSide;
/**
* List of URLs to images
*/
gallery: string[] }
export type SettingProfileModel = { name: string; java_id: bigint | null; res: Resolution | null; force_fullscreen: boolean | null; mem_max: number | null; launch_args: string | null; launch_env: string | null; hook_pre: string | null; hook_wrapper: string | null; hook_post: string | null; os_extra: SettingsOsExtra | null }
export type SettingProfileModel = { name: string; java_id: number | null; res: Resolution | null; force_fullscreen: boolean | null; mem_max: number | null; launch_args: string | null; launch_env: string | null; hook_pre: string | null; hook_wrapper: string | null; hook_post: string | null; os_extra: SettingsOsExtra | null }
export type Settings = { global_game_settings: SettingProfileModel; allow_parallel_running_clusters: boolean; enable_gamemode: boolean; discord_enabled: boolean; max_concurrent_requests: bigint; settings_version: number; native_window_frame: boolean }
export type Settings = { global_game_settings: SettingProfileModel; allow_parallel_running_clusters: boolean; enable_gamemode: boolean; discord_enabled: boolean; max_concurrent_requests: number; settings_version: number; native_window_frame: boolean }
export type SettingsOsExtra = Record<string, never>
export type SettingsOsExtra = { enable_gamemode: boolean | null }
export type Sort = "Relevance" | "Downloads" | "Newest" | "Updated"
@ -228,15 +228,17 @@ export type VersionType =
*/
"old_beta"
const ARGS_MAP = { 'events':'{"ingress":["event"],"message":["event"],"process":["event"]}', 'oneclient':'{"openDevTools":[],"getClustersGroupedByMajor":[]}', 'core':'{"getClusters":[],"updateClusterById":["id","request"],"getGameVersions":[],"searchPackages":["provider","query"],"getUser":["uuid"],"getWorlds":["id"],"readSettings":[],"getPackage":["provider","slug"],"getClusterById":["id"],"writeSettings":["setting"],"getMultiplePackages":["provider","slugs"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"getLoadersForVersion":["mc_version"],"getPackageBody":["provider","body"],"launchCluster":["id","uuid"],"getUsersFromAuthor":["provider","author"],"getGlobalProfile":[],"getUsers":[],"removeUser":["uuid"],"removeCluster":["id"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"createCluster":["options"],"getProfileOrDefault":["name"],"updateClusterProfile":["name","profile"],"openMsaLogin":[],"setDefaultUser":["uuid"],"getDefaultUser":["fallback"],"getScreenshots":["id"]}' }
const ARGS_MAP = { 'oneclient':'{"openDevTools":[],"getClustersGroupedByMajor":[]}', 'core':'{"getScreenshots":["id"],"updateClusterById":["id","request"],"getPackageBody":["provider","body"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"launchCluster":["id","uuid"],"getClusters":[],"getLoadersForVersion":["mc_version"],"getUser":["uuid"],"getGameVersions":[],"getDefaultUser":["fallback"],"getUsersFromAuthor":["provider","author"],"getProfileOrDefault":["name"],"removeCluster":["id"],"getUsers":[],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"getMultiplePackages":["provider","slugs"],"getLogs":["id"],"getWorlds":["id"],"setDefaultUser":["uuid"],"removeUser":["uuid"],"openMsaLogin":[],"getPackage":["provider","slug"],"writeSettings":["setting"],"createCluster":["options"],"readSettings":[],"getLogByName":["id","name"],"updateClusterProfile":["name","profile"],"getClusterById":["id"],"searchPackages":["provider","query"],"getGlobalProfile":[]}', 'events':'{"process":["event"],"ingress":["event"],"message":["event"]}', 'folders':'{"openCluster":["folder_name"],"fromCluster":["folder_name"]}' }
export type Router = { 'core': { getClusters: () => Promise<ClusterModel[]>,
getClusterById: (id: bigint) => Promise<ClusterModel | null>,
removeCluster: (id: bigint) => Promise<null>,
getClusterById: (id: number) => Promise<ClusterModel | null>,
removeCluster: (id: number) => Promise<null>,
createCluster: (options: CreateCluster) => Promise<ClusterModel>,
launchCluster: (id: bigint, uuid: string | null) => Promise<null>,
updateClusterById: (id: bigint, request: ClusterUpdate) => Promise<null>,
getScreenshots: (id: bigint) => Promise<string[]>,
getWorlds: (id: bigint) => Promise<string[]>,
launchCluster: (id: number, uuid: string | null) => Promise<null>,
updateClusterById: (id: number, request: ClusterUpdate) => Promise<null>,
getScreenshots: (id: number) => Promise<string[]>,
getWorlds: (id: number) => Promise<string[]>,
getLogs: (id: number) => Promise<string[]>,
getLogByName: (id: number, name: string) => Promise<string | null>,
getProfileOrDefault: (name: string | null) => Promise<SettingProfileModel>,
getGlobalProfile: () => Promise<SettingProfileModel>,
updateClusterProfile: (name: string, profile: ProfileUpdate) => Promise<SettingProfileModel>,
@ -254,14 +256,16 @@ searchPackages: (provider: Provider, query: SearchQuery) => Promise<Paginated<Se
getPackage: (provider: Provider, slug: string) => Promise<ManagedPackage>,
getPackageBody: (provider: Provider, body: ManagedPackageBody) => Promise<string>,
getMultiplePackages: (provider: Provider, slugs: string[]) => Promise<ManagedPackage[]>,
getPackageVersions: (provider: Provider, slug: string, mcVersion: string | null, loader: GameLoader | null, offset: bigint, limit: bigint) => Promise<Paginated<ManagedVersion>>,
downloadPackage: (provider: Provider, packageId: string, versionId: string, clusterId: bigint, skipCompatibility: boolean | null) => Promise<PackageModel>,
getPackageVersions: (provider: Provider, slug: string, mcVersion: string | null, loader: GameLoader | null, offset: number, limit: number) => Promise<Paginated<ManagedVersion>>,
downloadPackage: (provider: Provider, packageId: string, versionId: string, clusterId: number, skipCompatibility: boolean | null) => Promise<PackageModel>,
getUsersFromAuthor: (provider: Provider, author: PackageAuthor) => Promise<ManagedUser[]> },
'oneclient': { openDevTools: () => Promise<void>,
getClustersGroupedByMajor: () => Promise<Partial<{ [key in number]: ClusterModel[] }>> },
'events': { ingress: (event: IngressPayload) => Promise<void>,
message: (event: MessagePayload) => Promise<void>,
process: (event: ProcessPayload) => Promise<void> } };
process: (event: ProcessPayload) => Promise<void> },
'oneclient': { openDevTools: () => Promise<void>,
getClustersGroupedByMajor: () => Promise<Partial<{ [key in number]: ClusterModel[] }>> },
'folders': { fromCluster: (folderName: string) => Promise<string>,
openCluster: (folderName: string) => Promise<null> } };
export type { InferCommandOutput }

View file

@ -0,0 +1,56 @@
@reference "../styles/global.css";
.log_container {
@apply w-full whitespace-pre text-base font-mono bg-component-bg rounded-xl;
}
.log {
@apply relative w-full whitespace-pre text-base font-mono;
}
.log * {
white-space: pre !important;
user-select: text !important;
-webkit-user-select: text !important;
}
.log > span {
@apply box-content left-0 top-0 absolute h-full w-full rounded-md hover:bg-component-bg-hover;
}
.log > span[data-level=INFO] {
@apply text-code-info;
}
.log > span[data-level=WARN] {
@apply text-code-warn;
}
.log > span[data-level=ERROR] {
@apply text-code-error;
}
.log > span[data-level=DEBUG] {
@apply text-code-debug;
}
.log > span[data-level=TRACE] {
@apply text-code-trace;
}
.log > span > span:nth-child(1) {
@apply relative text-transparent;
}
.log > span > span:nth-child(1):before {
content: attr(data-time);
@apply absolute h-full w-full text-center select-text text-fg-secondary opacity-100;
}
.log > span > span:nth-child(2) {
@apply font-bold;
}
.log > span > span[data-chat="true"] {
@apply text-white;
}

View file

@ -0,0 +1,110 @@
import { useVirtualizer } from '@tanstack/react-virtual';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
import { useImperativeHandle, useRef, useState } from 'react';
import styles from './LogViewer.module.css';
export interface LogViewerProps {
content: string;
scrollRef: React.RefObject<HTMLElement | null>;
ref?: React.RefObject<LogViewerRef>;
}
export interface LogViewerRef {
push: (line: string) => void;
}
const LINE_HEIGHT = 16;
const REGEX_PATTERN = /\[((?:\S+ )?\d+:\d+:\d+(?:\.\d+)?)\] \[(.[^(\n\r/\u2028\u2029]*)\/(\w+)\]:? (?:\[(CHAT)\])?/;
export function LogViewer({
content,
scrollRef,
ref,
}: LogViewerProps) {
const linesRef = useRef<Array<string>>(content.split('\n'));
// used to force re-render (and re-init of virtualizer hook)
const [lastUpdated, setLastUpdated] = useState(Date.now());
const virtualizer = useVirtualizer({
count: linesRef.current.length,
estimateSize: () => LINE_HEIGHT,
getScrollElement: () => scrollRef.current ?? null,
paddingStart: 4,
paddingEnd: 4,
gap: 0,
overscan: 20,
});
useImperativeHandle(ref, () => ({
push: (line: string) => {
linesRef.current.push(line);
setLastUpdated(Date.now());
},
}), []);
return (
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
}}
>
<OverlayScrollbarsComponent
className={styles.log_container}
options={{
overflow: {
x: 'scroll',
y: 'hidden',
},
}}
style={{
height: '100%',
}}
>
<code className={styles.log}>
{virtualizer.getVirtualItems().map(virtualRow => (
<LogLine
key={virtualRow.key}
line={linesRef.current[virtualRow.index]}
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
/>
))}
</code>
</OverlayScrollbarsComponent>
</div>
);
}
function LogLine({
line,
style,
}: {
line: string;
style: React.CSSProperties;
}) {
line = line.replace(/§./g, ''); // remove color codes
const groups = line.match(REGEX_PATTERN);
if (groups === null)
return (
<span style={style}>
{line}
<br />
</span>
);
const [matched, time, threadName, level, isChat] = groups as [string, string, string, string, string | undefined];
return (
<span data-level={level} style={style}>
<span data-time={time}>[{time}]</span>
<span>[{threadName}/{level}]</span>
<span data-chat={isChat}>{isChat ? ' [CHAT] ' : ' '}{line.slice(matched.length)}</span>
<br />
</span>
);
}

View file

@ -4,20 +4,28 @@ import { useCanGoBack, useRouter } from '@tanstack/react-router';
import { ArrowLeftIcon } from '@untitled-theme/icons-react';
import { motion, useScroll, useTransform } from 'motion/react';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
import React, { useCallback, useRef } from 'react';
import React, { useCallback, useMemo, useRef } from 'react';
import { twMerge } from 'tailwind-merge';
export interface SheetPageProps {
headerSmall: React.ReactNode;
headerLarge: React.ReactNode;
children?: React.ReactNode;
scrollContainerRef?: React.RefObject<HTMLElement | null>;
}
export interface SheetPageContextApi {
scrollContainerRef: React.RefObject<HTMLElement | null>;
}
const SheetPageContext = React.createContext<SheetPageContextApi | null>(null);
export function SheetPage({
headerSmall,
headerLarge,
children,
scrollContainerRef,
}: {
headerSmall: React.ReactNode;
headerLarge: React.ReactNode;
children?: React.ReactNode;
scrollContainerRef?: React.RefObject<HTMLElement | null>;
}) {
}: SheetPageProps) {
const scrollContainer = useRef<HTMLElement>(null);
const headerLargeRef = useRef<HTMLDivElement>(null);
@ -39,6 +47,10 @@ export function SheetPage({
const smallOpacity = useTransform(scrollYProgress, [0, 1], ['1', '0']);
const largeOpacity = useTransform(scrollYProgress, [0, 1], ['0', '1']);
const context = useMemo<SheetPageContextApi>(() => ({
scrollContainerRef: scrollContainer,
}), [scrollContainer]);
return (
<div className="h-full">
<HeaderBackgrounds scrollYProgress={scrollYProgress} />
@ -63,7 +75,9 @@ export function SheetPage({
{headerLarge}
</motion.div>
{children}
<SheetPageContext.Provider value={context}>
{children}
</SheetPageContext.Provider>
</div>
</OverlayScrollbarsComponent>
</div>
@ -71,6 +85,15 @@ export function SheetPage({
);
}
export function useSheetPageContext() {
const ctx = React.useContext(SheetPageContext);
if (!ctx)
throw new Error('useSheetPageContext must be used within a SheetPageContext.Provider');
return ctx;
}
SheetPage.Content = ({
children,
className,

View file

@ -1,5 +1,7 @@
export * from './GameBackground';
export * from './Loader';
export * from './LogViewer';
export * from './Navbar';
export * from './SheetPage';
export * from './Spinner';
export * from './Tabs';

View file

@ -1,41 +1,66 @@
import { SheetPage } from '@/components/SheetPage';
import type { Key } from 'react-aria-components';
import { SheetPage, useSheetPageContext } from '@/components';
import { LogViewer } from '@/components/LogViewer';
import { bindings } from '@/main';
import { useCommand, useCommandSuspense } from '@onelauncher/common';
import { Dropdown } from '@onelauncher/common/components';
import { createFileRoute } from '@tanstack/react-router';
import { File02Icon, File05Icon } from '@untitled-theme/icons-react';
import { useEffect, useState } from 'react';
export const Route = createFileRoute('/app/cluster/logs')({
component: RouteComponent,
async beforeLoad(ctx) {
const cluster = await bindings.core.getClusterById(ctx.search.clusterId as unknown as bigint);
// TODO: make it use actual data once that's available
const logFile = {
fileName: 'latest.log',
content: await (await fetch('https://api.mclo.gs/1/raw/M1eHlVd')).text(),
};
return { cluster, logFile };
},
});
function RouteComponent() {
const { logFile } = Route.useRouteContext();
const { cluster } = Route.useRouteContext();
const { scrollContainerRef } = useSheetPageContext();
const { data: fileNames } = useCommandSuspense(['getLogs', cluster.id.toString()], () => bindings.core.getLogs(cluster.id));
const [activeFileName, setActiveFileName] = useState<Key | undefined>(fileNames[0] || undefined);
return (
<SheetPage.Content>
<div className="flex flex-col gap-4">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<h2 className="text-xxl font-semibold">Logs</h2>
<span className="flex font-mono items-center rounded-md bg-component-bg py-1.5 px-2 gap-1">
<File05Icon width={20} />
{logFile.fileName}
</span>
<h2 className="flex-1 text-xxl font-semibold">Logs</h2>
<div className="flex flex-row">
<Dropdown onSelectionChange={e => setActiveFileName(e)} selectedKey={activeFileName}>
{fileNames.map(fileName => (
<Dropdown.Item id={fileName} key={fileName}>
{fileName}
</Dropdown.Item>
))}
</Dropdown>
</div>
</div>
<code className="font-mono p-4 bg-component-bg whitespace-pre-line rounded-xl">
{logFile.content}
</code>
{/* Additional content can be added here */}
{activeFileName && (<LogContent fileName={activeFileName.toString()} scrollRef={scrollContainerRef} />)}
</div>
</SheetPage.Content>
);
}
function LogContent({
fileName,
scrollRef,
}: {
fileName: string;
scrollRef: React.RefObject<HTMLElement | null>;
}) {
const { cluster } = Route.useRouteContext();
// TODO: Fix cache
const { data: content } = useCommand(
['getLogByName', cluster.id.toString(), fileName],
() => bindings.core.getLogByName(cluster.id, fileName),
{
gcTime: 1000 * 60 * 1, // 1 minute
},
);
return (
content && <LogViewer content={content} scrollRef={scrollRef} />
);
}

View file

@ -1,10 +1,11 @@
import { Tab, TabList } from '@/components';
import { LoaderSuspense, Tab, TabList } from '@/components';
import { SheetPage } from '@/components/SheetPage';
import { bindings } from '@/main';
import { prettifyLoader } from '@/utils/loaders';
import { getVersionInfoOrDefault } from '@/utils/versionMap';
import { Button } from '@onelauncher/common/components';
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router';
import { FolderIcon } from '@untitled-theme/icons-react';
import { useMotionValueEvent, useScroll } from 'motion/react';
import { useRef, useState } from 'react';
@ -27,7 +28,7 @@ export const Route = createFileRoute('/app/cluster')({
const query = context.queryClient.ensureQueryData({
queryKey: ['getClusterById', search.clusterId],
queryFn: () => bindings.core.getClusterById(search.clusterId as unknown as bigint),
queryFn: () => bindings.core.getClusterById(search.clusterId),
});
const cluster = await query;
@ -71,7 +72,9 @@ function RouteComponent() {
</TabList>
<div className="relative">
<Outlet />
<LoaderSuspense spinner={{ size: 'large' }}>
<Outlet />
</LoaderSuspense>
</div>
</SheetPage>
);
@ -81,13 +84,13 @@ function HeaderLarge() {
const { cluster } = Route.useRouteContext();
const versionInfo = getVersionInfoOrDefault(cluster.mc_version);
const launch = () => {
bindings.core.launchCluster(cluster.id as unknown as bigint, null);
};
const openFolder = () => bindings.folders.openCluster(cluster.folder_name);
const launch = () => bindings.core.launchCluster(cluster.id, null);
return (
<div className="flex flex-row justify-between items-end gap-16">
<div className="flex flex-col gap-2">
<div className="flex flex-row items-end gap-16">
<div className="flex-1 flex flex-col gap-2">
<div className="flex flex-row gap-2 flex-wrap">
{versionInfo.tags.map(tag => (
<span className="text-sm font-medium text-fg-secondary bg-component-bg/40 px-2 py-1 rounded" key={tag}>
@ -99,13 +102,23 @@ function HeaderLarge() {
<p className="text-md font-medium text-fg-secondary">{versionInfo.longDescription}</p>
</div>
<Button
color="primary"
onPress={launch}
size="large"
>
Launch
</Button>
<div className="flex flex-row gap-2">
<Button
color="secondary"
onPress={openFolder}
size="iconLarge"
>
<FolderIcon />
</Button>
<Button
color="primary"
onPress={launch}
size="large"
>
Launch
</Button>
</div>
</div>
);
}
@ -113,20 +126,30 @@ function HeaderLarge() {
function HeaderSmall() {
const { cluster } = Route.useRouteContext();
const launch = () => {
bindings.core.launchCluster(cluster.id as unknown as bigint, null);
};
const openFolder = () => bindings.folders.openCluster(cluster.folder_name);
const launch = () => bindings.core.launchCluster(cluster.id, null);
return (
<div className="flex flex-row justify-between items-center h-full">
<h1 className="text-2lg h-full font-medium">{prettifyLoader(cluster.mc_loader)} {cluster.mc_version}</h1>
<Button
color="primary"
onPress={launch}
>
Launch
</Button>
<div className="flex flex-row gap-2">
<Button
color="secondary"
onPress={openFolder}
size="icon"
>
<FolderIcon />
</Button>
<Button
color="primary"
onPress={launch}
>
Launch
</Button>
</div>
</div>
);
}

View file

@ -73,6 +73,7 @@ async-stream = { workspace = true }
paste = { workspace = true }
byteorder = { workspace = true }
merge = { workspace = true }
opener = { workspace = true }
# integration
discord-rich-presence = { workspace = true }

View file

@ -4,6 +4,7 @@ use crate::error::LauncherResult;
use crate::store::Dirs;
use crate::utils::io;
/// Returns a list of screenshot file names
pub async fn get_screenshots(cluster: &clusters::Model) -> LauncherResult<Vec<String>> {
let dir = cluster.folder_name.clone();
let path = Dirs::get_clusters_dir()
@ -25,6 +26,7 @@ pub async fn get_screenshots(cluster: &clusters::Model) -> LauncherResult<Vec<St
Ok(list)
}
/// Returns a list of world filenames
pub async fn get_worlds(cluster: &clusters::Model) -> LauncherResult<Vec<String>> {
let dir = cluster.folder_name.clone();
let path = Dirs::get_clusters_dir().await?.join(dir).join("saves");
@ -42,3 +44,57 @@ pub async fn get_worlds(cluster: &clusters::Model) -> LauncherResult<Vec<String>
Ok(list)
}
/// Returns a list of log file names
pub async fn get_logs(cluster: &clusters::Model) -> LauncherResult<Vec<String>> {
let dir = cluster.folder_name.clone();
let path = Dirs::get_clusters_dir().await?.join(dir).join("logs");
if !path.exists() {
io::create_dir(&path).await?;
return Ok(Vec::new());
}
let mut list = vec![];
let mut files = io::read_dir(path).await?;
while let Ok(Some(entry)) = files.next_entry().await {
list.push(entry.file_name().to_string_lossy().to_string());
}
list.sort_by(|a, b| {
if a == "latest.log" {
std::cmp::Ordering::Less
} else if b == "latest.log" {
std::cmp::Ordering::Greater
} else {
a.cmp(b)
}
});
Ok(list)
}
/// returns a log from a cluster and file name
pub async fn get_log_by_name(
cluster: &clusters::Model,
file_name: &str,
) -> LauncherResult<Option<String>> {
let dir = cluster.folder_name.clone();
let path = Dirs::get_clusters_dir()
.await?
.join(dir)
.join("logs")
.join(file_name);
if !path.exists() {
return Ok(None);
}
let content = if file_name.ends_with(".gz") {
io::read_gz_to_string(&path).await?
} else {
io::read_to_string(&path).await?
};
Ok(Some(content))
}

View file

@ -8,8 +8,6 @@ use sea_orm::{
use crate::error::{DaoError, LauncherResult};
use crate::store::State;
use super::PackageError;
pub type PackageId = String;
/// Inserts a new package into the database.

View file

@ -1,5 +1,6 @@
use crate::api::packages::data::{
ManagedPackage, ManagedPackageBody, ManagedUser, ManagedVersion, PackageAuthor, SearchQuery, SearchResult
ManagedPackage, ManagedPackageBody, ManagedUser, ManagedVersion, PackageAuthor, SearchQuery,
SearchResult,
};
use crate::api::packages::provider::ProviderExt;
use crate::store::{Settings, State};
@ -48,6 +49,12 @@ pub trait TauriLauncherApi {
#[taurpc(alias = "getWorlds")]
async fn get_worlds(id: ClusterId) -> LauncherResult<Vec<String>>;
#[taurpc(alias = "getLogs")]
async fn get_logs(id: ClusterId) -> LauncherResult<Vec<String>>;
#[taurpc(alias = "getLogByName")]
async fn get_log_by_name(id: ClusterId, name: String) -> LauncherResult<Option<String>>;
// Setting Profiles
#[taurpc(alias = "getProfileOrDefault")]
async fn get_profile_or_default(name: Option<String>) -> LauncherResult<SettingsProfile>;
@ -109,7 +116,10 @@ pub trait TauriLauncherApi {
async fn get_package(provider: Provider, slug: String) -> LauncherResult<ManagedPackage>;
#[taurpc(alias = "getPackageBody")]
async fn get_package_body(provider: Provider, body: ManagedPackageBody) -> LauncherResult<String>;
async fn get_package_body(
provider: Provider,
body: ManagedPackageBody,
) -> LauncherResult<String>;
#[taurpc(alias = "getMultiplePackages")]
async fn get_multiple_packages(
@ -290,6 +300,22 @@ impl TauriLauncherApi for TauriLauncherApiImpl {
api::cluster::content::get_worlds(&cluster).await
}
async fn get_logs(self, id: ClusterId) -> LauncherResult<Vec<String>> {
let cluster = api::cluster::dao::get_cluster_by_id(id)
.await?
.ok_or_else(|| anyhow::anyhow!("cluster with id {} not found", id))?;
api::cluster::content::get_logs(&cluster).await
}
async fn get_log_by_name(self, id: ClusterId, name: String) -> LauncherResult<Option<String>> {
let cluster = api::cluster::dao::get_cluster_by_id(id)
.await?
.ok_or_else(|| anyhow::anyhow!("cluster with id {} not found", id))?;
api::cluster::content::get_log_by_name(&cluster, &name).await
}
// Setting Profiles
async fn get_global_profile(self) -> SettingsProfile {
api::setting_profiles::get_global_profile().await
@ -476,7 +502,11 @@ impl TauriLauncherApi for TauriLauncherApiImpl {
provider.get(&slug).await
}
async fn get_package_body(self, provider: Provider, body: ManagedPackageBody) -> LauncherResult<String> {
async fn get_package_body(
self,
provider: Provider,
body: ManagedPackageBody,
) -> LauncherResult<String> {
provider.get_body(&body).await
}

View file

@ -0,0 +1,28 @@
use std::path::PathBuf;
use crate::error::LauncherResult;
use crate::store::Dirs;
#[taurpc::procedures(path = "folders")]
pub trait TauriLauncherFoldersApi {
#[taurpc(alias = "fromCluster")]
async fn from_cluster(folder_name: String) -> LauncherResult<PathBuf>;
#[taurpc(alias = "openCluster")]
async fn open_cluster(folder_name: String) -> LauncherResult<()>;
}
#[taurpc::ipc_type]
pub struct TauriLauncherFoldersApiImpl;
#[taurpc::resolvers]
impl TauriLauncherFoldersApi for TauriLauncherFoldersApiImpl {
async fn from_cluster(self, folder_name: String) -> LauncherResult<PathBuf> {
Ok(Dirs::get_clusters_dir().await?.join(folder_name))
}
async fn open_cluster(self, folder_name: String) -> LauncherResult<()> {
let path = self.from_cluster(folder_name).await?;
Ok(opener::open(path)?)
}
}

View file

@ -3,3 +3,18 @@ pub use events::*;
mod commands;
pub use commands::*;
mod folders;
pub use folders::*;
pub trait TauRPCLauncherExt {
fn use_launcher_api(self) -> Self;
}
impl<R: tauri::Runtime> TauRPCLauncherExt for taurpc::Router<R> {
fn use_launcher_api(self) -> Self {
self.merge(TauriLauncherApiImpl.into_handler())
.merge(TauriLauncherEventApiImpl.into_handler())
.merge(TauriLauncherFoldersApiImpl.into_handler())
}
}

View file

@ -74,6 +74,12 @@ pub enum LauncherError {
#[skip]
url::ParseError,
),
#[error(transparent)]
OpenerError(
#[from]
#[skip]
opener::OpenError,
),
#[cfg(feature = "tauri")]
#[error(transparent)]

View file

@ -105,6 +105,9 @@ export interface DropdownProps<T extends object>
customMinimalIcon?: React.ReactNode;
popoverClassName?: string;
selectValue?: (arg1: SelectValueRenderProps<object> & { defaultChildren: React.ReactNode | undefined }) => string;
triggerClassName?: string;
triggerContentClassName?: string;
}
export function Dropdown<TValue extends object>(props: DropdownProps<TValue>) {
@ -119,6 +122,8 @@ export function Dropdown<TValue extends object>(props: DropdownProps<TValue>) {
minimal,
popoverClassName,
selectValue,
triggerClassName,
triggerContentClassName,
...rest
} = props;
@ -130,8 +135,8 @@ export function Dropdown<TValue extends object>(props: DropdownProps<TValue>) {
<>
{label && <AriaLabel className={labelClassName}>{label}</AriaLabel>}
<Button className={styles.trigger()} color="ghost">
<div className={styles.triggerValueContent()}>
<Button className={styles.trigger({ className: triggerClassName })} color="ghost">
<div className={styles.triggerValueContent({ className: triggerContentClassName })}>
{triggerTextPrefix && <span>{triggerTextPrefix}</span>}
{selectValue
? (
@ -177,7 +182,7 @@ Dropdown.Item = (props: DropdownItemProps) => {
const { className, children } = props;
return (
<AriaListBoxItem {...props} className={twMerge(itemStyles.container(), className?.toString())}>
<AriaListBoxItem aria-label="dropdown item" {...props} className={twMerge(itemStyles.container(), className?.toString())}>
{children}
</AriaListBoxItem>
);

View file

@ -11,7 +11,8 @@ type RegisterConfig = Register extends {
commands: infer Keys;
} ? Keys : never;
type CommandCacheKey = RegisterConfig[number] | (string & {}) | false;
type CommandCacheKey = RegisterConfig[number] | (string & {});
type CommandCacheKeys = Array<CommandCacheKey> | [CommandCacheKey] | [false];
async function fetchCommand<T>(command: () => Promise<T>): Promise<T> {
try {
@ -29,15 +30,15 @@ async function fetchCommand<T>(command: () => Promise<T>): Promise<T> {
}
export function useCommand<TQueryFnData, TSelected = TQueryFnData>(
cacheKey: CommandCacheKey,
cacheKey: CommandCacheKeys,
command: () => Promise<TQueryFnData>,
options?: Omit<
UndefinedInitialDataOptions<TQueryFnData, Error, TSelected, [CommandCacheKey] | []>,
UndefinedInitialDataOptions<TQueryFnData, Error, TSelected, CommandCacheKeys | []>,
'queryKey' | 'queryFn'
>,
): UseQueryResult<TSelected, Error> {
return useQuery<TQueryFnData, Error, TSelected, [CommandCacheKey] | []>({
queryKey: cacheKey === false ? [] : [cacheKey],
return useQuery<TQueryFnData, Error, TSelected, CommandCacheKeys | []>({
queryKey: cacheKey[0] === false ? [] : Array.isArray(cacheKey) ? cacheKey : [cacheKey],
queryFn: () => fetchCommand(command),
enabled: true,
...options,
@ -45,15 +46,15 @@ export function useCommand<TQueryFnData, TSelected = TQueryFnData>(
}
export function useCommandSuspense<TQueryFnData, TSelected = TQueryFnData>(
cacheKey: CommandCacheKey,
cacheKey: CommandCacheKeys,
command: () => Promise<TQueryFnData>,
options?: Omit<
UseSuspenseQueryOptions<TQueryFnData, Error, TSelected, [CommandCacheKey] | []>,
UseSuspenseQueryOptions<TQueryFnData, Error, TSelected, CommandCacheKeys | []>,
'queryKey' | 'queryFn'
>,
): UseSuspenseQueryResult<TSelected, Error> {
return useSuspenseQuery<TQueryFnData, Error, TSelected, [CommandCacheKey] | []>({
queryKey: cacheKey === false ? [] : [cacheKey],
return useSuspenseQuery<TQueryFnData, Error, TSelected, CommandCacheKeys | []>({
queryKey: cacheKey[0] === false ? [] : Array.isArray(cacheKey) ? cacheKey : [cacheKey],
queryFn: () => fetchCommand(command),
...options,
});

26
pnpm-lock.yaml generated
View file

@ -42,6 +42,9 @@ catalogs:
'@tanstack/react-table':
specifier: ^8.21.2
version: 8.21.3
'@tanstack/react-virtual':
specifier: ^3.13.12
version: 3.13.12
'@tanstack/router-plugin':
specifier: ^1.114.3
version: 1.119.0
@ -244,6 +247,9 @@ importers:
'@tanstack/react-table':
specifier: 'catalog:'
version: 8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@tanstack/react-virtual':
specifier: 'catalog:'
version: 3.13.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@tanstack/router-plugin':
specifier: 'catalog:'
version: 1.119.0(@tanstack/react-router@1.119.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite-plugin-solid@2.11.6(solid-js@1.9.6)(vite@6.3.4(@types/node@22.16.5)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.80.3)(tsx@4.19.4)(yaml@2.8.0)))(vite@6.3.4(@types/node@22.16.5)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.80.3)(tsx@4.19.4)(yaml@2.8.0))
@ -256,9 +262,6 @@ importers:
'@tauri-apps/plugin-dialog':
specifier: 'catalog:'
version: 2.2.1
'@tauri-apps/plugin-shell':
specifier: 'catalog:'
version: 2.2.1
'@untitled-theme/icons-react':
specifier: 'catalog:'
version: 0.14.0(@types/react@19.1.2)(react@19.1.0)
@ -2717,6 +2720,12 @@ packages:
react: '>=16.8'
react-dom: '>=16.8'
'@tanstack/react-virtual@3.13.12':
resolution: {integrity: sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/router-core@1.119.0':
resolution: {integrity: sha512-3dZYP5cCq3jJYgnRDzKR3w4sYzrXP5sw1st303ye87VV26r31I8UaIuUEs7kiJaxgWBvqHglWCiygBWQODZXVw==}
engines: {node: '>=12'}
@ -2774,6 +2783,9 @@ packages:
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
engines: {node: '>=12'}
'@tanstack/virtual-core@3.13.12':
resolution: {integrity: sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==}
'@tanstack/virtual-file-routes@1.115.0':
resolution: {integrity: sha512-XLUh1Py3AftcERrxkxC5Y5m5mfllRH3YR6YVlyjFgI2Tc2Ssy2NKmQFQIafoxfW459UJ8Dn81nWKETEIJifE4g==}
engines: {node: '>=12'}
@ -8576,6 +8588,12 @@ snapshots:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
'@tanstack/react-virtual@3.13.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@tanstack/virtual-core': 3.13.12
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
'@tanstack/router-core@1.119.0':
dependencies:
'@tanstack/history': 1.115.0
@ -8638,6 +8656,8 @@ snapshots:
'@tanstack/table-core@8.21.3': {}
'@tanstack/virtual-core@3.13.12': {}
'@tanstack/virtual-file-routes@1.115.0': {}
'@tauri-apps/api@2.5.0': {}

View file

@ -40,8 +40,6 @@ catalog:
# react + libraries
react: ^19.0.0
react-aria-components: ^1.8.0
'@tanstack/react-query': ^5.66.5
'@tanstack/react-router': ^1.114.3
'@untitled-theme/icons-react': ^0.14.0
motion: ^12.10.5
react-dom: ^19.0.0
@ -58,8 +56,11 @@ catalog:
'@tanstack/match-sorter-utils': ^8.19.4
'@tanstack/react-query-devtools': ^5.66.5
'@tanstack/react-router-devtools': ^1.114.3
'@tanstack/react-query': ^5.66.5
'@tanstack/react-router': ^1.114.3
'@tanstack/react-table': ^8.21.2
'@tanstack/router-plugin': ^1.114.3
'@tanstack/react-virtual': ^3.13.12
# tauri
taurpc: ^1.8.1