├── gui ├── src-tauri │ ├── src │ │ ├── build.rs │ │ ├── error.rs │ │ ├── progress.rs │ │ ├── main.rs │ │ └── app.rs │ ├── icons │ │ ├── icon.ico │ │ ├── icon.png │ │ ├── 32x32.png │ │ └── 128x128.png │ ├── Cargo.toml │ └── tauri.conf.json ├── public │ ├── favicon.png │ ├── images │ │ ├── bg.png │ │ ├── fish1.png │ │ ├── fish2.png │ │ ├── fish3.png │ │ └── logo.png │ ├── index.html │ ├── fontface.css │ └── global.css ├── svelte.config.js ├── src │ ├── store │ │ ├── currentGame.ts │ │ ├── downloadStore.ts │ │ ├── currentVersioning.ts │ │ └── versionStore.ts │ ├── components │ │ ├── LinkRenderer.svelte │ │ ├── ProgressBar.svelte │ │ ├── Changelog.svelte │ │ ├── GameTabs.svelte │ │ └── Sidebar.svelte │ ├── main.ts │ ├── global.d.ts │ ├── App.svelte │ └── utils │ │ └── constants.ts ├── tsconfig.json ├── package.json ├── .eslintrc.js ├── rollup.config.js └── yarn.lock ├── core ├── src │ ├── constant.rs │ ├── tracker.rs │ ├── archive │ │ ├── mod.rs │ │ ├── gz.rs │ │ └── zip.rs │ ├── error.rs │ ├── github │ │ ├── api.rs │ │ └── mod.rs │ ├── lib.rs │ ├── http.rs │ ├── release.rs │ └── storage.rs └── Cargo.toml ├── .gitignore ├── Cargo.toml ├── cli ├── src │ ├── main.rs │ ├── lib.rs │ ├── args.rs │ ├── progress.rs │ └── app.rs └── Cargo.toml ├── .github ├── dependabot.yml └── workflows │ ├── cron.yml │ ├── ci.yml │ └── cd.yml ├── LICENSE-MIT ├── README.md └── LICENSE-APACHE /gui/src-tauri/src/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /gui/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spicylobstergames/SpicyLauncher/HEAD/gui/public/favicon.png -------------------------------------------------------------------------------- /gui/public/images/bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spicylobstergames/SpicyLauncher/HEAD/gui/public/images/bg.png -------------------------------------------------------------------------------- /gui/public/images/fish1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spicylobstergames/SpicyLauncher/HEAD/gui/public/images/fish1.png -------------------------------------------------------------------------------- /gui/public/images/fish2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spicylobstergames/SpicyLauncher/HEAD/gui/public/images/fish2.png -------------------------------------------------------------------------------- /gui/public/images/fish3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spicylobstergames/SpicyLauncher/HEAD/gui/public/images/fish3.png -------------------------------------------------------------------------------- /gui/public/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spicylobstergames/SpicyLauncher/HEAD/gui/public/images/logo.png -------------------------------------------------------------------------------- /gui/src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spicylobstergames/SpicyLauncher/HEAD/gui/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /gui/src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spicylobstergames/SpicyLauncher/HEAD/gui/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /gui/src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spicylobstergames/SpicyLauncher/HEAD/gui/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /gui/src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spicylobstergames/SpicyLauncher/HEAD/gui/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /core/src/constant.rs: -------------------------------------------------------------------------------- 1 | pub const DATA_DIR: &str = "spicy-launcher"; 2 | pub const TEMP_DOWNLOAD_DIR: &str = "spicylauncher-downloads"; 3 | -------------------------------------------------------------------------------- /gui/svelte.config.js: -------------------------------------------------------------------------------- 1 | import preprocess from "svelte-preprocess"; 2 | 3 | export default { 4 | preprocess: preprocess({}), 5 | }; 6 | -------------------------------------------------------------------------------- /gui/src/store/currentGame.ts: -------------------------------------------------------------------------------- 1 | import { writable } from "svelte/store"; 2 | import type { Game } from "../global"; 3 | 4 | export const currentGame = writable("jumpy"); 5 | -------------------------------------------------------------------------------- /gui/src/components/LinkRenderer.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled files and executables 2 | /target/ 3 | 4 | # Backup files generated by rustfmt 5 | **/*.rs.bk 6 | 7 | # Frontend files 8 | **/node_modules/ 9 | **/public/build/ 10 | **/.DS_Store 11 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "core", 4 | "cli", 5 | "gui/src-tauri" 6 | ] 7 | 8 | [profile.release] 9 | panic = "abort" 10 | codegen-units = 1 11 | lto = true 12 | incremental = false 13 | opt-level = "s" 14 | strip = true 15 | -------------------------------------------------------------------------------- /gui/src/store/downloadStore.ts: -------------------------------------------------------------------------------- 1 | import { writable } from "svelte/store"; 2 | import type { StoreProgress } from "../global"; 3 | 4 | export const downloadProgress = writable({ 5 | event: "idle", 6 | received: 0, 7 | total: 0, 8 | percent: 0, 9 | }); 10 | -------------------------------------------------------------------------------- /gui/src-tauri/src/error.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error as ThisError; 2 | 3 | #[derive(ThisError, Debug)] 4 | pub enum Error { 5 | #[error(transparent)] 6 | Core(#[from] spicy_launcher_core::error::Error), 7 | #[error("Version not found: `{0}`")] 8 | UnknownVersion(String), 9 | } 10 | 11 | pub type Result = std::result::Result; 12 | -------------------------------------------------------------------------------- /gui/src/store/currentVersioning.ts: -------------------------------------------------------------------------------- 1 | import type { Version } from '../global' 2 | 3 | import { writable } from 'svelte/store' 4 | 5 | import { currentGame } from './currentGame' 6 | 7 | export const currentVersioning = writable('stable') 8 | 9 | currentGame.subscribe(_ => { 10 | currentVersioning.update(_ => 'stable') 11 | }) 12 | -------------------------------------------------------------------------------- /gui/src/store/versionStore.ts: -------------------------------------------------------------------------------- 1 | import type { Release } from '../global' 2 | 3 | import { writable, derived } from 'svelte/store' 4 | 5 | const versionStore = writable([]) 6 | 7 | export const changelog = derived(versionStore, $versions => { 8 | return $versions 9 | .map(version => `# ${version.version} - ${version.name}\n${version.body}`) 10 | .join('\n\n') 11 | }) 12 | 13 | export default versionStore 14 | -------------------------------------------------------------------------------- /gui/src-tauri/src/progress.rs: -------------------------------------------------------------------------------- 1 | use spicy_launcher_core::tracker::{Progress, ProgressEvent, ProgressTracker}; 2 | use tauri::Window; 3 | 4 | pub struct ProgressBar { 5 | pub window: Window, 6 | } 7 | 8 | impl ProgressTracker for ProgressBar { 9 | fn set_total_progress(&self, _: u64, _: ProgressEvent) {} 10 | fn update_progress(&self, progress: Progress) { 11 | log::trace!("{:?}", progress); 12 | self.window 13 | .emit("progress", progress) 14 | .expect("cannot send progress"); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /core/src/tracker.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Clone, Debug, Serialize, Deserialize)] 4 | pub enum ProgressEvent { 5 | Download, 6 | Extract, 7 | Finished, 8 | } 9 | 10 | #[derive(Clone, Debug, Serialize, Deserialize)] 11 | pub struct Progress { 12 | pub event: ProgressEvent, 13 | pub received: u64, 14 | pub total: u64, 15 | } 16 | 17 | pub trait ProgressTracker { 18 | fn set_total_progress(&self, total: u64, event: ProgressEvent); 19 | fn update_progress(&self, progress: Progress); 20 | } 21 | -------------------------------------------------------------------------------- /gui/src/main.ts: -------------------------------------------------------------------------------- 1 | import App from "./App.svelte"; 2 | 3 | import { appWindow } from "@tauri-apps/api/window"; 4 | import { downloadProgress } from "./store/downloadStore"; 5 | 6 | appWindow.listen("progress", (event) => { 7 | const progress = (event.payload.received / event.payload.total) * 100; 8 | downloadProgress.set({ 9 | ...event.payload, 10 | percent: progress, 11 | }); 12 | }); 13 | 14 | import type { Progress } from "./global"; 15 | 16 | const app = new App({ 17 | target: document.body, 18 | }); 19 | 20 | export default app; 21 | -------------------------------------------------------------------------------- /cli/src/main.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Parser; 3 | use spicy_launcher_cli::args::Args; 4 | use std::env; 5 | 6 | #[tokio::main] 7 | async fn main() -> Result<()> { 8 | let args = Args::parse(); 9 | if args.verbose == 1 { 10 | env::set_var("RUST_LOG", "debug"); 11 | } else if args.verbose > 1 { 12 | env::set_var("RUST_LOG", "trace"); 13 | } else if env::var_os("RUST_LOG").is_none() { 14 | env::set_var("RUST_LOG", "info"); 15 | } 16 | pretty_env_logger::init(); 17 | spicy_launcher_cli::run(args).await 18 | } 19 | -------------------------------------------------------------------------------- /cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "spicy-launcher-cli" 3 | version = "0.4.1" 4 | description = "Cross-platform launcher for Spicy Lobster games (CLI)" 5 | authors = ["Orhun Parmaksız ", "Spicy Lobster Studio"] 6 | edition = "2021" 7 | 8 | [dependencies] 9 | clap = { version = "4.4.11", features = ["derive", "env"] } 10 | anyhow = "1.0.76" 11 | tokio = { version = "1.35.1", features = ["full"] } 12 | pretty_env_logger = "0.5" 13 | log = "0.4" 14 | indicatif = "0.17.7" 15 | colored = "2" 16 | 17 | [dependencies.spicy-launcher-core] 18 | path = "../core" 19 | -------------------------------------------------------------------------------- /gui/src/global.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { GAMES, VERSIONING } from './utils/constants'; 4 | 5 | type Progress = { 6 | event: "Download" | "Extract" | "Finished" | "idle"; 7 | received: number; 8 | total: number; 9 | }; 10 | 11 | type StoreProgress = Progress & { 12 | percent: number; 13 | }; 14 | 15 | export type Release = { 16 | name: string; 17 | body: string; 18 | version: string; 19 | installed: boolean; 20 | prerelease: boolean; 21 | }; 22 | 23 | export type Game = keyof typeof GAMES; 24 | 25 | export type Version = keyof typeof VERSIONING; 26 | -------------------------------------------------------------------------------- /gui/src/components/ProgressBar.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 | 11 | 12 | %{progress.toFixed(2)} 13 |
14 | 15 | 32 | -------------------------------------------------------------------------------- /core/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "spicy-launcher-core" 3 | version = "0.4.1" 4 | description = "Core library of the Spicy Lobster launcher" 5 | authors = ["Orhun Parmaksız ", "Spicy Lobster Studio"] 6 | license = "MIT OR Apache-2.0" 7 | edition = "2021" 8 | 9 | [dependencies] 10 | reqwest = { version = "0.11.23", features = ["json", "stream"] } 11 | thiserror = "1.0.51" 12 | serde = { version = "1.0.193", features = ["derive"] } 13 | dirs-next = "2" 14 | platforms = "3" 15 | guess_host_triple = "0.1" 16 | bytesize = "1.3.0" 17 | flate2 = "1.0.28" 18 | tar = "0.4" 19 | zip = "0.6.6" 20 | ring = "0.17" 21 | log = "0.4" 22 | futures-util = "0.3.30" 23 | -------------------------------------------------------------------------------- /gui/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Spicy Launcher 8 | 9 | 10 | 11 | 12 | 13 | 14 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /gui/src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "spicy-launcher" 3 | version = "0.4.1" 4 | description = "Cross-platform launcher for Spicy Lobster games" 5 | authors = ["Orhun Parmaksız ", "Spicy Lobster Studio"] 6 | license = "MIT OR Apache-2.0" 7 | default-run = "spicy-launcher" 8 | edition = "2021" 9 | build = "src/build.rs" 10 | 11 | [features] 12 | default = ["custom-protocol"] 13 | custom-protocol = ["tauri/custom-protocol"] 14 | 15 | [dependencies] 16 | serde_json = "1.0.108" 17 | serde = { version = "1.0", features = ["derive"] } 18 | tauri = { version = "1.5.4", features = ["api-all"] } 19 | pretty_env_logger = "0.5.0" 20 | log = "0.4.20" 21 | thiserror = "1.0.51" 22 | 23 | [dependencies.spicy-launcher-core] 24 | path = "../../core" 25 | 26 | [build-dependencies] 27 | tauri-build = { version = "1.5.1", features = [] } 28 | -------------------------------------------------------------------------------- /gui/src/App.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
8 |
9 | 10 |
11 | 12 | 13 |
14 |
15 |
16 | 17 | 34 | -------------------------------------------------------------------------------- /core/src/archive/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod gz; 2 | pub mod zip; 3 | 4 | use serde::{Deserialize, Serialize}; 5 | use std::fmt; 6 | use std::path::Path; 7 | 8 | #[derive(Clone, Copy, Debug, Deserialize, Serialize)] 9 | pub enum ArchiveFormat { 10 | Gz, 11 | Zip, 12 | } 13 | 14 | impl fmt::Display for ArchiveFormat { 15 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 16 | write!(f, "{}", format!("{self:?}").to_lowercase()) 17 | } 18 | } 19 | 20 | impl ArchiveFormat { 21 | pub fn from_path(path: &Path) -> Option { 22 | for format in Self::variants() { 23 | if path.extension().and_then(|v| v.to_str()) == Some(&format.to_string()) { 24 | return Some(*format); 25 | } 26 | } 27 | None 28 | } 29 | 30 | pub fn variants() -> &'static [Self] { 31 | &[Self::Gz, Self::Zip] 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /cli/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod app; 2 | pub mod args; 3 | pub mod progress; 4 | 5 | use crate::app::App; 6 | use crate::args::{Args, Subcommand}; 7 | use anyhow::Result; 8 | use spicy_launcher_core::Game; 9 | 10 | pub async fn run(args: Args) -> Result<()> { 11 | let mut app = App::new()?; 12 | match args.subcommand { 13 | Some(Subcommand::Install(version_args)) => { 14 | app.install(version_args.game, version_args.version).await?; 15 | } 16 | Some(Subcommand::Uninstall(version_args)) => { 17 | app.uninstall(version_args.game, version_args.version) 18 | .await?; 19 | } 20 | Some(Subcommand::Launch(version_args)) => { 21 | app.launch(version_args.game, version_args.version)?; 22 | } 23 | _ => { 24 | for &game in Game::list() { 25 | app.print_releases(game).await?; 26 | } 27 | } 28 | } 29 | Ok(()) 30 | } 31 | -------------------------------------------------------------------------------- /gui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "src", 4 | 5 | // target node v8+ (https://node.green/) 6 | // the only missing feature is Array.prototype.values 7 | "lib": ["ESNext", "dom"], 8 | "target": "ESNext", 9 | 10 | "declaration": true, 11 | "declarationDir": "types", 12 | 13 | "noEmitOnError": true, 14 | "noErrorTruncation": true, 15 | 16 | // rollup takes care of these 17 | "module": "esnext", 18 | "moduleResolution": "node", 19 | "resolveJsonModule": true, 20 | "allowSyntheticDefaultImports": true, 21 | 22 | // Hides exports flagged with @internal from the d.ts output 23 | "stripInternal": true, 24 | 25 | "strict": true, 26 | "noImplicitThis": true, 27 | "noUnusedLocals": true, 28 | "noUnusedParameters": true, 29 | "typeRoots": ["./node_modules/@types"] 30 | }, 31 | "include": ["src/**/*"], 32 | "exclude": ["node_modules/*", "__sapper__/*", "public/*"] 33 | } 34 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for Cargo 4 | - package-ecosystem: cargo 5 | directory: "/" 6 | schedule: 7 | interval: monthly 8 | open-pull-requests-limit: 10 9 | groups: 10 | minor: 11 | update-types: 12 | - "minor" 13 | patch: 14 | update-types: 15 | - "patch" 16 | 17 | # Maintain dependencies for GitHub Actions 18 | - package-ecosystem: github-actions 19 | directory: "/" 20 | schedule: 21 | interval: monthly 22 | open-pull-requests-limit: 10 23 | groups: 24 | minor: 25 | update-types: 26 | - "minor" 27 | patch: 28 | update-types: 29 | - "patch" 30 | 31 | # Maintain dependencies for NPM 32 | - package-ecosystem: "npm" 33 | directory: "/gui" 34 | schedule: 35 | interval: monthly 36 | open-pull-requests-limit: 10 37 | groups: 38 | minor: 39 | update-types: 40 | - "minor" 41 | patch: 42 | update-types: 43 | - "patch" 44 | -------------------------------------------------------------------------------- /gui/src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | // Generates a serving of fish puns. 2 | export const quotes = [ 3 | "I’m hooked!", 4 | "Seems a bit fishy to me.", 5 | "I’d make him walk the plankton for that.", 6 | "Not bad, cod be better…", 7 | "It’s a great oppor-tuna-ty!", 8 | "We whaley need to stop now!", 9 | "Well, it’s oh-fish-ial.", 10 | "Keep your friends close and your anemones closer.", 11 | "Let’s just clam down should we.", 12 | "Holy shrimp! This scampi happening.", 13 | "The new squid on the block.", 14 | "This is sardine to get ridiculous.", 15 | "I’m so angry I could krill someone.", 16 | "You've got to be squidding me.", 17 | "Squid it already, will ya!", 18 | "Oh bouy, these are starting to get a little finicky", 19 | "I'm sorry, I can't kelp it!", 20 | "Too many fish puns. We should scale back.", 21 | ]; 22 | 23 | export const GAMES = { 24 | jumpy: "Jumpy", 25 | punchy: "Punchy", 26 | thetawave: "Thetawave", 27 | astratomic: "Astratomic", 28 | }; 29 | 30 | export const VERSIONING = { 31 | stable: "Stable", 32 | nightly: "Pre-Release", 33 | }; 34 | -------------------------------------------------------------------------------- /cli/src/args.rs: -------------------------------------------------------------------------------- 1 | use clap::{ArgAction, Args as ClapArgs, Parser}; 2 | use spicy_launcher_core::Game; 3 | 4 | #[derive(Debug, Parser, PartialEq, Eq)] 5 | #[command( 6 | version, 7 | about, 8 | help_template = "\ 9 | {before-help}{name} {version} 10 | {author-with-newline}{about-with-newline} 11 | {usage-heading} {usage} 12 | 13 | {all-args}{after-help} 14 | " 15 | )] 16 | pub struct Args { 17 | /// Increase logging verbosity. 18 | #[clap(short, long, action = ArgAction::Count)] 19 | pub verbose: u8, 20 | #[clap(subcommand)] 21 | pub subcommand: Option, 22 | } 23 | 24 | #[derive(Debug, Parser, PartialEq, Eq)] 25 | pub enum Subcommand { 26 | /// List available games and releases. 27 | List, 28 | /// Download and install a game. 29 | Install(VersionArgs), 30 | /// Uninstall a game. 31 | Uninstall(VersionArgs), 32 | /// Launch a game. 33 | Launch(VersionArgs), 34 | } 35 | 36 | #[derive(ClapArgs, Debug, PartialEq, Eq)] 37 | pub struct VersionArgs { 38 | /// The game name. 39 | pub game: Game, 40 | /// The version of the game. 41 | pub version: Option, 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020-2024 The Fish Fight Game & Spicy Lobster Developers 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a 4 | copy of this software and associated documentation files (the "Software"), 5 | to deal in the Software without restriction, including without limitation 6 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | and/or sell copies of the Software, and to permit persons to whom the 8 | Software is furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | DEALINGS IN THE SOFTWARE. 20 | 21 | -------------------------------------------------------------------------------- /.github/workflows/cron.yml: -------------------------------------------------------------------------------- 1 | name: Updater 2 | 3 | on: 4 | schedule: 5 | - cron: "*/15 * * * *" 6 | workflow_dispatch: 7 | 8 | jobs: 9 | update-releases: 10 | name: Sync with upstream 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | with: 16 | ref: upstream 17 | fetch-depth: 1 18 | 19 | - name: Update 20 | shell: bash 21 | run: | 22 | for game in "fishfolk/jumpy" "fishfolk/punchy" \ 23 | "thetawavegame/thetawave" "spicylobstergames/astratomic"; do 24 | curl -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ 25 | "https://api.github.com/repos/${game}/releases?per_page=100" > "${game#*/}.json" 26 | done 27 | 28 | - name: Commit 29 | run: | 30 | git config user.name 'github-actions[bot]' 31 | git config user.email 'github-actions[bot]@users.noreply.github.com' 32 | set +e 33 | git add *.json 34 | git commit -m "Update releases" 35 | git push https://${{ secrets.GITHUB_TOKEN }}@github.com/${GITHUB_REPOSITORY}.git upstream 36 | -------------------------------------------------------------------------------- /core/src/error.rs: -------------------------------------------------------------------------------- 1 | use std::path::{PathBuf, StripPrefixError}; 2 | use thiserror::Error as ThisError; 3 | 4 | #[derive(ThisError, Debug)] 5 | pub enum Error { 6 | #[error("IO error: `{0}`")] 7 | Io(#[from] std::io::Error), 8 | #[error("HTTP client error: `{0}`")] 9 | HttpClient(#[from] reqwest::Error), 10 | #[error("HTTP error: `{0}`")] 11 | Http(String), 12 | #[error("Platform error: `{0}`")] 13 | Platform(String), 14 | #[error("Storage error: `{0}`")] 15 | Storage(String), 16 | #[error("Zip extract error: `{0}`")] 17 | Zip(#[from] zip::result::ZipError), 18 | #[error("Verify error: `{0}`")] 19 | Verify(String), 20 | #[error("UTF-8 error: `{0}`")] 21 | Utf8(String), 22 | #[error("Conversion error: `{0}`")] 23 | Conversion(#[from] std::num::TryFromIntError), 24 | #[error("Failed to strip toplevel directory {} from {}: {error}", .toplevel.to_string_lossy(), .path.to_string_lossy())] 25 | StripToplevel { 26 | toplevel: PathBuf, 27 | path: PathBuf, 28 | error: StripPrefixError, 29 | }, 30 | #[error("Invalid game ID: `{0}`")] 31 | InvalidGameId(String), 32 | } 33 | 34 | pub type Result = std::result::Result; 35 | -------------------------------------------------------------------------------- /gui/src/components/Changelog.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
8 | character 9 | character 10 | 11 |
12 | 13 |
14 |
15 | 16 | 48 | -------------------------------------------------------------------------------- /gui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spicy-launcher", 3 | "version": "0.4.1", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "build": "rollup -c", 8 | "dev": "rollup -c -w", 9 | "start": "sirv public --no-clear", 10 | "check": "svelte-check --tsconfig ./tsconfig.json", 11 | "tauri": "tauri" 12 | }, 13 | "devDependencies": { 14 | "@rollup/plugin-commonjs": "^17.0.0", 15 | "@rollup/plugin-node-resolve": "^11.0.0", 16 | "@rollup/plugin-typescript": "^8.0.0", 17 | "@tauri-apps/cli": "^1.5.9", 18 | "@tsconfig/svelte": "^2.0.0", 19 | "eslint": "^8.55.0", 20 | "eslint-config-prettier": "^8.6.0", 21 | "eslint-plugin-import": "^2.29.1", 22 | "eslint-plugin-prettier": "^4.2.1", 23 | "eslint-plugin-svelte3": "^4.0.0", 24 | "prettier": "^2.8.3", 25 | "rollup": "^2.3.4", 26 | "rollup-plugin-css-only": "^3.1.0", 27 | "rollup-plugin-livereload": "^2.0.0", 28 | "rollup-plugin-svelte": "^7.1.6", 29 | "rollup-plugin-terser": "^7.0.0", 30 | "svelte": "^3.0.0", 31 | "svelte-check": "^2.0.0", 32 | "svelte-preprocess": "^4.0.0", 33 | "tslib": "^2.6.2", 34 | "typescript": "^4.0.0" 35 | }, 36 | "dependencies": { 37 | "@tauri-apps/api": "^1.5.3", 38 | "sass": "^1.69.6", 39 | "sirv-cli": "^2.0.0", 40 | "svelte-markdown": "^0.4.1" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /gui/src/components/GameTabs.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 |

