fix(gui): install / upgrade package

This commit is contained in:
anatawa12 2024-02-27 00:32:11 +09:00
commit 815b8c9cfd
No known key found for this signature in database
GPG key ID: 9CA909848B8E4EA6
6 changed files with 447 additions and 45 deletions

View file

@ -3,6 +3,7 @@ use std::io;
use std::num::Wrapping;
use std::path::PathBuf;
use std::ptr::NonNull;
use std::sync::atomic::{AtomicU32, Ordering};
use serde::Serialize;
use specta::specta;
@ -10,19 +11,24 @@ use tauri::async_runtime::Mutex;
use tauri::{generate_handler, Invoke, Runtime, State};
use vrc_get_vpm::environment::UserProject;
use vrc_get_vpm::io::DefaultProjectIo;
use vrc_get_vpm::unity_project::pending_project_changes::{
ConflictInfo, PackageChange, RemoveReason,
};
use vrc_get_vpm::unity_project::{AddPackageOperation, PendingProjectChanges};
use vrc_get_vpm::version::Version;
use vrc_get_vpm::{PackageCollection, PackageInfo, PackageJson, ProjectType, UnityProject};
use vrc_get_vpm::{PackageCollection, PackageInfo, PackageJson, ProjectType};
pub(crate) fn handlers<R: Runtime>() -> impl Fn(Invoke<R>) + Send + Sync + 'static {
generate_handler![
environment_projects,
environment_packages,
project_details,
environment_repositories_info,
environment_hide_repository,
environment_show_repository,
environment_set_hide_local_user_packages,
project_details,
project_install_package,
project_apply_pending_changes,
]
}
@ -32,11 +38,13 @@ pub(crate) fn export_ts() {
specta::collect_types![
environment_projects,
environment_packages,
project_details,
environment_repositories_info,
environment_hide_repository,
environment_show_repository,
environment_set_hide_local_user_packages,
project_details,
project_install_package,
project_apply_pending_changes,
]
.unwrap(),
specta::ts::ExportConfiguration::new().bigint(specta::ts::BigIntExportBehavior::Number),
@ -50,6 +58,7 @@ pub(crate) fn new_env_state() -> impl Send + Sync + 'static {
}
type Environment = vrc_get_vpm::Environment<reqwest::Client, vrc_get_vpm::io::DefaultEnvironmentIo>;
type UnityProject = vrc_get_vpm::UnityProject<vrc_get_vpm::io::DefaultProjectIo>;
async fn new_environment() -> io::Result<Environment> {
let client = reqwest::Client::new();
@ -79,6 +88,15 @@ struct EnvironmentState {
// null or reference to
projects: Box<[UserProject]>,
projects_version: Wrapping<u32>,
changes_info: Option<NonNull<PendingProjectChangesInfo<'static>>>,
}
static CHANGES_GLOBAL_INDEXER: AtomicU32 = AtomicU32::new(0);
struct PendingProjectChangesInfo<'env> {
environment_version: u32,
changes_version: u32,
changes: PendingProjectChanges<'env>,
}
struct EnvironmentHolder {
@ -118,6 +136,7 @@ impl EnvironmentState {
packages: None,
projects: Box::new([]),
projects_version: Wrapping(0),
changes_info: None,
}
}
}
@ -314,6 +333,7 @@ async fn environment_packages(
.collect::<Vec<_>>()
.into_boxed_slice();
if let Some(ptr) = env_state.packages {
env_state.packages = None; // avoid a double drop
unsafe { drop(Box::from_raw(ptr.as_ptr())) }
}
env_state.packages = NonNull::new(Box::into_raw(packages) as *mut _);
@ -421,11 +441,17 @@ struct TauriProjectDetails {
installed_packages: Vec<(String, TauriBasePackageInfo)>,
}
async fn load_project(project_path: String) -> Result<UnityProject, RustError> {
Ok(UnityProject::load(vrc_get_vpm::io::DefaultProjectIo::new(
PathBuf::from(project_path).into(),
))
.await?)
}
#[tauri::command]
#[specta::specta]
async fn project_details(project_path: String) -> Result<TauriProjectDetails, RustError> {
let unity_project =
UnityProject::load(DefaultProjectIo::new(PathBuf::from(project_path).into())).await?;
let unity_project = load_project(project_path).await?;
Ok(TauriProjectDetails {
unity: unity_project
@ -441,3 +467,188 @@ async fn project_details(project_path: String) -> Result<TauriProjectDetails, Ru
.collect(),
})
}
#[derive(Serialize, specta::Type)]
struct TauriPendingProjectChanges {
changes_version: u32,
package_changes: Vec<(String, TauriPackageChange)>,
remove_legacy_files: Vec<String>,
remove_legacy_folders: Vec<String>,
conflicts: Vec<(String, TauriConflictInfo)>,
}
impl TauriPendingProjectChanges {
fn new(version: u32, changes: &PendingProjectChanges) -> Self {
TauriPendingProjectChanges {
changes_version: version,
package_changes: changes
.package_changes()
.iter()
.filter_map(|(name, change)| Some((name.to_string(), change.try_into().ok()?)))
.collect(),
remove_legacy_files: changes
.remove_legacy_files()
.iter()
.map(|x| x.to_string_lossy().into_owned())
.collect(),
remove_legacy_folders: changes
.remove_legacy_folders()
.iter()
.map(|x| x.to_string_lossy().into_owned())
.collect(),
conflicts: changes
.conflicts()
.iter()
.map(|(name, info)| (name.to_string(), info.into()))
.collect(),
}
}
}
#[derive(Serialize, specta::Type)]
enum TauriPackageChange {
InstallNew(TauriBasePackageInfo),
Remove(TauriRemoveReason),
}
impl TryFrom<&PackageChange<'_>> for TauriPackageChange {
type Error = ();
fn try_from(value: &PackageChange) -> Result<Self, ()> {
Ok(match value {
PackageChange::Install(install) => TauriPackageChange::InstallNew(
TauriBasePackageInfo::new(install.install_package().ok_or(())?.package_json()),
),
PackageChange::Remove(remove) => TauriPackageChange::Remove(remove.reason().into()),
})
}
}
#[derive(Serialize, specta::Type)]
enum TauriRemoveReason {
Requested,
Legacy,
Unused,
}
impl From<RemoveReason> for TauriRemoveReason {
fn from(value: RemoveReason) -> Self {
match value {
RemoveReason::Requested => Self::Requested,
RemoveReason::Legacy => Self::Legacy,
RemoveReason::Unused => Self::Unused,
}
}
}
#[derive(Serialize, specta::Type)]
struct TauriConflictInfo {
packages: Vec<String>,
unity_conflict: bool,
}
impl From<&ConflictInfo> for TauriConflictInfo {
fn from(value: &ConflictInfo) -> Self {
Self {
packages: value
.conflicting_packages()
.iter()
.map(|x| x.to_string())
.collect(),
unity_conflict: value.conflicts_with_unity(),
}
}
}
#[tauri::command]
#[specta::specta]
async fn project_install_package(
state: State<'_, Mutex<EnvironmentState>>,
project_path: String,
env_version: u32,
package_index: usize,
) -> Result<TauriPendingProjectChanges, RustError> {
let mut env_state = state.lock().await;
let env_state = &mut *env_state;
if env_state.environment.environment_version != Wrapping(env_version) {
return Err(RustError::Unrecoverable(
"environment version mismatch".into(),
));
}
let environment = env_state.environment.get_environment_mut(false).await?;
let packages = unsafe { &*env_state.packages.unwrap().as_mut() };
let installing_package = packages[package_index];
let unity_project = load_project(project_path).await?;
let operation = if let Some(locked) = unity_project.get_locked(installing_package.name()) {
if installing_package.version() < locked.version() {
AddPackageOperation::Downgrade
} else {
AddPackageOperation::UpgradeLocked
}
} else {
AddPackageOperation::InstallToDependencies
};
let changes = match unity_project
.add_package_request(environment, vec![installing_package], operation, false)
.await
{
Ok(request) => request,
Err(e) => return Err(RustError::Unrecoverable(format!("{e}"))),
};
let new_version = CHANGES_GLOBAL_INDEXER.fetch_add(1, Ordering::SeqCst);
let result = TauriPendingProjectChanges::new(new_version, &changes);
let changes_info = Box::new(PendingProjectChangesInfo {
environment_version: env_version,
changes_version: new_version,
changes,
});
if let Some(ptr) = env_state.changes_info {
env_state.changes_info = None;
unsafe { drop(Box::from_raw(ptr.as_ptr())) }
}
env_state.changes_info = NonNull::new(Box::into_raw(changes_info) as *mut _);
Ok(result)
}
#[tauri::command]
#[specta::specta]
async fn project_apply_pending_changes(
state: State<'_, Mutex<EnvironmentState>>,
project_path: String,
changes_version: u32,
) -> Result<(), RustError> {
let mut env_state = state.lock().await;
let env_state = &mut *env_state;
let changes = unsafe { Box::from_raw(env_state.changes_info.take().unwrap().as_mut()) };
if changes.changes_version != changes_version {
return Err(RustError::Unrecoverable("changes version mismatch".into()));
}
let changes = *changes;
if changes.environment_version != env_state.environment.environment_version.0 {
return Err(RustError::Unrecoverable(
"environment version mismatch".into(),
));
}
let environment = env_state.environment.get_environment_mut(false).await?;
let mut unity_project = load_project(project_path).await?;
unity_project
.apply_pending_changes(environment, changes.changes)
.await?;
unity_project.save().await?;
Ok(())
}

