Initial commit (v0.1.0)

This commit is contained in:
Noah Bartlett
2026-03-08 12:38:39 -06:00
commit 7cb90d8625
24 changed files with 10045 additions and 0 deletions

1
repos/singularity/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

5015
repos/singularity/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
[package]
name = "singularity"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1"
directories = "5"
iced = { version = "0.12", features = ["advanced", "tokio", "multi-window", "svg"] }
resvg = "0.36"
tiny-skia = "0.11"
rfd = "0.14"
serde = { version = "1", features = ["derive"] }
toml = "0.8"
uuid = { version = "1", features = ["v4", "serde"] }
walkdir = "2"
which = "6"
zip = "0.6"

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<circle cx="32" cy="32" r="22" fill="currentColor" fill-opacity="0.06" />
<line x1="22" y1="22" x2="42" y2="42" />
<line x1="42" y1="22" x2="22" y2="42" />
</svg>

After

Width:  |  Height:  |  Size: 328 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<line x1="32" y1="12" x2="32" y2="34" />
<polyline points="24 26 32 34 40 26" />
<path d="M16 40v6a4 4 0 0 0 4 4h24a4 4 0 0 0 4-4v-6" />
</svg>

After

Width:  |  Height:  |  Size: 309 B

View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<line x1="32" y1="10" x2="32" y2="22" />
<polyline points="26 16 32 10 38 16" />
<line x1="32" y1="42" x2="32" y2="54" />
<polyline points="26 48 32 54 38 48" />
<circle cx="27" cy="26" r="1.6" fill="currentColor" stroke="none" />
<circle cx="37" cy="26" r="1.6" fill="currentColor" stroke="none" />
<circle cx="27" cy="32" r="1.6" fill="currentColor" stroke="none" />
<circle cx="37" cy="32" r="1.6" fill="currentColor" stroke="none" />
<circle cx="27" cy="38" r="1.6" fill="currentColor" stroke="none" />
<circle cx="37" cy="38" r="1.6" fill="currentColor" stroke="none" />
</svg>

After

Width:  |  Height:  |  Size: 762 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 24v-4a4 4 0 0 1 4-4h12l4 4h20a4 4 0 0 1 4 4v4" />
<path d="M10 28h44l-6 20a4 4 0 0 1-4 3H16a4 4 0 0 1-4-4V28z" />
</svg>

After

Width:  |  Height:  |  Size: 297 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<path d="M32 10L16 20L32 30L48 20L32 10Z" />
<path d="M16 20V42L32 52V30L16 20Z" />
<path d="M48 20V42L32 52V30L48 20Z" />
</svg>

After

Width:  |  Height:  |  Size: 295 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 26A16 16 0 0 1 44 26" />
<polyline points="40 22 44 26 40 30" />
<path d="M44 38A16 16 0 0 1 20 38" />
<polyline points="24 34 20 38 24 42" />
</svg>

After

Width:  |  Height:  |  Size: 330 B

View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<path d="M 12 10 L 44 10 L 52 18 L 52 54 L 12 54 Z" />
<rect x="18" y="10" width="20" height="10" rx="1.5" />
<line x1="32" y1="12" x2="32" y2="18" stroke-width="2" />
<rect x="18" y="36" width="28" height="18" rx="1.5" stroke-width="2" />
<line x1="22" y1="42" x2="42" y2="42" stroke-width="2" />
<line x1="22" y1="46" x2="42" y2="46" stroke-width="2" />
<line x1="22" y1="50" x2="42" y2="50" stroke-width="2" />
</svg>

After

