diff --git a/bun.lockb b/bun.lockb index 0055699..698a5d6 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index cd54fcb..d0641dc 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,9 @@ "@mui/icons-material": "^6.3.1", "@mui/material": "^6.3.1", "@tailwindcss/typography": "^0.5.16", + "axios": "^1.7.9", "electron-updater": "^6.3.9", + "glob": "^11.0.1", "react-router": "^7.1.1", "rehype-sanitize": "^6.0.0", "rehype-stringify": "^10.0.1", @@ -37,7 +39,8 @@ "remark-parse": "^11.0.0", "remark-rehype": "^11.1.1", "solar-js-sdk": "^0.1.2", - "unified": "^11.0.5" + "unified": "^11.0.5", + "unzipper": "^0.12.3" }, "devDependencies": { "@electron-toolkit/eslint-config-prettier": "^2.0.0", @@ -46,6 +49,7 @@ "@types/node": "^20.17.12", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", + "@types/unzipper": "^0.10.10", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", "electron": "^31.7.6", diff --git a/src/main/downloader.ts b/src/main/downloader.ts new file mode 100644 index 0000000..3b47401 --- /dev/null +++ b/src/main/downloader.ts @@ -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 { + // 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 { + 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')) + } + } +} diff --git a/src/main/index.ts b/src/main/index.ts index 7810e5f..c238341 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,6 +1,10 @@ -import { app, shell, BrowserWindow, ipcMain } from 'electron' +import { app, shell, BrowserWindow, ipcMain, dialog } from 'electron' import { join } from 'path' 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' function createWindow(): void { @@ -53,8 +57,15 @@ app.whenReady().then(() => { optimizer.watchWindowShortcuts(window) }) - // IPC test - ipcMain.on('ping', () => console.log('pong')) + ipcMain.handle('show-path-select', async () => { + const result = await dialog.showOpenDialog({ properties: ['openDirectory', 'createDirectory'] }) + if (result.canceled) return null + return result.filePaths[0] + }) + + initTasks() + initLibrary() + initInstaller() createWindow() diff --git a/src/main/installer.ts b/src/main/installer.ts new file mode 100644 index 0000000..ada0379 --- /dev/null +++ b/src/main/installer.ts @@ -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 { + 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) + } + } + } + } +} diff --git a/src/main/library.ts b/src/main/library.ts new file mode 100644 index 0000000..a3666c7 --- /dev/null +++ b/src/main/library.ts @@ -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() +} diff --git a/src/main/tasks.ts b/src/main/tasks.ts new file mode 100644 index 0000000..99a5f36 --- /dev/null +++ b/src/main/tasks.ts @@ -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 = {} + +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) +} diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index a153669..459a23a 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -4,5 +4,6 @@ declare global { interface Window { electron: ElectronAPI api: unknown + platform: NodeJS.Platform } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 2d18524..6ffea6e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -11,6 +11,7 @@ if (process.contextIsolated) { try { contextBridge.exposeInMainWorld('electron', electronAPI) contextBridge.exposeInMainWorld('api', api) + contextBridge.exposeInMainWorld('platform', process.platform) } catch (error) { console.error(error) } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 140825e..a210ca3 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -9,8 +9,6 @@ import { useEffect } from 'react' import ProductDetails from './pages/products/Details' function App(): JSX.Element { - // const ipcHandle = (): void => window.electron.ipcRenderer.send('ping') - const userStore = useUserStore() useEffect(() => { diff --git a/src/renderer/src/components/MaInstallDialog.tsx b/src/renderer/src/components/MaInstallDialog.tsx new file mode 100644 index 0000000..9d413ce --- /dev/null +++ b/src/renderer/src/components/MaInstallDialog.tsx @@ -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() + + const [installPath, setInstallPath] = useState() + + async function parseContent(content: string): Promise { + 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 ( + + Install {release?.version} + + + {release?.meta.title} + + + {release?.meta.description} + + + + + + + + Supported Platforms + + + {Object.keys(release?.assets ?? {}).join(', ')} + + + + + + {!checkPlatformAvailable(window.platform) && ( + + This release is not supported on your current platform. + + )} + + + + + ) +} diff --git a/src/renderer/src/pages/products/Details.tsx b/src/renderer/src/pages/products/Details.tsx index a2d8e19..10ab2b6 100644 --- a/src/renderer/src/pages/products/Details.tsx +++ b/src/renderer/src/pages/products/Details.tsx @@ -5,9 +5,6 @@ import { Card, CardContent, Container, - Dialog, - DialogContent, - DialogTitle, FormControl, Grid2 as Grid, InputLabel, @@ -17,7 +14,8 @@ import { } from '@mui/material' import { useEffect, useState } from 'react' 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 rehypeSanitize from 'rehype-sanitize' import rehypeStringify from 'rehype-stringify' @@ -59,7 +57,6 @@ export default function ProductDetails(): JSX.Element { const [releases, setReleases] = useState() const [selectedRelease, setSelectedRelease] = useState() - const [selectedReleaseContent, setSelectedReleaseContent] = useState() const [showInstaller, setShowInstaller] = useState(false) @@ -69,6 +66,7 @@ export default function ProductDetails(): JSX.Element { take: 10, }, }) + setReleases(resp.data) } @@ -81,11 +79,6 @@ export default function ProductDetails(): JSX.Element { parseContent(product.meta.introduction).then((out) => setContent(out)) }, [product?.meta?.introduction]) - useEffect(() => { - if (selectedRelease == null) return - parseContent(selectedRelease.meta.content).then((out) => setSelectedReleaseContent(out)) - }, [selectedRelease]) - return ( <> {product?.previews && ( @@ -109,7 +102,7 @@ export default function ProductDetails(): JSX.Element { {product?.icon && ( )} @@ -160,6 +153,7 @@ export default function ProductDetails(): JSX.Element { fullWidth sx={{ mt: 2 }} startIcon={} + disabled={selectedRelease == null} onClick={() => { if (selectedRelease == null) return setShowInstaller(true) @@ -173,26 +167,7 @@ export default function ProductDetails(): JSX.Element { - setShowInstaller(false)} maxWidth="sm" fullWidth> - Install v{selectedRelease?.version} - - - {selectedRelease?.meta.title} - - - {selectedRelease?.meta.description} - - - - - - - + setShowInstaller(false)} /> ) }