[Update/Refactor] Fetch and show the plugins info list.

Refactor some functions.
Try to fetch functions from github api just once when vrct started.(It used to every time plugin tab has opened so easy to reach to the api limit)
This commit is contained in:
Sakamoto Shiina
2025-03-13 12:17:36 +09:00
parent 77795192a0
commit b0f5751e11
17 changed files with 350 additions and 237 deletions

8
package-lock.json generated
View File

@@ -8,7 +8,7 @@
"name": "tauri-app",
"version": "0.0.0",
"dependencies": {
"@babel/standalone": "^7.26.9",
"@babel/standalone": "7.26.9",
"@emotion/react": "11.14.0",
"@emotion/styled": "11.14.0",
"@mui/material": "6.2.0",
@@ -21,18 +21,18 @@
"jotai": "2.10.3",
"js-base64": "3.7.7",
"js-yaml": "4.1.0",
"jszip": "^3.10.1",
"jszip": "3.10.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-i18next": "15.2.0",
"react-resizable-layout": "0.7.2",
"semver": "^7.7.1"
"semver": "7.7.1"
},
"devDependencies": {
"@tauri-apps/cli": "1.6.3",
"npm-run-all": "4.1.5",
"sass": "1.79.4",
"vite": "^6.2.1",
"vite": "6.2.1",
"vite-plugin-svgr": "4.3.0"
}
},

View File

