From 2f8f73571038156abfcf67b7fd442b5e955e8438 Mon Sep 17 00:00:00 2001 From: srgooglo Date: Tue, 24 Oct 2023 00:46:05 +0200 Subject: [PATCH] init --- .editorconfig | 9 + .eslintignore | 4 + .eslintrc.cjs | 9 + .gitignore | 40 ++ .prettierignore | 6 + .prettierrc.yaml | 4 + .vscode/extensions.json | 3 + .vscode/launch.json | 39 ++ .vscode/settings.json | 11 + README.md | 34 + dev-app-update.yml | 3 + electron-builder.yml | 42 ++ electron.vite.config.js | 34 + package.json | 47 ++ resources/icon.png | Bin 0 -> 35949 bytes src/main/index.js | 116 ++++ src/main/pkgManager.js | 601 ++++++++++++++++++ src/main/setup.js | 74 +++ src/preload/index.js | 33 + src/renderer/index.html | 16 + src/renderer/src/App.jsx | 125 ++++ src/renderer/src/components/Versions.jsx | 16 + src/renderer/src/contexts/global.js | 8 + src/renderer/src/contexts/installations.jsx | 108 ++++ src/renderer/src/main.jsx | 8 + src/renderer/src/pages/manager/index.jsx | 211 ++++++ src/renderer/src/pages/manager/index.less | 157 +++++ src/renderer/src/style/index.less | 153 +++++ src/renderer/src/style/reset.css | 48 ++ src/renderer/src/utils/getRootCssVar/index.js | 6 + 30 files changed, 1965 insertions(+) create mode 100644 .editorconfig create mode 100644 .eslintignore create mode 100644 .eslintrc.cjs create mode 100644 .gitignore create mode 100644 .prettierignore create mode 100644 .prettierrc.yaml create mode 100644 .vscode/extensions.json create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 README.md create mode 100644 dev-app-update.yml create mode 100644 electron-builder.yml create mode 100644 electron.vite.config.js create mode 100644 package.json create mode 100644 resources/icon.png create mode 100644 src/main/index.js create mode 100644 src/main/pkgManager.js create mode 100644 src/main/setup.js create mode 100644 src/preload/index.js create mode 100644 src/renderer/index.html create mode 100644 src/renderer/src/App.jsx create mode 100644 src/renderer/src/components/Versions.jsx create mode 100644 src/renderer/src/contexts/global.js create mode 100644 src/renderer/src/contexts/installations.jsx create mode 100644 src/renderer/src/main.jsx create mode 100644 src/renderer/src/pages/manager/index.jsx create mode 100644 src/renderer/src/pages/manager/index.less create mode 100644 src/renderer/src/style/index.less create mode 100644 src/renderer/src/style/reset.css create mode 100644 src/renderer/src/utils/getRootCssVar/index.js diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..cf640d5 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true \ No newline at end of file diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..a6f34fe --- /dev/null +++ b/.eslintignore @@ -0,0 +1,4 @@ +node_modules +dist +out +.gitignore diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..1bb7310 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,9 @@ +module.exports = { + extends: [ + 'eslint:recommended', + 'plugin:react/recommended', + 'plugin:react/jsx-runtime', + '@electron-toolkit', + '@electron-toolkit/eslint-config-prettier' + ] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dab6f28 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Secrets +/**/**/.env +/**/**/origin.server +/**/**/server.manifest +/**/**/server.registry + +/**/**/_shared + +# Trash +/**/**/*.log +/**/**/dumps.log +/**/**/.crash.log +/**/**/.tmp +/**/**/.cache +/**/**/cache +/**/**/out +/**/**/.out +/**/**/dist +/**/**/node_modules +/**/**/corenode_modules +/**/**/.DS_Store +/**/**/package-lock.json +/**/**/yarn.lock +/**/**/.evite +/**/**/build +/**/**/uploads +/**/**/d_data +/**/**/*.tar +/**/**/*.7z +/**/**/*.zip +/**/**/*.env + +# Logs +/**/**/npm-debug.log* +/**/**/yarn-error.log +/**/**/dumps.log +/**/**/corenode.log + +# Temporal configurations +/**/**/.aliaser \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..9c6b791 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +out +dist +pnpm-lock.yaml +LICENSE.md +tsconfig.json +tsconfig.*.json diff --git a/.prettierrc.yaml b/.prettierrc.yaml new file mode 100644 index 0000000..35893b3 --- /dev/null +++ b/.prettierrc.yaml @@ -0,0 +1,4 @@ +singleQuote: true +semi: false +printWidth: 100 +trailingComma: none diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..940260d --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["dbaeumer.vscode-eslint"] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..0b6b9a6 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,39 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug Main Process", + "type": "node", + "request": "launch", + "cwd": "${workspaceRoot}", + "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite", + "windows": { + "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd" + }, + "runtimeArgs": ["--sourcemap"], + "env": { + "REMOTE_DEBUGGING_PORT": "9222" + } + }, + { + "name": "Debug Renderer Process", + "port": 9222, + "request": "attach", + "type": "chrome", + "webRoot": "${workspaceFolder}/src/renderer", + "timeout": 60000, + "presentation": { + "hidden": true + } + } + ], + "compounds": [ + { + "name": "Debug All", + "configurations": ["Debug Main Process", "Debug Renderer Process"], + "presentation": { + "order": 1 + } + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..e879dfd --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[javascript]": { + "editor.defaultFormatter": "vscode.typescript-language-features" + }, + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..eee5d64 --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# rs-bundler + +An Electron application with React + +## Recommended IDE Setup + +- [VSCode](https://code.visualstudio.com/) + [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) + +## Project Setup + +### Install + +```bash +$ npm install +``` + +### Development + +```bash +$ npm run dev +``` + +### Build + +```bash +# For windows +$ npm run build:win + +# For macOS +$ npm run build:mac + +# For Linux +$ npm run build:linux +``` diff --git a/dev-app-update.yml b/dev-app-update.yml new file mode 100644 index 0000000..c8c0c0f --- /dev/null +++ b/dev-app-update.yml @@ -0,0 +1,3 @@ +provider: generic +url: https://example.com/auto-updates +updaterCacheDirName: rs-bundler-updater diff --git a/electron-builder.yml b/electron-builder.yml new file mode 100644 index 0000000..d72296e --- /dev/null +++ b/electron-builder.yml @@ -0,0 +1,42 @@ +appId: com.electron.app +productName: rs-bundler +directories: + buildResources: build +files: + - '!**/.vscode/*' + - '!src/*' + - '!electron.vite.config.{js,ts,mjs,cjs}' + - '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}' + - '!{.env,.env.*,.npmrc,pnpm-lock.yaml}' +asarUnpack: + - resources/** +win: + executableName: rs-bundler +nsis: + artifactName: ${name}-${version}-setup.${ext} + shortcutName: ${productName} + uninstallDisplayName: ${productName} + createDesktopShortcut: always +mac: + entitlementsInherit: build/entitlements.mac.plist + extendInfo: + - NSCameraUsageDescription: Application requests access to the device's camera. + - NSMicrophoneUsageDescription: Application requests access to the device's microphone. + - NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder. + - NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder. + notarize: false +dmg: + artifactName: ${name}-${version}.${ext} +linux: + target: + - AppImage + - snap + - deb + maintainer: electronjs.org + category: Utility +appImage: + artifactName: ${name}-${version}.${ext} +npmRebuild: false +publish: + provider: generic + url: https://example.com/auto-updates diff --git a/electron.vite.config.js b/electron.vite.config.js new file mode 100644 index 0000000..e393273 --- /dev/null +++ b/electron.vite.config.js @@ -0,0 +1,34 @@ +import { resolve } from 'path' +import { defineConfig, externalizeDepsPlugin } from 'electron-vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + main: { + plugins: [externalizeDepsPlugin()] + }, + preload: { + plugins: [externalizeDepsPlugin()] + }, + renderer: { + resolve: { + alias: { + "style": resolve('src/renderer/src/style'), + "components": resolve('src/renderer/src/components'), + "utils": resolve('src/renderer/src/utils'), + "contexts": resolve('src/renderer/src/contexts'), + "pages": resolve('src/renderer/src/pages'), + "hooks": resolve('src/renderer/src/hooks'), + "services": resolve('src/renderer/src/services'), + '@renderer': resolve('src/renderer/src') + } + }, + plugins: [react()], + css: { + preprocessorOptions: { + less: { + javascriptEnabled: true + } + } + } + } +}) diff --git a/package.json b/package.json new file mode 100644 index 0000000..fd3b92c --- /dev/null +++ b/package.json @@ -0,0 +1,47 @@ +{ + "name": "rs-bundler", + "version": "0.1.0", + "description": "An Electron application with React", + "main": "./out/main/index.js", + "author": "RageStudio", + "scripts": { + "format": "prettier --write .", + "lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix", + "start": "electron-vite preview", + "dev": "electron-vite dev", + "build": "electron-vite build", + "postinstall": "electron-builder install-app-deps", + "build:win": "npm run build && electron-builder --win --config", + "build:mac": "npm run build && electron-builder --mac --config", + "build:linux": "npm run build && electron-builder --linux --config" + }, + "dependencies": { + "@electron-toolkit/preload": "^2.0.0", + "@electron-toolkit/utils": "^2.0.0", + "antd": "^5.10.2", + "classnames": "^2.3.2", + "electron-updater": "^6.1.1", + "got": "11.8.3", + "less": "^4.2.0", + "lodash": "^4.17.21", + "node-7z": "^3.0.0", + "open": "8.4.2", + "react-icons": "^4.11.0", + "react-spinners": "^0.13.8", + "rimraf": "^5.0.5" + }, + "devDependencies": { + "@electron-toolkit/eslint-config": "^1.0.1", + "@electron-toolkit/eslint-config-prettier": "^1.0.1", + "@vitejs/plugin-react": "^4.0.4", + "electron": "^25.6.0", + "electron-builder": "^24.6.3", + "electron-vite": "^1.0.27", + "eslint": "^8.47.0", + "eslint-plugin-react": "^7.33.2", + "prettier": "^3.0.2", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "vite": "^4.4.9" + } +} diff --git a/resources/icon.png b/resources/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..cf9e8b2c87b5c18ac0b26913af6fd3ed00ec3bfb GIT binary patch literal 35949 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

(|V7O%OOlsc*vU+P^72X!U7K_eX*CG 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 E`T9pxN5i1O>p>VAy6%lIREYyb! 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 Kl11VsAO0U5dJ04U literal 0 HcmV?d00001 diff --git a/src/main/index.js b/src/main/index.js new file mode 100644 index 0000000..03cd81a --- /dev/null +++ b/src/main/index.js @@ -0,0 +1,116 @@ +import path from "node:path" + +import { app, shell, BrowserWindow, ipcMain } from "electron" +import { electronApp, optimizer, is } from "@electron-toolkit/utils" + +import open from "open" + +import icon from "../../resources/icon.png?asset" +import pkg from "../../package.json" + +import setup from "./setup" + +import PkgManager from "./pkgManager" + +class ElectronApp { + constructor() { + this.pkgManager = new PkgManager() + this.win = null + } + + handlers = { + pkg: () => { + return pkg + }, + "get:installations": async () => { + return await this.pkgManager.getInstallations() + }, + "bundle:update": (event, manifest_id) => { + this.pkgManager.update(manifest_id) + }, + "bundle:exec": (event, manifest_id) => { + this.pkgManager.exec(manifest_id) + }, + "bundle:install": async (event, manifest) => { + this.pkgManager.install(manifest) + }, + "bundle:uninstall": (event, manifest_id) => { + this.pkgManager.uninstall(manifest_id) + }, + "bundle:open": (event, manifest_id) => { + this.pkgManager.openBundleFolder(manifest_id) + }, + "check:setup": async () => { + return await setup() + } + } + + events = { + "open-runtime-path": () => { + return open(this.pkgManager.runtimePath) + }, + } + + createWindow() { + this.win = global.win = new BrowserWindow({ + width: 450, + height: 670, + show: false, + autoHideMenuBar: true, + ...(process.platform === "linux" ? { icon } : {}), + webPreferences: { + preload: path.join(__dirname, "../preload/index.js"), + sandbox: false + } + }) + + this.win.on("ready-to-show", () => { + this.win.show() + }) + + this.win.webContents.setWindowOpenHandler((details) => { + shell.openExternal(details.url) + + return { action: "deny" } + }) + + if (is.dev && process.env["ELECTRON_RENDERER_URL"]) { + this.win.loadURL(process.env["ELECTRON_RENDERER_URL"]) + } else { + this.win.loadFile(path.join(__dirname, "../renderer/index.html")) + } + } + + async initialize() { + for (const key in this.handlers) { + ipcMain.handle(key, this.handlers[key]) + } + + for (const key in this.events) { + ipcMain.on(key, this.events[key]) + } + + await app.whenReady() + + // Set app user model id for windows + electronApp.setAppUserModelId("com.electron") + + app.on("browser-window-created", (_, window) => { + optimizer.watchWindowShortcuts(window) + }) + + this.createWindow() + + app.on("activate", function () { + if (BrowserWindow.getAllWindows().length === 0) createWindow() + }) + + app.on("window-all-closed", () => { + if (process.platform !== "darwin") { + app.quit() + } + }) + } +} + +new ElectronApp().initialize() \ No newline at end of file diff --git a/src/main/pkgManager.js b/src/main/pkgManager.js new file mode 100644 index 0000000..afc6d94 --- /dev/null +++ b/src/main/pkgManager.js @@ -0,0 +1,601 @@ +import path from "node:path" +import { pipeline as streamPipeline } from "node:stream/promises" +import ChildProcess from "node:child_process" +import fs from "node:fs" +import os from "node:os" + +import open from "open" +import got from "got" +import { extractFull } from "node-7z" +import { rimraf } from "rimraf" +import lodash from "lodash" + +import pkg from "../../package.json" + +global.OS_USERDATA_PATH = path.resolve( + process.env.APPDATA || + (process.platform == "darwin" ? process.env.HOME + "/Library/Preferences" : process.env.HOME + "/.local/share"), +) + +global.RUNTIME_PATH = path.join(global.OS_USERDATA_PATH, "rs-bundler") + +const TMP_PATH = path.resolve(os.tmpdir(), "RS-MCPacks") +const INSTALLERS_PATH = path.join(global.RUNTIME_PATH, "installers") +const MANIFEST_PATH = path.join(global.RUNTIME_PATH, "manifests") + +const RealmDBDefault = { + created_at_version: pkg.version, + installations: [], +} + +function serializeIpc(data) { + const copy = lodash.cloneDeep(data) + + // remove fns + if (!Array.isArray(copy)) { + Object.keys(copy).forEach((key) => { + if (typeof copy[key] === "function") { + delete copy[key] + } + }) + } + + return copy +} + +function sendToRenderer(event, data) { + global.win.webContents.send(event, serializeIpc(data)) +} + +async function fetchAndCreateModule(manifest) { + console.log(`Fetching ${manifest}...`) + + try { + const response = await got.get(manifest) + const moduleCode = response.body + + const newModule = new module.constructor() + newModule._compile(moduleCode, manifest) + + return newModule + } catch (error) { + console.error(error) + } +} + +async function readManifest(manifest, { just_read = false } = {}) { + // check if manifest is a directory or a url + const urlRegex = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/gi + + if (urlRegex.test(manifest)) { + const _module = await fetchAndCreateModule(manifest) + const remoteUrl = lodash.clone(manifest) + + manifest = _module.exports + + manifest.remote_url = remoteUrl + } else { + if (!fs.existsSync(manifest)) { + throw new Error(`Manifest not found: ${manifest}`) + } + + if (!fs.statSync(manifest).isFile()) { + throw new Error(`Manifest is not a file: ${manifest}`) + } + + const manifestFilePath = lodash.clone(manifest) + + manifest = require(manifest) + + if (!just_read) { + // copy manifest + fs.copyFileSync(manifestFilePath, path.resolve(MANIFEST_PATH, path.basename(manifest))) + + manifest.remote_url = manifestFilePath + } + } + + return manifest +} + +export default class PkgManager { + constructor() { + this.initialize() + } + + get realmDbPath() { + return path.join(RUNTIME_PATH, "local_realm.json") + } + + get runtimePath() { + return RUNTIME_PATH + } + + async initialize() { + if (!fs.existsSync(RUNTIME_PATH)) { + fs.mkdirSync(RUNTIME_PATH, { recursive: true }) + } + + if (!fs.existsSync(INSTALLERS_PATH)) { + fs.mkdirSync(INSTALLERS_PATH, { recursive: true }) + } + + if (!fs.existsSync(MANIFEST_PATH)) { + fs.mkdirSync(MANIFEST_PATH, { recursive: true }) + } + + if (!fs.existsSync(TMP_PATH)) { + fs.mkdirSync(TMP_PATH, { recursive: true }) + } + + if (!fs.existsSync(this.realmDbPath)) { + console.log(`Creating default realm db...`, this.realmDbPath) + + await this.writeDb(RealmDBDefault) + } + } + + // DB Operations + async readDb() { + return JSON.parse(await fs.promises.readFile(this.realmDbPath, "utf8")) + } + + async writeDb(data) { + return fs.promises.writeFile(this.realmDbPath, JSON.stringify(data, null, 2)) + } + + async appendInstallation(manifest) { + const db = await this.readDb() + + const prevIndex = db.installations.findIndex((i) => i.id === manifest.id) + + if (prevIndex !== -1) { + db.installations[prevIndex] = manifest + } else { + db.installations.push(manifest) + } + + await this.writeDb(db) + } + + // CRUD Operations + async getInstallations() { + const db = await this.readDb() + + return db.installations + } + + async openBundleFolder(manifest_id) { + const db = await this.readDb() + + const index = db.installations.findIndex((i) => i.id === manifest_id) + + if (index !== -1) { + const manifest = db.installations[index] + + open(manifest.install_path) + } + } + + async install(manifest) { + let pendingTasks = [] + + manifest = await readManifest(manifest).catch((error) => { + sendToRenderer("runtime:error", "Cannot fetch this manifest") + + return false + }) + + if (!manifest) { + return false + } + + const packPath = path.resolve(INSTALLERS_PATH, manifest.id) + + if (typeof manifest.init === "function") { + const init_result = await manifest.init({ + pack_dir: packPath, + tmp_dir: TMP_PATH + }) + + manifest = { + ...manifest, + ...init_result, + } + + delete manifest.init + } + + manifest.status = "installing" + + console.log(`Starting to install ${manifest.pack_name}...`) + console.log(`Installing at >`, packPath) + + sendToRenderer("new:installation", manifest) + + fs.mkdirSync(packPath, { recursive: true }) + + await this.appendInstallation(manifest) + + try { + if (typeof manifest.git_clones_steps !== "undefined" && Array.isArray(manifest.git_clones_steps)) { + for await (const step of manifest.git_clones_steps) { + const _path = path.resolve(packPath, step.path) + + console.log(`Cloning ${step.url}...`) + + sendToRenderer(`installation:status`, { + ...manifest, + statusText: `Cloning ${step.url}`, + }) + + fs.mkdirSync(_path, { recursive: true }) + + await new Promise((resolve, reject) => { + const process = ChildProcess.exec(`git clone --recurse-submodules --remote-submodules ${step.url} ${_path}`, { + shell: true, + }) + + process.on("exit", resolve) + process.on("error", reject) + }) + } + } + + if (typeof manifest.http_downloads !== "undefined" && Array.isArray(manifest.http_downloads)) { + for await (const step of manifest.http_downloads) { + let _path = path.resolve(packPath, step.path ?? ".") + + console.log(`Downloading ${step.url}...`) + + sendToRenderer(`installation:status`, { + ...manifest, + statusText: `Downloading ${step.url}`, + }) + + if (step.tmp) { + _path = path.resolve(TMP_PATH, String(new Date().getTime())) + } + + fs.mkdirSync(path.resolve(_path, ".."), { recursive: true }) + + await streamPipeline( + got.stream(step.url), + fs.createWriteStream(_path) + ) + + if (step.execute) { + pendingTasks.push(async () => { + await new Promise(async (resolve, reject) => { + const process = ChildProcess.execFile(_path, { + shell: true, + }, (error) => { + if (error) { + reject(error) + } else { + resolve() + } + }) + + process.on("exit", resolve) + process.on("error", reject) + }) + }) + } + + if (step.extract) { + console.log(`Extracting ${step.extract}...`) + + sendToRenderer(`installation:status`, { + ...manifest, + statusText: `Extracting bundle ${step.extract}`, + }) + + await new Promise((resolve, reject) => { + const extract = extractFull(_path, step.extract, { + $bin: global.SEVENZIP_PATH + }) + + extract.on("error", reject) + extract.on("end", resolve) + }) + } + } + } + + if (pendingTasks.length > 0) { + console.log(`Performing pending tasks...`) + + sendToRenderer(`installation:status`, { + ...manifest, + statusText: `Performing pending tasks...`, + }) + + for await (const task of pendingTasks) { + await task() + } + } + + if (typeof manifest.after_install === "function") { + console.log(`Performing after_install hook...`) + + sendToRenderer(`installation:status`, { + ...manifest, + statusText: `Performing after_install hook...`, + }) + + await manifest.after_install({ + manifest, + pack_dir: packPath, + tmp_dir: TMP_PATH + }) + } + + manifest.status = "installed" + manifest.install_path = packPath + manifest.installed_at = new Date() + manifest.last_update = null + + await this.appendInstallation(manifest) + + console.log(`Successfully installed ${manifest.pack_name}!`) + + sendToRenderer(`installation:done`, { + ...manifest, + statusText: "Successfully installed", + }) + } catch (error) { + manifest.status = "failed" + + sendToRenderer(`installation:error`, { + ...manifest, + statusText: error.toString(), + }) + + console.error(error) + + fs.rmdirSync(packPath, { recursive: true }) + } + } + + async uninstall(manifest_id) { + console.log(`Uninstalling >`, manifest_id) + + sendToRenderer("installation:status", { + status: "uninstalling", + id: manifest_id, + statusText: `Uninstalling ${manifest_id}...`, + }) + + const db = await this.readDb() + + const manifest = db.installations.find((i) => i.id === manifest_id) + + if (!manifest) { + sendToRenderer("runtime:error", "Manifest not found") + return false + } + + if (manifest.remote_url) { + const remoteManifest = await readManifest(manifest.remote_url, { just_read: true }) + + if (typeof remoteManifest.uninstall === "function") { + console.log(`Performing uninstall hook...`) + + await remoteManifest.uninstall({ + manifest: remoteManifest, + pack_dir: remoteManifest.install_path, + tmp_dir: TMP_PATH, + }) + } + } + + await rimraf(manifest.install_path) + + db.installations = db.installations.filter((i) => i.id !== manifest_id) + + await this.writeDb(db) + + sendToRenderer("installation:uninstalled", { + id: manifest_id, + }) + } + + async update(manifest_id) { + try { + let pendingTasks = [] + + console.log(`Updating >`, manifest_id) + + sendToRenderer("installation:status", { + status: "updating", + id: manifest_id, + statusText: `Updating ${manifest_id}...`, + }) + + const db = await this.readDb() + + let manifest = db.installations.find((i) => i.id === manifest_id) + + if (!manifest) { + sendToRenderer("runtime:error", "Manifest not found") + return false + } + + console.log(manifest) + + const packPath = manifest.install_path + + if (manifest.remote_url) { + manifest = await readManifest(manifest.remote_url, { just_read: true }) + } + + manifest.status = "updating" + + if (typeof manifest.init === "function") { + const init_result = await manifest.init({ + pack_dir: packPath, + tmp_dir: TMP_PATH + }) + + manifest = { + ...manifest, + ...init_result, + } + + delete manifest.init + } + + console.log(manifest) + + if (typeof manifest.update === "function") { + sendToRenderer(`installation:status`, { + ...manifest, + statusText: `Performing update hook...`, + }) + + console.log(`Performing update hook...`) + + await manifest.update({ + manifest, + pack_dir: packPath, + tmp_dir: TMP_PATH + }) + } + + if (typeof manifest.git_update !== "undefined" && Array.isArray(manifest.git_update)) { + for await (const step of manifest.git_update) { + const _path = path.resolve(packPath, step.path) + + console.log(`GIT Pulling ${step.url}`) + + sendToRenderer(`installation:status`, { + ...manifest, + statusText: `GIT Pulling ${step.url}`, + }) + + await new Promise((resolve, reject) => { + const process = ChildProcess.exec(`git pull`, { + cwd: _path, + shell: true, + }) + + process.on("exit", resolve) + process.on("error", reject) + }) + } + } + + if (typeof manifest.http_downloads !== "undefined" && Array.isArray(manifest.http_downloads)) { + for await (const step of manifest.http_downloads) { + let _path = path.resolve(packPath, step.path ?? ".") + + console.log(`Downloading ${step.url}...`) + + sendToRenderer(`installation:status`, { + ...manifest, + statusText: `Downloading ${step.url}`, + }) + + if (step.tmp) { + _path = path.resolve(TMP_PATH, String(new Date().getTime())) + } + + fs.mkdirSync(path.resolve(_path, ".."), { recursive: true }) + + await streamPipeline( + got.stream(step.url), + fs.createWriteStream(_path) + ) + + if (step.execute) { + pendingTasks.push(async () => { + await new Promise(async (resolve, reject) => { + const process = ChildProcess.execFile(_path, { + shell: true, + }, (error) => { + if (error) { + reject(error) + } else { + resolve() + } + }) + + process.on("exit", resolve) + process.on("error", reject) + }) + }) + } + + if (step.extract) { + console.log(`Extracting ${step.extract}...`) + + sendToRenderer(`installation:status`, { + ...manifest, + statusText: `Extracting bundle ${step.extract}`, + }) + + await new Promise((resolve, reject) => { + const extract = extractFull(_path, step.extract, { + $bin: global.SEVENZIP_PATH + }) + + extract.on("error", reject) + extract.on("end", resolve) + }) + } + } + } + + if (pendingTasks.length > 0) { + console.log(`Performing pending tasks...`) + + sendToRenderer(`installation:status`, { + ...manifest, + statusText: `Performing pending tasks...`, + }) + + for await (const task of pendingTasks) { + await task() + } + } + + if (typeof manifest.after_install === "function") { + console.log(`Performing after_install hook...`) + + sendToRenderer(`installation:status`, { + ...manifest, + statusText: `Performing after_install hook...`, + }) + + await manifest.after_install({ + manifest, + pack_dir: packPath, + tmp_dir: TMP_PATH + }) + } + + manifest.status = "installed" + manifest.install_path = packPath + manifest.last_update = new Date() + + await this.appendInstallation(manifest) + + console.log(`Successfully updated ${manifest.pack_name}!`) + + sendToRenderer(`installation:done`, { + ...manifest, + statusText: "Successfully updated", + }) + } catch (error) { + manifest.status = "failed" + + sendToRenderer(`installation:error`, { + ...manifest, + statusText: error.toString(), + }) + + console.error(error) + } + } +} \ No newline at end of file diff --git a/src/main/setup.js b/src/main/setup.js new file mode 100644 index 0000000..a340569 --- /dev/null +++ b/src/main/setup.js @@ -0,0 +1,74 @@ +import path from "node:path" +import fs from "node:fs" +import os from "node:os" +import ChildProcess from "node:child_process" +import { pipeline as streamPipeline } from "node:stream/promises" + +import got from "got" + +function resolveDestBin(pre, post) { + let url = null + + if (process.platform === "darwin") { + url = `${pre}/mac/${process.arch}/${post}` + } + else if (process.platform === "win32") { + url = `${pre}/win/${process.arch}/${post}` + } + else { + url = `${pre}/linux/${process.arch}/${post}` + } + + return url +} + +async function main() { + const sevenzip_exec = path.resolve(global.RUNTIME_PATH, "7z-bin", process.platform === "win32" ? "7za.exe" : "7za") + const git_exec = path.resolve(global.RUNTIME_PATH, "git", process.platform === "win32" ? "git.exe" : "git") + + if (!fs.existsSync(sevenzip_exec)) { + global.win.webContents.send("initializing_text", "Downloading 7z binaries...") + + console.log(`Downloading 7z binaries...`) + + fs.mkdirSync(path.resolve(global.RUNTIME_PATH, "7z-bin"), { recursive: true }) + + let url = resolveDestBin(`https://storage.ragestudio.net/rstudio/binaries/7zip-bin`, process.platform === "win32" ? "7za.exe" : "7za") + + await streamPipeline( + got.stream(url), + fs.createWriteStream(sevenzip_exec) + ) + + if (os.platform() !== "win32") { + ChildProcess.execSync("chmod +x " + sevenzip_exec) + } + } + + if (!fs.existsSync(git_exec) && process.platform === "win32") { + global.win.webContents.send("initializing_text", "Downloading GIT binaries...") + + console.log(`Downloading git binaries...`) + + fs.mkdirSync(path.resolve(global.RUNTIME_PATH, "git"), { recursive: true }) + + let url = resolveDestBin(`https://storage.ragestudio.net/rstudio/binaries/git`, "git.7z") + + await streamPipeline( + got.stream(url), + fs.createWriteStream(git_exec) + ) + + if (os.platform() !== "win32") { + ChildProcess.execSync("chmod +x " + git_exec) + } + } + + global.SEVENZIP_PATH = sevenzip_exec + global.GIT_PATH = git_exec + + console.log(`7z binaries: ${sevenzip_exec}`) + console.log(`GIT binaries: ${git_exec}`) +} + +export default main \ No newline at end of file diff --git a/src/preload/index.js b/src/preload/index.js new file mode 100644 index 0000000..6c76a5f --- /dev/null +++ b/src/preload/index.js @@ -0,0 +1,33 @@ +import { contextBridge, ipcRenderer } from "electron" +import { electronAPI } from "@electron-toolkit/preload" + +const api = {} + +if (process.contextIsolated) { + try { + contextBridge.exposeInMainWorld( + "ipc", + { + exec: (channel, ...args) => { + return ipcRenderer.invoke(channel, ...args) + }, + send: (channel, args) => { + ipcRenderer.send(channel, args) + }, + on: (channel, listener) => { + ipcRenderer.on(channel, (event, ...args) => listener(event, ...args)) + }, + off: (channel, listener) => { + ipcRenderer.removeListener(channel, listener) + } + }, + ) + contextBridge.exposeInMainWorld('electron', electronAPI) + contextBridge.exposeInMainWorld('api', api) + } catch (error) { + console.error(error) + } +} else { + window.electron = electronAPI + window.api = api +} diff --git a/src/renderer/index.html b/src/renderer/index.html new file mode 100644 index 0000000..59218b3 --- /dev/null +++ b/src/renderer/index.html @@ -0,0 +1,16 @@ + + + + + RageStudio Bundler + + + + +

+ + + diff --git a/src/renderer/src/App.jsx b/src/renderer/src/App.jsx new file mode 100644 index 0000000..60724b2 --- /dev/null +++ b/src/renderer/src/App.jsx @@ -0,0 +1,125 @@ +import React from "react" +import * as antd from "antd" + +import BarLoader from "react-spinners/BarLoader" + +import GlobalStateContext from "contexts/global" + +import getRootCssVar from "utils/getRootCssVar" + +import InstallationsManager from "pages/manager" + +import { MdFolder } from "react-icons/md" + +globalThis.getRootCssVar = getRootCssVar + +const PageRender = () => { + const globalState = React.useContext(GlobalStateContext) + + if (globalState.initializing_text && globalState.loading) { + return
+ + +

Setting up...

+ + +
{globalState.initializing_text}
+
+
+ } + + return +} + +class App extends React.Component { + state = { + loading: true, + pkg: null, + initializing: false, + } + + ipcEvents = { + "runtime:error": (event, data) => { + antd.message.error(data) + }, + "runtime:info": (event, data) => { + antd.message.info(data) + }, + "initializing_text": (event, data) => { + this.setState({ + initializing_text: data, + }) + } + } + + componentDidMount = async () => { + for (const event in this.ipcEvents) { + ipc.on(event, this.ipcEvents[event]) + } + + const pkg = await ipc.exec("pkg") + + await ipc.exec("check:setup") + + this.setState({ + pkg: pkg, + loading: false, + }) + } + + componentWillUnmount = () => { + for (const event in this.ipcEvents) { + ipc.off(event, this.ipcEvents[event]) + } + } + + render() { + const { loading, pkg } = this.state + + return + + + +

RageStudio Bundler

+
+ + + + + + { + !loading && + + {pkg.name} + + + v{pkg.version} + + + + } + onClick={() => ipc.send("open-runtime-path")} + /> + + } +
+
+
+ } +} + +export default App diff --git a/src/renderer/src/components/Versions.jsx b/src/renderer/src/components/Versions.jsx new file mode 100644 index 0000000..56bd1b3 --- /dev/null +++ b/src/renderer/src/components/Versions.jsx @@ -0,0 +1,16 @@ +import { useState } from 'react' + +function Versions() { + const [versions] = useState(window.electron.process.versions) + + return ( +
    +
  • Electron v{versions.electron}
  • +
  • Chromium v{versions.chrome}
  • +
  • Node v{versions.node}
  • +
  • V8 v{versions.v8}
  • +
+ ) +} + +export default Versions diff --git a/src/renderer/src/contexts/global.js b/src/renderer/src/contexts/global.js new file mode 100644 index 0000000..cb3218e --- /dev/null +++ b/src/renderer/src/contexts/global.js @@ -0,0 +1,8 @@ +import React from "react" + +const GlobalStateContext = React.createContext({ + pkg: {}, + installations: [], +}) + +export default GlobalStateContext \ No newline at end of file diff --git a/src/renderer/src/contexts/installations.jsx b/src/renderer/src/contexts/installations.jsx new file mode 100644 index 0000000..0249669 --- /dev/null +++ b/src/renderer/src/contexts/installations.jsx @@ -0,0 +1,108 @@ +import React from "react" +import * as antd from "antd" + +export const Context = React.createContext([]) + +export class WithContext extends React.Component { + state = { + installations: [] + } + + ipcEvents = { + "new:installation": (event, data) => { + antd.message.loading(`Installing ${data.id}`) + + let newData = this.state.installations + + // search if installation already exists + const prev = this.state.installations.findIndex((item) => item.id === data.id) + + if (prev !== -1) { + newData[prev] = data + } else { + newData.push(data) + } + + this.setState({ + installations: newData, + }) + }, + "installation:status": (event, data) => { + console.log(`INSTALLATION STATUS: ${data.id} >`, data) + + const { id } = data + + let newData = this.state.installations + + const index = newData.findIndex((item) => item.id === id) + + if (index !== -1) { + newData[index] = { + ...newData[index], + ...data, + } + + this.setState({ + installations: newData + }) + } + }, + "installation:error": (event, data) => { + antd.message.error(`Failed to install ${data.id}`) + + this.ipcEvents["installation:status"](event, data) + }, + "installation:done": (event, data) => { + antd.message.success(`Successfully installed ${data.id}`) + + this.ipcEvents["installation:status"](event, data) + }, + "installation:uninstalled": (event, data) => { + antd.message.success(`Successfully uninstalled ${data.id}`) + + const index = this.state.installations.findIndex((item) => item.id === data.id) + + if (index !== -1) { + this.setState({ + installations: [ + ...this.state.installations.slice(0, index), + ...this.state.installations.slice(index + 1), + ] + }) + } + } + } + + componentDidMount = async () => { + const installations = await ipc.exec("get:installations") + + for (const event in this.ipcEvents) { + ipc.on(event, this.ipcEvents[event]) + } + + this.setState({ + installations: [ + ...this.state.installations, + ...installations, + ] + }) + } + + componentWillUnmount() { + for (const event in this.ipcEvents) { + ipc.off(event, this.ipcEvents[event]) + } + } + + render() { + return + {this.props.children} + + } +} + +export default Context \ No newline at end of file diff --git a/src/renderer/src/main.jsx b/src/renderer/src/main.jsx new file mode 100644 index 0000000..ea19dca --- /dev/null +++ b/src/renderer/src/main.jsx @@ -0,0 +1,8 @@ +import "./style/index.less" + +import React from "react" +import ReactDOM from "react-dom" + +import App from "./App" + +ReactDOM.render(, document.getElementById("root")) \ No newline at end of file diff --git a/src/renderer/src/pages/manager/index.jsx b/src/renderer/src/pages/manager/index.jsx new file mode 100644 index 0000000..7162428 --- /dev/null +++ b/src/renderer/src/pages/manager/index.jsx @@ -0,0 +1,211 @@ +import React from "react" +import * as antd from "antd" +import classnames from "classnames" + +import BarLoader from "react-spinners/BarLoader" + +import { MdAdd, MdUploadFile, MdFolder, MdDelete, MdPlayArrow, MdUpdate } from "react-icons/md" + +import { Context as InstallationsContext, WithContext } from "contexts/installations" + +import "./index.less" + +const NewInstallation = (props) => { + const [manifestUrl, setManifestUrl] = React.useState("") + + const handleInstall = (manifest) => { + ipc.exec("bundle:install", manifest) + .then(() => { + props.close() + }) + .catch((error) => { + antd.message.error(error) + }) + } + + return
+ setManifestUrl(e.target.value)} + onPressEnter={() => handleInstall(manifestUrl)} + /> + +

+ or +

+ + } + disabled + > + Local file + +
+} + +const InstallationItem = (props) => { + const { manifest } = props + + const isLoading = manifest.status === "installing" || manifest.status === "uninstalling" || manifest.status === "updating" + const isInstalled = manifest.status === "installed" + const isFailed = manifest.status === "failed" + + const onClickUpdate = () => { + ipc.exec("bundle:update", manifest.id) + } + + const onClickPlay = () => { + ipc.exec("bundle:exec", manifest.id) + } + + const onClickFolder = () => { + ipc.exec("bundle:open", manifest.id) + } + + const onClickDelete = () => { + ipc.exec("bundle:uninstall", manifest.id) + } + + return
+
+ + +
+

+ { + manifest.pack_name + } +

+

+ { + isLoading ? manifest.status : manifest.version ?? "N/A" + } +

+
+ +
+ { + isFailed && + Retry + + } + + { + isInstalled && } + onClick={onClickUpdate} + /> + } + + { + isInstalled && manifest.exec_path && } + onClick={onClickPlay} + /> + } + + { + isInstalled && } + onClick={onClickFolder} + /> + } + + { + isInstalled && + } + /> + + } +
+
+ +
+ { + isLoading && + } +

