feat: replace legacy Python CLI with GUI app

This commit is contained in:
Tickbase
2025-05-19 03:41:04 +02:00
parent e55f91a66d
commit 1e2cb52f6f
22 changed files with 118 additions and 222 deletions

2
.gitignore vendored
View File

@@ -10,11 +10,13 @@ lerna-debug.log*
node_modules
dist
dist-ssr
docs
*.local
*.lock
.env
CHANGELOG.md
scripts/prepare-release.js
scripts/update-server.js
# Editor directories and files
.vscode/*

View File

@@ -4,6 +4,17 @@ CreamLinux is a GUI application for Linux that simplifies the management of DLC
![Screenshot](./src/assets/screenshot.png)
## Beta Status
⚠️ **IMPORTANT**: CreamLinux is currently in BETA. This means:
- Some features may be incomplete or subject to change
- You might encounter bugs or unexpected behavior
- The application is under active development
- Your feedback and bug reports are invaluable
While the core functionality is working, please be aware that this is an early release. Im continuously working to improve stability, add features, and enhance the user experience. Please report any issues you encounter on [GitHub Issues page](https://github.com/Novattz/creamlinux-installer/issues).
## Features
- **Auto-discovery**: Automatically finds Steam games installed on your system
@@ -16,7 +27,7 @@ CreamLinux is a GUI application for Linux that simplifies the management of DLC
### AppImage (Recommended)
1. Download the latest `CreamLinux.AppImage` from the [Releases](https://github.com/novattz/creamlinux/releases) page
1. Download the latest `CreamLinux.AppImage` from the [Releases](https://github.com/Novattz/creamlinux-installer/releases) page
2. Make it executable:
```bash
chmod +x CreamLinux.AppImage
@@ -91,28 +102,12 @@ update-desktop-database ~/.local/share/applications
- **Game doesn't load**: Make sure the launch options are correctly set in Steam
- **DLCs not showing up**: Try refreshing the game list and reinstalling
- **Cannot find Steam**: Ensure Steam is installed and you've launched it at least once
- **Cannot find Steam**: Ensure Steam is installed and you've launched it at least once (Flatpak is not supported yet)
### Debug Logs
Logs are stored at: `~/.cache/creamlinux/creamlinux.log`
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
### Development Setup
1. Clone this repository
2. Install dependencies:
```bash
npm install
```
3. Start the development server:
```bash
npm run tauri dev
```
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE.md) file for details.

View File

@@ -1,19 +0,0 @@
{
"version": "0.1.13",
"notes": "Release version 0.1.13",
"pub_date": "2025-05-18T23:25:37.931Z",
"platforms": {
"linux-x86_64": {
"url": "https://github.com/novattz/rust-gui-dev/releases/download/v0.1.13/Creamlinux_0.1.13_amd64.AppImage",
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVUeFJ4alZDbGRMTEIxT2JMYS9QTDdSZUtIdWV1cGtvYlc5NnJrTUE1TndkdVAyS2xBbzJwbUQ1SVp1WWVtNWgwb01pU0ovcG1DOFVaQU03MjJZZzRyZUo5UThiUTJ3NEFJPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzQ3NjEwNzM3CWZpbGU6Q3JlYW1saW51eF8wLjEuMTNfYW1kNjQuQXBwSW1hZ2UKY2lHVlVObXBqcW4xTEFEL3EwQTdFSi8zVnF5cHJGcHFKQmx5VEZ3TXZmaUVMa0N2R2lmUVREa1gzZzJmL2dXNEluMDQxUFdpUGsya3hkUXkrbHV2QlE9PQo="
},
"linux-deb": {
"url": "https://github.com/novattz/rust-gui-dev/releases/download/v0.1.13/Creamlinux_0.1.13_amd64.deb",
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVUeFJ4alZDbGRMTEhGaXlpcExROGo5eXdPekE5NzhPNjFMbXFTUHVTRWtxRHFZTmI2T2xqMHNsM1JOZk5VOFNTYnhGTmhZUjBkaFoyVU1KYVRwa1R5dGF4NEp2ZnV4SGdFPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzQ3NjEwNzM3CWZpbGU6Q3JlYW1saW51eF8wLjEuMTNfYW1kNjQuZGViCmFnMzI1Q2gyamlMOTZPV1ZNQ2xXZzI5ekpOV3ZWQWI2OC9hQ1JpWUU4dTd3ZUNhdlRZWFZ6WVZ6RythNGt5YXl6UThLVll1K3doWDhubTFJS3NabEFnPT0K"
},
"linux-rpm": {
"url": "https://github.com/novattz/rust-gui-dev/releases/download/v0.1.13/Creamlinux-0.1.13-1.x86_64.rpm",
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVUeFJ4alZDbGRMTE9tNHJiQ1V6bFdINEYxTyt6UFN3OEpLR1NOZWJQZU9KekRyM3V0RmN5Wk1rRTVpejFtTXhlN25BR243UEpHVHRObkxZOEt3SXRINjhrQzdoNWZ5QkFVPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzQ3NjEwNzM3CWZpbGU6Q3JlYW1saW51eC0wLjEuMTMtMS54ODZfNjQucnBtClFscVNjYjNhTUk1T2NCZ3huZE00V3dRc2V0NDFXOUNjbElWMllUNHRLSnkwYmpGaFN4WGxWVWRxdVRxTDljdWd2dGs4Q2R5ZjFaeDVCV1hqTTl0YUJnPT0K"
}
}
}

View File

@@ -1,8 +1,11 @@
{
"name": "creamlinux",
"private": true,
"version": "0.1.13",
"version": "1.0.0",
"type": "module",
"author": "Tickbase",
"repository": "https://github.com/Novattz/creamlinux-installer",
"license": "MIT",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
@@ -47,4 +50,4 @@
"vite": "^6.3.1",
"vite-plugin-svgr": "^4.3.0"
}
}
}

View File

@@ -1,10 +1,10 @@
[package]
name = "app"
version = "0.1.13"
version = "1.0.0"
description = "DLC Manager for Steam games on Linux"
authors = ["tickbase"]
license = ""
repository = ""
license = "MIT"
repository = "https://github.com/Novattz/creamlinux-installer"
edition = "2021"
rust-version = "1.77.2"

View File

@@ -275,7 +275,7 @@ async fn fetch_game_dlcs(
})
.collect::<Vec<_>>();
// Cache in memory for this session (but not on disk)
// Cache in memory for this session
let state = app_handle.state::<AppState>();
let mut cache = state.dlc_cache.lock();
cache.insert(
@@ -323,7 +323,7 @@ async fn stream_game_dlcs(game_id: String, app_handle: tauri::AppHandle) -> Resu
game_id
);
// Convert to DLCInfoWithState for in-memory caching only
// Convert to DLCInfoWithState for in-memory caching
let dlcs_with_state = dlcs
.into_iter()
.map(|dlc| DlcInfoWithState {
@@ -333,7 +333,7 @@ async fn stream_game_dlcs(game_id: String, app_handle: tauri::AppHandle) -> Resu
})
.collect::<Vec<_>>();
// Update in-memory cache without storing to disk
// Update in-memory
let state = app_handle.state::<AppState>();
let mut dlc_cache = state.dlc_cache.lock();
dlc_cache.insert(

View File

@@ -558,7 +558,6 @@ pub async fn find_installed_games(steamapps_paths: &[PathBuf]) -> Vec<GameInfo>
// Every 10 files, yield to allow progress updates
if manifest_idx % 10 == 0 {
// We would update progress here in a full implementation
tokio::task::yield_now().await;
}
}

View File

@@ -11,22 +11,21 @@
"targets": "all",
"category": "Utility",
"createUpdaterArtifacts": true,
"icon": [
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.png"
]
"icon": ["icons/128x128.png", "icons/128x128@2x.png", "icons/icon.png"]
},
"productName": "Creamlinux",
"mainBinaryName": "creamlinux",
"version": "0.1.13",
"version": "1.0.0",
"identifier": "com.creamlinux.dev",
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDJDNEI1NzBBRDUxODQ3RjEKUldUeFJ4alZDbGRMTE5Vc241NG5yL080UklnaW1iUGdUWElPRXloRGtKZ3M2SWkzK0RGSDh3Q2kK",
"endpoints": [
"https://github.com/Novattz/rust-gui-dev/releases/latest/download/latest.json"
]
"https://github.com/Novattz/creamlinux-installer/releases/latest/download/latest.json"
],
"windows": {
"installMode": "passive"
}
}
},
"app": {
@@ -46,4 +45,4 @@
"csp": null
}
}
}
}

View File

@@ -1,5 +1,5 @@
import { useAppContext } from '@/contexts/useAppContext'
import { UpdateChecker } from '@/components/updater'
import { UpdateNotifier } from '@/components/updater'
import { useAppLogic } from '@/hooks'
import './styles/main.scss'
@@ -105,10 +105,12 @@ function App() {
onClose={handleDlcDialogClose}
onConfirm={handleDlcConfirm}
/>
<UpdateChecker />
{/* Simple update notifier that uses toast - no UI component */}
<UpdateNotifier />
</div>
</ErrorBoundary>
)
}
export default App
export default App

