mirror of
https://github.com/RPCSX/rpcsx-ui.git
synced 2026-01-31 01:05:23 +01:00
Migrate to react native/expo
This commit is contained in:
66
.github/workflows/android.yml
vendored
Normal file
66
.github/workflows/android.yml
vendored
Normal file
@@ -0,0 +1,66 @@
|
||||
name: Build RPCSX-UI For Android
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
on:
|
||||
push:
|
||||
paths-ignore:
|
||||
- "**/*.md"
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- "**/*.md"
|
||||
workflow_dispatch:
|
||||
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.ref }}-${{ github.event_name }}-${{ github.workflow }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
name: RPCSX-UI For Android
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@main
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup CCache
|
||||
uses: hendrikmuhs/ccache-action@v1.2
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 17
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Generate Android project
|
||||
run: npm run build:kit && npx expo prebuild --platform android
|
||||
|
||||
- name: Setup Gradle Cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('android/**/*.gradle*', 'android/**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Build using Gradle
|
||||
run: ./android/gradlew assembleRelease -p ./android --build-cache && mv android/app/build/outputs/apk/release/app-release.apk ./rpcsx.apk
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@main
|
||||
with:
|
||||
name: RPCSX-UI For Android
|
||||
path: rpcsx.apk
|
||||
compression-level: 0
|
||||
if-no-files-found: error
|
||||
16
.github/workflows/build.yml
vendored
16
.github/workflows/build.yml
vendored
@@ -1,5 +1,9 @@
|
||||
name: Build RPCSX-UI
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
on:
|
||||
push:
|
||||
paths-ignore:
|
||||
@@ -49,21 +53,25 @@ jobs:
|
||||
npm install
|
||||
|
||||
- name: Build ${{ matrix.name }}
|
||||
run: npm run build
|
||||
run: npm run build:web:release
|
||||
|
||||
- name: Check types
|
||||
run: npm run validate
|
||||
|
||||
- name: Install package dependencies for ${{ matrix.name }}
|
||||
run: |
|
||||
(cd electron; npm install)
|
||||
|
||||
- name: Package for ${{ matrix.name }}
|
||||
run: npx electron-forge package --platform ${{ matrix.platform }} --arch ${{ matrix.arch }}
|
||||
run: (cd electron; npx electron-forge package --platform ${{ matrix.platform }} --arch ${{ matrix.arch }})
|
||||
|
||||
- name: Make for ${{ matrix.name }}
|
||||
run: npx electron-forge make --platform ${{ matrix.platform }} --arch ${{ matrix.arch }}
|
||||
run: (cd electron; npx electron-forge make --platform ${{ matrix.platform }} --arch ${{ matrix.arch }} --skip-package)
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@main
|
||||
with:
|
||||
name: RPCSX-UI For ${{ matrix.name }}
|
||||
path: out/make/*
|
||||
path: electron/out/make/*
|
||||
compression-level: 0
|
||||
if-no-files-found: error
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -12,3 +12,8 @@ vite.config.ts.timestamp-*
|
||||
/.idea
|
||||
/.vscode
|
||||
/out
|
||||
|
||||
expo-env.d.ts
|
||||
.expo/
|
||||
android/
|
||||
/electron/out/
|
||||
|
||||
59
app.config.ts
Normal file
59
app.config.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { ConfigContext, ExpoConfig } from 'expo/config';
|
||||
|
||||
export default ({ config }: ConfigContext): ExpoConfig => ({
|
||||
...config,
|
||||
name: "RPCSX",
|
||||
slug: "rpcsx-ui",
|
||||
version: "0.1.0",
|
||||
icon: "rpcsx-ui/assets/images/icon.png",
|
||||
scheme: "rpcsx",
|
||||
userInterfaceStyle: "automatic",
|
||||
newArchEnabled: true,
|
||||
ios: {
|
||||
supportsTablet: true,
|
||||
bundleIdentifier: "net.rpcsx"
|
||||
},
|
||||
android: {
|
||||
adaptiveIcon: {
|
||||
foregroundImage: "rpcsx-ui/assets/images/icon.png",
|
||||
backgroundColor: "#ffffff"
|
||||
},
|
||||
edgeToEdgeEnabled: true,
|
||||
package: "net.rpcsx.next"
|
||||
},
|
||||
web: {
|
||||
bundler: "metro",
|
||||
output: "single",
|
||||
favicon: "rpcsx-ui/assets/images/favicon.png"
|
||||
},
|
||||
plugins: [
|
||||
"expo-asset",
|
||||
"expo-font",
|
||||
"expo-router",
|
||||
"expo-web-browser",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "rpcsx-ui/assets/images/icon.png",
|
||||
"imageWidth": 300,
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-document-picker",
|
||||
{
|
||||
"iCloudContainerEnvironment": "Production"
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-dev-client",
|
||||
{
|
||||
"launchMode": "most-recent"
|
||||
}
|
||||
]
|
||||
],
|
||||
experiments: {
|
||||
typedRoutes: true,
|
||||
}
|
||||
});
|
||||
9
build.mjs
Normal file
9
build.mjs
Normal file
@@ -0,0 +1,9 @@
|
||||
const kit = await import("./rpcsx-ui-kit/build/main.js");
|
||||
const options = { rootDir: import.meta.dirname, distDir: `${import.meta.dirname}/electron/build` };
|
||||
|
||||
try {
|
||||
await kit.build(options);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
}
|
||||
31
cleanup-svelte.sh
Executable file
31
cleanup-svelte.sh
Executable file
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script to clean up Svelte files after React conversion
|
||||
# Run this only after verifying all React components work correctly
|
||||
|
||||
echo "This script will delete all .svelte files and svelte-related configuration."
|
||||
echo "Make sure you have tested the React components first!"
|
||||
echo ""
|
||||
read -p "Are you sure you want to continue? (y/N): " -n 1 -r
|
||||
echo ""
|
||||
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "Cleanup cancelled."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Removing .svelte files..."
|
||||
find . -name "*.svelte" -type f -delete
|
||||
|
||||
echo "Removing svelte.config.js..."
|
||||
rm -f svelte.config.js
|
||||
|
||||
echo "Removing Svelte-specific dependencies..."
|
||||
npm uninstall @sveltejs/adapter-static @sveltejs/kit @sveltejs/vite-plugin-svelte svelte
|
||||
|
||||
echo "Cleanup complete!"
|
||||
echo ""
|
||||
echo "Don't forget to:"
|
||||
echo "1. Update any remaining imports that reference .svelte files to .tsx"
|
||||
echo "2. Test that the build system works correctly"
|
||||
echo "3. Update the rpcsx-ui-kit build system if needed"
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
"files": [
|
||||
{
|
||||
"source": "/src/locales/en.json",
|
||||
"translation": "/src/locales/%locale%.json"
|
||||
"source": "/rpcsx-ui/src/**/locales/en.json",
|
||||
"translation": "%original_path%/%locale%.json"
|
||||
}
|
||||
]
|
||||
|
||||
5099
electron/package-lock.json
generated
Normal file
5099
electron/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
electron/package.json
Normal file
26
electron/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "rpcsx-ui-package",
|
||||
"version": "0.1.0",
|
||||
"description": "RPCSX UI",
|
||||
"type": "module",
|
||||
"main": "build/main.js",
|
||||
"author": {
|
||||
"name": "RPCSX Team"
|
||||
},
|
||||
"scripts": {
|
||||
"package": "electron-forge package",
|
||||
"make": "electron-forge make"
|
||||
},
|
||||
"license": "GPL-3.0-only",
|
||||
"devDependencies": {
|
||||
"@electron-forge/cli": "^7.8.3",
|
||||
"@electron-forge/maker-squirrel": "^7.8.3",
|
||||
"@electron-forge/maker-zip": "^7.8.3",
|
||||
"@electron-forge/plugin-auto-unpack-natives": "^7.8.3",
|
||||
"@electron-forge/plugin-fuses": "^7.8.3",
|
||||
"@electron/fuses": "^1.8.0",
|
||||
"@reforged/maker-appimage": "^5.0.0",
|
||||
"electron": "^37.3.0",
|
||||
"electron-squirrel-startup": "^1.0.1"
|
||||
}
|
||||
}
|
||||
@@ -3,33 +3,42 @@ import tseslint from 'typescript-eslint';
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
import tsEslintPlugin from '@typescript-eslint/eslint-plugin';
|
||||
import globals from "globals";
|
||||
import expoConfig from 'eslint-config-expo/flat';
|
||||
import { defineConfig } from 'eslint/config';
|
||||
|
||||
export default tseslint.config(
|
||||
eslint.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
|
||||
export default defineConfig([
|
||||
expoConfig,
|
||||
{
|
||||
plugins: {
|
||||
'@typescript-eslint': tsEslintPlugin,
|
||||
},
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
ignores: ['dist/*'],
|
||||
},
|
||||
...tseslint.config(
|
||||
eslint.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
{
|
||||
plugins: {
|
||||
'@typescript-eslint': tsEslintPlugin,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
semi: [
|
||||
"error",
|
||||
"always"
|
||||
],
|
||||
'no-undef': 'off',
|
||||
"no-unused-vars": "off",
|
||||
'no-empty': "off",
|
||||
'@typescript-eslint/no-explicit-any': "off",
|
||||
"@typescript-eslint/no-unused-vars": "warn",
|
||||
"@typescript-eslint/explicit-function-return-type": "off"
|
||||
},
|
||||
}
|
||||
);
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
semi: [
|
||||
"error",
|
||||
"always"
|
||||
],
|
||||
'no-undef': 'off',
|
||||
"no-unused-vars": "off",
|
||||
'no-empty': "off",
|
||||
'@typescript-eslint/no-explicit-any': "off",
|
||||
"@typescript-eslint/no-unused-vars": "warn",
|
||||
"@typescript-eslint/explicit-function-return-type": "off"
|
||||
},
|
||||
}
|
||||
)
|
||||
]);
|
||||
|
||||
|
||||
31
metro.config.cjs
Normal file
31
metro.config.cjs
Normal file
@@ -0,0 +1,31 @@
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
const path = require('path');
|
||||
|
||||
const config = getDefaultConfig(path.join(__dirname));
|
||||
|
||||
const kit = require(`./rpcsx-ui-kit/build/main.js`);
|
||||
|
||||
const options = { rootDir: path.join(__dirname) };
|
||||
const generatedWorkspacePromise = kit.generate(options);
|
||||
module.exports = async () => {
|
||||
const generatedWorkspace = await generatedWorkspacePromise;
|
||||
const resolver = await kit.createResolver(generatedWorkspace);
|
||||
await kit.buildGenerated(options, generatedWorkspace.workspace, resolver);
|
||||
|
||||
if (config.resolver) {
|
||||
config.resolver.resolveRequest = (context, moduleName, platform) => {
|
||||
const result = resolver(moduleName, context.originModulePath, platform);
|
||||
|
||||
if (result) {
|
||||
return {
|
||||
type: 'sourceFile',
|
||||
filePath: result
|
||||
};
|
||||
}
|
||||
|
||||
return context.resolveRequest(context, moduleName, platform);
|
||||
};
|
||||
}
|
||||
|
||||
return config;
|
||||
};
|
||||
14294
package-lock.json
generated
14294
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
84
package.json
84
package.json
@@ -3,59 +3,89 @@
|
||||
"version": "0.1.0",
|
||||
"description": "RPCSX UI",
|
||||
"type": "module",
|
||||
"main": "rpcsx-ui/build/main.js",
|
||||
"main": ".rpcsx-ui-kit/rpcsx-ui-expo/navigation/src/index.tsx",
|
||||
"author": {
|
||||
"name": "RPCSX Team"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "npm run -w rpcsx-ui-kit build && vite build",
|
||||
"validate": "tsc -b .",
|
||||
"start": "electron-forge start",
|
||||
"package": "electron-forge package",
|
||||
"make": "electron-forge make"
|
||||
"build:kit": "npm run -w rpcsx-ui-kit build",
|
||||
"build:web:server": "node ./build.mjs",
|
||||
"build:web:ui": "expo export --platform web --dev --output-dir electron/build/ui --no-minify --source-maps",
|
||||
"build:web:ui:release": "expo export --platform web --dev --output-dir electron/build/ui",
|
||||
"build:web": "npm run build:kit && npm run build:web:server && npm run build:web:ui",
|
||||
"build:web:release": "npm run build:kit && npm run build:web:server && npm run build:web:ui:release",
|
||||
"build:android": "npm run build:kit && expo prebuild --platform android && ./android/gradlew assembleDebug -p ./android",
|
||||
"build:android:release": "npm run build:kit && expo prebuild --platform android && ./android/gradlew assembleRelease -p ./android",
|
||||
"build:all": "npm run build && npm run build:web && npm run build:android",
|
||||
"validate": "",
|
||||
"dev:ui": "npx expo start --dev-client",
|
||||
"dev:web:server": "electron build/main.js --dev",
|
||||
"install:android:release": "adb install android/app/build/outputs/apk/release/app-release.apk",
|
||||
"install:android": "adb install android/app/build/outputs/apk/debug/app-debug.apk"
|
||||
},
|
||||
"license": "GPL-3.0-only",
|
||||
"workspaces": [
|
||||
"./rpcsx-ui-kit"
|
||||
],
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^8.39.1",
|
||||
"@typescript-eslint/parser": "^8.39.1",
|
||||
"@expo/metro-runtime": "~5.0.4",
|
||||
"@expo/vector-icons": "^14.1.0",
|
||||
"@react-native-documents/picker": "^10.1.5",
|
||||
"@react-native/assets-registry": "^0.81.0",
|
||||
"@react-navigation/bottom-tabs": "^7.3.10",
|
||||
"@react-navigation/elements": "^2.3.8",
|
||||
"@react-navigation/native": "^7.1.6",
|
||||
"expo": "^53.0.20",
|
||||
"expo-blur": "~14.1.5",
|
||||
"expo-constants": "~17.1.7",
|
||||
"expo-dev-client": "^5.2.4",
|
||||
"expo-document-picker": "^13.1.6",
|
||||
"expo-font": "~13.3.2",
|
||||
"expo-haptics": "~14.1.4",
|
||||
"expo-image": "~2.4.0",
|
||||
"expo-linking": "~7.1.7",
|
||||
"expo-router": "~5.1.4",
|
||||
"expo-splash-screen": "~0.30.10",
|
||||
"expo-status-bar": "~2.2.3",
|
||||
"expo-symbols": "~0.4.5",
|
||||
"expo-system-ui": "~5.0.10",
|
||||
"expo-web-browser": "~14.2.0",
|
||||
"glob": "^11.0.3",
|
||||
"i18next": "^25.3.6",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"json5": "^2.2.3",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"prettier": "^3.6.2",
|
||||
"svelte-hero-icons": "^5.2.0",
|
||||
"svelte-i18n": "^4.0.1"
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react-native": "0.79.5",
|
||||
"react-native-gesture-handler": "~2.24.0",
|
||||
"react-native-reanimated": "~3.17.4",
|
||||
"react-native-safe-area-context": "^5.4.0",
|
||||
"react-native-screens": "~4.11.1",
|
||||
"react-native-web": "^0.20.0",
|
||||
"react-native-webview": "13.13.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-forge/cli": "^7.8.3",
|
||||
"@electron-forge/maker-squirrel": "^7.8.3",
|
||||
"@electron-forge/maker-zip": "^7.8.3",
|
||||
"@electron-forge/plugin-auto-unpack-natives": "^7.8.3",
|
||||
"@electron-forge/plugin-fuses": "^7.8.3",
|
||||
"@electron/fuses": "^1.8.0",
|
||||
"@expo/metro-config": "~0.20.0",
|
||||
"@reforged/maker-appimage": "^5.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.9",
|
||||
"@sveltejs/kit": "^2.31.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.1.2",
|
||||
"@tailwindcss/postcss": "^4.1.12",
|
||||
"@tailwindcss/vite": "^4.1.12",
|
||||
"@tauri-apps/cli": "^2.7.1",
|
||||
"@tauri-apps/cli": "^2.8.1",
|
||||
"@types/node": "^24.3.0",
|
||||
"electron": "^37.3.0",
|
||||
"@types/react": "~19.0.10",
|
||||
"electron": "^37.3.1",
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"esbuild": "^0.25.9",
|
||||
"eslint": "^9.33.0",
|
||||
"eslint-config-expo": "~9.2.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"metro": "^0.82.5",
|
||||
"postcss": "^8.5.6",
|
||||
"rpcsx-ui-kit": "file:rpcsx-ui-kit",
|
||||
"svelte": "^5.38.1",
|
||||
"svelte-check": "^4.3.1",
|
||||
"tailwindcss": "^4.1.12",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.39.1",
|
||||
"vite": "^7.1.2"
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.40.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
2322
rpcsx-ui-kit/src/generators.ts
Normal file
2322
rpcsx-ui-kit/src/generators.ts
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -23,5 +23,5 @@
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
]
|
||||
, "../rpcsx-ui/app.config.ts" ]
|
||||
}
|
||||
27
rpcsx-ui/.vscode/launch.json
vendored
27
rpcsx-ui/.vscode/launch.json
vendored
@@ -1,20 +1,27 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Launch Program",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
"name": "Build",
|
||||
"runtimeExecutable": "vite",
|
||||
"args": [
|
||||
"build"
|
||||
],
|
||||
"program": "${workspaceFolder}/build/main.js",
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/build/**/*.js"
|
||||
]
|
||||
"cwd": "${workspaceFolder}",
|
||||
"sourceMaps": true
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Launch",
|
||||
"runtimeExecutable": "electron",
|
||||
"args": [
|
||||
"rpcsx-ui/build/main.js"
|
||||
],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"sourceMaps": true
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
rpcsx-ui/assets/fonts/SpaceMono-Regular.ttf
Executable file
BIN
rpcsx-ui/assets/fonts/SpaceMono-Regular.ttf
Executable file
Binary file not shown.
BIN
rpcsx-ui/assets/images/favicon.png
Normal file
BIN
rpcsx-ui/assets/images/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
BIN
rpcsx-ui/assets/images/icon.png
Normal file
BIN
rpcsx-ui/assets/images/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 480 KiB |
BIN
rpcsx-ui/assets/images/rpcsx-large.png
Normal file
BIN
rpcsx-ui/assets/images/rpcsx-large.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.1 MiB |
BIN
rpcsx-ui/assets/images/rpcsx-logo.png
Normal file
BIN
rpcsx-ui/assets/images/rpcsx-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
rpcsx-ui/assets/images/rpcsx-logo@2x.png
Normal file
BIN
rpcsx-ui/assets/images/rpcsx-logo@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
BIN
rpcsx-ui/assets/images/rpcsx-logo@3x.png
Normal file
BIN
rpcsx-ui/assets/images/rpcsx-logo@3x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
@@ -7,6 +7,9 @@
|
||||
},
|
||||
{
|
||||
"name": "explorer"
|
||||
},
|
||||
{
|
||||
"name": "github"
|
||||
}
|
||||
]
|
||||
}
|
||||
20
rpcsx-ui/src/app/server/initialization.ts
Normal file
20
rpcsx-ui/src/app/server/initialization.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as bridge from '$core/bridge';
|
||||
import * as explorer from '$explorer';
|
||||
import { Window } from '$core/Window';
|
||||
|
||||
|
||||
const mainWindow: Window = {
|
||||
pushView: (...params) => bridge.viewPush(...params),
|
||||
setView: (...params) => bridge.viewSet(...params),
|
||||
popView: () => bridge.viewPop(),
|
||||
};
|
||||
|
||||
export function initialize() {
|
||||
explorer.pushExplorerView(mainWindow, {
|
||||
filter: {
|
||||
type: 'game'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
192
rpcsx-ui/src/app/server/initialization.web.ts
Normal file
192
rpcsx-ui/src/app/server/initialization.web.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { app, net, protocol, session, BrowserWindow, ipcMain } from 'electron';
|
||||
import * as locations from '$core/locations.js';
|
||||
import { PathLike } from 'fs';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import url from 'url';
|
||||
import { Future } from '$core/Future.js';
|
||||
import { shutdown } from '../../core/server/ComponentInstance';
|
||||
import * as explorer from '$explorer';
|
||||
import { Window } from '$core/Window';
|
||||
|
||||
function toWindow(browserWindow: BrowserWindow): Window {
|
||||
return {
|
||||
pushView: (name, props) => { browserWindow.webContents.send("view/push", name, props); },
|
||||
setView: (name, props) => { browserWindow.webContents.send("view/set", name, props); },
|
||||
popView: () => { browserWindow.webContents.send("view/pop"); },
|
||||
};
|
||||
}
|
||||
|
||||
function setupElectron() {
|
||||
const uiDirectory = path.join(locations.builtinResourcesPath, "ui");
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: 'app',
|
||||
privileges: {
|
||||
standard: true,
|
||||
secure: true,
|
||||
allowServiceWorkers: true,
|
||||
supportFetchAPI: true,
|
||||
codeCache: true
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const fixPath = async (loc: PathLike) => {
|
||||
loc = loc.toString();
|
||||
if (loc.length === 0) {
|
||||
return "index.html";
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(loc);
|
||||
if (stat.isFile()) {
|
||||
return loc;
|
||||
}
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
return fixPath(path.join(loc, "index.html"));
|
||||
}
|
||||
} catch {
|
||||
const ext = path.extname(loc);
|
||||
if (ext === ".html") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
if ((await fs.stat(loc + ".html")).isFile()) {
|
||||
return loc + ".html";
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
app.on('ready', () => {
|
||||
session.defaultSession.protocol.handle('app', async (request) => {
|
||||
const requestUrl = new URL(request.url);
|
||||
let pathname = requestUrl.pathname;
|
||||
if (pathname == "/Explorer") {
|
||||
pathname = "/";
|
||||
}
|
||||
|
||||
const filePath = path.join(uiDirectory, decodeURIComponent(pathname));
|
||||
console.log(`open ${filePath}, request ${pathname}`);
|
||||
|
||||
const relativePath = path.relative(uiDirectory, filePath);
|
||||
const isSafe = !relativePath.startsWith('..') && !path.isAbsolute(relativePath);
|
||||
|
||||
if (!isSafe) {
|
||||
return new Response('bad request', {
|
||||
status: 400,
|
||||
headers: { 'content-type': 'text/html' }
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const absolutePath = await fixPath(path.join(uiDirectory, relativePath));
|
||||
|
||||
if (absolutePath) {
|
||||
return net.fetch(url.pathToFileURL(absolutePath).toString());
|
||||
}
|
||||
} catch { }
|
||||
|
||||
return new Response('Not Found', {
|
||||
status: 404,
|
||||
headers: { 'content-type': 'text/html' }
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let MainWindow: BrowserWindow;
|
||||
|
||||
async function activateMainWindow() {
|
||||
console.log('window creation');
|
||||
const win = new BrowserWindow({
|
||||
title: "RPCSX",
|
||||
width: 800,
|
||||
height: 600,
|
||||
x: 0,
|
||||
y: 0,
|
||||
fullscreen: false,
|
||||
webPreferences: {
|
||||
preload: path.join(locations.builtinResourcesPath, "preload.js"),
|
||||
webSecurity: false
|
||||
}
|
||||
});
|
||||
|
||||
MainWindow = win;
|
||||
|
||||
if (process.argv.includes("--dev")) {
|
||||
await win.loadURL("http://localhost:8081/");
|
||||
} else {
|
||||
await win.loadURL("app://-");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
setupElectron();
|
||||
|
||||
export function initialize() {
|
||||
console.log('web initialization');
|
||||
ipcMain.on('window/create', (_event, options) => {
|
||||
const win = new BrowserWindow({
|
||||
webPreferences: {
|
||||
preload: path.join(locations.builtinResourcesPath, "preload.mjs"),
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
win.loadURL(`app://-/${options.url}`);
|
||||
});
|
||||
|
||||
// console.log(await github.githubReleases({
|
||||
// owner: "RPCSX",
|
||||
// repository: "rpcsx"
|
||||
// }));
|
||||
|
||||
const createWindow = async () => {
|
||||
await activateMainWindow();
|
||||
|
||||
const uiInitializedFuture = new Future<void>();
|
||||
ipcMain.once('frame/initialized', () => {
|
||||
console.log('frame/initialized');
|
||||
uiInitializedFuture.resolve();
|
||||
});
|
||||
|
||||
if (!uiInitializedFuture.hasValue()) {
|
||||
console.log('waiting for ui initialization completion');
|
||||
await uiInitializedFuture.value;
|
||||
}
|
||||
|
||||
uiInitializedFuture.dispose();
|
||||
|
||||
console.log('initialization complete');
|
||||
explorer.pushExplorerView(toWindow(MainWindow), {
|
||||
filter: {
|
||||
type: 'game'
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
app.whenReady().then(() => {
|
||||
createWindow();
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', async () => {
|
||||
await shutdown();
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,168 +1,5 @@
|
||||
import { app, net, protocol, session, BrowserWindow, ipcMain } from 'electron';
|
||||
import * as locations from '$core/locations.js';
|
||||
import { PathLike } from 'fs';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import url from 'url';
|
||||
import { Future } from '$core/Future.js';
|
||||
import { shutdown } from '../../core/server/ComponentInstance';
|
||||
import * as explorer from '$explorer';
|
||||
import { initialize } from "./initialization";
|
||||
|
||||
function setupElectron() {
|
||||
const uiDirectory = path.join(locations.builtinResourcesPath, "ui");
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: 'app',
|
||||
privileges: {
|
||||
standard: true,
|
||||
secure: true,
|
||||
allowServiceWorkers: true,
|
||||
supportFetchAPI: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const fixPath = async (loc: PathLike) => {
|
||||
loc = loc.toString();
|
||||
if (loc.length === 0) {
|
||||
return "index.html";
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(loc);
|
||||
if (stat.isFile()) {
|
||||
return loc;
|
||||
}
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
return fixPath(path.join(loc, "index.html"));
|
||||
}
|
||||
} catch {
|
||||
const ext = path.extname(loc);
|
||||
if (ext === ".html") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
if ((await fs.stat(loc + ".html")).isFile()) {
|
||||
return loc + ".html";
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
app.on('ready', () => {
|
||||
session.defaultSession.protocol.handle('app', async (request) => {
|
||||
const filePath = path.join(uiDirectory, decodeURIComponent(new URL(request.url).pathname));
|
||||
console.log(`open ${filePath}, request ${new URL(request.url).pathname}`);
|
||||
|
||||
const relativePath = path.relative(uiDirectory, filePath);
|
||||
const isSafe = !relativePath.startsWith('..') && !path.isAbsolute(relativePath);
|
||||
|
||||
if (!isSafe) {
|
||||
return new Response('bad request', {
|
||||
status: 400,
|
||||
headers: { 'content-type': 'text/html' }
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const absolutePath = await fixPath(path.join(uiDirectory, relativePath));
|
||||
|
||||
if (absolutePath) {
|
||||
return net.fetch(url.pathToFileURL(absolutePath).toString());
|
||||
}
|
||||
} catch { }
|
||||
|
||||
return new Response('Not Found', {
|
||||
status: 404,
|
||||
headers: { 'content-type': 'text/html' }
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let MainWindow: BrowserWindow;
|
||||
|
||||
async function activateMainWindow() {
|
||||
console.log('window creation');
|
||||
const win = new BrowserWindow({
|
||||
title: "RPCSX",
|
||||
width: 800,
|
||||
height: 600,
|
||||
x: 0,
|
||||
y: 0,
|
||||
fullscreen: false,
|
||||
webPreferences: {
|
||||
preload: path.join(locations.builtinResourcesPath, "preload.js"),
|
||||
webSecurity: false
|
||||
}
|
||||
});
|
||||
|
||||
MainWindow = win;
|
||||
|
||||
await win.loadURL("app://-");
|
||||
|
||||
}
|
||||
|
||||
setupElectron();
|
||||
|
||||
export function activate() {
|
||||
ipcMain.on('window/create', (_event, options) => {
|
||||
const win = new BrowserWindow({
|
||||
webPreferences: {
|
||||
preload: path.join(locations.builtinResourcesPath, "preload.mjs"),
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
win.loadURL(`app://-/${options.url}`);
|
||||
});
|
||||
|
||||
const createWindow = async () => {
|
||||
await activateMainWindow();
|
||||
|
||||
const uiInitializedFuture = new Future<void>();
|
||||
ipcMain.once('frame/initialized', () => {
|
||||
console.log('frame/initialized');
|
||||
uiInitializedFuture.resolve();
|
||||
});
|
||||
|
||||
if (!uiInitializedFuture.hasValue()) {
|
||||
console.log('waiting for ui initialization completion');
|
||||
await uiInitializedFuture.value;
|
||||
}
|
||||
|
||||
uiInitializedFuture.dispose();
|
||||
|
||||
console.log('initialization complete');
|
||||
|
||||
explorer.pushExplorerView(MainWindow.webContents, {
|
||||
query: "observer/executables",
|
||||
queryParams: {
|
||||
filter: { type: 'game' }
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
app.whenReady().then(() => {
|
||||
createWindow();
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', async () => {
|
||||
await shutdown();
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
export async function activate() {
|
||||
return initialize();
|
||||
}
|
||||
|
||||
@@ -37,6 +37,41 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"icon-resolution": {
|
||||
"type": "enum",
|
||||
"enumerators": {
|
||||
"normal": 0,
|
||||
"high": 1
|
||||
}
|
||||
},
|
||||
"localized-icon": {
|
||||
"type": "object",
|
||||
"params": {
|
||||
"uri": {
|
||||
"type": "string"
|
||||
},
|
||||
"lang": {
|
||||
"type": "string",
|
||||
"optional": true
|
||||
},
|
||||
"resolution": {
|
||||
"type": "icon-resolution",
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"localized-string": {
|
||||
"type": "object",
|
||||
"params": {
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"lang": {
|
||||
"type": "string",
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"client-info": {
|
||||
"type": "object",
|
||||
"params": {
|
||||
@@ -109,7 +144,8 @@
|
||||
"type": "string"
|
||||
},
|
||||
"requirements": {
|
||||
"type": "json-object"
|
||||
"type": "json-object",
|
||||
"optional": true
|
||||
},
|
||||
"args": {
|
||||
"type": "array",
|
||||
@@ -122,7 +158,8 @@
|
||||
"type": "object",
|
||||
"params": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
"type": "array",
|
||||
"item-type": "localized-string"
|
||||
},
|
||||
"version": {
|
||||
"type": "string"
|
||||
@@ -139,11 +176,13 @@
|
||||
"optional": true
|
||||
},
|
||||
"icon": {
|
||||
"type": "string",
|
||||
"type": "array",
|
||||
"item-type": "localized-icon",
|
||||
"optional": true
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"type": "array",
|
||||
"item-type": "localized-string",
|
||||
"optional": true
|
||||
},
|
||||
"region": {
|
||||
@@ -160,7 +199,8 @@
|
||||
"type": "object",
|
||||
"params": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
"type": "array",
|
||||
"item-type": "localized-string"
|
||||
},
|
||||
"version": {
|
||||
"type": "string"
|
||||
@@ -177,15 +217,13 @@
|
||||
"optional": true
|
||||
},
|
||||
"icon": {
|
||||
"type": "string",
|
||||
"type": "array",
|
||||
"item-type": "localized-icon",
|
||||
"optional": true
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"optional": true
|
||||
},
|
||||
"region": {
|
||||
"type": "string",
|
||||
"type": "array",
|
||||
"item-type": "localized-string",
|
||||
"optional": true
|
||||
},
|
||||
"id": {
|
||||
|
||||
3
rpcsx-ui/src/core/lib/CancelablePromise.ts
Normal file
3
rpcsx-ui/src/core/lib/CancelablePromise.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export interface CancelablePromise<T> extends Promise<T> {
|
||||
cancel(): void;
|
||||
}
|
||||
@@ -20,4 +20,5 @@ export type ComponentId = string;
|
||||
export type Component = {
|
||||
getId(): ComponentId;
|
||||
onClose(listener: () => void): IDisposable;
|
||||
sendEvent(event: string, params?: any): void;
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ export type IDisposable = {
|
||||
dispose: () => void | Promise<void>
|
||||
}
|
||||
|
||||
const noneFn = Object.freeze(function () { });
|
||||
export const noneFn = Object.freeze(function () { });
|
||||
|
||||
const noneDisposable: IDisposable = Object.freeze({
|
||||
dispose: noneFn
|
||||
@@ -86,6 +86,42 @@ export class Disposable implements IDisposable {
|
||||
return result === noneFn ? noneDisposable : new Disposable(result);
|
||||
}
|
||||
|
||||
static CreateEmpty(): Disposable {
|
||||
return new Disposable(noneFn);
|
||||
}
|
||||
|
||||
add(...x: (IDisposable | (() => void | Promise<void>))[]) {
|
||||
const unwrapped = Disposable.Unwrap(x);
|
||||
if (Disposable.IsNone(this)) {
|
||||
this._impl = unwrapped;
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof this._impl == "function") {
|
||||
if (typeof unwrapped == "function") {
|
||||
if (!Disposable.IsNone(unwrapped)) {
|
||||
this._impl = [this._impl, unwrapped];
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
unwrapped.unshift(this._impl);
|
||||
this._impl = unwrapped;
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof unwrapped == "function") {
|
||||
if (!Disposable.IsNone(unwrapped)) {
|
||||
this._impl.push(unwrapped);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this._impl.push(...unwrapped);
|
||||
}
|
||||
|
||||
async dispose() {
|
||||
const exceptions: any[] = [];
|
||||
|
||||
|
||||
122
rpcsx-ui/src/core/lib/Event.ts
Normal file
122
rpcsx-ui/src/core/lib/Event.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { CancelablePromise } from "./CancelablePromise";
|
||||
import { IDisposable, Disposable } from "./Disposable";
|
||||
import { createError } from "./Error";
|
||||
import { LinkedList } from "./LinkedList";
|
||||
|
||||
export interface Event<T> {
|
||||
(listener: (e: T) => unknown, disposable?: Disposable): IDisposable;
|
||||
}
|
||||
|
||||
export namespace Event {
|
||||
export const None: Event<any> = () => Disposable.None;
|
||||
|
||||
export function once<T>(event: Event<T>): Event<T> {
|
||||
return (listener, disposable?) => {
|
||||
let fired = false;
|
||||
|
||||
const result = event(e => {
|
||||
if (fired) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result) {
|
||||
result.dispose();
|
||||
} else {
|
||||
fired = true;
|
||||
}
|
||||
|
||||
return listener(e);
|
||||
}, disposable);
|
||||
|
||||
if (fired) {
|
||||
result.dispose();
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
export function toPromise<T>(event: Event<T>, disposable?: Disposable): CancelablePromise<T> {
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
const listener = once(event)(resolve, disposable);
|
||||
promise.cancel = () => {
|
||||
listener.dispose();
|
||||
reject(createError(ErrorCode.RequestCancelled));
|
||||
};
|
||||
}) as CancelablePromise<T>;
|
||||
|
||||
return promise;
|
||||
}
|
||||
}
|
||||
|
||||
type Listener<T> = (data: T) => void;
|
||||
|
||||
export class Emitter<T> implements IDisposable {
|
||||
private _firstListener?: Listener<T>;
|
||||
private _restListeners?: LinkedList<Listener<T>>;
|
||||
private _event?: Event<T>;
|
||||
private _disposed?: boolean;
|
||||
|
||||
|
||||
dispose() {
|
||||
this._disposed = true;
|
||||
this._restListeners?.dispose();
|
||||
this._restListeners = undefined;
|
||||
this._firstListener = undefined;
|
||||
this._event = undefined;
|
||||
}
|
||||
|
||||
get event(): Event<T> {
|
||||
this._event ??= (callback: (e: T) => unknown, disposable?: Disposable) => {
|
||||
if (this._disposed) {
|
||||
return Disposable.None;
|
||||
}
|
||||
|
||||
if (!this._restListeners && !this._firstListener) {
|
||||
this._firstListener = callback;
|
||||
|
||||
const result = {
|
||||
dispose: () => this._firstListener = undefined
|
||||
};
|
||||
|
||||
disposable?.add(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!this._restListeners) {
|
||||
this._restListeners = new LinkedList();
|
||||
}
|
||||
|
||||
const removeListener = this._restListeners.unshift(callback);
|
||||
const result = {
|
||||
dispose: () => {
|
||||
removeListener();
|
||||
|
||||
if (this._restListeners?.empty()) {
|
||||
this._restListeners.dispose();
|
||||
this._restListeners = undefined;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
disposable?.add(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
return this._event;
|
||||
}
|
||||
|
||||
emit(event: T) {
|
||||
if (this._restListeners) {
|
||||
this._restListeners.forEach(listener => listener(event));
|
||||
}
|
||||
|
||||
if (this._firstListener) {
|
||||
this._firstListener(event);
|
||||
}
|
||||
}
|
||||
|
||||
hasListeners() {
|
||||
return this._firstListener || this._restListeners;
|
||||
}
|
||||
};
|
||||
2
rpcsx-ui/src/core/lib/JsonTypes.d.ts
vendored
2
rpcsx-ui/src/core/lib/JsonTypes.d.ts
vendored
@@ -6,4 +6,4 @@ type JsonObject = {
|
||||
[P in string]: Json;
|
||||
};
|
||||
type JsonArray = Array<Json>;
|
||||
type Json = JsonNumber | JsonBoolean | JsonNull | JsonString | JsonObject | JsonArray;
|
||||
type Json = JsonNumber | JsonBoolean | JsonNull | JsonString | JsonArray | { [P in string]: Json };
|
||||
|
||||
73
rpcsx-ui/src/core/lib/LinkedList.ts
Normal file
73
rpcsx-ui/src/core/lib/LinkedList.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
class LinkedNode<T> {
|
||||
constructor(public value: T, public next?: LinkedNode<T>) {}
|
||||
}
|
||||
|
||||
export class LinkedList<T> {
|
||||
private _head?: LinkedNode<T> = undefined;
|
||||
|
||||
empty() {
|
||||
return this._head?.next == undefined;
|
||||
}
|
||||
|
||||
unshift(element: T) {
|
||||
if (this._head == undefined) {
|
||||
this._head = new LinkedNode<any>(undefined);
|
||||
}
|
||||
|
||||
const newHead = new LinkedNode<any>(undefined);
|
||||
|
||||
const node = this._head;
|
||||
node.value = element;
|
||||
newHead.next = node;
|
||||
|
||||
this._head = newHead;
|
||||
|
||||
return () => {
|
||||
if (newHead.next == node) {
|
||||
newHead.next = node.next;
|
||||
(node.value as any) = undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
shift() {
|
||||
if (!this._head?.next) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result = this._head.next.value;
|
||||
this._head = this._head.next;
|
||||
(this._head.value as any) = undefined;
|
||||
return result;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.clear();
|
||||
}
|
||||
|
||||
clear() {
|
||||
let node = this._head;
|
||||
while (node != undefined) {
|
||||
const nextNode = node.next;
|
||||
node.next = undefined;
|
||||
node = nextNode;
|
||||
}
|
||||
}
|
||||
|
||||
forEach(cb: (item: T, index: number) => void) {
|
||||
let node = this._head?.next;
|
||||
let index = 0;
|
||||
while (node !== undefined) {
|
||||
cb(node.value, index++);
|
||||
node = node.next;
|
||||
}
|
||||
}
|
||||
|
||||
*[Symbol.iterator](): Iterator<T> {
|
||||
let node = this._head?.next;
|
||||
while (node !== undefined) {
|
||||
yield node.value;
|
||||
node = node.next;
|
||||
}
|
||||
}
|
||||
}
|
||||
40
rpcsx-ui/src/core/lib/Localized.ts
Normal file
40
rpcsx-ui/src/core/lib/Localized.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
|
||||
export function getLocalizedString(string: LocalizedString[], langs: string[] = []) {
|
||||
for (let langIndex = 0; langIndex < langs.length; ++langIndex) {
|
||||
const lang = langs[langIndex];
|
||||
|
||||
for (let nameIndex = 0; nameIndex < string.length; ++nameIndex) {
|
||||
if (string[nameIndex].lang === lang) {
|
||||
return string[nameIndex].text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return string[0].text;
|
||||
}
|
||||
|
||||
export function getLocalizedIcon(icon: LocalizedIcon[], resolution: IconResolution = IconResolution.Normal, langs: string[] = []) {
|
||||
for (let langIndex = 0; langIndex < langs.length; ++langIndex) {
|
||||
const lang = langs[langIndex];
|
||||
|
||||
for (let iconIndex = 0; iconIndex < icon.length; ++iconIndex) {
|
||||
const localizedIcon = icon[iconIndex];
|
||||
if (localizedIcon.lang === lang && localizedIcon.resolution === resolution) {
|
||||
return localizedIcon.uri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let langIndex = 0; langIndex < langs.length; ++langIndex) {
|
||||
const lang = langs[langIndex];
|
||||
|
||||
for (let iconIndex = 0; iconIndex < icon.length; ++iconIndex) {
|
||||
const localizedIcon = icon[iconIndex];
|
||||
if (localizedIcon.lang === lang) {
|
||||
return localizedIcon.uri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return icon[0].uri;
|
||||
}
|
||||
@@ -1,6 +1,3 @@
|
||||
import * as fs from 'fs/promises';
|
||||
import { Stats } from 'fs';
|
||||
|
||||
type GenericSchema = {
|
||||
id?: string;
|
||||
label?: string;
|
||||
@@ -176,26 +173,26 @@ async function validateImpl(object: any, schema: Schema, path: string, onError:
|
||||
return emitError(SchemaErrorCode.InvalidType);
|
||||
}
|
||||
|
||||
if (schema.mustExist || schema.entity) {
|
||||
let stat: Stats | undefined;
|
||||
try {
|
||||
stat = await fs.stat(object);
|
||||
} catch { }
|
||||
// if (schema.mustExist || schema.entity) {
|
||||
// let stat: Stats | undefined;
|
||||
// try {
|
||||
// stat = await fs.stat(object);
|
||||
// } catch { }
|
||||
|
||||
if (stat) {
|
||||
if (schema.entity === "directory") {
|
||||
if (!stat.isDirectory()) {
|
||||
return emitError(SchemaErrorCode.ExpectedDirectory);
|
||||
}
|
||||
} else {
|
||||
if (!stat.isFile()) {
|
||||
return emitError(SchemaErrorCode.ExpectedFile);
|
||||
}
|
||||
}
|
||||
} else if (schema.mustExist) {
|
||||
return emitError(SchemaErrorCode.NotExists);
|
||||
}
|
||||
}
|
||||
// if (stat) {
|
||||
// if (schema.entity === "directory") {
|
||||
// if (!stat.isDirectory()) {
|
||||
// return emitError(SchemaErrorCode.ExpectedDirectory);
|
||||
// }
|
||||
// } else {
|
||||
// if (!stat.isFile()) {
|
||||
// return emitError(SchemaErrorCode.ExpectedFile);
|
||||
// }
|
||||
// }
|
||||
// } else if (schema.mustExist) {
|
||||
// return emitError(SchemaErrorCode.NotExists);
|
||||
// }
|
||||
// }
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
12
rpcsx-ui/src/core/lib/Stacktrace.ts
Normal file
12
rpcsx-ui/src/core/lib/Stacktrace.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export class Stacktrace {
|
||||
static Create() {
|
||||
const err = new Error();
|
||||
return new Stacktrace(err.stack ?? '');
|
||||
}
|
||||
|
||||
private constructor(readonly trace: string) { }
|
||||
|
||||
print() {
|
||||
console.warn(this.trace.split('\n').slice(2).join('\n'));
|
||||
}
|
||||
};
|
||||
1
rpcsx-ui/src/core/lib/ThemedColor.d.ts
vendored
Normal file
1
rpcsx-ui/src/core/lib/ThemedColor.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
type ThemedColor = { light: string; dark: string; };
|
||||
5
rpcsx-ui/src/core/lib/Window.ts
Normal file
5
rpcsx-ui/src/core/lib/Window.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type Window = {
|
||||
pushView: (name: string, props: any) => void | Promise<void>;
|
||||
setView: (name: string, props: any) => void | Promise<void>;
|
||||
popView: () => void | Promise<void>;
|
||||
};
|
||||
84
rpcsx-ui/src/core/lib/bridge.ts
Normal file
84
rpcsx-ui/src/core/lib/bridge.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
class Callback<T extends (...args: any[]) => any> {
|
||||
private _callback: T | undefined;
|
||||
private _queue: (() => void)[] = [];
|
||||
|
||||
set(callback: T) {
|
||||
this._callback = callback;
|
||||
this._queue.forEach(x => x());
|
||||
this._queue = [];
|
||||
}
|
||||
|
||||
call(...args: Parameters<T>) {
|
||||
if (this._callback) {
|
||||
return this._callback(...args);
|
||||
}
|
||||
|
||||
|
||||
return new Promise<ReturnType<T>>((resolve, reject) => {
|
||||
this._queue.push(() => {
|
||||
try {
|
||||
if (this._callback) {
|
||||
resolve(this._callback(...args));
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error("unexpected callback state"));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const callCb = new Callback<(method: string, params: any) => any>();
|
||||
const invokeCb = new Callback<(method: string, params: any) => void | Promise<void>>();
|
||||
const eventCb = new Callback<(event: string, handler: (...args: any[]) => Promise<void> | void) => void>();
|
||||
const viewPushCb = new Callback<(name: string, props: any) => void>();
|
||||
const viewSetCb = new Callback<(name: string, props: any) => void>();
|
||||
const viewPopCb = new Callback<() => void>();
|
||||
|
||||
export function setOnCall(cb: (...args: any[]) => any) {
|
||||
callCb.set(cb);
|
||||
}
|
||||
export function setOnInvoke(cb: (...args: any[]) => void | Promise<void>) {
|
||||
invokeCb.set(cb);
|
||||
}
|
||||
export function setOnEvent(cb: (...args: any[]) => () => void) {
|
||||
eventCb.set(cb);
|
||||
}
|
||||
|
||||
export function onViewPush(cb: (name: string, props: any) => void) {
|
||||
viewPushCb.set(cb);
|
||||
}
|
||||
export function onViewSet(cb: (name: string, props: any) => void) {
|
||||
viewSetCb.set(cb);
|
||||
}
|
||||
export function onViewPop(cb: () => void) {
|
||||
viewPopCb.set(cb);
|
||||
}
|
||||
|
||||
export function onEvent(event: string, handler: (...args: any[]) => Promise<void> | void) {
|
||||
eventCb.call(event, handler);
|
||||
}
|
||||
|
||||
export async function invoke(method: string, params: any): Promise<void> {
|
||||
return invokeCb.call(method, params);
|
||||
}
|
||||
|
||||
export async function call(method: string, params: any): Promise<any> {
|
||||
return callCb.call(method, params);
|
||||
}
|
||||
|
||||
export function viewPush(name: string, props: any) {
|
||||
viewPushCb.call(name, props);
|
||||
}
|
||||
export function viewSet(name: string, props: any) {
|
||||
viewSetCb.call(name, props);
|
||||
}
|
||||
export function viewPop() {
|
||||
viewPopCb.call();
|
||||
}
|
||||
|
||||
export function sendViewInitializationComplete() {
|
||||
}
|
||||
78
rpcsx-ui/src/core/renderer/Colors.ts
Normal file
78
rpcsx-ui/src/core/renderer/Colors.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
export const Colors = {
|
||||
light: {
|
||||
text: "#11181c",
|
||||
primary: '#4d5c92',
|
||||
onPrimary: '#ffffff',
|
||||
primaryContainer: '#dce1ff',
|
||||
onPrimaryContainer: '#354479',
|
||||
secondary: '#595d72',
|
||||
onSecondary: '#ffffff',
|
||||
secondaryContainer: '#dee1f9',
|
||||
onSecondaryContainer: '#424659',
|
||||
tertiary: '#75546f',
|
||||
onTertiary: '#ffffff',
|
||||
tertiaryContainer: '#ffd7f5',
|
||||
onTertiaryContainer: '#5b3d57',
|
||||
error: '#ba1a1a',
|
||||
onError: '#ffffff',
|
||||
errorContainer: '#ffdad6',
|
||||
onErrorContainer: '#93000a',
|
||||
background: '#faf8ff',
|
||||
onBackground: '#1a1b21',
|
||||
surface: '#faf8ff',
|
||||
onSurface: '#1a1b21',
|
||||
surfaceVariant: '#e2e1ec',
|
||||
onSurfaceVariant: '#45464f',
|
||||
outline: '#767680',
|
||||
outlineVariant: '#c6c6d0',
|
||||
scrim: '#000000',
|
||||
inverseSurface: '#2f3036',
|
||||
inverseOnSurface: '#f1f0f7',
|
||||
inversePrimary: '#b6c4ff',
|
||||
surfaceDim: '#dad9e0',
|
||||
surfaceBright: '#faf8ff',
|
||||
surfaceContainerLowest: '#ffffff',
|
||||
surfaceContainerLow: '#f4f3fa',
|
||||
surfaceContainer: '#eeedf4',
|
||||
surfaceContainerHigh: '#e9e7ef',
|
||||
surfaceContainerHighest: '#e3e1e9',
|
||||
},
|
||||
dark: {
|
||||
text: "#ecedee",
|
||||
primary: '#b6c4ff',
|
||||
onPrimary: '#1d2d61',
|
||||
primaryContainer: '#354479',
|
||||
onPrimaryContainer: '#dce1ff',
|
||||
secondary: '#c2c5dd',
|
||||
onSecondary: '#2b3042',
|
||||
secondaryContainer: '#424659',
|
||||
onSecondaryContainer: '#dee1f9',
|
||||
tertiary: '#e3bada',
|
||||
onTertiary: '#432740',
|
||||
tertiaryContainer: '#5b3d57',
|
||||
onTertiaryContainer: '#ffd7f5',
|
||||
error: '#ffb4ab',
|
||||
onError: '#690005',
|
||||
errorContainer: '#93000a',
|
||||
onErrorContainer: '#ffdad6',
|
||||
background: '#121318',
|
||||
onBackground: '#e3e1e9',
|
||||
surface: '#121318',
|
||||
onSurface: '#e3e1e9',
|
||||
surfaceVariant: '#45464f',
|
||||
onSurfaceVariant: '#c6c6d0',
|
||||
outline: '#90909a',
|
||||
outlineVariant: '#45464f',
|
||||
scrim: '#000000',
|
||||
inverseSurface: '#e3e1e9',
|
||||
inverseOnSurface: '#2f3036',
|
||||
inversePrimary: '#4d5c92',
|
||||
surfaceDim: '#121318',
|
||||
surfaceBright: '#38393f',
|
||||
surfaceContainerLowest: '#0d0e13',
|
||||
surfaceContainerLow: '#1a1b21',
|
||||
surfaceContainer: '#1e1f25',
|
||||
surfaceContainerHigh: '#292a2f',
|
||||
surfaceContainerHighest: '#34343a',
|
||||
}
|
||||
};
|
||||
22
rpcsx-ui/src/core/renderer/ExternalLink.tsx
Normal file
22
rpcsx-ui/src/core/renderer/ExternalLink.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Href, Link } from 'expo-router';
|
||||
import { openBrowserAsync } from 'expo-web-browser';
|
||||
import { type ComponentProps } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
|
||||
|
||||
export function ExternalLink({ href, ...rest }: Props) {
|
||||
return (
|
||||
<Link
|
||||
target="_blank"
|
||||
{...rest}
|
||||
href={href}
|
||||
onPress={async (event) => {
|
||||
if (Platform.OS !== 'web') {
|
||||
event.preventDefault();
|
||||
await openBrowserAsync(href);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
|
||||
let {
|
||||
onLoad,
|
||||
onError,
|
||||
url,
|
||||
}: {
|
||||
onLoad?: (event: Event) => void;
|
||||
onError?: (event: ErrorEvent) => void;
|
||||
url: string;
|
||||
} = $props();
|
||||
|
||||
let script: HTMLElement;
|
||||
|
||||
onMount(async () => {
|
||||
if (onLoad) {
|
||||
script.addEventListener("load", (event) => onLoad(event));
|
||||
}
|
||||
if (onError) {
|
||||
script.addEventListener("error", (event) => onError(event));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<script bind:this={script} src={url} type="module"></script>
|
||||
</svelte:head>
|
||||
@@ -1,27 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Icon, ArrowPath } from "svelte-hero-icons";
|
||||
import { _ } from "svelte-i18n";
|
||||
|
||||
export let gameCount: number;
|
||||
|
||||
let firmwareVersion = "0.0.0";
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="sticky bottom-0 flex flex-row p-2 gap-2 bg-neutral-800 items-center text-sm"
|
||||
>
|
||||
<button
|
||||
class="border border-neutral-600 bg-neutral-700 text-white h-5 w-5 p-1 rounded hover:bg-neutral-600 active:bg-neutral-700 shadow-sm"
|
||||
>
|
||||
<Icon src={ArrowPath} solid />
|
||||
</button>
|
||||
<h1>{$_("footer.games.installed", { values: { count: gameCount } })}</h1>
|
||||
|
||||
<div class="flex-grow"></div>
|
||||
|
||||
<h1>
|
||||
{$_("footer.firmware.version", {
|
||||
values: { version: firmwareVersion },
|
||||
})}
|
||||
</h1>
|
||||
</div>
|
||||
17
rpcsx-ui/src/core/renderer/HapticPressable.tsx
Normal file
17
rpcsx-ui/src/core/renderer/HapticPressable.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { ComponentProps } from "react";
|
||||
import { Pressable } from "react-native";
|
||||
import * as Haptics from 'expo-haptics';
|
||||
|
||||
export function HapticPressable(props: ComponentProps<typeof Pressable>) {
|
||||
return (
|
||||
<Pressable
|
||||
{...props}
|
||||
onPressIn={(ev) => {
|
||||
if (process.env.EXPO_OS !== "web") {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
props.onPressIn?.(ev);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Icon, Squares2x2, ListBullet, Cog6Tooth, ArrowLeft } from "svelte-hero-icons";
|
||||
import { _ } from "svelte-i18n";
|
||||
import * as core from "$core";
|
||||
// import { openWindow } from "helpers/window";
|
||||
|
||||
export let searchTerm: string;
|
||||
export let layout: "list" | "grid" = "list";
|
||||
export let showBackBtn = true;
|
||||
|
||||
let searchElement: HTMLInputElement;
|
||||
|
||||
export function focus() {
|
||||
searchElement.focus();
|
||||
}
|
||||
|
||||
async function createSettings() {
|
||||
console.log("Making settings widow");
|
||||
if (window.electron) {
|
||||
window.electron.ipcRenderer.send("view/push", "settings", {});
|
||||
}
|
||||
// openWindow({
|
||||
// url: "/settings",
|
||||
// title: "Settings",
|
||||
// });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="sticky top-0 flex flex-row items-center p-2 gap-2 bg-neutral-900">
|
||||
<div class="inline-flex shadow-sm rounded" role="group">
|
||||
{#if showBackBtn}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => core.popView()}
|
||||
class="border border-neutral-600 text-white disabled:text-neutral-400 h-8 w-8 p-1 rounded bg-neutral-700 hover:bg-neutral-600 active:bg-neutral-700 shadow-sm"
|
||||
>
|
||||
<Icon src={ArrowLeft} solid />
|
||||
</button>
|
||||
<div class="border-[0.1px] h-full border-neutral-900"></div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
disabled={layout === "list"}
|
||||
type="button"
|
||||
on:click={() => (layout = "list")}
|
||||
class="border border-r-0 border-neutral-600 text-white disabled:text-neutral-400 h-8 w-10 p-1 rounded-s bg-neutral-700 hover:bg-neutral-600 active:bg-neutral-700 shadow-sm"
|
||||
>
|
||||
<Icon src={ListBullet} solid />
|
||||
</button>
|
||||
<div class="border-[0.1px] h-full border-neutral-900"></div>
|
||||
<button
|
||||
disabled={layout === "grid"}
|
||||
type="button"
|
||||
on:click={() => (layout = "grid")}
|
||||
class="border border-l-0 border-neutral-600 text-white disabled:text-neutral-400 h-8 w-10 p-1 rounded-e bg-neutral-700 hover:bg-neutral-600 active:bg-neutral-700 shadow-sm"
|
||||
>
|
||||
<Icon src={Squares2x2} solid />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
on:click={createSettings}
|
||||
class="border border-neutral-600 text-white disabled:text-neutral-400 h-8 w-8 p-1 rounded bg-neutral-700 hover:bg-neutral-600 active:bg-neutral-700 shadow-sm"
|
||||
>
|
||||
<Icon src={Cog6Tooth} solid />
|
||||
</button>
|
||||
|
||||
<div class="flex-grow"></div>
|
||||
|
||||
<div>
|
||||
<form class="max-w-md mx-auto">
|
||||
<div class="relative">
|
||||
<input
|
||||
bind:this={searchElement}
|
||||
bind:value={searchTerm}
|
||||
on:input
|
||||
type="search"
|
||||
class="block w-full h-8 p-2 text-sm placeholder-neutral-400 text-white border border-neutral-600 rounded bg-neutral-700"
|
||||
placeholder={$_("header.search.placeholder")}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <div class="flex-grow" bind:this={editorElement}></div> -->
|
||||
48
rpcsx-ui/src/core/renderer/Icon.tsx
Normal file
48
rpcsx-ui/src/core/renderer/Icon.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import Ionicons from '@expo/vector-icons/Ionicons';
|
||||
import FontAwesome6 from '@expo/vector-icons/FontAwesome6';
|
||||
import { ComponentProps } from 'react';
|
||||
import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
|
||||
|
||||
export function MaterialIcon({
|
||||
name,
|
||||
size = 24,
|
||||
color,
|
||||
style,
|
||||
}: {
|
||||
name: ComponentProps<typeof MaterialIcons>['name'];
|
||||
size?: number;
|
||||
color: string | OpaqueColorValue;
|
||||
style?: StyleProp<TextStyle>;
|
||||
}) {
|
||||
return <MaterialIcons color={color} size={size} name={name} style={style} />;
|
||||
}
|
||||
|
||||
export function Ionicon({
|
||||
name,
|
||||
size = 24,
|
||||
color,
|
||||
style,
|
||||
}: {
|
||||
name: ComponentProps<typeof Ionicons>['name'];
|
||||
size?: number;
|
||||
color: string | OpaqueColorValue;
|
||||
style?: StyleProp<TextStyle>;
|
||||
}) {
|
||||
return <Ionicons color={color} size={size} name={name} style={style} />;
|
||||
}
|
||||
|
||||
|
||||
export function Awesome6({
|
||||
name,
|
||||
size = 24,
|
||||
color,
|
||||
style,
|
||||
}: {
|
||||
name: ComponentProps<typeof FontAwesome6>['name'];
|
||||
size?: number;
|
||||
color: string | OpaqueColorValue;
|
||||
style?: StyleProp<TextStyle>;
|
||||
}) {
|
||||
return <FontAwesome6 color={color} size={size} name={name} style={style} />;
|
||||
}
|
||||
25
rpcsx-ui/src/core/renderer/ThemedIcon.tsx
Normal file
25
rpcsx-ui/src/core/renderer/ThemedIcon.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useThemeColorOr } from './useThemeColor';
|
||||
import { ComponentProps } from 'react';
|
||||
import FontAwesome6 from '@expo/vector-icons/FontAwesome6';
|
||||
import Ionicons from '@expo/vector-icons/Ionicons';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
|
||||
const iconSets = {
|
||||
FontAwesome6,
|
||||
Ionicons,
|
||||
MaterialIcons
|
||||
};
|
||||
|
||||
export default function ThemedIcon<IconSet extends keyof typeof iconSets>({ color, iconSet, ...rest }: Omit<ComponentProps<typeof iconSets[IconSet]>, "color"> & { color?: ThemedColor, iconSet: IconSet }) {
|
||||
const resultColor = useThemeColorOr(color ?? {}, "text");
|
||||
|
||||
const Component = iconSets[iconSet] as any;
|
||||
return (
|
||||
<Component
|
||||
style={[
|
||||
{ color: resultColor },
|
||||
]}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
57
rpcsx-ui/src/core/renderer/ThemedText.tsx
Normal file
57
rpcsx-ui/src/core/renderer/ThemedText.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { StyleSheet, Text, TextProps } from 'react-native';
|
||||
import { useThemeColorOr } from './useThemeColor';
|
||||
|
||||
export type ThemedTextProps = TextProps & {
|
||||
color?: ThemedColor;
|
||||
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
|
||||
};
|
||||
|
||||
export function ThemedText({
|
||||
style,
|
||||
color,
|
||||
type = 'default',
|
||||
...rest
|
||||
}: ThemedTextProps) {
|
||||
const resultColor = useThemeColorOr(color ?? {}, "text");
|
||||
|
||||
return (
|
||||
<Text
|
||||
style={[
|
||||
{ color: resultColor },
|
||||
type === 'default' ? styles.default : undefined,
|
||||
type === 'title' ? styles.title : undefined,
|
||||
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined,
|
||||
type === 'subtitle' ? styles.subtitle : undefined,
|
||||
type === 'link' ? styles.link : undefined,
|
||||
style,
|
||||
]}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
default: {
|
||||
fontSize: 16,
|
||||
lineHeight: 32,
|
||||
},
|
||||
defaultSemiBold: {
|
||||
fontSize: 16,
|
||||
lineHeight: 32,
|
||||
fontWeight: '600',
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
lineHeight: 32,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
link: {
|
||||
lineHeight: 30,
|
||||
fontSize: 16,
|
||||
color: '#0a7ea4',
|
||||
},
|
||||
});
|
||||
14
rpcsx-ui/src/core/renderer/ThemedView.tsx
Normal file
14
rpcsx-ui/src/core/renderer/ThemedView.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { View, type ViewProps } from 'react-native';
|
||||
|
||||
import { useThemeColorOr } from './useThemeColor';
|
||||
|
||||
export type ThemedViewProps = ViewProps & {
|
||||
lightColor?: string;
|
||||
darkColor?: string;
|
||||
};
|
||||
|
||||
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
|
||||
const backgroundColor = useThemeColorOr({ light: lightColor, dark: darkColor }, "background");
|
||||
|
||||
return <View style={[{ backgroundColor }, style]} {...otherProps} />;
|
||||
}
|
||||
258
rpcsx-ui/src/core/renderer/ViewSelector.tsx
Normal file
258
rpcsx-ui/src/core/renderer/ViewSelector.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import { scheduleOnMainThread } from './scheduleOnMainThread';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { StyleProp, StyleSheet, View, ViewStyle, useWindowDimensions } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import Animated, {
|
||||
Easing,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
interpolate,
|
||||
withTiming,
|
||||
cancelAnimation,
|
||||
withSequence
|
||||
} from 'react-native-reanimated';
|
||||
import { useThemeColor } from './useThemeColor';
|
||||
|
||||
export function LeftRightViewSelector<T extends any>(props: { selectedItem: number, list: T[], renderItem: (item: T) => React.JSX.Element, style?: StyleProp<ViewStyle> }) {
|
||||
const [selectedItem, setSelectedItem] = useState(props.selectedItem);
|
||||
const [prevSelectedItem, setPrevSelectedItem] = useState(-1);
|
||||
const animation = useSharedValue(100);
|
||||
const layout = useWindowDimensions();
|
||||
|
||||
const directionRight = selectedItem > prevSelectedItem;
|
||||
const hideStyle = useAnimatedStyle(() => ({
|
||||
flexWrap: 'nowrap',
|
||||
transform: [{
|
||||
translateX: directionRight ? interpolate(animation.value, [0, 100], [0, -layout.width]) : interpolate(animation.value, [0, 100], [0, layout.width])
|
||||
}],
|
||||
opacity: interpolate(animation.value, [0, 100], [1, 0])
|
||||
}));
|
||||
|
||||
const showStyle = useAnimatedStyle(() => ({
|
||||
flexWrap: 'nowrap',
|
||||
transform: [{
|
||||
translateX: directionRight ? interpolate(animation.value, [0, 100], [layout.width, 0]) : interpolate(animation.value, [0, 100], [-layout.width, 0])
|
||||
}],
|
||||
opacity: interpolate(animation.value, [0, 100], [0, 1])
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedItem != props.selectedItem) {
|
||||
setPrevSelectedItem(selectedItem);
|
||||
setSelectedItem(props.selectedItem);
|
||||
animation.value = 0;
|
||||
}
|
||||
|
||||
if (prevSelectedItem != -1) {
|
||||
animation.value = withTiming(100, {
|
||||
duration: 400,
|
||||
easing: Easing.inOut(Easing.ease)
|
||||
}, (finished) => {
|
||||
if (finished) {
|
||||
scheduleOnMainThread(setPrevSelectedItem, -1);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const item = props.renderItem(props.list[selectedItem]);
|
||||
|
||||
if (prevSelectedItem == -1) {
|
||||
return (<View style={props.style}>{item}</View>)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={props.style}>
|
||||
{prevSelectedItem != -1 && <Animated.View style={[props.style, hideStyle, { position: 'absolute', height: "100%", width: "100%" }]}>
|
||||
{props.renderItem(props.list[prevSelectedItem])}
|
||||
</Animated.View>}
|
||||
{<Animated.View style={[props.style, showStyle, { position: 'absolute', height: "100%", width: "100%" }]}>
|
||||
{item}
|
||||
</Animated.View>}
|
||||
</View >
|
||||
);
|
||||
}
|
||||
|
||||
export function UpDownViewSelector<T extends any>(props: { selectedItem: number, list: T[], renderItem: (item: T) => React.JSX.Element, style?: StyleProp<ViewStyle> }) {
|
||||
const [selectedItem, setSelectedItem] = useState(props.selectedItem);
|
||||
const [prevSelectedItem, setPrevSelectedItem] = useState(-1);
|
||||
const animation = useSharedValue(100);
|
||||
const layout = useWindowDimensions();
|
||||
|
||||
const directionDown = selectedItem > prevSelectedItem;
|
||||
const hideStyle = useAnimatedStyle(() => ({
|
||||
flexWrap: 'nowrap',
|
||||
transform: [{
|
||||
translateY: directionDown ? interpolate(animation.value, [0, 100], [0, -layout.height]) : interpolate(animation.value, [0, 100], [0, layout.height])
|
||||
}],
|
||||
opacity: interpolate(animation.value, [0, 100], [1, 0])
|
||||
}));
|
||||
|
||||
const showStyle = useAnimatedStyle(() => ({
|
||||
flexWrap: 'nowrap',
|
||||
transform: [{
|
||||
translateY: directionDown ? interpolate(animation.value, [0, 100], [layout.height, 0]) : interpolate(animation.value, [0, 100], [-layout.height, 0])
|
||||
}],
|
||||
opacity: interpolate(animation.value, [0, 100], [0, 1])
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedItem != props.selectedItem) {
|
||||
setPrevSelectedItem(selectedItem);
|
||||
setSelectedItem(props.selectedItem);
|
||||
animation.value = 0;
|
||||
}
|
||||
|
||||
if (prevSelectedItem != -1) {
|
||||
animation.value = withTiming(100, {
|
||||
duration: 400,
|
||||
easing: Easing.inOut(Easing.ease)
|
||||
}, (finished) => {
|
||||
if (finished) {
|
||||
scheduleOnMainThread(setPrevSelectedItem, -1);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const item = props.renderItem(props.list[selectedItem]);
|
||||
|
||||
if (prevSelectedItem == -1) {
|
||||
return (<View style={props.style}>{item}</View>)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={props.style}>
|
||||
{prevSelectedItem != -1 && <Animated.View style={[props.style, hideStyle, { position: 'absolute', height: "100%", width: "100%" }]}>
|
||||
{props.renderItem(props.list[prevSelectedItem])}
|
||||
</Animated.View>}
|
||||
{<Animated.View style={[props.style, showStyle, { position: 'absolute', height: "100%", width: "100%" }]}>
|
||||
{item}
|
||||
</Animated.View>}
|
||||
</View >
|
||||
);
|
||||
}
|
||||
|
||||
export function DownShowViewSelector<T extends any>(props: { selectedItem: number, list: T[], renderItem: (item: T) => React.JSX.Element, style?: StyleProp<ViewStyle> }) {
|
||||
const [selectedItem, setSelectedItem] = useState(props.selectedItem);
|
||||
const [prevSelectedItem, setPrevSelectedItem] = useState(-1);
|
||||
const animation = useSharedValue(200);
|
||||
const layout = useWindowDimensions();
|
||||
|
||||
const hideStyle = useAnimatedStyle(() => ({
|
||||
flexWrap: 'nowrap',
|
||||
transform: [{
|
||||
translateY: animation.value < 100 ? interpolate(animation.value, [0, 100], [0, layout.height], 'clamp') : layout.height
|
||||
}],
|
||||
opacity: animation.value < 100 ? interpolate(animation.value * 10, [0, 100], [1, 0], 'clamp') : 0,
|
||||
display: animation.value < 100 ? undefined : 'none'
|
||||
}));
|
||||
|
||||
const showStyle = useAnimatedStyle(() => ({
|
||||
flexWrap: 'nowrap',
|
||||
opacity: interpolate(animation.value, [100, 200], [0, 1], 'clamp')
|
||||
}));
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedItem != props.selectedItem) {
|
||||
cancelAnimation(animation);
|
||||
|
||||
setPrevSelectedItem(selectedItem);
|
||||
setSelectedItem(props.selectedItem);
|
||||
animation.value = 0;
|
||||
}
|
||||
|
||||
if (prevSelectedItem != -1) {
|
||||
animation.value = withSequence(
|
||||
withTiming(100, {
|
||||
duration: 900,
|
||||
easing: Easing.inOut(Easing.ease)
|
||||
}),
|
||||
withTiming(200, {
|
||||
duration: 600,
|
||||
easing: Easing.inOut(Easing.ease)
|
||||
}, (finished) => {
|
||||
if (finished) {
|
||||
scheduleOnMainThread(setPrevSelectedItem, -1);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const showItem = props.renderItem(props.list[selectedItem]);
|
||||
|
||||
if (prevSelectedItem == -1) {
|
||||
return (<View style={props.style}>{showItem}</View>)
|
||||
}
|
||||
|
||||
const hideItem = props.renderItem(props.list[prevSelectedItem]);
|
||||
|
||||
return (
|
||||
<View style={props.style}>
|
||||
<Animated.View style={[props.style, hideStyle, { position: 'absolute', height: "100%", width: "100%" }]}>
|
||||
{hideItem}
|
||||
</Animated.View>
|
||||
<Animated.View style={[props.style, showStyle]}>
|
||||
{showItem}
|
||||
</Animated.View>
|
||||
</View >
|
||||
);
|
||||
}
|
||||
|
||||
export function TopViewSelector(props: { view?: React.JSX.Element }) {
|
||||
const animation = useSharedValue(0);
|
||||
const [prevView, setPrevView] = useState<React.JSX.Element | undefined>(undefined);
|
||||
const [currentView, setCurrentView] = useState<React.JSX.Element | undefined>(undefined);
|
||||
|
||||
const hideStyle = useAnimatedStyle(() => ({
|
||||
opacity: interpolate(animation.value * 5, [0, 100], [1, 0], 'clamp'),
|
||||
}));
|
||||
|
||||
const showStyle = useAnimatedStyle(() => ({
|
||||
opacity: interpolate(animation.value, [0, 100], [0, 1], 'clamp'),
|
||||
}));
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (props.view != currentView) {
|
||||
setCurrentView(props.view);
|
||||
if (currentView) {
|
||||
setPrevView(currentView);
|
||||
cancelAnimation(animation);
|
||||
animation.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
animation.value = withSequence(
|
||||
withTiming(100, {
|
||||
duration: 500,
|
||||
easing: Easing.ease
|
||||
}, (finished) => {
|
||||
if (finished) {
|
||||
animation.value = 0;
|
||||
scheduleOnMainThread(setPrevView, undefined);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const backgroundColor = useThemeColor("background");
|
||||
|
||||
if (!prevView) {
|
||||
return currentView;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[{ height: "100%", width: "100%", backgroundColor: backgroundColor }]}>
|
||||
<Animated.View style={[{ position: 'absolute', height: "100%", width: "100%" }, hideStyle]}>
|
||||
{prevView}
|
||||
</Animated.View>
|
||||
<Animated.View style={[{ opacity: 0, position: 'absolute', height: "100%", width: "100%" }, showStyle]}>
|
||||
{currentView}
|
||||
</Animated.View>
|
||||
</View >
|
||||
);
|
||||
}
|
||||
68
rpcsx-ui/src/core/renderer/bridge.web.ts
Normal file
68
rpcsx-ui/src/core/renderer/bridge.web.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
|
||||
import * as Disposable from '$core/Disposable';
|
||||
import { createError } from '$core/Error';
|
||||
import * as bridge from '../lib/bridge';
|
||||
export { viewPush, viewSet, viewPop } from '../lib/bridge';
|
||||
|
||||
export function setOnCall(_cb: (...args: any[]) => any) {}
|
||||
export function setOnInvoke(_cb: (...args: any[]) => void | Promise<void>) {}
|
||||
export function setOnEvent(_cb: (...args: any[]) => () => void) { }
|
||||
|
||||
export function onViewPush(cb: (name: string, props: any) => void) {
|
||||
if (window?.electron?.ipcRenderer) {
|
||||
window.electron.ipcRenderer.on("view/push", cb);
|
||||
}
|
||||
|
||||
bridge.onViewPush(cb);
|
||||
}
|
||||
export function onViewSet(cb: (name: string, props: any) => void) {
|
||||
if (window?.electron?.ipcRenderer) {
|
||||
window.electron.ipcRenderer.on("view/set", cb);
|
||||
}
|
||||
|
||||
bridge.onViewSet(cb);
|
||||
}
|
||||
export function onViewPop(cb: () => void) {
|
||||
if (window?.electron?.ipcRenderer) {
|
||||
window.electron.ipcRenderer.on("view/pop", cb);
|
||||
}
|
||||
|
||||
bridge.onViewPop(cb);
|
||||
}
|
||||
|
||||
export function onEvent(event: string, handler: (...args: any[]) => Promise<void> | void) {
|
||||
if (!window?.electron?.ipcRenderer) {
|
||||
return Disposable.noneFn;
|
||||
}
|
||||
|
||||
return window.electron.ipcRenderer.on(event, handler);
|
||||
}
|
||||
|
||||
export async function invoke(method: string, params: any): Promise<void> {
|
||||
if (!window?.electron?.ipcRenderer) {
|
||||
return;
|
||||
}
|
||||
|
||||
return window.electron.ipcRenderer.invoke(method, params);
|
||||
}
|
||||
|
||||
export async function call(method: string, params: any): Promise<any> {
|
||||
if (!window?.electron?.ipcRenderer) {
|
||||
throw createError(ErrorCode.InvalidRequest, "electron is not available");
|
||||
}
|
||||
|
||||
return window.electron.ipcRenderer.send(method, params);
|
||||
}
|
||||
|
||||
export function sendViewInitializationComplete() {
|
||||
if (window?.electron?.ipcRenderer) {
|
||||
window.electron.ipcRenderer.send("frame/initialized");
|
||||
} else {
|
||||
// Page was open in browser, show explorer screen
|
||||
bridge.viewPush("Explorer", {});
|
||||
}
|
||||
}
|
||||
|
||||
bridge.setOnEvent(onEvent);
|
||||
bridge.setOnInvoke(invoke);
|
||||
bridge.setOnCall(call);
|
||||
2
rpcsx-ui/src/core/renderer/pickDirectory.ts
Normal file
2
rpcsx-ui/src/core/renderer/pickDirectory.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { pickDirectory } from '@react-native-documents/picker';
|
||||
|
||||
5
rpcsx-ui/src/core/renderer/pickDirectory.web.ts
Normal file
5
rpcsx-ui/src/core/renderer/pickDirectory.web.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { pickDirectory as impl } from '@react-native-documents/picker';
|
||||
|
||||
export function pickDirectory(... _params: Parameters<typeof impl>): ReturnType<typeof impl> {
|
||||
throw new Error('not implemented for web');
|
||||
}
|
||||
15
rpcsx-ui/src/core/renderer/routes/Layout.tsx
Normal file
15
rpcsx-ui/src/core/renderer/routes/Layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ReactNode } from "react";
|
||||
// Import i18n initialization
|
||||
import "../../i18n";
|
||||
|
||||
type LayoutProps = {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
return (
|
||||
<main className="flex flex-col h-full max-h-full" id="content">
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
12
rpcsx-ui/src/core/renderer/scheduleOnMainThread.tsx
Normal file
12
rpcsx-ui/src/core/renderer/scheduleOnMainThread.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Platform } from 'react-native';
|
||||
import { runOnJS } from 'react-native-reanimated';
|
||||
|
||||
export function scheduleOnMainThread<Args extends unknown[], ReturnValue>(cb: ((...args: Args) => ReturnValue), ...args: Args) {
|
||||
'worklet';
|
||||
|
||||
if (Platform.OS == 'web') {
|
||||
return cb(...args);
|
||||
} else {
|
||||
return runOnJS(cb)(...args);
|
||||
}
|
||||
}
|
||||
5
rpcsx-ui/src/core/renderer/useColorScheme.ts
Normal file
5
rpcsx-ui/src/core/renderer/useColorScheme.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { useColorScheme as impl } from 'react-native';
|
||||
|
||||
export function useColorScheme() {
|
||||
return impl() ?? 'dark';
|
||||
}
|
||||
22
rpcsx-ui/src/core/renderer/useThemeColor.ts
Normal file
22
rpcsx-ui/src/core/renderer/useThemeColor.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useColorScheme } from './useColorScheme';
|
||||
import { Colors } from './Colors';
|
||||
|
||||
export function useThemeColor(
|
||||
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
|
||||
) {
|
||||
return Colors[useColorScheme()][colorName];
|
||||
}
|
||||
|
||||
export function useThemeColorOr(
|
||||
props: { light?: string; dark?: string },
|
||||
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
|
||||
) {
|
||||
const theme = useColorScheme();
|
||||
const colorFromProps = props[theme];
|
||||
|
||||
if (colorFromProps) {
|
||||
return colorFromProps;
|
||||
} else {
|
||||
return Colors[theme][colorName];
|
||||
}
|
||||
}
|
||||
4
rpcsx-ui/src/core/server/ComponentActivation.ts
Normal file
4
rpcsx-ui/src/core/server/ComponentActivation.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { type ComponentInstance } from "./ComponentInstance";
|
||||
|
||||
export function onComponentActivation(_component: ComponentInstance) {
|
||||
}
|
||||
67
rpcsx-ui/src/core/server/ComponentActivation.web.ts
Normal file
67
rpcsx-ui/src/core/server/ComponentActivation.web.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { ComponentInstance } from "./ComponentInstance";
|
||||
import { Disposable } from "$/Disposable";
|
||||
import { Component } from "lib/Component";
|
||||
import { ipcMain } from "electron";
|
||||
import { createError } from "lib/Error";
|
||||
|
||||
export function onComponentActivation(component: ComponentInstance) {
|
||||
const methods = component.getContribution(`methods`);
|
||||
const impl = component.getImpl();
|
||||
|
||||
const createRendererComponent = (webContents: Electron.WebContents) => {
|
||||
const rendererComponent: Component = {
|
||||
getId: () => ":renderer",
|
||||
onClose: (listener) => {
|
||||
webContents.on("destroyed", listener);
|
||||
return Disposable.Create(() => { webContents.off("destroyed", listener); });
|
||||
},
|
||||
sendEvent: (event, params) => {
|
||||
webContents.send(`${component.getName()}/${event}`, params);
|
||||
},
|
||||
};
|
||||
|
||||
return rendererComponent;
|
||||
};
|
||||
|
||||
if (methods) {
|
||||
Object.keys(methods).forEach(method => {
|
||||
const channel = `${component.getName()}/${method}`;
|
||||
|
||||
ipcMain.handle(channel, (event, params: JsonObject) => {
|
||||
if (!impl.call) {
|
||||
return createError(ErrorCode.InternalError, `component ${component.getName()} not defines call`);
|
||||
}
|
||||
|
||||
try {
|
||||
return impl.call(createRendererComponent(event.sender), method, params);
|
||||
} catch (e) {
|
||||
return e;
|
||||
}
|
||||
});
|
||||
component.manage(() => ipcMain.removeHandler(channel));
|
||||
});
|
||||
}
|
||||
|
||||
const notifications = component.getContribution(`notifications`);
|
||||
|
||||
if (notifications) {
|
||||
Object.keys(notifications).forEach(notification => {
|
||||
const channel = `${component.getName()}/${notification}`;
|
||||
|
||||
const handler = (event: Electron.IpcMainInvokeEvent, params: JsonObject) => {
|
||||
if (!impl.notify) {
|
||||
return createError(ErrorCode.InternalError, `component ${component.getName()} not defines notify`);
|
||||
}
|
||||
|
||||
try {
|
||||
return impl.notify(createRendererComponent(event.sender), notification, params);
|
||||
} catch (e) {
|
||||
return e;
|
||||
}
|
||||
};
|
||||
|
||||
ipcMain.on(channel, handler);
|
||||
component.manage(() => ipcMain.off(channel, handler));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import EventEmitter from "events";
|
||||
import { ComponentContext, ComponentId, Component } from "$/Component.js";
|
||||
import { Disposable, IDisposable } from "$/Disposable.js";
|
||||
import * as Event from "$/Event";
|
||||
import { ComponentContext, ComponentId, Component } from "$/Component";
|
||||
import { Disposable, IDisposable } from "$/Disposable";
|
||||
import { isJsonObject } from '$/Json';
|
||||
import { createError } from "lib/Error";
|
||||
import { createError } from "$/Error";
|
||||
import { get as settingsGet } from './Settings';
|
||||
import { Schema } from "lib/Schema";
|
||||
import { ipcMain } from "electron";
|
||||
import { Schema } from "$/Schema";
|
||||
import { onComponentActivation } from './ComponentActivation';
|
||||
|
||||
type Key<K, T> = T extends [never] ? string | symbol : K | keyof T;
|
||||
|
||||
@@ -32,7 +32,8 @@ export class ComponentInstance implements ComponentContext {
|
||||
protected initialized = false;
|
||||
protected activated = false;
|
||||
private disposeList = Disposable.None;
|
||||
private eventEmitter = new EventEmitter();
|
||||
private eventEmitter: Record<string, Event.Emitter<any>> = {};
|
||||
private externalEventEmitter: Record<string, Event.Emitter<any>> = {};
|
||||
readonly view = Object.freeze(this.createCallerView(this));
|
||||
|
||||
constructor(private readonly manifest: ComponentManifest, private impl: IComponentImpl) { }
|
||||
@@ -44,15 +45,20 @@ export class ComponentInstance implements ComponentContext {
|
||||
);
|
||||
}
|
||||
|
||||
private handleExternalEvent(sender: ComponentInstance, event: string, params: any) {
|
||||
this.externalEventEmitter[`${sender.getId()}/${event}`]?.emit(params);
|
||||
}
|
||||
|
||||
private createCallerView(caller: ComponentInstance): Component {
|
||||
return {
|
||||
getId: () => caller.getId(),
|
||||
onClose: (listener) => caller.onEvent(this, deactivateEvent, listener),
|
||||
sendEvent: (event, params) => caller.handleExternalEvent(this, event, params)
|
||||
};
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
await this.impl.initialize((event, params) => this.eventEmitter.emit(event, params));
|
||||
await this.impl.initialize((event, params) => this.eventEmitter[event]?.emit(params));
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
@@ -67,65 +73,19 @@ export class ComponentInstance implements ComponentContext {
|
||||
}
|
||||
|
||||
await this.impl.activate(this, settings);
|
||||
|
||||
const methods = this.getContribution(`methods`);
|
||||
|
||||
const rendererComponent: Component = {
|
||||
getId: () => ":renderer",
|
||||
onClose: (_listener) => Disposable.None, // FIXME
|
||||
};
|
||||
|
||||
if (methods) {
|
||||
Object.keys(methods).forEach(method => {
|
||||
const channel = `${this.getName()}/${method}`;
|
||||
|
||||
ipcMain.handle(channel, (_, params: JsonObject) => {
|
||||
if (!this.impl.call) {
|
||||
return createError(ErrorCode.InternalError, `component ${this.getName()} not defines call`);
|
||||
}
|
||||
|
||||
try {
|
||||
return this.impl.call(rendererComponent, method, params);
|
||||
} catch (e) {
|
||||
return e;
|
||||
}
|
||||
});
|
||||
this.manage(() => ipcMain.removeHandler(channel));
|
||||
});
|
||||
}
|
||||
|
||||
const notifications = this.getContribution(`notifications`);
|
||||
|
||||
if (notifications) {
|
||||
Object.keys(notifications).forEach(notification => {
|
||||
const channel = `${this.getName() }/${notification}`;
|
||||
|
||||
const handler = (_: any, params: JsonObject) => {
|
||||
if (!this.impl.notify) {
|
||||
return createError(ErrorCode.InternalError, `component ${this.getName()} not defines notify`);
|
||||
}
|
||||
|
||||
try {
|
||||
return this.impl.notify(rendererComponent, notification, params);
|
||||
} catch (e) {
|
||||
return e;
|
||||
}
|
||||
};
|
||||
|
||||
ipcMain.on(channel, handler);
|
||||
this.manage(() => ipcMain.off(channel, handler));
|
||||
});
|
||||
}
|
||||
|
||||
onComponentActivation(this);
|
||||
this.emitEvent(activateEvent);
|
||||
this.activated = true;
|
||||
}
|
||||
|
||||
async deactivate() {
|
||||
this.emitEvent(deactivateEvent);
|
||||
Object.values(this.externalEventEmitter).forEach(e => e.dispose());
|
||||
this.externalEventEmitter = {};
|
||||
await this.impl.deactivate(this);
|
||||
await this.disposeList.dispose();
|
||||
this.eventEmitter.removeAllListeners();
|
||||
Object.values(this.eventEmitter).forEach(e => e.dispose());
|
||||
this.eventEmitter = {};
|
||||
this.activated = false;
|
||||
}
|
||||
|
||||
@@ -152,7 +112,9 @@ export class ComponentInstance implements ComponentContext {
|
||||
|
||||
subscribe<K, T extends Record<keyof T, any[]> | [never] = [never]>(emitter: NodeJS.EventEmitter<T>, channel: Key<K, T>, listener: Listener<K, T, (...args: any[]) => void>) {
|
||||
emitter.on(channel, listener);
|
||||
this.manage(() => emitter.off(channel, listener));
|
||||
const disposable = Disposable.Create(() => { emitter.off(channel, listener); });
|
||||
this.manage(disposable);
|
||||
return disposable;
|
||||
}
|
||||
|
||||
isInitialized() {
|
||||
@@ -227,17 +189,38 @@ export class ComponentInstance implements ComponentContext {
|
||||
throw createError(ErrorCode.InvalidParams, `${caller.getId()}: component ${this.getName()} not emits event '${event}'`);
|
||||
}
|
||||
|
||||
this.eventEmitter.on(event, listener);
|
||||
const externalEvent = `${this.getId()}/${event}`;
|
||||
|
||||
caller.externalEventEmitter[externalEvent] ??= new Event.Emitter();
|
||||
const externalEmitter = caller.externalEventEmitter[externalEvent];
|
||||
|
||||
this.eventEmitter[event] ??= new Event.Emitter();
|
||||
const emitter = this.eventEmitter[event];
|
||||
|
||||
const externalDisposable = externalEmitter.event(listener);
|
||||
const emitterDisposable = emitter.event(listener);
|
||||
|
||||
const disposable = Disposable.Create(() => {
|
||||
this.eventEmitter.off(event, listener);
|
||||
externalDisposable.dispose();
|
||||
emitterDisposable.dispose();
|
||||
|
||||
if (!externalEmitter.hasListeners()) {
|
||||
delete caller.externalEventEmitter[externalEvent];
|
||||
}
|
||||
|
||||
if (!emitter.hasListeners()) {
|
||||
delete this.eventEmitter[event];
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
caller.manage(disposable);
|
||||
this.manage(disposable);
|
||||
return disposable;
|
||||
}
|
||||
|
||||
emitEvent(event: string, params?: JsonObject) {
|
||||
this.eventEmitter.emit(event, params);
|
||||
ipcMain.emit(`${this.getName()}/${event}`, params);
|
||||
this.eventEmitter[event]?.emit(params);
|
||||
}
|
||||
|
||||
getManifest() {
|
||||
@@ -255,6 +238,10 @@ export class ComponentInstance implements ComponentContext {
|
||||
getId() {
|
||||
return getComponentId(this.manifest);
|
||||
}
|
||||
|
||||
getImpl() {
|
||||
return this.impl;
|
||||
}
|
||||
}
|
||||
|
||||
const registeredComponents: Record<string, ComponentInstance> = {};
|
||||
@@ -450,12 +437,20 @@ export async function startup() {
|
||||
await Promise.all(Object.values(registeredComponents).map(component => initializeComponent(component.getManifest())));
|
||||
|
||||
for (const component of Object.values(registeredComponents)) {
|
||||
await activateComponent(component.getManifest());
|
||||
try {
|
||||
await activateComponent(component.getManifest());
|
||||
} catch (e) {
|
||||
console.error(`failed to activate ${component.getId()}`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function shutdown() {
|
||||
for (const component of Object.values(registeredComponents)) {
|
||||
await unregisterComponent(component.getId());
|
||||
try {
|
||||
await unregisterComponent(component.getId());
|
||||
} catch (e) {
|
||||
console.error(`failed to shutdown ${component.getId()}`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,15 +29,17 @@ export class Extension implements IComponentImpl {
|
||||
reject: (reason?: any) => void;
|
||||
};
|
||||
} = {};
|
||||
|
||||
private nextMessageId = 1;
|
||||
private errorHandlers: ErrorHandler[] = [];
|
||||
private messageBuffer = "";
|
||||
private processingQueue: (() => Promise<void>)[] = [];
|
||||
private responseWatchdog: NodeJS.Timeout | null = null;
|
||||
private exitController = new AbortController();
|
||||
private componentManifest: ComponentManifest;
|
||||
|
||||
constructor(
|
||||
public readonly manifest: ComponentManifest,
|
||||
public readonly manifest: ExtensionInfo,
|
||||
public readonly extensionProcess: Process) {
|
||||
|
||||
extensionProcess.stdout.on('data', (message: string) => {
|
||||
@@ -49,6 +51,11 @@ export class Extension implements IComponentImpl {
|
||||
|
||||
this.debugLog("Starting");
|
||||
|
||||
this.componentManifest = {
|
||||
...this.manifest,
|
||||
name: this.manifest.name[0].text
|
||||
};
|
||||
|
||||
extensionProcess.on('exit', () => {
|
||||
this.debugLog("Exit");
|
||||
this.alive = false;
|
||||
@@ -58,10 +65,10 @@ export class Extension implements IComponentImpl {
|
||||
clearTimeout(this.responseWatchdog);
|
||||
}
|
||||
|
||||
uninitializeComponent(this.manifest);
|
||||
uninitializeComponent(this.componentManifest);
|
||||
});
|
||||
|
||||
registerComponent(manifest, this);
|
||||
registerComponent(this.componentManifest, this);
|
||||
}
|
||||
|
||||
private debugLog(message: string) {
|
||||
@@ -337,7 +344,7 @@ export class Extension implements IComponentImpl {
|
||||
const params = "params" in message ? message["params"] as JsonObject : undefined;
|
||||
const [componentName, method] = componentMethod.split("/", 1);
|
||||
const component = findComponent(componentName);
|
||||
const self = findComponent(this.manifest.name);
|
||||
const self = findComponent(this.componentManifest.name);
|
||||
|
||||
if (!component || !self) {
|
||||
this.send({
|
||||
@@ -409,7 +416,7 @@ export class Extension implements IComponentImpl {
|
||||
}
|
||||
|
||||
getId() {
|
||||
return getComponentId(this.manifest);
|
||||
return getComponentId(this.componentManifest);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fork, spawn } from 'child_process';
|
||||
import { Duplex, Readable, Writable } from 'stream';
|
||||
import { dirname } from 'path';
|
||||
import EventEmitter from 'events';
|
||||
|
||||
// FIXME: remove this import
|
||||
import type { Readable, Writable } from 'stream';
|
||||
import { Target } from "./Target";
|
||||
|
||||
export type Process = {
|
||||
stdin: Writable;
|
||||
@@ -22,142 +22,6 @@ export type Launcher = {
|
||||
launch: (path: string, args: string[], params: LaunchParams) => Promise<Process> | Process;
|
||||
}
|
||||
|
||||
export const nativeLauncher: Launcher = {
|
||||
launch: async (path: string, args: string[], params: LaunchParams) => {
|
||||
const newProcess = spawn(path, args, {
|
||||
argv0: path,
|
||||
cwd: dirname(path),
|
||||
signal: params.signal,
|
||||
stdio: 'pipe'
|
||||
});
|
||||
|
||||
newProcess.stdout.setEncoding('utf8');
|
||||
newProcess.stderr.setEncoding('utf8');
|
||||
|
||||
const result: Process = {
|
||||
stdin: newProcess.stdin,
|
||||
stdout: newProcess.stdout,
|
||||
stderr: newProcess.stderr,
|
||||
|
||||
kill: (signal: number | NodeJS.Signals) => {
|
||||
newProcess.kill(signal);
|
||||
},
|
||||
on: (event: string, handler: (...args: any[]) => void) => {
|
||||
newProcess.on(event, handler);
|
||||
},
|
||||
once: (event: string, handler: (...args: any[]) => void) => {
|
||||
newProcess.once(event, handler);
|
||||
},
|
||||
off: (event: string, handler: (...args: any[]) => void) => {
|
||||
newProcess.off(event, handler);
|
||||
}
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
export const nodeLauncher: Launcher = {
|
||||
launch: async (path: string, args: string[], params: LaunchParams) => {
|
||||
const newProcess = fork(path, args, {
|
||||
signal: params.signal,
|
||||
stdio: 'pipe',
|
||||
cwd: dirname(path),
|
||||
});
|
||||
|
||||
newProcess.stdout!.setEncoding('utf8');
|
||||
newProcess.stderr!.setEncoding('utf8');
|
||||
|
||||
const result: Process = {
|
||||
stdin: newProcess.stdin!,
|
||||
stdout: newProcess.stdout!,
|
||||
stderr: newProcess.stderr!,
|
||||
|
||||
kill: (signal: number | NodeJS.Signals) => {
|
||||
newProcess.kill(signal);
|
||||
},
|
||||
on: (event: string, handler: (...args: any[]) => void) => {
|
||||
newProcess.on(event, handler);
|
||||
},
|
||||
once: (event: string, handler: (...args: any[]) => void) => {
|
||||
newProcess.once(event, handler);
|
||||
},
|
||||
off: (event: string, handler: (...args: any[]) => void) => {
|
||||
newProcess.off(event, handler);
|
||||
}
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
export const inlineLauncher: Launcher = {
|
||||
launch: async (path: string, args: string[], params: LaunchParams) => {
|
||||
const imported = await import(path);
|
||||
|
||||
if (!("initialize" in imported) || typeof imported.initialize != 'function') {
|
||||
throw new Error(`${path}: invalid inline module`);
|
||||
}
|
||||
|
||||
const eventEmitter = new EventEmitter();
|
||||
const stdin = new Duplex();
|
||||
const stdout = new Duplex();
|
||||
const stderr = new Duplex();
|
||||
imported.initialize(eventEmitter, args, params.launcherRequirements, stdin, stdout, stderr);
|
||||
|
||||
const result: Process = {
|
||||
stdin: stdin,
|
||||
stdout: stdout,
|
||||
stderr: stderr,
|
||||
|
||||
kill: (_signal: number | NodeJS.Signals) => {
|
||||
if (("dispose" in imported) && typeof imported.dispose == 'function') {
|
||||
imported.dispose();
|
||||
}
|
||||
},
|
||||
on: (event: string, handler: (...args: any[]) => void) => {
|
||||
eventEmitter.on(event, handler);
|
||||
},
|
||||
once: (event: string, handler: (...args: any[]) => void) => {
|
||||
eventEmitter.once(event, handler);
|
||||
},
|
||||
off: (event: string, handler: (...args: any[]) => void) => {
|
||||
eventEmitter.off(event, handler);
|
||||
}
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
export class Target {
|
||||
constructor(
|
||||
public fileFormat: string,
|
||||
public arch: string,
|
||||
public platform: string,
|
||||
) { }
|
||||
|
||||
static parse(triple: string) {
|
||||
const parts = triple.split('-');
|
||||
if (parts.length != 3) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return new Target(parts[0], parts[1], parts[2]);
|
||||
}
|
||||
|
||||
static native() {
|
||||
return nativeTarget;
|
||||
}
|
||||
|
||||
format(): string {
|
||||
return this.fileFormat + "-" + this.arch + "-" + this.platform;
|
||||
}
|
||||
}
|
||||
|
||||
const nativeTarget = new Target(process.platform === "win32" ? "pe" : "elf", process.arch, process.platform);
|
||||
|
||||
const launcherStorage: {
|
||||
[key: string]: Launcher
|
||||
} = {};
|
||||
@@ -185,7 +49,3 @@ export function getLauncher(target: Target | string) {
|
||||
export function getLauncherList() {
|
||||
return Object.keys(launcherStorage);
|
||||
}
|
||||
|
||||
addLauncher(Target.native(), nativeLauncher);
|
||||
addLauncher(new Target("js", "any", "node"), nodeLauncher);
|
||||
addLauncher(new Target("js", "any", "inline"), inlineLauncher);
|
||||
|
||||
@@ -1,35 +1,14 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from "path";
|
||||
import * as locations from '$/locations';
|
||||
import { fixObject, generateObject, Schema } from '$/Schema';
|
||||
|
||||
const defaultSettingsDir = locations.rootPath;
|
||||
const defaultSettingsPath = path.join(defaultSettingsDir, "settings.json");
|
||||
let g_filePath = defaultSettingsPath;
|
||||
let currentSettings: JsonObject = {};
|
||||
|
||||
export async function load(params?: { filePath: string }) {
|
||||
if (params) {
|
||||
g_filePath = params.filePath;
|
||||
} else {
|
||||
g_filePath = defaultSettingsPath;
|
||||
}
|
||||
|
||||
try {
|
||||
const settingsText = await fs.readFile(g_filePath, { encoding: "utf8" });
|
||||
currentSettings = JSON.parse(settingsText);
|
||||
} catch (e) {
|
||||
console.error('failed to load settings', e);
|
||||
currentSettings = {};
|
||||
}
|
||||
export async function load(_params?: { filePath: string }) {
|
||||
// FIXME: implement
|
||||
currentSettings = {};
|
||||
}
|
||||
|
||||
export function save(filePath?: string) {
|
||||
if (!filePath) {
|
||||
filePath = g_filePath;
|
||||
}
|
||||
|
||||
return fs.writeFile(filePath, JSON.stringify(currentSettings, null, 4), { encoding: "utf8" });
|
||||
export function save(_filePath?: string) {
|
||||
// FIXME: implement
|
||||
}
|
||||
|
||||
export function get(name: string, settings: Record<string, Schema>, fix = true) {
|
||||
|
||||
56
rpcsx-ui/src/core/server/Settings.web.ts
Normal file
56
rpcsx-ui/src/core/server/Settings.web.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from "path";
|
||||
import * as locations from '$/locations';
|
||||
import { fixObject, generateObject, Schema } from '$/Schema';
|
||||
|
||||
const defaultSettingsDir = locations.rootPath;
|
||||
const defaultSettingsPath = path.join(defaultSettingsDir, "settings.json");
|
||||
let g_filePath = defaultSettingsPath;
|
||||
let currentSettings: JsonObject = {};
|
||||
|
||||
export async function load(params?: { filePath: string }) {
|
||||
if (params) {
|
||||
g_filePath = params.filePath;
|
||||
} else {
|
||||
g_filePath = defaultSettingsPath;
|
||||
}
|
||||
|
||||
console.log("settings path:", g_filePath);
|
||||
|
||||
try {
|
||||
const settingsText = await fs.readFile(g_filePath, { encoding: "utf8" });
|
||||
currentSettings = JSON.parse(settingsText);
|
||||
} catch (e) {
|
||||
console.error('failed to load settings', e);
|
||||
currentSettings = {};
|
||||
}
|
||||
}
|
||||
|
||||
export function save(filePath?: string) {
|
||||
if (!filePath) {
|
||||
filePath = g_filePath;
|
||||
}
|
||||
|
||||
return fs.writeFile(filePath, JSON.stringify(currentSettings, null, 4), { encoding: "utf8" });
|
||||
}
|
||||
|
||||
export function get(name: string, settings: Record<string, Schema>, fix = true) {
|
||||
const schema: Schema = {
|
||||
type: "object",
|
||||
properties: settings
|
||||
};
|
||||
|
||||
if (name in currentSettings) {
|
||||
if (!fix) {
|
||||
return currentSettings[name];
|
||||
}
|
||||
|
||||
const fixed = fixObject(currentSettings[name], schema);
|
||||
currentSettings[name] = fixed;
|
||||
return fixed;
|
||||
}
|
||||
|
||||
const result = generateObject(schema);
|
||||
currentSettings[name] = result;
|
||||
return result;
|
||||
}
|
||||
26
rpcsx-ui/src/core/server/Target.ts
Normal file
26
rpcsx-ui/src/core/server/Target.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export class Target {
|
||||
constructor(
|
||||
public fileFormat: string,
|
||||
public arch: string,
|
||||
public platform: string,
|
||||
) { }
|
||||
|
||||
static parse(triple: string) {
|
||||
const parts = triple.split('-');
|
||||
if (parts.length != 3) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return new Target(parts[0], parts[1], parts[2]);
|
||||
}
|
||||
|
||||
static native() {
|
||||
return nativeTarget;
|
||||
}
|
||||
|
||||
format(): string {
|
||||
return this.fileFormat + "-" + this.arch + "-" + this.platform;
|
||||
}
|
||||
}
|
||||
|
||||
const nativeTarget = new Target("none", "none", "none");
|
||||
26
rpcsx-ui/src/core/server/Target.web.ts
Normal file
26
rpcsx-ui/src/core/server/Target.web.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export class Target {
|
||||
constructor(
|
||||
public fileFormat: string,
|
||||
public arch: string,
|
||||
public platform: string,
|
||||
) { }
|
||||
|
||||
static parse(triple: string) {
|
||||
const parts = triple.split('-');
|
||||
if (parts.length != 3) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return new Target(parts[0], parts[1], parts[2]);
|
||||
}
|
||||
|
||||
static native() {
|
||||
return nativeTarget;
|
||||
}
|
||||
|
||||
format(): string {
|
||||
return this.fileFormat + "-" + this.arch + "-" + this.platform;
|
||||
}
|
||||
}
|
||||
|
||||
const nativeTarget = new Target(process.platform === "win32" ? "pe" : "elf", process.arch, process.platform);
|
||||
17
rpcsx-ui/src/core/server/extension-api.ts
Normal file
17
rpcsx-ui/src/core/server/extension-api.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { createError } from '$/Error';
|
||||
|
||||
export async function loadExtension(_request: ExtensionLoadRequest): Promise<ExtensionLoadResponse> {
|
||||
throw createError(ErrorCode.InvalidRequest);
|
||||
}
|
||||
|
||||
export async function unloadExtension(_request: ExtensionUnloadRequest): Promise<ExtensionUnloadResponse> {
|
||||
throw createError(ErrorCode.InvalidRequest);
|
||||
}
|
||||
|
||||
export async function installExtension(_request: ExtensionInstallRequest): Promise<ExtensionInstallResponse> {
|
||||
throw createError(ErrorCode.InvalidRequest);
|
||||
}
|
||||
|
||||
export async function removeExtension(_request: ExtensionRemoveRequest): Promise<ExtensionRemoveResponse> {
|
||||
throw createError(ErrorCode.InvalidRequest);
|
||||
}
|
||||
120
rpcsx-ui/src/core/server/extension-api.web.ts
Normal file
120
rpcsx-ui/src/core/server/extension-api.web.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import * as locations from '$core/locations';
|
||||
import { findComponent, findComponentById, unregisterComponent } from './ComponentInstance';
|
||||
import { createError } from 'lib/Error';
|
||||
import { getLauncher } from './Launcher';
|
||||
import { Extension } from './Extension';
|
||||
|
||||
export async function loadExtension(request: ExtensionLoadRequest): Promise<ExtensionLoadResponse> {
|
||||
if (findComponentById(request.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const extensionManifestLocation = path.join(locations.extensionsPath, request.id, "extension.json");
|
||||
|
||||
const manifestText = await (async () => {
|
||||
try {
|
||||
return await fs.readFile(extensionManifestLocation, "utf8");
|
||||
} catch {
|
||||
throw createError(ErrorCode.InvalidParams, `extension ${request.id} not found`);
|
||||
}
|
||||
})();
|
||||
|
||||
const manifest = await (async () => {
|
||||
try {
|
||||
return JSON.parse(manifestText) as ExtensionInfo;
|
||||
} catch {
|
||||
throw createError(ErrorCode.InternalError, `extension ${request.id} is broken`);
|
||||
}
|
||||
})();
|
||||
|
||||
const launcher = getLauncher(manifest.launcher.type);
|
||||
if (launcher == null) {
|
||||
throw createError(ErrorCode.InternalError, `launcher ${manifest.launcher.type} not found`);
|
||||
}
|
||||
|
||||
const process = await (async () => {
|
||||
try {
|
||||
return launcher.launch(path.join(locations.extensionsPath, request.id, manifest.executable), manifest.args ?? [], {
|
||||
launcherRequirements: manifest.launcher.requirements ?? {},
|
||||
});
|
||||
} catch {
|
||||
throw createError(ErrorCode.InternalError, `${request.id}: failed to spawn extension process`);
|
||||
}
|
||||
})();
|
||||
|
||||
new Extension(manifest, process);
|
||||
}
|
||||
|
||||
export async function unloadExtension(request: ExtensionUnloadRequest): Promise<ExtensionUnloadResponse> {
|
||||
await unregisterComponent(request.id);
|
||||
}
|
||||
|
||||
export async function installExtension(request: ExtensionInstallRequest): Promise<ExtensionInstallResponse> {
|
||||
// FIXME: unpack package
|
||||
const extensionManifestLocation = path.join(request.path, "extension.json");
|
||||
|
||||
const manifestText = await (async () => {
|
||||
try {
|
||||
return await fs.readFile(extensionManifestLocation, "utf8");
|
||||
} catch {
|
||||
throw createError(ErrorCode.InvalidParams, `extension ${request.path} not found`);
|
||||
}
|
||||
})();
|
||||
|
||||
const manifest = await (async () => {
|
||||
try {
|
||||
return JSON.parse(manifestText) as ExtensionInfo;
|
||||
} catch {
|
||||
throw createError(ErrorCode.InternalError, `extension ${request.path} is broken`);
|
||||
}
|
||||
})();
|
||||
|
||||
if (findComponent(manifest.name[0].text, manifest.version)) {
|
||||
throw createError(ErrorCode.InvalidRequest, `extension ${request.path} already installed`);
|
||||
}
|
||||
|
||||
const launcher = getLauncher(manifest.launcher.type);
|
||||
if (launcher == null) {
|
||||
throw createError(ErrorCode.InternalError, `launcher ${manifest.launcher.type} not found`);
|
||||
}
|
||||
|
||||
const process = await (async () => {
|
||||
try {
|
||||
return launcher.launch(path.join(request.path, manifest.executable), manifest.args ?? [], {
|
||||
launcherRequirements: manifest.launcher.requirements ?? {},
|
||||
});
|
||||
} catch {
|
||||
throw createError(ErrorCode.InternalError, `${request.path}: failed to spawn extension process`);
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
const extension = new Extension(manifest, process);
|
||||
return { id: extension.getId() };
|
||||
} catch (e) {
|
||||
process.kill("SIGKILL");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeExtension(request: ExtensionRemoveRequest): Promise<ExtensionRemoveResponse> {
|
||||
if (findComponentById(request.id)) {
|
||||
throw createError(ErrorCode.InvalidRequest, `extension ${request.id} in use`);
|
||||
}
|
||||
|
||||
const extensionLocation = path.join(locations.extensionsPath, request.id);
|
||||
|
||||
try {
|
||||
await fs.stat(extensionLocation);
|
||||
} catch {
|
||||
throw createError(ErrorCode.InvalidRequest, `Extension ${request.id} not found`);
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.rm(extensionLocation, { force: true, recursive: true });
|
||||
} catch (e) {
|
||||
throw createError(ErrorCode.InternalError, `Failed to remove extension ${request.id}: ${e}`);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,10 @@
|
||||
import * as api from '$';
|
||||
// import * as api from '$';
|
||||
import { Component } from '$core/Component';
|
||||
import { createError } from '$core/Error';
|
||||
import { ComponentInstance, findComponent, findComponentById, getActivatedComponentList, unregisterComponent } from './ComponentInstance';
|
||||
import * as locations from '$core/locations';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { ComponentInstance, findComponentById, getActivatedComponentList } from './ComponentInstance';
|
||||
import * as settings from './Settings';
|
||||
import { getLauncher } from './Launcher';
|
||||
import { Extension } from './Extension';
|
||||
import { Schema, SchemaError, SchemaObject, validateObject } from 'lib/Schema';
|
||||
import { ipcMain } from 'electron';
|
||||
|
||||
ipcMain.on('view/push', (event, view: string, ...args: any[]) => {
|
||||
event.sender.send('view/push', view, ...args);
|
||||
});
|
||||
ipcMain.on('view/set', (event, view: string, ...args: any[]) => {
|
||||
event.sender.send('view/set', view, ...args);
|
||||
});
|
||||
ipcMain.on('view/pop', (event, view: string, ...args: any[]) => {
|
||||
event.sender.send('view/pop', view, ...args);
|
||||
});
|
||||
import * as extensionApi from './extension-api';
|
||||
|
||||
export async function activate() {
|
||||
await settings.load();
|
||||
@@ -48,116 +33,19 @@ export async function deactivateComponent(_caller: Component, request: Component
|
||||
}
|
||||
|
||||
export async function loadExtension(_caller: Component, request: ExtensionLoadRequest): Promise<ExtensionLoadResponse> {
|
||||
if (findComponentById(request.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const extensionManifestLocation = path.join(locations.extensionsPath, request.id, "extension.json");
|
||||
|
||||
const manifestText = await (async () => {
|
||||
try {
|
||||
return await fs.readFile(extensionManifestLocation, "utf8");
|
||||
} catch {
|
||||
throw createError(ErrorCode.InvalidParams, `extension ${request.id} not found`);
|
||||
}
|
||||
})();
|
||||
|
||||
const manifest = await (async () => {
|
||||
try {
|
||||
return JSON.parse(manifestText) as ExtensionInfo;
|
||||
} catch {
|
||||
throw createError(ErrorCode.InternalError, `extension ${request.id} is broken`);
|
||||
}
|
||||
})();
|
||||
|
||||
const launcher = getLauncher(manifest.launcher.type);
|
||||
if (launcher == null) {
|
||||
throw createError(ErrorCode.InternalError, `launcher ${manifest.launcher.type} not found`);
|
||||
}
|
||||
|
||||
const process = await (async () => {
|
||||
try {
|
||||
return launcher.launch(path.join(locations.extensionsPath, request.id, manifest.executable), manifest.args ?? [], {
|
||||
launcherRequirements: manifest.launcher.requirements,
|
||||
});
|
||||
} catch {
|
||||
throw createError(ErrorCode.InternalError, `${request.id}: failed to spawn extension process`);
|
||||
}
|
||||
})();
|
||||
|
||||
new Extension(manifest, process);
|
||||
return extensionApi.loadExtension(request);
|
||||
}
|
||||
|
||||
export async function unloadExtension(_caller: Component, request: ExtensionUnloadRequest): Promise<ExtensionUnloadResponse> {
|
||||
await unregisterComponent(request.id);
|
||||
return extensionApi.unloadExtension(request);
|
||||
}
|
||||
|
||||
export async function installExtension(_caller: Component, request: ExtensionInstallRequest): Promise<ExtensionInstallResponse> {
|
||||
// FIXME: unpack package
|
||||
const extensionManifestLocation = path.join(request.path, "extension.json");
|
||||
|
||||
const manifestText = await (async () => {
|
||||
try {
|
||||
return await fs.readFile(extensionManifestLocation, "utf8");
|
||||
} catch {
|
||||
throw createError(ErrorCode.InvalidParams, `extension ${request.path} not found`);
|
||||
}
|
||||
})();
|
||||
|
||||
const manifest = await (async () => {
|
||||
try {
|
||||
return JSON.parse(manifestText) as ExtensionInfo;
|
||||
} catch {
|
||||
throw createError(ErrorCode.InternalError, `extension ${request.path} is broken`);
|
||||
}
|
||||
})();
|
||||
|
||||
if (findComponent(manifest.name, manifest.version)) {
|
||||
throw createError(ErrorCode.InvalidRequest, `extension ${request.path} already installed`);
|
||||
}
|
||||
|
||||
const launcher = getLauncher(manifest.launcher.type);
|
||||
if (launcher == null) {
|
||||
throw createError(ErrorCode.InternalError, `launcher ${manifest.launcher.type} not found`);
|
||||
}
|
||||
|
||||
const process = await (async () => {
|
||||
try {
|
||||
return launcher.launch(path.join(request.path, manifest.executable), manifest.args ?? [], {
|
||||
launcherRequirements: manifest.launcher.requirements,
|
||||
});
|
||||
} catch {
|
||||
throw createError(ErrorCode.InternalError, `${request.path}: failed to spawn extension process`);
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
const extension = new Extension(manifest, process);
|
||||
return { id: extension.getId() };
|
||||
} catch (e) {
|
||||
process.kill("SIGKILL");
|
||||
throw e;
|
||||
}
|
||||
return extensionApi.installExtension(request);
|
||||
}
|
||||
|
||||
export async function removeExtension(_caller: Component, request: ExtensionRemoveRequest): Promise<ExtensionRemoveResponse> {
|
||||
if (findComponentById(request.id)) {
|
||||
throw createError(ErrorCode.InvalidRequest, `extension ${request.id} in use`);
|
||||
}
|
||||
|
||||
const extensionLocation = path.join(locations.extensionsPath, request.id);
|
||||
|
||||
try {
|
||||
await fs.stat(extensionLocation);
|
||||
} catch {
|
||||
throw createError(ErrorCode.InvalidRequest, `Extension ${request.id} not found`);
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.rm(extensionLocation, { force: true, recursive: true });
|
||||
} catch (e) {
|
||||
throw createError(ErrorCode.InternalError, `Failed to remove extension ${request.id}: ${e}`);
|
||||
}
|
||||
return extensionApi.removeExtension(request);
|
||||
}
|
||||
|
||||
function getComponentInstanceSettings(instance: ComponentInstance) {
|
||||
@@ -261,7 +149,7 @@ export async function handleSettingsSet(caller: Component, request: SettingsSetR
|
||||
}
|
||||
|
||||
member[name] = request.value;
|
||||
api.emitSettingsUpdateEvent(request);
|
||||
// api.emitSettingsUpdateEvent(request);
|
||||
}
|
||||
|
||||
export async function handleSettingsGet(caller: Component, request: SettingsGetRequest): Promise<SettingsGetResponse> {
|
||||
|
||||
3
rpcsx-ui/src/core/server/registerBuiltinLaunchers.ts
Normal file
3
rpcsx-ui/src/core/server/registerBuiltinLaunchers.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
|
||||
|
||||
export function registerBuiltinLaunchers() {}
|
||||
120
rpcsx-ui/src/core/server/registerBuiltinLaunchers.web.ts
Normal file
120
rpcsx-ui/src/core/server/registerBuiltinLaunchers.web.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { dirname } from "path";
|
||||
import { addLauncher, Launcher, LaunchParams, Process } from "./Launcher";
|
||||
import { Target } from "./Target";
|
||||
import { fork, spawn } from "child_process";
|
||||
import { Duplex, EventEmitter } from "stream";
|
||||
|
||||
const nativeLauncher: Launcher = {
|
||||
launch: async (path: string, args: string[], params: LaunchParams) => {
|
||||
const newProcess = spawn(path, args, {
|
||||
argv0: path,
|
||||
cwd: dirname(path),
|
||||
signal: params.signal,
|
||||
stdio: 'pipe'
|
||||
});
|
||||
|
||||
newProcess.stdout.setEncoding('utf8');
|
||||
newProcess.stderr.setEncoding('utf8');
|
||||
|
||||
const result: Process = {
|
||||
stdin: newProcess.stdin,
|
||||
stdout: newProcess.stdout,
|
||||
stderr: newProcess.stderr,
|
||||
|
||||
kill: (signal: number | NodeJS.Signals) => {
|
||||
newProcess.kill(signal);
|
||||
},
|
||||
on: (event: string, handler: (...args: any[]) => void) => {
|
||||
newProcess.on(event, handler);
|
||||
},
|
||||
once: (event: string, handler: (...args: any[]) => void) => {
|
||||
newProcess.once(event, handler);
|
||||
},
|
||||
off: (event: string, handler: (...args: any[]) => void) => {
|
||||
newProcess.off(event, handler);
|
||||
}
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
const nodeLauncher: Launcher = {
|
||||
launch: async (path: string, args: string[], params: LaunchParams) => {
|
||||
const newProcess = fork(path, args, {
|
||||
signal: params.signal,
|
||||
stdio: 'pipe',
|
||||
cwd: dirname(path),
|
||||
});
|
||||
|
||||
newProcess.stdout!.setEncoding('utf8');
|
||||
newProcess.stderr!.setEncoding('utf8');
|
||||
|
||||
const result: Process = {
|
||||
stdin: newProcess.stdin!,
|
||||
stdout: newProcess.stdout!,
|
||||
stderr: newProcess.stderr!,
|
||||
|
||||
kill: (signal: number | NodeJS.Signals) => {
|
||||
newProcess.kill(signal);
|
||||
},
|
||||
on: (event: string, handler: (...args: any[]) => void) => {
|
||||
newProcess.on(event, handler);
|
||||
},
|
||||
once: (event: string, handler: (...args: any[]) => void) => {
|
||||
newProcess.once(event, handler);
|
||||
},
|
||||
off: (event: string, handler: (...args: any[]) => void) => {
|
||||
newProcess.off(event, handler);
|
||||
}
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
const inlineLauncher: Launcher = {
|
||||
launch: async (path: string, args: string[], params: LaunchParams) => {
|
||||
const imported = await import(path);
|
||||
|
||||
if (!("activate" in imported) || typeof imported.activate != 'function') {
|
||||
throw new Error(`${path}: invalid inline module`);
|
||||
}
|
||||
|
||||
const eventEmitter = new EventEmitter();
|
||||
const stdin = new Duplex();
|
||||
const stdout = new Duplex();
|
||||
const stderr = new Duplex();
|
||||
imported.activate(eventEmitter, args, params.launcherRequirements, stdin, stdout, stderr);
|
||||
|
||||
const result: Process = {
|
||||
stdin: stdin,
|
||||
stdout: stdout,
|
||||
stderr: stderr,
|
||||
|
||||
kill: (_signal: number | NodeJS.Signals) => {
|
||||
if (("deactivate" in imported) && typeof imported.deactivate == 'function') {
|
||||
imported.deactivate();
|
||||
}
|
||||
},
|
||||
on: (event: string, handler: (...args: any[]) => void) => {
|
||||
eventEmitter.on(event, handler);
|
||||
},
|
||||
once: (event: string, handler: (...args: any[]) => void) => {
|
||||
eventEmitter.once(event, handler);
|
||||
},
|
||||
off: (event: string, handler: (...args: any[]) => void) => {
|
||||
eventEmitter.off(event, handler);
|
||||
}
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
export function registerBuiltinLaunchers() {
|
||||
addLauncher(Target.native(), nativeLauncher);
|
||||
addLauncher(new Target("js", "any", "node"), nodeLauncher);
|
||||
addLauncher(new Target("js", "any", "inline"), inlineLauncher);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,180 @@
|
||||
},
|
||||
{
|
||||
"name": "settings"
|
||||
},
|
||||
{
|
||||
"name": "progress"
|
||||
}
|
||||
]
|
||||
],
|
||||
"contributions": {
|
||||
"types": {
|
||||
"item": {
|
||||
"type": "object",
|
||||
"params": {
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "array",
|
||||
"item-type": "$core/localized-string"
|
||||
},
|
||||
"description": {
|
||||
"type": "array",
|
||||
"item-type": "$core/localized-string",
|
||||
"optional": true
|
||||
},
|
||||
"location": {
|
||||
"type": "string",
|
||||
"optional": true
|
||||
},
|
||||
"icon": {
|
||||
"type": "array",
|
||||
"item-type": "$core/localized-icon",
|
||||
"optional": true
|
||||
},
|
||||
"publisher": {
|
||||
"type": "string",
|
||||
"optional": true
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"optional": true
|
||||
},
|
||||
"size": {
|
||||
"type": "number",
|
||||
"optional": true
|
||||
},
|
||||
"actions": {
|
||||
"type": "json-object",
|
||||
"optional": true
|
||||
},
|
||||
"progress": {
|
||||
"type": "number",
|
||||
"optional": true
|
||||
},
|
||||
"launcher": {
|
||||
"type": "launcher-info",
|
||||
"optional": true
|
||||
},
|
||||
"title-id": {
|
||||
"type": "string",
|
||||
"optional": true
|
||||
},
|
||||
"content-id": {
|
||||
"type": "string",
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"item-filter": {
|
||||
"type": "object",
|
||||
"params": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"optional": true
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"optional": true
|
||||
},
|
||||
"location": {
|
||||
"type": "string",
|
||||
"optional": true
|
||||
},
|
||||
"icon": {
|
||||
"type": "array",
|
||||
"item-type": "string",
|
||||
"optional": true
|
||||
},
|
||||
"publisher": {
|
||||
"type": "string",
|
||||
"optional": true
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"optional": true
|
||||
},
|
||||
"size": {
|
||||
"type": "number",
|
||||
"optional": true
|
||||
},
|
||||
"actions": {
|
||||
"type": "json-object",
|
||||
"optional": true
|
||||
},
|
||||
"progress": {
|
||||
"type": "number",
|
||||
"optional": true
|
||||
},
|
||||
"launcher": {
|
||||
"type": "launcher-info",
|
||||
"optional": true
|
||||
},
|
||||
"title-id": {
|
||||
"type": "string",
|
||||
"optional": true
|
||||
},
|
||||
"content-id": {
|
||||
"type": "string",
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"methods": {
|
||||
"get": {
|
||||
"handler": "handleGet",
|
||||
"params": {
|
||||
"filter": {
|
||||
"type": "item-filter",
|
||||
"optional": true
|
||||
},
|
||||
"query": {
|
||||
"type": "string",
|
||||
"optional": true
|
||||
},
|
||||
"channel": {
|
||||
"type": "number",
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"returns": {
|
||||
"channel": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"add": {
|
||||
"handler": "handleAdd",
|
||||
"params": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"item-type": "item"
|
||||
}
|
||||
}
|
||||
},
|
||||
"remove": {
|
||||
"handler": "handleRemove",
|
||||
"params": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"item-type": "item-filter"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"events": {
|
||||
"items": {
|
||||
"channel": {
|
||||
"type": "number"
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"item-type": "item"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
45
rpcsx-ui/src/explorer/lib/Explorer.d.ts
vendored
45
rpcsx-ui/src/explorer/lib/Explorer.d.ts
vendored
@@ -1,45 +0,0 @@
|
||||
type IconResolution = 'normal' | 'high';
|
||||
|
||||
type LocalizedString = {
|
||||
text: string;
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
type LocalizedIcon = {
|
||||
uri: string;
|
||||
lang?: string;
|
||||
resolution?: IconResolution;
|
||||
}
|
||||
|
||||
type ExplorerItemBase = {
|
||||
type: string;
|
||||
name: LocalizedString[] | string;
|
||||
icon?: LocalizedIcon[] | string;
|
||||
publisher?: string;
|
||||
version?: string;
|
||||
size?: number;
|
||||
actions?: Record<string, any>;
|
||||
progress?: string;
|
||||
}
|
||||
|
||||
type ExecutableExplorerItem = {
|
||||
launcher: LauncherInfo;
|
||||
}
|
||||
|
||||
type ExplorerItemGame = ExplorerItemBase & ExecutableExplorerItem & {
|
||||
type: 'game';
|
||||
titleId?: string;
|
||||
contentId?: string;
|
||||
}
|
||||
|
||||
type ExplorerItemPackage = ExplorerItemBase & ExecutableExplorerItem & {
|
||||
type: 'package';
|
||||
titleId?: string;
|
||||
contentId?: string;
|
||||
}
|
||||
|
||||
type ExplorerItemExtension = ExplorerItemBase & {
|
||||
type: 'extension';
|
||||
}
|
||||
|
||||
type ExplorerItem = ExplorerItemGame | ExplorerItemPackage | ExplorerItemExtension;
|
||||
@@ -1,25 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as itemUtil from "helpers/ExplorerItemUtils";
|
||||
|
||||
export let item: ExplorerItem;
|
||||
export let oncontextmenu: (() => void) | undefined;
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore <a11y_no_static_element_interactions> -->
|
||||
<div
|
||||
class="flex flex-col text-center items-center p-3 gap-3 text-sm rounded bg-neutral-700 hover:bg-neutral-600 active:ring-1 shadow-sm"
|
||||
oncontextmenu={() => {
|
||||
if (oncontextmenu) oncontextmenu();
|
||||
}}
|
||||
>
|
||||
<img
|
||||
class="h-[8rem] w-[8rem]"
|
||||
src={itemUtil.getIcon(item)}
|
||||
alt="{itemUtil.getName(item)} Icon"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div class="flex flex-col justify-center items-center w-[8rem] h-[4rem]">
|
||||
<h1 class="overflow-hidden line-clamp-2">{itemUtil.getName(item)}</h1>
|
||||
<h3 class="overflow-hidden line-clamp-2">{"titleId" in item ? item.titleId : ""}</h3>
|
||||
</div>
|
||||
</div>
|
||||
31
rpcsx-ui/src/explorer/renderer/ExplorerGridItem.tsx
Normal file
31
rpcsx-ui/src/explorer/renderer/ExplorerGridItem.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { ExplorerItem, getIcon, getName } from "./types";
|
||||
|
||||
interface ExplorerGridItemProps {
|
||||
item: ExplorerItem;
|
||||
onContextMenu?: () => void;
|
||||
}
|
||||
|
||||
export default function ExplorerGridItem({ item, onContextMenu }: ExplorerGridItemProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col text-center items-center p-3 gap-3 text-sm rounded bg-neutral-700 hover:bg-neutral-600 active:ring-1 shadow-sm"
|
||||
onContextMenu={(e) => {
|
||||
if (onContextMenu) {
|
||||
e.preventDefault();
|
||||
onContextMenu();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<img
|
||||
className="h-[8rem] w-[8rem]"
|
||||
src={getIcon(item) || '/default-icon.png'}
|
||||
alt={`${getName(item)} Icon`}
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="flex flex-col justify-center items-center w-[8rem] h-[4rem]">
|
||||
<h1 className="overflow-hidden line-clamp-2">{getName(item)}</h1>
|
||||
<h3 className="overflow-hidden line-clamp-2">{item.titleId ? item.titleId : ""}</h3>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as itemUtil from "helpers/ExplorerItemUtils";
|
||||
import { FileHelper } from "$core/helpers/FileHelper";
|
||||
|
||||
export let item: ExplorerItem;
|
||||
export let oncontextmenu: (() => void) | undefined;
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore <a11y_no_static_element_interactions> -->
|
||||
<div
|
||||
class="flex flex-row p-3 gap-3 text-sm rounded bg-neutral-700 hover:bg-neutral-600 shadow-sm"
|
||||
oncontextmenu={() => {
|
||||
if (oncontextmenu) {
|
||||
oncontextmenu();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<img
|
||||
class="h-20 w-20"
|
||||
src={itemUtil.getIcon(item)}
|
||||
alt="{itemUtil.getName(item)} Icon"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div class="flex flex-col text-left">
|
||||
<h1 class="font-bold">{itemUtil.getName(item)}</h1>
|
||||
<p>{item.publisher}</p>
|
||||
<p>{item.version}</p>
|
||||
<p></p>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow"></div>
|
||||
|
||||
<div class="flex flex-col text-right">
|
||||
<p>{"titleId" in item ? item.titleId : ""}</p>
|
||||
<p>{item.size ? FileHelper.humanFileSize(item.size, true) : ""}</p>
|
||||
<p>{"contentId" in item ? itemUtil.getRegion(item.contentId) : ""}</p>
|
||||
<p>{"contentId" in item ? item.contentId : ""}</p>
|
||||
</div>
|
||||
</div>
|
||||
42
rpcsx-ui/src/explorer/renderer/ExplorerListItem.tsx
Normal file
42
rpcsx-ui/src/explorer/renderer/ExplorerListItem.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { ExplorerItem, getIcon, getName, getRegion, FileHelper } from "./types";
|
||||
|
||||
interface ExplorerListItemProps {
|
||||
item: ExplorerItem;
|
||||
onContextMenu?: () => void;
|
||||
}
|
||||
|
||||
export default function ExplorerListItem({ item, onContextMenu }: ExplorerListItemProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-row p-3 gap-3 text-sm rounded bg-neutral-700 hover:bg-neutral-600 shadow-sm"
|
||||
onContextMenu={(e) => {
|
||||
if (onContextMenu) {
|
||||
e.preventDefault();
|
||||
onContextMenu();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<img
|
||||
className="h-20 w-20"
|
||||
src={getIcon(item) || '/default-icon.png'}
|
||||
alt={`${getName(item)} Icon`}
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="flex flex-col text-left">
|
||||
<h1 className="font-bold">{getName(item)}</h1>
|
||||
<p>{item.publisher}</p>
|
||||
<p>{item.version}</p>
|
||||
<p></p>
|
||||
</div>
|
||||
|
||||
<div className="flex-grow"></div>
|
||||
|
||||
<div className="flex flex-col text-right">
|
||||
<p>{'titleId' in item ? item.titleId : ''}</p>
|
||||
<p>{item.size ? FileHelper.humanFileSize(item.size, true) : ''}</p>
|
||||
<p>{item.contentId ? getRegion(item.contentId) : ''}</p>
|
||||
<p>{item.contentId ? item.contentId : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Region } from 'models/Region';
|
||||
import { Region } from '$/Region';
|
||||
import { IconResolution } from '$core/enums';
|
||||
import { getLocalizedString, getLocalizedIcon } from '$core/Localized';
|
||||
|
||||
export function getRegion(contentId?: string) {
|
||||
if (contentId === undefined || contentId.length != 36) {
|
||||
@@ -18,54 +20,14 @@ export function getRegion(contentId?: string) {
|
||||
}
|
||||
|
||||
export function getName(item: ExplorerItem, langs: string[] = []) {
|
||||
if (!Array.isArray(item.name)) {
|
||||
return item.name;
|
||||
}
|
||||
|
||||
for (let langIndex = 0; langIndex < langs.length; ++langIndex) {
|
||||
const lang = langs[langIndex];
|
||||
|
||||
for (let nameIndex = 0; nameIndex < item.name.length; ++nameIndex) {
|
||||
if (item.name[nameIndex].lang === lang) {
|
||||
return item.name[nameIndex].text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return item.name[0].text;
|
||||
return getLocalizedString(item.name, langs);
|
||||
}
|
||||
|
||||
export function getIcon(item: ExplorerItem, resolution: IconResolution = 'normal', langs: string[] = []) {
|
||||
export function getIcon(item: ExplorerItem, resolution: IconResolution = IconResolution.Normal, langs: string[] = []) {
|
||||
if (!item.icon) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!Array.isArray(item.icon)) {
|
||||
return item.icon;
|
||||
}
|
||||
|
||||
for (let langIndex = 0; langIndex < langs.length; ++langIndex) {
|
||||
const lang = langs[langIndex];
|
||||
|
||||
for (let iconIndex = 0; iconIndex < item.icon.length; ++iconIndex) {
|
||||
const icon = item.icon[iconIndex];
|
||||
if (icon.lang === lang && icon.resolution === resolution) {
|
||||
return icon.uri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let langIndex = 0; langIndex < langs.length; ++langIndex) {
|
||||
const lang = langs[langIndex];
|
||||
|
||||
for (let iconIndex = 0; iconIndex < item.icon.length; ++iconIndex) {
|
||||
const icon = item.icon[iconIndex];
|
||||
if (icon.lang === lang) {
|
||||
return icon.uri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return item.icon[0].uri;
|
||||
return getLocalizedIcon(item.icon, resolution, langs);
|
||||
}
|
||||
|
||||
|
||||
116
rpcsx-ui/src/explorer/renderer/types.ts
Normal file
116
rpcsx-ui/src/explorer/renderer/types.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
// Explorer types for React conversion
|
||||
|
||||
export interface ExplorerItem {
|
||||
name: string | Array<{ lang: string; text: string }>;
|
||||
publisher: string;
|
||||
version: string;
|
||||
titleId?: string;
|
||||
size?: number;
|
||||
contentId?: string;
|
||||
icon?: string | Array<{ lang: string; resolution: ExplorerIconResolution; uri: string }>;
|
||||
// Add other properties as needed
|
||||
}
|
||||
|
||||
export enum ExplorerIconResolution {
|
||||
Normal = "Normal"
|
||||
}
|
||||
|
||||
export interface ExplorerItemFilter {
|
||||
// Define filter properties as needed
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface FileHelper {
|
||||
humanFileSize: (bytes: number, si: boolean) => string;
|
||||
}
|
||||
|
||||
// Mock FileHelper for now
|
||||
export const FileHelper: FileHelper = {
|
||||
humanFileSize: (bytes: number, si: boolean = false) => {
|
||||
const thresh = si ? 1000 : 1024;
|
||||
if (Math.abs(bytes) < thresh) {
|
||||
return bytes + ' B';
|
||||
}
|
||||
const units = si
|
||||
? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||
: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
|
||||
let u = -1;
|
||||
do {
|
||||
bytes /= thresh;
|
||||
++u;
|
||||
} while (Math.abs(bytes) >= thresh && u < units.length - 1);
|
||||
return bytes.toFixed(1) + ' ' + units[u];
|
||||
}
|
||||
};
|
||||
|
||||
export enum Region {
|
||||
Unknown = "Unknown",
|
||||
Europe = "Europe",
|
||||
Asia = "Asia",
|
||||
World = "World",
|
||||
Japan = "Japan",
|
||||
Korea = "Korea",
|
||||
USA = "USA"
|
||||
}
|
||||
|
||||
export function getRegion(contentId?: string): Region {
|
||||
if (contentId === undefined || contentId.length !== 36) {
|
||||
return Region.Unknown;
|
||||
}
|
||||
|
||||
switch (contentId[0]) {
|
||||
case 'E': return Region.Europe;
|
||||
case 'H': return Region.Asia;
|
||||
case 'I': return Region.World;
|
||||
case 'J': return Region.Japan;
|
||||
case 'K': return Region.Korea;
|
||||
case 'U': return Region.USA;
|
||||
default:
|
||||
return Region.Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
export function getName(item: ExplorerItem, langs: string[] = []): string {
|
||||
if (!Array.isArray(item.name)) {
|
||||
return item.name;
|
||||
}
|
||||
|
||||
for (const lang of langs) {
|
||||
for (const name of item.name) {
|
||||
if (name.lang === lang) {
|
||||
return name.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return first available name if no language match
|
||||
return item.name.length > 0 ? item.name[0].text : '';
|
||||
}
|
||||
|
||||
export function getIcon(item: ExplorerItem, resolution: ExplorerIconResolution = ExplorerIconResolution.Normal, langs: string[] = []): string | undefined {
|
||||
if (!item.icon) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!Array.isArray(item.icon)) {
|
||||
return item.icon;
|
||||
}
|
||||
|
||||
for (const lang of langs) {
|
||||
for (const icon of item.icon) {
|
||||
if (icon.lang === lang && icon.resolution === resolution) {
|
||||
return icon.uri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const lang of langs) {
|
||||
for (const icon of item.icon) {
|
||||
if (icon.lang === lang) {
|
||||
return icon.uri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return item.icon[0]?.uri;
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Footer from "$core/Footer.svelte";
|
||||
import Header from "$core/Header.svelte";
|
||||
import { getKeyModifiers, KeyboardModifiers } from "$core/helpers/Keyboard";
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import * as itemUtil from "helpers/ExplorerItemUtils";
|
||||
import Menu from "$menu/Menu.svelte";
|
||||
import ExplorerGridItem from "../ExplorerGridItem.svelte";
|
||||
import ExplorerListItem from "../ExplorerListItem.svelte";
|
||||
|
||||
export let query: string;
|
||||
export let queryParams: {
|
||||
filter?: Partial<ExplorerItem>;
|
||||
sort?: Partial<ExplorerItem>;
|
||||
sortAsc?: boolean;
|
||||
};
|
||||
|
||||
export let layout: "list" | "grid" = "list";
|
||||
|
||||
console.log(layout, query, queryParams);
|
||||
|
||||
const items: ExplorerItem[] = [];
|
||||
let filteredItems: ExplorerItem[] = [];
|
||||
let contextMenu: Menu;
|
||||
|
||||
let rootElement: HTMLDivElement;
|
||||
let searchElement: Header;
|
||||
let searchTerm = "";
|
||||
let prevSearchTerm = "";
|
||||
|
||||
const search = () => {
|
||||
const text = searchTerm.toLowerCase();
|
||||
const extendsPrevious = prevSearchTerm.length > 0 && text.includes(prevSearchTerm);
|
||||
prevSearchTerm = text;
|
||||
|
||||
filteredItems =
|
||||
text.length === 0
|
||||
? items
|
||||
: (extendsPrevious ? filteredItems : items).filter(
|
||||
(item) => itemUtil.getName(item)?.toLowerCase().includes(text) ?? false,
|
||||
);
|
||||
};
|
||||
|
||||
let removeKeyboardInterception = () => {};
|
||||
|
||||
function installKeyboardInterception() {
|
||||
let element: null | HTMLElement = rootElement;
|
||||
while (element) {
|
||||
if (element.parentElement?.parentElement != null) {
|
||||
element = element.parentElement;
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
const modifiers = getKeyModifiers(e);
|
||||
if (modifiers != KeyboardModifiers.None) {
|
||||
if (
|
||||
modifiers != KeyboardModifiers.Shift &&
|
||||
(modifiers != KeyboardModifiers.Ctrl || e.code != "KeyF")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key.length != 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(e.key >= "A" && e.key <= "Z") ||
|
||||
(e.key >= "a" && e.key <= "z") ||
|
||||
(e.key >= "0" && e.key <= "9")
|
||||
) {
|
||||
searchElement.focus();
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
if (element) {
|
||||
element.addEventListener("keydown", handler);
|
||||
removeKeyboardInterception = () => {
|
||||
element?.removeEventListener("keydown", handler);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
installKeyboardInterception();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
removeKeyboardInterception();
|
||||
});
|
||||
|
||||
function __<T extends string>(x: T, _options?: object) {
|
||||
// return $_(x, _options);
|
||||
return x;
|
||||
}
|
||||
|
||||
function createContextMenu(item: ExplorerItem) {
|
||||
const menuItems: MenuItem[] = [];
|
||||
|
||||
if (item.actions) {
|
||||
if (item.actions.run) {
|
||||
const action = item.actions.run;
|
||||
menuItems.push({
|
||||
label: __("Run"),
|
||||
onClick: () => window.electron.ipcRenderer.send("action/run", action),
|
||||
});
|
||||
|
||||
menuItems.push({
|
||||
label: __("Run with ..."),
|
||||
onClick: () => window.electron.ipcRenderer.send("action/run-with", action),
|
||||
});
|
||||
|
||||
if (item.actions.install && item.actions.delete) {
|
||||
const installAction = item.actions.install;
|
||||
const deleteAction = item.actions.delete;
|
||||
menuItems.push({
|
||||
label: __("Reinstall"),
|
||||
onClick: () =>
|
||||
window.electron.ipcRenderer.send("action/reinstall", installAction, deleteAction),
|
||||
});
|
||||
}
|
||||
} else if (item.actions.install) {
|
||||
if (item.actions.install) {
|
||||
const action = item.actions.install;
|
||||
menuItems.push({
|
||||
label: __("Install"),
|
||||
onClick: () => window.electron.ipcRenderer.send("action/install", action),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (item.actions.settings) {
|
||||
const action = item.actions.settings;
|
||||
menuItems.push({
|
||||
label: __("Settings"),
|
||||
onClick: () => window.electron.ipcRenderer.send("action/settings", action),
|
||||
});
|
||||
}
|
||||
|
||||
if (item.actions.delete) {
|
||||
const action = item.actions.delete;
|
||||
menuItems.push({
|
||||
label: __("Delete"),
|
||||
onClick: () => window.electron.ipcRenderer.send("action/delete", action),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
menuItems.push({ label: "Run" });
|
||||
menuItems.push({ label: "Run with ..." });
|
||||
menuItems.push({ label: "Settings" });
|
||||
menuItems.push({ label: "Delete" });
|
||||
|
||||
return () => {
|
||||
if (menuItems.length > 0) {
|
||||
contextMenu.show(itemUtil.getName(item), menuItems);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (window.electron) {
|
||||
window.electron.ipcRenderer.on(query, (params: { executables: ExplorerItem[] }) => {
|
||||
// console.log(newItems);
|
||||
items.push(...params.executables);
|
||||
search();
|
||||
});
|
||||
window.electron.ipcRenderer.send(query, queryParams);
|
||||
}
|
||||
|
||||
search();
|
||||
</script>
|
||||
|
||||
<Menu bind:this={contextMenu}></Menu>
|
||||
|
||||
<div class="min-h-full h-full flex flex-col" bind:this={rootElement}>
|
||||
<Header bind:searchTerm bind:this={searchElement} on:input={search} bind:layout />
|
||||
<div class="flex-grow overflow-y-scroll">
|
||||
{#if layout === "grid"}
|
||||
<div class="grid grid-cols-auto-repeat justify-center gap-2 m-2">
|
||||
{#each filteredItems as item}
|
||||
<ExplorerGridItem {item} oncontextmenu={createContextMenu(item)} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-2 m-2">
|
||||
{#each filteredItems as item}
|
||||
<ExplorerListItem {item} oncontextmenu={createContextMenu(item)} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Footer bind:gameCount={filteredItems.length} />
|
||||
</div>
|
||||
454
rpcsx-ui/src/explorer/renderer/views/Explorer.tsx
Normal file
454
rpcsx-ui/src/explorer/renderer/views/Explorer.tsx
Normal file
@@ -0,0 +1,454 @@
|
||||
import { ComponentProps, ReactElement, useEffect, useState } from 'react';
|
||||
import { Image, Pressable, ScrollView, StyleSheet, View, FlatList } from 'react-native';
|
||||
import { useThemeColor } from '$core/useThemeColor'
|
||||
import { } from '@react-navigation/elements';
|
||||
import ThemedIcon from '$core/ThemedIcon';
|
||||
import { ThemedText } from '$core/ThemedText';
|
||||
import { getIcon, getName } from "$/ExplorerItemUtils"
|
||||
import { HapticPressable } from '$core/HapticPressable';
|
||||
import { DownShowViewSelector, LeftRightViewSelector } from '$core/ViewSelector';
|
||||
import { getLocalizedString } from '$core/Localized';
|
||||
import * as settings from '$settings';
|
||||
|
||||
import Animated, {
|
||||
Easing,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
interpolate,
|
||||
withTiming,
|
||||
cancelAnimation,
|
||||
interpolateColor,
|
||||
} from 'react-native-reanimated';
|
||||
import { useColorScheme } from '$core/useColorScheme';
|
||||
|
||||
|
||||
const games: (ExecutableInfo & ExplorerItem)[] = [
|
||||
{
|
||||
type: 'game',
|
||||
executable: "test-executable",
|
||||
launcher: {
|
||||
type: "test-launcher",
|
||||
requirements: {}
|
||||
},
|
||||
description: [
|
||||
{
|
||||
text: "Test Game"
|
||||
}
|
||||
],
|
||||
name: [
|
||||
{
|
||||
text: "Test Game"
|
||||
}
|
||||
],
|
||||
version: "0.1"
|
||||
},
|
||||
{
|
||||
type: 'game',
|
||||
executable: "test-executable",
|
||||
launcher: {
|
||||
type: "test-launcher",
|
||||
requirements: {}
|
||||
},
|
||||
description: [
|
||||
{
|
||||
text: "Test Game"
|
||||
}
|
||||
],
|
||||
name: [
|
||||
{
|
||||
text: "Test Game"
|
||||
}
|
||||
],
|
||||
version: "0.1"
|
||||
},
|
||||
{
|
||||
type: 'game',
|
||||
executable: "test-executable",
|
||||
launcher: {
|
||||
type: "test-launcher",
|
||||
requirements: {}
|
||||
},
|
||||
description: [
|
||||
{
|
||||
text: "Test Game"
|
||||
}
|
||||
],
|
||||
name: [
|
||||
{
|
||||
text: "Test Game"
|
||||
}
|
||||
],
|
||||
version: "0.1"
|
||||
},
|
||||
];
|
||||
const extensions: (ExtensionInfo & ExplorerItem)[] = [
|
||||
{
|
||||
type: 'extension',
|
||||
executable: "test-executable",
|
||||
launcher: {
|
||||
type: "test-launcher",
|
||||
requirements: {}
|
||||
},
|
||||
name: [{
|
||||
text: "Unknown"
|
||||
}],
|
||||
version: "0.1"
|
||||
},
|
||||
{
|
||||
type: 'extension',
|
||||
executable: "test-executable",
|
||||
launcher: {
|
||||
type: "test-launcher",
|
||||
requirements: {}
|
||||
},
|
||||
name: [{
|
||||
text: "Unknown"
|
||||
}],
|
||||
version: "0.1"
|
||||
},
|
||||
{
|
||||
type: 'extension',
|
||||
executable: "test-executable",
|
||||
launcher: {
|
||||
type: "test-launcher",
|
||||
requirements: {}
|
||||
},
|
||||
name: [{
|
||||
text: "Unknown"
|
||||
}],
|
||||
version: "0.1"
|
||||
},
|
||||
{
|
||||
type: 'extension',
|
||||
executable: "test-executable",
|
||||
launcher: {
|
||||
type: "test-launcher",
|
||||
requirements: {}
|
||||
},
|
||||
name: [{
|
||||
text: "Unknown"
|
||||
}],
|
||||
version: "0.1"
|
||||
},
|
||||
{
|
||||
type: 'extension',
|
||||
executable: "test-executable",
|
||||
launcher: {
|
||||
type: "test-launcher",
|
||||
requirements: {}
|
||||
},
|
||||
name: [{
|
||||
text: "Unknown"
|
||||
}],
|
||||
version: "0.1"
|
||||
},
|
||||
{
|
||||
type: 'extension',
|
||||
executable: "test-executable",
|
||||
launcher: {
|
||||
type: "test-launcher",
|
||||
requirements: {}
|
||||
},
|
||||
name: [{
|
||||
text: "Unknown"
|
||||
}],
|
||||
version: "0.1"
|
||||
},
|
||||
{
|
||||
type: 'extension',
|
||||
executable: "test-executable",
|
||||
launcher: {
|
||||
type: "test-launcher",
|
||||
requirements: {}
|
||||
},
|
||||
name: [{
|
||||
text: "Unknown"
|
||||
}],
|
||||
version: "0.1"
|
||||
},
|
||||
{
|
||||
type: 'extension',
|
||||
executable: "test-executable",
|
||||
launcher: {
|
||||
type: "test-launcher",
|
||||
requirements: {}
|
||||
},
|
||||
name: [{
|
||||
text: "Unknown"
|
||||
}],
|
||||
version: "0.1"
|
||||
},
|
||||
{
|
||||
type: 'extension',
|
||||
executable: "test-executable",
|
||||
launcher: {
|
||||
type: "test-launcher",
|
||||
requirements: {}
|
||||
},
|
||||
name: [{
|
||||
text: "Unknown"
|
||||
}],
|
||||
version: "0.1"
|
||||
},
|
||||
{
|
||||
type: 'extension',
|
||||
executable: "test-executable",
|
||||
launcher: {
|
||||
type: "test-launcher",
|
||||
requirements: {}
|
||||
},
|
||||
name: [{
|
||||
text: "Unknown"
|
||||
}],
|
||||
version: "0.1"
|
||||
},
|
||||
{
|
||||
type: 'extension',
|
||||
executable: "test-executable",
|
||||
launcher: {
|
||||
type: "test-launcher",
|
||||
requirements: {}
|
||||
},
|
||||
name: [{
|
||||
text: "Unknown"
|
||||
}],
|
||||
version: "0.1"
|
||||
}
|
||||
];
|
||||
|
||||
type ScreenTabProps = {
|
||||
active: boolean;
|
||||
title: string;
|
||||
};
|
||||
|
||||
const AnimatedThemedText = Animated.createAnimatedComponent(ThemedText);
|
||||
|
||||
function ScreenTab(props: Omit<ComponentProps<typeof HapticPressable>, "children"> & ScreenTabProps) {
|
||||
const [activeState, setActiveState] = useState(props.active);
|
||||
const animation = useSharedValue(props.active ? 100 : 0);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
fontSize: interpolate(animation.value, [0, 100], [28, 32]),
|
||||
lineHeight: interpolate(animation.value, [0, 100], [24, 24]),
|
||||
opacity: interpolate(animation.value, [0, 100], [0.6, 1]),
|
||||
}));
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (activeState != props.active) {
|
||||
setActiveState(props.active);
|
||||
cancelAnimation(animation);
|
||||
}
|
||||
|
||||
animation.value = withTiming(props.active ? 100 : 0, {
|
||||
duration: 400,
|
||||
easing: Easing.out(Easing.exp)
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<HapticPressable
|
||||
{...props}
|
||||
children={
|
||||
<AnimatedThemedText
|
||||
type='title'
|
||||
style={animatedStyle}>
|
||||
{props.title}
|
||||
</AnimatedThemedText>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
|
||||
|
||||
function ExplorerItemHeader({ item, active, ...rest }: { item: ExplorerItem, active: boolean, } & ComponentProps<typeof Pressable>) {
|
||||
const [activeState, setActiveState] = useState(active);
|
||||
const animation = useSharedValue(active ? 100 : 0);
|
||||
|
||||
const activeBackground = useThemeColor("primaryContainer");
|
||||
const inactiveBackground = useThemeColor("surfaceContainer");
|
||||
|
||||
useEffect(() => {
|
||||
if (activeState != active) {
|
||||
setActiveState(active);
|
||||
cancelAnimation(animation);
|
||||
}
|
||||
|
||||
animation.value = withTiming(active ? 100 : 0, {
|
||||
duration: 500,
|
||||
easing: Easing.out(Easing.exp)
|
||||
});
|
||||
});
|
||||
|
||||
const buttonsContainerStyle = useAnimatedStyle(() => ({
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
alignItems: 'center',
|
||||
alignSelf: 'center',
|
||||
justifyContent: 'center',
|
||||
flexWrap: 'nowrap',
|
||||
backgroundColor: interpolateColor(animation.value, [0, 100], [inactiveBackground, activeBackground])
|
||||
}));
|
||||
|
||||
|
||||
const icon = getIcon(item);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
image: {
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
alignSelf: 'center',
|
||||
justifyContent: 'center',
|
||||
flexWrap: 'nowrap',
|
||||
height: "100%",
|
||||
minWidth: 100,
|
||||
},
|
||||
});
|
||||
|
||||
return <AnimatedPressable style={buttonsContainerStyle} {...rest}>
|
||||
{icon ? <Image source={{ uri: icon }} style={styles.image} resizeMethod='scale' resizeMode='contain' /> :
|
||||
<View style={styles.image}><ThemedIcon iconSet="Ionicons" name="extension-puzzle-sharp" size={100} /><ThemedText>{getName(item)}</ThemedText></View>
|
||||
}
|
||||
</AnimatedPressable>
|
||||
}
|
||||
|
||||
function ExplorerItemBody({ item }: { item: ExplorerItem }) {
|
||||
return (<Animated.ScrollView style={{ margin: 80, flex: 1, flexGrow: 5 }}>
|
||||
{item.description && <ThemedText type='subtitle'>{item.description && getLocalizedString(item.description)}</ThemedText>}
|
||||
</Animated.ScrollView>)
|
||||
}
|
||||
|
||||
function ExplorerView({ items }: { items: ExplorerItem[] }) {
|
||||
const styles = StyleSheet.create({
|
||||
topContainer: {
|
||||
flex: 1,
|
||||
flexGrow: 1,
|
||||
},
|
||||
scrollContainer: {
|
||||
flex: 1,
|
||||
flexGrow: 1,
|
||||
marginHorizontal: 60,
|
||||
},
|
||||
scrollContentContainer: {
|
||||
},
|
||||
descriptionContainer: {
|
||||
flex: 1,
|
||||
flexGrow: 1,
|
||||
flexDirection: 'column',
|
||||
}
|
||||
});
|
||||
|
||||
const [selectedItem, selectItem] = useState(0);
|
||||
|
||||
return (
|
||||
<View style={styles.topContainer}>
|
||||
<ScrollView style={styles.descriptionContainer} showsVerticalScrollIndicator={false}>
|
||||
<FlatList data={items} style={styles.scrollContainer} contentContainerStyle={styles.scrollContentContainer} horizontal={true} showsHorizontalScrollIndicator={false} renderItem={
|
||||
({ item, index }) => {
|
||||
const name = getName(item);
|
||||
return <ExplorerItemHeader key={index + name + item.type + item.version} item={item} style={{ margin: 30 }} onPress={() => selectItem(index)} active={index == selectedItem}></ExplorerItemHeader>
|
||||
}
|
||||
}>
|
||||
</FlatList>
|
||||
|
||||
<DownShowViewSelector list={items} renderItem={item => <ExplorerItemBody item={item} />} selectedItem={selectedItem} />
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
type Screen = {
|
||||
title: string;
|
||||
view: (setBackgroundImage: (image: string | undefined) => void) => ReactElement;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
layout?: "list" | "grid";
|
||||
filter?: ExplorerItemFilter;
|
||||
sort?: Partial<ExplorerItem>;
|
||||
sortAsc?: boolean;
|
||||
};
|
||||
|
||||
const ExplorerStyles = StyleSheet.create({
|
||||
rootContainer: { height: "100%", width: "100%" },
|
||||
menuContainer: {
|
||||
flexDirection: 'row',
|
||||
minHeight: 50,
|
||||
marginLeft: 60,
|
||||
marginRight: 40,
|
||||
marginTop: 60
|
||||
},
|
||||
containerButtons: {
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
alignItems: 'flex-end',
|
||||
flexGrow: 1,
|
||||
gap: 40,
|
||||
},
|
||||
containerMenuItems: {
|
||||
flexDirection: 'row',
|
||||
flex: 1,
|
||||
alignItems: 'flex-start',
|
||||
flexWrap: 'wrap',
|
||||
gap: 40,
|
||||
},
|
||||
containerTabs: {
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
flexGrow: 1,
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'flex-start',
|
||||
gap: 40,
|
||||
},
|
||||
contentContainer: {
|
||||
flexDirection: 'row',
|
||||
flexGrow: 1,
|
||||
flex: 1,
|
||||
margin: 40,
|
||||
},
|
||||
});
|
||||
|
||||
const screens: Screen[] = [
|
||||
{
|
||||
title: "Games",
|
||||
view: () => <ExplorerView items={games} />
|
||||
},
|
||||
{
|
||||
title: "Extensions",
|
||||
view: () => <ExplorerView items={extensions} />
|
||||
}
|
||||
];
|
||||
|
||||
export function Explorer(props?: Props) {
|
||||
const [background, setBackground] = useState<string | undefined>(undefined);
|
||||
const [activeTab, setActiveTab] = useState(1);
|
||||
const theme = useColorScheme();
|
||||
|
||||
return (
|
||||
<View style={[ExplorerStyles.rootContainer, { backgroundColor: theme == 'dark' ? "black" : "white", backgroundImage: background }]}>
|
||||
<View style={ExplorerStyles.menuContainer}>
|
||||
<View style={ExplorerStyles.containerTabs}>
|
||||
<View style={ExplorerStyles.containerMenuItems}>
|
||||
{
|
||||
screens.map((screen, index) =>
|
||||
<ScreenTab key={screen.title} title={screen.title} active={activeTab == index} onPress={() => setActiveTab(index)} />
|
||||
)
|
||||
}
|
||||
</View>
|
||||
</View>
|
||||
<View style={ExplorerStyles.containerButtons}>
|
||||
<View style={ExplorerStyles.containerMenuItems}>
|
||||
<HapticPressable><ThemedIcon iconSet="Ionicons" name="search" size={40} /></HapticPressable>
|
||||
<HapticPressable onPress={() => settings.pushSettingsView({})}><ThemedIcon iconSet="Ionicons" name="settings-outline" size={40} /></HapticPressable>
|
||||
<HapticPressable><ThemedIcon iconSet="FontAwesome6" name="user" size={40} /></HapticPressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<LeftRightViewSelector list={screens} style={{ flex: 1, flexGrow: 1 }} renderItem={item => item.view((image) => setBackground(image))} selectedItem={activeTab} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
159
rpcsx-ui/src/explorer/server/Component.ts
Normal file
159
rpcsx-ui/src/explorer/server/Component.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { Component, ComponentId } from "$core/Component";
|
||||
import * as api from "$";
|
||||
import * as progress from "$progress";
|
||||
import { IDisposable } from "$core/Disposable";
|
||||
import { createError } from "$core/Error";
|
||||
|
||||
type Item = ExplorerItem & {
|
||||
source: ComponentId;
|
||||
};
|
||||
|
||||
export class ExplorerComponent implements IDisposable {
|
||||
items: Item[] = [];
|
||||
progressToItem: Record<number, Item> = {};
|
||||
|
||||
dispose() {
|
||||
this.items = [];
|
||||
}
|
||||
|
||||
async cancel(channel: number) {
|
||||
await progress.progressUpdate({
|
||||
channel,
|
||||
status: ProgressStatus.Canceled
|
||||
});
|
||||
}
|
||||
|
||||
add(caller: Component, params: ExplorerAddRequest) {
|
||||
// FIXME: merge items?
|
||||
this.items.push(...params.items.map(x => ({ ...x, source: caller.getId() })));
|
||||
}
|
||||
|
||||
fuzzyMatch(a: string, b: string) {
|
||||
a = a.toLowerCase().split(" ").filter(x => x).join(" ").normalize();
|
||||
b = b.toLowerCase().split(" ").filter(x => x).join(" ").normalize();
|
||||
return a.includes(b);
|
||||
}
|
||||
|
||||
filterItem(item: ExplorerItem, filter: ExplorerItemFilter, strict: boolean) {
|
||||
const matchFilterString = (value: string | undefined, filter: string | undefined) => {
|
||||
if (strict) {
|
||||
return filter === undefined || filter === value;
|
||||
}
|
||||
|
||||
if (filter == undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value == undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.fuzzyMatch(value, filter);
|
||||
};
|
||||
|
||||
const matchLocalizedString = (value: LocalizedString[] | undefined, filter: string | undefined) => {
|
||||
if (filter == undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value == undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return
|
||||
};
|
||||
|
||||
const matchFilterSize = (value: number | undefined, filter: number | undefined) => {
|
||||
if (strict) {
|
||||
return filter === undefined || filter === value;
|
||||
}
|
||||
|
||||
if (filter == undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value == undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
return value >= filter;
|
||||
};
|
||||
|
||||
const matchFilterNumber = (value: number | undefined, filter: number | undefined) => {
|
||||
if (strict) {
|
||||
return filter === undefined || filter === value;
|
||||
}
|
||||
|
||||
if (filter == undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value == undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
return value >= filter;
|
||||
};
|
||||
|
||||
return matchFilterString(item.type, filter.type) &&
|
||||
matchLocalizedString(item.name, filter.name) &&
|
||||
// matchFilterString(item.icon, filter.icon) &&
|
||||
matchFilterString(item.publisher, filter.publisher) &&
|
||||
matchFilterString(item.version, filter.version) &&
|
||||
matchFilterSize(item.size, filter.size) &&
|
||||
// matchFilterString(item.actions, filter.actions) &&
|
||||
matchFilterNumber(item.progress, filter.progress) &&
|
||||
matchFilterString(item.launcher, filter.launcher) &&
|
||||
matchFilterString(item.titleId, filter.titleId) &&
|
||||
matchFilterString(item.contentId, filter.contentId);
|
||||
}
|
||||
|
||||
itemToQueryString(item: ExplorerItem) {
|
||||
let result = "";
|
||||
if (item.name) result += item.name.map(x => x.text).join();
|
||||
if (item.publisher) result += item.publisher;
|
||||
// if (item.launcher) result += item.launcher;
|
||||
if (item.titleId) result += item.titleId;
|
||||
if (item.contentId) result += item.contentId;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
remove(caller: Component, params: ExplorerRemoveRequest) {
|
||||
const callerId = caller.getId();
|
||||
params.items.forEach(filter => {
|
||||
this.items = this.items.filter(x => x.source != callerId || !this.filterItem(x, filter, true));
|
||||
});
|
||||
}
|
||||
|
||||
async get(caller: Component, params: ExplorerGetRequest): Promise<ExplorerGetResponse> {
|
||||
const progressChannel = params.channel ?? (await progress.progressCreate({
|
||||
name: "explorer-get",
|
||||
title: "Explorer progress"
|
||||
})).channel;
|
||||
|
||||
progress.onProgressUpdate(({ value }) => {
|
||||
if (value.status == ProgressStatus.Canceled) {
|
||||
this.cancel(progressChannel);
|
||||
}
|
||||
});
|
||||
|
||||
caller.onClose(() => progress.progressUpdate({
|
||||
channel: progressChannel,
|
||||
status: ProgressStatus.Canceled
|
||||
}));
|
||||
|
||||
api.sendExplorerItemsEvent(caller, {
|
||||
channel: 0,
|
||||
items: []
|
||||
});
|
||||
|
||||
params.query;
|
||||
|
||||
return { channel: progressChannel };
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1 +1,218 @@
|
||||
export function test() { }
|
||||
import { Component, ComponentContext } from "$core/Component";
|
||||
// import * as api from "$";
|
||||
import * as progress from "$progress";
|
||||
import { IDisposable } from "$core/Disposable";
|
||||
import { createError } from "$core/Error";
|
||||
import { ExplorerComponent } from "./Component";
|
||||
|
||||
|
||||
const games: (ExecutableInfo & ExplorerItem)[] = [
|
||||
{
|
||||
type: 'game',
|
||||
executable: "test-executable",
|
||||
launcher: {
|
||||
type: "test-launcher",
|
||||
requirements: {}
|
||||
},
|
||||
description: [
|
||||
{
|
||||
text: "Sonic Mania"
|
||||
}
|
||||
],
|
||||
name: [
|
||||
{
|
||||
text: "Sonic Mania"
|
||||
}
|
||||
],
|
||||
version: "0.1"
|
||||
|
||||
},
|
||||
{
|
||||
type: 'extension',
|
||||
executable: "test-executable",
|
||||
launcher: {
|
||||
type: "test-launcher",
|
||||
requirements: {}
|
||||
},
|
||||
description: [
|
||||
{
|
||||
text: "RPCSX PlayStation 3 extension"
|
||||
}
|
||||
],
|
||||
name: [
|
||||
{
|
||||
text: "RPCSX Explorer"
|
||||
}
|
||||
],
|
||||
version: "0.1"
|
||||
},
|
||||
{
|
||||
type: 'extension',
|
||||
executable: "test-executable",
|
||||
launcher: {
|
||||
type: "test-launcher",
|
||||
requirements: {}
|
||||
},
|
||||
description: [
|
||||
{
|
||||
text: "RPCSX PlayStation 4/5 extension"
|
||||
}
|
||||
],
|
||||
name: [
|
||||
{
|
||||
text: "RPCSX Explorer"
|
||||
}
|
||||
],
|
||||
version: "0.1"
|
||||
},
|
||||
{
|
||||
type: 'extension',
|
||||
executable: "test-executable",
|
||||
description: [
|
||||
{
|
||||
text: "RPCSX Dev extension"
|
||||
}
|
||||
],
|
||||
launcher: {
|
||||
type: "test-launcher",
|
||||
requirements: {}
|
||||
},
|
||||
name: [{
|
||||
text: "Unknown"
|
||||
}],
|
||||
version: "0.1"
|
||||
},
|
||||
{
|
||||
type: 'extension',
|
||||
executable: "test-executable",
|
||||
launcher: {
|
||||
type: "test-launcher",
|
||||
requirements: {}
|
||||
},
|
||||
name: [{
|
||||
text: "Unknown"
|
||||
}],
|
||||
version: "0.1"
|
||||
},
|
||||
{
|
||||
type: 'extension',
|
||||
executable: "test-executable",
|
||||
launcher: {
|
||||
type: "test-launcher",
|
||||
requirements: {}
|
||||
},
|
||||
name: [{
|
||||
text: "Unknown"
|
||||
}],
|
||||
version: "0.1"
|
||||
},
|
||||
{
|
||||
type: 'extension',
|
||||
executable: "test-executable",
|
||||
launcher: {
|
||||
type: "test-launcher",
|
||||
requirements: {}
|
||||
},
|
||||
name: [{
|
||||
text: "Unknown"
|
||||
}],
|
||||
version: "0.1"
|
||||
},
|
||||
{
|
||||
type: 'extension',
|
||||
executable: "test-executable",
|
||||
launcher: {
|
||||
type: "test-launcher",
|
||||
requirements: {}
|
||||
},
|
||||
name: [{
|
||||
text: "Unknown"
|
||||
}],
|
||||
version: "0.1"
|
||||
},
|
||||
{
|
||||
type: 'extension',
|
||||
executable: "test-executable",
|
||||
launcher: {
|
||||
type: "test-launcher",
|
||||
requirements: {}
|
||||
},
|
||||
name: [{
|
||||
text: "Unknown"
|
||||
}],
|
||||
version: "0.1"
|
||||
},
|
||||
{
|
||||
type: 'extension',
|
||||
executable: "test-executable",
|
||||
launcher: {
|
||||
type: "test-launcher",
|
||||
requirements: {}
|
||||
},
|
||||
name: [{
|
||||
text: "Unknown"
|
||||
}],
|
||||
version: "0.1"
|
||||
},
|
||||
{
|
||||
type: 'extension',
|
||||
executable: "test-executable",
|
||||
launcher: {
|
||||
type: "test-launcher",
|
||||
requirements: {}
|
||||
},
|
||||
name: [{
|
||||
text: "Unknown"
|
||||
}],
|
||||
version: "0.1"
|
||||
},
|
||||
{
|
||||
type: 'extension',
|
||||
executable: "test-executable",
|
||||
launcher: {
|
||||
type: "test-launcher",
|
||||
requirements: {}
|
||||
},
|
||||
name: [{
|
||||
text: "Unknown"
|
||||
}],
|
||||
version: "0.1"
|
||||
}
|
||||
];
|
||||
|
||||
let component: ExplorerComponent | undefined;
|
||||
|
||||
|
||||
export function activate(context: ComponentContext) {
|
||||
component = new ExplorerComponent();
|
||||
context.manage(component);
|
||||
}
|
||||
|
||||
export function deactivate() {
|
||||
component?.dispose();
|
||||
component = undefined;
|
||||
}
|
||||
|
||||
export async function handleAdd(caller: Component, params: ExplorerAddRequest) {
|
||||
if (!component) {
|
||||
throw createError(ErrorCode.InvalidRequest);
|
||||
}
|
||||
|
||||
return component.add(caller, params);
|
||||
}
|
||||
|
||||
export function handleRemove(caller: Component, params: ExplorerRemoveRequest) {
|
||||
if (!component) {
|
||||
throw createError(ErrorCode.InvalidRequest);
|
||||
}
|
||||
|
||||
return component.remove(caller, params);
|
||||
}
|
||||
|
||||
export async function handleGet(caller: Component, params: ExplorerGetRequest): Promise<ExplorerGetResponse> {
|
||||
if (!component) {
|
||||
throw createError(ErrorCode.InvalidRequest);
|
||||
}
|
||||
|
||||
return component.get(caller, params);
|
||||
}
|
||||
|
||||
10
rpcsx-ui/src/github/server/impl.ts
Normal file
10
rpcsx-ui/src/github/server/impl.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createError } from "$core/Error";
|
||||
|
||||
|
||||
export async function fetchReleasesLatest(_params: GithubReleasesLatestRequest): Promise<GithubReleasesLatestResponse> {
|
||||
throw createError(ErrorCode.InternalError, "Method not implemented");
|
||||
}
|
||||
|
||||
export async function fetchReleases(params: GithubReleasesRequest): Promise<GithubReleasesResponse> {
|
||||
throw createError(ErrorCode.InternalError, "Method not implemented");
|
||||
}
|
||||
66
rpcsx-ui/src/github/server/impl.web.ts
Normal file
66
rpcsx-ui/src/github/server/impl.web.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
|
||||
import * as self from '$';
|
||||
import http from 'https';
|
||||
|
||||
type CacheEntry = {
|
||||
timestamp: number;
|
||||
content: string;
|
||||
};
|
||||
|
||||
const cache: Record<string, CacheEntry> = {};
|
||||
const invalidationPeriodMs = 10 * 60000;
|
||||
|
||||
function get(url: string) {
|
||||
const currentTime = Date.now();
|
||||
const cacheEntry = cache[url];
|
||||
if (cacheEntry && cacheEntry.timestamp < currentTime && currentTime - cacheEntry.timestamp < invalidationPeriodMs) {
|
||||
return cacheEntry.content;
|
||||
}
|
||||
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
http.get(url, { headers: { "User-Agent": "Mozilla/5.0" } }, res => {
|
||||
let content = '';
|
||||
res.on("data", chunk => {
|
||||
content += chunk;
|
||||
});
|
||||
|
||||
res.on('error', e => {
|
||||
reject(e);
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
try {
|
||||
cache[url] = {
|
||||
timestamp: Date.now(),
|
||||
content
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
const currentTime = Date.now();
|
||||
const cacheEntry = cache[url];
|
||||
if (currentTime - cacheEntry.timestamp >= invalidationPeriodMs) {
|
||||
delete cache[url];
|
||||
}
|
||||
}, invalidationPeriodMs);
|
||||
resolve(content);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchReleasesLatest(params: GithubReleasesLatestRequest): Promise<GithubReleasesLatestResponse> {
|
||||
const url = `${(await self.settings.getUrl()).value}/repos/${params.owner}/${params.repository}/releases/latest`;
|
||||
|
||||
const content = await get(url);
|
||||
return { release: JSON.parse(content) as GithubRelease };
|
||||
}
|
||||
|
||||
export async function fetchReleases(params: GithubReleasesRequest): Promise<GithubReleasesResponse> {
|
||||
const url = `${(await self.settings.getUrl()).value}/repos/${params.owner}/${params.repository}/releases`;
|
||||
|
||||
const content = await get(url);
|
||||
return { releases: JSON.parse(content) as Array<GithubRelease> };
|
||||
}
|
||||
@@ -1,66 +1,10 @@
|
||||
import { Component } from '$core/Component';
|
||||
import http from 'https';
|
||||
import * as self from '$';
|
||||
|
||||
type CacheEntry = {
|
||||
timestamp: number;
|
||||
content: string;
|
||||
};
|
||||
|
||||
const cache: Record<string, CacheEntry> = {};
|
||||
const invalidationPeriodMs = 10 * 60000;
|
||||
|
||||
function get(url: string) {
|
||||
const currentTime = Date.now();
|
||||
const cacheEntry = cache[url];
|
||||
if (cacheEntry && cacheEntry.timestamp < currentTime && currentTime - cacheEntry.timestamp < invalidationPeriodMs) {
|
||||
return cacheEntry.content;
|
||||
}
|
||||
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
http.get(url, { headers: { "User-Agent": "Mozilla/5.0" } }, res => {
|
||||
let content = '';
|
||||
res.on("data", chunk => {
|
||||
content += chunk;
|
||||
});
|
||||
|
||||
res.on('error', e => {
|
||||
reject(e);
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
try {
|
||||
cache[url] = {
|
||||
timestamp: Date.now(),
|
||||
content
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
const currentTime = Date.now();
|
||||
const cacheEntry = cache[url];
|
||||
if (currentTime - cacheEntry.timestamp >= invalidationPeriodMs) {
|
||||
delete cache[url];
|
||||
}
|
||||
}, invalidationPeriodMs);
|
||||
resolve(content);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
import * as impl from './impl';
|
||||
|
||||
export async function handleReleasesLatest(_caller: Component, params: GithubReleasesLatestRequest): Promise<GithubReleasesLatestResponse> {
|
||||
const url = `${(await self.settings.getUrl()).value}/repos/${params.owner}/${params.repository}/releases/latest`;
|
||||
|
||||
const content = await get(url);
|
||||
return { release: JSON.parse(content) as GithubRelease };
|
||||
return impl.fetchReleasesLatest(params);
|
||||
}
|
||||
|
||||
export async function handleReleases(_caller: Component, params: GithubReleasesRequest): Promise<GithubReleasesResponse> {
|
||||
const url = `${(await self.settings.getUrl()).value}/repos/${params.owner}/${params.repository}/releases`;
|
||||
|
||||
const content = await get(url);
|
||||
return { releases: JSON.parse(content) as Array<GithubRelease> };
|
||||
return impl.fetchReleases(params);
|
||||
}
|
||||
|
||||
36
rpcsx-ui/src/i18n.ts
Normal file
36
rpcsx-ui/src/i18n.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import XHR from "i18next-http-backend";
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
|
||||
// Import locale files
|
||||
import coreEn from './core/renderer/locales/en.json';
|
||||
import settingsEn from './settings/renderer/locales/en.json';
|
||||
|
||||
const resources = {
|
||||
en: {
|
||||
translation: {
|
||||
...coreEn,
|
||||
...settingsEn,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
i18n
|
||||
.use(XHR)
|
||||
.use(initReactI18next)
|
||||
.use(LanguageDetector)
|
||||
.init({
|
||||
resources,
|
||||
detection: {
|
||||
order: ['querystring', 'navigator'],
|
||||
lookupQuerystring: 'lng'
|
||||
},
|
||||
lng: 'en', // Default language
|
||||
fallbackLng: 'en',
|
||||
interpolation: {
|
||||
escapeValue: false, // React already escapes values
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
87
rpcsx-ui/src/menu/renderer/Menu.tsx
Normal file
87
rpcsx-ui/src/menu/renderer/Menu.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
|
||||
|
||||
interface MenuItem {
|
||||
label: string;
|
||||
icon?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export interface MenuRef {
|
||||
show: (title: string, items: MenuItem[]) => void;
|
||||
hide: () => void;
|
||||
}
|
||||
|
||||
const Menu = forwardRef<MenuRef>((_, ref) => {
|
||||
const menuElementRef = useRef<HTMLDivElement>(null);
|
||||
const contentElementRef = useRef<HTMLDivElement>(null);
|
||||
const [items, setItems] = useState<MenuItem[]>([]);
|
||||
const [title, setTitle] = useState("");
|
||||
|
||||
const show = (showTitle: string, showItems: MenuItem[]) => {
|
||||
setItems(showItems);
|
||||
setTitle(showTitle);
|
||||
|
||||
if (menuElementRef.current && contentElementRef.current) {
|
||||
menuElementRef.current.classList.remove("modal-hide");
|
||||
contentElementRef.current.classList.remove("modal-content-hide");
|
||||
menuElementRef.current.style.display = "block";
|
||||
}
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
if (menuElementRef.current && contentElementRef.current) {
|
||||
menuElementRef.current.classList.add("modal-hide");
|
||||
contentElementRef.current.classList.add("modal-content-hide");
|
||||
menuElementRef.current.style.display = "none";
|
||||
}
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
show,
|
||||
hide
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="modal" ref={menuElementRef}>
|
||||
<div className="modal-bg" onClick={hide}></div>
|
||||
<div
|
||||
className="modal-content bg-neutral-900 flex h-full p-2 space-x-2"
|
||||
ref={contentElementRef}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ul className="flex-col space-y-2 w-full">
|
||||
<li className="w-full">
|
||||
{title}
|
||||
</li>
|
||||
|
||||
{items.map((item, index) => (
|
||||
<li key={index} className="w-full">
|
||||
<button
|
||||
onClick={() => {
|
||||
hide();
|
||||
if (item.onClick) {
|
||||
item.onClick();
|
||||
}
|
||||
}}
|
||||
className="hover:bg-neutral-600 inline-flex items-center p-2 pe-10 w-full rounded active:bg-neutral-800/40 shadow-sm"
|
||||
>
|
||||
{item.icon && (
|
||||
<img
|
||||
className="w-5 h-5 me-2 text-white"
|
||||
src={item.icon}
|
||||
alt={`${item.label} Icon`}
|
||||
/>
|
||||
)}
|
||||
{item.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Menu.displayName = "Menu";
|
||||
|
||||
export default Menu;
|
||||
@@ -1,61 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import * as api from "$";
|
||||
|
||||
let progressElement: HTMLElement;
|
||||
export let hidden = false;
|
||||
export let channel: number;
|
||||
|
||||
if (window.electron) {
|
||||
onMount(() => {
|
||||
api.onProgressUpdate((event) => {
|
||||
progressElement.animate(
|
||||
[
|
||||
{ width: progressElement.style.width },
|
||||
{ width: `${event.value}%` },
|
||||
],
|
||||
{
|
||||
duration: 600,
|
||||
iterations: 1,
|
||||
fill: "forwards",
|
||||
easing: "ease-in-out",
|
||||
},
|
||||
);
|
||||
|
||||
if (
|
||||
event.value.status == ProgressStatus.Error ||
|
||||
event.value.status == ProgressStatus.Complete
|
||||
) {
|
||||
// setTimeout(hide, 1500);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => api.progressUnsubscribe({ channel }));
|
||||
api.progressSubscribe({ channel });
|
||||
}
|
||||
|
||||
export function hide() {
|
||||
hidden = true;
|
||||
if (progressElement) {
|
||||
progressElement.classList.remove("modal-show");
|
||||
progressElement.classList.add("modal-hide-fast");
|
||||
}
|
||||
}
|
||||
export function show() {
|
||||
hidden = false;
|
||||
if (progressElement) {
|
||||
progressElement.classList.remove("modal-hide-fast");
|
||||
progressElement.classList.add("modal-show");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="progress-bar shadow-xl m-10 shadow-white"
|
||||
style={hidden ? "display:none" : ""}
|
||||
>
|
||||
<span class="bg-white rounded progress-fg" bind:this={progressElement}>
|
||||
<span class="progress-bg"> </span>
|
||||
</span>
|
||||
</div>
|
||||
98
rpcsx-ui/src/progress/renderer/Progress.tsx
Normal file
98
rpcsx-ui/src/progress/renderer/Progress.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useEffect, useRef, forwardRef, useImperativeHandle } from "react";
|
||||
import "./types";
|
||||
|
||||
interface ProgressProps {
|
||||
hidden?: boolean;
|
||||
channel: number;
|
||||
}
|
||||
|
||||
export interface ProgressRef {
|
||||
hide: () => void;
|
||||
show: () => void;
|
||||
}
|
||||
|
||||
enum ProgressStatus {
|
||||
InProgress = "InProgress",
|
||||
Complete = "Complete",
|
||||
Error = "Error"
|
||||
}
|
||||
|
||||
const Progress = forwardRef<ProgressRef, ProgressProps>(({
|
||||
hidden = false,
|
||||
channel
|
||||
}, ref) => {
|
||||
const progressElementRef = useRef<HTMLSpanElement>(null);
|
||||
const hiddenRef = useRef(hidden);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
hide: () => {
|
||||
hiddenRef.current = true;
|
||||
if (progressElementRef.current) {
|
||||
progressElementRef.current.classList.remove("modal-show");
|
||||
progressElementRef.current.classList.add("modal-hide-fast");
|
||||
}
|
||||
},
|
||||
show: () => {
|
||||
hiddenRef.current = false;
|
||||
if (progressElementRef.current) {
|
||||
progressElementRef.current.classList.remove("modal-hide-fast");
|
||||
progressElementRef.current.classList.add("modal-show");
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electron) return;
|
||||
|
||||
// Mock API calls - these would need to be implemented properly
|
||||
const handleProgressUpdate = (event: any) => {
|
||||
if (progressElementRef.current) {
|
||||
progressElementRef.current.animate(
|
||||
[
|
||||
{ width: progressElementRef.current.style.width },
|
||||
{ width: `${event.value}%` },
|
||||
],
|
||||
{
|
||||
duration: 600,
|
||||
iterations: 1,
|
||||
fill: "forwards" as FillMode,
|
||||
easing: "ease-in-out",
|
||||
}
|
||||
);
|
||||
|
||||
if (
|
||||
event.value.status === ProgressStatus.Error ||
|
||||
event.value.status === ProgressStatus.Complete
|
||||
) {
|
||||
// setTimeout(() => hide(), 1500);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Mock API subscription - would need real implementation
|
||||
// api.onProgressUpdate(handleProgressUpdate);
|
||||
// api.progressSubscribe({ channel });
|
||||
|
||||
// Suppress unused variable warning
|
||||
void handleProgressUpdate;
|
||||
|
||||
return () => {
|
||||
// api.progressUnsubscribe({ channel });
|
||||
};
|
||||
}, [channel]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="progress-bar shadow-xl m-10 shadow-white"
|
||||
style={{ display: hidden ? "none" : "block" }}
|
||||
>
|
||||
<span className="bg-white rounded progress-fg" ref={progressElementRef}>
|
||||
<span className="progress-bg"> </span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Progress.displayName = "Progress";
|
||||
|
||||
export default Progress;
|
||||
94
rpcsx-ui/src/progress/renderer/SplashProgress.tsx
Normal file
94
rpcsx-ui/src/progress/renderer/SplashProgress.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { useEffect, useRef, forwardRef, useImperativeHandle } from "react";
|
||||
import * as api from "$";
|
||||
|
||||
interface ProgressProps {
|
||||
hidden?: boolean;
|
||||
channel: number;
|
||||
}
|
||||
|
||||
export interface ProgressRef {
|
||||
hide: () => void;
|
||||
show: () => void;
|
||||
}
|
||||
|
||||
enum ProgressStatus {
|
||||
InProgress = "InProgress",
|
||||
Complete = "Complete",
|
||||
Error = "Error"
|
||||
}
|
||||
|
||||
const Progress = forwardRef<ProgressRef, ProgressProps>(({
|
||||
hidden = false,
|
||||
channel
|
||||
}, ref) => {
|
||||
const progressElementRef = useRef<HTMLSpanElement>(null);
|
||||
const hiddenRef = useRef(hidden);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
hide: () => {
|
||||
hiddenRef.current = true;
|
||||
if (progressElementRef.current) {
|
||||
progressElementRef.current.classList.remove("modal-show");
|
||||
progressElementRef.current.classList.add("modal-hide-fast");
|
||||
}
|
||||
},
|
||||
show: () => {
|
||||
hiddenRef.current = false;
|
||||
if (progressElementRef.current) {
|
||||
progressElementRef.current.classList.remove("modal-hide-fast");
|
||||
progressElementRef.current.classList.add("modal-show");
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electron) return;
|
||||
|
||||
// Mock API calls - these would need to be implemented properly
|
||||
const handleProgressUpdate = (event: any) => {
|
||||
if (progressElementRef.current) {
|
||||
progressElementRef.current.animate(
|
||||
[
|
||||
{ width: progressElementRef.current.style.width },
|
||||
{ width: `${event.value}%` },
|
||||
],
|
||||
{
|
||||
duration: 600,
|
||||
iterations: 1,
|
||||
fill: "forwards" as FillMode,
|
||||
easing: "ease-in-out",
|
||||
}
|
||||
);
|
||||
|
||||
if (
|
||||
event.value.status === ProgressStatus.Error ||
|
||||
event.value.status === ProgressStatus.Complete
|
||||
) {
|
||||
// setTimeout(() => hide(), 1500);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
api.onProgressUpdate(handleProgressUpdate);
|
||||
api.progressSubscribe({ channel });
|
||||
|
||||
return () => {
|
||||
api.progressUnsubscribe({ channel });
|
||||
};
|
||||
}, [channel]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="progress-bar shadow-xl m-10 shadow-white"
|
||||
style={{ display: hidden ? "none" : "block" }}
|
||||
>
|
||||
<span className="bg-white rounded progress-fg" ref={progressElementRef}>
|
||||
<span className="progress-bg"> </span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Progress.displayName = "Progress";
|
||||
|
||||
export default Progress;
|
||||
@@ -1,8 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Progress from "../Progress.svelte";
|
||||
export let channel: number;
|
||||
</script>
|
||||
|
||||
<div class="min-h-full h-full content-center bg-neural-700">
|
||||
<Progress bind:channel></Progress>
|
||||
</div>
|
||||
@@ -1,7 +1,7 @@
|
||||
// import { ipcMain } from 'electron';
|
||||
import { Component } from '$core/Component';
|
||||
import { createError } from '$core/Error';
|
||||
import * as api from '$';
|
||||
// import * as api from '$';
|
||||
|
||||
import { Disposable, IDisposable } from '$core/Disposable';
|
||||
|
||||
@@ -87,20 +87,14 @@ export function progressUpdate(caller: Component, params: ProgressUpdateRequest)
|
||||
info.message = params.message.length > 0 ? params.message : undefined;
|
||||
}
|
||||
|
||||
api.emitProgressUpdateEvent({ value: { channel: params.channel, ...info } });
|
||||
// api.emitProgressUpdateEvent({ value: { channel: params.channel, ...info } });
|
||||
|
||||
subscriptions[params.channel] ??= new Set();
|
||||
// const channelSubscriptions = subscriptions[params.channel];
|
||||
const channelSubscriptions = subscriptions[params.channel];
|
||||
|
||||
// for (const subscriber of channelSubscriptions) {
|
||||
// subscriber.send("progress/update", {
|
||||
// value: info.value,
|
||||
// status: info.status,
|
||||
// title: info.title,
|
||||
// description: info.description,
|
||||
// message: info.message,
|
||||
// });
|
||||
// }
|
||||
for (const subscriber of channelSubscriptions) {
|
||||
// api.sendProgressUpdateEvent(subscriber, { value: { channel: params.channel, ...info } });
|
||||
}
|
||||
|
||||
if (params.status !== undefined) {
|
||||
if (params.status == ProgressStatus.Canceled ||
|
||||
@@ -124,16 +118,8 @@ export function progressSubscribe(caller: Component, params: ProgressSubscribeRe
|
||||
subscriptions[params.channel] ??= new Set();
|
||||
subscriptions[params.channel].add(caller);
|
||||
|
||||
// FIXME:
|
||||
|
||||
// const info = channels[channel];
|
||||
// event.sender.send("progress/update", {
|
||||
// value: info.value,
|
||||
// status: info.status,
|
||||
// title: info.title,
|
||||
// description: info.description,
|
||||
// message: info.message,
|
||||
// });
|
||||
const info = channels[params.channel];
|
||||
// api.sendProgressUpdateEvent(caller, { value: { channel: params.channel, ...info } });
|
||||
}
|
||||
|
||||
export function progressUnsubscribe(caller: Component, params: ProgressUnsubscribeRequest) {
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Icon, ChevronDown } from "svelte-hero-icons";
|
||||
|
||||
export let values: string[];
|
||||
export let selectedValue: string;
|
||||
export let label: string;
|
||||
|
||||
let isDropdownOpen = false;
|
||||
|
||||
function toggleDropdown() {
|
||||
isDropdownOpen = !isDropdownOpen;
|
||||
}
|
||||
|
||||
function changeSelectedValue(value: string) {
|
||||
selectedValue = value;
|
||||
toggleDropdown();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-row gap-5 items-center">
|
||||
<div class="w-56">
|
||||
<p>{label}</p>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
on:click={toggleDropdown}
|
||||
type="button"
|
||||
class="inline-flex gap-2 items-center rounded border border-neutral-600 bg-neutral-700 text-white px-2 py-1 hover:bg-neutral-600 active:bg-neutral-700 shadow-sm"
|
||||
>
|
||||
{selectedValue}
|
||||
<div class="flex-grow"></div>
|
||||
<div class="w-5 h-5">
|
||||
<Icon src={ChevronDown} solid />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="{isDropdownOpen
|
||||
? ''
|
||||
: 'hidden'} absolute mt-1 z-10 rounded border border-neutral-600 bg-neutral-700 text-white p-1 shadow-sm"
|
||||
>
|
||||
<ul class="inline-flex flex-col gap-1 w-full">
|
||||
{#each values as value}
|
||||
<button
|
||||
class="{selectedValue == value
|
||||
? 'bg-blue-600'
|
||||
: 'hover:bg-neutral-600'} rounded px-2 py-1 text-left"
|
||||
on:click={() => changeSelectedValue(value)}
|
||||
>
|
||||
<li>
|
||||
{value}
|
||||
</li>
|
||||
</button>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
65
rpcsx-ui/src/settings/renderer/Dropdown.tsx
Normal file
65
rpcsx-ui/src/settings/renderer/Dropdown.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronDownIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
type DropdownProps = {
|
||||
values: string[];
|
||||
selectedValue: string;
|
||||
onSelectedValueChange: (value: string) => void;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export default function Dropdown({ values, selectedValue, onSelectedValueChange, label }: DropdownProps) {
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
|
||||
const toggleDropdown = () => {
|
||||
setIsDropdownOpen(!isDropdownOpen);
|
||||
};
|
||||
|
||||
const changeSelectedValue = (value: string) => {
|
||||
onSelectedValueChange(value);
|
||||
toggleDropdown();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-row gap-5 items-center">
|
||||
<div className="w-56">
|
||||
<p>{label}</p>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
onClick={toggleDropdown}
|
||||
type="button"
|
||||
className="inline-flex gap-2 items-center rounded border border-neutral-600 bg-neutral-700 text-white px-2 py-1 hover:bg-neutral-600 active:bg-neutral-700 shadow-sm"
|
||||
>
|
||||
{selectedValue}
|
||||
<div className="flex-grow"></div>
|
||||
<div className="w-5 h-5">
|
||||
<ChevronDownIcon className="w-full h-full" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={`${
|
||||
isDropdownOpen ? "" : "hidden"
|
||||
} absolute mt-1 z-10 rounded border border-neutral-600 bg-neutral-700 text-white p-1 shadow-sm`}
|
||||
>
|
||||
<ul className="inline-flex flex-col gap-1 w-full">
|
||||
{values.map((value, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className={`${
|
||||
selectedValue === value
|
||||
? "bg-blue-600"
|
||||
: "hover:bg-neutral-600"
|
||||
} rounded px-2 py-1 text-left`}
|
||||
onClick={() => changeSelectedValue(value)}
|
||||
>
|
||||
<li>{value}</li>
|
||||
</button>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Icon } from "svelte-hero-icons";
|
||||
import type { Schema } from "$core/Schema";
|
||||
import Toggle from "./Toggle.svelte";
|
||||
import * as core from "$core";
|
||||
import SettingsSchema from "./SettingsSchema.svelte";
|
||||
|
||||
export let schema: Schema;
|
||||
export let value: any;
|
||||
export let depth = 0;
|
||||
export let active = 0;
|
||||
</script>
|
||||
|
||||
{#if schema !== undefined}
|
||||
{#if schema.type === "object"}
|
||||
{#if depth === 0}
|
||||
<div class="bg-neutral-900 flex h-full p-2 space-x-2">
|
||||
<ul class="flex-col space-y-2">
|
||||
{#each Object.keys(schema.properties) as property, index}
|
||||
<li>
|
||||
<button
|
||||
on:click={() => (active = index)}
|
||||
class="{active == index
|
||||
? 'bg-blue-700/40'
|
||||
: 'hover:bg-neutral-700/40'} inline-flex items-center p-2 pe-10 w-full rounded active:bg-neutral-800/40 shadow-sm"
|
||||
>
|
||||
{#if "icon" in schema.properties[property]}
|
||||
<div class="w-5 h-5 me-2 text-white">
|
||||
<Icon
|
||||
src={schema.properties[property]
|
||||
?.icon}
|
||||
solid
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{schema.properties[property].label ?? property}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
<li>
|
||||
<button
|
||||
on:click={() => {
|
||||
active = Object.keys(schema.properties).length;
|
||||
core.popView();
|
||||
}}
|
||||
class="{active ==
|
||||
Object.keys(schema.properties).length
|
||||
? 'bg-blue-700/40'
|
||||
: 'hover:bg-neutral-700/40'} inline-flex items-center p-2 pe-10 w-full rounded active:bg-neutral-800/40 shadow-sm"
|
||||
>
|
||||
{"Exit"}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div
|
||||
class="border border-neutral-600 bg-neutral-800 rounded p-2 h-full w-full"
|
||||
>
|
||||
<SettingsSchema
|
||||
schema={schema.properties[
|
||||
Object.keys(schema.properties)[active]
|
||||
]}
|
||||
value={value[Object.keys(schema.properties)[active]]}
|
||||
depth={depth + 1}
|
||||
></SettingsSchema>
|
||||
<!-- <svelte:component this={activeTab.content} /> -->
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="bg-neutral-900 flex h-full p-2 space-x-2">
|
||||
<ul class="flex-col space-y-2">
|
||||
{#each Object.keys(schema.properties) as property, index}
|
||||
<li>
|
||||
<button
|
||||
on:click={() => (active = index)}
|
||||
class="{active == index
|
||||
? 'bg-blue-700/40'
|
||||
: 'hover:bg-neutral-700/40'} inline-flex items-center p-2 pe-10 w-full rounded active:bg-neutral-800/40 shadow-sm"
|
||||
>
|
||||
{#if "icon" in schema.properties[property]}
|
||||
<div class="w-5 h-5 me-2 text-white">
|
||||
<Icon
|
||||
src={schema.properties[property]
|
||||
?.icon}
|
||||
solid
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{schema.properties[property].label ?? property}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<div
|
||||
class="border border-neutral-600 bg-neutral-800 rounded p-2 h-full w-full"
|
||||
>
|
||||
<SettingsSchema
|
||||
schema={schema.properties[
|
||||
Object.keys(schema.properties)[active]
|
||||
]}
|
||||
value={value[Object.keys(schema.properties)[active]]}
|
||||
depth={depth + 1}
|
||||
></SettingsSchema>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if schema.type === "boolean"}
|
||||
<Toggle
|
||||
value={value || schema.defaultValue}
|
||||
label={schema.label ?? ""}
|
||||
onchange={(newValue) => (value = newValue)}
|
||||
/>
|
||||
{:else}
|
||||
<h1>Unimplemented {schema.type}</h1>
|
||||
{/if}
|
||||
{/if}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user