Width:  |  Height:  |  Size: 594 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<path d="M 27.22 9.50 A 23.00 23.00 0 0 1 36.78 9.50 L 36.94 16.78 A 16.00 16.00 0 0 1 42.71 20.11 L 49.09 16.61 A 23.00 23.00 0 0 1 53.87 24.89 L 47.65 28.67 A 16.00 16.00 0 0 1 47.65 35.33 L 53.87 39.11 A 23.00 23.00 0 0 1 49.09 47.39 L 42.71 43.89 A 16.00 16.00 0 0 1 36.94 47.22 L 36.78 54.50 A 23.00 23.00 0 0 1 27.22 54.50 L 27.06 47.22 A 16.00 16.00 0 0 1 21.29 43.89 L 14.91 47.39 A 23.00 23.00 0 0 1 10.13 39.11 L 16.35 35.33 A 16.00 16.00 0 0 1 16.35 28.67 L 10.13 24.89 A 23.00 23.00 0 0 1 14.91 16.61 L 21.29 20.11 A 16.00 16.00 0 0 1 27.06 16.78 L 27.22 9.50 Z" />
<circle cx="32" cy="32" r="8" />
</svg>

After

Width:  |  Height:  |  Size: 781 B

View File

@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="64mm"
height="64mm"
viewBox="0 0 64 64"
version="1.1"
id="svg1"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
sodipodi:docname="singularity.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
inkscape:zoom="2.1392361"
inkscape:cx="100.50317"
inkscape:cy="121.53871"
inkscape:window-width="1440"
inkscape:window-height="890"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<circle
style="fill:#000000;stroke-width:0.264583"
id="path1"
cx="32"
cy="32"
r="6.75" />
<path
style="fill:none;stroke:#000000;stroke-width:0.529167;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
d="m 17.439052,32.157117 c 4.303269,1.842883 6.566716,8.842883 14.470703,9.842883"
id="path10" />
<path
style="fill:none;stroke:#000000;stroke-width:0.529167;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
d="M 9.2760915,35.249149 C 17.834317,36.836421 23.152596,44.590107 31.538711,45.63837"
id="path11" />
<path
style="fill:none;stroke:#000000;stroke-width:0.529167;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
d="M 46.751501,32.157117 C 42.448232,34 40.184785,41 32.280798,42"
id="path12" />
<path
style="fill:none;stroke:#000000;stroke-width:0.529167;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
d="M 54.172374,35.249149 C 45.614149,36.836421 40.29587,44.590107 31.909754,45.63837"
id="path13" />
<path
style="fill:none;stroke:#000000;stroke-width:0.529167;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
d="M 15,30 C 21,29 24,21 24,12"
id="path14" />
<path
style="fill:none;stroke:#000000;stroke-width:0.529167;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
d="M 49,30 C 43,29 40,21 40,12"
id="path15" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<path d="M 10 32 A 22 6 0 0 0 54 32 A 24 10 0 0 1 10 32 Z" />
<path d="M 17 30 A 15 3 0 0 0 47 30 A 17 6 0 0 1 17 30 Z" />
<circle cx="32" cy="32" r="7" fill="currentColor" stroke="none" />
</svg>

After

Width:  |  Height:  |  Size: 362 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<path d="M 6 36 C 18 36 24 25 32 25 C 40 25 46 36 58 36" />
<path d="M 12 33 C 21 33 26 27 32 27 C 38 27 43 33 52 33" />
<circle cx="32" cy="32" r="7" fill="currentColor" stroke="none" />
</svg>

After

Width:  |  Height:  |  Size: 360 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<line x1="32" y1="42" x2="32" y2="18" />
<polyline points="24 26 32 18 40 26" />
<path d="M16 42v6a4 4 0 0 0 4 4h24a4 4 0 0 0 4-4v-6" />
</svg>

After

Width:  |  Height:  |  Size: 309 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<circle cx="32" cy="32" r="22" />
<line x1="32" y1="18" x2="32" y2="38" />
<circle cx="32" cy="46" r="2.5" fill="currentColor" stroke="none" />
</svg>

After

Width:  |  Height:  |  Size: 316 B

