Compare commits

..

8 commits

Author SHA1 Message Date
anatawa12
033e1c5dbe fix: add polyfill for ResizeObserver 2024-09-03 04:59:11 +09:00
anatawa12
9d2b851930
Merge branch 'master' into try-support-mojave 2024-09-03 04:16:58 +09:00
anatawa12
dbf3c5a5a1 chore: add polyfill for ResizeObserver 2024-09-03 04:14:26 +09:00
anatawa12
b701544bb2
Merge branch 'master' into try-support-mojave 2024-08-31 20:24:33 +09:00
anatawa12
0429b685b8
ci: install cargo-about 2024-08-21 16:33:55 +09:00
anatawa12
39c1816c8c
docs(changelog): partial macOS Mojave Support 2024-08-21 16:32:33 +09:00
anatawa12
a484ea5512
ci: add ci to check for new ecmascript version features 2024-08-21 16:30:47 +09:00
anatawa12
426a8bda51
feat: try to support older versions of macOS 2024-08-21 16:30:18 +09:00
538 changed files with 23076 additions and 43934 deletions

View file

@ -1,2 +0,0 @@
[alias]
xtask = "run --profile xtask --target host-tuple -p xtask --"

View file

@ -1,4 +1,2 @@
# reformat with biomejs
798da7111ab611c07ff7171574cd2ab9e8254515
# reformat with biomejs 1.9.2
d7d9ba4e84925794e60e4ba194f96c6932418d29

1
.gitattributes vendored
View file

@ -1 +0,0 @@
* text=auto eol=lf

2
.github/FUNDING.yml vendored
View file

@ -1,2 +0,0 @@
github: [anatawa12]
custom: [https://booth.pm/ja/items/6448396]

View file

@ -1,50 +0,0 @@
name: 'Sign in place'
description: 'Signs exe in-place'
inputs:
signpath-api-token:
description: SignPath REST API access token.
required: true
signing-policy-slug:
description: SignPath signing policy slug
version:
required: true
description: Version number of artifact we're signing
path:
description: The path of artifact we'll sign
required: true
artifact-name:
description: The name of artifact on github
required: true
runs:
using: "composite"
steps:
- name: upload unsigned artifact
id: upload-unsigned-artifact
uses: actions/upload-artifact@v4
with:
path: ${{ inputs.path }}
name: ${{ inputs.artifact-name }}
- name: Sign on signpath
id: sign
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ inputs.signpath-api-token }}
organization-id: 'c2d4dbf9-920f-4318-9017-7306e0fc7590'
project-slug: 'vrc-get'
signing-policy-slug: ${{ inputs.signing-policy-slug }}
github-artifact-id: '${{ steps.upload-unsigned-artifact.outputs.artifact-id }}'
wait-for-completion: true
output-artifact-directory: .github/actions/sign-windows/signed/${{ inputs.artifact-name }}
parameters: |
version: ${{ toJSON(inputs.version) }}
- name: Copy artifact
env:
DOWNLOADED_PATH: .github/actions/sign-windows/signed/${{ inputs.artifact-name }}
REPLACE_TO: ${{ inputs.path }}
shell: bash
run:
cp -f "$DOWNLOADED_PATH/$(basename "$REPLACE_TO")" "$REPLACE_TO"

View file

@ -1,8 +0,0 @@
If you're writing code:
- Please don't make localization for locales other than en / ja. I cannot review those locales.
- Run cargo clippy for lints and cargo fmt for format before commit.
- After completing the code and commit, please add a changelog entry. Please note that the numbers in the changelog file are pull request numbers, not issue numbers.
- Please add it to the bottom of the change list.
- Please use the proper section for each change. "Fix" should be used only for bug fixes. UX improvements typically belong under "Change", and new features typically under "Add". These are not strict rules, so use them flexibly.
- You should use Conventional Commits (chore:, fix:, dev:, build:, docs:, style:, lint:, and others).
- Please split commits for implementation and changelog updates.

View file

@ -8,8 +8,6 @@ updates:
groups:
tauri:
patterns: [ 'tauri', 'tauri-build' ]
clap:
patterns: [ 'clap', 'clap_complete' ]
- package-ecosystem: github-actions
directory: "/"
open-pull-requests-limit: 20
@ -25,9 +23,3 @@ updates:
patterns: [ "@radix-ui/*" ]
react:
patterns: [ "react", "react-dom", "@types/react", "@types/react-dom" ]
tanstack-router:
patterns: [ "@tanstack/*router*" ]
tailwindcss:
patterns: [ "tailwindcss", "@tailwindcss/*" ]
i18next:
patterns: [ "i18next", "react-i18next" ]

View file

@ -48,28 +48,11 @@ module.exports = async ({github, context}) => {
discussionNumber: 1443,
replyId: 'DC_kwDOIza9ks4An6A8'
},
{
id: 'ko',
discussionNumber: 1823,
replyId: 'DC_kwDOIza9ks4AswKE'
},
];
/** @type {{missingCount: number, extraCount: number, id: string, discussionNumber: number}[]} */
const localeData = [];
for (const locale of locales) {
const proceed = await processOneLocale(github, owner, repo, locale.discussionNumber, locale.replyId, locale.id);
localeData.push({
id: locale.id,
discussionNumber: locale.discussionNumber,
missingCount: proceed.missingCount,
extraCount: proceed.extraCount,
})
await processOneLocale(github, owner, repo, locale.discussionNumber, locale.replyId, locale.id);
}
// 894 is English Localization and Text Representation
await updateRootLocale(github, owner, repo, 894, localeData);
}
/**
@ -80,113 +63,9 @@ module.exports = async ({github, context}) => {
* @param number {number}
* @param replyToId {string}
* @param localeId {string}
* @return {Promise<{missingCount: number, extraCount: number}>}
* @return {Promise<void>}
*/
async function processOneLocale(github, owner, repo, number, replyToId, localeId) {
const enJson = json5.parse(await fs.readFile(`vrc-get-gui/locales/en.json5`, "utf8"));
const enKeys = normalizeKeys(Object.keys(enJson.translation));
const transJson = json5.parse(await fs.readFile(`vrc-get-gui/locales/${localeId}.json5`, "utf8"));
const transKeys = normalizeKeys(Object.keys(transJson.translation));
const {missingList: missingKeys, extraList: extraKeys} = missingAndExtras(enKeys, transKeys);
const newData = {
missingKeys,
extraKeys,
};
const newAutoPart = `**Missing Keys:**\n${listToMarkdown(missingKeys)}\n\n**Excess Keys:**\n${listToMarkdown(extraKeys)}\n`;
const {discussionId, previousJson: dataJson} = await updateComment(github, owner, repo, number, newAutoPart, newData);
dataJson.missingKeys ??= [];
dataJson.extraKeys ??= [];
// create comment if there are new missing / extra keys
const {extraList: newlyAddedMissingKeys} = missingAndExtras(normalizeKeys(dataJson.missingKeys), missingKeys);
const {extraList: newlyAddedExtraKeys} = missingAndExtras(normalizeKeys(dataJson.extraKeys), extraKeys);
if (newlyAddedMissingKeys.length > 0 || newlyAddedExtraKeys.length > 0) {
const text = `
There are new missing / excess keys in the translation. Please update the translation!
**New Missing Keys:**
${listToMarkdown(newlyAddedMissingKeys)}
**New Excess Keys:**
${listToMarkdown(newlyAddedExtraKeys)}
`
await github.graphql(`
mutation($discussionId: ID!, $replyToId: ID!, $body: String!) {
addDiscussionComment(input: {discussionId: $discussionId, replyToId: $replyToId, body: $body}) {
comment {
body
}
}
}
`, {discussionId, replyToId, body: text});
}
return {
missingCount: missingKeys.length,
extraCount: extraKeys.length,
}
}
/**
* Updates the root locale configuration for a specified repository.
*
* @param {import('@octokit/rest').Octokit} github - The GitHub API client instance used to interact with the GitHub API.
* @param {string} owner - The owner of the repository where the root locale is to be updated.
* @param {string} repo - The name of the repository where the root locale is to be updated.
* @param {number} number
* @param {{missingCount: number, extraCount: number, id: string, discussionNumber: number}[]} localeData - The locale data object containing the updated root locale configuration.
* @return {Promise<void>} A promise that resolves to the API response for the update operation.
*/
async function updateRootLocale(github, owner, repo, number, localeData) {
let table = "| locale | missing count | exceeding count | link |\n" +
"| -- | -- | -- | -- |\n"
for (let {missingCount, extraCount, id, discussionNumber} of localeData) {
table += `| ${id} | ${missingCount} | ${extraCount} | [link](https://github.com/${owner}/${repo}/discussions/${discussionNumber}) |\n`;
}
await updateComment(github, owner, repo, number, table, {});
}
/**
* @template T
* @param beforeList {T[]}
* @param afterList {T[]}
* @return {{missingList: T[], extraList: T[]}}
*/
function missingAndExtras(beforeList, afterList) {
const missingList = beforeList.filter(key => !afterList.includes(key)).filter(key => !optionalKeys.includes(key));
const extraList = afterList.filter(key => !beforeList.includes(key)).filter(key => !optionalKeys.includes(key));
return {missingList, extraList};
}
function listToMarkdown(values) {
return values.length === 0 ? 'nothing' : values.map(key => `- \`${key}\``).join('\n')
}
/**
*
* @param github {import('@octokit/rest').Octokit}
* @param owner {string}
* @param repo {string}
* @param number {number}
* @param content {string} the updated content
* @param newData {object} data stored in the comment
* @return {Promise<{previousJson: object, discussionId:string}>}
*/
async function updateComment(
github,
owner, repo, number,
content,
newData,
) {
/** @type {{data: {repository: {discussion: {body: string}}}}} */
const result = await github.graphql(`
query($owner: String!, $repo: String!, $number: Int!) {
@ -214,14 +93,42 @@ async function updateComment(
const postAutoPart = split1[1] ?? '';
const dataJsonLine = autoPart.split(/\r?\n/).find(l => l.startsWith(dataJsonLinePrefix));
const previousJson = dataJsonLine ? JSON.parse(dataJsonLine.slice(dataJsonLinePrefix.length)) : {};
// the dataJson is for computing the difference and create new comment if there are changes
const dataJson = dataJsonLine ? JSON.parse(dataJsonLine.slice(dataJsonLinePrefix.length)) : {};
dataJson.missingKeys ??= [];
dataJson.extraKeys ??= [];
const enJson = json5.parse(await fs.readFile(`vrc-get-gui/locales/en.json5`, "utf8"));
const enKeys = normalizeKeys(Object.keys(enJson.translation));
const transJson = json5.parse(await fs.readFile(`vrc-get-gui/locales/${localeId}.json5`, "utf8"));
const transKeys = normalizeKeys(Object.keys(transJson.translation));
const missingKeys = enKeys.filter(key => !transKeys.includes(key)).filter(key => !optionalKeys.includes(key));
const extraKeys = transKeys.filter(key => !enKeys.includes(key)).filter(key => !optionalKeys.includes(key));
const missingKeysStr = missingKeys.length === 0 ? 'nothing' : missingKeys.map(key => `- \`${key}\``).join('\n');
const excessKeysStr = extraKeys.length === 0 ? 'nothing' : extraKeys.map(key => `- \`${key}\``).join('\n');
const newData = {
missingKeys,
extraKeys,
};
const newAutoPart = `
**Missing Keys:**
${missingKeysStr}
**Excess Keys:**
${excessKeysStr}
const newBody = `${manualPart}${autoPartStart}
${content}
<!-- data part
${dataJsonLinePrefix}${JSON.stringify(newData)}
-->
${autoPartEnd}${postAutoPart}`;
`;
const newBody = `${manualPart}${autoPartStart}${newAutoPart}${autoPartEnd}${postAutoPart}`;
await github.graphql(`
mutation($discussionId: ID!, $body: String!) {
@ -233,9 +140,36 @@ ${autoPartEnd}${postAutoPart}`;
}
`, {discussionId, body: newBody});
return {
previousJson,
discussionId,
// create comment if there are new missing / extra keys
const oldMissingKeys = new Set(normalizeKeys(dataJson.missingKeys));
const oldExtraKeys = new Set(normalizeKeys(dataJson.extraKeys));
const newlyAddedMissingKeys = missingKeys.filter(key => !oldMissingKeys.has(key));
const newlyAddedExtraKeys = extraKeys.filter(key => !oldExtraKeys.has(key));
if (newlyAddedMissingKeys.length > 0 || newlyAddedExtraKeys.length > 0) {
const newMissingKeysStr = newlyAddedMissingKeys.length === 0 ? 'nothing' : newlyAddedMissingKeys.map(key => `- \`${key}\``).join('\n');
const newExcessKeysStr = newlyAddedExtraKeys.length === 0 ? 'nothing' : newlyAddedExtraKeys.map(key => `- \`${key}\``).join('\n');
const text = `
There are new missing / excess keys in the translation. Please update the translation!
**New Missing Keys:**
${newMissingKeysStr}
**New Excess Keys:**
${newExcessKeysStr}
`
await github.graphql(`
mutation($discussionId: ID!, $replyToId: ID!, $body: String!) {
addDiscussionComment(input: {discussionId: $discussionId, replyToId: $replyToId, body: $body}) {
comment {
body
}
}
}
`, {discussionId, replyToId, body: text});
}
}

View file

@ -4,6 +4,7 @@ on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
env:
CARGO_TERM_COLOR: always
@ -15,8 +16,7 @@ jobs:
matrix:
include:
- triple: x86_64-unknown-linux-gnu
on: ubuntu-22.04
bundles: appimage,appimage-updater
on: ubuntu-latest
setup: |
sudo apt update && sudo apt install -y lld
ld.lld --version
@ -26,14 +26,12 @@ jobs:
- triple: x86_64-pc-windows-msvc
on: windows-latest
bundles: setup-exe,setup-exe-zip,exe-updater
- triple: universal-apple-darwin
on: macos-14
setup: |
rustup target add aarch64-apple-darwin
rustup target add x86_64-apple-darwin
bundles: app,dmg,app-updater
triple:
- x86_64-unknown-linux-gnu
#- aarch64-unknown-linux-gnu
@ -46,25 +44,9 @@ jobs:
RUSTFLAGS: ${{ matrix.rustflags }}
steps:
- uses: samypr100/setup-dev-drive@v4
with:
drive-size: 12GB # github actions grantees 14 GB of disk space. we have few GB for action environment
drive-path: "/dev_drive.vhdx"
mount-path: ${{ github.workspace }}
trusted-dev-drive: true
- name: Preinitialize git repository
shell: bash
run: |
# hopefully for non-cleaning environments, actions/checkout will tries to clean existing repository
# if existing dir is not git repository nor for specified repository.
# this step creates git directory to workaround the behavior
git init
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY"
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
submodules: recursive
- run: rustup update stable
- name: Install cross-compilation tools
uses: taiki-e/setup-cross-toolchain-action@v1
if: ${{ matrix.triple != 'universal-apple-darwin' }}
@ -73,8 +55,11 @@ jobs:
- uses: Swatinem/rust-cache@v2
with:
key: ci-build-gui-${{ matrix.triple }}
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.2xx
- name: Cache javascript essentials
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: |
~/.npm
@ -83,6 +68,11 @@ jobs:
key: ${{ runner.os }}-nextjs-${{ hashFiles('vrc-get-gui/package-lock.json') }}-${{ hashFiles('vrc-get-gui/**/*.js', 'vrc-get-gui/**/*.jsx', 'vrc-get-gui/**/*.ts', 'vrc-get-gui/**/*.tsx') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('vrc-get-gui/package-lock.json') }}-
- uses: taiki-e/install-action@v2
env:
GITHUB_TOKEN: ${{ github.token }}
with:
tool: cargo-about
- name: Setup
run: ${{ matrix.setup }}
@ -102,16 +92,20 @@ jobs:
cp vrc-get-gui/Tauri.toml vrc-get-gui/Tauri.toml.bak
grep -v "remove if ci" < vrc-get-gui/Tauri.toml.bak > vrc-get-gui/Tauri.toml
- name: Build ALCOM binary
run:
cargo xtask build-alcom --release --target ${{ matrix.triple }} --devtools ${{ (secrets.ACTIONS_STEP_DEBUG || vars.ACTIONS_STEP_DEBUG) == 'true' && '--verbose' || '' }}
- name: Build installer
- name: Enable Devtools Feature
shell: bash
run: cargo xtask bundle-alcom --release --target ${{ matrix.triple }} --bundles ${{ matrix.bundles }}
run: |
cargo add --package vrc-get-gui tauri --features devtools
- uses: tauri-apps/tauri-action@v0
with:
projectPath: vrc-get-gui
tauriScript: npm run tauri
args: |
--target ${{ matrix.triple }} -c '{"version":"${{ steps.version.outputs.version }}", "bundle":{"windows":{"certificateThumbprint": null}}}'
- name: Upload built binary
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.triple }}
path: |
@ -122,199 +116,37 @@ jobs:
target/${{ matrix.triple }}/release/bundle/*/ALCOM*
target/${{ matrix.triple }}/release/bundle/*/alcom*
build-rpm:
strategy:
fail-fast: false
matrix:
include:
- install_rust: false
- no_dist: false
- mock-env: fedora-40-x86_64
install_rust: true
no_dist: true
mock-env:
- fedora-40-x86_64
- fedora-rawhide-x86_64
js-version:
runs-on: ubuntu-latest
container:
image: 'fedora:latest'
options: --privileged
env:
MOCK_ENV: ${{ matrix.mock-env }}
RPMBUILD_OPTS: ${{ case(matrix.no_dist, '-D "dist %{nil}"', '') }} ${{ case(matrix.install_rust, '-D "install_rust 1"', '') }}
steps:
- name: Install CI dependencies
run: dnf install -y git tar curl
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Cache javascript essentials
uses: actions/cache@v4
with:
submodules: recursive
# https://github.com/actions/checkout/issues/1169
- run: git config --system --add safe.directory $GITHUB_WORKSPACE
- name: install dependencies
run: dnf install -y mock rpmbuild
- name: prepare rpm build environment
run: mkdir -p ~/rpmbuild/{BUILD,RPMS,SOURCES,SPECS,SRPMS}
- name: update version name in spec file
run: |
COMMIT_HASH="$(git rev-parse HEAD)"
SHORT_HASH="$(git rev-parse --short HEAD)"
PKG_VERSION="$(<vrc-get-gui/Cargo.toml sed -En -e '/^version/{s/.*"(.*)".*/\1/p;}')+${SHORT_HASH}"
echo "PKG_VERSION=$PKG_VERSION" >> $GITHUB_ENV
cp vrc-get-gui/Cargo.toml vrc-get-gui/Cargo.toml.bak
sed -E "/^version/s/\"$/+$(git rev-parse --short HEAD)\"/" < vrc-get-gui/Cargo.toml.bak > vrc-get-gui/Cargo.toml
rm vrc-get-gui/Cargo.toml.bak
git add vrc-get-gui/Cargo.toml
sed -i vrc-get-gui/bundle/alcom.spec -e "/^Version:/c\Version: ${PKG_VERSION//-/\~}"
- name: build source rpm package
run: |
git archive --format=tar --prefix=vrc-get-gui-v$PKG_VERSION/ $(git write-tree) | gzip > ~/rpmbuild/SOURCES/gui-v$PKG_VERSION.tar.gz
eval "rpmbuild -bs vrc-get-gui/bundle/alcom.spec $RPMBUILD_OPTS"
- name: build rpm package
run: eval "mock -v -r '$(ls -1 /etc/mock{/eol,}/$MOCK_ENV.cfg 2>/dev/null)' --enable-network $RPMBUILD_OPTS rebuild ~/rpmbuild/SRPMS/alcom-${PKG_VERSION//-/\~}-1*.src.rpm"
- name: copy built binaries
run: |
mkdir -p artifacts
cp ~/rpmbuild/SRPMS/alcom-${PKG_VERSION//-/\~}-1*.src.rpm artifacts/
cp /var/lib/mock/$MOCK_ENV/result/alcom-${PKG_VERSION//-/\~}-1*.${MOCK_ENV##*-}.rpm artifacts/
- name: Upload built binary
uses: actions/upload-artifact@v7
path: |
~/.npm
${{ github.workspace }}/vrc-get-gui/.next/cache
${{ github.workspace }}/vrc-get-gui/build
key: ${{ runner.os }}-nextjs-${{ hashFiles('vrc-get-gui/package-lock.json') }}-${{ hashFiles('vrc-get-gui/**/*.js', 'vrc-get-gui/**/*.jsx', 'vrc-get-gui/**/*.ts', 'vrc-get-gui/**/*.tsx') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('vrc-get-gui/package-lock.json') }}-
- uses: taiki-e/install-action@v2
env:
GITHUB_TOKEN: ${{ github.token }}
with:
name: rpm-${{ matrix.mock-env }}
path: artifacts/*
tool: cargo-about
build-deb:
strategy:
fail-fast: false
matrix:
include:
- install_rust: false
- install_nodejs: false
- apt-components: main
- apt-with-updates: false
# Old distributions have older tools than we need. Download tools in build process
- pbuilder-distribution: bookworm
install_rust: true
install_nodejs: true
- pbuilder-distribution: jammy
install_rust: true
install_nodejs: true
# Debian uses mirror from debian-archive.trafficmanager.net which is managed by microsoft on azure
- pbuilder-distribution: bookworm
mirror: http://debian-archive.trafficmanager.net/debian/
keyring: /usr/share/keyrings/debian-archive-keyring.gpg
- pbuilder-distribution: sid
mirror: http://debian-archive.trafficmanager.net/debian/
keyring: /usr/share/keyrings/debian-archive-keyring.gpg
# Ubuntu legacy release
- pbuilder-distribution: jammy
mirror: http://archive.ubuntu.com/ubuntu/
apt-components: main universe
apt-with-updates: true
keyring: /usr/share/keyrings/ubuntu-archive-keyring.gpg
# For note,
# bookworm: libc6@2.36
# sid: libc6@2.39 as of 2026/06/14
# jammy: libc6@2.35
pbuilder-distribution:
# debian distribution
- bookworm
- sid
# ubuntu distribution
- jammy # jammy is the oldest ubuntu release with libwebkit2gtk-4.1 >= 2.41 (but requires -updates and universe)
target-arch:
- amd64
runs-on: ubuntu-latest
env:
TARGET_ARCH: ${{ matrix.target-arch }}
PBUILDER_DISTRIBUTION: ${{ matrix.pbuilder-distribution }}
PBUILDER_MIRROR: ${{ matrix.mirror }}
PBUILDER_KEYRING: ${{ matrix.keyring }}
PBUILDER_COMPONENTS: ${{ matrix.apt-components }}
PBUILDER_OTHERMIRROR: ${{ case(matrix.apt-with-updates, format('deb {0} {1}-updates {2}', matrix.mirror, matrix.pbuilder-distribution, matrix.apt-components), '') }}
INSTALL_RUST: ${{ case(matrix.install_rust, '1', '0') }}
INSTALL_NODEJS: ${{ case(matrix.install_nodejs, '1', '0') }}
steps:
- uses: actions/checkout@v6
with:
path: vrc-get
submodules: recursive
- name: install dependencies
run: sudo apt update && sudo apt install -y pbuilder debian-archive-keyring debhelper-compat=13
- name: prepare deb build environment
working-directory: vrc-get
run: |
cp -r vrc-get-gui/bundle/debian debian
sudo pbuilder create \
--architecture "$TARGET_ARCH" \
--keyring "$PBUILDER_KEYRING" \
--mirror "$PBUILDER_MIRROR" \
--distribution "$PBUILDER_DISTRIBUTION" \
--components "$PBUILDER_COMPONENTS" \
--othermirror "$PBUILDER_OTHERMIRROR"
- name: update changelog
working-directory: vrc-get
run: |
COMMIT_HASH="$(git rev-parse HEAD)"
SHORT_HASH="$(git rev-parse --short HEAD)"
PKG_VERSION="$(<vrc-get-gui/Cargo.toml sed -En -e '/^version/{s/.*"(.*)".*/\1/p;}')+${SHORT_HASH}"
echo "PKG_VERSION=$PKG_VERSION" >> $GITHUB_ENV
cp debian/changelog debian/changelog.bak
cat - debian/changelog.bak <<CHANGELOG > debian/changelog
alcom (${PKG_VERSION//-/\~}-1) experimental;
* Upgraded version to ${PKG_VERSION}
-- anatawa12 <i@anatawa12.com> $(date -u +"%a, %d %b %Y %H:%M:%S +0000")
CHANGELOG
rm debian/changelog.bak
cp vrc-get-gui/Cargo.toml vrc-get-gui/Cargo.toml.bak
sed -E "/^version/s/\"$/+$(git rev-parse --short HEAD)\"/" < vrc-get-gui/Cargo.toml.bak > vrc-get-gui/Cargo.toml
rm vrc-get-gui/Cargo.toml.bak
git add vrc-get-gui/Cargo.toml
echo cat debian/changelog
cat debian/changelog
dpkg-parsechangelog
- name: build source deb package
working-directory: vrc-get
run: |
git archive --format=tar $(git write-tree) | xz > ../alcom_${PKG_VERSION//-/\~}.orig.tar.xz
dpkg-buildpackage -d -S
- name: build deb package
working-directory: vrc-get
run: |
sudo --preserve-env=INSTALL_RUST,INSTALL_NODEJS pbuilder build --use-network yes ../alcom_${PKG_VERSION//-/\~}-1.dsc
- name: copy built binaries
run: |
mkdir -p artifacts
cp vrc-get/debian/changelog artifacts/
cp /var/cache/pbuilder/result/alcom_${PKG_VERSION//-/\~}* artifacts/
ls artifacts
- name: Print information about built package
run: dpkg-deb -I artifacts/alcom_${PKG_VERSION//-/\~}-1_$TARGET_ARCH.deb
- name: Upload built binary
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7
with:
name: deb-${{ matrix.pbuilder-distribution }}-${{ matrix.target-arch }}
path: artifacts/*
- name: Build web essentials
working-directory: vrc-get-gui
run: npm run build
- name: Check for ECMAScript Version
working-directory: vrc-get-gui
run: npm run es-check
conclude-gui:
runs-on: ubuntu-latest
if: ${{ always() }}
needs: [ build-gui ]
needs: [ build-gui, js-version ]
steps:
- name: Conclude Tests
env: { NEEDS: "${{ toJSON(needs) }}" }

View file

@ -4,6 +4,7 @@ on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
env:
CARGO_TERM_COLOR: always
@ -73,7 +74,7 @@ jobs:
RUSTFLAGS: ${{ matrix.rustflags }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: dtolnay/rust-toolchain@stable
@ -86,6 +87,9 @@ jobs:
- uses: Swatinem/rust-cache@v2
with:
key: ci-build-${{ matrix.triple }}
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.2xx
- name: Setup
run: ${{ matrix.setup }}
@ -99,24 +103,24 @@ jobs:
- name: Build
run: cargo build --verbose --target ${{ matrix.triple }}
- name: Upload built binary
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.triple }}
path: |
target/${{ matrix.triple }}/debug/vrc-get*
target/${{ matrix.triple }}/debug/libvrc-get*
- name: Build tests
run: cargo build --tests --target ${{ matrix.triple }} -p vrc-get -p vrc-get-vpm
run: cargo build --tests --target ${{ matrix.triple }}
- name: Run tests
if: ${{ matrix.run-test }}
run: cargo test --verbose --target ${{ matrix.triple }} -p vrc-get -p vrc-get-vpm
run: cargo test --verbose --target ${{ matrix.triple }}
- name: Check binary is statically linked
shell: bash
if: ${{ matrix.static-linked }}
env:
RUSTFLAGS: ''
run: |
cargo xtask check-static-link target/${{ matrix.triple }}/debug/vrc-get${WINDIR:+.exe}
# https://github.com/taiki-e/setup-cross-toolchain-action/issues/18
unset CARGO_BUILD_TARGET
cargo run -p build-check-static-link target/${{ matrix.triple }}/debug/vrc-get*
conclude:
runs-on: ubuntu-latest

View file

@ -9,11 +9,11 @@ jobs:
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v3
- uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- uses: actions/labeler@v6
- uses: actions/labeler@v5
with:
repo-token: ${{ steps.app-token.outputs.token }}

View file

@ -17,7 +17,7 @@ jobs:
fmt:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- run: cargo fmt --all -- --check
@ -28,7 +28,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
submodules: recursive
@ -40,7 +40,10 @@ jobs:
run:
sudo apt update && sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- uses: actions/cache@v5
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.2xx
- uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
@ -50,12 +53,13 @@ jobs:
target/
key: lints-${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- uses: auguwu/clippy-action@1.5.0
- uses: auguwu/clippy-action@1.4.0
with:
token: ${{secrets.GITHUB_TOKEN}}
check-args: --all --all-targets --all-features --exclude windows-installer-wrapper
check-args: --all --all-targets --all-features
args: -Dclippy::todo -Dwarnings
biome:
permissions:
contents: read
@ -63,19 +67,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: run Biome
working-directory: vrc-get-gui
run: npm run biome -- ci
typecheck:
permissions:
contents: read
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: run Type Check
working-directory: vrc-get-gui
run: npm ci && npx tsc

View file

@ -12,13 +12,13 @@ jobs:
contents: read
discussions: write
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20.x'
- run: npm i --omit=dev
working-directory: .github/scripts
- uses: actions/github-script@v9
- uses: actions/github-script@v7
with:
script: |
const script = require('./.github/scripts/localization-updates.js')

View file

@ -18,9 +18,6 @@ on:
default: true
required: false
concurrency:
group: releasing
jobs:
pre-build:
name: Update version name
@ -31,12 +28,11 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: anatawa12/something-releaser@v3
- uses: snow-actions/git-config-user@v1.0.0
- run: rustup update stable
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
@ -67,21 +63,6 @@ jobs:
;;
esac
GUI_VERSION="$(get-version -t gui)"
# update debian package
cp vrc-get-gui/bundle/debian/changelog vrc-get-gui/bundle/debian/changelog.bak
cat - vrc-get-gui/bundle/debian/changelog.bak <<CHANGELOG > vrc-get-gui/bundle/debian/changelog
alcom (${GUI_VERSION//-/\~}-1) stable;
* Upgraded version to ${GUI_VERSION}
-- anatawa12 <i@anatawa12.com> $(date -u +"%a, %d %b %Y %H:%M:%S +0000")
CHANGELOG
rm vrc-get-gui/bundle/debian/changelog.bak
# update rpm package
sed -i vrc-get-gui/bundle/alcom.spec -e "/^Version:/c\Version: ${GUI_VERSION//-/\~}"
case "$GITHUB_REF_NAME" in
master | master-* )
echo "head is master or master-*"
@ -96,7 +77,7 @@ jobs:
;;
esac
gh-export-variable GUI_VERSION "${GUI_VERSION}"
gh-export-variable GUI_VERSION "$(get-version -t gui)"
env:
RELEASE_KIND_IN: ${{ inputs.release_kind }}
DRY_RUN: ${{ inputs.dry-run }}
@ -104,11 +85,14 @@ jobs:
# region changelog
- name: Create Changelog
id: changelog
# if: ${{ !steps.update-version.outputs.prerelease }}
uses: anatawa12/sh-actions/changelog/prepare-release@master
with:
path: CHANGELOG-gui.md
version: ${{ env.GUI_VERSION }}
prerelease: ${{ env.PRERELEASE }}
# use CHANGELOG-gui.md for all releases including beta for now
# there are several logics uses generated changelog, so remember to update them if you change this
prerelease: false # ${{ env.PRERELEASE }}
tag-prefix: gui-v
prerelease-note-heading: |
Version ${{ env.GUI_VERSION }}
@ -117,21 +101,41 @@ jobs:
Version ${{ env.GUI_VERSION }}
---
- name: Upload CHANGELOG.md
uses: actions/upload-artifact@v7
# if: ${{ !steps.update-version.outputs.prerelease }}
uses: actions/upload-artifact@v4
with:
name: CHANGELOG
path: CHANGELOG.md
- name: copy release note
# if: ${{ !steps.update-version.outputs.prerelease }}
run: cp "${{ steps.changelog.outputs.release-note }}" release-note.md
- name: Upload release note
uses: actions/upload-artifact@v7
# if: ${{ !steps.update-version.outputs.prerelease }}
uses: actions/upload-artifact@v4
with:
name: release-note-for-release
path: release-note.md
- name: remove temp release note file
# if: ${{ !steps.update-version.outputs.prerelease }}
run: rm release-note.md
# endregion changelog
# region notes
- name: Reset notes.txt
if: ${{ !steps.update-version.outputs.prerelease }}
run: |
cat <<SHELL > vrc-get-gui/notes.txt
# This is notes text for the updater json, which will be shown in the updater dialog.
# Lines starting with '#' will be ignored (as comments).
SHELL
- name: Reset notes-beta.txt
run: |
cat <<SHELL > vrc-get-gui/notes-beta.txt
# This is notes text for the updater json, which will be shown in the updater dialog.
# Lines starting with '#' will be ignored (as comments).
SHELL
# endregion
- name: Commit
id: update
run: |-
@ -140,6 +144,29 @@ jobs:
git branch releasing
git push -f -u origin releasing
build-web:
name: Build gui web
runs-on: ubuntu-latest
needs: [ pre-build ]
steps:
- uses: actions/checkout@v4
with:
ref: 'releasing'
submodules: recursive
- uses: taiki-e/install-action@v2
env:
GITHUB_TOKEN: ${{ github.token }}
with:
tool: cargo-about
- run: npm run build
working-directory: vrc-get-gui
- uses: actions/upload-artifact@v4
with:
name: vrc-get-gui-web
path: vrc-get-gui/out
build-rust:
name: Build rust
environment:
@ -150,67 +177,57 @@ jobs:
include:
# note: when you changed paths for tauri updater (which are files with .sig),
# remember keep in sync with build-updater-json
- name: x86_64-linux-appimage
triple: x86_64-unknown-linux-gnu
on: ubuntu-22.04
- triple: x86_64-unknown-linux-gnu
on: ubuntu-latest
setup: |
sudo apt update && sudo apt install -y lld
ld.lld --version
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
rustflags: "-C link-arg=-fuse-ld=lld"
last-bundles: appimage,appimage-updater
updater-bundle: bundle/appimage/ALCOM_${GUI_VERSION}_x86_64.AppImage.tar.gz
dist-path: |
bundle/appimage/ALCOM_${GUI_VERSION}_x86_64.AppImage:alcom-${GUI_VERSION}-x86_64.AppImage
bundle/appimage/ALCOM_${GUI_VERSION}_amd64.AppImage:alcom-${GUI_VERSION}-x86_64.AppImage
bundle/appimage/ALCOM_${GUI_VERSION}_x86_64.AppImage.tar.gz:alcom-${GUI_VERSION}-x86_64.AppImage.tar.gz
bundle/appimage/ALCOM_${GUI_VERSION}_x86_64.AppImage.tar.gz.sig:alcom-${GUI_VERSION}-x86_64.AppImage.tar.gz.sig
bundle/appimage/ALCOM_${GUI_VERSION}_amd64.AppImage.tar.gz:alcom-${GUI_VERSION}-x86_64.AppImage.tar.gz
bundle/appimage/ALCOM_${GUI_VERSION}_amd64.AppImage.tar.gz.sig:alcom-${GUI_VERSION}-x86_64.AppImage.tar.gz.sig
- name: x86_64-windows-all
triple: x86_64-pc-windows-msvc
on: windows-2022
last-bundles: setup-exe-zip,exe-updater
updater-bundle: bundle/setup/alcom-updater.exe
- triple: x86_64-pc-windows-msvc
on: windows-latest
esigner: true
dist-path: |
ALCOM.exe:ALCOM-${GUI_VERSION}-x86_64.exe
bundle/setup/alcom-setup.exe:ALCOM-${GUI_VERSION}-x86_64-setup.exe
bundle/setup/alcom-setup.exe.zip:ALCOM-${GUI_VERSION}-x86_64-setup.exe.zip
bundle/setup/alcom-updater.exe:ALCOM-${GUI_VERSION}-x86_64-updater.exe
bundle/setup/alcom-updater.exe.sig:ALCOM-${GUI_VERSION}-x86_64-updater.exe.sig
bundle/nsis/ALCOM_${GUI_VERSION}_x64-setup.exe:ALCOM-${GUI_VERSION}-x86_64-setup.exe
bundle/nsis/ALCOM_${GUI_VERSION}_x64-setup.nsis.zip:ALCOM-${GUI_VERSION}-x86_64-setup.nsis.zip
bundle/nsis/ALCOM_${GUI_VERSION}_x64-setup.nsis.zip.sig:ALCOM-${GUI_VERSION}-x86_64-setup.nsis.zip.sig
- name: universal-macos-all
triple: universal-apple-darwin
- triple: universal-apple-darwin
on: macos-14
setup: |
rustup target add aarch64-apple-darwin
rustup target add x86_64-apple-darwin
last-bundles: dmg,app-updater
updater-bundle: bundle/macos/ALCOM.app.tar.gz
dist-path: |
bundle/dmg/ALCOM_${GUI_VERSION}_universal.dmg:ALCOM-${GUI_VERSION}-universal.dmg
bundle/macos/ALCOM.app.tar.gz:ALCOM-${GUI_VERSION}-universal.app.tar.gz
bundle/macos/ALCOM.app.tar.gz.sig:ALCOM-${GUI_VERSION}-universal.app.tar.gz.sig
name:
- x86_64-linux-appimage
triple:
- x86_64-unknown-linux-gnu
#- aarch64-unknown-linux-musl
- x86_64-windows-all
#- aarch64-windows-all
- universal-macos-all
- x86_64-pc-windows-msvc
#- aarch64-pc-windows-msvc
- universal-apple-darwin
runs-on: ${{ matrix.on }}
env:
RUSTFLAGS: ${{ matrix.rustflags }}
needs: [ pre-build ]
needs: [ pre-build, build-web ]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
ref: 'releasing'
submodules: recursive
- run: rustup update stable
- name: Install cross-compilation tools
uses: taiki-e/setup-cross-toolchain-action@v1
if: ${{ matrix.triple != 'universal-apple-darwin' }}
@ -220,72 +237,54 @@ jobs:
with:
cache-targets: false # for release build, do not cache build artifacts
key: release-gui # there are no elements about build result, so it's ok to share between all builds
- uses: taiki-e/install-action@v2
env:
GITHUB_TOKEN: ${{ github.token }}
with:
tool: cargo-about
- name: Setup
run: ${{ matrix.setup }}
- name: Build ALCOM binary
run: |
cargo xtask build-alcom --target ${{ matrix.triple }} --release ${{ matrix.alcom-build-options }}
- uses: anatawa12/setup-eSigner-CKA@v1
if: ${{ matrix.esigner }}
with:
mode: ${{ vars.WIN_ESIGNER_MODE }}
username: ${{ secrets.WIN_ESIGNER_USERNAME }}
password: ${{ secrets.WIN_ESIGNER_PASSWORD }}
totp-secret: ${{ secrets.WIN_ESIGNER_TOTP_SECRET }}
- name: pre-sign Bundle ALCOM app (macOS)
if: ${{ contains(matrix.name, 'macos') }}
run: cargo xtask bundle-alcom --target ${{ matrix.triple }} --release --bundles app
- name: Download Web Artifact
uses: actions/download-artifact@v4
with:
name: vrc-get-gui-web
path: vrc-get-gui/out
- name: Sign ALCOM app (macOS)
if: ${{ contains(matrix.name, 'macos') }}
- uses: tauri-apps/tauri-action@v0
env:
# apple code signing
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: cargo xtask sign-alcom-app --target ${{ matrix.triple }}
- name: Sign ALCOM exe (Windows)
if: ${{ contains(matrix.name, 'windows') }}
uses: ./.github/actions/sign-windows
with:
artifact-name: alcom-exe-unsigned
path: target/${{ matrix.triple }}/release/ALCOM.exe
signpath-api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
signing-policy-slug: ${{ case(inputs.dry-run, 'test-signing', 'release-signing') }}
version: ${{ needs.pre-build.outputs.gui-version }}
- name: Bundle Setup exe (Windows)
if: ${{ contains(matrix.name, 'windows') }}
run: cargo xtask bundle-alcom --target ${{ matrix.triple }} --release --bundles setup-exe
- name: Sign Setup exe (Windows)
if: ${{ contains(matrix.name, 'windows') }}
uses: ./.github/actions/sign-windows
with:
artifact-name: alcom-setup-exe-unsigned
path: target/${{ matrix.triple }}/release/bundle/setup/alcom-setup.exe
signpath-api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
signing-policy-slug: ${{ case(inputs.dry-run, 'test-signing', 'release-signing') }}
version: ${{ needs.pre-build.outputs.gui-version }}
- name: Bundle ALCOM (${{ matrix.last-bundles }})
run: cargo xtask bundle-alcom --target ${{ matrix.triple }} --release --bundles ${{ matrix.last-bundles }}
- name: Sign updater artifacts (All Platforms)
shell: bash
if: ${{ matrix.updater-bundle }}
env:
GUI_VERSION: ${{ needs.pre-build.outputs.gui-version }}
UPDATER_BUNDLE: ${{ matrix.updater-bundle }}
# tauri updater signing
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
run: |
UPDATER_BUNDLE="${UPDATER_BUNDLE//\$\{GUI_VERSION\}/$GUI_VERSION}"
cargo xtask sign-alcom-updater "target/${{ matrix.triple }}/release/${UPDATER_BUNDLE}"
# we have to use x86 version of signtool since eSignerCKA does not work with x64 version
TAURI_WINDOWS_SIGNTOOL_PATH: C:\Program Files (x86)\Windows Kits\10\bin\10.0.22621.0\x86\signtool.exe
with:
projectPath: vrc-get-gui
tauriScript: npm run tauri
# disable beforeBuildCommand since we already build web
args: |
--target ${{ matrix.triple }} -c '{"build":{"beforeBuildCommand":null}}'
- name: Move artifacts
if: ${{ !cancelled() }}
shell: bash
env:
GUI_VERSION: ${{ needs.pre-build.outputs.gui-version }}
@ -303,153 +302,9 @@ jobs:
mv "target/${{ matrix.triple }}/release/$src" "artifacts/$dst"
done
- uses: actions/upload-artifact@v7
if: ${{ !cancelled() }}
- uses: actions/upload-artifact@v4
with:
name: artifacts-${{ matrix.name }}
path: artifacts/*
build-rpm:
needs: [ pre-build ]
strategy:
fail-fast: false
matrix:
include:
- install_rust: false
- no_dist: false
- mock-env: fedora-40-x86_64
install_rust: true
no_dist: true
mock-env:
- fedora-40-x86_64
runs-on: ubuntu-latest
container:
image: 'fedora:latest'
options: --privileged
env:
MOCK_ENV: ${{ matrix.mock-env }}
RPMBUILD_OPTS: ${{ case(matrix.no_dist, '-D "dist %{nil}"', '') }} ${{ case(matrix.install_rust, '-D "install_rust 1"', '') }}
PKG_VERSION: ${{ needs.pre-build.outputs.gui-version }}
steps:
- name: Install CI dependencies
run: dnf install -y git tar curl
- uses: actions/checkout@v6
with:
ref: 'releasing'
submodules: recursive
# https://github.com/actions/checkout/issues/1169
- run: git config --system --add safe.directory $GITHUB_WORKSPACE
- name: install dependencies
run: dnf install -y mock rpmbuild
- name: prepare rpm build environment
run: mkdir -p ~/rpmbuild/{BUILD,RPMS,SOURCES,SPECS,SRPMS}
- name: build source rpm package
run: |
git archive --format=tar --prefix=vrc-get-gui-v$PKG_VERSION/ $(git write-tree) | gzip > ~/rpmbuild/SOURCES/gui-v$PKG_VERSION.tar.gz
eval "rpmbuild -bs vrc-get-gui/bundle/alcom.spec $RPMBUILD_OPTS"
- name: build rpm package
run: eval "mock -v -r '$(ls -1 /etc/mock{/eol,}/$MOCK_ENV.cfg 2>/dev/null)' --enable-network $RPMBUILD_OPTS rebuild ~/rpmbuild/SRPMS/alcom-${PKG_VERSION//-/\~}-1*.src.rpm"
- name: copy built binaries
run: |
mkdir -p artifacts
cp ~/rpmbuild/SRPMS/alcom-${PKG_VERSION//-/\~}-1*.src.rpm artifacts/
cp /var/lib/mock/$MOCK_ENV/result/alcom-${PKG_VERSION//-/\~}-1*.${MOCK_ENV##*-}.rpm artifacts/
- name: Upload built binary
uses: actions/upload-artifact@v7
with:
name: artifacts-rpm-${{ matrix.mock-env }}
path: artifacts/*
build-deb:
needs: [ pre-build ]
strategy:
fail-fast: false
matrix:
include:
- install_rust: false
- install_nodejs: false
- apt-components: main
- apt-with-updates: false
# Old distributions have older tools than we need. Download tools in build process
- pbuilder-distribution: jammy
install_rust: true
install_nodejs: true
# Debian uses mirror from debian-archive.trafficmanager.net which is managed by microsoft on azure
- pbuilder-distribution: jammy
mirror: http://archive.ubuntu.com/ubuntu/
apt-components: main universe
apt-with-updates: true
keyring: /usr/share/keyrings/ubuntu-archive-keyring.gpg
# We build on jammy since it's the distribution with a) libwebkit2gtk-4.1 >= 2.41 and 2) oldest libc version required.
# bookworm: libc6@2.36
# sid: libc6@2.39 as of 2026/06/14
# jammy: libc6@2.35
pbuilder-distribution:
- jammy # jammy is the oldest ubuntu release with libwebkit2gtk-4.1 >= 2.41 (but requires -updates and universe)
target-arch:
- amd64
runs-on: ubuntu-latest
env:
TARGET_ARCH: ${{ matrix.target-arch }}
PBUILDER_DISTRIBUTION: ${{ matrix.pbuilder-distribution }}
PBUILDER_MIRROR: ${{ matrix.mirror }}
PBUILDER_KEYRING: ${{ matrix.keyring }}
PBUILDER_COMPONENTS: ${{ matrix.apt-components }}
PBUILDER_OTHERMIRROR: ${{ case(matrix.apt-with-updates, format('deb {0} {1}-updates {2}', matrix.mirror, matrix.pbuilder-distribution, matrix.apt-components), '') }}
INSTALL_RUST: ${{ case(matrix.install_rust, '1', '0') }}
INSTALL_NODEJS: ${{ case(matrix.install_nodejs, '1', '0') }}
PKG_VERSION: ${{ needs.pre-build.outputs.gui-version }}
steps:
- uses: actions/checkout@v6
with:
ref: 'releasing'
path: vrc-get
submodules: recursive
- name: install dependencies
run: sudo apt update && sudo apt install -y pbuilder debian-archive-keyring debhelper-compat=13
- name: prepare deb build environment
working-directory: vrc-get
run: |
( mkdir debian && cd debian && ln -s ../vrc-get-gui/bundle/debian/* . )
sudo pbuilder create \
--architecture "$TARGET_ARCH" \
--keyring "$PBUILDER_KEYRING" \
--mirror "$PBUILDER_MIRROR" \
--distribution "$PBUILDER_DISTRIBUTION" \
--components "$PBUILDER_COMPONENTS" \
--othermirror "$PBUILDER_OTHERMIRROR"
- name: build source deb package
working-directory: vrc-get
run: |
git archive --format=tar HEAD | xz > ../alcom_${PKG_VERSION//-/\~}.orig.tar.xz
dpkg-buildpackage -d -S
- name: build deb package
working-directory: vrc-get
run: |
sudo --preserve-env=INSTALL_RUST,INSTALL_NODEJS pbuilder build --use-network yes ../alcom_${PKG_VERSION//-/\~}-1.dsc
- name: copy built binaries
run: |
mkdir -p artifacts
cp /var/cache/pbuilder/result/alcom_${PKG_VERSION//-/\~}-1_$TARGET_ARCH.deb artifacts/
cp /var/cache/pbuilder/result/alcom_${PKG_VERSION//-/\~}-1_$TARGET_ARCH.buildinfo artifacts/
cp /var/cache/pbuilder/result/alcom_${PKG_VERSION//-/\~}-1_$TARGET_ARCH.changes artifacts/
cp /var/cache/pbuilder/result/alcom_${PKG_VERSION//-/\~}-1.debian.tar.xz artifacts/
cp /var/cache/pbuilder/result/alcom_${PKG_VERSION//-/\~}-1.dsc artifacts/
ls artifacts
- name: Print information about built package
run: dpkg-deb -I artifacts/alcom_${PKG_VERSION//-/\~}-1_$TARGET_ARCH.deb
- name: Upload built binary
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7
with:
name: artifacts-deb-${{ matrix.pbuilder-distribution }}-${{ matrix.target-arch }}
name: artifacts-${{ matrix.triple }}
path: artifacts/*
build-updater-json:
@ -457,11 +312,11 @@ jobs:
needs: [ pre-build, build-rust ]
steps:
# use release
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Download All Artifacts
uses: actions/download-artifact@v8
uses: actions/download-artifact@v4
with:
path: assets
pattern: artifacts-*
@ -470,14 +325,15 @@ jobs:
- name: Run updater-json
env:
GUI_VERSION: ${{ needs.pre-build.outputs.gui-version }}
run: cargo xtask alcom-updater-json --version "$GUI_VERSION" --assets assets updater.json
run: cargo run -p build-updater-json
- name: Upload updater-json
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: updater.json
path: |
updater.json
updater-beta.json
publish-to-github:
name: Publish to GitHub
@ -488,16 +344,16 @@ jobs:
permissions:
contents: write
runs-on: ubuntu-latest
needs: [ pre-build, build-rust, build-rpm, build-deb, build-updater-json ]
needs: [ pre-build, build-rust, build-updater-json ]
env:
GUI_VERSION: ${{ needs.pre-build.outputs.gui-version }}
steps:
- uses: actions/create-github-app-token@v3
- uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
ref: 'releasing'
fetch-depth: 2
@ -510,7 +366,7 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
- name: Download All Artifacts
uses: actions/download-artifact@v8
uses: actions/download-artifact@v4
with:
path: assets
pattern: artifacts-*
@ -518,7 +374,7 @@ jobs:
- name: Download changelog
# if: ${{ !needs.pre-build.outputs.prerelease }}
uses: actions/download-artifact@v8
uses: actions/download-artifact@v4
with:
name: release-note-for-release
path: changelog
@ -571,7 +427,7 @@ jobs:
- build-rust
- publish-to-github
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
ref: 'releasing'
fetch-depth: 2
@ -588,14 +444,14 @@ jobs:
env:
GUI_VERSION: ${{ needs.pre-build.outputs.gui-version }}
steps:
- uses: actions/create-github-app-token@v3
- uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
owner: vrc-get
repositories: vrc-get.anatawa12.com
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
repository: 'vrc-get/vrc-get.anatawa12.com'
ref: 'master'
@ -604,7 +460,7 @@ jobs:
- uses: snow-actions/git-config-user@v1.0.0
- name: Download updater.json
uses: actions/download-artifact@v8
uses: actions/download-artifact@v4
with:
name: updater.json
path: .
@ -618,7 +474,7 @@ jobs:
mv updater.json public/api/gui/tauri-updater.json
fi
rm public/api/gui/tauri-updater-beta.json || true
mv updater.json public/api/gui/tauri-updater-beta.json
mv updater-beta.json public/api/gui/tauri-updater-beta.json
- name: Commit
run: |-

173
.github/workflows/publish-litedb.yml vendored Normal file
View file

@ -0,0 +1,173 @@
name: Publish (litedb)
on:
workflow_dispatch:
inputs:
release_kind:
type: choice
description: The type of release.
default: prerelease
required: true
options:
- prerelease
- start-rc
- stable
jobs:
pre-build:
name: Update version name
runs-on: ubuntu-latest
outputs:
litedb-version: ${{ env.LITEDB_VERSION }}
prerelease: ${{ steps.update-version.outputs.prerelease }}
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: anatawa12/something-releaser@v3
- uses: snow-actions/git-config-user@v1.0.0
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Update Version Name
id: update-version
run: |
# set version name in properties file
case "$RELEASE_KIND_IN" in
"prerelease" )
get-version -t litedb | version-next | set-version -t litedb
gh-export-variable PRERELEASE true
gh-set-output prerelease true
;;
"start-rc" )
get-version -t litedb | version-set-channel - rc 0 | set-version -t litedb
gh-export-variable PRERELEASE true
gh-set-output prerelease true
;;
"stable" )
get-version -t litedb | version-set-channel - stable | set-version -t litedb
gh-export-variable PRERELEASE false
gh-set-output prerelease '' # empty string for false
;;
* )
echo "invalid release kind: $RELEASE_KIND_IN"
exit 255
;;
esac
case "$GITHUB_REF_NAME" in
master | master-* )
echo "head is master or master-*"
;;
* )
echo "invalid release kind: $RELEASE_KIND_IN is not allowd for $GITHUB_REF_NAME"
exit 255
;;
esac
gh-export-variable LITEDB_VERSION "$(get-version -t litedb)"
env:
RELEASE_KIND_IN: ${{ github.event.inputs.release_kind }}
# check for unexpected breaking ABI changes
- name: Check semver
uses: obi1kenobi/cargo-semver-checks-action@v2
with:
package: vrc-get-litedb
- name: Commit
id: update
run: |-
# commit & tag
git commit -am "litedb v$LITEDB_VERSION"
git branch releasing
git push -f -u origin releasing
publish-crates-io:
name: Publish to crates.io
environment:
name: crates.io
url: https://crates.io/crates/vrc-get
runs-on: ubuntu-latest
needs: [ pre-build ]
steps:
- uses: actions/checkout@v4
with:
ref: 'releasing'
fetch-depth: 1
submodules: recursive
- name: Publish CARGO
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_TOKEN }}
run: cargo publish --package vrc-get-litedb
publish-to-github:
name: Publish to GitHub
environment:
name: actions-github-app
url: https://github.com/anatawa12/vrc-get/releases/litedb-v${{ needs.pre-build.outputs.litedb-version }}
permissions:
contents: write
runs-on: ubuntu-latest
needs: [ pre-build, publish-crates-io ]
env:
LITEDB_VERSION: ${{ needs.pre-build.outputs.litedb-version }}
steps:
- uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- uses: actions/checkout@v4
with:
ref: 'releasing'
fetch-depth: 2
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
# tools
- uses: anatawa12/something-releaser@v3
- uses: snow-actions/git-config-user@v1.0.0
- uses: dtolnay/rust-toolchain@stable
- name: Push tag
run: |-
# set tag and publish current version
git tag "litedb-v$LITEDB_VERSION"
git push --tags
# create master and push
git switch -c master
git fetch origin master --depth=1
git log --all --graph
git push -u origin master
sleep 1
- name: prepare next release & push
if: ${{ !needs.pre-build.outputs.prerelease }}
run: |
get-version -t litedb | version-next | version-set-channel - beta 0 | set-version -t litedb
LITEDB_NEXT="$(get-version -t litedb | version-stable)"
git commit -am "chore: prepare for next version: litedb $LITEDB_NEXT"
git push
cleanup:
name: Cleanup
if: ${{ !failure() && !cancelled() }}
permissions:
contents: write
runs-on: ubuntu-latest
needs:
- pre-build
- publish-crates-io
- publish-to-github
steps:
- uses: actions/checkout@v4
with:
ref: 'releasing'
fetch-depth: 2
- name: remove releasing branch
run: git push --delete origin releasing

View file

@ -12,14 +12,6 @@ on:
- prerelease
- start-rc
- stable
dry-run:
type: boolean
description: Dry Run, If true, do not publish release to GitHub.
default: true
required: false
concurrency:
group: releasing
jobs:
pre-build:
@ -32,7 +24,7 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: anatawa12/something-releaser@v3
@ -71,16 +63,12 @@ jobs:
esac
case "$GITHUB_REF_NAME" in
master | master-* | hotfix-* )
echo "head is master, master-*, or hotfix-*"
master | master-* )
echo "head is master or master-*"
;;
* )
if [ "$DRY_RUN" = "true" ]; then
echo "head is not master, but DRY_RUN is true"
else
echo "head is not master, but DRY_RUN is false"
exit 255
fi
echo "invalid release kind: $RELEASE_KIND_IN is not allowd for $GITHUB_REF_NAME"
exit 255
;;
esac
@ -88,7 +76,6 @@ jobs:
gh-export-variable VPM_VERSION "$(get-version -t vpm)"
env:
RELEASE_KIND_IN: ${{ github.event.inputs.release_kind }}
DRY_RUN: ${{ inputs.dry-run }}
# region changelog
- name: Create Changelog
@ -107,7 +94,7 @@ jobs:
---
- name: Upload CHANGELOG.md
if: ${{ !steps.update-version.outputs.prerelease }}
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: CHANGELOG
path: CHANGELOG.md
@ -116,7 +103,7 @@ jobs:
run: cp "${{ steps.changelog.outputs.release-note }}" release-note.md
- name: Upload release note
if: ${{ !steps.update-version.outputs.prerelease }}
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: release-note-for-release
path: release-note.md
@ -177,7 +164,7 @@ jobs:
needs: [ pre-build ]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
ref: 'releasing'
submodules: recursive
@ -199,13 +186,12 @@ jobs:
run: cargo build --target ${{ matrix.triple }} --release --verbose
- name: Check binary is statically linked
shell: bash
env:
RUSTFLAGS: ''
run: |
cargo xtask check-static-link target/${{ matrix.triple }}/release/vrc-get${WINDIR:+.exe}
# https://github.com/taiki-e/setup-cross-toolchain-action/issues/18
unset CARGO_BUILD_TARGET
cargo run -p build-check-static-link target/${{ matrix.triple }}/release/vrc-get*
- name: Move artifacts
if: ${{ !cancelled() }}
shell: bash
run: |-
mkdir artifacts
@ -215,22 +201,20 @@ jobs:
done
popd
- uses: actions/upload-artifact@v7
if: ${{ !cancelled() }}
- uses: actions/upload-artifact@v4
with:
name: artifacts-${{ matrix.triple }}
path: artifacts/*
publish-crates-io:
name: Publish to crates.io
if: ${{ !inputs.dry-run }}
environment:
name: crates.io
url: https://crates.io/crates/vrc-get
runs-on: ubuntu-latest
needs: [ pre-build, build-rust ]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
ref: 'releasing'
fetch-depth: 1
@ -248,7 +232,6 @@ jobs:
publish-to-github:
name: Publish to GitHub
if: ${{ !inputs.dry-run }}
environment:
name: actions-github-app
url: https://github.com/anatawa12/vrc-get/releases/v${{ needs.pre-build.outputs.cli-version }}
@ -260,12 +243,12 @@ jobs:
CLI_VERSION: ${{ needs.pre-build.outputs.cli-version }}
VPM_VERSION: ${{ needs.pre-build.outputs.vpm-version }}
steps:
- uses: actions/create-github-app-token@v3
- uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
ref: 'releasing'
fetch-depth: 2
@ -278,7 +261,7 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
- name: Download All Artifacts
uses: actions/download-artifact@v8
uses: actions/download-artifact@v4
with:
path: assets
pattern: artifacts-*
@ -286,24 +269,22 @@ jobs:
- name: Download changelog
if: ${{ !needs.pre-build.outputs.prerelease }}
uses: actions/download-artifact@v8
uses: actions/download-artifact@v4
with:
name: release-note-for-release
path: changelog
- name: Push tag
env:
BRANCH: ${{ github.ref_name }}
run: |-
# set tag and publish current version
git tag "v$CLI_VERSION"
git tag "vpm-v$VPM_VERSION"
git push --tags
# create master and push
git switch -c "$BRANCH"
git fetch origin "$BRANCH" --depth=1
git switch -c master
git fetch origin master --depth=1
git log --all --graph
git push -u origin "$BRANCH"
git push -u origin master
sleep 1
- name: create release
@ -343,7 +324,7 @@ jobs:
CLI_VERSION: ${{ needs.pre-build.outputs.cli-version }}
VPM_VERSION: ${{ needs.pre-build.outputs.vpm-version }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
ref: 'releasing'
fetch-depth: 2
@ -353,7 +334,7 @@ jobs:
publish-to-homebrew:
name: Publish to homebrew
# vrc-get is on autobump list https://github.com/Homebrew/homebrew-core/blame/master/.github/autobump.txt
if: false # ${{ !inputs.dry-run && !needs.pre-build.outputs.prerelease }}
if: false # ${{ !needs.pre-build.outputs.prerelease }}
environment:
name: homebrew-core
url: https://github.com/homebrew/homebrew-core
@ -367,7 +348,7 @@ jobs:
publish-to-winget:
name: Publish to winget
if: ${{ !inputs.dry-run && !needs.pre-build.outputs.prerelease }}
if: ${{ !needs.pre-build.outputs.prerelease }}
needs: [ pre-build, publish-to-github ]
uses: vrc-get/vrc-get/.github/workflows/publish-cli-winget.yml@master

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "vrc-get-litedb/dotnet/LiteDB"]
path = vrc-get-litedb/dotnet/LiteDB
url = https://github.com/anatawa12/LiteDB.git

View file

@ -8,291 +8,35 @@ The format is based on [Keep a Changelog].
## [Unreleased]
### Added
- Implement project sorting by creation date `#2941`
- System Information card to Settings Page `#1406`
- Traditional Chinese translation `#1442`
- Reinstall some selected packages `#1450`
- Install and Upgrade packages at once `#1450`
- Upgrade to the stable latest version even if some package has newer prerelease version `#1450`
- Buttons to open settings, logs, and templates location `#1451`
- Error page `#1457`
- Ctrl + F on Log, Projects List, and Project page will focus search box on the page `#1485`
- Partial macOS Mojave Support `#1404`
### Changed
- GitHub Releases for ALCOM is no longer prereleases
- Moved log files to `<vpm-home>/vrc-get/gui-logs` `#1446`
- Logs pages overhaul `#1456`
### Deprecated
### Removed
### Fixed
### Security
## [1.1.6] - 2026-06-02
### Added
- The package list can show hidden packages. [`#2731`](https://github.com/vrc-get/vrc-get/pull/2731)
- Build-time option to disable auto updater [`#2759`](https://github.com/vrc-get/vrc-get/pull/2759)
- Please read README for new build instruction.
- User repositories can now be reordered by drag and drop [`#2935`](https://github.com/vrc-get/vrc-get/pull/2935)
### Changed
- The "Clear Selection" button in the package management screen is now red (destructive style) to distinguish it from the "Install Selected" button [`#2803`](https://github.com/vrc-get/vrc-get/pull/2803)
- File filled with '\0' or whitespace will be treated as empty file [`#2710`](https://github.com/vrc-get/vrc-get/pull/2710)
- This should prevent `syntax error loading settings.json: expected value at line 1 column 1` if settings.json is broken
- We also added a backup file to recover from settings.json corruption [`#2933`](https://github.com/vrc-get/vrc-get/pull/2933)
- Completely changed how do we build ALCOM and how do we self-update ALCOM [`#2759`](https://github.com/vrc-get/vrc-get/pull/2759) [`#2828`](https://github.com/vrc-get/vrc-get/pull/2828) [`#2881`](https://github.com/vrc-get/vrc-get/pull/2881) [`#2882`](https://github.com/vrc-get/vrc-get/pull/2882) [`#2885`](https://github.com/vrc-get/vrc-get/pull/2885)
- This fixes few problems relates to auto update
- Please read README for new build instruction.
- Improved backup speed by parallelizing the process [`#2746`](https://github.com/vrc-get/vrc-get/pull/2746)
- Along with this change, the default compression level has been changed to `zip-fast`
- We added dialog on enabling "Show Prerelease Packages" [`#2795`](https://github.com/vrc-get/vrc-get/pull/2795)
- I hope this prevents users unexpectedly adding prerelease packages
- Path for unitypackage on Template Editor now can be reselected [`#2635`](https://github.com/vrc-get/vrc-get/pull/2635)
- ALCOM now refuses launching project if project is on noexec mount points [`#2814`](https://github.com/vrc-get/vrc-get/pull/2814)
- This would cause problems with several native plugins
- Already-added packages are now excluded from the package name suggestions in the Template Editor [`#2828`](https://github.com/vrc-get/vrc-get/pull/2828)
- Extended some timeouts to 1 minute [`#2826`](https://github.com/vrc-get/vrc-get/pull/2826)
- Prevents timeouts in slow DNS environments
- Improved robustness for package installation errors [`#2844`](https://github.com/vrc-get/vrc-get/pull/2844)
- It is now unlikely that vrc-get will leave the project directory corrupted if an I/O error occurs while installing a package
- Backslashes in path in zip file are now treated as path separator on unix [`#2845`](https://github.com/vrc-get/vrc-get/pull/2845)
- This fixes problem with Gesture Manager 3.9.7
- Empty string for `documentationUrl` and `changelogUrl` are now allowed and ignored [`#2930`](https://github.com/vrc-get/vrc-get/pull/2930)
- They are formerly rejected as invalid url
### Fixed
- Fixed an issue where the progress bar flickered and did not display correct progress in environments using WebKit as the renderer. [`#2641`](https://github.com/vrc-get/vrc-get/pull/2641)
- Fails to import UnityPackages with files in `Packages` directory [`#2679`](https://github.com/vrc-get/vrc-get/pull/2679)
- null as vpmDependencies value is not allowed [`#2709`](https://github.com/vrc-get/vrc-get/pull/2709)
- It's not recommended, but we allow null for `vpmDependencies` as a alias of `{}`
- ALCOM cannot detect per-user flatpak installation of unity hub [`#2812`](https://github.com/vrc-get/vrc-get/pull/2812)
- Unabled to import some untypackages [`#2821`](https://github.com/vrc-get/vrc-get/pull/2821)
- It's hard to say but some older unitypackages ware unsupported.
- Panic when resolving projects where dependency packages depend on newer versions of locked packages [`#2822`](https://github.com/vrc-get/vrc-get/pull/2822)
- Missing glibc and libgcc_s dependency notation in .deb / .rpm distributon [`#2828`](https://github.com/vrc-get/vrc-get/pull/2828)
- Unclear error message for invalid version name or version range [`#2842`](https://github.com/vrc-get/vrc-get/pull/2842)
- Default file names in save dialogs now include the appropriate file extension [`#2846`](https://github.com/vrc-get/vrc-get/pull/2846)
- Template export now defaults to `{template name}.alcomtemplate`
- Repository list export now defaults to `repositories.txt`
- Uninformative `[object Object]` appearing as an error message [`#2848`](https://github.com/vrc-get/vrc-get/pull/2848)
- New Unity Hub loading method may not load manually added Unity Editors [`#2850`](https://github.com/vrc-get/vrc-get/pull/2850)
- New Unity Hub loading method does load unity hub configuration on Linux [`#2850`](https://github.com/vrc-get/vrc-get/pull/2850)
- Too many open files when copying project `#2867
- Added workaround for VRCDefaultWorldScene generation issue in SDK 3.10.2 or later [`#2916`](https://github.com/vrc-get/vrc-get/pull/2916)
- See [this][default-scene-canny] canny for bug in VRCSDK and issue [#2913][issue-2913] for our decision.
### Security
- Package hash checks are now enforced when installing packages [`#2849`](https://github.com/vrc-get/vrc-get/pull/2849)
- It has been about two years since the error message for package hash mismatches was introduced.
- It is now enforced for security.
[default-scene-canny]: https://feedback.vrchat.com/sdk-bug-reports/p/3102-3103-vrcscenetemplateinitializer-does-not-create-sample-scene-if-udon-prepr
[issue-2913]: https://github.com/vrc-get/vrc-get/issues/2913
## [1.1.5] - 2025-11-16
- Fix package version selector dropdown exceeding window height [`#2589`](https://github.com/vrc-get/vrc-get/pull/2589)
- The dropdown list now has a maximum height of 50% of the viewport or 24rem, whichever is smaller
- This prevents the version selector from overflowing the window on small screens
- Fix muted-foreground color [`#2516`](https://github.com/vrc-get/vrc-get/pull/2516) [`#2517`](https://github.com/vrc-get/vrc-get/pull/2517)
- Remove `DialogDescription` not in `DialogHeader` to fix text color
- Fix 'Detected Loop' panic with valid database file [`#2607`](https://github.com/vrc-get/vrc-get/pull/2607)
## [1.1.4] - 2025-09-02
### Added
- Add compact gui option [`#2436`](https://github.com/vrc-get/vrc-get/pull/2436) [`#2450`](https://github.com/vrc-get/vrc-get/pull/2450) [`#2470`](https://github.com/vrc-get/vrc-get/pull/2470)
### Changed
- Improved saving interacting with setting files [`#2485`](https://github.com/vrc-get/vrc-get/pull/2485)
- This should reduce "EOF while parsing a value at line 1 column 0" error on launch.
- This should reduce losing settings after crashing ALCOM or PC.
### Fixed
- Specifying a single unityversion doesn't work properly in alcomtemplate [`#2452`](https://github.com/vrc-get/vrc-get/pull/2452)
- For example, if you'd like to specify `2022.3.22f1`, you need to set `2022.3.22`, not `2022.3.22f1`
- You can now see correct validation and suggestions for this.
- Home/End and Up/Down keys now consistently control cursor position in autocomplete fields [`#2466`](https://github.com/vrc-get/vrc-get/pull/2466)
- Home/End keys now always move the text cursor regardless of autocomplete state
- Up/Down keys move the text cursor when suggestions are not visible, and navigate suggestions when they are visible
- Previously, these keys would sometimes be captured for suggestion navigation when autocomplete was open
## [1.1.3] - 2025-07-28
### Added
- Add support for `keywords` UPM manifest field [`#2375`](https://github.com/vrc-get/vrc-get/pull/2375)
- You now can specifiy search keywords for package with `keywords` UPM manifest field
- Favorites for templates [`#2376`](https://github.com/vrc-get/vrc-get/pull/2376)
- It's much easier to select project templates you likely to use.
### Changed
- Improved the Template Editor with AutoComplete [`#2371`](https://github.com/vrc-get/vrc-get/pull/2371)
- You no longer need to remember the package name (id) and version associated with the package.
- You now can search package by display name, name (id), aliases to enter package name, and ALCOM shows common version range for you.
- Updated project settings of templates to include Item layer [`#2373`](https://github.com/vrc-get/vrc-get/pull/2373)
- You should no longer need to update layers and collision matrix before uploading world
- Improved behavior about `settings.json` to `vcc.litedb` migration [`#2327`](https://github.com/vrc-get/vrc-get/pull/2327)
- See [`vrchat-community/creator-companion#492`](https://github.com/vrchat-community/creator-companion/issues/492) and the PR for details
- Last used template is now preserved [`#2376`](https://github.com/vrc-get/vrc-get/pull/2376)
- When you generally create project with custom template, you no longer need to change template every time.
### Fixed
- Packages are not deselected after installing packages [`#2372`](https://github.com/vrc-get/vrc-get/pull/2372)
## [1.1.2] - 2025-06-30
### Fixed
- Fixed `a - b` version range is not correctly serialized on the `vpm-manifest.json`
- Frontend error on package list update [`#2341`](https://github.com/vrc-get/vrc-get/pull/2341)
## [1.1.1] - 2025-06-21
### Fixed
- Unity can be duplicated [`#2321`](https://github.com/vrc-get/vrc-get/pull/2321)
- Crash on creating a new project on Windows [`#2326`](https://github.com/vrc-get/vrc-get/pull/2326)
## [1.1.0] - 2025-06-19
### Added
- Support for Projects with Unity 2018 or older [`#2106`](https://github.com/vrc-get/vrc-get/pull/2106)
- Projects with Unity 2018 cannot be added before, but since this version you can add to your project list.
- Unity 2017 or older doesn't have Unity Package Manager, the base system for VPM,
so you cannot manage packages for projects with older unity.
You can only launch Unity to open the project.
- Projects with Unity 4 or older are still not supported, but I hope no one want to use such a vintage Unity with ALCOM.
- New Project Template System [`#2105`](https://github.com/vrc-get/vrc-get/pull/2105) [`#2114`](https://github.com/vrc-get/vrc-get/pull/2114) [`#2125`](https://github.com/vrc-get/vrc-get/pull/2125) [`#2129`](https://github.com/vrc-get/vrc-get/pull/2129) [`#2204`](https://github.com/vrc-get/vrc-get/pull/2204) [`#2259`](https://github.com/vrc-get/vrc-get/pull/2259) [`#2260`](https://github.com/vrc-get/vrc-get/pull/2260) [`#2261`](https://github.com/vrc-get/vrc-get/pull/2261) [`#2275`](https://github.com/vrc-get/vrc-get/pull/2275) [`#2276`](https://github.com/vrc-get/vrc-get/pull/2276)
- You now can create Project Templates in ALCOM.
- The new form of template can install multiple VPM packages at once, and you also can import unitypackages.
- You now can create blank project along with this system change.
- Warning on upgrading major version or installing incompatible versions [`#2159`](https://github.com/vrc-get/vrc-get/pull/2159) [`#2313`](https://github.com/vrc-get/vrc-get/pull/2313)
- When you're upgrading package versions majorly, you'll see the warning message about breaking changes.
- I hope this should reduce problems with unexpectedly upgrading packages majorly.
- In addition, we added more messages when you're installing packages with some compatibility concerns.
- The previous version only has those messages at the bottom of the window, so you may not notice the message.
- Not only that, you now can see the package is upgraded, reinstalled, downgraded, or newly installed.
- Menu option to copy a project [`#2168`](https://github.com/vrc-get/vrc-get/pull/2168) [`#2219`](https://github.com/vrc-get/vrc-get/pull/2219) [`#2225`](https://github.com/vrc-get/vrc-get/pull/2225)
- Simple enough, you can copy a project.
- Remember recent project locations [`#2182`](https://github.com/vrc-get/vrc-get/pull/2182)
- ALCOM now remembers a few multiple recent locations for project creation, and you can select from recent locations
- Support for flatpak installation of unity hub [`#1586`](https://github.com/vrc-get/vrc-get/pull/1586)
- ALCOM now detects flatpak installation of unity hub automatically
- Projects page Grid View [`#2245`](https://github.com/vrc-get/vrc-get/pull/2245) [`#2257`](https://github.com/vrc-get/vrc-get/pull/2257)
### Changed
- Changed how we read VCC's project information [`#1997`](https://github.com/vrc-get/vrc-get/pull/1997) [`#2036`](https://github.com/vrc-get/vrc-get/pull/2036) [`#2041`](https://github.com/vrc-get/vrc-get/pull/2041)
- Along with this, building this project no longer needs dotnet SDK to build.
- Migrated the project to Rust 2024 [`#1956`](https://github.com/vrc-get/vrc-get/pull/1956)
- This is internal changes should not cause behavior changes
- This would require Rust 1.85 for building this project
- Removed `cargo-about` from build-time dependency [`#1961`](https://github.com/vrc-get/vrc-get/pull/1961)
- This is internal changes should not cause behavior changes
- I listed here since this may need update on package metadata of some package managers
- The method to retrieve the list of Unity from Unity Hub [`#1808`](https://github.com/vrc-get/vrc-get/pull/1808) [`#1971`](https://github.com/vrc-get/vrc-get/pull/1971)
- Since this version, ALCOM reads UnityHub's configuration files to get list of Unity installed to the machine.
- Before this version, ALCOM called headless Unity Hub in the background.
- New method might have some compatibility problem, especially with some sandbox system.
- Please report us if you find some problem with the new system.
- Enhance os info for windows [`#1968`](https://github.com/vrc-get/vrc-get/pull/1968)
- You now can select multiple folders at once to adding project [`#2018`](https://github.com/vrc-get/vrc-get/pull/2018)
- I didn't know official VCC had such a feature. Sorry for lack of feature!
- You now can toggle "Show Prerelease Packages" from Manage Project page [`#2020`](https://github.com/vrc-get/vrc-get/pull/2020)
- You can toggle "Show Prerelease Packages" from Select Packages dropdown
- The requirements for unity project [`#2106`](https://github.com/vrc-get/vrc-get/pull/2106)
- Since this version, `Projectsettings/ProjectVersion.txt` is required.
- Improves launching unity behavior [`#2124`](https://github.com/vrc-get/vrc-get/pull/2124)
- On linux, ALCOM will now read exit code, therefore, Unity no longer remains as a defunct process.
- On macOS, we now launch Unity as a distinct / individual process, therefore several macOS subsystems should treat Unity as Unity instead of Unity as a part of ALCOM.
- Downgraded glibc requirements for linux images [`#2160`](https://github.com/vrc-get/vrc-get/pull/2160)
- This release will be built on ubuntu 22.04 so glibc 2.35 is new requirements
- If you want to use on platforms with older glibc, build yourself or pull request to build on older environments.
- Loading projects / repositories is now asynchronously [`#2169`](https://github.com/vrc-get/vrc-get/pull/2169)
- You should be able to open a project / install packages much quickly than before!
- The reload button will keep rotating while loading asynchronously
- Open changelog, documentation, and reinstall single package from package list [`#2184`](https://github.com/vrc-get/vrc-get/pull/2184) [`#2208`](https://github.com/vrc-get/vrc-get/pull/2208) [`#2298`](https://github.com/vrc-get/vrc-get/pull/2298)
- You can open the changelog and documentation from `...` button at the right of package list
- Option to exclude VPM Packages from backups [`#2185`](https://github.com/vrc-get/vrc-get/pull/2185)
- You can exclude VPM Packages from backups to reduce size of backup a little
- However, if the package author ignored the recommendation from VRChat and us, and removed package from their repository, you may need to install another version when restoring the backup.
- Since many of the repository maintainers have removed many packages in their repository and VPM Packages are relatively small, this feature is disabled by default. You can enable this on the settings page.
- Show the range of requested package in missing dependencies dialog [`#2187`](https://github.com/vrc-get/vrc-get/pull/2187)
- `LastSceneManagerSetup.txt` in `Library` directory will be included in backups or copying project [`#2205`](https://github.com/vrc-get/vrc-get/pull/2205)
- With this file preserved, you can expect to open the last opened scene file when you opened projects restored from backups.
- Improved behavior when the project directory is not a valid project but the directory exists [`#2225`](https://github.com/vrc-get/vrc-get/pull/2225)
- Open Unity now will update `Last Modified` of a project. [`#2228`](https://github.com/vrc-get/vrc-get/pull/2228)
### Fixed
- Layout shift on select package [`#2045`](https://github.com/vrc-get/vrc-get/pull/2045)
- Unable to change the unity version from "unknown" if ProjectVersion.txt does not exists [`#2183`](https://github.com/vrc-get/vrc-get/pull/2183)
- Uninstall package is not reverted successfully if removing package is prevented by `ERROR_SHARING_VIOLATION` [`#2209`](https://github.com/vrc-get/vrc-get/pull/2209)
- Too Many Open Files on backing up some projects [`#2262`](https://github.com/vrc-get/vrc-get/pull/2262)
## [1.0.1] - 2025-02-05
### Added
- Add Korean localization [`#1822`](https://github.com/vrc-get/vrc-get/pull/1822)
### Fixed
- Fixed toast message when adding repositories [`#1815`](https://github.com/vrc-get/vrc-get/pull/1815)
- Improved several linux desktop support [`#1821`](https://github.com/vrc-get/vrc-get/pull/1821)
- Backup file used UTC time instead of Local time [`#1862`](https://github.com/vrc-get/vrc-get/pull/1862)
- Worlds templates doesn't have proper input axis settings [`#1902`](https://github.com/vrc-get/vrc-get/pull/1902)
## [1.0.0] - 2025-01-01
### Fixed
- Link to unity hub is hardcoded to Japanese [`#1810`](https://github.com/vrc-get/vrc-get/pull/1810)
- Fixed link to respect currently configured language
- Fixed Logs page autoscroll not enable on start [`#1811`](https://github.com/vrc-get/vrc-get/pull/1811)
- Fixed failed to load project list with invalid unity version stored [`#1813`](https://github.com/vrc-get/vrc-get/pull/1813)
## [0.1.17] - 2024-12-22
### Changed
- Several GUI improvements [`#1672`](https://github.com/vrc-get/vrc-get/pull/1672) [`#1771`](https://github.com/vrc-get/vrc-get/pull/1771) [`#1775`](https://github.com/vrc-get/vrc-get/pull/1775) [`#1772`](https://github.com/vrc-get/vrc-get/pull/1772) [`#1779`](https://github.com/vrc-get/vrc-get/pull/1779)
- Removed `-debugCodeOptimization` from default unity arguments [`#1742`](https://github.com/vrc-get/vrc-get/pull/1742)
- Projects that failes to resolve will also be added to Project List now [`#1748`](https://github.com/vrc-get/vrc-get/pull/1748)
- Previsously project dir is created but not added to list
- Dialog is shown when some installing packages are not found [`#1749`](https://github.com/vrc-get/vrc-get/pull/1749) [`#1776`](https://github.com/vrc-get/vrc-get/pull/1776)
- The new dialog also suggest you to google & add repository for the package
- Previously the first package we could not found are shown on the error toast but now collect and show missing packages as many as possible
### Fixed
- Prerelease version is choosen even if good stable version exists [`#1745`](https://github.com/vrc-get/vrc-get/pull/1745)
## [0.1.16] - 2024-11-12
### Added
- Support for China version of Unity releases like `2022.3.22f1c1` `#1558
- `rpm` `deb` packaging for Linux [`#1575`](https://github.com/vrc-get/vrc-get/pull/1575)
- This is to test how good / bad `rpm` or `deb` distribution is.
- We **may** create dnf / apt package repository in the future, but not planned for now.
- Skipping finding legacy assets when downgrading / upgrading / reinstalling package [`#1581`](https://github.com/vrc-get/vrc-get/pull/1581)
- This will speed up the process of downgrading / upgrading / reinstalling package.
### Changed
- Separated quick open actions to own settings box. [`#1496`](https://github.com/vrc-get/vrc-get/pull/1496)
- Improved behavior with downloading package error [`#1557`](https://github.com/vrc-get/vrc-get/pull/1557)
- Installing unlocked package is now possible with warning [`#1557`](https://github.com/vrc-get/vrc-get/pull/1557)
- Added many logs for installing package [`#1557`](https://github.com/vrc-get/vrc-get/pull/1557)
- Migration feature is no longer marked as experimental [`#1559`](https://github.com/vrc-get/vrc-get/pull/1559)
- Several UX improvements [`#1561`](https://github.com/vrc-get/vrc-get/pull/1561) [`#1565`](https://github.com/vrc-get/vrc-get/pull/1565) [`#1569`](https://github.com/vrc-get/vrc-get/pull/1569) [`#1571`](https://github.com/vrc-get/vrc-get/pull/1571) [`#1573`](https://github.com/vrc-get/vrc-get/pull/1573)
- Added more error log [`#1652`](https://github.com/vrc-get/vrc-get/pull/1652)
- Improved error message when specified drive not found [`#1653`](https://github.com/vrc-get/vrc-get/pull/1653)
### Fixed
- Clicking VCC link while adding vpm repository would close previously opened add repository dialog [`#1570`](https://github.com/vrc-get/vrc-get/pull/1570)
- Opnening Templetes directory might fails [`#1641`](https://github.com/vrc-get/vrc-get/pull/1641)
- Backup file name is incorrect if project name contains '.' [`#1648`](https://github.com/vrc-get/vrc-get/pull/1648)
- Error creating project if the project path is "C:" [`#1651`](https://github.com/vrc-get/vrc-get/pull/1651)
- "missing field Verison" error if some unity version is missing [`#1654`](https://github.com/vrc-get/vrc-get/pull/1654)
## [0.1.15] - 2024-09-05
### Added
- System Information card to Settings Page [`#1406`](https://github.com/vrc-get/vrc-get/pull/1406)
- Traditional Chinese translation [`#1442`](https://github.com/vrc-get/vrc-get/pull/1442)
- Reinstall some selected packages [`#1450`](https://github.com/vrc-get/vrc-get/pull/1450)
- Install and Upgrade packages at once [`#1450`](https://github.com/vrc-get/vrc-get/pull/1450)
- Upgrade to the stable latest version even if some package has newer prerelease version [`#1450`](https://github.com/vrc-get/vrc-get/pull/1450)
- Buttons to open settings, logs, and templates location [`#1451`](https://github.com/vrc-get/vrc-get/pull/1451)
- Error page [`#1457`](https://github.com/vrc-get/vrc-get/pull/1457)
- Ctrl + F on Log, Projects List, and Project page will focus search box on the page [`#1485`](https://github.com/vrc-get/vrc-get/pull/1485)
### Changed
- GitHub Releases for ALCOM is no longer prereleases
- Moved log files to `<vpm-home>/vrc-get/gui-logs` [`#1446`](https://github.com/vrc-get/vrc-get/pull/1446)
- Logs pages overhaul [`#1456`](https://github.com/vrc-get/vrc-get/pull/1456)
### Fixed
- Fails to uninstall packages on macOS with filesystem that doesn't support resource fork [`#1402`](https://github.com/vrc-get/vrc-get/pull/1402)
- Fails to uninstall packages on macOS with filesystem that doesn't support resource fork `#1402`
- This is typically seen on ExFAT or FAT32 filesystems, not on APFS or HFS+ filesystems.
- macOS internally creates files starting with `._` for resource fork if the filesystem does not support resource fork.
- vrc-get-vpm does not handle this file correctly and fails to uninstall the package.
- environment version mismatch error after resolving packages [`#1447`](https://github.com/vrc-get/vrc-get/pull/1447)
- Raw error for InstallAsUnlocked is shown on gui [`#1448`](https://github.com/vrc-get/vrc-get/pull/1448)
- Ctrl + F on Windows will show the search box by WebView2 [`#1485`](https://github.com/vrc-get/vrc-get/pull/1485)
- Project Path is shown instead of Project Name [`#1484`](https://github.com/vrc-get/vrc-get/pull/1484)
- environment version mismatch error after resolving packages `#1447`
- Raw error for InstallAsUnlocked is shown on gui `#1448`
- Ctrl + F on Windows will show the search box by WebView2 `#1485`
### Security
## [0.1.14] - 2024-08-13
### Added
@ -703,19 +447,7 @@ Release pipeline fixes
- Apple code signing [`#422`](https://github.com/anatawa12/vrc-get/pull/422)
- Migrate vpm 2019 project to 2022 [`#435`](https://github.com/anatawa12/vrc-get/pull/435)
[Unreleased]: https://github.com/vrc-get/vrc-get/compare/gui-v1.1.6...HEAD
[1.1.6]: https://github.com/vrc-get/vrc-get/compare/gui-v1.1.5...gui-v1.1.6
[1.1.5]: https://github.com/vrc-get/vrc-get/compare/gui-v1.1.4...gui-v1.1.5
[1.1.4]: https://github.com/vrc-get/vrc-get/compare/gui-v1.1.3...gui-v1.1.4
[1.1.3]: https://github.com/vrc-get/vrc-get/compare/gui-v1.1.2...gui-v1.1.3
[1.1.2]: https://github.com/vrc-get/vrc-get/compare/gui-v1.1.1...gui-v1.1.2
[1.1.1]: https://github.com/vrc-get/vrc-get/compare/gui-v1.1.0...gui-v1.1.1
[1.1.0]: https://github.com/vrc-get/vrc-get/compare/gui-v1.0.1...gui-v1.1.0
[1.0.1]: https://github.com/vrc-get/vrc-get/compare/gui-v1.0.0...gui-v1.0.1
[1.0.0]: https://github.com/vrc-get/vrc-get/compare/gui-v0.1.17...gui-v1.0.0
[0.1.17]: https://github.com/vrc-get/vrc-get/compare/gui-v0.1.16...gui-v0.1.17
[0.1.16]: https://github.com/vrc-get/vrc-get/compare/gui-v0.1.15...gui-v0.1.16
[0.1.15]: https://github.com/vrc-get/vrc-get/compare/gui-v0.1.14...gui-v0.1.15
[Unreleased]: https://github.com/vrc-get/vrc-get/compare/gui-v0.1.14...HEAD
[0.1.14]: https://github.com/vrc-get/vrc-get/compare/gui-v0.1.13...gui-v0.1.14
[0.1.13]: https://github.com/vrc-get/vrc-get/compare/gui-v0.1.12...gui-v0.1.13
[0.1.12]: https://github.com/vrc-get/vrc-get/compare/gui-v0.1.11...gui-v0.1.12

View file

@ -8,87 +8,34 @@ The format is based on [Keep a Changelog].
## [Unreleased]
### Added
- 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`
- `vrc-get cache clear`, command to clear package cache `#1204`
- Importing / Exporting Repositories list `#1209`
- `vrc-get repo import <list file>` and `vrc-get repo export`
- User Package Management `#1222`
- This release adds `vrc-get user-package` subcommands to manage user packages.
- `vrc-get reinstall <package id>` to reinstall specified packages `#1223`
### Changed
- Improved saving interacting with setting files `#2485` `#2710`
- This should reduce "EOF while parsing a value at line 1 column 0" error on launch.
- This should reduce losing settings after crashing ALCOM or PC.
- null as vpmDependencies value is not allowed `#2709`
- It's not recommended, but we allow null for `vpmDependencies` as a alias of `{}`
- Improved robustness for package installation errors `#2844`
- It is now unlikely that vrc-get will leave the project directory corrupted if an I/O error occurs while installing a package
- Backslashes in path in zip file are now treated as path separator on unix `#2845`
- This fixes problem with Gesture Manager 3.9.7
- Error message will be shown if the SHA256 hash of the downloaded zip file does not match with the hash in the repository `#1183`
- Currently, official VCC does not verify the hash of the downloaded zip file, but it's better to verify the hash.
- For compatibility, even if the hash does not match, the file will be extracted with an error message.
- In the future, we may make this a hard error.
### Deprecated
### Removed
### Fixed
- Fix 'Detected Loop' panic with valid database file `#2607`
- Panic when resolving projects where dependency packages depend on newer versions of locked packages `#2822`
- Warning for backup/project path in AppData folder not shown when path is in Roaming or LocalLow [`#2827`](https://github.com/vrc-get/vrc-get/pull/2827)
- Unclear error message for invalid version name or version range `#2842`
- Empty string for `documentationUrl` and `changelogUrl` are now allowed and ignored `#2930`
- They are formerly rejected as invalid url
### Security
- Package hash checks are now enforced when installing packages `#2849`
- It has been about two years since the error message for package hash mismatches was introduced.
- It is now enforced for security.
## [1.9.1] - 2025-07-28
### Changed
- Changed how we read VCC's project information [`#1997`](https://github.com/vrc-get/vrc-get/pull/1997)
- Along with this, building this project no longer needs dotnet SDK to build.
- Migrated the project to Rust 2024 [`#1956`](https://github.com/vrc-get/vrc-get/pull/1956)
- This is internal changes should not cause behavior changes
- This would require Rust 1.85 for building this project
- Removed `cargo-about` from build-time dependency [`#1961`](https://github.com/vrc-get/vrc-get/pull/1961)
- This is internal changes should not cause behavior changes
- I listed here since this may need update on package metadata of some package managers
- The method to retrieve the list of Unity from Unity Hub [`#1808`](https://github.com/vrc-get/vrc-get/pull/1808) [`#1971`](https://github.com/vrc-get/vrc-get/pull/1971)
- You now can select multiple folders at once to adding project [`#2018`](https://github.com/vrc-get/vrc-get/pull/2018)
- I didn't know official VCC had such a feature. Sorry for lack of feature!
- The requirements for unity project [`#2106`](https://github.com/vrc-get/vrc-get/pull/2106)
- Since this version, `Projectsettings/ProjectVersion.txt` is required.
### Fixed
- Uninstall package is not reverted successfully if removing package is prevented by `ERROR_SHARING_VIOLATION` [`#2209`](https://github.com/vrc-get/vrc-get/pull/2209)
- Fixed `a - b` version range is not correctly serialized on the `vpm-manifest.json`
## [1.9.0] - 2025-01-01
### Added
- Per-package `headers` field support [`#718`](https://github.com/vrc-get/vrc-get/pull/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`](https://github.com/vrc-get/vrc-get/pull/1081)
- `vrc-get cache clear`, command to clear package cache [`#1204`](https://github.com/vrc-get/vrc-get/pull/1204)
- Importing / Exporting Repositories list [`#1209`](https://github.com/vrc-get/vrc-get/pull/1209)
- `vrc-get repo import <list file>` and `vrc-get repo export`
- User Package Management [`#1222`](https://github.com/vrc-get/vrc-get/pull/1222)
- This release adds `vrc-get user-package` subcommands to manage user packages.
- `vrc-get reinstall <package id>` to reinstall specified packages [`#1223`](https://github.com/vrc-get/vrc-get/pull/1223)
- Skipping finding legacy assets when downgrading / upgrading / reinstalling package [`#1581`](https://github.com/vrc-get/vrc-get/pull/1581)
- This will speed up the process of downgrading / upgrading / reinstalling package.
### Changed
- Error message will be shown if the SHA256 hash of the downloaded zip file does not match with the hash in the repository [`#1183`](https://github.com/vrc-get/vrc-get/pull/1183)
- Currently, official VCC does not verify the hash of the downloaded zip file, but it's better to verify the hash.
- For compatibility, even if the hash does not match, the file will be extracted with an error message.
- In the future, we may make this a hard error.
- Migration feature is no longer marked as experimental [`#1559`](https://github.com/vrc-get/vrc-get/pull/1559)
### Fixed
- Unity from Unity Hub will be registered as manually registered Unity [`#1081`](https://github.com/vrc-get/vrc-get/pull/1081)
- Fails to uninstall packages on macOS with filesystem that doesn't support resource fork [`#1402`](https://github.com/vrc-get/vrc-get/pull/1402)
- Unity from Unity Hub will be registered as manually registered Unity `#1081`
- Fails to uninstall packages on macOS with filesystem that doesn't support resource fork `#1402`
- This is typically seen on ExFAT or FAT32 filesystems, not on APFS or HFS+ filesystems.
- macOS internally creates files starting with `._` for resource fork if the filesystem does not support resource fork.
- vrc-get-vpm does not handle this file correctly and fails to uninstall the package.
- Prerelease version is choosen even if good stable version exists [`#1745`](https://github.com/vrc-get/vrc-get/pull/1745)
## [1.8.2] - 2024-10-16
### Fixed
- Hotfix: Added contact information about author of the project to the User-Agent
### Security
## [1.8.1] - 2024-05-13
### Changed
@ -506,10 +453,7 @@ The format is based on [Keep a Changelog].
## [0.1.0] - 2023-01-25
Initial Release
[Unreleased]: https://github.com/vrc-get/vrc-get/compare/v1.9.1...HEAD
[1.9.1]: https://github.com/vrc-get/vrc-get/compare/v1.9.0...v1.9.1
[1.9.0]: https://github.com/vrc-get/vrc-get/compare/v1.8.2...v1.9.0
[1.8.2]: https://github.com/vrc-get/vrc-get/compare/v1.8.1...v1.8.2
[Unreleased]: https://github.com/vrc-get/vrc-get/compare/v1.8.1...HEAD
[1.8.1]: https://github.com/vrc-get/vrc-get/compare/v1.8.0...v1.8.1
[1.8.0]: https://github.com/vrc-get/vrc-get/compare/v1.7.1...v1.8.0
[1.7.1]: https://github.com/anatawa12/vrc-get/compare/v1.7.0...v1.7.1

View file

@ -6,6 +6,7 @@ This project consists of multiple projects.
Each project may have its own contribution guidelines, so please read the `CONTRIBUTING.md` file in the project folder.
- [vrc-get CLI](vrc-get/CONTRIBUTING.md) (not available yet)
- [vrc-get LiteDB](vrc-get-litedb/CONTRIBUTING.md)
- [vrc-get GUI](vrc-get-gui/CONTRIBUTING.md)
- [vrc-get VPM](vrc-get-vpm/CONTRIBUTING.md) (not available yet)
@ -22,7 +23,11 @@ so it is recommended to install with rustup and update it regularly.
Not only Rust, some projects may require additional dependencies.
For ALCOM, you need to install Node.js >=20 supported and npm for building the frontend.
For VCC-related features of vrc-get, and ALCOM, you need to install .NET SDK to work with.
Please refer to the [.NET installation guide](https://dotnet.microsoft.com/download) to install .NET SDK if you don't have it.
For ALCOM, you need to install any LTS version of Node.js and npm for building the frontend.
Please refer to the [Node.js installation guide](https://nodejs.org/en/download/) to install Node.js if you don't have it.
@ -40,3 +45,7 @@ You can work on any OS system, but this repository generally uses Symbolic Links
For Windows machines, you may need to set up so your current user can create symbolic links.
Please refer to git-for-windows documentation page <https://github.com/git-for-windows/git/wiki/Symbolic-Links>
In addition, when you work with `vrc-get-litedb` project,
you need to clone the repository with `--recurse-submodules` option
(or setup submodules manually with `git submodule update --init --recursive` after cloning).

4779
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -2,10 +2,11 @@
resolver = "2"
members = [
"xtask",
"build-check-static-link",
"build-updater-json",
"vrc-get",
"vrc-get-gui",
"vrc-get-gui/windows-installer-wrapper",
"vrc-get-litedb",
"vrc-get-vpm",
]
@ -13,21 +14,15 @@ members = [
# "build-check-static-link" is excluded since "build-check-static-link" is not a part of the main project, a utility for project
default-members = [
"vrc-get",
"vrc-get-litedb",
"vrc-get-vpm",
]
[workspace.package]
edition = "2024"
edition = "2021"
license = "MIT"
authors = ["anatawa12 <anatawa12@icloud.com>"]
homepage = "https://github.com/anatawa12/vrc-get#readme"
repository = "https://github.com/anatawa12/vrc-get"
readme = "README.md"
[profile.xtask]
inherits = "dev"
incremental = false
debug = 1
opt-level = 0
lto = "off"

View file

@ -9,7 +9,6 @@
[![MacPorts Version][shields-macports-vrc-get]][macports-vrc-get]
[![Scoop Version][shields-scoop-version]][scoop-vrc-get]
[![AUR Version][shields-aur-version]][aur-vrc-get]
[![WinGet Version][shields-winget-version]][winget-vrc-get]
Open Source command line client of VRChat Package Manager,
the main feature of VRChat Creator Companion (VCC), which supports Windows, Linux, and macOS.
@ -142,7 +141,6 @@ See [README of ALCOM][alcom] for more details.
[shields-homebrew-version]: https://img.shields.io/homebrew/v/vrc-get
[shields-macports-vrc-get]: https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fports.macports.org%2Fapi%2Fv1%2Fports%2Fvrc-get%2F&query=%24.version&label=macports
[shields-scoop-version]: https://img.shields.io/scoop/v/vrc-get?bucket=https%3A%2F%2Fgithub.com%2Fbabo4d%2Fscoop-xrtools
[shields-winget-version]: https://img.shields.io/winget/v/anatawa12.vrc-get
<!-- TODO: macports: https://github.com/badges/shields/issues/9588 -->
@ -164,7 +162,6 @@ See [README of ALCOM][alcom] for more details.
[homebrew-vrc-get]: https://formulae.brew.sh/formula/vrc-get
[macports-vrc-get]: https://ports.macports.org/port/vrc-get
[scoop-vrc-get]: https://github.com/babo4d/scoop-xrtools/blob/master/bucket/vrc-get.json
[winget-vrc-get]: https://github.com/microsoft/winget-pkgs/tree/master/manifests/a/anatawa12/vrc-get
## Contribution

View file

@ -0,0 +1 @@
/*

View file

@ -0,0 +1,23 @@
[package]
name = "build-check-static-link"
version = "0.1.0"
edition.workspace = true
license.workspace = true
authors.workspace = true
homepage.workspace = true
repository.workspace = true
readme.workspace = true
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
[dependencies.object]
version = "0.36"
default-features = false
features = [
"read_core",
"macho",
"pe",
"elf",
]

View file

@ -0,0 +1,11 @@
This crate is to ensure built binary is statically linked or dynamically linked to the system libraries.
this crate use `object` crate to read the binary file.
## Usage
```
cargo run -p build-check-static-link <path/to/binary>
```
exits with zero if statically linked or linked with allowed dynamic libraries, otherwise exits with non-zero.

View file

@ -0,0 +1,136 @@
use object::{Endian, Endianness, FileKind, Object};
use std::fs;
use std::process::exit;
fn main() {
let mut args = std::env::args();
let _ = args.next();
let mut success = true;
for arg in args {
if arg.ends_with(".d") {
println!("skipping .d file: {}", arg);
continue;
}
let binary = std::path::Path::new(&arg);
let binary = fs::read(binary).unwrap();
success |= match FileKind::parse(binary.as_slice()).expect("detecting type") {
FileKind::MachO64 => process_mach_64::<Endianness>(&binary),
FileKind::Pe64 => process_pe_64(&binary),
FileKind::Elf64 => process_elf_64::<Endianness>(&binary),
unknown => panic!("unknown file type: {:?}", unknown),
};
}
if success {
exit(0)
} else {
exit(1)
}
}
fn process_mach_64<E: Endian>(binary: &[u8]) -> bool {
use object::macho::*;
use object::read::macho::*;
let mut success = true;
let parsed = MachHeader64::<E>::parse(binary, 0).expect("failed to parse binary");
let endian = parsed.endian().unwrap();
let mut commands = parsed
.load_commands(endian, binary, 0)
.expect("parsing binary");
while let Some(command) = commands.next().expect("reading binary") {
if let Some(dylib) = command.dylib().unwrap() {
let dylib = command.string(endian, dylib.dylib.name).unwrap();
match dylib {
| b"/System/Library/Frameworks/Security.framework/Versions/A/Security"
| b"/System/Library/Frameworks/SystemConfiguration.framework/Versions/A/SystemConfiguration"
| b"/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation"
| b"/System/Library/Frameworks/Foundation.framework/Versions/C/Foundation"
| b"/usr/lib/libobjc.A.dylib"
| b"/usr/lib/libiconv.2.dylib"
| b"/usr/lib/libSystem.B.dylib"
=> {
// known system library
println!("system dylib: {}", std::str::from_utf8(dylib).unwrap());
}
unknown => {
println!("ERROR: unknown dylib: {:?}", std::str::from_utf8(unknown).unwrap_or("unable to parse with utf8"));
success = false;
},
}
} else if command.cmd() == LC_LOAD_DYLINKER {
let data: &DylinkerCommand<E> = command.data().expect("parse LC_LOAD_DYLINKER");
if command.string(endian, data.name).unwrap() != b"/usr/lib/dyld" {
println!("ERROR: dylinker is not /usr/lib/dyld");
success = false;
} else {
println!("dylinker: /usr/lib/dyld");
}
}
}
success
}
fn process_pe_64(binary: &[u8]) -> bool {
use object::read::pe::*;
use object::LittleEndian as LE;
let mut success = true;
let parsed = PeFile64::parse(binary).expect("failed to parse binary");
let table = parsed.import_table().unwrap().unwrap();
let mut iter = table.descriptors().unwrap();
while let Some(x) = iter.next().unwrap() {
let dll = table.name(x.name.get(LE)).unwrap();
match dll.to_ascii_lowercase().as_slice() {
| b"advapi32.dll"
| b"kernel32.dll"
| b"bcrypt.dll" // TODO: check if this is a system library
| b"ntdll.dll"
| b"shell32.dll"
| b"ole32.dll"
| b"ws2_32.dll"
| b"crypt32.dll"
=> {
println!("system dll: {}", std::str::from_utf8(dll).unwrap());
// known system library
}
unknown => {
println!("ERROR: unknown dll: {:?}", std::str::from_utf8(unknown).unwrap_or("unable to parse with utf8"));
success = false;
},
}
}
success
}
fn process_elf_64<E: Endian>(binary: &[u8]) -> bool {
use object::elf::*;
use object::read::elf::*;
let mut success = true;
let parsed = ElfFile64::<E>::parse(binary).expect("failed to parse binary");
for x in parsed.imports().unwrap() {
println!(
"dynamic importing symbol: {}",
std::str::from_utf8(x.name()).unwrap()
);
success = false;
}
for segment in parsed.elf_program_headers() {
if segment.p_type.get(parsed.endian()) == PT_INTERP {
let data = segment.data(parsed.endian(), parsed.data()).unwrap();
println!("interpreter: {:?}", std::str::from_utf8(data).unwrap());
success = false;
}
}
success
}

View file

@ -0,0 +1 @@
/*

View file

@ -0,0 +1,17 @@
[package]
name = "build-updater-json"
version = "0.1.0"
edition.workspace = true
license.workspace = true
authors.workspace = true
homepage.workspace = true
repository.workspace = true
readme.workspace = true
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
chrono = { version = "0.4", default-features = false, features = ["now", "serde"] }
indexmap = { version = "2", features = ["serde"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

View file

@ -0,0 +1,90 @@
// see https://tauri.app/v1/guides/distribution/updater/ for json format
use chrono::{Timelike, Utc};
use indexmap::IndexMap;
use serde::Serialize;
use std::path::Path;
#[derive(Serialize)]
struct UpdaterJson {
version: String,
notes: String,
pub_date: chrono::DateTime<Utc>,
platforms: IndexMap<String, Platform>,
}
#[derive(Serialize)]
struct Platform {
signature: String,
url: String,
}
fn main() {
// consts
const DOWNLOAD_URL_BASE: &str =
"https://github.com/vrc-get/vrc-get/releases/download/gui-v{version}";
let platform_file_name = [
("darwin-x86_64", "ALCOM-{version}-universal.app.tar.gz"),
("darwin-aarch64", "ALCOM-{version}-universal.app.tar.gz"),
("linux-x86_64", "alcom-{version}-x86_64.AppImage.tar.gz"),
//("linux-aarch64", "alcom-{version}-aarch64.AppImage.tar.gz"),
("windows-x86_64", "ALCOM-{version}-x86_64-setup.nsis.zip"),
//("windows-aarch64", "ALCOM-{version}-aarch64-setup.nsis.zip"),
]
.into_iter()
.collect::<IndexMap<_, _>>();
let version = std::env::var("GUI_VERSION").expect("GUI_VERSION not set");
let base_url = DOWNLOAD_URL_BASE.replace("{version}", &version);
// create platforms info
let mut platforms = IndexMap::new();
for (platform, file_name) in platform_file_name {
let file_name = file_name.replace("{version}", &version);
std::fs::metadata(format!("assets/{file_name}"))
.unwrap_or_else(|e| panic!("{}: {}", file_name, e));
let signature = std::fs::read_to_string(format!("assets/{file_name}.sig"))
.unwrap_or_else(|e| panic!("{}.sig: {}", file_name, e));
let url = format!("{}/{}", base_url, file_name);
platforms.insert(platform.to_string(), Platform { signature, url });
}
let stable_notes = get_notes("vrc-get-gui/notes.txt".as_ref());
let beta_notes = get_notes("vrc-get-gui/notes-beta.txt".as_ref());
let is_beta = version.contains('-');
let mut updater = UpdaterJson {
version,
notes: String::new(),
pub_date: Utc::now().with_nanosecond(0).unwrap(),
platforms,
};
if !is_beta {
updater.notes = stable_notes;
write_json("updater.json", &updater);
}
updater.notes = beta_notes;
write_json("updater-beta.json", &updater);
}
fn write_json(path: impl AsRef<Path>, json: impl Serialize) {
let json = serde_json::to_string_pretty(&json).unwrap();
std::fs::write(path, json).expect("write updater.json");
}
fn get_notes(path: &Path) -> String {
let notes = std::fs::read_to_string(path).expect("read notes.txt");
// lines starts with # are comments
notes
.trim_end()
.lines()
.filter(|x| !x.starts_with('#'))
.map(|x| x.trim_end())
.collect::<Vec<_>>()
.join("\n")
}

View file

@ -1,45 +0,0 @@
# Repository List file format
This document describes the format of the repository list file used in ALCOM / vrc-get.
## File format
1. The file is a UTF-8 encoded text file.
2. For each line, text after '#' will be ignored as a comment.
3. Each line contains a repository URL, or empty.
4. Each line is trimmed before processing. (this mean any line only with spaces will be ignored)
5. Each repository line should only contain a valid URL.
6. The URL schema must be `http`, `https`, or `vcc`.\
Other schemas are ignored and might be recognized for other purposes in the future.
7. If the URL is a `http` or `https` URL, the URL represents a VPM repository without headers.
8. If the URL is a `vcc` URL, the URL should be a VCC URL to add VPM repository, which is described below.\
This notation is used to express the repository with headers.
## VCC URL format
The VCC URL to add VPM Repository will be a valid URL with the following format:
- schema must be `vcc`
- the host part must be `vpm`
- the path part must be `/addRepo`
- the query part must contain single `url` parameter which represents the repository URL to add.
- the query part may contain `headers[]` parameter with represents the HTTP headers for the repository.
- query value will be split by `:` and prior part will be the header name and the rest will be the header value.
## Examples
```text
# This is a comment
http://example.com/repo
https://example.com/repo
vcc://vpm/addRepo?url=http://example.com/repo&headers[]=header-name:header-value
```
This file represents a repository list with the following repositories:
- `http://example.com/repo`
- `https://example.com/repo`
- `http://example.com/repo` with a custom header `header-name:header-value`
Another example may be found at the `repositories.txt` at the root of this repository.

View file

@ -4,5 +4,11 @@
.pnp.js
/gen
# next.js
/.next/
/out/
/build/
# typescript
*.tsbuildinfo
next-env.d.ts

View file

@ -1 +0,0 @@
ignore-scripts=true

View file

@ -25,13 +25,12 @@
/*.ts
/*.tsx
/*.json
/index.html
# documentation
/*.md
# template
/project-templates/**
/templates/**
# icons
/app-icon.afdesign

View file

@ -1,7 +1,7 @@
[package]
name = "vrc-get-gui"
version = "1.1.7-beta.0"
description = "A fast open-source alternative of VRChat Creator Companion"
version = "0.1.15-beta.0"
description = "A Tauri App"
homepage.workspace = true
authors.workspace = true
@ -16,25 +16,23 @@ path = "src/main.rs"
[build-dependencies]
flate2 = "1"
tar = "0.4"
tauri-build = { version = "2", features = [ "config-toml" ] }
tauri-build = { version = "2.0.0-rc.3", features = [ "config-toml" ] }
[dependencies]
serde_json = "1"
serde = { version = "1", features = ["derive"] }
serde_with = { version = "3", features = ["base64"] }
tauri = { version = "=2.11.2", features = [ "config-toml" ] } # = for sync version between npm and cargo
vrc-get-vpm = { path = "../vrc-get-vpm", features = ["experimental-project-management", "experimental-unity-management"] }
reqwest = { version = "0.13", features = ["gzip", "brotli", "json"] }
specta = { version = "2.0.0-rc.24", features = [ "chrono", "url", "indexmap" ] }
tauri-specta = { version = "2.0.0-rc.24", features = ["typescript"] }
specta-typescript = "0.0.11"
tauri = { version = "2.0.0-rc.3", features = [ "config-toml" ] }
vrc-get-vpm = { path = "../vrc-get-vpm", features = ["experimental-project-management", "experimental-unity-management", "tokio"] }
reqwest = "0.12"
specta = { version = "2.0.0-rc.20", features = [ "chrono", "url", "indexmap" ] }
tauri-specta = { version = "2.0.0-rc.17", features = ["typescript"] }
specta-typescript = "0.0.7"
open = "5"
arc-swap = "1"
log = { version = "0.4", features = [ "std", "kv" ] }
chrono = { version = "0.4", features = [ "serde" ] }
ringbuffer = "0.16"
ringbuffer = "0.15"
tokio = { version = "1", features = ["process"] }
tokio-util = "0.7"
fs_extra = "1"
indexmap = "2"
futures = "0.3"
@ -42,43 +40,28 @@ tar = "0.4"
flate2 = "1"
uuid = { version = "1", features = ["v4"] }
trash = "5"
async_zip = { version = "0.0.18", features = ["tokio", "deflate"] }
async-compression = { version = "0.4", features = ["gzip", "futures-io"] }
async_zip = { version = "0.0.17", features = ["deflate", "tokio"] }
async-stream = "0.3"
tauri-plugin-single-instance = "2"
tauri-plugin-dialog = "2"
minisign-verify = "0.2"
base64 = "0.22"
semver = "1"
tempfile = "3"
sha2 = "0.11"
hex = "0.4"
tauri-plugin-single-instance = "2.0.0-rc.0"
tauri-plugin-updater = "2.0.0-rc.1"
tauri-plugin-dialog = "2.0.0-rc.1"
sys-locale = "0.3"
log-panics = { version = "2", features = ["with-backtrace"] }
url = "2"
dirs-next = "2"
yoke = { version = "0.8", features = ["derive"] }
yoke = { version = "0.7", features = ["derive"] }
atomicbox = "0.4"
stable_deref_trait = "1"
itertools = "0.14"
sysinfo = "0.39.3"
[target.'cfg(windows)'.dependencies]
windows = { version = "0.62", features = ["Win32_Storage_FileSystem", "Win32_System_IO", "Win32_NetworkManagement_IpHelper", "Wdk_System_SystemServices", "Win32_System_SystemInformation", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] }
winreg = "0.56"
wmi = "0.18"
windows = { version = "0.58", features = ["Win32_Storage_FileSystem", "Win32_System_IO", "Win32_NetworkManagement_IpHelper", "Wdk_System_SystemServices", "Win32_System_SystemInformation"] }
winreg = "0.52"
[target.'cfg(target_os = "macos")'.dependencies]
plist = { version = "1" }
objc2-app-kit = { version = "0.3.0", features = ['NSWorkspace', "block2"] }
objc2-foundation = "0.3.0"
block2 = "0.6.0"
objc2 = "0.6.0"
dispatch2 = "0.3.0"
rlimit = "0.11.0"
cocoa = "0.26"
[target.'cfg(unix)'.dependencies]
nix = { version = "0.31", features = ["fs", "mount"] }
nix = { version = "0.29", features = ["fs"] }
[features]
# this feature is used for production builds or when `devPath` points to the filesystem and the built-in dev server is disabled.
@ -86,11 +69,7 @@ nix = { version = "0.31", features = ["fs", "mount"] }
# DO NOT REMOVE!!
custom-protocol = [ "tauri/custom-protocol" ]
no-self-updater = []
# Devtools
#
# Enables browser devtools for debugging javascript part.
# It's not recommended to enable this for production builds,
# development use only!
devtools = ["tauri/devtools"]
# rustc-check-cfg is not supported by tauri 1.x yet so we need to ignore it
# https://github.com/tauri-apps/tauri/pull/10392
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(dev)'] }

View file

@ -15,48 +15,5 @@
</array>
</dict>
</array>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>ALCOM Project Template</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSItemContentTypes</key>
<array>
<string>com.anatawa12.vrc-get.alcomtemplate</string>
</array>
</dict>
</array>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>com.anatawa12.vrc-get.alcomtemplate</string>
<key>UTTypeIcons</key>
<dict>
<key>UTTypeIconText</key>
<string>Template</string>
<key>UTTypeIconBackgroundName</key>
<string></string>
<key>UTTypeIconBadgeName</key>
<string>icon.icns</string>
</dict>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
<string>public.content</string>
</array>
<key>UTTypeDescription</key>
<string>ALCOM Project Template</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>alcomtemplate</string>
</array>
</dict>
</dict>
</array>
</dict>
</plist>

View file

@ -1,114 +1,46 @@
# ALCOM
# ALCOM (experimental)
[![Github Release][shields-github-version]][release-alcom]
[![Homebrew Version][shields-homebrew-version]][homebrew-alcom]
[![Scoop Version][shields-scoop-version]][scoop-alcom]
[![AUR Version][shields-aur-version]][aur-alcom]
[![WinGet Version][shields-winget-version]][winget-alcom]
<!-- [![MacPorts Version][shields-macports-vrc-get]][macports-vrc-get] -->
This folder contains the experimental GUI version of vrc-get, ALCOM.
[shields-github-version]: https://img.shields.io/github/v/release/vrc-get/vrc-get?filter=gui-v*
[shields-homebrew-version]: https://img.shields.io/homebrew/cask/v/alcom
[shields-scoop-version]: https://img.shields.io/scoop/v/vrc-alcom?bucket=https%3A%2F%2Fgithub.com%2Fbabo4d%2Fscoop-xrtools
[shields-aur-version]: https://img.shields.io/aur/version/alcom
[shields-winget-version]: https://img.shields.io/winget/v/anatawa12.ALCOM
<!-- [shields-macports-vrc-get]: https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fports.macports.org%2Fapi%2Fv1%2Fports%2Falcom%2F&query=%24.version&label=macports -->
<!-- TODO: macports: https://github.com/badges/shields/issues/9588 -->
[release-alcom]: https://github.com/vrc-get/vrc-get/releases?q=gui-v1
[homebrew-alcom]: https://formulae.brew.sh/cask/alcom
[scoop-alcom]: https://github.com/babo4d/scoop-xrtools/blob/master/bucket/vrc-alcom.json
[aur-alcom]: https://aur.archlinux.org/packages/alcom
[winget-alcom]: https://github.com/microsoft/winget-pkgs/tree/master/manifests/a/anatawa12/ALCOM
<!-- [macports-vrc-get]: https://ports.macports.org/port/alcom -->
[scoop-xrtools]: https://github.com/babo4d/scoop-xrtools/
A crossplatform fast open-source alternative of VRChat Creator Companion
[Homepage](https://vrc-get.anatawa12.com/alcom/)
[Homepage (Help Wanted)](https://vrc-get.anatawa12.com/alcom/)
## Installation
The recommended way to install ALCOM is download from [Website][alcom-site].
Or you can install ALCOM from package managers like [Homebrew][homebrew-alcom], [Scoop][scoop-xrtools], [AUR][aur-alcom], or [WinGet][winget-alcom].
The recommended way to install ALCOM is download from [GitHub Releases][alcom-releases].
If you want, you may download the HEAD build from [GitHub Actions][alcom-nightly]
[alcom-site]: https://vrc-get.anatawa12.com/alcom/
[alcom-releases]: https://github.com/anatawa12/vrc-get/releases?q=gui-v0
[alcom-nightly]: https://github.com/vrc-get/vrc-get/actions/workflows/ci-gui.yml?query=branch%3Amaster
## Supported Platforms
ALCOM runs on macOS, Windows, and Linux.
We support modern versions of the platforms.
Basically, we support the versions that the platform vendor supports.
This support policy is to describe how my limited development resources use so it's welcome
to pull requests that ports ALCOM to an older version of OSes.
However, I won't maintain the backports so may break at any moment, I'll try to not break as possible though.
Here are detailed version support policies for each platform:
Version numbers shown here are as of the writing (Dec 2024), so might be outdated.
- macOS: We support the latest version of macOS that is available for non-vintage and non-obsoleted Macs.\
This means currently we support macOS 13 (Ventura) or later.
On macOS, we use WKWebView, which is built-in to macOS, so no additional requirements are needed.
- Windows: We support the latest version of Windows that is supported as mainstream by Microsoft.\
This means currently we support Windows 10 21H2 or later and Windows 11 23H2 or later.
On windows, we use WebView2 so WebView2 should also be updated to supported versions.
Currently, WebView2 with Edge 130 or later is supported.
- Linux: Linux is not well-supported, Linux support is best-effort by the community.\
No maintainer is using Linux as a primary platform, so we can't guarantee the quality of the Linux version.\
No specific version is guaranteed to work, but we will try to fix issues with your help.\
Basically, modern webkit2gtk 4.1 is required to run ALCOM since we use modern web features.
## Requirements (building)
To build ALCOM, you need to have the following installed:
- [Node.js] >=20 supported — to build the web part of the project
- [Node.js] LTS — to build the web part of the project
- [npm] v10 — to install the dependencies of the web part (bundled with node.js so no extra attention needed in most case)
- [cargo] latest — to build the most part of the project
- And other requirements for tauri, see [tauri requirements](https://v2.tauri.app/start/prerequisites/#system-dependencies)
- [cargo-about] latest — to generate the licenses json (for development, not required but required for building release binary)
- [.NET SDK] v8 — to build vrc-get-litedb crate
Please note that ALCOM requires the latest version of rust toolchain at that time.
We update the required version of cargo without notice.
Therefore, It's recommended to update rust toolchain before building the project.
Please note that ALCOM requires the latest version of cargo and cargo-about at that time.
We update the required version of cargo and cargo-about without notice.
Therefore, you may need to update them before building the project.
[Node.js]: https://nodejs.org/en
[npm]: https://www.npmjs.com
[cargo]: https://doc.rust-lang.org/cargo/
[cargo-about]: https://github.com/EmbarkStudios/cargo-about
[.NET SDK]: https://dotnet.microsoft.com/download
## Building
To build the project, run the following command:
```bash
cargo xtask build-alcom --release
npm run tauri build
```
This command builds the main ALCOM executable for the current platform.
For cross-compilation, add the `--target` command-line parameter.
The executable will be created in the `target/release` directory.
There are a few build options available when building ALCOM.
Most notably, you can disable the self-updater with the `--no-self-updater` option.
Note that this does not disable update checks.
ALCOM will show a message when a newer release is available instead of offering a self-update.
Directly distributing the executable may be suitable for some environments, but we also provide bundled distributions.
To bundle ALCOM, run the following command after building it.
```bash
cargo xtask bundle-alcom --release --bundles <bundles>
```
Check `--help` for the list of supported bundle types.
## Development
ALCOM is currently based on tauri and next.js.
@ -118,7 +50,3 @@ Run `npm run tauri dev` to start the development server and gui.
## Contribution
For how to contribute localization to ALCOM (vrc-get-gui): [CONTRIBUTING.md](CONTRIBUTING.md) (**Please read [../CONTRIBUTING.md#configuration-requirements](../CONTRIBUTING.md#configuration-requirements) first before you read [CONTRIBUTING.md](CONTRIBUTING.md)!**)
## License
ALCOM is licensed under the MIT License. See [LICENSE](../LICENSE) for more information.

View file

@ -6,3 +6,52 @@ beforeBuildCommand = "npm run build"
beforeDevCommand = "npm run dev"
devUrl = "http://localhost:3030"
frontendDist = "out"
[bundle]
active = true
targets = [
"appimage",
"nsis", #-setup.exe
"app", # needs for dmg
"dmg",
]
longDescription = "ALCOM is a fast and open-source alternative VCC (VRChat Creator Companion) written in rust and tauri."
shortDescription = "ALCOM - Alternative Creator Companion"
category = "DeveloperTool"
copyright = "(c) anatawa12 and other contributors"
externalBin = []
icon = [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico",
]
resources = []
publisher = "anatawa12"
createUpdaterArtifacts = "v1Compatible" # remove if ci # we do not generate updater artifacts in CI
[bundle.linux.deb]
depends = []
[bundle.macOS]
exceptionDomain = ""
frameworks = []
providerShortName = "anatawa12"
[bundle.windows]
nsis.template = "installer.nsi"
# signing
certificateThumbprint = "0D17F6395EC64A2B1D341BB7AC5B3163EB148BB7"
timestampUrl = "http://ts.ssl.com"
digestAlgorithm = "sha256"
tsp = true
[plugins.updater]
endpoints = []
pubkey = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDkyMjAzMkU2Q0ZGQjQ0MjYKUldRbVJQdlA1aklna2d2NnRoM3ZsT3lzWEQ3MC9zTGpaWVR4NGdQOXR0UGJaOHBlY2xCcFY5bHcK"
[app.security]

View file

@ -0,0 +1,16 @@
import { SideBar } from "@/components/SideBar";
export default function MainLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<>
<SideBar className={"flex-grow-0"} />
<div className={"h-screen flex-grow overflow-hidden flex p-4"}>
{children}
</div>
</>
);
}

View file

@ -0,0 +1,276 @@
import { ScrollableCardTable } from "@/components/ScrollableCardTable";
import { SearchBox } from "@/components/SearchBox";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { type LogEntry, type LogLevel, commands } from "@/lib/bindings";
import { isFindKey, useDocumentEvent } from "@/lib/events";
import globalInfo from "@/lib/global-info";
import { tc } from "@/lib/i18n";
import { BugOff, CircleX, Info, OctagonAlert } from "lucide-react";
import { memo, useMemo, useRef, useState } from "react";
export const LogListCard = memo(function LogListCard({
logEntry,
}: {
logEntry: LogEntry[];
}) {
const [search, setSearch] = useState("");
const [shouldShowLogLevel, setShouldShowLogLevel] = useState<LogLevel[]>([
"Info",
"Warn",
"Error",
]);
const logsShown = useMemo(
() =>
logEntry.filter(
(log) =>
log.message.toLowerCase().includes(search?.toLowerCase() ?? "") &&
shouldShowLogLevel.includes(log.level),
),
[logEntry, search, shouldShowLogLevel],
);
const TABLE_HEAD = ["logs:time", "logs:level", "logs:message"];
return (
<Card className="flex-grow flex-shrink flex shadow-none w-full">
<CardContent className="w-full p-2 flex flex-col gap-2">
<ManageLogsHeading
search={search}
setSearch={setSearch}
shouldShowLogLevel={shouldShowLogLevel}
setShouldShowLogLevel={setShouldShowLogLevel}
/>
<ScrollableCardTable>
<thead>
<tr>
{TABLE_HEAD.map((head, index) => (
<th
// biome-ignore lint/suspicious/noArrayIndexKey: static array
key={index}
className={
"sticky top-0 z-10 border-b border-primary bg-secondary text-secondary-foreground p-2.5"
}
>
<small className="font-normal leading-none">{tc(head)}</small>
</th>
))}
<th
className={
"sticky top-0 z-10 border-b border-primary bg-secondary text-secondary-foreground p-2.5"
}
/>
</tr>
</thead>
<tbody>
{logsShown.map((row) => (
<tr key={row.time} className="even:bg-secondary/30">
<LogRow log={row} />
</tr>
))}
</tbody>
</ScrollableCardTable>
</CardContent>
</Card>
);
});
function LogLevelMenuItem({
logLevel,
className,
shouldShowLogLevel,
setShouldShowLogLevel,
}: {
logLevel: LogLevel;
className?: string;
shouldShowLogLevel: LogLevel[];
setShouldShowLogLevel: React.Dispatch<React.SetStateAction<LogLevel[]>>;
}) {
const selected = shouldShowLogLevel.includes(logLevel);
const onChange = () => {
if (selected) {
setShouldShowLogLevel((prev) =>
prev.filter((logLevelFilter) => logLevelFilter !== logLevel),
);
} else {
setShouldShowLogLevel((prev) => [...prev, logLevel]);
}
};
return (
<DropdownMenuItem
className="p-0"
onSelect={(e) => {
e.preventDefault();
}}
>
<label
className={
"flex cursor-pointer items-center gap-2 p-2 whitespace-normal"
}
>
<Checkbox
checked={selected}
onCheckedChange={onChange}
className="hover:before:content-none"
/>
<p className={className}>{logLevel}</p>
</label>
</DropdownMenuItem>
);
}
function ManageLogsHeading({
search,
setSearch,
shouldShowLogLevel,
setShouldShowLogLevel,
}: {
search: string;
setSearch: (value: string) => void;
shouldShowLogLevel: LogLevel[];
setShouldShowLogLevel: React.Dispatch<React.SetStateAction<LogLevel[]>>;
}) {
const searchRef = useRef<HTMLInputElement>(null);
useDocumentEvent(
"keydown",
(e) => {
if (isFindKey(e)) {
searchRef.current?.focus();
}
},
[],
);
return (
<div
className={
"flex flex-wrap flex-shrink-0 flex-grow-0 flex-row gap-2 items-center"
}
>
<SearchBox
className={"w-max flex-grow"}
value={search}
onChange={(e) => setSearch(e.target.value)}
ref={searchRef}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className={"flex-shrink-0 p-3"}>
{tc("logs:manage:select logs level")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<LogLevelMenuItem
logLevel="Info"
shouldShowLogLevel={shouldShowLogLevel}
setShouldShowLogLevel={setShouldShowLogLevel}
/>
<LogLevelMenuItem
logLevel="Warn"
className="text-warning"
shouldShowLogLevel={shouldShowLogLevel}
setShouldShowLogLevel={setShouldShowLogLevel}
/>
<LogLevelMenuItem
logLevel="Error"
className="text-destructive"
shouldShowLogLevel={shouldShowLogLevel}
setShouldShowLogLevel={setShouldShowLogLevel}
/>
<LogLevelMenuItem
logLevel="Debug"
className="text-info"
shouldShowLogLevel={shouldShowLogLevel}
setShouldShowLogLevel={setShouldShowLogLevel}
/>
{/* Currently no trace level logs will be passed to frontend */}
{/*<LogLevelMenuItem
logLevel="Trace"
shouldShowLogLevel={shouldShowLogLevel}
setShouldShowLogLevel={setShouldShowLogLevel}
/>*/}
</DropdownMenuContent>
</DropdownMenu>
<Button
onClick={() =>
commands.utilOpen(
`${globalInfo.vpmHomeFolder}/vrc-get/gui-logs`,
"ErrorIfNotExists",
)
}
>
{tc("settings:button:open logs")}
</Button>
</div>
);
}
const LogRow = memo(function LogRow({
log,
}: {
log: LogEntry;
}) {
const cellClass = "p-2.5";
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleString();
};
const getFontColorClass = (level: LogLevel) => {
switch (level) {
case "Info":
return "";
case "Warn":
return "text-warning";
case "Error":
return "text-destructive";
case "Debug":
return "text-info";
default:
return "";
}
};
const fontColorClass = getFontColorClass(log.level);
const typeIconClass = `${fontColorClass} w-5 h-5`;
return (
<>
<td className={`${cellClass} min-w-32 w-32`}>{formatDate(log.time)}</td>
<td className={`${cellClass} min-w-28 w-28`}>
<div className="flex flex-row gap-2">
<div className="flex items-center">
{log.level === "Info" ? (
<Info className={typeIconClass} />
) : log.level === "Warn" ? (
<OctagonAlert className={typeIconClass} />
) : log.level === "Error" ? (
<CircleX className={typeIconClass} />
) : log.level === "Debug" ? (
<BugOff className={typeIconClass} />
) : (
<Info className={typeIconClass} />
)}
</div>
<div className="flex flex-col justify-center">
<p className={`font-normal ${fontColorClass}`}>{log.level}</p>
</div>
</div>
</td>
<td className={`${cellClass} min-w-32 w-32`}>{log.message}</td>
</>
);
});

View file

@ -0,0 +1,43 @@
"use client";
import { HNavBar, VStack } from "@/components/layout";
import type { LogEntry } from "@/lib/bindings";
import { commands } from "@/lib/bindings";
import { tc } from "@/lib/i18n";
import { useTauriListen } from "@/lib/use-tauri-listen";
import React, { useCallback, useEffect } from "react";
import { LogListCard } from "./log-list-card";
export default function Page() {
const [logEntries, setLogEntries] = React.useState<LogEntry[]>([]);
useEffect(() => {
commands
.utilGetLogEntries()
.then((list) => setLogEntries([...list].reverse()));
}, []);
useTauriListen<LogEntry>(
"log",
useCallback((event) => {
setLogEntries((entries) => {
const entry = event.payload as LogEntry;
return [entry, ...entries];
});
}, []),
);
return (
<VStack>
<HNavBar className={"flex-shrink-0"}>
<p className="cursor-pointer py-1.5 font-bold flex-grow-0">
{tc("logs")}
</p>
<div className={"flex-grow"} />
</HNavBar>
<main className="flex-shrink overflow-hidden flex w-full">
<LogListCard logEntry={logEntries} />
</main>
</VStack>
);
}

View file

@ -0,0 +1,305 @@
"use client";
import { ScrollableCardTable } from "@/components/ScrollableCardTable";
import { HNavBar, VStack } from "@/components/layout";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
DialogDescription,
DialogFooter,
DialogOpen,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { TauriUserRepository } from "@/lib/bindings";
import { commands } from "@/lib/bindings";
import { tc } from "@/lib/i18n";
import { toastThrownError } from "@/lib/toast";
import { useFilePickerFunction } from "@/lib/use-file-picker-dialog";
import { useTauriListen } from "@/lib/use-tauri-listen";
import { useQuery } from "@tanstack/react-query";
import { ChevronDown, CircleX } from "lucide-react";
import type React from "react";
import {
Suspense,
useCallback,
useEffect,
useId,
useMemo,
useState,
} from "react";
import { HeadingPageName } from "../tab-selector";
import { useAddRepository } from "./use-add-repository";
import { useImportRepositories } from "./use-import-repositories";
export default function Page() {
return (
<Suspense>
<PageBody />
</Suspense>
);
}
function PageBody() {
const result = useQuery({
queryKey: ["environmentRepositoriesInfo"],
queryFn: commands.environmentRepositoriesInfo,
});
const addRepositoryInfo = useAddRepository({
refetch: () => result.refetch(),
});
const importRepositoryInfo = useImportRepositories({
refetch: () => result.refetch(),
});
const [exportRepositoriesRaw, exportDialog] = useFilePickerFunction(
commands.environmentExportRepositories,
);
const exportRepositories = useCallback(async () => {
try {
await exportRepositoriesRaw();
} catch (e) {
toastThrownError(e);
}
}, [exportRepositoriesRaw]);
const hiddenUserRepos = useMemo(
() => new Set(result.data?.hidden_user_repositories),
[result],
);
async function removeRepository(id: string) {
try {
await commands.environmentRemoveRepository(id);
await result.refetch();
} catch (e) {
toastThrownError(e);
}
}
const addRepository = addRepositoryInfo.addRepository;
const processDeepLink = useCallback(
async function processDeepLink() {
const data = await commands.deepLinkTakeAddRepository();
if (data == null) return;
await addRepository(data.url, data.headers);
},
[addRepository],
);
useTauriListen<null>(
"deep-link-add-repository",
useCallback(
(_) => {
// noinspection JSIgnoredPromiseFromCall
processDeepLink();
},
[processDeepLink],
),
);
// biome-ignore lint/correctness/useExhaustiveDependencies: we want to do on mount
useEffect(() => {
// noinspection JSIgnoredPromiseFromCall
processDeepLink();
// Only for initial load
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<VStack>
<HNavBar className={"flex-shrink-0"}>
<HeadingPageName pageType={"/packages/repositories"} />
<div className={"flex-grow"} />
<DropdownMenu>
<div className={"flex divide-x"}>
<Button
className={"rounded-r-none"}
onClick={addRepositoryInfo.openAddDialog}
>
{tc("vpm repositories:button:add repository")}
</Button>
<DropdownMenuTrigger asChild className={"rounded-l-none pl-2 pr-2"}>
<Button>
<ChevronDown className={"w-4 h-4"} />
</Button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent>
<DropdownMenuItem
onClick={importRepositoryInfo.startImportingRepositories}
>
{tc("vpm repositories:button:import repositories")}
</DropdownMenuItem>
<DropdownMenuItem onClick={exportRepositories}>
{tc("vpm repositories:button:export repositories")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</HNavBar>
<ScrollableCardTable>
<RepositoryTableBody
userRepos={result.data?.user_repositories || []}
hiddenUserRepos={hiddenUserRepos}
removeRepository={removeRepository}
refetch={() => result.refetch()}
/>
</ScrollableCardTable>
{addRepositoryInfo.dialog}
{importRepositoryInfo.dialog}
{exportDialog}
</VStack>
);
}
function RepositoryTableBody({
userRepos,
hiddenUserRepos,
removeRepository,
refetch,
}: {
userRepos: TauriUserRepository[];
hiddenUserRepos: Set<string>;
removeRepository: (id: string) => void;
refetch: () => void;
}) {
const TABLE_HEAD = [
"", // checkbox
"general:name",
"vpm repositories:url",
"", // actions
];
return (
<>
<thead>
<tr>
{TABLE_HEAD.map((head, index) => (
<th
// biome-ignore lint/suspicious/noArrayIndexKey: static array
key={index}
className={
"sticky top-0 z-10 border-b border-primary bg-secondary text-secondary-foreground p-2.5"
}
>
<small className="font-normal leading-none">{tc(head)}</small>
</th>
))}
</tr>
</thead>
<tbody>
{userRepos.map((repo) => (
<RepositoryRow
key={repo.id}
repo={repo}
hiddenUserRepos={hiddenUserRepos}
remove={() => removeRepository(repo.id)}
refetch={refetch}
/>
))}
</tbody>
</>
);
}
function RepositoryRow({
repo,
hiddenUserRepos,
remove,
refetch,
}: {
repo: TauriUserRepository;
hiddenUserRepos: Set<string>;
remove: () => void;
refetch: () => void;
}) {
const cellClass = "p-2.5";
const id = useId();
const [removeDialogOpen, setRemoveDialogOpen] = useState(false);
const selected = !hiddenUserRepos.has(repo.id);
const onChange = () => {
if (selected) {
commands.environmentHideRepository(repo.id).then(refetch);
} else {
commands.environmentShowRepository(repo.id).then(refetch);
}
};
let dialog: React.ReactNode;
if (removeDialogOpen) {
dialog = (
<DialogOpen>
<DialogTitle>{tc("vpm repositories:remove repository")}</DialogTitle>
<DialogDescription>
<p className={"whitespace-normal font-normal"}>
{tc("vpm repositories:dialog:confirm remove description", {
name: repo.display_name,
})}
</p>
</DialogDescription>
<DialogFooter>
<Button onClick={() => setRemoveDialogOpen(false)}>
{tc("general:button:cancel")}
</Button>
<Button
onClick={() => {
remove();
setRemoveDialogOpen(false);
}}
className={"ml-2"}
>
{tc("vpm repositories:remove repository")}
</Button>
</DialogFooter>
</DialogOpen>
);
}
return (
<tr className="even:bg-secondary/30">
<td className={cellClass}>
<Checkbox id={id} checked={selected} onCheckedChange={onChange} />
</td>
<td className={cellClass}>
<label htmlFor={id}>
<p className="font-normal">{repo.display_name}</p>
</label>
</td>
<td className={cellClass}>
<p className="font-normal">{repo.url}</p>
</td>
<td className={`${cellClass} w-0`}>
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => setRemoveDialogOpen(true)}
variant={"ghost"}
size={"icon"}
>
<CircleX className={"size-5 text-destructive"} />
</Button>
</TooltipTrigger>
<TooltipContent>
{tc("vpm repositories:remove repository")}
</TooltipContent>
</Tooltip>
</td>
{dialog}
</tr>
);
}

View file

@ -1,91 +1,166 @@
import { queryOptions } from "@tanstack/react-query";
import type React from "react";
import { useState } from "react";
import {
ReorderableList,
useReorderableList,
} from "@/components/ReorderableList";
import { Button } from "@/components/ui/button";
import { DialogFooter } from "@/components/ui/dialog";
import {
DialogDescription,
DialogFooter,
DialogOpen,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { assertNever } from "@/lib/assert-never";
import type {
TauriDuplicatedReason,
TauriRemoteRepositoryInfo,
} from "@/lib/bindings";
import type { TauriRemoteRepositoryInfo } from "@/lib/bindings";
import { commands } from "@/lib/bindings";
import { type DialogApi, type DialogContext, showDialog } from "@/lib/dialog";
import { tc, tt } from "@/lib/i18n";
import { queryClient } from "@/lib/query-client";
import { toastError, toastSuccess } from "@/lib/toast";
import { toastError, toastSuccess, toastThrownError } from "@/lib/toast";
import type React from "react";
import { useCallback, useState } from "react";
const environmentRepositoriesInfo = queryOptions({
queryKey: ["environmentRepositoriesInfo"],
queryFn: commands.environmentRepositoriesInfo,
});
type State =
| {
type: "normal";
}
| {
type: "enteringRepositoryInfo";
}
| {
type: "loadingRepository";
}
| {
type: "duplicated";
}
| {
type: "confirming";
repo: TauriRemoteRepositoryInfo;
url: string;
headers: { [key: string]: string };
};
export async function openAddRepositoryDialog() {
using dialog = showDialog();
const repoInfo = await dialog.ask(EnteringRepositoryInfo, {});
if (repoInfo == null) return;
await addRepositoryImpl(dialog, repoInfo.url, repoInfo.headers);
interface AddRepository {
dialog: React.ReactNode;
openAddDialog: () => void;
addRepository: (
url: string,
headers: { [p: string]: string },
) => Promise<void>;
}
export async function addRepository(
url: string,
headers: Record<string, string>,
) {
using dialog = showDialog();
await addRepositoryImpl(dialog, url, headers);
}
async function addRepositoryImpl(
dialog: DialogApi,
url: string,
headers: Record<string, string>,
) {
dialog.replace(<LoadingRepository cancel={dialog.close} />);
const info = await commands.environmentDownloadRepository(url, headers);
switch (info.type) {
case "BadUrl":
toastError(tt("vpm repositories:toast:invalid url"));
return;
case "DownloadError":
toastError(
tt("vpm repositories:toast:load failed", {
message: info.message,
}),
);
return;
case "Duplicated":
await dialog.askClosing(Duplicated, {
reason: info.reason,
duplicatedName: info.duplicated_name,
});
return;
case "Success":
break;
default:
assertNever(info, "info");
export function useAddRepository({
refetch,
}: {
refetch: () => void;
}): AddRepository {
const [state, setState] = useState<State>({ type: "normal" });
function cancel() {
setState({ type: "normal" });
}
if (
await dialog.askClosing(Confirming, {
repo: info.value,
headers: headers,
})
const openAddDialog = useCallback(() => {
setState({ type: "enteringRepositoryInfo" });
}, []);
const addRepository = useCallback(async function addRepository(
url: string,
headers: { [key: string]: string },
) {
await commands.environmentAddRepository(url, headers);
toastSuccess(tt("vpm repositories:toast:repository added"));
await queryClient.invalidateQueries(environmentRepositoriesInfo);
try {
setState({ type: "loadingRepository" });
const info = await commands.environmentDownloadRepository(url, headers);
switch (info.type) {
case "BadUrl":
toastError(tt("vpm repositories:toast:invalid url"));
setState({ type: "normal" });
return;
case "DownloadError":
toastError(
tt("vpm repositories:toast:load failed", { message: info.message }),
);
setState({ type: "normal" });
return;
case "Duplicated":
setState({ type: "duplicated" });
return;
case "Success":
break;
default:
assertNever(info, "info");
}
setState({ type: "confirming", repo: info.value, url, headers });
} catch (e) {
toastThrownError(e);
setState({ type: "normal" });
}
}, []);
let dialogBody: React.ReactNode;
switch (state.type) {
case "normal":
dialogBody = null;
break;
case "enteringRepositoryInfo":
dialogBody = (
<EnteringRepositoryInfo
cancel={() => setState({ type: "normal" })}
addRepository={(url, headers) => addRepository(url, headers)}
/>
);
break;
case "loadingRepository":
dialogBody = <LoadingRepository cancel={cancel} />;
break;
case "duplicated":
dialogBody = <Duplicated cancel={cancel} />;
break;
case "confirming": {
const doAddRepository = async () => {
try {
await commands.environmentAddRepository(state.url, state.headers);
setState({ type: "normal" });
toastSuccess(tt("vpm repositories:toast:repository added"));
// noinspection ES6MissingAwait
refetch();
} catch (e) {
toastThrownError(e);
setState({ type: "normal" });
}
};
dialogBody = (
<Confirming
repo={state.repo}
headers={state.headers}
cancel={cancel}
add={doAddRepository}
/>
);
break;
}
default:
assertNever(state, "state");
}
const dialog = dialogBody ? (
<DialogOpen>
<DialogTitle>{tc("vpm repositories:button:add repository")}</DialogTitle>
{dialogBody}
</DialogOpen>
) : null;
return {
dialog,
addRepository,
openAddDialog,
};
}
function EnteringRepositoryInfo({
dialog,
cancel,
addRepository,
}: {
dialog: DialogContext<null | {
url: string;
headers: Record<string, string>;
}>;
cancel: () => void;
addRepository: (url: string, headers: { [name: string]: string }) => void;
}) {
const [url, setUrl] = useState("");
@ -151,12 +226,12 @@ function EnteringRepositoryInfo({
if (header.name.trim() === "") continue;
headers[header.name.trim()] = header.value.trim();
}
dialog.close({ url, headers });
addRepository(url, headers);
};
return (
<>
<div>
<DialogDescription>
<p className={"font-normal"}>
{tc("vpm repositories:dialog:enter repository info")}
</p>
@ -195,7 +270,7 @@ function EnteringRepositoryInfo({
<Input
type={"text"}
value={value.name}
className={"grow"}
className={"flex-grow"}
onChange={(e) =>
reordableListContext.update(id, (old) => ({
...old,
@ -210,7 +285,7 @@ function EnteringRepositoryInfo({
<Input
type={"text"}
value={value.value}
className={"grow"}
className={"flex-grow"}
onChange={(e) =>
reordableListContext.update(id, (old) => ({
...old,
@ -242,11 +317,9 @@ function EnteringRepositoryInfo({
{tc("vpm repositories:hint:duplicate headers")}
</p>
)}
</div>
</DialogDescription>
<DialogFooter>
<Button onClick={() => dialog.close(null)}>
{tc("general:button:cancel")}
</Button>
<Button onClick={cancel}>{tc("general:button:cancel")}</Button>
<Button
onClick={onAddRepository}
className={"ml-2"}
@ -259,12 +332,16 @@ function EnteringRepositoryInfo({
);
}
function LoadingRepository({ cancel }: { cancel: () => void }) {
function LoadingRepository({
cancel,
}: {
cancel: () => void;
}) {
return (
<>
<div>
<DialogDescription>
<p>{tc("vpm repositories:dialog:downloading...")}</p>
</div>
</DialogDescription>
<DialogFooter>
<Button onClick={cancel}>{tc("general:button:cancel")}</Button>
</DialogFooter>
@ -273,44 +350,17 @@ function LoadingRepository({ cancel }: { cancel: () => void }) {
}
function Duplicated({
reason,
duplicatedName,
dialog,
cancel,
}: {
reason: TauriDuplicatedReason;
duplicatedName: string;
dialog: DialogContext<void>;
cancel: () => void;
}) {
const duplicatedDisplayName =
duplicatedName === "com.vrchat.repos.curated"
? tt("vpm repositories:source:curated")
: duplicatedName === "com.vrchat.repos.official"
? tt("vpm repositories:source:official")
: duplicatedName;
let message: React.ReactNode;
switch (reason) {
case "URLDuplicated":
message = tc("vpm repositories:dialog:url duplicated", {
name: duplicatedDisplayName,
});
break;
case "IDDuplicated":
message = tc("vpm repositories:dialog:id duplicated", {
name: duplicatedDisplayName,
});
break;
}
return (
<>
<div>
<DialogDescription>
<p>{tc("vpm repositories:dialog:already added")}</p>
<p>{message}</p>
</div>
</DialogDescription>
<DialogFooter>
<Button onClick={() => dialog.close()}>
{tc("general:button:ok")}
</Button>
<Button onClick={cancel}>{tc("general:button:ok")}</Button>
</DialogFooter>
</>
);
@ -318,17 +368,19 @@ function Duplicated({
function Confirming({
repo,
cancel,
add,
headers,
dialog,
}: {
repo: TauriRemoteRepositoryInfo;
headers: { [key: string]: string };
dialog: DialogContext<boolean>;
cancel: () => void;
add: () => void;
}) {
return (
<>
{/* TODO: use ScrollArea (I failed to use it inside dialog) */}
<div className={"max-h-[50vh] overflow-y-auto font-normal"}>
<DialogDescription className={"max-h-[50vh] overflow-y-auto font-normal"}>
<p className={"font-normal"}>
{tc("vpm repositories:dialog:name", { name: repo.display_name })}
</p>
@ -341,7 +393,7 @@ function Confirming({
{tc("vpm repositories:dialog:headers")}
</p>
<ul className={"list-disc pl-6"}>
{Object.entries(headers).map(([key, value]) => (
{Object.entries(headers).map(([key, value], idx) => (
<li key={key}>
{key}: {value}
</li>
@ -353,16 +405,14 @@ function Confirming({
{tc("vpm repositories:dialog:packages")}
</p>
<ul className={"list-disc pl-6"}>
{repo.packages.map((info) => (
{repo.packages.map((info, idx) => (
<li key={info.name}>{info.display_name ?? info.name}</li>
))}
</ul>
</div>
</DialogDescription>
<DialogFooter>
<Button onClick={() => dialog.close(false)}>
{tc("general:button:cancel")}
</Button>
<Button onClick={() => dialog.close(true)} className={"ml-2"}>
<Button onClick={cancel}>{tc("general:button:cancel")}</Button>
<Button onClick={add} className={"ml-2"}>
{tc("vpm repositories:button:add repository")}
</Button>
</DialogFooter>

View file

@ -0,0 +1,398 @@
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import {
DialogDescription,
DialogFooter,
DialogOpen,
DialogTitle,
} from "@/components/ui/dialog";
import { Progress } from "@/components/ui/progress";
import { assertNever } from "@/lib/assert-never";
import type {
TauriDownloadRepository,
TauriRepositoryDescriptor,
} from "@/lib/bindings";
import { commands } from "@/lib/bindings";
import { callAsyncCommand } from "@/lib/call-async-command";
import { tc, tt } from "@/lib/i18n";
import { toastSuccess, toastThrownError } from "@/lib/toast";
import { useFilePickerFunction } from "@/lib/use-file-picker-dialog";
import type React from "react";
import { useCallback, useState } from "react";
type ParsedRepositories = {
repositories: TauriRepositoryDescriptor[];
unparsable_lines: string[];
};
type State =
| {
type: "normal";
}
| {
type: "confirmingRepositories";
pickResult: ParsedRepositories;
}
| {
type: "loadingRepositories";
totalCount: number;
downloaded: number;
cancel: () => void;
}
| {
type: "confirmingPackages";
repositories: [TauriRepositoryDescriptor, TauriDownloadRepository][];
}
| {
type: "addingRepositories";
};
interface AddRepository {
dialog: React.ReactNode;
startImportingRepositories: () => void;
}
export function useImportRepositories({
refetch,
}: {
refetch: () => void;
}): AddRepository {
const [state, setState] = useState<State>({ type: "normal" });
const [importRepositoryPick, pickDialog] = useFilePickerFunction(
commands.environmentImportRepositoryPick,
);
function cancel() {
if ("cancel" in state) state.cancel();
setState({ type: "normal" });
}
const startImportingRepositories = useCallback(
async function startImportingRepositories() {
try {
const pickResult = await importRepositoryPick();
switch (pickResult.type) {
case "NoFilePicked":
// no-op
return;
case "ParsedRepositories":
// continue
break;
default:
assertNever(pickResult, "pickResult");
}
console.log("confirmingRepositories", pickResult);
setState({ type: "confirmingRepositories", pickResult });
} catch (e) {
toastThrownError(e);
setState({ type: "normal" });
}
},
[importRepositoryPick],
);
const downloadRepositories = useCallback(async function downloadRepositories(
repositories: TauriRepositoryDescriptor[],
) {
try {
const totalCount = repositories.length;
const [cancel, resultPromise] = callAsyncCommand(
commands.environmentImportDownloadRepositories,
[repositories],
(downloaded) => {
setState({
type: "loadingRepositories",
totalCount,
downloaded,
cancel,
});
},
);
setState({
type: "loadingRepositories",
totalCount,
downloaded: 0,
cancel,
});
const result = await resultPromise;
if (result === "cancelled") {
return;
}
setState({ type: "confirmingPackages", repositories: result });
} catch (e) {
toastThrownError(e);
setState({ type: "normal" });
}
}, []);
const addRepositories = useCallback(
async function addRepositories(repositories: TauriRepositoryDescriptor[]) {
try {
setState({ type: "addingRepositories" });
await commands.environmentImportAddRepositories(repositories);
toastSuccess(tt("vpm repositories:toast:repositories added"));
refetch();
setState({ type: "normal" });
} catch (e) {
toastThrownError(e);
setState({ type: "normal" });
}
},
[refetch],
);
let dialogBody: React.ReactNode;
switch (state.type) {
case "normal":
dialogBody = null;
break;
case "confirmingRepositories":
dialogBody = (
<ConfirmingRepositoryList
pickResult={state.pickResult}
cancel={cancel}
importRepositories={downloadRepositories}
/>
);
break;
case "loadingRepositories":
dialogBody = (
<LoadingRepositories
cancel={cancel}
downloaded={state.downloaded}
totalCount={state.totalCount}
/>
);
break;
case "confirmingPackages":
dialogBody = (
<ConfirmingPackages
repositories={state.repositories}
cancel={cancel}
addRepositories={addRepositories}
/>
);
break;
case "addingRepositories":
dialogBody = <AddingRepositories />;
break;
default:
assertNever(state, "state");
}
const confirmDialog = dialogBody ? (
<DialogOpen>
<DialogTitle>
{tc("vpm repositories:dialog:import repositories")}
</DialogTitle>
{dialogBody}
</DialogOpen>
) : null;
return {
dialog: (
<>
{pickDialog}
{confirmDialog}
</>
),
startImportingRepositories,
};
}
function shortRepositoryDescription(
repo: TauriRepositoryDescriptor,
): React.ReactNode {
if (Object.keys(repo.headers).length > 0) {
return tc("vpm repositories:dialog:repository with headers", {
repoUrl: repo.url,
});
}
return repo.url;
}
function ConfirmingRepositoryList({
pickResult,
cancel,
importRepositories,
}: {
pickResult: ParsedRepositories;
cancel: () => void;
importRepositories: (repositories: TauriRepositoryDescriptor[]) => void;
}) {
const onContinue = useCallback(
async function onContinue() {
importRepositories(pickResult.repositories);
},
[importRepositories, pickResult.repositories],
);
return (
<>
{/* TODO: use ScrollArea (I failed to use it inside dialog) */}
<DialogDescription className={"max-h-[50vh] overflow-y-auto font-normal"}>
<p className={"font-normal whitespace-normal"}>
{tc("vpm repositories:dialog:confirm repository list")}
</p>
<ul className={"list-disc pl-6"}>
{pickResult.repositories.map((info) => (
<li key={info.url}>{shortRepositoryDescription(info)}</li>
))}
</ul>
{pickResult.unparsable_lines.length > 0 && (
<>
<p className={"font-normal whitespace-normal"}>
{tc("vpm repositories:dialog:unparsable lines list")}
</p>
<ul className={"list-disc pl-6"}>
{pickResult.unparsable_lines.map((line, idx) => (
// biome-ignore lint/suspicious/noArrayIndexKey: unchanged
<li key={idx} className={"whitespace-pre"}>
{line}
</li>
))}
</ul>
</>
)}
</DialogDescription>
<DialogFooter className={"gap-2"}>
<Button onClick={cancel}>{tc("general:button:cancel")}</Button>
<Button onClick={onContinue}>
{tc("vpm repositories:dialog:button:continue importing repositories")}
</Button>
</DialogFooter>
</>
);
}
function LoadingRepositories({
cancel,
downloaded,
totalCount,
}: {
cancel: () => void;
downloaded: number;
totalCount: number;
}) {
return (
<>
<DialogDescription>
<p>{tc("vpm repositories:dialog:downloading repositories...")}</p>
<Progress value={downloaded} max={totalCount} />
<div className={"text-center"}>
{tc("vpm repositories:dialog:downloaded n/m", {
downloaded,
totalCount,
})}
</div>
</DialogDescription>
<DialogFooter>
<Button onClick={cancel}>{tc("general:button:cancel")}</Button>
</DialogFooter>
</>
);
}
function ConfirmingPackages({
repositories,
cancel,
addRepositories,
}: {
repositories: [TauriRepositoryDescriptor, TauriDownloadRepository][];
cancel: () => void;
addRepositories: (repositories: TauriRepositoryDescriptor[]) => void;
}) {
const add = useCallback(
async function add() {
addRepositories(
repositories
.filter(([_, download]) => download.type === "Success")
.map(([repo, _]) => repo),
);
},
[addRepositories, repositories],
);
return (
<>
{/* TODO: use ScrollArea (I failed to use it inside dialog) */}
<DialogDescription className={"font-normal"}>
<p className={"whitespace-normal"}>
{tc("vpm repositories:dialog:confirm packages list")}
</p>
<Accordion
type="single"
collapsible
className="max-h-[50vh] overflow-y-auto w-full"
>
{repositories.map(([repo, download]) => {
let error: boolean;
let content: React.ReactNode;
switch (download.type) {
case "BadUrl":
throw new Error("BadUrl should not be here");
case "Duplicated":
error = true;
content = tc(
"vpm repositories:dialog:download error:duplicated",
);
break;
case "DownloadError":
error = true;
content = tc(
"vpm repositories:dialog:download error:download error",
);
break;
case "Success":
error = false;
content = (
<ul className={"list-disc pl-6"}>
{download.value.packages.map((info, idx) => (
<li key={info.name}>{info.display_name ?? info.name}</li>
))}
</ul>
);
break;
default:
assertNever(download, "download");
}
const destrucive = error ? "text-destructive" : "";
return (
<AccordionItem value={repo.url} key={repo.url}>
<AccordionTrigger className={`${destrucive} py-2 text-base`}>
{shortRepositoryDescription(repo)}
</AccordionTrigger>
<AccordionContent className={destrucive}>
{content}
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
</DialogDescription>
<DialogFooter>
<Button onClick={cancel}>{tc("general:button:cancel")}</Button>
<Button onClick={add} className={"ml-2"}>
{tc("vpm repositories:button:add repositories")}
</Button>
</DialogFooter>
</>
);
}
function AddingRepositories() {
return (
<>
<DialogDescription>
<p>{tc("vpm repositories:dialog:adding repositories...")}</p>
</DialogDescription>
</>
);
}

View file

@ -0,0 +1,54 @@
import { tc } from "@/lib/i18n";
import Link from "next/link";
type PageType = "/packages/user-packages" | "/packages/repositories";
export function HeadingPageName({
pageType,
}: {
pageType: PageType;
}) {
return (
<div className={"-ml-2"}>
<div
className={"grid grid-cols-2 gap-1.5 bg-secondary p-1 -m-1 rounded-md"}
>
<HeadingButton
currentPage={pageType}
targetPage={"/packages/repositories"}
>
{tc("packages:community repositories")}
</HeadingButton>
<HeadingButton
currentPage={pageType}
targetPage={"/packages/user-packages"}
>
{tc("packages:user packages")}
</HeadingButton>
</div>
</div>
);
}
function HeadingButton({
currentPage,
targetPage,
children,
}: {
currentPage: PageType;
targetPage: PageType;
children: React.ReactNode;
}) {
const button =
"cursor-pointer py-1.5 font-bold flex-grow-0 hover:bg-background rounded-sm text-center p-2";
if (currentPage === targetPage) {
return <div className={`${button} bg-background`}>{children}</div>;
} else {
return (
<Link href={targetPage} className={button}>
{children}
</Link>
);
}
}

View file

@ -1,21 +1,13 @@
"use client";
import {
queryOptions,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { CircleX } from "lucide-react";
import { Suspense, useId } from "react";
import { HNavBar, VStack } from "@/components/layout";
import { ScrollableCardTable } from "@/components/ScrollableCardTable";
import { HNavBar, VStack } from "@/components/layout";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogTitle,
DialogTrigger,
@ -28,16 +20,15 @@ import {
import type { TauriUserPackage } from "@/lib/bindings";
import { commands } from "@/lib/bindings";
import { tc } from "@/lib/i18n";
import { usePrevPathName } from "@/lib/prev-page";
import { toastError, toastSuccess, toastThrownError } from "@/lib/toast";
import { useFilePickerFunction } from "@/lib/use-file-picker-dialog";
import { toVersionString } from "@/lib/version";
import { HeadingPageName } from "../-tab-selector";
import { useQuery } from "@tanstack/react-query";
import { CircleX } from "lucide-react";
import { Suspense, useCallback, useId } from "react";
import { HeadingPageName } from "../tab-selector";
export const Route = createFileRoute("/_main/packages/user-packages/")({
component: Page,
});
function Page() {
export default function Page() {
return (
<Suspense>
<PageBody />
@ -45,101 +36,80 @@ function Page() {
);
}
const environmentGetUserPackages = queryOptions({
queryKey: ["environmentGetUserPackages"],
queryFn: commands.environmentGetUserPackages,
});
function PageBody() {
const result = useQuery(environmentGetUserPackages);
const queryClient = useQueryClient();
const addUserPackageWithPicker = useMutation({
mutationFn: async () =>
await commands.environmentAddUserPackageWithPicker(),
onSuccess: async (result) => {
switch (result) {
case "NoFolderSelected":
break;
case "InvalidSelection":
toastError(tc("user packages:toast:invalid selection"));
break;
case "AlreadyAdded":
toastSuccess(tc("user packages:toast:package already added"));
break;
case "Successful":
toastSuccess(tc("user packages:toast:package added"));
await queryClient.invalidateQueries(environmentGetUserPackages);
break;
}
},
onError: (error) => {
console.error(error);
toastThrownError(error);
},
const result = useQuery({
queryKey: ["environmentGetUserPackages"],
queryFn: commands.environmentGetUserPackages,
});
const bodyAnimation = usePrevPathName().startsWith("/packages")
? "slide-left"
: "";
const [envAddUserPackage, dialog] = useFilePickerFunction(
commands.environmentAddUserPackageWithPicker,
);
const addUserPackage = useCallback(
async function addUserPackage() {
try {
switch (await envAddUserPackage()) {
case "NoFolderSelected":
break;
case "InvalidSelection":
toastError(tc("user packages:toast:invalid selection"));
break;
case "AlreadyAdded":
toastSuccess(tc("user packages:toast:package already added"));
break;
case "Successful":
toastSuccess(tc("user packages:toast:package added"));
await result.refetch();
break;
}
} catch (e) {
toastThrownError(e);
}
},
[envAddUserPackage, result],
);
const removeUserPackage = useCallback(
async function removeUserPackage(path: string) {
try {
await commands.environmentRemoveUserPackages(path);
toastSuccess(tc("user packages:toast:package removed"));
await result.refetch();
} catch (e) {
toastThrownError(e);
}
},
[result],
);
return (
<VStack>
<HNavBar
className="shrink-0"
leading={<HeadingPageName pageType={"/packages/user-packages"} />}
trailing={
<Button
className={"compact:h-10"}
onClick={() => addUserPackageWithPicker.mutate()}
>
{tc("user packages:button:add package")}
</Button>
}
/>
<main
className={`shrink overflow-hidden flex w-full h-full ${bodyAnimation}`}
>
<ScrollableCardTable className={"h-full w-full"}>
<RepositoryTableBody userPackages={result.data || []} />
</ScrollableCardTable>
</main>
<HNavBar className={"flex-shrink-0"}>
<HeadingPageName pageType={"/packages/user-packages"} />
<div className={"flex-grow"} />
<Button onClick={addUserPackage}>
{tc("user packages:button:add package")}
</Button>
</HNavBar>
<ScrollableCardTable>
<RepositoryTableBody
userPackages={result.data || []}
removeUserPackage={removeUserPackage}
/>
</ScrollableCardTable>
{dialog}
</VStack>
);
}
function RepositoryTableBody({
userPackages,
removeUserPackage,
}: {
userPackages: TauriUserPackage[];
removeUserPackage: (path: string) => void;
}) {
const queryClient = useQueryClient();
const removeUserPackages = useMutation({
mutationFn: async (path: string) =>
await commands.environmentRemoveUserPackages(path),
onMutate: async (path) => {
await queryClient.invalidateQueries(environmentGetUserPackages);
const data = queryClient.getQueryData(
environmentGetUserPackages.queryKey,
);
if (data !== undefined) {
queryClient.setQueryData(
environmentGetUserPackages.queryKey,
data.filter((x) => x.path === path),
);
}
return data;
},
onError: (error, _, ctx) => {
console.error(error);
toastThrownError(error);
queryClient.setQueryData(environmentGetUserPackages.queryKey, ctx);
},
onSettled: async () => {
await queryClient.invalidateQueries(environmentGetUserPackages);
},
});
const TABLE_HEAD = [
"general:name",
"user packages:path",
@ -156,7 +126,7 @@ function RepositoryTableBody({
// biome-ignore lint/suspicious/noArrayIndexKey: static array
key={index}
className={
"sticky top-0 z-10 border-b border-primary bg-secondary text-secondary-foreground px-2.5 py-1.5"
"sticky top-0 z-10 border-b border-primary bg-secondary text-secondary-foreground p-2.5"
}
>
<small className="font-normal leading-none">{tc(head)}</small>
@ -169,7 +139,7 @@ function RepositoryTableBody({
<PackageRow
key={pkg.path}
pkg={pkg}
remove={() => removeUserPackages.mutate(pkg.path)}
remove={() => removeUserPackage(pkg.path)}
/>
))}
</tbody>
@ -184,7 +154,7 @@ function PackageRow({
pkg: TauriUserPackage;
remove: () => void;
}) {
const cellClass = "p-2.5 compact:py-1";
const cellClass = "p-2.5";
const id = useId();
const pkgDisplayNames = pkg.package.display_name ?? pkg.package.name;
@ -219,14 +189,14 @@ function PackageRow({
<DialogTitle>
{tc("user packages:dialog:remove package")}
</DialogTitle>
<div>
<DialogDescription>
<p className={"whitespace-normal font-normal"}>
{tc("user packages:dialog:confirm remove description", {
name: pkgDisplayNames,
path: pkg.path,
})}
</p>
</div>
</DialogDescription>
<DialogFooter>
<DialogClose asChild>
<Button>{tc("general:button:cancel")}</Button>

View file

@ -0,0 +1,407 @@
import { VStack } from "@/components/layout";
import { Button } from "@/components/ui/button";
import {
DialogDescription,
DialogFooter,
DialogOpen,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { assertNever } from "@/lib/assert-never";
import type {
TauriProjectDirCheckResult,
TauriProjectTemplate,
} from "@/lib/bindings";
import { commands } from "@/lib/bindings";
import { tc, tt } from "@/lib/i18n";
import { pathSeparator } from "@/lib/os";
import { toastError, toastSuccess, toastThrownError } from "@/lib/toast";
import { useFilePickerFunction } from "@/lib/use-file-picker-dialog";
import { useDebounce } from "@uidotdev/usehooks";
import { RefreshCw } from "lucide-react";
import { useRouter } from "next/navigation";
import type React from "react";
import { useEffect, useState } from "react";
type CreateProjectstate =
| "loadingInitialInformation"
| "enteringInformation"
| "creating";
export function CreateProject({
close,
refetch,
}: {
close?: () => void;
refetch?: () => void;
}) {
const router = useRouter();
const [state, setState] = useState<CreateProjectstate>(
"loadingInitialInformation",
);
const [projectNameCheckState, setProjectNameCheckState] = useState<
"checking" | TauriProjectDirCheckResult
>("Ok");
type CustomTemplate = TauriProjectTemplate & { type: "Custom" };
const templateUnityVersions = [
"2022.3.22f1",
"2022.3.6f1",
"2019.4.31f1",
] as const;
const latestUnityVersion = templateUnityVersions[0];
type TemplateType = "avatars" | "worlds" | "custom";
type TemplateUnityVersion = (typeof templateUnityVersions)[number];
const [customTemplates, setCustomTemplates] = useState<CustomTemplate[]>([]);
const [templateType, setTemplateType] = useState<TemplateType>("avatars");
const [unityVersion, setUnityVersion] =
useState<TemplateUnityVersion>(latestUnityVersion);
const [customTemplate, setCustomTemplate] = useState<CustomTemplate>();
function onCustomTemplateChange(value: string) {
const newCustomTemplate: CustomTemplate = {
type: "Custom",
name: value,
};
setCustomTemplate(newCustomTemplate);
}
const [projectNameRaw, setProjectName] = useState("New Project");
const projectName = projectNameRaw.trim();
const [projectLocation, setProjectLocation] = useState("");
const projectNameDebounced = useDebounce(projectName, 500);
const [pickProjectDefaultPath, dialog] = useFilePickerFunction(
commands.environmentPickProjectDefaultPath,
);
useEffect(() => {
(async () => {
const information =
await commands.environmentProjectCreationInformation();
const customTemplates = information.templates.filter(
(template): template is CustomTemplate => template.type === "Custom",
);
setCustomTemplates(customTemplates);
setCustomTemplate(customTemplates[0]);
setProjectLocation(information.default_path);
setState("enteringInformation");
})();
}, []);
useEffect(() => {
let canceled = false;
(async () => {
try {
setProjectNameCheckState("checking");
const result = await commands.environmentCheckProjectName(
projectLocation,
projectNameDebounced,
);
if (canceled) return;
setProjectNameCheckState(result);
} catch (e) {
console.error("Error checking project name", e);
toastThrownError(e);
}
})();
return () => {
canceled = true;
};
}, [projectNameDebounced, projectLocation]);
const selectProjectDefaultFolder = async () => {
try {
const result = await pickProjectDefaultPath();
switch (result.type) {
case "NoFolderSelected":
// no-op
break;
case "InvalidSelection":
toastError(tt("general:toast:invalid directory"));
break;
case "Successful":
setProjectLocation(result.new_path);
break;
default:
assertNever(result);
}
} catch (e) {
console.error(e);
toastThrownError(e);
}
};
const createProject = async () => {
try {
setState("creating");
let template: TauriProjectTemplate;
switch (templateType) {
case "avatars":
case "worlds":
template = {
type: "Builtin",
id: `${templateType}-${unityVersion}`,
name: `${templateType}-${unityVersion}`,
};
break;
case "custom":
if (customTemplate === undefined)
throw new Error("Custom template not selected");
template = customTemplate;
break;
default:
assertNever(templateType, "template type");
}
await commands.environmentCreateProject(
projectLocation,
projectName,
template,
);
toastSuccess(tt("projects:toast:project created"));
close?.();
refetch?.();
const projectPath = `${projectLocation}${pathSeparator()}${projectName}`;
router.push(`/projects/manage?${new URLSearchParams({ projectPath })}`);
} catch (e) {
console.error(e);
toastThrownError(e);
close?.();
}
};
const checking =
projectNameDebounced !== projectName ||
projectNameCheckState === "checking";
let projectNameState: "Ok" | "warn" | "err";
let projectNameCheck: React.ReactNode;
switch (projectNameCheckState) {
case "Ok":
projectNameCheck = tc("projects:hint:create project ready");
projectNameState = "Ok";
break;
case "InvalidNameForFolderName":
projectNameCheck = tc("projects:hint:invalid project name");
projectNameState = "err";
break;
case "MayCompatibilityProblem":
projectNameCheck = tc("projects:hint:warn symbol in project name");
projectNameState = "warn";
break;
case "WideChar":
projectNameCheck = tc(
"projects:hint:warn multibyte char in project name",
);
projectNameState = "warn";
break;
case "AlreadyExists":
projectNameCheck = tc("projects:hint:project already exists");
projectNameState = "err";
break;
case "checking":
projectNameCheck = <RefreshCw className={"w-5 h-5 animate-spin"} />;
projectNameState = "Ok";
break;
default:
assertNever(projectNameCheckState);
}
let projectNameStateClass: React.ReactNode;
switch (projectNameState) {
case "Ok":
projectNameStateClass = "text-success";
break;
case "warn":
projectNameStateClass = "text-warning";
break;
case "err":
projectNameStateClass = "text-destructive";
}
if (checking)
projectNameCheck = <RefreshCw className={"w-5 h-5 animate-spin"} />;
let dialogBody: React.ReactNode;
switch (state) {
case "loadingInitialInformation":
dialogBody = <RefreshCw className={"w-5 h-5 animate-spin"} />;
break;
case "enteringInformation": {
const renderUnityVersion = (unityVersion: string) => {
if (unityVersion === latestUnityVersion) {
return (
<>
{unityVersion}{" "}
<span className={"text-success"}>{tc("projects:latest")}</span>
</>
);
} else {
return unityVersion;
}
};
dialogBody = (
<>
<VStack>
<div className={"flex gap-1"}>
<div className={"flex items-center"}>
<label>{tc("projects:template:type")}</label>
</div>
<Select
defaultValue={templateType}
onValueChange={(value) =>
setTemplateType(value as TemplateType)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value={"avatars"}>
{tc("projects:type:avatars")}
</SelectItem>
<SelectItem value={"worlds"}>
{tc("projects:type:worlds")}
</SelectItem>
<SelectItem
value={"custom"}
disabled={customTemplates.length === 0}
>
{tc("projects:type:custom")}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
{templateType !== "custom" ? (
<div className={"flex gap-1"}>
<div className={"flex items-center"}>
<label>{tc("projects:template:unity version")}</label>
</div>
<Select
defaultValue={unityVersion}
onValueChange={(value) =>
setUnityVersion(value as TemplateUnityVersion)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{templateUnityVersions.map((unityVersion) => (
<SelectItem value={unityVersion} key={unityVersion}>
{renderUnityVersion(unityVersion)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
<div className={"flex gap-1"}>
<div className={"flex items-center"}>
<label>{tc("projects:template")}</label>
</div>
<Select
value={customTemplate?.name}
onValueChange={onCustomTemplateChange}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{customTemplates.map((template) => (
<SelectItem value={template.name} key={template.name}>
{template.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
)}
<Input
value={projectNameRaw}
onChange={(e) => setProjectName(e.target.value)}
/>
<div className={"flex gap-1 items-center"}>
<Input className="flex-auto" value={projectLocation} disabled />
<Button
className="flex-none px-4"
onClick={selectProjectDefaultFolder}
>
{tc("general:button:select")}
</Button>
</div>
<small className={"whitespace-normal"}>
{tc(
"projects:hint:path of creating project",
{ path: `${projectLocation}${pathSeparator()}${projectName}` },
{
components: {
path: (
<span
className={
"p-0.5 font-path whitespace-pre bg-secondary text-secondary-foreground"
}
/>
),
},
},
)}
</small>
<small className={`whitespace-normal ${projectNameStateClass}`}>
{projectNameCheck}
</small>
</VStack>
</>
);
break;
}
case "creating":
dialogBody = (
<>
<RefreshCw className={"w-5 h-5 animate-spin"} />
<p>{tc("projects:creating project...")}</p>
</>
);
break;
}
return (
<DialogOpen>
<DialogTitle>{tc("projects:create new project")}</DialogTitle>
<DialogDescription>{dialogBody}</DialogDescription>
<DialogFooter className={"gap-2"}>
<Button onClick={close} disabled={state === "creating"}>
{tc("general:button:cancel")}
</Button>
<Button
onClick={createProject}
disabled={
state === "creating" || checking || projectNameState === "err"
}
>
{tc("projects:button:create")}
</Button>
</DialogFooter>
{dialog}
</DialogOpen>
);
}

View file

@ -6,7 +6,6 @@ import type {
TauriUserRepository,
TauriVersion,
} from "@/lib/bindings";
import { VRCSDK_PACKAGES } from "@/lib/constants";
import {
compareUnityVersion,
compareVersion,
@ -28,33 +27,30 @@ export type PackageLatestInfo =
hasUnityIncompatibleLatest: boolean;
};
type UrlInfo = {
// null source means URL comes from installed one which has the highest priority
url: string;
source: TauriVersion | null;
};
export interface PackageRowInfo {
id: string;
infoSource: TauriVersion;
displayName: string;
description: string;
keywords: string[];
aliases: string[];
unityCompatible: Map<string, TauriPackage>;
unityIncompatible: Map<string, TauriPackage>;
sources: Set<string>;
isThereSource: boolean; // this will be true even if all sources are hidden
visibleSources: Set<string>;
installed: null | {
version: TauriVersion;
yanked: boolean;
};
latest: PackageLatestInfo;
stableLatest: PackageLatestInfo;
changelogUrl: null | UrlInfo;
documentationUrl: null | UrlInfo;
}
export const VRCSDK_PACKAGES = [
"com.vrchat.avatars",
"com.vrchat.worlds",
"com.vrchat.base",
];
export function combinePackagesAndProjectDetails(
packages: TauriPackage[],
project: TauriProjectDetails | null,
@ -93,9 +89,7 @@ export function combinePackagesAndProjectDetails(
const yankedVersions = new Set<`${string}:${string}`>();
const knownPackages = new Set<string>();
const packagesPerRepository = new Map<string, TauriPackage[]>();
const hiddenPackagesPerRepository = new Map<string, TauriPackage[]>();
const userPackages: TauriPackage[] = [];
const hiddenUserPackages: TauriPackage[] = [];
for (const pkg of packages) {
if (!showPrereleasePackages && pkg.version.pre) continue;
@ -110,19 +104,13 @@ export function combinePackagesAndProjectDetails(
let packages: TauriPackage[];
// check the repository is visible
if (pkg.source === "LocalUser") {
if (hideLocalUserPackages) {
packages = hiddenUserPackages;
} else {
packages = userPackages;
}
if (hideLocalUserPackages) continue;
packages = userPackages;
} else if ("Remote" in pkg.source) {
if (hiddenRepositoriesSet.has(pkg.source.Remote.id)) {
packages = hiddenPackagesPerRepository.get(pkg.source.Remote.id) ?? [];
hiddenPackagesPerRepository.set(pkg.source.Remote.id, packages);
} else {
packages = packagesPerRepository.get(pkg.source.Remote.id) ?? [];
packagesPerRepository.set(pkg.source.Remote.id, packages);
}
if (hiddenRepositoriesSet.has(pkg.source.Remote.id)) continue;
packages = packagesPerRepository.get(pkg.source.Remote.id) ?? [];
packagesPerRepository.set(pkg.source.Remote.id, packages);
} else {
assertNever(pkg.source);
}
@ -141,18 +129,15 @@ export function combinePackagesAndProjectDetails(
id: pkg.name,
displayName: pkg.display_name ?? pkg.name,
description: pkg.description ?? "",
keywords: pkg.keywords,
aliases: pkg.aliases,
infoSource: pkg.version,
unityCompatible: new Map(),
unityIncompatible: new Map(),
sources: new Set(),
isThereSource: false,
visibleSources: new Set(),
installed: null,
latest: { status: "none" },
stableLatest: { status: "none" },
changelogUrl: null,
documentationUrl: null,
}),
);
}
@ -163,21 +148,13 @@ export function combinePackagesAndProjectDetails(
const packageRowInfo = getRowInfo(pkg);
packageRowInfo.isThereSource = true;
setUrlInfo(packageRowInfo, "changelogUrl", pkg.changelog_url, pkg.version);
setUrlInfo(
packageRowInfo,
"documentationUrl",
pkg.documentation_url,
pkg.version,
);
if (compareVersion(pkg.version, packageRowInfo.infoSource) > 0) {
// use display name from the latest version
packageRowInfo.infoSource = pkg.version;
packageRowInfo.displayName = pkg.display_name ?? pkg.name;
packageRowInfo.description =
pkg.description || packageRowInfo.description;
packageRowInfo.keywords = pkg.keywords;
packageRowInfo.aliases = pkg.aliases;
}
if (project == null || isUnityCompatible(pkg, project.unity)) {
@ -188,14 +165,8 @@ export function combinePackagesAndProjectDetails(
if (pkg.source === "LocalUser") {
packageRowInfo.sources.add("User");
if (!hideLocalUserPackages) {
packageRowInfo.visibleSources.add("User");
}
} else if ("Remote" in pkg.source) {
packageRowInfo.sources.add(pkg.source.Remote.display_name);
if (!hiddenRepositoriesSet.has(pkg.source.Remote.id)) {
packageRowInfo.visibleSources.add(pkg.source.Remote.display_name);
}
}
}
@ -203,13 +174,6 @@ export function combinePackagesAndProjectDetails(
packagesPerRepository.get("com.vrchat.repos.official")?.forEach(addPackage);
packagesPerRepository.get("com.vrchat.repos.curated")?.forEach(addPackage);
userPackages.forEach(addPackage);
hiddenUserPackages.forEach((pkg) => {
const packageRowInfo = getRowInfo(pkg);
packageRowInfo.isThereSource = true;
if (pkg.source === "LocalUser") {
packageRowInfo.sources.add("User");
}
});
packagesPerRepository.delete("com.vrchat.repos.official");
packagesPerRepository.delete("com.vrchat.repos.curated");
@ -224,17 +188,6 @@ export function combinePackagesAndProjectDetails(
packages.forEach(addPackage);
}
// process hidden repositories - only add to sources, not to version calculations
for (const packages of hiddenPackagesPerRepository.values()) {
packages.forEach((pkg) => {
const packageRowInfo = getRowInfo(pkg);
packageRowInfo.isThereSource = true;
if (pkg.source !== "LocalUser") {
packageRowInfo.sources.add(pkg.source.Remote.display_name);
}
});
}
// sort versions
for (const value of packagesTable.values()) {
value.unityCompatible = new Map(
@ -316,24 +269,16 @@ export function combinePackagesAndProjectDetails(
for (const [_, pkg] of project.installed_packages) {
const packageRowInfo = getRowInfo(pkg);
setUrlInfo(packageRowInfo, "changelogUrl", pkg.changelog_url, null);
setUrlInfo(
packageRowInfo,
"documentationUrl",
pkg.documentation_url,
null,
);
// if installed, use the installed version to get the display name
packageRowInfo.displayName = pkg.display_name ?? pkg.name;
packageRowInfo.keywords = [...pkg.keywords, ...packageRowInfo.keywords];
packageRowInfo.aliases = [...pkg.aliases, ...packageRowInfo.aliases];
packageRowInfo.installed = {
version: pkg.version,
yanked:
pkg.is_yanked ||
yankedVersions.has(`${pkg.name}:${toVersionString(pkg.version)}`),
};
packageRowInfo.isThereSource = true;
packageRowInfo.isThereSource = knownPackages.has(pkg.name);
// if we have the latest version, check if it's upgradable
if (packageRowInfo.latest.status !== "none") {
@ -430,25 +375,3 @@ export function combinePackagesAndProjectDetails(
return asArray;
}
function setUrlInfo<K extends string>(
obj: { [P in K]: null | UrlInfo },
key: K,
url: string | null,
version: TauriVersion | null,
) {
if (url == null) return;
const current = obj[key];
if (current == null) {
obj[key] = { url, source: version };
} else {
if (version == null) {
obj[key] = { url, source: version };
} else if (current.source == null) {
// do not update
} else if (compareVersion(current.source, version) < 0) {
// if this version is newer than current, update
obj[key] = { url, source: version };
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,40 @@
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
type ComponentProps,
type ElementRef,
createContext,
forwardRef,
useContext,
} from "react";
interface PageContext {
isLoading: boolean;
}
export const PageContext = createContext<PageContext>({
isLoading: false,
});
PageContext.displayName = "PageContext";
export const PageContextProvider = PageContext.Provider;
export function usePageContext() {
return useContext(PageContext);
}
export const ButtonDisabledIfLoading = forwardRef<
ElementRef<typeof Button>,
ComponentProps<typeof Button>
>(function ButtonDisabledIfLoading({ disabled, ...props }, ref) {
const { isLoading } = usePageContext();
return <Button disabled={isLoading || disabled} {...props} ref={ref} />;
});
export const CheckboxDisabledIfLoading = forwardRef<
ElementRef<typeof Checkbox>,
ComponentProps<typeof Checkbox>
>(function CheckboxDisabledIfLoading({ disabled, ...props }, ref) {
const { isLoading } = usePageContext();
return <Checkbox disabled={isLoading || disabled} {...props} ref={ref} />;
});

View file

@ -0,0 +1,671 @@
"use client";
import { HNavBar, VStack } from "@/components/layout";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
DialogDescription,
DialogFooter,
DialogOpen,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
UnityArgumentsSettings,
useUnityArgumentsSettings,
} from "@/components/unity-arguments-settings";
import { useBackupProjectModal } from "@/lib/backup-project";
import type { TauriProjectDetails, TauriUnityVersions } from "@/lib/bindings";
import { commands } from "@/lib/bindings";
import { tc } from "@/lib/i18n";
import { nameFromPath } from "@/lib/os";
import { useRemoveProjectModal } from "@/lib/remove-project";
import { toastSuccess, toastThrownError } from "@/lib/toast";
import { useOpenUnity } from "@/lib/use-open-unity";
import { compareUnityVersionString } from "@/lib/version";
import {
type UseQueryResult,
useQueries,
useQuery,
} from "@tanstack/react-query";
import { ArrowLeft, ChevronDown } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useCallback, useMemo, useState } from "react";
import {
VRCSDK_PACKAGES,
combinePackagesAndProjectDetails,
} from "./collect-package-row-info";
import { PackageListCard } from "./package-list-card";
import { PageContextProvider } from "./page-context";
import {
useUnity2022Migration,
useUnity2022PatchMigration,
useUnityVersionChange,
} from "./unity-migration";
import { usePackageChangeDialog } from "./use-package-change";
export default function Page() {
return (
<Suspense>
<PageBody />
</Suspense>
);
}
function PageBody() {
const searchParams = useSearchParams();
const router = useRouter();
const projectRemoveModal = useRemoveProjectModal({
onRemoved: () => router.back(),
});
const backupProjectModal = useBackupProjectModal();
const projectPath = searchParams.get("projectPath") ?? "";
const projectName = nameFromPath(projectPath);
// repositoriesInfo: list of repositories and their visibility
// packagesResult: list of packages
// detailsResult: project details including installed packages
// unityVersionsResult: list of unity versions installed
const [repositoriesInfo, packagesResult, detailsResult, unityVersionsResult] =
useQueries({
queries: [
{
queryKey: ["environmentRepositoriesInfo"],
queryFn: commands.environmentRepositoriesInfo,
refetchOnWindowFocus: false,
},
{
queryKey: ["environmentPackages"],
queryFn: commands.environmentPackages,
refetchOnWindowFocus: false,
},
{
queryKey: ["projectDetails", projectPath],
queryFn: () => commands.projectDetails(projectPath),
refetchOnWindowFocus: false,
},
{
queryKey: ["environmentUnityVersions"],
queryFn: () => commands.environmentUnityVersions(),
},
],
});
const [manualRefetching, setManualRefething] = useState<boolean>(false);
const packageRowsData = useMemo(() => {
const packages = packagesResult.data ?? [];
const details = detailsResult.data ?? null;
const hiddenRepositories =
repositoriesInfo.data?.hidden_user_repositories ?? [];
const hideUserPackages =
repositoriesInfo.data?.hide_local_user_packages ?? false;
const definedRepositories = repositoriesInfo.data?.user_repositories ?? [];
const showPrereleasePackages =
repositoriesInfo.data?.show_prerelease_packages ?? false;
return combinePackagesAndProjectDetails(
packages,
details,
hiddenRepositories,
hideUserPackages,
definedRepositories,
showPrereleasePackages,
);
}, [repositoriesInfo.data, packagesResult.data, detailsResult.data]);
const onRefresh = useCallback(async () => {
try {
setManualRefething(true);
await commands.environmentRefetchPackages();
packagesResult.refetch();
detailsResult.refetch();
repositoriesInfo.refetch();
unityVersionsResult.refetch();
} finally {
setManualRefething(false);
}
}, [detailsResult, packagesResult, repositoriesInfo, unityVersionsResult]);
const onRefreshProject = useCallback(() => {
detailsResult.refetch();
packagesResult.refetch(); // package changes require package list to be refreshed
}, [detailsResult, packagesResult]);
const packageChangeDialog = usePackageChangeDialog({
projectPath,
onRefreshProject,
packageRowsData,
existingPackages: detailsResult.data?.installed_packages,
});
const unity2022Migration = useUnity2022Migration({
projectPath,
refresh: onRefresh,
});
const unity2022PatchMigration = useUnity2022PatchMigration({
projectPath,
refresh: onRefresh,
});
const onRefreshRepositories = useCallback(() => {
repositoriesInfo.refetch();
}, [repositoriesInfo]);
const onRemoveProject = useCallback(() => {
projectRemoveModal.startRemove({
path: projectPath,
name: projectName,
is_exists: true,
});
}, [projectName, projectPath, projectRemoveModal]);
const onBackupProject = useCallback(() => {
backupProjectModal.startBackup({
path: projectPath,
name: projectName,
});
}, [backupProjectModal, projectName, projectPath]);
const onResolveRequest = useCallback(() => {
packageChangeDialog.createChanges(
{ type: "resolve" },
commands.projectResolve(projectPath),
);
}, [packageChangeDialog, projectPath]);
const isLoading =
packagesResult.isFetching ||
detailsResult.isFetching ||
repositoriesInfo.isFetching ||
unityVersionsResult.isLoading ||
packageChangeDialog.installingPackage ||
manualRefetching;
console.log(`rerender: isloading: ${isLoading}`);
function checkIfMigrationTo2022Recommended(data: TauriProjectDetails) {
if (data.unity == null) return false;
// migrate if the project is using 2019 and has vrcsdk
if (data.unity[0] !== 2019) return false;
return data.installed_packages.some(([id, _]) =>
VRCSDK_PACKAGES.includes(id),
);
}
function checkIf2022PatchMigrationRecommended(
data: TauriProjectDetails,
unityData: TauriUnityVersions,
) {
if (
!data.installed_packages.some(([id, _]) => VRCSDK_PACKAGES.includes(id))
)
return false;
if (data.unity == null) return false;
if (data.unity[0] !== 2022) return false;
// unity patch is 2022.
return data.unity_str !== unityData.recommended_version;
}
const isResolveRecommended = detailsResult?.data?.should_resolve;
const isMigrationTo2022Recommended =
detailsResult.status === "success" &&
checkIfMigrationTo2022Recommended(detailsResult.data);
const is2022PatchMigrationRecommended =
detailsResult.status === "success" &&
unityVersionsResult.status === "success" &&
checkIf2022PatchMigrationRecommended(
detailsResult.data,
unityVersionsResult.data,
);
const pageContext = useMemo(() => ({ isLoading }), [isLoading]);
return (
<PageContextProvider value={pageContext}>
<VStack>
<ProjectViewHeader
className={"flex-shrink-0"}
projectName={projectName}
projectPath={projectPath}
unityVersion={detailsResult.data?.unity_str ?? null}
unityRevision={detailsResult.data?.unity_revision ?? null}
onRemove={onRemoveProject}
onBackup={onBackupProject}
/>
<Card
className={"flex-shrink-0 p-2 flex flex-row flex-wrap items-center"}
>
<p className="cursor-pointer py-1.5 font-bold flex-grow flex-shrink overflow-hidden basis-52">
{tc(
"projects:manage:project location",
{ path: projectPath },
{
components: {
path: (
<span
className={
"p-0.5 font-path whitespace-pre bg-secondary text-secondary-foreground"
}
/>
),
},
},
)}
</p>
<div className={"flex-grow-0 flex-shrink-0 w-2"} />
<div className="flex-grow-0 flex-shrink-0 flex flex-row items-center">
<p className="cursor-pointer py-1.5 font-bold flex-grow-0 flex-shrink-0">
{tc("projects:manage:unity version")}
</p>
<div className={"flex-grow-0 flex-shrink-0"}>
<UnityVersionSelector
disabled={isLoading}
projectPath={projectPath}
detailsResult={detailsResult}
unityVersions={unityVersionsResult.data}
/>
</div>
</div>
</Card>
{isResolveRecommended && (
<SuggestResolveProjectCard
disabled={isLoading}
onResolveRequested={onResolveRequest}
/>
)}
{isMigrationTo2022Recommended && (
<SuggestMigrateTo2022Card
disabled={isLoading}
onMigrateRequested={() => unity2022Migration.request({})}
/>
)}
{is2022PatchMigrationRecommended && (
<Suggest2022PatchMigrationCard
disabled={isLoading}
onMigrateRequested={() => unity2022PatchMigration.request({})}
/>
)}
<main className="flex-shrink overflow-hidden flex w-full">
<PackageListCard
projectPath={projectPath}
createChanges={packageChangeDialog.createChanges}
packageRowsData={packageRowsData}
repositoriesInfo={repositoriesInfo.data}
onRefresh={onRefresh}
onRefreshRepositories={onRefreshRepositories}
/>
</main>
{packageChangeDialog.dialog}
{unity2022Migration.dialog}
{unity2022PatchMigration.dialog}
{projectRemoveModal.dialog}
{backupProjectModal.dialog}
</VStack>
</PageContextProvider>
);
}
function UnityVersionSelector({
disabled,
projectPath,
detailsResult,
unityVersions,
}: {
disabled?: boolean;
projectPath: string;
detailsResult: UseQueryResult<TauriProjectDetails>;
unityVersions?: TauriUnityVersions;
}) {
const unityChangeVersion = useUnityVersionChange({
projectPath,
refresh: () => detailsResult.refetch(),
});
const unityVersionNames = useMemo(() => {
if (unityVersions == null) return null;
const versionNames = [
...new Set<string>(unityVersions.unity_paths.map(([, path]) => path)),
];
versionNames.sort((a, b) => compareUnityVersionString(b, a));
return versionNames;
}, [unityVersions]);
const onChange = useCallback(
async (version: string) => {
const detailsData = detailsResult.data;
if (detailsData == null) return;
const currentUnityVersion = detailsData.unity_str;
if (currentUnityVersion == null) return;
const isVRCProject = detailsData.installed_packages.some(([id, _]) =>
VRCSDK_PACKAGES.includes(id),
);
unityChangeVersion.request({
version,
isVRCProject,
currentUnityVersion,
});
},
[detailsResult.data, unityChangeVersion],
);
return (
<Select
disabled={disabled}
value={detailsResult.data?.unity_str ?? undefined}
onValueChange={onChange}
>
<SelectTrigger>
{detailsResult.status === "success" ? (
detailsResult.data.unity_str ?? "unknown"
) : (
<span className={"text-primary"}>Loading...</span>
)}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{unityVersionNames == null ? (
<SelectLabel>Loading...</SelectLabel>
) : (
unityVersionNames.map((v) => (
<SelectItem key={v} value={v}>
{v}
</SelectItem>
))
)}
</SelectGroup>
</SelectContent>
{unityChangeVersion.dialog}
</Select>
);
}
function SuggestResolveProjectCard({
disabled,
onResolveRequested,
}: {
disabled?: boolean;
onResolveRequested: () => void;
}) {
return (
<Card className={"flex-shrink-0 p-2 flex flex-row items-center"}>
<p className="cursor-pointer py-1.5 font-bold flex-grow-0 flex-shrink overflow-hidden whitespace-normal text-sm">
{tc("projects:manage:suggest resolve")}
</p>
<div className={"flex-grow flex-shrink-0 w-2"} />
<Button
variant={"ghost-destructive"}
onClick={onResolveRequested}
disabled={disabled}
>
{tc("projects:manage:button:resolve")}
</Button>
</Card>
);
}
function SuggestMigrateTo2022Card({
disabled,
onMigrateRequested,
}: {
disabled?: boolean;
onMigrateRequested: () => void;
}) {
return (
<Card className={"flex-shrink-0 p-2 flex flex-row items-center"}>
<p className="cursor-pointer py-1.5 font-bold flex-grow-0 flex-shrink overflow-hidden whitespace-normal text-sm">
{tc("projects:manage:suggest unity migration")}
</p>
<div className={"flex-grow flex-shrink-0 w-2"} />
<Button
variant={"ghost-destructive"}
onClick={onMigrateRequested}
disabled={disabled}
>
{tc("projects:manage:button:unity migrate")}
</Button>
</Card>
);
}
function Suggest2022PatchMigrationCard({
disabled,
onMigrateRequested,
}: {
disabled?: boolean;
onMigrateRequested: () => void;
}) {
return (
<Card className={"flex-shrink-0 p-2 flex flex-row items-center"}>
<p className="cursor-pointer py-1.5 font-bold flex-grow-0 flex-shrink overflow-hidden whitespace-normal text-sm">
{tc("projects:manage:suggest unity patch migration")}
</p>
<div className={"flex-grow flex-shrink-0 w-2"} />
<Button
variant={"ghost-destructive"}
onClick={onMigrateRequested}
disabled={disabled}
>
{tc("projects:manage:button:unity migrate")}
</Button>
</Card>
);
}
function ProjectViewHeader({
className,
projectName,
projectPath,
unityVersion,
unityRevision,
onRemove,
onBackup,
}: {
className?: string;
projectName: string;
projectPath: string;
unityVersion: string | null;
unityRevision: string | null;
onRemove?: () => void;
onBackup?: () => void;
}) {
const openUnity = useOpenUnity();
const [openLaunchOptions, setOpenLaunchOptions] = useState<
| false
| {
initialArgs: null | string[];
defaultArgs: string[];
}
>(false);
const onChangeLaunchOptions = async () => {
const initialArgs = await commands.projectGetCustomUnityArgs(projectPath);
const defaultArgs = await commands.environmentGetDefaultUnityArguments();
setOpenLaunchOptions({
initialArgs,
defaultArgs,
});
};
const closeChangeLaunchOptions = () => {
setOpenLaunchOptions(false);
};
return (
<HNavBar className={className}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={"ghost"}
size={"icon"}
onClick={() => history.back()}
>
<ArrowLeft className={"w-5 h-5"} />
</Button>
</TooltipTrigger>
<TooltipContent>
{tc("projects:manage:tooltip:back to projects")}
</TooltipContent>
</Tooltip>
<p className="cursor-pointer py-1.5 font-bold flex-grow-0 whitespace-pre">
{projectName}
</p>
<div className="relative flex gap-2 w-max flex-grow" />
<DropdownMenu>
<div className={"flex divide-x"}>
<Button
onClick={() =>
openUnity.openUnity(projectPath, unityVersion, unityRevision)
}
className={"rounded-r-none pl-4 pr-3"}
>
{tc("projects:button:open unity")}
</Button>
<DropdownMenuTrigger asChild className={"rounded-l-none pl-2 pr-2"}>
<Button>
<ChevronDown className={"w-4 h-4"} />
</Button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent>
<DropdownMenuContentBody
projectPath={projectPath}
onRemove={onRemove}
onBackup={onBackup}
onChangeLaunchOptions={onChangeLaunchOptions}
/>
</DropdownMenuContent>
</DropdownMenu>
{openUnity.dialog}
{openLaunchOptions !== false && (
<DialogOpen>
<LaunchSettings
projectPath={projectPath}
initialValue={openLaunchOptions.initialArgs}
defaultUnityArgs={openLaunchOptions.defaultArgs}
close={closeChangeLaunchOptions}
/>
</DialogOpen>
)}
</HNavBar>
);
}
function LaunchSettings({
projectPath,
defaultUnityArgs,
initialValue,
close,
}: {
projectPath: string;
defaultUnityArgs: string[];
initialValue: string[] | null;
close: () => void;
}) {
const context = useUnityArgumentsSettings(initialValue, defaultUnityArgs);
const saveAndClose = async () => {
await commands.projectSetCustomUnityArgs(projectPath, context.currentValue);
close();
};
return (
<>
<DialogTitle>{tc("projects:dialog:launch options")}</DialogTitle>
{/* TODO: use ScrollArea (I failed to use it inside dialog) */}
<DialogDescription className={"max-h-[50dvh] overflow-y-auto"}>
<h3 className={"text-lg"}>
{tc("projects:dialog:command-line arguments")}
</h3>
<UnityArgumentsSettings context={context} />
</DialogDescription>
<DialogFooter>
<Button onClick={close} variant={"destructive"}>
{tc("general:button:cancel")}
</Button>
<Button onClick={saveAndClose} disabled={context.hasError}>
{tc("general:button:save")}
</Button>
</DialogFooter>
</>
);
}
function DropdownMenuContentBody({
projectPath,
onRemove,
onBackup,
onChangeLaunchOptions,
}: {
projectPath: string;
onRemove?: () => void;
onBackup?: () => void;
onChangeLaunchOptions?: () => void;
}) {
const openProjectFolder = () =>
commands.utilOpen(projectPath, "ErrorIfNotExists");
const forgetUnity = async () => {
try {
await commands.projectSetUnityPath(projectPath, null);
toastSuccess(tc("projects:toast:forgot unity path"));
} catch (e) {
console.error(e);
toastThrownError(e);
}
};
const unityPathQuery = useQuery({
queryFn: () => commands.projectGetUnityPath(projectPath),
queryKey: ["projectGetUnityPath", projectPath],
refetchOnWindowFocus: false,
});
const unityPath = unityPathQuery.data;
return (
<>
<DropdownMenuItem onClick={onChangeLaunchOptions}>
{tc("projects:menuitem:change launch options")}
</DropdownMenuItem>
{unityPath && (
<DropdownMenuItem onClick={forgetUnity}>
{tc("projects:menuitem:forget unity path")}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={openProjectFolder}>
{tc("projects:menuitem:open directory")}
</DropdownMenuItem>
<DropdownMenuItem onClick={onBackup}>
{tc("projects:menuitem:backup")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={onRemove}
className={"bg-destructive text-destructive-foreground"}
>
{tc("projects:remove project")}
</DropdownMenuItem>
</>
);
}

View file

@ -0,0 +1,730 @@
import { Button } from "@/components/ui/button";
import {
DialogDescription,
DialogFooter,
DialogOpen,
DialogTitle,
} from "@/components/ui/dialog";
import { assertNever } from "@/lib/assert-never";
import type { TauriUnityVersions } from "@/lib/bindings";
import { commands } from "@/lib/bindings";
import { callAsyncCommand } from "@/lib/call-async-command";
import { tc, tt } from "@/lib/i18n";
import { toastError, toastSuccess, toastThrownError } from "@/lib/toast";
import { useUnitySelectorDialog } from "@/lib/use-unity-selector-dialog";
import { compareUnityVersionString, parseUnityVersion } from "@/lib/version";
import { useRouter } from "next/navigation";
import React, { Fragment, useCallback } from "react";
type UnityInstallation = [path: string, version: string, fromHub: boolean];
function findRecommendedUnity(
unityVersions: TauriUnityVersions,
): FindUnityResult {
const versions = unityVersions.unity_paths.filter(
([_p, v, _]) => v === unityVersions.recommended_version,
);
if (versions.length === 0) {
return {
expectingVersion: unityVersions.recommended_version,
installLink: unityVersions.install_recommended_version_link,
found: false,
};
} else {
return {
expectingVersion: unityVersions.recommended_version,
found: true,
installations: versions,
};
}
}
export function useUnity2022Migration({
projectPath,
refresh,
}: {
projectPath: string;
refresh?: () => void;
}): Result<Record<string, never>> {
return useMigrationInternal({
projectPath,
updateProjectPreUnityLaunch: async (project) =>
await commands.projectMigrateProjectTo2022(project),
findUnity: findRecommendedUnity,
refresh,
ConfirmComponent: MigrationConfirmMigrationDialog,
dialogHeader: () => tc("projects:manage:dialog:unity migrate header"),
});
}
function MigrationConfirmMigrationDialog({ cancel, doMigrate }: ConfirmProps) {
return (
<>
<DialogDescription>
<p className={"text-destructive"}>
{tc("projects:dialog:vpm migrate description")}
</p>
</DialogDescription>
<DialogFooter>
<Button onClick={cancel} className="mr-1">
{tc("general:button:cancel")}
</Button>
<Button
onClick={() => doMigrate(false)}
variant={"destructive"}
className="mr-1"
>
{tc("projects:button:migrate copy")}
</Button>
<Button onClick={() => doMigrate(true)} variant={"destructive"}>
{tc("projects:button:migrate in-place")}
</Button>
</DialogFooter>
</>
);
}
export function useUnity2022PatchMigration({
projectPath,
refresh,
}: {
projectPath: string;
refresh?: () => void;
}): Result<Record<string, never>> {
return useMigrationInternal({
projectPath,
updateProjectPreUnityLaunch: async () => {}, // nothing pre-launch
findUnity: findRecommendedUnity,
refresh,
ConfirmComponent: MigrationConfirmMigrationPatchDialog,
dialogHeader: () => tc("projects:manage:dialog:unity migrate header"),
});
}
function MigrationConfirmMigrationPatchDialog({
result,
cancel,
doMigrate,
}: ConfirmProps) {
const unity = result.expectingVersion;
return (
<>
<DialogDescription>
<p className={"text-destructive"}>
{tc("projects:dialog:migrate unity2022 patch description", { unity })}
</p>
</DialogDescription>
<DialogFooter>
<Button onClick={cancel} className="mr-1">
{tc("general:button:cancel")}
</Button>
<Button onClick={() => doMigrate(true)} variant={"destructive"}>
{tc("projects:button:migrate in-place")}
</Button>
</DialogFooter>
</>
);
}
// endregion unity version change
export function useUnityVersionChange({
projectPath,
refresh,
}: {
projectPath: string;
refresh?: () => void;
}): Result<{
version: string;
currentUnityVersion: string;
isVRCProject: boolean;
}> {
const use = useMigrationInternal({
projectPath,
updateProjectPreUnityLaunch: async (project, data) => {
if (
data.isVRC &&
data.kind === "upgradeMajor" &&
data.targetUnityVersion.startsWith("2022.")
) {
await commands.projectMigrateProjectTo2022(project);
}
},
findUnity: findUnityForUnityChange,
refresh,
ConfirmComponent: UnityVersionChange,
dialogHeader: (data) => {
if (data.isVRC && data.isTargetVersionSupportedByVRC) {
switch (data.kind) {
case "upgradePatchOrMinor":
case "upgradeMajor":
return tc("projects:manage:dialog:unity migrate header");
}
}
return tc("projects:manage:dialog:unity change version header");
},
});
const request = use.request;
return {
dialog: use.dialog,
request: useCallback(
({ version, currentUnityVersion, isVRCProject }) => {
if (currentUnityVersion == null) throw new Error("unexpected");
const v = detectChangeUnityKind(
currentUnityVersion,
version,
isVRCProject,
);
request(v);
},
[request],
),
};
}
function UnityVersionChange({
cancel,
doMigrate,
data,
result,
}: ConfirmProps<ChangeUnityData>) {
// TODO: description
if (data.isVRC && data.isTargetVersionSupportedByVRC) {
// for supported migrations, show dialog same as migration
switch (data.kind) {
case "upgradePatchOrMinor":
return (
<MigrationConfirmMigrationPatchDialog
cancel={cancel}
doMigrate={doMigrate}
result={result}
data={{}}
/>
);
case "upgradeMajor":
return (
<MigrationConfirmMigrationDialog
cancel={cancel}
doMigrate={doMigrate}
result={result}
data={{}}
/>
);
}
}
let mainMessage: React.ReactNode;
switch (data.kind) {
case "downgradeMajor":
if (data.isVRC) {
if (data.isTargetVersionSupportedByVRC) {
mainMessage = tc([
"projects:manage:dialog:downgrade major vrchat supported",
"projects:manage:dialog:downgrade major",
]);
} else {
mainMessage = tc([
"projects:manage:dialog:downgrade major vrchat unsupported",
"projects:manage:dialog:downgrade major",
]);
}
} else {
mainMessage = tc("projects:manage:dialog:downgrade major");
}
break;
case "downgradePatchOrMinor":
if (data.isVRC) {
if (data.isTargetVersionSupportedByVRC) {
mainMessage = tc([
"projects:manage:dialog:downgrade minor vrchat supported",
"projects:manage:dialog:downgrade minor",
]);
} else {
mainMessage = tc([
"projects:manage:dialog:downgrade minor vrchat unsupported",
"projects:manage:dialog:downgrade minor",
]);
}
} else {
mainMessage = tc("projects:manage:dialog:downgrade minor");
}
break;
case "upgradePatchOrMinor":
if (data.isVRC) {
if (data.isTargetVersionSupportedByVRC) {
mainMessage = tc([
"projects:manage:dialog:upgrade minor vrchat supported",
"projects:manage:dialog:upgrade minor",
]);
} else {
mainMessage = tc([
"projects:manage:dialog:upgrade minor vrchat unsupported",
"projects:manage:dialog:upgrade minor",
]);
}
} else {
mainMessage = tc("projects:manage:dialog:upgrade minor");
}
break;
case "upgradeMajor":
if (data.isVRC) {
if (data.isTargetVersionSupportedByVRC) {
mainMessage = tc([
"projects:manage:dialog:upgrade major vrchat supported",
"projects:manage:dialog:upgrade major",
]);
} else {
mainMessage = tc([
"projects:manage:dialog:upgrade major vrchat unsupported",
"projects:manage:dialog:upgrade major",
]);
}
} else {
mainMessage = tc("projects:manage:dialog:upgrade major");
}
break;
default:
assertNever(data.kind);
}
return (
<>
<DialogDescription>
<p className={"text-destructive"}>{mainMessage}</p>
</DialogDescription>
<DialogFooter>
<Button onClick={cancel} className="mr-1">
{tc("general:button:cancel")}
</Button>
<Button onClick={() => doMigrate(true)} variant={"destructive"}>
{tc("projects:button:change unity version")}
</Button>
</DialogFooter>
</>
);
}
type ChangeUnityKind =
| "downgradeMajor"
| "downgradePatchOrMinor"
| "upgradePatchOrMinor"
| "upgradeMajor";
type ChangeUnityData = (
| {
kind: ChangeUnityKind;
isVRC: false;
}
| {
kind: ChangeUnityKind;
isVRC: true;
isTargetVersionSupportedByVRC: boolean;
}
) & {
targetUnityVersion: string;
};
function detectChangeUnityKind(
currentVersion: string,
targetUnityVersion: string,
isVRCProject: boolean,
): ChangeUnityData {
// biome-ignore lint/style/noNonNullAssertion: the version is known to be valid
const parsedCurrent = parseUnityVersion(currentVersion)!;
// biome-ignore lint/style/noNonNullAssertion: the version is known to be valid
const parsedTarget = parseUnityVersion(targetUnityVersion)!;
const kind: ChangeUnityData["kind"] =
compareUnityVersionString(currentVersion, targetUnityVersion) >= 0
? parsedCurrent.major === parsedTarget.major
? "downgradePatchOrMinor"
: "downgradeMajor"
: parsedCurrent.major === parsedTarget.major
? "upgradePatchOrMinor"
: "upgradeMajor";
if (isVRCProject) {
const supportedVersions = ["2019.4.31f1", "2022.3.6f1", "2022.3.22f1"];
return {
kind,
isVRC: true,
isTargetVersionSupportedByVRC:
supportedVersions.includes(targetUnityVersion),
targetUnityVersion,
};
} else {
return {
kind,
isVRC: false,
targetUnityVersion,
};
}
}
function findUnityForUnityChange(
unityVersions: TauriUnityVersions,
data: ChangeUnityData,
): FindUnityResult {
const foundVersions = unityVersions.unity_paths.filter(
([_p, v, _]) => v === data.targetUnityVersion,
);
if (foundVersions.length === 0) throw new Error("unreachable");
return {
expectingVersion: data.targetUnityVersion,
found: true,
installations: foundVersions,
};
}
// endregion
type StateInternal<Data> =
| {
state: "normal";
}
| {
state: "confirm";
data: Data;
findResult: FindUnityResult & { found: true };
}
| {
state: "noExactUnity2022";
data: Data;
findResult: FindUnityResult & { found: false };
}
| {
state: "copyingProject";
data: Data;
}
| {
state: "updating";
data: Data;
}
| {
state: "finalizing";
data: Data;
lines: [number, string][];
};
type Result<Data> = {
dialog: React.ReactNode;
request: (data: Data) => void;
};
type ConfirmProps<Data = Record<string, never>> = {
result: FindUnityResult;
data: Data;
cancel: () => void;
doMigrate: (inPlace: boolean) => void;
};
type FindUnityResult = FindUnityFoundResult | FindUnityNotFoundResult;
interface FindUnityFoundResult {
expectingVersion: string;
found: true;
installations: UnityInstallation[];
}
interface FindUnityNotFoundResult {
expectingVersion: string;
installLink: string;
found: false;
}
function useMigrationInternal<Data>({
projectPath,
updateProjectPreUnityLaunch,
findUnity,
refresh,
ConfirmComponent,
dialogHeader,
}: {
projectPath: string;
updateProjectPreUnityLaunch: (
projectPath: string,
data: Data,
) => Promise<unknown>;
findUnity: (unityVersions: TauriUnityVersions, data: Data) => FindUnityResult;
refresh?: () => void;
ConfirmComponent: React.ComponentType<ConfirmProps<Data>>;
dialogHeader: (data: Data) => React.ReactNode;
}): Result<Data> {
const router = useRouter();
const unitySelector = useUnitySelectorDialog();
const [installStatus, setInstallStatus] = React.useState<StateInternal<Data>>(
{ state: "normal" },
);
const request = async (data: Data) => {
if (await commands.projectIsUnityLaunching(projectPath)) {
toastError(tt("projects:toast:close unity before migration"));
return;
}
const unityVersions = await commands.environmentUnityVersions();
const findResult = findUnity(unityVersions, data);
if (!findResult.found) {
setInstallStatus({ state: "noExactUnity2022", data, findResult });
} else setInstallStatus({ state: "confirm", data, findResult });
};
const startChangeUnityVersion = async (
inPlace: boolean,
unityFound: UnityInstallation[],
data: Data,
) => {
try {
switch (unityFound.length) {
case 0:
throw new Error("unreachable");
case 1:
void continueChangeUnityVersion(inPlace, unityFound[0][0], data);
break;
default: {
const selected = await unitySelector.select(unityFound);
if (selected == null) setInstallStatus({ state: "normal" });
else
void continueChangeUnityVersion(inPlace, selected.unityPath, data);
break;
}
}
} catch (e) {
console.error(e);
toastThrownError(e);
setInstallStatus({ state: "normal" });
}
};
const continueChangeUnityVersion = async (
inPlace: boolean,
unityPath: string,
data: Data,
) => {
try {
let migrateProjectPath: string;
if (inPlace) {
migrateProjectPath = projectPath;
} else {
// copy
setInstallStatus({ state: "copyingProject", data });
migrateProjectPath =
await commands.environmentCopyProjectForMigration(projectPath);
}
setInstallStatus({ state: "updating", data });
await updateProjectPreUnityLaunch(migrateProjectPath, data);
setInstallStatus({ state: "finalizing", lines: [], data });
let lineNumber = 0;
const [, promise] = callAsyncCommand(
commands.projectCallUnityForMigration,
[migrateProjectPath, unityPath],
(lineString) => {
setInstallStatus((prev) => {
if (prev.state !== "finalizing") return prev;
lineNumber++;
const line: [number, string] = [lineNumber, lineString];
if (prev.lines.length > 200) {
return { ...prev, lines: [...prev.lines.slice(1), line] };
} else {
return { ...prev, lines: [...prev.lines, line] };
}
});
},
);
const finalizeResult = await promise;
if (finalizeResult === "cancelled") {
throw new Error("unexpectedly cancelled");
}
switch (finalizeResult.type) {
case "ExistsWithNonZero":
toastError(tt("projects:toast:unity exits with non-zero"));
break;
case "FinishedSuccessfully":
toastSuccess(tt("projects:toast:unity migrated"));
break;
default:
assertNever(finalizeResult);
}
if (inPlace) {
setInstallStatus({ state: "normal" });
refresh?.();
} else {
setInstallStatus({ state: "normal" });
router.replace(
`/projects/manage?${new URLSearchParams({ projectPath: migrateProjectPath })}`,
);
}
} catch (e) {
console.error(e);
toastThrownError(e);
setInstallStatus({ state: "normal" });
}
};
const cancelChangeUnityVersion = async () => {
setInstallStatus({ state: "normal" });
};
let dialogHeaderForState: React.ReactNode = null;
let dialogBodyForState: React.ReactNode = null;
switch (installStatus.state) {
case "normal":
dialogBodyForState = null;
break;
case "confirm":
dialogHeaderForState = dialogHeader(installStatus.data);
dialogBodyForState = (
<ConfirmComponent
result={installStatus.findResult}
cancel={cancelChangeUnityVersion}
data={installStatus.data}
doMigrate={(inPlace) =>
startChangeUnityVersion(
inPlace,
installStatus.findResult.installations,
installStatus.data,
)
}
/>
);
break;
case "copyingProject":
dialogHeaderForState = dialogHeader(installStatus.data);
dialogBodyForState = <MigrationCopyingDialog />;
break;
case "updating":
dialogHeaderForState = dialogHeader(installStatus.data);
dialogBodyForState = <MigrationMigratingDialog />;
break;
case "noExactUnity2022":
dialogHeaderForState = dialogHeader(installStatus.data);
dialogBodyForState = (
<NoExactUnity2022Dialog
expectedVersion={installStatus.findResult.expectingVersion}
installWithUnityHubLink={installStatus.findResult.installLink}
close={cancelChangeUnityVersion}
/>
);
break;
case "finalizing":
dialogHeaderForState = dialogHeader(installStatus.data);
dialogBodyForState = (
<MigrationCallingUnityForMigrationDialog lines={installStatus.lines} />
);
break;
default:
assertNever(installStatus);
}
return {
dialog: (
<>
{unitySelector.dialog}
{dialogBodyForState == null ? null : (
<DialogOpen className={"whitespace-normal leading-relaxed"}>
<DialogTitle>{dialogHeaderForState}</DialogTitle>
{dialogBodyForState}
</DialogOpen>
)}
</>
),
request,
};
}
function MigrationCopyingDialog() {
return (
<DialogDescription>
<p>{tc("projects:pre-migrate copying...")}</p>
<p>{tc("projects:do not close")}</p>
</DialogDescription>
);
}
function MigrationMigratingDialog() {
return (
<DialogDescription>
<p>{tc("projects:migrating...")}</p>
<p>{tc("projects:do not close")}</p>
</DialogDescription>
);
}
function MigrationCallingUnityForMigrationDialog({
lines,
}: {
lines: [number, string][];
}) {
const ref = React.useRef<HTMLDivElement>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: we want to scroll to bottom on lines changed
React.useEffect(() => {
ref.current?.scrollIntoView({ behavior: "auto" });
}, [lines]);
return (
<DialogDescription>
<p>{tc("projects:manage:dialog:unity migrate finalizing...")}</p>
<p>{tc("projects:do not close")}</p>
{/* TODO: use ScrollArea (I failed to use it inside dialog) */}
<pre
className={
"overflow-y-auto h-[50vh] bg-secondary text-secondary-foreground text-sm"
}
>
{lines.map(([lineNumber, line]) => (
<Fragment key={lineNumber}>
{line}
{"\n"}
</Fragment>
))}
<div ref={ref} />
</pre>
</DialogDescription>
);
}
function NoExactUnity2022Dialog({
expectedVersion,
installWithUnityHubLink,
close,
}: {
expectedVersion: string;
installWithUnityHubLink: string;
close: () => void;
}) {
const openUnityHub = async () => {
await commands.utilOpenUrl(installWithUnityHubLink);
};
return (
<>
<DialogDescription>
<p>
{tc(
"projects:manage:dialog:exact version unity not found for patch migration description",
{ unity: expectedVersion },
)}
</p>
</DialogDescription>
<DialogFooter className={"gap-2"}>
<Button onClick={openUnityHub}>
{tc("projects:dialog:open unity hub")}
</Button>
<Button onClick={close} className="mr-1">
{tc("general:button:close")}
</Button>
</DialogFooter>
</>
);
}

View file

@ -0,0 +1,489 @@
import { Button } from "@/components/ui/button";
import {
DialogDescription,
DialogFooter,
DialogOpen,
DialogTitle,
} from "@/components/ui/dialog";
import { assertNever } from "@/lib/assert-never";
import type {
TauriBasePackageInfo,
TauriPackage,
TauriPackageChange,
TauriPendingProjectChanges,
TauriRemoveReason,
} from "@/lib/bindings";
import { commands } from "@/lib/bindings";
import { tc, tt } from "@/lib/i18n";
import { toastInfo, toastSuccess, toastThrownError } from "@/lib/toast";
import { compareVersion, toVersionString } from "@/lib/version";
import type React from "react";
import { useCallback, useMemo, useState } from "react";
import type { PackageRowInfo } from "./collect-package-row-info";
export type RequestedOperation =
| {
type: "install";
pkg: TauriPackage;
hasUnityIncompatibleLatest?: boolean;
}
| {
type: "upgradeAll";
hasUnityIncompatibleLatest: boolean;
}
| {
type: "resolve";
}
| {
type: "reinstallAll";
}
| {
type: "remove";
displayName: string;
}
| {
type: "bulkInstalled";
hasUnityIncompatibleLatest: boolean;
}
| {
type: "bulkReinstalled";
}
| {
type: "bulkRemoved";
};
type InstallStatus =
| {
status: "normal";
}
| {
status: "creatingChanges";
}
| {
status: "promptingChanges";
changes: TauriPendingProjectChanges;
requested: RequestedOperation;
}
| {
status: "applyingChanges";
};
interface PackageChangeDialog {
createChanges: (
operation: RequestedOperation,
createPromise: Promise<TauriPendingProjectChanges>,
) => void;
dialog: React.ReactNode;
installingPackage: boolean;
}
export function usePackageChangeDialog({
projectPath,
onRefreshProject,
packageRowsData,
existingPackages,
}: {
projectPath: string;
onRefreshProject: () => void;
packageRowsData: PackageRowInfo[];
existingPackages?: [string, TauriBasePackageInfo][];
}): PackageChangeDialog {
const [installStatus, setInstallStatus] = useState<InstallStatus>({
status: "normal",
});
const createChanges = useCallback(
async (
operation: RequestedOperation,
createPromise: Promise<TauriPendingProjectChanges>,
) => {
try {
setInstallStatus({ status: "creatingChanges" });
const changes = await createPromise;
setInstallStatus({
status: "promptingChanges",
changes,
requested: operation,
});
} catch (e) {
console.error(e);
toastThrownError(e);
setInstallStatus({ status: "normal" });
}
},
[],
);
let dialogForState: React.ReactNode = null;
switch (installStatus.status) {
case "promptingChanges": {
const applyChanges = async ({
changes,
requested,
}: {
changes: TauriPendingProjectChanges;
requested: RequestedOperation;
}) => {
try {
setInstallStatus({ status: "applyingChanges" });
await commands.projectApplyPendingChanges(
projectPath,
changes.changes_version,
);
setInstallStatus({ status: "normal" });
onRefreshProject();
switch (requested.type) {
case "install":
toastSuccess(
tt("projects:manage:toast:package installed", {
name: requested.pkg.display_name ?? requested.pkg.name,
version: toVersionString(requested.pkg.version),
}),
);
if (requested.hasUnityIncompatibleLatest) {
toastInfo(
tt(
"projects:manage:toast:the package has newer latest with incompatible unity",
),
);
}
break;
case "remove":
toastSuccess(
tt("projects:manage:toast:package removed", {
name: requested.displayName,
}),
);
break;
case "resolve":
toastSuccess(tt("projects:manage:toast:resolved"));
break;
case "reinstallAll":
toastSuccess(
tt("projects:manage:toast:all packages reinstalled"),
);
break;
case "upgradeAll":
toastSuccess(tt("projects:manage:toast:all packages upgraded"));
if (requested.hasUnityIncompatibleLatest) {
toastInfo(
tt(
"projects:manage:toast:some package has newer latest with incompatible unity",
),
);
}
break;
case "bulkInstalled":
toastSuccess(
tt("projects:manage:toast:selected packages installed"),
);
if (requested.hasUnityIncompatibleLatest) {
toastInfo(
tt(
"projects:manage:toast:some package has newer latest with incompatible unity",
),
);
}
break;
case "bulkRemoved":
toastSuccess(
tt("projects:manage:toast:selected packages removed"),
);
break;
case "bulkReinstalled":
toastSuccess(
tt("projects:manage:toast:selected packages reinstalled"),
);
break;
default:
assertNever(requested);
}
} catch (e) {
console.error(e);
setInstallStatus({ status: "normal" });
toastThrownError(e);
}
};
const cancel = async () => {
setInstallStatus({ status: "normal" });
try {
await commands.projectClearPendingChanges();
} catch (e) {
console.error(e);
toastThrownError(e);
}
};
dialogForState = (
<ProjectChangesDialog
packages={packageRowsData}
changes={installStatus.changes}
existingPackages={existingPackages}
cancel={cancel}
apply={() => applyChanges(installStatus)}
/>
);
break;
}
}
return {
dialog: dialogForState,
createChanges,
installingPackage: installStatus.status !== "normal",
};
}
function ProjectChangesDialog({
changes,
packages,
existingPackages,
cancel,
apply,
}: {
changes: TauriPendingProjectChanges;
packages: PackageRowInfo[];
existingPackages?: [string, TauriBasePackageInfo][];
cancel: () => void;
apply: () => void;
}) {
const versionConflicts = changes.conflicts.filter(
([_, c]) => c.packages.length > 0,
);
const unityConflicts = changes.conflicts.filter(([_, c]) => c.unity_conflict);
const getPackageDisplayName = useMemo(() => {
const packagesById = new Map(packages.map((p) => [p.id, p]));
return (pkgId: string) => packagesById.get(pkgId)?.displayName ?? pkgId;
}, [packages]);
const TypographyItem = ({ children }: { children: React.ReactNode }) => (
<div className={"p-3"}>
<p className={"font-normal"}>{children}</p>
</div>
);
function isInstallNew(
pair: [string, TauriPackageChange],
): pair is [string, { InstallNew: TauriPackage }] {
return "InstallNew" in pair[1];
}
function isRemove(
pair: [string, TauriPackageChange],
): pair is [string, { Remove: TauriRemoveReason }] {
return "Remove" in pair[1];
}
const existingPackageMap = new Map(existingPackages ?? []);
const installingPackages = changes.package_changes.filter(isInstallNew);
const removingPackages = changes.package_changes.filter(isRemove);
const reInstallingPackages = installingPackages.filter(([pkgId, c]) => {
const info = existingPackageMap.get(pkgId);
return (
info !== undefined &&
compareVersion(c.InstallNew.version, info.version) === 0
);
});
const installingNewPackages = installingPackages.filter(([pkgId, c]) => {
const info = existingPackageMap.get(pkgId);
return (
info === undefined ||
compareVersion(c.InstallNew.version, info.version) !== 0
);
});
const removingRequestedPackages = removingPackages.filter(
([_, c]) => c.Remove === "Requested",
);
const removingLegacyPackages = removingPackages.filter(
([_, c]) => c.Remove === "Legacy",
);
const removingUnusedPackages = removingPackages.filter(
([_, c]) => c.Remove === "Unused",
);
reInstallingPackages.sort(comparePackageChangeByName);
installingNewPackages.sort(comparePackageChangeByName);
removingRequestedPackages.sort(comparePackageChangeByName);
removingLegacyPackages.sort(comparePackageChangeByName);
removingUnusedPackages.sort(comparePackageChangeByName);
const ChangelogButton = ({ url }: { url?: string | null }) => {
if (url == null) return null;
try {
const parsed = new URL(url);
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
return (
<Button
className={"ml-1 px-2"}
size={"sm"}
onClick={() => commands.utilOpenUrl(url)}
>
{tc("projects:manage:button:see changelog")}
</Button>
);
}
} catch {}
return null;
};
return (
<DialogOpen className={"whitespace-normal"}>
<DialogTitle>{tc("projects:manage:button:apply changes")}</DialogTitle>
{/* TODO: use ScrollArea (I failed to use it inside dialog) */}
<DialogDescription className={"overflow-y-auto max-h-[50vh]"}>
<p>{tc("projects:manage:dialog:confirm changes description")}</p>
<div className={"flex flex-col gap-1 p-2"}>
{installingNewPackages.map(([pkgId, pkgChange]) => {
const name =
pkgChange.InstallNew.display_name ?? pkgChange.InstallNew.name;
const version = toVersionString(pkgChange.InstallNew.version);
return (
<div key={pkgId} className={"flex items-center p-3"}>
<p className={"font-normal"}>
{tc("projects:manage:dialog:install package", {
name,
version,
})}
</p>
<ChangelogButton url={pkgChange.InstallNew.changelog_url} />
</div>
);
})}
{installingNewPackages.length > 0 &&
reInstallingPackages.length > 0 && <hr />}
{reInstallingPackages.map(([pkgId, pkgChange]) => {
const name =
pkgChange.InstallNew.display_name ?? pkgChange.InstallNew.name;
const version = toVersionString(pkgChange.InstallNew.version);
return (
<div key={pkgId} className={"flex items-center p-3"}>
<p className={"font-normal"}>
{tc("projects:manage:dialog:reinstall package", {
name,
version,
})}
</p>
<ChangelogButton url={pkgChange.InstallNew.changelog_url} />
</div>
);
})}
{removingRequestedPackages.map(([pkgId, _]) => {
const name = getPackageDisplayName(pkgId);
return (
<TypographyItem key={pkgId}>
{tc("projects:manage:dialog:uninstall package as requested", {
name,
})}
</TypographyItem>
);
})}
{removingLegacyPackages.map(([pkgId, _]) => {
const name = getPackageDisplayName(pkgId);
return (
<TypographyItem key={pkgId}>
{tc("projects:manage:dialog:uninstall package as legacy", {
name,
})}
</TypographyItem>
);
})}
{removingUnusedPackages.map(([pkgId, _]) => {
const name = getPackageDisplayName(pkgId);
return (
<TypographyItem key={pkgId}>
{tc("projects:manage:dialog:uninstall package as unused", {
name,
})}
</TypographyItem>
);
})}
</div>
{versionConflicts.length > 0 ? (
<>
<p className={"text-destructive"}>
{tc("projects:manage:dialog:package version conflicts", {
count: versionConflicts.length,
})}
</p>
<div className={"flex flex-col gap-1 p-2"}>
{versionConflicts.map(([pkgId, conflict]) => {
return (
<TypographyItem key={pkgId}>
{tc("projects:manage:dialog:conflicts with", {
pkg: getPackageDisplayName(pkgId),
other: conflict.packages
.map((p) => getPackageDisplayName(p))
.join(", "),
})}
</TypographyItem>
);
})}
</div>
</>
) : null}
{unityConflicts.length > 0 ? (
<>
<p className={"text-destructive"}>
{tc("projects:manage:dialog:unity version conflicts", {
count: unityConflicts.length,
})}
</p>
<div className={"flex flex-col gap-1 p-2"}>
{unityConflicts.map(([pkgId, _]) => (
<TypographyItem key={pkgId}>
{tc(
"projects:manage:dialog:package not supported your unity",
{ pkg: getPackageDisplayName(pkgId) },
)}
</TypographyItem>
))}
</div>
</>
) : null}
{changes.remove_legacy_files.length > 0 ||
changes.remove_legacy_folders.length > 0 ? (
<>
<p className={"text-destructive"}>
{tc(
"projects:manage:dialog:files and directories are removed as legacy",
)}
</p>
<div className={"flex flex-col gap-1 p-2"}>
{changes.remove_legacy_files.map((f) => (
<TypographyItem key={f}>{f}</TypographyItem>
))}
{changes.remove_legacy_folders.map((f) => (
<TypographyItem key={f}>{f}</TypographyItem>
))}
</div>
</>
) : null}
</DialogDescription>
<DialogFooter>
<Button onClick={cancel} className="mr-1">
{tc("general:button:cancel")}
</Button>
<Button onClick={apply} variant={"destructive"}>
{tc("projects:manage:button:apply")}
</Button>
</DialogFooter>
</DialogOpen>
);
}
function comparePackageChangeByName(
[aName, _1]: [string, TauriPackageChange],
[bName, _2]: [string, TauriPackageChange],
): number {
return aName.localeCompare(bName);
}

View file

@ -0,0 +1,443 @@
"use client";
import { ScrollableCardTable } from "@/components/ScrollableCardTable";
import { SearchBox } from "@/components/SearchBox";
import { HNavBar, VStack } from "@/components/layout";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { assertNever } from "@/lib/assert-never";
import type { TauriProject, TauriProjectType } from "@/lib/bindings";
import { commands } from "@/lib/bindings";
import { isFindKey, useDocumentEvent } from "@/lib/events";
import { tc, tt } from "@/lib/i18n";
import { toastError, toastSuccess, toastThrownError } from "@/lib/toast";
import { useFilePickerFunction } from "@/lib/use-file-picker-dialog";
import { type OpenUnityFunction, useOpenUnity } from "@/lib/use-open-unity";
import { compareUnityVersionString } from "@/lib/version";
import { useQuery } from "@tanstack/react-query";
import {
ChevronDown,
ChevronUp,
ChevronsUpDown,
RefreshCw,
Star,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { CreateProject } from "./create-project";
import { ProjectRow } from "./project-row";
const sortings = ["lastModified", "name", "unity", "type"] as const;
type SimpleSorting = (typeof sortings)[number];
type Sorting = SimpleSorting | `${SimpleSorting}Reversed`;
function isSorting(s: string): s is Sorting {
return sortings.some(
(sorting) => sorting === s || `${sorting}Reversed` === s,
);
}
export default function Page() {
const result = useQuery({
queryKey: ["projects"],
queryFn: commands.environmentProjects,
});
const [search, setSearch] = useState("");
const [loadingOther, setLoadingOther] = useState(false);
const [createProjectState, setCreateProjectState] = useState<
"normal" | "creating"
>("normal");
const openUnity = useOpenUnity();
const startCreateProject = () => setCreateProjectState("creating");
const loading = result.isFetching || loadingOther;
return (
<VStack>
<ProjectViewHeader
className={"flex-shrink-0"}
refresh={() => result.refetch()}
startCreateProject={startCreateProject}
isLoading={loading}
search={search}
setSearch={setSearch}
/>
{result.status === "pending" ? (
<Card className="w-full shadow-none overflow-hidden p-4">
{tc("general:loading...")}
</Card>
) : result.status === "error" ? (
<Card className="w-full shadow-none overflow-hidden p-4">
{tc("projects:error:load error", { msg: result.error.message })}
</Card>
) : (
<ProjectsTableCard
projects={result.data}
search={search}
loading={loading}
openUnity={openUnity.openUnity}
refresh={() => result.refetch()}
onRemoved={() => result.refetch()}
/>
)}
{createProjectState === "creating" && (
<CreateProject
close={() => setCreateProjectState("normal")}
refetch={() => result.refetch()}
/>
)}
{openUnity.dialog}
</VStack>
);
}
function compareProjectType(
a: TauriProjectType,
b: TauriProjectType,
): 0 | -1 | 1 {
if (a === b) return 0;
// legacy unknown
if (a === "LegacySdk2") return 1;
if (b === "LegacySdk2") return -1;
if (a === "UpmStarter") return 1;
if (b === "UpmStarter") return -1;
// legacy worlds
if (a === "LegacyWorlds") return 1;
if (b === "LegacyWorlds") return -1;
if (a === "UpmWorlds") return 1;
if (b === "UpmWorlds") return -1;
// legacy avatars
if (a === "LegacyAvatars") return 1;
if (b === "LegacyAvatars") return -1;
if (a === "UpmAvatars") return 1;
if (b === "UpmAvatars") return -1;
// unknown
if (a === "Unknown") return 1;
if (b === "Unknown") return -1;
if (a === "VpmStarter") return 1;
if (b === "VpmStarter") return -1;
// worlds
if (a === "Worlds") return 1;
if (b === "Worlds") return -1;
// avatars
if (a === "Avatars") return 1;
if (b === "Avatars") return -1;
assertNever(a, "project type");
}
function ProjectsTableCard({
projects,
search,
onRemoved,
loading,
refresh,
openUnity,
}: {
projects: TauriProject[];
openUnity: OpenUnityFunction;
search?: string;
loading?: boolean;
onRemoved?: () => void;
refresh?: () => void;
}) {
const [sorting, setSortingState] = useState<Sorting>("lastModified");
useEffect(() => {
(async () => {
let newSorting = await commands.environmentGetProjectSorting();
if (newSorting === null) newSorting = "lastModified";
if (!isSorting(newSorting)) {
setSortingState("lastModified");
} else {
setSortingState(newSorting);
}
})();
}, []);
const projectsShown = useMemo(() => {
const searched = projects.filter((project) =>
project.name.toLowerCase().includes(search?.toLowerCase() ?? ""),
);
searched.sort((a, b) => b.last_modified - a.last_modified);
switch (sorting) {
case "lastModified":
// already sorted
break;
case "lastModifiedReversed":
searched.sort((a, b) => a.last_modified - b.last_modified);
break;
case "name":
searched.sort((a, b) => a.name.localeCompare(b.name));
break;
case "nameReversed":
searched.sort((a, b) => b.name.localeCompare(a.name));
break;
case "type":
searched.sort((a, b) =>
compareProjectType(a.project_type, b.project_type),
);
break;
case "typeReversed":
searched.sort((a, b) =>
compareProjectType(b.project_type, a.project_type),
);
break;
case "unity":
searched.sort((a, b) => compareUnityVersionString(a.unity, b.unity));
break;
case "unityReversed":
searched.sort((a, b) => compareUnityVersionString(b.unity, a.unity));
break;
default:
assertNever(sorting);
}
searched.sort((a, b) => {
if (a.favorite && !b.favorite) return -1;
if (!a.favorite && b.favorite) return 1;
return 0;
});
return searched;
}, [projects, sorting, search]);
const thClass = "sticky top-0 z-10 border-b border-primary p-2.5";
const iconClass = "size-3 invisible project-table-header-chevron-up-down";
const setSorting = async (simpleSorting: SimpleSorting) => {
let newSorting: Sorting;
if (sorting === simpleSorting) {
newSorting = `${simpleSorting}Reversed`;
} else if (sorting === `${simpleSorting}Reversed`) {
newSorting = simpleSorting;
} else {
newSorting = simpleSorting;
}
setSortingState(newSorting);
try {
await commands.environmentSetProjectSorting(newSorting);
} catch (e) {
console.error("Error setting project sorting", e);
toastThrownError(e);
}
};
const headerBg = (target: SimpleSorting) =>
sorting === target || sorting === `${target}Reversed`
? "bg-primary text-primary-foreground"
: "bg-secondary text-secondary-foreground";
const icon = (target: SimpleSorting) =>
sorting === target ? (
<ChevronDown className={"size-3"} />
) : sorting === `${target}Reversed` ? (
<ChevronUp className={"size-3"} />
) : (
<ChevronsUpDown className={iconClass} />
);
return (
<ScrollableCardTable>
<thead>
<tr>
<th className={`${thClass} bg-secondary text-secondary-foreground`}>
<Star className={"size-4"} />
</th>
<th className={`${thClass} ${headerBg("name")}`}>
<button
type="button"
className={"flex w-full project-table-button"}
onClick={() => setSorting("name")}
>
{icon("name")}
<small className="font-normal leading-none">
{tc("general:name")}
</small>
</button>
</th>
<th className={`${thClass} ${headerBg("type")}`}>
<button
type="button"
className={"flex w-full project-table-button"}
onClick={() => setSorting("type")}
>
{icon("type")}
<small className="font-normal leading-none">
{tc("projects:type")}
</small>
</button>
</th>
<th className={`${thClass} ${headerBg("unity")}`}>
<button
type="button"
className={"flex w-full project-table-button"}
onClick={() => setSorting("unity")}
>
{icon("unity")}
<small className="font-normal leading-none">
{tc("projects:unity")}
</small>
</button>
</th>
<th className={`${thClass} ${headerBg("lastModified")}`}>
<button
type="button"
className={"flex w-full project-table-button"}
onClick={() => setSorting("lastModified")}
>
{icon("lastModified")}
<small className="font-normal leading-none">
{tc("projects:last modified")}
</small>
</button>
</th>
<th className={`${thClass} bg-secondary text-secondary-foreground`} />
</tr>
</thead>
<tbody>
{projectsShown.map((project) => (
<ProjectRow
key={project.index}
project={project}
loading={loading}
refresh={refresh}
onRemoved={onRemoved}
openUnity={openUnity}
/>
))}
</tbody>
</ScrollableCardTable>
);
}
function ProjectViewHeader({
className,
refresh,
startCreateProject,
isLoading,
search,
setSearch,
}: {
className?: string;
refresh?: () => void;
startCreateProject?: () => void;
isLoading?: boolean;
search: string;
setSearch: (search: string) => void;
}) {
const [addProjectWithPicker, dialog] = useFilePickerFunction(
commands.environmentAddProjectWithPicker,
);
const addProject = async () => {
try {
const result = await addProjectWithPicker();
switch (result) {
case "NoFolderSelected":
// no-op
break;
case "InvalidSelection":
toastError(tt("general:toast:invalid directory"));
break;
case "Successful":
toastSuccess(tt("projects:toast:project added"));
refresh?.();
break;
case "AlreadyAdded":
toastError(tt("projects:toast:project already exists"));
break;
default:
assertNever(result);
}
} catch (e) {
console.error("Error adding project", e);
toastThrownError(e);
}
};
const searchRef = useRef<HTMLInputElement>(null);
useDocumentEvent(
"keydown",
(e) => {
if (isFindKey(e)) {
searchRef.current?.focus();
}
},
[],
);
return (
<HNavBar className={`${className}`}>
<p className="cursor-pointer py-1.5 font-bold flex-grow-0">
{tc("projects")}
</p>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={"ghost"}
size={"icon"}
onClick={() => refresh?.()}
disabled={isLoading}
>
{isLoading ? (
<RefreshCw className="w-5 h-5 animate-spin" />
) : (
<RefreshCw className={"w-5 h-5"} />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{tc("projects:tooltip:refresh")}</TooltipContent>
</Tooltip>
<SearchBox
className={"w-max flex-grow"}
value={search}
onChange={(e) => setSearch(e.target.value)}
ref={searchRef}
/>
<DropdownMenu>
<div className={"flex divide-x"}>
<Button
className={"rounded-r-none pl-4 pr-3"}
onClick={startCreateProject}
>
{tc("projects:create new project")}
</Button>
<DropdownMenuTrigger asChild className={"rounded-l-none pl-2 pr-2"}>
<Button>
<ChevronDown className={"w-4 h-4"} />
</Button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent>
<DropdownMenuItem onClick={addProject}>
{tc("projects:add existing project")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{dialog}
</HNavBar>
);
}

View file

@ -0,0 +1,548 @@
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
DialogDescription,
DialogFooter,
DialogOpen,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipPortal,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useBackupProjectModal } from "@/lib/backup-project";
import type { TauriProject, TauriProjectType } from "@/lib/bindings";
import { commands } from "@/lib/bindings";
import { tc, tt } from "@/lib/i18n";
import { useRemoveProjectModal } from "@/lib/remove-project";
import { toastError, toastSuccess, toastThrownError } from "@/lib/toast";
import type { OpenUnityFunction } from "@/lib/use-open-unity";
import {
CircleHelp,
CircleUserRound,
Ellipsis,
Globe,
Star,
} from "lucide-react";
import { useRouter } from "next/navigation";
import React, {
type ComponentProps,
forwardRef,
useContext,
useState,
} from "react";
const ProjectDisplayType: Record<
TauriProjectType,
"avatars" | "worlds" | "sdk2" | "unknown"
> = {
Unknown: "unknown",
LegacySdk2: "sdk2",
LegacyWorlds: "worlds",
LegacyAvatars: "avatars",
UpmWorlds: "worlds",
UpmAvatars: "avatars",
UpmStarter: "unknown",
Worlds: "worlds",
Avatars: "avatars",
VpmStarter: "unknown",
};
const LegacyProjectTypes = [
"LegacySdk2",
"LegacyWorlds",
"LegacyAvatars",
"UpmWorlds",
"UpmAvatars",
"UpmStarter",
];
export function ProjectRow({
project,
openUnity,
onRemoved,
loading,
refresh,
}: {
project: TauriProject;
openUnity: OpenUnityFunction;
onRemoved?: () => void;
loading?: boolean;
refresh?: () => void;
}) {
const removeProjectModal = useRemoveProjectModal({ onRemoved });
const backupProjectModal = useBackupProjectModal();
const cellClass = "p-2.5";
const noGrowCellClass = `${cellClass} w-1`;
const typeIconClass = "w-5 h-5";
const projectTypeKind = ProjectDisplayType[project.project_type] ?? "unknown";
const displayType = tc(`projects:type:${projectTypeKind}`);
const isLegacy = LegacyProjectTypes.includes(project.project_type);
const lastModified = new Date(project.last_modified);
const lastModifiedHumanReadable = `${lastModified.getFullYear().toString().padStart(4, "0")}-${(lastModified.getMonth() + 1).toString().padStart(2, "0")}-${lastModified.getDate().toString().padStart(2, "0")} ${lastModified.getHours().toString().padStart(2, "0")}:${lastModified.getMinutes().toString().padStart(2, "0")}:${lastModified.getSeconds().toString().padStart(2, "0")}`;
const openProjectFolder = () =>
commands.utilOpen(project.path, "ErrorIfNotExists");
const onToggleFavorite = async () => {
try {
await commands.environmentSetFavoriteProject(
project.list_version,
project.index,
!project.favorite,
);
refresh?.();
} catch (e) {
console.error("Error migrating project", e);
toastThrownError(e);
}
};
const removed = !project.is_exists;
return (
<ProjectRowContext.Provider value={{ removed, loading: Boolean(loading) }}>
<tr
className={`even:bg-secondary/30 ${removed || loading ? "opacity-50" : ""}`}
>
<td className={`${cellClass} w-3`}>
<div className={"relative inline-flex"}>
<Checkbox
checked={project.favorite}
onCheckedChange={onToggleFavorite}
disabled={removed || loading}
className="hover:before:content-none before:transition-none border-none !text-primary peer"
/>
<span
className={
"text-background opacity-0 peer-data-[state=checked]:opacity-100 pointer-events-none absolute top-2/4 left-2/4 -translate-y-2/4 -translate-x-2/4"
}
>
<Star strokeWidth={3} className={"size-3"} />
</span>
</div>
</td>
<td className={`${cellClass} max-w-64 overflow-hidden`}>
<Tooltip>
<TooltipTriggerIfRemoved
className={"text-left select-text cursor-auto w-full"}
>
<div className="flex flex-col">
<Tooltip>
<TooltipTriggerIfExists
className={"text-left select-text cursor-auto w-full"}
>
<p className="font-normal whitespace-pre">{project.name}</p>
</TooltipTriggerIfExists>
<TooltipContent>{project.name}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTriggerIfExists
className={"text-left select-text cursor-auto w-full"}
>
<p className="font-normal opacity-50 text-sm whitespace-pre">
{project.path}
</p>
</TooltipTriggerIfExists>
<TooltipContent>{project.path}</TooltipContent>
</Tooltip>
</div>
</TooltipTriggerIfRemoved>
<TooltipPortal>
<TooltipContent>
{tc("projects:tooltip:no directory")}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</td>
<td className={`${cellClass} w-[8em] min-w-[8em]`}>
<div className="flex flex-row gap-2">
<div className="flex items-center">
{projectTypeKind === "avatars" ? (
<CircleUserRound className={typeIconClass} />
) : projectTypeKind === "worlds" ? (
<Globe className={typeIconClass} />
) : (
<CircleHelp className={typeIconClass} />
)}
</div>
<div className="flex flex-col justify-center">
<p className="font-normal">{displayType}</p>
{isLegacy && (
<p className="font-normal opacity-50 dark:opacity-80 text-sm text-destructive">
{tc("projects:type:legacy")}
</p>
)}
</div>
</div>
</td>
<td className={noGrowCellClass}>
<p className="font-normal">{project.unity}</p>
</td>
<td className={noGrowCellClass}>
<Tooltip>
<TooltipTrigger>
<time dateTime={lastModified.toISOString()}>
<time className="font-normal">
{formatDateOffset(project.last_modified)}
</time>
</time>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>{lastModifiedHumanReadable}</TooltipContent>
</TooltipPortal>
</Tooltip>
</td>
<td className={noGrowCellClass}>
<div className="flex flex-row gap-2 max-w-min">
<ButtonDisabledIfRemoved
onClick={() =>
openUnity(project.path, project.unity, project.unity_revision)
}
>
{tc("projects:button:open unity")}
</ButtonDisabledIfRemoved>
<ManageOrMigrateButton project={project} refresh={refresh} />
<ButtonDisabledIfRemoved
onClick={() => backupProjectModal.startBackup(project)}
variant={"success"}
>
{tc("projects:backup")}
</ButtonDisabledIfRemoved>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size={"icon"}
className={
"hover:bg-primary/10 text-primary hover:text-primary"
}
>
<Ellipsis className={"size-5"} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onClick={openProjectFolder}
disabled={removed || loading}
>
{tc("projects:menuitem:open directory")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => removeProjectModal.startRemove(project)}
disabled={loading}
className={"text-destructive focus:text-destructive"}
>
{tc("projects:remove project")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{removeProjectModal.dialog}
{backupProjectModal.dialog}
</td>
</tr>
</ProjectRowContext.Provider>
);
}
function ManageOrMigrateButton({
project,
refresh,
}: {
project: TauriProject;
refresh?: () => void;
}) {
const router = useRouter();
switch (project.project_type) {
case "LegacySdk2":
return (
<Tooltip>
<TooltipTriggerIfExists asChild>
<ButtonDisabledIfRemoved variant="success" disabled>
{tc("projects:button:migrate")}
</ButtonDisabledIfRemoved>
</TooltipTriggerIfExists>
<TooltipContent>
{tc("projects:tooltip:sdk2 migration hint")}
</TooltipContent>
</Tooltip>
);
case "LegacyWorlds":
case "LegacyAvatars":
return <MigrateButton project={project} refresh={refresh} />;
case "UpmWorlds":
case "UpmAvatars":
case "UpmStarter":
return (
<Tooltip>
<TooltipTriggerIfExists asChild>
<ButtonDisabledIfRemoved variant="info" disabled>
{tc("projects:button:manage")}
</ButtonDisabledIfRemoved>
</TooltipTriggerIfExists>
<TooltipContent>
{tc("projects:tooltip:git-vcc not supported")}
</TooltipContent>
</Tooltip>
);
case "Unknown":
case "Worlds":
case "Avatars":
case "VpmStarter":
return (
<ButtonDisabledIfRemoved
onClick={() =>
router.push(
`/projects/manage?${new URLSearchParams({ projectPath: project.path })}`,
)
}
variant="info"
>
{tc("projects:button:manage")}
</ButtonDisabledIfRemoved>
);
}
}
function MigrateButton({
project,
refresh,
}: {
project: TauriProject;
refresh?: () => void;
}) {
type MigrateState =
| {
type: "normal";
}
| {
type: "migrateVpm:confirm";
}
| {
type: "migrateVpm:copyingProject";
}
| {
type: "migrateVpm:updating";
};
const [dialogStatus, setDialogStatus] = useState<MigrateState>({
type: "normal",
});
const startMigrateVpm = async () => {
if (await commands.projectIsUnityLaunching(project.path)) {
toastError(tt("projects:toast:close unity before migration"));
return;
}
setDialogStatus({ type: "migrateVpm:confirm" });
};
const doMigrateVpm = async (inPlace: boolean) => {
setDialogStatus({ type: "normal" });
try {
let migrateProjectPath: string;
if (inPlace) {
migrateProjectPath = project.path;
} else {
// copy
setDialogStatus({ type: "migrateVpm:copyingProject" });
migrateProjectPath = await commands.environmentCopyProjectForMigration(
project.path,
);
}
setDialogStatus({ type: "migrateVpm:updating" });
await commands.projectMigrateProjectToVpm(migrateProjectPath);
setDialogStatus({ type: "normal" });
toastSuccess(tt("projects:toast:project migrated"));
refresh?.();
} catch (e) {
console.error("Error migrating project", e);
setDialogStatus({ type: "normal" });
toastThrownError(e);
}
};
let dialogContent: React.ReactNode = null;
switch (dialogStatus.type) {
case "migrateVpm:confirm":
dialogContent = (
<DialogOpen className={"whitespace-normal"}>
<DialogTitle>{tc("projects:dialog:vpm migrate header")}</DialogTitle>
<DialogDescription>
<p className={"text-destructive"}>
{tc("projects:dialog:vpm migrate description")}
</p>
</DialogDescription>
<DialogFooter>
<Button
onClick={() => setDialogStatus({ type: "normal" })}
className="mr-1"
>
{tc("general:button:cancel")}
</Button>
<Button
onClick={() => doMigrateVpm(false)}
variant={"destructive"}
className="mr-1"
>
{tc("projects:button:migrate copy")}
</Button>
<Button onClick={() => doMigrateVpm(true)} variant={"destructive"}>
{tc("projects:button:migrate in-place")}
</Button>
</DialogFooter>
</DialogOpen>
);
break;
case "migrateVpm:copyingProject":
dialogContent = (
<DialogOpen className={"whitespace-normal"}>
<DialogTitle>{tc("projects:dialog:vpm migrate header")}</DialogTitle>
<DialogDescription>
<p>{tc("projects:pre-migrate copying...")}</p>
</DialogDescription>
</DialogOpen>
);
break;
case "migrateVpm:updating":
dialogContent = (
<DialogOpen className={"whitespace-normal"}>
<DialogTitle>{tc("projects:dialog:vpm migrate header")}</DialogTitle>
<DialogDescription>
<p>{tc("projects:migrating...")}</p>
</DialogDescription>
</DialogOpen>
);
break;
}
return (
<>
<ButtonDisabledIfRemoved variant={"success"} onClick={startMigrateVpm}>
{tc("projects:button:migrate")}
</ButtonDisabledIfRemoved>
{dialogContent}
</>
);
}
// region utilities
const ProjectRowContext = React.createContext<{
removed: boolean;
loading: boolean;
}>({
removed: false,
loading: false,
});
const ButtonDisabledIfRemoved = forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(function RemovedButton(props, ref) {
const rowContext = useContext(ProjectRowContext);
if (rowContext.removed) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
{...props}
className={`disabled:pointer-events-auto ${props.className}`}
disabled
ref={ref}
/>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>{tt("projects:tooltip:no directory")}</TooltipContent>
</TooltipPortal>
</Tooltip>
);
} else {
return (
<Button
{...props}
className={`disabled:pointer-events-auto ${props.className}`}
disabled={props.disabled || rowContext.loading || rowContext.removed}
ref={ref}
/>
);
}
});
const TooltipTriggerIfRemoved = ({
children,
...props
}: ComponentProps<typeof TooltipTrigger>) => {
const rowContext = useContext(ProjectRowContext);
if (rowContext.removed) {
return <TooltipTrigger {...props}>{children}</TooltipTrigger>;
} else {
return children;
}
};
const TooltipTriggerIfExists = ({
children,
...props
}: ComponentProps<typeof TooltipTrigger>) => {
const rowContext = useContext(ProjectRowContext);
if (rowContext.removed) {
return children;
} else {
return <TooltipTrigger {...props}>{children}</TooltipTrigger>;
}
};
function formatDateOffset(date: number): React.ReactNode {
const now = Date.now();
const diff = now - date;
const PER_SECOND = 1000;
const PER_MINUTE = 60 * PER_SECOND;
const PER_HOUR = 60 * PER_MINUTE;
const PER_DAY = 24 * PER_HOUR;
const PER_WEEK = 7 * PER_DAY;
const PER_MONTH = 30 * PER_DAY;
const PER_YEAR = 365 * PER_DAY;
const diffAbs = Math.abs(diff);
if (diffAbs < PER_MINUTE) return tc("projects:last modified:moments");
if (diffAbs < PER_HOUR)
return tc("projects:last modified:minutes", {
count: Math.floor(diff / PER_MINUTE),
});
if (diffAbs < PER_DAY)
return tc("projects:last modified:hours", {
count: Math.floor(diff / PER_HOUR),
});
if (diffAbs < PER_WEEK)
return tc("projects:last modified:days", {
count: Math.floor(diff / PER_DAY),
});
if (diffAbs < PER_MONTH)
return tc("projects:last modified:weeks", {
count: Math.floor(diff / PER_WEEK),
});
if (diffAbs < PER_YEAR)
return tc("projects:last modified:months", {
count: Math.floor(diff / PER_MONTH),
});
return tc("projects:last modified:years", {
count: Math.floor(diff / PER_YEAR),
});
}
// endregion

View file

@ -0,0 +1,8 @@
import { loadLicenses } from "@/lib/licenses";
import RenderPage from "./render-client";
const licenses = await loadLicenses();
export default function Page() {
return <RenderPage licenses={licenses} />;
}

View file

@ -1,13 +1,23 @@
"use client";
import licenses from "build:licenses.json";
import { VStack } from "@/components/layout";
import { ScrollableCard } from "@/components/ScrollableCard";
import { ScrollPageContainer } from "@/components/ScrollPageContainer";
import { ScrollableCard } from "@/components/ScrollableCard";
import { VStack } from "@/components/layout";
import { Card } from "@/components/ui/card";
import { commands } from "@/lib/bindings";
import type { Licenses } from "@/lib/licenses";
export default function RenderPage({
licenses,
}: { licenses: Licenses | null }) {
if (licenses === null) {
return (
<div className={"whitespace-normal"}>
<p>Failed to load licenses.</p>
</div>
);
}
export default function RenderPage() {
return (
<ScrollPageContainer>
<VStack>
@ -20,7 +30,7 @@ export default function RenderPage() {
<ul />
</Card>
{licenses.map((license) => (
{licenses.map((license, idx) => (
<Card className={"p-4"} key={license.text}>
<h3>{license.name}</h3>
<h4>Used by:</h4>

View file

@ -0,0 +1,618 @@
"use client";
import { CheckForUpdateMessage } from "@/components/CheckForUpdateMessage";
import { ScrollPageContainer } from "@/components/ScrollPageContainer";
import { ScrollableCardTable } from "@/components/ScrollableCardTable";
import {
BackupFormatSelect,
BackupPathWarnings,
FilePathRow,
LanguageSelector,
ProjectPathWarnings,
ThemeSelector,
} from "@/components/common-setting-parts";
import { HNavBar, VStack } from "@/components/layout";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
DialogDescription,
DialogFooter,
DialogOpen,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
UnityArgumentsSettings,
useUnityArgumentsSettings,
} from "@/components/unity-arguments-settings";
import { assertNever } from "@/lib/assert-never";
import type {
CheckForUpdateResponse,
TauriEnvironmentSettings,
} from "@/lib/bindings";
import { commands } from "@/lib/bindings";
import globalInfo, { useGlobalInfo } from "@/lib/global-info";
import { tc, tt } from "@/lib/i18n";
import {
toastError,
toastNormal,
toastSuccess,
toastThrownError,
} from "@/lib/toast";
import { useFilePickerFunction } from "@/lib/use-file-picker-dialog";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import type React from "react";
import { useState } from "react";
export default function Page() {
const result = useQuery({
queryKey: ["environmentGetSettings"],
queryFn: commands.environmentGetSettings,
});
let body: React.ReactNode;
switch (result.status) {
case "error":
body = <Card className={"p-4"}>{tc("settings:error:load error")}</Card>;
break;
case "pending":
body = <Card className={"p-4"}>{tc("general:loading...")}</Card>;
break;
case "success":
body = <Settings settings={result.data} refetch={result.refetch} />;
break;
default:
assertNever(result);
}
return (
<VStack>
<HNavBar className={"flex-shrink-0"}>
<p className="cursor-pointer py-1.5 font-bold flex-grow-0">
{tc("settings")}
</p>
</HNavBar>
{body}
</VStack>
);
}
function Settings({
settings,
refetch,
}: {
settings: TauriEnvironmentSettings;
refetch: () => void;
}) {
const isMac = useGlobalInfo().osType === "Darwin";
return (
<ScrollPageContainer>
<main className="flex flex-col gap-2 flex-shrink flex-grow">
<Card className={"flex-shrink-0 p-4"}>
<h2 className={"pb-2"}>{tc("settings:unity hub path")}</h2>
<FilePathRow
withoutSelect
path={settings.unity_hub}
pick={commands.environmentPickUnityHub}
refetch={refetch}
notFoundMessage={"Unity Hub Not Found"}
successMessage={tc("settings:toast:unity hub path updated")}
/>
</Card>
<UnityInstallationsCard
refetch={refetch}
unityPaths={settings.unity_paths}
/>
<UnityLaunchArgumentsCard
refetch={refetch}
unityArgs={settings.default_unity_arguments}
/>
<Card className={"flex-shrink-0 p-4"}>
<h2>{tc("settings:default project path")}</h2>
<p className={"whitespace-normal"}>
{tc("settings:default project path description")}
</p>
<FilePathRow
path={settings.default_project_path}
pick={commands.environmentPickProjectDefaultPath}
refetch={refetch}
successMessage={tc("settings:toast:default project path updated")}
/>
<ProjectPathWarnings projectPath={settings.default_project_path} />
</Card>
<BackupCard
projectBackupPath={settings.project_backup_path}
backupFormat={settings.backup_format}
refetch={refetch}
/>
<PackagesCard
showPrereleasePackages={settings.show_prerelease_packages}
refetch={refetch}
/>
<AppearanceCard />
<AlcomCard
isMac={isMac}
releaseChannel={settings.release_channel}
useAlcomForVccProtocol={settings.use_alcom_for_vcc_protocol}
refetch={refetch}
/>
<SystemInformationCard />
</main>
</ScrollPageContainer>
);
}
function UnityInstallationsCard({
refetch,
unityPaths,
}: {
refetch: () => void;
unityPaths: [path: string, version: string, fromHub: boolean][];
}) {
const [pickUnity, unityDialog] = useFilePickerFunction(
commands.environmentPickUnity,
);
const addUnity = async () => {
try {
const result = await pickUnity();
switch (result) {
case "NoFolderSelected":
// no-op
break;
case "InvalidSelection":
toastError(tt("settings:toast:not unity"));
break;
case "AlreadyAdded":
toastError(tt("settings:toast:unity already added"));
break;
case "Successful":
toastSuccess(tt("settings:toast:unity added"));
refetch();
break;
default:
assertNever(result);
}
} catch (e) {
console.error(e);
toastThrownError(e);
}
};
const UNITY_TABLE_HEAD = [
"settings:unity:version",
"settings:unity:path",
"general:source",
];
return (
<Card className={"flex-shrink-0 p-4"}>
<div className={"pb-2 flex align-middle"}>
<div className={"flex-grow flex items-center"}>
<h2>{tc("settings:unity installations")}</h2>
</div>
<Button onClick={addUnity} size={"sm"} className={"m-1"}>
{tc("settings:button:add unity")}
</Button>
</div>
<ScrollableCardTable className="w-full min-h-[20vh]">
<thead>
<tr>
{UNITY_TABLE_HEAD.map((head, index) => (
<th
// biome-ignore lint/suspicious/noArrayIndexKey: static array
key={index}
className={
"sticky top-0 z-10 border-b border-primary bg-secondary text-secondary-foreground p-2.5"
}
>
<small className="font-normal leading-none">{tc(head)}</small>
</th>
))}
</tr>
</thead>
<tbody>
{unityPaths.map(([path, version, isFromHub]) => (
<tr key={path} className="even:bg-secondary/30">
<td className={"p-2.5"}>{version}</td>
<td className={"p-2.5"}>{path}</td>
<td className={"p-2.5"}>
{isFromHub
? tc("settings:unity:source:unity hub")
: tc("settings:unity:source:manual")}
</td>
</tr>
))}
</tbody>
</ScrollableCardTable>
{unityDialog}
</Card>
);
}
function UnityLaunchArgumentsCard({
refetch,
unityArgs,
}: {
refetch: () => void;
unityArgs: string[] | null;
}) {
const [open, setOpen] = useState(false);
const defaultUnityArgs = useGlobalInfo().defaultUnityArguments;
const realUnityArgs = unityArgs ?? defaultUnityArgs;
const close = () => setOpen(false);
const openDialog = () => setOpen(true);
return (
<Card className={"flex-shrink-0 p-4"}>
<div className={"pb-2 flex align-middle"}>
<div className={"flex-grow flex items-center"}>
<h2>{tc("settings:default unity arguments")}</h2>
</div>
<Button onClick={openDialog} size={"sm"} className={"m-1"}>
{tc("general:button:edit")}
</Button>
</div>
<p className={"text-sm"}>
{tc("settings:default unity arguments description")}
</p>
<ol className={"flex flex-col"}>
{realUnityArgs.map((v, i) => (
<Input disabled key={i + v} value={v} className={"w-full"} />
))}
</ol>
{open && (
<DialogOpen>
<LaunchArgumentsEditDialogBody
unityArgs={unityArgs}
refetch={refetch}
close={close}
/>
</DialogOpen>
)}
</Card>
);
}
function LaunchArgumentsEditDialogBody({
unityArgs,
refetch,
close,
}: {
unityArgs: string[] | null;
refetch: () => void;
close: () => void;
}) {
const context = useUnityArgumentsSettings(
unityArgs,
globalInfo.defaultUnityArguments,
);
const saveAndClose = async () => {
await commands.environmentSetDefaultUnityArguments(context.currentValue);
close();
refetch();
};
return (
<>
<DialogTitle>
{tc("settings:dialog:default launch arguments")}
</DialogTitle>
{/* TODO: use ScrollArea (I failed to use it inside dialog) */}
<DialogDescription className={"max-h-[50dvh] overflow-y-auto"}>
<UnityArgumentsSettings context={context} />
</DialogDescription>
<DialogFooter>
<Button onClick={close} variant={"destructive"}>
{tc("general:button:cancel")}
</Button>
<Button onClick={saveAndClose} disabled={context.hasError}>
{tc("general:button:save")}
</Button>
</DialogFooter>
</>
);
}
function BackupCard({
projectBackupPath,
backupFormat,
refetch,
}: {
projectBackupPath: string;
backupFormat: string;
refetch: () => void;
}) {
const setBackupFormat = async (format: string) => {
try {
await commands.environmentSetBackupFormat(format);
refetch();
} catch (e) {
console.error(e);
toastThrownError(e);
}
};
return (
<Card className={"flex-shrink-0 p-4"}>
<h2>{tc("projects:backup")}</h2>
<div className="mt-2">
<h3>{tc("settings:backup:path")}</h3>
<p className={"whitespace-normal text-sm"}>
{tc("settings:backup:path description")}
</p>
<FilePathRow
path={projectBackupPath}
pick={commands.environmentPickProjectBackupPath}
refetch={refetch}
successMessage={tc("settings:toast:backup path updated")}
/>
<BackupPathWarnings backupPath={projectBackupPath} />
</div>
<div className="mt-2">
<h3>{tc("settings:backup:format")}</h3>
<p className={"whitespace-normal text-sm"}>
{tc("settings:backup:format description")}
</p>
<label className={"flex items-center"}>
<BackupFormatSelect
backupFormat={backupFormat}
setBackupFormat={setBackupFormat}
/>
</label>
</div>
</Card>
);
}
function PackagesCard({
showPrereleasePackages,
refetch,
}: {
showPrereleasePackages: boolean;
refetch: () => void;
}) {
const clearPackageCache = async () => {
try {
await commands.environmentClearPackageCache();
toastSuccess(tc("settings:toast:package cache cleared"));
} catch (e) {
console.error(e);
toastThrownError(e);
}
};
const toggleShowPrereleasePackages = async (e: "indeterminate" | boolean) => {
try {
await commands.environmentSetShowPrereleasePackages(e === true);
refetch();
} catch (e) {
console.error(e);
toastThrownError(e);
}
};
return (
<Card className={"flex-shrink-0 p-4 flex flex-col gap-4"}>
<h2>{tc("settings:packages")}</h2>
<div className={"flex flex-row flex-wrap gap-2"}>
<Button onClick={clearPackageCache}>
{tc("settings:clear package cache")}
</Button>
</div>
<div>
<label className={"flex items-center gap-2"}>
<Checkbox
checked={showPrereleasePackages}
onCheckedChange={(e) => toggleShowPrereleasePackages(e)}
/>
{tc("settings:show prerelease")}
</label>
<p className={"text-sm whitespace-normal"}>
{tc("settings:show prerelease description")}
</p>
</div>
</Card>
);
}
function AppearanceCard() {
return (
<Card className={"flex-shrink-0 p-4"}>
<h2>{tc("settings:appearance")}</h2>
<LanguageSelector />
<ThemeSelector />
</Card>
);
}
function AlcomCard({
isMac,
releaseChannel,
useAlcomForVccProtocol,
refetch,
}: {
isMac: boolean;
releaseChannel: string;
useAlcomForVccProtocol: boolean;
refetch: () => void;
}) {
const [updateState, setUpdateState] = useState<CheckForUpdateResponse | null>(
null,
);
const globalInfo = useGlobalInfo();
const checkForUpdate = async () => {
try {
const checkVersion = await commands.utilCheckForUpdate();
if (checkVersion) {
setUpdateState(checkVersion);
} else {
toastNormal(tc("check update:toast:no updates"));
}
} catch (e) {
toastThrownError(e);
console.error(e);
}
};
const reportIssue = async () => {
const url = new URL("https://github.com/vrc-get/vrc-get/issues/new");
url.searchParams.append("labels", "bug,vrc-get-gui");
url.searchParams.append("template", "01_gui_bug-report.yml");
url.searchParams.append("os", `${globalInfo.osInfo} - ${globalInfo.arch}`);
url.searchParams.append("webview-version", `${globalInfo.webviewVersion}`);
let version = globalInfo.version ?? "unknown";
if (globalInfo.commitHash) {
version += ` (${globalInfo.commitHash})`;
} else {
version += " (unknown commit)";
}
url.searchParams.append("version", version);
void commands.utilOpenUrl(url.toString());
};
const changeReleaseChannel = async (value: "indeterminate" | boolean) => {
await commands.environmentSetReleaseChannel(
value === true ? "beta" : "stable",
);
refetch();
};
const changeUseAlcomForVcc = async (value: "indeterminate" | boolean) => {
await commands.environmentSetUseAlcomForVccProtocol(value === true);
refetch();
};
const installVccProtocol = async () => {
try {
await commands.deepLinkInstallVcc();
toastSuccess(tc("settings:toast:vcc scheme installed"));
} catch (e) {
console.error(e);
toastThrownError(e);
}
};
const openVpmFolderContent = (subPath: string) => {
return async () => {
try {
await commands.utilOpen(
`${globalInfo.vpmHomeFolder}/${subPath}`,
"ErrorIfNotExists",
);
} catch (e) {
console.error(e);
toastThrownError(e);
}
};
};
return (
<Card className={"flex-shrink-0 p-4 flex flex-col gap-4"}>
{updateState && (
<CheckForUpdateMessage
response={updateState}
close={() => setUpdateState(null)}
/>
)}
<h2>ALCOM</h2>
<div className={"flex flex-row flex-wrap gap-2"}>
<Button onClick={checkForUpdate}>{tc("settings:check update")}</Button>
<Button onClick={reportIssue}>
{tc("settings:button:open issue")}
</Button>
</div>
<div className={"flex flex-row flex-wrap gap-2"}>
<Button onClick={openVpmFolderContent("settings.json")}>
{tc("settings:button:open settings.json")}
</Button>
<Button onClick={openVpmFolderContent("vrc-get/gui-config.json")}>
{tc("settings:button:open gui config.json")}
</Button>
<Button onClick={openVpmFolderContent("vrc-get/gui-logs")}>
{tc("settings:button:open logs")}
</Button>
<Button onClick={openVpmFolderContent("Templates")}>
{tc("settings:button:open custom templates")}
</Button>
</div>
<div>
<label className={"flex items-center gap-2"}>
<Checkbox
checked={releaseChannel === "beta"}
onCheckedChange={(e) => changeReleaseChannel(e)}
/>
{tc("settings:receive beta updates")}
</label>
<p className={"text-sm whitespace-normal"}>
{tc("settings:beta updates description")}
</p>
</div>
{!isMac && (
<div>
<label className={"flex items-center gap-2"}>
<Checkbox
checked={useAlcomForVccProtocol}
onCheckedChange={(e) => changeUseAlcomForVcc(e)}
/>
{tc("settings:use alcom for vcc scheme")}
</label>
<Button
className={"my-1"}
disabled={!useAlcomForVccProtocol}
onClick={installVccProtocol}
>
{tc("settings:register vcc scheme now")}
</Button>
<p className={"text-sm whitespace-normal"}>
{tc([
"settings:use vcc scheme description",
"settings:vcc scheme description",
])}
</p>
</div>
)}
<p className={"whitespace-normal"}>
{tc(
"settings:licenses description",
{},
{
components: {
l: <Link href={"/settings/licenses"} className={"underline"} />,
},
},
)}
</p>
</Card>
);
}
function SystemInformationCard() {
const info = useGlobalInfo();
return (
<Card className={"flex-shrink-0 p-4 flex flex-col gap-4"}>
<h2>{tc("settings:system information")}</h2>
<dl>
<dt>{tc("settings:os")}</dt>
<dd className={"ml-8 mb-1"}>{info.osInfo}</dd>
<dt>{tc("settings:architecture")}</dt>
<dd className={"ml-8 mb-1"}>{info.arch}</dd>
<dt>{tc("settings:webview version")}</dt>
<dd className={"ml-8 mb-1"}>{info.webviewVersion}</dd>
<dt>{tc("settings:alcom version")}</dt>
<dd className={"ml-8 mb-1"}>{info.version}</dd>
<dt>{tc("settings:alcom commit hash")}</dt>
<dd className={"ml-8 mb-1"}>{info.commitHash}</dd>
</dl>
</Card>
);
}

View file

@ -1,9 +1,8 @@
"use client";
import { createFileRoute } from "@tanstack/react-router";
import { HNavBar, HNavBarText, VStack } from "@/components/layout";
import { ScrollableCardTable } from "@/components/ScrollableCardTable";
import { ScrollPageContainer } from "@/components/ScrollPageContainer";
import { ScrollableCardTable } from "@/components/ScrollableCardTable";
import { HNavBar, VStack } from "@/components/layout";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
@ -17,29 +16,19 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { tc } from "@/lib/i18n";
import {
toastError,
toastInfo,
toastNormal,
toastSuccess,
toastWarning,
} from "@/lib/toast";
import { toastError, toastInfo, toastNormal, toastSuccess } from "@/lib/toast";
export const Route = createFileRoute("/_main/dev-palette/")({
component: Page,
});
function Page() {
export default function Page() {
return (
<VStack>
<HNavBar
className="shrink-0"
leading={<HNavBarText>UI Palette (dev only)</HNavBarText>}
/>
<HNavBar className={"flex-shrink-0"}>
<p className="cursor-pointer py-1.5 font-bold flex-grow-0">
UI Palette (dev only)
</p>
</HNavBar>
<ScrollPageContainer>
<main className="flex flex-col gap-2 shrink grow">
<Card className={"shrink-0 p-4"}>
<main className="flex flex-col gap-2 flex-shrink flex-grow">
<Card className={"flex-shrink-0 p-4"}>
<h2 className={"pb-2"}>File Selector</h2>
<div className={"flex gap-1 items-center"}>
<Input
@ -50,9 +39,9 @@ function Page() {
<Button className={"flex-none px-4"}>Select</Button>
</div>
</Card>
<Card className={"shrink-0 p-4"}>
<Card className={"flex-shrink-0 p-4"}>
<div className={"pb-2 flex align-middle"}>
<div className={"grow flex items-center"}>
<div className={"flex-grow flex items-center"}>
<h2>Table</h2>
</div>
<Button size={"sm"} className={"m-1"}>
@ -63,7 +52,7 @@ function Page() {
<UnityTableBody />
</ScrollableCardTable>
</Card>
<Card className={"shrink-0 p-4"}>
<Card className={"flex-shrink-0 p-4"}>
<h2>Dropdown Selector</h2>
<div className="mt-2">
<label className={"flex items-center"}>
@ -85,7 +74,7 @@ function Page() {
</label>
</div>
</Card>
<Card className={"shrink-0 p-4"}>
<Card className={"flex-shrink-0 p-4"}>
<p className={"whitespace-normal"}>Some Description Here</p>
<label className={"flex items-center"}>
<div className={"p-3"}>
@ -94,7 +83,7 @@ function Page() {
Checkbox
</label>
</Card>
<Card className={"shrink-0 p-4"}>
<Card className={"flex-shrink-0 p-4"}>
<h2 className={"pb-2"}>Buttons</h2>
<div className={"flex gap-2 items-center"}>
<Button>Normal</Button>
@ -107,7 +96,7 @@ function Page() {
<Button variant={"ghost-destructive"}>Ghost Destructive</Button>
</div>
</Card>
<Card className={"shrink-0 p-4"}>
<Card className={"flex-shrink-0 p-4"}>
<h2 className={"pb-2"}>Toasts</h2>
<div className={"flex gap-2 items-center"}>
<Button onClick={() => toastNormal("Normal Toast Body")}>
@ -119,12 +108,6 @@ function Page() {
>
Error
</Button>
<Button
variant={"warning"}
onClick={() => toastWarning("Warning Toast Body")}
>
Warning
</Button>
<Button
variant={"success"}
onClick={() => toastSuccess("Success Toast Body")}
@ -137,14 +120,6 @@ function Page() {
>
Info
</Button>
<Button
variant={"info"}
onClick={() =>
toastInfo(tc("settings:toast:vcc scheme installed"))
}
>
Info with html inside
</Button>
</div>
</Card>
</main>
@ -185,7 +160,7 @@ function UnityTableBody() {
</tr>
</thead>
<tbody>
{unityPaths.map(([path, version, _isFromHub]) => (
{unityPaths.map(([path, version, isFromHub]) => (
<tr key={path} className="even:bg-secondary/30">
<td className={"p-2.5"}>{version}</td>
<td className={"p-2.5"}>{path}</td>

View file

@ -0,0 +1,37 @@
"use client";
import { useRouter } from "next/navigation";
export default function SetupLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const isDev = process.env.NODE_ENV === "development";
return (
<>
<div className={"h-screen flex-grow overflow-hidden flex p-4"}>
{children}
</div>
{isDev && <DevTools />}
</>
);
}
function DevTools() {
const router = useRouter();
return (
<div className={"absolute bottom-0 left-0 p-4 flex flex-col gap-3"}>
<p>debug tools</p>
<div className={"flex gap-3"}>
<button type="button" onClick={() => router.back()}>
Go Back
</button>
<button type="button" onClick={() => router.push("/settings")}>
Go Settings
</button>
</div>
</div>
);
}

View file

@ -1,21 +1,14 @@
"use client";
import { createFileRoute } from "@tanstack/react-router";
import {
GuiAnimationSwitch,
GuiCompactSwitch,
LanguageSelector,
ThemeSelector,
} from "@/components/common-setting-parts";
import { CardDescription } from "@/components/ui/card";
import { tc } from "@/lib/i18n";
import { SetupPageBase } from "../-setup-page-base";
import { SetupPageBase } from "../setup-page-base";
export const Route = createFileRoute("/_setup/setup/appearance/")({
component: Page,
});
function Page() {
export default function Page() {
return (
<SetupPageBase
heading={tc("setup:entry:welcome")}
@ -40,8 +33,6 @@ function Body() {
</CardDescription>
<LanguageSelector />
<ThemeSelector />
<GuiAnimationSwitch />
<GuiCompactSwitch />
</>
);
}

View file

@ -0,0 +1,68 @@
"use client";
import {
BackupFormatSelect,
BackupPathWarnings,
FilePathRow,
} from "@/components/common-setting-parts";
import { CardDescription } from "@/components/ui/card";
import { commands } from "@/lib/bindings";
import { useGlobalInfo } from "@/lib/global-info";
import { tc } from "@/lib/i18n";
import { toastThrownError } from "@/lib/toast";
import { type BodyProps, SetupPageBase } from "../setup-page-base";
export default function Page() {
const isMac = useGlobalInfo().osType === "Darwin";
return (
<SetupPageBase
heading={tc("setup:backups:heading")}
Body={Body}
nextPage={isMac ? "/setup/finish" : "/setup/system-setting"}
prevPage={"/setup/project-path"}
pageId={"Backups"}
/>
);
}
function Body({ environment, refetch }: BodyProps) {
const projectBackupPath = environment.project_backup_path;
const backupFormat = environment.backup_format;
const setBackupFormat = async (format: string) => {
try {
await commands.environmentSetBackupFormat(format);
refetch();
} catch (e) {
console.error(e);
toastThrownError(e);
}
};
return (
<>
<h3>{tc("setup:backups:location")}</h3>
<CardDescription className={"whitespace-normal"}>
{tc("setup:backups:location description")}
</CardDescription>
<FilePathRow
withoutSelect
path={projectBackupPath}
pick={commands.environmentPickProjectBackupPath}
refetch={refetch}
successMessage={tc("settings:toast:backup path updated")}
/>
<BackupPathWarnings backupPath={projectBackupPath} />
<div className={"pb-3"} />
<h3>{tc("setup:backups:archive")}</h3>
<CardDescription className={"whitespace-normal"}>
{tc("settings:backup:format description")}
</CardDescription>
<BackupFormatSelect
backupFormat={backupFormat}
setBackupFormat={setBackupFormat}
/>
</>
);
}

View file

@ -1,26 +1,19 @@
"use client";
import { createFileRoute } from "@tanstack/react-router";
import { CardDescription } from "@/components/ui/card";
import { useGlobalInfo } from "@/lib/global-info";
import { tc } from "@/lib/i18n";
import { SetupPageBase } from "../-setup-page-base";
import { SetupPageBase } from "../setup-page-base";
export const Route = createFileRoute("/_setup/setup/finish/")({
component: Page,
});
function Page() {
const shouldInstallDeepLink = useGlobalInfo().shouldInstallDeepLink;
export default function Page() {
const isMac = useGlobalInfo().osType === "Darwin";
return (
<SetupPageBase
heading={tc("setup:finish:heading")}
Body={Body}
nextPage={"/projects"}
prevPage={
shouldInstallDeepLink ? "/setup/system-setting" : "/setup/backups"
}
prevPage={isMac ? "/setup/backups" : "/setup/system-setting"}
nextContent={tc("setup:finish:next")}
pageId={null}
/>

View file

@ -0,0 +1,40 @@
"use client";
import {
FilePathRow,
ProjectPathWarnings,
} from "@/components/common-setting-parts";
import { CardDescription } from "@/components/ui/card";
import { commands } from "@/lib/bindings";
import { tc } from "@/lib/i18n";
import { type BodyProps, SetupPageBase } from "../setup-page-base";
export default function Page() {
return (
<SetupPageBase
heading={tc("setup:project-path:heading")}
Body={Body}
nextPage={"/setup/backups"}
prevPage={"/setup/unity-hub"}
pageId={"ProjectPath"}
/>
);
}
function Body({ environment, refetch }: BodyProps) {
return (
<>
<CardDescription className={"whitespace-normal"}>
{tc("setup:project-path:description")}
</CardDescription>
<FilePathRow
withoutSelect
path={environment.default_project_path}
pick={commands.environmentPickProjectDefaultPath}
refetch={refetch}
successMessage={tc("settings:toast:default project path updated")}
/>
<ProjectPathWarnings projectPath={environment.default_project_path} />
</>
);
}

View file

@ -1,16 +1,17 @@
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { Circle, CircleCheck, CircleChevronRight } from "lucide-react";
import type React from "react";
import { Button } from "@/components/ui/button";
import { Card, CardFooter, CardHeader } from "@/components/ui/card";
import type { SetupPages, TauriEnvironmentSettings } from "@/lib/bindings";
import { commands } from "@/lib/bindings";
import { useGlobalInfo } from "@/lib/global-info";
import { tc } from "@/lib/i18n";
import { useQuery } from "@tanstack/react-query";
import { Circle, CircleCheck, CircleChevronRight } from "lucide-react";
import { useRouter } from "next/navigation";
import type React from "react";
export type BodyProps = Readonly<{
environment: TauriEnvironmentSettings;
refetch: () => void;
}>;
export function SetupPageBase({
@ -18,7 +19,6 @@ export function SetupPageBase({
Body,
nextPage,
prevPage,
onFinish,
backContent = tc("setup:back"),
nextContent = tc("setup:next"),
pageId,
@ -28,13 +28,12 @@ export function SetupPageBase({
Body: React.ComponentType<BodyProps>;
nextPage: string;
prevPage: string | null;
onFinish?: () => void;
backContent?: React.ReactNode;
nextContent?: React.ReactNode;
pageId: SetupPages | null;
withoutSteps?: boolean;
}) {
const navigate = useNavigate();
const router = useRouter();
const result = useQuery({
queryKey: ["environmentGetSettings"],
@ -43,8 +42,7 @@ export function SetupPageBase({
const onNext = async () => {
if (pageId) await commands.environmentFinishedSetupPage(pageId);
navigate({ to: nextPage });
onFinish?.();
router.push(nextPage);
};
return (
@ -52,9 +50,9 @@ export function SetupPageBase({
<div className={"flex gap-4"}>
{!withoutSteps && <StepCard current={pageId} />}
<Card
className={`${withoutSteps ? "w-[30rem]" : "w-96"} min-w-[50vw] min-h-[max(50dvh,20rem)] p-4 flex gap-3 compact:min-h-[max(40dvh,20rem)]`}
className={`${withoutSteps ? "w-[30rem]" : "w-96"} min-w-[50vw] min-h-[max(50dvh,20rem)] p-4 flex gap-3`}
>
<div className={"flex flex-col grow gap-3 compact:gap-2"}>
<div className={"flex flex-col flex-grow"}>
<CardHeader>
<h1 className={"text-center"}>{heading}</h1>
</CardHeader>
@ -62,12 +60,15 @@ export function SetupPageBase({
{!result.data ? (
<p>{tc("setup:loading")}</p>
) : (
<Body environment={result.data} />
<Body
environment={result.data}
refetch={() => result.refetch()}
/>
)}
<div className={"grow"} />
<CardFooter className="p-0 pt-3 items-end flex-row gap-2 justify-end compact:-m-2">
<div className={"flex-grow"} />
<CardFooter className="p-0 pt-3 items-end flex-row gap-2 justify-end">
{prevPage && (
<Button onClick={() => navigate({ to: prevPage })}>
<Button onClick={() => router.push(prevPage)}>
{backContent}
</Button>
)}
@ -80,7 +81,11 @@ export function SetupPageBase({
);
}
function StepCard({ current }: { current: SetupPages | null }) {
function StepCard({
current,
}: {
current: SetupPages | null;
}) {
// TODO: get progress from backend
const finisheds = useQuery({
queryKey: ["environmentGetFinishedSetupPages"],
@ -88,7 +93,7 @@ function StepCard({ current }: { current: SetupPages | null }) {
initialData: [],
}).data;
const shouldInstallDeepLink = useGlobalInfo().shouldInstallDeepLink;
const isMac = useGlobalInfo().osType === "Darwin";
return (
<Card className={"w-48 p-4"}>
@ -113,7 +118,7 @@ function StepCard({ current }: { current: SetupPages | null }) {
finisheds={finisheds}
pageId={"Backups"}
/>
{shouldInstallDeepLink && (
{!isMac && (
<StepElement
current={current}
finisheds={finisheds}

View file

@ -0,0 +1,69 @@
"use client";
import { Checkbox } from "@/components/ui/checkbox";
import { commands } from "@/lib/bindings";
import { useGlobalInfo } from "@/lib/global-info";
import { tc } from "@/lib/i18n";
import { useQuery } from "@tanstack/react-query";
import { type BodyProps, SetupPageBase } from "../setup-page-base";
export default function Page() {
return (
<SetupPageBase
heading={tc("setup:system-setting:heading")}
Body={Body}
nextPage={"/setup/finish"}
prevPage={"/setup/backups"}
pageId={"SystemSetting"}
/>
);
}
function Body({ environment, refetch }: BodyProps) {
const useAlcomForVccProtocol = environment.use_alcom_for_vcc_protocol;
const isBadHostName = useQuery({
queryKey: ["util_is_bad_hostname"],
queryFn: commands.utilIsBadHostname,
initialData: false,
});
const changeUseAlcomForVcc = async (value: "indeterminate" | boolean) => {
await commands.environmentSetUseAlcomForVccProtocol(value === true);
refetch();
};
const isMac = useGlobalInfo().osType === "Darwin";
return (
<>
{!isMac ? (
<div>
<label className={"flex items-center gap-2"}>
<Checkbox
checked={useAlcomForVccProtocol}
onCheckedChange={(e) => changeUseAlcomForVcc(e)}
/>
{tc("settings:use alcom for vcc scheme")}
</label>
<p className={"text-sm whitespace-normal text-muted-foreground"}>
{tc("setup:system-setting:vcc scheme description")}
</p>
</div>
) : (
<div>
<p className={"text-sm whitespace-normal text-muted-foreground"}>
{tc("setup:system-setting:macos bug message")}
</p>
</div>
)}
{isBadHostName.data && (
<div className={"mt-3"}>
<p className={"text-sm whitespace-normal text-warning"}>
{tc("setup:system-setting:hostname-with-non-ascii")}
</p>
</div>
)}
</>
);
}

View file

@ -1,7 +1,5 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { FilePathRow } from "@/components/common-setting-parts";
import {
Accordion,
@ -11,23 +9,15 @@ import {
} from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import { CardDescription } from "@/components/ui/card";
import { assertNever } from "@/lib/assert-never";
import { commands } from "@/lib/bindings";
import { tc, tt } from "@/lib/i18n";
import { toastError, toastSuccess, toastThrownError } from "@/lib/toast";
import { type BodyProps, SetupPageBase } from "../-setup-page-base";
import { tc } from "@/lib/i18n";
import { type BodyProps, SetupPageBase } from "../setup-page-base";
export const Route = createFileRoute("/_setup/setup/unity-hub/")({
component: Page,
});
function Page() {
export default function Page() {
return (
<SetupPageBase
heading={tc("setup:unity-hub:heading")}
Body={Body}
// user should set unity hub path so we re-update unity paths
onFinish={() => commands.environmentUpdateUnityPathsFromUnityHub()}
nextPage={"/setup/project-path"}
prevPage={"/setup/appearance"}
pageId={"UnityHub"}
@ -35,39 +25,9 @@ function Page() {
);
}
function Body({ environment }: BodyProps) {
function Body({ environment, refetch }: BodyProps) {
const hubInstalled = !!environment.unity_hub;
const queryClient = useQueryClient();
const pickUnityHub = useMutation({
mutationFn: async () => await commands.environmentPickUnityHub(),
onError: (e) => {
console.error(e);
toastThrownError(e);
},
onSuccess: (result) => {
switch (result.type) {
case "NoFolderSelected":
// no-op
break;
case "InvalidSelection":
toastError(tc("general:toast:invalid directory"));
break;
case "Successful":
toastSuccess(tc("settings:toast:unity hub path updated"));
break;
default:
assertNever(result);
}
},
onSettled: async () => {
await queryClient.invalidateQueries({
queryKey: ["environmentGetSettings"],
});
},
});
return (
<>
<CardDescription className={"whitespace-normal"}>
@ -80,10 +40,12 @@ function Body({ environment }: BodyProps) {
{tc("setup:unity-hub:using this unity hub")}:
</p>
<FilePathRow
withoutSelect
path={environment.unity_hub ?? ""}
pick={pickUnityHub.mutate}
pick={commands.environmentPickUnityHub}
refetch={refetch}
notFoundMessage={"Unity Hub Not Found"}
withOpen={false}
successMessage={tc("settings:toast:unity hub path updated")}
/>
</>
) : (
@ -92,18 +54,12 @@ function Body({ environment }: BodyProps) {
<div className={"flex flex-row flex-wrap gap-2"}>
<Button
onClick={() =>
commands.utilOpenUrl(tt("setup:unity-hub:unity hub link"))
commands.utilOpenUrl("https://unity.com/ja/download")
}
>
{tc("setup:unity-hub:download unity hub from unity.com")}
</Button>
<Button
onClick={() =>
queryClient.invalidateQueries({
queryKey: ["environmentGetSettings"],
})
}
>
<Button onClick={refetch}>
{tc("setup:unity-hub:recheck installation")}
</Button>
</div>
@ -117,10 +73,12 @@ function Body({ environment }: BodyProps) {
{tc("setup:unity-hub:detection failed description")}
</p>
<FilePathRow
path={environment.unity_hub ?? ""}
pick={pickUnityHub.mutate}
withoutSelect
path={environment.unity_hub}
pick={commands.environmentPickUnityHub}
refetch={refetch}
notFoundMessage={"Unity Hub Not Found"}
withOpen={false}
successMessage={tc("settings:toast:unity hub path updated")}
/>
</AccordionContent>
</AccordionItem>

View file

@ -1,14 +0,0 @@
import { LoaderCircle } from "lucide-react";
export default function Loading({
loadingText = "Loading...",
}: {
loadingText?: React.ReactNode;
}) {
return (
<div className="flex flex-col items-center justify-center h-full w-full space-y-4">
<LoaderCircle className="h-10 w-10 animate-spin" />
<p className="text-xl font-semibold text-gray-700">{loadingText}</p>
</div>
);
}

View file

@ -1,34 +0,0 @@
import { createRootRoute, Outlet } from "@tanstack/react-router";
import ErrorPage from "@/app/-error";
import { Providers } from "@/components/providers";
import "./globals.css";
import React, { Suspense } from "react";
const TanStackRouterDevtools = import.meta.env.PROD
? () => null // Render nothing in production
: React.lazy(() =>
// Lazy load in development
import("@tanstack/router-devtools").then((res) => ({
default: res.TanStackRouterDevtools,
// For Embedded Mode
// default: res.TanStackRouterDevtoolsPanel
})),
);
export const Route = createRootRoute({
component: RootComponent,
errorComponent: ErrorPage,
});
function RootComponent() {
return (
<>
<Providers>
<Outlet />
</Providers>
<Suspense>
<TanStackRouterDevtools position={"bottom-right"} />
</Suspense>
</>
);
}

View file

@ -1,136 +0,0 @@
import { BugOff, CircleX, Info, OctagonAlert } from "lucide-react";
import { memo, useEffect, useMemo, useRef } from "react";
import { ScrollableCardTable } from "@/components/ScrollableCardTable";
import type { LogEntry, LogLevel } from "@/lib/bindings";
import { tc } from "@/lib/i18n";
export const LogsListCard = memo(function LogsListCard({
logEntry,
shouldShowLogLevel,
search,
autoScroll,
}: {
logEntry: LogEntry[];
shouldShowLogLevel: LogLevel[];
search: string;
autoScroll: boolean;
}) {
const logsShown = useMemo(
() =>
logEntry.filter(
(log) =>
log.message.toLowerCase().includes(search?.toLowerCase() ?? "") &&
shouldShowLogLevel.includes(log.level),
),
[logEntry, search, shouldShowLogLevel],
);
const scrollContainerRef = useRef<HTMLDivElement>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: should scroll to the bottom whenever the logsShown changes.
useEffect(() => {
if (!autoScroll) return;
if (!scrollContainerRef.current) return;
const container = scrollContainerRef.current;
const isNearBottom =
container.scrollHeight - (container.scrollTop + container.clientHeight) <
50;
if (!isNearBottom) {
container.scrollTop = container.scrollHeight;
}
}, [logsShown, autoScroll]);
const TABLE_HEAD = ["logs:time", "logs:level", "logs:message"];
return (
<ScrollableCardTable
className={"h-full w-full"}
viewportRef={scrollContainerRef}
>
<thead className={"w-full"}>
<tr>
{TABLE_HEAD.map((head, index) => (
<th
// biome-ignore lint/suspicious/noArrayIndexKey: static array
key={index}
className={
"sticky top-0 z-10 border-b border-primary bg-secondary text-secondary-foreground p-2.5"
}
>
<small className="font-normal leading-none">{tc(head)}</small>
</th>
))}
<th
className={
"sticky top-0 z-10 border-b border-primary bg-secondary text-secondary-foreground p-2.5"
}
/>
</tr>
</thead>
<tbody>
{logsShown.map((row) => (
<tr key={row.time} className="even:bg-secondary/30">
<LogRow log={row} />
</tr>
))}
</tbody>
</ScrollableCardTable>
);
});
const LogRow = memo(function LogRow({ log }: { log: LogEntry }) {
const cellClass = "p-2.5 compact:py-1";
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleString();
};
const getFontColorClass = (level: LogLevel) => {
switch (level) {
case "Info":
return "";
case "Warn":
return "text-warning";
case "Error":
return "text-destructive";
case "Debug":
return "text-info";
default:
return "";
}
};
const fontColorClass = getFontColorClass(log.level);
const typeIconClass = `${fontColorClass} w-5 h-5`;
return (
<>
<td className={`${cellClass} min-w-32 w-32`}>{formatDate(log.time)}</td>
<td className={`${cellClass} min-w-28 w-28`}>
<div className="flex flex-row gap-2">
<div className="flex items-center">
{log.level === "Info" ? (
<Info className={typeIconClass} />
) : log.level === "Warn" ? (
<OctagonAlert className={typeIconClass} />
) : log.level === "Error" ? (
<CircleX className={typeIconClass} />
) : log.level === "Debug" ? (
<BugOff className={typeIconClass} />
) : (
<Info className={typeIconClass} />
)}
</div>
<div className="flex flex-col justify-center">
<p className={`font-normal ${fontColorClass}`}>{log.level}</p>
</div>
</div>
</td>
<td className={`${cellClass} min-w-32 w-full`}>{log.message}</td>
</>
);
});

View file

@ -1,287 +0,0 @@
"use client";
import {
queryOptions,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { ArrowDownFromLine } from "lucide-react";
import { useRef, useState } from "react";
import { HNavBar, HNavBarText, VStack } from "@/components/layout";
import { SearchBox } from "@/components/SearchBox";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { LogEntry, LogLevel } from "@/lib/bindings";
import { commands } from "@/lib/bindings";
import { isFindKey, useDocumentEvent } from "@/lib/events";
import globalInfo from "@/lib/global-info";
import { tc } from "@/lib/i18n";
import { toastThrownError } from "@/lib/toast";
import { useTauriListen } from "@/lib/use-tauri-listen";
import { useSessionStorage } from "@/lib/useSessionStorage";
import { LogsListCard } from "./-logs-list-card";
export const Route = createFileRoute("/_main/log/")({
component: Page,
});
const utilGetLogEntries = queryOptions({
queryKey: ["utilGetLogEntries"],
queryFn: async () => commands.utilGetLogEntries(),
});
const environmentLogsLevel = queryOptions({
queryKey: ["environmentLogsLevel"],
queryFn: async () => commands.environmentLogsLevel(),
});
function Page() {
const [search, setSearch] = useState("");
const queryClient = useQueryClient();
const logEntriesQuery = useQuery(utilGetLogEntries);
const logsLevel = useQuery(environmentLogsLevel);
const handleLogLevelChange = useMutation({
mutationFn: async (value: LogLevel[]) =>
commands.environmentSetLogsLevel(value),
onMutate: async (value) => {
await queryClient.cancelQueries(environmentLogsLevel);
const data = queryClient.getQueryData(environmentLogsLevel.queryKey);
queryClient.setQueryData(environmentLogsLevel.queryKey, value);
return data;
},
onError: (e, _, data) => {
console.error(e);
toastThrownError(e);
queryClient.setQueryData(environmentLogsLevel.queryKey, data);
},
onSettled: async () => {
await queryClient.invalidateQueries(environmentLogsLevel);
},
});
const autoScroll = useSessionStorage({
key: "logs_auto_scroll",
parse: (value) => value === "true",
fallbackValue: true,
});
const handleLogAutoScrollChange = (value: boolean) => {
sessionStorage.setItem("logs_auto_scroll", String(value));
// Manually dispatch storage event to force state synchronization within the same page,
// as native sessionStorage.setItem doesn't trigger storage event for the current origin
window.dispatchEvent(
new StorageEvent("storage", {
key: "logs_auto_scroll",
newValue: String(value),
storageArea: sessionStorage,
}),
);
};
useTauriListen<LogEntry>("log", (event) => {
const entry = event.payload as LogEntry;
const entries = queryClient.getQueryData(utilGetLogEntries.queryKey) ?? [];
queryClient.setQueryData(utilGetLogEntries.queryKey, [...entries, entry]);
});
const shouldShowLogLevel = logsLevel.data ?? [];
return (
<VStack>
<ManageLogsHeading
search={search}
setSearch={setSearch}
shouldShowLogLevel={shouldShowLogLevel}
handleLogLevelChange={handleLogLevelChange.mutate}
handleLogAutoScrollChange={handleLogAutoScrollChange}
autoScroll={autoScroll}
/>
<main className="shrink overflow-hidden flex w-full h-full">
<LogsListCard
logEntry={logEntriesQuery.data ?? []}
search={search}
shouldShowLogLevel={shouldShowLogLevel}
autoScroll={autoScroll}
/>
</main>
</VStack>
);
}
function ManageLogsHeading({
search,
setSearch,
shouldShowLogLevel,
handleLogLevelChange,
handleLogAutoScrollChange,
autoScroll,
}: {
search: string;
setSearch: (value: string) => void;
shouldShowLogLevel: LogLevel[];
handleLogLevelChange: (newLogLevels: LogLevel[]) => void;
handleLogAutoScrollChange: (newAutoScroll: boolean) => void;
autoScroll: boolean;
}) {
const searchRef = useRef<HTMLInputElement>(null);
useDocumentEvent(
"keydown",
(e) => {
if (isFindKey(e)) {
searchRef.current?.focus();
}
},
[],
);
return (
<HNavBar
className="shrink-0"
leading={
<>
<HNavBarText>{tc("logs")}</HNavBarText>
<SearchBox
className={"w-max grow"}
value={search}
onChange={(e) => setSearch(e.target.value)}
ref={searchRef}
/>
</>
}
trailing={
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className={"shrink-0 p-3 compact:h-10"}>
{tc("logs:manage:select logs level")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<LogLevelMenuItem
logLevel="Info"
shouldShowLogLevel={shouldShowLogLevel}
handleLogLevelChange={handleLogLevelChange}
/>
<LogLevelMenuItem
logLevel="Warn"
className="text-warning"
shouldShowLogLevel={shouldShowLogLevel}
handleLogLevelChange={handleLogLevelChange}
/>
<LogLevelMenuItem
logLevel="Error"
className="text-destructive"
shouldShowLogLevel={shouldShowLogLevel}
handleLogLevelChange={handleLogLevelChange}
/>
<LogLevelMenuItem
logLevel="Debug"
className="text-info"
shouldShowLogLevel={shouldShowLogLevel}
handleLogLevelChange={handleLogLevelChange}
/>
{/* Currently no trace level logs will be passed to frontend */}
{/*<LogLevelMenuItem
logLevel="Trace"
shouldShowLogLevel={shouldShowLogLevel}
setShouldShowLogLevel={setShouldShowLogLevel}
/>*/}
</DropdownMenuContent>
</DropdownMenu>
<Button
className={"compact:h-10"}
onClick={() =>
commands.utilOpen(
`${globalInfo.vpmHomeFolder}/vrc-get/gui-logs`,
"ErrorIfNotExists",
)
}
>
{tc("settings:button:open logs")}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={"ghost"}
onClick={() => handleLogAutoScrollChange(!autoScroll)}
className={`compact:h-10 ${
autoScroll
? "bg-secondary border border-primary"
: "bg-transparent"
}`}
>
<ArrowDownFromLine className={"w-5 h-5"} />
</Button>
</TooltipTrigger>
<TooltipContent>{tc("logs:manage:auto scroll")}</TooltipContent>
</Tooltip>
</>
}
/>
);
}
function LogLevelMenuItem({
logLevel,
className,
shouldShowLogLevel,
handleLogLevelChange,
}: {
logLevel: LogLevel;
className?: string;
shouldShowLogLevel: LogLevel[];
handleLogLevelChange: (newLogLevels: LogLevel[]) => void;
}) {
const selected = shouldShowLogLevel.includes(logLevel);
const onChange = () => {
const newLogLevels = selected
? shouldShowLogLevel.filter(
(logLevelFilter) => logLevelFilter !== logLevel,
)
: [...shouldShowLogLevel, logLevel];
handleLogLevelChange(newLogLevels);
};
return (
<DropdownMenuItem
className="p-0"
onSelect={(e) => {
e.preventDefault();
}}
>
<label
className={
"flex cursor-pointer items-center gap-2 p-2 whitespace-normal"
}
>
<Checkbox
checked={selected}
onCheckedChange={onChange}
className="hover:before:content-none"
/>
<p className={className}>{logLevel}</p>
</label>
</DropdownMenuItem>
);
}

View file

@ -1,67 +0,0 @@
import { Link } from "@tanstack/react-router";
import { tc } from "@/lib/i18n";
type PageType =
| "/packages/user-packages"
| "/packages/repositories"
| "/packages/templates";
// Note: For historical reasons, templates page are under packages in route.
export function HeadingPageName({ pageType }: { pageType: PageType }) {
// Note for p-1 rounded-md -m-1 compact:m-0
// For normal mode, we use 1-unit of the outer padding for selector rectangle, so we use negative margin to eat padding.
// For compact mode, the height of the button is 2 units shorter than normal with the height of the navbar is remaining.
// Therefore we use the 1 unit space for outer padding for selector rectangle.
return (
<div className={"flex compact:h-10 items-center"}>
<div
className={
"grid grid-cols-3 gap-1.5 bg-secondary p-1 rounded-md -m-1 compact:m-0"
}
>
<HeadingButton
currentPage={pageType}
targetPage={"/packages/repositories"}
>
{tc("packages:repositories")}
</HeadingButton>
<HeadingButton
currentPage={pageType}
targetPage={"/packages/user-packages"}
>
{tc("packages:user packages")}
</HeadingButton>
<HeadingButton
currentPage={pageType}
targetPage={"/packages/templates"}
>
{tc("packages:templates")}
</HeadingButton>
</div>
</div>
);
}
function HeadingButton({
currentPage,
targetPage,
children,
}: {
currentPage: PageType;
targetPage: PageType;
children: React.ReactNode;
}) {
const button =
"cursor-pointer px-3 py-2 font-bold grow-0 hover:bg-background rounded-sm text-center p-2 compact:h-8 compact:py-1";
if (currentPage === targetPage) {
return <div className={`${button} bg-background`}>{children}</div>;
} else {
return (
<Link to={targetPage} className={button}>
{children}
</Link>
);
}
}

View file

@ -1,271 +0,0 @@
import { queryOptions } from "@tanstack/react-query";
import type React from "react";
import { useEffect, useRef, useState } from "react";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import { DialogFooter } from "@/components/ui/dialog";
import { Progress } from "@/components/ui/progress";
import { assertNever } from "@/lib/assert-never";
import type {
TauriDownloadRepository,
TauriRepositoryDescriptor,
} from "@/lib/bindings";
import { commands } from "@/lib/bindings";
import { callAsyncCommand } from "@/lib/call-async-command";
import { type DialogContext, showDialog } from "@/lib/dialog";
import { tc, tt } from "@/lib/i18n";
import { queryClient } from "@/lib/query-client";
import { toastSuccess } from "@/lib/toast";
import { useEffectEvent } from "@/lib/use-effect-event";
type ParsedRepositories = {
repositories: TauriRepositoryDescriptor[];
unparsable_lines: string[];
};
const environmentRepositoriesInfo = queryOptions({
queryKey: ["environmentRepositoriesInfo"],
queryFn: commands.environmentRepositoriesInfo,
});
export async function importRepositories() {
using dialog = showDialog();
const pickResult = await commands.environmentImportRepositoryPick();
switch (pickResult.type) {
case "NoFilePicked":
// no-op
return;
case "ParsedRepositories":
// continue
break;
default:
assertNever(pickResult, "pickResult");
}
console.log("confirmingRepositories", pickResult);
const repositories = await dialog.ask(ConfirmingRepositoryList, {
pickResult,
});
if (repositories == null) return;
const packages = await dialog.ask(LoadingRepositories, {
repositories,
});
if (packages == null) return;
const repositoriesToAdd = await dialog.ask(ConfirmingPackages, {
packages,
});
if (repositoriesToAdd == null) return;
dialog.replace(<AddingRepositories />);
await commands.environmentImportAddRepositories(repositoriesToAdd);
toastSuccess(tt("vpm repositories:toast:repository added"));
dialog.close();
await queryClient.invalidateQueries(environmentRepositoriesInfo);
}
function shortRepositoryDescription(
repo: TauriRepositoryDescriptor,
): React.ReactNode {
if (Object.keys(repo.headers).length > 0) {
return tc("vpm repositories:dialog:repository with headers", {
repoUrl: repo.url,
});
}
return repo.url;
}
function ConfirmingRepositoryList({
pickResult,
dialog,
}: {
pickResult: ParsedRepositories;
dialog: DialogContext<TauriRepositoryDescriptor[] | null>;
}) {
return (
<>
{/* TODO: use ScrollArea (I failed to use it inside dialog) */}
<div className={"max-h-[50vh] overflow-y-auto font-normal"}>
<p className={"font-normal whitespace-normal"}>
{tc("vpm repositories:dialog:confirm repository list")}
</p>
<ul className={"list-disc pl-6"}>
{pickResult.repositories.map((info) => (
<li key={info.url}>{shortRepositoryDescription(info)}</li>
))}
</ul>
{pickResult.unparsable_lines.length > 0 && (
<>
<p className={"font-normal whitespace-normal"}>
{tc("vpm repositories:dialog:unparsable lines list")}
</p>
<ul className={"list-disc pl-6"}>
{pickResult.unparsable_lines.map((line, idx) => (
// biome-ignore lint/suspicious/noArrayIndexKey: unchanged
<li key={idx} className={"whitespace-pre"}>
{line}
</li>
))}
</ul>
</>
)}
</div>
<DialogFooter className={"gap-2"}>
<Button onClick={() => dialog.close(null)}>
{tc("general:button:cancel")}
</Button>
<Button onClick={() => dialog.close(pickResult.repositories)}>
{tc("vpm repositories:dialog:button:continue importing repositories")}
</Button>
</DialogFooter>
</>
);
}
function LoadingRepositories({
repositories,
dialog,
}: {
repositories: TauriRepositoryDescriptor[];
dialog: DialogContext<
[TauriRepositoryDescriptor, TauriDownloadRepository][] | null
>;
}) {
const cancelRef = useRef<() => void>(() => {});
const totalCount = repositories.length;
const [downloaded, setDownloaded] = useState(0);
const event = useEffectEvent(() => {
const [cancel, resultPromise] = callAsyncCommand(
commands.environmentImportDownloadRepositories,
[repositories],
(downloaded) => setDownloaded(downloaded),
);
cancelRef.current = cancel;
resultPromise.then((x) => dialog.close(x === "cancelled" ? null : x));
});
useEffect(() => event(), []);
return (
<>
<div>
<p>{tc("vpm repositories:dialog:downloading repositories...")}</p>
<Progress value={downloaded} max={totalCount} />
<div className={"text-center"}>
{tc("vpm repositories:dialog:downloaded n/m", {
downloaded,
totalCount,
})}
</div>
</div>
<DialogFooter>
<Button onClick={() => cancelRef.current?.()}>
{tc("general:button:cancel")}
</Button>
</DialogFooter>
</>
);
}
function ConfirmingPackages({
packages,
dialog,
}: {
packages: [TauriRepositoryDescriptor, TauriDownloadRepository][];
dialog: DialogContext<TauriRepositoryDescriptor[] | null>;
}) {
async function add() {
dialog.close(
packages
.filter(([_, download]) => download.type === "Success")
.map(([repo, _]) => repo),
);
}
return (
<>
{/* TODO: use ScrollArea (I failed to use it inside dialog) */}
<div className={"font-normal"}>
<p className={"whitespace-normal"}>
{tc("vpm repositories:dialog:confirm packages list")}
</p>
<Accordion
type="single"
collapsible
className="max-h-[50vh] overflow-y-auto w-full"
>
{packages.map(([repo, download]) => {
let error: boolean;
let content: React.ReactNode;
switch (download.type) {
case "BadUrl":
throw new Error("BadUrl should not be here");
case "Duplicated":
error = true;
content = tc(
"vpm repositories:dialog:download error:duplicated",
);
break;
case "DownloadError":
error = true;
content = tc(
"vpm repositories:dialog:download error:download error",
);
break;
case "Success":
error = false;
content = (
<ul className={"list-disc pl-6"}>
{download.value.packages.map((info) => (
<li key={info.name}>{info.display_name ?? info.name}</li>
))}
</ul>
);
break;
default:
assertNever(download, "download");
}
const destrucive = error ? "text-destructive" : "";
return (
<AccordionItem value={repo.url} key={repo.url}>
<AccordionTrigger className={`${destrucive} py-2 text-base`}>
{shortRepositoryDescription(repo)}
</AccordionTrigger>
<AccordionContent className={destrucive}>
{content}
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
</div>
<DialogFooter>
<Button onClick={() => dialog.close(null)}>
{tc("general:button:cancel")}
</Button>
<Button onClick={add} className={"ml-2"}>
{tc("vpm repositories:button:add repositories")}
</Button>
</DialogFooter>
</>
);
}
function AddingRepositories() {
return (
<div>
<p>{tc("vpm repositories:dialog:adding repositories...")}</p>
</div>
);
}

View file

@ -1,889 +0,0 @@
"use client";
import {
type CollisionDetection,
closestCenter,
DndContext,
type DragEndEvent,
type DragOverEvent,
DragOverlay,
type DragStartEvent,
defaultDropAnimation,
defaultDropAnimationSideEffects,
type Modifier,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import {
queryOptions,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { ChevronDown, CircleX, GripVertical } from "lucide-react";
import {
Suspense,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { HNavBar, VStack } from "@/components/layout";
import { ScrollableCardTable } from "@/components/ScrollableCardTable";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { DialogFooter, DialogTitle } from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { TauriUserRepository } from "@/lib/bindings";
import { commands } from "@/lib/bindings";
import { type DialogContext, openSingleDialog } from "@/lib/dialog";
import { tc, tt } from "@/lib/i18n";
import { usePrevPathName } from "@/lib/prev-page";
import { toastThrownError } from "@/lib/toast";
import { useTauriListen } from "@/lib/use-tauri-listen";
import { cn } from "@/lib/utils";
import { HeadingPageName } from "../-tab-selector";
import { addRepository, openAddRepositoryDialog } from "./-use-add-repository";
import { importRepositories } from "./-use-import-repositories";
export const Route = createFileRoute("/_main/packages/repositories/")({
component: Page,
});
type UserRepoWithListId = TauriUserRepository & { listId: string };
function Page() {
return (
<Suspense>
<PageBody />
</Suspense>
);
}
const restrictToVerticalAxis: Modifier = ({ transform }) => ({
...transform,
x: 0,
});
const DRAG_OVERLAY_MODIFIERS = [restrictToVerticalAxis];
const customDropAnimation: typeof defaultDropAnimation = {
...defaultDropAnimation,
sideEffects: defaultDropAnimationSideEffects({
styles: {
active: { opacity: "0" },
},
}),
};
const TABLE_HEAD = [
"", // checkbox
"general:name",
"vpm repositories:url",
"", // actions
"", // grip handle
] as const;
const environmentRepositoriesInfo = queryOptions({
queryKey: ["environmentRepositoriesInfo"],
queryFn: commands.environmentRepositoriesInfo,
});
// Scrolls the given viewport element when the pointer is near the top or bottom
// edge during drag. dnd-kit's built-in autoscroll is disabled because it causes
// jitter with Radix UI ScrollArea (wrong container detection + double-smoothing).
function useDragAutoScroll(
viewportRef: React.RefObject<HTMLElement | null>,
isActive: boolean,
): void {
useEffect(() => {
if (!isActive) return;
const THRESHOLD = 80; // px from edge to begin scrolling
const MAX_SPEED = 15; // px/frame at the very edge
let pointerY = 0;
const onPointerMove = (e: PointerEvent) => {
pointerY = e.clientY;
};
window.addEventListener("pointermove", onPointerMove, { passive: true });
let rafId: number;
const tick = () => {
const viewport = viewportRef.current;
if (viewport) {
const { top, bottom } = viewport.getBoundingClientRect();
const distFromTop = pointerY - top;
const distFromBottom = bottom - pointerY;
let delta = 0;
if (distFromTop >= 0 && distFromTop < THRESHOLD) {
delta = -MAX_SPEED * (1 - distFromTop / THRESHOLD);
} else if (distFromBottom >= 0 && distFromBottom < THRESHOLD) {
delta = MAX_SPEED * (1 - distFromBottom / THRESHOLD);
}
if (delta !== 0) {
viewport.scrollTo({
top: viewport.scrollTop + delta,
behavior: "instant",
});
}
}
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
return () => {
window.removeEventListener("pointermove", onPointerMove);
cancelAnimationFrame(rafId);
};
}, [isActive, viewportRef]);
}
function computeSlotKey(repo: TauriUserRepository, used: Set<string>): string {
const base = `${repo.id} ${repo.url ?? ""}`;
let key = base;
let counter = 0;
while (used.has(key)) {
counter++;
key = `${base} ${counter}`;
}
used.add(key);
return key;
}
function PageBody() {
const result = useQuery(environmentRepositoriesInfo);
const exportRepositories = useMutation({
mutationFn: async () => await commands.environmentExportRepositories(),
onError: (e) => {
console.error(e);
toastThrownError(e);
},
});
const importRepositoriesMutation = useMutation({
mutationFn: async () => await importRepositories(),
onError: (e) => {
console.error(e);
toastThrownError(e);
},
});
const processDeepLink = useCallback(async function processDeepLink() {
const data = await commands.deepLinkTakeAddRepository();
if (data == null) return;
await addRepository(data.url, data.headers);
}, []);
const hiddenUserRepos = useMemo(
() => new Set(result.data?.hidden_user_repositories),
[result.data?.hidden_user_repositories],
);
useTauriListen<null>("deep-link-add-repository", (_) => {
void processDeepLink();
});
// biome-ignore lint/correctness/useExhaustiveDependencies: we want to do on mount
useEffect(() => {
void processDeepLink();
// Only for initial load
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const guiAnimation = useQuery({
queryKey: ["environmentGuiAnimation"],
queryFn: commands.environmentGuiAnimation,
initialData: true,
}).data;
const userRepos = result.data?.user_repositories;
const listIdMapRef = useRef<Map<string, string>>(new Map());
const augmentedUserRepos = useMemo<UserRepoWithListId[]>(() => {
if (!userRepos) {
listIdMapRef.current = new Map();
return [];
}
const prev = listIdMapRef.current;
const next = new Map<string, string>();
const usedKeys = new Set<string>();
const result: UserRepoWithListId[] = [];
for (const r of userRepos) {
const key = computeSlotKey(r, usedKeys);
const listId = prev.get(key) ?? crypto.randomUUID();
next.set(key, listId);
result.push({ ...r, listId });
}
listIdMapRef.current = next;
return result;
}, [userRepos]);
const [orderedListIds, setOrderedListIds] = useState<string[]>(() =>
augmentedUserRepos.map((r) => r.listId),
);
useEffect(() => {
setOrderedListIds(augmentedUserRepos.map((r) => r.listId));
}, [augmentedUserRepos]);
const userRepoByListId = useMemo(
() => new Map(augmentedUserRepos.map((r) => [r.listId, r])),
[augmentedUserRepos],
);
const userRepoByListIdRef =
useRef<Map<string, UserRepoWithListId>>(userRepoByListId);
useEffect(() => {
userRepoByListIdRef.current = userRepoByListId;
}, [userRepoByListId]);
const [activeId, setActiveId] = useState<string | null>(null);
const [overId, setOverId] = useState<string | null>(null);
const [columnWidths, setColumnWidths] = useState<number[]>([]);
const theadRowRef = useRef<HTMLTableRowElement>(null);
const scrollViewportRef = useRef<HTMLDivElement>(null);
const sensors = useSensors(useSensor(PointerSensor));
const orderedListIdsSet = useMemo(
() => new Set(orderedListIds),
[orderedListIds],
);
const collisionDetection = useCallback<CollisionDetection>(
(args) =>
closestCenter({
...args,
droppableContainers: args.droppableContainers.filter((c) =>
orderedListIdsSet.has(c.id as string),
),
}),
[orderedListIdsSet],
);
const queryClient = useQueryClient();
const reorderMutation = useMutation({
mutationFn: (listIds: string[]) => {
const repos = listIds
.map((lid) => userRepoByListId.get(lid))
.filter((r): r is UserRepoWithListId => r !== undefined)
.map((r) => ({ index: r.index, id: r.id }));
return commands.environmentReorderRepositories(repos);
},
// Pin listIds to the new positions so duplicate-keyed rows don't swap their listIds on refetch.
onMutate: (newListIds: string[]) => {
const prevMap = new Map(listIdMapRef.current);
const rebuilt = new Map<string, string>();
const usedKeys = new Set<string>();
for (const lid of newListIds) {
const repo = userRepoByListIdRef.current.get(lid);
if (!repo) continue;
const key = computeSlotKey(repo, usedKeys);
rebuilt.set(key, lid);
}
listIdMapRef.current = rebuilt;
return { prevMap };
},
onSettled: () => queryClient.invalidateQueries(environmentRepositoriesInfo),
onError: (e, _newListIds, ctx) => {
if (ctx?.prevMap) listIdMapRef.current = ctx.prevMap;
toastThrownError(e);
},
});
const setHideRepository = useMutation({
mutationFn: async ({ id, shown }: { id: string; shown: boolean }) => {
if (shown) {
await commands.environmentShowRepository(id);
} else {
await commands.environmentHideRepository(id);
}
},
onMutate: async ({ id, shown }: { id: string; shown: boolean }) => {
await queryClient.cancelQueries(environmentRepositoriesInfo);
const data = queryClient.getQueryData(
environmentRepositoriesInfo.queryKey,
);
if (data !== undefined) {
let hidden_user_repositories: string[];
if (shown) {
if (data.hidden_user_repositories.includes(id)) {
hidden_user_repositories = data.hidden_user_repositories;
} else {
hidden_user_repositories = [...data.hidden_user_repositories, id];
}
} else {
hidden_user_repositories = data.hidden_user_repositories.filter(
(x) => x !== id,
);
}
queryClient.setQueryData(environmentRepositoriesInfo.queryKey, {
...data,
hidden_user_repositories,
});
}
return data;
},
onError: (e, _, ctx) => {
reportError(e);
console.error(e);
queryClient.setQueryData(environmentRepositoriesInfo.queryKey, ctx);
},
onSettled: async () => {
await queryClient.invalidateQueries(environmentRepositoriesInfo);
},
});
const activeVisualIndex = useMemo(() => {
if (!activeId) return 0;
const effectiveId = overId ?? activeId;
return orderedListIds.indexOf(effectiveId) + 2; // +2 for the 2 fixed rows
}, [activeId, overId, orderedListIds]);
function handleDragStart(event: DragStartEvent) {
setActiveId(event.active.id as string);
if (theadRowRef.current) {
const widths = Array.from(
theadRowRef.current.querySelectorAll("th"),
(th) => th.getBoundingClientRect().width,
);
setColumnWidths(widths);
}
}
function handleDragOver(event: DragOverEvent) {
setOverId((event.over?.id as string | null) ?? null);
}
function handleDragEnd(event: DragEndEvent) {
setActiveId(null);
setOverId(null);
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = orderedListIds.indexOf(active.id as string);
const newIndex = orderedListIds.indexOf(over.id as string);
const newListIds = arrayMove(orderedListIds, oldIndex, newIndex);
setOrderedListIds(newListIds);
reorderMutation.mutate(newListIds);
}
}
function handleDragCancel() {
setActiveId(null);
setOverId(null);
}
useDragAutoScroll(scrollViewportRef, activeId !== null);
const bodyAnimation = usePrevPathName().startsWith("/packages")
? "slide-right"
: "";
return (
<DndContext
sensors={sensors}
collisionDetection={collisionDetection}
autoScroll={false}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<VStack>
<div style={activeId !== null ? { pointerEvents: "none" } : undefined}>
<HNavBar
className="shrink-0"
leading={<HeadingPageName pageType={"/packages/repositories"} />}
trailing={
<DropdownMenu>
<div className={"flex divide-x"}>
<Button
className={"rounded-r-none compact:h-10"}
onClick={() => openAddRepositoryDialog()}
>
{tc("vpm repositories:button:add repository")}
</Button>
<DropdownMenuTrigger
asChild
className={"rounded-l-none pl-2 pr-2 compact:h-10"}
>
<Button>
<ChevronDown className={"w-4 h-4"} />
</Button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent>
<DropdownMenuItem
onClick={() => importRepositoriesMutation.mutate()}
>
{tc("vpm repositories:button:import repositories")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => exportRepositories.mutate()}>
{tc("vpm repositories:button:export repositories")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
}
/>
</div>
<main
className={`shrink overflow-hidden flex w-full h-full ${bodyAnimation}`}
>
<ScrollableCardTable
className={"h-full w-full"}
viewportRef={scrollViewportRef}
>
<RepositoryTableBody
orderedListIds={orderedListIds}
userRepoByListId={userRepoByListId}
hiddenUserRepos={hiddenUserRepos}
theadRowRef={theadRowRef}
guiAnimation={guiAnimation}
onToggleVisibility={(id, shown) =>
setHideRepository.mutate({ id, shown })
}
isDragActive={activeId !== null}
/>
</ScrollableCardTable>
</main>
</VStack>
<DragOverlay
modifiers={DRAG_OVERLAY_MODIFIERS}
dropAnimation={guiAnimation ? customDropAnimation : null}
>
{activeId ? (
<RepositoryDragOverlay
repo={userRepoByListId.get(activeId)}
selected={
!hiddenUserRepos.has(userRepoByListId.get(activeId)?.id ?? "")
}
columnWidths={columnWidths}
visualIndex={activeVisualIndex}
guiAnimation={guiAnimation}
/>
) : null}
</DragOverlay>
</DndContext>
);
}
function RepositoryTableBody({
orderedListIds,
userRepoByListId,
hiddenUserRepos,
theadRowRef,
guiAnimation,
onToggleVisibility,
isDragActive,
}: {
orderedListIds: string[];
userRepoByListId: Map<string, UserRepoWithListId>;
hiddenUserRepos: Set<string>;
theadRowRef: React.RefObject<HTMLTableRowElement | null>;
guiAnimation: boolean;
onToggleVisibility: (id: string, shown: boolean) => void;
isDragActive: boolean;
}) {
return (
<>
<thead>
<tr ref={theadRowRef}>
{TABLE_HEAD.map((head, index) => (
<th
// biome-ignore lint/suspicious/noArrayIndexKey: static array
key={index}
className={
"sticky top-0 z-10 border-b border-primary bg-secondary text-secondary-foreground px-2.5 py-1.5"
}
>
<small className="font-normal leading-none">{tc(head)}</small>
</th>
))}
</tr>
</thead>
<tbody>
<RepositoryRow
repoId={"com.vrchat.repos.official"}
url={"https://packages.vrchat.com/official?download"}
displayName={tt("vpm repositories:source:official")}
hiddenUserRepos={hiddenUserRepos}
canRemove={false}
rowIndex={0}
guiAnimation={guiAnimation}
onToggleVisibility={onToggleVisibility}
isDragActive={isDragActive}
/>
<RepositoryRow
repoId={"com.vrchat.repos.curated"}
url={"https://packages.vrchat.com/curated?download"}
displayName={tt("vpm repositories:source:curated")}
hiddenUserRepos={hiddenUserRepos}
className={"border-b border-primary/10"}
canRemove={false}
rowIndex={1}
guiAnimation={guiAnimation}
onToggleVisibility={onToggleVisibility}
isDragActive={isDragActive}
/>
<SortableContext
items={orderedListIds}
strategy={verticalListSortingStrategy}
>
{orderedListIds.map((listId, index) => {
const repo = userRepoByListId.get(listId);
if (!repo) return null;
return (
<RepositoryRow
key={listId}
listId={listId}
repoId={repo.id}
repoIndex={repo.index}
displayName={repo.display_name}
url={repo.url}
hiddenUserRepos={hiddenUserRepos}
rowIndex={2 + index}
guiAnimation={guiAnimation}
onToggleVisibility={onToggleVisibility}
isDragActive={isDragActive}
/>
);
})}
</SortableContext>
</tbody>
</>
);
}
const CELL_CLASS = "p-2.5 compact:py-1 align-middle";
function RepositoryRowCells({
labelId,
displayName,
url,
canRemove,
selected,
onCheckedChange,
onRemove,
dragListeners,
dragAttributes,
}: {
labelId?: string;
displayName: string;
url: string | null | undefined;
canRemove: boolean;
selected: boolean;
onCheckedChange?: (shown: boolean) => void;
onRemove?: () => void;
dragListeners?: ReturnType<typeof useSortable>["listeners"];
dragAttributes?: ReturnType<typeof useSortable>["attributes"];
}) {
const interactive = onCheckedChange !== undefined;
return (
<>
<td className={CELL_CLASS}>
{interactive ? (
<div className="flex">
<Checkbox
id={labelId}
checked={selected}
onCheckedChange={(x) => onCheckedChange(x === true)}
/>
</div>
) : (
<div className="pointer-events-none flex">
<Checkbox checked={selected} />
</div>
)}
</td>
<td className={CELL_CLASS}>
{interactive ? (
<label htmlFor={labelId}>
<p className="font-normal">{displayName}</p>
</label>
) : (
<p className="font-normal">{displayName}</p>
)}
</td>
<td className={CELL_CLASS}>
<p className="font-normal">{url}</p>
</td>
<td className={`${CELL_CLASS} w-0`}>
{interactive ? (
<Tooltip>
<TooltipTrigger asChild={canRemove}>
<Button
disabled={!canRemove}
onClick={onRemove}
variant={"ghost"}
size={"icon"}
>
<CircleX className={"size-5 text-destructive"} />
</Button>
</TooltipTrigger>
<TooltipContent>
{canRemove
? tc("vpm repositories:remove repository")
: tc(
"vpm repositories:tooltip:remove curated or official repository",
)}
</TooltipContent>
</Tooltip>
) : (
<Button variant={"ghost"} size={"icon"} disabled>
<CircleX className={"size-5 text-destructive"} />
</Button>
)}
</td>
<td
className={cn(
CELL_CLASS,
"w-0",
canRemove ? "cursor-move" : "cursor-not-allowed",
)}
{...(canRemove ? dragListeners : undefined)}
{...(canRemove ? dragAttributes : undefined)}
>
<GripVertical
className={cn(
"size-5 text-muted-foreground",
!canRemove && "opacity-50",
)}
/>
</td>
</>
);
}
function RepositoryRow({
listId,
repoId,
repoIndex,
displayName,
url,
hiddenUserRepos,
className,
canRemove = true,
rowIndex,
guiAnimation,
onToggleVisibility,
isDragActive,
}: {
listId?: string;
repoId: TauriUserRepository["id"];
repoIndex?: number;
displayName: TauriUserRepository["display_name"];
url: TauriUserRepository["url"];
hiddenUserRepos: Set<string>;
className?: string;
canRemove?: boolean;
rowIndex: number;
guiAnimation: boolean;
onToggleVisibility: (id: string, shown: boolean) => void;
isDragActive: boolean;
}) {
const labelId = useId();
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: listId ?? repoId, disabled: !canRemove });
const visualIndex = useMemo(() => {
if (isDragging) return rowIndex;
const dy = transform?.y ?? 0;
if (dy < 0) return rowIndex - 1;
if (dy > 0) return rowIndex + 1;
return rowIndex;
}, [rowIndex, transform?.y, isDragging]);
const dragStyle = useMemo<React.CSSProperties>(
() => ({
transform: transform ? `translateY(${transform.y}px)` : undefined,
transition: guiAnimation
? [transition, isDragActive ? undefined : "background-color 200ms ease"]
.filter(Boolean)
.join(", ") || undefined
: undefined,
opacity: isDragging ? 0 : 1,
position: "relative",
}),
[transform, transition, isDragging, guiAnimation, isDragActive],
);
const selected = !hiddenUserRepos.has(repoId);
return (
<tr
ref={setNodeRef}
style={dragStyle}
className={cn(visualIndex % 2 === 1 ? "bg-secondary/30" : "", className)}
>
<RepositoryRowCells
labelId={labelId}
displayName={displayName}
url={url}
canRemove={canRemove}
selected={selected}
onCheckedChange={(shown) => onToggleVisibility(repoId, shown)}
onRemove={() =>
void openSingleDialog(RemoveRepositoryDialog, {
displayName,
index: repoIndex ?? 0,
id: repoId,
})
}
dragListeners={listeners}
dragAttributes={attributes}
/>
</tr>
);
}
function RepositoryDragOverlay({
repo,
selected,
columnWidths,
visualIndex,
guiAnimation,
}: {
repo: TauriUserRepository | undefined;
selected: boolean;
columnWidths: number[];
visualIndex: number;
guiAnimation: boolean;
}) {
const style = useMemo<React.CSSProperties>(
() => ({
transition: guiAnimation ? "background-color 200ms ease" : undefined,
}),
[guiAnimation],
);
if (!repo) return null;
return (
<table
className={cn(
"w-full table-fixed text-left",
visualIndex % 2 === 1 ? "bg-secondary/30" : "",
)}
style={style}
>
{columnWidths.length > 0 && (
<colgroup>
{columnWidths.map((w, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: fixed column order
<col key={i} style={{ width: w }} />
))}
</colgroup>
)}
<tbody>
<tr>
<RepositoryRowCells
displayName={repo.display_name}
url={repo.url}
canRemove={true}
selected={selected}
/>
</tr>
</tbody>
</table>
);
}
function RemoveRepositoryDialog({
dialog,
displayName,
index,
id,
}: {
dialog: DialogContext<void>;
displayName: string;
index: number;
id: string;
}) {
const queryClient = useQueryClient();
const removeRepository = useMutation({
mutationFn: async (args: { index: number; id: string }) =>
await commands.environmentRemoveRepository(args.index, args.id),
onMutate: async ({ index }) => {
await queryClient.cancelQueries(environmentRepositoriesInfo);
const data = queryClient.getQueryData(
environmentRepositoriesInfo.queryKey,
);
if (data !== undefined) {
queryClient.setQueryData(environmentRepositoriesInfo.queryKey, {
...data,
user_repositories: data.user_repositories.filter(
(x) => x.index !== index,
),
});
}
return data;
},
onError: (e, _args, ctx) => {
queryClient.setQueryData(environmentRepositoriesInfo.queryKey, ctx);
toastThrownError(e);
},
onSettled: () => queryClient.invalidateQueries(environmentRepositoriesInfo),
});
return (
<>
<DialogTitle>{tc("vpm repositories:remove repository")}</DialogTitle>
<div>
<p className={"whitespace-normal font-normal"}>
{tc("vpm repositories:dialog:confirm remove description", {
name: displayName,
})}
</p>
</div>
<DialogFooter>
<Button onClick={() => dialog.close()}>
{tc("general:button:cancel")}
</Button>
<Button
onClick={() => {
dialog.close();
removeRepository.mutate({ index, id });
}}
className={"ml-2"}
>
{tc("vpm repositories:remove repository")}
</Button>
</DialogFooter>
</>
);
}

File diff suppressed because it is too large Load diff

View file

@ -1,325 +0,0 @@
import { useMutation } from "@tanstack/react-query";
import { RefreshCw } from "lucide-react";
import type React from "react";
import { useEffect, useId, useMemo, useState } from "react";
import { VStack } from "@/components/layout";
import { TemplateSelect } from "@/components/TemplateSelect";
import { Button } from "@/components/ui/button";
import { DialogFooter, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { assertNever } from "@/lib/assert-never";
import type { TauriProjectTemplateInfo } from "@/lib/bindings";
import { commands } from "@/lib/bindings";
import { type DialogContext, showDialog } from "@/lib/dialog";
import { tc, tt } from "@/lib/i18n";
import { router } from "@/lib/main";
import { pathSeparator } from "@/lib/os";
import {
ProjectNameCheckResult,
useProjectNameCheck,
} from "@/lib/project-name-check";
import { queryClient } from "@/lib/query-client";
import { toastError, toastSuccess, toastThrownError } from "@/lib/toast";
export async function createProject() {
const information = await commands.environmentProjectCreationInformation();
using dialog = showDialog();
const result = await dialog.ask(EnteringInformation, {
templates: information.templates,
favoriteTemplates: information.favorite_templates,
lastUsedTemplate: information.last_used_template,
projectLocation: information.default_path,
recentProjectLocations: information.recent_project_locations,
});
if (result == null) return;
dialog.replace(<CreatingProject />);
await commands.environmentCreateProject(
result.projectLocation,
result.projectName,
result.templateId,
information.templates_version,
result.unityVersion,
);
dialog.close();
toastSuccess(tt("projects:toast:project created"));
await queryClient.invalidateQueries({
queryKey: ["environmentProjects"],
});
const projectPath = `${result.projectLocation}${pathSeparator()}${result.projectName}`;
router.navigate({
to: "/projects/manage",
search: { projectPath },
});
}
function DialogBase({
children,
close,
createProject,
}: {
children: React.ReactNode;
close?: () => void;
createProject?: () => void;
}) {
return (
<>
<DialogTitle>{tc("projects:create new project")}</DialogTitle>
<div>{children}</div>
<DialogFooter className={"gap-2"}>
<Button onClick={close} disabled={!close}>
{tc("general:button:cancel")}
</Button>
<Button onClick={createProject} disabled={!createProject}>
{tc("projects:button:create")}
</Button>
</DialogFooter>
</>
);
}
interface ProjectCreationInformation {
templateId: string;
unityVersion: string;
projectLocation: string;
projectName: string;
}
function EnteringInformation({
templates,
projectLocation: projectLocationFirst,
recentProjectLocations: recentProjectLocationsReversed,
favoriteTemplates,
lastUsedTemplate,
dialog,
}: {
templates: TauriProjectTemplateInfo[];
projectLocation: string;
favoriteTemplates: string[];
lastUsedTemplate: string | null;
recentProjectLocations: string[];
dialog: DialogContext<null | ProjectCreationInformation>;
}) {
const templateById = useMemo(
() => new Map(templates.map((t) => [t.id, t])),
[templates],
);
const [templateId, setTemplateId] = useState<string>(() => {
const template = lastUsedTemplate
? templateById.get(lastUsedTemplate)
: undefined;
return template?.available &&
template.unity_versions.length !== 0 &&
lastUsedTemplate != null
? lastUsedTemplate
: templates[0].id;
});
const [unityVersion, setUnityVersion] = useState<string>(
() =>
templateById.get(templateId)?.unity_versions?.[0] ??
templates[0].unity_versions[0],
);
const [projectNameRaw, setProjectName] = useState("New Project");
const projectName = projectNameRaw.trim();
const [projectLocation, setProjectLocation] = useState(projectLocationFirst);
const [lastPickedLocation, setLastPickedLocation] =
useState(projectLocationFirst);
const projectNameCheckState = useProjectNameCheck(
projectLocation,
projectName,
);
const usePickProjectDefaultPath = useMutation({
mutationFn: () => commands.environmentPickProjectDefaultPath(),
onSuccess: (result) => {
switch (result.type) {
case "NoFolderSelected":
// no-op
break;
case "InvalidSelection":
toastError(tt("general:toast:invalid directory"));
break;
case "Successful":
setProjectLocation(result.new_path);
setLastPickedLocation(result.new_path);
break;
default:
assertNever(result);
}
},
onError: (e) => {
console.error(e);
toastThrownError(e);
},
});
const createProject = async () => {
dialog.close({
templateId,
unityVersion,
projectLocation,
projectName,
});
};
const templateInputId = useId();
const unityInputId = useId();
const unityVersions = templateById.get(templateId)?.unity_versions ?? [];
const badProjectName = ["AlreadyExists", "InvalidNameForFolderName"].includes(
projectNameCheckState,
);
const canCreateProject =
projectNameCheckState !== "checking" && !badProjectName;
useEffect(() => {
setUnityVersion(unityVersions[0]);
}, [unityVersions]);
const recentProjectLocations = useMemo(() => {
const copied = [...recentProjectLocationsReversed];
copied.reverse();
return copied;
}, [recentProjectLocationsReversed]);
return (
<DialogBase
close={() => dialog.close(null)}
createProject={canCreateProject ? createProject : undefined}
>
<VStack>
<div className={"flex gap-1"}>
<div className={"flex items-center whitespace-nowrap"}>
<label htmlFor={templateInputId}>{tc("projects:template")}</label>
</div>
<TemplateSelect
value={templateId}
onValueChange={setTemplateId}
templates={templates}
favoriteTemplates={favoriteTemplates}
selectTriggerId={templateInputId}
/>
</div>
<div className={"flex items-center gap-1 whitespace-nowrap"}>
<label htmlFor={unityInputId}>
{tc("projects:template:unity version")}
</label>
<Select
value={unityVersion}
onValueChange={(value) => setUnityVersion(value)}
disabled={unityVersions.length === 1}
>
<SelectTrigger id={unityInputId}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{unityVersions.map((unityVersion) => (
<SelectItem value={unityVersion} key={unityVersion}>
<UnityVersion
unityVersion={unityVersion}
latestUnityVersion={
unityVersions.length === 1 ? "" : unityVersions[0]
}
/>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Input
value={projectNameRaw}
onChange={(e) => setProjectName(e.target.value)}
/>
<div className={"flex gap-1 items-center"}>
{/*Note that this is an abuse of Select*/}
<Select value={""} onValueChange={(v) => setProjectLocation(v)}>
<SelectTrigger>
<SelectValue placeholder={projectLocation} />
</SelectTrigger>
<SelectContent>
{!recentProjectLocations.includes(lastPickedLocation) && (
<SelectItem value={lastPickedLocation}>
{lastPickedLocation}
</SelectItem>
)}
{recentProjectLocations.map((path) => (
<SelectItem value={path} key={path}>
{path}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
className="flex-none px-4"
onClick={() => usePickProjectDefaultPath.mutate()}
>
{tc("general:button:select")}
</Button>
</div>
<small className={"whitespace-normal"}>
{tc(
"projects:hint:path of creating project",
{ path: `${projectLocation}${pathSeparator()}${projectName}` },
{
components: {
path: (
<span
className={
"p-0.5 font-path whitespace-pre bg-secondary text-secondary-foreground"
}
/>
),
},
},
)}
</small>
<ProjectNameCheckResult projectNameCheckState={projectNameCheckState} />
</VStack>
</DialogBase>
);
}
function UnityVersion({
unityVersion,
latestUnityVersion,
}: {
unityVersion: string;
latestUnityVersion: string;
}) {
if (unityVersion === latestUnityVersion) {
return (
<>
{unityVersion}{" "}
<span className={"text-success"}>{tc("projects:latest")}</span>
</>
);
} else {
return unityVersion;
}
}
function CreatingProject() {
return (
<DialogBase>
<div className={"flex items-center gap-2"}>
<RefreshCw className={"w-5 h-5 animate-spin"} />
<p>{tc("projects:creating project...")}</p>
</div>
</DialogBase>
);
}

View file

@ -1,235 +0,0 @@
import { CircleHelp, CircleUserRound, Ellipsis, Globe } from "lucide-react";
import {
ButtonDisabledIfInvalid,
getProjectDisplayInfo,
ManageOrMigrateButton,
ProjectContext,
TooltipTriggerIfInvalid,
TooltipTriggerIfValid,
useSetProjectFavoriteMutation,
} from "@/app/_main/projects/-project-row";
import { copyProject } from "@/app/_main/projects/manage/-copy-project";
import { BackupProjectDialog } from "@/components/BackupProjectDialog";
import { FavoriteStarToggleButton } from "@/components/FavoriteStarButton";
import { OpenUnityButton } from "@/components/OpenUnityButton";
import { RemoveProjectDialog } from "@/components/RemoveProjectDialog";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipPortal,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { TauriProject } from "@/lib/bindings";
import { commands } from "@/lib/bindings";
import {
dateToString,
dayToString,
formatDateOffset,
} from "@/lib/dateToString";
import { openSingleDialog } from "@/lib/dialog";
import { tc } from "@/lib/i18n";
import { toastThrownError } from "@/lib/toast";
export function ProjectGridItem({
project,
loading,
}: {
project: TauriProject;
loading?: boolean;
}) {
const setProjectFavorite = useSetProjectFavoriteMutation();
const typeIconClass = "w-5 h-5";
const { projectTypeKind, displayType, isLegacy, createdAt, lastModified } =
getProjectDisplayInfo(project);
const removed = !project.is_exists;
const is_valid = project.is_valid;
return (
<ProjectContext.Provider
value={{ removed, is_valid, loading: Boolean(loading) }}
>
<Card className="relative p-4 bg-card flex flex-col gap-2 group compact:p-2 compact:pl-3 compact:gap-1">
<div className={"absolute top-2 right-2 gap-2 flex"}>
<div className="relative content-center">
<FavoriteStarToggleButton
favorite={project.favorite}
disabled={removed || loading}
onToggle={() =>
setProjectFavorite.mutate({
...project,
favorite: !project.favorite,
})
}
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Ellipsis className="size-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onClick={() =>
commands.utilOpen(project.path, "ErrorIfNotExists")
}
disabled={!project.is_exists || loading}
>
{tc("projects:menuitem:open directory")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={async () => {
try {
await copyProject(project.path);
} catch (e) {
console.error(e);
toastThrownError(e);
}
}}
disabled={!project.is_valid}
>
{tc("projects:menuitem:copy project")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
openSingleDialog(RemoveProjectDialog, { project })
}
disabled={loading}
className="text-destructive focus:text-destructive"
>
{tc("projects:remove project")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Tooltip>
<TooltipTriggerIfInvalid
className={"text-left select-text cursor-auto w-full"}
>
<div className="flex flex-col">
<Tooltip>
<TooltipTriggerIfValid
className={"text-left select-text cursor-auto w-full"}
>
<p className="font-normal whitespace-pre overflow-ellipsis overflow-hidden">
{project.name}
</p>
<p className="font-normal opacity-50 text-sm whitespace-pre overflow-ellipsis overflow-hidden compact:hidden">
{project.path}
</p>
</TooltipTriggerIfValid>
<TooltipContent>{project.path}</TooltipContent>
</Tooltip>
</div>
</TooltipTriggerIfInvalid>
<TooltipPortal>
<TooltipContent>
{removed
? tc("projects:tooltip:no directory")
: tc("projects:tooltip:invalid project")}
</TooltipContent>
</TooltipPortal>
</Tooltip>
<div className="flex flex-row gap-2">
<div className="flex items-center">
{projectTypeKind === "avatars" ? (
<CircleUserRound className={typeIconClass} />
) : projectTypeKind === "worlds" ? (
<Globe className={typeIconClass} />
) : (
<CircleHelp className={typeIconClass} />
)}
</div>
<div className="flex flex-col justify-center">
<p className="font-normal">{displayType}</p>
{isLegacy && (
<p className="font-normal opacity-50 dark:opacity-80 text-sm text-destructive">
{tc("projects:type:legacy")}
</p>
)}
</div>
<p className="text-sm flex flex-col justify-center">·</p>
<div className="flex flex-col justify-center">
<p className={"text-sm"}>{project.unity}</p>
</div>
</div>
<div className="flex flex-row gap-1">
<div className="text-xs text-muted-foreground">
{tc("general:created at")}:{" "}
<Tooltip>
<TooltipTrigger>
<time dateTime={createdAt.toISOString()}>
<time className="font-normal">
{dayToString(project.created_at)}
</time>
</time>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{dateToString(project.created_at)}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
<p className="text-xs text-muted-foreground">/</p>
<div className="text-xs text-muted-foreground">
{tc("general:last modified")}:{" "}
<Tooltip>
<TooltipTrigger>
<time dateTime={lastModified.toISOString()}>
<time className="font-normal">
{formatDateOffset(project.last_modified)}
</time>
</time>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{dateToString(project.last_modified)}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
</div>
<div className="mt-2 flex flex-wrap gap-2 justify-end compact:gap-1">
<ButtonDisabledIfInvalid asChild>
<OpenUnityButton
projectPath={project.path}
unityVersion={project.unity}
unityRevision={project.unity_revision}
/>
</ButtonDisabledIfInvalid>
<ManageOrMigrateButton project={project} />
<ButtonDisabledIfInvalid
onClick={() =>
openSingleDialog(BackupProjectDialog, {
projectPath: project.path,
})
}
variant="success"
>
{tc("projects:backup")}
</ButtonDisabledIfInvalid>
</div>
</Card>
</ProjectContext.Provider>
);
}

View file

@ -1,572 +0,0 @@
import {
queryOptions,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { CircleHelp, CircleUserRound, Ellipsis, Globe } from "lucide-react";
import React, { type ComponentProps, useContext } from "react";
import { copyProject } from "@/app/_main/projects/manage/-copy-project";
import { MigrationCopyingDialog } from "@/app/_main/projects/manage/-unity-migration";
import { BackupProjectDialog } from "@/components/BackupProjectDialog";
import { FavoriteStarToggleButton } from "@/components/FavoriteStarButton";
import { OpenUnityButton } from "@/components/OpenUnityButton";
import { RemoveProjectDialog } from "@/components/RemoveProjectDialog";
import { Button } from "@/components/ui/button";
import { DialogFooter, DialogTitle } from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipPortal,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { assertNever } from "@/lib/assert-never";
import type { TauriProject, TauriProjectType } from "@/lib/bindings";
import { commands } from "@/lib/bindings";
import {
dateToString,
dayToString,
formatDateOffset,
} from "@/lib/dateToString";
import { type DialogContext, openSingleDialog, showDialog } from "@/lib/dialog";
import { tc, tt } from "@/lib/i18n";
import { router } from "@/lib/main";
import { queryClient } from "@/lib/query-client";
import { toastError, toastSuccess, toastThrownError } from "@/lib/toast";
import { compareUnityVersionString } from "@/lib/version";
export const ProjectDisplayType: Record<
TauriProjectType,
"avatars" | "worlds" | "sdk2" | "unknown"
> = {
Unknown: "unknown",
LegacySdk2: "sdk2",
LegacyWorlds: "worlds",
LegacyAvatars: "avatars",
UpmWorlds: "worlds",
UpmAvatars: "avatars",
UpmStarter: "unknown",
Worlds: "worlds",
Avatars: "avatars",
VpmStarter: "unknown",
};
export const LegacyProjectTypes = [
"LegacySdk2",
"LegacyWorlds",
"LegacyAvatars",
"UpmWorlds",
"UpmAvatars",
"UpmStarter",
];
const environmentProjects = queryOptions({
queryKey: ["environmentProjects"],
queryFn: commands.environmentProjects,
});
export function ProjectRow({
project,
loading,
}: {
project: TauriProject;
loading?: boolean;
}) {
const cellClass = "p-2.5 compact:py-1";
const noGrowCellClass = `${cellClass} w-1`;
const typeIconClass = "w-5 h-5";
const { projectTypeKind, displayType, isLegacy, createdAt, lastModified } =
getProjectDisplayInfo(project);
const openProjectFolder = () =>
commands.utilOpen(project.path, "ErrorIfNotExists");
const onCopyProject = async () => {
try {
await copyProject(project.path);
} catch (e) {
console.error(e);
toastThrownError(e);
}
};
const setProjectFavorite = useSetProjectFavoriteMutation();
const removed = !project.is_exists;
const is_valid = project.is_valid;
return (
<ProjectContext.Provider
value={{ removed, is_valid, loading: Boolean(loading) }}
>
<tr
className={`group even:bg-secondary/30 ${removed || loading || !(project.is_valid ?? true) ? "opacity-50" : ""}`}
>
<td className={noGrowCellClass}>
<div className={"relative flex"}>
<FavoriteStarToggleButton
favorite={project.favorite}
disabled={removed || loading}
onToggle={() =>
setProjectFavorite.mutate({
...project,
favorite: !project.favorite,
})
}
/>
</div>
</td>
<td className={`${cellClass} max-w-64 overflow-hidden`}>
<Tooltip>
<TooltipTriggerIfInvalid
className={"text-left select-text cursor-auto w-full"}
>
<div className="flex flex-col">
<Tooltip>
<TooltipTriggerIfValid
className={"text-left select-text cursor-auto w-full"}
>
<p className="font-normal whitespace-pre">{project.name}</p>
<p className="font-normal opacity-50 text-sm whitespace-pre compact:hidden">
{project.path}
</p>
</TooltipTriggerIfValid>
<TooltipContent>{project.path}</TooltipContent>
</Tooltip>
</div>
</TooltipTriggerIfInvalid>
<TooltipPortal>
<TooltipContent>
{removed
? tc("projects:tooltip:no directory")
: tc("projects:tooltip:invalid project")}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</td>
<td className={noGrowCellClass}>
<div className="flex flex-row gap-2">
<div className="flex items-center">
{projectTypeKind === "avatars" ? (
<CircleUserRound className={typeIconClass} />
) : projectTypeKind === "worlds" ? (
<Globe className={typeIconClass} />
) : (
<CircleHelp className={typeIconClass} />
)}
</div>
<div className="flex flex-col justify-center">
<p className="font-normal">{displayType}</p>
{isLegacy && (
<p className="font-normal opacity-50 dark:opacity-80 text-sm text-destructive">
{tc("projects:type:legacy")}
</p>
)}
</div>
</div>
</td>
<td className={noGrowCellClass}>
<p className="font-normal">{project.unity}</p>
</td>
<td className={noGrowCellClass}>
<Tooltip>
<TooltipTrigger>
<time dateTime={createdAt.toISOString()}>
<time className="font-normal">
{dayToString(project.created_at)}
</time>
</time>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{dateToString(project.created_at)}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</td>
<td className={noGrowCellClass}>
<Tooltip>
<TooltipTrigger>
<time dateTime={lastModified.toISOString()}>
<time className="font-normal">
{formatDateOffset(project.last_modified)}
</time>
</time>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{dateToString(project.last_modified)}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</td>
<td className={noGrowCellClass}>
<div className="flex flex-row gap-2 max-w-min items-center">
<ButtonDisabledIfInvalid asChild>
<OpenUnityButton
projectPath={project.path}
unityVersion={project.unity}
unityRevision={project.unity_revision}
/>
</ButtonDisabledIfInvalid>
<ManageOrMigrateButton project={project} />
<ButtonDisabledIfInvalid
onClick={async () => {
try {
await openSingleDialog(BackupProjectDialog, {
projectPath: project.path,
});
} catch (e) {
console.error(e);
toastThrownError(e);
}
}}
variant={"success"}
>
{tc("projects:backup")}
</ButtonDisabledIfInvalid>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size={"icon"}
className={
"hover:bg-primary/10 text-primary hover:text-primary"
}
>
<Ellipsis className={"size-5"} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onClick={openProjectFolder}
disabled={removed || loading}
>
{tc("projects:menuitem:open directory")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={onCopyProject}
disabled={removed || !(is_valid ?? true)}
>
{tc("projects:menuitem:copy project")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
openSingleDialog(RemoveProjectDialog, { project })
}
disabled={loading}
className={"text-destructive focus:text-destructive"}
>
{tc("projects:remove project")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</td>
</tr>
</ProjectContext.Provider>
);
}
export function ManageOrMigrateButton({ project }: { project: TauriProject }) {
const navigate = useNavigate();
if (compareUnityVersionString(project.unity, "2018.0.0f0") < 0) {
// No UPM is supported in unity 2017 or older
return (
<Tooltip>
<TooltipTriggerIfValid asChild>
<ButtonDisabledIfInvalid variant="success" disabled>
{tc("projects:button:manage")}
</ButtonDisabledIfInvalid>
</TooltipTriggerIfValid>
<TooltipContent>
{tc("projects:tooltip:no upm in unity")}
</TooltipContent>
</Tooltip>
);
}
switch (project.project_type) {
case "LegacySdk2":
return (
<Tooltip>
<TooltipTriggerIfValid asChild>
<ButtonDisabledIfInvalid variant="success" disabled>
{tc("projects:button:migrate")}
</ButtonDisabledIfInvalid>
</TooltipTriggerIfValid>
<TooltipContent>
{tc("projects:tooltip:sdk2 migration hint")}
</TooltipContent>
</Tooltip>
);
case "LegacyWorlds":
case "LegacyAvatars":
return (
<ButtonDisabledIfInvalid
variant={"success"}
onClick={() => void migrateVpm(project.path)}
>
{tc("projects:button:migrate")}
</ButtonDisabledIfInvalid>
);
case "UpmWorlds":
case "UpmAvatars":
case "UpmStarter":
return (
<Tooltip>
<TooltipTriggerIfValid asChild>
<ButtonDisabledIfInvalid variant="info" disabled>
{tc("projects:button:manage")}
</ButtonDisabledIfInvalid>
</TooltipTriggerIfValid>
<TooltipContent>
{tc("projects:tooltip:git-vcc not supported")}
</TooltipContent>
</Tooltip>
);
case "Unknown":
case "Worlds":
case "Avatars":
case "VpmStarter":
return (
<ButtonDisabledIfInvalid
onClick={() =>
navigate({
to: "/projects/manage",
search: { projectPath: project.path },
})
}
variant="info"
>
{tc("projects:button:manage")}
</ButtonDisabledIfInvalid>
);
}
}
type MigrationProjectBackupType = "none" | "copy" | "backupArchive";
async function migrateVpm(projectPath: string) {
if (await commands.projectIsUnityLaunching(projectPath)) {
toastError(tt("projects:toast:close unity before migration"));
return;
}
using dialog = showDialog();
const backupType = await dialog.ask(ConfirmVpmMigrationDialog, {});
if (backupType == null) return "";
let migrateProjectPath: string;
switch (backupType) {
case "none":
migrateProjectPath = projectPath;
break;
case "copy": {
migrateProjectPath = await dialog.ask(MigrationCopyingDialog, {
header: tc("projects:dialog:vpm migrate header"),
projectPath,
});
break;
}
case "backupArchive": {
const result = await dialog.ask(BackupProjectDialog, {
projectPath,
});
if (result === "cancelled") {
return;
}
migrateProjectPath = projectPath;
break;
}
default:
assertNever(backupType);
}
dialog.replace(<VpmMigrationUpdating />);
await commands.projectMigrateProjectToVpm(migrateProjectPath);
toastSuccess(tt("projects:toast:project migrated"));
await queryClient.invalidateQueries({
queryKey: ["environmentProjects"],
});
router.navigate({
to: "/projects/manage",
search: {
projectPath: migrateProjectPath,
},
});
}
function ConfirmVpmMigrationDialog({
dialog,
}: {
dialog: DialogContext<MigrationProjectBackupType | null>;
}) {
return (
<div className={"contents whitespace-normal"}>
<DialogTitle>{tc("projects:dialog:vpm migrate header")}</DialogTitle>
<div>
<p>{tc("projects:dialog:vpm migrate description")}</p>
</div>
<DialogFooter className={"gap-1"}>
<Button onClick={() => dialog.close(null)}>
{tc("general:button:cancel")}
</Button>
<Button onClick={() => dialog.close("backupArchive")}>
{tc("projects:button:backup and migrate")}
</Button>
<Button onClick={() => dialog.close("copy")}>
{tc("projects:button:migrate copy")}
</Button>
<Button onClick={() => dialog.close("none")} variant={"destructive"}>
{tc("projects:button:migrate in-place")}
</Button>
</DialogFooter>
</div>
);
}
function VpmMigrationUpdating() {
return (
<div className={"contents whitespace-normal"}>
<DialogTitle>{tc("projects:dialog:vpm migrate header")}</DialogTitle>
<div>
<p>{tc("projects:migrating...")}</p>
</div>
</div>
);
}
// region utilities
export const ProjectContext = React.createContext<{
removed: boolean;
is_valid: boolean | null;
loading: boolean;
}>({
removed: false,
is_valid: null,
loading: false,
});
export const ButtonDisabledIfInvalid = function RemovedButton(
props: React.ComponentProps<typeof Button>,
) {
const rowContext = useContext(ProjectContext);
if (rowContext.removed || !(rowContext.is_valid ?? true)) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
{...props}
className={`disabled:pointer-events-auto ${props.className}`}
disabled
/>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{rowContext.removed
? tc("projects:tooltip:no directory")
: tc("projects:tooltip:invalid project")}
</TooltipContent>
</TooltipPortal>
</Tooltip>
);
} else {
return (
<Button
{...props}
className={`disabled:pointer-events-auto ${props.className}`}
disabled={props.disabled || rowContext.loading || rowContext.removed}
/>
);
}
};
export const TooltipTriggerIfInvalid = ({
children,
...props
}: ComponentProps<typeof TooltipTrigger>) => {
const rowContext = useContext(ProjectContext);
if (rowContext.removed || !(rowContext.is_valid ?? true)) {
return <TooltipTrigger {...props}>{children}</TooltipTrigger>;
} else {
return children;
}
};
export const TooltipTriggerIfValid = ({
children,
...props
}: ComponentProps<typeof TooltipTrigger>) => {
const rowContext = useContext(ProjectContext);
if (rowContext.removed || !(rowContext.is_valid ?? true)) {
return children;
} else {
return <TooltipTrigger {...props}>{children}</TooltipTrigger>;
}
};
export function getProjectDisplayInfo(project: TauriProject) {
const projectTypeKind = ProjectDisplayType[project.project_type] ?? "unknown";
const displayType = tc(`projects:type:${projectTypeKind}`);
const isLegacy = LegacyProjectTypes.includes(project.project_type);
const createdAt = new Date(project.created_at);
const lastModified = new Date(project.last_modified);
return {
projectTypeKind,
displayType,
isLegacy,
createdAt,
lastModified,
};
}
export function useSetProjectFavoriteMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (project: Pick<TauriProject, "path" | "favorite">) =>
commands.environmentSetFavoriteProject(project.path, project.favorite),
onMutate: async (project) => {
await queryClient.cancelQueries(environmentProjects);
const previousData = queryClient.getQueryData<TauriProject[]>(
environmentProjects.queryKey,
);
if (previousData !== undefined) {
queryClient.setQueryData<TauriProject[]>(
environmentProjects.queryKey,
previousData.map((v) =>
v.path === project.path ? { ...v, favorite: project.favorite } : v,
),
);
}
return previousData;
},
onError: (error, _, context) => {
console.error("Error migrating project", error);
toastThrownError(error);
if (context) {
queryClient.setQueryData(environmentProjects.queryKey, context);
}
},
});
}
// endregion

View file

@ -1,131 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { ArrowDown, ArrowUp } from "lucide-react";
import { useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { TauriProject } from "@/lib/bindings";
import { commands } from "@/lib/bindings";
import { tc } from "@/lib/i18n";
import { ProjectGridItem } from "./-project-grid-item";
import {
isSorting,
type sortings,
sortSearchProjects,
useSetProjectSortingMutation,
} from "./-projects-list-card";
type SimpleSorting = (typeof sortings)[number];
type Sorting = SimpleSorting | `${SimpleSorting}Reversed`;
const sortingOptions: { key: SimpleSorting; label: string }[] = [
{ key: "name", label: "general:name" },
{ key: "type", label: "projects:type" },
{ key: "unity", label: "projects:unity" },
{ key: "createdAt", label: "general:created at" },
{ key: "lastModified", label: "general:last modified" },
];
export function ProjectsGridCard({
projects,
search,
loading,
}: {
projects: TauriProject[];
search?: string;
loading?: boolean;
}) {
const sortingQuery = useQuery({
initialData: "lastModified" as Sorting,
queryKey: ["environmentGetProjectSorting"],
queryFn: async () => {
const newSorting = await commands.environmentGetProjectSorting();
return !isSorting(newSorting) ? "lastModified" : newSorting;
},
});
const setSortingStateMutation = useSetProjectSortingMutation();
const currentKey = sortingQuery.data.replace(
/Reversed$/,
"",
) as SimpleSorting;
const isReversed = sortingQuery.data.endsWith("Reversed");
const handleChangeSortingKey = (key: SimpleSorting) => {
const newSorting = isReversed ? `${key}Reversed` : key;
setSortingStateMutation.mutate({ sorting: newSorting as Sorting });
};
const toggleOrder = () => {
const newSorting: Sorting = isReversed
? currentKey
: `${currentKey}Reversed`;
setSortingStateMutation.mutate({ sorting: newSorting });
};
const projectsShown = useMemo(() => {
return sortSearchProjects(projects, search ?? "", sortingQuery.data);
}, [projects, search, sortingQuery.data]);
return (
<div className="flex flex-col h-full w-full overflow-hidden">
<Card className="flex items-center mb-3 flex-wrap p-2 gap-2 compact:p-1 compact:gap-1">
<p className="grow-0 whitespace-pre pl-2 leading-tight">
{tc("projects:sort by")}
</p>
<Select
value={currentKey}
onValueChange={(value) =>
handleChangeSortingKey(value as SimpleSorting)
}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{sortingOptions.map((option) => (
<SelectItem key={option.key} value={option.key}>
{tc(option.label)}
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="ghost" size="icon" onClick={toggleOrder}>
{isReversed ? (
<ArrowUp className="size-4" />
) : (
<ArrowDown className="size-4" />
)}
</Button>
</Card>
<ScrollArea
type="auto"
className="h-full w-full vrc-get-scrollable-card rounded-l-xl"
scrollBarClassName="bg-background rounded-full border-l-0 p-[1.5px]"
>
<div
className="grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-3 gap-3 overflow-x-hidden mr-4
compact:grid-cols-2 compact:lg:grid-cols-3 compact:2xl:grid-cols-4 compact:gap-1.5"
>
{projectsShown.map((project) => (
<ProjectGridItem
key={project.path}
project={project}
loading={loading}
/>
))}
</div>
</ScrollArea>
</div>
);
}

View file

@ -1,280 +0,0 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronDown, ChevronsUpDown, ChevronUp, Star } from "lucide-react";
import { useMemo } from "react";
import { ScrollableCardTable } from "@/components/ScrollableCardTable";
import { assertNever } from "@/lib/assert-never";
import type { TauriProject, TauriProjectType } from "@/lib/bindings";
import { commands } from "@/lib/bindings";
import { tc } from "@/lib/i18n";
import { toastThrownError } from "@/lib/toast";
import { compareUnityVersionString } from "@/lib/version";
import { ProjectRow } from "./-project-row";
export const sortings = [
"createdAt",
"lastModified",
"name",
"unity",
"type",
] as const;
type SimpleSorting = (typeof sortings)[number];
type Sorting = SimpleSorting | `${SimpleSorting}Reversed`;
export function isSorting(s: string | unknown): s is Sorting {
return sortings.some(
(sorting) => sorting === s || `${sorting}Reversed` === s,
);
}
export function compareProjectType(
a: TauriProjectType,
b: TauriProjectType,
): 0 | -1 | 1 {
if (a === b) return 0;
// legacy unknown
if (a === "LegacySdk2") return 1;
if (b === "LegacySdk2") return -1;
if (a === "UpmStarter") return 1;
if (b === "UpmStarter") return -1;
// legacy worlds
if (a === "LegacyWorlds") return 1;
if (b === "LegacyWorlds") return -1;
if (a === "UpmWorlds") return 1;
if (b === "UpmWorlds") return -1;
// legacy avatars
if (a === "LegacyAvatars") return 1;
if (b === "LegacyAvatars") return -1;
if (a === "UpmAvatars") return 1;
if (b === "UpmAvatars") return -1;
// unknown
if (a === "Unknown") return 1;
if (b === "Unknown") return -1;
if (a === "VpmStarter") return 1;
if (b === "VpmStarter") return -1;
// worlds
if (a === "Worlds") return 1;
if (b === "Worlds") return -1;
// avatars
if (a === "Avatars") return 1;
if (b === "Avatars") return -1;
assertNever(a, "project type");
}
export function ProjectsTableCard({
projects,
search,
loading,
}: {
projects: TauriProject[];
search?: string;
loading?: boolean;
}) {
const sortingQuery = useQuery({
initialData: "lastModified" as Sorting,
queryKey: ["environmentGetProjectSorting"],
queryFn: async () => {
const newSorting = await commands.environmentGetProjectSorting();
return !isSorting(newSorting) ? "lastModified" : newSorting;
},
});
const setSortingStateMutation = useSetProjectSortingMutation();
const projectsShown = useMemo(() => {
return sortSearchProjects(projects, search ?? "", sortingQuery.data);
}, [projects, search, sortingQuery.data]);
const thClass = "sticky top-0 z-10 border-b border-primary p-2.5";
const iconClass = "size-3 invisible project-table-header-chevron-up-down";
const setSorting = async (simpleSorting: SimpleSorting) => {
let newSorting: Sorting;
if (sortingQuery.data === simpleSorting) {
newSorting = `${simpleSorting}Reversed`;
} else if (sortingQuery.data === `${simpleSorting}Reversed`) {
newSorting = simpleSorting;
} else {
newSorting = simpleSorting;
}
setSortingStateMutation.mutate({ sorting: newSorting });
};
const headerBg = (target: SimpleSorting) =>
sortingQuery.data === target || sortingQuery.data === `${target}Reversed`
? "bg-primary text-primary-foreground"
: "bg-secondary text-secondary-foreground";
const icon = (target: SimpleSorting) =>
sortingQuery.data === target ? (
<ChevronDown className={"size-3"} />
) : sortingQuery.data === `${target}Reversed` ? (
<ChevronUp className={"size-3"} />
) : (
<ChevronsUpDown className={iconClass} />
);
return (
<ScrollableCardTable className={"h-full w-full"}>
<thead>
<tr>
<th className={`${thClass} bg-secondary text-secondary-foreground`}>
<Star className={"size-4"} />
</th>
<th className={`${thClass} ${headerBg("name")}`}>
<button
type="button"
className={"flex w-full project-table-button"}
onClick={() => setSorting("name")}
>
{icon("name")}
<small className="font-normal leading-none">
{tc("general:name")}
</small>
</button>
</th>
<th className={`${thClass} ${headerBg("type")}`}>
<button
type="button"
className={"flex w-full project-table-button"}
onClick={() => setSorting("type")}
>
{icon("type")}
<small className="font-normal leading-none">
{tc("projects:type")}
</small>
</button>
</th>
<th className={`${thClass} ${headerBg("unity")}`}>
<button
type="button"
className={"flex w-full project-table-button"}
onClick={() => setSorting("unity")}
>
{icon("unity")}
<small className="font-normal leading-none">
{tc("projects:unity")}
</small>
</button>
</th>
<th className={`${thClass} ${headerBg("createdAt")}`}>
<button
type="button"
className={"flex w-full project-table-button"}
onClick={() => setSorting("createdAt")}
>
{icon("createdAt")}
<small className="font-normal leading-none">
{tc("general:created at")}
</small>
</button>
</th>
<th className={`${thClass} ${headerBg("lastModified")}`}>
<button
type="button"
className={"flex w-full project-table-button"}
onClick={() => setSorting("lastModified")}
>
{icon("lastModified")}
<small className="font-normal leading-none">
{tc("general:last modified")}
</small>
</button>
</th>
<th className={`${thClass} bg-secondary text-secondary-foreground`} />
</tr>
</thead>
<tbody>
{projectsShown.map((project) => (
<ProjectRow key={project.path} project={project} loading={loading} />
))}
</tbody>
</ScrollableCardTable>
);
}
export function sortSearchProjects(
projects: TauriProject[],
search: string,
sorting: Sorting,
): TauriProject[] {
const searched = projects.filter((project) =>
project.name.toLowerCase().includes(search?.toLowerCase() ?? ""),
);
searched.sort((a, b) => b.last_modified - a.last_modified);
switch (sorting) {
case "createdAt":
searched.sort((a, b) => b.created_at - a.created_at);
break;
case "createdAtReversed":
searched.sort((a, b) => a.created_at - b.created_at);
break;
case "lastModified":
searched.sort((a, b) => b.last_modified - a.last_modified);
break;
case "lastModifiedReversed":
searched.sort((a, b) => a.last_modified - b.last_modified);
break;
case "name":
searched.sort((a, b) => a.name.localeCompare(b.name));
break;
case "nameReversed":
searched.sort((a, b) => b.name.localeCompare(a.name));
break;
case "type":
searched.sort((a, b) =>
compareProjectType(a.project_type, b.project_type),
);
break;
case "typeReversed":
searched.sort((a, b) =>
compareProjectType(b.project_type, a.project_type),
);
break;
case "unity":
searched.sort((a, b) => compareUnityVersionString(a.unity, b.unity));
break;
case "unityReversed":
searched.sort((a, b) => compareUnityVersionString(b.unity, a.unity));
break;
default:
assertNever(sorting);
}
searched.sort((a, b) => {
if (a.favorite && !b.favorite) return -1;
if (!a.favorite && b.favorite) return 1;
return 0;
});
return searched;
}
export function useSetProjectSortingMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ sorting }: { sorting: Sorting }) => {
await commands.environmentSetProjectSorting(sorting);
},
onMutate: async ({ sorting }) => {
await queryClient.cancelQueries({
queryKey: ["environmentGetProjectSorting"],
});
queryClient.setQueryData(["environmentGetProjectSorting"], () => sorting);
},
onError: (error) => {
console.error("Error setting project sorting", error);
toastThrownError(error);
},
});
}

View file

@ -1,280 +0,0 @@
"use client";
import {
queryOptions,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { ChevronDown, LayoutGrid, LayoutList, RefreshCw } from "lucide-react";
import { useRef, useState } from "react";
import { createProject } from "@/app/_main/projects/-create-project";
import { ProjectsGridCard } from "@/app/_main/projects/-projects-grid-card";
import Loading from "@/app/-loading";
import { HNavBar, HNavBarText, VStack } from "@/components/layout";
import { SearchBox } from "@/components/SearchBox";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { assertNever } from "@/lib/assert-never";
import { commands } from "@/lib/bindings";
import { isFindKey, useDocumentEvent } from "@/lib/events";
import { useProjectUpdateInProgress } from "@/lib/global-events";
import { tc, tt } from "@/lib/i18n";
import { toastError, toastSuccess, toastThrownError } from "@/lib/toast";
import { ProjectsTableCard } from "./-projects-list-card";
export const Route = createFileRoute("/_main/projects/")({
component: Page,
});
const environmentProjects = queryOptions({
queryKey: ["environmentProjects"],
queryFn: commands.environmentProjects,
});
function Page() {
const result = useQuery(environmentProjects);
const [search, setSearch] = useState("");
const viewModeQuery = useQuery({
initialData: "List",
queryKey: ["environmentGetProjectViewMode"],
queryFn: async () => {
return await commands.environmentProjectViewMode();
},
});
const queryClient = useQueryClient();
const setViewModeMutation = useMutation({
mutationFn: async (value: string) => {
await commands.environmentSetProjectViewMode(value);
},
onMutate: async (value: string) => {
await queryClient.setQueryData(["environmentGetProjectViewMode"], value);
},
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: ["environmentGetProjectViewMode"],
});
},
});
const viewMode = viewModeQuery.data ?? true;
const setViewMode = (value: string) => {
setViewModeMutation.mutate(value);
};
const startCreateProject = () => void createProject();
const loading = result.isFetching;
return (
<VStack>
<ProjectViewHeader
startCreateProject={startCreateProject}
isLoading={loading}
search={search}
setSearch={setSearch}
viewMode={viewMode}
setViewMode={setViewMode}
/>
<main className="shrink overflow-hidden flex w-full h-full">
{result.status === "pending" ? (
<Card className="w-full shadow-none overflow-hidden p-4">
<Loading loadingText={tc("general:loading...")} />
</Card>
) : result.status === "error" ? (
<Card className="w-full shadow-none overflow-hidden p-4">
{tc("projects:error:load error", { msg: result.error.message })}
</Card>
) : viewMode === "List" ? (
<ProjectsTableCard
projects={result.data}
search={search}
loading={loading}
/>
) : viewMode === "Grid" ? (
<ProjectsGridCard
projects={result.data}
search={search}
loading={loading}
/>
) : (
<ProjectsTableCard
projects={result.data}
search={search}
loading={loading}
/>
)}
</main>
</VStack>
);
}
function ProjectViewHeader({
startCreateProject,
isLoading,
search,
setSearch,
viewMode,
setViewMode,
}: {
startCreateProject?: () => void;
isLoading?: boolean;
search: string;
setSearch: (search: string) => void;
viewMode: string;
setViewMode: (viewMode: string) => void;
}) {
const queryClient = useQueryClient();
const addProjectWithPicker = useMutation({
mutationFn: async () => await commands.environmentAddProjectWithPicker(),
onSuccess: (result) => {
switch (result) {
case "NoFolderSelected":
// no-op
break;
case "InvalidSelection":
toastError(tt("general:toast:invalid directory"));
break;
case "Successful":
toastSuccess(tt("projects:toast:project added"));
break;
case "AlreadyAdded":
toastError(tt("projects:toast:project already exists"));
break;
default:
assertNever(result);
}
},
onError: (e) => {
console.error("Error adding project", e);
toastThrownError(e);
},
onSettled: async () => {
await queryClient.invalidateQueries(environmentProjects);
},
});
const inProgress = useProjectUpdateInProgress();
const searchRef = useRef<HTMLInputElement>(null);
useDocumentEvent(
"keydown",
(e) => {
if (isFindKey(e)) {
searchRef.current?.focus();
}
},
[],
);
isLoading = isLoading || inProgress;
return (
<HNavBar
className="shrink-0"
leading={
<>
<HNavBarText>{tc("projects")}</HNavBarText>
<Tooltip>
<TooltipTrigger asChild>
<Button
className={"compact:h-10 compact:w-10"}
variant={"ghost"}
size={"icon"}
onClick={() =>
queryClient.invalidateQueries(environmentProjects)
}
disabled={isLoading}
>
{isLoading ? (
<RefreshCw className="w-5 h-5 animate-spin" />
) : (
<RefreshCw className={"w-5 h-5"} />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{tc("projects:tooltip:refresh")}</TooltipContent>
</Tooltip>
<SearchBox
className={"w-max grow compact:h-10"}
value={search}
onChange={(e) => setSearch(e.target.value)}
ref={searchRef}
/>
<Button
className={"compact:h-10"}
variant={"ghost"}
onClick={() => {
if (viewMode === "List") {
setViewMode("Grid");
} else {
setViewMode("List");
}
}}
>
{viewMode === "List" ? (
<>
<LayoutList className={"w-5 h-5"} />
<p className="ml-2">{tc("projects:list view")}</p>
</>
) : viewMode === "Grid" ? (
<>
<LayoutGrid className={"w-5 h-5"} />
<p className="ml-2">{tc("projects:grid view")}</p>
</>
) : (
<>
<LayoutList className={"w-5 h-5"} />
<p className="ml-2">{tc("projects:list view")}</p>
</>
)}
</Button>
</>
}
trailing={
<DropdownMenu>
<div className={"flex divide-x"}>
<Button
className={"rounded-r-none pl-4 pr-3 compact:h-10"}
onClick={startCreateProject}
>
{tc("projects:create new project")}
</Button>
<DropdownMenuTrigger
asChild
className={"rounded-l-none pl-2 pr-2 compact:h-10"}
>
<Button>
<ChevronDown className={"w-4 h-4"} />
</Button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => addProjectWithPicker.mutate()}>
{tc("projects:add existing project")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
}
/>
);
}

View file

@ -1,214 +0,0 @@
import { useMutation } from "@tanstack/react-query";
import type { NavigateFn } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { VStack } from "@/components/layout";
import { Button } from "@/components/ui/button";
import { DialogFooter, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import { assertNever } from "@/lib/assert-never";
import { commands, type TauriCopyProjectProgress } from "@/lib/bindings";
import { callAsyncCommand } from "@/lib/call-async-command";
import { type DialogContext, showDialog } from "@/lib/dialog";
import { tc, tt } from "@/lib/i18n";
import { directoryFromPath, nameFromPath, pathSeparator } from "@/lib/os";
import {
ProjectNameCheckResult,
useProjectNameCheck,
} from "@/lib/project-name-check";
import { queryClient } from "@/lib/query-client";
import { toastError, toastSuccess, toastThrownError } from "@/lib/toast";
export async function copyProject(existingPath: string, navigate?: NavigateFn) {
using dialog = showDialog();
const newPath = await dialog.ask(CopyProjectNameDialog, {
projectPath: existingPath,
});
if (newPath == null) return; // cancelled
await dialog.ask(CopyingDialog, {
projectPath: existingPath,
newProjectPath: newPath,
});
dialog.close();
toastSuccess(
tc("projects:toast:successfully copied project", {
name: nameFromPath(existingPath),
}),
);
await Promise.all([
queryClient.invalidateQueries({
queryKey: ["projectDetails", existingPath],
}),
queryClient.invalidateQueries({
queryKey: ["environmentProjects"],
}),
]);
await navigate?.({
replace: true,
to: "/projects/manage",
search: { projectPath: newPath },
});
}
function CopyProjectNameDialog({
dialog,
projectPath,
}: {
dialog: DialogContext<string | null>;
projectPath: string;
}) {
const oldName = nameFromPath(projectPath);
const [projectNameRaw, setProjectName] = useState(`${oldName}-Copy`);
const projectName = projectNameRaw.trim();
const [projectLocation, setProjectLocation] = useState(
directoryFromPath(projectPath),
);
const projectNameCheckState = useProjectNameCheck(
projectLocation,
projectName,
);
const usePickProjectLocationPath = useMutation({
mutationFn: () => commands.utilPickDirectory(projectLocation),
onSuccess: (result) => {
switch (result.type) {
case "NoFolderSelected":
// no-op
break;
case "InvalidSelection":
toastError(tt("general:toast:invalid directory"));
break;
case "Successful":
setProjectLocation(result.new_path);
break;
default:
assertNever(result);
}
},
onError: (e) => {
console.error(e);
toastThrownError(e);
},
});
const createProject = async () => {
dialog.close(`${projectLocation}${pathSeparator()}${projectName}`);
};
const badProjectName = ["AlreadyExists", "InvalidNameForFolderName"].includes(
projectNameCheckState,
);
const canCreateProject =
projectNameCheckState !== "checking" && !badProjectName;
return (
<>
<DialogTitle>
{tc("projects:dialog:copy project", { name: oldName })}
</DialogTitle>
<div>
<VStack>
<Input
value={projectNameRaw}
onChange={(e) => setProjectName(e.target.value)}
/>
<div className={"flex gap-1 items-center"}>
<Input className="flex-auto" value={projectLocation} disabled />
<Button
className="flex-none px-4"
onClick={() => usePickProjectLocationPath.mutate()}
>
{tc("general:button:select")}
</Button>
</div>
<small className={"whitespace-normal"}>
{tc(
"projects:hint:path of creating project",
{ path: `${projectLocation}${pathSeparator()}${projectName}` },
{
components: {
path: (
<span
className={
"p-0.5 font-path whitespace-pre bg-secondary text-secondary-foreground"
}
/>
),
},
},
)}
</small>
<ProjectNameCheckResult
projectNameCheckState={projectNameCheckState}
/>
</VStack>
</div>
<DialogFooter className={"gap-2"}>
<Button onClick={() => dialog.close(null)}>
{tc("general:button:cancel")}
</Button>
<Button onClick={createProject} disabled={!canCreateProject}>
{tc("projects:button:create")}
</Button>
</DialogFooter>
</>
);
}
export function CopyingDialog({
projectPath,
newProjectPath,
dialog,
}: {
projectPath: string;
newProjectPath: string;
dialog: DialogContext<string>;
}) {
const oldName = nameFromPath(projectPath);
const [progress, setProgress] = useState<TauriCopyProjectProgress>({
proceed: 0,
total: 1,
last_proceed: "Collecting files...",
});
useEffect(() => {
const [_, promise] = callAsyncCommand(
commands.environmentCopyProject,
[projectPath, newProjectPath],
(progress) => {
setProgress((prev) => {
if (prev.proceed > progress.proceed) return prev;
return progress;
});
},
);
promise.then(dialog.close, dialog.error);
}, [projectPath, newProjectPath, dialog.close, dialog.error]);
return (
<>
<DialogTitle>
{tc("projects:dialog:copy project", { name: oldName })}
</DialogTitle>
<div>
<p>{tc("projects:dialog:copying...")}</p>
<p>
{tc("projects:dialog:proceed k/n", {
count: progress.proceed,
total: progress.total,
})}
</p>
<Progress value={progress.proceed} max={progress.total} />
<p>{tc("projects:do not close")}</p>
</div>
<DialogFooter className={"gap-2"}>
<Button disabled>{tc("general:button:cancel")}</Button>
</DialogFooter>
</>
);
}

File diff suppressed because it is too large Load diff

View file

@ -1,44 +0,0 @@
import { type ComponentProps, createContext, useContext } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
interface PageContext {
isLoading: boolean;
}
export const PageContext = createContext<PageContext>({
isLoading: false,
});
PageContext.displayName = "PageContext";
export const PageContextProvider = PageContext.Provider;
export function usePageContext() {
return useContext(PageContext);
}
export const ButtonDisabledIfLoading = function ButtonDisabledIfLoading({
disabled,
...props
}: ComponentProps<typeof Button>) {
const { isLoading } = usePageContext();
return <Button disabled={isLoading || disabled} {...props} />;
};
export const DropdownMenuItemDisabledIfLoading =
function ButtonDisabledIfLoading({
disabled,
...props
}: ComponentProps<typeof DropdownMenuItem>) {
const { isLoading } = usePageContext();
return <DropdownMenuItem disabled={isLoading || disabled} {...props} />;
};
export const CheckboxDisabledIfLoading = function CheckboxDisabledIfLoading({
disabled,
...props
}: ComponentProps<typeof Checkbox>) {
const { isLoading } = usePageContext();
return <Checkbox disabled={isLoading || disabled} {...props} />;
};

View file

@ -1,583 +0,0 @@
import type { NavigateFn } from "@tanstack/react-router";
import React, { Fragment, useEffect, useState } from "react";
import { BackupProjectDialog } from "@/components/BackupProjectDialog";
import { Button } from "@/components/ui/button";
import { DialogFooter, DialogTitle } from "@/components/ui/dialog";
import { Progress } from "@/components/ui/progress";
import { UnitySelectorDialog } from "@/components/unity-selector-dialog";
import { assertNever } from "@/lib/assert-never";
import type {
TauriCallUnityForMigrationResult,
TauriCopyProjectProgress,
TauriUnityVersions,
} from "@/lib/bindings";
import { commands } from "@/lib/bindings";
import { callAsyncCommand } from "@/lib/call-async-command";
import { VRCSDK_UNITY_VERSIONS } from "@/lib/constants";
import { type DialogContext, openSingleDialog, showDialog } from "@/lib/dialog";
import { tc, tt } from "@/lib/i18n";
import { queryClient } from "@/lib/query-client";
import { toastError, toastSuccess, toastThrownError } from "@/lib/toast";
import { compareUnityVersionString, parseUnityVersion } from "@/lib/version";
export async function unityVersionChange({
version: targetUnityVersion,
currentUnityVersion,
isVRCProject,
mayUseChinaVariant = false,
projectPath,
navigate,
}: {
version: string;
currentUnityVersion: string;
isVRCProject: boolean;
mayUseChinaVariant?: boolean;
projectPath: string;
navigate: NavigateFn;
}) {
try {
const data = detectChangeUnityKind(
currentUnityVersion,
targetUnityVersion,
isVRCProject,
);
if (await commands.projectIsUnityLaunching(projectPath)) {
toastError(tt("projects:toast:close unity before migration"));
return;
}
const header = headerText(data);
const unityVersions = await commands.environmentUnityVersions();
const findResult = findUnityForUnityChange(
unityVersions,
targetUnityVersion,
mayUseChinaVariant,
);
if (!findResult.found) {
await openSingleDialog(NoExactUnity2022Dialog, {
expectedVersion: targetUnityVersion,
installWithUnityHubLink: findResult.installLink,
header,
});
return;
}
using dialog = showDialog();
let backupTypePromise: Promise<ProjectBackupType | null>;
if (data.isVRC && data.isTargetVersionSupportedByVRC) {
// for supported migrations, show dialog same as migration
switch (data.kind) {
case "upgradePatchOrMinor":
backupTypePromise = dialog.ask(MigrationConfirmMigrationPatchDialog, {
header,
unity: targetUnityVersion,
});
break;
case "upgradeMajor":
backupTypePromise = dialog.ask(MigrationConfirmMigrationDialog, {
header,
});
}
}
backupTypePromise ??= dialog.ask(UnityVersionChange, {
data,
header,
});
const backupType = await backupTypePromise;
if (backupType == null) return;
let unityPath: string;
if (findResult.installations.length === 1) {
unityPath = findResult.installations[0][0];
} else {
const selected = await dialog.ask(UnitySelectorDialog, {
unityVersions: findResult.installations,
});
if (selected == null) return;
unityPath = selected.unityPath;
}
let migrateProjectPath: string;
switch (backupType) {
case "none":
migrateProjectPath = projectPath;
break;
case "copy": {
migrateProjectPath = await dialog.ask(MigrationCopyingDialog, {
projectPath,
header,
});
break;
}
case "backupArchive": {
const result = await dialog.ask(BackupProjectDialog, {
projectPath,
header,
});
if (result === "cancelled") return;
migrateProjectPath = projectPath;
break;
}
default:
assertNever(backupType);
}
dialog.replace(<MigrationMigratingDialog header={header} />);
if (
data.isVRC &&
data.kind === "upgradeMajor" &&
targetUnityVersion.startsWith("2022.")
) {
await commands.projectMigrateProjectTo2022(migrateProjectPath);
}
const finalizeResult = await dialog.askClosing(
MigrationCallingUnityForMigrationDialog,
{
unityPath,
migrateProjectPath,
header,
},
);
if (finalizeResult === "cancelled") {
throw new Error("unexpectedly cancelled");
}
switch (finalizeResult.type) {
case "ExistsWithNonZero":
toastError(tt("projects:toast:unity exits with non-zero"));
break;
case "FinishedSuccessfully":
toastSuccess(tt("projects:toast:unity migrated"));
break;
default:
assertNever(finalizeResult);
}
await Promise.all([
queryClient.invalidateQueries({
queryKey: ["projectDetails", projectPath],
}),
queryClient.invalidateQueries({
queryKey: ["environmentProjects"],
}),
]);
if (migrateProjectPath !== projectPath) {
await navigate({
replace: true,
to: "/projects/manage",
search: { projectPath: migrateProjectPath },
});
}
} catch (e) {
console.error(e);
toastThrownError(e);
}
}
function headerText(data: ChangeUnityData) {
if (data.isVRC && data.isTargetVersionSupportedByVRC) {
switch (data.kind) {
case "upgradePatchOrMinor":
case "upgradeMajor":
return tc("projects:manage:dialog:unity migrate header");
}
}
return tc("projects:manage:dialog:unity change version header");
}
function NoExactUnity2022Dialog({
expectedVersion,
installWithUnityHubLink,
dialog,
header,
}: {
expectedVersion: string;
installWithUnityHubLink?: string;
dialog: DialogContext<void>;
header: React.ReactNode;
}) {
return (
<>
<DialogTitle>{header}</DialogTitle>
<div>
<p>
{tc(
"projects:manage:dialog:exact version unity not found for patch migration description",
{ unity: expectedVersion },
)}
</p>
</div>
<DialogFooter className={"gap-2"}>
{installWithUnityHubLink && (
<Button
onClick={() => void commands.utilOpenUrl(installWithUnityHubLink)}
>
{tc("projects:dialog:open unity hub")}
</Button>
)}
<Button onClick={() => dialog.close()} className="mr-1">
{tc("general:button:close")}
</Button>
</DialogFooter>
</>
);
}
function MigrationConfirmMigrationPatchDialog({
unity,
dialog,
header,
}: {
unity: string;
dialog: DialogContext<ProjectBackupType | null>;
header: React.ReactNode;
}) {
return (
<>
<DialogTitle>{header}</DialogTitle>
<div>
<p className={"text-destructive"}>
{tc("projects:dialog:migrate unity2022 patch description", { unity })}
</p>
</div>
<DialogFooter>
<Button onClick={() => dialog.close(null)} className="mr-1">
{tc("general:button:cancel")}
</Button>
<Button onClick={() => dialog.close("none")} variant={"destructive"}>
{tc("projects:button:migrate in-place")}
</Button>
</DialogFooter>
</>
);
}
function MigrationConfirmMigrationDialog({
dialog,
header,
}: {
dialog: DialogContext<ProjectBackupType | null>;
header: React.ReactNode;
}) {
return (
<>
<DialogTitle>{header}</DialogTitle>
<div>
<p>{tc("projects:dialog:vpm migrate description")}</p>
</div>
<DialogFooter className={"gap-1"}>
<Button onClick={() => dialog.close(null)}>
{tc("general:button:cancel")}
</Button>
<Button onClick={() => dialog.close("backupArchive")}>
{tc("projects:button:backup and migrate")}
</Button>
<Button onClick={() => dialog.close("copy")}>
{tc("projects:button:migrate copy")}
</Button>
<Button onClick={() => dialog.close("none")} variant={"destructive"}>
{tc("projects:button:migrate in-place")}
</Button>
</DialogFooter>
</>
);
}
function UnityVersionChange({
data,
dialog,
header,
}: {
data: ChangeUnityData;
dialog: DialogContext<ProjectBackupType | null>;
header: React.ReactNode;
}) {
let mainMessage: React.ReactNode;
if (data.kind === "changeChina") {
mainMessage = tc("projects:manage:dialog:changing china releases");
} else {
const category = {
downgradeMajor: "downgrade major",
downgradePatchOrMinor: "downgrade minor",
upgradePatchOrMinor: "upgrade minor",
upgradeMajor: "upgrade major",
}[data.kind];
if (data.isVRC) {
if (data.isTargetVersionSupportedByVRC) {
mainMessage = tc([
`projects:manage:dialog:${category} vrchat supported`,
`projects:manage:dialog:${category}`,
]);
} else {
mainMessage = tc([
`projects:manage:dialog:${category} vrchat unsupported`,
`projects:manage:dialog:${category}`,
]);
}
} else {
mainMessage = tc(`projects:manage:dialog:${category}`);
}
}
return (
<>
<DialogTitle>{header}</DialogTitle>
<div>
<p className={"text-destructive"}>{mainMessage}</p>
</div>
<DialogFooter>
<Button onClick={() => dialog.close(null)} className="mr-1">
{tc("general:button:cancel")}
</Button>
<Button onClick={() => dialog.close("none")} variant={"destructive"}>
{tc("projects:button:change unity version")}
</Button>
</DialogFooter>
</>
);
}
export function MigrationCopyingDialog({
projectPath,
dialog,
header,
}: {
projectPath: string;
dialog: DialogContext<string>;
header: React.ReactNode;
}) {
const [progress, setProgress] = useState<TauriCopyProjectProgress>({
proceed: 0,
total: 1,
last_proceed: "Collecting files...",
});
useEffect(() => {
const [_, promise] = callAsyncCommand(
commands.environmentCopyProjectForMigration,
[projectPath],
(progress) => {
setProgress((prev) => {
if (prev.proceed > progress.proceed) return prev;
return progress;
});
},
);
promise.then(dialog.close, dialog.error);
}, [projectPath, dialog.close, dialog.error]);
return (
<>
<DialogTitle>{header}</DialogTitle>
<div>
<p>{tc("projects:pre-migrate copying...")}</p>
<p>
{tc("projects:dialog:proceed k/n", {
count: progress.proceed,
total: progress.total,
})}
</p>
<Progress value={progress.proceed} max={progress.total} />
<p>{tc("projects:do not close")}</p>
</div>
</>
);
}
function MigrationMigratingDialog({ header }: { header: React.ReactNode }) {
return (
<>
<DialogTitle>{header}</DialogTitle>
<div>
<p>{tc("projects:migrating...")}</p>
<p>{tc("projects:do not close")}</p>
</div>
</>
);
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
type UnityInstallation = [path: string, version: string, fromHub: boolean];
type FindUnityResult = FindUnityFoundResult | FindUnityNotFoundResult;
type ProjectBackupType = "none" | "copy" | "backupArchive";
interface FindUnityFoundResult {
found: true;
installations: UnityInstallation[];
}
interface FindUnityNotFoundResult {
installLink?: string;
found: false;
}
type ChangeUnityKind =
| "changeChina" // Changing between 'c' releases and non 'c' releases
| "downgradeMajor"
| "downgradePatchOrMinor"
| "upgradePatchOrMinor"
| "upgradeMajor";
type ChangeUnityData =
| {
kind: ChangeUnityKind;
isVRC: false;
}
| {
kind: ChangeUnityKind;
isVRC: true;
isTargetVersionSupportedByVRC: boolean;
};
function detectChangeUnityKind(
currentVersion: string,
targetUnityVersion: string,
isVRCProject: boolean,
): ChangeUnityData {
// biome-ignore lint/style/noNonNullAssertion: the version is known to be valid
const parsedCurrent = parseUnityVersion(currentVersion)!;
// biome-ignore lint/style/noNonNullAssertion: the version is known to be valid
const parsedTarget = parseUnityVersion(targetUnityVersion)!;
const cmp = compareUnityVersionString(currentVersion, targetUnityVersion);
const majorOrMinor =
parsedCurrent.major === parsedTarget.major ? "PatchOrMinor" : "Major";
const kind: ChangeUnityData["kind"] =
cmp === 0
? "changeChina"
: cmp > 0
? `downgrade${majorOrMinor}`
: `upgrade${majorOrMinor}`;
if (isVRCProject) {
return {
kind,
isVRC: true,
isTargetVersionSupportedByVRC:
VRCSDK_UNITY_VERSIONS.includes(targetUnityVersion),
};
} else {
return {
kind,
isVRC: false,
};
}
}
function findUnityForUnityChange(
unityVersions: TauriUnityVersions,
targetUnityVersion: string,
mayUseChinaVariant: boolean,
): FindUnityResult {
let foundVersions = unityVersions.unity_paths.filter(
([_p, v, _]) => v === targetUnityVersion,
);
// if international version not found, try to find china version
if (
foundVersions.length === 0 &&
mayUseChinaVariant &&
parseUnityVersion(targetUnityVersion)?.chinaIncrement == null
) {
const chinaVersion = `${targetUnityVersion}c1`;
foundVersions = unityVersions.unity_paths.filter(
([_p, v, _]) => v === chinaVersion,
);
}
if (foundVersions.length === 0) {
if (
compareUnityVersionString(
targetUnityVersion,
unityVersions.recommended_version,
) === 0
) {
return {
// This is using link to international version but china version of hub will handle international to china conversion
installLink: unityVersions.install_recommended_version_link,
found: false,
};
} else {
return {
found: false,
};
}
}
return {
found: true,
installations: foundVersions,
};
}
function MigrationCallingUnityForMigrationDialog({
unityPath,
migrateProjectPath,
dialog,
header,
}: {
unityPath: string;
migrateProjectPath: string;
dialog: DialogContext<"cancelled" | TauriCallUnityForMigrationResult>;
header: React.ReactNode;
}) {
const [lines, setLines] = useState<[number, string][]>([]);
useEffect(() => {
let lineNumber = 0;
const [, promise] = callAsyncCommand(
commands.projectCallUnityForMigration,
[migrateProjectPath, unityPath],
(lineString) => {
setLines((prev) => {
lineNumber++;
const line: [number, string] = [lineNumber, lineString];
if (prev.length > 200) {
return [...prev.slice(1), line];
} else {
return [...prev, line];
}
});
},
);
promise.then(dialog.close, dialog.error);
}, [migrateProjectPath, unityPath, dialog]);
const ref = React.useRef<HTMLDivElement>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: we want to scroll to bottom on lines changed
React.useEffect(() => {
ref.current?.scrollIntoView({ behavior: "auto" });
}, [lines]);
return (
<>
<DialogTitle>{header}</DialogTitle>
<div>
<p>{tc("projects:manage:dialog:unity migrate finalizing...")}</p>
<p>{tc("projects:do not close")}</p>
{/* TODO: use ScrollArea (I failed to use it inside dialog) */}
<pre
className={
"overflow-y-auto h-[50vh] bg-secondary text-secondary-foreground text-sm"
}
>
{lines.map(([lineNumber, line]) => (
<Fragment key={lineNumber}>
{line}
{"\n"}
</Fragment>
))}
<div ref={ref} />
</pre>
</div>
</>
);
}

View file

@ -1,796 +0,0 @@
import type { DefaultError } from "@tanstack/query-core";
import { queryOptions, type UseMutationOptions } from "@tanstack/react-query";
import { CircleAlert } from "lucide-react";
import type React from "react";
import { Fragment } from "react";
import { DelayedButton } from "@/components/DelayedButton";
import { ExternalLink } from "@/components/ExternalLink";
import { Button } from "@/components/ui/button";
import {
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { assertNever } from "@/lib/assert-never";
import type {
TauriBasePackageInfo,
TauriPackage,
TauriPackageChange,
TauriPendingProjectChanges,
TauriVersion,
} from "@/lib/bindings";
import { commands } from "@/lib/bindings";
import { type DialogContext, openSingleDialog } from "@/lib/dialog";
import { isHandleable } from "@/lib/errors";
import { tc, tt } from "@/lib/i18n";
import { queryClient } from "@/lib/query-client";
import { toastInfo, toastSuccess, toastThrownError } from "@/lib/toast";
import { groupBy, keyComparator } from "@/lib/utils";
import { compareVersion, toVersionString } from "@/lib/version";
export type RequestedOperation =
| {
type: "install";
pkg: TauriPackage;
hasUnityIncompatibleLatest?: boolean;
}
| {
type: "upgradeAll";
hasUnityIncompatibleLatest: boolean;
packages: TauriPackage[];
}
| {
type: "resolve";
}
| {
type: "reinstallAll";
}
| {
type: "remove";
displayName: string;
packageId: string;
}
| {
type: "bulkInstalled";
hasUnityIncompatibleLatest: boolean;
packages: TauriPackage[];
}
| {
type: "bulkReinstalled";
packageIds: string[];
}
| {
type: "bulkRemoved";
packageIds: string[];
};
function environmentPackages(projectPath: string) {
return queryOptions({
queryKey: ["projectDetails", projectPath],
queryFn: () => commands.projectDetails(projectPath),
refetchOnWindowFocus: false,
});
}
function mutationOptions<
TOptions extends UseMutationOptions<TData, TError, TVariables, TContext>,
TData = unknown,
TError = DefaultError,
TVariables = void,
TContext = unknown,
>(
options: TOptions & UseMutationOptions<TData, TError, TVariables, TContext>,
): TOptions {
return options;
}
export function applyChangesMutation(projectPath: string) {
return mutationOptions({
mutationKey: ["projectApplyChanges", projectPath],
mutationFn: async (operation: RequestedOperation) =>
await applyChanges(projectPath, operation),
onError: (e) => {
console.error(e);
toastThrownError(e);
},
onSettled: async () => {
document.dispatchEvent(new Event("post-package-changes"));
await queryClient.invalidateQueries({
queryKey: ["projectDetails", projectPath],
});
await queryClient.invalidateQueries({
queryKey: ["environmentPackages"],
});
},
});
}
export async function applyChanges(
projectPath: string,
operation: RequestedOperation,
) {
try {
const existingPackages = queryClient.getQueryData(
environmentPackages(projectPath).queryKey,
)?.installed_packages;
const changes = await createChanges(projectPath, operation);
if (
!(await openSingleDialog(ProjectChangesDialog, {
changes,
existingPackages,
}))
) {
// close window
return;
}
await commands.projectApplyPendingChanges(
projectPath,
changes.changes_version,
);
showToast(operation);
} catch (e) {
if (isHandleable(e) && e.body.type === "MissingDependencies") {
await openSingleDialog(MissingDependenciesDialog, {
dependencies: e.body.dependencies,
});
} else {
throw e;
}
}
}
function createChanges(
projectPath: string,
operation: RequestedOperation,
): Promise<TauriPendingProjectChanges> {
switch (operation.type) {
case "install":
return commands.projectInstallPackages(projectPath, [
[operation.pkg.name, toVersionString(operation.pkg.version)],
]);
case "upgradeAll":
return commands.projectInstallPackages(
projectPath,
operation.packages.map((pkg) => [
pkg.name,
toVersionString(pkg.version),
]),
);
case "resolve":
case "reinstallAll":
return commands.projectResolve(projectPath);
case "remove":
return commands.projectRemovePackages(projectPath, [operation.packageId]);
case "bulkInstalled":
return commands.projectInstallPackages(
projectPath,
operation.packages.map((pkg) => [
pkg.name,
toVersionString(pkg.version),
]),
);
case "bulkReinstalled":
return commands.projectReinstallPackages(
projectPath,
operation.packageIds,
);
case "bulkRemoved":
return commands.projectRemovePackages(projectPath, operation.packageIds);
default:
assertNever(operation);
}
}
function showToast(requested: RequestedOperation) {
switch (requested.type) {
case "install":
toastSuccess(
tt("projects:manage:toast:package installed", {
name: requested.pkg.display_name ?? requested.pkg.name,
version: toVersionString(requested.pkg.version),
}),
);
if (requested.hasUnityIncompatibleLatest) {
toastInfo(
tt(
"projects:manage:toast:the package has newer latest with incompatible unity",
),
);
}
break;
case "remove":
toastSuccess(
tt("projects:manage:toast:package removed", {
name: requested.displayName,
}),
);
break;
case "resolve":
toastSuccess(tt("projects:manage:toast:resolved"));
break;
case "reinstallAll":
toastSuccess(tt("projects:manage:toast:all packages reinstalled"));
break;
case "upgradeAll":
toastSuccess(tt("projects:manage:toast:all packages upgraded"));
if (requested.hasUnityIncompatibleLatest) {
toastInfo(
tt(
"projects:manage:toast:some package has newer latest with incompatible unity",
),
);
}
break;
case "bulkInstalled":
toastSuccess(tt("projects:manage:toast:selected packages installed"));
if (requested.hasUnityIncompatibleLatest) {
toastInfo(
tt(
"projects:manage:toast:some package has newer latest with incompatible unity",
),
);
}
break;
case "bulkRemoved":
toastSuccess(tt("projects:manage:toast:selected packages removed"));
break;
case "bulkReinstalled":
toastSuccess(tt("projects:manage:toast:selected packages reinstalled"));
break;
default:
assertNever(requested);
}
}
const TypographyItem = ({ children }: { children: React.ReactNode }) => (
<div className={"p-3"}>
<p className={"font-normal"}>{children}</p>
</div>
);
function ProjectChangesDialog({
changes,
existingPackages,
dialog,
}: {
changes: TauriPendingProjectChanges;
existingPackages?: [string, TauriBasePackageInfo][];
dialog: DialogContext<boolean>;
}) {
const versionConflicts = changes.conflicts.filter(
([_, c]) => c.packages.length > 0,
);
const unityConflicts = changes.conflicts.filter(([_, c]) => c.unity_conflict);
const unlockedConflicts = changes.conflicts.flatMap(
([_, c]) => c.unlocked_names,
);
const existingPackageMap = new Map(existingPackages ?? []);
const categorizedChanges = changes.package_changes.map(([pkgId, change]) =>
categorizeChange(pkgId, change, existingPackageMap),
);
categorizedChanges.sort(keyComparator("packageId"));
const groupedChanges = Array.from(groupBy(categorizedChanges, (c) => c.type));
groupedChanges.sort(keyComparator(0));
const installingPackageById = new Map(
changes.package_changes
.map(([id, change]) =>
"InstallNew" in change ? ([id, change.InstallNew] as const) : undefined,
)
.filter((x) => x != null),
);
function getPackageDisplayName(id: string) {
return (
installingPackageById.get(id)?.display_name ??
existingPackageMap.get(id)?.display_name ??
id
);
}
const breakingChanges = groupedChanges.some(
([a]) => a === PackageChangeCategory.UpgradeMajor,
);
const incompatibility = changes.conflicts.length !== 0;
const needsCare = breakingChanges || incompatibility;
return (
<div className={"contents whitespace-normal"}>
<DialogHeader>
<DialogTitle>{tc("projects:manage:button:apply changes")}</DialogTitle>
<DialogDescription>
<p>{tc("projects:manage:dialog:confirm changes description")}</p>
{breakingChanges && (
<div
className={
"flex border border-solid border-warning mt-3 py-2 me-1.5"
}
>
<CircleAlert
className={"text-warning self-center mx-2 shrink-0"}
/>
<p>{tc("projects:manage:dialog:note breaking changes")}</p>
</div>
)}
{incompatibility && (
<div
className={
"flex border border-solid border-warning mt-3 py-2 me-1.5"
}
>
<CircleAlert
className={"text-warning self-center mx-2 shrink-0"}
/>
<p>{tc("projects:manage:dialog:note incompatibility")}</p>
</div>
)}
</DialogDescription>
</DialogHeader>
<div className="overflow-hidden flex">
<ScrollArea
type="always"
className={"w-full"}
scrollBarClassName={"bg-background pb-2.5"}
>
<div className="pr-2 overflow-x-hidden">
<div className={"flex flex-col gap-1 p-2"}>
{groupedChanges.map(([category, changes], index) => {
return (
<Fragment key={category}>
{index !== 0 && <hr />}
{changes.map((change) => (
<PackageChange key={change.packageId} change={change} />
))}
</Fragment>
);
})}
</div>
{versionConflicts.length > 0 ? (
<>
<p className={"text-destructive"}>
{tc("projects:manage:dialog:package version conflicts", {
count: versionConflicts.length,
})}
</p>
<div className={"flex flex-col gap-1 p-2"}>
{versionConflicts.map(([pkgId, conflict]) => {
return (
<TypographyItem key={pkgId}>
{tc("projects:manage:dialog:conflicts with", {
pkg: getPackageDisplayName(pkgId),
other: conflict.packages
.map((p) => getPackageDisplayName(p))
.join(", "),
})}
</TypographyItem>
);
})}
</div>
</>
) : null}
{unityConflicts.length > 0 ? (
<>
<p className={"text-destructive"}>
{tc("projects:manage:dialog:unity version conflicts", {
count: unityConflicts.length,
})}
</p>
<div className={"flex flex-col gap-1 p-2"}>
{unityConflicts.map(([pkgId, _]) => (
<TypographyItem key={pkgId}>
{tc(
"projects:manage:dialog:package not supported your unity",
{
pkg: getPackageDisplayName(pkgId),
},
)}
</TypographyItem>
))}
</div>
</>
) : null}
{changes.remove_legacy_files.length > 0 ||
changes.remove_legacy_folders.length > 0 ? (
<>
<p className={"text-destructive"}>
{tc(
"projects:manage:dialog:files and directories are removed as legacy",
)}
</p>
<div className={"flex flex-col gap-1 p-2"}>
{changes.remove_legacy_files.map((f) => (
<TypographyItem key={f}>{f}</TypographyItem>
))}
{changes.remove_legacy_folders.map((f) => (
<TypographyItem key={f}>{f}</TypographyItem>
))}
</div>
</>
) : null}
{unlockedConflicts.length > 0 ? (
<>
<p className={"text-destructive"}>
{tc(
"projects:manage:dialog:packages installed in the following directories will be removed",
)}
</p>
<div className={"flex flex-col gap-1 p-2"}>
{unlockedConflicts.map((f) => (
<TypographyItem key={f}>{f}</TypographyItem>
))}
</div>
</>
) : null}
</div>
</ScrollArea>
</div>
<DialogFooter>
<Button onClick={() => dialog.close(false)} className="mr-1">
{tc("general:button:cancel")}
</Button>
<DelayedButton
onClick={() => dialog.close(true)}
variant={needsCare ? "destructive" : "warning"}
delay={needsCare ? 1000 : 0}
>
{tc("projects:manage:button:apply")}
</DelayedButton>
</DialogFooter>
</div>
);
}
function PackageChange({
change,
}: {
change: PackageChangeDisplayInformation;
}) {
switch (change.type) {
case PackageChangeCategory.UpgradeMajor:
return (
<div className={"flex items-center p-3 justify-between bg-warning/10"}>
<p className={"font-normal"}>
{tc("projects:manage:dialog:upgrade package", {
name: change.displayName,
previousVersion: toVersionString(change.previousVersion),
version: toVersionString(change.version),
})}
<span className={"text-warning"}>
{"\u200B"}
<CircleAlert
className={
"inline px-1 size-5 -mt-0.5 box-content align-middle"
}
/>
{tc("projects:manage:dialog:breaking changes")}
</span>
</p>
<ChangelogButton url={change.changelogUrl} />
</div>
);
case PackageChangeCategory.Upgrade:
return (
<div className={"flex items-center p-3 justify-between"}>
<p className={"font-normal"}>
{tc("projects:manage:dialog:upgrade package", {
name: change.displayName,
previousVersion: toVersionString(change.previousVersion),
version: toVersionString(change.version),
})}
</p>
<ChangelogButton url={change.changelogUrl} />
</div>
);
case PackageChangeCategory.Downgrade:
return (
<div className={"flex items-center p-3 justify-between"}>
<p className={"font-normal"}>
{tc("projects:manage:dialog:downgrade package", {
name: change.displayName,
previousVersion: toVersionString(change.previousVersion),
version: toVersionString(change.version),
})}
</p>
<ChangelogButton url={change.changelogUrl} />
</div>
);
case PackageChangeCategory.InstallNew:
return (
<div className={"flex items-center p-3 justify-between"}>
<p className={"font-normal"}>
{tc("projects:manage:dialog:install package", {
name: change.displayName,
version: toVersionString(change.version),
})}
</p>
<ChangelogButton url={change.changelogUrl} />
</div>
);
case PackageChangeCategory.UninstallRequested:
return (
<div className={"flex items-center p-3 justify-between"}>
<p className={"font-normal"}>
{tc("projects:manage:dialog:uninstall package as requested", {
name: change.displayName,
})}
</p>
</div>
);
case PackageChangeCategory.UninstallUnused:
return (
<div className={"flex items-center p-3 justify-between"}>
<p className={"font-normal"}>
{tc("projects:manage:dialog:uninstall package as unused", {
name: change.displayName,
})}
</p>
</div>
);
case PackageChangeCategory.UninstallLegacy:
return (
<div className={"flex items-center p-3 justify-between"}>
<p className={"font-normal"}>
{tc("projects:manage:dialog:uninstall package as legacy", {
name: change.displayName,
})}
</p>
</div>
);
case PackageChangeCategory.Reinstall:
return (
<div className={"flex items-center p-3 justify-between"}>
<p className={"font-normal select-text"}>
{tc("projects:manage:dialog:reinstall package", {
name: change.displayName,
version: toVersionString(change.version),
})}
</p>
<ChangelogButton url={change.changelogUrl} />
</div>
);
}
}
enum PackageChangeCategory {
InstallNew = 0,
UpgradeMajor = 1,
Upgrade = 2,
Downgrade = 3,
UninstallRequested = 4,
UninstallUnused = 5,
UninstallLegacy = 6,
Reinstall = 7,
}
type PackageChangeDisplayInformation = {
packageId: string;
displayName: string;
} & (
| {
type: PackageChangeCategory.UpgradeMajor;
version: TauriVersion;
previousVersion: TauriVersion;
changelogUrl: string | null;
}
| {
type: PackageChangeCategory.Upgrade;
version: TauriVersion;
previousVersion: TauriVersion;
changelogUrl: string | null;
}
| {
type: PackageChangeCategory.Downgrade;
version: TauriVersion;
previousVersion: TauriVersion;
changelogUrl: string | null;
}
| {
type: PackageChangeCategory.Reinstall;
version: TauriVersion;
changelogUrl: string | null;
}
| {
type: PackageChangeCategory.InstallNew;
version: TauriVersion;
changelogUrl: string | null;
}
| {
type: PackageChangeCategory.UninstallRequested;
}
| {
type: PackageChangeCategory.UninstallUnused;
}
| {
type: PackageChangeCategory.UninstallLegacy;
}
);
function categorizeChange(
pkgId: string,
change: TauriPackageChange,
installedPackages: Map<string, TauriBasePackageInfo>,
): PackageChangeDisplayInformation {
if (change.InstallNew !== undefined) {
const name = change.InstallNew.display_name ?? change.InstallNew.name;
const installed = installedPackages.get(pkgId);
if (installed == null) {
return {
packageId: pkgId,
displayName: name,
type: PackageChangeCategory.InstallNew,
version: change.InstallNew.version,
changelogUrl: change.InstallNew.changelog_url,
};
} else {
const compare = compareVersion(
installed.version,
change.InstallNew.version,
);
switch (compare) {
case 1:
return {
packageId: pkgId,
displayName: name,
type: PackageChangeCategory.Downgrade,
version: change.InstallNew.version,
previousVersion: installed.version,
changelogUrl: change.InstallNew.changelog_url,
};
case 0:
return {
packageId: pkgId,
displayName: name,
type: PackageChangeCategory.Reinstall,
version: change.InstallNew.version,
changelogUrl: change.InstallNew.changelog_url,
};
case -1:
if (
isUpgradingMajorly(
pkgId,
installed.version,
change.InstallNew.version,
)
) {
return {
packageId: pkgId,
displayName: name,
type: PackageChangeCategory.UpgradeMajor,
version: change.InstallNew.version,
previousVersion: installed.version,
changelogUrl: change.InstallNew.changelog_url,
};
} else {
return {
packageId: pkgId,
displayName: name,
type: PackageChangeCategory.Upgrade,
version: change.InstallNew.version,
previousVersion: installed.version,
changelogUrl: change.InstallNew.changelog_url,
};
}
}
}
} else {
const name = installedPackages.get(pkgId)?.display_name ?? pkgId;
switch (change.Remove) {
case "Requested":
return {
packageId: pkgId,
displayName: name,
type: PackageChangeCategory.UninstallRequested,
};
case "Legacy":
return {
packageId: pkgId,
displayName: name,
type: PackageChangeCategory.UninstallLegacy,
};
case "Unused":
return {
packageId: pkgId,
displayName: name,
type: PackageChangeCategory.UninstallUnused,
};
}
}
}
function isUpgradingMajorly(
pkgId: string,
prevVersion: TauriVersion,
newVersion: TauriVersion,
): boolean {
function firstNonZeroVersionNum(version: TauriVersion): number {
if (version.major !== 0) return version.major;
if (version.minor !== 0) return version.minor;
return version.patch;
}
// generic case: non-zero first version number will be the major version
if (
firstNonZeroVersionNum(prevVersion) !== firstNonZeroVersionNum(newVersion)
) {
return true;
}
// Special case: VRChat SDK uses Branding.Breaking.Bumps.
// Therefore the second number bump means major version bump.
// See https://vcc.docs.vrchat.com/vpm/packages/#brandingbreakingbumps
// See https://feedback.vrchat.com/sdk-bug-reports/p/feedback-please-dont-make-vrcsdk-to-4x-unless-as-big-breaking-changes-as-2-to-3
if (
pkgId === "com.vrchat.avatars" ||
pkgId === "com.vrchat.worlds" ||
pkgId === "com.vrchat.base"
) {
if (prevVersion.minor !== newVersion.minor) {
return true;
}
}
// No conditions met so it's not major bump
return false;
}
function ChangelogButton({ url }: { url?: string | null }) {
if (url == null) return null;
try {
const parsed = new URL(url);
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
return (
<Button
className={"ml-1 px-2"}
size={"sm"}
onClick={() => commands.utilOpenUrl(url)}
>
<ExternalLink>
{tc("projects:manage:button:see changelog")}
</ExternalLink>
</Button>
);
}
} catch {}
return null;
}
function MissingDependenciesDialog({
dependencies,
dialog,
}: {
dependencies: [pkg: string, range: string][];
dialog: DialogContext<void>;
}) {
return (
<div>
<DialogTitle className={"text-destructive"}>
<CircleAlert className="size-6 inline" />{" "}
{tc("projects:manage:dialog:missing dependencies")}
</DialogTitle>
<div>
<p className={"whitespace-normal"}>
{tc("projects:manage:dialog:missing dependencies description")}
</p>
<ul className={"list-disc ml-4 mt-2"}>
{dependencies.map(([dep, range]) => (
<li key={dep}>
{dep} version {range}
</li>
))}
</ul>
</div>
<DialogFooter>
<Button onClick={() => dialog.close()}>
{tc("general:button:close")}
</Button>
</DialogFooter>
</div>
);
}

View file

@ -1,771 +0,0 @@
"use client";
import {
queryOptions,
type UseQueryResult,
useIsMutating,
useMutation,
useQueries,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
createFileRoute,
useNavigate,
useRouter,
} from "@tanstack/react-router";
import { ArrowLeft, ChevronDown } from "lucide-react";
import type React from "react";
import { Suspense, useMemo } from "react";
import { copyProject } from "@/app/_main/projects/manage/-copy-project";
import { BackupProjectDialog } from "@/components/BackupProjectDialog";
import { HNavBar, VStack } from "@/components/layout";
import { OpenUnityButton } from "@/components/OpenUnityButton";
import { RemoveProjectDialog } from "@/components/RemoveProjectDialog";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { DialogFooter, DialogTitle } from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
UnityArgumentsSettings,
useUnityArgumentsSettings,
} from "@/components/unity-arguments-settings";
import type { TauriProjectDetails, TauriUnityVersions } from "@/lib/bindings";
import { commands } from "@/lib/bindings";
import { VRCSDK_PACKAGES, VRCSDK_UNITY_VERSIONS } from "@/lib/constants";
import { type DialogContext, openSingleDialog } from "@/lib/dialog";
import { tc } from "@/lib/i18n";
import { nameFromPath } from "@/lib/os";
import { toastSuccess, toastThrownError } from "@/lib/toast";
import { compareUnityVersionString, parseUnityVersion } from "@/lib/version";
import { combinePackagesAndProjectDetails } from "./-collect-package-row-info";
import { PackageListCard } from "./-package-list-card";
import { PageContextProvider } from "./-page-context";
import { unityVersionChange } from "./-unity-migration";
import { applyChangesMutation } from "./-use-package-change";
interface SearchParams {
projectPath: string;
}
export const Route = createFileRoute("/_main/projects/manage/")({
component: Page,
validateSearch: (a): SearchParams => ({
projectPath: a.projectPath == null ? "" : `${a.projectPath}`,
}),
});
function Page() {
return (
<Suspense>
<PageBody />
</Suspense>
);
}
function PageBody() {
const { projectPath } = Route.useSearch();
const router = useRouter();
// repositoriesInfo: list of repositories and their visibility
// packagesResult: list of packages
// detailsResult: project details including installed packages
// unityVersionsResult: list of unity versions installed
const [repositoriesInfo, packagesResult, detailsResult, unityVersionsResult] =
useQueries({
queries: [
{
queryKey: ["environmentRepositoriesInfo"],
queryFn: commands.environmentRepositoriesInfo,
refetchOnWindowFocus: false,
},
{
queryKey: ["environmentPackages"],
queryFn: commands.environmentPackages,
refetchOnWindowFocus: false,
},
{
queryKey: ["projectDetails", projectPath],
queryFn: () => commands.projectDetails(projectPath),
refetchOnWindowFocus: false,
},
{
queryKey: ["environmentUnityVersions"],
queryFn: () => commands.environmentUnityVersions(),
},
],
});
const packageRowsData = useMemo(() => {
const packages = packagesResult.data ?? [];
const details = detailsResult.data ?? null;
const hiddenRepositories =
repositoriesInfo.data?.hidden_user_repositories ?? [];
const hideUserPackages =
repositoriesInfo.data?.hide_local_user_packages ?? false;
const definedRepositories = repositoriesInfo.data?.user_repositories ?? [];
const showPrereleasePackages =
repositoriesInfo.data?.show_prerelease_packages ?? false;
return combinePackagesAndProjectDetails(
packages,
details,
hiddenRepositories,
hideUserPackages,
definedRepositories,
showPrereleasePackages,
);
}, [repositoriesInfo.data, packagesResult.data, detailsResult.data]);
const queryClient = useQueryClient();
const refetchPackages = useMutation({
mutationFn: async () => await commands.environmentRefetchPackages(),
onError: (e) => {
reportError(e);
console.error(e);
},
onSettled: async () => {
await Promise.all([
queryClient.invalidateQueries({
queryKey: ["environmentRepositoriesInfo"],
}),
queryClient.invalidateQueries({ queryKey: ["environmentPackages"] }),
queryClient.invalidateQueries({
queryKey: ["projectDetails", projectPath],
}),
queryClient.invalidateQueries({
queryKey: ["environmentUnityVersions"],
}),
]);
},
});
const fetchingMutation = useIsMutating({
mutationKey: applyChangesMutation(projectPath).mutationKey,
});
const requestChangeUnityVersion = (
version: string,
mayUseChinaVariant?: boolean,
) => {
if (detailsResult.data == null)
throw new Error("Project details not ready");
const isVRCProject = detailsResult.data.installed_packages.some(([id, _]) =>
VRCSDK_PACKAGES.includes(id),
);
void unityVersionChange({
projectPath,
version,
isVRCProject,
currentUnityVersion: detailsResult.data.unity_str ?? "unknown",
mayUseChinaVariant,
navigate: router.navigate,
});
};
const isLoading =
packagesResult.isFetching ||
detailsResult.isFetching ||
repositoriesInfo.isFetching ||
unityVersionsResult.isLoading ||
fetchingMutation !== 0 ||
refetchPackages.isPending;
console.log(`rerender: isloading: ${isLoading}`);
const pageContext = useMemo(() => ({ isLoading }), [isLoading]);
return (
<PageContextProvider value={pageContext}>
<VStack>
<ProjectViewHeader
className="shrink-0"
isLoading={isLoading}
detailsResult={detailsResult}
unityVersionsResult={unityVersionsResult}
requestChangeUnityVersion={requestChangeUnityVersion}
/>
{detailsResult?.data?.should_resolve && (
<SuggestResolveProjectCard disabled={isLoading} />
)}
<MigrationCards
isLoading={isLoading}
detailsResult={detailsResult.data}
unityVersionsResult={unityVersionsResult.data}
requestChangeUnityVersion={requestChangeUnityVersion}
/>
<main className="shrink overflow-hidden flex w-full h-full">
<PackageListCard
packageRowsData={packageRowsData}
repositoriesInfo={repositoriesInfo.data}
onRefresh={() => refetchPackages.mutate()}
/>
</main>
</VStack>
</PageContextProvider>
);
}
function UnityVersionSelector({
disabled,
detailsResult,
requestChangeUnityVersion,
unityVersions,
}: {
disabled?: boolean;
detailsResult: UseQueryResult<TauriProjectDetails>;
requestChangeUnityVersion: (version: string) => void;
unityVersions?: TauriUnityVersions;
}) {
const unityVersionNames = useMemo(() => {
if (unityVersions == null) return null;
const versionNames = [
...new Set<string>(unityVersions.unity_paths.map(([, path]) => path)),
];
versionNames.sort((a, b) => compareUnityVersionString(b, a));
return versionNames;
}, [unityVersions]);
const isVRCProject =
detailsResult.data?.installed_packages.some(([id, _]) =>
VRCSDK_PACKAGES.includes(id),
) ?? false;
let unityVersionList: React.ReactNode;
if (unityVersionNames == null) {
unityVersionList = <SelectLabel>Loading...</SelectLabel>;
} else if (isVRCProject) {
const vrcSupportedVersions = unityVersionNames.filter((v) =>
VRCSDK_UNITY_VERSIONS.includes(v),
);
const vrcUnsupportedVersions = unityVersionNames.filter(
(v) => !VRCSDK_UNITY_VERSIONS.includes(v),
);
if (
vrcUnsupportedVersions.length === 0 ||
vrcUnsupportedVersions.length === 0
) {
unityVersionList = unityVersionNames.map((v) => (
<SelectItem key={v} value={v}>
{v}
</SelectItem>
));
} else {
// if there are both supported and unsupported versions, show them separately
unityVersionList = (
<>
{vrcSupportedVersions.map((v) => (
<SelectItem key={v} value={v}>
{v}
</SelectItem>
))}
<SelectLabel>
<Separator className={"-ml-6 mr-0 w-auto"} />
</SelectLabel>
{vrcUnsupportedVersions.map((v) => (
<SelectItem key={v} value={v}>
{v}
</SelectItem>
))}
</>
);
}
} else {
unityVersionList = unityVersionNames.map((v) => (
<SelectItem key={v} value={v}>
{v}
</SelectItem>
));
}
return (
<Select
disabled={disabled}
value={detailsResult.data?.unity_str ?? undefined}
onValueChange={requestChangeUnityVersion}
>
<SelectTrigger className={"compact:h-10"}>
{detailsResult.status === "success" ? (
(detailsResult.data.unity_str ?? "unknown")
) : (
<span className={"text-primary"}>Loading...</span>
)}
</SelectTrigger>
<SelectContent>
<SelectGroup>{unityVersionList}</SelectGroup>
</SelectContent>
</Select>
);
}
function SuggestResolveProjectCard({ disabled }: { disabled?: boolean }) {
const { projectPath } = Route.useSearch();
const packageChange = useMutation(applyChangesMutation(projectPath));
return (
<Card className={"shrink-0 p-2 flex flex-row items-center compact:p-1"}>
<p className="cursor-pointer py-1.5 font-bold grow-0 shrink overflow-hidden whitespace-normal text-sm pl-2">
{tc("projects:manage:suggest resolve")}
</p>
<div className={"grow shrink-0 w-2"} />
<Button
variant={"ghost-destructive"}
onClick={() => packageChange.mutate({ type: "resolve" })}
disabled={disabled}
>
{tc("projects:manage:button:resolve")}
</Button>
</Card>
);
}
function MigrationCards({
isLoading,
detailsResult,
unityVersionsResult,
requestChangeUnityVersion,
}: {
isLoading: boolean;
detailsResult?: TauriProjectDetails;
unityVersionsResult?: TauriUnityVersions;
requestChangeUnityVersion: (
version: string,
keepChinaVariant?: boolean,
) => void;
}) {
if (detailsResult == null) return null;
if (unityVersionsResult == null) return null;
if (detailsResult.unity == null) return false;
if (detailsResult.unity_str == null) return false;
const currentUnity = detailsResult.unity_str;
const isVRChatProject = detailsResult.installed_packages.some(([id, _]) =>
VRCSDK_PACKAGES.includes(id),
);
// we only migrate VRChat project (for now)
if (!isVRChatProject) return null;
// for 2019 projects, VRChat recommends migrating to 2022
const isMigrationTo2022Recommended = detailsResult.unity[0] === 2019;
const is2022PatchMigrationRecommended =
detailsResult.unity[0] === 2022 &&
compareUnityVersionString(
detailsResult.unity_str,
unityVersionsResult.recommended_version,
) !== 0;
const isChinaToInternationalMigrationRecommended =
parseUnityVersion(detailsResult.unity_str)?.chinaIncrement != null;
return (
<>
{isMigrationTo2022Recommended && (
<SuggestMigrateTo2022Card
disabled={isLoading}
onMigrateRequested={() =>
requestChangeUnityVersion(
unityVersionsResult.recommended_version,
true,
)
}
/>
)}
{is2022PatchMigrationRecommended && (
<Suggest2022PatchMigrationCard
disabled={isLoading}
onMigrateRequested={() =>
requestChangeUnityVersion(
unityVersionsResult.recommended_version,
true,
)
}
/>
)}
{isChinaToInternationalMigrationRecommended && (
<SuggestChinaToInternationalMigrationCard
disabled={isLoading}
onMigrateRequested={() => {
const internationalVersion = currentUnity.slice(
0,
currentUnity.indexOf("c"),
);
requestChangeUnityVersion(internationalVersion);
}}
/>
)}
</>
);
}
function SuggestMigrateTo2022Card({
disabled,
onMigrateRequested,
}: {
disabled?: boolean;
onMigrateRequested: () => void;
}) {
return (
<Card className={"shrink-0 p-2 flex flex-row items-center compact:p-1"}>
<p className="cursor-pointer py-1.5 font-bold grow-0 shrink overflow-hidden whitespace-normal text-sm pl-2">
{tc("projects:manage:suggest unity migration")}
</p>
<div className={"grow shrink-0 w-2"} />
<Button
variant={"ghost-destructive"}
onClick={onMigrateRequested}
disabled={disabled}
>
{tc("projects:manage:button:unity migrate")}
</Button>
</Card>
);
}
function Suggest2022PatchMigrationCard({
disabled,
onMigrateRequested,
}: {
disabled?: boolean;
onMigrateRequested: () => void;
}) {
return (
<Card className={"shrink-0 p-2 flex flex-row items-center compact:p-1"}>
<p className="cursor-pointer py-1.5 font-bold grow-0 shrink overflow-hidden whitespace-normal text-sm pl-2">
{tc("projects:manage:suggest unity patch migration")}
</p>
<div className={"grow shrink-0 w-2"} />
<Button
variant={"ghost-destructive"}
onClick={onMigrateRequested}
disabled={disabled}
>
{tc("projects:manage:button:unity migrate")}
</Button>
</Card>
);
}
function SuggestChinaToInternationalMigrationCard({
disabled,
onMigrateRequested,
}: {
disabled?: boolean;
onMigrateRequested: () => void;
}) {
return (
<Card className={"shrink-0 p-2 flex flex-row items-center compact:p-1"}>
<p className="cursor-pointer py-1.5 font-bold grow-0 shrink overflow-hidden whitespace-normal text-sm pl-2">
{tc("projects:manage:suggest unity china to international migration")}
</p>
<div className={"grow shrink-0 w-2"} />
<Button
variant={"ghost-destructive"}
onClick={onMigrateRequested}
disabled={disabled}
>
{tc("projects:manage:button:unity migrate")}
</Button>
</Card>
);
}
function ProjectViewHeader({
className,
isLoading,
detailsResult,
unityVersionsResult,
requestChangeUnityVersion,
}: {
className?: string;
isLoading: boolean | undefined;
detailsResult: UseQueryResult<TauriProjectDetails, Error>;
unityVersionsResult: UseQueryResult<TauriUnityVersions, Error>;
requestChangeUnityVersion: (
version: string,
mayUseChinaVariant?: boolean,
) => void;
}) {
const { projectPath } = Route.useSearch();
const projectName = nameFromPath(projectPath);
return (
<HNavBar
className={className}
leading={
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
className={"compact:h-10"}
variant={"ghost"}
size={"sm"}
onClick={() => history.back()}
>
<ArrowLeft className={"w-5 h-5"} />
</Button>
</TooltipTrigger>
<TooltipContent>
{tc("projects:manage:tooltip:back to projects")}
</TooltipContent>
</Tooltip>
<div className={"pl-2 space-y-0 shrink min-w-0 compact:pl-0"}>
<p className="cursor-pointer font-bold grow-0 whitespace-pre mb-0 leading-tight">
{projectName}
</p>
<p className="cursor-pointer text-sm leading-tight mt-0">
{tc(
"projects:manage:project location",
{ path: projectPath },
{
components: {
path: (
<span
className={
"p-0.5 font-path whitespace-pre bg-secondary text-secondary-foreground"
}
/>
),
},
},
)}
</p>
</div>
</>
}
trailing={
<>
<div className="flex items-center gap-1">
<p className="cursor-pointer py-1.5 font-bold">
{tc("projects:manage:unity version")}
</p>
<div className={"flex"}>
<UnityVersionSelector
disabled={isLoading}
detailsResult={detailsResult}
unityVersions={unityVersionsResult.data}
requestChangeUnityVersion={requestChangeUnityVersion}
/>
</div>
</div>
<div className={"grow-0 shrink-0 w-max"}>
<ProjectButton
projectPath={projectPath}
unityVersion={detailsResult.data?.unity_str ?? null}
unityRevision={detailsResult.data?.unity_revision ?? null}
/>
</div>
</>
}
/>
);
}
function LaunchSettings({
defaultUnityArgs,
initialValue,
dialog,
}: {
defaultUnityArgs: string[];
initialValue: string[] | null;
dialog: DialogContext<string[] | null | false>;
}) {
const context = useUnityArgumentsSettings(initialValue, defaultUnityArgs);
const saveAndClose = async () => {
dialog.close(context.currentValue);
};
return (
<>
<DialogTitle>{tc("projects:dialog:launch options")}</DialogTitle>
{/* TODO: use ScrollArea (I failed to use it inside dialog) */}
<div className={"max-h-[50dvh] overflow-y-auto"}>
<h3 className={"text-lg"}>
{tc("projects:dialog:command-line arguments")}
</h3>
<UnityArgumentsSettings context={context} />
</div>
<DialogFooter>
<Button onClick={() => dialog.close(false)} variant={"destructive"}>
{tc("general:button:cancel")}
</Button>
<Button onClick={saveAndClose} disabled={context.hasError}>
{tc("general:button:save")}
</Button>
</DialogFooter>
</>
);
}
function projectGetUnityPath(projectPath: string) {
return queryOptions({
queryFn: () => commands.projectGetUnityPath(projectPath),
queryKey: ["projectGetUnityPath", projectPath],
refetchOnWindowFocus: false,
});
}
function DropdownMenuContentBody({
projectPath,
removeProject,
onChangeLaunchOptions,
}: {
projectPath: string;
removeProject?: () => void;
onChangeLaunchOptions?: () => void;
}) {
const openProjectFolder = () =>
commands.utilOpen(projectPath, "ErrorIfNotExists");
const queryClient = useQueryClient();
const setUnityPath = useMutation({
mutationFn: async (unityPath: string | null) =>
await commands.projectSetUnityPath(projectPath, unityPath),
onMutate: async (unityPath) => {
const getUnityPath = projectGetUnityPath(projectPath);
await queryClient.invalidateQueries(getUnityPath);
const data = queryClient.getQueryData(getUnityPath.queryKey);
queryClient.setQueryData(getUnityPath.queryKey, unityPath);
return data;
},
onError: (e, _, data) => {
console.error(e);
toastThrownError(e);
queryClient.setQueryData(projectGetUnityPath(projectPath).queryKey, data);
},
onSuccess: () => {
toastSuccess(tc("projects:toast:forgot unity path"));
},
});
const unityPathQuery = useQuery(projectGetUnityPath(projectPath));
const navigate = useNavigate();
const onCopyProject = async () => {
try {
await copyProject(projectPath, navigate);
} catch (e) {
console.error(e);
toastThrownError(e);
}
};
const onBackup = async () => {
try {
await openSingleDialog(BackupProjectDialog, {
projectPath,
});
} catch (e) {
console.error(e);
toastThrownError(e);
}
};
const unityPath = unityPathQuery.data;
return (
<>
<DropdownMenuItem onClick={onChangeLaunchOptions}>
{tc("projects:menuitem:change launch options")}
</DropdownMenuItem>
{unityPath && (
<DropdownMenuItem onClick={() => setUnityPath.mutate(null)}>
{tc("projects:menuitem:forget unity path")}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={openProjectFolder}>
{tc("projects:menuitem:open directory")}
</DropdownMenuItem>
<DropdownMenuItem onClick={onCopyProject}>
{tc("projects:menuitem:copy project")}
</DropdownMenuItem>
<DropdownMenuItem onClick={onBackup}>
{tc("projects:menuitem:backup")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={removeProject}
className={"text-destructive focus:text-destructive"}
>
{tc("projects:remove project")}
</DropdownMenuItem>
</>
);
}
function ProjectButton({
projectPath,
unityVersion,
unityRevision,
}: {
projectPath: string;
unityVersion: string | null;
unityRevision: string | null;
}) {
const onChangeLaunchOptions = async () => {
const initialArgs = await commands.projectGetCustomUnityArgs(projectPath);
const defaultArgs = await commands.environmentGetDefaultUnityArguments();
const settings = await openSingleDialog(LaunchSettings, {
initialValue: initialArgs,
defaultUnityArgs: defaultArgs,
});
if (settings === false) return;
await commands.projectSetCustomUnityArgs(projectPath, settings);
};
return (
<DropdownMenu>
<div className={"flex divide-x"}>
<OpenUnityButton
projectPath={projectPath}
unityVersion={unityVersion}
unityRevision={unityRevision}
className={"rounded-r-none pl-4 pr-3 compact:h-10"}
/>
<DropdownMenuTrigger
asChild
className={"rounded-l-none pl-2 pr-2 compact:h-10"}
>
<Button>
<ChevronDown className={"w-4 h-4"} />
</Button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent>
<DropdownMenuContentBody
projectPath={projectPath}
removeProject={() => {
void openSingleDialog(RemoveProjectDialog, {
project: {
path: projectPath,
is_exists: true,
},
});
}}
onChangeLaunchOptions={onChangeLaunchOptions}
/>
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -1,83 +0,0 @@
"use client";
import { createFileRoute, Outlet, useLocation } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { SideBar } from "@/components/SideBar";
import { commands } from "@/lib/bindings";
import { useDocumentEvent } from "@/lib/events";
import { updateCurrentPath, usePrevPathName } from "@/lib/prev-page";
import { useEffectEvent } from "@/lib/use-effect-event";
export const Route = createFileRoute("/_main")({
component: MainLayout,
});
function MainLayout() {
const [animationState, setAnimationState] = useState("");
const [isVisible, setIsVisible] = useState(false);
const [guiAnimation, setGuiAnimation] = useState(false);
const previousPathName = usePrevPathName();
const pathName = useLocation().pathname;
useDocumentEvent(
"gui-animation",
(event) => {
setGuiAnimation(event.detail);
},
[],
);
const onPathChange = useEffectEvent((pathName: string) => {
updateCurrentPath(pathName);
(async () => {
setGuiAnimation(await commands.environmentGuiAnimation());
})();
if (!guiAnimation) return;
if (pathName === previousPathName) return;
const pageCategory = pathName.split("/")[1];
const previousPageCategory = previousPathName.split("/")[1];
if (pageCategory !== previousPageCategory) {
// category change is always fade-in
setAnimationState("fade-in");
} else {
// go deeper is slide-left, go back is slide-right, and no animation if not child-parent relation
if (pathName.startsWith(previousPathName)) {
setAnimationState("slide-left");
} else if (previousPathName.startsWith(pathName)) {
setAnimationState("slide-right");
}
}
});
useEffect(() => {
onPathChange(pathName);
}, [pathName]);
useEffect(() => {
(async () => {
if (await commands.environmentGuiCompact()) {
document.documentElement.setAttribute("compact", "");
} else {
document.documentElement.removeAttribute("compact");
}
setIsVisible(true);
})();
}, []);
return (
<>
<SideBar
className={`grow-0 ${isVisible ? "slide-right" : "invisible"}`}
/>
<div
className={`h-screen grow overflow-hidden flex p-4 compact:p-2 ${animationState}`}
onAnimationEnd={() => setAnimationState("")}
>
<Outlet />
</div>
</>
);
}

View file

@ -1,962 +0,0 @@
"use client";
import {
queryOptions,
useMutation,
useQueryClient,
useSuspenseQuery,
} from "@tanstack/react-query";
import { createFileRoute, Link } from "@tanstack/react-router";
import { RefreshCw } from "lucide-react";
import type React from "react";
import { Suspense, useEffect, useTransition } from "react";
import Loading from "@/app/-loading";
import { CheckForUpdateMessage } from "@/components/CheckForUpdateMessage";
import {
BackupFormatSelect,
BackupPathWarnings,
FilePathRow,
GuiAnimationSwitch,
GuiCompactSwitch,
LanguageSelector,
ProjectPathWarnings,
ThemeSelector,
} from "@/components/common-setting-parts";
import { HNavBar, HNavBarText, VStack } from "@/components/layout";
import { ScrollableCardTable } from "@/components/ScrollableCardTable";
import { ScrollPageContainer } from "@/components/ScrollPageContainer";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { DialogFooter, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
UnityArgumentsSettings,
useUnityArgumentsSettings,
} from "@/components/unity-arguments-settings";
import { assertNever } from "@/lib/assert-never";
import type { OpenOptions, UnityHubAccessMethod } from "@/lib/bindings";
import { commands } from "@/lib/bindings";
import { type DialogContext, openSingleDialog } from "@/lib/dialog";
import globalInfo, { useGlobalInfo } from "@/lib/global-info";
import { tc, tt } from "@/lib/i18n";
import {
toastError,
toastNormal,
toastSuccess,
toastThrownError,
} from "@/lib/toast";
import { useEffectEvent } from "@/lib/use-effect-event";
import { cn } from "@/lib/utils";
export const Route = createFileRoute("/_main/settings/")({
component: Page,
});
const environmentGetSettings = queryOptions({
queryKey: ["environmentGetSettings"],
queryFn: commands.environmentGetSettings,
});
function Page() {
return (
<VStack>
<HNavBar
className="shrink-0"
leading={<HNavBarText>{tc("settings")}</HNavBarText>}
/>
<Suspense
fallback={
<Card className={"p-4"}>
<Loading loadingText={tc("general:loading...")} />
</Card>
}
>
<Settings />
</Suspense>
</VStack>
);
}
function Settings() {
const [updatingUnityPaths, updateUnityPathsTransition] = useTransition();
const queryClient = useQueryClient();
const updateUnityPaths = async () => {
updateUnityPathsTransition(async () => {
await commands.environmentUpdateUnityPathsFromUnityHub();
await queryClient.invalidateQueries(environmentGetSettings);
});
};
// at the time settings page is opened, unity hub path update might be in progress so we wait for it
const waitForHubUpdate = useEffectEvent(async () => {
updateUnityPathsTransition(async () => {
await commands.environmentWaitForUnityHubUpdate();
await queryClient.invalidateQueries(environmentGetSettings);
});
});
useEffect(() => void waitForHubUpdate(), []);
return (
<ScrollPageContainer viewportClassName={"rounded-xl shadow-xl h-full"}>
<main className="flex flex-col gap-2 shrink grow">
<UnityHubPathCard updateUnityPaths={updateUnityPaths} />
<UnityInstallationsCard
updatingUnityPaths={updatingUnityPaths}
updateUnityPaths={updateUnityPaths}
/>
<UnityLaunchArgumentsCard />
<DefaultProjectPathCard />
<BackupCard />
<PackagesCard />
<AppearanceCard />
<FilesAndFoldersCard />
<AlcomCard />
<SystemInformationCard />
</main>
</ScrollPageContainer>
);
}
function SettingsCard({
className,
children,
...props
}: React.ComponentProps<typeof Card>) {
return (
<Card className={cn("shrink-0 p-4 compact:p-3", className)} {...props}>
{children}
</Card>
);
}
function UnityHubPathCard({
updateUnityPaths,
}: {
updateUnityPaths: () => Promise<void>;
}) {
const queryClient = useQueryClient();
const {
data: { unityHub },
} = useSuspenseQuery({
...environmentGetSettings,
select: (data) => ({
unityHub: data.unity_hub,
}),
});
const pickUnityHub = useMutation({
mutationFn: async () => await commands.environmentPickUnityHub(),
onError: (e) => {
console.error(e);
toastThrownError(e);
},
onSuccess: (result) => {
switch (result.type) {
case "NoFolderSelected":
// no-op
break;
case "InvalidSelection":
toastError(tc("general:toast:invalid directory"));
break;
case "Successful":
toastSuccess(tc("settings:toast:unity hub path updated"));
break;
default:
assertNever(result);
}
},
onSettled: async () => {
await queryClient.invalidateQueries(environmentGetSettings);
await updateUnityPaths();
},
});
return (
<SettingsCard>
<h2 className={"pb-2"}>{tc("settings:unity hub path")}</h2>
<FilePathRow
path={unityHub}
pick={pickUnityHub.mutate}
notFoundMessage={"Unity Hub Not Found"}
withOpen={false}
/>
</SettingsCard>
);
}
function UnityInstallationsCard({
updatingUnityPaths,
updateUnityPaths,
}: {
updatingUnityPaths: boolean;
updateUnityPaths: () => void;
}) {
const queryClient = useQueryClient();
const {
data: { unityPaths, unityHubAccessMethod },
} = useSuspenseQuery({
...environmentGetSettings,
select: (data) => ({
unityPaths: data.unity_paths,
unityHubAccessMethod: data.unity_hub_access_method,
}),
});
const addUnity = useMutation({
mutationFn: async () => await commands.environmentPickUnity(),
onError: (e) => {
console.error(e);
toastThrownError(e);
},
onSuccess: (result) => {
switch (result) {
case "NoFolderSelected":
// no-op
break;
case "InvalidSelection":
toastError(tt("settings:toast:not unity"));
break;
case "AlreadyAdded":
toastError(tt("settings:toast:unity already added"));
break;
case "Successful":
toastSuccess(tt("settings:toast:unity added"));
break;
default:
assertNever(result);
}
},
onSettled: async () => {
await queryClient.invalidateQueries(environmentGetSettings);
},
});
const setAccessMethod = useMutation({
mutationFn: async (method: UnityHubAccessMethod) =>
await commands.environmentSetUnityHubAccessMethod(method),
onMutate: async (method) => {
await queryClient.cancelQueries(environmentGetSettings);
const current = queryClient.getQueryData(environmentGetSettings.queryKey);
if (current != null) {
queryClient.setQueryData(environmentGetSettings.queryKey, {
...current,
unity_hub_access_method: method,
});
}
return current;
},
onError: (e, _, prev) => {
console.error(e);
toastThrownError(e);
queryClient.setQueryData(environmentGetSettings.queryKey, prev);
},
onSettled: async () => {
await queryClient.invalidateQueries(environmentGetSettings);
},
});
const UNITY_TABLE_HEAD = [
"settings:unity:version",
"settings:unity:path",
"general:source",
];
return (
<SettingsCard className={"flex flex-col gap-2"}>
<div className={"flex align-middle"}>
<div className={"grow flex items-center"}>
<h2>{tc("settings:unity installations")}</h2>
</div>
{updatingUnityPaths && (
<div className={"flex items-center m-1"}>
<Tooltip>
<TooltipTrigger>
<RefreshCw className="w-5 h-5 animate-spin" />
</TooltipTrigger>
<TooltipContent>
{tc("settings:tooltip:reload unity from unity hub")}
</TooltipContent>
</Tooltip>
</div>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
disabled={updatingUnityPaths}
onClick={updateUnityPaths}
size={"sm"}
className={"m-1"}
>
{tc("settings:button:reload unity from unity hub")}
</Button>
</TooltipTrigger>
<TooltipContent>
{tc("settings:tooltip:reload unity from unity hub")}
</TooltipContent>
</Tooltip>
<Button
disabled={updatingUnityPaths}
onClick={() => addUnity.mutate()}
size={"sm"}
className={"m-1"}
>
{tc("settings:button:add unity")}
</Button>
</div>
<ScrollableCardTable
className={`w-full min-h-[20vh] ${updatingUnityPaths ? "opacity-50" : ""}`}
>
<thead>
<tr>
{UNITY_TABLE_HEAD.map((head, index) => (
<th
// biome-ignore lint/suspicious/noArrayIndexKey: static array
key={index}
className={
"sticky top-0 z-10 border-b border-primary bg-secondary text-secondary-foreground p-2.5"
}
>
<small className="font-normal leading-none">{tc(head)}</small>
</th>
))}
</tr>
</thead>
<tbody>
{unityPaths.map(([path, version, isFromHub]) => (
<tr key={path} className="even:bg-secondary/30">
<td className={"p-2.5"}>{version}</td>
<td className={"p-2.5"}>{path}</td>
<td className={"p-2.5"}>
{isFromHub
? tc("settings:unity:source:unity hub")
: tc("settings:unity:source:manual")}
</td>
</tr>
))}
</tbody>
</ScrollableCardTable>
<div>
<label className={"flex items-center gap-2"}>
<Checkbox
checked={unityHubAccessMethod === "CallHub"}
onCheckedChange={(e) =>
setAccessMethod.mutate(e === true ? "CallHub" : "ReadConfig")
}
/>
{tc("settings:use legacy unity hub loading")}
</label>
<p className={"text-sm whitespace-normal"}>
{tc("settings:use legacy unity hub loading description")}
</p>
</div>
</SettingsCard>
);
}
function UnityLaunchArgumentsCard() {
const { data: unityArgs } = useSuspenseQuery({
...environmentGetSettings,
select: (d) => d.default_unity_arguments,
});
const defaultUnityArgs = useGlobalInfo().defaultUnityArguments;
const realUnityArgs = unityArgs ?? defaultUnityArgs;
return (
<SettingsCard>
<div className={"mb-2 flex align-middle"}>
<div className={"grow flex items-center"}>
<h2>{tc("settings:default unity arguments")}</h2>
</div>
<Button
onClick={async () => {
try {
await openSingleDialog(LaunchArgumentsEditDialogBody, {
unityArgs,
});
} catch (e) {
console.log(e);
toastThrownError(e);
}
}}
size={"sm"}
className={"m-1"}
>
{tc("general:button:edit")}
</Button>
</div>
<p className={"whitespace-normal"}>
{tc("settings:default unity arguments description")}
</p>
<ol className={"flex flex-col"}>
{realUnityArgs.map((v, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: unity args are ordered list
<Input disabled key={i + v} value={v} className={"w-full"} />
))}
</ol>
</SettingsCard>
);
}
function LaunchArgumentsEditDialogBody({
unityArgs,
dialog,
}: {
unityArgs: string[] | null;
dialog: DialogContext<boolean>;
}) {
const queryClient = useQueryClient();
const setDefaultArgs = useMutation({
mutationFn: async ({ value }: { value: string[] | null }) => {
return await commands.environmentSetDefaultUnityArguments(value);
},
onMutate: async ({ value }) => {
await queryClient.cancelQueries(environmentGetSettings);
const current = queryClient.getQueryData(environmentGetSettings.queryKey);
if (current != null) {
queryClient.setQueryData(environmentGetSettings.queryKey, {
...current,
default_unity_arguments: value,
});
}
return current;
},
onError: (e, _, prev) => {
dialog.error(e);
queryClient.setQueryData(environmentGetSettings.queryKey, prev);
},
onSuccess: () => {
dialog.close(true);
},
onSettled: async () => {
await queryClient.invalidateQueries(environmentGetSettings);
},
});
const context = useUnityArgumentsSettings(
unityArgs,
globalInfo.defaultUnityArguments,
);
return (
<>
<DialogTitle>
{tc("settings:dialog:default launch arguments")}
</DialogTitle>
{/* TODO: use ScrollArea (I failed to use it inside dialog) */}
<div className={"max-h-[50dvh] overflow-y-auto"}>
<UnityArgumentsSettings context={context} />
</div>
<DialogFooter>
<Button onClick={() => dialog.close(false)} variant={"destructive"}>
{tc("general:button:cancel")}
</Button>
<Button
onClick={() =>
void setDefaultArgs.mutate({ value: context.currentValue })
}
disabled={context.hasError}
>
{tc("general:button:save")}
</Button>
</DialogFooter>
</>
);
}
function DefaultProjectPathCard() {
const queryClient = useQueryClient();
const {
data: { defaultProjectPath },
} = useSuspenseQuery({
...environmentGetSettings,
select: (data) => ({
defaultProjectPath: data.default_project_path,
}),
});
const pickProjectDefaultPath = useMutation({
mutationFn: async () => await commands.environmentPickProjectDefaultPath(),
onError: (e) => {
console.error(e);
toastThrownError(e);
},
onSuccess: (result) => {
switch (result.type) {
case "NoFolderSelected":
// no-op
break;
case "InvalidSelection":
toastError(tc("general:toast:invalid directory"));
break;
case "Successful":
toastSuccess(tc("settings:toast:default project path updated"));
break;
default:
assertNever(result);
}
},
onSettled: async () => {
await queryClient.invalidateQueries(environmentGetSettings);
},
});
return (
<SettingsCard>
<h2 className={"mb-2"}>{tc("settings:default project path")}</h2>
<p className={"whitespace-normal"}>
{tc("settings:default project path description")}
</p>
<FilePathRow
path={defaultProjectPath}
pick={pickProjectDefaultPath.mutate}
/>
<ProjectPathWarnings projectPath={defaultProjectPath} />
</SettingsCard>
);
}
function BackupCard() {
const queryClient = useQueryClient();
const {
data: { projectBackupPath, backupFormat, excludeVpmPackagesFromBackup },
} = useSuspenseQuery({
...environmentGetSettings,
select: (data) => ({
projectBackupPath: data.project_backup_path,
backupFormat: data.backup_format,
excludeVpmPackagesFromBackup: data.exclude_vpm_packages_from_backup,
}),
});
const pickProjectBackupPath = useMutation({
mutationFn: async () => await commands.environmentPickProjectBackupPath(),
onError: (e) => {
console.error(e);
toastThrownError(e);
},
onSuccess: (result) => {
switch (result.type) {
case "NoFolderSelected":
// no-op
break;
case "InvalidSelection":
toastError(tc("general:toast:invalid directory"));
break;
case "Successful":
toastSuccess(tc("settings:toast:backup path updated"));
break;
default:
assertNever(result);
}
},
onSettled: async () => {
await queryClient.invalidateQueries(environmentGetSettings);
},
});
const setExcludeVpmPackagesFromBackup = useMutation({
mutationFn: async (flag: boolean) =>
await commands.environmentSetExcludeVpmPackagesFromBackup(flag),
onError: (e) => {
console.error(e);
toastThrownError(e);
},
onSuccess: (_, flag) => {
queryClient.setQueryData(environmentGetSettings.queryKey, (old) => {
if (old == null) return old;
return { ...old, excludeVpmPackagesFromBackup: flag };
});
},
onSettled: async () => {
await queryClient.invalidateQueries(environmentGetSettings);
},
});
return (
<SettingsCard>
<h2>{tc("projects:backup")}</h2>
<div className="mt-2">
<h3>{tc("settings:backup:path")}</h3>
<p className={"whitespace-normal text-sm"}>
{tc("settings:backup:path description")}
</p>
<FilePathRow
path={projectBackupPath}
pick={pickProjectBackupPath.mutate}
/>
<BackupPathWarnings backupPath={projectBackupPath} />
</div>
<div className="mt-2">
<h3>{tc("settings:backup:format")}</h3>
<p className={"whitespace-normal text-sm"}>
{tc("settings:backup:format description")}
</p>
<label className={"flex items-center"}>
<BackupFormatSelect backupFormat={backupFormat} />
</label>
</div>
<div className="mt-2">
<label className={"flex items-center gap-2"}>
<Checkbox
checked={excludeVpmPackagesFromBackup}
onCheckedChange={(e) =>
setExcludeVpmPackagesFromBackup.mutate(e === true)
}
/>
{tc("settings:backup:exclude vpm packages from backup")}
</label>
<p className={"text-sm whitespace-normal"}>
{tc("settings:backup:exclude vpm packages from backup description")}
</p>
</div>
</SettingsCard>
);
}
function PackagesCard() {
const queryClient = useQueryClient();
const {
data: { showPrereleasePackages },
} = useSuspenseQuery({
...environmentGetSettings,
select: (data) => ({
showPrereleasePackages: data.show_prerelease_packages,
}),
});
const clearPackageCache = useMutation({
mutationFn: async () => await commands.environmentClearPackageCache(),
onError: (e) => {
console.error(e);
toastThrownError(e);
},
onSuccess: async () => {
toastSuccess(tc("settings:toast:package cache cleared"));
},
onSettled: async () => {
await queryClient.invalidateQueries({
queryKey: ["environmentPackages"],
});
},
});
const setShowPrerelease = useMutation({
mutationFn: async (showPrerelease: boolean) =>
await commands.environmentSetShowPrereleasePackages(showPrerelease),
onMutate: async (showPrerelease) => {
await queryClient.cancelQueries(environmentGetSettings);
const current = queryClient.getQueryData(environmentGetSettings.queryKey);
if (current != null) {
queryClient.setQueryData(environmentGetSettings.queryKey, {
...current,
show_prerelease_packages: showPrerelease,
});
}
return current;
},
onError: (e, _, prev) => {
console.error(e);
toastThrownError(e);
queryClient.setQueryData(environmentGetSettings.queryKey, prev);
},
onSettled: async () => {
await queryClient.invalidateQueries(environmentGetSettings);
},
});
return (
<SettingsCard className={"flex flex-col gap-4"}>
<h2>{tc("settings:packages")}</h2>
<div className={"flex flex-row flex-wrap gap-2"}>
<Button onClick={() => clearPackageCache.mutate()}>
{tc("settings:clear package cache")}
</Button>
</div>
<div>
<label className={"flex items-center gap-2"}>
<Checkbox
checked={showPrereleasePackages}
onCheckedChange={(e) => setShowPrerelease.mutate(e === true)}
/>
{tc("settings:show prerelease")}
</label>
<p className={"text-sm whitespace-normal"}>
{tc("settings:show prerelease description")}
</p>
</div>
</SettingsCard>
);
}
function AppearanceCard() {
return (
<SettingsCard className={"flex flex-col gap-2"}>
<h2>{tc("settings:appearance")}</h2>
<LanguageSelector />
<ThemeSelector />
<GuiAnimationSwitch />
<GuiCompactSwitch />
</SettingsCard>
);
}
function FilesAndFoldersCard() {
const openVpmFolderContent = (
subPath: string,
ifNotExists: OpenOptions = "ErrorIfNotExists",
) => {
return async () => {
try {
await commands.utilOpen(
`${globalInfo.vpmHomeFolder}/${subPath}`,
ifNotExists,
);
} catch (e) {
console.error(e);
toastThrownError(e);
}
};
};
return (
<SettingsCard>
<h2>{tc("settings:files and directories")}</h2>
<p className={"mt-2"}>
{tc("settings:files and directories:description")}
</p>
<div className={"flex flex-row flex-wrap gap-2"}>
<Button
className={"normal-case"}
onClick={openVpmFolderContent("settings.json")}
>
{tc("settings:button:open settings.json")}
</Button>
<Button
className={"normal-case"}
onClick={openVpmFolderContent("vrc-get/gui-config.json")}
>
{tc("settings:button:open gui config.json")}
</Button>
<Button onClick={openVpmFolderContent("vrc-get/gui-logs")}>
{tc("settings:button:open logs")}
</Button>
<Button
onClick={openVpmFolderContent("Templates", "CreateFolderIfNotExists")}
>
{tc("settings:button:open vcc templates")}
</Button>
</div>
</SettingsCard>
);
}
function AlcomCard() {
const globalInfo = useGlobalInfo();
const queryClient = useQueryClient();
const {
data: { releaseChannel, useAlcomForVccProtocol },
} = useSuspenseQuery({
...environmentGetSettings,
select: (data) => ({
releaseChannel: data.release_channel,
useAlcomForVccProtocol: data.use_alcom_for_vcc_protocol,
}),
});
const setShowPrerelease = useMutation({
mutationFn: async (releaseChannel: string) =>
await commands.environmentSetReleaseChannel(releaseChannel),
onMutate: async (releaseChannel) => {
await queryClient.cancelQueries(environmentGetSettings);
const current = queryClient.getQueryData(environmentGetSettings.queryKey);
if (current != null) {
queryClient.setQueryData(environmentGetSettings.queryKey, {
...current,
release_channel: releaseChannel,
});
}
return current;
},
onError: (e, _, prev) => {
console.error(e);
toastThrownError(e);
queryClient.setQueryData(environmentGetSettings.queryKey, prev);
},
onSettled: async () => {
await queryClient.invalidateQueries(environmentGetSettings);
},
});
const setUseAlcomForVccProtocol = useMutation({
mutationFn: async (use: boolean) =>
await commands.environmentSetUseAlcomForVccProtocol(use),
onMutate: async (use) => {
await queryClient.cancelQueries(environmentGetSettings);
const current = queryClient.getQueryData(environmentGetSettings.queryKey);
if (current != null) {
queryClient.setQueryData(environmentGetSettings.queryKey, {
...current,
use_alcom_for_vcc_protocol: use,
});
}
return current;
},
onError: (e, _, prev) => {
console.error(e);
toastThrownError(e);
queryClient.setQueryData(environmentGetSettings.queryKey, prev);
},
onSettled: async () => {
await queryClient.invalidateQueries(environmentGetSettings);
},
});
const installVccProtocol = useMutation({
mutationFn: async () => await commands.deepLinkInstallVcc(),
onSuccess: () => {
toastSuccess(tc("settings:toast:vcc scheme installed"));
},
onError: (e) => {
console.error(e);
toastThrownError(e);
},
});
const checkForUpdate = async () => {
try {
const checkVersion = await commands.utilCheckForUpdate();
if (checkVersion) {
await openSingleDialog(CheckForUpdateMessage, {
response: checkVersion,
});
} else {
toastNormal(tc("check update:toast:no updates"));
}
} catch (e) {
toastThrownError(e);
console.error(e);
}
};
const reportIssue = async () => {
const url = new URL("https://github.com/vrc-get/vrc-get/issues/new");
url.searchParams.append("labels", "bug,vrc-get-gui");
url.searchParams.append("template", "01_gui_bug-report.yml");
url.searchParams.append("os", `${globalInfo.osInfo} - ${globalInfo.arch}`);
url.searchParams.append("webview-version", `${globalInfo.webviewVersion}`);
let version = globalInfo.version ?? "unknown";
if (globalInfo.commitHash) {
version += ` (${globalInfo.commitHash})`;
} else {
version += " (unknown commit)";
}
url.searchParams.append("version", version);
void commands.utilOpenUrl(url.toString());
};
return (
<SettingsCard className={"flex flex-col gap-4"}>
<h2>ALCOM</h2>
<div className={"flex flex-row flex-wrap gap-2"}>
{globalInfo.checkForUpdates && (
<Button onClick={checkForUpdate}>
{tc("settings:check update")}
</Button>
)}
<Button onClick={reportIssue}>
{tc("settings:button:open issue")}
</Button>
</div>
{globalInfo.checkForUpdates && (
<div>
<label className={"flex items-center gap-2"}>
<Checkbox
checked={releaseChannel === "beta"}
onCheckedChange={(value) =>
setShowPrerelease.mutate(value === true ? "beta" : "stable")
}
/>
{tc("settings:receive beta updates")}
</label>
<p className={"text-sm whitespace-normal"}>
{tc("settings:beta updates description")}
</p>
</div>
)}
{globalInfo.shouldInstallDeepLink && (
<div>
<label className={"flex items-center gap-2"}>
<Checkbox
checked={useAlcomForVccProtocol}
onCheckedChange={(value) =>
setUseAlcomForVccProtocol.mutate(value === true)
}
/>
{tc("settings:use alcom for vcc scheme")}
</label>
<Button
className={"my-1"}
disabled={!useAlcomForVccProtocol}
onClick={() => installVccProtocol.mutate()}
>
{tc("settings:register vcc scheme now")}
</Button>
<p className={"text-sm whitespace-normal"}>
{tc([
"settings:use vcc scheme description",
"settings:vcc scheme description",
])}
</p>
</div>
)}
<p className={"whitespace-normal"}>
{tc(
"settings:licenses description",
{},
{
components: {
l: <Link to={"/settings/licenses"} className={"underline"} />,
},
},
)}
</p>
</SettingsCard>
);
}
function SystemInformationCard() {
const info = useGlobalInfo();
return (
<SettingsCard className={"flex flex-col gap-4"}>
<h2>{tc("settings:system information")}</h2>
<dl>
<dt>{tc("settings:os")}</dt>
<dd className={"opacity-50 mb-2"}>{info.osInfo}</dd>
<dt>{tc("settings:architecture")}</dt>
<dd className={"opacity-50 mb-2"}>{info.arch}</dd>
<dt>{tc("settings:webview version")}</dt>
<dd className={"opacity-50 mb-2"}>{info.webviewVersion}</dd>
<dt>{tc("settings:alcom version")}</dt>
<dd className={"opacity-50 mb-2"}>{info.version}</dd>
<dt>{tc("settings:alcom commit hash")}</dt>
<dd className={"opacity-50 mb-2"}>{info.commitHash}</dd>
</dl>
</SettingsCard>
);
}

View file

@ -1,50 +0,0 @@
import licenses from "build:licenses.json";
import { createFileRoute } from "@tanstack/react-router";
import { VStack } from "@/components/layout";
import { ScrollableCard } from "@/components/ScrollableCard";
import { ScrollPageContainer } from "@/components/ScrollPageContainer";
import { Card } from "@/components/ui/card";
import { commands } from "@/lib/bindings";
export const Route = createFileRoute("/_main/settings/licenses/")({
component: Page,
});
function Page() {
return (
<ScrollPageContainer>
<VStack>
<Card className={"p-4"}>
<p>
This project is built on top of many open-source projects.
<br />
Here are the licenses of the projects used in this project:
</p>
<ul />
</Card>
{licenses.map((license) => (
<Card className={"p-4"} key={license.text}>
<h3>{license.name}</h3>
<h4>Used by:</h4>
<ul className={"ml-2"}>
{license.packages.map((pkg) => (
<li key={`${pkg.name}@${pkg.version}`}>
<button
type="button"
onClick={() => commands.utilOpenUrl(pkg.url)}
>
{pkg.name} ({pkg.version})
</button>
</li>
))}
</ul>
<ScrollableCard className="max-h-52">
<pre className={"whitespace-pre-wrap"}>{license.text}</pre>
</ScrollableCard>
</Card>
))}
</VStack>
</ScrollPageContainer>
);
}

View file

@ -1,44 +0,0 @@
"use client";
import {
createFileRoute,
Outlet,
useNavigate,
useRouter,
} from "@tanstack/react-router";
export const Route = createFileRoute("/_setup")({
component: SetupLayout,
});
function SetupLayout() {
const isDev = import.meta.env.DEV;
return (
<>
<div className={"h-screen grow overflow-hidden flex p-4"}>
<Outlet />
</div>
{isDev && <DevTools />}
</>
);
}
function DevTools() {
const router = useRouter();
const navigate = useNavigate();
return (
<div className={"absolute bottom-0 left-0 p-4 flex flex-col gap-3"}>
<p>debug tools</p>
<div className={"flex gap-3"}>
<button type="button" onClick={() => router.history.back()}>
Go Back
</button>
<button type="button" onClick={() => navigate({ to: "/settings" })}>
Go Settings
</button>
</div>
</div>
);
}

View file

@ -1,92 +0,0 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import {
BackupFormatSelect,
BackupPathWarnings,
FilePathRow,
} from "@/components/common-setting-parts";
import { CardDescription } from "@/components/ui/card";
import { assertNever } from "@/lib/assert-never";
import { commands } from "@/lib/bindings";
import { useGlobalInfo } from "@/lib/global-info";
import { tc } from "@/lib/i18n";
import { toastError, toastSuccess, toastThrownError } from "@/lib/toast";
import { type BodyProps, SetupPageBase } from "../-setup-page-base";
export const Route = createFileRoute("/_setup/setup/backups/")({
component: Page,
});
function Page() {
const shouldInstallDeepLink = useGlobalInfo().shouldInstallDeepLink;
return (
<SetupPageBase
heading={tc("setup:backups:heading")}
Body={Body}
nextPage={
shouldInstallDeepLink ? "/setup/system-setting" : "/setup/finish"
}
prevPage={"/setup/project-path"}
pageId={"Backups"}
/>
);
}
function Body({ environment }: BodyProps) {
const projectBackupPath = environment.project_backup_path;
const backupFormat = environment.backup_format;
const queryClient = useQueryClient();
const pickProjectBackupPath = useMutation({
mutationFn: async () => await commands.environmentPickProjectBackupPath(),
onError: (e) => {
console.error(e);
toastThrownError(e);
},
onSuccess: (result) => {
switch (result.type) {
case "NoFolderSelected":
// no-op
break;
case "InvalidSelection":
toastError(tc("general:toast:invalid directory"));
break;
case "Successful":
toastSuccess(tc("settings:toast:backup path updated"));
break;
default:
assertNever(result);
}
},
onSettled: async () => {
await queryClient.invalidateQueries({
queryKey: ["environmentGetSettings"],
});
},
});
return (
<>
<h3>{tc("setup:backups:location")}</h3>
<CardDescription className={"whitespace-normal"}>
{tc("setup:backups:location description")}
</CardDescription>
<FilePathRow
path={projectBackupPath}
pick={pickProjectBackupPath.mutate}
withOpen={false}
/>
<BackupPathWarnings backupPath={projectBackupPath} />
<div className={"pb-3"} />
<h3>{tc("setup:backups:archive")}</h3>
<CardDescription className={"whitespace-normal"}>
{tc("settings:backup:format description")}
</CardDescription>
<BackupFormatSelect backupFormat={backupFormat} />
</>
);
}

View file

@ -1,76 +0,0 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import {
FilePathRow,
ProjectPathWarnings,
} from "@/components/common-setting-parts";
import { CardDescription } from "@/components/ui/card";
import { assertNever } from "@/lib/assert-never";
import { commands } from "@/lib/bindings";
import { tc } from "@/lib/i18n";
import { toastError, toastSuccess, toastThrownError } from "@/lib/toast";
import { type BodyProps, SetupPageBase } from "../-setup-page-base";
export const Route = createFileRoute("/_setup/setup/project-path/")({
component: Page,
});
function Page() {
return (
<SetupPageBase
heading={tc("setup:project-path:heading")}
Body={Body}
nextPage={"/setup/backups"}
prevPage={"/setup/unity-hub"}
pageId={"ProjectPath"}
/>
);
}
function Body({ environment }: BodyProps) {
const queryClient = useQueryClient();
const pickProjectDefaultPath = useMutation({
mutationFn: async () => await commands.environmentPickProjectDefaultPath(),
onError: (e) => {
console.error(e);
toastThrownError(e);
},
onSuccess: (result) => {
switch (result.type) {
case "NoFolderSelected":
// no-op
break;
case "InvalidSelection":
toastError(tc("general:toast:invalid directory"));
break;
case "Successful":
toastSuccess(tc("settings:toast:default project path updated"));
break;
default:
assertNever(result);
}
},
onSettled: async () => {
await queryClient.invalidateQueries({
queryKey: ["environmentGetSettings"],
});
},
});
return (
<>
<CardDescription className={"whitespace-normal"}>
{tc("setup:project-path:description")}
</CardDescription>
<FilePathRow
path={environment.default_project_path}
pick={pickProjectDefaultPath.mutate}
withOpen={false}
/>
<ProjectPathWarnings projectPath={environment.default_project_path} />
</>
);
}

View file

@ -1,107 +0,0 @@
"use client";
import {
queryOptions,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { Checkbox } from "@/components/ui/checkbox";
import { commands } from "@/lib/bindings";
import { useGlobalInfo } from "@/lib/global-info";
import { tc } from "@/lib/i18n";
import { toastThrownError } from "@/lib/toast";
import { type BodyProps, SetupPageBase } from "../-setup-page-base";
export const Route = createFileRoute("/_setup/setup/system-setting/")({
component: Page,
});
function Page() {
return (
<SetupPageBase
heading={tc("setup:system-setting:heading")}
Body={Body}
nextPage={"/setup/finish"}
prevPage={"/setup/backups"}
pageId={"SystemSetting"}
/>
);
}
const environmentGetSettings = queryOptions({
queryKey: ["environmentGetSettings"],
queryFn: commands.environmentGetSettings,
});
function Body({ environment }: BodyProps) {
const useAlcomForVccProtocol = environment.use_alcom_for_vcc_protocol;
const isBadHostName = useQuery({
queryKey: ["util_is_bad_hostname"],
queryFn: commands.utilIsBadHostname,
initialData: false,
});
const queryClient = useQueryClient();
const setUseAlcomForVccProtocol = useMutation({
mutationFn: async (use: boolean) =>
await commands.environmentSetUseAlcomForVccProtocol(use),
onMutate: async (use) => {
await queryClient.cancelQueries(environmentGetSettings);
const current = queryClient.getQueryData(environmentGetSettings.queryKey);
if (current != null) {
queryClient.setQueryData(environmentGetSettings.queryKey, {
...current,
use_alcom_for_vcc_protocol: use,
});
}
return current;
},
onError: (e, _, prev) => {
console.error(e);
toastThrownError(e);
queryClient.setQueryData(environmentGetSettings.queryKey, prev);
},
onSettled: async () => {
await queryClient.invalidateQueries(environmentGetSettings);
},
});
const isMac = useGlobalInfo().osType === "Darwin";
return (
<>
{!isMac ? (
<div>
<label className={"flex items-center gap-2"}>
<Checkbox
checked={useAlcomForVccProtocol}
onCheckedChange={(e) =>
setUseAlcomForVccProtocol.mutate(e === true)
}
/>
{tc("settings:use alcom for vcc scheme")}
</label>
<p className={"text-sm whitespace-normal text-muted-foreground"}>
{tc("setup:system-setting:vcc scheme description")}
</p>
</div>
) : (
<div>
<p className={"text-sm whitespace-normal text-muted-foreground"}>
{tc("setup:system-setting:macos bug message")}
</p>
</div>
)}
{isBadHostName.data && (
<div className={"mt-3"}>
<p className={"text-sm whitespace-normal text-warning"}>
{tc("setup:system-setting:hostname-with-non-ascii")}
</p>
</div>
)}
</>
);
}

View file

@ -1,25 +1,20 @@
"use client"; // Error components must be Client Components
import { useEffect } from "react";
import { commands } from "@/lib/bindings";
import globalInfo from "@/lib/global-info";
import { useEffect } from "react";
export default function ErrorPage({
error,
}: {
error: object;
reset?: () => void;
error: Error;
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
// When there is overridden toString, use it. if not, use stringify
const errorMessage =
error.toString === Object.prototype.toString
? JSON.stringify(error)
: error.toString();
const errorStack =
"stack" in error ? `${error.stack}` : "No stacktrace provided";
const errorMessage = `${error}`;
const errorStack = `${error.stack}`;
const openIssue = () => {
try {
@ -53,10 +48,10 @@ export default function ErrorPage({
<div className={"w-full flex items-center justify-center"}>
<div
className={
"rounded-xl border bg-card text-card-foreground shadow-xs min-w-[50vw] max-w-[100vw] p-4 flex gap-3"
"rounded-xl border bg-card text-card-foreground shadow-sm min-w-[50vw] max-w-[100vw] p-4 flex gap-3"
}
>
<div className={"flex flex-col grow overflow-hidden"}>
<div className={"flex flex-col flex-grow overflow-hidden"}>
<h2>Client-side unrecoverable error occurred</h2>
<p>This must be a bug! Please report this bug!</p>
<div>

View file

@ -1,166 +1,48 @@
@import "tailwindcss";
@import "tw-animate-css";
@tailwind base;
@tailwind components;
@tailwind utilities;
@custom-variant dark (&:is(.dark *));
@custom-variant compact (&:is([compact] *));
@theme inline {
--background-image-gradient-radial: radial-gradient(var(--tw-gradient-stops));
--background-image-gradient-conic: conic-gradient(
from 180deg at 50% 50%,
var(--tw-gradient-stops)
);
--font-sans: system-ui;
--font-path: system-ui;
--font-mono: consolas, monospace;
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-info: var(--info);
--color-info-foreground: var(--info-foreground);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out;
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
@layer base {
:root {
--background: 0 0% 100%;
--background-start: 190 7.89% 85.1%;
--background-end: 0, 0%, 100%;
--foreground: 240 10% 20%;
--card: 0 0% 100%;
--card-foreground: 240 10% 35%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 20%;
--primary: 240 5.9% 20%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 30%;
--info: 207 90% 54%;
--info-foreground: 210 40% 98%;
--success: 122 39% 49%;
--success-foreground: 210 40% 98%;
--warning: 52.15, 100%, 46.47%;
--warning-foreground: 210 40% 98%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--radius: 0.75rem;
}
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
}
@layer utilities {
.anchor-none {
overflow-anchor: none;
}
}
:root {
--background: hsl(0 0% 100%);
--background-start: hsl(190 7.89% 85.1%);
--background-end: hsl(0, 0%, 100%);
--foreground: hsl(240 10% 20%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(240 10% 35%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(240 10% 20%);
--primary: hsl(240 5.9% 20%);
--primary-foreground: hsl(0 0% 98%);
--secondary: hsl(240 4.8% 95.9%);
--secondary-foreground: hsl(240 5.9% 10%);
--muted: hsl(240 4.8% 95.9%);
--muted-foreground: oklch(0.556 0 0);
--accent: hsl(240 4.8% 95.9%);
--accent-foreground: hsl(240 5.9% 30%);
--info: hsl(207 90% 54%);
--info-foreground: hsl(210 40% 98%);
--success: hsl(122 39% 49%);
--success-foreground: hsl(210 40% 98%);
--warning: hsl(52.15, 100%, 46.47%);
--warning-foreground: hsl(240 10% 13%);
--destructive: hsl(0 84.2% 60.2%);
--destructive-foreground: hsl(0 0% 98%);
--border: hsl(240 5.9% 90%);
--input: hsl(240 5.9% 90%);
--ring: hsl(240 5.9% 10%);
--radius: 0.75rem;
}
.dark {
--bg-color: hsl(240 10% 13%);
--fg-color: hsl(240 10% 85%);
--secondary-bg: hsl(240 3.7% 19%);
--primary-fg: hsl(240 5.9% 15%);
--background: var(--bg-color);
--background-start: hsl(0 0% 3%);
--background-end: hsl(10 8% 15%);
--foreground: var(--fg-color);
--card: var(--bg-color);
--card-foreground: var(--fg-color);
--popover: var(--bg-color);
--popover-foreground: var(--fg-color);
--primary: var(--fg-color);
--primary-foreground: var(--primary-fg);
--secondary: var(--secondary-bg);
--secondary-foreground: var(--fg-color);
--muted: var(--secondary-bg);
--muted-foreground: oklch(0.708 0 0);
--accent: var(--secondary-bg);
--accent-foreground: var(--fg-color);
--info: hsl(207 90% 54%);
--info-foreground: hsl(210 40% 90%);
--success: hsl(122 39% 49%);
--success-foreground: hsl(210 40% 90%);
--warning: hsl(52.15, 100%, 46.47%);
--warning-foreground: hsl(240 10% 13%);
--destructive: hsl(0 84.2% 60.2%);
--destructive-foreground: var(--fg-color);
--border: var(--secondary-bg);
--input: var(--secondary-bg);
--ring: hsl(240 4.9% 83.9%);
}
@media (prefers-color-scheme: dark) {
.system {
--bg-color: hsl(240 10% 13%);
--fg-color: hsl(240 10% 85%);
--secondary-bg: hsl(240 3.7% 19%);
--primary-fg: hsl(240 5.9% 15%);
.dark {
--bg-color: 240 10% 13%;
--fg-color: 240 10% 85%;
--secondary-bg: 240 3.7% 19%;
--primary-fg: 240 5.9% 15%;
--background: var(--bg-color);
--background-start: hsl(0 0% 3%);
--background-end: hsl(10 8% 15%);
--background-start: 0 0% 3%;
--background-end: 10 8% 15%;
--foreground: var(--fg-color);
--card: var(--bg-color);
--card-foreground: var(--fg-color);
@ -171,52 +53,57 @@
--secondary: var(--secondary-bg);
--secondary-foreground: var(--fg-color);
--muted: var(--secondary-bg);
--muted-foreground: oklch(0.708 0 0);
--muted-foreground: 240 5% 74%;
--accent: var(--secondary-bg);
--accent-foreground: var(--fg-color);
--info: hsl(207 90% 54%);
--info-foreground: hsl(210 40% 90%);
--success: hsl(122 39% 49%);
--success-foreground: hsl(210 40% 90%);
--warning: hsl(52.15, 100%, 46.47%);
--warning-foreground: hsl(240 10% 13%);
--destructive: hsl(0 84.2% 60.2%);
--info: 207 90% 54%;
--info-foreground: 210 40% 90%;
--success: 122 39% 49%;
--success-foreground: 210 40% 90%;
--warning: 52.15, 100%, 46.47%;
--warning-foreground: 210 40% 90%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: var(--fg-color);
--border: var(--secondary-bg);
--input: var(--secondary-bg);
--ring: hsl(240 4.9% 83.9%);
}
}
body {
--toastify-font-family: var(--font-sans);
--toastify-color-light: var(--background);
/*--toastify-color-info: #3498db;*/
--toastify-color-success: var(--success);
/*--toastify-color-warning: #f1c40f;*/
--toastify-color-error: var(--destructive);
/*--toastify-color-transparent: rgba(255, 255, 255, 0.7);*/
--toastify-icon-color-info: var(--toastify-color-info);
--toastify-icon-color-success: var(--toastify-color-success);
--toastify-icon-color-warning: var(--toastify-color-warning);
--toastify-icon-color-error: var(--toastify-color-error);
/* size and fonts are not customized */
--toastify-text-color-light: var(--foreground);
--toastify-color-progress-info: var(--toastify-color-info);
--toastify-color-progress-success: var(--toastify-color-success);
--toastify-color-progress-warning: var(--toastify-color-warning);
--toastify-color-progress-error: var(--toastify-color-error);
.Toastify__toast {
box-shadow: 0 4px 12px hsl(var(--primary) / 0.05);
--ring: 240 4.9% 83.9%;
}
.Toastify__close-button--light {
color: hsl(var(--foreground));
@media (prefers-color-scheme: dark) {
.system {
--bg-color: 240 10% 13%;
--fg-color: 240 10% 85%;
--secondary-bg: 240 3.7% 19%;
--primary-fg: 240 5.9% 15%;
--background: var(--bg-color);
--background-start: 0 0% 3%;
--background-end: 10 8% 15%;
--foreground: var(--fg-color);
--card: var(--bg-color);
--card-foreground: var(--fg-color);
--popover: var(--bg-color);
--popover-foreground: var(--fg-color);
--primary: var(--fg-color);
--primary-foreground: var(--primary-fg);
--secondary: var(--secondary-bg);
--secondary-foreground: var(--fg-color);
--muted: var(--secondary-bg);
--muted-foreground: 240 5% 74%;
--accent: var(--secondary-bg);
--accent-foreground: var(--fg-color);
--info: 207 90% 54%;
--info-foreground: 210 40% 90%;
--success: 122 39% 49%;
--success-foreground: 210 40% 90%;
--warning: 52.15, 100%, 46.47%;
--warning-foreground: 210 40% 90%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: var(--fg-color);
--border: var(--secondary-bg);
--input: var(--secondary-bg);
--ring: 240 4.9% 83.9%;
}
}
}
@ -225,20 +112,20 @@ body {
@apply border-border;
}
button:not(:disabled),
[role="button"]:not(:disabled) {
cursor: pointer;
}
button:disabled,
[role="button"]:disabled {
cursor: not-allowed;
}
body {
@apply bg-background text-foreground;
background: linear-gradient(to bottom, transparent, var(--background-end))
var(--background-start);
color: hsl(var(--foreground));
background: linear-gradient(
to bottom,
transparent,
hsl(var(--background-end))
) hsl(var(--background-start));
}
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}
@ -276,18 +163,43 @@ code {
@apply font-mono;
}
html {
overscroll-behavior: none;
}
/*
this is a ad-hoc way to apply toastify variables.
We could not find way to correctly order the toastify css and this css so put in body to get higher specificity
*/
body {
--toastify-color-light: hsl(var(--background));
/*--toastify-color-info: #3498db;*/
--toastify-color-success: hsl(var(--success));
/*--toastify-color-warning: #f1c40f;*/
--toastify-color-error: hsl(var(--destructive));
/*--toastify-color-transparent: rgba(255, 255, 255, 0.7);*/
--toastify-icon-color-info: var(--toastify-color-info);
--toastify-icon-color-success: var(--toastify-color-success);
--toastify-icon-color-warning: var(--toastify-color-warning);
--toastify-icon-color-error: var(--toastify-color-error);
/* size and fonts are not customized */
--toastify-text-color-light: hsl(var(--foreground));
--toastify-color-progress-info: var(--toastify-color-info);
--toastify-color-progress-success: var(--toastify-color-success);
--toastify-color-progress-warning: var(--toastify-color-warning);
--toastify-color-progress-error: var(--toastify-color-error);
.Toastify__toast {
box-shadow: 0 4px 12px hsl(var(--primary) / 0.05);
}
.Toastify__close-button--light {
color: hsl(var(--foreground));
}
}
/* Radix ui sets display:block for each scroll viewport element but it seem it make worse */
[data-radix-scroll-area-viewport] > div {
/* biome-ignore lint/complexity/noImportantStyles: necessary to override element */
display: block !important;
}
@ -295,22 +207,7 @@ html {
* Add padding end for horizontal scroll bar of scrollable card if vertical scroll bar is invisible
* This prevents the horizontal scroll bar hide corner of the card
*/
.vrc-get-scrollable-card:not(
:has(> .vrc-get-scrollable-card-vertical-bar)
) > div[data-radix-scroll-area-viewport]
> div
> div.vrc-get-scrollable-card-horizontal-bar {
@apply pe-2.5;
}
/*
* Add padding end for the content area of scrollable card if vertical scroll bar is visible
* This prevents the table / items from being hidden behind the vertical scroll bar
*/
.vrc-get-scrollable-card:has(
> .vrc-get-scrollable-card-vertical-bar
) > div[data-radix-scroll-area-viewport]
> div {
.vrc-get-scrollable-card:not(:has(> .vrc-get-scrollable-card-vertical-bar)) > div[data-radix-scroll-area-viewport] > div > div.vrc-get-scrollable-card-horizontal-bar {
@apply pe-2.5;
}
@ -318,51 +215,3 @@ html {
contain-intrinsic-size: 0 7em;
contain: size;
}
.fade-in {
animation: fadeInUp 0.1s ease-in-out;
}
.slide-left {
animation: slideLeft 0.1s ease-in-out;
}
.slide-right {
animation: slideRight 0.1s ease-in-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideLeft {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slideRight {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}

View file

@ -1,11 +0,0 @@
import { createFileRoute, Outlet } from "@tanstack/react-router";
import ErrorPage from "@/app/-error";
export const Route = createFileRoute("/")({
component: RouteComponent,
errorComponent: ErrorPage,
});
function RouteComponent() {
return <Outlet />;
}

View file

@ -0,0 +1,40 @@
import type { Metadata } from "next";
import "./globals.css";
import "react-toastify/ReactToastify.css";
import { Providers } from "@/components/providers";
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
// biome-ignore lint/a11y/useHtmlLang: we cannot determine the language of the content. we add in inner div.
<html>
<head>
{/* empty png to avoid erros */}
<link
rel="icon"
type="image/png"
href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII="
/>
{/* eslint-disable-next-line @next/next/no-sync-scripts */}
<script src="vrc-get://localhost/global-info.js" />
{/* eslint-disable-next-line @next/next/no-sync-scripts */}
<script src="http://vrc-get.localhost/global-info.js" />
</head>
<body
className={
"font-sans w-screen h-screen flex flex-row overflow-hidden whitespace-nowrap"
}
>
<Providers>{children}</Providers>
</body>
</html>
);
}

View file

@ -1,16 +1,14 @@
{
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"files": {
"includes": [
"**",
"!project-templates",
"!node_modules",
"!.next",
"!out",
"!gen",
"!lib/bindings.ts",
"!lib/routeTree.gen.ts",
"!build"
"ignore": [
"templates",
"node_modules",
".next",
"out",
"gen",
"lib/bindings.ts",
"build"
]
},
"formatter": {
@ -25,20 +23,9 @@
// In my opinion, '!.' => '?.' is not reasonable for all cases, so I disabled automatic fix.
"fix": "none",
"level": "error"
},
"noRestrictedGlobals": {
"level": "error",
"options": {
"deniedGlobals": {
"close": "window.close is unlikely to be called"
}
}
}
},
"suspicious": {
// false positives with tailwind css
// see https://github.com/biomejs/biome/issues/7223
"noUnknownAtRules": "off",
"noAssignInExpressions": "off"
},
"correctness": {
@ -50,41 +37,16 @@
"name": "useDocumentEvent",
"closureIndex": 1,
"dependenciesIndex": 2
},
{
"name": "useEffectEvent",
"stableResult": true
}
]
}
},
"noUnusedImports": "error"
},
"a11y": {
"noLabelWithoutControl": {
"level": "warn",
"options": {
"inputComponents": [
"Checkbox",
"SelectTrigger",
"BackupFormatSelect"
]
}
}
}
},
"enabled": true
},
"assist": {
"actions": {
"source": {
"organizeImports": "on"
}
}
},
"css": {
"parser": {
"tailwindDirectives": true
}
"organizeImports": {
"enabled": true
}
}

View file

@ -1,3 +0,0 @@
thumbnail.png
booth.zip
app-icon.png

Some files were not shown because too many files have changed in this diff Show more