Core installer & downloader

This commit is contained in:
LittleSheep 2025-01-11 19:48:11 +08:00
parent 3e9a784ab8
commit 3c191d3052
12 changed files with 424 additions and 37 deletions

BIN
bun.lockb

Binary file not shown.

View File

@ -29,7 +29,9 @@
"@mui/icons-material": "^6.3.1", "@mui/icons-material": "^6.3.1",
"@mui/material": "^6.3.1", "@mui/material": "^6.3.1",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"axios": "^1.7.9",
"electron-updater": "^6.3.9", "electron-updater": "^6.3.9",
"glob": "^11.0.1",
"react-router": "^7.1.1", "react-router": "^7.1.1",
"rehype-sanitize": "^6.0.0", "rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.1", "rehype-stringify": "^10.0.1",
@ -37,7 +39,8 @@
"remark-parse": "^11.0.0", "remark-parse": "^11.0.0",
"remark-rehype": "^11.1.1", "remark-rehype": "^11.1.1",
"solar-js-sdk": "^0.1.2", "solar-js-sdk": "^0.1.2",
"unified": "^11.0.5" "unified": "^11.0.5",
"unzipper": "^0.12.3"
}, },
"devDependencies": { "devDependencies": {
"@electron-toolkit/eslint-config-prettier": "^2.0.0", "@electron-toolkit/eslint-config-prettier": "^2.0.0",
@ -46,6 +49,7 @@
"@types/node": "^20.17.12", "@types/node": "^20.17.12",
"@types/react": "^18.3.18", "@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5", "@types/react-dom": "^18.3.5",
"@types/unzipper": "^0.10.10",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"electron": "^31.7.6", "electron": "^31.7.6",

78
src/main/downloader.ts Normal file
View File

@ -0,0 +1,78 @@
import * as fs from 'fs'
import * as path from 'path'
import axios from 'axios'
import unzipper from 'unzipper'
import { InstallProgressPeriod, InstallTask, updateInstallTask } from './tasks'
import { randomUUID } from 'crypto'
function downloadFile(task: InstallTask, url: string, output: string): Promise<void> {
// eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve, reject) => {
const writer = fs.createWriteStream(output)
const res = await axios.get(url as string, {
responseType: 'stream',
})
const len = parseInt(res.headers['content-length'], 10)
let downloaded = 0
res.data.on('data', (chunk: any) => {
downloaded += chunk.length
const percent = downloaded / len
if (!task.progress) return
task.progress.value = percent
task.progress.period = InstallProgressPeriod.Downloading
task.progress.details = `${downloaded}/${len}`
updateInstallTask(task)
})
res.data.pipe(writer)
writer.on('finish', resolve)
writer.on('error', reject)
})
}
function convertAssetUri(uri: string): string {
if (uri.startsWith('attachments://')) {
return `https://api.sn.solsynth.dev/cgi/uc/attachments/${uri.replace('attachments://', '')}`
}
if (uri.startsWith('http')) {
return uri
}
throw new Error('Unable handle assets uri')
}
export async function downloadAssets(task: InstallTask): Promise<void> {
const platform = process.platform
const asset = task.release.assets[platform]
if (!asset) return
const output = path.join(task.basePath, randomUUID())
await downloadFile(task, convertAssetUri(asset.uri), output)
const requiresExtractTypes = ['application/zip']
if (requiresExtractTypes.includes(asset.contentType)) {
if (task.progress) {
task.progress.period = InstallProgressPeriod.Extracting
task.progress.details = null
task.progress.value = null
updateInstallTask(task)
}
const stream = fs.createReadStream(output)
switch (asset.contentType) {
case 'application/zip':
stream.on('close', () => fs.unlinkSync(output))
stream.pipe(unzipper.Extract({ path: path.dirname(output) }))
break
default:
console.error(new Error('Failed to decompress, unsupported type'))
}
}
}

View File

@ -1,6 +1,10 @@
import { app, shell, BrowserWindow, ipcMain } from 'electron' import { app, shell, BrowserWindow, ipcMain, dialog } from 'electron'
import { join } from 'path' import { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils' import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import { initInstaller } from './installer'
import { initTasks } from './tasks'
import { initLibrary } from './library'
import icon from '../../resources/icon.png?asset' import icon from '../../resources/icon.png?asset'
function createWindow(): void { function createWindow(): void {
@ -53,8 +57,15 @@ app.whenReady().then(() => {
optimizer.watchWindowShortcuts(window) optimizer.watchWindowShortcuts(window)
}) })
// IPC test ipcMain.handle('show-path-select', async () => {
ipcMain.on('ping', () => console.log('pong')) const result = await dialog.showOpenDialog({ properties: ['openDirectory', 'createDirectory'] })
if (result.canceled) return null
return result.filePaths[0]
})
initTasks()
initLibrary()
initInstaller()
createWindow() createWindow()

103
src/main/installer.ts Normal file
View File

@ -0,0 +1,103 @@
import { randomUUID } from 'crypto'
import { exec } from 'child_process'
import { join } from 'path'
import { ipcMain, WebContents } from 'electron'
import { glob } from 'glob'
import { unlinkSync } from 'fs'
import { installTasks, InstallTask, updateInstallTask, InstallProgressPeriod } from './tasks'
import { downloadAssets } from './downloader'
import { setAppRecord } from './library'
export function initInstaller(): void {
ipcMain.handle('install-product-release', (evt, task: InstallTask) => submitInstallTask(task, evt.sender))
}
function submitInstallTask(task: InstallTask, contents: WebContents): void {
task.id = randomUUID()
task.progress = { value: 0, period: InstallProgressPeriod.Initializing, done: false, error: null }
task.emitter = contents
installTasks[task.id] = task
updateInstallTask(task)
downloadAssets(task)
.then(async () => {
try {
await runInstaller(task)
if (task.progress) {
task.progress.period = InstallProgressPeriod.Indexing
task.progress.details = null
task.progress.value = null
updateInstallTask(task)
}
setAppRecord({
id: task.id ?? randomUUID(),
product: task.product,
release: task.release,
basePath: task.basePath,
})
} catch (err: any) {
if (task.progress) {
task.progress.error = err.toString()
}
} finally {
if (task.progress) {
task.progress.period = InstallProgressPeriod.Completed
task.progress.details = null
task.progress.value = 1
task.progress.done = true
updateInstallTask(task)
}
}
})
.catch((err: any) => {
if (task.progress) {
task.progress.error = err.toString()
task.progress.done = true
updateInstallTask(task)
}
})
}
async function runInstaller(task: InstallTask): Promise<void> {
const platform = process.platform
const installer = task.release.installers[platform]
if (!installer) return
if (task.progress) {
task.progress.period = InstallProgressPeriod.Installing
task.progress.details = null
task.progress.value = null
updateInstallTask(task)
}
if (installer.script) {
if (task.progress) {
task.progress.details = 'Running installer scripts...'
updateInstallTask(task)
}
exec(installer.script, {
cwd: installer.workdir ? join(task.basePath, installer.workdir) : task.basePath,
})
}
if (installer.patches) {
if (task.progress) {
task.progress.details = 'Applying patches...'
updateInstallTask(task)
}
for (const patch of installer.patches) {
switch (patch.action) {
case 'rm':
for (const file of await glob(join(task.basePath, patch.glob), {
signal: AbortSignal.timeout(1000 * 3),
})) {
unlinkSync(file)
}
}
}
}
}

62
src/main/library.ts Normal file
View File

@ -0,0 +1,62 @@
import { ipcMain, app } from 'electron'
import { MaProduct, MaRelease } from 'solar-js-sdk'
import { join } from 'path'
import * as fs from 'fs'
export interface AppLibrary {
apps: LocalAppRecord[]
}
export let library: AppLibrary
function readLocalLibrary(): AppLibrary {
const basePath = app.getPath('userData')
const libraryPath = join(basePath, 'apps_library.json')
if (fs.existsSync(libraryPath)) {
const data = fs.readFileSync(libraryPath, 'utf-8')
library = JSON.parse(data)
} else {
library = { apps: [] }
saveLocalLibrary()
}
return library
}
function saveLocalLibrary() {
const basePath = app.getPath('userData')
const libraryPath = join(basePath, 'apps_library.json')
fs.writeFileSync(libraryPath, JSON.stringify(library), 'utf-8')
}
export interface LocalAppRecord {
id: string
product: MaProduct
release: MaRelease
basePath: string
}
export function initLibrary(): void {
readLocalLibrary()
ipcMain.handle('get-app-library', () => getAppLibrary())
}
export function getAppLibrary(): LocalAppRecord[] {
return library.apps
}
export function setAppRecord(record: LocalAppRecord) {
let current = getAppLibrary()
let hit = false
current = current.map((rec) => {
if (rec.id === record.id) hit = true
return rec.id === record.id ? record : rec
})
if (!hit) {
current.push(record)
}
library.apps = current
saveLocalLibrary()
}

40
src/main/tasks.ts Normal file
View File

@ -0,0 +1,40 @@
import { ipcMain, WebContents } from 'electron'
import { MaProduct, MaRelease } from 'solar-js-sdk'
export interface InstallTask {
id?: string
product: MaProduct
release: MaRelease
basePath: string
progress?: InstallProgress
emitter?: WebContents
}
export enum InstallProgressPeriod {
Initializing = 0,
Downloading = 1,
Extracting = 2,
Installing = 3,
Indexing = 4,
Completed = 5,
}
export interface InstallProgress {
value: number | null
period: number
details?: string | null
error: string | null
done: boolean
}
export const installTasks: Record<string, InstallTask> = {}
export function initTasks() {
ipcMain.handle('get-install-tasks', () => Object.values(installTasks))
}
export function updateInstallTask(task: InstallTask): void {
if (!task.emitter) return
if (task.id) installTasks[task.id] = task
task.emitter.send('update:install-task', task)
}

View File

@ -4,5 +4,6 @@ declare global {
interface Window { interface Window {
electron: ElectronAPI electron: ElectronAPI
api: unknown api: unknown
platform: NodeJS.Platform
} }
} }

View File

@ -11,6 +11,7 @@ if (process.contextIsolated) {
try { try {
contextBridge.exposeInMainWorld('electron', electronAPI) contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('api', api) contextBridge.exposeInMainWorld('api', api)
contextBridge.exposeInMainWorld('platform', process.platform)
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} }

View File

@ -9,8 +9,6 @@ import { useEffect } from 'react'
import ProductDetails from './pages/products/Details' import ProductDetails from './pages/products/Details'
function App(): JSX.Element { function App(): JSX.Element {
// const ipcHandle = (): void => window.electron.ipcRenderer.send('ping')
const userStore = useUserStore() const userStore = useUserStore()
useEffect(() => { useEffect(() => {

View File

@ -0,0 +1,114 @@
import {
Dialog,
DialogTitle,
DialogContent,
Typography,
Card,
Box,
DialogActions,
Button,
TextField,
} from '@mui/material'
import { useEffect, useState } from 'react'
import { MaRelease } from 'solar-js-sdk'
import { unified } from 'unified'
import rehypeSanitize from 'rehype-sanitize'
import rehypeStringify from 'rehype-stringify'
import remarkGfm from 'remark-gfm'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
export function MaInstallDialog({
release,
open,
onClose,
}: {
release?: MaRelease
open: boolean
onClose: () => void
}): JSX.Element {
const [content, setContent] = useState<string>()
const [installPath, setInstallPath] = useState<string>()
async function parseContent(content: string): Promise<string> {
content = content.replace(
/!\[.*?\]\(solink:\/\/attachments\/([\w-]+)\)/g,
'![alt](https://api.sn.solsynth.dev/cgi/uc/attachments/$1)',
)
const out = await unified()
.use(remarkParse)
.use(remarkRehype)
.use(remarkGfm)
.use(rehypeSanitize)
.use(rehypeStringify)
.process(content)
return String(out)
}
useEffect(() => {
if (release == null) return
parseContent(release.meta.content).then((out) => setContent(out))
}, [release])
function checkPlatformAvailable(platform: string): boolean {
return Object.keys(release?.assets ?? {}).includes(platform)
}
function selectInstallPath() {
window.electron.ipcRenderer.invoke('show-path-select').then((out: string) => setInstallPath(out))
}
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>Install {release?.version}</DialogTitle>
<DialogContent>
<Typography variant="subtitle1" fontWeight="bold" component="h2">
{release?.meta.title}
</Typography>
<Typography variant="body1" component="p" gutterBottom>
{release?.meta.description}
</Typography>
<Card variant="outlined" sx={{ my: 2, maxHeight: '280px', overflowY: 'auto' }}>
<Box
component="article"
className="prose prose-md dark:prose-invert"
sx={{ p: 2 }}
dangerouslySetInnerHTML={{ __html: content ?? '' }}
/>
</Card>
<Typography variant="subtitle1" fontWeight="bold" component="h2">
Supported Platforms
</Typography>
<Typography variant="body1" component="p" gutterBottom fontFamily="monospace" fontSize={13}>
{Object.keys(release?.assets ?? {}).join(', ')}
</Typography>
<TextField
placeholder="Install Path"
size="small"
value={installPath}
onClick={selectInstallPath}
slotProps={{ htmlInput: { readOnly: true } }}
sx={{ mt: 2 }}
fullWidth
/>
</DialogContent>
<DialogActions>
{!checkPlatformAvailable(window.platform) && (
<Typography variant="caption" component="p" sx={{ flexGrow: 1, px: 2, opacity: 0.75 }}>
This release is not supported on your current platform.
</Typography>
)}
<Button onClick={onClose}>Cancel</Button>
<Button autoFocus disabled={!checkPlatformAvailable(window.platform) || !installPath}>
Install
</Button>
</DialogActions>
</Dialog>
)
}

View File

@ -5,9 +5,6 @@ import {
Card, Card,
CardContent, CardContent,
Container, Container,
Dialog,
DialogContent,
DialogTitle,
FormControl, FormControl,
Grid2 as Grid, Grid2 as Grid,
InputLabel, InputLabel,
@ -17,7 +14,8 @@ import {
} from '@mui/material' } from '@mui/material'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router' import { useNavigate, useParams } from 'react-router'
import { MaProduct, MaRelease, sni } from 'solar-js-sdk' import { MaInstallDialog } from '@renderer/components/MaInstallDialog'
import { getAttachmentUrl, MaProduct, MaRelease, sni } from 'solar-js-sdk'
import { unified } from 'unified' import { unified } from 'unified'
import rehypeSanitize from 'rehype-sanitize' import rehypeSanitize from 'rehype-sanitize'
import rehypeStringify from 'rehype-stringify' import rehypeStringify from 'rehype-stringify'
@ -59,7 +57,6 @@ export default function ProductDetails(): JSX.Element {
const [releases, setReleases] = useState<MaRelease[]>() const [releases, setReleases] = useState<MaRelease[]>()
const [selectedRelease, setSelectedRelease] = useState<MaRelease>() const [selectedRelease, setSelectedRelease] = useState<MaRelease>()
const [selectedReleaseContent, setSelectedReleaseContent] = useState<string>()
const [showInstaller, setShowInstaller] = useState(false) const [showInstaller, setShowInstaller] = useState(false)
@ -69,6 +66,7 @@ export default function ProductDetails(): JSX.Element {
take: 10, take: 10,
}, },
}) })
setReleases(resp.data) setReleases(resp.data)
} }
@ -81,11 +79,6 @@ export default function ProductDetails(): JSX.Element {
parseContent(product.meta.introduction).then((out) => setContent(out)) parseContent(product.meta.introduction).then((out) => setContent(out))
}, [product?.meta?.introduction]) }, [product?.meta?.introduction])
useEffect(() => {
if (selectedRelease == null) return
parseContent(selectedRelease.meta.content).then((out) => setSelectedReleaseContent(out))
}, [selectedRelease])
return ( return (
<> <>
{product?.previews && ( {product?.previews && (
@ -109,7 +102,7 @@ export default function ProductDetails(): JSX.Element {
{product?.icon && ( {product?.icon && (
<Avatar <Avatar
variant="rounded" variant="rounded"
src={product.icon} src={getAttachmentUrl(product.icon)}
sx={{ width: 64, height: 64, border: 1, borderColor: 'divider', borderRadius: 4, mb: 1, mx: '-4px' }} sx={{ width: 64, height: 64, border: 1, borderColor: 'divider', borderRadius: 4, mb: 1, mx: '-4px' }}
/> />
)} )}
@ -160,6 +153,7 @@ export default function ProductDetails(): JSX.Element {
fullWidth fullWidth
sx={{ mt: 2 }} sx={{ mt: 2 }}
startIcon={<DownloadIcon />} startIcon={<DownloadIcon />}
disabled={selectedRelease == null}
onClick={() => { onClick={() => {
if (selectedRelease == null) return if (selectedRelease == null) return
setShowInstaller(true) setShowInstaller(true)
@ -173,26 +167,7 @@ export default function ProductDetails(): JSX.Element {
</Grid> </Grid>
</Container> </Container>
<Dialog open={showInstaller} onClose={() => setShowInstaller(false)} maxWidth="sm" fullWidth> <MaInstallDialog release={selectedRelease} open={showInstaller} onClose={() => setShowInstaller(false)} />
<DialogTitle>Install v{selectedRelease?.version}</DialogTitle>
<DialogContent>
<Typography variant="subtitle1" fontWeight="bold" component="h2">
{selectedRelease?.meta.title}
</Typography>
<Typography variant="body1" component="p" gutterBottom>
{selectedRelease?.meta.description}
</Typography>
<Card variant="outlined" sx={{ my: 2, maxHeight: '360px', overflowY: 'auto' }}>
<Box
component="article"
className="prose prose-md dark:prose-invert"
sx={{ p: 2 }}
dangerouslySetInnerHTML={{ __html: selectedReleaseContent ?? '' }}
/>
</Card>
</DialogContent>
</Dialog>
</> </>
) )
} }