✨ Core installer & downloader
This commit is contained in:
		| @@ -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", | ||||
|   | ||||
							
								
								
									
										78
									
								
								src/main/downloader.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								src/main/downloader.ts
									
									
									
									
									
										Normal 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')) | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -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() | ||||
|  | ||||
|   | ||||
							
								
								
									
										103
									
								
								src/main/installer.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										103
									
								
								src/main/installer.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										62
									
								
								src/main/library.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										40
									
								
								src/main/tasks.ts
									
									
									
									
									
										Normal 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) | ||||
| } | ||||
							
								
								
									
										1
									
								
								src/preload/index.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								src/preload/index.d.ts
									
									
									
									
										vendored
									
									
								
							| @@ -4,5 +4,6 @@ declare global { | ||||
|   interface Window { | ||||
|     electron: ElectronAPI | ||||
|     api: unknown | ||||
|     platform: NodeJS.Platform | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -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) | ||||
|   } | ||||
|   | ||||
| @@ -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(() => { | ||||
|   | ||||
							
								
								
									
										114
									
								
								src/renderer/src/components/MaInstallDialog.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										114
									
								
								src/renderer/src/components/MaInstallDialog.tsx
									
									
									
									
									
										Normal 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, | ||||
|       '', | ||||
|     ) | ||||
|  | ||||
|     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> | ||||
|   ) | ||||
| } | ||||
| @@ -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<MaRelease[]>() | ||||
|   const [selectedRelease, setSelectedRelease] = useState<MaRelease>() | ||||
|   const [selectedReleaseContent, setSelectedReleaseContent] = useState<string>() | ||||
|  | ||||
|   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 && ( | ||||
|                 <Avatar | ||||
|                   variant="rounded" | ||||
|                   src={product.icon} | ||||
|                   src={getAttachmentUrl(product.icon)} | ||||
|                   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 | ||||
|                   sx={{ mt: 2 }} | ||||
|                   startIcon={<DownloadIcon />} | ||||
|                   disabled={selectedRelease == null} | ||||
|                   onClick={() => { | ||||
|                     if (selectedRelease == null) return | ||||
|                     setShowInstaller(true) | ||||
| @@ -173,26 +167,7 @@ export default function ProductDetails(): JSX.Element { | ||||
|         </Grid> | ||||
|       </Container> | ||||
|  | ||||
|       <Dialog open={showInstaller} onClose={() => setShowInstaller(false)} maxWidth="sm" fullWidth> | ||||
|         <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> | ||||
|       <MaInstallDialog release={selectedRelease} open={showInstaller} onClose={() => setShowInstaller(false)} /> | ||||
|     </> | ||||
|   ) | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user