diff --git a/Cargo.lock b/Cargo.lock index 1a4d9b3a..195ec597 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 66590318..81dc2a18 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/apps/oneclient/desktop/src/lib.rs b/apps/oneclient/desktop/src/lib.rs index bab5afdc..2bc41045 100644 --- a/apps/oneclient/desktop/src/lib.rs +++ b/apps/oneclient/desktop/src/lib.rs @@ -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) -> 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| { diff --git a/apps/oneclient/frontend/package.json b/apps/oneclient/frontend/package.json index a32c6add..f46e29b7 100644 --- a/apps/oneclient/frontend/package.json +++ b/apps/oneclient/frontend/package.json @@ -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:", diff --git a/apps/oneclient/frontend/src/bindings.gen.ts b/apps/oneclient/frontend/src/bindings.gen.ts index 9a5dfc93..a5d1b1ab 100644 --- a/apps/oneclient/frontend/src/bindings.gen.ts +++ b/apps/oneclient/frontend/src/bindings.gen.ts @@ -6,7 +6,7 @@ type TAURI_CHANNEL = (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 = { total: bigint; offset: bigint; limit: bigint; items: T[] } +export type Paginated = { 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 +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, -getClusterById: (id: bigint) => Promise, -removeCluster: (id: bigint) => Promise, +getClusterById: (id: number) => Promise, +removeCluster: (id: number) => Promise, createCluster: (options: CreateCluster) => Promise, -launchCluster: (id: bigint, uuid: string | null) => Promise, -updateClusterById: (id: bigint, request: ClusterUpdate) => Promise, -getScreenshots: (id: bigint) => Promise, -getWorlds: (id: bigint) => Promise, +launchCluster: (id: number, uuid: string | null) => Promise, +updateClusterById: (id: number, request: ClusterUpdate) => Promise, +getScreenshots: (id: number) => Promise, +getWorlds: (id: number) => Promise, +getLogs: (id: number) => Promise, +getLogByName: (id: number, name: string) => Promise, getProfileOrDefault: (name: string | null) => Promise, getGlobalProfile: () => Promise, updateClusterProfile: (name: string, profile: ProfileUpdate) => Promise, @@ -254,14 +256,16 @@ searchPackages: (provider: Provider, query: SearchQuery) => Promise Promise, getPackageBody: (provider: Provider, body: ManagedPackageBody) => Promise, getMultiplePackages: (provider: Provider, slugs: string[]) => Promise, -getPackageVersions: (provider: Provider, slug: string, mcVersion: string | null, loader: GameLoader | null, offset: bigint, limit: bigint) => Promise>, -downloadPackage: (provider: Provider, packageId: string, versionId: string, clusterId: bigint, skipCompatibility: boolean | null) => Promise, +getPackageVersions: (provider: Provider, slug: string, mcVersion: string | null, loader: GameLoader | null, offset: number, limit: number) => Promise>, +downloadPackage: (provider: Provider, packageId: string, versionId: string, clusterId: number, skipCompatibility: boolean | null) => Promise, getUsersFromAuthor: (provider: Provider, author: PackageAuthor) => Promise }, -'oneclient': { openDevTools: () => Promise, -getClustersGroupedByMajor: () => Promise> }, 'events': { ingress: (event: IngressPayload) => Promise, message: (event: MessagePayload) => Promise, -process: (event: ProcessPayload) => Promise } }; +process: (event: ProcessPayload) => Promise }, +'oneclient': { openDevTools: () => Promise, +getClustersGroupedByMajor: () => Promise> }, +'folders': { fromCluster: (folderName: string) => Promise, +openCluster: (folderName: string) => Promise } }; export type { InferCommandOutput } diff --git a/apps/oneclient/frontend/src/components/LogViewer.module.css b/apps/oneclient/frontend/src/components/LogViewer.module.css new file mode 100644 index 00000000..53312b83 --- /dev/null +++ b/apps/oneclient/frontend/src/components/LogViewer.module.css @@ -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; +} diff --git a/apps/oneclient/frontend/src/components/LogViewer.tsx b/apps/oneclient/frontend/src/components/LogViewer.tsx new file mode 100644 index 00000000..7821417e --- /dev/null +++ b/apps/oneclient/frontend/src/components/LogViewer.tsx @@ -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; + ref?: React.RefObject; +} + +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>(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 ( +
+ + + + {virtualizer.getVirtualItems().map(virtualRow => ( + + ))} + + +
+ ); +} + +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 ( + + {line} +
+
+ ); + + const [matched, time, threadName, level, isChat] = groups as [string, string, string, string, string | undefined]; + + return ( + + [{time}] + [{threadName}/{level}] + {isChat ? ' [CHAT] ' : ' '}{line.slice(matched.length)} +
+
+ ); +} diff --git a/apps/oneclient/frontend/src/components/SheetPage.tsx b/apps/oneclient/frontend/src/components/SheetPage.tsx index 0c09f5a5..0480121a 100644 --- a/apps/oneclient/frontend/src/components/SheetPage.tsx +++ b/apps/oneclient/frontend/src/components/SheetPage.tsx @@ -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; +} + +export interface SheetPageContextApi { + scrollContainerRef: React.RefObject; +} + +const SheetPageContext = React.createContext(null); + export function SheetPage({ headerSmall, headerLarge, children, scrollContainerRef, -}: { - headerSmall: React.ReactNode; - headerLarge: React.ReactNode; - children?: React.ReactNode; - scrollContainerRef?: React.RefObject; -}) { +}: SheetPageProps) { const scrollContainer = useRef(null); const headerLargeRef = useRef(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(() => ({ + scrollContainerRef: scrollContainer, + }), [scrollContainer]); + return (
@@ -63,7 +75,9 @@ export function SheetPage({ {headerLarge} - {children} + + {children} +
@@ -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, diff --git a/apps/oneclient/frontend/src/components/index.ts b/apps/oneclient/frontend/src/components/index.ts index 97466938..a7c7f430 100644 --- a/apps/oneclient/frontend/src/components/index.ts +++ b/apps/oneclient/frontend/src/components/index.ts @@ -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'; diff --git a/apps/oneclient/frontend/src/routes/app/cluster/logs.tsx b/apps/oneclient/frontend/src/routes/app/cluster/logs.tsx index 9e36bf78..8a680b6f 100644 --- a/apps/oneclient/frontend/src/routes/app/cluster/logs.tsx +++ b/apps/oneclient/frontend/src/routes/app/cluster/logs.tsx @@ -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(fileNames[0] || undefined); return (
-
-

Logs

- - - {logFile.fileName} - +

Logs

+ +
+ setActiveFileName(e)} selectedKey={activeFileName}> + {fileNames.map(fileName => ( + + {fileName} + + ))} +
- - {logFile.content} - - {/* Additional content can be added here */} + + {activeFileName && ()}
); } + +function LogContent({ + fileName, + scrollRef, +}: { + fileName: string; + scrollRef: React.RefObject; +}) { + 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 && + ); +} diff --git a/apps/oneclient/frontend/src/routes/app/cluster/route.tsx b/apps/oneclient/frontend/src/routes/app/cluster/route.tsx index 96f22a36..26e82c79 100644 --- a/apps/oneclient/frontend/src/routes/app/cluster/route.tsx +++ b/apps/oneclient/frontend/src/routes/app/cluster/route.tsx @@ -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() {
- + + +
); @@ -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 ( -
-
+
+
{versionInfo.tags.map(tag => ( @@ -99,13 +102,23 @@ function HeaderLarge() {

{versionInfo.longDescription}

- +
+ + + +
); } @@ -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 (

{prettifyLoader(cluster.mc_loader)} {cluster.mc_version}

- +
+ + + +
); } diff --git a/packages/core/Cargo.toml b/packages/core/Cargo.toml index ba162198..50c3fbbc 100644 --- a/packages/core/Cargo.toml +++ b/packages/core/Cargo.toml @@ -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 } diff --git a/packages/core/src/api/cluster/content.rs b/packages/core/src/api/cluster/content.rs index 97411227..daba14c8 100644 --- a/packages/core/src/api/cluster/content.rs +++ b/packages/core/src/api/cluster/content.rs @@ -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> { 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 LauncherResult> { 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 Ok(list) } + +/// Returns a list of log file names +pub async fn get_logs(cluster: &clusters::Model) -> LauncherResult> { + 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> { + 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)) +} diff --git a/packages/core/src/api/packages/dao.rs b/packages/core/src/api/packages/dao.rs index 2a884a74..ae0be132 100644 --- a/packages/core/src/api/packages/dao.rs +++ b/packages/core/src/api/packages/dao.rs @@ -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. diff --git a/packages/core/src/api/tauri/commands.rs b/packages/core/src/api/tauri/commands.rs index 143a2e8e..8d9bcd21 100644 --- a/packages/core/src/api/tauri/commands.rs +++ b/packages/core/src/api/tauri/commands.rs @@ -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>; + #[taurpc(alias = "getLogs")] + async fn get_logs(id: ClusterId) -> LauncherResult>; + + #[taurpc(alias = "getLogByName")] + async fn get_log_by_name(id: ClusterId, name: String) -> LauncherResult>; + // Setting Profiles #[taurpc(alias = "getProfileOrDefault")] async fn get_profile_or_default(name: Option) -> LauncherResult; @@ -109,7 +116,10 @@ pub trait TauriLauncherApi { async fn get_package(provider: Provider, slug: String) -> LauncherResult; #[taurpc(alias = "getPackageBody")] - async fn get_package_body(provider: Provider, body: ManagedPackageBody) -> LauncherResult; + async fn get_package_body( + provider: Provider, + body: ManagedPackageBody, + ) -> LauncherResult; #[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> { + 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> { + 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 { + async fn get_package_body( + self, + provider: Provider, + body: ManagedPackageBody, + ) -> LauncherResult { provider.get_body(&body).await } diff --git a/packages/core/src/api/tauri/folders.rs b/packages/core/src/api/tauri/folders.rs new file mode 100644 index 00000000..8ec44c97 --- /dev/null +++ b/packages/core/src/api/tauri/folders.rs @@ -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; + + #[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 { + 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)?) + } +} diff --git a/packages/core/src/api/tauri/mod.rs b/packages/core/src/api/tauri/mod.rs index 5a467e3c..068c9e89 100644 --- a/packages/core/src/api/tauri/mod.rs +++ b/packages/core/src/api/tauri/mod.rs @@ -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 TauRPCLauncherExt for taurpc::Router { + fn use_launcher_api(self) -> Self { + self.merge(TauriLauncherApiImpl.into_handler()) + .merge(TauriLauncherEventApiImpl.into_handler()) + .merge(TauriLauncherFoldersApiImpl.into_handler()) + } +} diff --git a/packages/core/src/error.rs b/packages/core/src/error.rs index 51daba92..0817b4b9 100644 --- a/packages/core/src/error.rs +++ b/packages/core/src/error.rs @@ -74,6 +74,12 @@ pub enum LauncherError { #[skip] url::ParseError, ), + #[error(transparent)] + OpenerError( + #[from] + #[skip] + opener::OpenError, + ), #[cfg(feature = "tauri")] #[error(transparent)] diff --git a/packages/web_common/src/components/Dropdown.tsx b/packages/web_common/src/components/Dropdown.tsx index 0269f048..336ffe32 100644 --- a/packages/web_common/src/components/Dropdown.tsx +++ b/packages/web_common/src/components/Dropdown.tsx @@ -105,6 +105,9 @@ export interface DropdownProps customMinimalIcon?: React.ReactNode; popoverClassName?: string; selectValue?: (arg1: SelectValueRenderProps & { defaultChildren: React.ReactNode | undefined }) => string; + + triggerClassName?: string; + triggerContentClassName?: string; } export function Dropdown(props: DropdownProps) { @@ -119,6 +122,8 @@ export function Dropdown(props: DropdownProps) { minimal, popoverClassName, selectValue, + triggerClassName, + triggerContentClassName, ...rest } = props; @@ -130,8 +135,8 @@ export function Dropdown(props: DropdownProps) { <> {label && {label}} -