diff --git a/.github/workflows/Build.yml b/.github/workflows/Build.yml new file mode 100644 index 0000000..998489f --- /dev/null +++ b/.github/workflows/Build.yml @@ -0,0 +1,289 @@ +name: Build + +on: + workflow_dispatch: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + release: + types: [published] + +env: + CARGO_TERM_COLOR: always + +jobs: + build-ui: + runs-on: windows-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: yarn + cache-dependency-path: src/celemod-ui/yarn.lock + - name: Install UI dependencies + working-directory: src/celemod-ui + run: yarn install --frozen-lockfile + - name: Build UI resources + working-directory: src/celemod-ui + run: yarn build + - uses: actions/upload-artifact@v4.3.1 + with: + name: ui-dist + path: resources/dist.rc + build-windows: + needs: build-ui + runs-on: windows-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/download-artifact@v4 + with: + name: ui-dist + path: resources + - name: Install latest nightly + uses: actions-rs/toolchain@v1 + with: + toolchain: nightly + override: true + components: rustfmt, clippy + - name: Build Rust + run: cargo build --verbose --release + - name: Copy Dependencies + run: cp ./resources/sciter.dll ./target/release/sciter.dll + - name: Create Windows zip + run: Compress-Archive -Path ./target/release/cele-mod.exe,./target/release/sciter.dll -DestinationPath "celemod-windows.zip" + - uses: actions/upload-artifact@v4.3.1 + with: + name: windows-exe + path: ./target/release/cele-mod.exe + - uses: actions/upload-artifact@v4.3.1 + with: + name: windows-zip + path: celemod-windows.zip + build-linux: + needs: build-ui + runs-on: ubuntu-22.04 + steps: + - name: Install dependencies + run: | + sudo sh -c 'echo "deb http://archive.ubuntu.com/ubuntu focal-updates main" > /etc/apt/sources.list.d/focal-updates.list' + sudo apt-get update + sudo apt-get install -y \ + pkg-config \ + cmake \ + clang \ + libpango-1.0-0 \ + libatk1.0-dev \ + libgtk-3-dev \ + file + - uses: actions/checkout@v3 + - uses: actions/download-artifact@v4 + with: + name: ui-dist + path: resources + - name: Install latest nightly + uses: actions-rs/toolchain@v1 + with: + toolchain: nightly + override: true + components: rustfmt, clippy + - name: Build Rust + run: cargo build --verbose --release + - name: Download AppImage tools + run: | + wget -q https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage + wget -q https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage + chmod +x linuxdeploy-x86_64.AppImage appimagetool-x86_64.AppImage + - name: Create AppDir + run: | + mkdir -p AppDir/usr/bin + mkdir -p AppDir/usr/lib + mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps + mkdir -p AppDir/usr/share/applications + + # Copy binary and library + cp ./target/release/cele-mod AppDir/usr/bin/cele-mod + cp ./resources/libsciter.so AppDir/usr/lib/libsciter.so + chmod +x AppDir/usr/bin/cele-mod + + # Copy icon + cp ./resources/icon.png AppDir/usr/share/icons/hicolor/256x256/apps/celemod.png + + # Create .desktop file + cat > AppDir/usr/share/applications/celemod.desktop << 'EOF' + [Desktop Entry] + Name=CeleMod + Comment=Celeste Mod Manager + Exec=cele-mod + Icon=celemod + Terminal=false + Type=Application + Categories=Game;Utility; + EOF + + # Create AppRun wrapper script to set GDK_BACKEND=x11 + cat > AppDir/AppRun << 'EOF' + #!/bin/sh + export GDK_BACKEND=x11 + exec "$(dirname "$(readlink -f "$0")")/usr/bin/cele-mod" "$@" + EOF + chmod +x AppDir/AppRun + + # Create symlinks + ln -s usr/share/applications/celemod.desktop AppDir/celemod.desktop + ln -s usr/share/icons/hicolor/256x256/apps/celemod.png AppDir/celemod.png + - name: Build AppImage + run: | + ARCH=x86_64 ./linuxdeploy-x86_64.AppImage --appdir AppDir --output appimage || true + # Fallback: use appimagetool directly if linuxdeploy fails + if [ ! -f CeleMod-*.AppImage ]; then + ARCH=x86_64 ./appimagetool-x86_64.AppImage AppDir CeleMod-x86_64.AppImage + fi + mv CeleMod-*.AppImage celemod-linux.AppImage || true + - name: Create zip fallback + run: | + mkdir -p celemod-linux + cp ./target/release/cele-mod celemod-linux/cele-mod + cp ./resources/libsciter.so celemod-linux/libsciter.so + cp ./resources/icon.png celemod-linux/celemod.png + zip -r "celemod-linux.zip" celemod-linux + - uses: actions/upload-artifact@v4.3.1 + with: + name: linux-zip + path: celemod-linux.zip + - uses: actions/upload-artifact@v4.3.1 + with: + name: linux-appimage + path: celemod-linux.AppImage + build-macos: + needs: build-ui + runs-on: macos-latest + steps: + - name: Install dependencies + run: | + brew install pango + brew install gtk+3 + brew install protobuf + brew install create-dmg + - uses: actions/checkout@v3 + - uses: actions/download-artifact@v4 + with: + name: ui-dist + path: resources + - name: Install latest nightly + uses: actions-rs/toolchain@v1 + with: + toolchain: nightly + override: true + components: rustfmt, clippy + - name: Build Rust MacOS + run: export CMAKE_POLICY_VERSION_MINIMUM=3.5 && cargo build --verbose --release + - name: Create macOS app bundle + run: | + mkdir -p CeleMod.app/Contents/MacOS + mkdir -p CeleMod.app/Contents/Resources + cp ./target/release/cele-mod CeleMod.app/Contents/MacOS/CeleMod + cp ./resources/libsciter.dylib CeleMod.app/Contents/MacOS/libsciter.dylib + cp ./resources/icon.icns CeleMod.app/Contents/Resources/AppIcon.icns + chmod +x CeleMod.app/Contents/MacOS/CeleMod + + # Create Info.plist with icon + cat > CeleMod.app/Contents/Info.plist << 'EOF' + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + CeleMod + CFBundleIconFile + AppIcon + CFBundleIdentifier + com.celemod.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + CeleMod + CFBundleDisplayName + CeleMod + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 10.13 + NSHighResolutionCapable + + NSHumanReadableCopyright + Copyright © 2024 CeleMod. All rights reserved. + + + EOF + - name: Ad-hoc sign app bundle + run: | + codesign --force --deep -s - CeleMod.app + - name: Create DMG (optional) + run: | + create-dmg --volname "CeleMod" --volicon "./resources/icon.icns" --window-pos 200 120 --window-size 800 400 --icon-size 100 --app-drop-link 600 185 "CeleMod.dmg" "CeleMod.app" || true + # If create-dmg fails, just zip the app + if [ ! -f CeleMod.dmg ]; then + zip -r "celemod-macos.zip" CeleMod.app + fi + - name: Prepare artifact + run: | + if [ -f CeleMod.dmg ]; then + mv CeleMod.dmg celemod-macos.dmg + else + # Ensure zip exists + if [ ! -f celemod-macos.zip ]; then + zip -r "celemod-macos.zip" CeleMod.app + fi + fi + - uses: actions/upload-artifact@v4.3.1 + with: + name: macos-app + path: | + celemod-macos.dmg + celemod-macos.zip + release: + runs-on: ubuntu-latest + needs: [build-windows, build-linux, build-macos] + if: github.event_name == 'release' + steps: + - uses: actions/download-artifact@v4 + with: + name: windows-exe + path: . + - uses: actions/download-artifact@v4 + with: + name: windows-zip + path: . + - uses: actions/download-artifact@v4 + with: + name: linux-zip + path: . + - uses: actions/download-artifact@v4 + with: + name: linux-appimage + path: . + - uses: actions/download-artifact@v4 + with: + name: macos-app + path: . + - name: Prepare release files + run: mv cele-mod.exe "cele-mod-no-dependencies.exe" + - uses: softprops/action-gh-release@v1 + with: + files: | + cele-mod-no-dependencies.exe + celemod-windows.zip + celemod-linux.zip + celemod-linux.AppImage + celemod-macos.dmg + celemod-macos.zip + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/resources/dist.rc b/resources/dist.rc index fc76ab0..b4d6b93 100644 Binary files a/resources/dist.rc and b/resources/dist.rc differ diff --git a/src/celemod-ui/src/routes/Everest.scss b/src/celemod-ui/src/routes/Everest.scss index faa7f0b..9213b16 100644 --- a/src/celemod-ui/src/routes/Everest.scss +++ b/src/celemod-ui/src/routes/Everest.scss @@ -135,9 +135,8 @@ horizontal-align: center; .wrapperin { - padding-right: 20px; - padding-bottom: 10px; - font-size: 100px; + padding-bottom: 14px; + font-size: 88px; } &>* { @@ -145,30 +144,33 @@ } .tip { - font-size: 20px; + font-size: 34px; font-weight: 700; - margin-right: 20px; + margin-right: 0; + margin-bottom: 8px; } .url { - font-size: 15px; + font-size: 14px; font-weight: 500; - margin-right: 12px; - max-width: 90%; + max-width: 78%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - opacity: 0.8; + opacity: 0.62; } .state { - font-size: 15px; - font-weight: 700; - margin-top: 10px; - padding-right: 20px; - width: 90%; + font-size: 13px; + font-weight: 500; + margin-top: 8px; + width: 78%; text-align: center; horizontal-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + opacity: 0.72; textarea { text-align: left; diff --git a/src/celemod-ui/src/routes/Everest.tsx b/src/celemod-ui/src/routes/Everest.tsx index a8e4d06..e9805be 100644 --- a/src/celemod-ui/src/routes/Everest.tsx +++ b/src/celemod-ui/src/routes/Everest.tsx @@ -32,6 +32,22 @@ interface Maddie480EverestVersion { isNative: boolean; } +const getInstallTip = (state: string | null) => { + if (state?.startsWith('[1/3]')) return '正在下载'; + if (state?.startsWith('[2/3]')) return '正在解压'; + return '正在安装'; +}; + +const getInstallDetail = (state: string | null) => { + if (!state) return null; + return state + .replace(/^\[\d+\/\d+\]\s*/, '') + .replace(/^Download Everest:?/i, '') + .replace(/^Extract Everest files:?/i, '') + .replace(/^Run MiniInstaller:?/i, '') + .trim(); +}; + const Channel = ({ dataFull, branch, @@ -106,7 +122,7 @@ export const Everest = () => { setInstallingUrl(url); setInstallProgress(null); setFailedReason(null); - setInstallState('Downloading Everest'); + setInstallState('[1/3] Download Everest'); callRemote( 'download_and_install_everest', gamePath, @@ -283,13 +299,11 @@ export const Everest = () => { })} /> -
- {installState === 'Downloading Everest' - ? _i18n.t('正在下载') - : _i18n.t('正在安装')} -
+
{getInstallTip(installState)}
{installingUrl}
-
{installState}
+ {getInstallDetail(installState) ? ( +
{getInstallDetail(installState)}
+ ) : null} )} diff --git a/src/celemod-ui/src/states.ts b/src/celemod-ui/src/states.ts index 141d220..79f4434 100644 --- a/src/celemod-ui/src/states.ts +++ b/src/celemod-ui/src/states.ts @@ -141,7 +141,7 @@ const createPersistedStateByKey = (key: string, defaultValue: T) => createPer export const [initMirror, useMirror, currentMirror] = createPersistedStateByKey('mirror', 'wegfan') export const [initGamePath, useGamePath] = createPersistedState('', storage => { if (storage?.root?.lastGamePath) - return storage.root.lastGamePath + return callRemote("normalize_game_path", storage.root.lastGamePath) const paths = callRemote("get_celeste_dirs").split("\n").filter((v: string | null) => v); return paths[0] }, (storage, data, save) => { @@ -157,4 +157,4 @@ export const [initSearchSort, useSearchSort] = createPersistedStateByKey<'new' | export const [initAutoDisableNewMods, useAutoDisableNewMods] = createPersistedStateByKey('autoDisableNewMods', false) -export const [initModComments, useModComments] = createPersistedStateByKey('modComments', {}) \ No newline at end of file +export const [initModComments, useModComments] = createPersistedStateByKey('modComments', {}) diff --git a/src/celemod-ui/src/utils.ts b/src/celemod-ui/src/utils.ts index 6b59d5c..b3749a1 100644 --- a/src/celemod-ui/src/utils.ts +++ b/src/celemod-ui/src/utils.ts @@ -134,7 +134,11 @@ export const selectGamePath = (successCallback) => { // strip file:// and Celeste.exe const prefix = "file://".length; const decoded = decodeURI(res); - const path = dirname(decoded.slice(prefix)); + const path = callRemote("normalize_game_path", dirname(decoded.slice(prefix))); + if (!callRemote("verify_celeste_install", path)) { + alert("Invalid Celeste install path."); + return; + } console.log("Selected", path); successCallback(path); return path; diff --git a/src/everest.rs b/src/everest.rs index 528fd25..0f98c29 100644 --- a/src/everest.rs +++ b/src/everest.rs @@ -1,15 +1,15 @@ use crate::{ureq, wegfan}; -use anyhow::bail; +use ::ureq::get; +use anyhow::{Context, bail}; use lazy_static::lazy_static; use serde::{Deserialize, Serialize}; -use ::ureq::get; use std::{ collections::HashMap, - io::{BufRead, BufReader}, + io::{BufRead, BufReader, Write}, path::{Path, PathBuf}, process::{Command, Stdio}, - sync::{atomic::AtomicBool, Arc}, + sync::{Arc, atomic::AtomicBool}, }; #[derive(Serialize, Deserialize)] @@ -101,8 +101,8 @@ static MAGIC_STR: &str = "EverestBuild"; static MAGIC_STR_ONLY_ORIGIN_EXE: &str = "_StarJumpEnd+"; pub fn get_everest_version(game_path: &str) -> Option { - fn check_file(path: String) -> Option { - println!("Checking {path}"); + fn check_file(path: PathBuf) -> Option { + println!("Checking {}", path.display()); let buf = std::fs::read(path).ok()?; let str = unsafe { std::str::from_utf8_unchecked(&buf) }; let pos = str.find(MAGIC_STR); @@ -116,16 +116,18 @@ pub fn get_everest_version(game_path: &str) -> Option { Some(str) } - check_file(game_path.to_owned() + "/Celeste.exe") + let game_path = Path::new(game_path); + + check_file(game_path.join("Celeste.exe")) .or_else(|| { - if let Ok(data) = std::fs::read(game_path.to_owned() + "/Celeste.exe") + if let Ok(data) = std::fs::read(game_path.join("Celeste.exe")) && data .windows(MAGIC_STR_ONLY_ORIGIN_EXE.as_bytes().len()) .any(|window| window == MAGIC_STR_ONLY_ORIGIN_EXE.as_bytes()) { None } else { - check_file(game_path.to_owned() + "/Celeste.dll") + check_file(game_path.join("Celeste.dll")) } }) .or(None) @@ -139,7 +141,6 @@ fn run_command( cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::piped()); const CREATE_NO_WINDOW: u32 = 0x08000000; - const DETACHED_PROCESS: u32 = 0x00000008; #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; #[cfg(target_os = "windows")] @@ -151,28 +152,80 @@ fn run_command( .ok_or_else(|| anyhow::anyhow!("Invalid installer path"))?, ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let metadata = std::fs::metadata(&installer_path)?; + let mut permissions = metadata.permissions(); + permissions.set_mode(permissions.mode() | 0o755); + std::fs::set_permissions(&installer_path, permissions)?; + } + let mut child = cmd.spawn()?; - let stdout = child.stdout.take().unwrap(); + let stdout = child + .stdout + .take() + .context("Failed to capture installer stdout")?; + let stderr = child + .stderr + .take() + .context("Failed to capture installer stderr")?; let reader = BufReader::new(stdout); + let stderr_handle = std::thread::spawn(move || { + let mut lines = Vec::new(); + for line in BufReader::new(stderr).lines() { + match line { + Ok(line) => lines.push(line), + Err(err) => { + lines.push(format!("Failed to read installer stderr: {err}")); + break; + } + } + } + lines + }); - let mut line_count = 50f32; + let mut line_count = 0f32; for line in reader.lines() { let line = line?; - line_count += 0.5; - progress_callback(line, line_count); + line_count = (line_count + 1).min(99.0); + progress_callback(format!("[3/3] Run MiniInstaller: {line}"), line_count); } - let output = child.wait_with_output()?; - - let stderr = String::from_utf8(output.stderr)?; + let status = child.wait()?; + let stderr = stderr_handle + .join() + .unwrap_or_else(|_| vec!["Failed to join installer stderr reader".to_string()]) + .join("\n"); - if !output.status.success() { + if !status.success() { bail!("Command failed with error: {}", stderr); } + progress_callback("[3/3] Run MiniInstaller".to_string(), 100.0); + Ok(()) } +#[cfg(target_os = "windows")] +fn installer_name() -> anyhow::Result<&'static str> { + match std::env::consts::ARCH { + "x86_64" => Ok("MiniInstaller-win64.exe"), + "x86" => Ok("MiniInstaller-win.exe"), + arch => bail!("Unsupported Windows architecture: {arch}"), + } +} + +#[cfg(target_os = "macos")] +fn installer_name() -> anyhow::Result<&'static str> { + Ok("MiniInstaller-osx") +} + +#[cfg(target_os = "linux")] +fn installer_name() -> anyhow::Result<&'static str> { + Ok("MiniInstaller-linux") +} + pub fn download_and_install_everest( game_path: &str, url: &str, @@ -181,41 +234,36 @@ pub fn download_and_install_everest( let generate_backup = false; let temp_path = std::env::temp_dir().join("everest.zip"); - let game_path = std::path::Path::new(game_path); - let temp_path = temp_path.to_str().unwrap(); - let game_path = game_path.to_str().unwrap(); + let game_path = std::path::Path::new(game_path); let cancel_flag = Arc::new(AtomicBool::new(false)); ureq::download_file_with_progress( url, temp_path, &mut |callback| { - progress_callback("Downloading Everest".to_string(), callback.progress); + progress_callback("[1/3] Download Everest".to_string(), callback.progress); }, false, &cancel_flag, )?; - progress_callback("Installing Everest".to_string(), 50.0); + progress_callback("[2/3] Extract Everest files".to_string(), 0.0); // unzip everest/main/* to game_path and overwrite all let mut archive = zip::ZipArchive::new(std::fs::File::open(temp_path)?)?; let archive_len = archive.len(); - let backup_dir = std::path::Path::new(game_path).join("backup"); + let backup_dir = game_path.join("backup"); for i in 0..archive_len { let mut file = archive.by_index(i)?; let dist_name = file.mangled_name(); // strip /main/ from the name let dist_name = dist_name.strip_prefix("main/")?; - let outpath = std::path::Path::new(game_path).join(dist_name); - let status_str = format!("Extracting {}", outpath.display()); - progress_callback( - status_str, - (i as f32) / (archive_len as f32) / 2f32 * 100f32, - ); + let outpath = game_path.join(dist_name); + let status_str = format!("[2/3] Extract Everest files: {}", outpath.display()); + progress_callback(status_str, (i as f32) / (archive_len as f32) * 100.0); if file.name().ends_with('/') { std::fs::create_dir_all(&outpath)?; } else { @@ -235,22 +283,12 @@ pub fn download_and_install_everest( let mut outfile = std::fs::File::create(&outpath)?; std::io::copy(&mut file, &mut outfile)?; + outfile.flush()?; } } - let target = match std::env::consts::ARCH { - "x86_64" => "win-x64", - "x86" => "win-x86", - _ => unimplemented!("Unsupported target"), - }; - - let installer_name = match target { - "win-x64" => "MiniInstaller-win64.exe", - "win-x86" => "MiniInstaller-win.exe", - _ => unimplemented!("Unsupported target"), - }; - - let installer_path = std::path::Path::new(game_path).join(installer_name); + progress_callback("[3/3] Run MiniInstaller".to_string(), 0.0); + let installer_path = game_path.join(installer_name()?); run_command(installer_path, progress_callback) } diff --git a/src/main.rs b/src/main.rs index 8d3b98d..d854957 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,6 @@ use serde::{Deserialize, Serialize}; use anyhow::{Context, bail}; -use ureq::DownloadCallbackInfo; use dirs; use everest::get_mod_cached_new; use game_scanner::prelude::Game; @@ -14,8 +13,12 @@ use std::{ fs, io::Read, path::{Path, PathBuf}, - sync::{Arc, Mutex, atomic::{AtomicBool, AtomicUsize, Ordering}}, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, AtomicUsize, Ordering}, + }, }; +use ureq::DownloadCallbackInfo; static TEST_MODE: AtomicBool = AtomicBool::new(false); @@ -44,9 +47,9 @@ lazy_static::lazy_static! { static ref DOWNLOAD_CANCEL_FLAGS: Mutex>> = Mutex::new(HashMap::new()); } -mod ureq; mod blacklist; mod everest; +mod ureq; mod wegfan; #[macro_use] @@ -123,7 +126,13 @@ fn get_invalid_zip_mod_files(mods_folder_path: &str) -> Vec { entries .filter_map(|entry| entry.ok()) .filter(|entry| entry.file_type().map(|v| v.is_file()).unwrap_or(false)) - .filter(|entry| entry.path().extension().map(|v| v == "zip").unwrap_or(false)) + .filter(|entry| { + entry + .path() + .extension() + .map(|v| v == "zip") + .unwrap_or(false) + }) .filter(|entry| !is_valid_zip_archive(&entry.path())) .filter_map(|entry| entry.file_name().into_string().ok()) .collect() @@ -237,12 +246,23 @@ fn delete_mod_files(mods_folder_path: &str, file_names: &[String]) -> anyhow::Re Ok(()) } -fn download_mod_archive(url: &str, dest: &str, progress_callback: &mut dyn FnMut(DownloadCallbackInfo), multi_thread: bool) -> anyhow::Result<()> { +fn download_mod_archive( + url: &str, + dest: &str, + progress_callback: &mut dyn FnMut(DownloadCallbackInfo), + multi_thread: bool, +) -> anyhow::Result<()> { let cancel_flag = Arc::new(AtomicBool::new(false)); download_mod_archive_with_cancel(url, dest, progress_callback, multi_thread, &cancel_flag) } -fn download_mod_archive_with_cancel(url: &str, dest: &str, progress_callback: &mut dyn FnMut(DownloadCallbackInfo), multi_thread: bool, cancel_flag: &Arc) -> anyhow::Result<()> { +fn download_mod_archive_with_cancel( + url: &str, + dest: &str, + progress_callback: &mut dyn FnMut(DownloadCallbackInfo), + multi_thread: bool, + cancel_flag: &Arc, +) -> anyhow::Result<()> { let tmp_dir = std::env::temp_dir().join("CelemodTemp").join("mods"); std::fs::create_dir_all(&tmp_dir)?; @@ -326,8 +346,14 @@ fn get_installed_mods_sync(mods_folder_path: String) -> Vec { let mut mods = Vec::new(); let mod_data = get_mod_cached_new().unwrap(); - for entry in fs::read_dir(mods_folder_path).unwrap() { - let entry = entry.unwrap(); + let Ok(entries) = fs::read_dir(mods_folder_path) else { + return mods; + }; + + for entry in entries { + let Ok(entry) = entry else { + continue; + }; println!("Checking mod entry: {:?}", entry.file_name()); let res: anyhow::Result<_> = try { if false { @@ -508,6 +534,91 @@ fn get_celestes() -> Vec { games } +fn normalize_game_path(path: &str) -> String { + normalize_game_path_buf(Path::new(path)) + .to_string_lossy() + .to_string() +} + +fn normalize_game_path_buf(path: &Path) -> PathBuf { + #[cfg(target_os = "macos")] + { + fn has_game_artifact(path: &Path) -> bool { + path.join("Celeste.exe").is_file() + || path.join("Celeste.dll").is_file() + || path.join("Celeste").is_file() + } + + fn is_named(path: &Path, name: &str) -> bool { + path.file_name().and_then(|v| v.to_str()) == Some(name) + } + + fn resources_if_valid(path: PathBuf) -> Option { + if path.is_dir() + && (has_game_artifact(&path) + || path + .parent() + .map(|contents| contents.join("MacOS").join("Celeste").is_file()) + .unwrap_or(false)) + { + Some(path) + } else { + None + } + } + + let path = if path.is_file() { + path.parent().unwrap_or(path) + } else { + path + }; + + if is_named(path, "Resources") { + if let Some(resources) = resources_if_valid(path.to_path_buf()) { + return resources; + } + } + + if is_named(path, "MacOS") { + if let Some(contents) = path.parent() { + if let Some(resources) = resources_if_valid(contents.join("Resources")) { + return resources; + } + } + } + + if is_named(path, "Contents") { + if let Some(resources) = resources_if_valid(path.join("Resources")) { + return resources; + } + } + + if path.extension().and_then(|v| v.to_str()) == Some("app") { + if let Some(resources) = resources_if_valid(path.join("Contents").join("Resources")) { + return resources; + } + } + + if let Some(resources) = + resources_if_valid(path.join("Celeste.app").join("Contents").join("Resources")) + { + return resources; + } + + if has_game_artifact(path) { + if let Some(parent) = path.parent() { + if is_named(parent, "Contents") { + if let Some(resources) = resources_if_valid(parent.join("Resources")) { + return resources; + } + } + } + } + } + + path.to_path_buf() +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] enum DownloadStatus { Waiting, @@ -543,6 +654,10 @@ impl Handler { _use_cn_proxy: bool, multi_thread: bool, ) { + if let Err(e) = std::fs::create_dir_all(&mods_dir) { + eprintln!("Failed to create mods dir {}: {}", mods_dir, e); + } + let dest = Path::new(&mods_dir) .join(make_path_compatible_name(&name) + ".zip") .to_str() @@ -585,7 +700,11 @@ impl Handler { Err(e) => { eprintln!("Failed to get mod data: {}", e); callback - .call(None, &make_args!(format!("Failed to get mod data: {}", e)), None) + .call( + None, + &make_args!(format!("Failed to get mod data: {}", e)), + None, + ) .unwrap(); return; } @@ -600,7 +719,8 @@ impl Handler { let post_callback: Arc, &str) + Send + Sync> = { let sync_cb = Arc::clone(&sync_cb); Arc::new(move |tasklist: &Vec, state: &str| { - sync_cb.0 + sync_cb + .0 .call( None, &make_args!(serde_json::to_string(tasklist).unwrap(), state), @@ -679,7 +799,8 @@ impl Handler { deps.into_iter() .filter_map(|(dep, min_ver)| { if installed_mods.iter().any(|m| { - m.name == dep && compare_version(&m.version, &min_ver) >= 0 + m.name == dep + && compare_version(&m.version, &min_ver) >= 0 }) { return None; } @@ -847,29 +968,53 @@ impl Handler { } get_celestes() .iter() - .map(|game| game.path.clone().unwrap().to_str().unwrap().to_string()) + .map(|game| normalize_game_path_buf(&game.path.clone().unwrap())) + .map(|path| path.to_string_lossy().to_string()) .collect::>() .join("\n") } fn start_game(&self, path: String) { + let path = normalize_game_path(&path); let celestes = get_celestes(); - let game = celestes - .iter() - .find(|game| game.path.clone().unwrap().to_str().unwrap() == path) - .unwrap(); - game_scanner::manager::launch_game(game).unwrap(); + if let Some(game) = celestes.iter().find(|game| { + normalize_game_path_buf(&game.path.clone().unwrap()) + .to_string_lossy() + .to_string() + == path + }) { + game_scanner::manager::launch_game(game).unwrap(); + } else { + self.start_game_directly(path, false); + } } fn start_game_directly(&self, path: String, origin: bool) { - #[cfg(windows)] - let file = "Celeste.exe"; + let path = normalize_game_path(&path); + let path = Path::new(&path); - #[cfg(unix)] - let file = "Celeste"; + #[cfg(windows)] + let game = path.join("Celeste.exe"); + + #[cfg(all(unix, not(target_os = "macos")))] + let game = path.join("Celeste"); + + #[cfg(target_os = "macos")] + let game = { + let direct = path.join("Celeste"); + if direct.exists() { + direct + } else if path.file_name().and_then(|name| name.to_str()) == Some("Resources") { + path.parent().unwrap_or(path).join("MacOS").join("Celeste") + } else { + direct + } + }; - let game = Path::new(&path).join(file); - let game_origin = Path::new(&path).join("orig").join(file); + let game_origin = path.join("orig").join( + game.file_name() + .unwrap_or_else(|| std::ffi::OsStr::new("Celeste")), + ); if origin { if game_origin.exists() { @@ -948,6 +1093,7 @@ impl Handler { fn get_blacklist_profiles(&self, game_path: String, callback: sciter::Value) { std::thread::spawn(move || { + let game_path = normalize_game_path(&game_path); let profiles = blacklist::get_mod_blacklist_profiles(&game_path); callback .call( @@ -965,6 +1111,7 @@ impl Handler { profile_name: String, always_on_mods: String, ) -> String { + let game_path = normalize_game_path(&game_path); let always_on_mods: Vec = serde_json::from_str(&always_on_mods).unwrap(); let result = blacklist::apply_mod_blacklist_profile(&game_path, &profile_name, &always_on_mods); @@ -984,6 +1131,7 @@ impl Handler { mod_files: String, enabled: bool, ) -> String { + let game_path = normalize_game_path(&game_path); let mod_names: Vec = serde_json::from_str(&mod_names).unwrap(); let mod_files: Vec = serde_json::from_str(&mod_files).unwrap(); let mods: Vec<(&String, &String)> = mod_names.iter().zip(mod_files.iter()).collect(); @@ -999,6 +1147,7 @@ impl Handler { } fn new_mod_blacklist_profile(&self, game_path: String, profile_name: String) -> String { + let game_path = normalize_game_path(&game_path); let result = blacklist::new_mod_blacklist_profile(&game_path, &profile_name); if let Err(e) = result { eprintln!("Failed to create blacklist profile: {}", e); @@ -1009,6 +1158,7 @@ impl Handler { } fn get_current_profile(&self, game_path: String) -> String { + let game_path = normalize_game_path(&game_path); let result = blacklist::get_current_profile(&game_path); if let Err(e) = result { eprintln!("Failed to get current profile: {}", e); @@ -1019,6 +1169,7 @@ impl Handler { } fn remove_mod_blacklist_profile(&self, game_path: String, profile_name: String) -> String { + let game_path = normalize_game_path(&game_path); let result = blacklist::remove_mod_blacklist_profile(&game_path, &profile_name); if let Err(e) = result { eprintln!("Failed to remove blacklist profile: {}", e); @@ -1029,6 +1180,7 @@ impl Handler { } fn get_current_blacklist_content(&self, game_path: String) -> String { + let game_path = normalize_game_path(&game_path); let result = blacklist::get_current_blacklist_content(&game_path); if let Err(e) = result { eprintln!("Failed to get current blacklist content: {}", e); @@ -1100,6 +1252,7 @@ impl Handler { } fn sync_blacklist_profile_from_file(&self, game_path: String, profile_name: String) -> String { + let game_path = normalize_game_path(&game_path); let result = blacklist::sync_blacklist_profile_from_file(&game_path, &profile_name); if let Err(e) = result { eprintln!("Failed to sync blacklist profile: {}", e); @@ -1109,7 +1262,13 @@ impl Handler { } } - fn set_mod_options_order(&self, game_path: String, profile_name: String, order_json: String) -> String { + fn set_mod_options_order( + &self, + game_path: String, + profile_name: String, + order_json: String, + ) -> String { + let game_path = normalize_game_path(&game_path); let order: Vec = match serde_json::from_str(&order_json) { Ok(v) => v, Err(e) => return format!("Failed to parse order: {}", e), @@ -1183,6 +1342,7 @@ impl Handler { fn delete_mods(&self, game_path: String, mod_names: String, callback: sciter::Value) { std::thread::spawn(move || { + let game_path = normalize_game_path(&game_path); let mods_folder_path = Path::new(&game_path) .join("Mods") .to_string_lossy() @@ -1208,7 +1368,12 @@ impl Handler { }); } - fn delete_mod_files(&self, mods_folder_path: String, file_names: String, callback: sciter::Value) { + fn delete_mod_files( + &self, + mods_folder_path: String, + file_names: String, + callback: sciter::Value, + ) { std::thread::spawn(move || { let file_names: Vec = serde_json::from_str(&file_names).unwrap_or_default(); let result = match delete_mod_files(&mods_folder_path, &file_names) { @@ -1225,6 +1390,7 @@ impl Handler { let version = if is_test_mode() { "4000".to_string() } else { + let game_path = normalize_game_path(&game_path); everest::get_everest_version(&game_path) .map(|v| v.to_string()) .unwrap_or_default() @@ -1244,6 +1410,7 @@ impl Handler { callback.call(None, &make_args!("Success"), None).unwrap(); return; } + let game_path = normalize_game_path(&game_path); let callback2 = callback.clone(); match everest::download_and_install_everest(&game_path, &url, &mut |msg, progress| { callback @@ -1251,7 +1418,7 @@ impl Handler { .unwrap(); }) { Ok(()) => { - callback2.call(None, &make_args!("Success"), None).unwrap(); + callback2.call(None, &make_args!("Success", 100.0), None).unwrap(); } Err(e) => { callback2 @@ -1312,16 +1479,32 @@ impl Handler { if is_test_mode() && path == get_test_game_path().to_string_lossy() { return true; } + let path = normalize_game_path(&path); let path = Path::new(&path); - let checklist = vec!["Celeste.exe", "Celeste"]; + let checklist = vec!["Celeste.exe", "Celeste", "Celeste.dll"]; for file in checklist { if path.join(file).exists() { return true; } } + #[cfg(target_os = "macos")] + { + if path.file_name().and_then(|name| name.to_str()) == Some("Resources") + && path + .parent() + .map(|contents| contents.join("MacOS").join("Celeste").exists()) + .unwrap_or(false) + { + return true; + } + } false } + fn normalize_game_path(&self, path: String) -> String { + normalize_game_path(&path) + } + fn show_log_window(&self) { #[cfg(windows)] { @@ -1365,6 +1548,7 @@ impl sciter::EventHandler for Handler { fn do_self_update(String, Value); fn start_game_directly(String, bool); fn verify_celeste_install(String); + fn normalize_game_path(String); fn get_mod_latest_info(Value); fn show_log_window(); fn get_current_blacklist_content(String); @@ -1489,20 +1673,19 @@ fn main() { #[cfg(not(target_os = "windows"))] const INDEX_HTML: &str = "index.html"; - #[cfg(debug_assertions)] frame.load_html( read_to_string_bom(Path::new("../../src/celemod-ui/debug_index.html")) .unwrap() - .as_bytes(), Some( - &format!("app://{}", INDEX_HTML) - )); + .as_bytes(), + Some(&format!("app://{}", INDEX_HTML)), + ); #[cfg(not(debug_assertions))] { frame .archive_handler(include_bytes!("../resources/dist.rc")) .unwrap(); - + frame.load_file(&format!("this://app/{}", INDEX_HTML)); }