2827
repos/singularity/src/app.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,123 @@
use anyhow::{Context, Result};
use directories::{BaseDirs, ProjectDirs};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{PathBuf};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum ThemeChoice {
SingularityDark,
SteamDark,
Nord,
TokyoNightStorm,
Oxocarbon,
}
impl ThemeChoice {
pub const ALL: [ThemeChoice; 5] = [
ThemeChoice::SingularityDark,
ThemeChoice::SteamDark,
ThemeChoice::Nord,
ThemeChoice::TokyoNightStorm,
ThemeChoice::Oxocarbon,
];
}
impl Default for ThemeChoice {
fn default() -> Self {
ThemeChoice::SingularityDark
}
}
impl std::fmt::Display for ThemeChoice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ThemeChoice::SingularityDark => write!(f, "Singularity Dark"),
ThemeChoice::SteamDark => write!(f, "Steam Slate"),
ThemeChoice::Nord => write!(f, "Nord"),
ThemeChoice::TokyoNightStorm => write!(f, "Tokyo Night Storm"),
ThemeChoice::Oxocarbon => write!(f, "Oxocarbon"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
pub base_dir: PathBuf,
pub steam_dir: Option<PathBuf>,
pub games: Vec<GameEntry>,
#[serde(default)]
pub theme: ThemeChoice,
#[serde(default)]
pub last_selected_game: Option<Uuid>,
#[serde(default)]
pub recent_games: Vec<Uuid>,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
base_dir: default_base_dir(),
steam_dir: None,
games: Vec::new(),
theme: ThemeChoice::default(),
last_selected_game: None,
recent_games: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GameEntry {
pub id: Uuid,
pub name: String,
pub path: PathBuf,
}
impl GameEntry {
pub fn new(name: String, path: PathBuf) -> Self {
Self {
id: Uuid::new_v4(),
name,
path,
}
}
}
pub fn default_base_dir() -> PathBuf {
if let Some(base_dirs) = BaseDirs::new() {
base_dirs.home_dir().join(".mods")
} else {
PathBuf::from(".mods")
}
}
pub fn config_path() -> Result<PathBuf> {
if let Some(project_dirs) = ProjectDirs::from("com", "singularity", "singularity") {
let dir = project_dirs.config_dir();
fs::create_dir_all(dir).context("create config directory")?;
Ok(dir.join("config.toml"))
} else {
let fallback = default_base_dir();
fs::create_dir_all(&fallback).context("create fallback config directory")?;
Ok(fallback.join("config.toml"))
}
}
pub fn load_config() -> Result<AppConfig> {
let path = config_path()?;
if !path.exists() {
return Ok(AppConfig::default());
}
let contents = fs::read_to_string(&path).context("read config")?;
let config = toml::from_str(&contents).context("parse config")?;
Ok(config)
}
pub fn save_config(config: &AppConfig) -> Result<()> {
let path = config_path()?;
let contents = toml::to_string_pretty(config).context("serialize config")?;
fs::write(&path, contents).context("write config")?;
Ok(())
}

View File

@@ -0,0 +1,256 @@
use iced::advanced::layout;
use iced::advanced::mouse;
use iced::advanced::overlay;
use iced::advanced::renderer;
use iced::advanced::widget;
use iced::advanced::{Clipboard, Layout, Shell, Widget};
use iced::{Element, Length, Point, Rectangle, Renderer, Size, Theme, Vector};
pub struct DragOverlay<'a, Message> {
content: Element<'a, Message>,
ghost: Element<'a, Message>,
show_ghost: bool,
cursor: Option<Point>,
ghost_offset_x: f32,
}
impl<'a, Message: 'a> DragOverlay<'a, Message> {
pub fn new(content: impl Into<Element<'a, Message>>) -> Self {
Self {
content: content.into(),
ghost: iced::widget::space::Space::new(Length::Shrink, Length::Shrink).into(),
show_ghost: false,
cursor: None,
ghost_offset_x: 0.0,
}
}
pub fn ghost(
mut self,
ghost: impl Into<Element<'a, Message>>,
cursor: Point,
offset_x: f32,
) -> Self {
self.ghost = ghost.into();
self.show_ghost = true;
self.cursor = Some(cursor);
self.ghost_offset_x = offset_x;
self
}
}
impl<'a, Message> Widget<Message, Theme, Renderer> for DragOverlay<'a, Message> {
fn children(&self) -> Vec<widget::Tree> {
vec![widget::Tree::new(&self.content), widget::Tree::new(&self.ghost)]
}
fn diff(&self, tree: &mut widget::Tree) {
tree.diff_children(&[self.content.as_widget(), self.ghost.as_widget()]);
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn layout(
&self,
tree: &mut widget::Tree,
renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
self.content
.as_widget()
.layout(&mut tree.children[0], renderer, limits)
}
fn operate(
&self,
tree: &mut widget::Tree,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn widget::Operation<Message>,
) {
self.content.as_widget().operate(
&mut tree.children[0],
layout,
renderer,
operation,
);
}
fn on_event(
&mut self,
tree: &mut widget::Tree,
event: iced::Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) -> iced::event::Status {
self.content.as_widget_mut().on_event(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
)
}
fn mouse_interaction(
&self,
tree: &widget::Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &Renderer,
) -> mouse::Interaction {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn draw(
&self,
tree: &widget::Tree,
renderer: &mut Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut widget::Tree,
layout: Layout<'_>,
renderer: &Renderer,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
let mut overlays = Vec::new();
let (content_tree, ghost_tree) = tree.children.split_at_mut(1);
let content_tree = &mut content_tree[0];
let ghost_tree = &mut ghost_tree[0];
let content_overlay =
self.content
.as_widget_mut()
.overlay(content_tree, layout, renderer, translation);
if let Some(overlay) = content_overlay {
overlays.push(overlay);
}
if self.show_ghost {
if let Some(cursor) = self.cursor {
let bounds = layout.bounds();
let position = Point::new(bounds.x + translation.x, bounds.y + translation.y);
overlays.push(overlay::Element::new(Box::new(GhostOverlay {
ghost: &self.ghost,
state: ghost_tree,
cursor,
position,
content_size: bounds.size(),
offset_x: self.ghost_offset_x,
})));
}
}
if overlays.is_empty() {
None
} else {
Some(overlay::Group::with_children(overlays).overlay())
}
}
}
impl<'a, Message: 'a> From<DragOverlay<'a, Message>> for Element<'a, Message> {
fn from(overlay: DragOverlay<'a, Message>) -> Self {
Element::new(overlay)
}
}
struct GhostOverlay<'a, Message> {
ghost: &'a Element<'a, Message>,
state: &'a mut widget::Tree,
cursor: Point,
position: Point,
content_size: Size,
offset_x: f32,
}
impl<'a, Message> overlay::Overlay<Message, Theme, Renderer> for GhostOverlay<'a, Message> {
fn layout(&mut self, renderer: &Renderer, bounds: Size) -> layout::Node {
let ghost_layout = self.ghost.as_widget().layout(
self.state,
renderer,
&layout::Limits::new(Size::ZERO, bounds),
);
let size = ghost_layout.bounds().size();
let mut x = self.position.x + (self.content_size.width - size.width) / 2.0 + self.offset_x;
let mut y = self.position.y + self.cursor.y - size.height / 2.0;
let max_x = self.position.x + self.content_size.width - size.width;
if max_x >= self.position.x {
x = x.clamp(self.position.x, max_x);
} else {
x = self.position.x;
}
let max_y = self.position.y + self.content_size.height - size.height;
if max_y >= self.position.y {
y = y.clamp(self.position.y, max_y);
} else {
y = self.position.y;
}
if x + size.width > bounds.width {
x = (bounds.width - size.width).max(0.0);
}
if y + size.height > bounds.height {
y = (bounds.height - size.height).max(0.0);
}
layout::Node::with_children(size, vec![ghost_layout])
.translate(Vector::new(x, y))
}
fn draw(
&self,
renderer: &mut Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor_position: mouse::Cursor,
) {
self.ghost.as_widget().draw(
self.state,
renderer,
theme,
style,
layout.children().next().unwrap(),
cursor_position,
&Rectangle::with_size(Size::INFINITY),
);
}
fn is_over(
&self,
_layout: Layout<'_>,
_renderer: &Renderer,
_cursor_position: Point,
) -> bool {
false
}
}

102
repos/singularity/src/fs.rs Normal file
View File

@@ -0,0 +1,102 @@
use anyhow::{Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
use uuid::Uuid;
use walkdir::WalkDir;
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct GameDirs {
pub root: PathBuf,
pub downloads: PathBuf,
pub zips: PathBuf,
pub mods: PathBuf,
pub staging: PathBuf,
pub profiles: PathBuf,
pub backups: PathBuf,
pub state: PathBuf,
}
pub fn game_root(base_dir: &Path, game_id: Uuid) -> PathBuf {
base_dir.join("games").join(game_id.to_string())
}
pub fn ensure_base_dir(base_dir: &Path) -> Result<()> {
fs::create_dir_all(base_dir).context("create base dir")?;
fs::create_dir_all(base_dir.join("games")).context("create games dir")?;
Ok(())
}
pub fn ensure_game_dirs(base_dir: &Path, game_id: Uuid) -> Result<GameDirs> {
let root = game_root(base_dir, game_id);
let downloads = root.join("downloads");
let zips = root.join("zips");
let mods = root.join("mods");
let staging = root.join("staging");
let profiles = root.join("profiles");
let backups = root.join("backups");
let state = root.join("game_state.toml");
for dir in [&root, &downloads, &zips, &mods, &staging, &profiles, &backups] {
fs::create_dir_all(dir).context("create game dir")?;
}
Ok(GameDirs {
root,
downloads,
zips,
mods,
staging,
profiles,
backups,
state,
})
}
pub fn clone_directory_tree(src: &Path, dest: &Path) -> Result<()> {
if !src.is_dir() {
return Ok(());
}
fs::create_dir_all(dest).context("create staging root")?;
for entry in WalkDir::new(src).min_depth(1) {
let entry = entry.context("walk game tree")?;
if entry.file_type().is_dir() {
let rel = entry.path().strip_prefix(src).context("strip prefix")?;
let target = dest.join(rel);
fs::create_dir_all(&target).context("create staging dir")?;
}
}
Ok(())
}
pub fn case_match_path(game_root: &Path, rel_path: &Path) -> Result<PathBuf> {
let mut resolved = PathBuf::new();
let mut current = game_root.to_path_buf();
for component in rel_path.components() {
let segment = component.as_os_str().to_string_lossy().to_string();
let matched = match_case_in_dir(&current, &segment);
let final_segment = matched.unwrap_or(segment);
resolved.push(&final_segment);
current = current.join(&final_segment);
}
Ok(resolved)
}
fn match_case_in_dir(dir: &Path, name: &str) -> Option<String> {
if !dir.is_dir() {
return None;
}
let target = name.to_lowercase();
let entries = fs::read_dir(dir).ok()?;
for entry in entries {
let entry = entry.ok()?;
let file_name = entry.file_name();
let file_name_str = file_name.to_string_lossy();
if file_name_str.to_lowercase() == target {
return Some(file_name_str.to_string());
}
}
None
}

View File

@@ -0,0 +1,16 @@
mod app;
mod config;
mod drag_overlay;
mod fs;
mod mods;
mod state;
mod steam;
use iced::multi_window::Application;
fn main() -> iced::Result {
let mut settings = iced::Settings::default();
settings.window.icon = app::window_icon();
settings.default_font = iced::Font::with_name("Sans");
app::SingularityApp::run(settings)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,99 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GameState {
pub active_profile: String,
pub profiles: Vec<Profile>,
pub last_launch: Option<ProfileSnapshot>,
pub previous_launch: Option<ProfileSnapshot>,
#[serde(default)]
pub backups: Vec<std::path::PathBuf>,
#[serde(default)]
pub created_dirs: HashMap<String, Vec<std::path::PathBuf>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Profile {
pub name: String,
pub enabled_mods: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileSnapshot {
pub name: String,
pub enabled_mods: Vec<String>,
}
impl Default for GameState {
fn default() -> Self {
Self {
active_profile: "default".to_string(),
profiles: vec![Profile {
name: "default".to_string(),
enabled_mods: Vec::new(),
}],
last_launch: None,
previous_launch: None,
backups: Vec::new(),
created_dirs: HashMap::new(),
}
}
}
impl GameState {
pub fn active_profile_mut(&mut self) -> Option<&mut Profile> {
self.profiles
.iter_mut()
.find(|profile| profile.name == self.active_profile)
}
pub fn active_profile(&self) -> Option<&Profile> {
self.profiles
.iter()
.find(|profile| profile.name == self.active_profile)
}
pub fn ensure_default_profile(&mut self) {
if self.active_profile.is_empty() {
self.active_profile = "default".to_string();
}
if self.active_profile().is_none() {
self.profiles.push(Profile {
name: self.active_profile.clone(),
enabled_mods: Vec::new(),
});
}
}
pub fn record_launch_snapshot(&mut self) {
if let Some(profile) = self.active_profile() {
let snapshot = ProfileSnapshot {
name: profile.name.clone(),
enabled_mods: profile.enabled_mods.clone(),
};
self.previous_launch = self.last_launch.take();
self.last_launch = Some(snapshot);
}
}
}
pub fn load_game_state(path: &Path) -> Result<GameState> {
if !path.exists() {
return Ok(GameState::default());
}
let contents = fs::read_to_string(path).context("read game state")?;
let mut state: GameState = toml::from_str(&contents).context("parse game state")?;
state.ensure_default_profile();
Ok(state)
}
pub fn save_game_state(path: &Path, state: &GameState) -> Result<()> {
let contents = toml::to_string_pretty(state).context("serialize game state")?;
fs::write(path, contents).context("write game state")?;
Ok(())
}

View File

@@ -0,0 +1,95 @@
use anyhow::{Context, Result};
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct GameCandidate {
pub name: String,
pub path: PathBuf,
}
pub fn normalize_steam_root(input: &Path) -> Option<PathBuf> {
let mut candidates = Vec::new();
candidates.push(input.to_path_buf());
candidates.push(input.join("steam"));
candidates.push(input.join("root"));
for candidate in candidates {
if candidate.join("steamapps").is_dir() {
return Some(candidate);
}
}
if input.file_name().and_then(|name| name.to_str()) == Some("steamapps") {
if input.join("common").is_dir() {
return input.parent().map(PathBuf::from);
}
}
if input.file_name().and_then(|name| name.to_str()) == Some("common") {
if let Some(parent) = input.parent() {
if parent.join("libraryfolders.vdf").is_file() || parent.join("common").is_dir() {
if let Some(root) = parent.parent() {
if root.join("steamapps").is_dir() {
return Some(root.to_path_buf());
}
}
}
}
}
None
}
pub fn discover_games(steam_dir: &Path) -> Result<Vec<GameCandidate>> {
let library_paths = discover_library_paths(steam_dir)?;
let mut games = Vec::new();
for library in library_paths {
let common = library.join("steamapps").join("common");
if !common.is_dir() {
continue;
}
for entry in fs::read_dir(&common).context("read steam common")? {
let entry = entry.context("steam entry")?;
if !entry.file_type().context("entry type")?.is_dir() {
continue;
}
let path = entry.path();
let name = entry
.file_name()
.to_string_lossy()
.to_string();
games.push(GameCandidate { name, path });
}
}
Ok(games)
}
fn discover_library_paths(steam_dir: &Path) -> Result<Vec<PathBuf>> {
let mut libraries = HashSet::new();
libraries.insert(steam_dir.to_path_buf());
let vdf_path = steam_dir
.join("steamapps")
.join("libraryfolders.vdf");
if vdf_path.is_file() {
let contents = fs::read_to_string(&vdf_path).context("read libraryfolders")?;
for line in contents.lines() {
let line = line.trim();
if !line.starts_with('"') || !line.contains("\"path\"") {
continue;
}
let parts: Vec<&str> = line.split('"').collect();
if parts.len() >= 4 {
let raw_path = parts[3];
let normalized = raw_path.replace("\\\\", "\\");
libraries.insert(PathBuf::from(normalized));
}
}
}
Ok(libraries.into_iter().collect())
}