mirror of
https://github.com/vrc-get/vrc-get.git
synced 2026-06-21 09:58:08 +00:00
Merge branch 'master' into upgrade-litedb
This commit is contained in:
commit
e4693c1348
11 changed files with 532 additions and 151 deletions
|
|
@ -11,6 +11,8 @@ The format is based on [Keep a Changelog].
|
|||
- Per-package `headers` field support `#718`
|
||||
- Since this is adding support for missing features, I treat this as a bugfix and not bump minor version.
|
||||
- De-duplicating duplicated projects or Unity in VCC project list `#1081`
|
||||
- Customizing Command Line Arguments for Unity `#1127`
|
||||
- Preserve Unity if multiple instance of the same unity version are installed `#1127`
|
||||
|
||||
### Changed
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,14 @@
|
|||
import {Button} from "@/components/ui/button";
|
||||
import {Card, CardHeader} from "@/components/ui/card";
|
||||
import {Checkbox} from "@/components/ui/checkbox";
|
||||
import {DialogDescription, DialogFooter, DialogOpen, DialogTitle} from "@/components/ui/dialog";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogOpen,
|
||||
DialogTitle
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -21,9 +28,25 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {Tooltip, TooltipContent, TooltipTrigger} from "@/components/ui/tooltip";
|
||||
import React, {Fragment, memo, Suspense, useCallback, useMemo, useState} from "react";
|
||||
import React, {
|
||||
Dispatch,
|
||||
memo,
|
||||
SetStateAction,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState
|
||||
} from "react";
|
||||
import {ArrowLeftIcon, ArrowPathIcon, ChevronDownIcon, EllipsisHorizontalIcon,} from "@heroicons/react/24/solid";
|
||||
import {ArrowUpCircleIcon, MinusCircleIcon, PlusCircleIcon,} from "@heroicons/react/24/outline";
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpCircleIcon,
|
||||
ArrowUpIcon,
|
||||
MinusCircleIcon,
|
||||
PlusCircleIcon,
|
||||
XCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import {HNavBar, VStack} from "@/components/layout";
|
||||
import {useRouter, useSearchParams} from "next/navigation";
|
||||
import {SearchBox} from "@/components/SearchBox";
|
||||
|
|
@ -38,10 +61,13 @@ import {
|
|||
environmentUnityVersions,
|
||||
projectApplyPendingChanges,
|
||||
projectDetails,
|
||||
projectGetCustomUnityArgs,
|
||||
projectInstallMultiplePackage,
|
||||
projectInstallPackage,
|
||||
projectRemovePackages,
|
||||
projectResolve,
|
||||
projectSetCustomUnityArgs,
|
||||
projectSetUnityPath,
|
||||
projectUpgradeMultiplePackage,
|
||||
TauriBasePackageInfo,
|
||||
TauriPackage,
|
||||
|
|
@ -63,6 +89,7 @@ import {tc, tt} from "@/lib/i18n";
|
|||
import {nameFromPath} from "@/lib/os";
|
||||
import {useBackupProjectModal} from "@/lib/backup-project";
|
||||
import {useUnity2022Migration, useUnity2022PatchMigration} from "@/app/projects/manage/unity-migration";
|
||||
import {Input} from "@/components/ui/input";
|
||||
|
||||
export default function Page(props: {}) {
|
||||
return <Suspense><PageBody {...props}/></Suspense>
|
||||
|
|
@ -463,12 +490,10 @@ function PageBody() {
|
|||
|
||||
const unity2022Migration = useUnity2022Migration({
|
||||
projectPath,
|
||||
unityVersions: unityVersionsResult.data,
|
||||
refresh: onRefresh
|
||||
});
|
||||
const unity2022PatchMigration = useUnity2022PatchMigration({
|
||||
projectPath,
|
||||
unityVersions: unityVersionsResult.data,
|
||||
refresh: onRefresh
|
||||
});
|
||||
|
||||
|
|
@ -518,7 +543,6 @@ function PageBody() {
|
|||
projectPath={projectPath}
|
||||
unityVersion={detailsResult.data?.unity_str ?? null}
|
||||
unityRevision={detailsResult.data?.unity_revision ?? null}
|
||||
unityVersions={unityVersionsResult?.data}
|
||||
onRemove={onRemoveProject}
|
||||
onBackup={onBackupProject}
|
||||
/>
|
||||
|
|
@ -1623,7 +1647,6 @@ function ProjectViewHeader({
|
|||
projectPath,
|
||||
unityVersion,
|
||||
unityRevision,
|
||||
unityVersions,
|
||||
onRemove,
|
||||
onBackup
|
||||
}: {
|
||||
|
|
@ -1632,12 +1655,17 @@ function ProjectViewHeader({
|
|||
projectPath: string
|
||||
unityVersion: string | null,
|
||||
unityRevision: string | null,
|
||||
unityVersions: TauriUnityVersions | undefined,
|
||||
onRemove?: () => void,
|
||||
onBackup?: () => void,
|
||||
}) {
|
||||
const openUnity = useOpenUnity(unityVersions);
|
||||
const openUnity = useOpenUnity();
|
||||
const openProjectFolder = () => utilOpen(projectPath);
|
||||
const [openLaunchOptions, setOpenLaunchOptions] = useState(false);
|
||||
|
||||
const onChangeLaunchOptions = () => setOpenLaunchOptions(true);
|
||||
const closeChangeLaunchOptions = () => setOpenLaunchOptions(false);
|
||||
|
||||
const forgetUnity = () => void projectSetUnityPath(projectPath, null);
|
||||
|
||||
return (
|
||||
<HNavBar className={className}>
|
||||
|
|
@ -1669,11 +1697,172 @@ function ProjectViewHeader({
|
|||
</div>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={openProjectFolder}>{tc("projects:menuitem:open directory")}</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={forgetUnity}>{tc("projects:menuitem:forget unity path")}</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onBackup}>{tc("projects:menuitem:backup")}</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onChangeLaunchOptions}>{tc("projects:menuitem:change launch options")}</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onRemove} className={"bg-destructive text-destructive-foreground"}>{tc("projects:remove project")}</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{openUnity.dialog}
|
||||
<Dialog open={openLaunchOptions}>
|
||||
<DialogContent>
|
||||
<LaunchSettings projectPath={projectPath} close={closeChangeLaunchOptions}/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</HNavBar>
|
||||
);
|
||||
}
|
||||
|
||||
const defaultArgs = [
|
||||
"-debugCodeOptimization",
|
||||
]
|
||||
|
||||
function LaunchSettings(
|
||||
{
|
||||
projectPath,
|
||||
close
|
||||
} : {
|
||||
projectPath: string;
|
||||
close: () => void;
|
||||
}
|
||||
) {
|
||||
const [customizeCommandline, setCustomizeCommandline] = useState(false);
|
||||
// Note: remember to change similar in rust side
|
||||
const [customCommandlineArgs, setCustomCommandlineArgs] = useState(defaultArgs);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const args = await projectGetCustomUnityArgs(projectPath);
|
||||
if (args != null) {
|
||||
setCustomizeCommandline(true);
|
||||
setCustomCommandlineArgs(args);
|
||||
}
|
||||
})()
|
||||
}, [projectPath])
|
||||
|
||||
const save = async () => {
|
||||
await projectSetCustomUnityArgs(projectPath, customizeCommandline ? customCommandlineArgs : null)
|
||||
close();
|
||||
}
|
||||
|
||||
let errorMessage;
|
||||
|
||||
if (customizeCommandline && customCommandlineArgs.some(x => x.length == 0)) {
|
||||
errorMessage = tc("projects:hint:some arguments are empty");
|
||||
}
|
||||
|
||||
return <>
|
||||
<DialogTitle>{tc("projects:dialog:launch options")}</DialogTitle>
|
||||
<DialogDescription className={"max-h-[50dvh] overflow-y-auto"}>
|
||||
<h3 className={"text-lg"}>{tc("projects:dialog:command-line arguments")}</h3>
|
||||
{customizeCommandline
|
||||
? <>
|
||||
<Button
|
||||
onClick={() => setCustomizeCommandline(false)}>{tc("projects:dialog:use default command line arguments")}</Button>
|
||||
<CommandLineArgsConfig
|
||||
commandLineArgs={customCommandlineArgs}
|
||||
setCommandLineArgs={setCustomCommandlineArgs}
|
||||
errorMessage={errorMessage}
|
||||
/>
|
||||
</>
|
||||
: <>
|
||||
<Button
|
||||
onClick={() => setCustomizeCommandline(true)}>{tc("projects:dialog:customize command line arguments")}</Button>
|
||||
<CommandLineArgsConfig
|
||||
commandLineArgs={defaultArgs}
|
||||
disabled
|
||||
/>
|
||||
</>
|
||||
}
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Button onClick={save} disabled={errorMessage != null}>{tc("general:button:save")}</Button>
|
||||
<Button onClick={close} variant={'destructive'}>{tc("general:button:cancel")}</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
}
|
||||
|
||||
function CommandLineArgsConfig(
|
||||
{
|
||||
commandLineArgs,
|
||||
setCommandLineArgs,
|
||||
disabled,
|
||||
errorMessage,
|
||||
}: {
|
||||
commandLineArgs: string[];
|
||||
setCommandLineArgs?: Dispatch<SetStateAction<string[]>>;
|
||||
disabled?: boolean;
|
||||
errorMessage?: React.ReactNode;
|
||||
}
|
||||
) {
|
||||
return <>
|
||||
<div>
|
||||
{commandLineArgs.map((arg, i) => <div key={i} className={"flex gap-1"}>
|
||||
<Input
|
||||
disabled={disabled}
|
||||
value={arg}
|
||||
onChange={e => setCommandLineArgs?.(prev => {
|
||||
const copy = [...prev];
|
||||
copy[i] = e.target.value;
|
||||
return copy;
|
||||
})}
|
||||
className={"w-full"}
|
||||
/>
|
||||
<Button
|
||||
disabled={disabled}
|
||||
variant={'ghost'}
|
||||
size={"icon"}
|
||||
className={'my-1'}
|
||||
onClick={() => setCommandLineArgs?.(prev => {
|
||||
const copy = [...prev];
|
||||
copy.splice(i, 1);
|
||||
return copy;
|
||||
})}
|
||||
>
|
||||
<XCircleIcon className={"size-5 text-destructive"}/>
|
||||
</Button>
|
||||
<div className={'flex flex-col my-1'}>
|
||||
<Button
|
||||
disabled={disabled || i == 0}
|
||||
variant={'ghost'}
|
||||
size={"icon"}
|
||||
className={'h-5'}
|
||||
onClick={() => setCommandLineArgs?.(prev => {
|
||||
const copy = [...prev];
|
||||
const tmp = copy[i];
|
||||
copy[i] = copy[i - 1];
|
||||
copy[i - 1] = tmp;
|
||||
return copy;
|
||||
})}
|
||||
>
|
||||
<ArrowUpIcon className={"size-2"}/>
|
||||
</Button>
|
||||
<Button
|
||||
disabled={disabled || i == commandLineArgs.length - 1}
|
||||
variant={'ghost'}
|
||||
size={"icon"}
|
||||
className={'h-5'}
|
||||
onClick={() => setCommandLineArgs?.(prev => {
|
||||
const copy = [...prev];
|
||||
const tmp = copy[i];
|
||||
copy[i] = copy[i + 1];
|
||||
copy[i + 1] = tmp;
|
||||
return copy;
|
||||
})}
|
||||
>
|
||||
<ArrowDownIcon className={"size-2"}/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>)}
|
||||
</div>
|
||||
<div className={"flex gap-1 m-1 items-center"}>
|
||||
<Button disabled={disabled}
|
||||
onClick={() => setCommandLineArgs?.(v => [...v, ""])}>{tc("general:button:add")}</Button>
|
||||
<Button disabled={disabled}
|
||||
onClick={() => setCommandLineArgs?.(defaultArgs)}>{tc("general:button:reset")}</Button>
|
||||
<div className={"text-destructive whitespace-normal"}>
|
||||
{errorMessage}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import {DialogDescription, DialogFooter, DialogOpen, DialogTitle} from "@/compon
|
|||
import {tc, tt} from "@/lib/i18n";
|
||||
import {toastError, toastSuccess, toastThrownError} from "@/lib/toast";
|
||||
import {
|
||||
environmentCopyProjectForMigration,
|
||||
environmentCopyProjectForMigration, environmentUnityVersions,
|
||||
projectCallUnityForMigration, projectIsUnityLaunching,
|
||||
projectMigrateProjectTo2022, TauriUnityVersions
|
||||
} from "@/lib/bindings";
|
||||
|
|
@ -23,17 +23,14 @@ function findRecommendedUnity(unityVersions?: TauriUnityVersions): UnityInstalla
|
|||
export function useUnity2022Migration(
|
||||
{
|
||||
projectPath,
|
||||
unityVersions,
|
||||
refresh,
|
||||
}: {
|
||||
projectPath: string,
|
||||
unityVersions?: TauriUnityVersions,
|
||||
refresh?: () => void,
|
||||
}
|
||||
): Result {
|
||||
return useMigrationInternal({
|
||||
projectPath,
|
||||
unityVersions,
|
||||
updateProjectPreUnityLaunch: async (project) => await projectMigrateProjectTo2022(project),
|
||||
refresh,
|
||||
ConfirmComponent: MigrationConfirmMigrationDialog,
|
||||
|
|
@ -61,17 +58,14 @@ function MigrationConfirmMigrationDialog({cancel, doMigrate}: ConfirmProps) {
|
|||
export function useUnity2022PatchMigration(
|
||||
{
|
||||
projectPath,
|
||||
unityVersions,
|
||||
refresh,
|
||||
}: {
|
||||
projectPath: string,
|
||||
unityVersions?: TauriUnityVersions,
|
||||
refresh?: () => void,
|
||||
}
|
||||
): Result {
|
||||
return useMigrationInternal({
|
||||
projectPath,
|
||||
unityVersions,
|
||||
updateProjectPreUnityLaunch: async () => {
|
||||
}, // nothing pre-launch
|
||||
refresh,
|
||||
|
|
@ -109,8 +103,11 @@ type StateInternal = {
|
|||
state: "normal";
|
||||
} | {
|
||||
state: "confirm";
|
||||
unityVersions: TauriUnityVersions;
|
||||
unityFound: UnityInstallation[];
|
||||
} | {
|
||||
state: "noExactUnity2022";
|
||||
unityVersions: TauriUnityVersions;
|
||||
} | {
|
||||
state: "copyingProject";
|
||||
} | {
|
||||
|
|
@ -134,14 +131,12 @@ type ConfirmProps = {
|
|||
function useMigrationInternal(
|
||||
{
|
||||
projectPath,
|
||||
unityVersions,
|
||||
updateProjectPreUnityLaunch,
|
||||
refresh,
|
||||
|
||||
ConfirmComponent,
|
||||
}: {
|
||||
projectPath: string,
|
||||
unityVersions?: TauriUnityVersions,
|
||||
updateProjectPreUnityLaunch: (projectPath: string) => Promise<unknown>,
|
||||
refresh?: () => void,
|
||||
|
||||
|
|
@ -158,20 +153,19 @@ function useMigrationInternal(
|
|||
toastError(tt("projects:toast:close unity before migration"));
|
||||
return;
|
||||
}
|
||||
const unityVersions = await environmentUnityVersions();
|
||||
const unityFound = findRecommendedUnity(unityVersions);
|
||||
if (unityFound.length == 0)
|
||||
setInstallStatus({state: "noExactUnity2022"});
|
||||
setInstallStatus({state: "noExactUnity2022", unityVersions});
|
||||
else
|
||||
setInstallStatus({state: "confirm"});
|
||||
setInstallStatus({state: "confirm", unityVersions, unityFound});
|
||||
}
|
||||
|
||||
const startMigrateProjectTo2022 = async (inPlace: boolean) => {
|
||||
const startMigrateProjectTo2022 = async (inPlace: boolean, unityFound: UnityInstallation[]) => {
|
||||
try {
|
||||
const unityFound = findRecommendedUnity(unityVersions);
|
||||
switch (unityFound.length) {
|
||||
case 0:
|
||||
setInstallStatus({state: "noExactUnity2022"});
|
||||
break;
|
||||
throw new Error("unreachable");
|
||||
case 1:
|
||||
// noinspection ES6MissingAwait
|
||||
continueMigrateProjectTo2022(inPlace, unityFound[0][0]);
|
||||
|
|
@ -181,8 +175,7 @@ function useMigrationInternal(
|
|||
if (selected == null)
|
||||
setInstallStatus({state: "normal"});
|
||||
else
|
||||
// noinspection ES6MissingAwait
|
||||
continueMigrateProjectTo2022(inPlace, selected);
|
||||
void continueMigrateProjectTo2022(inPlace, selected.unityPath);
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -258,9 +251,9 @@ function useMigrationInternal(
|
|||
break;
|
||||
case "confirm":
|
||||
dialogBodyForState = <ConfirmComponent
|
||||
unity={unityVersions!.recommended_version}
|
||||
unity={installStatus.unityVersions!.recommended_version}
|
||||
cancel={cancelMigrateProjectTo2022}
|
||||
doMigrate={startMigrateProjectTo2022}
|
||||
doMigrate={(inPlace) => startMigrateProjectTo2022(inPlace, installStatus.unityFound)}
|
||||
/>;
|
||||
break;
|
||||
case "copyingProject":
|
||||
|
|
@ -271,8 +264,8 @@ function useMigrationInternal(
|
|||
break;
|
||||
case "noExactUnity2022":
|
||||
dialogBodyForState = <NoExactUnity2022Dialog
|
||||
expectedVersion={unityVersions!.recommended_version}
|
||||
installWithUnityHubLink={unityVersions!.install_recommended_version_link}
|
||||
expectedVersion={installStatus.unityVersions!.recommended_version}
|
||||
installWithUnityHubLink={installStatus.unityVersions!.install_recommended_version_link}
|
||||
close={cancelMigrateProjectTo2022}
|
||||
/>;
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -85,15 +85,10 @@ export default function Page() {
|
|||
queryKey: ["projects"],
|
||||
queryFn: environmentProjects,
|
||||
});
|
||||
const unityVersionsResult = useQuery({
|
||||
queryKey: ["unityVersions"],
|
||||
queryFn: () => environmentUnityVersions(),
|
||||
});
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [loadingOther, setLoadingOther] = useState(false);
|
||||
const [createProjectState, setCreateProjectState] = useState<'normal' | 'creating'>('normal');
|
||||
const openUnity = useOpenUnity(unityVersionsResult?.data);
|
||||
const openUnity = useOpenUnity();
|
||||
|
||||
const startCreateProject = () => setCreateProjectState('creating');
|
||||
|
||||
|
|
|
|||
|
|
@ -190,6 +190,22 @@ export function projectCreateBackup(channel: string, projectPath: string) {
|
|||
return invoke()<AsyncCallResult<null, null>>("project_create_backup", { channel,projectPath })
|
||||
}
|
||||
|
||||
export function projectGetCustomUnityArgs(projectPath: string) {
|
||||
return invoke()<string[] | null>("project_get_custom_unity_args", { projectPath })
|
||||
}
|
||||
|
||||
export function projectSetCustomUnityArgs(projectPath: string, args: string[] | null) {
|
||||
return invoke()<boolean>("project_set_custom_unity_args", { projectPath,args })
|
||||
}
|
||||
|
||||
export function projectGetUnityPath(projectPath: string) {
|
||||
return invoke()<string | null>("project_get_unity_path", { projectPath })
|
||||
}
|
||||
|
||||
export function projectSetUnityPath(projectPath: string, unityPath: string | null) {
|
||||
return invoke()<boolean>("project_set_unity_path", { projectPath,unityPath })
|
||||
}
|
||||
|
||||
export function utilOpen(path: string) {
|
||||
return invoke()<null>("util_open", { path })
|
||||
}
|
||||
|
|
@ -214,35 +230,35 @@ export function deepLinkInstallVcc() {
|
|||
return invoke()<null>("deep_link_install_vcc")
|
||||
}
|
||||
|
||||
export type TauriPackageSource = "LocalUser" | { Remote: { id: string; display_name: string } }
|
||||
export type TauriConflictInfo = { packages: string[]; unity_conflict: boolean }
|
||||
export type TauriPickProjectBackupPathResult = "NoFolderSelected" | "InvalidSelection" | "Successful"
|
||||
export type TauriCreateProjectResult = "AlreadyExists" | "TemplateNotFound" | "Successful"
|
||||
export type TauriVersion = { major: number; minor: number; patch: number; pre: string; build: string }
|
||||
export type TauriBasePackageInfo = { name: string; display_name: string | null; description: string | null; aliases: string[]; version: TauriVersion; unity: [number, number] | null; changelog_url: string | null; vpm_dependencies: string[]; legacy_packages: string[]; is_yanked: boolean }
|
||||
export type TauriPackageSource = "LocalUser" | { Remote: { id: string; display_name: string } }
|
||||
export type TauriProjectDirCheckResult = "InvalidNameForFolderName" | "MayCompatibilityProblem" | "WideChar" | "AlreadyExists" | "Ok"
|
||||
export type TauriUnityVersions = { unity_paths: ([string, string, boolean])[]; recommended_version: string; install_recommended_version_link: string }
|
||||
export type TauriRemoteRepositoryInfo = { display_name: string; id: string; url: string; packages: TauriBasePackageInfo[] }
|
||||
export type TauriPendingProjectChanges = { changes_version: number; package_changes: ([string, TauriPackageChange])[]; remove_legacy_files: string[]; remove_legacy_folders: string[]; conflicts: ([string, TauriConflictInfo])[] }
|
||||
export type TauriProjectDetails = { unity: [number, number] | null; unity_str: string | null; unity_revision: string | null; installed_packages: ([string, TauriBasePackageInfo])[]; should_resolve: boolean }
|
||||
export type TauriUserRepository = { id: string; url: string | null; display_name: string }
|
||||
export type LogEntry = { time: string; level: LogLevel; target: string; message: string }
|
||||
export type TauriAddRepositoryResult = "BadUrl" | "Success"
|
||||
export type TauriPickUnityHubResult = "NoFolderSelected" | "InvalidSelection" | "Successful"
|
||||
export type TauriPickProjectBackupPathResult = "NoFolderSelected" | "InvalidSelection" | "Successful"
|
||||
export type TauriEnvironmentSettings = { default_project_path: string; project_backup_path: string; unity_hub: string; unity_paths: ([string, string, boolean])[]; show_prerelease_packages: boolean; backup_format: string }
|
||||
export type TauriRemoteRepositoryInfo = { display_name: string; id: string; url: string; packages: TauriBasePackageInfo[] }
|
||||
export type LogLevel = "Error" | "Warn" | "Info" | "Debug" | "Trace"
|
||||
export type TauriProjectDirCheckResult = "InvalidNameForFolderName" | "MayCompatibilityProblem" | "WideChar" | "AlreadyExists" | "Ok"
|
||||
export type AddRepositoryInfo = { url: string; headers: { [key: string]: string } }
|
||||
export type TauriProject = { list_version: number; index: number; name: string; path: string; project_type: TauriProjectType; unity: string; unity_revision: string | null; last_modified: number; created_at: number; favorite: boolean; is_exists: boolean }
|
||||
export type TauriBasePackageInfo = { name: string; display_name: string | null; description: string | null; aliases: string[]; version: TauriVersion; unity: [number, number] | null; changelog_url: string | null; vpm_dependencies: string[]; legacy_packages: string[]; is_yanked: boolean }
|
||||
export type TauriProjectCreationInformation = { templates: TauriProjectTemplate[]; default_path: string }
|
||||
export type TauriPickUnityResult = "NoFolderSelected" | "InvalidSelection" | "AlreadyAdded" | "Successful"
|
||||
export type TauriProjectTemplate = { type: "Builtin"; id: string; name: string } | { type: "Custom"; name: string }
|
||||
export type TauriUnityVersions = { unity_paths: ([string, string, boolean])[]; recommended_version: string; install_recommended_version_link: string }
|
||||
export type TauriAddProjectWithPickerResult = "NoFolderSelected" | "InvalidSelection" | "AlreadyAdded" | "Successful"
|
||||
export type TauriRepositoriesInfo = { user_repositories: TauriUserRepository[]; hidden_user_repositories: string[]; hide_local_user_packages: boolean; show_prerelease_packages: boolean }
|
||||
export type AsyncCallResult<P, R> = { type: "Result"; value: R } | { type: "Started" } | { type: "UnusedProgress"; progress: P }
|
||||
export type TauriCallUnityForMigrationResult = { type: "ExistsWithNonZero"; status: string } | { type: "FinishedSuccessfully" }
|
||||
export type TauriRemoveReason = "Requested" | "Legacy" | "Unused"
|
||||
export type LogEntry = { time: string; level: LogLevel; target: string; message: string }
|
||||
export type TauriDownloadRepository = { type: "BadUrl" } | { type: "Duplicated" } | { type: "DownloadError"; message: string } | { type: "Success"; value: TauriRemoteRepositoryInfo }
|
||||
export type TauriProjectType = "Unknown" | "LegacySdk2" | "LegacyWorlds" | "LegacyAvatars" | "UpmWorlds" | "UpmAvatars" | "UpmStarter" | "Worlds" | "Avatars" | "VpmStarter"
|
||||
export type TauriPackageChange = { InstallNew: TauriBasePackageInfo } | { Remove: TauriRemoveReason }
|
||||
export type TauriPickUnityHubResult = "NoFolderSelected" | "InvalidSelection" | "Successful"
|
||||
export type TauriRemoveReason = "Requested" | "Legacy" | "Unused"
|
||||
export type LogLevel = "Error" | "Warn" | "Info" | "Debug" | "Trace"
|
||||
export type TauriCallUnityForMigrationResult = { type: "ExistsWithNonZero"; status: string } | { type: "FinishedSuccessfully" }
|
||||
export type TauriPackage = ({ name: string; display_name: string | null; description: string | null; aliases: string[]; version: TauriVersion; unity: [number, number] | null; changelog_url: string | null; vpm_dependencies: string[]; legacy_packages: string[]; is_yanked: boolean }) & { env_version: number; index: number; source: TauriPackageSource }
|
||||
export type AddRepositoryInfo = { url: string; headers: { [key: string]: string } }
|
||||
export type TauriPickUnityResult = "NoFolderSelected" | "InvalidSelection" | "AlreadyAdded" | "Successful"
|
||||
export type TauriConflictInfo = { packages: string[]; unity_conflict: boolean }
|
||||
export type TauriAddProjectWithPickerResult = "NoFolderSelected" | "InvalidSelection" | "AlreadyAdded" | "Successful"
|
||||
export type TauriPackageChange = { InstallNew: TauriBasePackageInfo } | { Remove: TauriRemoveReason }
|
||||
export type TauriProjectTemplate = { type: "Builtin"; id: string; name: string } | { type: "Custom"; name: string }
|
||||
export type TauriProjectType = "Unknown" | "LegacySdk2" | "LegacyWorlds" | "LegacyAvatars" | "UpmWorlds" | "UpmAvatars" | "UpmStarter" | "Worlds" | "Avatars" | "VpmStarter"
|
||||
export type AsyncCallResult<P, R> = { type: "Result"; value: R } | { type: "Started" } | { type: "UnusedProgress"; progress: P }
|
||||
export type TauriCreateProjectResult = "AlreadyExists" | "TemplateNotFound" | "Successful"
|
||||
export type TauriProjectCreationInformation = { templates: TauriProjectTemplate[]; default_path: string }
|
||||
export type TauriProject = { list_version: number; index: number; name: string; path: string; project_type: TauriProjectType; unity: string; unity_revision: string | null; last_modified: number; created_at: number; favorite: boolean; is_exists: boolean }
|
||||
export type TauriUserRepository = { id: string; url: string | null; display_name: string }
|
||||
export type TauriPickProjectDefaultPathResult = { type: "NoFolderSelected" } | { type: "InvalidSelection" } | { type: "Successful"; new_path: string }
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
import {projectOpenUnity, TauriUnityVersions} from "@/lib/bindings";
|
||||
import {
|
||||
environmentUnityVersions,
|
||||
projectGetUnityPath,
|
||||
projectOpenUnity,
|
||||
projectSetUnityPath,
|
||||
TauriUnityVersions
|
||||
} from "@/lib/bindings";
|
||||
import i18next, {tc} from "@/lib/i18n";
|
||||
import {toastError, toastNormal} from "@/lib/toast";
|
||||
import {useUnitySelectorDialog} from "@/lib/use-unity-selector-dialog";
|
||||
|
|
@ -22,7 +28,7 @@ type StateInternal = {
|
|||
unityHubLink: string;
|
||||
}
|
||||
|
||||
export function useOpenUnity(unityVersions: TauriUnityVersions | undefined): Result {
|
||||
export function useOpenUnity(): Result {
|
||||
const unitySelector = useUnitySelectorDialog();
|
||||
const [installStatus, setInstallStatus] = React.useState<StateInternal>({state: "normal"});
|
||||
|
||||
|
|
@ -31,6 +37,10 @@ export function useOpenUnity(unityVersions: TauriUnityVersions | undefined): Res
|
|||
toastError(i18next.t("projects:toast:invalid project unity version"));
|
||||
return;
|
||||
}
|
||||
const [unityVersions, selectedPath] = await Promise.all([
|
||||
environmentUnityVersions(),
|
||||
projectGetUnityPath(projectPath),
|
||||
]);
|
||||
if (unityVersions == null) {
|
||||
toastError(i18next.t("projects:toast:match version unity not found", {unity: unityVersion}));
|
||||
return;
|
||||
|
|
@ -51,6 +61,12 @@ export function useOpenUnity(unityVersions: TauriUnityVersions | undefined): Res
|
|||
}
|
||||
return;
|
||||
case 1: {
|
||||
if (selectedPath) {
|
||||
if (foundVersions[0][0] != selectedPath) {
|
||||
// if only unity is not
|
||||
void projectSetUnityPath(projectPath, null);
|
||||
}
|
||||
}
|
||||
const result = await projectOpenUnity(projectPath, foundVersions[0][0]);
|
||||
if (result)
|
||||
toastNormal(i18next.t("projects:toast:opening unity..."));
|
||||
|
|
@ -59,9 +75,23 @@ export function useOpenUnity(unityVersions: TauriUnityVersions | undefined): Res
|
|||
}
|
||||
return;
|
||||
default: {
|
||||
const selected = await unitySelector.select(foundVersions);
|
||||
if (selectedPath) {
|
||||
const found = foundVersions.find(([p, _v, _i]) => p === selectedPath);
|
||||
if (found) {
|
||||
const result = await projectOpenUnity(projectPath, selectedPath);
|
||||
if (result)
|
||||
toastNormal(i18next.t("projects:toast:opening unity..."));
|
||||
else
|
||||
toastError(i18next.t("projects:toast:unity already running"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
const selected = await unitySelector.select(foundVersions, true);
|
||||
if (selected == null) return;
|
||||
const result = await projectOpenUnity(projectPath, foundVersions[0][0]);
|
||||
if (selected.keepUsingThisVersion) {
|
||||
void projectSetUnityPath(projectPath, selected.unityPath);
|
||||
}
|
||||
const result = await projectOpenUnity(projectPath, selected.unityPath);
|
||||
if (result)
|
||||
toastNormal(i18next.t("projects:toast:opening unity..."));
|
||||
else
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {DialogDescription, DialogFooter, DialogOpen, DialogTitle} from "@/compon
|
|||
import {Label} from "@/components/ui/label";
|
||||
import {RadioGroup, RadioGroupItem} from "@/components/ui/radio-group";
|
||||
import {tc} from "@/lib/i18n";
|
||||
import {Checkbox} from "@/components/ui/checkbox";
|
||||
|
||||
type UnityInstallation = [path: string, version: string, fromHub: boolean];
|
||||
|
||||
|
|
@ -12,20 +13,35 @@ type StateUnitySelector = {
|
|||
} | {
|
||||
state: "selecting";
|
||||
unityVersions: UnityInstallation[];
|
||||
resolve: (unityPath: string | null) => void;
|
||||
supportKeepUsing: boolean; // if true, show the option to keep using this unity in the future
|
||||
resolve: (unityInfo: SelectResult | null) => void;
|
||||
}
|
||||
|
||||
type SelectResult = SelectResultWithoutInTheFuture | SelectResultWithInTheFuture
|
||||
|
||||
type SelectResultWithoutInTheFuture = {
|
||||
unityPath: string,
|
||||
}
|
||||
|
||||
type SelectResultWithInTheFuture = {
|
||||
unityPath: string,
|
||||
keepUsingThisVersion: boolean,
|
||||
}
|
||||
|
||||
type ResultUnitySelector = {
|
||||
dialog: React.ReactNode;
|
||||
select: (unityList: [path: string, version: string, fromHub: boolean][]) => Promise<string | null>;
|
||||
select(unityList: UnityInstallation[]): Promise<SelectResultWithoutInTheFuture | null>
|
||||
select(unityList: UnityInstallation[], supportKeepUsing: true): Promise<SelectResultWithInTheFuture | null>
|
||||
}
|
||||
|
||||
export function useUnitySelectorDialog(): ResultUnitySelector {
|
||||
const [installStatus, setInstallStatus] = React.useState<StateUnitySelector>({state: "normal"});
|
||||
|
||||
const select = (unityVersions: UnityInstallation[]) => {
|
||||
return new Promise<string | null>((resolve) => {
|
||||
setInstallStatus({state: "selecting", unityVersions, resolve});
|
||||
function select(unityVersions: UnityInstallation[]): Promise<SelectResultWithoutInTheFuture | null>
|
||||
function select(unityVersions: UnityInstallation[], supportKeepUsing: boolean): Promise<SelectResultWithInTheFuture | null>
|
||||
function select(unityVersions: UnityInstallation[], supportKeepUsing?: boolean) {
|
||||
return new Promise<SelectResult | null>((resolve) => {
|
||||
setInstallStatus({state: "selecting", unityVersions, resolve, supportKeepUsing: supportKeepUsing ?? false});
|
||||
});
|
||||
}
|
||||
let dialog: React.ReactNode = null;
|
||||
|
|
@ -34,16 +50,21 @@ export function useUnitySelectorDialog(): ResultUnitySelector {
|
|||
case "normal":
|
||||
break;
|
||||
case "selecting":
|
||||
const resolveWrapper = (unityPath: string | null) => {
|
||||
const cancel = () => {
|
||||
setInstallStatus({state: "normal"});
|
||||
installStatus.resolve(unityPath);
|
||||
installStatus.resolve(null);
|
||||
}
|
||||
const resolveWrapper = (unityPath: string, keepUsingThisVersion: boolean) => {
|
||||
setInstallStatus({state: "normal"});
|
||||
installStatus.resolve(installStatus.supportKeepUsing ? {unityPath, keepUsingThisVersion} : {unityPath})
|
||||
};
|
||||
dialog = <DialogOpen className={"whitespace-normal"}>
|
||||
<DialogTitle>{tc("projects:manage:dialog:select unity header")}</DialogTitle>
|
||||
<SelectUnityVersionDialog
|
||||
unityVersions={installStatus.unityVersions}
|
||||
cancel={() => resolveWrapper(null)}
|
||||
onSelect={(unityPath) => resolveWrapper(unityPath)}
|
||||
cancel={cancel}
|
||||
withKeepUsing={installStatus.supportKeepUsing}
|
||||
onSelect={resolveWrapper}
|
||||
/>
|
||||
</DialogOpen>;
|
||||
break;
|
||||
|
|
@ -58,15 +79,18 @@ function SelectUnityVersionDialog(
|
|||
{
|
||||
unityVersions,
|
||||
cancel,
|
||||
withKeepUsing,
|
||||
onSelect,
|
||||
}: {
|
||||
unityVersions: UnityInstallation[],
|
||||
cancel: () => void,
|
||||
onSelect: (unityPath: string) => void,
|
||||
withKeepUsing: boolean,
|
||||
onSelect: (unityPath: string, keepUsingThisVersion: boolean) => void,
|
||||
}) {
|
||||
const id = useId();
|
||||
|
||||
const [selectedUnityPath, setSelectedUnityPath] = useState<string | null>(null);
|
||||
const [keepUsingThisVersion, setKeepUsingThisVersion] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -74,6 +98,14 @@ function SelectUnityVersionDialog(
|
|||
<p>
|
||||
{tc("projects:manage:dialog:multiple unity found")}
|
||||
</p>
|
||||
{withKeepUsing && <div>
|
||||
<label className={"flex cursor-pointer items-center gap-2 p-2 whitespace-normal"}>
|
||||
<Checkbox checked={keepUsingThisVersion}
|
||||
onCheckedChange={(e) => setKeepUsingThisVersion(e == true)}
|
||||
className="hover:before:content-none"/>
|
||||
{"Keep Using This Version"}
|
||||
</label>
|
||||
</div>}
|
||||
<RadioGroup
|
||||
onValueChange={(path) => setSelectedUnityPath(path)}
|
||||
value={selectedUnityPath ?? undefined}
|
||||
|
|
@ -92,7 +124,7 @@ function SelectUnityVersionDialog(
|
|||
<DialogFooter>
|
||||
<Button onClick={cancel} className="mr-1">{tc("general:button:cancel")}</Button>
|
||||
<Button
|
||||
onClick={() => onSelect(selectedUnityPath!)}
|
||||
onClick={() => onSelect(selectedUnityPath!, keepUsingThisVersion)}
|
||||
disabled={selectedUnityPath == null}
|
||||
>{tc("projects:manage:button:continue")}</Button>
|
||||
</DialogFooter>
|
||||
|
|
|
|||
|
|
@ -44,6 +44,9 @@
|
|||
"projects:remove project": "Remove Project",
|
||||
"projects:dialog:warn removing project": "You're about to remove the project <strong>{{name}}</strong>. Are you sure?",
|
||||
"general:button:cancel": "Cancel",
|
||||
"general:button:save": "Save",
|
||||
"general:button:add": "Add",
|
||||
"general:button:reset": "Reset",
|
||||
"projects:button:remove from list": "Remove from the List",
|
||||
"projects:button:remove directory": "Remove the Directory",
|
||||
"projects:dialog:removing...": "Removing the project...",
|
||||
|
|
@ -58,7 +61,14 @@
|
|||
"projects:button:open unity": "Open Unity",
|
||||
"projects:backup": "Backup",
|
||||
"projects:menuitem:backup": "Make Backup",
|
||||
"projects:menuitem:change launch options": "Change Launch Options",
|
||||
"projects:menuitem:open directory": "Open Project Directory",
|
||||
"projects:menuitem:forget unity path": "Forget Unity for This Project",
|
||||
"projects:dialog:launch options": "Launch Options",
|
||||
"projects:dialog:command-line arguments": "Command-line Arguments",
|
||||
"projects:dialog:use default command line arguments": "Use Default Command-line Arguments",
|
||||
"projects:dialog:customize command line arguments": "Customize Command-line Arguments",
|
||||
"projects:hint:some arguments are empty": "Some arguments are empty.",
|
||||
"general:toast:invalid directory": "Invalid directory was selected.",
|
||||
"projects:toast:project added": "Project was addded successfully.",
|
||||
"projects:toast:project already exists": "The project was already added.",
|
||||
|
|
|
|||
|
|
@ -90,6 +90,10 @@ pub(crate) fn handlers() -> impl Fn(Invoke) + Send + Sync + 'static {
|
|||
project_open_unity,
|
||||
project_is_unity_launching,
|
||||
project_create_backup,
|
||||
project_get_custom_unity_args,
|
||||
project_set_custom_unity_args,
|
||||
project_get_unity_path,
|
||||
project_set_unity_path,
|
||||
util_open,
|
||||
util_get_log_entries,
|
||||
util_get_version,
|
||||
|
|
@ -148,6 +152,10 @@ pub(crate) fn export_ts() {
|
|||
project_open_unity,
|
||||
project_is_unity_launching,
|
||||
project_create_backup,
|
||||
project_get_custom_unity_args,
|
||||
project_set_custom_unity_args,
|
||||
project_get_unity_path,
|
||||
project_set_unity_path,
|
||||
util_open,
|
||||
util_get_log_entries,
|
||||
util_get_version,
|
||||
|
|
@ -2390,16 +2398,28 @@ async fn project_open_unity(
|
|||
return Ok(false);
|
||||
}
|
||||
|
||||
let mut custom_args: Option<Vec<String>> = None;
|
||||
|
||||
with_environment!(&state, |environment| {
|
||||
if let Some(project) = environment.find_project(project_path.as_ref())? {
|
||||
custom_args = project
|
||||
.custom_unity_args()
|
||||
.map(|x| Vec::from_iter(x.iter().map(ToOwned::to_owned)));
|
||||
}
|
||||
update_project_last_modified(environment, project_path.as_ref()).await;
|
||||
});
|
||||
|
||||
crate::os::start_command(
|
||||
"Unity".as_ref(),
|
||||
unity_path.as_ref(),
|
||||
&["-projectPath".as_ref(), OsStr::new(project_path.as_str())],
|
||||
)
|
||||
.await?;
|
||||
let mut args = vec!["-projectPath".as_ref(), OsStr::new(project_path.as_str())];
|
||||
|
||||
if let Some(custom_args) = &custom_args {
|
||||
args.extend(custom_args.iter().map(OsStr::new));
|
||||
} else {
|
||||
// TODO: configurable default options?
|
||||
// Note: remember to change similar in typescript
|
||||
args.push(OsStr::new("-debugCodeOptimization"));
|
||||
}
|
||||
|
||||
crate::os::start_command("Unity".as_ref(), unity_path.as_ref(), &args).await?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
|
@ -2632,6 +2652,92 @@ async fn project_create_backup(
|
|||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn project_get_custom_unity_args(
|
||||
state: State<'_, Mutex<EnvironmentState>>,
|
||||
project_path: String,
|
||||
) -> Result<Option<Vec<String>>, RustError> {
|
||||
with_environment!(&state, |environment| {
|
||||
let result;
|
||||
if let Some(project) = environment.find_project(project_path.as_ref())? {
|
||||
result = project
|
||||
.custom_unity_args()
|
||||
.map(|x| x.iter().map(ToOwned::to_owned).collect());
|
||||
} else {
|
||||
result = None;
|
||||
}
|
||||
environment.disconnect_litedb();
|
||||
Ok(result)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn project_set_custom_unity_args(
|
||||
state: State<'_, Mutex<EnvironmentState>>,
|
||||
project_path: String,
|
||||
args: Option<Vec<String>>,
|
||||
) -> Result<bool, RustError> {
|
||||
with_environment!(&state, |environment| {
|
||||
if let Some(mut project) = environment.find_project(project_path.as_ref())? {
|
||||
if let Some(args) = args {
|
||||
project.set_custom_unity_args(args);
|
||||
} else {
|
||||
project.clear_custom_unity_args();
|
||||
}
|
||||
environment.update_project(&project)?;
|
||||
environment.disconnect_litedb();
|
||||
Ok(true)
|
||||
} else {
|
||||
environment.disconnect_litedb();
|
||||
Ok(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn project_get_unity_path(
|
||||
state: State<'_, Mutex<EnvironmentState>>,
|
||||
project_path: String,
|
||||
) -> Result<Option<String>, RustError> {
|
||||
with_environment!(&state, |environment| {
|
||||
let result;
|
||||
if let Some(project) = environment.find_project(project_path.as_ref())? {
|
||||
result = project.unity_path().map(ToOwned::to_owned);
|
||||
} else {
|
||||
result = None;
|
||||
}
|
||||
environment.disconnect_litedb();
|
||||
Ok(result)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn project_set_unity_path(
|
||||
state: State<'_, Mutex<EnvironmentState>>,
|
||||
project_path: String,
|
||||
unity_path: Option<String>,
|
||||
) -> Result<bool, RustError> {
|
||||
with_environment!(&state, |environment| {
|
||||
if let Some(mut project) = environment.find_project(project_path.as_ref())? {
|
||||
if let Some(unity_path) = unity_path {
|
||||
project.set_unity_path(unity_path);
|
||||
} else {
|
||||
project.clear_unity_path();
|
||||
}
|
||||
environment.update_project(&project)?;
|
||||
environment.disconnect_litedb();
|
||||
Ok(true)
|
||||
} else {
|
||||
environment.disconnect_litedb();
|
||||
Ok(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn util_open(path: String) -> Result<(), RustError> {
|
||||
|
|
|
|||
|
|
@ -6,13 +6,14 @@
|
|||
//! Since the `cmd.exe` has a unique escape sequence behavior,
|
||||
//! It's necessary to escape the path and arguments correctly.
|
||||
//!
|
||||
//! I wrote this module based on [research by Y.m Ryota][research-zenn].
|
||||
//! I wrote this module based on [BatBadBut] article.
|
||||
//!
|
||||
//! [research-zenn]: https://zenn.dev/tryjsky/articles/0610b2f32453e7
|
||||
//! [BatBadBut]: https://flatt.tech/research/posts/batbadbut-you-cant-securely-execute-commands-on-windows/#as-a-developer
|
||||
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::fs::OpenOptions;
|
||||
use std::mem::MaybeUninit;
|
||||
use std::os::windows::ffi::EncodeWide;
|
||||
use std::os::windows::prelude::*;
|
||||
use std::path::Path;
|
||||
use std::{io, result};
|
||||
|
|
@ -31,22 +32,12 @@ pub(crate) async fn start_command(
|
|||
args: &[&OsStr],
|
||||
) -> std::io::Result<()> {
|
||||
// prepare
|
||||
let percent_env_name = "PERCENT".encode_utf16().collect::<Vec<_>>();
|
||||
|
||||
let mut cmd_args = Vec::new();
|
||||
cmd_args.extend("/d /c start /b \"".encode_utf16());
|
||||
cmd_args.extend(name.encode_wide());
|
||||
cmd_args.push(b'"' as u16);
|
||||
cmd_args.extend("/E:ON /V:OFF /d /c start /b ".encode_utf16());
|
||||
append_cmd_escaped(&mut cmd_args, name.encode_wide());
|
||||
cmd_args.push(b' ' as u16);
|
||||
|
||||
// since pathname cannot have '"' in it, we don't need to escape it
|
||||
cmd_args.push('"' as u16);
|
||||
append_cmd_no_caret_escape(
|
||||
&mut cmd_args,
|
||||
path.encode_wide().collect::<Vec<_>>().as_slice(),
|
||||
&percent_env_name,
|
||||
);
|
||||
cmd_args.push('"' as u16);
|
||||
append_cmd_escaped(&mut cmd_args, path.encode_wide());
|
||||
|
||||
let mut buffer = Vec::new();
|
||||
for arg in args {
|
||||
|
|
@ -54,7 +45,7 @@ pub(crate) async fn start_command(
|
|||
let arg = arg.encode_wide().collect::<Vec<_>>();
|
||||
buffer.clear();
|
||||
append_cpp_escaped(&mut buffer, &arg);
|
||||
append_cmd_escaped(&mut cmd_args, &buffer, &percent_env_name);
|
||||
append_cmd_escaped(&mut cmd_args, buffer.iter().copied());
|
||||
}
|
||||
|
||||
// execute
|
||||
|
|
@ -67,13 +58,20 @@ pub(crate) async fn start_command(
|
|||
if !status.success() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("cmd.exe /d /c start /d failed with status: {}", status),
|
||||
format!(
|
||||
"cmd.exe /E:ON /V:OFF /d /c start /d failed with status: {}",
|
||||
status
|
||||
),
|
||||
));
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
/d /c /E:ON /V:OFF start /b "Unity" "C:\Program Files\Unity\Hub\Editor\2022.3.22f1\Editor\Unity.exe" "-projectPath" """D:\VRC\新しいフォルダー (3)\world 2""" "-debugCodeOptimization"
|
||||
*/
|
||||
|
||||
fn append_cpp_escaped(args: &mut Vec<u16>, arg: &[u16]) {
|
||||
let need_quote = arg.iter().any(|&c| c == b' ' as u16 || c == b'\t' as u16);
|
||||
if need_quote {
|
||||
|
|
@ -101,64 +99,41 @@ fn append_cpp_escaped(args: &mut Vec<u16>, arg: &[u16]) {
|
|||
}
|
||||
}
|
||||
|
||||
// ' ' (whitespace), '=', ';', ',', '<', '>', '|', '&', '^', '(', ')', '!', '"', '@'
|
||||
// We need another escape for '%'
|
||||
const ESCAPE_CHARS: &[u16] = &[
|
||||
0x20, 0x3d, 0x3b, 0x2c, 0x3c, 0x3e, 0x7c, 0x26, 0x5e, 0x28, 0x29, 0x21, 0x22, 0x40,
|
||||
];
|
||||
// %%cd:~,%
|
||||
const PERCENT_ESCAPED: &[u16] = &[0x25, 0x25, 0x63, 0x64, 0x3a, 0x7e, 0x2c, 0x25];
|
||||
|
||||
fn append_cmd_escaped(args: &mut Vec<u16>, arg: &[u16], percent_env_var_name: &[u16]) {
|
||||
if arg.first().copied() == Some('"' as u16) && arg.last().copied() == Some('"' as u16) {
|
||||
// it's "-quoted, so we don't need to escape if there is no '"' inside
|
||||
let contains_quote = arg.iter().filter(|&&x| x == '"' as u16).count() > 2;
|
||||
if contains_quote {
|
||||
append_cmd_caret_escaped(args, arg, percent_env_var_name);
|
||||
} else {
|
||||
append_cmd_no_caret_escape(args, arg, percent_env_var_name);
|
||||
}
|
||||
} else if arg.iter().any(|x| ESCAPE_CHARS.contains(x)) {
|
||||
if !arg.iter().any(|&x| x == '"' as u16) {
|
||||
// if contains escape chars but not ", we can use "-quoting
|
||||
args.push(b'"' as u16);
|
||||
append_cmd_no_caret_escape(args, arg, percent_env_var_name);
|
||||
args.push(b'"' as u16);
|
||||
} else {
|
||||
// if contains ", we have to use caret-escaping
|
||||
append_cmd_caret_escaped(args, arg, percent_env_var_name);
|
||||
}
|
||||
} else {
|
||||
// no escape is needed
|
||||
append_cmd_no_caret_escape(args, arg, percent_env_var_name);
|
||||
}
|
||||
}
|
||||
// based on https://flatt.tech/research/posts/batbadbut-you-cant-securely-execute-commands-on-windows/#as-a-developer
|
||||
fn append_cmd_escaped(args: &mut Vec<u16>, arg: impl Iterator<Item = u16>) {
|
||||
// Enclose the argument with double quotes (").
|
||||
args.push('"' as u16);
|
||||
|
||||
fn append_cmd_no_caret_escape(args: &mut Vec<u16>, arg: &[u16], percent_env_var_name: &[u16]) {
|
||||
// even without ^-escaping, we need to escape '%' since env var expansion is proceeded even
|
||||
// inside '"'-quoted string
|
||||
for &x in arg {
|
||||
let mut backslash = 0;
|
||||
for x in arg {
|
||||
if x == b'%' as u16 {
|
||||
args.push(b'%' as u16);
|
||||
args.extend_from_slice(percent_env_var_name);
|
||||
args.push(b'%' as u16);
|
||||
args.extend_from_slice(PERCENT_ESCAPED);
|
||||
} else if x == b'"' as u16 {
|
||||
// Replace the backslash (\) in front of the double quote (") with two backslashes (\\).
|
||||
// To implement that, append the backslashes again
|
||||
args.extend(std::iter::repeat(b'\\' as u16).take(backslash));
|
||||
// Replace the double quote (") with two double quotes ("").
|
||||
args.push(b'"' as u16);
|
||||
args.push(b'"' as u16);
|
||||
} else if x == '\n' as u16 {
|
||||
// Remove newline characters (\n).
|
||||
} else {
|
||||
args.push(x);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn append_cmd_caret_escaped(args: &mut Vec<u16>, arg: &[u16], percent_env_var_name: &[u16]) {
|
||||
for &x in arg {
|
||||
if x == b'%' as u16 {
|
||||
args.push(b'%' as u16);
|
||||
args.extend_from_slice(percent_env_var_name);
|
||||
args.push(b'%' as u16);
|
||||
} else if ESCAPE_CHARS.contains(&x) {
|
||||
args.push(b'^' as u16);
|
||||
args.push(x);
|
||||
// count b'\\'
|
||||
if x == b'\\' as u16 {
|
||||
backslash += 1;
|
||||
} else {
|
||||
args.push(x);
|
||||
backslash = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Enclose the argument with double quotes (").
|
||||
args.push('"' as u16);
|
||||
}
|
||||
|
||||
pub(crate) fn is_locked(path: &Path) -> io::Result<bool> {
|
||||
|
|
|
|||
|
|
@ -217,22 +217,25 @@ impl<T: HttpClient, IO: EnvironmentIo> Environment<T, IO> {
|
|||
Ok(self.get_db()?.get_values(COLLECTION)?)
|
||||
}
|
||||
|
||||
pub fn update_project_last_modified(&mut self, project_path: &Path) -> io::Result<()> {
|
||||
pub fn find_project(&self, project_path: &Path) -> io::Result<Option<UserProject>> {
|
||||
check_absolute_path(project_path)?;
|
||||
let db = self.get_db()?;
|
||||
let project_path = normalize_path(project_path);
|
||||
|
||||
let mut project = db.get_values::<UserProject>(COLLECTION)?;
|
||||
let Some(project) = project
|
||||
.iter_mut()
|
||||
.find(|x| Path::new(x.path()) == project_path)
|
||||
else {
|
||||
Ok(project
|
||||
.into_iter()
|
||||
.find(|x| Path::new(x.path()) == project_path))
|
||||
}
|
||||
|
||||
pub fn update_project_last_modified(&mut self, project_path: &Path) -> io::Result<()> {
|
||||
check_absolute_path(project_path)?;
|
||||
let Some(mut project) = self.find_project(project_path)? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
project.last_modified = DateTime::now();
|
||||
db.update(COLLECTION, project)?;
|
||||
|
||||
self.update_project(&project)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -306,6 +309,8 @@ struct VrcGetMeta {
|
|||
cached_unity_version: Option<UnityVersion>,
|
||||
#[serde(default)]
|
||||
unity_revision: Option<String>,
|
||||
custom_unity_args: Option<Vec<String>>,
|
||||
unity_path: Option<String>,
|
||||
}
|
||||
|
||||
impl UserProject {
|
||||
|
|
@ -380,4 +385,32 @@ impl UserProject {
|
|||
.filter(|x| x.cached_unity_version == self.unity_version)
|
||||
.and_then(|x| x.unity_revision.as_deref())
|
||||
}
|
||||
|
||||
pub fn custom_unity_args(&self) -> Option<&[String]> {
|
||||
self.vrc_get
|
||||
.as_ref()
|
||||
.and_then(|x| x.custom_unity_args.as_deref())
|
||||
}
|
||||
|
||||
pub fn set_custom_unity_args(&mut self, custom_unity_args: Vec<String>) {
|
||||
self.vrc_get
|
||||
.get_or_insert_with(Default::default)
|
||||
.custom_unity_args = Some(custom_unity_args);
|
||||
}
|
||||
|
||||
pub fn clear_custom_unity_args(&mut self) {
|
||||
self.vrc_get.as_mut().map(|x| x.custom_unity_args = None);
|
||||
}
|
||||
|
||||
pub fn unity_path(&self) -> Option<&str> {
|
||||
self.vrc_get.as_ref().and_then(|x| x.unity_path.as_deref())
|
||||
}
|
||||
|
||||
pub fn set_unity_path(&mut self, unity_path: String) {
|
||||
self.vrc_get.get_or_insert_with(Default::default).unity_path = Some(unity_path);
|
||||
}
|
||||
|
||||
pub fn clear_unity_path(&mut self) {
|
||||
self.vrc_get.as_mut().map(|x| x.unity_path = None);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue