Added checking for updates

This commit is contained in:
erik 2025-05-31 00:06:18 +02:00
parent cc15863e81
commit 5ec6525257
19 changed files with 1231 additions and 100 deletions

View file

@ -1,8 +1,7 @@
<Application x:Class="MossyUpdater.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:MossyUpdater"
StartupUri="MainWindow.xaml">
xmlns:local="clr-namespace:MossyUpdater">
<Application.Resources>
</Application.Resources>

View file

@ -1,14 +1,52 @@
using System.Configuration;
using System.Data;
using System.Windows;
using System.Windows;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using MossyUpdater.Services;
using MossyUpdater.ViewModels;
namespace MossyUpdater
{
/// <summary>
/// Interaction logic for App.xaml
/// </summary>
public partial class App : Application
{
private ServiceProvider? _serviceProvider;
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
var services = new ServiceCollection();
ConfigureServices(services);
_serviceProvider = services.BuildServiceProvider();
var mainWindow = new MainWindow
{
DataContext = _serviceProvider.GetRequiredService<MainViewModel>()
};
mainWindow.Show();
}
protected override void OnExit(ExitEventArgs e)
{
var mainViewModel = _serviceProvider?.GetService<MainViewModel>();
mainViewModel?.Dispose();
_serviceProvider?.Dispose();
base.OnExit(e);
}
private static void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<ILogger, FileLogger>();
services.AddSingleton<ISettingsService, SettingsService>();
services.AddSingleton<IQuotesService, QuotesService>();
services.AddHttpClient<IDownloadService, DownloadService>(client =>
{
client.Timeout = TimeSpan.FromMinutes(10);
client.DefaultRequestHeaders.Add("User-Agent", "MossyUpdater/1.0");
});
services.AddSingleton<MainViewModel>();
}
}
}

47
Converters.cs Normal file
View file

@ -0,0 +1,47 @@
using System;
using System.Globalization;
using System.Windows;
using System.Windows.Data;
using System.Windows.Media;
namespace MossyUpdater
{
public class StringToVisibilityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
return string.IsNullOrEmpty(value as string) ? Visibility.Collapsed : Visibility.Visible;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
public class BoolToFontWeightConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
return value is true ? FontWeights.Bold : FontWeights.Normal;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
public class BoolToUpdateColorConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
return value is true ? Brushes.Orange : Brushes.Green;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
}

View file

@ -1,7 +1,9 @@
<Window x:Class="MossyUpdater.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="MossyUpdater" Height="331" Width="500">
xmlns:local="clr-namespace:MossyUpdater"
Title="MossyUpdater" Height="450" Width="550" MinHeight="400" MinWidth="500" ResizeMode="CanResize"
Icon="pack://application:,,,/MossyUpdater;component/favicon.ico">
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
@ -13,32 +15,38 @@
<RowDefinition Height="Auto"/>
<!-- 3: filename -->
<RowDefinition Height="Auto"/>
<!-- 4: download button -->
<!-- 4: update status -->
<RowDefinition Height="Auto"/>
<!-- 5: status text -->
<!-- 5: download buttons -->
<RowDefinition Height="Auto"/>
<!-- 6: progress bar -->
<RowDefinition Height="Auto"/>
<!-- 7: status text -->
<RowDefinition Height="*"/>
<!-- 6: image (fills rest) -->
<!-- 8: image (fills rest) -->
</Grid.RowDefinitions>
<!-- 0 -->
<TextBlock Text="MosswartMassacre URL to DLL:" Grid.Row="0" Margin="0,0,0,5"/>
<!-- 1 -->
<TextBox x:Name="UrlTextBox"
Grid.Row="1"
<TextBox Grid.Row="1"
Height="25"
Margin="0,0,0,10"
Text="https://git.snakedesert.se/SawatoMosswartsEnjoyersClub/MosswartMassacre/raw/branch/spawn-detection/MosswartMassacre/bin/Release/MosswartMassacre.dll"/>
Text="{Binding Url, UpdateSourceTrigger=PropertyChanged}"
IsEnabled="{Binding CanInteract}"/>
<!-- 2 -->
<StackPanel Grid.Row="2" Orientation="Horizontal" Margin="0,0,0,10">
<TextBox x:Name="FolderTextBox"
Width="350"
<TextBox Width="350"
Height="25"
Margin="0,0,5,0"/>
Margin="0,0,5,0"
Text="{Binding Folder, UpdateSourceTrigger=PropertyChanged}"
IsEnabled="{Binding CanInteract}"/>
<Button Content="Browse"
Width="75"
Click="Browse_Click"/>
Command="{Binding BrowseCommand}"
IsEnabled="{Binding CanInteract}"/>
</StackPanel>
<!-- 3 -->
@ -46,31 +54,71 @@
<TextBlock Text="Filename:"
VerticalAlignment="Center"
Margin="0,0,5,0"/>
<TextBox x:Name="FilenameTextBox"
Width="200"
Text="MosswartMassacre.dll"/>
<TextBox Width="200"
Text="{Binding Filename, UpdateSourceTrigger=PropertyChanged}"
IsEnabled="{Binding CanInteract}"/>
<Button Content="Check Updates"
Width="100"
Margin="10,0,0,0"
Command="{Binding CheckUpdatesCommand}"
IsEnabled="{Binding CanInteract}"/>
</StackPanel>
<!-- 4 -->
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="Download &amp; Unblock"
Width="150"
Click="Download_Click"/>
</StackPanel>
<TextBlock Grid.Row="4"
Text="{Binding UpdateStatus}"
Margin="0,0,0,10"/>
<!-- 5 -->
<TextBlock x:Name="StatusTextBlock"
Grid.Row="5"
Margin="0,10,0,10"
Foreground="Green"/>
<StackPanel Grid.Row="5" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,0,0,10">
<Button Content="Download &amp; Unblock"
Width="150"
Margin="0,0,10,0"
Command="{Binding DownloadCommand}"/>
<Button Content="Cancel"
Width="75"
Command="{Binding CancelCommand}"/>
</StackPanel>
<!-- 6: your image as embedded Resource -->
<Image Grid.Row="6"
<!-- 6 -->
<ProgressBar Grid.Row="6"
Height="20"
Margin="0,0,0,10"
Value="{Binding ProgressValue}"/>
<!-- 7 -->
<TextBlock Grid.Row="7"
Text="{Binding StatusMessage}"
Margin="0,0,0,10"
TextWrapping="Wrap"
Foreground="{Binding StatusColor}"/>
<!-- 8: Quote display with image -->
<Grid Grid.Row="8" Margin="0,10,0,0">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Quote -->
<TextBlock Grid.Row="0"
Text="{Binding CurrentQuote}"
Opacity="{Binding QuoteOpacity}"
TextWrapping="Wrap"
FontStyle="Italic"
FontSize="12"
Foreground="DarkSlateGray"
HorizontalAlignment="Center"
TextAlignment="Center"
Margin="10,0,10,10"/>
<!-- Image -->
<Image Grid.Row="1"
Source="pack://application:,,,/MossyUpdater;component/Mosswart_Mask_Live.png"
Stretch="Uniform"
HorizontalAlignment="Center"
VerticalAlignment="Top"
Margin="0,10,0,0"
Height="100"/>
MaxHeight="100"/>
</Grid>
</Grid>
</Window>

View file

@ -1,9 +1,4 @@
using System;
using System.IO;
using System.Net.Http;
using System.Threading.Tasks;
using System.Windows;
using Ookii.Dialogs.Wpf; // ← add this
using System.Windows;
namespace MossyUpdater
{
@ -13,60 +8,5 @@ namespace MossyUpdater
{
InitializeComponent();
}
private void Browse_Click(object sender, RoutedEventArgs e)
{
var dlg = new VistaFolderBrowserDialog
{
Description = "Select a destination folder",
UseDescriptionForTitle = true
};
// ShowDialog returns true if the user clicked OK
if (dlg.ShowDialog() == true)
FolderTextBox.Text = dlg.SelectedPath;
}
private async void Download_Click(object sender, RoutedEventArgs e)
{
string url = UrlTextBox.Text.Trim();
string folder = FolderTextBox.Text.Trim();
string filename = FilenameTextBox.Text.Trim();
if (string.IsNullOrEmpty(url)
|| string.IsNullOrEmpty(folder)
|| string.IsNullOrEmpty(filename))
{
StatusTextBlock.Text = "Please fill in all fields.";
StatusTextBlock.Foreground = System.Windows.Media.Brushes.Red;
return;
}
string fullPath = Path.Combine(folder, filename);
try
{
using var client = new HttpClient();
var data = await client.GetByteArrayAsync(url);
await File.WriteAllBytesAsync(fullPath, data);
UnblockFile(fullPath);
StatusTextBlock.Text = $"Downloaded and unblocked:\n{fullPath}";
StatusTextBlock.Foreground = System.Windows.Media.Brushes.Green;
}
catch (Exception ex)
{
StatusTextBlock.Text = $"Error: {ex.Message}";
StatusTextBlock.Foreground = System.Windows.Media.Brushes.Red;
}
}
private void UnblockFile(string path)
{
var zone = path + ":Zone.Identifier";
if (File.Exists(zone))
File.Delete(zone);
}
}
}

View file

@ -10,9 +10,12 @@
<ItemGroup>
<None Remove="Mosswart_Mask_Live.png" />
<None Remove="favicon.ico" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" />
<PackageReference Include="Ookii.Dialogs.Wpf" Version="5.0.1" />
<PackageReference Include="System.Drawing.Common" Version="9.0.5" />
<PackageReference Include="System.Windows.Extensions" Version="9.0.5" />
@ -20,6 +23,7 @@
<ItemGroup>
<Resource Include="Mosswart_Mask_Live.png" />
<Resource Include="favicon.ico" />
</ItemGroup>
</Project>

View file

