feat: Started on updates and workflows

This commit is contained in:
Tickbase
2025-05-18 21:05:32 +02:00
parent 2376690230
commit e633524465
18 changed files with 7471 additions and 454 deletions

135
.github/workflows/build-release.yml vendored Normal file
View File

@@ -0,0 +1,135 @@
name: 'Build and Release CreamLinux'
on:
push:
branches: ['main']
pull_request:
branches: ['main']
env:
CARGO_TERM_COLOR: always
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
jobs:
build-and-release:
strategy:
fail-fast: false
matrix:
platform: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Required for semantic-release
- name: Set up semantic-release environment
run: |
echo "GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> $GITHUB_ENV
# More environment setup for release if needed
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install frontend dependencies
run: npm ci
- name: Run ESLint
run: npm run lint
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@v2
with:
workspaces: 'src-tauri -> target'
cache-on-failure: true
# Setup platform-specific dependencies
- name: Install Linux dependencies
if: matrix.platform == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: Install macOS dependencies
if: matrix.platform == 'macos-latest'
run: |
rustup target add aarch64-apple-darwin
- name: Install Windows dependencies
if: matrix.platform == 'windows-latest'
run: |
# Windows typically doesn't need additional dependencies
# Sync package version
- name: Sync Version
run: npm run sync-version
# Run semantic-release only on the ubuntu runner to avoid conflicts
- name: Semantic Release
if: matrix.platform == 'ubuntu-latest' && github.event_name == 'push' && github.ref == 'refs/heads/main'
run: npx semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Build the app with updater artifacts
- name: Build the app
run: npm run tauri build
# Upload the artifacts for each platform
- name: Upload Linux artifacts
if: matrix.platform == 'ubuntu-latest'
uses: actions/upload-artifact@v4
with:
name: creamlinux-linux
path: |
src-tauri/target/release/bundle/deb/*.deb
src-tauri/target/release/bundle/appimage/*.AppImage
src-tauri/target/release/bundle/appimage/*.AppImage.sig
- name: Upload macOS artifacts
if: matrix.platform == 'macos-latest'
uses: actions/upload-artifact@v4
with:
name: creamlinux-macos
path: |
src-tauri/target/release/bundle/macos/*.app
src-tauri/target/release/bundle/macos/*.app.tar.gz
src-tauri/target/release/bundle/macos/*.app.tar.gz.sig
src-tauri/target/release/bundle/dmg/*.dmg
- name: Upload Windows artifacts
if: matrix.platform == 'windows-latest'
uses: actions/upload-artifact@v4
with:
name: creamlinux-windows
path: |
src-tauri/target/release/bundle/msi/*.msi
src-tauri/target/release/bundle/msi/*.msi.sig
src-tauri/target/release/bundle/nsis/*.exe
src-tauri/target/release/bundle/nsis/*.exe.sig
# Generate updater JSON file (run only on ubuntu)
- name: Generate updater JSON
if: matrix.platform == 'ubuntu-latest' && github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
node scripts/generate-updater-json.js
# Create GitHub release with all artifacts
- name: Create Release
uses: softprops/action-gh-release@v1
if: matrix.platform == 'ubuntu-latest' && github.event_name == 'push' && github.ref == 'refs/heads/main'
with:
files: |
latest.json
src-tauri/target/release/bundle/**/*.{AppImage,AppImage.sig,deb,dmg,app.tar.gz,app.tar.gz.sig,msi,msi.sig,exe,exe.sig}
draft: false
prerelease: false
token: ${{ secrets.GITHUB_TOKEN }}
generate_release_notes: true

View File

@@ -1,59 +0,0 @@
name: 'Build CreamLinux'
on:
push:
branches: ['main']
pull_request:
branches: ['main']
env:
CARGO_TERM_COLOR: always
jobs:
build-tauri:
strategy:
fail-fast: false
matrix:
platform: [ubuntu-latest]
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@v2
with:
workspaces: 'src-tauri -> target'
cache-on-failure: true
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: Install frontend dependencies
run: npm install
- name: Run ESLint
run: npm run lint
- name: Build the app
run: npm run tauri build
- name: Upload binary artifacts
uses: actions/upload-artifact@v4
with:
name: creamlinux-${{ runner.os }}
path: |
src-tauri/target/release/creamlinux
src-tauri/target/release/bundle/deb/*.deb
src-tauri/target/release/bundle/appimage/*.AppImage

31
.releaserc.js Normal file
View File

@@ -0,0 +1,31 @@
module.exports = {
branches: ['main'],
plugins: [
'@semantic-release/commit-analyzer',
'@semantic-release/release-notes-generator',
'@semantic-release/changelog',
[
'@semantic-release/npm',
{
// Don't actually publish to npm
npmPublish: false,
},
],
[
'@semantic-release/git',
{
assets: ['package.json', 'CHANGELOG.md'],
message: 'chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}',
},
],
[
'@semantic-release/github',
{
assets: [
{ path: 'latest.json', label: 'Updater manifest file' },
// We don't need to specify release assets here as they're handled by the GitHub release action
],
},
],
],
}

6268
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,10 +9,18 @@
"lint": "eslint .",
"preview": "vite preview",
"tauri": "tauri",
"optimize-svg": "node scripts/optimize-svg.js"
"optimize-svg": "node scripts/optimize-svg.js",
"sync-version": "node scripts/sync-version.js",
"generate-updater": "node scripts/generate-updater-json.js",
"update-tauri-config": "node scripts/update-tauri-config.js",
"update-api-changelog": "node scripts/api-changelog.js",
"prepare-release": "npm run sync-version && npm run update-api-changelog && npm run update-tauri-config",
"release": "semantic-release"
},
"dependencies": {
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-process": "^2.2.1",
"@tauri-apps/plugin-updater": "^2.7.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"sass": "^1.89.0",
@@ -20,6 +28,9 @@
},
"devDependencies": {
"@eslint/js": "^9.22.0",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"@semantic-release/github": "^11.0.2",
"@svgr/core": "^8.1.0",
"@svgr/webpack": "^8.1.0",
"@tauri-apps/cli": "^2.5.0",
@@ -32,6 +43,7 @@
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"sass-embedded": "^1.86.3",
"semantic-release": "^24.2.4",
"typescript": "~5.7.2",
"typescript-eslint": "^8.26.1",
"vite": "^6.3.1",

215
scripts/api-changelog.js Normal file
View File

@@ -0,0 +1,215 @@
const fs = require('fs')
const path = require('path')
const { execSync } = require('child_process')
// Define paths
const rustFilesPath = path.join(__dirname, '..', 'src-tauri')
function getApiDefinitions() {
// Get a list of all Rust files
const rustFiles = findRustFiles(rustFilesPath)
// Extract API functions and structs from Rust files
const apiDefinitions = {
commands: [],
structs: [],
}
rustFiles.forEach((filePath) => {
const content = fs.readFileSync(filePath, 'utf8')
// Find Tauri commands (API endpoints)
const commandRegex = /#\[tauri::command\]\s+(?:pub\s+)?(?:async\s+)?fn\s+([a-zA-Z0-9_]+)/g
let match
while ((match = commandRegex.exec(content)) !== null) {
apiDefinitions.commands.push({
name: match[1],
file: path.relative(rustFilesPath, filePath),
})
}
// Find structs that are likely part of the API
const structRegex = /#\[derive\(.*Serialize.*\)\]\s+(?:pub\s+)?struct\s+([a-zA-Z0-9_]+)/g
while ((match = structRegex.exec(content)) !== null) {
apiDefinitions.structs.push({
name: match[1],
file: path.relative(rustFilesPath, filePath),
})
}
})
return apiDefinitions
}
function findRustFiles(dir) {
let results = []
const list = fs.readdirSync(dir)
list.forEach((file) => {
const filePath = path.join(dir, file)
const stat = fs.statSync(filePath)
if (stat && stat.isDirectory() && file !== 'target') {
// Recursively search subdirectories, but skip the 'target' directory
results = results.concat(findRustFiles(filePath))
} else if (path.extname(file) === '.rs') {
// Add Rust files to the results
results.push(filePath)
}
})
return results
}
function compareApiDefinitions(oldApi, newApi) {
const changes = {
commands: {
added: [],
removed: [],
},
structs: {
added: [],
removed: [],
},
}
// Find added and removed commands
const oldCommandNames = oldApi.commands.map((cmd) => cmd.name)
const newCommandNames = newApi.commands.map((cmd) => cmd.name)
changes.commands.added = newApi.commands.filter((cmd) => !oldCommandNames.includes(cmd.name))
changes.commands.removed = oldApi.commands.filter((cmd) => !newCommandNames.includes(cmd.name))
// Find added and removed structs
const oldStructNames = oldApi.structs.map((struct) => struct.name)
const newStructNames = newApi.structs.map((struct) => struct.name)
changes.structs.added = newApi.structs.filter((struct) => !oldStructNames.includes(struct.name))
changes.structs.removed = oldApi.structs.filter((struct) => !newStructNames.includes(struct.name))
return changes
}
function updateChangelog(changes) {
const changelogPath = path.join(__dirname, '..', 'CHANGELOG.md')
let changelog = ''
// Create changelog if it doesn't exist
if (fs.existsSync(changelogPath)) {
changelog = fs.readFileSync(changelogPath, 'utf8')
} else {
changelog =
'# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n'
}
// Get the current version and date
const packageJson = require(path.join(__dirname, '..', 'package.json'))
const version = packageJson.version
const date = new Date().toISOString().split('T')[0]
// Create the new changelog entry
let newEntry = `## [${version}] - ${date}\n\n`
if (
changes.commands.added.length > 0 ||
changes.commands.removed.length > 0 ||
changes.structs.added.length > 0 ||
changes.structs.removed.length > 0
) {
newEntry += '### API Changes\n\n'
if (changes.commands.added.length > 0) {
newEntry += '#### New Commands\n\n'
changes.commands.added.forEach((cmd) => {
newEntry += `- \`${cmd.name}\` in \`${cmd.file}\`\n`
})
newEntry += '\n'
}
if (changes.commands.removed.length > 0) {
newEntry += '#### Removed Commands\n\n'
changes.commands.removed.forEach((cmd) => {
newEntry += `- \`${cmd.name}\` (was in \`${cmd.file}\`)\n`
})
newEntry += '\n'
}
if (changes.structs.added.length > 0) {
newEntry += '#### New Structures\n\n'
changes.structs.added.forEach((struct) => {
newEntry += `- \`${struct.name}\` in \`${struct.file}\`\n`
})
newEntry += '\n'
}
if (changes.structs.removed.length > 0) {
newEntry += '#### Removed Structures\n\n'
changes.structs.removed.forEach((struct) => {
newEntry += `- \`${struct.name}\` (was in \`${struct.file}\`)\n`
})
newEntry += '\n'
}
} else {
newEntry += 'No API changes in this release.\n\n'
}
// Add git commit information since last release
try {
// Get the latest tag
const latestTag = execSync('git describe --tags --abbrev=0', { encoding: 'utf8' }).trim()
// Get commits since the latest tag
const commits = execSync(`git log ${latestTag}..HEAD --pretty=format:"%h %s (%an)"`, {
encoding: 'utf8',
}).trim()
if (commits) {
newEntry += '### Other Changes\n\n'
// Split commits by line and add them to the changelog
commits.split('\n').forEach((commit) => {
newEntry += `- ${commit}\n`
})
newEntry += '\n'
}
} catch (error) {
// If there are no tags or other git issues, just continue without commit info
console.warn('Could not get git commit information:', error.message)
}
// Add the new entry to the changelog
if (changelog.includes('## [')) {
// Insert new entry after the first heading
changelog = changelog.replace('# Changelog\n\n', `# Changelog\n\n${newEntry}`)
} else {
// Append to the end if no existing versions
changelog += newEntry
}
// Write the updated changelog
fs.writeFileSync(changelogPath, changelog)
console.log(`Updated CHANGELOG.md with API changes for version ${version}`)
}
// Main execution
try {
// Check if we have a saved API definition
const apiCachePath = path.join(__dirname, '.api-cache.json')
let oldApi = { commands: [], structs: [] }
let newApi = getApiDefinitions()
if (fs.existsSync(apiCachePath)) {
oldApi = JSON.parse(fs.readFileSync(apiCachePath, 'utf8'))
}
// Compare and update the changelog
const changes = compareApiDefinitions(oldApi, newApi)
updateChangelog(changes)
// Save the new API definition for next time
fs.writeFileSync(apiCachePath, JSON.stringify(newApi, null, 2))
} catch (error) {
console.error('Error updating API changelog:', error)
process.exit(1)
}

View File

@@ -0,0 +1,82 @@
const fs = require('fs')
const path = require('path')
const { execSync } = require('child_process')
// Read the current version from package.json
const packageJson = require('../package.json')
const version = packageJson.version
console.log(`Current version: ${version}`)
// Get the current date in RFC 3339 format for pub_date
const pubDate = new Date().toISOString()
// Base URL where the assets will be available
const baseUrl = 'https://github.com/tickbase/creamlinux/releases/download'
const releaseTag = `v${version}`
const releaseUrl = `${baseUrl}/${releaseTag}`
// Create the updater JSON structure
const updaterJson = {
version,
notes: `Release version ${version}`,
pub_date: pubDate,
platforms: {
// Windows x64
'windows-x86_64': {
url: `${releaseUrl}/creamlinux-setup.exe`,
signature: readSignature('windows', 'creamlinux-setup.exe'),
},
// Linux x64
'linux-x86_64': {
url: `${releaseUrl}/creamlinux.AppImage`,
signature: readSignature('linux', 'creamlinux.AppImage'),
},
// macOS x64 and arm64 (universal)
'darwin-universal': {
url: `${releaseUrl}/creamlinux.app.tar.gz`,
signature: readSignature('macos', 'creamlinux.app.tar.gz'),
},
},
}
// Write the updater JSON file
fs.writeFileSync('latest.json', JSON.stringify(updaterJson, null, 2))
console.log('Created latest.json updater file')
// Helper function to read signature files
function readSignature(platform, filename) {
try {
// Determine path based on platform
let sigPath
switch (platform) {
case 'windows':
// Check both NSIS and MSI
try {
sigPath = path.join('src-tauri', 'target', 'release', 'bundle', 'nsis', `${filename}.sig`)
return fs.readFileSync(sigPath, 'utf8').trim()
} catch (e) {
sigPath = path.join('src-tauri', 'target', 'release', 'bundle', 'msi', `${filename}.sig`)
return fs.readFileSync(sigPath, 'utf8').trim()
}
case 'linux':
sigPath = path.join(
'src-tauri',
'target',
'release',
'bundle',
'appimage',
`${filename}.sig`
)
return fs.readFileSync(sigPath, 'utf8').trim()
case 'macos':
sigPath = path.join('src-tauri', 'target', 'release', 'bundle', 'macos', `${filename}.sig`)
return fs.readFileSync(sigPath, 'utf8').trim()
default:
throw new Error(`Unknown platform: ${platform}`)
}
} catch (error) {
console.error(`Error reading signature for ${platform}/${filename}:`, error.message)
return ''
}
}

29
scripts/sync-version.js Normal file
View File

@@ -0,0 +1,29 @@
const fs = require('fs')
const path = require('path')
// Read current version from package.json
const packageJsonPath = path.join(__dirname, '..', 'package.json')
const packageJson = require(packageJsonPath)
const version = packageJson.version
console.log(`Current version: ${version}`)
// Update Cargo.toml
const cargoTomlPath = path.join(__dirname, '..', 'src-tauri', 'Cargo.toml')
let cargoToml = fs.readFileSync(cargoTomlPath, 'utf8')
// Replace the version in Cargo.toml
cargoToml = cargoToml.replace(/version\s*=\s*"[^"]+"/m, `version = "${version}"`)
fs.writeFileSync(cargoTomlPath, cargoToml)
console.log(`Updated Cargo.toml version to ${version}`)
// Update tauri.conf.json
const tauriConfigPath = path.join(__dirname, '..', 'src-tauri', 'tauri.conf.json')
const tauriConfig = JSON.parse(fs.readFileSync(tauriConfigPath, 'utf8'))
// Set the version in tauri.conf.json
tauriConfig.version = version
fs.writeFileSync(tauriConfigPath, JSON.stringify(tauriConfig, null, 2))
console.log(`Updated tauri.conf.json version to ${version}`)
console.log('Version synchronization completed!')

View File

@@ -0,0 +1,42 @@
const fs = require('fs')
const path = require('path')
// Path to your tauri.conf.json file
const tauriConfigPath = path.join(__dirname, '..', 'src-tauri', 'tauri.conf.json')
// Read the current config
const rawConfig = fs.readFileSync(tauriConfigPath, 'utf8')
const config = JSON.parse(rawConfig)
// Add or update the updater configuration
if (!config.plugins) {
config.plugins = {}
}
// Get the public key from environment variable
const pubkey = process.env.TAURI_PUBLIC_KEY || ''
if (!pubkey) {
console.warn(
'Warning: TAURI_PUBLIC_KEY environment variable is not set. Updater will not work correctly!'
)
}
// Configure the updater plugin
config.plugins.updater = {
pubkey,
endpoints: ['https://github.com/tickbase/creamlinux/releases/latest/download/latest.json'],
}
// Configure bundle settings for updater artifacts
if (!config.bundle) {
config.bundle = {}
}
// Set createUpdaterArtifacts to true
config.bundle.createUpdaterArtifacts = true
// Write the updated config back to the file
fs.writeFileSync(tauriConfigPath, JSON.stringify(config, null, 2))
console.log('Tauri config updated with updater configuration')

View File

@@ -30,6 +30,10 @@ tauri-plugin-shell = "2.0.0-rc"
tauri-plugin-dialog = "2.0.0-rc"
tauri-plugin-fs = "2.0.0-rc"
num_cpus = "1.16.0"
tauri-plugin-process = "2"
[features]
custom-protocol = ["tauri/custom-protocol"]
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-updater = "2"

View File

@@ -0,0 +1,14 @@
{
"identifier": "desktop-capability",
"platforms": [
"macOS",
"windows",
"linux"
],
"windows": [
"main"
],
"permissions": [
"updater:default"
]
}

View File

@@ -1,6 +1,6 @@
#![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
mod dlc_manager;
@@ -22,527 +22,529 @@ use tokio::time::Instant;
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct GameAction {
game_id: String,
action: String,
game_id: String,
action: String,
}
// Mark fields with # to allow unused fields
#[derive(Debug, Clone)]
struct DlcCache {
#[allow(dead_code)]
data: Vec<DlcInfoWithState>,
#[allow(dead_code)]
timestamp: Instant,
#[allow(dead_code)]
data: Vec<DlcInfoWithState>,
#[allow(dead_code)]
timestamp: Instant,
}
// Structure to hold the state of installed games
struct AppState {
games: Mutex<HashMap<String, Game>>,
dlc_cache: Mutex<HashMap<String, DlcCache>>,
fetch_cancellation: Arc<AtomicBool>,
games: Mutex<HashMap<String, Game>>,
dlc_cache: Mutex<HashMap<String, DlcCache>>,
fetch_cancellation: Arc<AtomicBool>,
}
#[tauri::command]
fn get_all_dlcs_command(game_path: String) -> Result<Vec<DlcInfoWithState>, String> {
info!("Getting all DLCs (enabled and disabled) for: {}", game_path);
dlc_manager::get_all_dlcs(&game_path)
info!("Getting all DLCs (enabled and disabled) for: {}", game_path);
dlc_manager::get_all_dlcs(&game_path)
}
// Scan and get the list of Steam games
#[tauri::command]
async fn scan_steam_games(
state: State<'_, AppState>,
app_handle: tauri::AppHandle,
state: State<'_, AppState>,
app_handle: tauri::AppHandle,
) -> Result<Vec<Game>, String> {
info!("Starting Steam games scan");
emit_scan_progress(&app_handle, "Locating Steam libraries...", 10);
info!("Starting Steam games scan");
emit_scan_progress(&app_handle, "Locating Steam libraries...", 10);
// Get default Steam paths
let paths = searcher::get_default_steam_paths();
// Get default Steam paths
let paths = searcher::get_default_steam_paths();
// Find Steam libraries
emit_scan_progress(&app_handle, "Finding Steam libraries...", 15);
let libraries = searcher::find_steam_libraries(&paths);
// Find Steam libraries
emit_scan_progress(&app_handle, "Finding Steam libraries...", 15);
let libraries = searcher::find_steam_libraries(&paths);
// Group libraries by path to avoid duplicates in logs
let mut unique_libraries = std::collections::HashSet::new();
for lib in &libraries {
unique_libraries.insert(lib.to_string_lossy().to_string());
}
// Group libraries by path to avoid duplicates in logs
let mut unique_libraries = std::collections::HashSet::new();
for lib in &libraries {
unique_libraries.insert(lib.to_string_lossy().to_string());
}
info!(
"Found {} Steam library directories:",
unique_libraries.len()
);
for (i, lib) in unique_libraries.iter().enumerate() {
info!(" Library {}: {}", i + 1, lib);
}
info!(
"Found {} Steam library directories:",
unique_libraries.len()
);
for (i, lib) in unique_libraries.iter().enumerate() {
info!(" Library {}: {}", i + 1, lib);
}
emit_scan_progress(
&app_handle,
&format!(
"Found {} Steam libraries. Starting game scan...",
unique_libraries.len()
),
20,
);
emit_scan_progress(
&app_handle,
&format!(
"Found {} Steam libraries. Starting game scan...",
unique_libraries.len()
),
20,
);
// Find installed games
let games_info = searcher::find_installed_games(&libraries).await;
// Find installed games
let games_info = searcher::find_installed_games(&libraries).await;
emit_scan_progress(
&app_handle,
&format!("Found {} games. Processing...", games_info.len()),
90,
);
emit_scan_progress(
&app_handle,
&format!("Found {} games. Processing...", games_info.len()),
90,
);
// Log summary of games found
info!("Games scan complete - Found {} games", games_info.len());
info!(
"Native games: {}",
games_info.iter().filter(|g| g.native).count()
);
info!(
"Proton games: {}",
games_info.iter().filter(|g| !g.native).count()
);
info!(
"Games with CreamLinux: {}",
games_info.iter().filter(|g| g.cream_installed).count()
);
info!(
"Games with SmokeAPI: {}",
games_info.iter().filter(|g| g.smoke_installed).count()
);
// Log summary of games found
info!("Games scan complete - Found {} games", games_info.len());
info!(
"Native games: {}",
games_info.iter().filter(|g| g.native).count()
);
info!(
"Proton games: {}",
games_info.iter().filter(|g| !g.native).count()
);
info!(
"Games with CreamLinux: {}",
games_info.iter().filter(|g| g.cream_installed).count()
);
info!(
"Games with SmokeAPI: {}",
games_info.iter().filter(|g| g.smoke_installed).count()
);
// Convert to our Game struct
let mut result = Vec::new();
// Convert to our Game struct
let mut result = Vec::new();
info!("Processing games into application state...");
for game_info in games_info {
// Only log detailed game info at Debug level to keep Info logs cleaner
debug!(
"Processing game: {}, Native: {}, CreamLinux: {}, SmokeAPI: {}",
game_info.title, game_info.native, game_info.cream_installed, game_info.smoke_installed
);
info!("Processing games into application state...");
for game_info in games_info {
// Only log detailed game info at Debug level to keep Info logs cleaner
debug!(
"Processing game: {}, Native: {}, CreamLinux: {}, SmokeAPI: {}",
game_info.title, game_info.native, game_info.cream_installed, game_info.smoke_installed
);
let game = Game {
id: game_info.id,
title: game_info.title,
path: game_info.path.to_string_lossy().to_string(),
native: game_info.native,
api_files: game_info.api_files,
cream_installed: game_info.cream_installed,
smoke_installed: game_info.smoke_installed,
installing: false,
};
let game = Game {
id: game_info.id,
title: game_info.title,
path: game_info.path.to_string_lossy().to_string(),
native: game_info.native,
api_files: game_info.api_files,
cream_installed: game_info.cream_installed,
smoke_installed: game_info.smoke_installed,
installing: false,
};
result.push(game.clone());
result.push(game.clone());
// Store in state for later use
state.games.lock().insert(game.id.clone(), game);
}
// Store in state for later use
state.games.lock().insert(game.id.clone(), game);
}
emit_scan_progress(
&app_handle,
&format!("Scan complete. Found {} games.", result.len()),
100,
);
emit_scan_progress(
&app_handle,
&format!("Scan complete. Found {} games.", result.len()),
100,
);
info!("Game scan completed successfully");
Ok(result)
info!("Game scan completed successfully");
Ok(result)
}
// Helper function to emit scan progress events
fn emit_scan_progress(app_handle: &tauri::AppHandle, message: &str, progress: u32) {
// Log first, then emit the event
info!("Scan progress: {}% - {}", progress, message);
// Log first, then emit the event
info!("Scan progress: {}% - {}", progress, message);
let payload = serde_json::json!({
"message": message,
"progress": progress
});
let payload = serde_json::json!({
"message": message,
"progress": progress
});
if let Err(e) = app_handle.emit("scan-progress", payload) {
warn!("Failed to emit scan-progress event: {}", e);
}
if let Err(e) = app_handle.emit("scan-progress", payload) {
warn!("Failed to emit scan-progress event: {}", e);
}
}
// Fetch game info by ID - useful for single game updates
#[tauri::command]
fn get_game_info(game_id: String, state: State<AppState>) -> Result<Game, String> {
let games = state.games.lock();
games
.get(&game_id)
.cloned()
.ok_or_else(|| format!("Game with ID {} not found", game_id))
let games = state.games.lock();
games
.get(&game_id)
.cloned()
.ok_or_else(|| format!("Game with ID {} not found", game_id))
}
// Unified action handler for installation and uninstallation
#[tauri::command]
async fn process_game_action(
game_action: GameAction,
state: State<'_, AppState>,
app_handle: tauri::AppHandle,
game_action: GameAction,
state: State<'_, AppState>,
app_handle: tauri::AppHandle,
) -> Result<Game, String> {
// Clone the information we need from state to avoid lifetime issues
let game = {
let games = state.games.lock();
games
.get(&game_action.game_id)
.cloned()
.ok_or_else(|| format!("Game with ID {} not found", game_action.game_id))?
};
// Clone the information we need from state to avoid lifetime issues
let game = {
let games = state.games.lock();
games
.get(&game_action.game_id)
.cloned()
.ok_or_else(|| format!("Game with ID {} not found", game_action.game_id))?
};
// Parse the action string to determine type and operation
let (installer_type, action) = match game_action.action.as_str() {
"install_cream" => (InstallerType::Cream, InstallerAction::Install),
"uninstall_cream" => (InstallerType::Cream, InstallerAction::Uninstall),
"install_smoke" => (InstallerType::Smoke, InstallerAction::Install),
"uninstall_smoke" => (InstallerType::Smoke, InstallerAction::Uninstall),
_ => return Err(format!("Invalid action: {}", game_action.action)),
};
// Parse the action string to determine type and operation
let (installer_type, action) = match game_action.action.as_str() {
"install_cream" => (InstallerType::Cream, InstallerAction::Install),
"uninstall_cream" => (InstallerType::Cream, InstallerAction::Uninstall),
"install_smoke" => (InstallerType::Smoke, InstallerAction::Install),
"uninstall_smoke" => (InstallerType::Smoke, InstallerAction::Uninstall),
_ => return Err(format!("Invalid action: {}", game_action.action)),
};
// Execute the action
installer::process_action(
game_action.game_id.clone(),
installer_type,
action,
game.clone(),
app_handle.clone(),
)
.await?;
// Execute the action
installer::process_action(
game_action.game_id.clone(),
installer_type,
action,
game.clone(),
app_handle.clone(),
)
.await?;
// Update game status in state based on the action
let updated_game = {
let mut games_map = state.games.lock();
let game = games_map.get_mut(&game_action.game_id).ok_or_else(|| {
format!(
"Game with ID {} not found after action",
game_action.game_id
)
})?;
// Update game status in state based on the action
let updated_game = {
let mut games_map = state.games.lock();
let game = games_map.get_mut(&game_action.game_id).ok_or_else(|| {
format!(
"Game with ID {} not found after action",
game_action.game_id
)
})?;
// Update installation status
match (installer_type, action) {
(InstallerType::Cream, InstallerAction::Install) => {
game.cream_installed = true;
}
(InstallerType::Cream, InstallerAction::Uninstall) => {
game.cream_installed = false;
}
(InstallerType::Smoke, InstallerAction::Install) => {
game.smoke_installed = true;
}
(InstallerType::Smoke, InstallerAction::Uninstall) => {
game.smoke_installed = false;
}
}
// Update installation status
match (installer_type, action) {
(InstallerType::Cream, InstallerAction::Install) => {
game.cream_installed = true;
}
(InstallerType::Cream, InstallerAction::Uninstall) => {
game.cream_installed = false;
}
(InstallerType::Smoke, InstallerAction::Install) => {
game.smoke_installed = true;
}
(InstallerType::Smoke, InstallerAction::Uninstall) => {
game.smoke_installed = false;
}
}
// Reset installing flag
game.installing = false;
// Reset installing flag
game.installing = false;
// Return updated game info
game.clone()
};
// Return updated game info
game.clone()
};
// Emit an event to update the UI for this specific game
if let Err(e) = app_handle.emit("game-updated", &updated_game) {
warn!("Failed to emit game-updated event: {}", e);
}
// Emit an event to update the UI for this specific game
if let Err(e) = app_handle.emit("game-updated", &updated_game) {
warn!("Failed to emit game-updated event: {}", e);
}
Ok(updated_game)
Ok(updated_game)
}
// Fetch DLC list for a game
#[tauri::command]
async fn fetch_game_dlcs(
game_id: String,
app_handle: tauri::AppHandle,
game_id: String,
app_handle: tauri::AppHandle,
) -> Result<Vec<DlcInfoWithState>, String> {
info!("Fetching DLCs for game ID: {}", game_id);
info!("Fetching DLCs for game ID: {}", game_id);
// Fetch DLC data
match installer::fetch_dlc_details(&game_id).await {
Ok(dlcs) => {
// Convert to DlcInfoWithState
let dlcs_with_state = dlcs
.into_iter()
.map(|dlc| DlcInfoWithState {
appid: dlc.appid,
name: dlc.name,
enabled: true,
})
.collect::<Vec<_>>();
// Fetch DLC data
match installer::fetch_dlc_details(&game_id).await {
Ok(dlcs) => {
// Convert to DlcInfoWithState
let dlcs_with_state = dlcs
.into_iter()
.map(|dlc| DlcInfoWithState {
appid: dlc.appid,
name: dlc.name,
enabled: true,
})
.collect::<Vec<_>>();
// Cache in memory for this session (but not on disk)
let state = app_handle.state::<AppState>();
let mut cache = state.dlc_cache.lock();
cache.insert(
game_id.clone(),
DlcCache {
data: dlcs_with_state.clone(),
timestamp: Instant::now(),
},
);
// Cache in memory for this session (but not on disk)
let state = app_handle.state::<AppState>();
let mut cache = state.dlc_cache.lock();
cache.insert(
game_id.clone(),
DlcCache {
data: dlcs_with_state.clone(),
timestamp: Instant::now(),
},
);
Ok(dlcs_with_state)
}
Err(e) => Err(format!("Failed to fetch DLC details: {}", e)),
}
Ok(dlcs_with_state)
}
Err(e) => Err(format!("Failed to fetch DLC details: {}", e)),
}
}
#[tauri::command]
fn abort_dlc_fetch(game_id: String, app_handle: tauri::AppHandle) -> Result<(), String> {
info!("Request to abort DLC fetch for game ID: {}", game_id);
info!("Request to abort DLC fetch for game ID: {}", game_id);
let state = app_handle.state::<AppState>();
state.fetch_cancellation.store(true, Ordering::SeqCst);
let state = app_handle.state::<AppState>();
state.fetch_cancellation.store(true, Ordering::SeqCst);
// Reset after a short delay
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(500));
let state = app_handle.state::<AppState>();
state.fetch_cancellation.store(false, Ordering::SeqCst);
});
// Reset after a short delay
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(500));
let state = app_handle.state::<AppState>();
state.fetch_cancellation.store(false, Ordering::SeqCst);
});
Ok(())
Ok(())
}
// Fetch DLC list with progress updates (streaming)
#[tauri::command]
async fn stream_game_dlcs(game_id: String, app_handle: tauri::AppHandle) -> Result<(), String> {
info!("Streaming DLCs for game ID: {}", game_id);
info!("Streaming DLCs for game ID: {}", game_id);
// Fetch DLC data from API
match installer::fetch_dlc_details_with_progress(&game_id, &app_handle).await {
Ok(dlcs) => {
info!(
"Successfully streamed {} DLCs for game {}",
dlcs.len(),
game_id
);
// Fetch DLC data from API
match installer::fetch_dlc_details_with_progress(&game_id, &app_handle).await {
Ok(dlcs) => {
info!(
"Successfully streamed {} DLCs for game {}",
dlcs.len(),
game_id
);
// Convert to DLCInfoWithState for in-memory caching only
let dlcs_with_state = dlcs
.into_iter()
.map(|dlc| DlcInfoWithState {
appid: dlc.appid,
name: dlc.name,
enabled: true,
})
.collect::<Vec<_>>();
// Convert to DLCInfoWithState for in-memory caching only
let dlcs_with_state = dlcs
.into_iter()
.map(|dlc| DlcInfoWithState {
appid: dlc.appid,
name: dlc.name,
enabled: true,
})
.collect::<Vec<_>>();
// Update in-memory cache without storing to disk
let state = app_handle.state::<AppState>();
let mut dlc_cache = state.dlc_cache.lock();
dlc_cache.insert(
game_id.clone(),
DlcCache {
data: dlcs_with_state,
timestamp: tokio::time::Instant::now(),
},
);
// Update in-memory cache without storing to disk
let state = app_handle.state::<AppState>();
let mut dlc_cache = state.dlc_cache.lock();
dlc_cache.insert(
game_id.clone(),
DlcCache {
data: dlcs_with_state,
timestamp: tokio::time::Instant::now(),
},
);
Ok(())
}
Err(e) => {
error!("Failed to stream DLC details: {}", e);
// Emit error event
let error_payload = serde_json::json!({
"error": format!("Failed to fetch DLC details: {}", e)
});
Ok(())
}
Err(e) => {
error!("Failed to stream DLC details: {}", e);
// Emit error event
let error_payload = serde_json::json!({
"error": format!("Failed to fetch DLC details: {}", e)
});
if let Err(emit_err) = app_handle.emit("dlc-error", error_payload) {
warn!("Failed to emit dlc-error event: {}", emit_err);
}
if let Err(emit_err) = app_handle.emit("dlc-error", error_payload) {
warn!("Failed to emit dlc-error event: {}", emit_err);
}
Err(format!("Failed to fetch DLC details: {}", e))
}
}
Err(format!("Failed to fetch DLC details: {}", e))
}
}
}
// Clear caches command renamed to flush_data for clarity
#[tauri::command]
fn clear_caches() -> Result<(), String> {
info!("Data flush requested - cleaning in-memory state only");
Ok(())
info!("Data flush requested - cleaning in-memory state only");
Ok(())
}
// Get the list of enabled DLCs for a game
#[tauri::command]
fn get_enabled_dlcs_command(game_path: String) -> Result<Vec<String>, String> {
info!("Getting enabled DLCs for: {}", game_path);
dlc_manager::get_enabled_dlcs(&game_path)
info!("Getting enabled DLCs for: {}", game_path);
dlc_manager::get_enabled_dlcs(&game_path)
}
// Update the DLC configuration for a game
#[tauri::command]
fn update_dlc_configuration_command(
game_path: String,
dlcs: Vec<DlcInfoWithState>,
game_path: String,
dlcs: Vec<DlcInfoWithState>,
) -> Result<(), String> {
info!("Updating DLC configuration for: {}", game_path);
dlc_manager::update_dlc_configuration(&game_path, dlcs)
info!("Updating DLC configuration for: {}", game_path);
dlc_manager::update_dlc_configuration(&game_path, dlcs)
}
// Install CreamLinux with selected DLCs
#[tauri::command]
async fn install_cream_with_dlcs_command(
game_id: String,
selected_dlcs: Vec<DlcInfoWithState>,
app_handle: tauri::AppHandle,
game_id: String,
selected_dlcs: Vec<DlcInfoWithState>,
app_handle: tauri::AppHandle,
) -> Result<Game, String> {
info!(
"Installing CreamLinux with selected DLCs for game: {}",
game_id
);
info!(
"Installing CreamLinux with selected DLCs for game: {}",
game_id
);
// Clone selected_dlcs for later use
let selected_dlcs_clone = selected_dlcs.clone();
// Clone selected_dlcs for later use
let selected_dlcs_clone = selected_dlcs.clone();
// Install CreamLinux with the selected DLCs
match dlc_manager::install_cream_with_dlcs(game_id.clone(), app_handle.clone(), selected_dlcs)
.await
{
Ok(_) => {
// Return updated game info
let state = app_handle.state::<AppState>();
// Install CreamLinux with the selected DLCs
match dlc_manager::install_cream_with_dlcs(game_id.clone(), app_handle.clone(), selected_dlcs)
.await
{
Ok(_) => {
// Return updated game info
let state = app_handle.state::<AppState>();
// Get a mutable reference and update the game
let game = {
let mut games_map = state.games.lock();
let game = games_map.get_mut(&game_id).ok_or_else(|| {
format!("Game with ID {} not found after installation", game_id)
})?;
// Get a mutable reference and update the game
let game = {
let mut games_map = state.games.lock();
let game = games_map.get_mut(&game_id).ok_or_else(|| {
format!("Game with ID {} not found after installation", game_id)
})?;
// Update installation status
game.cream_installed = true;
game.installing = false;
// Update installation status
game.cream_installed = true;
game.installing = false;
// Clone the game for returning later
game.clone()
};
// Clone the game for returning later
game.clone()
};
// Emit an event to update the UI
if let Err(e) = app_handle.emit("game-updated", &game) {
warn!("Failed to emit game-updated event: {}", e);
}
// Emit an event to update the UI
if let Err(e) = app_handle.emit("game-updated", &game) {
warn!("Failed to emit game-updated event: {}", e);
}
// Show installation complete dialog with instructions
let instructions = installer::InstallationInstructions {
type_: "cream_install".to_string(),
command: "sh ./cream.sh %command%".to_string(),
game_title: game.title.clone(),
dlc_count: Some(selected_dlcs_clone.iter().filter(|dlc| dlc.enabled).count()),
};
// Show installation complete dialog with instructions
let instructions = installer::InstallationInstructions {
type_: "cream_install".to_string(),
command: "sh ./cream.sh %command%".to_string(),
game_title: game.title.clone(),
dlc_count: Some(selected_dlcs_clone.iter().filter(|dlc| dlc.enabled).count()),
};
installer::emit_progress(
&app_handle,
&format!("Installation Completed: {}", game.title),
"CreamLinux has been installed successfully!",
100.0,
true,
true,
Some(instructions),
);
installer::emit_progress(
&app_handle,
&format!("Installation Completed: {}", game.title),
"CreamLinux has been installed successfully!",
100.0,
true,
true,
Some(instructions),
);
Ok(game)
}
Err(e) => {
error!("Failed to install CreamLinux with selected DLCs: {}", e);
Err(format!(
"Failed to install CreamLinux with selected DLCs: {}",
e
))
}
}
Ok(game)
}
Err(e) => {
error!("Failed to install CreamLinux with selected DLCs: {}", e);
Err(format!(
"Failed to install CreamLinux with selected DLCs: {}",
e
))
}
}
}
// Setup logging
fn setup_logging() -> Result<(), Box<dyn std::error::Error>> {
use log::LevelFilter;
use log4rs::append::file::FileAppender;
use log4rs::config::{Appender, Config, Root};
use log4rs::encode::pattern::PatternEncoder;
use std::fs;
use log::LevelFilter;
use log4rs::append::file::FileAppender;
use log4rs::config::{Appender, Config, Root};
use log4rs::encode::pattern::PatternEncoder;
use std::fs;
// Get XDG cache directory
let xdg_dirs = xdg::BaseDirectories::with_prefix("creamlinux")?;
let log_path = xdg_dirs.place_cache_file("creamlinux.log")?;
// Get XDG cache directory
let xdg_dirs = xdg::BaseDirectories::with_prefix("creamlinux")?;
let log_path = xdg_dirs.place_cache_file("creamlinux.log")?;
// Clear the log file on startup
if log_path.exists() {
if let Err(e) = fs::write(&log_path, "") {
eprintln!("Warning: Failed to clear log file: {}", e);
}
}
// Clear the log file on startup
if log_path.exists() {
if let Err(e) = fs::write(&log_path, "") {
eprintln!("Warning: Failed to clear log file: {}", e);
}
}
// Create a file appender
let file = FileAppender::builder()
.encoder(Box::new(PatternEncoder::new(
"[{d(%Y-%m-%d %H:%M:%S)}] {l}: {m}\n",
)))
.build(log_path)?;
// Create a file appender
let file = FileAppender::builder()
.encoder(Box::new(PatternEncoder::new(
"[{d(%Y-%m-%d %H:%M:%S)}] {l}: {m}\n",
)))
.build(log_path)?;
// Build the config
let config = Config::builder()
.appender(Appender::builder().build("file", Box::new(file)))
.build(Root::builder().appender("file").build(LevelFilter::Info))?;
// Build the config
let config = Config::builder()
.appender(Appender::builder().build("file", Box::new(file)))
.build(Root::builder().appender("file").build(LevelFilter::Info))?;
// Initialize log4rs with this config
log4rs::init_config(config)?;
// Initialize log4rs with this config
log4rs::init_config(config)?;
info!("CreamLinux started with a clean log file");
Ok(())
info!("CreamLinux started with a clean log file");
Ok(())
}
fn main() {
// Set up logging first
if let Err(e) = setup_logging() {
eprintln!("Warning: Failed to initialize logging: {}", e);
}
// Set up logging first
if let Err(e) = setup_logging() {
eprintln!("Warning: Failed to initialize logging: {}", e);
}
info!("Initializing CreamLinux application");
info!("Initializing CreamLinux application");
let app_state = AppState {
games: Mutex::new(HashMap::new()),
dlc_cache: Mutex::new(HashMap::new()),
fetch_cancellation: Arc::new(AtomicBool::new(false)),
};
let app_state = AppState {
games: Mutex::new(HashMap::new()),
dlc_cache: Mutex::new(HashMap::new()),
fetch_cancellation: Arc::new(AtomicBool::new(false)),
};
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.manage(app_state)
.invoke_handler(tauri::generate_handler![
scan_steam_games,
get_game_info,
process_game_action,
fetch_game_dlcs,
stream_game_dlcs,
get_enabled_dlcs_command,
update_dlc_configuration_command,
install_cream_with_dlcs_command,
get_all_dlcs_command,
clear_caches,
abort_dlc_fetch,
])
.setup(|app| {
// Add a setup handler to do any initialization work
info!("Tauri application setup");
tauri::Builder::default()
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.manage(app_state)
.invoke_handler(tauri::generate_handler![
scan_steam_games,
get_game_info,
process_game_action,
fetch_game_dlcs,
stream_game_dlcs,
get_enabled_dlcs_command,
update_dlc_configuration_command,
install_cream_with_dlcs_command,
get_all_dlcs_command,
clear_caches,
abort_dlc_fetch,
])
.setup(|app| {
// Add a setup handler to do any initialization work
info!("Tauri application setup");
#[cfg(debug_assertions)]
{
if std::env::var("OPEN_DEVTOOLS").ok().as_deref() == Some("1") {
if let Some(window) = app.get_webview_window("main") {
window.open_devtools();
}
}
}
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
#[cfg(debug_assertions)]
{
if std::env::var("OPEN_DEVTOOLS").ok().as_deref() == Some("1") {
if let Some(window) = app.get_webview_window("main") {
window.open_devtools();
}
}
}
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@@ -4,19 +4,25 @@
"frontendDist": "../dist",
"devUrl": "http://localhost:1420",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build"
"beforeBuildCommand": "npm run sync-version && npm run build"
},
"bundle": {
"active": true,
"targets": "all",
"category": "Utility",
"createUpdaterArtifacts": true,
"icon": ["icons/128x128.png", "icons/128x128@2x.png", "icons/icon.png"]
},
"productName": "Creamlinux",
"mainBinaryName": "creamlinux",
"version": "0.1.0",
"identifier": "com.creamlinux.dev",
"plugins": {},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDJDNEI1NzBBRDUxODQ3RjEKUldUeFJ4alZDbGRMTE5Vc241NG5yL080UklnaW1iUGdUWElPRXloRGtKZ3M2SWkzK0RGSDh3Q2kK",
"endpoints": ["https://github.com/Novattz/rust-gui-dev/releases/latest/download/latest.json"]
}
},
"app": {
"withGlobalTauri": false,
"windows": [

View File

@@ -1,4 +1,5 @@
import { useAppContext } from '@/contexts/useAppContext'
import { UpdateChecker } from '@/components/updater'
import { useAppLogic } from '@/hooks'
import './styles/main.scss'
@@ -104,6 +105,7 @@ function App() {
onClose={handleDlcDialogClose}
onConfirm={handleDlcConfirm}
/>
<UpdateChecker />
</div>
</ErrorBoundary>
)

View File

@@ -0,0 +1,147 @@
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 @@
export { default as UpdateChecker } from './UpdateChecker'

View File

@@ -1 +1,2 @@
@forward './loading';
@forward './updater';

View File

@@ -0,0 +1,85 @@
@use '../../themes/index' as *;
@use '../../abstracts/index' as *;
/*
Update checker component styles
*/
.update-checker {
border-radius: var(--radius-md);
background-color: var(--elevated-bg);
padding: 1.25rem;
margin: 1rem 0;
border: 1px solid var(--border-soft);
box-shadow: var(--shadow-standard);
max-width: 500px;
position: fixed;
bottom: 20px;
right: 20px;
z-index: var(--z-modal) - 1;
&.error {
border-color: var(--danger);
background-color: var(--danger-soft);
}
.update-info {
margin-bottom: 1rem;
h3 {
font-size: 1.2rem;
color: var(--primary-color);
margin-bottom: 0.5rem;
font-weight: var(--bold);
}
p {
color: var(--text-secondary);
margin-bottom: 0.5rem;
font-size: 0.9rem;
}
.update-notes {
font-size: 0.85rem;
color: var(--text-soft);
max-height: 120px;
overflow-y: auto;
padding: 0.5rem;
background-color: rgba(0, 0, 0, 0.1);
border-radius: var(--radius-sm);
white-space: pre-line;
margin-top: 0.5rem;
@include custom-scrollbar;
}
}
.update-progress {
margin-top: 1rem;
.progress-bar-container {
height: 6px;
background-color: var(--border-soft);
border-radius: 3px;
margin-bottom: 0.5rem;
overflow: hidden;
}
.progress-bar {
height: 100%;
background-color: var(--primary-color);
border-radius: 3px;
transition: width 0.3s ease;
}
p {
font-size: 0.8rem;
color: var(--text-secondary);
text-align: right;
}
}
.update-actions {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
}
}