@@ -41,7 +41,6 @@ export const App = () => {
<FontFamilyController />
<TransparencyController />
<WindowGeometryController />
<PluginsController />
{(currentIsBackendReady.data === false || currentIsVrctAvailable.data === false)
? <SplashComponent />
@@ -57,6 +56,7 @@ const Contents = () => {
const { currentIsSoftwareUpdating } = useIsSoftwareUpdating();
return (
<>
<PluginsController />
<SubtitlesController />
<WindowTitleBar />

View File

@@ -1,15 +1,24 @@
import React, { useEffect, useRef } from "react";
import { usePlugins } from "@logics_configs";
import React, { useEffect } from "react";
if (typeof window !== "undefined") {
window.React = React;
}
export const PluginsController = () => {
const { loadAllPlugins } = usePlugins();
const hasRunRef = useRef(false);
const {
loadAllPlugins,
asyncUpdatePluginInfoList,
} = usePlugins();
useEffect(() => {
loadAllPlugins();
if (!hasRunRef.current) {
asyncUpdatePluginInfoList().then(() => {
loadAllPlugins();
});
}
return () => hasRunRef.current = true;
}, []);
return null;

View File

@@ -0,0 +1,41 @@
import { useTranslation } from "react-i18next";
import CircularProgress from "@mui/material/CircularProgress";
import styles from "./_DownloadButton.module.scss";
export const _DownloadButton = ({option, ...props}) => {
const { t } = useTranslation();
const renderContent = () => {
const circular_progress = Math.floor(option.progress / 10) * 10;
switch (true) {
case option.progress !== null:
return (
<>
<CircularProgress
variant={(option.progress === 100) ? "indeterminate" : "determinate"}
value={circular_progress}
size="3rem"
sx={{ color: "var(--primary_300_color)" }}
/>
<p className={styles.progress_label}>{`${Math.round(option.progress)}%`}</p>
</>
);
case option.is_pending:
return <CircularProgress size="3rem" sx={{ color: "var(--dark_600_color)" }}/>;
case !option.is_downloaded:
return (
<button
className={styles.download_button}
onClick={() => props.downloadStartFunction(option.id)}
>
<p className={styles.download_button_label}>{t("config_page.model_download_button_label")}</p>
</button>
);
default:
return null;
}
};
return <div className={styles.download_container}>{renderContent()}</div>;
};

View File

@@ -0,0 +1,30 @@
@import "@scss_mixins";
.download_container {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
max-width: 8rem;
}
.download_button {
pointer-events: auto;
background-color: var(--dark_800_color);
padding: 0.8rem;
flex-shrink: 0;
&:hover {
background-color: var(--dark_750_color);
}
&:active {
background-color: var(--dark_800_color);
}
}
.download_button_label {
font-size: 1.2rem;
}
.progress_label {
position: absolute;
font-size: 1rem;
}

View File

@@ -1,9 +1,7 @@
import { useTranslation } from "react-i18next";
import CircularProgress from "@mui/material/CircularProgress";
import styles from "./DownloadModels.module.scss";
import {
RadioButton,
} from "../index";
import { _DownloadButton } from "../_atoms/_download_button/_DownloadButton";
export const DownloadModels = (props) => {
const options = props.options.map(item => ({
@@ -19,47 +17,9 @@ export const DownloadModels = (props) => {
options={options}
checked_variable={props.checked_variable}
column={true}
ChildComponent={ModelSelector}
ChildComponent={_DownloadButton}
downloadStartFunction={props.downloadStartFunction}
/>
</>
);
};
const ModelSelector = ({option, ...props}) => {
const { t } = useTranslation();
const renderContent = () => {
const circular_progress = Math.floor(option.progress / 10) * 10;
switch (true) {
case option.progress !== null:
return (
<>
<CircularProgress
variant={(option.progress === 100) ? "indeterminate" : "determinate"}
value={circular_progress}
size="3rem"
sx={{ color: "var(--primary_300_color)" }}
/>
<p className={styles.progress_label}>{`${Math.round(option.progress)}%`}</p>
</>
);
case option.is_pending:
return <CircularProgress size="3rem" sx={{ color: "var(--dark_600_color)" }}/>;
case !option.is_downloaded:
return (
<button
className={styles.download_button}
onClick={() => props.downloadStartFunction(option.id)}
>
<p className={styles.download_button_label}>{t("config_page.model_download_button_label")}</p>
</button>
);
default:
return null;
}
};
return <div className={styles.download_container}>{renderContent()}</div>;
};
};

View File

@@ -1,30 +0,0 @@
@import "@scss_mixins";
.download_container {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
max-width: 8rem;
}
.download_button {
pointer-events: auto;
background-color: var(--dark_800_color);
padding: 0.8rem;
flex-shrink: 0;
&:hover {
background-color: var(--dark_750_color);
}
&:active {
background-color: var(--dark_800_color);
}
}
.download_button_label {
font-size: 1.2rem;
}
.progress_label {
position: absolute;
font-size: 1rem;
}

View File

@@ -0,0 +1,33 @@
import {
SwitchBox,
} from "../index";
import { _DownloadButton } from "../_atoms/_download_button/_DownloadButton";
export const DownloadPlugins = ({plugin_info, ...props}) => {
const option = {
is_pending: plugin_info.is_pending,
is_downloaded: plugin_info.is_downloaded,
}
console.log(plugin_info);
return (
<div>
{/* <SwitchBox
variable={currentEnableAutoMicSelect}
toggleFunction={toggleEnableAutoMicSelect}
/> */}
{plugin_info.is_plugin_supported ?
<_DownloadButton
option={option}
downloadStartFunction={props.downloadStartFunction}
/>
:
<div>
Unavailable
</div>
}
</div>
);
};

View File

@@ -11,4 +11,5 @@ export { Slider } from "./slider/Slider";
export { SwitchBox } from "./switch_box/SwitchBox";
export { ThresholdComponent } from "./threshold_component/ThresholdComponent";
export { WordFilter, WordFilterListToggleComponent } from "./word_filter/WordFilter";
export { DownloadModels } from "./download_models/DownloadModels";
export { DownloadModels } from "./download_models/DownloadModels";
export { DownloadPlugins } from "./download_plugins/DownloadPlugins";

View File

@@ -1,12 +1,7 @@
import React, { useState, useEffect } from "react";
import semver from "semver";
import { usePlugins } from "@logics_configs";
import styles from "./Plugins.module.scss";
import { fetch as tauriFetch, ResponseType } from "@tauri-apps/api/http";
const MAIN_VRCT_VERSION = "3.0.5";
// PLUGIN_LIST_URL は中央リポジトリにある、各プラグインの plugin_info.json への URL の配列を保持する JSON の URL
const PLUGIN_LIST_URL = "https://raw.githubusercontent.com/ShiinaSakamoto/vrct_plugins_list/main/vrct_plugins_list.json";
import { DownloadPlugins } from "../_components";
export const Plugins = () => {
return (
@@ -17,146 +12,85 @@ export const Plugins = () => {
};
const PluginDownloadContainer = () => {
const [plugin_list, set_plugin_list] = useState([]);
const [download_progress, set_download_progress] = useState({});
const { downloadAndExtractPlugin } = usePlugins();
const {
downloadAndExtractPlugin,
currentPluginsInfoList,
updatePluginsInfoList,
} = usePlugins();
useEffect(() => {
async function asyncFetchPluginInfoList() {
try {
// tauriFetch を使用して vrct_plugins_list.json を取得CORS 対策)
const response = await tauriFetch(PLUGIN_LIST_URL, {
method: "GET",
responseType: ResponseType.Json,
headers: { "Cache-Control": "no-cache" }
});
if (response.status !== 200) {
throw new Error("Failed to fetch plugin list, status: " + response.status);
const downloadStartFunction = async (plugin) => {
updatePluginsInfoList((old_value) => {
const new_value = old_value.data.map(d => {
if (d.plugin_id === plugin.plugin_id) {
d.is_pending = true;
}
// 取得される plugin_list.json は各プラグインの plugin_info.json への raw URL の配列とする
const plugins_data = response.data;
const updated_list = await Promise.all(
plugins_data.map(async (plugin_data) => {
try {
const plugin_manifest = await asyncFetchPluginManifest(plugin_data.url);
return { ...plugin_manifest };
} catch (error) {
console.error("Error fetching manifest for URL:", plugin_data.url, error);
// エラー発生時は、plugin_data.title とエラーメッセージを返す
return {
title: plugin_data.title,
plugin_id: plugin_data.plugin_id || plugin_data.title,
error: error.message,
url: plugin_data.url
};
}
})
);
set_plugin_list(updated_list);
} catch (error) {
console.error("Error fetching plugin info list:", error);
}
}
asyncFetchPluginInfoList();
}, []);
const handleDownload = async (plugin) => {
return d;
});
return new_value;
});
await downloadAndExtractPlugin(plugin);
updatePluginsInfoList((old_value) => {
const new_value = old_value.data.map(d => {
if (d.plugin_id === plugin.plugin_id) {
d.is_pending = false;
}
return d;
});
return new_value;
});
};
const plugin_list = currentPluginsInfoList.data;
// const plugin_list = [
// {
// title: "VRCT Example Plugins 1",
// plugin_id: "vrct_plugin_example_1",
// asset_name: "vrct_plugin_example_1.zip",
// plugin_version: "0.0.6",
// min_supported_vrct_version: "3.0.4",
// max_supported_vrct_version: "3.0.6",
// is_plugin_supported: true,
// // url: manifest_url
// },
// {
// title: "VRCT Example Plugins 2",
// plugin_id: "vrct_plugin_example_2",
// asset_name: "vrct_plugin_example_2.zip",
// plugin_version: "0.0.1",
// min_supported_vrct_version: "3.0.4",
// max_supported_vrct_version: "3.0.7",
// is_plugin_supported: true,
// // url: manifest_url
// },
// ];
return (
<div>
<div className={styles.plugins_list_container}>
{plugin_list.map((plugin) => (
<div key={plugin.plugin_id}>
<h3>{plugin.title}</h3>
<h4>{plugin.plugin_id}</h4>
<div key={plugin.plugin_id} className={styles.plugin_wrapper}>
<p className={styles.title}>{plugin.title}</p>
<p className={styles.plugin_id}>{plugin.plugin_id}</p>
{plugin.error ? (
<p style={{ color: "red" }}>Error: {plugin.error}</p>
) : (
<>
<p>Version: {plugin.plugin_version}</p>
<p>
Compatible: {plugin.min_compatible_version} ~ {plugin.max_compatible_version}
</p>
<button onClick={() => handleDownload(plugin)}>
Download and Load Plugin
</button>
{download_progress[plugin.plugin_id] !== undefined && (
<div>
Download Progress: {download_progress[plugin.plugin_id].toFixed(0)}%
</div>
)}
</>
<div className={styles.plugin_info_wrapper}>
<div className={styles.plugin_info}>
<p>Version: {plugin.plugin_version}</p>
<p>
Compatible: {plugin.min_supported_vrct_version} ~ {plugin.max_supported_vrct_version}
</p>
</div>
<DownloadPlugins
plugin_info={plugin}
downloadStartFunction={downloadStartFunction}
/>
</div>
)}
</div>
))}
</div>
);
};
// GitHub Releases の latest 情報から plugin_info.json を取得するtauriFetch を使用)
async function asyncFetchPluginManifest(manifest_url) {
// リリース情報を取得
const release_response = await tauriFetch(manifest_url, {
method: "GET",
responseType: ResponseType.Json,
headers: {
"Accept": "application/vnd.github+json",
"User-Agent": "VRCTPluginApp"
}
});
if (release_response.status !== 200) {
throw new Error(`Failed to fetch release info from ${manifest_url}`);
}
const release_data = release_response.data;
// assets 内に plugin_info.json があるかチェック
const manifest_asset = release_data.assets.find(asset => asset.name === "plugin_info.json");
if (!manifest_asset) {
throw new Error("plugin_info.json not found in release assets");
}
// plugin_info.json の内容を取得
const manifest_response = await tauriFetch(manifest_asset.browser_download_url, {
method: "GET",
responseType: ResponseType.Json,
headers: {
"Accept": "application/json",
"User-Agent": "VRCTPluginApp",
"Cache-Control": "no-cache"
}
});
if (manifest_response.status !== 200) {
throw new Error(`Failed to fetch plugin_info.json from ${manifest_asset.browser_download_url}`);
}
const plugin_manifest = manifest_response.data;
return {
title: plugin_manifest.title,
plugin_id: plugin_manifest.plugin_id,
plugin_version: plugin_manifest.plugin_version,
min_compatible_version: plugin_manifest.min_compatible_version,
max_compatible_version: plugin_manifest.max_compatible_version,
asset_name: plugin_manifest.asset_name,
url: manifest_url
};
}
export { PluginDownloadContainer };
// // プラグインのマニフェストplugin.json から取得した情報の例)
// const plugin_manifest = {
// compatible_lower_version: "3.0.4",
// compatible_upper_version: "3.0.6",
// // 他の情報...
// };
// const isPluginCompatible = (main_version, lower_version, upper_version) => {
// // lower_version 以上かつ upper_version 以下なら互換性ありと判定
// return semver.gte(main_version, lower_version) && semver.lte(main_version, upper_version);
// };
// if (isPluginCompatible(currentSoftwareVersion.data, plugin_manifest.compatible_lower_version, plugin_manifest.compatible_upper_version)) {
// console.log("プラグインは互換性があります。");
// } else {
// console.error("プラグインは現在の VRCT バージョンと互換性がありません。");
// }
};

View File

@@ -2,4 +2,34 @@
display: flex;
gap: 6.4rem;
flex-direction: column;
}
.plugins_list_container {
display: flex;
flex-direction: column;
align-items: center;
}
.plugin_wrapper {
width: 100%;
display: flex;
flex-direction: column;
padding: 2rem;
&:not(:last-child) {
border-bottom: 0.1rem solid var(--dark_750_color);
}
}
.title {
font-size: 1.6rem;
}
.plugin_id {
font-size: 1rem;
}
.download_button {
background-color: var(--dark_750_color);
padding: 0.4rem 0.6rem;
font-size: 1.2rem;
}

View File

@@ -1,10 +1,8 @@
// PluginHost.jsx
import React from "react";
import { useStore_LoadedPluginsList } from "@store";
import { usePlugins } from "@logics_configs";
// export const PluginHost = ({ location }) => {
export const PluginHost = () => {
const { currentLoadedPluginsList } = useStore_LoadedPluginsList();
const { currentLoadedPluginsList } = usePlugins();
console.log(currentLoadedPluginsList.data);
return (

View File

@@ -11,4 +11,5 @@ export { useMessage } from "./useMessage";
export { useUpdateSoftware } from "./useUpdateSoftware";
export { useVolume } from "./useVolume";
export { useHandleNetworkConnection } from "./useHandleNetworkConnection";
export { useIsVrctAvailable } from "./useIsVrctAvailable";
export { useIsVrctAvailable } from "./useIsVrctAvailable";
export { useFetch } from "./useFetch";

View File

@@ -0,0 +1,21 @@
import { fetch as tauriFetch, ResponseType } from "@tauri-apps/api/http";
export const useFetch = () => {
const asyncTauriFetchGithub = async (url) => {
console.log("tauriFetch");
const release_response = await tauriFetch(url, {
method: "GET",
responseType: ResponseType.Json,
headers: {
"Accept": "application/vnd.github+json",
"User-Agent": "VRCTPluginApp"
}
});
return release_response;
};
return {
asyncTauriFetchGithub,
};
};

View File

@@ -1,16 +1,31 @@
import { invoke } from '@tauri-apps/api/tauri';
import { createAtomWithHook, useStore_LoadedPluginsList } from "@store";
import { useSoftwareVersion } from "@logics_configs";
import semver from "semver";
import { invoke } from "@tauri-apps/api/tauri";
import {
createAtomWithHook,
useStore_LoadedPluginsList,
useStore_PluginsInfoList,
} from "@store";
import { transform } from "@babel/standalone";
import { writeFile, createDir, exists, removeDir, readDir, BaseDirectory, readTextFile } from "@tauri-apps/api/fs";
const dev_plugin_mapping = import.meta.glob("/src-tauri/plugins/**/index.jsx", { eager: true });
import JSZip from "jszip";
import { fetch as tauriFetch, ResponseType } from "@tauri-apps/api/http";
import { useFetch } from "@logics_common";
import { useSoftwareVersion } from "@logics_configs";
// PLUGIN_LIST_URL は中央リポジトリにある、各プラグインの plugin_info.json への URL の配列を保持する JSON の URL
const PLUGIN_LIST_URL = "https://raw.githubusercontent.com/ShiinaSakamoto/vrct_plugins_list/main/vrct_plugins_list.json";
export const usePlugins = () => {
const { updateLoadedPluginsList } = useStore_LoadedPluginsList();
const { currentLoadedPluginsList, updateLoadedPluginsList } = useStore_LoadedPluginsList();
const { currentPluginsInfoList, updatePluginsInfoList, pendingPluginsInfoList } = useStore_PluginsInfoList();
const { currentSoftwareVersion } = useSoftwareVersion();
const { asyncTauriFetchGithub } = useFetch();
const plugin_context = {
registerComponent: ({ plugin_id, location, component }) => {
if (!plugin_id || !location || !component) {
@@ -137,17 +152,9 @@ export const usePlugins = () => {
}
};
// GitHub API を使用して、最新リリース情報から asset_name に一致するアセットのブラウザダウンロード URL を返す
const fetchLatestPluginZipUrl = async (plugin) => {
const api_url = plugin.url;
const response = await tauriFetch(api_url, {
method: "GET",
responseType: ResponseType.Json,
headers: {
"Accept": "application/vnd.github+json",
"User-Agent": "VRCTPluginApp"
}
});
const response = await asyncTauriFetchGithub(api_url);
if (response.status !== 200) {
throw new Error("Failed to fetch latest release info, status: " + response.status);
}
@@ -159,9 +166,87 @@ export const usePlugins = () => {
return asset.browser_download_url;
};
const asyncUpdatePluginInfoList = async () => {
pendingPluginsInfoList();
try {
const response = await asyncTauriFetchGithub(PLUGIN_LIST_URL);
if (response.status !== 200) {
throw new Error("Failed to fetch plugin list, status: " + response.status);
}
const plugins_data = response.data;
const updated_list = await Promise.all(
plugins_data.map(async (plugin_data) => {
try {
const plugin_info = await asyncFetchPluginInfo(plugin_data.url);
return { ...plugin_info };
} catch (error) {
console.error("Error fetching plugin info for URL:", plugin_data.url, error);
// エラー発生時は、plugin_data.title とエラーメッセージを返す
return {
title: plugin_data.title,
plugin_id: plugin_data.plugin_id || plugin_data.title,
error: error.message,
url: plugin_data.url
};
}
})
);
updatePluginsInfoList(updated_list);
} catch (error) {
console.error("Error fetching plugin info list:", error);
}
}
const asyncFetchPluginInfo = async (plugin_info_asset_url) => {
const release_response = await asyncTauriFetchGithub(plugin_info_asset_url);
if (release_response.status !== 200) {
throw new Error(`Failed to fetch release info from ${plugin_info_asset_url}`);
}
const plugin_info_json = release_response.data.assets.find(asset => asset.name === "plugin_info.json");
if (!plugin_info_json) {
throw new Error("plugin_info.json not found in release assets");
}
const plugin_info_json_response = await asyncTauriFetchGithub(plugin_info_json.browser_download_url);
if (plugin_info_json_response.status !== 200) {
throw new Error(`Failed to fetch plugin_info.json from ${plugin_info_json.browser_download_url}`);
}
const plugin_info = plugin_info_json_response.data;
const isPluginCompatible = (main_version, lower_version, upper_version) => {
console.log(main_version, lower_version, upper_version);
// lower_version 以上かつ upper_version 以下なら互換性ありと判定
return semver.gte(main_version, lower_version) && semver.lte(main_version, upper_version);
};
const is_plugin_supported = isPluginCompatible(currentSoftwareVersion.data, plugin_info.min_supported_vrct_version, plugin_info.max_supported_vrct_version);
return {
title: plugin_info.title,
plugin_id: plugin_info.plugin_id,
plugin_version: plugin_info.plugin_version,
min_supported_vrct_version: plugin_info.min_supported_vrct_version,
max_supported_vrct_version: plugin_info.max_supported_vrct_version,
is_plugin_supported: is_plugin_supported,
asset_name: plugin_info.asset_name,
url: plugin_info_asset_url
};
}
return {
asyncUpdatePluginInfoList,
loadAllPlugins,
downloadAndExtractPlugin,
currentLoadedPluginsList,
updateLoadedPluginsList,
currentPluginsInfoList,
updatePluginsInfoList,
};
};

View File

@@ -275,8 +275,8 @@ export const { atomInstance: Atom_Hotkeys, useHook: useStore_Hotkeys } = createA
}, "Hotkeys");
// Plugins
export const { atomInstance: Atom_InstalledPluginsPath, useHook: useStore_InstalledPluginsPath } = createAtomWithHook([], "InstalledPluginsPath");
export const { atomInstance: Atom_LoadedPluginsList, useHook: useStore_LoadedPluginsList } = createAtomWithHook([], "LoadedPluginsList");
export const { atomInstance: Atom_PluginsInfoList, useHook: useStore_PluginsInfoList } = createAtomWithHook([], "PluginsInfoList");
// Advanced Settings
export const { atomInstance: Atom_OscIpAddress, useHook: useStore_OscIpAddress } = createAtomWithHook("127.0.0.1", "OscIpAddress");