[TMP] Plugins system.
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
UiSizeController,
|
||||
FontFamilyController,
|
||||
TransparencyController,
|
||||
PluginsController,
|
||||
} from "./_app_controllers/index.js";
|
||||
|
||||
import { WindowTitleBar } from "./window_title_bar/WindowTitleBar";
|
||||
@@ -40,6 +41,7 @@ export const App = () => {
|
||||
<FontFamilyController />
|
||||
<TransparencyController />
|
||||
<WindowGeometryController />
|
||||
<PluginsController />
|
||||
|
||||
{(currentIsBackendReady.data === false || currentIsVrctAvailable.data === false)
|
||||
? <SplashComponent />
|
||||
|
||||
17
src-ui/app/_app_controllers/PluginsController.jsx
Normal file
17
src-ui/app/_app_controllers/PluginsController.jsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useEffect } from "react";
|
||||
import { usePlugins } from "@logics_configs";
|
||||
|
||||
// ホスト側でReactやjotaiをグローバル変数として提供
|
||||
import ReactModule from "react";
|
||||
if (typeof window !== "undefined") {
|
||||
window.React = ReactModule;
|
||||
}
|
||||
|
||||
export const PluginsController = () => {
|
||||
const { loadAllPlugins } = usePlugins();
|
||||
useEffect(() => {
|
||||
loadAllPlugins();
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -5,4 +5,5 @@ export { UiLanguageController } from "./UiLanguageController";
|
||||
export { ConfigPageCloseTriggerController } from "./ConfigPageCloseTriggerController";
|
||||
export { UiSizeController } from "./UiSizeController";
|
||||
export { FontFamilyController } from "./FontFamilyController";
|
||||
export { TransparencyController } from "./TransparencyController";
|
||||
export { TransparencyController } from "./TransparencyController";
|
||||
export { PluginsController } from "./PluginsController";
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
AdvancedSettings,
|
||||
Vr,
|
||||
Hotkeys,
|
||||
Plugins,
|
||||
Supporters,
|
||||
AboutVrct,
|
||||
} from "@setting_box";
|
||||
@@ -32,6 +33,8 @@ export const SettingBox = () => {
|
||||
return <Hotkeys />;
|
||||
case "advanced_settings":
|
||||
return <AdvancedSettings />;
|
||||
case "plugins":
|
||||
return <Plugins />;
|
||||
case "supporters":
|
||||
return <Supporters />;
|
||||
case "about_vrct":
|
||||
|
||||
@@ -6,5 +6,6 @@ export { Others, VrcMicMuteSyncContainer } from "./others/Others";
|
||||
export { AdvancedSettings } from "./advanced_settings/AdvancedSettings";
|
||||
export { Vr } from "./vr/Vr";
|
||||
export { Hotkeys } from "./hotkeys/Hotkeys";
|
||||
export { Plugins } from "./plugins/Plugins";
|
||||
export { AboutVrct } from "./about_vrct/AboutVrct";
|
||||
export { Supporters } from "./supporters/Supporters";
|
||||
@@ -0,0 +1,60 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { usePlugins } from "@logics_configs";
|
||||
import styles from "./Plugins.module.scss";
|
||||
|
||||
export const Plugins = () => {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<PluginDownloadContainer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PluginDownloadContainer = () => {
|
||||
const [plugin_list, set_plugin_list] = useState([]);
|
||||
const [download_progress, set_download_progress] = useState({});
|
||||
|
||||
const { downloadAndExtractPlugin } = usePlugins();
|
||||
|
||||
useEffect(() => {
|
||||
// GitHub上のJSONファイルからプラグインリストを取得
|
||||
const fetchPluginList = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
"https://raw.githubusercontent.com/ShiinaSakamoto/vrct_plugins_list/main/vrct_plugins_list.json"
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch plugin list");
|
||||
}
|
||||
const data = await response.json();
|
||||
set_plugin_list(data);
|
||||
} catch (error) {
|
||||
console.error("Error fetching plugin list:", error);
|
||||
}
|
||||
};
|
||||
fetchPluginList();
|
||||
}, []);
|
||||
|
||||
const handleDownload = async (plugin) => {
|
||||
await downloadAndExtractPlugin(plugin);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{plugin_list.map((plugin) => (
|
||||
<div key={plugin.plugin_id}>
|
||||
<h3>{plugin.title}</h3>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
.container {
|
||||
display: flex;
|
||||
gap: 6.4rem;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export const SidebarSection = () => {
|
||||
<Tab tab_id="vr" />
|
||||
<Tab tab_id="others" />
|
||||
<Tab tab_id="hotkeys" />
|
||||
<Tab tab_id="plugins" />
|
||||
<Tab tab_id="advanced_settings" />
|
||||
</div>
|
||||
<div className={styles.separated_tabs_wrapper}>
|
||||
|
||||
@@ -9,12 +9,16 @@ import { useStore_IsOpenedLanguageSelector } from "@store";
|
||||
import { useLanguageSettings } from "@logics_main";
|
||||
import { useEffect } from "react";
|
||||
import { SubtitleSystemContainer } from "./subtitle_system_container/SubtitleSystemContainer";
|
||||
|
||||
import { PluginHost } from "./PluginHost";
|
||||
|
||||
export const MainSection = () => {
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<TopBar />
|
||||
<SubtitleSystemContainer />
|
||||
{/* <SubtitleSystemContainer /> */}
|
||||
<PluginHost />
|
||||
{/* <MessageContainer /> */}
|
||||
<HandleLanguageSelector />
|
||||
</div>
|
||||
|
||||
20
src-ui/app/main_page/main_section/PluginHost.jsx
Normal file
20
src-ui/app/main_page/main_section/PluginHost.jsx
Normal file
@@ -0,0 +1,20 @@
|
||||
// PluginHost.jsx
|
||||
import React from "react";
|
||||
import { useStore_LoadedPluginsList } from "@store";
|
||||
|
||||
// export const PluginHost = ({ location }) => {
|
||||
export const PluginHost = () => {
|
||||
const { currentLoadedPluginsList } = useStore_LoadedPluginsList();
|
||||
console.log(currentLoadedPluginsList.data);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{currentLoadedPluginsList.data
|
||||
.filter((plugin) => plugin.location === "main_section")
|
||||
.map((plugin, index) => {
|
||||
const PluginComponent = plugin.component;
|
||||
return PluginComponent ? <PluginComponent key={index} /> : null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -60,6 +60,8 @@ export { useOscPort } from "./advanced_settings/useOscPort";
|
||||
|
||||
export { useSupporters } from "./supporters/useSupporters";
|
||||
|
||||
export { usePlugins } from "./plugins/usePlugins";
|
||||
|
||||
|
||||
export { useSettingBoxScrollPosition } from "./useSettingBoxScrollPosition";
|
||||
export { useSoftwareVersion } from "./useSoftwareVersion";
|
||||
190
src-ui/logics/configs/plugins/usePlugins.js
Normal file
190
src-ui/logics/configs/plugins/usePlugins.js
Normal file
@@ -0,0 +1,190 @@
|
||||
import { invoke } from '@tauri-apps/api/tauri';
|
||||
import { createAtomWithHook, useStore_LoadedPluginsList } from "@store";
|
||||
import { transform } from "@babel/standalone";
|
||||
import { writeFile, createDir, exists, 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";
|
||||
|
||||
export const usePlugins = () => {
|
||||
const { updateLoadedPluginsList } = useStore_LoadedPluginsList();
|
||||
|
||||
const plugin_context = {
|
||||
registerComponent: ({ plugin_id, location, component }) => {
|
||||
if (!plugin_id || !location || !component) {
|
||||
return console.error("An invalid plugin was detected.", plugin_id, location, component);
|
||||
}
|
||||
updateLoadedPluginsList((prev) => {
|
||||
const filtered = prev.data.filter(item => item.plugin_id !== plugin_id);
|
||||
return [...filtered, { plugin_id, location, component }];
|
||||
});
|
||||
},
|
||||
createAtomWithHook: (...args) => createAtomWithHook(...args)
|
||||
};
|
||||
|
||||
const asyncLoadPlugin = async (plugin_relative_path) => {
|
||||
plugin_relative_path = "plugins/" + plugin_relative_path;
|
||||
console.log("plugin_relative_path",plugin_relative_path);
|
||||
|
||||
try {
|
||||
const plugin_code = await readTextFile(plugin_relative_path, { dir: BaseDirectory.Resource, recursive: true });
|
||||
const cleanedCode = removeImportStatements(plugin_code);
|
||||
const transpiled_code = transform(cleanedCode, {
|
||||
presets: [
|
||||
["env", { modules: false }],
|
||||
"react"
|
||||
],
|
||||
sourceType: "module"
|
||||
}).code;
|
||||
const blob = new Blob([transpiled_code], { type: "text/javascript" });
|
||||
const blob_url = URL.createObjectURL(blob);
|
||||
const plugin_module = await import(/* @vite-ignore */ blob_url);
|
||||
URL.revokeObjectURL(blob_url);
|
||||
|
||||
if (plugin_module && plugin_module.init) {
|
||||
plugin_module.init(plugin_context);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load plugin from", plugin_relative_path, error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadAllPlugins = async () => {
|
||||
if (import.meta.env.DEV) {
|
||||
// ホットリロード対応 src-tauri以下にあるpluginsディレクトリから直接読み込み(開発用)
|
||||
Object.entries(dev_plugin_mapping).forEach(([key, plugin_module]) => {
|
||||
console.log(plugin_module);
|
||||
if (plugin_module && plugin_module.init) {
|
||||
plugin_module.init(plugin_context);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
const plugin_files = await readDir("plugins", { dir: BaseDirectory.Resource, recursive: true });
|
||||
for (const target_dir of plugin_files) {
|
||||
console.log(target_dir);
|
||||
|
||||
const target_path = target_dir.name + "\\index.jsx";
|
||||
await asyncLoadPlugin(target_path, plugin_context);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading plugins:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const downloadAndExtractPlugin = async (plugin) => {
|
||||
try {
|
||||
const plugin_zip_url = await fetchLatestPluginZipUrl(plugin);
|
||||
console.log("Latest plugin zip URL:", plugin_zip_url);
|
||||
|
||||
// Rust コマンド経由で zip をダウンロード
|
||||
const base64Zip = await invoke("download_zip_asset", { url: plugin_zip_url });
|
||||
// base64Zip は文字列なので、デコードして Uint8Array に変換
|
||||
const binaryString = atob(base64Zip);
|
||||
const len = binaryString.length;
|
||||
const bytes = new Uint8Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
|
||||
// JSZip で zip を解凍
|
||||
const zip = await JSZip.loadAsync(bytes);
|
||||
|
||||
// const plugin_dir_exists = await exists("plugins", { dir: BaseDirectory.Resource, recursive: true });
|
||||
await createDir("plugins/" + plugin.asset_name.replace(".zip", ""), { dir: BaseDirectory.Resource, recursive: true });
|
||||
// if (!plugin_dir_exists) {
|
||||
// }
|
||||
const target_plugin_path = "plugins/" + plugin.asset_name.replace(".zip", "");
|
||||
|
||||
|
||||
const filePromises = [];
|
||||
zip.forEach((relativePath, zipEntry) => {
|
||||
// .git 以下のファイルはスキップ
|
||||
if (relativePath.startsWith(".git") || relativePath.includes("/.git/")) {
|
||||
// console.log("Skipping .git file: " + relativePath);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const filePath = target_plugin_path + "/" + relativePath;
|
||||
|
||||
if (zipEntry.dir) {
|
||||
// フォルダの場合、ディレクトリを作成
|
||||
filePromises.push(
|
||||
createDir(filePath, { dir: BaseDirectory.Resource, recursive: true }).catch((err) => {
|
||||
console.log(err);
|
||||
|
||||
if (!err.message?.includes("already exists")) {
|
||||
console.error("Failed to create directory:", filePath, err);
|
||||
}
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// ファイルの場合、ディレクトリを作成してから書き込む
|
||||
const dirPath = filePath.substring(0, filePath.lastIndexOf("/")); // 親ディレクトリのパス
|
||||
|
||||
const promise = createDir(dirPath, { dir: BaseDirectory.Resource, recursive: true })
|
||||
.catch((err) => {
|
||||
if (!err.message?.includes("already exists")) {
|
||||
console.error("Failed to create parent directory:", dirPath, err);
|
||||
}
|
||||
})
|
||||
.then(() => zipEntry.async("text"))
|
||||
.then(async (fileData) => {
|
||||
await writeFile(filePath, fileData, { dir: BaseDirectory.Resource, recursive: true });
|
||||
});
|
||||
|
||||
filePromises.push(promise);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(filePromises);
|
||||
console.log("Plugin downloaded successfully.");
|
||||
|
||||
const index_file_relative_path = plugin.asset_name.replace(".zip", "") + "/" + "index.jsx"
|
||||
console.log("index_file_relative_path", index_file_relative_path);
|
||||
|
||||
await asyncLoadPlugin(index_file_relative_path);
|
||||
|
||||
console.log("Plugin loaded successfully.");
|
||||
} catch (error) {
|
||||
console.error("Error downloading and extracting plugin:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// JSON内のURLから GitHub API を使って最新リリース情報を取得し、
|
||||
// assets 配列から plugin.asset_name に一致するアセットの browser_download_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"
|
||||
}
|
||||
});
|
||||
if (response.status !== 200) {
|
||||
throw new Error("Failed to fetch latest release info, status: " + response.status);
|
||||
}
|
||||
const release_info = response.data;
|
||||
const asset = release_info.assets.find((a) => a.name === plugin.asset_name);
|
||||
if (!asset) {
|
||||
throw new Error(`Asset ${plugin.asset_name} not found in the latest release`);
|
||||
}
|
||||
return asset.browser_download_url;
|
||||
};
|
||||
|
||||
return {
|
||||
loadAllPlugins,
|
||||
downloadAndExtractPlugin,
|
||||
};
|
||||
};
|
||||
|
||||
const removeImportStatements = (code) => {
|
||||
return code
|
||||
.split("\n")
|
||||
.filter(line => !line.match(/^import\s+.*['"]react['"]/))
|
||||
.join("\n");
|
||||
};
|
||||
@@ -34,7 +34,7 @@ const generatePropertyNames = (base_name) => ({
|
||||
});
|
||||
|
||||
|
||||
const createAtomWithHook = (initialValue, base_name, options) => {
|
||||
export const createAtomWithHook = (initialValue, base_name, options) => {
|
||||
const property_names = generatePropertyNames(base_name);
|
||||
const atomInstance = atom({
|
||||
state: (options?.is_state_ok) ? "ok" : "pending",
|
||||
@@ -274,6 +274,10 @@ export const { atomInstance: Atom_Hotkeys, useHook: useStore_Hotkeys } = createA
|
||||
toggle_transcription_receive: null,
|
||||
}, "Hotkeys");
|
||||
|
||||
// Plugins
|
||||
export const { atomInstance: Atom_InstalledPluginsPath, useHook: useStore_InstalledPluginsPath } = createAtomWithHook([], "InstalledPluginsPath");
|
||||
export const { atomInstance: Atom_LoadedPluginsList, useHook: useStore_LoadedPluginsList } = createAtomWithHook([], "LoadedPluginsList");
|
||||
|
||||
// Advanced Settings
|
||||
export const { atomInstance: Atom_OscIpAddress, useHook: useStore_OscIpAddress } = createAtomWithHook("127.0.0.1", "OscIpAddress");
|
||||
export const { atomInstance: Atom_OscPort, useHook: useStore_OscPort } = createAtomWithHook("9000", "OscPort");
|
||||
|
||||
Reference in New Issue
Block a user