8 Commits

Author SHA1 Message Date
Novattz
1b8fdadbf2 version bump 2026-03-13 14:54:52 +00:00
Novattz
ecd7b4dceb changelog 2026-03-13 14:54:15 +00:00
Novattz
640eb9a0d5 add types node to tsconfig 2026-03-13 14:51:50 +00:00
Novattz
b42086ca27 Manually add DLC dialog #99 2026-03-13 14:51:33 +00:00
Novattz
b9beb0d704 Fix steam api being nested too deep #100 2026-03-13 14:26:46 +00:00
Novattz
09e7bcac6f bump version 2026-01-18 09:43:08 +01:00
Novattz
b7f219a25f changelog 2026-01-18 09:43:02 +01:00
Novattz
2b205d8376 reduce time to detect game bitness 2026-01-18 09:42:58 +01:00
12 changed files with 361 additions and 134 deletions

View File

@@ -1,3 +1,16 @@
## [1.4.2] - 13-03-2026
### Added
- Added a dialog so users can manually add DLC's incase they are missing from the steam api
### Fixed
- Fixed an issue where if the libsteam_api.so file is nested too deeply in a game causing the app to not find it.
## [1.4.1] - 18-01-2026
### Added
- Dramatically reduced the time that bitness detection takes to detect game bitness
## [1.4.0] - 17-01-2026 ## [1.4.0] - 17-01-2026
### Added ### Added

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "creamlinux", "name": "creamlinux",
"version": "1.4.0", "version": "1.4.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "creamlinux", "name": "creamlinux",
"version": "1.4.0", "version": "1.4.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.5.0", "@tauri-apps/api": "^2.5.0",

View File

