5SYW3DZVWIASHAD4XZLUIG6HS2EOZLER2DPWITGD5YTQPU3RZYYAC
<?xml version='1.0' encoding='windows-1252'?>
<!--
Copyright (C) 2017 Christopher R. Field.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<!--
The "cargo wix" subcommand provides a variety of predefined variables available
for customization of this template. The values for each variable are set at
installer creation time. The following variables are available:
TargetTriple = The rustc target triple name.
TargetEnv = The rustc target environment. This is typically either
"msvc" or "gnu" depending on the toolchain downloaded and
installed.
TargetVendor = The rustc target vendor. This is typically "pc", but Rust
does support other vendors, like "uwp".
CargoTargetBinDir = The complete path to the binary (exe). The default would
be "target\release\<BINARY_NAME>.exe" where
"<BINARY_NAME>" is replaced with the name of each binary
target defined in the package's manifest (Cargo.toml). If
a different rustc target triple is used than the host,
i.e. cross-compiling, then the default path would be
"target\<CARGO_TARGET>\<CARGO_PROFILE>\<BINARY_NAME>.exe",
where "<CARGO_TARGET>" is replaced with the "CargoTarget"
variable value and "<CARGO_PROFILE>" is replaced with the
value from the `CargoProfile` variable.
CargoTargetDir = The path to the directory for the build artifacts, i.e.
"target".
CargoProfile = Either "debug" or `release` depending on the build
profile. The default is "release".
Version = The version for the installer. The default is the
"Major.Minor.Fix" semantic versioning number of the Rust
package.
-->
<!--
Please do not remove these pre-processor If-Else blocks. These are used with
the `cargo wix` subcommand to automatically determine the installation
destination for 32-bit versus 64-bit installers. Removal of these lines will
cause installation errors.
-->
<?if $(sys.BUILDARCH) = x64 or $(sys.BUILDARCH) = arm64 ?>
<?define PlatformProgramFilesFolder = "ProgramFiles64Folder" ?>
<?else ?>
<?define PlatformProgramFilesFolder = "ProgramFilesFolder" ?>
<?endif ?>
<Wix xmlns='http://schemas.microsoft.com/wix/2006/wi'>
<Product
Id='*'
Name='papa'
UpgradeCode='1475BD9C-1F72-42ED-ADF8-1019033241B4'
Manufacturer='AnActualEmerald'
Language='1033'
Codepage='1252'
Version='$(var.Version)'>
<Package Id='*'
Keywords='Installer'
Description='A cli mod manager for the Northstar launcher'
Manufacturer='AnActualEmerald'
InstallerVersion='450'
Languages='1033'
Compressed='yes'
InstallScope='perMachine'
SummaryCodepage='1252'
/>
<MajorUpgrade
Schedule='afterInstallInitialize'
DowngradeErrorMessage='A newer version of [ProductName] is already installed. Setup will now exit.'/>
<Media Id='1' Cabinet='media1.cab' EmbedCab='yes' DiskPrompt='CD-ROM #1'/>
<Property Id='DiskPrompt' Value='papa Installation'/>
<Directory Id='TARGETDIR' Name='SourceDir'>
<Directory Id='$(var.PlatformProgramFilesFolder)' Name='PFiles'>
<Directory Id='APPLICATIONFOLDER' Name='papa'>
<!--
Disabling the license sidecar file in the installer is a two step process:
1. Comment out or remove the `Component` tag along with its contents.
2. Comment out or remove the `ComponentRef` tag with the "License" Id
attribute value further down in this file.
-->
<Component Id='License' Guid='*'>
<File Id='LicenseFile'
Name='License.rtf'
DiskId='1'
Source='wix\License.rtf'
KeyPath='yes'/>
</Component>
<Directory Id='Bin' Name='bin'>
<Component Id='Path' Guid='141A476B-5BBE-426B-A686-D7A7B8D93761' KeyPath='yes'>
<Environment
Id='PATH'
Name='PATH'
Value='[Bin]'
Permanent='no'
Part='last'
Action='set'
System='yes'/>
</Component>
<Component Id='binary0' Guid='*'>
<File
Id='exe0'
Name='papa.exe'
DiskId='1'
Source='$(var.CargoTargetBinDir)\papa.exe'
KeyPath='yes'/>
</Component>
</Directory>
</Directory>
</Directory>
</Directory>
<Feature
Id='Binaries'
Title='Application'
Description='Installs all binaries and the license.'
Level='1'
ConfigurableDirectory='APPLICATIONFOLDER'
AllowAdvertise='no'
Display='expand'
Absent='disallow'>
<!--
Comment out or remove the following `ComponentRef` tag to remove
the license sidecar file from the installer.
-->
<ComponentRef Id='License'/>
<ComponentRef Id='binary0'/>
<Feature
Id='Environment'
Title='PATH Environment Variable'
Description='Add the install location of the [ProductName] executable to the PATH system environment variable. This allows the [ProductName] executable to be called from any location.'
Level='1'
Absent='allow'>
<ComponentRef Id='Path'/>
</Feature>
</Feature>
<SetProperty Id='ARPINSTALLLOCATION' Value='[APPLICATIONFOLDER]' After='CostFinalize'/>
<!--
Disabling the custom product icon for the application in the
Add/Remove Programs control panel requires commenting out or
removing the following `Icon` and `Property` tags.
-->
<Icon Id='ProductICO' SourceFile='./ScorchIcon.ico'/>
<Property Id='ARPPRODUCTICON' Value='ProductICO' />
<Property Id='ARPHELPLINK' Value='https://github.com/AnActualEmerald/papa'/>
<UI>
<UIRef Id='WixUI_FeatureTree'/>
<!--
Disabling the EULA dialog in the installer is a two step process:
1. Uncomment the following two `Publish` tags
2. Comment out or remove the `<WiXVariable Id='WixUILicenseRtf'...` tag further down
-->
<!--<Publish Dialog='WelcomeDlg' Control='Next' Event='NewDialog' Value='CustomizeDlg' Order='99'>1</Publish>-->
<!--<Publish Dialog='CustomizeDlg' Control='Back' Event='NewDialog' Value='WelcomeDlg' Order='99'>1</Publish>-->
</UI>
<!--
Disabling the EULA dialog in the installer requires commenting out
or removing the following `WixVariable` tag
-->
<WixVariable Id='WixUILicenseRtf' Value='wix\License.rtf'/>
<!--
Uncomment the next `WixVaraible` tag to customize the installer's
Graphical User Interface (GUI) and add a custom banner image across
the top of each screen. See the WiX Toolset documentation for details
about customization.
The banner BMP dimensions are 493 x 58 pixels.
-->
<!--<WixVariable Id='WixUIBannerBmp' Value='wix\Banner.bmp'/>-->
<!--
Uncomment the next `WixVariable` tag to customize the installer's
Graphical User Interface (GUI) and add a custom image to the first
dialog, or screen. See the WiX Toolset documentation for details about
customization.
The dialog BMP dimensions are 493 x 312 pixels.
-->
<!--<WixVariable Id='WixUIDialogBmp' Value='wix\Dialog.bmp'/>-->
</Product>
</Wix>
{\rtf1\ansi\deff0\nouicompat{\fonttbl{\f0\fnil\fcharset0 Arial;}{\f1\fnil\fcharset0 Courier New;}}
{\*\generator Riched20 10.0.15063}\viewkind4\uc1
\pard\sa180\fs24\lang9 Copyright (c) 2022 AnActualEmerald\par
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\par
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\par
\f1 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\f0\par
}
�
use std::{cmp::min, fs::File, io::Write, path::PathBuf};
use futures_util::StreamExt;
use indicatif::{ProgressBar, ProgressStyle};
use reqwest::Client;
pub async fn download_file(url: String, file_path: PathBuf) -> Result<(), String> {
let client = Client::new();
//send the request
let res = client
.get(&url)
.send()
.await
.or(Err(format!("Unable to GET from {}", &url)))?;
let file_size = res.content_length().ok_or(format!(
"Unable to read content length of response from {}",
url
))?;
//setup the progress bar
let pb = ProgressBar::new(file_size).with_style(ProgressStyle::default_bar().template(
"{msg}\n{spinner:.green} [{duration}] {wide_bar:.cyan} {bytes}/{total_bytes} {bytes_per_sec}",
).progress_chars("#>-"));
//start download in chunks
let mut file = File::create(&file_path).or(Err(format!(
"Failed to create file {}",
file_path.display()
)))?;
let mut downloaded: u64 = 0;
let mut stream = res.bytes_stream();
while let Some(item) = stream.next().await {
let chunk = item.or(Err(format!("Error downloading file :(")))?;
file.write_all(&chunk).or(Err(format!(
"Error writing to file {}",
file_path.display()
)))?;
let new = min(downloaded + (chunk.len() as u64), file_size);
downloaded = new;
pb.set_position(new);
}
pb.finish_with_message(format!("Downloaded {}", url));
Ok(())
}
//supposing the mod name is formatted like Author.Mod@v1.0.0
pub fn parse_mod_name(name: &str) -> Option<String> {
let parts = name.split_once(".")?;
let author = parts.0;
let parts = parts.1.split_once("@")?;
let m_name = parts.0;
let ver = parts.1.replace("v", "");
Some(format!("/{}/{}/{}", author, m_name, ver))
}
#[cfg(feature = "northstar")]
use crate::core::northstar::{init_northstar, update_northstar};
use std::path::PathBuf;
use clap::{Parser, Subcommand};
use log::debug;
use crate::api::model;
use directories::ProjectDirs;
use rustyline::Editor;
mod api;
mod core;
use crate::core::commands::*;
#[derive(Parser)]
#[clap(name = "Papa")]
#[clap(author = "AnAcutalEmerald <emerald_actual@proton.me>")]
#[clap(version = env!("CARGO_PKG_VERSION"))]
#[clap(about = "Command line mod manager for Northstar")]
#[clap(after_help = "Welcome back. Cockpit cooling reactivated.")]
struct Cli {
#[clap(subcommand)]
command: Commands,
#[clap(short, long)]
debug: bool,
}
#[derive(Subcommand)]
enum Commands {
///Install a mod or mods from https://northstar.thunderstore.io/
#[clap(alias = "i")]
Install {
#[clap(value_name = "MOD")]
#[clap(help = "Mod name(s) to install")]
#[clap(required_unless_present = "url")]
mod_names: Vec<String>,
///Alternate url to use - won't be tracked or updated
#[clap(short, long)]
#[clap(value_name = "URL")]
url: Option<String>,
///Don't ask for confirmation
#[clap(short, long)]
yes: bool,
///Force installation
#[clap(short, long)]
force: bool,
///Make mod globally available
#[clap(short, long)]
global: bool,
},
///Remove a mod or mods from the current mods directory
#[clap(alias = "r", alias = "rm")]
Remove {
#[clap(value_name = "MOD")]
#[clap(help = "Mod name(s) to remove")]
mod_names: Vec<String>,
},
///List installed mods
#[clap(alias = "l", alias = "ls")]
List {
///List only globally installed mods
#[clap(short, long)]
global: bool,
///List both local and global mods
#[clap(short, long)]
all: bool,
},
///Clear mod cache
#[clap(alias = "c")]
Clear {
#[clap(
help = "Force removal of all files in the cahce directory, not just downloaded packages"
)]
#[clap(long, short)]
full: bool,
},
///Display or update the configuration
#[clap(alias = "cfg")]
Config {
#[clap(long, short, value_name = "PATH")]
///Set the directory where 'mods/' can be found
mods_dir: Option<String>,
#[clap(long, short, value_name = "CACHE")]
///Set whether or not to cache packages
cache: Option<bool>,
},
///Update currently installed mods
#[clap(alias = "u")]
Update {
///Don't ask for confirmation
#[clap(short, long)]
yes: bool,
},
///Search for a mod
#[clap(alias = "s")]
Search {
///The term to search for
term: Vec<String>,
},
///Disable mod(s) or sub-mod(s)
Disable { mods: Vec<String> },
///Enable mod(s) or sub-mod(s)
Enable { mods: Vec<String> },
//These will only be available on linux for now because symlinks on Windows are weird
#[cfg(target_os = "linux")]
#[clap(alias = "link", alias = "ln")]
///Link a global mod to the current mods folder
Include {
mods: Vec<String>,
#[clap(long, short)]
force: bool,
},
#[cfg(target_os = "linux")]
#[clap(alias = "unlink")]
///Unlink a global mod from the current mods folder
Exclude { mods: Vec<String> },
///Commands for managing Northstar itself
#[cfg(feature = "northstar")]
#[clap(alias("ns"))]
Northstar {
#[clap(subcommand)]
command: NstarCommands,
},
}
#[derive(Subcommand)]
enum NstarCommands {
// ///Installs northstar to provided path, or current directory.
// Install { game_path: Option<PathBuf> },
///Initializes a new northstar installation in the provided path, or current directory.
Init { game_path: Option<PathBuf> },
///Updats the current northstar install. Must have been installed with `papa northstar init`.
Update {},
#[cfg(feature = "launcher")]
///Start the Northstar client
Start {},
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
if cli.debug {
std::env::set_var("RUST_LOG", "DEBUG");
}
env_logger::builder().format_timestamp(None).init();
let dirs = ProjectDirs::from("me", "greenboi", "papa").unwrap();
let rl = Editor::<()>::new();
let mut ctx = core::Ctx::new(dirs, rl);
let res = match cli.command {
Commands::Update { yes } => update(&mut ctx, yes).await,
Commands::Config {
mods_dir: None,
cache: None,
} => {
println!(
"Current config:\n{}",
toml::to_string_pretty(&ctx.config).unwrap()
);
Ok(())
}
Commands::Config { mods_dir, cache } => update_config(&mut ctx, mods_dir, cache),
Commands::List { global, all } => list(&ctx, global, all),
Commands::Install {
mod_names: _,
url: Some(url),
yes: _,
force: _,
global: _,
} => install_from_url(&ctx, url).await,
Commands::Install {
mod_names,
url: None,
yes,
force,
global,
} => install(&mut ctx, mod_names, yes, force, global).await,
Commands::Disable { mods } => disable(&mut ctx, mods),
Commands::Enable { mods } => enable(&mut ctx, mods),
Commands::Search { term } => search(&ctx, term).await,
Commands::Remove { mod_names } => remove(&ctx, mod_names),
Commands::Clear { full } => clear(&ctx, full),
#[cfg(feature = "northstar")]
Commands::Northstar { command } => match command {
// NstarCommands::Install { game_path } => {
// let game_path = if let Some(p) = game_path {
// p.canonicalize().unwrap()
// } else {
// std::env::current_dir().unwrap()
// };
// core.install_northstar(&game_path).await
// }
NstarCommands::Init { game_path } => {
let game_path = if let Some(p) = game_path {
match p.canonicalize() {
Ok(p) => p,
Err(e) => {
debug!("{:#?}", e);
println!("{}", e);
return;
}
}
} else {
std::env::current_dir().unwrap()
};
init_northstar(&mut ctx, &game_path).await
}
NstarCommands::Update {} => update_northstar(&mut ctx).await,
#[cfg(feature = "launcher")]
NstarCommands::Start {} => ctx.start_northstar(&ctx),
},
#[cfg(target_os = "linux")]
Commands::Include { mods, force } => include(&ctx, mods, force),
#[cfg(target_os = "linux")]
Commands::Exclude { mods } => exclude(&ctx, mods),
};
if let Some(e) = res.err() {
if cli.debug {
debug!("{:#?}", e);
} else {
println!("{}", e);
}
}
}
use crate::api;
use crate::api::model::LocalIndex;
use crate::api::model::SubMod;
use crate::model;
use crate::model::InstalledMod;
use crate::model::Mod;
use anyhow::{anyhow, Context, Result};
use directories::ProjectDirs;
use std::collections::HashSet;
use std::ffi::OsStr;
use std::fs::{self, File, OpenOptions};
use std::io::Write;
use std::path::Path;
#[macro_export]
macro_rules! g2re {
($e:expr) => {{
let re = $e.replace('*', ".+");
regex::Regex::new(&re)
}};
}
pub async fn update_index(path: &Path) -> Vec<model::Mod> {
print!("Updating package index...");
let mut index = api::get_package_index().await.unwrap().to_vec();
// save_file(&dirs.cache_dir().join("index.ron"), index)?;
let installed = get_installed(path).unwrap();
for e in index.iter_mut() {
e.installed = installed
.mods
.iter()
.any(|f| f.package_name == e.name && f.version == e.version);
}
println!(" Done!");
index
}
pub fn get_installed(path: &Path) -> Result<LocalIndex> {
let path = path.join(".papa.ron");
if path.exists() {
let raw = fs::read_to_string(path).context("Unable to read installed packages")?;
Ok(ron::from_str(&raw)?)
} else {
if let Some(p) = path.parent() {
if !p.exists() {
fs::create_dir_all(p)?;
}
}
File::create(path)
.context("Unable to create installed package index")?
.write_all(ron::to_string(&LocalIndex::new()).unwrap().as_bytes())?;
Ok(LocalIndex::new())
}
}
#[inline]
pub fn save_installed(path: &Path, installed: &LocalIndex) -> Result<()> {
let path = path.join(".papa.ron");
save_file(&path, ron::to_string(installed).unwrap())?;
Ok(())
}
#[inline]
pub fn check_cache(path: &Path) -> Option<File> {
if let Ok(f) = OpenOptions::new().read(true).open(path) {
Some(f)
} else {
None
}
}
#[inline(always)]
pub fn ensure_dirs(dirs: &ProjectDirs) {
fs::create_dir_all(dirs.cache_dir()).unwrap();
fs::create_dir_all(dirs.config_dir()).unwrap();
fs::create_dir_all(dirs.data_local_dir()).unwrap();
}
pub fn remove_file(path: &Path) -> Result<()> {
fs::remove_file(path).context(format!("Unable to remove file {}", path.display()))
}
// pub fn remove_dir(dir: &Path) -> Result<(), String> {
// fs::remove_dir_all(dir)
// .map_err(|_| format!("Unable to remove directory {}", dir.display()))?;
//
// Ok(())
// }
pub fn clear_cache(dir: &Path, force: bool) -> Result<()> {
for entry in fs::read_dir(dir).context(format!("unable to read directory {}", dir.display()))? {
let path = entry.context("Error reading directory entry")?.path();
if path.is_dir() {
clear_cache(&path, force)?;
fs::remove_dir(&path)
.context(format!("Unable to remove directory {}", path.display()))?;
} else if path.extension() == Some(OsStr::new("zip")) || force {
fs::remove_file(&path).context(format!("Unable to remove file {}", path.display()))?;
}
}
Ok(())
}
// pub fn list_dir(dir: &Path) -> Result<Vec<String>, String> {
// Ok(fs::read_dir(dir)
// .map_err(|_| format!("unable to read directory {}", dir.display()))
// .map_err(|_| format!("Unable to read directory {}", dir.display()))?
// .filter(|f| f.is_ok())
// .map(|f| f.unwrap())
// .map(|f| f.file_name().to_string_lossy().into_owned())
// .collect())
// }
#[inline]
pub fn save_file(file: &Path, data: String) -> Result<()> {
fs::write(file, data.as_bytes())?;
Ok(())
}
// //supposing the mod name is formatted like Author.Mod@v1.0.0
// pub fn parse_mod_name(name: &str) -> Option<String> {
// let parts = name.split_once('.')?;
// let author = parts.0;
// //let parts = parts.1.split_once('@')?;
// let m_name = parts.1;
// //let ver = parts.1.replace('v', "");
//
// let big_snake = Converter::new()
// .set_delim("_")
// .set_pattern(Pattern::Capital);
//
// Some(format!("{}.{}", author, big_snake.convert(&m_name)))
// }
pub fn resolve_deps<'a>(
valid: &mut Vec<&'a Mod>,
base: &'a Mod,
installed: &'a HashSet<InstalledMod>,
index: &'a Vec<Mod>,
) -> Result<()> {
for dep in &base.deps {
let dep_name = dep.split('-').collect::<Vec<&str>>()[1];
if !installed.iter().any(|e| e.package_name == dep_name) {
if let Some(d) = index.iter().find(|f| f.name == dep_name) {
resolve_deps(valid, d, installed, index)?;
valid.push(d);
} else {
return Err(anyhow!(
"Unable to resolve dependency {} of {}",
dep,
base.name
));
}
}
}
Ok(())
}
pub fn disable_mod(m: &mut SubMod) -> Result<bool> {
if m.disabled() {
return Ok(false);
}
let name = &m.name;
let old_path = m.path.clone();
let dir = m.path.parent().unwrap().join(".disabled");
if !dir.exists() {
fs::create_dir_all(&dir)?;
}
m.path = dir.join(name);
fs::rename(old_path, &m.path).context("Failed to rename mod")?;
Ok(true)
}
pub fn enable_mod(m: &mut SubMod, mods_dir: &Path) -> Result<bool> {
if !m.disabled() {
return Ok(false);
}
let old_path = m.path.clone();
m.path = mods_dir.join(&m.name);
fs::rename(old_path, &m.path).context("Failed to reanem mod")?;
Ok(true)
}
use std::{
fs::{self, File, OpenOptions},
io,
path::Path,
};
use log::debug;
use zip::ZipArchive;
use crate::api::model::Mod;
use anyhow::{anyhow, Context, Result};
use super::{actions, config, utils, Ctx};
pub(crate) async fn init_northstar(ctx: &mut Ctx, game_path: &Path) -> Result<()> {
let version = install_northstar(&ctx, game_path).await?;
ctx.config.game_path = game_path.to_path_buf();
ctx.config.nstar_version = Some(version);
ctx.config
.set_dir(game_path.join("R2Northstar").join("mods").to_str().unwrap());
println!("Set mod directory to {}", ctx.config.mod_dir().display());
config::save_config(ctx.dirs.config_dir(), &ctx.config)?;
Ok(())
}
#[cfg(feature = "launcher")]
pub fn start_northstar(ctx: &Ctx) -> Result<(), String> {
let game = ctx.config.game_path.join("NorthstarLauncher.exe");
std::process::Command::new(game)
// .stderr(Stdio::null())
// .stdin(Stdio::null())
// .stdout(Stdio::null())
.spawn()
.expect("Unable to start game");
Ok(())
}
///Update N* at the path that was initialized
///
///Returns OK if the path isn't set, but notifies the user
pub async fn update_northstar(ctx: &mut Ctx) -> Result<()> {
if let Some(current) = &ctx.config.nstar_version {
let index = utils::update_index(ctx.config.mod_dir()).await;
let nmod = index
.iter()
.find(|f| f.name.to_lowercase() == "northstar")
.ok_or_else(|| anyhow!("Couldn't find Northstar on thunderstore???"))?;
if nmod.version == *current {
println!("Northstar is already up to date ({})", current);
return Ok(());
}
if let Ok(s) = ctx.rl.readline(&format!(
"Update Northstar to version {}? [Y/n]",
nmod.version
)) {
if &s.to_lowercase() == "n" {
return Ok(());
}
}
do_install(&ctx, nmod, &ctx.config.game_path).await?;
ctx.config.nstar_version = Some(nmod.version.clone());
config::save_config(ctx.dirs.config_dir(), &ctx.config)?;
Ok(())
} else {
println!(
"Only Northstar installations done with `papa northstar init` can be updated this way"
);
Ok(())
}
}
///Install N* to the provided path
///
///Returns the version that was installed
pub async fn install_northstar(ctx: &Ctx, game_path: &Path) -> Result<String> {
let index = utils::update_index(ctx.config.mod_dir()).await;
let nmod = index
.iter()
.find(|f| f.name.to_lowercase() == "northstar")
.ok_or_else(|| anyhow!("Couldn't find Northstar on thunderstore???"))?;
do_install(ctx, nmod, game_path).await?;
Ok(nmod.version.clone())
}
///Install N* from the provided mod
///
///Checks cache, else downloads the latest version
async fn do_install(ctx: &Ctx, nmod: &Mod, game_path: &Path) -> Result<()> {
let filename = format!("northstar-{}.zip", nmod.version);
let nfile = if let Some(f) = utils::check_cache(&ctx.dirs.cache_dir().join(&filename)) {
println!("Using cached verision of Northstar@{}...", nmod.version);
f
} else {
actions::download_file(&nmod.url, ctx.dirs.cache_dir().join(&filename)).await?
};
println!("Extracting Northstar...");
extract(ctx, nfile, game_path)?;
// println!("Copying Files...");
// ctx.move_files(game_path, extracted)?;
println!("Done!");
Ok(())
}
// fn move_files(&ctx, game_path: &Path, extracted: PathBuf) -> Result<(), String> {
// let nstar = extracted.join("Northstar");
// ctx.copy_dirs(&nstar, &nstar, game_path)?;
// println!("Northstar installed sucessfully!");
// println!("Cleaning up...");
// fs::remove_dir_all(nstar).map_err(|e| format!("Unable to remove temp directory {}", e))?;
// Ok(())
// }
// ///Recurses through a directory and moves each entry to the target, keeping the directory structure
// fn copy_dirs(&ctx, root: &Path, dir: &Path, target: &Path) -> Result<(), String> {
// for entry in (dir
// .read_dir()
// .map_err(|_| "Unable to read directory".to_string())?)
// .flatten()
// {
// if entry.path().is_dir() {
// ctx.copy_dirs(root, &entry.path(), target)?;
// continue;
// } else if let Some(p) = entry.path().parent() {
// let target = target.join(p.strip_prefix(&root).unwrap());
// debug!("Create dir {}", target.display());
// fs::create_dir_all(target).map_err(|_| "Failed to create directory".to_string())?;
// }
// let target = target.join(entry.path().strip_prefix(root).unwrap());
// debug!(
// "Moving file {} to {}",
// entry.path().display(),
// target.display()
// );
// fs::rename(entry.path(), target).map_err(|e| format!("Unable to move file: {}", e))?;
// }
// Ok(())
// }
///Extract N* zip file to target game path
fn extract(ctx: &Ctx, zip_file: File, target: &Path) -> Result<()> {
let mut archive = ZipArchive::new(&zip_file).context("Unable to open zip archive")?;
for i in 0..archive.len() {
let mut f = archive.by_index(i).unwrap();
//skip any files that have been excluded
if let Some(n) = f.enclosed_name() {
if ctx.config.exclude.iter().any(|e| Path::new(e) == n) {
continue;
}
} else {
return Err(anyhow!(
"Unable to read name of compressed file {}",
f.name()
));
}
//This should work fine for N* because the dir structure *should* always be the same
if f.enclosed_name().unwrap().starts_with("Northstar") {
let out = target.join(
f.enclosed_name()
.unwrap()
.strip_prefix("Northstar")
.unwrap(),
);
if (*f.name()).ends_with('/') {
debug!("Create directory {}", f.name());
fs::create_dir_all(target.join(f.name())).context("Unable to create directory")?;
continue;
} else if let Some(p) = out.parent() {
fs::create_dir_all(&p).context("Unable to create directory")?;
}
let mut outfile = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&out)?;
debug!("Write file {}", out.display());
io::copy(&mut f, &mut outfile).context("Unable to write to file")?;
}
}
Ok(())
}
use std::path::PathBuf;
use directories::ProjectDirs;
use rustyline::Editor;
use crate::api::model::Cache;
use self::config::Config;
pub mod actions;
pub mod config;
#[cfg(feature = "northstar")]
pub mod northstar;
pub(crate) mod commands;
pub(crate) mod utils;
pub struct Ctx {
pub config: Config,
pub dirs: ProjectDirs,
pub rl: Editor<()>,
pub cache: Cache,
pub local_target: PathBuf,
pub global_target: PathBuf,
}
impl Ctx {
pub fn new(dirs: ProjectDirs, rl: Editor<()>) -> Self {
utils::ensure_dirs(&dirs);
let config = config::load_config(dirs.config_dir()).expect("Unable to load config file");
let cache = Cache::build(dirs.cache_dir()).unwrap();
let lt = config.mod_dir.clone();
let gt = dirs.data_local_dir();
Ctx {
config,
dirs: dirs.clone(),
rl,
cache,
local_target: lt,
global_target: gt.to_path_buf(),
}
}
}
use std::{error::Error, fmt::Display};
#[derive(Debug)]
pub(crate) enum ScorchError {
FsError(Box<dyn Error>),
NotFound(String),
NetworkError(Box<dyn Error>),
Generic(Box<dyn Error>),
}
impl Display for ScorchError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
e => write!(f, "{}", e),
}
}
}
// impl From<String> for Error {
// fn from(e: String) -> Self {
// }
// }
// impl From<&str> for Error {
// fn from(e: &str) -> Self {
// Error(e.to_string())
// }
// }
impl From<std::io::Error> for ScorchError {
fn from(e: std::io::Error) -> Self {
ScorchError::FsError(Box::new(e))
}
}
use std::fs::{read_to_string, File};
use std::io::Write;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use anyhow::{anyhow, Context, Result};
#[derive(Serialize, Deserialize)]
pub struct Config {
pub mod_dir: PathBuf,
cache: bool,
#[serde(default)]
pub game_path: PathBuf,
pub nstar_version: Option<String>,
#[serde(default)]
pub exclude: Vec<String>,
}
impl Config {
pub fn new(dir: String, cache: bool, game_path: String, nstar_version: Option<String>) -> Self {
Config {
mod_dir: PathBuf::from(dir),
cache,
game_path: PathBuf::from(game_path),
nstar_version,
exclude: vec![
"ns_startup_args.txt".to_string(),
"ns_startup_args_dedi.txt".to_string(),
],
}
}
pub fn mod_dir(&self) -> &Path {
Path::new(&self.mod_dir)
}
pub fn cache(&self) -> bool {
self.cache
}
pub fn set_dir(&mut self, dir: &str) {
self.mod_dir = PathBuf::from(dir);
}
pub fn set_cache(&mut self, cache: &bool) {
self.cache = *cache;
}
}
pub fn load_config(config_dir: &Path) -> Result<Config> {
let cfg_path = config_dir.join("config.toml");
if cfg_path.exists() {
let cfg = read_to_string(cfg_path).context("Unable to read config file")?;
toml::from_str(&cfg).context("Unable to parse config")
} else {
let mut cfg = File::create(cfg_path).context("Unable to create config file")?;
let def = Config::new(String::from("./mods"), true, String::new(), None);
let parsed = toml::to_string_pretty(&def).context("Failed to serialize default config")?;
cfg.write_all(parsed.as_bytes())
.context("Unable to write config file")?;
Ok(def)
}
}
pub fn save_config(config_dir: &Path, config: &Config) -> Result<()> {
let cfg_path = config_dir.join("config.toml");
if cfg_path.exists() {
let mut cfg = File::create(&cfg_path).context("Error opening config file")?;
let parsed = toml::to_string_pretty(&config).context("Error serializing config")?;
cfg.write_all(parsed.as_bytes())
.context("Unable to write config file")?;
} else {
return Err(anyhow!("Config file does not exist to write to"));
}
Ok(())
}
use std::{fs, path::Path};
use crate::{
api::model::{LocalIndex, Mod},
core::{actions, Ctx},
};
use anyhow::{Context, Result};
use log::{debug, trace};
pub(super) async fn do_update(
ctx: &mut Ctx,
outdated: &Vec<&Mod>,
installed: &mut LocalIndex,
target: &Path,
) -> Result<()> {
let mut downloaded = vec![];
for base in outdated {
let name = &base.name;
let url = &base.url;
let path = ctx
.dirs
.cache_dir()
.join(format!("{}_{}.zip", name, base.version));
match actions::download_file(url, path).await {
Ok(f) => downloaded.push(f),
Err(e) => eprintln!("{}", e),
}
}
for f in downloaded.into_iter() {
let mut pkg = actions::install_mod(&f, target).unwrap();
ctx.cache.clean(&pkg.package_name, &pkg.version)?;
if let Some(i) = installed
.mods
.clone()
.iter()
.find(|e| e.package_name == pkg.package_name)
{
let mut inst = i.clone();
inst.version = pkg.version;
installed.mods.remove(i);
//Don't know if sorting is needed here but seems like a good assumption
inst.mods
.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
pkg.mods
.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
for (a, b) in inst.mods.iter().zip(pkg.mods.iter()) {
trace!("a mod: {:#?} | b mod: {:#?}", a, b);
if a.disabled() {
fs::remove_dir_all(&a.path).unwrap();
debug!(
"Moving mod from {} to {}",
b.path.display(),
a.path.display()
);
fs::rename(&b.path, &a.path).unwrap_or_else(|e| {
debug!("Unable to move sub-mod to old path");
debug!("{}", e);
});
}
}
inst.mods = pkg.mods;
installed.mods.insert(inst);
println!("Updated {}", pkg.package_name);
}
}
Ok(())
}
pub(super) fn link_dir(original: &Path, target: &Path) -> Result<()> {
debug!("Linking dir {} to {}", original.display(), target.display());
for e in original.read_dir()? {
let e = e?;
if e.path().is_dir() {
link_dir(&e.path(), &target.join(e.file_name()))?;
continue;
}
let target = target.join(e.file_name());
if let Some(p) = target.parent() {
if !p.exists() {
fs::create_dir_all(p)?;
}
}
debug!(
"Create hardlink {} -> {}",
e.path().display(),
target.display()
);
fs::hard_link(e.path(), &target).context("Failed to create hard link")?;
}
Ok(())
}
use std::fs;
use log::debug;
use crate::{core::{Ctx, utils, commands::utils::{do_update, link_dir}}, api::{self, model}};
use anyhow::{Result, anyhow};
pub async fn update(ctx: &mut Ctx, yes: bool) -> Result<()> {
let local_target = ctx.local_target.clone();
let global_target = ctx.global_target.clone();
print!("Updating package index...");
let index = &api::get_package_index().await?;
println!(" Done!");
let mut installed = utils::get_installed(ctx.config.mod_dir())?;
let mut global = utils::get_installed(ctx.dirs.data_local_dir())?;
let outdated: Vec<&model::Mod> = index
.iter()
.filter(|e| {
installed.mods.iter().any(|i| {
i.package_name.trim() == e.name.trim() && i.version.trim() != e.version.trim()
})
})
.collect();
let glob_outdated: Vec<&model::Mod> = index
.iter()
.filter(|e| {
global.mods.iter().any(|i| {
i.package_name.trim() == e.name.trim() && i.version.trim() != e.version.trim()
})
})
.collect();
if outdated.is_empty() && glob_outdated.is_empty() {
println!("Already up to date!");
} else {
let size: i64 = outdated.iter().map(|f| f.file_size).sum::<i64>()
+ glob_outdated.iter().map(|f| f.file_size).sum::<i64>();
println!("Updating: \n");
print!("\t");
outdated
.iter()
.chain(glob_outdated.iter())
.enumerate()
.for_each(|(i, f)| {
if i > 0 && i % 5 == 0 {
println!("\n");
print!("\t");
}
print!(" \x1b[36m{}@{}\x1b[0m ", f.name, f.version);
});
println!("\n");
if !yes {
if let Ok(line) = ctx.rl.readline(&format!(
"Will download ~{:.2} MB (compressed), okay? (This will overwrite any changes made to mod files) [Y/n]: ",
size as f64 / 1_048_576f64
)) {
if line.to_lowercase() == "n" {
return Ok(());
}
} else {
return Ok(());
}
}
do_update(ctx, &outdated, &mut installed, &local_target).await?;
do_update(ctx, &glob_outdated, &mut global, &global_target).await?;
//check if any link mods are being updated
let relink = installed
.linked
.clone()
.into_iter()
.filter(|e| glob_outdated.iter().any(|f| e.package_name == f.name));
for r in relink {
debug!("Relinking mod {}", r.package_name);
//Update the submod links
for p in r.mods.iter() {
//delete the current link first
let target = ctx.local_target.join(&p.name);
if target.exists() {
fs::remove_dir_all(&target)?;
}
link_dir(&p.path, &target)?;
}
//replace the linked mod with the new mod info
let n = global
.mods
.iter()
.find(|e| e.package_name == r.package_name)
.ok_or_else(|| anyhow!("Unable to find linked mod in global index"))?;
if !installed.linked.remove(&r) {
debug!("Didn't find old linked mod to remove");
}
if !installed.linked.insert(n.clone()) {
debug!("Failed to add updated mod to linked set");
}
}
utils::save_installed(ctx.config.mod_dir(), &installed)?;
utils::save_installed(ctx.dirs.data_local_dir(), &global)?;
}
//Would be cool to do an && on these let statements
if let Some(current) = &ctx.config.nstar_version {
if let Some(nmod) = index.iter().find(|e| e.name.to_lowercase() == "northstar") {
if *current != nmod.version {
println!(
"An update for Northstar is available! \x1b[93m{}\x1b[0m -> \x1b[93m{}\x1b[0m",
current, nmod.version
);
println!("Run \"\x1b[96mpapa northstar update\x1b[0m\" to install it!");
}
}
}
Ok(())
}
use crate::{
api::model::Mod,
core::{utils, Ctx},
};
use anyhow::Result;
pub(crate) async fn search(ctx: &Ctx, term: Vec<String>) -> Result<()> {
let index = utils::update_index(ctx.config.mod_dir()).await;
let print = |f: &Mod| {
println!(
" \x1b[92m{}@{}\x1b[0m [{}]{}\n\n {}",
f.name,
f.version,
f.file_size_string(),
if f.installed { "[installed]" } else { "" },
f.desc
);
println!();
};
println!("Searching...");
println!();
if !term.is_empty() {
index
.iter()
.filter(|f| {
//TODO: Use better method to match strings
term.iter().any(|e| {
f.name.to_lowercase().contains(&e.to_lowercase())
|| f.desc.to_lowercase().contains(&e.to_lowercase())
})
})
.for_each(print);
} else {
index.iter().for_each(print)
}
Ok(())
}
use crate::{
api::model::InstalledMod,
core::{actions, utils, Ctx},
};
use anyhow::Result;
pub fn remove(ctx: &Ctx, mod_names: Vec<String>) -> Result<()> {
let mut installed = utils::get_installed(ctx.config.mod_dir())?;
let valid: Vec<InstalledMod> = mod_names
.iter()
.filter_map(|f| {
installed
.mods
.clone()
.iter()
.find(|e| e.package_name.trim().to_lowercase() == f.trim().to_lowercase())
.filter(|e| installed.mods.remove(e))
.cloned()
})
.collect();
let paths = valid.iter().flat_map(|f| f.flatten_paths()).collect();
actions::uninstall(paths)?;
utils::save_installed(ctx.config.mod_dir(), &installed)?;
Ok(())
}
//================
//Command handlers
//================
mod clear;
pub use clear::clear;
mod config;
pub use config::update_config;
mod disable;
pub(crate) use disable::disable;
mod enable;
pub(crate) use enable::enable;
mod exclude;
pub(crate) use exclude::exclude;
mod include;
pub(crate) use include::include;
mod install;
pub(crate) use install::*;
mod list;
pub(crate) use list::list;
mod remove;
pub(crate) use remove::remove;
mod search;
pub(crate) use search::search;
mod update;
pub(crate) use update::update;
//=================
//Command utilities
//=================
mod utils;
use crate::core::{utils, Ctx};
use anyhow::Result;
pub fn list(ctx: &Ctx, global: bool, all: bool) -> Result<()> {
let do_list = |target, global| -> Result<()> {
let index = utils::get_installed(target)?;
let msg = if global {
"Global mods:"
} else {
"Local mods:"
};
println!("{}", msg);
if !index.mods.is_empty() {
index.mods.into_iter().for_each(|m| {
let disabled = if !m.any_disabled() || m.mods.len() > 1 {
""
} else {
"[disabled]"
};
println!(
" \x1b[92m{}@{}\x1b[0m {}",
m.package_name, m.version, disabled
);
if m.mods.len() > 1 {
for (i, e) in m.mods.iter().enumerate() {
let character = if i + 1 < m.mods.len() { "├" } else { "└" };
let disabled = if e.disabled() { "[disabled]" } else { "" };
println!(
" \x1b[92m{}─\x1b[0m \x1b[0;96m{}\x1b[0m {}",
character, e.name, disabled
);
}
}
});
} else {
println!(" No mods currently installed");
}
println!();
if !index.linked.is_empty() {
println!("Linked mods:");
index
.linked
.into_iter()
.for_each(|m| println!(" \x1b[92m{}@{}\x1b[0m", m.package_name, m.version));
println!();
}
Ok(())
};
if !all {
let target = if global {
ctx.dirs.data_local_dir()
} else {
ctx.config.mod_dir()
};
do_list(target, global)
} else {
do_list(ctx.config.mod_dir(), false)?;
do_list(ctx.dirs.data_local_dir(), true)?;
Ok(())
}
}
use anyhow::{anyhow, Result};
use regex::Regex;
use crate::core::{actions, utils, Ctx};
pub async fn install(
ctx: &mut Ctx,
mod_names: Vec<String>,
yes: bool,
force: bool,
global: bool,
) -> Result<()> {
let target = if global {
ctx.dirs.data_local_dir()
} else {
ctx.config.mod_dir()
};
let index = utils::update_index(target).await;
let mut installed = utils::get_installed(target)?;
let mut valid = vec![];
for name in mod_names {
let re = Regex::new(r"(.+)@?(v?\d.\d.\d)?").unwrap();
if !re.is_match(&name) {
println!("{} should be in 'ModName@1.2.3' format", name);
continue;
}
let parts = re.captures(&name).unwrap();
let base = index
.iter()
.find(|e| e.name.to_lowercase() == parts[1].to_lowercase())
.ok_or_else(|| anyhow!("No such package {}", &parts[1]))?;
if base.installed && !force {
println!(
"Package \x1b[36m{}\x1b[0m version \x1b[36m{}\x1b[0m already installed",
base.name, base.version
);
continue;
}
utils::resolve_deps(&mut valid, base, &installed.mods, &index)?;
valid.push(base);
}
//Gaurd against an empty list (maybe all the mods are already installed?)
if valid.is_empty() {
return Ok(());
}
let size: i64 = valid.iter().map(|f| f.file_size).sum();
println!("Installing:\n");
print!("\t");
valid.iter().enumerate().for_each(|(i, f)| {
if i > 0 && i % 5 == 0 {
println!("\n");
print!("\t");
}
print!(" \x1b[36m{}@{}\x1b[0m ", f.name, f.version);
});
println!("\n");
let msg = format!(
"Will download ~{:.2} MIB (compressed), okay? [Y/n]: ",
size as f64 / 1_048_576f64
);
if !yes {
if let Ok(line) = ctx.rl.readline(&msg) {
if line.to_lowercase() == "n" {
return Ok(());
}
} else {
return Ok(());
}
}
let mut downloaded = vec![];
for base in valid {
let name = &base.name;
let path = ctx
.dirs
.cache_dir()
.join(format!("{}_{}.zip", name, base.version));
//would love to use this in the same if as the let but it's unstable so...
if ctx.config.cache() {
if let Some(f) = ctx.cache.check(&path) {
println!("Using cached version of {}", name);
downloaded.push(f);
continue;
}
}
match actions::download_file(&base.url, path).await {
Ok(f) => downloaded.push(f),
Err(e) => eprintln!("{}", e),
}
}
println!(
"Extracting mod{} to {}",
if downloaded.len() > 1 { "s" } else { "" },
target.display()
);
for e in downloaded
.iter()
.map(|f| -> Result<()> {
let pkg = actions::install_mod(f, target)?;
installed.mods.insert(pkg.clone());
ctx.cache.clean(&pkg.package_name, &pkg.version)?;
println!("Installed {}!", pkg.package_name);
Ok(())
})
.filter(|f| f.is_err())
{
println!("Encountered errors while installing mods:");
println!("{}", e.unwrap_err());
}
utils::save_installed(target, &installed)?;
Ok(())
}
pub async fn install_from_url(ctx: &Ctx, url: String) -> Result<()> {
let file_name = url
.as_str()
.replace(':', "")
.split('/')
.collect::<Vec<&str>>()
.join("");
println!("Downloading to {}", file_name);
let path = ctx.dirs.cache_dir().join(file_name);
match actions::download_file(url.to_string().as_str(), path.clone()).await {
Ok(f) => {
let _pkg = actions::install_mod(&f, ctx.config.mod_dir()).unwrap();
utils::remove_file(&path)?;
println!("Installed {}", url);
}
Err(e) => eprintln!("{}", e),
}
Ok(())
}
use crate::core::{commands::utils::link_dir, utils, Ctx};
use anyhow::{Context, Result};
pub(crate) fn include(ctx: &Ctx, mods: Vec<String>, force: bool) -> Result<()> {
let mut local = utils::get_installed(&ctx.local_target)?;
let global = utils::get_installed(&ctx.global_target)?;
for m in mods.iter() {
if let Some(g) = global
.mods
.iter()
.find(|e| e.package_name.trim().to_lowercase() == m.trim().to_lowercase())
{
if !force && local.linked.contains(g) {
println!("Mod '{}' already linked", m);
continue;
}
for m in g.mods.iter() {
link_dir(&m.path, &ctx.local_target.join(&m.name)).context(format!(
"Unable to create link to {}... Does a file by that name already exist?",
ctx.local_target.join(&m.name).display()
))?;
}
println!("Linked {}!", m);
local.linked.insert(g.clone());
} else {
println!("No mod '{}' globally installed", m);
}
}
utils::save_installed(&ctx.local_target, &local)?;
Ok(())
}
use std::fs;
use log::warn;
use crate::core::{utils, Ctx};
use anyhow::Result;
pub(crate) fn exclude(ctx: &Ctx, mods: Vec<String>) -> Result<()> {
let mut local = utils::get_installed(&ctx.local_target)?;
for m in mods {
if let Some(g) = local
.linked
.clone()
.iter()
.find(|e| e.package_name.trim().to_lowercase() == m.trim().to_lowercase())
{
for m in g.mods.iter() {
fs::remove_dir_all(ctx.local_target.join(&m.name))?;
}
println!("Removed link to {}", m);
local.linked.remove(g);
} else {
warn!(
"Coudln't find link to {} in directory {}",
m,
ctx.local_target.display()
);
println!("No mod '{}' linked to current mod directory", m);
}
}
utils::save_installed(&ctx.local_target, &local)?;
Ok(())
}
use crate::core::{utils, Ctx};
use anyhow::Result;
pub(crate) fn enable(ctx: &Ctx, mods: Vec<String>) -> Result<()> {
let mut installed = utils::get_installed(ctx.config.mod_dir())?;
for m in mods {
let m = m.to_lowercase();
for i in installed.mods.clone().iter() {
installed.mods.remove(i);
let mut i = i.clone();
if i.package_name.to_lowercase() == m {
for sub in i.mods.iter_mut() {
utils::enable_mod(sub, ctx.config.mod_dir())?;
}
println!("Enabled {}", m);
} else {
for e in i.mods.iter_mut() {
if e.name.to_lowercase() == m {
utils::enable_mod(e, ctx.config.mod_dir())?;
println!("Enabled {}", m);
}
}
}
installed.mods.insert(i);
}
}
utils::save_installed(ctx.config.mod_dir(), &installed)?;
Ok(())
}
use crate::core::{utils, Ctx};
use anyhow::Result;
pub(crate) fn disable(ctx: &Ctx, mods: Vec<String>) -> Result<()> {
let mut installed = utils::get_installed(ctx.config.mod_dir())?;
for m in mods {
let m = m.to_lowercase();
for i in installed.mods.clone().iter() {
installed.mods.remove(i);
let mut i = i.clone();
if i.package_name.to_lowercase() == m {
for sub in i.mods.iter_mut() {
utils::disable_mod(sub)?;
}
println!("Disabled {}", m);
} else {
for e in i.mods.iter_mut() {
if e.name.to_lowercase() == m {
utils::disable_mod(e)?;
println!("Disabled {}", m);
}
}
}
installed.mods.insert(i);
}
}
utils::save_installed(ctx.config.mod_dir(), &installed)?;
Ok(())
}
use crate::core::{config, Ctx};
use anyhow::Result;
pub fn update_config(ctx: &mut Ctx, mods_dir: Option<String>, cache: Option<bool>) -> Result<()> {
if let Some(dir) = mods_dir {
ctx.config.set_dir(&dir);
println!("Set install directory to {}", dir);
}
if let Some(cache) = cache {
ctx.config.set_cache(&cache);
if cache {
println!("Turned caching on");
} else {
println!("Turned caching off");
}
}
config::save_config(ctx.dirs.config_dir(), &ctx.config)?;
Ok(())
}
use crate::core::{utils, Ctx};
use anyhow::Result;
pub fn clear(ctx: &Ctx, full: bool) -> Result<()> {
if full {
println!("Clearing cache files...");
} else {
println!("Clearing cached packages...");
}
utils::clear_cache(ctx.dirs.cache_dir(), full)?;
println!("Done!");
Ok(())
}
use std::{
cmp::min,
fs::{self, File, OpenOptions},
io::{self, Read, Write},
path::{Path, PathBuf},
time::SystemTime,
};
use futures_util::StreamExt;
use indicatif::{ProgressBar, ProgressStyle};
use reqwest::Client;
use zip::ZipArchive;
use crate::{
api::model::SubMod,
model::{InstalledMod, Manifest},
};
use log::{debug, error};
use anyhow::{anyhow, Context, Result};
///URL to download
///file name to save to
pub async fn download_file(url: &str, file_path: PathBuf) -> Result<File> {
let client = Client::new();
//send the request
let res = client
.get(url)
.send()
.await
.context(format!("Unable to GET from {}", url))?;
if !res.status().is_success() {
error!("Got bad response from thunderstore");
error!("{:?}", res);
return Err(anyhow!("{} at URL {}", res.status(), url));
}
let file_size = res
.content_length()
.ok_or_else(|| anyhow!("Unable to read content length of response from {}", url))?;
debug!("file_size: {}", file_size);
//setup the progress bar
let pb = ProgressBar::new(file_size).with_style(ProgressStyle::default_bar().template(
"{msg}\n{spinner:.green} [{duration}] [{bar:30.cyan}] {bytes}/{total_bytes} {bytes_per_sec}",
).progress_chars("=>-")).with_message(format!("Downloading {}", url));
//start download in chunks
let mut file = File::create(&file_path)?;
// .map_err(|_| (format!("Failed to create file {}", file_path.display())))?;
let mut downloaded: u64 = 0;
let mut stream = res.bytes_stream();
debug!("Starting download from {}", url);
while let Some(item) = stream.next().await {
let chunk = item.context("Error downloading file :(")?;
file.write_all(&chunk)
.context("Failed to write chunk to file")?;
let new = min(downloaded + (chunk.len() as u64), file_size);
downloaded = new;
pb.set_position(new);
}
let finished = File::open(&file_path).context("Unable to open finished file")?;
debug!("Finished download to {}", file_path.display());
pb.finish_with_message(format!(
"Downloaded {}!",
file_path.file_name().unwrap().to_string_lossy()
));
Ok(finished)
}
pub fn uninstall(mods: Vec<&PathBuf>) -> Result<()> {
for p in mods {
if fs::remove_dir_all(p).is_err() {
//try removing a file too, just in case
debug!("Removing dir failed, attempting to remove file...");
fs::remove_file(p).context(format!("Unable to remove directory {}", p.display()))?
}
println!("Removed {}", p.display());
}
Ok(())
}
pub fn install_mod(zip_file: &File, target_dir: &Path) -> Result<InstalledMod> {
debug!("Starting mod insall");
let mods_dir = target_dir
.canonicalize()
.context("Couldn't resolve mods directory path")?;
//Get the package manifest
let mut manifest = String::new();
//Extract mod to a temp directory so that we can easily see any sub-mods
//This wouldn't be needed if the ZipArchive recreated directories, but oh well
let temp_dir = mods_dir.join(
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs()
.to_string(),
);
{
let mut archive = ZipArchive::new(zip_file).context("Unable to read zip archive")?;
archive
.by_name("manifest.json")
.context("Couldn't find manifest file")?
.read_to_string(&mut manifest)
.unwrap();
fs::create_dir_all(&temp_dir).context("Unable to create temp directory")?;
for i in 0..archive.len() {
let mut file = archive.by_index(i).unwrap();
let out = temp_dir.join(&file.enclosed_name().unwrap());
if let Some(e) = out.extension() {
if out.exists() && e == std::ffi::OsStr::new("cfg") {
debug!("Skipping existing config file {}", out.display());
continue;
}
}
debug!("Extracting file to {}", out.display());
if (*file.name()).ends_with('/') {
fs::create_dir_all(&out)?;
continue;
} else if let Some(p) = out.parent() {
fs::create_dir_all(&p)?;
}
let mut outfile = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&out)?;
io::copy(&mut file, &mut outfile).context("Unable to copy file")?;
}
}
let mut mods = vec![];
if let Ok(entries) = temp_dir.read_dir() {
for e in entries {
let e = e.unwrap();
if e.path().is_dir() {
if e.path().ends_with("mods") {
let mut dirs = e.path().read_dir().unwrap();
while let Some(Ok(e)) = dirs.next() {
mods.push(SubMod::new(e.file_name().to_str().unwrap(), &e.path()));
}
} else {
mods.push(SubMod::new(e.file_name().to_str().unwrap(), &e.path()));
}
}
}
}
if mods.is_empty() {
return Err(anyhow!("Couldn't find a directory to copy"));
}
for p in mods
.iter()
.map(|p| (&p.path, mods_dir.join(p.path.file_name().unwrap())))
{
if p.1.exists() {
fs::remove_dir_all(&p.1)?;
}
fs::rename(p.0, p.1)?;
}
for mut m in mods.iter_mut() {
m.path = mods_dir.join(m.path.file_name().unwrap());
}
let manifest: Manifest = serde_json::from_str(&manifest).context("Unable to parse manifest")?;
fs::remove_dir_all(&temp_dir).context("Unable to remove temp directory")?;
Ok(InstalledMod {
package_name: manifest.name,
version: manifest.version_number,
mods,
depends_on: vec![],
needed_by: vec![],
})
}
use anyhow::{Context, Result};
use log::{debug, warn};
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::{
collections::HashSet,
ffi::OsStr,
fs::{self, File, OpenOptions},
path::{Path, PathBuf},
};
use crate::core::utils;
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Mod {
pub name: String,
pub version: String,
pub url: String,
pub desc: String,
pub deps: Vec<String>,
pub file_size: i64,
#[serde(default)]
pub installed: bool,
#[serde(default)]
pub upgradable: bool,
}
impl Mod {
pub fn file_size_string(&self) -> String {
if self.file_size / 1_000_000 >= 1 {
let size = self.file_size as f64 / 1_048_576f64;
format!("{:.2} MB", size)
} else {
let size = self.file_size as f64 / 1024f64;
format!("{:.2} KB", size)
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Hash, PartialEq, Eq)]
pub struct InstalledMod {
pub package_name: String,
pub version: String,
pub mods: Vec<SubMod>,
//TODO: Implement local dep tracking
pub depends_on: Vec<String>,
pub needed_by: Vec<String>,
}
impl InstalledMod {
pub fn flatten_paths(&self) -> Vec<&PathBuf> {
self.mods.iter().map(|m| &m.path).collect()
}
pub fn any_disabled(&self) -> bool {
self.mods.iter().any(|m| m.disabled())
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Hash, PartialEq, Eq)]
pub struct SubMod {
pub path: PathBuf,
pub name: String,
}
impl SubMod {
pub fn new(name: &str, path: &Path) -> Self {
SubMod {
name: name.to_string(),
path: path.to_owned(),
}
}
pub fn disabled(&self) -> bool {
self.path
.components()
.any(|f| f.as_os_str() == OsStr::new(".disabled"))
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Manifest {
pub name: String,
pub version_number: String,
pub website_url: String,
pub description: String,
pub dependencies: Vec<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct LocalIndex {
pub mods: HashSet<InstalledMod>,
#[serde(default)]
pub linked: HashSet<InstalledMod>,
}
impl LocalIndex {
pub fn new() -> Self {
Self {
mods: HashSet::new(),
linked: HashSet::new(),
}
}
}
#[derive(Clone)]
struct CachedMod {
name: String,
version: String,
path: PathBuf,
}
impl PartialEq for CachedMod {
fn eq(&self, other: &Self) -> bool {
self.name == other.name && self.version == other.version
}
}
impl CachedMod {
fn new(name: &str, version: &str, path: &Path) -> Self {
CachedMod {
name: name.to_string(),
version: version.to_string(),
path: path.to_owned(),
}
}
}
pub struct Cache {
re: Regex,
pkgs: Vec<CachedMod>,
}
impl Cache {
pub fn build(dir: &Path) -> Result<Self> {
let cache = fs::read_dir(dir)?;
let re =
Regex::new(r"(.+)[_-](\d\.\d\.\d)(\.zip)?").context("Unable to create cache regex")?;
let mut pkgs = vec![];
for e in cache.flatten() {
if !e.path().is_dir() {
debug!("Reading {} into cache", e.path().display());
let file_name = e.file_name();
if let Some(c) = re.captures(file_name.to_str().unwrap()) {
let name = c.get(1).unwrap().as_str().trim();
let ver = c.get(2).unwrap().as_str().trim();
pkgs.push(CachedMod::new(name, ver, dir));
debug!("Added {} version {} to cache", name, ver);
} else {
warn!(
"Unexpected filename in cache dir: {}",
file_name.to_str().unwrap()
);
}
}
}
Ok(Cache { pkgs, re })
}
///Cleans all cached versions of a package except the version provided
pub fn clean(&mut self, name: &str, version: &str) -> Result<bool> {
let mut res = false;
for m in self
.pkgs
.clone()
.into_iter()
.filter(|e| e.name == name && e.version != version)
{
if let Some(index) = self.pkgs.iter().position(|e| e == &m) {
utils::remove_file(&m.path)?;
self.pkgs.swap_remove(index);
res = true
}
}
Ok(res)
}
///Checks if a path is in the current cache
pub fn check(&self, path: &Path) -> Option<File> {
if self.has(path) {
self.open_file(path)
} else {
None
}
}
fn has(&self, path: &Path) -> bool {
if let Some(name) = path.file_name() {
if let Some(parts) = self.re.captures(name.to_str().unwrap()) {
let name = parts.get(1).unwrap().as_str();
let ver = parts.get(2).unwrap().as_str();
if let Some(c) = self.pkgs.iter().find(|e| e.name == name) {
if c.version == ver {
return true;
}
}
}
}
false
}
#[inline(always)]
fn open_file(&self, path: &Path) -> Option<File> {
if let Ok(f) = OpenOptions::new().read(true).open(path) {
Some(f)
} else {
None
}
}
}
use reqwest::Client;
use serde_json::Value;
pub mod model;
use model::Mod;
use anyhow::{anyhow, Context, Result};
pub async fn get_package_index() -> Result<Vec<Mod>> {
let client = Client::new();
let raw = client
.get("https://northstar.thunderstore.io/c/northstar/api/v1/package/")
.header("accept", "application/json")
.send()
.await
.context("Error making request to update package index")?;
if raw.status().is_success() {
let parsed: Value = serde_json::from_str(&raw.text().await.unwrap())
.context("Unable to parse response body")?;
map_response(&parsed)
.ok_or_else(|| anyhow!("{}", serde_json::to_string(&parsed).unwrap()))
.context("Response body was malformed?")
} else {
Err(anyhow!("{}", raw.status().as_str()))
}
}
fn map_response(res: &Value) -> Option<Vec<Mod>> {
match res {
Value::Array(v) => Some(
v.iter()
.map(|e| {
let name = e["name"].as_str().unwrap().to_string();
let latest = e["versions"][0].clone();
let version = latest["version_number"].as_str().unwrap().to_string();
let url = latest["download_url"].as_str().unwrap().to_string();
let file_size = latest["file_size"].as_i64().unwrap();
let desc = latest["description"].as_str().unwrap().to_string();
let deps = if let Value::Array(d) = &latest["dependencies"] {
//TODO: Support dependencies
d.iter()
.map(|e| e.as_str().unwrap().to_string())
.filter(|e| !e.starts_with("northstar-Northstar")) //Don't try to install northstar for any mods that "depend" on it
.collect()
} else {
vec![]
};
Mod {
name,
version,
url,
deps,
desc,
file_size,
installed: false,
upgradable: false,
}
})
.collect(),
),
_ => None,
}
}
set shell := ["fish", "-c"]
set export
@draft t:
@cargo build --release
@cargo deb
@gh release create {{t}} --notes "# Papa {{t}}" -d --title "Papa {{t}}"
@fish -c "gh release upload {{t}} target/debian/papa_(string replace 'v' '' {{t}})_amd64.deb"
@gh release upload {{t}} target/release/papa