Migrate to react native/expo

This commit is contained in:
DH
2025-08-23 07:30:42 +03:00
parent c64e5c92a5
commit 0cbd9f085f
114 changed files with 24651 additions and 6750 deletions

66
.github/workflows/android.yml vendored Normal file
View 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

View File

@@ -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
View File

@@ -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
View 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
View 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
View 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"

View File

@@ -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

File diff suppressed because it is too large Load Diff

26
electron/package.json Normal file
View 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"
}
}

View File

@@ -3,8 +3,16 @@ 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(
export default defineConfig([
expoConfig,
{
ignores: ['dist/*'],
},
...tseslint.config(
eslint.configs.recommended,
tseslint.configs.recommended,
{
@@ -31,5 +39,6 @@ export default tseslint.config(
"@typescript-eslint/explicit-function-return-type": "off"
},
}
);
)
]);

31
metro.config.cjs Normal file
View 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;
};

14286
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View File

@@ -1,5 +0,0 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -23,5 +23,5 @@
},
"include": [
"src/**/*"
]
, "../rpcsx-ui/app.config.ts" ]
}

View File

@@ -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
}
]
}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View File

@@ -7,6 +7,9 @@
},
{
"name": "explorer"
},
{
"name": "github"
}
]
}

View 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'
}
});
}

View 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();
}
});
}

View File

@@ -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();
}

View File

@@ -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": {

View File

@@ -0,0 +1,3 @@
export interface CancelablePromise<T> extends Promise<T> {
cancel(): void;
}

View File

@@ -20,4 +20,5 @@ export type ComponentId = string;
export type Component = {
getId(): ComponentId;
onClose(listener: () => void): IDisposable;
sendEvent(event: string, params?: any): void;
};

View File

@@ -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[] = [];

View 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;
}
};

View File

@@ -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 };

View 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;
}
}
}

View 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;
}

View File

@@ -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;
}

View 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'));
}
};

View File

@@ -0,0 +1 @@
type ThemedColor = { light: string; dark: string; };

View 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>;
};

View 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() {
}

View 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',
}
};

View 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);
}
}}
/>
);
}

View File

@@ -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>

View File

@@ -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>

View 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);
}}
/>
);
}

View File

@@ -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> -->

View 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} />;
}

View 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}
/>
);
}

View 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',
},
});

View 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} />;
}

View 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 >
);
}

View 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);

View File

@@ -0,0 +1,2 @@
export { pickDirectory } from '@react-native-documents/picker';

View 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');
}

View 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>
);
}

View 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);
}
}

View File

@@ -0,0 +1,5 @@
import { useColorScheme as impl } from 'react-native';
export function useColorScheme() {
return impl() ?? 'dark';
}

View 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];
}
}

View File

@@ -0,0 +1,4 @@
import { type ComponentInstance } from "./ComponentInstance";
export function onComponentActivation(_component: ComponentInstance) {
}

View 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));
});
}
}

View File

@@ -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)) {
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)) {
try {
await unregisterComponent(component.getId());
} catch (e) {
console.error(`failed to shutdown ${component.getId()}`, e);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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);
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) {

View 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;
}

View 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");

View 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);

View 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);
}

View 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}`);
}
}

View File

@@ -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> {

View File

@@ -0,0 +1,3 @@
export function registerBuiltinLaunchers() {}

View 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);
}

View File

@@ -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"
}
}
}
}
]
}

View File

@@ -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;

View File

@@ -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>

View 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>
);
}

View File

@@ -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>

View 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>
);
}

View File

@@ -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);
}

View 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;
}

View File

@@ -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>

View 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>
)
}

View 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 };
}
};

View File

@@ -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);
}

View 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");
}

View 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> };
}

View File

@@ -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
View 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;

View 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;

View File

@@ -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>

View 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;

View 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;

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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>

View 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>
);
}

View File

@@ -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