{manifest.statusText ?? "Unknown status"}

+
+
+} + +class InstallationsManager extends React.Component { + static contextType = InstallationsContext + + state = { + drawerVisible: false, + } + + toggleDrawer = (to) => { + this.setState({ + drawerVisible: to ?? !this.state.drawerVisible, + }) + } + + render() { + const { installations } = this.context + + const empty = installations.length == 0 + + return
+ } + onClick={() => this.toggleDrawer(true)} + > + Add new installation + + +
+ { + empty && + } + + { + installations.map((manifest) => { + return + }) + } +
+ + this.toggleDrawer(false)} + > + this.toggleDrawer(false)} + /> + +
+ } +} + +const InstallationsManagerPage = (props) => { + return + + +} + +export default InstallationsManagerPage \ No newline at end of file diff --git a/src/renderer/src/pages/manager/index.less b/src/renderer/src/pages/manager/index.less new file mode 100644 index 0000000..2029dde --- /dev/null +++ b/src/renderer/src/pages/manager/index.less @@ -0,0 +1,157 @@ +.installations_manager { + display: flex; + flex-direction: column; + + height: 100%; + + gap: 20px; + + .installations_list { + display: flex; + flex-direction: column; + + height: 100%; + padding: 10px; + + gap: 10px; + + background-color: var(--background-color-secondary); + border-radius: 12px; + + &.empty { + align-items: center; + justify-content: center; + } + } +} + +@installation-item-borderRadius: 12px; + +.installation_item_wrapper { + position: relative; + + display: flex; + flex-direction: column; + + &.status_visible { + .installation_item { + border-bottom: 1px solid var(--border-color); + } + + .installation_status { + height: fit-content; + + padding: 10px 20px; + padding-top: calc(8px + 10px); + + opacity: 1; + transform: translateY(-8px); + } + } + + &:nth-child(odd) { + .installation_item { + background-color: var(--background-color-primary); + } + + .installation_status { + background-color: var(--background-color-primary); + } + } + + .installation_item { + display: flex; + flex-direction: row; + + gap: 20px; + + padding: 5px; + + border-radius: @installation-item-borderRadius; + + background-color: var(--background-color-primary); + + z-index: 50; + + .installation_item_info { + display: flex; + flex-direction: column; + + gap: 10px; + + p { + font-size: 0.7rem; + text-transform: uppercase; + } + } + + .installation_item_icon { + width: 50px; + height: 50px; + + min-width: 50px; + min-height: 50px; + + overflow: hidden; + + border-radius: 12px; + + img { + width: 100%; + height: 100%; + } + } + + .installation_item_actions { + display: flex; + + width: 100%; + + gap: 10px; + + align-items: center; + justify-content: flex-end; + } + } + + .installation_status { + position: relative; + + z-index: 49; + + display: inline-flex; + flex-direction: column; + + background-color: var(--background-color-primary); + + gap: 10px; + + width: 100%; + + border-radius: 0 0 12px 12px; + + padding: 0; + margin: 0; + opacity: 0; + height: 0; + overflow: hidden; + + p { + font-size: 0.7rem; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + line-height: 14px; + } + } +} + +.new_installation_prompt { + display: flex; + flex-direction: column; + + align-items: center; + justify-content: center; + + gap: 20px; +} \ No newline at end of file diff --git a/src/renderer/src/style/index.less b/src/renderer/src/style/index.less new file mode 100644 index 0000000..4188ccf --- /dev/null +++ b/src/renderer/src/style/index.less @@ -0,0 +1,153 @@ +@import "style/reset.css"; + +@var-text-color: #fff; +@var-background-color-primary: #424549; +@var-background-color-secondary: #1e2124; +@var-primary-color: #36d7b7; //#F3B61F; +@var-border-color: #a1a2a2; + +:root { + --background-color-primary: @var-background-color-primary; + --background-color-secondary: @var-background-color-secondary; + --primary-color: @var-primary-color; + --text-color: @var-text-color; + --border-color: @var-border-color; +} + +html, +body { + padding: 0; + margin: 0; + background: var(--background-color-primary); + color: var(--text-color); + + font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen; + + width: 100vw; + height: 100vh; + + overflow: hidden; +} + +*, +*:before, +*:after { + box-sizing: border-box; +} + +#root { + width: 100%; + height: 100%; + + overflow: hidden; +} + +.app_layout { + width: 100%; + height: 100%; + + background-color: var(--background-color-primary); +} + +.app_header { + display: inline-flex; + flex-direction: row; + + align-items: center; + + background-color: darken(@var-background-color-primary, 5%); + + gap: 30px; +} + +.app_footer { + display: inline-flex; + flex-direction: row; + + align-items: center; + + gap: 30px; + + background-color: darken(@var-background-color-primary, 5%); + + border: 1px solid @var-border-color; + + padding: 10px 40px; + + margin: 10px; + border-radius: 12px; +} + +.app_content { + width: 100%; + height: 100%; + + padding: 20px; + + background-color: var(--background-color-primary); +} + +h1, +h2, +h3, +h4, +h5, +h6, +p, +span { + display: inline-flex; + flex-direction: row; + + align-items: center; + + color: var(--text-color); + + margin: 0; + + gap: 10px; +} + +* svg { + margin: 0; +} + +.ant-btn { + display: inline-flex; + + align-items: center; + justify-content: center; + + margin: 0; + + gap: 6px; +} + +.ant-message-notice-wrapper { + .ant-message-notice-content { + color: var(--text-color) !important; + background-color: var(--background-color-primary) !important; + } +} + +.app_setup { + display: flex; + flex-direction: column; + + gap: 20px; + + h1 { + font-size: 2.3rem; + } + + code { + background-color: var(--background-color-secondary); + + padding: 20px; + + border-radius: 12px; + } +} + +.app_loader { + width: 100% !important; +} \ No newline at end of file diff --git a/src/renderer/src/style/reset.css b/src/renderer/src/style/reset.css new file mode 100644 index 0000000..e29c0f5 --- /dev/null +++ b/src/renderer/src/style/reset.css @@ -0,0 +1,48 @@ +/* http://meyerweb.com/eric/tools/css/reset/ + v2.0 | 20110126 + License: none (public domain) +*/ + +html, body, div, span, applet, object, iframe, +h1, h2, h3, h4, h5, h6, p, blockquote, pre, +a, abbr, acronym, address, big, cite, code, +del, dfn, em, img, ins, kbd, q, s, samp, +small, strike, strong, sub, sup, tt, var, +b, u, i, center, +dl, dt, dd, ol, ul, li, +fieldset, form, label, legend, +table, caption, tbody, tfoot, thead, tr, th, td, +article, aside, canvas, details, embed, +figure, figcaption, footer, header, hgroup, +menu, nav, output, ruby, section, summary, +time, mark, audio, video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + font: inherit; + vertical-align: baseline; +} +/* HTML5 display-role reset for older browsers */ +article, aside, details, figcaption, figure, +footer, header, hgroup, menu, nav, section { + display: block; +} +body { + line-height: 1; +} +ol, ul { + list-style: none; +} +blockquote, q { + quotes: none; +} +blockquote:before, blockquote:after, +q:before, q:after { + content: ''; + content: none; +} +table { + border-collapse: collapse; + border-spacing: 0; +} diff --git a/src/renderer/src/utils/getRootCssVar/index.js b/src/renderer/src/utils/getRootCssVar/index.js new file mode 100644 index 0000000..1b3726c --- /dev/null +++ b/src/renderer/src/utils/getRootCssVar/index.js @@ -0,0 +1,6 @@ +function getRootCssVar(key) { + const root = document.querySelector(':root') + return window.getComputedStyle(root).getPropertyValue(key) +} + +export default getRootCssVar \ No newline at end of file