Binary file not shown.

Before

Width:  |  Height:  |  Size: 362 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -35,7 +35,7 @@ const ActionButton: FC<ActionButtonProps> = ({
return isInstalled ? `Uninstall ${product}` : `Install ${product}`
}
// Map to our button variant
// Map to button variant
const getButtonVariant = (): ButtonVariant => {
// For uninstall actions, use danger variant
if (isInstalled) return 'danger'

View File

@@ -4,17 +4,18 @@ export interface DialogHeaderProps {
children: ReactNode
className?: string
onClose?: () => void
hideCloseButton?: boolean;
}
/**
* Header component for dialogs
* Contains the title and optional close button
*/
const DialogHeader = ({ children, className = '', onClose }: DialogHeaderProps) => {
const DialogHeader = ({ children, className = '', onClose, hideCloseButton = false }: DialogHeaderProps) => {
return (
<div className={`dialog-header ${className}`}>
{children}
{onClose && (
{onClose && !hideCloseButton && (
<button className="dialog-close-button" onClick={onClose} aria-label="Close dialog">
×
</button>

View File

@@ -141,7 +141,7 @@ const DlcSelectionDialog = ({
return (
<Dialog visible={visible} onClose={onClose} size="large" preventBackdropClose={isLoading}>
<DialogHeader onClose={onClose}>
<DialogHeader onClose={onClose} hideCloseButton={true}>
<h3>{dialogTitle}</h3>
<div className="dlc-game-info">
<span className="game-title">{gameTitle}</span>

View File

@@ -123,7 +123,7 @@ const ProgressDialog = ({
size="medium"
preventBackdropClose={!isCloseButtonEnabled}
>
<DialogHeader>
<DialogHeader onClose={onClose} hideCloseButton={true}>
<h3>{title}</h3>
</DialogHeader>

View File

@@ -36,7 +36,7 @@ const AnimatedBackground = () => {
color: string
}
// Color palette matching our theme
// Color palette matching theme
const colors = [
'rgba(74, 118, 196, 0.5)', // primary blue
'rgba(155, 125, 255, 0.5)', // purple

View File

@@ -1,147 +0,0 @@
import { useState, useEffect } from 'react'
import { check, type Update, type DownloadEvent } from '@tauri-apps/plugin-updater'
import { relaunch } from '@tauri-apps/plugin-process'
import { Button } from '@/components/buttons'
/**
* React component that checks for updates and provides
* UI for downloading and installing them
*/
const UpdateChecker = () => {
const [updateAvailable, setUpdateAvailable] = useState(false)
const [updateInfo, setUpdateInfo] = useState<Update | null>(null)
const [isChecking, setIsChecking] = useState(false)
const [isDownloading, setIsDownloading] = useState(false)
const [downloadProgress, setDownloadProgress] = useState(0)
const [error, setError] = useState<string | null>(null)
// Check for updates on component mount
useEffect(() => {
checkForUpdates()
}, [])
const checkForUpdates = async () => {
try {
setIsChecking(true)
setError(null)
// Check for updates
const update = await check()
if (update) {
console.log(`Update available: ${update.version}`)
setUpdateAvailable(true)
setUpdateInfo(update)
} else {
console.log('No updates available')
setUpdateAvailable(false)
}
} catch (err) {
console.error('Failed to check for updates:', err)
setError(`Failed to check for updates: ${err instanceof Error ? err.message : String(err)}`)
} finally {
setIsChecking(false)
}
}
const downloadAndInstallUpdate = async () => {
if (!updateInfo) return
try {
setIsDownloading(true)
setError(null)
let downloaded = 0
let contentLength = 0
// Download and install update
await updateInfo.downloadAndInstall((event: DownloadEvent) => {
switch (event.event) {
case 'Started':
// Started event includes contentLength
if ('contentLength' in event.data && typeof event.data.contentLength === 'number') {
contentLength = event.data.contentLength
console.log(`Started downloading ${contentLength} bytes`)
}
break
case 'Progress':
// Progress event includes chunkLength
if ('chunkLength' in event.data && typeof event.data.chunkLength === 'number' && contentLength > 0) {
downloaded += event.data.chunkLength
const progress = (downloaded / contentLength) * 100
setDownloadProgress(progress)
console.log(`Downloaded ${downloaded} from ${contentLength}`)
}
break
case 'Finished':
console.log('Download finished')
break
}
})
console.log('Update installed, relaunching application')
await relaunch()
} catch (err) {
console.error('Failed to download and install update:', err)
setError(`Failed to download and install update: ${err instanceof Error ? err.message : String(err)}`)
setIsDownloading(false)
}
}
if (isChecking) {
return <div className="update-checker">Checking for updates...</div>
}
if (error) {
return (
<div className="update-checker error">
<p>{error}</p>
<Button variant="primary" onClick={checkForUpdates}>Try Again</Button>
</div>
)
}
if (!updateAvailable || !updateInfo) {
return null // Don't show anything if there's no update
}
return (
<div className="update-checker">
<div className="update-info">
<h3>Update Available</h3>
<p>Version {updateInfo.version} is available to download.</p>
{updateInfo.body && <p className="update-notes">{updateInfo.body}</p>}
</div>
{isDownloading ? (
<div className="update-progress">
<div className="progress-bar-container">
<div
className="progress-bar"
style={{ width: `${downloadProgress}%` }}
/>
</div>
<p>Downloading: {Math.round(downloadProgress)}%</p>
</div>
) : (
<div className="update-actions">
<Button
variant="primary"
onClick={downloadAndInstallUpdate}
disabled={isDownloading}
>
Download & Install
</Button>
<Button
variant="secondary"
onClick={() => setUpdateAvailable(false)}
>
Later
</Button>
</div>
)}
</div>
)
}
export default UpdateChecker

View File

@@ -0,0 +1,14 @@
import { useUpdateChecker } from '@/hooks/useUpdateChecker'
/**
* Simple component that uses the update checker hook
* Can be dropped in anywhere in the app
*/
const UpdateNotifier = () => {
useUpdateChecker()
// This component doesn't render anything
return null
}
export default UpdateNotifier

View File

@@ -1 +1,5 @@
export { default as UpdateChecker } from './UpdateChecker'
// Update checker implementation
export { default as useUpdateChecker } from '@/hooks/useUpdateChecker'
// Simple component for using the checker
export { default as UpdateNotifier } from './UpdateNotifier'

View File

@@ -100,7 +100,7 @@ export const AppProvider = ({ children }: AppProviderProps) => {
const handleDlcConfirm = (selectedDlcs: DlcInfo[]) => {
const { gameId, isEditMode } = dlcDialog
// MODIFIED: Create a deep copy to ensure we don't have reference issues
// Create a deep copy to ensure we don't have reference issues
const dlcsCopy = selectedDlcs.map((dlc) => ({ ...dlc }))
// Log detailed info before closing dialog

View File

@@ -25,7 +25,7 @@ export function useDlcManager() {
const [isFetchingDlcs, setIsFetchingDlcs] = useState(false)
const dlcFetchController = useRef<AbortController | null>(null)
const activeDlcFetchId = useRef<string | null>(null)
const [forceReload, setForceReload] = useState(false) // Add this state to force reloads
const [forceReload, setForceReload] = useState(false)
// DLC selection dialog state
const [dlcDialog, setDlcDialog] = useState<DlcDialogState>({
@@ -156,7 +156,7 @@ export function useDlcManager() {
}
}
// MODIFIED: Handle game edit (show DLC management dialog) with proper reloading
// Handle game edit (show DLC management dialog) with proper reloading
const handleGameEdit = async (gameId: string, games: Game[]) => {
const game = games.find((g) => g.id === gameId)
if (!game || !game.cream_installed) return
@@ -173,17 +173,17 @@ export function useDlcManager() {
visible: true,
gameId,
gameTitle: game.title,
dlcs: [], // Always start with empty DLCs to force a fresh load
dlcs: [],
enabledDlcs: [],
isLoading: true,
isEditMode: true, // This is an edit operation
isEditMode: true,
progress: 0,
progressMessage: 'Reading DLC configuration...',
timeLeft: '',
error: null,
})
// MODIFIED: Always get a fresh copy from the config file
// Always get a fresh copy from the config file
console.log('Loading DLC configuration from disk...')
try {
const allDlcs = await invoke<DlcInfo[]>('get_all_dlcs_command', {
@@ -197,7 +197,7 @@ export function useDlcManager() {
// Log the fresh DLC config
console.log('Loaded existing DLC configuration:', allDlcs)
// IMPORTANT: Create a completely new array to avoid reference issues
// Create a completely new array to avoid reference issues
const freshDlcs = allDlcs.map((dlc) => ({ ...dlc }))
setDlcDialog((prev) => ({
@@ -256,7 +256,7 @@ export function useDlcManager() {
}
}
// MODIFIED: Handle DLC selection dialog close
// Handle DLC selection dialog close
const handleDlcDialogClose = () => {
// Cancel any in-progress DLC fetching
if (isFetchingDlcs && activeDlcFetchId.current) {

View File

@@ -150,7 +150,7 @@ export function useGameActions() {
try {
if (isEditMode) {
// MODIFIED: Create a deep copy to ensure we don't have reference issues
// Create a deep copy to ensure we don't have reference issues
const dlcsCopy = selectedDlcs.map((dlc) => ({ ...dlc }))
// Show progress dialog for editing
@@ -201,7 +201,7 @@ export function useGameActions() {
selectedDlcs,
})
// Note: The progress dialog will be updated through the installation-progress event listener
// The progress dialog will be updated through the installation-progress event listener
}
} catch (error) {
console.error('Error processing DLC selection:', error)

View File

@@ -0,0 +1,43 @@
import { useEffect } from 'react'
import { check } from '@tauri-apps/plugin-updater'
import { useToasts } from '@/hooks'
/**
* Hook that silently checks for updates and shows a toast notification if an update is available
*/
export function useUpdateChecker() {
const { success, error } = useToasts()
useEffect(() => {
// Check for updates on component mount
const checkForUpdates = async () => {
try {
// Check for updates
const update = await check()
// If update is available, show a toast notification
if (update) {
console.log(`Update available: ${update.version}`)
success(`Update v${update.version} available! Check GitHub for details.`, {
duration: 8000 // Show for 8 seconds
})
}
} catch (err) {
// Log error but don't show to user
console.error('Update check failed:', err)
}
}
// Small delay to avoid interfering with app startup
const timer = setTimeout(() => {
checkForUpdates()
}, 3000)
return () => clearTimeout(timer)
}, [success, error])
// This hook doesn't return anything
return null
}
export default useUpdateChecker