commit 2f8f73571038156abfcf67b7fd442b5e955e8438 Author: srgooglo Date: Tue Oct 24 00:46:05 2023 +0200 init 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 0000000..cf9e8b2 Binary files /dev/null and b/resources/icon.png differ 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 ( + + ) +} + +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