// Copyright 2025 Takuto Nakamura // // 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. using System.Diagnostics; using System.Drawing.Drawing2D; using System.Drawing.Imaging; using System.Text.Json; namespace RunCat365 { internal class CustomRunnerRepository { internal const int MIN_FRAME_COUNT = 2; internal const int MAX_FRAME_COUNT = 30; private const int MAX_FRAME_HEIGHT = 32; private const int MIN_FRAME_WIDTH = 10; private const int MAX_FRAME_WIDTH = 32; private const string PROFILES_FILE_NAME = "profiles.json"; private readonly string basePath; private List profiles = []; internal CustomRunnerRepository() { basePath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "RunCat365", "CustomRunners" ); Directory.CreateDirectory(basePath); Load(); } internal List GetAll() { return [.. profiles]; } internal CustomRunnerProfile? GetByName(string name) { return profiles.Find(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); } internal List LoadFrames(string name) { var profile = GetByName(name); if (profile is null) return []; var runnerDirectory = Path.Combine(basePath, SanitizeDirectoryName(name)); var frames = new List(); foreach (var fileName in profile.FrameFileNames) { var filePath = Path.Combine(runnerDirectory, fileName); if (!File.Exists(filePath)) continue; var frame = TryLoadBitmap(filePath); if (frame is not null) frames.Add(frame); } return frames; } internal Bitmap? LoadFirstFrame(string name) { var profile = GetByName(name); if (profile is null || profile.FrameFileNames.Count == 0) return null; var runnerDirectory = Path.Combine(basePath, SanitizeDirectoryName(name)); var filePath = Path.Combine(runnerDirectory, profile.FrameFileNames[0]); return File.Exists(filePath) ? TryLoadBitmap(filePath) : null; } private static Bitmap? TryLoadBitmap(string filePath) { try { return new Bitmap(filePath); } catch (Exception ex) when (ex is ArgumentException or OutOfMemoryException or FileNotFoundException) { Debug.WriteLine($"Failed to load custom runner frame '{filePath}': {ex.Message}"); return null; } } internal bool Save(string name, List frames) { if (string.IsNullOrWhiteSpace(name)) return false; if (frames.Count < MIN_FRAME_COUNT || frames.Count > MAX_FRAME_COUNT) return false; var runnerDirectory = Path.Combine(basePath, SanitizeDirectoryName(name)); Directory.CreateDirectory(runnerDirectory); var existingProfile = GetByName(name); if (existingProfile is not null) { DeleteFrameFiles(existingProfile); profiles.Remove(existingProfile); } var profile = new CustomRunnerProfile { Name = name }; for (int i = 0; i < frames.Count; i++) { using var resized = ResizeFrame(frames[i]); var fileName = $"frame_{i}.png"; var filePath = Path.Combine(runnerDirectory, fileName); resized.Save(filePath, ImageFormat.Png); profile.FrameFileNames.Add(fileName); } profiles.Add(profile); return TrySaveProfiles(); } internal bool Delete(string name) { var profile = GetByName(name); if (profile is null) return false; DeleteFrameFiles(profile); var runnerDirectory = Path.Combine(basePath, SanitizeDirectoryName(name)); try { if (Directory.Exists(runnerDirectory)) { Directory.Delete(runnerDirectory, true); } } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { Debug.WriteLine($"Failed to delete runner directory '{runnerDirectory}': {ex.Message}"); } profiles.Remove(profile); TrySaveProfiles(); return true; } internal bool Exists(string name) { return GetByName(name) is not null; } private void Load() { var profilesPath = Path.Combine(basePath, PROFILES_FILE_NAME); if (!File.Exists(profilesPath)) { profiles = []; return; } try { var json = File.ReadAllText(profilesPath); profiles = JsonSerializer.Deserialize>(json) ?? []; } catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException) { Debug.WriteLine($"Failed to load custom runner profiles: {ex.Message}"); profiles = []; } } private bool TrySaveProfiles() { var profilesPath = Path.Combine(basePath, PROFILES_FILE_NAME); try { var options = new JsonSerializerOptions { WriteIndented = true }; var json = JsonSerializer.Serialize(profiles, options); File.WriteAllText(profilesPath, json); return true; } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { Debug.WriteLine($"Failed to write custom runner profiles: {ex.Message}"); return false; } } private void DeleteFrameFiles(CustomRunnerProfile profile) { var runnerDirectory = Path.Combine(basePath, SanitizeDirectoryName(profile.Name)); foreach (var fileName in profile.FrameFileNames) { var filePath = Path.Combine(runnerDirectory, fileName); try { File.Delete(filePath); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { Debug.WriteLine($"Failed to delete frame file '{filePath}': {ex.Message}"); } } } private static Bitmap ResizeFrame(Bitmap original) { if (original.Height <= MAX_FRAME_HEIGHT && original.Width >= MIN_FRAME_WIDTH && original.Width <= MAX_FRAME_WIDTH) { return new Bitmap(original); } var scale = (float)MAX_FRAME_HEIGHT / original.Height; var newWidth = Math.Clamp((int)(original.Width * scale), MIN_FRAME_WIDTH, MAX_FRAME_WIDTH); var newHeight = MAX_FRAME_HEIGHT; var resized = new Bitmap(newWidth, newHeight); using var graphics = Graphics.FromImage(resized); graphics.InterpolationMode = InterpolationMode.HighQualityBicubic; graphics.DrawImage(original, 0, 0, newWidth, newHeight); return resized; } private static string SanitizeDirectoryName(string name) { var invalidChars = Path.GetInvalidFileNameChars(); var sanitized = new string(name.Select(c => invalidChars.Contains(c) ? '_' : c).ToArray()); return sanitized.ToLowerInvariant(); } } }