This commit is contained in:
srgooglo 2023-10-24 00:46:05 +02:00
commit 2f8f735710
30 changed files with 1965 additions and 0 deletions

9
.editorconfig Normal file
View File

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

4
.eslintignore Normal file
View File

@ -0,0 +1,4 @@
node_modules
dist
out
.gitignore

9
.eslintrc.cjs Normal file
View File

@ -0,0 +1,9 @@
module.exports = {
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'@electron-toolkit',
'@electron-toolkit/eslint-config-prettier'
]
}

40
.gitignore vendored Normal file
View File

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

6
.prettierignore Normal file
View File

@ -0,0 +1,6 @@
out
dist
pnpm-lock.yaml
LICENSE.md
tsconfig.json
tsconfig.*.json

4
.prettierrc.yaml Normal file
View File

@ -0,0 +1,4 @@
singleQuote: true
semi: false
printWidth: 100
trailingComma: none

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["dbaeumer.vscode-eslint"]
}

39
.vscode/launch.json vendored Normal file
View File

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

11
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,11 @@
{
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.defaultFormatter": "vscode.typescript-language-features"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}

34
README.md Normal file
View File

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

3
dev-app-update.yml Normal file
View File

@ -0,0 +1,3 @@
provider: generic
url: https://example.com/auto-updates
updaterCacheDirName: rs-bundler-updater

42
electron-builder.yml Normal file
View File

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

34
electron.vite.config.js Normal file
View File

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

47
package.json Normal file
View File

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

BIN
resources/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

116
src/main/index.js Normal file
View File

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

601
src/main/pkgManager.js Normal file
View File

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

74
src/main/setup.js Normal file
View File

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

33
src/preload/index.js Normal file
View File

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

16
src/renderer/index.html Normal file
View File

@ -0,0 +1,16 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>RageStudio Bundler</title>
<!-- <meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'"
/> -->
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

125
src/renderer/src/App.jsx Normal file
View File

@ -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 <div className="app_setup">
<BarLoader
className="app_loader"
color={getRootCssVar("--primary-color")}
/>
<h1>Setting up...</h1>
<code>
<pre>{globalState.initializing_text}</pre>
</code>
</div>
}
return <InstallationsManager />
}
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 <antd.ConfigProvider
theme={{
token: {
colorPrimary: getRootCssVar("--primary-color"),
colorBgContainer: getRootCssVar("--background-color-primary"),
colorPrimaryBg: getRootCssVar("--background-color-primary"),
},
algorithm: antd.theme.darkAlgorithm
}}
>
<GlobalStateContext.Provider value={this.state}>
<antd.Layout className="app_layout">
<antd.Layout.Header className="app_header">
<h1>RageStudio Bundler</h1>
</antd.Layout.Header>
<antd.Layout.Content className="app_content">
<PageRender />
</antd.Layout.Content>
{
!loading && <antd.Layout.Footer className="app_footer">
<span>
{pkg.name}
<antd.Tag>
v{pkg.version}
</antd.Tag>
</span>
<antd.Button
size="small"
icon={<MdFolder />}
onClick={() => ipc.send("open-runtime-path")}
/>
</antd.Layout.Footer>
}
</antd.Layout>
</GlobalStateContext.Provider>
</antd.ConfigProvider>
}
}
export default App

View File

@ -0,0 +1,16 @@
import { useState } from 'react'
function Versions() {
const [versions] = useState(window.electron.process.versions)
return (
<ul className="versions">
<li className="electron-version">Electron v{versions.electron}</li>
<li className="chrome-version">Chromium v{versions.chrome}</li>
<li className="node-version">Node v{versions.node}</li>
<li className="v8-version">V8 v{versions.v8}</li>
</ul>
)
}
export default Versions

View File

@ -0,0 +1,8 @@
import React from "react"
const GlobalStateContext = React.createContext({
pkg: {},
installations: [],
})
export default GlobalStateContext

View File

@ -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 <Context.Provider
value={{
installations: this.state.installations
}}
>
{this.props.children}
</Context.Provider>
}
}
export default Context

View File

@ -0,0 +1,8 @@
import "./style/index.less"
import React from "react"
import ReactDOM from "react-dom"
import App from "./App"
ReactDOM.render(<App />, document.getElementById("root"))

View File

@ -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 <div className="new_installation_prompt">
<antd.Input
placeholder="Manifest URL"
value={manifestUrl}
onChange={(e) => setManifestUrl(e.target.value)}
onPressEnter={() => handleInstall(manifestUrl)}
/>
<h2>
or
</h2>
<antd.Button
icon={<MdUploadFile />}
disabled
>
Local file
</antd.Button>
</div>
}
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 <div
className={classnames(
"installation_item_wrapper",
{
["status_visible"]: !isInstalled
}
)}
>
<div className="installation_item">
<img src={manifest.icon} className="installation_item_icon" />
<div className="installation_item_info">
<h2>
{
manifest.pack_name
}
</h2>
<p>
{
isLoading ? manifest.status : manifest.version ?? "N/A"
}
</p>
</div>
<div className="installation_item_actions">
{
isFailed && <antd.Button
type="primary"
>
Retry
</antd.Button>
}
{
isInstalled && <antd.Button
type="primary"
icon={<MdUpdate />}
onClick={onClickUpdate}
/>
}
{
isInstalled && manifest.exec_path && <antd.Button
type="primary"
icon={<MdPlayArrow />}
onClick={onClickPlay}
/>
}
{
isInstalled && <antd.Button
type="primary"
icon={<MdFolder />}
onClick={onClickFolder}
/>
}
{
isInstalled && <antd.Popconfirm
title="Delete Installation"
onConfirm={onClickDelete}
>
<antd.Button
type="ghost"
icon={<MdDelete />}
/>
</antd.Popconfirm>
}
</div>
</div>
<div
className="installation_status"
>
{
isLoading && <BarLoader color={getRootCssVar("--primary-color")} className="app_loader" />
}
<p>{manifest.statusText ?? "Unknown status"}</p>
</div>
</div>
}
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 <div className="installations_manager">
<antd.Button
type="primary"
icon={<MdAdd />}
onClick={() => this.toggleDrawer(true)}
>
Add new installation
</antd.Button>
<div className={empty ? "installations_list empty" : "installations_list"}>
{
empty && <antd.Empty description="No installations" />
}
{
installations.map((manifest) => {
return <InstallationItem key={manifest.id} manifest={manifest} />
})
}
</div>
<antd.Drawer
title="Add new installation"
placement="bottom"
open={this.state.drawerVisible}
onClose={() => this.toggleDrawer(false)}
>
<NewInstallation
close={() => this.toggleDrawer(false)}
/>
</antd.Drawer>
</div>
}
}
const InstallationsManagerPage = (props) => {
return <WithContext>
<InstallationsManager {...props} />
</WithContext>
}
export default InstallationsManagerPage

View File

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

View File

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

View File

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

View File

@ -0,0 +1,6 @@
function getRootCssVar(key) {
const root = document.querySelector(':root')
return window.getComputedStyle(root).getPropertyValue(key)
}
export default getRootCssVar