12 Commits
Author SHA1 Message Date
stefankoelle 4c7592582c release v0.9.6
## Summary

### New Features
- Improved Hatari launch configuration with {cfg} placeholder validation in args template
- Enhanced path traversal protection in overlay directory browser
- Added support for more joystick configurations
- Improved demo setup with folders G-Z

### Fixes
- Fixed hatari config file path handling to use bundled config by default
- Improved favorites service with positional constructor for better initialization
- Fixed Modal display logic for Hatari launches
- Enhanced error handling and validation throughout
- Fixed various typos and code style issues
- Improved menu rendering with better handling of console dimensions

### Documentation
- Updated README with Hatari setup instructions
- Updated AGENTS.md with enhanced release process
2026-08-11 20:08:06 +02:00
stefankoelle 5e2ab6581c README.md 2026-08-11 20:05:05 +02:00
stefankoelle e839c01a1e fix create release 2026-08-11 20:02:42 +02:00
stefankoelle 0117ba90c3 model center and wait for hatari 2026-08-11 19:55:44 +02:00
stefankoelle 9649910df6 demo.sh 2026-08-11 19:34:12 +02:00
stefankoelle 7971385beb cleanup 2026-08-11 19:31:18 +02:00
stefankoelle 410da6a98b Merge remote-tracking branch 'origin/main' 2026-08-11 19:30:56 +02:00
stefankoelle dd17b60784 launcher fixes - hatari config fixes 2026-08-11 19:30:35 +02:00
stefankoelle 5d8db1347b release v0.9.5 2026-08-11 19:30:33 +02:00
stefankoelle 7b40cbc331 launcher fixes - hatari config fixes 2026-08-11 19:22:53 +02:00
stefankoelle 4660cc6be8 release v0.9.5\n\nThis release updates the Marcer GameDVD Launcher with the following changes:\n\n#### Bug Fixes\n- Fix bundled config path resolution using Directory.GetCurrentDirectory() for better cross-platform compatibility\n\n#### Features\n- Add bundled Hatari configuration file for automatic fallback\n- Make Hatari.ConfigFile optional with automatic fallback to bundled config\n\n#### Code Improvements\n- Centralize configuration handling for better maintainability\n- Improve documentation and comments throughout\n\nThe launcher now provides improved configuration handling and bundled support for Hatari. 2026-08-11 14:51:40 +02:00
stefankoelle 148c2bb484 fix: use Directory.GetCurrentDirectory() for bundled config path 2026-08-11 14:45:27 +02:00
13 changed files with 216 additions and 230 deletions
+11 -6
View File
@@ -14,22 +14,27 @@ Create a release tag and push it to origin. The GitHub Action will automatically
- Run `git status --porcelain` - Run `git status --porcelain`
- If any output, warn the user and stop (commit first) - If any output, warn the user and stop (commit first)
3. Analyze changes since last release: 3. Find the previous tag dynamically:
- Run `git log --oneline $(git describe --tags --abbrev=0 HEAD)..HEAD` to list all commits - Run `git describe --tags --abbrev=0 HEAD` to find the most recent tag before HEAD
- If no previous tag exists (first release), note this and skip to step 5
- Report the previous tag to the user (e.g. "Previous tag found: v0.9.5")
4. Analyze changes since the previous tag:
- Run `git log --oneline <PREV_TAG>..HEAD` to list all commits between the previous tag and HEAD
- Read the changed files to understand context - Read the changed files to understand context
- Write a concise, well-structured release summary in English with: - Write a concise, well-structured release summary in English with:
- A one-line overview - A one-line overview
- Bullet points for each notable change (features, fixes, breaking changes) - Bullet points for each notable change (features, fixes, breaking changes)
- Keep it developer-friendly, no fluff - Keep it developer-friendly, no fluff
4. Create release commit with the summary as message: 5. Create release commit with the summary as message:
- Run `git commit --allow-empty -m "release v$ARGUMENTS\n\n<summary>"` - Run `git commit --allow-empty -m "release v$ARGUMENTS\n\n<summary>"`
- The commit message IS the release notes — the GitHub Action picks it up automatically - The commit message IS the release notes — the GitHub Action picks it up automatically
5. Create annotated tag on that commit: 6. Create annotated tag on that commit:
- Run `git tag -a v$ARGUMENTS -m "Release v$ARGUMENTS"` - Run `git tag -a v$ARGUMENTS -m "Release v$ARGUMENTS"`
6. Push commit and tag: 7. Push commit and tag:
- Run `git push origin main --tags` (or current branch) - Run `git push origin main --tags` (or current branch)
7. Confirm success with the version number 8. Confirm success with the version number
+26
View File
@@ -47,6 +47,32 @@ Download the ZIP for your platform from the [Releases](https://github.com/anomal
- **.NET Runtime 10** or later ([download](https://dotnet.microsoft.com/download/dotnet/10.0)) - **.NET Runtime 10** or later ([download](https://dotnet.microsoft.com/download/dotnet/10.0))
- **Hatari Emulator** (Windows native, or Linux/macOS via Wine or native build) — a Hatari configuration file (`MarcerGameDvd-Hatari.cfg`) is bundled with the launcher and used automatically when `Hatari.ConfigFile` is empty. - **Hatari Emulator** (Windows native, or Linux/macOS via Wine or native build) — a Hatari configuration file (`MarcerGameDvd-Hatari.cfg`) is bundled with the launcher and used automatically when `Hatari.ConfigFile` is empty.
- **TOS 1.04** ROM image (`TOS.IMG`) — required by Hatari to boot the Atari ST system.
### 🕹️ Setting Up Hatari
Before using the launcher, you need to install Hatari and provide a TOS ROM:
1. **Download Hatari** from the official site: [https://hatari.tuxfamily.org/download.html](https://hatari.tuxfamily.org/download.html)
- **Windows:** Download and extract the latest Windows binary.
- **Linux:** Install via your package manager (e.g. `sudo apt install hatari`) or build from source.
- **macOS:** Download the macOS build or install via Homebrew: `brew install hatari`.
2. **Get TOS 1.04** (also known as TOS UK or TOS 1.04):
- TOS is copyrighted Atari ROM software — you must own a legal copy.
- Place the ROM file as `TOS.IMG` in the same directory as the launcher executable (or the Hatari working directory).
- The bundled `MarcerGameDvd-Hatari.cfg` is preconfigured to look for `TOS.IMG` in the current directory.
3. **Configure the path** in `launcher.config.json`:
```json
"Hatari": {
"Executable": "C:\\Tools\\hatari\\hatari.exe",
"ConfigFile": "",
"ArgsTemplate": "-c \"{cfg}\" --disk-a \"{zip}\""
}
```
- Set `Hatari.Executable` to the full path of your `hatari` (or `hatari.exe`) binary.
- Leave `Hatari.ConfigFile` empty to use the bundled configuration automatically.
### ⚡ Quick Start ### ⚡ Quick Start
+8 -3
View File
@@ -13,7 +13,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
DEMO_DIR="$SCRIPT_DIR/.demo" DEMO_DIR="$SCRIPT_DIR/.demo"
ROOT_DIR="$DEMO_DIR/root" ROOT_DIR="$DEMO_DIR/root"
PATCH_DIR="$DEMO_DIR/patch" PATCH_DIR="$DEMO_DIR/patch"
CONFIG_FILE="$SCRIPT_DIR/launcher.config.json" CONFIG_FILE="$DEMO_DIR/launcher.config.json"
# --- Step 1: Build --- # --- Step 1: Build ---
echo "=== Building MarcerGameDvdLauncher ===" echo "=== Building MarcerGameDvdLauncher ==="
@@ -83,6 +83,11 @@ fake_zip "$ROOT_DIR/E/Racing/Pole Position.zip"
fake_zip "$ROOT_DIR/E/Racing/Out Run.zip" fake_zip "$ROOT_DIR/E/Racing/Out Run.zip"
fake_zip "$ROOT_DIR/E/Racing/Daytona USA.zip" fake_zip "$ROOT_DIR/E/Racing/Daytona USA.zip"
# === Folders G-Z: empty placeholder folders ===
for letter in {G..Z}; do
mkdir -p "$ROOT_DIR/$letter"
done
# --- PATCH layer (overlay/additions) --- # --- PATCH layer (overlay/additions) ---
echo "Creating patch layer..." echo "Creating patch layer..."
@@ -160,7 +165,7 @@ echo "=== Writing launcher.config.json ==="
HATARI_FAKE="$DEMO_DIR/hatari.exe" HATARI_FAKE="$DEMO_DIR/hatari.exe"
cat > "$HATARI_FAKE" <<'HATEXEC' cat > "$HATARI_FAKE" <<'HATEXEC'
#!/bin/bash #!/bin/bash
echo "[DEMO] Hatari would launch with: $@" echo "[DEMO] Hatari would launch with: $0 $@"
HATEXEC HATEXEC
chmod +x "$HATARI_FAKE" chmod +x "$HATARI_FAKE"
@@ -171,7 +176,7 @@ cat > "$CONFIG_FILE" <<EOF
"Hatari": { "Hatari": {
"Executable": "$HATARI_FAKE", "Executable": "$HATARI_FAKE",
"ConfigFile": "", "ConfigFile": "",
"ArgsTemplate": "{zip}" "ArgsTemplate": "-c \"{cfg}\" --disk-a \"{zip}\""
} }
} }
EOF EOF
+4 -10
View File
@@ -4,7 +4,7 @@
namespace MarcerGameDvdLauncher namespace MarcerGameDvdLauncher
{ {
// Manages loading, saving and querying favorite ZIP paths. // Manages loading, saving and querying favorite ZIP paths.
public class FavoritesService public class FavoritesService(string filePath)
{ {
// Virtual folder name displayed at the root when favorites exist. // Virtual folder name displayed at the root when favorites exist.
// Not configurable — fixed UI element. // Not configurable — fixed UI element.
@@ -14,15 +14,10 @@ namespace MarcerGameDvdLauncher
// Not configurable — fixed persistence file. // Not configurable — fixed persistence file.
public const string DefaultFileName = "favorites.txt"; public const string DefaultFileName = "favorites.txt";
private readonly string _filePath; private readonly string _filePath = filePath ?? throw new ArgumentNullException(nameof(filePath));
// Use a SortedSet so favorites are kept in sorted order in memory. // Use a SortedSet so favorites are kept in sorted order in memory.
private SortedSet<string> _favorites = new(StringComparer.OrdinalIgnoreCase); private SortedSet<string> _favorites = new(StringComparer.OrdinalIgnoreCase);
public FavoritesService(string filePath)
{
_filePath = filePath ?? throw new ArgumentNullException(nameof(filePath));
}
// Load favorites from disk (no-op if file missing) // Load favorites from disk (no-op if file missing)
public void Load() public void Load()
{ {
@@ -31,7 +26,7 @@ namespace MarcerGameDvdLauncher
var lines = File.ReadAllLines(_filePath, System.Text.Encoding.UTF8); var lines = File.ReadAllLines(_filePath, System.Text.Encoding.UTF8);
foreach (var l in lines) foreach (var l in lines)
{ {
var t = l?.Trim(); var t = l.Trim();
if (string.IsNullOrEmpty(t)) continue; if (string.IsNullOrEmpty(t)) continue;
try try
{ {
@@ -71,14 +66,13 @@ namespace MarcerGameDvdLauncher
if (string.IsNullOrWhiteSpace(path)) throw new ArgumentNullException(nameof(path)); if (string.IsNullOrWhiteSpace(path)) throw new ArgumentNullException(nameof(path));
var full = Path.GetFullPath(path); var full = Path.GetFullPath(path);
bool added; bool added;
if (_favorites.Contains(full)) if (!_favorites.Add(full))
{ {
_favorites.Remove(full); _favorites.Remove(full);
added = false; added = false;
} }
else else
{ {
_favorites.Add(full);
added = true; added = true;
} }
Save(); Save();
+9 -17
View File
@@ -8,7 +8,7 @@ namespace MarcerGameDvdLauncher
// The Hatari configuration file that is shipped with the launcher. // The Hatari configuration file that is shipped with the launcher.
// When Hatari.ConfigFile is not set in launcher.config.json, this file // When Hatari.ConfigFile is not set in launcher.config.json, this file
// (resolved relative to the executable directory) is used automatically. // (resolved relative to the executable directory) is used automatically.
public const string DefaultConfigFile = "MarcerGameDvd-Hatari.cfg"; private const string DEFAULT_CONFIG_FILE = "MarcerGameDvd-Hatari.cfg";
private readonly string _exePath; private readonly string _exePath;
private readonly string _cfgPath; private readonly string _cfgPath;
@@ -26,6 +26,8 @@ namespace MarcerGameDvdLauncher
// Validate argsTemplate // Validate argsTemplate
if (string.IsNullOrWhiteSpace(argsTemplate) || !argsTemplate.Contains("{zip}")) if (string.IsNullOrWhiteSpace(argsTemplate) || !argsTemplate.Contains("{zip}"))
throw new ArgumentException("Hatari.ArgsTemplate must contain the {zip} placeholder.", nameof(argsTemplate)); throw new ArgumentException("Hatari.ArgsTemplate must contain the {zip} placeholder.", nameof(argsTemplate));
if (string.IsNullOrWhiteSpace(argsTemplate) || !argsTemplate.Contains("{cfg}"))
throw new ArgumentException("Hatari.ArgsTemplate must contain the {cfg} placeholder.", nameof(argsTemplate));
_exePath = exePath; _exePath = exePath;
_cfgPath = cfgPath; _cfgPath = cfgPath;
@@ -42,17 +44,8 @@ namespace MarcerGameDvdLauncher
throw new ArgumentException("ZIP archive path must not be empty.", nameof(zipFilePath)); throw new ArgumentException("ZIP archive path must not be empty.", nameof(zipFilePath));
try try
{ {
// Build arguments: only include config section if cfgPath is not empty var args = _argsTemplate;
string args = _argsTemplate; args = args.Replace("{cfg}", !string.IsNullOrWhiteSpace(_cfgPath) ? _cfgPath : Path.Combine(Directory.GetCurrentDirectory(),DEFAULT_CONFIG_FILE));
if (!string.IsNullOrWhiteSpace(_cfgPath))
{
args = args.Replace("{cfg}", _cfgPath);
}
else
{
// Remove {cfg} placeholder entirely if config file is empty
args = args.Replace("{cfg}", string.Empty);
}
args = args.Replace("{zip}", zipFilePath); args = args.Replace("{zip}", zipFilePath);
var psi = new System.Diagnostics.ProcessStartInfo var psi = new System.Diagnostics.ProcessStartInfo
@@ -62,11 +55,10 @@ namespace MarcerGameDvdLauncher
UseShellExecute = false, UseShellExecute = false,
WorkingDirectory = Directory.GetCurrentDirectory() WorkingDirectory = Directory.GetCurrentDirectory()
}; };
System.Diagnostics.Process.Start(psi); var process = System.Diagnostics.Process.Start(psi);
// Show a modal indicating the emulator was started and wait until // Show a modal indicating the emulator is running and wait until
// the user releases the Return key before clearing the modal. This // the process has exited before clearing the modal.
// prevents accidental key repeats from triggering other actions. ProgramHelpers.ShowModalUntilProcessExited(process, "Hatari is running...");
ProgramHelpers.ShowModalUntilReturnReleased("Hatari started. Release Return to continue...");
} }
catch (Exception ex) catch (Exception ex)
{ {
+48 -68
View File
@@ -1,11 +1,6 @@
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de) // Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
// Licensed under the MIT License. See LICENSE file in project root for details. // Licensed under the MIT License. See LICENSE file in project root for details.
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
namespace MarcerGameDvdLauncher namespace MarcerGameDvdLauncher
{ {
/// <summary> /// <summary>
@@ -13,42 +8,25 @@ namespace MarcerGameDvdLauncher
/// Extracted from AppHost so that LauncherApp stays focused on lifecycle management. /// Extracted from AppHost so that LauncherApp stays focused on lifecycle management.
/// No functional change - all key-handling behaviour is preserved exactly. /// No functional change - all key-handling behaviour is preserved exactly.
/// </summary> /// </summary>
internal class InputController internal class InputController(
OverlayDirectoryBrowser directoryBrowser,
MenuRenderer menuRenderer,
NavigationController navigationController,
HatariLauncher hatariLauncher,
FavoritesService favoritesService,
UIErrorService errorService)
{ {
private readonly OverlayDirectoryBrowser _directoryBrowser;
private readonly MenuRenderer _menuRenderer;
private readonly NavigationController _navigationController;
private readonly HatariLauncher _hatariLauncher;
private readonly FavoritesService _favoritesService;
private readonly UIErrorService _errorService;
private List<GameEntry> _gameEntries = new(); private List<GameEntry> _gameEntries = new();
public IReadOnlyList<GameEntry> GameEntries => _gameEntries;
public InputController(OverlayDirectoryBrowser directoryBrowser, MenuRenderer menuRenderer,
NavigationController navigationController, HatariLauncher hatariLauncher,
FavoritesService favoritesService, UIErrorService errorService)
{
_directoryBrowser = directoryBrowser;
_menuRenderer = menuRenderer;
_navigationController = navigationController;
_hatariLauncher = hatariLauncher;
_favoritesService = favoritesService;
_errorService = errorService;
}
private bool IsFavorite(GameEntry e) private bool IsFavorite(GameEntry e)
{ {
return e.Kind == EntryKind.Zip return e.Kind == EntryKind.Zip && favoritesService.IsFavorite(e.InPatch ? e.PatchPath : e.RootPath);
? _favoritesService.IsFavorite(e.InPatch ? e.PatchPath : e.RootPath)
: false;
} }
private void DrawMenu(int availableLines) private void DrawMenu(int availableLines)
{ {
_menuRenderer.DrawMenu(_gameEntries, _navigationController.ScrollOffset, menuRenderer.DrawMenu(_gameEntries, navigationController.ScrollOffset,
_navigationController.SelectedIndex, availableLines, IsFavorite); navigationController.SelectedIndex, availableLines, IsFavorite);
} }
/// <summary> /// <summary>
@@ -56,7 +34,7 @@ namespace MarcerGameDvdLauncher
/// </summary> /// </summary>
public void RefreshView(int availableLines) public void RefreshView(int availableLines)
{ {
_navigationController.UpdateScrollOffset(_gameEntries.Count, availableLines); navigationController.UpdateScrollOffset(_gameEntries.Count, availableLines);
DrawMenu(availableLines); DrawMenu(availableLines);
} }
@@ -68,9 +46,9 @@ namespace MarcerGameDvdLauncher
try try
{ {
// If we are at the virtual Favorites folder, produce the flat list from the favorites service // If we are at the virtual Favorites folder, produce the flat list from the favorites service
if (string.Equals(_navigationController.CurrentRelativePath, FavoritesService.FavoritesRootName, StringComparison.OrdinalIgnoreCase)) if (string.Equals(navigationController.CurrentRelativePath, FavoritesService.FavoritesRootName, StringComparison.OrdinalIgnoreCase))
{ {
var favs = _favoritesService.GetAll(); var favs = favoritesService.GetAll();
_gameEntries = new List<GameEntry>(); _gameEntries = new List<GameEntry>();
foreach (var p in favs) foreach (var p in favs)
{ {
@@ -85,15 +63,15 @@ namespace MarcerGameDvdLauncher
IsVirtual = false IsVirtual = false
}); });
} }
_navigationController.SetEntriesCount(_gameEntries.Count); navigationController.SetEntriesCount(_gameEntries.Count);
return; return;
} }
// Otherwise use the overlay directory browser for normal folders // Otherwise use the overlay directory browser for normal folders
_gameEntries = _directoryBrowser.GetEntries(_navigationController.CurrentRelativePath); _gameEntries = directoryBrowser.GetEntries(navigationController.CurrentRelativePath);
// If we are at the root and there are favorites, prepend a virtual Favorites folder // If we are at the root and there are favorites, prepend a virtual Favorites folder
if (string.IsNullOrEmpty(_navigationController.CurrentRelativePath) && _favoritesService.HasFavorites()) if (string.IsNullOrEmpty(navigationController.CurrentRelativePath) && favoritesService.HasFavorites())
{ {
var virtualEntry = new GameEntry var virtualEntry = new GameEntry
{ {
@@ -108,14 +86,14 @@ namespace MarcerGameDvdLauncher
_gameEntries.Insert(0, virtualEntry); _gameEntries.Insert(0, virtualEntry);
} }
_navigationController.SetEntriesCount(_gameEntries.Count); navigationController.SetEntriesCount(_gameEntries.Count);
} }
catch (Exception ex) catch (Exception ex)
{ {
// Show the error to the user and continue with an empty list // Show the error to the user and continue with an empty list
_errorService.ShowError(ex.Message); errorService.ShowError(ex.Message);
_gameEntries = new List<GameEntry>(); _gameEntries = new List<GameEntry>();
_navigationController.SetEntriesCount(0); navigationController.SetEntriesCount(0);
} }
} }
@@ -126,9 +104,9 @@ namespace MarcerGameDvdLauncher
{ {
if (key.KeyChar == '?') if (key.KeyChar == '?')
{ {
_menuRenderer.ShowHelpBox(availableLines); menuRenderer.ShowHelpBox(availableLines);
Console.ReadKey(intercept: true); Console.ReadKey(intercept: true);
_menuRenderer.InvalidateCache(); menuRenderer.InvalidateCache();
DrawMenu(availableLines); DrawMenu(availableLines);
ProgramHelpers.FlushInputBuffer(); ProgramHelpers.FlushInputBuffer();
return false; return false;
@@ -138,16 +116,16 @@ namespace MarcerGameDvdLauncher
{ {
case ConsoleKey.UpArrow: case ConsoleKey.UpArrow:
{ {
int previousSelectedIndex = _navigationController.SelectedIndex; int previousSelectedIndex = navigationController.SelectedIndex;
bool didScroll = _navigationController.MoveUp(_gameEntries, availableLines); bool didScroll = navigationController.MoveUp(_gameEntries, availableLines);
if (didScroll) if (didScroll)
{ {
DrawMenu(availableLines); DrawMenu(availableLines);
} }
else else
{ {
_menuRenderer.RedrawEntry(_gameEntries, previousSelectedIndex, previousSelectedIndex - _navigationController.ScrollOffset, false, availableLines, IsFavorite); menuRenderer.RedrawEntry(_gameEntries, previousSelectedIndex, previousSelectedIndex - navigationController.ScrollOffset, false, availableLines, IsFavorite);
_menuRenderer.RedrawEntry(_gameEntries, _navigationController.SelectedIndex, _navigationController.SelectedIndex - _navigationController.ScrollOffset, true, availableLines, IsFavorite); menuRenderer.RedrawEntry(_gameEntries, navigationController.SelectedIndex, navigationController.SelectedIndex - navigationController.ScrollOffset, true, availableLines, IsFavorite);
} }
// flush input to avoid key repeat // flush input to avoid key repeat
ProgramHelpers.FlushInputBuffer(); ProgramHelpers.FlushInputBuffer();
@@ -155,16 +133,16 @@ namespace MarcerGameDvdLauncher
break; break;
case ConsoleKey.DownArrow: case ConsoleKey.DownArrow:
{ {
int previousSelectedIndex = _navigationController.SelectedIndex; int previousSelectedIndex = navigationController.SelectedIndex;
bool didScroll = _navigationController.MoveDown(_gameEntries, availableLines); bool didScroll = navigationController.MoveDown(_gameEntries, availableLines);
if (didScroll) if (didScroll)
{ {
DrawMenu(availableLines); DrawMenu(availableLines);
} }
else else
{ {
_menuRenderer.RedrawEntry(_gameEntries, previousSelectedIndex, previousSelectedIndex - _navigationController.ScrollOffset, false, availableLines, IsFavorite); menuRenderer.RedrawEntry(_gameEntries, previousSelectedIndex, previousSelectedIndex - navigationController.ScrollOffset, false, availableLines, IsFavorite);
_menuRenderer.RedrawEntry(_gameEntries, _navigationController.SelectedIndex, _navigationController.SelectedIndex - _navigationController.ScrollOffset, true, availableLines, IsFavorite); menuRenderer.RedrawEntry(_gameEntries, navigationController.SelectedIndex, navigationController.SelectedIndex - navigationController.ScrollOffset, true, availableLines, IsFavorite);
} }
ProgramHelpers.FlushInputBuffer(); ProgramHelpers.FlushInputBuffer();
} }
@@ -172,27 +150,29 @@ namespace MarcerGameDvdLauncher
case ConsoleKey.Enter: case ConsoleKey.Enter:
case ConsoleKey.RightArrow: case ConsoleKey.RightArrow:
{ {
var oldRelativePath = _navigationController.CurrentRelativePath; var oldRelativePath = navigationController.CurrentRelativePath;
var isDirectory = _gameEntries.Count > 0 && _gameEntries[_navigationController.SelectedIndex].Kind == EntryKind.Directory; var isDirectory = _gameEntries.Count > 0 && _gameEntries[navigationController.SelectedIndex].Kind == EntryKind.Directory;
_navigationController.HandleEnter(_gameEntries); navigationController.HandleEnter(_gameEntries);
if (isDirectory && oldRelativePath != _navigationController.CurrentRelativePath) if (isDirectory && oldRelativePath != navigationController.CurrentRelativePath)
{ {
ReloadGameEntries(); ReloadGameEntries();
_navigationController.UpdateScrollOffset(_gameEntries.Count, availableLines); navigationController.UpdateScrollOffset(_gameEntries.Count, availableLines);
} }
DrawMenu(availableLines); DrawMenu(availableLines);
// Only start a ZIP if NOT switching to a directory // Only start a ZIP if NOT switching to a directory
if (!isDirectory && _gameEntries.Count > 0 && _gameEntries[_navigationController.SelectedIndex].Kind == EntryKind.Zip) if (!isDirectory && _gameEntries.Count > 0 && _gameEntries[navigationController.SelectedIndex].Kind == EntryKind.Zip)
{ {
string zipToLaunch = _gameEntries[_navigationController.SelectedIndex].InPatch ? _gameEntries[_navigationController.SelectedIndex].PatchPath : _gameEntries[_navigationController.SelectedIndex].RootPath; string zipToLaunch = _gameEntries[navigationController.SelectedIndex].InPatch ? _gameEntries[navigationController.SelectedIndex].PatchPath : _gameEntries[navigationController.SelectedIndex].RootPath;
try try
{ {
_hatariLauncher.Launch(zipToLaunch); hatariLauncher.Launch(zipToLaunch);
} }
catch (Exception ex) catch (Exception ex)
{ {
_errorService.ShowError(ex.Message); errorService.ShowError(ex.Message);
} }
// Redraw menu after Hatari has exited
DrawMenu(availableLines);
} }
// flush input to avoid leftover key events after an enter/navigation // flush input to avoid leftover key events after an enter/navigation
ProgramHelpers.FlushInputBuffer(); ProgramHelpers.FlushInputBuffer();
@@ -201,23 +181,23 @@ namespace MarcerGameDvdLauncher
case ConsoleKey.Backspace: case ConsoleKey.Backspace:
case ConsoleKey.LeftArrow: case ConsoleKey.LeftArrow:
{ {
_navigationController.GoUpDirectory(); navigationController.GoUpDirectory();
ReloadGameEntries(); ReloadGameEntries();
_navigationController.UpdateScrollOffset(_gameEntries.Count, availableLines); navigationController.UpdateScrollOffset(_gameEntries.Count, availableLines);
DrawMenu(availableLines); DrawMenu(availableLines);
ProgramHelpers.FlushInputBuffer(); ProgramHelpers.FlushInputBuffer();
} }
break; break;
case ConsoleKey.PageDown: case ConsoleKey.PageDown:
{ {
_navigationController.PageDown(_gameEntries, availableLines); navigationController.PageDown(_gameEntries, availableLines);
DrawMenu(availableLines); DrawMenu(availableLines);
ProgramHelpers.FlushInputBuffer(); ProgramHelpers.FlushInputBuffer();
} }
break; break;
case ConsoleKey.PageUp: case ConsoleKey.PageUp:
{ {
_navigationController.PageUp(_gameEntries, availableLines); navigationController.PageUp(_gameEntries, availableLines);
DrawMenu(availableLines); DrawMenu(availableLines);
ProgramHelpers.FlushInputBuffer(); ProgramHelpers.FlushInputBuffer();
} }
@@ -226,17 +206,17 @@ namespace MarcerGameDvdLauncher
case ConsoleKey.Oem8: case ConsoleKey.Oem8:
{ {
// Toggle favorite for selected ZIP (handles numpad * and some layouts) // Toggle favorite for selected ZIP (handles numpad * and some layouts)
if (_gameEntries.Count > 0 && _gameEntries[_navigationController.SelectedIndex].Kind == EntryKind.Zip) if (_gameEntries.Count > 0 && _gameEntries[navigationController.SelectedIndex].Kind == EntryKind.Zip)
{ {
var ge = _gameEntries[_navigationController.SelectedIndex]; var ge = _gameEntries[navigationController.SelectedIndex];
string path = ge.InPatch ? ge.PatchPath : ge.RootPath; string path = ge.InPatch ? ge.PatchPath : ge.RootPath;
try try
{ {
_favoritesService.Toggle(path); favoritesService.Toggle(path);
} }
catch (Exception ex) catch (Exception ex)
{ {
_errorService.ShowError("Failed to toggle favorite: " + ex.Message); errorService.ShowError("Failed to toggle favorite: " + ex.Message);
} }
// Redraw the whole menu so the '*' marker updates immediately // Redraw the whole menu so the '*' marker updates immediately
DrawMenu(availableLines); DrawMenu(availableLines);
+3 -8
View File
@@ -63,16 +63,11 @@ namespace MarcerGameDvdLauncher
if (!File.Exists(cfg.Hatari.Executable)) if (!File.Exists(cfg.Hatari.Executable))
throw new InvalidOperationException($"Hatari executable not found: {cfg.Hatari.Executable}"); throw new InvalidOperationException($"Hatari executable not found: {cfg.Hatari.Executable}");
// If no Hatari config file was specified, fall back to the bundled
// MarcerGameDvd-Hatari.cfg that ships with the launcher.
if (string.IsNullOrWhiteSpace(cfg.Hatari.ConfigFile))
{
cfg.Hatari.ConfigFile = Path.Combine(exeDir, HatariLauncher.DefaultConfigFile);
}
// Validate the Hatari config file (either user-specified or bundled fallback) // Validate the Hatari config file (either user-specified or bundled fallback)
if (!File.Exists(cfg.Hatari.ConfigFile)) if (!string.IsNullOrEmpty(cfg.Hatari.ConfigFile) && !File.Exists(cfg.Hatari.ConfigFile))
throw new InvalidOperationException($"Hatari configuration file not found: {cfg.Hatari.ConfigFile}"); throw new InvalidOperationException($"Hatari configuration file not found: {cfg.Hatari.ConfigFile}");
if (string.IsNullOrWhiteSpace(cfg.Hatari.ArgsTemplate) || !cfg.Hatari.ArgsTemplate.Contains("{cfg}"))
throw new InvalidOperationException("Hatari.ArgsTemplate must contain the {cfg} placeholder.");
if (string.IsNullOrWhiteSpace(cfg.Hatari.ArgsTemplate) || !cfg.Hatari.ArgsTemplate.Contains("{zip}")) if (string.IsNullOrWhiteSpace(cfg.Hatari.ArgsTemplate) || !cfg.Hatari.ArgsTemplate.Contains("{zip}"))
throw new InvalidOperationException("Hatari.ArgsTemplate must contain the {zip} placeholder."); throw new InvalidOperationException("Hatari.ArgsTemplate must contain the {zip} placeholder.");
@@ -11,12 +11,14 @@ bConsoleWindow = FALSE
nNumberBase = 10 nNumberBase = 10
nSymbolLines = -1 nSymbolLines = -1
nMemdumpLines = -1 nMemdumpLines = -1
nFindLines = -1
nDisasmLines = -1 nDisasmLines = -1
nBacktraceLines = 0 nBacktraceLines = 0
nExceptionDebugMask = 1073741830 nExceptionDebugMask = 1073741830
nDisasmOptions = 7 nDisasmOptions = 7
nMemConvLocale = FALSE
bDisasmUAE = TRUE bDisasmUAE = TRUE
bSymbolsAutoLoad = TRUE nSymbolsAutoLoad = 1
bMatchAllSymbols = FALSE bMatchAllSymbols = FALSE
[Screen] [Screen]
@@ -42,13 +44,14 @@ nMaxHeight = 1024
nZoomFactor = 3 nZoomFactor = 3
bUseSdlRenderer = FALSE bUseSdlRenderer = FALSE
ScreenShotFormat = 2 ScreenShotFormat = 2
szScreenShotDir =
bUseVsync = FALSE bUseVsync = FALSE
[Joystick0] [Joystick0]
nJoystickMode = 0 nJoystickMode = 0
bEnableAutoFire = FALSE bEnableAutoFire = FALSE
bEnableJumpOnFire2 = TRUE bEnableJumpOnFire2 = TRUE
nJoyId = -1 nJoyId = 0
nJoyBut1Index = 0 nJoyBut1Index = 0
nJoyBut2Index = 1 nJoyBut2Index = 1
nJoyBut3Index = 2 nJoyBut3Index = 2
@@ -76,7 +79,7 @@ kFire = Right Ctrl
nJoystickMode = 0 nJoystickMode = 0
bEnableAutoFire = FALSE bEnableAutoFire = FALSE
bEnableJumpOnFire2 = TRUE bEnableJumpOnFire2 = TRUE
nJoyId = -1 nJoyId = 0
nJoyBut1Index = 0 nJoyBut1Index = 0
nJoyBut2Index = 1 nJoyBut2Index = 1
nJoyBut3Index = 2 nJoyBut3Index = 2
@@ -106,7 +109,7 @@ kButton9 = 9
nJoystickMode = 0 nJoystickMode = 0
bEnableAutoFire = FALSE bEnableAutoFire = FALSE
bEnableJumpOnFire2 = TRUE bEnableJumpOnFire2 = TRUE
nJoyId = -1 nJoyId = 0
nJoyBut1Index = 0 nJoyBut1Index = 0
nJoyBut2Index = 1 nJoyBut2Index = 1
nJoyBut3Index = 2 nJoyBut3Index = 2
@@ -136,7 +139,7 @@ kButton9 =
nJoystickMode = 0 nJoystickMode = 0
bEnableAutoFire = FALSE bEnableAutoFire = FALSE
bEnableJumpOnFire2 = TRUE bEnableJumpOnFire2 = TRUE
nJoyId = -1 nJoyId = 0
nJoyBut1Index = 0 nJoyBut1Index = 0
nJoyBut2Index = 1 nJoyBut2Index = 1
nJoyBut3Index = 2 nJoyBut3Index = 2
@@ -150,7 +153,7 @@ kFire = Right Ctrl
nJoystickMode = 0 nJoystickMode = 0
bEnableAutoFire = FALSE bEnableAutoFire = FALSE
bEnableJumpOnFire2 = TRUE bEnableJumpOnFire2 = TRUE
nJoyId = -1 nJoyId = 0
nJoyBut1Index = 0 nJoyBut1Index = 0
nJoyBut2Index = 1 nJoyBut2Index = 1
nJoyBut3Index = 2 nJoyBut3Index = 2
@@ -161,7 +164,7 @@ kRight = Right
kFire = Right Ctrl kFire = Right Ctrl
[Keyboard] [Keyboard]
bDisableKeyRepeat = FALSE bFastForwardKeyRepeat = TRUE
nKeymapType = 0 nKeymapType = 0
nCountryCode = -1 nCountryCode = -1
nKbdLayout = -1 nKbdLayout = -1
@@ -252,13 +255,17 @@ szDiskImageDirectory =
nGemdosDrive = 0 nGemdosDrive = 0
bBootFromHardDisk = FALSE bBootFromHardDisk = FALSE
bUseHardDiskDirectory = FALSE bUseHardDiskDirectory = FALSE
szHardDiskDirectory = R:\ szHardDiskDirectory = R:
nGemdosCase = 0 nGemdosCase = 0
nWriteProtection = 0 nWriteProtection = 0
bFilenameConversion = FALSE bFilenameConversion = FALSE
bGemdosHostTime = FALSE bGemdosHostTime = FALSE
[ACSI] [ACSI]
bUseDevice0 = FALSE
sDeviceFile0 =
nBlockSize0 = 512
nAcsiVersion0 = 1
[SCSI] [SCSI]
@@ -266,6 +273,7 @@ bGemdosHostTime = FALSE
[IDE] [IDE]
[ROM] [ROM]
szTosImageFileName = TOS.IMG szTosImageFileName = TOS.IMG
bPatchTos = TRUE bPatchTos = TRUE
@@ -312,13 +320,13 @@ bCompatibleCpu = TRUE
nModelType = 0 nModelType = 0
bBlitter = FALSE bBlitter = FALSE
nDSPType = 0 nDSPType = 0
nVMEType = 1
nRtcYear = 0 nRtcYear = 0
bPatchTimerD = FALSE bPatchTimerD = FALSE
bFastBoot = FALSE bFastBoot = FALSE
bFastForward = FALSE bFastForward = FALSE
bAddressSpace24 = TRUE bAddressSpace24 = TRUE
bCycleExactCpu = TRUE bCycleExactCpu = TRUE
bCpuDataCache = TRUE
n_FPUType = 0 n_FPUType = 0
bSoftFloatFPU = FALSE bSoftFloatFPU = FALSE
bMMU = TRUE bMMU = TRUE
@@ -328,3 +336,4 @@ VideoTiming = 3
AviRecordVcodec = 2 AviRecordVcodec = 2
AviRecordFps = 0 AviRecordFps = 0
AviRecordFile = AviRecordFile =
+26 -44
View File
@@ -3,7 +3,7 @@
namespace MarcerGameDvdLauncher namespace MarcerGameDvdLauncher
{ {
public class MenuRenderer public class MenuRenderer(AppColorConfig? colors = null)
{ {
// Simple line-based double buffer to avoid full Clear() flicker. // Simple line-based double buffer to avoid full Clear() flicker.
// cachedBuffer holds the last rendered text and colors for each visible row. // cachedBuffer holds the last rendered text and colors for each visible row.
@@ -12,12 +12,7 @@ namespace MarcerGameDvdLauncher
private int _cachedWidth = -1; private int _cachedWidth = -1;
// Color configuration (injected; defaults to built-in scheme if null) // Color configuration (injected; defaults to built-in scheme if null)
private readonly AppColorConfig _colors; private readonly AppColorConfig _colors = colors ?? new AppColorConfig();
public MenuRenderer(AppColorConfig? colors = null)
{
_colors = colors ?? new AppColorConfig();
}
// availableLines is provided per-draw so the renderer adapts to console resizes // availableLines is provided per-draw so the renderer adapts to console resizes
public void DrawMenu(List<GameEntry> entries, int scrollOffset, int selectedIndex, int availableLines, Func<GameEntry, bool>? isFavorite = null) public void DrawMenu(List<GameEntry> entries, int scrollOffset, int selectedIndex, int availableLines, Func<GameEntry, bool>? isFavorite = null)
@@ -140,54 +135,41 @@ namespace MarcerGameDvdLauncher
// Renders a centered, bordered help box with key bindings inside the // Renders a centered, bordered help box with key bindings inside the
// available console area. The caller is responsible for waiting on a // available console area. The caller is responsible for waiting on a
// key and redrawing the menu afterwards. // key and redrawing the menu afterward.
public void ShowHelpBox(int availableLines) public void ShowHelpBox(int availableLines)
{ {
try var width = Console.WindowWidth;
{ var helpLines = GetHelpLines();
int width = Console.WindowWidth; var boxHeight = Math.Min(helpLines.Length + 2, Math.Max(3, availableLines));
string[] helpLines = GetHelpLines(); var boxWidth = Math.Max(1, width);
int boxHeight = Math.Min(helpLines.Length + 2, Math.Max(3, availableLines)); var topRow = Math.Max(0, (availableLines - boxHeight) / 2);
int boxWidth = Math.Max(1, width);
int topRow = Math.Max(0, (availableLines - boxHeight) / 2);
Console.BackgroundColor = ConsoleColor.DarkGray; Console.BackgroundColor = ConsoleColor.DarkGray;
Console.ForegroundColor = ConsoleColor.White; Console.ForegroundColor = ConsoleColor.White;
string topBorder = "+" + new string('-', Math.Max(0, boxWidth - 2)) + "+"; var topBorder = "+" + new string('-', Math.Max(0, boxWidth - 2)) + "+";
Console.SetCursorPosition(0, topRow); Console.SetCursorPosition(0, topRow);
Console.Write(topBorder); Console.Write(topBorder);
for (int i = 0; i < boxHeight - 2; i++) for (var i = 0; i < boxHeight - 2; i++)
{ {
int row = topRow + 1 + i; var row = topRow + 1 + i;
string content; var content = i < helpLines.Length
if (i < helpLines.Length) ? PadToWidth(helpLines[i], boxWidth - 2)
{ : new string(' ', Math.Max(0, boxWidth - 2));
content = PadToWidth(helpLines[i], boxWidth - 2);
}
else
{
content = new string(' ', Math.Max(0, boxWidth - 2));
}
Console.SetCursorPosition(0, row); Console.SetCursorPosition(0, row);
Console.Write("|" + content + "|"); Console.Write("|" + content + "|");
} }
int bottomRow = topRow + boxHeight - 1; var bottomRow = topRow + boxHeight - 1;
if (bottomRow < Console.WindowHeight) if (bottomRow < Console.WindowHeight)
{ {
string bottomBorder = "+" + new string('-', Math.Max(0, boxWidth - 2)) + "+"; var bottomBorder = "+" + new string('-', Math.Max(0, boxWidth - 2)) + "+";
Console.SetCursorPosition(0, bottomRow); Console.SetCursorPosition(0, bottomRow);
Console.Write(bottomBorder); Console.Write(bottomBorder);
} }
Console.ResetColor(); Console.ResetColor();
} }
catch
{
}
}
private static string[] GetHelpLines() private static string[] GetHelpLines()
{ {
@@ -235,7 +217,7 @@ namespace MarcerGameDvdLauncher
{ {
if (width <= 0) return string.Empty; if (width <= 0) return string.Empty;
string label = GetLabel(e, isFavorite); var label = GetLabel(e, isFavorite);
// If the console width is smaller than the label, truncate the label // If the console width is smaller than the label, truncate the label
if (width <= label.Length) if (width <= label.Length)
@@ -243,7 +225,7 @@ namespace MarcerGameDvdLauncher
return label.Substring(0, width); return label.Substring(0, width);
} }
int maxNameLen = width - label.Length; // space left for name var maxNameLen = width - label.Length; // space left for name
string displayName; string displayName;
if (e.Name.Length <= maxNameLen) if (e.Name.Length <= maxNameLen)
{ {
@@ -257,7 +239,7 @@ namespace MarcerGameDvdLauncher
displayName = e.Name.Substring(0, Math.Max(0, maxNameLen)); displayName = e.Name.Substring(0, Math.Max(0, maxNameLen));
} }
int padding = Math.Max(0, width - label.Length - displayName.Length); var padding = Math.Max(0, width - label.Length - displayName.Length);
var result = label + displayName + new string(' ', padding); var result = label + displayName + new string(' ', padding);
// Ensure exact width (defensive): truncate or pad if needed // Ensure exact width (defensive): truncate or pad if needed
if (result.Length > width) return result.Substring(0, width); if (result.Length > width) return result.Substring(0, width);
@@ -273,7 +255,7 @@ namespace MarcerGameDvdLauncher
{ {
// Layer label (7 chars) // Layer label (7 chars)
string layer; string layer;
if (e.InRoot && e.InPatch) layer = "[BOTH] "; if (e is {InRoot: true, InPatch: true}) layer = "[BOTH] ";
else if (e.InPatch) layer = "[PTCH] "; else if (e.InPatch) layer = "[PTCH] ";
else if (e.InRoot) layer = "[ROOT] "; else if (e.InRoot) layer = "[ROOT] ";
else layer = " "; else layer = " ";
@@ -299,15 +281,15 @@ namespace MarcerGameDvdLauncher
if (e.Kind == EntryKind.Directory) if (e.Kind == EntryKind.Directory)
{ {
if (e.InRoot && e.InPatch) return _colors.FolderBoth; // Both layers if (e is {InRoot: true, InPatch: true}) return _colors.FolderBoth; // Both layers
if (e.InPatch && !e.InRoot) return _colors.FolderPatchOnly; // Only patch if (e is {InPatch: true, InRoot: false}) return _colors.FolderPatchOnly; // Only patch
if (e.InRoot && !e.InPatch) return _colors.FolderRootOnly; // Only root if (e is {InRoot: true, InPatch: false}) return _colors.FolderRootOnly; // Only root
} }
else if (e.Kind == EntryKind.Zip) else if (e.Kind == EntryKind.Zip)
{ {
if (e.InRoot && e.InPatch) return _colors.ZipBoth; // Both layers if (e is {InRoot: true, InPatch: true}) return _colors.ZipBoth; // Both layers
if (e.InRoot && !e.InPatch) return _colors.ZipRootOnly; // Only root if (e is { InRoot: true, InPatch: false }) return _colors.ZipRootOnly; // Only root
if (e.InPatch && !e.InRoot) return _colors.ZipPatchOnly; // Only patch if (e is {InPatch: true, InRoot: false}) return _colors.ZipPatchOnly; // Only patch
} }
return ConsoleColor.DarkGray; return ConsoleColor.DarkGray;
} }
@@ -8,11 +8,11 @@ namespace MarcerGameDvdLauncher
// Scroll fractions: when the selection cursor reaches 2/3 of the visible window // Scroll fractions: when the selection cursor reaches 2/3 of the visible window
// height from the top, the list scrolls down; when it reaches 1/3, it scrolls up. // height from the top, the list scrolls down; when it reaches 1/3, it scrolls up.
// Not configurable — these ratios are the established navigation behaviour. // Not configurable — these ratios are the established navigation behaviour.
private const double BottomScrollFraction = 2.0 / 3.0; private const double BOTTOM_SCROLL_FRACTION = 2.0 / 3.0;
private const double TopScrollFraction = 1.0 / 3.0; private const double TOP_SCROLL_FRACTION = 1.0 / 3.0;
public int SelectedIndex { get; private set; } = 0; public int SelectedIndex { get; private set; }
public int ScrollOffset { get; private set; } = 0; public int ScrollOffset { get; private set; }
public string CurrentRelativePath { get; private set; } = ""; public string CurrentRelativePath { get; private set; } = "";
// Stores the last selection & scroll position for each directory // Stores the last selection & scroll position for each directory
private readonly Dictionary<string, (int sel, int scroll)> _lastSelections = new(); private readonly Dictionary<string, (int sel, int scroll)> _lastSelections = new();
@@ -105,12 +105,12 @@ namespace MarcerGameDvdLauncher
if (availableLines < 1) availableLines = 1; if (availableLines < 1) availableLines = 1;
if (entryCount <= availableLines) { ScrollOffset = 0; return; } if (entryCount <= availableLines) { ScrollOffset = 0; return; }
if (SelectedIndex == 0) { ScrollOffset = 0; return; } if (SelectedIndex == 0) { ScrollOffset = 0; return; }
int bottomScrollTrigger = ScrollOffset + (int)(availableLines * BottomScrollFraction); int bottomScrollTrigger = ScrollOffset + (int)(availableLines * BOTTOM_SCROLL_FRACTION);
int topScrollTrigger = ScrollOffset + (int)(availableLines * TopScrollFraction); int topScrollTrigger = ScrollOffset + (int)(availableLines * TOP_SCROLL_FRACTION);
if (SelectedIndex >= bottomScrollTrigger && (ScrollOffset + availableLines) < entryCount) if (SelectedIndex >= bottomScrollTrigger && (ScrollOffset + availableLines) < entryCount)
ScrollOffset = SelectedIndex - (int)(availableLines * BottomScrollFraction); ScrollOffset = SelectedIndex - (int)(availableLines * BOTTOM_SCROLL_FRACTION);
else if (SelectedIndex < topScrollTrigger && ScrollOffset > 0) else if (SelectedIndex < topScrollTrigger && ScrollOffset > 0)
ScrollOffset = SelectedIndex - (int)(availableLines * TopScrollFraction); ScrollOffset = SelectedIndex - (int)(availableLines * TOP_SCROLL_FRACTION);
if (ScrollOffset < 0) ScrollOffset = 0; if (ScrollOffset < 0) ScrollOffset = 0;
if (ScrollOffset > entryCount - availableLines) if (ScrollOffset > entryCount - availableLines)
ScrollOffset = entryCount - availableLines; ScrollOffset = entryCount - availableLines;
@@ -19,16 +19,16 @@ namespace MarcerGameDvdLauncher
public class OverlayDirectoryBrowser(string root, string patch) public class OverlayDirectoryBrowser(string root, string patch)
{ {
public List<GameEntry> GetEntries(string currentRelativePath) public List<GameEntry> GetEntries(string? currentRelativePath)
{ {
try try
{ {
// Normalize and protect against path traversal or absolute paths in the relative path // Normalize and protect against path traversal or absolute paths in the relative path
string rel = currentRelativePath ?? string.Empty; var rel = currentRelativePath ?? string.Empty;
// Remove any leading directory separators // Remove any leading directory separators
rel = rel.TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); rel = rel.TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
// If the relative path is rooted or contains parent directory segments, reset to root // If the relative path is rooted or contains parent directory segments, reset to root
if (Path.IsPathRooted(rel) || rel.Split(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }, StringSplitOptions.RemoveEmptyEntries).Any(p => p == "..")) if (Path.IsPathRooted(rel) || rel.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries).Any(p => p == ".."))
{ {
rel = string.Empty; rel = string.Empty;
} }
+2 -2
View File
@@ -6,11 +6,11 @@ namespace MarcerGameDvdLauncher
class Program class Program
{ {
// Console window title. Not configurable — fixed application display name. // Console window title. Not configurable — fixed application display name.
private const string DefaultTitle = "Marcer GameDVD Launcher"; private const string DEFAULT_TITLE = "Marcer GameDVD Launcher";
static void Main(string[] args) static void Main(string[] args)
{ {
Console.Title = DefaultTitle; Console.Title = DEFAULT_TITLE;
var app = new LauncherApp(); var app = new LauncherApp();
app.Run(); app.Run();
} }
+20 -22
View File
@@ -1,7 +1,6 @@
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de) // Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
// Licensed under the MIT License. See LICENSE file in project root for details. // Licensed under the MIT License. See LICENSE file in project root for details.
using System;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
namespace MarcerGameDvdLauncher namespace MarcerGameDvdLauncher
@@ -40,11 +39,11 @@ namespace MarcerGameDvdLauncher
// These are defined by the Windows API and do not change. // These are defined by the Windows API and do not change.
private const int STD_INPUT_HANDLE = -10; private const int STD_INPUT_HANDLE = -10;
[System.Runtime.InteropServices.DllImport("kernel32.dll")] [DllImport("kernel32.dll")]
private static extern IntPtr GetStdHandle(int nStdHandle); private static extern IntPtr GetStdHandle(int nStdHandle);
[System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)] [DllImport("kernel32.dll", SetLastError = true)]
[return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)] [return: MarshalAs(UnmanagedType.Bool)]
private static extern bool FlushConsoleInputBuffer(IntPtr hConsoleInput); private static extern bool FlushConsoleInputBuffer(IntPtr hConsoleInput);
// P/Invoke to query key state (used to detect physical key release) // P/Invoke to query key state (used to detect physical key release)
@@ -54,37 +53,36 @@ namespace MarcerGameDvdLauncher
[DllImport("user32.dll")] [DllImport("user32.dll")]
private static extern short GetAsyncKeyState(int vKey); private static extern short GetAsyncKeyState(int vKey);
// Shows a simple modal message on the reserved last console line and // Shows a simple modal message in the center of the console and
// blocks until the Return key is physically released. The message is // blocks until the given process has exited. The message is then
// then removed and the method returns. This is designed to be a // removed and the method returns.
// lightweight modal for the console UI and uses the last console line public static void ShowModalUntilProcessExited(System.Diagnostics.Process? process, string? message)
// which the application reserves for transient messages.
public static void ShowModalUntilReturnReleased(string message)
{ {
try try
{ {
int lastRow = AvailableLines;
int width = Console.WindowWidth; int width = Console.WindowWidth;
string text = message ?? string.Empty; int height = Console.WindowHeight;
if (text.Length > width) text = text.Substring(0, Math.Max(0, width - 3)) + "..."; int centerRow = height / 2;
int padding = Math.Max(0, width - text.Length);
string line = text + new string(' ', padding); var text = message ?? string.Empty;
if (text.Length > width - 4) text = text.Substring(0, Math.Max(0, width - 7)) + "...";
int leftPad = Math.Max(0, (width - text.Length) / 2);
var line = new string(' ', leftPad) + text;
Console.BackgroundColor = ConsoleColor.DarkGray; Console.BackgroundColor = ConsoleColor.DarkGray;
Console.ForegroundColor = ConsoleColor.White; Console.ForegroundColor = ConsoleColor.White;
try { Console.SetCursorPosition(0, lastRow); } catch { } try { Console.SetCursorPosition(0, centerRow); } catch { }
try { Console.Write(line); } catch { } try { Console.Write(line.PadRight(width)); } catch { }
Console.ResetColor(); Console.ResetColor();
// Wait until Return key is not pressed // Wait until the external process has exited
// GetAsyncKeyState returns a short where the high-order bit is set when key is down while (process != null && !process.HasExited)
while ((GetAsyncKeyState(VK_RETURN) & 0x8000) != 0)
{ {
Thread.Sleep(10); Thread.Sleep(100);
} }
// Clear the line // Clear the line
try { Console.SetCursorPosition(0, lastRow); } catch { } try { Console.SetCursorPosition(0, centerRow); } catch { }
try { Console.Write(new string(' ', width)); } catch { } try { Console.Write(new string(' ', width)); } catch { }
} }
catch catch