use std::path::PathBuf;
use anyhow::{Context, Result, bail};
use tracing::info;
#[derive(Debug, Clone)]
pub struct NxmUri {
pub game_domain: String,
pub mod_id: u64,
pub file_id: u64,
#[allow(dead_code)]
pub key: Option<String>,
#[allow(dead_code)]
pub expires: Option<u64>,
}
impl NxmUri {
pub fn parse(uri: &str) -> Result<Self> {
let uri = uri.strip_prefix("nxm://").ok_or_else(|| {
anyhow::anyhow!("not an nxm:// URI: {uri}")
})?;
let (path, query) = uri.split_once('?').unwrap_or((uri, ""));
let segments: Vec<&str> = path.split('/').collect();
if segments.len() < 5 || segments[1] != "mods" || segments[3] != "files" {
bail!("invalid nxm:// URI format: nxm://{uri}");
}
let game_domain = segments[0].to_string();
let mod_id: u64 = segments[2]
.parse()
.with_context(|| format!("invalid mod_id: {}", segments[2]))?;
let file_id: u64 = segments[4]
.parse()
.with_context(|| format!("invalid file_id: {}", segments[4]))?;
let mut key = None;
let mut expires = None;
for param in query.split('&') {
if let Some((k, v)) = param.split_once('=') {
match k {
"key" => key = Some(v.to_string()),
"expires" => expires = v.parse().ok(),
_ => {}
}
}
}
Ok(NxmUri {
game_domain,
mod_id,
file_id,
key,
expires,
})
}
}
pub async fn handle(uri: String, _profile: Option<String>) -> Result<()> {
let parsed = NxmUri::parse(&uri)?;
println!("nxm:// download request:");
println!(" Game: {}", parsed.game_domain);
println!(" Mod ID: {}", parsed.mod_id);
println!(" File ID: {}", parsed.file_id);
let api_key = modde_sources::nexus::auth::load_api_key()?;
let client = reqwest::Client::new();
let api = modde_sources::nexus::api::NexusApi::new(client.clone(), api_key.clone());
let mod_info = api.get_mod(&parsed.game_domain, parsed.mod_id).await?;
println!(" Mod: {} by {}", mod_info.name, mod_info.author);
println!(" Version: {}", mod_info.version);
let download_url = modde_sources::nexus::cdn::generate_download_link(
&client,
&api_key,
&parsed.game_domain,
parsed.mod_id,
parsed.file_id,
)
.await?;
info!(url = %download_url, "resolved CDN URL");
let downloads_dir = modde_core::paths::downloads_dir();
std::fs::create_dir_all(&downloads_dir)?;
let file_info = api.get_mod_files(&parsed.game_domain, parsed.mod_id).await?;
let file_name = file_info
.files
.iter()
.find(|f| f.file_id == parsed.file_id)
.map(|f| f.file_name.clone())
.unwrap_or_else(|| format!("{}_{}.zip", parsed.mod_id, parsed.file_id));
let dest = downloads_dir.join(&file_name);
println!(" Downloading to: {}", dest.display());
let resp = client.get(&download_url).send().await?;
let bytes = resp.bytes().await?;
tokio::fs::write(&dest, &bytes).await?;
println!(" Download complete: {}", dest.display());
println!("\nInstall with: modde install mod '{}'", dest.display());
Ok(())
}
pub fn install_handler() -> Result<PathBuf> {
install_handler_platform()
}
#[cfg(target_os = "linux")]
fn install_handler_platform() -> Result<PathBuf> {
let desktop_entry = r#"[Desktop Entry]
Type=Application
Name=modde NXM Handler
Comment=Handle nxm:// download links from Nexus Mods
Exec=modde nxm handle %u
Terminal=false
NoDisplay=true
MimeType=x-scheme-handler/nxm;
Categories=Game;
"#;
let home = modde_core::paths::home_dir();
let desktop_dir = home.join(".local/share/applications");
std::fs::create_dir_all(&desktop_dir)?;
let desktop_path = desktop_dir.join("modde-nxm-handler.desktop");
std::fs::write(&desktop_path, desktop_entry)?;
println!("Installed NXM handler: {}", desktop_path.display());
println!("\nRegistering with xdg-mime...");
let status = std::process::Command::new("xdg-mime")
.args(["default", "modde-nxm-handler.desktop", "x-scheme-handler/nxm"])
.status();
match status {
Ok(s) if s.success() => {
println!("Registered as default nxm:// handler.");
println!("You can now click 'Download with Mod Manager' on Nexus Mods.");
}
_ => {
println!("Warning: xdg-mime registration failed.");
println!("Manually register with:");
println!(" xdg-mime default modde-nxm-handler.desktop x-scheme-handler/nxm");
}
}
Ok(desktop_path)
}
#[cfg(target_os = "macos")]
fn install_handler_platform() -> Result<PathBuf> {
let exe_path = std::env::current_exe().context("failed to determine modde binary path")?;
let home = modde_core::paths::home_dir();
let app_dir = home.join("Applications/modde-nxm-handler.app/Contents");
let macos_dir = app_dir.join("MacOS");
std::fs::create_dir_all(&macos_dir)?;
let plist = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>modde NXM Handler</string>
<key>CFBundleIdentifier</key>
<string>com.modde.nxm-handler</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>CFBundleExecutable</key>
<string>nxm-handler</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>NXM Protocol</string>
<key>CFBundleURLSchemes</key>
<array>
<string>nxm</string>
</array>
</dict>
</array>
</dict>
</plist>"#
);
std::fs::write(app_dir.join("Info.plist"), plist)?;
let handler_script = format!(
"#!/bin/sh\nexec \"{}\" nxm handle \"$@\"\n",
exe_path.display()
);
let handler_path = macos_dir.join("nxm-handler");
std::fs::write(&handler_path, handler_script)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&handler_path, std::fs::Permissions::from_mode(0o755))?;
}
let app_path = home.join("Applications/modde-nxm-handler.app");
println!("Installed NXM handler: {}", app_path.display());
println!("The nxm:// protocol should be registered automatically.");
println!("If not, open the .app once to register it with Launch Services.");
Ok(app_path)
}
#[cfg(target_os = "windows")]
fn install_handler_platform() -> Result<PathBuf> {
use winreg::enums::*;
use winreg::RegKey;
let exe_path = std::env::current_exe().context("failed to determine modde binary path")?;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let (nxm_key, _) = hkcu
.create_subkey(r"Software\Classes\nxm")
.context("failed to create nxm registry key")?;
nxm_key.set_value("", &"URL:NXM Protocol")?;
nxm_key.set_value("URL Protocol", &"")?;
let (cmd_key, _) = hkcu
.create_subkey(r"Software\Classes\nxm\shell\open\command")
.context("failed to create nxm command registry key")?;
cmd_key.set_value(
"",
&format!("\"{}\" nxm handle \"%1\"", exe_path.display()),
)?;
println!("Registered nxm:// protocol handler in Windows registry.");
println!("You can now click 'Download with Mod Manager' on Nexus Mods.");
Ok(exe_path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_nxm_uri() {
let uri = "nxm://skyrimspecialedition/mods/12604/files/35834?key=abc123&expires=1700000000";
let parsed = NxmUri::parse(uri).unwrap();
assert_eq!(parsed.game_domain, "skyrimspecialedition");
assert_eq!(parsed.mod_id, 12604);
assert_eq!(parsed.file_id, 35834);
assert_eq!(parsed.key.as_deref(), Some("abc123"));
assert_eq!(parsed.expires, Some(1700000000));
}
#[test]
fn test_parse_nxm_uri_no_query() {
let uri = "nxm://fallout4/mods/100/files/200";
let parsed = NxmUri::parse(uri).unwrap();
assert_eq!(parsed.game_domain, "fallout4");
assert_eq!(parsed.mod_id, 100);
assert_eq!(parsed.file_id, 200);
assert!(parsed.key.is_none());
assert!(parsed.expires.is_none());
}
#[test]
fn test_parse_invalid_uri() {
assert!(NxmUri::parse("https://example.com").is_err());
assert!(NxmUri::parse("nxm://invalid").is_err());
assert!(NxmUri::parse("nxm://game/bad/format").is_err());
}
}