Merge branch 'master' into upgrade-litedb

This commit is contained in:
anatawa12 2024-06-19 16:41:50 +09:00 committed by GitHub
commit e4693c1348
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 532 additions and 151 deletions

View file

@ -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

View file

@ -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>
</>
}

View file

@ -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;

View file

@ -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');

View file

@ -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 }

View file

@ -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

View file

@ -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>

View file

@ -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.",

View file

@ -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> {

View file

@ -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> {

View file

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