@ -1,2 +1,87 @@
# MossyUpdater
A modern Windows desktop application for downloading and unblocking files, featuring an entertaining quote rotation system and robust download management.
## Features
### 🚀 **Core Functionality**
- **Smart Downloads**: Download files with real-time progress tracking
- **Automatic Unblocking**: Removes Windows security restrictions (Zone.Identifier)
- **Update Detection**: Checks if remote files have changed
- **Retry Logic**: Automatically retries failed downloads (3 attempts)
- **Cancellation Support**: Cancel downloads in progress
### 🎨 **User Experience**
- **Quote Rotation**: 50 hilarious "Time According to Alex" quotes with smooth fade transitions
- **Progress Visualization**: Real-time download progress with status updates
- **Responsive Design**: Resizable window with modern WPF interface
- **Settings Persistence**: Remembers your preferences between sessions
- **Custom Icon**: Features favicon.ico as the application icon
### 🔧 **Technical Features**
- **MVVM Architecture**: Clean separation of concerns with data binding
- **Dependency Injection**: Modern IoC container with service registration
- **Streaming Downloads**: Efficient handling of large files
- **Comprehensive Logging**: Detailed logs saved to `%AppData%/MossyUpdater/log.txt`
- **Error Handling**: User-friendly error messages with detailed logging
## Quick Start
1. **Build the application**:
```bash
dotnet build
```
2. **Run the application**:
```bash
dotnet run
```
3. **Use the application**:
- Enter a download URL (defaults to MosswartMassacre.dll)
- Browse and select destination folder
- Click "Check Updates" to see if file has changed
- Click "Download & Unblock" to start the download
- Enjoy the rotating quotes while you wait!
## System Requirements
- **Platform**: Windows 10/11
- **Framework**: .NET 8.0
- **Architecture**: x64
## Publishing
Create a single-file executable:
```bash
dotnet publish -c Release -p:PublishProfile=FolderProfile
```
Output location: `bin\Release\net8.0-windows\publish\win-x64\`
## Configuration
Settings are automatically saved to:
- **Settings**: `%AppData%/MossyUpdater/settings.json`
- **Logs**: `%AppData%/MossyUpdater/log.txt`
## Quote System
Features 50 witty quotes from "Time According to Alex" about time management, rotating every 5 seconds with smooth fade transitions. Sample quotes include:
> "I don't run late. I glide fashionably through time." - Time According to Alex
> "Time is an illusion. My ETA is performance art." - Time According to Alex
## Development
Built with modern C# and WPF, featuring:
- **Services Pattern**: Modular business logic
- **MVVM Pattern**: Clean UI separation
- **Async/Await**: Non-blocking operations
- **Dependency Injection**: Testable architecture
- **Resource Management**: Proper disposal patterns
## License
Built for the SawatoMosswartsEnjoyersClub/MosswartMassacre project.

224
Services/DownloadService.cs Normal file
View file

@ -0,0 +1,224 @@
using System.Diagnostics;
using System.IO;
using System.Net;
using System.Net.Http;
namespace MossyUpdater.Services
{
public class DownloadService : IDownloadService
{
private readonly HttpClient _httpClient;
private readonly ILogger _logger;
private const int MaxRetries = 3;
private static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(2);
public DownloadService(HttpClient httpClient, ILogger logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<DownloadResult> DownloadFileAsync(string url, string destinationPath, IProgress<DownloadProgress>? progress = null, CancellationToken cancellationToken = default)
{
var stopwatch = Stopwatch.StartNew();
var result = new DownloadResult();
await _logger.LogInfoAsync($"Starting download from {url} to {destinationPath}");
for (int attempt = 1; attempt <= MaxRetries; attempt++)
{
try
{
if (attempt > 1)
{
progress?.Report(new DownloadProgress { Status = $"Retrying download (attempt {attempt}/{MaxRetries})..." });
await _logger.LogInfoAsync($"Retry attempt {attempt}/{MaxRetries}");
await Task.Delay(RetryDelay, cancellationToken);
}
else
{
progress?.Report(new DownloadProgress { Status = "Starting download..." });
}
using var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
var totalBytes = response.Content.Headers.ContentLength;
progress?.Report(new DownloadProgress { TotalBytes = totalBytes, Status = "Downloading..." });
var directory = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
using var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var fileStream = new FileStream(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 8192, useAsync: true);
var buffer = new byte[8192];
long totalBytesRead = 0;
int bytesRead;
while ((bytesRead = await contentStream.ReadAsync(buffer, 0, buffer.Length, cancellationToken)) > 0)
{
await fileStream.WriteAsync(buffer, 0, bytesRead, cancellationToken);
totalBytesRead += bytesRead;
progress?.Report(new DownloadProgress
{
BytesReceived = totalBytesRead,
TotalBytes = totalBytes,
Status = $"Downloaded {FormatBytes(totalBytesRead)}" + (totalBytes.HasValue ? $" of {FormatBytes(totalBytes.Value)}" : "")
});
}
result.Success = true;
result.BytesDownloaded = totalBytesRead;
progress?.Report(new DownloadProgress
{
BytesReceived = totalBytesRead,
TotalBytes = totalBytes,
Status = "Download completed successfully"
});
await _logger.LogInfoAsync($"Download completed successfully. Downloaded {FormatBytes(totalBytesRead)} in {stopwatch.Elapsed:mm\\:ss}");
break;
}
catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException && attempt < MaxRetries)
{
await _logger.LogWarningAsync($"Timeout on attempt {attempt}: {ex.Message}");
continue;
}
catch (OperationCanceledException)
{
result.ErrorMessage = "Download was cancelled";
progress?.Report(new DownloadProgress { Status = "Download cancelled" });
await _logger.LogInfoAsync("Download was cancelled by user");
if (File.Exists(destinationPath))
{
try { File.Delete(destinationPath); } catch { }
}
break;
}
catch (HttpRequestException ex) when (IsRetryableHttpError(ex) && attempt < MaxRetries)
{
await _logger.LogWarningAsync($"Retryable error on attempt {attempt}: {ex.Message}");
continue;
}
catch (Exception ex)
{
result.ErrorMessage = GetFriendlyErrorMessage(ex);
progress?.Report(new DownloadProgress { Status = $"Error: {result.ErrorMessage}" });
await _logger.LogErrorAsync($"Download failed on attempt {attempt}", ex);
if (File.Exists(destinationPath))
{
try { File.Delete(destinationPath); } catch { }
}
if (attempt >= MaxRetries)
break;
}
}
stopwatch.Stop();
result.Duration = stopwatch.Elapsed;
return result;
}
public async Task<FileUpdateInfo> CheckForUpdatesAsync(string url, string localFilePath, CancellationToken cancellationToken = default)
{
var updateInfo = new FileUpdateInfo();
try
{
var request = new HttpRequestMessage(HttpMethod.Head, url);
using var response = await _httpClient.SendAsync(request, cancellationToken);
if (response.IsSuccessStatusCode)
{
updateInfo.RemoteLastModified = response.Content.Headers.LastModified?.DateTime;
updateInfo.RemoteSize = response.Content.Headers.ContentLength;
if (File.Exists(localFilePath))
{
var localFileInfo = new FileInfo(localFilePath);
updateInfo.LocalLastModified = localFileInfo.LastWriteTime;
updateInfo.LocalSize = localFileInfo.Length;
updateInfo.UpdateAvailable =
updateInfo.RemoteLastModified.HasValue &&
updateInfo.RemoteLastModified > updateInfo.LocalLastModified ||
(updateInfo.RemoteSize.HasValue && updateInfo.RemoteSize != updateInfo.LocalSize);
}
else
{
updateInfo.UpdateAvailable = true;
}
}
else
{
updateInfo.ErrorMessage = $"Failed to check for updates: {response.StatusCode}";
}
}
catch (Exception ex)
{
updateInfo.ErrorMessage = $"Error checking for updates: {ex.Message}";
}
return updateInfo;
}
public void UnblockFile(string filePath)
{
try
{
var zoneIdentifier = filePath + ":Zone.Identifier";
if (File.Exists(zoneIdentifier))
{
File.Delete(zoneIdentifier);
}
}
catch
{
}
}
private static string FormatBytes(long bytes)
{
string[] suffixes = { "B", "KB", "MB", "GB", "TB" };
int counter = 0;
decimal number = bytes;
while (Math.Round(number / 1024) >= 1)
{
number /= 1024;
counter++;
}
return $"{number:n1} {suffixes[counter]}";
}
private static bool IsRetryableHttpError(HttpRequestException ex)
{
return ex.Message.Contains("timeout", StringComparison.OrdinalIgnoreCase) ||
ex.Message.Contains("connection", StringComparison.OrdinalIgnoreCase) ||
ex.Message.Contains("network", StringComparison.OrdinalIgnoreCase);
}
private static string GetFriendlyErrorMessage(Exception ex)
{
return ex switch
{
HttpRequestException httpEx when httpEx.Message.Contains("404") => "File not found on server",
HttpRequestException httpEx when httpEx.Message.Contains("403") => "Access denied to file",
HttpRequestException httpEx when httpEx.Message.Contains("timeout") => "Download timed out",
UnauthorizedAccessException => "Access denied to destination folder",
DirectoryNotFoundException => "Destination folder does not exist",
IOException => "File operation failed",
_ => ex.Message
};
}
}
}

52
Services/FileLogger.cs Normal file
View file

@ -0,0 +1,52 @@
using System.IO;
namespace MossyUpdater.Services
{
public class FileLogger : ILogger
{
private readonly string _logPath;
private readonly SemaphoreSlim _semaphore = new(1, 1);
public FileLogger()
{
var appDataPath = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
var appFolder = Path.Combine(appDataPath, "MossyUpdater");
Directory.CreateDirectory(appFolder);
_logPath = Path.Combine(appFolder, "log.txt");
}
public async Task LogInfoAsync(string message)
{
await WriteLogAsync(LogLevel.Info, message);
}
public async Task LogWarningAsync(string message)
{
await WriteLogAsync(LogLevel.Warning, message);
}
public async Task LogErrorAsync(string message, Exception? exception = null)
{
var fullMessage = exception != null ? $"{message} - Exception: {exception}" : message;
await WriteLogAsync(LogLevel.Error, fullMessage);
}
private async Task WriteLogAsync(LogLevel level, string message)
{
await _semaphore.WaitAsync();
try
{
var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
var logEntry = $"[{timestamp}] [{level}] {message}{Environment.NewLine}";
await File.AppendAllTextAsync(_logPath, logEntry);
}
catch
{
}
finally
{
_semaphore.Release();
}
}
}
}

View file

@ -0,0 +1,37 @@
namespace MossyUpdater.Services
{
public interface IDownloadService
{
Task<DownloadResult> DownloadFileAsync(string url, string destinationPath, IProgress<DownloadProgress>? progress = null, CancellationToken cancellationToken = default);
Task<FileUpdateInfo> CheckForUpdatesAsync(string url, string localFilePath, CancellationToken cancellationToken = default);
void UnblockFile(string filePath);
}
public class DownloadResult
{
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
public long BytesDownloaded { get; set; }
public TimeSpan Duration { get; set; }
}
public class DownloadProgress
{
public long BytesReceived { get; set; }
public long? TotalBytes { get; set; }
public double ProgressPercentage => TotalBytes.HasValue && TotalBytes > 0
? (double)BytesReceived / TotalBytes.Value * 100
: 0;
public string Status { get; set; } = string.Empty;
}
public class FileUpdateInfo
{
public bool UpdateAvailable { get; set; }
public DateTime? RemoteLastModified { get; set; }
public DateTime? LocalLastModified { get; set; }
public long? RemoteSize { get; set; }
public long? LocalSize { get; set; }
public string? ErrorMessage { get; set; }
}
}

16
Services/ILogger.cs Normal file
View file

@ -0,0 +1,16 @@
namespace MossyUpdater.Services
{
public interface ILogger
{
Task LogInfoAsync(string message);
Task LogWarningAsync(string message);
Task LogErrorAsync(string message, Exception? exception = null);
}
public enum LogLevel
{
Info,
Warning,
Error
}
}

View file

@ -0,0 +1,9 @@
namespace MossyUpdater.Services
{
public interface IQuotesService
{
string GetRandomQuote();
string GetNextQuote();
IEnumerable<string> GetAllQuotes();
}
}

View file

@ -0,0 +1,19 @@
namespace MossyUpdater.Services
{
public interface ISettingsService
{
Task<AppSettings> LoadSettingsAsync();
Task SaveSettingsAsync(AppSettings settings);
}
public class AppSettings
{
public string LastUsedFolder { get; set; } = string.Empty;
public string DefaultUrl { get; set; } = "https://git.snakedesert.se/SawatoMosswartsEnjoyersClub/MosswartMassacre/raw/branch/spawn-detection/MosswartMassacre/bin/Release/MosswartMassacre.dll";
public string DefaultFilename { get; set; } = "MosswartMassacre.dll";
public double WindowWidth { get; set; } = 550;
public double WindowHeight { get; set; } = 450;
public bool CheckForUpdatesOnStartup { get; set; } = true;
public int DownloadTimeoutMinutes { get; set; } = 10;
}
}

80
Services/QuotesService.cs Normal file
View file

@ -0,0 +1,80 @@
namespace MossyUpdater.Services
{
public class QuotesService : IQuotesService
{
private readonly string[] _quotes = new[]
{
"I don't run late. I glide fashionably through time.",
"Time is a flat circle—and I'm still trying to find the starting point.",
"Being on time is like spotting a unicorn: rare, beautiful, and definitely not me.",
"Time zones are a social construct. I operate on instinct.",
"Every time I try to be early, time responds with traffic.",
"Time is an illusion. My ETA is performance art.",
"I treat deadlines like speed limits: suggestions.",
"I don't set alarms. I set vague intentions.",
"If you want me to be on time, lie about when it starts.",
"Why stress about time when you can just embrace chaos?",
"My to-do list is a historical document.",
"Time and I broke up. It was toxic.",
"I'm not late. I'm temporally creative.",
"'In five minutes' means something different in my language.",
"I don't track time. I let it track me—poorly.",
"Time management is easy. Just avoid doing anything.",
"Time is fleeting. So is my attention span.",
"I live in the moment. Just never the right one.",
"Time doesn't control me. It just heavily inconveniences me.",
"I'm a time optimist: always wrong, always hopeful.",
"Punctuality is for people who don't trust spontaneity.",
"My life is a series of missed trains and strong coffee.",
"If time is a river, I'm definitely upstream without a paddle.",
"I don't watch the clock. I avoid eye contact with it.",
"'ASAP' means 'as soon as procrastination ends.'",
"Early is suspicious. On time is impressive. Late is expected.",
"Time management? I prefer time improvisation.",
"I like to keep time on its toes. Mostly by ignoring it.",
"Alarms are like plot twists. I didn't see it coming, and I still ignore it.",
"If being late was a sport, I'd already have missed the medal ceremony.",
"I don't lose track of time. I just pretend it doesn't exist.",
"Watches are decorative lies.",
"Time flies. I miss every flight.",
"My planner is just a coloring book for stress.",
"I'm not in a hurry. I'm in denial.",
"I live in the now, just usually a little bit after everyone else.",
"My calendar and I are estranged, but we're working on it.",
"The early bird catches the worm. I order food later.",
"Time moves fast. I move slower.",
"I was going to be on time, but then I remembered who I am.",
"I see '9:00 AM' and read it as 'guideline.'",
"Every plan is a maybe with extra steps.",
"I don't have time blindness—I just have time indifference.",
"I'm not late. Reality is early.",
"Time and I aren't speaking after what happened last Monday.",
"Being on time is impressive. Being consistently late is a brand.",
"I'm in sync with the universe—just in a different dimension.",
"My sense of time is like my sock drawer: chaotic and mostly missing.",
"Some people chase time. I let it wander off.",
"I'm not running late. I'm setting the tone for a relaxed experience."
};
private int _currentIndex = 0;
private readonly Random _random = new();
public string GetRandomQuote()
{
var index = _random.Next(_quotes.Length);
return $"\"{_quotes[index]}\" - Time According to Alex";
}
public string GetNextQuote()
{
var quote = $"\"{_quotes[_currentIndex]}\" - Time According to Alex";
_currentIndex = (_currentIndex + 1) % _quotes.Length;
return quote;
}
public IEnumerable<string> GetAllQuotes()
{
return _quotes.Select(q => $"\"{q}\" - Time According to Alex");
}
}
}

View file

@ -0,0 +1,50 @@
using System.IO;
using System.Text.Json;
namespace MossyUpdater.Services
{
public class SettingsService : ISettingsService
{
private readonly string _settingsPath;
public SettingsService()
{
var appDataPath = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
var appFolder = Path.Combine(appDataPath, "MossyUpdater");
Directory.CreateDirectory(appFolder);
_settingsPath = Path.Combine(appFolder, "settings.json");
}
public async Task<AppSettings> LoadSettingsAsync()
{
try
{
if (!File.Exists(_settingsPath))
return new AppSettings();
var json = await File.ReadAllTextAsync(_settingsPath);
var settings = JsonSerializer.Deserialize<AppSettings>(json);
return settings ?? new AppSettings();
}
catch
{
return new AppSettings();
}
}
public async Task SaveSettingsAsync(AppSettings settings)
{
try
{
var json = JsonSerializer.Serialize(settings, new JsonSerializerOptions
{
WriteIndented = true
});
await File.WriteAllTextAsync(_settingsPath, json);
}
catch
{
}
}
}
}

364
ViewModels/MainViewModel.cs Normal file
View file

@ -0,0 +1,364 @@
using System;
using System.IO;
using System.Windows.Input;
using MossyUpdater.Services;
using Ookii.Dialogs.Wpf;
namespace MossyUpdater.ViewModels
{
public class MainViewModel : ViewModelBase, IDisposable
{
private readonly IDownloadService _downloadService;
private readonly ISettingsService _settingsService;
private readonly ILogger _logger;
private readonly IQuotesService _quotesService;
private CancellationTokenSource? _cancellationTokenSource;
private System.Windows.Threading.DispatcherTimer? _quoteTimer;
private string _url = "https://git.snakedesert.se/SawatoMosswartsEnjoyersClub/MosswartMassacre/raw/branch/spawn-detection/MosswartMassacre/bin/Release/MosswartMassacre.dll";
private string _folder = string.Empty;
private string _filename = "MosswartMassacre.dll";
private string _statusMessage = string.Empty;
private System.Windows.Media.Brush _statusColor = System.Windows.Media.Brushes.Green;
private double _progressValue = 0;
private bool _isProgressVisible = false;
private bool _isDownloading = false;
private string _updateStatus = string.Empty;
private bool _updateAvailable = false;
private string _currentQuote = string.Empty;
private double _quoteOpacity = 1.0;
public MainViewModel(IDownloadService downloadService, ISettingsService settingsService, ILogger logger, IQuotesService quotesService)
{
_downloadService = downloadService ?? throw new ArgumentNullException(nameof(downloadService));
_settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_quotesService = quotesService ?? throw new ArgumentNullException(nameof(quotesService));
BrowseCommand = new RelayCommand(BrowseFolder);
DownloadCommand = new AsyncRelayCommand(DownloadAndUnblock, CanDownload);
CancelCommand = new RelayCommand(CancelDownload, () => IsDownloading);
CheckUpdatesCommand = new AsyncRelayCommand(CheckForUpdates);
InitializeQuoteRotation();
_ = LoadSettingsAsync();
}
public string Url
{
get => _url;
set => SetProperty(ref _url, value);
}
public string Folder
{
get => _folder;
set => SetProperty(ref _folder, value);
}
public string Filename
{
get => _filename;
set => SetProperty(ref _filename, value);
}
public string StatusMessage
{
get => _statusMessage;
set => SetProperty(ref _statusMessage, value);
}
public System.Windows.Media.Brush StatusColor
{
get => _statusColor;
set => SetProperty(ref _statusColor, value);
}
public double ProgressValue
{
get => _progressValue;
set => SetProperty(ref _progressValue, value);
}
public bool IsProgressVisible
{
get => _isProgressVisible;
set => SetProperty(ref _isProgressVisible, value);
}
public bool IsDownloading
{
get => _isDownloading;
set
{
SetProperty(ref _isDownloading, value);
OnPropertyChanged(nameof(CanInteract));
}
}
public string UpdateStatus
{
get => _updateStatus;
set => SetProperty(ref _updateStatus, value);
}
public bool UpdateAvailable
{
get => _updateAvailable;
set => SetProperty(ref _updateAvailable, value);
}
public string CurrentQuote
{
get => _currentQuote;
set => SetProperty(ref _currentQuote, value);
}
public double QuoteOpacity
{
get => _quoteOpacity;
set => SetProperty(ref _quoteOpacity, value);
}
public bool CanInteract => !IsDownloading;
public ICommand BrowseCommand { get; }
public ICommand DownloadCommand { get; }
public ICommand CancelCommand { get; }
public ICommand CheckUpdatesCommand { get; }
private void BrowseFolder()
{
var dialog = new VistaFolderBrowserDialog
{
Description = "Select a destination folder",
UseDescriptionForTitle = true,
SelectedPath = Folder
};
if (dialog.ShowDialog() == true)
{
Folder = dialog.SelectedPath;
_ = SaveSettingsAsync();
}
}
private bool CanDownload()
{
return !IsDownloading &&
!string.IsNullOrWhiteSpace(Url) &&
!string.IsNullOrWhiteSpace(Folder) &&
!string.IsNullOrWhiteSpace(Filename);
}
private async Task DownloadAndUnblock()
{
if (string.IsNullOrWhiteSpace(Url) || string.IsNullOrWhiteSpace(Folder) || string.IsNullOrWhiteSpace(Filename))
{
SetStatus("Please fill in all fields.", System.Windows.Media.Brushes.Red);
return;
}
var fullPath = Path.Combine(Folder, Filename);
_cancellationTokenSource = new CancellationTokenSource();
try
{
IsDownloading = true;
IsProgressVisible = true;
ProgressValue = 0;
var progress = new Progress<DownloadProgress>(OnDownloadProgress);
var result = await _downloadService.DownloadFileAsync(Url, fullPath, progress, _cancellationTokenSource.Token);
if (result.Success)
{
_downloadService.UnblockFile(fullPath);
SetStatus($"Downloaded and unblocked successfully!\n{fullPath}\nSize: {FormatBytes(result.BytesDownloaded)}, Duration: {result.Duration:mm\\:ss}", System.Windows.Media.Brushes.Green);
ProgressValue = 100;
await CheckForUpdates();
}
else if (!string.IsNullOrEmpty(result.ErrorMessage))
{
SetStatus($"Download failed: {result.ErrorMessage}", System.Windows.Media.Brushes.Red);
ProgressValue = 0;
}
}
catch (Exception ex)
{
SetStatus($"Unexpected error: {ex.Message}", System.Windows.Media.Brushes.Red);
ProgressValue = 0;
}
finally
{
IsDownloading = false;
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = null;
await Task.Delay(3000);
IsProgressVisible = false;
}
}
private void CancelDownload()
{
_cancellationTokenSource?.Cancel();
SetStatus("Download cancelled by user.", System.Windows.Media.Brushes.Orange);
}
private async Task CheckForUpdates()
{
if (string.IsNullOrWhiteSpace(Url) || string.IsNullOrWhiteSpace(Folder) || string.IsNullOrWhiteSpace(Filename))
{
UpdateStatus = "";
UpdateAvailable = false;
return;
}
var localPath = Path.Combine(Folder, Filename);
try
{
var updateInfo = await _downloadService.CheckForUpdatesAsync(Url, localPath);
if (!string.IsNullOrEmpty(updateInfo.ErrorMessage))
{
UpdateStatus = $"Update check failed: {updateInfo.ErrorMessage}";
UpdateAvailable = false;
}
else if (updateInfo.UpdateAvailable)
{
UpdateStatus = "Update available! Remote file is newer or different.";
UpdateAvailable = true;
}
else
{
UpdateStatus = "File is up to date.";
UpdateAvailable = false;
}
}
catch (Exception ex)
{
UpdateStatus = $"Update check error: {ex.Message}";
UpdateAvailable = false;
}
}
private void OnDownloadProgress(DownloadProgress progress)
{
ProgressValue = progress.ProgressPercentage;
SetStatus(progress.Status, System.Windows.Media.Brushes.Blue);
}
private void SetStatus(string message, System.Windows.Media.Brush color)
{
StatusMessage = message;
StatusColor = color;
}
private async Task LoadSettingsAsync()
{
try
{
var settings = await _settingsService.LoadSettingsAsync();
Url = settings.DefaultUrl;
Folder = settings.LastUsedFolder;
Filename = settings.DefaultFilename;
if (settings.CheckForUpdatesOnStartup && !string.IsNullOrWhiteSpace(Url) && !string.IsNullOrWhiteSpace(Folder) && !string.IsNullOrWhiteSpace(Filename))
{
await CheckForUpdates();
}
}
catch (Exception ex)
{
await _logger.LogErrorAsync("Failed to load settings", ex);
}
}
private async Task SaveSettingsAsync()
{
try
{
var settings = new AppSettings
{
DefaultUrl = Url,
LastUsedFolder = Folder,
DefaultFilename = Filename,
CheckForUpdatesOnStartup = true,
DownloadTimeoutMinutes = 10
};
await _settingsService.SaveSettingsAsync(settings);
}
catch (Exception ex)
{
await _logger.LogErrorAsync("Failed to save settings", ex);
}
}
private static string FormatBytes(long bytes)
{
string[] suffixes = { "B", "KB", "MB", "GB", "TB" };
int counter = 0;
decimal number = bytes;
while (Math.Round(number / 1024) >= 1)
{
number /= 1024;
counter++;
}
return $"{number:n1} {suffixes[counter]}";
}
private void InitializeQuoteRotation()
{
CurrentQuote = _quotesService.GetNextQuote();
_quoteTimer = new System.Windows.Threading.DispatcherTimer
{
Interval = TimeSpan.FromSeconds(5)
};
_quoteTimer.Tick += OnQuoteTimerTick;
_quoteTimer.Start();
}
private async void OnQuoteTimerTick(object? sender, EventArgs e)
{
await RotateQuoteWithFade();
}
private async Task RotateQuoteWithFade()
{
const double fadeOutDuration = 300;
const double fadeInDuration = 300;
const int fadeSteps = 30;
var fadeOutDelay = TimeSpan.FromMilliseconds(fadeOutDuration / fadeSteps);
var fadeInDelay = TimeSpan.FromMilliseconds(fadeInDuration / fadeSteps);
for (int i = fadeSteps; i >= 0; i--)
{
QuoteOpacity = (double)i / fadeSteps;
await Task.Delay(fadeOutDelay);
}
CurrentQuote = _quotesService.GetNextQuote();
for (int i = 0; i <= fadeSteps; i++)
{
QuoteOpacity = (double)i / fadeSteps;
await Task.Delay(fadeInDelay);
}
}
public void Dispose()
{
_quoteTimer?.Stop();
_quoteTimer = null;
_cancellationTokenSource?.Dispose();
}
}
}

View file

@ -0,0 +1,94 @@
using System.Windows.Input;
namespace MossyUpdater.ViewModels
{
public class RelayCommand : ICommand
{
private readonly Action<object?> _execute;
private readonly Predicate<object?>? _canExecute;
public RelayCommand(Action<object?> execute, Predicate<object?>? canExecute = null)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
public RelayCommand(Action execute, Func<bool>? canExecute = null)
: this(_ => execute(), canExecute == null ? null : _ => canExecute())
{
}
public event EventHandler? CanExecuteChanged
{
add { CommandManager.RequerySuggested += value; }
remove { CommandManager.RequerySuggested -= value; }
}
public bool CanExecute(object? parameter)
{
return _canExecute?.Invoke(parameter) ?? true;
}
public void Execute(object? parameter)
{
_execute(parameter);
}
public void RaiseCanExecuteChanged()
{
CommandManager.InvalidateRequerySuggested();
}
}
public class AsyncRelayCommand : ICommand
{
private readonly Func<object?, Task> _execute;
private readonly Predicate<object?>? _canExecute;
private bool _isExecuting;
public AsyncRelayCommand(Func<object?, Task> execute, Predicate<object?>? canExecute = null)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
public AsyncRelayCommand(Func<Task> execute, Func<bool>? canExecute = null)
: this(_ => execute(), canExecute == null ? null : _ => canExecute())
{
}
public event EventHandler? CanExecuteChanged
{
add { CommandManager.RequerySuggested += value; }
remove { CommandManager.RequerySuggested -= value; }
}
public bool CanExecute(object? parameter)
{
return !_isExecuting && (_canExecute?.Invoke(parameter) ?? true);
}
public async void Execute(object? parameter)
{
if (!CanExecute(parameter))
return;
try
{
_isExecuting = true;
RaiseCanExecuteChanged();
await _execute(parameter);
}
finally
{
_isExecuting = false;
RaiseCanExecuteChanged();
}
}
public void RaiseCanExecuteChanged()
{
CommandManager.InvalidateRequerySuggested();
}
}
}

View file

@ -0,0 +1,25 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace MossyUpdater.ViewModels
{
public abstract class ViewModelBase : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value))
return false;
field = value;
OnPropertyChanged(propertyName);
return true;
}
}
}

BIN
favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB