Merge branch 'oneclient/main' of https://github.com/Polyfrost/OneLauncher into oneclient/main

This commit is contained in:
emirsassan 2025-08-18 09:49:38 +03:00
commit 01afca2b6a
31 changed files with 776 additions and 133 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

@ -16,16 +16,17 @@
"@onelauncher/common": "workspace:*",
"@tailwindcss/vite": "catalog:",
"@tanstack/match-sorter-utils": "catalog:",
"@tanstack/react-devtools": "catalog:",
"@tanstack/react-query": "catalog:",
"@tanstack/react-query-devtools": "catalog:",
"@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,6 +228,7 @@ export type VersionType =
*/
"old_beta"
<<<<<<< HEAD
const ARGS_MAP = { 'events':'{"ingress":["event"],"message":["event"],"process":["event"]}', 'oneclient':'{"openDevTools":[],"getClustersGroupedByMajor":[]}', 'core':'{"launchCluster":["id","uuid"],"writeSettings":["setting"],"getUser":["uuid"],"getDefaultUser":["fallback"],"updateClusterById":["id","request"],"setDefaultUser":["uuid"],"createCluster":["options"],"getClusters":[],"openMsaLogin":[],"readSettings":[],"getGameVersions":[],"updateClusterProfile":["name","profile"],"searchPackages":["provider","query"],"getClusterById":["id"],"getProfileOrDefault":["name"],"getScreenshots":["id"],"getGlobalProfile":[],"getUsers":[],"getMultiplePackages":["provider","slugs"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"getUsersFromAuthor":["provider","author"],"getLoadersForVersion":["mc_version"],"removeUser":["uuid"],"removeCluster":["id"],"getWorlds":["id"],"getPackage":["provider","slug"],"getPackageBody":["provider","body"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"]}' }
export type Router = { 'events': { ingress: (event: IngressPayload) => Promise<void>,
message: (event: MessagePayload) => Promise<void>,
@ -237,11 +238,19 @@ getClustersGroupedByMajor: () => Promise<Partial<{ [key in number]: ClusterModel
'core': { getClusters: () => Promise<ClusterModel[]>,
getClusterById: (id: bigint) => Promise<ClusterModel | null>,
removeCluster: (id: bigint) => Promise<null>,
=======
const ARGS_MAP = { 'oneclient':'{"openDevTools":[],"getClustersGroupedByMajor":[]}', 'core':'{"openMsaLogin":[],"readSettings":[],"getProfileOrDefault":["name"],"getLogByName":["id","name"],"getWorlds":["id"],"launchCluster":["id","uuid"],"getGameVersions":[],"getUser":["uuid"],"removeUser":["uuid"],"getUsersFromAuthor":["provider","author"],"getPackage":["provider","slug"],"setDefaultUser":["uuid"],"getMultiplePackages":["provider","slugs"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"updateClusterById":["id","request"],"getGlobalProfile":[],"getLoadersForVersion":["mc_version"],"getPackageBody":["provider","body"],"writeSettings":["setting"],"getScreenshots":["id"],"getLogs":["id"],"getDefaultUser":["fallback"],"getClusterById":["id"],"removeCluster":["id"],"updateClusterProfile":["name","profile"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"createCluster":["options"],"getClusters":[],"searchPackages":["provider","query"],"getUsers":[]}', 'events':'{"ingress":["event"],"process":["event"],"message":["event"]}', 'folders':'{"fromCluster":["folder_name"],"openCluster":["folder_name"]}' }
export type Router = { 'core': { getClusters: () => Promise<ClusterModel[]>,
getClusterById: (id: number) => Promise<ClusterModel | null>,
removeCluster: (id: number) => Promise<null>,
>>>>>>> fdf74258a065dd4c89df333cc17f5591afd0f3c9
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>,
@ -259,9 +268,22 @@ 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[]>,
<<<<<<< HEAD
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>,
getUsersFromAuthor: (provider: Provider, author: PackageAuthor) => Promise<ManagedUser[]> } };
=======
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[]> },
'folders': { fromCluster: (folderName: string) => Promise<string>,
openCluster: (folderName: string) => Promise<null> },
'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> } };
>>>>>>> fdf74258a065dd4c89df333cc17f5591afd0f3c9
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,116 @@
import { useVirtualizer } from '@tanstack/react-virtual';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
import { useImperativeHandle, useMemo, useState } from 'react';
import styles from './LogViewer.module.css';
export interface LogViewerProps {
content: string;
scrollRef: React.RefObject<HTMLElement | null>;
autoScroll?: boolean;
ref?: React.RefObject<LogViewerRef>;
}
export interface LogViewerRef {
push: (line: string) => void;
// search: (query: 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,
autoScroll = false,
ref,
}: LogViewerProps) {
const lines = useMemo<Array<string>>(() => content.split('\n'), [content]);
// used to force re-render (and re-init of virtualizer hook)
const [_lastUpdated, setLastUpdated] = useState(Date.now());
const virtualizer = useVirtualizer({
count: lines.length,
estimateSize: () => LINE_HEIGHT,
getScrollElement: () => scrollRef.current ?? null,
paddingStart: 4,
paddingEnd: 4,
gap: 0,
overscan: 20,
});
useImperativeHandle(ref, () => ({
push: (line) => {
const len = lines.push(line);
setLastUpdated(Date.now());
if (autoScroll)
virtualizer.scrollToIndex(len - 1);
},
}), [autoScroll, lines, virtualizer]);
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={lines[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,7 +1,9 @@
import type { QueryClient } from '@tanstack/react-query';
import type { NavigateOptions, ToOptions } from '@tanstack/react-router';
import { TanStackDevtools } from '@tanstack/react-devtools';
import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools';
import { createRootRouteWithContext, Outlet, useRouter } from '@tanstack/react-router';
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools';
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools';
import { RouterProvider } from 'react-aria-components';
interface AppRouterContext {
@ -27,12 +29,28 @@ function RootRoute() {
navigate={(to, options) => router.navigate({ to, ...options })}
useHref={to => router.buildLocation({ to }).href}
>
<DevTools />
<div className="h-screen flex flex-col overflow-hidden text-fg-primary">
<Outlet />
<TanStackRouterDevtools />
</div>
</RouterProvider>
// </AnimatedOutletProvider>
);
}
function DevTools() {
return (
<TanStackDevtools
plugins={[
{
name: 'Tanstack Query',
render: <ReactQueryDevtoolsPanel />,
},
{
name: 'Tanstack Router',
render: <TanStackRouterDevtoolsPanel />,
},
]}
/>
);
}

View file

@ -1,41 +1,68 @@
import { SheetPage } from '@/components/SheetPage';
import type { Key } from 'react-aria-components';
import { LoaderContainer, SheetPage, useSheetPageContext } from '@/components';
import { LogViewer } from '@/components/LogViewer';
import { bindings } from '@/main';
import { getMessageFromError, 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 { 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], () => 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, error, isLoading } = useCommand(
['getLogByName', cluster.id, fileName],
() => bindings.core.getLogByName(cluster.id, fileName),
{
gcTime: 1000 * 60 * 1, // 1 minute
},
);
return (
<LoaderContainer loading={isLoading}>
<LogViewer content={getMessageFromError(error) || content || 'Empty log file'} scrollRef={scrollRef} />
</LoaderContainer>
);
}

View file

@ -5,8 +5,10 @@ import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/app/cluster/overview')({
component: RouteComponent,
async beforeLoad(ctx) {
const cluster = await bindings.core.getClusterById(ctx.search.clusterId as unknown as bigint);
return { cluster };
const cluster = await bindings.core.getClusterById(ctx.search.clusterId);
return {
cluster,
};
},
});

View file

@ -1,16 +1,16 @@
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';
export interface ClusterRouteSearchParams {
clusterId: number;
a: number;
}
export const Route = createFileRoute('/app/cluster')({
@ -18,7 +18,6 @@ export const Route = createFileRoute('/app/cluster')({
validateSearch: (search): ClusterRouteSearchParams => {
return {
clusterId: Number(search.clusterId),
a: 6,
};
},
async beforeLoad({ context, search }) {
@ -27,7 +26,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 +70,9 @@ function RouteComponent() {
</TabList>
<div className="relative">
<Outlet />
<LoaderSuspense spinner={{ size: 'large' }}>
<Outlet />
</LoaderSuspense>
</div>
</SheetPage>
);
@ -81,13 +82,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 +100,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 +124,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

@ -24,7 +24,7 @@ function RouteComponent() {
const navigate = Route.useNavigate();
const { data: clusters } = useCommandSuspense('getClustersGroupedByMajor', bindings.oneclient.getClustersGroupedByMajor);
const { data: clusters } = useCommandSuspense(['getClustersGroupedByMajor'], bindings.oneclient.getClustersGroupedByMajor);
const loadMinorVersion = useCallback((major: number) => {
const existing = prevMinorVersions.find(v => v.major === major);
@ -194,7 +194,7 @@ function RouteComponent() {
navigate({
to: `/app/cluster/overview`,
search: {
clusterId: cluster.id as unknown as number,
clusterId: cluster.id,
},
});
}}

View file

@ -19,7 +19,7 @@ export const Route = createFileRoute('/app/')({
function RouteComponent() {
const { data: clusters } = useCommandSuspense(
'getClusters',
['getClusters'],
bindings.core.getClusters,
{
select(data) {

View file

@ -1,3 +1,4 @@
import type { LauncherError } from '@/bindings.gen';
import type { bindings } from '@/main';
export type BindingCommands = (keyof typeof bindings.core) | (keyof typeof bindings.oneclient);
@ -5,5 +6,6 @@ export type BindingCommands = (keyof typeof bindings.core) | (keyof typeof bindi
declare module '@onelauncher/common' {
interface Register {
commands: Array<BindingCommands>;
defaultError: LauncherError;
}
}

View file

@ -25,5 +25,5 @@
"skipLibCheck": true,
"noUncheckedSideEffectImports": true
},
"include": ["**/*.ts", "**/*.tsx", "vite.config.ts"]
"include": ["**/*.ts", "**/*.tsx", "vite.config.ts", "../../../packages/web_common/src/utils/error.ts"]
}

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,59 @@ 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 {
if entry.file_type().await.is_ok_and(|ft| ft.is_file()) {
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

@ -53,6 +53,7 @@ export function Button({
}: ButtonProps) {
return (
<AriaButton
aria-label={props['aria-label'] ?? 'button'}
className={buttonVariants({ color, size, className })}
isDisabled={props.isDisabled}
{...props}

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,27 +122,26 @@ export function Dropdown<TValue extends object>(props: DropdownProps<TValue>) {
minimal,
popoverClassName,
selectValue,
triggerClassName,
triggerContentClassName,
'aria-label': ariaLabel,
...rest
} = props;
const styles = dropdownStyles({ minimal, className });
return (
<AriaSelect {...rest} className={styles.root()}>
<AriaSelect aria-label={ariaLabel ?? 'dropdown'} {...rest} className={styles.root()}>
{({ isOpen }) => (
<>
{label && <AriaLabel className={labelClassName}>{label}</AriaLabel>}
{label && <AriaLabel aria-label={label} 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
? (
<SelectValue>
{selectValue}
</SelectValue>
)
: <SelectValue />}
<SelectValue>
{selectValue}
</SelectValue>
</div>
{
isOpen
@ -148,10 +150,10 @@ export function Dropdown<TValue extends object>(props: DropdownProps<TValue>) {
}
</Button>
<AriaPopover className={twMerge(styles.popover(), popoverClassName)}>
<AriaPopover aria-label="dropdown menu" className={twMerge(styles.popover(), popoverClassName)}>
<div className={styles.popoverContentWrapper()}>
<OverlayScrollbarsComponent className={styles.overlayScrollbars()}>
<AriaListBox className={styles.listBox()}>
<AriaListBox aria-label="dropdown list" className={styles.listBox()}>
{children}
</AriaListBox>
</OverlayScrollbarsComponent>
@ -177,7 +179,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

@ -1,17 +1,31 @@
/* eslint-disable query/exhaustive-deps -- False */
import type { UndefinedInitialDataOptions, UseMutationOptions, UseMutationResult, UseQueryResult, UseSuspenseQueryOptions, UseSuspenseQueryResult } from '@tanstack/react-query';
import type { AppError } from '../utils/error';
import { useMutation, useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { isLauncherError } from '../utils/error';
// this gets overwritten by the consumer project
export interface Register {
// commands
// commands: string[];
// defaultError: Error;
}
type RegisterConfig = Register extends {
type CommandKeys = Register extends {
commands: infer Keys;
} ? Keys : never;
type CommandCacheKey = RegisterConfig[number] | (string & {}) | false;
type DefaultError = Register extends {
defaultError: infer Error;
} ? Error : never;
type QueryKey = [CommandKeys[number], ...ReadonlyArray<unknown>];
declare module '@tanstack/react-query' {
interface Register {
queryKey: QueryKey;
mutationKey: QueryKey;
}
}
async function fetchCommand<T>(command: () => Promise<T>): Promise<T> {
try {
@ -20,40 +34,40 @@ async function fetchCommand<T>(command: () => Promise<T>): Promise<T> {
}
// TODO: (lynith) handle errors better (requires modifications to TauRPC)
catch (e) {
console.error(e);
if (e instanceof Error)
console.error('[fetch]', e);
if (isLauncherError(e))
return Promise.reject(e);
else
return Promise.reject(new Error('Unknown error'));
return Promise.reject(e);
}
}
export function useCommand<TQueryFnData, TSelected = TQueryFnData>(
cacheKey: CommandCacheKey,
export function useCommand<TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey>(
cacheKey: TQueryKey,
command: () => Promise<TQueryFnData>,
options?: Omit<
UndefinedInitialDataOptions<TQueryFnData, Error, TSelected, [CommandCacheKey] | []>,
UndefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey>,
'queryKey' | 'queryFn'
>,
): UseQueryResult<TSelected, Error> {
return useQuery<TQueryFnData, Error, TSelected, [CommandCacheKey] | []>({
queryKey: cacheKey === false ? [] : [cacheKey],
): UseQueryResult<TData, TError> {
return useQuery<TQueryFnData, TError, TData, TQueryKey>({
queryKey: cacheKey,
queryFn: () => fetchCommand(command),
enabled: true,
...options,
});
}
export function useCommandSuspense<TQueryFnData, TSelected = TQueryFnData>(
cacheKey: CommandCacheKey,
export function useCommandSuspense<TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey>(
cacheKey: TQueryKey,
command: () => Promise<TQueryFnData>,
options?: Omit<
UseSuspenseQueryOptions<TQueryFnData, Error, TSelected, [CommandCacheKey] | []>,
UseSuspenseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
'queryKey' | 'queryFn'
>,
): UseSuspenseQueryResult<TSelected, Error> {
return useSuspenseQuery<TQueryFnData, Error, TSelected, [CommandCacheKey] | []>({
queryKey: cacheKey === false ? [] : [cacheKey],
): UseSuspenseQueryResult<TData, TError> {
return useSuspenseQuery<TQueryFnData, TError, TData, TQueryKey>({
queryKey: cacheKey,
queryFn: () => fetchCommand(command),
...options,
});
@ -61,7 +75,7 @@ export function useCommandSuspense<TQueryFnData, TSelected = TQueryFnData>(
export function useCommandMut<
TData,
TError = Error,
TError = DefaultError,
TVariables = void,
TContext = unknown,
>(

View file

@ -0,0 +1,23 @@
export interface AppError {
data: string | AppError;
type: string;
}
export function isLauncherError<TErr = AppError>(error: unknown): error is TErr {
return typeof error === 'object'
&& error !== null
&& 'data' in error
&& 'type' in error;
}
export function getMessageFromError<T extends AppError>(error: T): string;
export function getMessageFromError<T extends AppError>(error: T | null | undefined): string | null;
export function getMessageFromError(error: AppError | null | undefined): string | null {
if (!error)
return null;
if (typeof error.data === 'string')
return error.data;
return getMessageFromError(error.data);
}

View file

@ -1,2 +1,3 @@
export * from './dev';
export * from './error';
export * from './nativeExperience';

152
pnpm-lock.yaml generated
View file

@ -27,6 +27,9 @@ catalogs:
'@tanstack/match-sorter-utils':
specifier: ^8.19.4
version: 8.19.4
'@tanstack/react-devtools':
specifier: ^0.4.0
version: 0.4.0
'@tanstack/react-query':
specifier: ^5.66.5
version: 5.75.0
@ -42,6 +45,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
@ -229,6 +235,9 @@ importers:
'@tanstack/match-sorter-utils':
specifier: 'catalog:'
version: 8.19.4
'@tanstack/react-devtools':
specifier: 'catalog:'
version: 0.4.0(@types/react-dom@19.1.3(@types/react@19.1.2))(@types/react@19.1.2)(csstype@3.1.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(solid-js@1.9.6)
'@tanstack/react-query':
specifier: 'catalog:'
version: 5.75.0(react@19.1.0)
@ -244,6 +253,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 +268,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)
@ -2477,6 +2486,26 @@ packages:
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
engines: {node: '>=18'}
'@solid-primitives/event-listener@2.4.3':
resolution: {integrity: sha512-h4VqkYFv6Gf+L7SQj+Y6puigL/5DIi7x5q07VZET7AWcS+9/G3WfIE9WheniHWJs51OEkRB43w6lDys5YeFceg==}
peerDependencies:
solid-js: ^1.6.12
'@solid-primitives/keyboard@1.3.3':
resolution: {integrity: sha512-9dQHTTgLBqyAI7aavtO+HnpTVJgWQA1ghBSrmLtMu1SMxLPDuLfuNr+Tk5udb4AL4Ojg7h9JrKOGEEDqsJXWJA==}
peerDependencies:
solid-js: ^1.6.12
'@solid-primitives/rootless@1.5.2':
resolution: {integrity: sha512-9HULb0QAzL2r47CCad0M+NKFtQ+LrGGNHZfteX/ThdGvKIg2o2GYhBooZubTCd/RTu2l2+Nw4s+dEfiDGvdrrQ==}
peerDependencies:
solid-js: ^1.6.12
'@solid-primitives/utils@6.3.2':
resolution: {integrity: sha512-hZ/M/qr25QOCcwDPOHtGjxTD8w2mNyVAYvcfgwzBHq2RwNqHNdDNsMZYap20+ruRwW4A3Cdkczyoz0TSxLCAPQ==}
peerDependencies:
solid-js: ^1.6.12
'@stylistic/eslint-plugin-js@4.2.0':
resolution: {integrity: sha512-MiJr6wvyzMYl/wElmj8Jns8zH7Q1w8XoVtm+WM6yDaTrfxryMyb8n0CMxt82fo42RoLIfxAEtM6tmQVxqhk0/A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -2655,6 +2684,22 @@ packages:
peerDependencies:
vite: ^5.2.0 || ^6
'@tanstack/devtools-event-bus@0.2.1':
resolution: {integrity: sha512-JMq3AmrQR2LH9P8Rcj1MTq8Iq/mPk/PyuqSw1L0hO2Wl8G1oz5ue31fS8u8lIgOCVR/mGdJah18p+Pj5OosRJA==}
engines: {node: '>=18'}
'@tanstack/devtools-ui@0.3.0':
resolution: {integrity: sha512-lyP0eM6juIWn8zgI8xI32Lh86gCnjUyNePE9F7Bfgkv5taILmmJAHW5Mme4T2ufv7L8NLwOiBY/bZYnP4zev0w==}
engines: {node: '>=18'}
peerDependencies:
solid-js: '>=1.9.7'
'@tanstack/devtools@0.4.0':
resolution: {integrity: sha512-3LrVUMI/s76XxYbygecOueXPrUf9gDc700iebQrBZ0ZvDmZDB/Umwa84gNx6NAxfQyI24lvB0IqQ8lp970oDSg==}
engines: {node: '>=18'}
peerDependencies:
solid-js: '>=1.9.7'
'@tanstack/eslint-config@0.1.0':
resolution: {integrity: sha512-/lVsQmmezpqqbCFtaxGNkoSnQ/+H0PAHyHF37PGQ6ZBrF8RXktgazMdsD5SCaNT9oEnGCl274GCZ9sY80b50Sg==}
engines: {node: '>=18'}
@ -2678,6 +2723,15 @@ packages:
'@tanstack/query-devtools@5.74.7':
resolution: {integrity: sha512-nSNlfuGdnHf4yB0S+BoNYOE1o3oAH093weAYZolIHfS2stulyA/gWfSk/9H4ZFk5mAAHb5vNqAeJOmbdcGPEQw==}
'@tanstack/react-devtools@0.4.0':
resolution: {integrity: sha512-QkN0WcbwKjYa107yEYOau+yhaay3XXwL2Ue+V0JxzaBOhvbnFI9tDzvIx7T1NZs8qEUgSjsqmMj/H81cISs2EA==}
engines: {node: '>=18'}
peerDependencies:
'@types/react': '>=16.8'
'@types/react-dom': '>=16.8'
react: '>=16.8'
react-dom: '>=16.8'
'@tanstack/react-query-devtools@5.75.0':
resolution: {integrity: sha512-ogTsCCONHPq8opyeU/NJZlus8+g36KnK+cybC1cs0Knr8iO/wAYgnciz+3DVNq38kRflBb0iJhHkUexp/7WfcQ==}
peerDependencies:
@ -2717,6 +2771,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 +2834,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'}
@ -5952,6 +6015,18 @@ packages:
utf-8-validate:
optional: true
ws@8.18.3:
resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
xml-name-validator@5.0.0:
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
engines: {node: '>=18'}
@ -8325,6 +8400,27 @@ snapshots:
'@sindresorhus/merge-streams@4.0.0': {}
'@solid-primitives/event-listener@2.4.3(solid-js@1.9.6)':
dependencies:
'@solid-primitives/utils': 6.3.2(solid-js@1.9.6)
solid-js: 1.9.6
'@solid-primitives/keyboard@1.3.3(solid-js@1.9.6)':
dependencies:
'@solid-primitives/event-listener': 2.4.3(solid-js@1.9.6)
'@solid-primitives/rootless': 1.5.2(solid-js@1.9.6)
'@solid-primitives/utils': 6.3.2(solid-js@1.9.6)
solid-js: 1.9.6
'@solid-primitives/rootless@1.5.2(solid-js@1.9.6)':
dependencies:
'@solid-primitives/utils': 6.3.2(solid-js@1.9.6)
solid-js: 1.9.6
'@solid-primitives/utils@6.3.2(solid-js@1.9.6)':
dependencies:
solid-js: 1.9.6
'@stylistic/eslint-plugin-js@4.2.0(eslint@9.25.1(jiti@2.4.2))':
dependencies:
eslint: 9.25.1(jiti@2.4.2)
@ -8496,6 +8592,33 @@ snapshots:
tailwindcss: 4.1.10
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)
'@tanstack/devtools-event-bus@0.2.1':
dependencies:
ws: 8.18.3
transitivePeerDependencies:
- bufferutil
- utf-8-validate
'@tanstack/devtools-ui@0.3.0(csstype@3.1.3)(solid-js@1.9.6)':
dependencies:
goober: 2.1.16(csstype@3.1.3)
solid-js: 1.9.6
transitivePeerDependencies:
- csstype
'@tanstack/devtools@0.4.0(csstype@3.1.3)(solid-js@1.9.6)':
dependencies:
'@solid-primitives/keyboard': 1.3.3(solid-js@1.9.6)
'@tanstack/devtools-event-bus': 0.2.1
'@tanstack/devtools-ui': 0.3.0(csstype@3.1.3)(solid-js@1.9.6)
clsx: 2.1.1
goober: 2.1.16(csstype@3.1.3)
solid-js: 1.9.6
transitivePeerDependencies:
- bufferutil
- csstype
- utf-8-validate
'@tanstack/eslint-config@0.1.0(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3)':
dependencies:
'@eslint/js': 9.25.1
@ -8529,6 +8652,19 @@ snapshots:
'@tanstack/query-devtools@5.74.7': {}
'@tanstack/react-devtools@0.4.0(@types/react-dom@19.1.3(@types/react@19.1.2))(@types/react@19.1.2)(csstype@3.1.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(solid-js@1.9.6)':
dependencies:
'@tanstack/devtools': 0.4.0(csstype@3.1.3)(solid-js@1.9.6)
'@types/react': 19.1.2
'@types/react-dom': 19.1.3(@types/react@19.1.2)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
transitivePeerDependencies:
- bufferutil
- csstype
- solid-js
- utf-8-validate
'@tanstack/react-query-devtools@5.75.0(@tanstack/react-query@5.75.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@tanstack/query-devtools': 5.74.7
@ -8576,6 +8712,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 +8780,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': {}
@ -12419,6 +12563,8 @@ snapshots:
ws@8.18.0:
optional: true
ws@8.18.3: {}
xml-name-validator@5.0.0:
optional: true

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,12 @@ 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
'@tanstack/react-devtools': ^0.4.0
# tauri
taurpc: ^1.8.1