added settings

This commit is contained in:
srgooglo 2023-11-21 19:12:56 +01:00
parent 979a4fa515
commit 2c0533a16f
11 changed files with 486 additions and 95 deletions

View File

@ -1,32 +1,16 @@
import lodash from "lodash"
import sendToRender from "./utils/sendToRender"
global.sendToRenderer = (event, data) => {
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
}
global.win.webContents.send(event, serializeIpc(data))
}
const { autoUpdater } = require("electron-differential-updater")
const ProtocolRegistry = require("protocol-registry")
global.SettingsStore = new Store({
name: "settings",
watch: true,
})
import path from "node:path"
import { app, shell, BrowserWindow, ipcMain } from "electron"
import { electronApp, optimizer, is } from "@electron-toolkit/utils"
import isDev from "electron-is-dev"
import Store from "electron-store"
import open from "open"
@ -37,6 +21,11 @@ import setup from "./setup"
import PkgManager from "./pkg_mng"
import { readManifest } from "./utils/readManifest"
import GoogleDriveAPI from "./lib/google_drive"
const { autoUpdater } = require("electron-differential-updater")
const ProtocolRegistry = require("protocol-registry")
const protocolRegistryNamespace = "rsbundle"
class ElectronApp {
@ -46,9 +35,6 @@ class ElectronApp {
}
handlers = {
pkg: () => {
return pkg
},
"get:installations": async () => {
return await this.pkgManager.getInstallations()
},
@ -73,9 +59,6 @@ class ElectronApp {
"pkg:apply_changes": (event, manifest_id, changes) => {
this.pkgManager.applyChanges(manifest_id, changes)
},
"check:setup": async () => {
return await setup()
},
"updater:check": () => {
autoUpdater.checkForUpdates()
},
@ -83,7 +66,32 @@ class ElectronApp {
setTimeout(() => {
autoUpdater.quitAndInstall()
}, 3000)
}
},
"settings:get": (e, key) => {
return global.SettingsStore.get(key)
},
"settings:set": (e, key, value) => {
return global.SettingsStore.set(key, value)
},
"settings:delete": (e, key) => {
return global.SettingsStore.delete(key)
},
"settings:has": (e, key) => {
return global.SettingsStore.has(key)
},
"app:init": async (event, data) => {
await setup()
// check if can decode google drive token
const googleDrive_enabled = !!await GoogleDriveAPI.readCredentials()
return {
pkg: pkg,
authorizedServices: {
drive: googleDrive_enabled,
},
}
},
}
events = {
@ -92,11 +100,6 @@ class ElectronApp {
},
}
sendToRender(event, ...args) {
console.log(`[sendToRender][${event}]`, ...args)
this.win.webContents.send(event, ...args)
}
createWindow() {
this.win = global.win = new BrowserWindow({
width: 450,
@ -129,8 +132,6 @@ class ElectronApp {
}
handleURLProtocol(url) {
console.log(url)
const urlStarter = `${protocolRegistryNamespace}://`
if (url.startsWith(urlStarter)) {
@ -143,18 +144,17 @@ class ElectronApp {
switch (action) {
case "install": {
return this.sendToRender("installation:invoked", value)
return sendToRender("installation:invoked", value)
}
default: {
return this.sendToRender("new:message", {
return sendToRender("new:message", {
message: "Unrecognized URL action",
})
}
}
} else {
// by default if no action is specified, assume is a install action
return this.sendToRender("installation:invoked", urlValue)
return sendToRender("installation:invoked", urlValue)
}
}
}
@ -217,7 +217,7 @@ class ElectronApp {
autoUpdater.on("update-downloaded", (ev, info) => {
console.log(info)
this.sendToRender("update-available", info)
sendToRender("update-available", info)
})
if (isDev) {
@ -240,7 +240,9 @@ class ElectronApp {
}
}
this.createWindow()
await GoogleDriveAPI.init()
await this.createWindow()
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {

View File

@ -1,7 +1,22 @@
import { contextBridge, ipcRenderer } from "electron"
import { electronAPI } from "@electron-toolkit/preload"
const api = {}
const api = {
settings: {
get: (key) => {
return ipcRenderer.invoke("settings:get", key)
},
set: (key, value) => {
return ipcRenderer.invoke("settings:set", key, value)
},
delete: (key) => {
return ipcRenderer.invoke("settings:delete", key)
},
has: (key) => {
return ipcRenderer.invoke("settings:has", key)
},
},
}
if (process.contextIsolated) {
try {
@ -21,8 +36,8 @@ if (process.contextIsolated) {
ipcRenderer.removeListener(channel, listener)
}
},
)
contextBridge.exposeInMainWorld("electron", electronAPI)
contextBridge.exposeInMainWorld("api", api)

View File

@ -10,7 +10,7 @@ import ManifestInfo from "components/ManifestInfo"
import AppHeader from "layout/components/Header"
import AppModalDialog from "layout/components/ModalDialog"
import { PageRender } from "./router.jsx"
import { InternalRouter, PageRender } from "./router.jsx"
globalThis.getRootCssVar = getRootCssVar
globalThis.notification = antd.notification
@ -32,7 +32,10 @@ window.app = {
app.modal.close()
}
})
}
},
checkUpdates: () => {
ipc.exec("updater:check")
},
}
class App extends React.Component {
@ -41,6 +44,10 @@ class App extends React.Component {
pkg: null,
initializing: false,
updateAvailable: false,
authorizedServices: {
drive: false,
},
}
ipcEvents = {
@ -50,14 +57,6 @@ class App extends React.Component {
"runtime:info": (event, data) => {
antd.message.info(data)
},
"initializing_text": (event, data) => {
this.setState({
initializing_text: data,
})
},
"installation:invoked": (event, manifest) => {
app.invokeInstall(manifest)
},
"new:notification": (event, data) => {
antd.notification[data.type || "info"]({
message: data.message,
@ -71,26 +70,47 @@ class App extends React.Component {
antd.message[data.type || "info"](data.message)
},
"update-available": (event, data) => {
this.onUpdateAvailable(data)
},
"initializing_text": (event, data) => {
this.setState({
updateAvailable: true,
initializing_text: data,
})
},
"installation:invoked": (event, manifest) => {
app.invokeInstall(manifest)
},
"drive:authorized": (event, data) => {
this.setState({
authorizedServices: {
drive: true,
},
})
console.log(data)
message.success("Google Drive API authorized")
},
}
antd.Modal.confirm({
title: "Update Available",
content: <>
<p>
A new version of the application is available.
</p>
</>,
okText: "Update",
cancelText: "Later",
onOk: () => {
app.applyUpdate()
}
})
}
onUpdateAvailable = () => {
this.setState({
updateAvailable: true,
})
console.log(data)
antd.Modal.confirm({
title: "Update Available",
content: <>
<p>
A new version of the application is available.
</p>
</>,
okText: "Update",
cancelText: "Later",
onOk: () => {
app.applyUpdate()
}
})
}
componentDidMount = async () => {
@ -98,13 +118,16 @@ class App extends React.Component {
ipc.on(event, this.ipcEvents[event])
}
const pkg = await ipc.exec("pkg")
const initResult = await ipc.exec("app:init")
await ipc.exec("check:setup")
console.log(`[INIT] >`, initResult)
this.setState({
pkg: pkg,
loading: false,
pkg: initResult.pkg,
authorizedServices: {
drive: initResult.authorizedServices?.drive ?? false
},
})
}
@ -125,17 +148,19 @@ class App extends React.Component {
algorithm: antd.theme.darkAlgorithm
}}
>
<GlobalStateContext.Provider value={this.state}>
<AppModalDialog />
<InternalRouter>
<GlobalStateContext.Provider value={this.state}>
<AppModalDialog />
<antd.Layout className="app_layout">
<AppHeader />
<antd.Layout className="app_layout">
<AppHeader />
<antd.Layout.Content className="app_content">
<PageRender />
</antd.Layout.Content>
</antd.Layout>
</GlobalStateContext.Provider>
<antd.Layout.Content className="app_content">
<PageRender />
</antd.Layout.Content>
</antd.Layout>
</GlobalStateContext.Provider>
</InternalRouter>
</antd.ConfigProvider>
}
}

View File

@ -0,0 +1,19 @@
import React from "react"
import * as MDIcons from "react-icons/md"
import * as SIIcons from "react-icons/si"
export const Icons = {
...MDIcons,
...SIIcons,
}
export const Icon = ({ icon }) => {
if (icon && Icons[icon]) {
return React.createElement(Icons[icon])
}
return <></>
}
export default Icons

View File

@ -1,6 +1,6 @@
import React from "react"
import * as antd from "antd"
import { MdFolder, MdSettings, MdDownload } from "react-icons/md"
import { Icons } from "components/Icons"
import GlobalStateContext from "contexts/global"
@ -10,16 +10,24 @@ const Header = (props) => {
const ctx = React.useContext(GlobalStateContext)
return <antd.Layout.Header className="app_header">
<div className="branding">
<div className="branding" onClick={() => app.location.push("/")}>
<Icon />
</div>
{
!ctx.loading && <div className="menu">
{
ctx.authorizedServices?.drive && <Icons.SiGoogledrive
style={{
color: `var(--primary-color)`,
}}
/>
}
{
ctx.updateAvailable && <antd.Button
size="small"
icon={<MdDownload />}
icon={<Icons.MdDownload />}
onClick={app.applyUpdate}
>
Update now
@ -28,12 +36,13 @@ const Header = (props) => {
<antd.Button
size="small"
icon={<MdSettings />}
icon={<Icons.MdSettings />}
onClick={() => app.location.push("/settings")}
/>
<antd.Button
size="small"
icon={<MdFolder />}
icon={<Icons.MdFolder />}
onClick={() => ipc.send("open-runtime-path")}
/>

View File

@ -1,15 +1,178 @@
import React from "react"
import * as antd from "antd"
import { Icons, Icon } from "components/Icons"
import "./index.less"
const settingsList = [
{
id: "drive_auth",
name: "Google Drive Authorize",
description: "Authorize your Google Drive account to be used for bundles installation.",
icon: "SiGoogledrive",
type: "button",
value: async () => {
return api.settings.get("drive_auth")
},
render: (props) => {
return <antd.Button
type="primary"
onClick={() => {
if (!props.value) {
message.info("Authorizing...")
return ipc.exec("drive:authorize")
}
return api.settings.delete("drive_auth")
}}
>
{
props.value ? "Deauthorize" : "Authorize"
}
</antd.Button>
}
},
{
id: "check_update",
name: "Check for updates",
description: "Check for updates to the app.",
icon: "MdUpdate",
type: "button",
props: {
children: "Check",
onClick: () => {
message.info("Checking for updates...")
app.checkUpdates()
}
}
}
]
const Settings = () => {
const SettingTypeToComponent = {
switch: antd.Switch,
button: antd.Button,
}
const SettingItem = (props) => {
const {
id,
name,
description,
type,
icon,
props: _props,
render,
} = props.setting
const [loading, setLoading] = React.useState(false)
const [value, setValue] = React.useState(null)
React.useEffect(() => {
if (typeof props.setting.value === "function") {
setLoading(true)
props.setting.value().then((value) => {
setValue(value)
setLoading(false)
})
} else {
setLoading(false)
}
}, [props.setting.value])
let componentProps = {
value: value,
..._props,
}
async function handleChange(value) {
console.log(`Setting [${id}] set to >`, value)
setValue(value)
api.settings.set(id, value)
}
switch (type) {
case "switch": {
componentProps.defaultChecked = defaultProps.defaultChecked ?? false
componentProps.onChange = (e) => {
handleChange(e)
}
break
}
}
const Component = SettingTypeToComponent[type.toLowerCase()]
const Render = () => {
if (typeof render === "function") {
return render(componentProps)
}
return React.createElement(Component, componentProps)
}
return <div
className="app_settings-list-item"
>
<div className="app_settings-list-item-info">
<div className="app_settings-list-item-label">
<Icon icon={icon} />
<h2>
{name}
</h2>
</div>
<div className="app_settings-list-item-description">
<p>
{description}
</p>
</div>
</div>
<div className="app_settings-list-item-component">
{
loading && <antd.Spin />
}
{
!loading && <Render />
}
</div>
</div>
}
const Settings = () => {
return <div className="app_settings">
<div className="app_settings-header">
<div className="app_settings-header-back">
<Icons.MdChevronLeft
onClick={() => {
app.location.push("/")
}}
/>
Back
</div>
<div className="app_settings-header-title">
<Icons.MdSettings />
<h1>Settings</h1>
</div>
</div>
<div className="app_settings-list">
{
settingsList.map((setting, index) => {
return <SettingItem
key={index}
setting={setting}
/>
})
}
</div>
<div className="software_info">
</div>
</div>
}
export default Settings

View File

@ -0,0 +1,100 @@
@import "style/vars.less";
.app_settings {
display: flex;
flex-direction: column;
gap: 10px;
.app_settings-header {
display: flex;
flex-direction: column;
gap: 20px;
.app_settings-header-back {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
svg {
color: var(--primary-color);
border: 1px solid var(--primary-color);
border-radius: 100%;
cursor: pointer;
font-size: 1.5rem;
}
}
.app_settings-header-title {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
font-size: 1.5rem;
}
}
.app_settings-list {
display: flex;
flex-direction: column;
background-color: var(--background-color-secondary);
border-radius: 12px;
.app_settings-list-item {
display: flex;
flex-direction: row;
align-items: center;
&:nth-child(odd) {
background-color: mix(#fff, @var-background-color-secondary, 5%);
}
border-radius: 12px;
padding: 10px;
.app_settings-list-item-info {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
.app_settings-list-item-label {
display: flex;
flex-direction: row;
gap: 6px;
}
.app_settings-list-item-description {
display: inline-flex;
flex-direction: row;
font-size: 0.7rem;
}
}
.app_settings-list-item-component {
display: flex;
flex-direction: column;
justify-content: flex-end;
}
}
}
}

View File

@ -1,9 +1,63 @@
import React from "react"
import BarLoader from "react-spinners/BarLoader"
import { BrowserRouter, Route, Routes, useNavigate } from "react-router-dom"
import GlobalStateContext from "contexts/global"
import InstallationsManager from "pages/manager"
import PackagesMangerPage from "pages/manager"
import SettingsPage from "pages/settings"
const NavigationController = (props) => {
if (!app.location) {
app.location = Object()
}
const navigate = useNavigate()
async function setLocation(to, state = {}) {
// clean double slashes
to = to.replace(/\/{2,}/g, "/")
// if state is a number, it's a delay
if (typeof state !== "object") {
state = {}
}
app.location.last = window.location
return navigate(to, {
state
})
}
async function backLocation() {
app.location.last = window.location
if (transitionDuration >= 100) {
await new Promise((resolve) => setTimeout(resolve, transitionDuration))
}
return window.history.back()
}
React.useEffect(() => {
app.location = {
last: window.location,
push: setLocation,
back: backLocation,
}
}, [])
return props.children
}
export const InternalRouter = (props) => {
return <BrowserRouter>
<NavigationController>
{props.children}
</NavigationController>
</BrowserRouter>
}
export const PageRender = () => {
const globalState = React.useContext(GlobalStateContext)
@ -23,5 +77,8 @@ export const PageRender = () => {
</div>
}
return <InstallationsManager />
return <Routes>
<Route path="/" element={<PackagesMangerPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
}

View File

@ -1,11 +1,7 @@
@import "style/reset.css";
@import "style/fix.less";
@var-text-color: #fff;
@var-background-color-primary: #424549;
@var-background-color-secondary: #1e2124;
@var-primary-color: #36d7b7; //#F3B61F;
@var-border-color: #a1a2a2;
@import "style/vars.less";
:root {
--background-color-primary: @var-background-color-primary;

View File

@ -0,0 +1,5 @@
@var-text-color: #fff;
@var-background-color-primary: #424549;
@var-background-color-secondary: #1e2124;
@var-primary-color: #36d7b7; //#F3B61F;
@var-border-color: #a1a2a2;