Games:

7 | 17 | 27 | 37 | 47 |
48 | 49 | 65 | -------------------------------------------------------------------------------- /gui/src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "package": { 3 | "productName": "spicy-launcher", 4 | "version": "0.4.1" 5 | }, 6 | "build": { 7 | "distDir": "../public", 8 | "devPath": "http://localhost:8080", 9 | "beforeDevCommand": "npm run dev", 10 | "beforeBuildCommand": "npm run build" 11 | }, 12 | "tauri": { 13 | "bundle": { 14 | "active": true, 15 | "targets": "all", 16 | "identifier": "com.spicylobster.launcher", 17 | "icon": ["icons/32x32.png", "icons/128x128.png", "icons/icon.ico"], 18 | "resources": [], 19 | "externalBin": [], 20 | "copyright": "Copyright (c) Orhun Parmaksız & Spicy Lobster Studio 2022-2024. All rights reserved.", 21 | "category": "Game", 22 | "shortDescription": "Spicy Lobster Launcher", 23 | "longDescription": "A cross-platform launcher for Spicy Lobster games", 24 | "deb": { 25 | "depends": [] 26 | }, 27 | "macOS": { 28 | "frameworks": [], 29 | "minimumSystemVersion": "", 30 | "exceptionDomain": "", 31 | "signingIdentity": null, 32 | "entitlements": null 33 | }, 34 | "windows": { 35 | "certificateThumbprint": null, 36 | "digestAlgorithm": "sha256", 37 | "timestampUrl": "" 38 | } 39 | }, 40 | "updater": { 41 | "active": false 42 | }, 43 | "allowlist": { 44 | "all": true 45 | }, 46 | "windows": [ 47 | { 48 | "title": "Spicy Launcher", 49 | "width": 1280, 50 | "height": 720, 51 | "resizable": false, 52 | "fullscreen": false 53 | } 54 | ], 55 | "security": { 56 | "csp": "default-src blob: data: filesystem: ws: wss: http: https: tauri: 'unsafe-eval' 'unsafe-inline' 'self' img-src: 'self'" 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /core/src/github/api.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | pub type Releases = Vec; 4 | 5 | #[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 6 | #[serde(rename_all = "camelCase")] 7 | pub struct Release { 8 | pub url: String, 9 | #[serde(rename = "assets_url")] 10 | pub assets_url: String, 11 | #[serde(rename = "upload_url")] 12 | pub upload_url: String, 13 | #[serde(rename = "html_url")] 14 | pub html_url: String, 15 | pub id: i64, 16 | #[serde(rename = "node_id")] 17 | pub node_id: String, 18 | #[serde(rename = "tag_name")] 19 | pub tag_name: String, 20 | #[serde(rename = "target_commitish")] 21 | pub target_commitish: String, 22 | pub name: String, 23 | pub draft: bool, 24 | pub prerelease: bool, 25 | #[serde(rename = "created_at")] 26 | pub created_at: String, 27 | #[serde(rename = "published_at")] 28 | pub published_at: String, 29 | pub assets: Vec, 30 | #[serde(rename = "tarball_url")] 31 | pub tarball_url: String, 32 | #[serde(rename = "zipball_url")] 33 | pub zipball_url: String, 34 | pub body: String, 35 | } 36 | 37 | #[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 38 | #[serde(rename_all = "camelCase")] 39 | pub struct Asset { 40 | pub url: String, 41 | pub id: i64, 42 | #[serde(rename = "node_id")] 43 | pub node_id: Option, 44 | pub name: String, 45 | pub label: Option, 46 | #[serde(rename = "content_type")] 47 | pub content_type: Option, 48 | pub state: Option, 49 | pub size: i64, 50 | #[serde(rename = "created_at")] 51 | pub created_at: String, 52 | #[serde(rename = "updated_at")] 53 | pub updated_at: String, 54 | #[serde(rename = "browser_download_url")] 55 | pub browser_download_url: String, 56 | } 57 | -------------------------------------------------------------------------------- /gui/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["plugin:prettier/recommended"], 3 | parser: "@typescript-eslint/parser", // add the TypeScript parser 4 | plugins: ["@typescript-eslint", "import", "svelte3", "prettier"], 5 | settings: { 6 | "svelte3/typescript": () => require("typescript"), // pass the TypeScript package to the Svelte plugin 7 | }, 8 | settings: { 9 | "svelte3/typescript": () => require("typescript"), // pass the TypeScript package to the Svelte plugin 10 | }, 11 | rules: { 12 | "prettier/prettier": "error", 13 | "no-console": "off", 14 | "@typescript-eslint/no-unused-vars": [ 15 | "error", 16 | { 17 | vars: "all", 18 | args: "after-used", 19 | ignoreRestSiblings: false, 20 | varsIgnorePattern: "^", 21 | argsIgnorePattern: "^", 22 | }, 23 | ], 24 | "no-restricted-syntax": [ 25 | "error", 26 | { 27 | selector: 28 | "CallExpression[callee.object.name='console'][callee.property.name!=/^(log|warn|error|info|trace)$/]", 29 | message: "Unexpected property on console object was called", 30 | }, 31 | ], 32 | "import/first": "error", 33 | "import/newline-after-import": "error", 34 | "import/no-duplicates": "error", 35 | "import/order": [ 36 | "error", 37 | { 38 | groups: [ 39 | "type", 40 | "builtin", 41 | "external", 42 | "internal", 43 | ["sibling", "parent"], 44 | "index", 45 | "object", 46 | ], 47 | pathGroups: [], 48 | pathGroupsExcludedImportTypes: [], 49 | "newlines-between": "always", 50 | alphabetize: { 51 | order: "asc", 52 | caseInsensitive: true, 53 | }, 54 | }, 55 | ], 56 | }, 57 | }; 58 | -------------------------------------------------------------------------------- /core/src/archive/gz.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Result; 2 | use crate::tracker::{Progress, ProgressEvent, ProgressTracker}; 3 | use crate::Game; 4 | use flate2::read::GzDecoder; 5 | use std::fs::File; 6 | use std::path::Path; 7 | use tar::Archive as TarArchive; 8 | 9 | pub fn extract( 10 | target: &Path, 11 | game: Game, 12 | destination: &Path, 13 | version: &str, 14 | tracker: &mut Tracker, 15 | ) -> Result<()> { 16 | let tar_gz = File::open(target)?; 17 | let tar = GzDecoder::new(tar_gz); 18 | let mut archive = TarArchive::new(tar); 19 | let total = archive.entries()?.count().try_into()?; 20 | tracker.set_total_progress(total, ProgressEvent::Extract); 21 | tracker.update_progress(Progress { 22 | event: ProgressEvent::Extract, 23 | received: 0, 24 | total, 25 | }); 26 | 27 | let tar_gz = File::open(target)?; 28 | let tar = GzDecoder::new(tar_gz); 29 | let mut archive = TarArchive::new(tar); 30 | for (i, entry) in archive.entries()?.enumerate() { 31 | let mut entry = entry?; 32 | let mut entry_path = entry.path()?.to_path_buf(); 33 | for prefix in [ 34 | format!("{}-{}", game.id(), version), 35 | format!("{}-{}", game.id(), version.trim_start_matches('v')), 36 | ] { 37 | if let Ok(path) = entry_path.strip_prefix(prefix) { 38 | entry_path = path.to_path_buf(); 39 | break; 40 | } 41 | } 42 | tracker.update_progress(Progress { 43 | event: ProgressEvent::Extract, 44 | received: i.try_into()?, 45 | total, 46 | }); 47 | let extract_path = destination.join(version).join(entry_path); 48 | std::fs::create_dir_all(destination.join(version))?; 49 | log::trace!("Extracting to: {:?}", extract_path); 50 | entry.unpack(extract_path)?; 51 | } 52 | 53 | Ok(()) 54 | } 55 | -------------------------------------------------------------------------------- /gui/public/fontface.css: -------------------------------------------------------------------------------- 1 | /* cyrillic-ext */ 2 | @font-face { 3 | font-family: "Press Start 2P"; 4 | font-style: normal; 5 | font-weight: 400; 6 | font-display: swap; 7 | src: url(https://fonts.gstatic.com/s/pressstart2p/v12/e3t4euO8T-267oIAQAu6jDQyK3nYivNm4I81PZQ.woff2) 8 | format("woff2"); 9 | unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, 10 | U+FE2E-FE2F; 11 | } 12 | /* cyrillic */ 13 | @font-face { 14 | font-family: "Press Start 2P"; 15 | font-style: normal; 16 | font-weight: 400; 17 | font-display: swap; 18 | src: url(https://fonts.gstatic.com/s/pressstart2p/v12/e3t4euO8T-267oIAQAu6jDQyK3nRivNm4I81PZQ.woff2) 19 | format("woff2"); 20 | unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; 21 | } 22 | /* greek */ 23 | @font-face { 24 | font-family: "Press Start 2P"; 25 | font-style: normal; 26 | font-weight: 400; 27 | font-display: swap; 28 | src: url(https://fonts.gstatic.com/s/pressstart2p/v12/e3t4euO8T-267oIAQAu6jDQyK3nWivNm4I81PZQ.woff2) 29 | format("woff2"); 30 | unicode-range: U+0370-03FF; 31 | } 32 | /* latin-ext */ 33 | @font-face { 34 | font-family: "Press Start 2P"; 35 | font-style: normal; 36 | font-weight: 400; 37 | font-display: swap; 38 | src: url(https://fonts.gstatic.com/s/pressstart2p/v12/e3t4euO8T-267oIAQAu6jDQyK3nbivNm4I81PZQ.woff2) 39 | format("woff2"); 40 | unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, 41 | U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; 42 | } 43 | /* latin */ 44 | @font-face { 45 | font-family: "Press Start 2P"; 46 | font-style: normal; 47 | font-weight: 400; 48 | font-display: swap; 49 | src: url(https://fonts.gstatic.com/s/pressstart2p/v12/e3t4euO8T-267oIAQAu6jDQyK3nVivNm4I81.woff2) 50 | format("woff2"); 51 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, 52 | U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, 53 | U+FEFF, U+FFFD; 54 | } 55 | -------------------------------------------------------------------------------- /core/src/lib.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use std::str::FromStr; 3 | 4 | pub mod archive; 5 | pub mod constant; 6 | pub mod error; 7 | pub mod github; 8 | mod http; 9 | pub mod release; 10 | pub mod storage; 11 | pub mod tracker; 12 | 13 | /// The different games supported by the launcher. 14 | #[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize)] 15 | #[serde(rename_all = "snake_case")] 16 | pub enum Game { 17 | Jumpy, 18 | Punchy, 19 | Thetawave, 20 | Astratomic, 21 | } 22 | 23 | impl FromStr for Game { 24 | type Err = error::Error; 25 | 26 | fn from_str(s: &str) -> Result { 27 | match s { 28 | "jumpy" => Ok(Game::Jumpy), 29 | "punchy" => Ok(Game::Punchy), 30 | "thetawave" => Ok(Game::Thetawave), 31 | "astratomic" => Ok(Game::Astratomic), 32 | id => Err(error::Error::InvalidGameId(id.to_string())), 33 | } 34 | } 35 | } 36 | 37 | impl Game { 38 | pub fn id(&self) -> &'static str { 39 | match self { 40 | Game::Jumpy => "jumpy", 41 | Game::Punchy => "punchy", 42 | Game::Thetawave => "thetawave", 43 | Game::Astratomic => "astratomic", 44 | } 45 | } 46 | 47 | pub fn binary_name(&self) -> String { 48 | let id = self.id(); 49 | if cfg!(target_os = "windows") { 50 | format!("{id}.exe") 51 | } else { 52 | id.to_string() 53 | } 54 | } 55 | 56 | pub fn list() -> &'static [Game] { 57 | &[Game::Jumpy, Game::Punchy, Game::Thetawave, Game::Astratomic] 58 | } 59 | } 60 | 61 | impl std::fmt::Display for Game { 62 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 63 | let name = match self { 64 | Game::Jumpy => "Jumpy", 65 | Game::Punchy => "Punchy", 66 | Game::Thetawave => "Thetawave", 67 | Game::Astratomic => "astratomic", 68 | }; 69 | write!(f, "{name}") 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /core/src/http.rs: -------------------------------------------------------------------------------- 1 | use crate::error::{Error, Result}; 2 | use crate::tracker::{Progress, ProgressEvent, ProgressTracker}; 3 | use futures_util::StreamExt; 4 | use reqwest::Client as ReqwestClient; 5 | use serde::de::DeserializeOwned; 6 | use std::cmp::min; 7 | use std::io::Write; 8 | 9 | static APP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")); 10 | 11 | pub struct HttpClient { 12 | inner: ReqwestClient, 13 | } 14 | 15 | impl HttpClient { 16 | pub fn new() -> Result { 17 | Ok(Self { 18 | inner: ReqwestClient::builder() 19 | .user_agent(APP_USER_AGENT) 20 | .build()?, 21 | }) 22 | } 23 | 24 | pub async fn get_text(&self, url: &str) -> Result { 25 | Ok(self.inner.get(url).send().await?.text().await?) 26 | } 27 | 28 | pub async fn get_json(&self, url: &str) -> Result 29 | where 30 | T: DeserializeOwned, 31 | { 32 | Ok(self.inner.get(url).send().await?.json::().await?) 33 | } 34 | 35 | pub async fn download( 36 | &self, 37 | url: &str, 38 | output: &mut Output, 39 | tracker: &mut Tracker, 40 | ) -> Result<()> { 41 | let response: reqwest::Response = self.inner.get(url).send().await?; 42 | let content_length = response 43 | .content_length() 44 | .ok_or_else(|| Error::Http(String::from("content length is unknown")))?; 45 | let mut stream = response.bytes_stream(); 46 | let mut downloaded = 0; 47 | while let Some(chunk) = stream.next().await { 48 | let chunk = chunk?; 49 | output.write_all(&chunk)?; 50 | downloaded = min(downloaded + chunk.len() as u64, content_length); 51 | tracker.update_progress(Progress { 52 | event: ProgressEvent::Download, 53 | received: downloaded, 54 | total: content_length, 55 | }); 56 | } 57 | Ok(()) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /cli/src/progress.rs: -------------------------------------------------------------------------------- 1 | use indicatif::{ProgressBar as IndicatifProgressBar, ProgressStyle}; 2 | use spicy_launcher_core::tracker::{Progress, ProgressEvent, ProgressTracker}; 3 | use std::time::Duration; 4 | 5 | const TICK_MS: u64 = 80; 6 | const DOWNLOAD_TEMPLATE: &str = "{msg}\n{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})"; 7 | const EXTRACT_TEMPLATE: &str = 8 | "{msg}\n{spinner:.green} [{elapsed_precise}] [{wide_bar:.green}] {percent}% ({eta})"; 9 | 10 | pub struct ProgressBar { 11 | inner: IndicatifProgressBar, 12 | } 13 | 14 | impl Default for ProgressBar { 15 | fn default() -> Self { 16 | Self { 17 | inner: IndicatifProgressBar::new_spinner(), 18 | } 19 | } 20 | } 21 | 22 | impl ProgressBar { 23 | pub fn enable_tick(&self) { 24 | self.inner 25 | .enable_steady_tick(Duration::from_millis(TICK_MS)); 26 | } 27 | 28 | pub fn set_message>(&self, message: S) { 29 | self.inner.set_message(message.as_ref().to_string()); 30 | } 31 | 32 | pub fn reset_style(&self) { 33 | self.inner.set_style(ProgressStyle::default_spinner()) 34 | } 35 | 36 | pub fn finish(&self) { 37 | self.inner.finish_and_clear(); 38 | } 39 | } 40 | 41 | impl ProgressTracker for ProgressBar { 42 | fn set_total_progress(&self, total: u64, event: ProgressEvent) { 43 | self.inner.set_style( 44 | ProgressStyle::default_bar() 45 | .template(match event { 46 | ProgressEvent::Download => DOWNLOAD_TEMPLATE, 47 | ProgressEvent::Extract => EXTRACT_TEMPLATE, 48 | ProgressEvent::Finished => "", 49 | }) 50 | .expect("invalid template") 51 | .progress_chars("#>-"), 52 | ); 53 | self.inner.set_length(total); 54 | self.inner.reset_elapsed(); 55 | } 56 | 57 | fn update_progress(&self, progress: Progress) { 58 | self.inner.set_position(progress.received); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /core/src/release.rs: -------------------------------------------------------------------------------- 1 | use crate::archive::ArchiveFormat; 2 | use crate::error::{Error, Result}; 3 | use bytesize::ByteSize; 4 | use platforms::platform::Platform; 5 | use serde::{Deserialize, Serialize}; 6 | use std::env; 7 | use std::path::Path; 8 | 9 | #[derive(Clone, Debug, Deserialize, Serialize)] 10 | pub struct Asset { 11 | pub name: String, 12 | pub download_url: String, 13 | pub size: u64, 14 | pub archive_format: Option, 15 | } 16 | 17 | impl Asset { 18 | pub fn new(name: String, download_url: String, size: u64) -> Self { 19 | Self { 20 | archive_format: ArchiveFormat::from_path(Path::new(&name)), 21 | name, 22 | download_url, 23 | size, 24 | } 25 | } 26 | 27 | pub fn get_size(&self) -> String { 28 | ByteSize(self.size).to_string_as(false) 29 | } 30 | } 31 | 32 | #[derive(Clone, Debug, Default, Deserialize, Serialize)] 33 | pub struct Release { 34 | pub name: String, 35 | pub version: String, 36 | pub body: String, 37 | pub installed: bool, 38 | pub prerelease: bool, 39 | #[serde(skip)] 40 | pub assets: Vec, 41 | } 42 | 43 | impl From for Release { 44 | fn from(version: String) -> Self { 45 | Self { 46 | version, 47 | ..Self::default() 48 | } 49 | } 50 | } 51 | 52 | impl Release { 53 | pub fn get_asset(&self) -> Result { 54 | let platform = match env::var("PLATFORM_OVERRIDE").ok() { 55 | Some(target_triple) => Platform::find(&target_triple), 56 | None => guess_host_triple::guess_host_triple().and_then(Platform::find), 57 | } 58 | .ok_or_else(|| Error::Platform(String::from("unknown platform")))?; 59 | log::debug!("Platform: {}", platform); 60 | self.assets 61 | .clone() 62 | .into_iter() 63 | .find(|asset| { 64 | asset.archive_format.is_some() && asset.name.contains(platform.target_triple) 65 | }) 66 | .ok_or_else(|| { 67 | Error::Platform(String::from( 68 | "release assets for the current platform do not exist", 69 | )) 70 | }) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | schedule: 11 | - cron: "0 0 * * 0" 12 | 13 | jobs: 14 | build: 15 | name: Build 16 | runs-on: ${{ matrix.config.os }} 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | config: 21 | - { os: ubuntu-latest, target: "x86_64-unknown-linux-gnu" } 22 | - { os: macos-latest, target: "x86_64-apple-darwin" } 23 | - { os: windows-latest, target: "x86_64-pc-windows-msvc" } 24 | profile: ["", "--release"] 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v4 28 | - name: Install dependencies 29 | if: matrix.config.os == 'ubuntu-latest' 30 | run: | 31 | sudo apt-get update 32 | sudo apt-get install --allow-unauthenticated -y -qq \ 33 | libgtk-3-dev webkit2gtk-4.0 libappindicator3-dev librsvg2-dev patchelf 34 | - name: Cache Cargo dependencies 35 | uses: Swatinem/rust-cache@v2 36 | - name: Install Rust 37 | uses: actions-rs/toolchain@v1 38 | with: 39 | toolchain: stable 40 | target: ${{ matrix.config.target }} 41 | override: true 42 | profile: minimal 43 | - name: Build 44 | uses: actions-rs/cargo@v1 45 | with: 46 | command: build 47 | args: --locked --target ${{ matrix.config.target }} ${{ matrix.profile }} 48 | 49 | fmt: 50 | runs-on: ubuntu-latest 51 | name: Formatting 52 | steps: 53 | - name: Checkout 54 | uses: actions/checkout@v4 55 | - name: Check formatting 56 | uses: actions-rs/cargo@v1 57 | with: 58 | command: fmt 59 | args: --all -- --check 60 | 61 | clippy: 62 | runs-on: ubuntu-latest 63 | name: Linting 64 | steps: 65 | - name: Checkout 66 | uses: actions/checkout@v4 67 | - name: Install dependencies 68 | run: | 69 | sudo apt-get update 70 | sudo apt-get install --allow-unauthenticated -y -qq \ 71 | libgtk-3-dev webkit2gtk-4.0 libappindicator3-dev librsvg2-dev patchelf 72 | - name: Install Clippy 73 | run: rustup component add clippy 74 | - name: Run Clippy 75 | run: cargo clippy -- -D warnings 76 | -------------------------------------------------------------------------------- /gui/src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr( 2 | all(not(debug_assertions), target_os = "windows"), 3 | windows_subsystem = "windows" 4 | )] 5 | 6 | mod app; 7 | mod error; 8 | mod progress; 9 | 10 | use crate::app::App; 11 | use crate::error::Result as AppResult; 12 | use progress::ProgressBar; 13 | use spicy_launcher_core::release::Release; 14 | use spicy_launcher_core::tracker::{Progress, ProgressEvent}; 15 | use spicy_launcher_core::Game; 16 | use std::env; 17 | use tauri::{Error, State, Window}; 18 | 19 | #[tauri::command] 20 | async fn get_versions(game: Game, app: State<'_, App>) -> Result, ()> { 21 | Ok(app.get_versions(game).await.expect("cannot fetch versions")) 22 | } 23 | 24 | #[tauri::command] 25 | async fn uninstall( 26 | game: Game, 27 | version: String, 28 | app: State<'_, App>, 29 | _window: Window, 30 | ) -> Result<(), Error> { 31 | app.uninstall(game, &version) 32 | .await 33 | .expect("cannot uninstall version"); 34 | Ok(()) 35 | } 36 | 37 | #[tauri::command] 38 | async fn install( 39 | game: Game, 40 | version: String, 41 | app: State<'_, App>, 42 | window: Window, 43 | ) -> Result<(), Error> { 44 | let mut progress_bar = ProgressBar { window }; 45 | app.install(game, &version, &mut progress_bar) 46 | .await 47 | .expect("cannot download version"); 48 | progress_bar.window.emit( 49 | "progress", 50 | Progress { 51 | event: ProgressEvent::Finished, 52 | received: 100, 53 | total: 100, 54 | }, 55 | )?; 56 | Ok(()) 57 | } 58 | 59 | #[tauri::command] 60 | async fn launch( 61 | game: Game, 62 | version: String, 63 | app: State<'_, App>, 64 | window: Window, 65 | ) -> Result<(), Error> { 66 | app.launch(game, version).await.expect("cannot launch game"); 67 | window.close()?; 68 | Ok(()) 69 | } 70 | 71 | fn main() -> AppResult<()> { 72 | if env::var_os("RUST_LOG").is_none() { 73 | env::set_var("RUST_LOG", "info"); 74 | } 75 | pretty_env_logger::init(); 76 | let app = App::new()?; 77 | tauri::Builder::default() 78 | .manage(app) 79 | .invoke_handler(tauri::generate_handler![ 80 | get_versions, 81 | uninstall, 82 | install, 83 | launch 84 | ]) 85 | .run(tauri::generate_context!()) 86 | .expect("error while running tauri application"); 87 | Ok(()) 88 | } 89 | -------------------------------------------------------------------------------- /gui/src-tauri/src/app.rs: -------------------------------------------------------------------------------- 1 | use crate::error::{Error, Result}; 2 | use crate::progress::ProgressBar; 3 | use spicy_launcher_core::github::GitHubClient; 4 | use spicy_launcher_core::release::Release; 5 | use spicy_launcher_core::storage::LocalStorage; 6 | use spicy_launcher_core::Game; 7 | 8 | pub struct App { 9 | client: GitHubClient, 10 | storage: LocalStorage, 11 | } 12 | 13 | impl App { 14 | pub fn new() -> Result { 15 | let client = GitHubClient::new()?; 16 | let storage = LocalStorage::init()?; 17 | log::debug!("{:#?}", storage); 18 | Ok(Self { client, storage }) 19 | } 20 | 21 | pub async fn get_versions(&self, game: Game) -> Result> { 22 | let available_relases = self.storage.get_available_releases(game)?; 23 | let mut releases = self.client.get_releases(game).await?; 24 | releases.iter_mut().for_each(|release| { 25 | release.installed = available_relases 26 | .iter() 27 | .any(|r| r.version == release.version) 28 | }); 29 | Ok(releases) 30 | } 31 | 32 | pub async fn uninstall(&self, game: Game, version: &str) -> Result<()> { 33 | log::info!("Uninstalling {}...", version); 34 | self.storage.remove_version(game, version)?; 35 | Ok(()) 36 | } 37 | 38 | pub async fn install( 39 | &self, 40 | game: Game, 41 | version: &str, 42 | progress_bar: &mut ProgressBar, 43 | ) -> Result<()> { 44 | log::info!("Installing {}...", version); 45 | let versions = self.get_versions(game).await?; 46 | let release = versions 47 | .iter() 48 | .find(|release| release.version == version) 49 | .ok_or_else(|| Error::UnknownVersion(version.to_string()))?; 50 | let asset = release.get_asset()?; 51 | let download_path = self.storage.temp_dir.join(&asset.name); 52 | self.client 53 | .download_asset(&asset, &download_path, progress_bar) 54 | .await?; 55 | self.client.verify_asset(&asset, &download_path).await?; 56 | self.storage.extract_archive( 57 | &asset, 58 | &download_path, 59 | game, 60 | &release.version, 61 | progress_bar, 62 | )?; 63 | Ok(()) 64 | } 65 | 66 | pub async fn launch(&self, game: Game, version: String) -> Result<()> { 67 | log::info!("Launching {}...", version); 68 | Ok(self.storage.launch_game(game, &version)?) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spicy Launcher 2 | 3 | [![Build Status](https://img.shields.io/github/actions/workflow/status/spicylobstergames/SpicyLauncher/ci.yml?logo=github&labelColor=1e1c24&color=8bcfcf)](https://github.com/spicylobstergames/SpicyLauncher/actions) [![License](https://img.shields.io/badge/License-MIT%20or%20Apache%202-green.svg?label=license&labelColor=1e1c24&color=34925e)](#license) [![Discord](https://img.shields.io/badge/chat-on%20discord-green.svg?logo=discord&logoColor=fff&labelColor=1e1c24&color=8d5b3f)](https://discord.gg/4smxjcheE5) 4 | 5 | A cross-platform launcher for playing [Spicy Lobster](https://github.com/spicylobstergames) games. 6 | 7 | ![gui_preview](https://user-images.githubusercontent.com/24392180/153517081-9a8b6fb6-3901-430f-abe3-712c1dd8feb4.gif) 8 | 9 | ## Supported games 10 | 11 | - [x] [Fish Folk: Jumpy](https://github.com/fishfolks/jumpy) (`jumpy`) 12 | - [x] [Fish Folk: Punchy](https://github.com/fishfolks/punchy) (`punchy`) 13 | - [x] [Thetawave](https://github.com/thetawavegame/thetawave) (`thetawave`) 14 | - [x] [Astratomic](https://github.com/spicylobstergames/astratomic) (`astratomic`) 15 | 16 | ## Features 17 | 18 | - [x] Install and launch (via GUI/CLI) 19 | - [ ] Auto updates 20 | - [ ] Mod management 21 | 22 | ## Download 23 | 24 | See [available releases](https://github.com/spicylobstergames/SpicyLauncher/releases). 25 | 26 | ## Build from source 27 | 28 | ```sh 29 | # Build CLI 30 | $ cd cli/ 31 | $ cargo build --release 32 | ``` 33 | 34 | ```sh 35 | # Build GUI 36 | $ cd gui/ 37 | $ yarn install --ignore-engines 38 | $ yarn tauri build 39 | ``` 40 | 41 | ## CLI 42 | 43 | ![cli_preview](https://user-images.githubusercontent.com/24392180/153515463-847a02c6-de6b-438a-a97d-03cb56d5e7d5.gif) 44 | 45 | ### Usage 46 | 47 | ``` 48 | spicy-launcher-cli [OPTIONS] [COMMAND] 49 | ``` 50 | 51 | ``` 52 | Commands: 53 | list List available games and releases 54 | install Download and install a game 55 | uninstall Uninstall a game 56 | launch Launch a game 57 | help Print this message or the help of the given subcommand(s) 58 | 59 | Options: 60 | -v, --verbose... Increase logging verbosity 61 | -h, --help Print help information 62 | -V, --version Print version information 63 | ``` 64 | 65 | ### Examples 66 | 67 | List available releases: 68 | 69 | ```sh 70 | spicy-launcher-cli 71 | ``` 72 | 73 | Install the latest version of a game: 74 | 75 | ```sh 76 | spicy-launcher-cli install 77 | ``` 78 | 79 | Launch the game: 80 | 81 | ```sh 82 | spicy-launcher-cli launch 83 | ``` 84 | 85 | Uninstall: 86 | 87 | ```sh 88 | spicy-launcher-cli uninstall 89 | ``` 90 | 91 | #### License 92 | 93 | 94 | All code is licensed under The MIT License or Apache 2.0 License. 95 | 96 | -------------------------------------------------------------------------------- /gui/rollup.config.js: -------------------------------------------------------------------------------- 1 | import svelte from "rollup-plugin-svelte"; 2 | import commonjs from "@rollup/plugin-commonjs"; 3 | import resolve from "@rollup/plugin-node-resolve"; 4 | import livereload from "rollup-plugin-livereload"; 5 | import { terser } from "rollup-plugin-terser"; 6 | import sveltePreprocess from "svelte-preprocess"; 7 | import typescript from "@rollup/plugin-typescript"; 8 | import css from "rollup-plugin-css-only"; 9 | 10 | const production = !process.env.ROLLUP_WATCH; 11 | 12 | function serve() { 13 | let server; 14 | 15 | function toExit() { 16 | if (server) server.kill(0); 17 | } 18 | 19 | return { 20 | writeBundle() { 21 | if (server) return; 22 | server = require("child_process").spawn( 23 | "npm", 24 | ["run", "start", "--", "--dev"], 25 | { 26 | stdio: ["ignore", "inherit", "inherit"], 27 | shell: true, 28 | } 29 | ); 30 | 31 | process.on("SIGTERM", toExit); 32 | process.on("exit", toExit); 33 | }, 34 | }; 35 | } 36 | 37 | export default { 38 | input: "src/main.ts", 39 | output: { 40 | sourcemap: true, 41 | format: "iife", 42 | name: "app", 43 | file: "public/build/bundle.js", 44 | }, 45 | plugins: [ 46 | svelte({ 47 | preprocess: sveltePreprocess({ sourceMap: !production }), 48 | compilerOptions: { 49 | // enable run-time checks when not in production 50 | dev: !production, 51 | }, 52 | 53 | onwarn: (warning, handler) => { 54 | const { code } = warning; 55 | if (code === "css-unused-selector") return; 56 | 57 | handler(warning); 58 | }, 59 | }), 60 | 61 | // we'll extract any component CSS out into 62 | // a separate file - better for performance 63 | css({ output: "bundle.css" }), 64 | 65 | // If you have external dependencies installed from 66 | // npm, you'll most likely need these plugins. In 67 | // some cases you'll need additional configuration - 68 | // consult the documentation for details: 69 | // https://github.com/rollup/plugins/tree/master/packages/commonjs 70 | resolve({ 71 | browser: true, 72 | dedupe: ["svelte"], 73 | }), 74 | commonjs(), 75 | typescript({ 76 | sourceMap: !production, 77 | inlineSources: !production, 78 | }), 79 | 80 | // In dev mode, call `npm run start` once 81 | // the bundle has been generated 82 | !production && serve(), 83 | 84 | // Watch the `public` directory and refresh the 85 | // browser on changes when not in production 86 | !production && livereload("public"), 87 | 88 | // If we're building for production (npm run build 89 | // instead of npm run dev), minify 90 | production && terser(), 91 | ], 92 | watch: { 93 | clearScreen: false, 94 | }, 95 | }; 96 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Deployment 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*.*.*" 7 | 8 | jobs: 9 | release: 10 | name: Publish on GitHub 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | build: [linux, macos, windows] 16 | include: 17 | - build: linux 18 | os: ubuntu-latest 19 | target: x86_64-unknown-linux-gnu 20 | - build: macos 21 | os: macos-latest 22 | target: x86_64-apple-darwin 23 | - build: windows 24 | os: windows-latest 25 | target: x86_64-pc-windows-msvc 26 | 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v4 30 | 31 | - name: Install dependencies 32 | if: matrix.os == 'ubuntu-latest' 33 | run: | 34 | sudo apt-get update 35 | sudo apt-get install \ 36 | --allow-unauthenticated -y -qq \ 37 | libgtk-3-dev \ 38 | webkit2gtk-4.0 \ 39 | libappindicator3-dev \ 40 | librsvg2-dev patchelf 41 | 42 | - name: Install node 43 | uses: actions/setup-node@v4 44 | 45 | - name: Install Rust 46 | uses: actions-rs/toolchain@v1 47 | with: 48 | toolchain: stable 49 | target: ${{ matrix.target }} 50 | override: true 51 | profile: minimal 52 | 53 | - name: Build CLI 54 | working-directory: cli 55 | shell: bash 56 | run: | 57 | cargo build --verbose --locked \ 58 | --release --target ${{ matrix.target }} 59 | 60 | - name: Build GUI 61 | working-directory: gui 62 | shell: bash 63 | run: | 64 | yarn install --ignore-engines 65 | yarn tauri info 66 | CI=true yarn tauri build --target ${{ matrix.target }} 67 | 68 | - name: Prepare artifacts [Unix] 69 | shell: bash 70 | if: matrix.os != 'windows-latest' 71 | run: | 72 | release_dir="spicy-launcher" 73 | mkdir $release_dir 74 | for bin in 'spicy-launcher' 'spicy-launcher-cli'; do 75 | cp "target/${{ matrix.target }}/release/$bin" $release_dir/ 76 | done 77 | tar -czvf "spicy-launcher-${{ matrix.build }}.tar.gz" $release_dir/ 78 | if [ "${{ matrix.os }}" = "ubuntu-latest" ]; then 79 | bundles=("deb" "AppImage") 80 | else 81 | bundles=("app" "dmg") 82 | fi 83 | for bundle in "${bundles[@]}"; do 84 | mv "target/${{ matrix.target }}/release"/bundle/*/spicy-launcher*.$bundle \ 85 | "spicy-launcher-${{ matrix.build }}.$bundle" 86 | done 87 | 88 | - name: Prepare artifacts [Windows] 89 | shell: bash 90 | if: matrix.os == 'windows-latest' 91 | run: | 92 | release_dir="spicy-launcher" 93 | mkdir $release_dir 94 | for bin in 'spicy-launcher.exe' 'spicy-launcher-cli.exe'; do 95 | cp "target/${{ matrix.target }}/release/$bin" $release_dir/ 96 | done 97 | 7z a -tzip "spicy-launcher-${{ matrix.build }}.zip" $release_dir/ 98 | mv "target/${{ matrix.target }}/release"/bundle/*/spicy-launcher*.msi \ 99 | "spicy-launcher-${{ matrix.build }}.msi" 100 | 101 | - name: Publish release 102 | uses: svenstaro/upload-release-action@v2 103 | with: 104 | file: spicy-launcher-${{ matrix.build }}* 105 | file_glob: true 106 | overwrite: true 107 | tag: ${{ github.ref }} 108 | repo_token: ${{ secrets.GITHUB_TOKEN }} 109 | -------------------------------------------------------------------------------- /core/src/github/mod.rs: -------------------------------------------------------------------------------- 1 | mod api; 2 | 3 | use crate::error::{Error, Result}; 4 | use crate::http::HttpClient; 5 | use crate::release::{Asset, Release}; 6 | use crate::tracker::ProgressTracker; 7 | use crate::Game; 8 | use api::Releases; 9 | use ring::digest::{Context, SHA256}; 10 | use std::fmt::Write; 11 | use std::fs::File; 12 | use std::io::{BufReader, Read}; 13 | use std::io::{Error as IoError, ErrorKind as IoErrorKind, Result as IoResult}; 14 | use std::path::Path; 15 | 16 | pub struct GitHubClient { 17 | http_client: HttpClient, 18 | } 19 | 20 | impl GitHubClient { 21 | pub fn new() -> Result { 22 | Ok(Self { 23 | http_client: HttpClient::new()?, 24 | }) 25 | } 26 | 27 | pub async fn get_releases(&self, game: Game) -> Result> { 28 | Ok(self 29 | .http_client 30 | .get_json::( 31 | &format!( 32 | "https://raw.githubusercontent.com/spicylobstergames/SpicyLauncher/upstream/{}.json", 33 | game.id() 34 | ) 35 | ) 36 | .await? 37 | .iter() 38 | .map(|github_release| Release { 39 | installed: false, 40 | prerelease: github_release.prerelease, 41 | body: github_release.body.to_string(), 42 | name: github_release.name.to_string(), 43 | version: github_release.tag_name.to_string(), 44 | assets: github_release 45 | .assets 46 | .iter() 47 | .map(|release_asset| { 48 | Asset::new( 49 | release_asset.name.to_string(), 50 | release_asset.browser_download_url.to_string(), 51 | release_asset.size.try_into().unwrap_or_default(), 52 | ) 53 | }) 54 | .collect(), 55 | }) 56 | .collect()) 57 | } 58 | 59 | pub async fn download_asset( 60 | &self, 61 | asset: &Asset, 62 | path: &Path, 63 | tracker: &mut Tracker, 64 | ) -> Result<()> { 65 | let mut file = File::create(path)?; 66 | self.http_client 67 | .download(&asset.download_url, &mut file, tracker) 68 | .await?; 69 | Ok(()) 70 | } 71 | 72 | pub async fn verify_asset(&self, asset: &Asset, path: &Path) -> Result<()> { 73 | let sha256sum = self 74 | .http_client 75 | .get_text(&format!("{}.sha256", asset.download_url)) 76 | .await?; 77 | let mut reader = BufReader::new(File::open(path)?); 78 | let mut context = Context::new(&SHA256); 79 | let mut buffer = [0; 1024]; 80 | loop { 81 | let bytes_read = reader.read(&mut buffer)?; 82 | if bytes_read != 0 { 83 | context.update(&buffer[..bytes_read]); 84 | } else { 85 | break; 86 | } 87 | } 88 | let digest = context 89 | .finish() 90 | .as_ref() 91 | .iter() 92 | .collect::>() 93 | .iter() 94 | .try_fold::>(String::new(), |mut output, b| { 95 | write!(output, "{b:02x}") 96 | .map_err(|e| IoError::new(IoErrorKind::Other, e.to_string()))?; 97 | Ok(output) 98 | })?; 99 | if digest != sha256sum.trim() { 100 | Err(Error::Verify(format!( 101 | "checksum mismatch: expected {digest:?}, got {sha256sum:?}" 102 | ))) 103 | } else { 104 | Ok(()) 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /core/src/archive/zip.rs: -------------------------------------------------------------------------------- 1 | use crate::error::{Error, Result}; 2 | use crate::tracker::{Progress, ProgressEvent, ProgressTracker}; 3 | use std::fs::{self, File}; 4 | use std::io::{self, Read, Seek}; 5 | use std::path::{Path, PathBuf}; 6 | use zip::ZipArchive; 7 | 8 | pub fn extract( 9 | target: &Path, 10 | target_dir: &Path, 11 | strip_toplevel: bool, 12 | tracker: &mut Tracker, 13 | ) -> Result<()> { 14 | let source = File::open(target)?; 15 | if !target_dir.exists() { 16 | fs::create_dir_all(target_dir)?; 17 | } 18 | let mut archive = ZipArchive::new(source)?; 19 | let total = archive.len().try_into()?; 20 | tracker.set_total_progress(total, ProgressEvent::Extract); 21 | let do_strip_toplevel = strip_toplevel && has_toplevel(&mut archive)?; 22 | for i in 0..archive.len() { 23 | let mut file = archive.by_index(i)?; 24 | let mut relative_path = file.mangled_name(); 25 | if do_strip_toplevel { 26 | let base = relative_path 27 | .components() 28 | .take(1) 29 | .fold(PathBuf::new(), |mut p, c| { 30 | p.push(c); 31 | p 32 | }); 33 | relative_path = relative_path 34 | .strip_prefix(&base) 35 | .map_err(|error| Error::StripToplevel { 36 | toplevel: base, 37 | path: relative_path.clone(), 38 | error, 39 | })? 40 | .to_path_buf() 41 | } 42 | if relative_path.to_string_lossy().is_empty() { 43 | continue; 44 | } 45 | let mut output_path = target_dir.to_path_buf(); 46 | output_path.push(relative_path); 47 | log::trace!("Extracting to: {:#?}", output_path); 48 | if file.name().ends_with('/') { 49 | fs::create_dir_all(&output_path)?; 50 | } else { 51 | if let Some(parent) = output_path.parent() { 52 | if !parent.exists() { 53 | fs::create_dir_all(parent)?; 54 | } 55 | } 56 | let mut outfile = File::create(&output_path)?; 57 | io::copy(&mut file, &mut outfile)?; 58 | } 59 | #[cfg(unix)] 60 | set_unix_mode(&file, &output_path)?; 61 | tracker.update_progress(Progress { 62 | event: ProgressEvent::Extract, 63 | received: i.try_into()?, 64 | total, 65 | }); 66 | } 67 | Ok(()) 68 | } 69 | 70 | fn has_toplevel(archive: &mut ZipArchive) -> Result { 71 | let mut top_level_dir: Option = None; 72 | if archive.len() < 2 { 73 | return Ok(false); 74 | } 75 | for i in 0..archive.len() { 76 | let file = archive.by_index(i)?.mangled_name(); 77 | if let Some(top_level_dir) = &top_level_dir { 78 | if !file.starts_with(top_level_dir) { 79 | log::trace!("Found different toplevel directory"); 80 | return Ok(false); 81 | } 82 | } else { 83 | let components: PathBuf = file.components().take(1).collect(); 84 | log::trace!( 85 | "Checking if path component {:?} is the only toplevel directory", 86 | components 87 | ); 88 | top_level_dir = Some(components); 89 | } 90 | } 91 | log::trace!("Found no other toplevel directory"); 92 | Ok(true) 93 | } 94 | 95 | #[cfg(unix)] 96 | fn set_unix_mode(file: &zip::read::ZipFile, output_path: &Path) -> io::Result<()> { 97 | if let Some(mode) = file.unix_mode() { 98 | fs::set_permissions( 99 | output_path, 100 | std::os::unix::fs::PermissionsExt::from_mode(mode), 101 | )? 102 | } 103 | Ok(()) 104 | } 105 | -------------------------------------------------------------------------------- /core/src/storage.rs: -------------------------------------------------------------------------------- 1 | use crate::archive::{self, ArchiveFormat}; 2 | use crate::error::{Error, Result}; 3 | use crate::release::{Asset, Release}; 4 | use crate::tracker::ProgressTracker; 5 | use crate::{constant::*, Game}; 6 | use std::env; 7 | use std::fs; 8 | use std::path::{Path, PathBuf}; 9 | use std::process::Command; 10 | 11 | #[derive(Debug)] 12 | pub struct LocalStorage { 13 | pub temp_dir: PathBuf, 14 | pub data_dir: PathBuf, 15 | } 16 | 17 | impl LocalStorage { 18 | pub fn init() -> Result { 19 | let temp_dir = env::temp_dir().join(TEMP_DOWNLOAD_DIR); 20 | let data_dir = dirs_next::data_local_dir() 21 | .ok_or_else(|| Error::Storage(String::from("local data directory not found")))? 22 | .join(DATA_DIR); 23 | for path in &[&temp_dir, &data_dir] { 24 | if !path.exists() { 25 | fs::create_dir(path)?; 26 | } 27 | } 28 | Ok(Self { temp_dir, data_dir }) 29 | } 30 | 31 | /// Get the filesystem path that stores the versions for a specific game. 32 | pub fn game_path(&self, game: Game) -> PathBuf { 33 | self.data_dir.join(game.id()) 34 | } 35 | 36 | /// Get the filesystem path storing the specified version of the game. 37 | /// 38 | /// > **Note:** The path may or may not exist. 39 | pub fn version_path(&self, game: Game, release_version: &str) -> PathBuf { 40 | self.game_path(game).join(release_version) 41 | } 42 | 43 | /// Remove the specified version from the filesystem, if it is installed. 44 | pub fn remove_version(&self, game: Game, release_version: &str) -> Result<()> { 45 | let target_dir = self.version_path(game, release_version); 46 | if target_dir.exists() { 47 | std::fs::remove_dir_all(target_dir)?; 48 | } 49 | Ok(()) 50 | } 51 | 52 | pub fn extract_archive( 53 | &self, 54 | asset: &Asset, 55 | archive_path: &Path, 56 | game: Game, 57 | release_version: &str, 58 | tracker: &mut Tracker, 59 | ) -> Result<()> { 60 | match asset.archive_format { 61 | Some(ArchiveFormat::Gz) => { 62 | archive::gz::extract( 63 | archive_path, 64 | game, 65 | &self.game_path(game), 66 | release_version, 67 | tracker, 68 | )?; 69 | } 70 | Some(ArchiveFormat::Zip) => { 71 | archive::zip::extract( 72 | archive_path, 73 | &self.version_path(game, release_version), 74 | true, 75 | tracker, 76 | )?; 77 | } 78 | _ => {} 79 | } 80 | Ok(()) 81 | } 82 | 83 | pub fn get_available_releases(&self, game: Game) -> Result> { 84 | let game_path = self.game_path(game); 85 | if !game_path.exists() { 86 | return Ok(Vec::new()); 87 | } 88 | 89 | Ok(fs::read_dir(game_path)? 90 | .filter_map(|entry| Some(entry.ok()?.path())) 91 | .filter(|entry| entry.is_dir() && entry.join(game.binary_name()).exists()) 92 | .filter_map(|directory| { 93 | directory 94 | .file_name() 95 | .map(|v| v.to_string_lossy().to_string()) 96 | }) 97 | .map(Release::from) 98 | .collect()) 99 | } 100 | 101 | pub fn launch_game(&self, game: Game, version: &str) -> Result<()> { 102 | let binary_path = &self 103 | .data_dir 104 | .join(game.id()) 105 | .join(version) 106 | .join(game.binary_name()); 107 | log::debug!("Launching: {:?}", binary_path); 108 | Command::new( 109 | binary_path 110 | .to_str() 111 | .ok_or_else(|| Error::Utf8(String::from("path contains invalid characters")))?, 112 | ) 113 | .current_dir(self.version_path(game, version)) 114 | .spawn()?; 115 | Ok(()) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /cli/src/app.rs: -------------------------------------------------------------------------------- 1 | use crate::progress::ProgressBar; 2 | use anyhow::{anyhow, Result}; 3 | use colored::Colorize; 4 | use spicy_launcher_core::github::GitHubClient; 5 | use spicy_launcher_core::release::Release; 6 | use spicy_launcher_core::storage::LocalStorage; 7 | use spicy_launcher_core::tracker::{ProgressEvent, ProgressTracker}; 8 | use spicy_launcher_core::Game; 9 | 10 | pub struct App { 11 | client: GitHubClient, 12 | storage: LocalStorage, 13 | progress_bar: ProgressBar, 14 | } 15 | 16 | impl App { 17 | pub fn new() -> Result { 18 | Ok(Self { 19 | client: GitHubClient::new()?, 20 | storage: LocalStorage::init()?, 21 | progress_bar: ProgressBar::default(), 22 | }) 23 | } 24 | 25 | async fn get_releases(&self, game: Game) -> Result> { 26 | self.progress_bar.enable_tick(); 27 | self.progress_bar.set_message("Updating... Please wait."); 28 | Ok(self.client.get_releases(game).await?) 29 | } 30 | 31 | fn find_version(&self, version: Option, releases: Vec) -> Result { 32 | if releases.is_empty() { 33 | return Err(anyhow!("No releases found/installed :(")); 34 | } 35 | 36 | match version { 37 | Some(version) => { 38 | let normalized_version = if version.starts_with('v') { 39 | version.clone() 40 | } else { 41 | format!("v{}", version) 42 | }; 43 | 44 | releases 45 | .clone() 46 | .into_iter() 47 | .find(|release| release.version == normalized_version) 48 | .ok_or_else(|| { 49 | anyhow!( 50 | "Version {} not found, available versions are: {}", 51 | version.red(), 52 | releases 53 | .iter() 54 | .enumerate() 55 | .map(|(i, release)| if i != releases.len() - 1 { 56 | format!("{},", release.version) 57 | } else { 58 | release.version.to_string() 59 | }) 60 | .collect::>() 61 | .join(" ") 62 | .blue() 63 | ) 64 | }) 65 | } 66 | None => Ok(releases[0].clone()), 67 | } 68 | } 69 | 70 | pub async fn print_releases(&self, game: Game) -> Result<()> { 71 | let available_relases = self.storage.get_available_releases(game)?; 72 | 73 | let mut releases: Vec = self.get_releases(game).await?; 74 | releases.iter_mut().for_each(|release| { 75 | release.installed = available_relases 76 | .iter() 77 | .any(|r| r.version == release.version) 78 | }); 79 | 80 | self.progress_bar.finish(); 81 | 82 | println!(); 83 | println!("🐟 {game} - Available versions:"); 84 | for release in releases { 85 | let release_title = format!( 86 | "- {} {} ({}) [{}]", 87 | game, 88 | release.version.blue(), 89 | release.name.green(), 90 | if release.installed { 91 | "installed".green().to_string() 92 | } else { 93 | String::from("not installed") 94 | } 95 | ); 96 | 97 | if release.prerelease { 98 | println!("{release_title} [{}]", "pre-release".yellow()); 99 | } else { 100 | println!("{release_title}"); 101 | } 102 | } 103 | println!(); 104 | 105 | Ok(()) 106 | } 107 | 108 | pub async fn install(&mut self, game: Game, version: Option) -> Result<()> { 109 | let releases = self.get_releases(game).await?; 110 | let release = self.find_version(version, releases)?; 111 | let asset = release.get_asset()?; 112 | let download_path = self.storage.temp_dir.join(&asset.name); 113 | self.progress_bar 114 | .set_total_progress(asset.size, ProgressEvent::Download); 115 | self.progress_bar 116 | .set_message(format!("{} {}", "Downloading".blue(), &asset.name,)); 117 | self.client 118 | .download_asset(&asset, &download_path, &mut self.progress_bar) 119 | .await?; 120 | self.progress_bar.reset_style(); 121 | self.progress_bar.enable_tick(); 122 | self.progress_bar 123 | .set_message(format!("{} {}", "Verifying".yellow(), &asset.name)); 124 | self.client.verify_asset(&asset, &download_path).await?; 125 | self.progress_bar 126 | .set_message(format!("{} {}", "Extracting".green(), &asset.name)); 127 | self.storage.extract_archive( 128 | &asset, 129 | &download_path, 130 | game, 131 | &release.version, 132 | &mut self.progress_bar, 133 | )?; 134 | self.progress_bar.finish(); 135 | log::info!("{} is ready to play! 🐟", &release.version); 136 | Ok(()) 137 | } 138 | 139 | pub async fn uninstall(&mut self, game: Game, version: Option) -> Result<()> { 140 | let releases = self.get_releases(game).await?; 141 | let release = self.find_version(version, releases)?; 142 | let install_path = self.storage.version_path(game, &release.version); 143 | if install_path.exists() { 144 | log::debug!("Removing {:?}", install_path); 145 | self.progress_bar.set_message(format!( 146 | "{} {}", 147 | "Uninstalling".green(), 148 | &release.version 149 | )); 150 | self.storage.remove_version(game, &release.version)?; 151 | self.progress_bar.finish(); 152 | log::info!("{} is uninstalled.", &release.version); 153 | } else { 154 | self.progress_bar.finish(); 155 | log::warn!("{} is not installed.", release.version); 156 | } 157 | Ok(()) 158 | } 159 | 160 | pub fn launch(&self, game: Game, version: Option) -> Result<()> { 161 | let available_relases = self.storage.get_available_releases(game)?; 162 | let release = self.find_version(version, available_relases)?; 163 | self.storage.launch_game(game, &release.version)?; 164 | Ok(()) 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /gui/src/components/Sidebar.svelte: -------------------------------------------------------------------------------- 1 | 117 | 118 |