View file

@ -4,8 +4,8 @@ import {
Button,
ButtonGroup,
Card,
Checkbox,
IconButton,
Checkbox, Dialog, DialogBody, DialogFooter, DialogHeader,
IconButton, List, ListItem,
Menu,
MenuHandler,
MenuItem,
@ -16,7 +16,7 @@ import {
Tooltip,
Typography
} from "@material-tailwind/react";
import React, {Suspense, useMemo} from "react";
import React, {Suspense, useMemo, useState} from "react";
import {ArrowLeftIcon, ArrowPathIcon, ChevronDownIcon,} from "@heroicons/react/24/solid";
import {MinusCircleIcon, PlusCircleIcon,} from "@heroicons/react/24/outline";
import {HNavBar, VStack} from "@/components/layout";
@ -28,19 +28,31 @@ import {
environmentPackages,
environmentRepositoriesInfo,
environmentSetHideLocalUserPackages,
environmentShowRepository,
projectDetails,
environmentShowRepository, projectApplyPendingChanges,
projectDetails, projectInstallPackage,
TauriBasePackageInfo,
TauriPackage,
TauriPackage, TauriPendingProjectChanges,
TauriProjectDetails,
TauriVersion
} from "@/lib/bindings";
import {compareUnityVersion, compareVersion, toVersionString} from "@/lib/version";
import {createWatcher} from "tailwindcss/src/oxide/cli/build/watching";
export default function Page(props: {}) {
return <Suspense><PageBody {...props}/></Suspense>
}
type InstallStatus = {
status: "normal";
} | {
status: "creatingChanges";
} | {
status: "promptingChanges";
changes: TauriPendingProjectChanges;
} | {
status: "applyingChanges";
}
function PageBody() {
const searchParams = useSearchParams();
@ -72,6 +84,8 @@ function PageBody() {
]
});
const [installStatus, setInstallStatus] = useState<InstallStatus>({status: "normal"});
const packageRows = useMemo(() => {
const packages = packagesResult.status == 'success' ? packagesResult.data : [];
const details = detailsResult.status == 'success' ? detailsResult.data : null;
@ -102,7 +116,36 @@ function PageBody() {
repositoriesInfo.refetch();
};
const isLoading = packagesResult.isFetching || detailsResult.isFetching || repositoriesInfo.isFetching;
const onInstallRequested = async (pkg: TauriPackage) => {
try {
setInstallStatus({status: "creatingChanges"});
console.log("install", pkg.name, pkg.version);
const changes = await projectInstallPackage(projectPath, pkg.env_version, pkg.index);
setInstallStatus({status: "promptingChanges", changes});
} catch (e) {
console.error(e);
setInstallStatus({status: "normal"});
}
}
const onRemoveRequested = (pkgId: string) => {
console.log("remove", pkgId);
}
const applyChanges = async (changes: TauriPendingProjectChanges) => {
try {
setInstallStatus({status: "applyingChanges"});
await projectApplyPendingChanges(projectPath, changes.changes_version);
setInstallStatus({status: "normal"});
detailsResult.refetch();
} catch (e) {
console.error(e);
setInstallStatus({status: "normal"});
}
}
const installingPackage = installStatus.status != "normal";
const isLoading = packagesResult.isFetching || detailsResult.isFetching || repositoriesInfo.isFetching || installingPackage;
return (
<VStack className={"m-4"}>
@ -186,16 +229,129 @@ function PageBody() {
</tr>
</thead>
<tbody>
{packageRows.map((row) => (<PackageRow pkg={row} key={row.id}/>))}
{packageRows.map((row) => (
<PackageRow pkg={row} key={row.id}
locked={installingPackage}
onInstallRequested={onInstallRequested}
onRemoveRequested={onRemoveRequested}/>
))}
</tbody>
</table>
</Card>
</Card>
{
installStatus.status === "promptingChanges" ? (
<ProjectChangesDialog changes={installStatus.changes}
cancel={() => setInstallStatus({status: "normal"})}
apply={() => applyChanges(installStatus.changes)}
/>
) : null
}
</main>
</VStack>
);
}
function ProjectChangesDialog(
{
changes,
cancel,
apply,
}: {
changes: TauriPendingProjectChanges,
cancel: () => void,
apply: () => void,
}) {
const versionConflicts = changes.conflicts.filter(([_, c]) => c.packages.length > 0);
const unityConflicts = changes.conflicts.filter(([_, c]) => c.unity_conflict);
return (
<Dialog open handler={() => {}}>
<DialogHeader>Apply Changes</DialogHeader>
<DialogBody>
<Typography className={"text-gray-900"}>
You're applying the following changes to the project
</Typography>
<List>
{changes.package_changes.map(([pkgId, pkgChange]) => {
if ('InstallNew' in pkgChange) {
return <ListItem key={pkgId}>
Install {pkgChange.InstallNew.display_name ?? pkgChange.InstallNew.name} version {toVersionString(pkgChange.InstallNew.version)}
</ListItem>
} else {
switch (pkgChange.Remove) {
case "Requested":
return <ListItem key={pkgId}>Remove {pkgId} since you requested.</ListItem>
case "Legacy":
return <ListItem key={pkgId}>Remove {pkgId} since it's a legacy package.</ListItem>
case "Unused":
return <ListItem key={pkgId}>Remove {pkgId} since it's unused.</ListItem>
}
}
})}
</List>
{
versionConflicts.length > 0 ? (
<>
<Typography className={"text-red-700"}>
There are version conflicts
</Typography>
<List>
{versionConflicts.map(([pkgId, conflict]) => (
<ListItem key={pkgId}>
{pkgId} conflicts with {conflict.packages.map(p => p).join(", ")}
</ListItem>
))}
</List>
</>
) : null
}
{
unityConflicts.length > 0 ? (
<>
<Typography className={"text-red-700"}>
There are unity version conflicts
</Typography>
<List>
{unityConflicts.map(([pkgId, _]) => (
<ListItem key={pkgId}>
{pkgId} does not support your unity version
</ListItem>
))}
</List>
</>
) : null
}
{
changes.remove_legacy_files.length > 0 || changes.remove_legacy_folders.length > 0 ? (
<>
<Typography className={"text-red-700"}>
The following legacy files and folders will be removed
</Typography>
<List>
{changes.remove_legacy_files.map(f => (
<ListItem key={f}>
{f}
</ListItem>
))}
{changes.remove_legacy_folders.map(f => (
<ListItem key={f}>
{f}
</ListItem>
))}
</List>
</>
): null
}
</DialogBody>
<DialogFooter>
<Button onClick={cancel} className="mr-1">Cancel</Button>
<Button onClick={apply} color={"red"}>Apply</Button>
</DialogFooter>
</Dialog>
);
}
function RepositoryMenuItem(
{
hiddenUserRepositories,
@ -266,8 +422,8 @@ interface PackageRowInfo {
id: string;
infoSource: TauriVersion;
displayName: string;
unityCompatible: Map<string, TauriBasePackageInfo>;
unityIncompatible: Map<string, TauriBasePackageInfo>;
unityCompatible: Map<string, TauriPackage>;
unityIncompatible: Map<string, TauriPackage>;
sources: Set<string>;
installed: null | {
version: TauriVersion;
@ -394,21 +550,25 @@ function combinePackagesAndProjectDetails(
return asArray;
}
type PackageInfo = {
installed: string | null;
versions: string[];
displayName: string;
id: string;
source: string;
};
function PackageRow({pkg}: { pkg: PackageRowInfo }) {
function PackageRow(
{
pkg,
locked,
onInstallRequested,
onRemoveRequested,
}: {
pkg: PackageRowInfo;
locked: boolean;
onInstallRequested: (pkg: TauriPackage) => void;
onRemoveRequested: (pkgId: string) => void;
}) {
const cellClass = "p-2.5";
const noGrowCellClass = `${cellClass} w-1`;
const versionNames = [...pkg.unityCompatible.keys()];
const latestVersion = versionNames[0];
const latestVersion: string | undefined = versionNames[0];
let installedInfo;
const notInstalled = "Not Installed";
let installedInfo: string;
if (pkg.installed) {
const version = toVersionString(pkg.installed.version);
if (pkg.installed.yanked) {
@ -417,9 +577,27 @@ function PackageRow({pkg}: { pkg: PackageRowInfo }) {
installedInfo = version;
}
} else {
installedInfo = "Not Installed";
installedInfo = notInstalled;
}
const onChange = (version: string | undefined) => {
if (!version) return;
const pkgVersion = pkg.unityCompatible.get(version);
if (!pkgVersion) return;
onInstallRequested(pkgVersion);
}
const installLatest = () => {
if (!latestVersion) return;
const latest = pkg.unityCompatible.get(latestVersion);
if (!latest) return;
onInstallRequested(latest);
}
const remove = () => {
onRemoveRequested(pkg.id);
};
return (
<tr className="even:bg-blue-gray-50/50">
<td className={`${cellClass} overflow-hidden max-w-80 overflow-ellipsis`}>
@ -440,8 +618,12 @@ function PackageRow({pkg}: { pkg: PackageRowInfo }) {
labelProps={{className: "hidden"}}
menuProps={{className: "z-20"}}
className={`border-blue-gray-200 ${pkg.installed?.yanked ? "text-red-700" : ""}`}
onChange={onChange}
selected={() => <>{installedInfo}</>}
disabled={locked}
>
{versionNames.map(v => <Option key={v} value={v}>{v}</Option>)}
<Option value={notInstalled} hidden>{notInstalled}</Option>
</Select>
</td>
<td className={noGrowCellClass}>
@ -474,12 +656,13 @@ function PackageRow({pkg}: { pkg: PackageRowInfo }) {
{
pkg.installed ? (
<Tooltip content={"Remove Package"}>
<IconButton variant={'text'}><MinusCircleIcon
<IconButton variant={'text'} disabled={locked} onClick={remove}><MinusCircleIcon
className={"size-5 text-red-700"}/></IconButton>
</Tooltip>
) : (
<Tooltip content={"Add Package"}>
<IconButton variant={'text'}><PlusCircleIcon
<IconButton variant={'text'} disabled={locked && !!latestVersion}
onClick={installLatest}><PlusCircleIcon
className={"size-5 text-gray-800"}/></IconButton>
</Tooltip>
)

View file

@ -18,10 +18,6 @@ export function environmentPackages() {
return invoke()<TauriPackage[]>("environment_packages")
}
export function projectDetails(projectPath: string) {
return invoke()<TauriProjectDetails>("project_details", { projectPath })
}
export function environmentRepositoriesInfo() {
return invoke()<TauriRepositoriesInfo>("environment_repositories_info")
}
@ -38,12 +34,28 @@ export function environmentSetHideLocalUserPackages(value: boolean) {
return invoke()<null>("environment_set_hide_local_user_packages", { value })
}
export type TauriVersion = { major: number; minor: number; patch: number; pre: string; build: string }
export type TauriProject = { list_version: number; index: number; name: string; path: string; project_type: TauriProjectType; unity: string; last_modified: number; created_at: number }
export type TauriRepositoriesInfo = { user_repositories: TauriUserRepository[]; hidden_user_repositories: string[]; hide_local_user_packages: boolean }
export type TauriPackage = ({ name: string; display_name: string | null; version: TauriVersion; unity: [number, number] | null; is_yanked: boolean }) & { env_version: number; index: number; source: TauriPackageSource }
export function projectDetails(projectPath: string) {
return invoke()<TauriProjectDetails>("project_details", { projectPath })
}
export function projectInstallPackage(projectPath: string, envVersion: number, packageIndex: number) {
return invoke()<TauriPendingProjectChanges>("project_install_package", { projectPath,envVersion,packageIndex })
}
export function projectApplyPendingChanges(projectPath: string, changesVersion: number) {
return invoke()<null>("project_apply_pending_changes", { projectPath,changesVersion })
}
export type TauriProjectType = "Unknown" | "LegacySdk2" | "LegacyWorlds" | "LegacyAvatars" | "UpmWorlds" | "UpmAvatars" | "UpmStarter" | "Worlds" | "Avatars" | "VpmStarter"
export type TauriPackageSource = "LocalUser" | { Remote: { id: string; display_name: string } }
export type TauriBasePackageInfo = { name: string; display_name: string | null; version: TauriVersion; unity: [number, number] | null; is_yanked: boolean }
export type TauriPackage = ({ name: string; display_name: string | null; version: TauriVersion; unity: [number, number] | null; is_yanked: boolean }) & { env_version: number; index: number; source: TauriPackageSource }
export type TauriProjectDetails = { unity: [number, number] | null; unity_str: string; installed_packages: ([string, TauriBasePackageInfo])[] }
export type TauriPendingProjectChanges = { changes_version: number; package_changes: ([string, TauriPackageChange])[]; remove_legacy_files: string[]; remove_legacy_folders: string[]; conflicts: ([string, TauriConflictInfo])[] }
export type TauriRemoveReason = "Requested" | "Legacy" | "Unused"
export type TauriRepositoriesInfo = { user_repositories: TauriUserRepository[]; hidden_user_repositories: string[]; hide_local_user_packages: boolean }
export type TauriBasePackageInfo = { name: string; display_name: string | null; version: TauriVersion; unity: [number, number] | null; is_yanked: boolean }
export type TauriPackageChange = { InstallNew: TauriBasePackageInfo } | { Remove: TauriRemoveReason }
export type TauriConflictInfo = { packages: string[]; unity_conflict: boolean }
export type TauriPackageSource = "LocalUser" | { Remote: { id: string; display_name: string } }
export type TauriUserRepository = { id: string; display_name: string }
export type TauriProject = { list_version: number; index: number; name: string; path: string; project_type: TauriProjectType; unity: string; last_modified: number; created_at: number }
export type TauriVersion = { major: number; minor: number; patch: number; pre: string; build: string }

View file

@ -169,7 +169,7 @@ impl<IO: ProjectIo> UnityProject<IO> {
self.manifest.dependencies().map(|(name, _)| name)
}
pub(crate) fn get_locked(&self, name: &str) -> Option<LockedDependencyInfo> {
pub fn get_locked(&self, name: &str) -> Option<LockedDependencyInfo> {
self.manifest.get_locked(name)
}

View file

@ -32,7 +32,6 @@ pub struct PendingProjectChanges<'env> {
pub(crate) conflicts: HashMap<Box<str>, ConflictInfo>,
}
#[non_exhaustive]
#[derive(Debug)]
pub enum PackageChange<'env> {
Install(Install<'env>),
@ -88,7 +87,6 @@ impl<'env> Remove<'env> {
}
}
#[non_exhaustive]
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum RemoveReason {
Requested,

View file

@ -174,7 +174,6 @@ fn print_prompt_install(changes: &PendingProjectChanges) {
PackageChange::Remove(change) => {
removed.push((change.reason(), name));
}
_ => {}
}
}
@ -215,7 +214,6 @@ fn print_prompt_install(changes: &PendingProjectChanges) {
RemoveReason::Requested => "requested",
RemoveReason::Legacy => "legacy",
RemoveReason::Unused => "unused",
_ => unreachable!(),
};
println!("- {} (removed since {})", name, reason_name);
}