@@ -1,7 +1,7 @@
{ {
"name": "creamlinux", "name": "creamlinux",
"private": true, "private": true,
"version": "1.4.0", "version": "1.4.2",
"type": "module", "type": "module",
"author": "Tickbase", "author": "Tickbase",
"repository": "https://github.com/Novattz/creamlinux-installer", "repository": "https://github.com/Novattz/creamlinux-installer",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "creamlinux-installer" name = "creamlinux-installer"
version = "1.4.0" version = "1.4.2"
description = "DLC Manager for Steam games on Linux" description = "DLC Manager for Steam games on Linux"
authors = ["tickbase"] authors = ["tickbase"]
license = "MIT" license = "MIT"

View File

@@ -410,9 +410,9 @@ impl SmokeAPI {
fn find_libsteam_api(game_path: &Path) -> Result<std::path::PathBuf, String> { fn find_libsteam_api(game_path: &Path) -> Result<std::path::PathBuf, String> {
use walkdir::WalkDir; use walkdir::WalkDir;
// Scan for libsteam_api.so (not too deep to keep it fast) // Scan for libsteam_api.so (some games place it several subdirectories deep)
for entry in WalkDir::new(game_path) for entry in WalkDir::new(game_path)
.max_depth(3) .max_depth(8)
.into_iter() .into_iter()
.filter_map(Result::ok) .filter_map(Result::ok)
{ {

View File

@@ -13,14 +13,19 @@ pub enum Bitness {
/// Detect the bitness of a Linux Binary by reading ELF header /// Detect the bitness of a Linux Binary by reading ELF header
/// ELF format: https://en.wikipedia.org/wiki/Executable_and_Linkable_Format /// ELF format: https://en.wikipedia.org/wiki/Executable_and_Linkable_Format
fn detect_binary_bitness(file_path: &Path) -> Option<Bitness> { fn detect_binary_bitness(file_path: &Path) -> Option<Bitness> {
// Read first 5 bytes of the file to check ELF header use std::io::Read;
let bytes = match fs::read(file_path) {
Ok(b) if b.len() >= 5 => b, // Only read first 5 bytes
_ => return None, let mut file = fs::File::open(file_path).ok()?;
}; let mut bytes = [0u8; 5];
// Read exactly 5 bytes or fail
if file.read_exact(&mut bytes).is_err() {
return None;
}
// Check for ELF magic number (0x7F 'E' 'L' 'F') // Check for ELF magic number (0x7F 'E' 'L' 'F')
if bytes.len() < 5 || &bytes[0..4] != b"\x7FELF" { if &bytes[0..4] != b"\x7FELF" {
return None; return None;
} }
@@ -60,14 +65,32 @@ pub fn detect_game_bitness(game_path: &str) -> Result<Bitness, String> {
"logs", "logs",
"assets", "assets",
"_CommonRedist", "_CommonRedist",
"data",
"Data",
"Docs",
"docs",
"screenshots",
"Screenshots",
"saves",
"Saves",
"mods",
"Mods",
"maps",
"Maps",
]; ];
// Limit scan depth to avoid deep recursion // Limit scan depth to avoid deep recursion
const MAX_DEPTH: usize = 5; const MAX_DEPTH: usize = 3;
// Stop after finding reasonable confidence (10 binaries)
const CONFIDENCE_THRESHOLD: usize = 10;
let mut bit64_binaries = Vec::new(); let mut bit64_binaries = Vec::new();
let mut bit32_binaries = Vec::new(); let mut bit32_binaries = Vec::new();
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
// Scan for Linux binaries // Scan for Linux binaries
for entry in WalkDir::new(game_path_obj) for entry in WalkDir::new(game_path_obj)
.max_depth(MAX_DEPTH) .max_depth(MAX_DEPTH)
@@ -83,6 +106,12 @@ pub fn detect_game_bitness(game_path: &str) -> Result<Bitness, String> {
}) })
.filter_map(Result::ok) .filter_map(Result::ok)
{ {
// Early termination when we have high confidence
if bit64_binaries.len() >= CONFIDENCE_THRESHOLD || bit32_binaries.len() >= CONFIDENCE_THRESHOLD {
debug!("Reached confidence threshold, stopping scan early");
break;
}
let path = entry.path(); let path = entry.path();
// Only check files // Only check files
@@ -102,20 +131,24 @@ pub fn detect_game_bitness(game_path: &str) -> Result<Bitness, String> {
|| filename.starts_with("lib"); || filename.starts_with("lib");
// Check if file is executable // Check if file is executable
#[cfg(unix)] let is_executable = {
{ {
use std::os::unix::fs::PermissionsExt; // Get metadata once and check both extension and permissions
if let Ok(metadata) = fs::metadata(path) { if let Ok(metadata) = fs::metadata(path) {
let permissions = metadata.permissions(); let permissions = metadata.permissions();
let is_executable = permissions.mode() & 0o111 != 0; let executable = permissions.mode() & 0o111 != 0;
// Skip files that are neither executable nor have binary extensions // Skip files that are neither executable nor have binary extensions
if !is_executable && !has_binary_extension { executable || has_binary_extension
continue; } else {
// If we can't read metadata, only proceed if it has binary extension
has_binary_extension
} }
} else {
continue;
} }
};
if !is_executable {
continue;
} }
// Detect bitness // Detect bitness
@@ -123,8 +156,24 @@ pub fn detect_game_bitness(game_path: &str) -> Result<Bitness, String> {
debug!("Found {:?} binary: {}", bitness, path.display()); debug!("Found {:?} binary: {}", bitness, path.display());
match bitness { match bitness {
Bitness::Bit64 => bit64_binaries.push(path.to_path_buf()), Bitness::Bit64 => {
Bitness::Bit32 => bit32_binaries.push(path.to_path_buf()), bit64_binaries.push(path.to_path_buf());
// If we find libsteam_api.so and it's 64-bit, we can be very confident
if filename == "libsteam_api.so" {
info!("Found 64-bit libsteam_api.so");
return Ok(Bitness::Bit64);
}
},
Bitness::Bit32 => {
bit32_binaries.push(path.to_path_buf());
// If we find libsteam_api.so and it's 32-bit, we can be very confident
if filename == "libsteam_api.so" {
info!("Found 32-bit libsteam_api.so");
return Ok(Bitness::Bit32);
}
},
} }
} }
} }

View File

@@ -19,7 +19,7 @@
}, },
"productName": "Creamlinux", "productName": "Creamlinux",
"mainBinaryName": "creamlinux", "mainBinaryName": "creamlinux",
"version": "1.4.0", "version": "1.4.2",
"identifier": "com.creamlinux.dev", "identifier": "com.creamlinux.dev",
"app": { "app": {
"withGlobalTauri": false, "withGlobalTauri": false,

View File

@@ -0,0 +1,93 @@
import { useState, useEffect } from 'react'
import Dialog from './Dialog'
import DialogHeader from './DialogHeader'
import DialogBody from './DialogBody'
import DialogFooter from './DialogFooter'
import DialogActions from './DialogActions'
import { Button } from '@/components/buttons'
import { DlcInfo } from '@/types'
export interface AddDlcDialogProps {
visible: boolean
onClose: () => void
onAdd: (dlc: DlcInfo) => void
existingIds: Set<string>
}
/**
* Add DLC Manually dialog
* Allows users to manually enter a DLC ID and name when it is
* missing from the Steam API and cannot be fetched automatically
*/
const AddDlcDialog = ({ visible, onClose, onAdd, existingIds }: AddDlcDialogProps) => {
const [id, setId] = useState('')
const [name, setName] = useState('')
const [error, setError] = useState('')
// Reset form state when dialog closes
useEffect(() => {
if (!visible) {
setId('')
setName('')
setError('')
}
}, [visible])
// Validate inputs and add the DLC to the list
const handleSubmit = () => {
const trimmedId = id.trim()
const trimmedName = name.trim()
if (!trimmedId) return setError('DLC ID is required.')
if (!/^\d+$/.test(trimmedId)) return setError('DLC ID must be a number.')
if (existingIds.has(trimmedId)) return setError('A DLC with this ID already exists.')
if (!trimmedName) return setError('DLC name is required.')
onAdd({ appid: trimmedId, name: trimmedName, enabled: true })
onClose()
}
return (
<Dialog visible={visible} onClose={onClose} size="small">
<DialogHeader onClose={onClose}>
<h3>Add DLC Manually</h3>
</DialogHeader>
<DialogBody>
<div className="add-dlc-form">
<div className="add-dlc-field">
<label className="add-dlc-label">DLC ID</label>
<input
type="text"
className="add-dlc-input"
placeholder="e.g. 1234560"
value={id}
onChange={(e) => { setId(e.target.value); setError('') }}
onKeyDown={(e) => e.key === 'Enter' && handleSubmit()}
autoFocus
/>
</div>
<div className="add-dlc-field">
<label className="add-dlc-label">DLC Name</label>
<input
type="text"
className="add-dlc-input"
placeholder="e.g. Expansion - My DLC"
value={name}
onChange={(e) => { setName(e.target.value); setError('') }}
onKeyDown={(e) => e.key === 'Enter' && handleSubmit()}
/>
</div>
{error && <p className="add-dlc-error">{error}</p>}
</div>
</DialogBody>
<DialogFooter>
<DialogActions>
<Button variant="secondary" onClick={onClose}>Cancel</Button>
<Button variant="primary" onClick={handleSubmit}>Add DLC</Button>
</DialogActions>
</DialogFooter>
</Dialog>
)
}
export default AddDlcDialog

View File

@@ -4,6 +4,7 @@ import DialogHeader from './DialogHeader'
import DialogBody from './DialogBody' import DialogBody from './DialogBody'
import DialogFooter from './DialogFooter' import DialogFooter from './DialogFooter'
import DialogActions from './DialogActions' import DialogActions from './DialogActions'
import AddDlcDialog from './AddDlcDialog'
import { Button, AnimatedCheckbox } from '@/components/buttons' import { Button, AnimatedCheckbox } from '@/components/buttons'
import { DlcInfo } from '@/types' import { DlcInfo } from '@/types'
import { Icon, check, info } from '@/components/icons' import { Icon, check, info } from '@/components/icons'
@@ -51,6 +52,7 @@ const DlcSelectionDialog = ({
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [selectAll, setSelectAll] = useState(true) const [selectAll, setSelectAll] = useState(true)
const [initialized, setInitialized] = useState(false) const [initialized, setInitialized] = useState(false)
const [showAddDlc, setShowAddDlc] = useState(false)
// Reset dialog state when it opens or closes // Reset dialog state when it opens or closes
useEffect(() => { useEffect(() => {
@@ -126,6 +128,11 @@ const DlcSelectionDialog = ({
) )
}, [selectAll]) }, [selectAll])
// Add a manually-entered DLC to the list
const handleAddDlc = useCallback((dlc: DlcInfo) => {
setSelectedDlcs((prev) => [...prev, dlc])
}, [])
// Submit selected DLCs to parent component // Submit selected DLCs to parent component
const handleConfirm = useCallback(() => { const handleConfirm = useCallback(() => {
// Create a deep copy to prevent reference issues // Create a deep copy to prevent reference issues
@@ -151,124 +158,141 @@ const DlcSelectionDialog = ({
} }
return ( return (
<Dialog visible={visible} onClose={onClose} size="large" preventBackdropClose={isLoading}> <>
<DialogHeader onClose={onClose} hideCloseButton={true}> <Dialog visible={visible} onClose={onClose} size="large" preventBackdropClose={isLoading}>
<h3>{dialogTitle}</h3> <DialogHeader onClose={onClose} hideCloseButton={true}>
<div className="dlc-game-info"> <h3>{dialogTitle}</h3>
<span className="game-title">{gameTitle}</span> <div className="dlc-game-info">
<span className="dlc-count"> <span className="game-title">{gameTitle}</span>
{selectedCount} of {selectedDlcs.length} DLCs selected <span className="dlc-count">
{getLoadingInfoText()} {selectedCount} of {selectedDlcs.length} DLCs selected
</span> {getLoadingInfoText()}
</div> </span>
</DialogHeader> </div>
</DialogHeader>
<div className="dlc-dialog-search"> <div className="dlc-dialog-search">
<input <input
type="text" type="text"
placeholder="Search DLCs..." placeholder="Search DLCs..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="dlc-search-input" className="dlc-search-input"
/>
<div className="select-all-container">
<AnimatedCheckbox
checked={selectAll}
onChange={handleToggleSelectAll}
label="Select All"
/> />
</div> <div className="select-all-container">
</div> <AnimatedCheckbox
checked={selectAll}
{(isLoading || isUpdating) && loadingProgress > 0 && ( onChange={handleToggleSelectAll}
<div className="dlc-loading-progress"> label="Select All"
<div className="progress-bar-container"> />
<div className="progress-bar" style={{ width: `${loadingProgress}%` }} />
</div>
<div className="loading-details">
<span>{isUpdating ? 'Updating DLC list' : 'Loading DLCs'}: {loadingProgress}%</span>
{estimatedTimeLeft && (
<span className="time-left">Est. time left: {estimatedTimeLeft}</span>
)}
</div> </div>
</div> </div>
)}
<DialogBody className="dlc-list-container"> {(isLoading || isUpdating) && loadingProgress > 0 && (
{selectedDlcs.length > 0 ? ( <div className="dlc-loading-progress">
<ul className="dlc-list"> <div className="progress-bar-container">
{filteredDlcs.map((dlc) => ( <div className="progress-bar" style={{ width: `${loadingProgress}%` }} />
<li key={dlc.appid} className="dlc-item"> </div>
<AnimatedCheckbox <div className="loading-details">
checked={dlc.enabled} <span>{isUpdating ? 'Updating DLC list' : 'Loading DLCs'}: {loadingProgress}%</span>
onChange={() => handleToggleDlc(dlc.appid)} {estimatedTimeLeft && (
label={dlc.name} <span className="time-left">Est. time left: {estimatedTimeLeft}</span>
sublabel={`ID: ${dlc.appid}`} )}
/> </div>
</li>
))}
{isLoading && (
<li className="dlc-item dlc-item-loading">
<div className="loading-pulse"></div>
</li>
)}
</ul>
) : (
<div className="dlc-loading">
<div className="loading-spinner"></div>
<p>Loading DLC information...</p>
</div> </div>
)} )}
</DialogBody>
<DialogFooter> <DialogBody className="dlc-list-container">
{/* Show update results */} {selectedDlcs.length > 0 ? (
{!isUpdating && !isLoading && isEditMode && updateAttempted && ( <ul className="dlc-list">
<> {filteredDlcs.map((dlc) => (
{newDlcsCount > 0 && ( <li key={dlc.appid} className="dlc-item">
<div className="dlc-update-results dlc-update-success"> <AnimatedCheckbox
<span className="update-message"> checked={dlc.enabled}
<Icon name={check} size="md" variant="solid" className="dlc-update-icon-success"/> Found {newDlcsCount} new DLC{newDlcsCount > 1 ? 's' : ''}! onChange={() => handleToggleDlc(dlc.appid)}
</span> label={dlc.name}
</div> sublabel={`ID: ${dlc.appid}`}
)} />
{newDlcsCount === 0 && ( </li>
<div className="dlc-update-results dlc-update-info"> ))}
<span className="update-message"> {isLoading && (
<Icon name={info} size="md" variant="solid" className="dlc-update-icon-info"/> No new DLCs found. Your list is up to date! <li className="dlc-item dlc-item-loading">
</span> <div className="loading-pulse"></div>
</div> </li>
)} )}
</> </ul>
)} ) : (
<div className="dlc-loading">
<DialogActions> <div className="loading-spinner"></div>
<Button <p>Loading DLC information...</p>
variant="secondary" </div>
onClick={onClose} )}
disabled={(isLoading || isUpdating) && loadingProgress < 10} </DialogBody>
>
Cancel <DialogFooter>
</Button> {/* Show update results */}
{!isUpdating && !isLoading && isEditMode && updateAttempted && (
{/* Update button - only show in edit mode */} <>
{isEditMode && onUpdate && ( {newDlcsCount > 0 && (
<div className="dlc-update-results dlc-update-success">
<span className="update-message">
<Icon name={check} size="md" variant="solid" className="dlc-update-icon-success"/> Found {newDlcsCount} new DLC{newDlcsCount > 1 ? 's' : ''}!
</span>
</div>
)}
{newDlcsCount === 0 && (
<div className="dlc-update-results dlc-update-info">
<span className="update-message">
<Icon name={info} size="md" variant="solid" className="dlc-update-icon-info"/> No new DLCs found. Your list is up to date!
</span>
</div>
)}
</>
)}
<DialogActions>
<Button <Button
variant="warning" variant="secondary"
onClick={() => onUpdate(gameId)} onClick={onClose}
disabled={(isLoading || isUpdating) && loadingProgress < 10}
>
Cancel
</Button>
<Button
variant="secondary"
onClick={() => setShowAddDlc(true)}
disabled={isLoading || isUpdating} disabled={isLoading || isUpdating}
> >
{isUpdating ? 'Updating...' : 'Update DLC List'} Add DLC Manually
</Button> </Button>
)}
{/* Update button - only show in edit mode */}
<Button variant="primary" onClick={handleConfirm} disabled={isLoading || isUpdating}> {isEditMode && onUpdate && (
{actionButtonText} <Button
</Button> variant="warning"
</DialogActions> onClick={() => onUpdate(gameId)}
</DialogFooter> disabled={isLoading || isUpdating}
</Dialog> >
{isUpdating ? 'Updating...' : 'Update DLC List'}
</Button>
)}
<Button variant="primary" onClick={handleConfirm} disabled={isLoading || isUpdating}>
{actionButtonText}
</Button>
</DialogActions>
</DialogFooter>
</Dialog>
<AddDlcDialog
visible={showAddDlc}
onClose={() => setShowAddDlc(false)}
onAdd={handleAddDlc}
existingIds={new Set(selectedDlcs.map((d) => d.appid))}
/>
</>
) )
} }
export default DlcSelectionDialog export default DlcSelectionDialog

View File

@@ -6,6 +6,7 @@ export { default as DialogFooter } from './DialogFooter'
export { default as DialogActions } from './DialogActions' export { default as DialogActions } from './DialogActions'
export { default as ProgressDialog } from './ProgressDialog' export { default as ProgressDialog } from './ProgressDialog'
export { default as DlcSelectionDialog } from './DlcSelectionDialog' export { default as DlcSelectionDialog } from './DlcSelectionDialog'
export { default as AddDlcDialog } from './AddDlcDialog'
export { default as SettingsDialog } from './SettingsDialog' export { default as SettingsDialog } from './SettingsDialog'
export { default as SmokeAPISettingsDialog } from './SmokeAPISettingsDialog' export { default as SmokeAPISettingsDialog } from './SmokeAPISettingsDialog'
export { default as ConflictDialog } from './ConflictDialog' export { default as ConflictDialog } from './ConflictDialog'
@@ -20,5 +21,6 @@ export type { DialogFooterProps } from './DialogFooter'
export type { DialogActionsProps } from './DialogActions' export type { DialogActionsProps } from './DialogActions'
export type { ProgressDialogProps, InstallationInstructions } from './ProgressDialog' export type { ProgressDialogProps, InstallationInstructions } from './ProgressDialog'
export type { DlcSelectionDialogProps } from './DlcSelectionDialog' export type { DlcSelectionDialogProps } from './DlcSelectionDialog'
export type { AddDlcDialogProps } from './AddDlcDialog'
export type { ConflictDialogProps, Conflict } from './ConflictDialog' export type { ConflictDialogProps, Conflict } from './ConflictDialog'
export type { UnlockerSelectionDialogProps } from './UnlockerSelectionDialog' export type { UnlockerSelectionDialogProps } from './UnlockerSelectionDialog'

View File

@@ -209,6 +209,51 @@
} }
} }
// Add DLC manually form
.add-dlc-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.add-dlc-field {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.add-dlc-label {
font-size: 0.85rem;
color: var(--text-secondary);
font-weight: 500;
}
.add-dlc-input {
background-color: var(--border-dark);
border: 1px solid var(--border-soft);
border-radius: 4px;
color: var(--text-primary);
padding: 0.6rem 1rem;
font-size: 0.9rem;
transition: all var(--duration-normal) var(--easing-ease-out);
&:focus {
border-color: var(--primary-color);
outline: none;
box-shadow: 0px 0px 6px rgba(245, 150, 130, 0.2);
}
&::placeholder {
color: var(--text-muted);
}
}
.add-dlc-error {
font-size: 0.82rem;
color: var(--error);
margin: 0;
}
// Loading animations // Loading animations
@keyframes spin { @keyframes spin {
0% { 0% {

View File

@@ -1,5 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"types": ["node"],
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["src/*"] "@/*": ["src/*"]