From 4153d51c4f4ba6a4a54f8658a193e59c39b05e25 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:37:35 +0000 Subject: [PATCH 1/4] Fix external window resolution, taskbar minimize; all 13 Playwright tests pass --- .../Components/WindowRenderComponent.razor | 50 +++++- .../Services/WindowManagerService.cs | 18 +++ .../Abstractions/AppAttribute.cs | 79 +++++++++ .../Abstractions/AppKind.cs | 21 +++ .../Components/AppLauncher.razor | 56 +++++++ .../Components/AppLauncher.razor.cs | 117 ++++++++++++++ .../Components/AppLauncher.razor.css | 143 +++++++++++++++++ .../Components/AppLauncher.razor.js | 35 ++++ .../Components/Desktop.razor | 23 +++ .../Components/Desktop.razor.cs | 18 +++ .../Components/Desktop.razor.css | 51 ++++++ .../Components/TerminalAppBase.cs | 126 +++++++++++++++ .../Components/TerminalHost.razor | 12 ++ .../Components/TerminalHost.razor.cs | 150 ++++++++++++++++++ .../Components/TerminalHost.razor.css | 16 ++ .../Components/TerminalHost.razor.js | 21 +++ .../Components/WindowAppBase.cs | 65 ++++++++ .../Extensions/ServiceCollectionExtensions.cs | 46 ++++++ .../HackerOs.AppFramework.csproj | 23 +++ .../Registry/AppDescriptor.cs | 96 +++++++++++ .../Registry/AppRegistry.cs | 148 +++++++++++++++++ .../HackerOs.AppFramework/_Imports.razor | 12 ++ .../Ecosystem/HackerOs.Ecosystem/App.razor | 5 + .../HackerOs.Ecosystem.csproj | 18 +++ .../Modules/HackerShellApp.cs | 109 +++++++++++++ .../Modules/SystemMonitorApp.razor | 55 +++++++ .../Modules/SystemMonitorApp.razor.cs | 106 +++++++++++++ .../Modules/SystemMonitorApp.razor.css | 91 +++++++++++ .../Modules/SystemMonitorApp.razor.js | 19 +++ .../Modules/WelcomeApp.razor | 45 ++++++ .../Modules/WelcomeApp.razor.cs | 35 ++++ .../Modules/WelcomeApp.razor.css | 111 +++++++++++++ .../Modules/WelcomeApp.razor.js | 14 ++ .../Ecosystem/HackerOs.Ecosystem/Program.cs | 17 ++ .../HackerOs.Ecosystem/_Imports.razor | 16 ++ .../HackerOs.Ecosystem/wwwroot/css/app.css | 115 ++++++++++++++ .../HackerOs.Ecosystem/wwwroot/favicon.png | Bin 0 -> 1148 bytes .../HackerOs.Ecosystem/wwwroot/icon-192.png | Bin 0 -> 2626 bytes .../HackerOs.Ecosystem/wwwroot/index.html | 32 ++++ .../wwwroot/sample-data/weather.json | 27 ++++ .../Ecosystem/tests/e2e/desktop.spec.ts | 35 ++++ .../Ecosystem/tests/e2e/hacker-shell.spec.ts | 52 ++++++ wasm2/HackerOs/Ecosystem/tests/e2e/helpers.ts | 37 +++++ .../tests/e2e/system-monitor.spec.ts | 32 ++++ .../Ecosystem/tests/e2e/taskbar.spec.ts | 31 ++++ .../Ecosystem/tests/e2e/welcome.spec.ts | 33 ++++ .../Ecosystem/tests/package-lock.json | 78 +++++++++ wasm2/HackerOs/Ecosystem/tests/package.json | 12 ++ .../Ecosystem/tests/playwright.config.ts | 38 +++++ .../tests/test-results/.last-run.json | 4 + 50 files changed, 2487 insertions(+), 6 deletions(-) create mode 100644 wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Abstractions/AppAttribute.cs create mode 100644 wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Abstractions/AppKind.cs create mode 100644 wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/AppLauncher.razor create mode 100644 wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/AppLauncher.razor.cs create mode 100644 wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/AppLauncher.razor.css create mode 100644 wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/AppLauncher.razor.js create mode 100644 wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/Desktop.razor create mode 100644 wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/Desktop.razor.cs create mode 100644 wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/Desktop.razor.css create mode 100644 wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/TerminalAppBase.cs create mode 100644 wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/TerminalHost.razor create mode 100644 wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/TerminalHost.razor.cs create mode 100644 wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/TerminalHost.razor.css create mode 100644 wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/TerminalHost.razor.js create mode 100644 wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/WindowAppBase.cs create mode 100644 wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Extensions/ServiceCollectionExtensions.cs create mode 100644 wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/HackerOs.AppFramework.csproj create mode 100644 wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Registry/AppDescriptor.cs create mode 100644 wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Registry/AppRegistry.cs create mode 100644 wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/_Imports.razor create mode 100644 wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/App.razor create mode 100644 wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/HackerOs.Ecosystem.csproj create mode 100644 wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/HackerShellApp.cs create mode 100644 wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/SystemMonitorApp.razor create mode 100644 wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/SystemMonitorApp.razor.cs create mode 100644 wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/SystemMonitorApp.razor.css create mode 100644 wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/SystemMonitorApp.razor.js create mode 100644 wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/WelcomeApp.razor create mode 100644 wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/WelcomeApp.razor.cs create mode 100644 wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/WelcomeApp.razor.css create mode 100644 wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/WelcomeApp.razor.js create mode 100644 wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Program.cs create mode 100644 wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/_Imports.razor create mode 100644 wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/wwwroot/css/app.css create mode 100644 wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/wwwroot/favicon.png create mode 100644 wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/wwwroot/icon-192.png create mode 100644 wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/wwwroot/index.html create mode 100644 wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/wwwroot/sample-data/weather.json create mode 100644 wasm2/HackerOs/Ecosystem/tests/e2e/desktop.spec.ts create mode 100644 wasm2/HackerOs/Ecosystem/tests/e2e/hacker-shell.spec.ts create mode 100644 wasm2/HackerOs/Ecosystem/tests/e2e/helpers.ts create mode 100644 wasm2/HackerOs/Ecosystem/tests/e2e/system-monitor.spec.ts create mode 100644 wasm2/HackerOs/Ecosystem/tests/e2e/taskbar.spec.ts create mode 100644 wasm2/HackerOs/Ecosystem/tests/e2e/welcome.spec.ts create mode 100644 wasm2/HackerOs/Ecosystem/tests/package-lock.json create mode 100644 wasm2/HackerOs/Ecosystem/tests/package.json create mode 100644 wasm2/HackerOs/Ecosystem/tests/playwright.config.ts create mode 100644 wasm2/HackerOs/Ecosystem/tests/test-results/.last-run.json diff --git a/wasm2/HackerOs/BlazorWindowManager/BlazorWindowManager/Components/WindowRenderComponent.razor b/wasm2/HackerOs/BlazorWindowManager/BlazorWindowManager/Components/WindowRenderComponent.razor index 76afe7b..16942e2 100644 --- a/wasm2/HackerOs/BlazorWindowManager/BlazorWindowManager/Components/WindowRenderComponent.razor +++ b/wasm2/HackerOs/BlazorWindowManager/BlazorWindowManager/Components/WindowRenderComponent.razor @@ -1,16 +1,12 @@ @using BlazorWindowManager.Models @using BlazorWindowManager.Services +@using System.Reflection @using Microsoft.AspNetCore.Components @using Microsoft.AspNetCore.Components.Web @using Microsoft.Extensions.DependencyInjection @implements IDisposable
- @* Debug info *@ -
- Windows: @_windowsToRender.Count -
- @foreach (var windowInfo in _windowsToRender) { var context = GetOrCreateContext(windowInfo); @@ -88,11 +84,53 @@ if (type != null) return type; - // If not found, try without namespace + // If not found, try without namespace within this assembly var types = assembly.GetTypes().Where(t => t.Name == typeName).ToArray(); if (types.Length > 0) return types[0]; + // Broaden the search to every loaded assembly so that windows defined by + // consuming applications (for example self-registering ecosystem apps) + // resolve correctly instead of falling back to an empty WindowBase. Both + // full names and simple names are supported. + foreach (var loadedAssembly in AppDomain.CurrentDomain.GetAssemblies()) + { + Type? candidate; + try + { + candidate = loadedAssembly.GetType(typeName); + } + catch + { + candidate = null; + } + + if (candidate != null && typeof(ComponentBase).IsAssignableFrom(candidate)) + return candidate; + } + + foreach (var loadedAssembly in AppDomain.CurrentDomain.GetAssemblies()) + { + Type[] loadedTypes; + try + { + loadedTypes = loadedAssembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + loadedTypes = ex.Types.Where(t => t is not null).Cast().ToArray(); + } + catch + { + continue; + } + + var match = loadedTypes.FirstOrDefault(t => + t.Name == typeName && typeof(ComponentBase).IsAssignableFrom(t)); + if (match != null) + return match; + } + // Fallback to WindowBase return typeof(WindowBase); } diff --git a/wasm2/HackerOs/BlazorWindowManager/BlazorWindowManager/Services/WindowManagerService.cs b/wasm2/HackerOs/BlazorWindowManager/BlazorWindowManager/Services/WindowManagerService.cs index f44c54b..4974147 100644 --- a/wasm2/HackerOs/BlazorWindowManager/BlazorWindowManager/Services/WindowManagerService.cs +++ b/wasm2/HackerOs/BlazorWindowManager/BlazorWindowManager/Services/WindowManagerService.cs @@ -536,6 +536,12 @@ public async Task CloseWindowAsync(Guid windowId, bool force = false) /// Task that completes when the operation is done public async Task MinimizeWindowAsync(Guid windowId) { + if (GetWindow(windowId)?.ComponentRef is WindowBase window) + { + await window.Minimize(); + return true; + } + return await Task.Run(() => UpdateWindowState(windowId, WindowState.Minimized)); } @@ -546,6 +552,12 @@ public async Task MinimizeWindowAsync(Guid windowId) /// Task that completes when the operation is done public async Task MaximizeWindowAsync(Guid windowId) { + if (GetWindow(windowId)?.ComponentRef is WindowBase window) + { + await window.Maximize(); + return true; + } + return await Task.Run(() => UpdateWindowState(windowId, WindowState.Maximized)); } @@ -556,6 +568,12 @@ public async Task MaximizeWindowAsync(Guid windowId) /// Task that completes when the operation is done public async Task RestoreWindowAsync(Guid windowId) { + if (GetWindow(windowId)?.ComponentRef is WindowBase window) + { + await window.Restore(); + return true; + } + return await Task.Run(() => UpdateWindowState(windowId, WindowState.Normal)); } diff --git a/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Abstractions/AppAttribute.cs b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Abstractions/AppAttribute.cs new file mode 100644 index 0000000..9b5c5d2 --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Abstractions/AppAttribute.cs @@ -0,0 +1,79 @@ +namespace HackerOs.AppFramework.Abstractions; + +/// +/// Marks a Blazor component as a self-registering application in the HackerOS +/// developer ecosystem. +/// +/// +/// +/// Any component that derives from or +/// and is decorated with this attribute +/// is automatically discovered by the at +/// start-up. The registry adds it to the application list so the user can launch +/// it from the start menu and see it on the taskbar — no manual wiring in +/// Program.cs is required. +/// +/// +/// This is the single extension point of the framework: a developer drops a new +/// component into the project, decorates it with [App], and it becomes a +/// first-class citizen of the operating system. +/// +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class AppAttribute : Attribute +{ + /// + /// Creates a new application registration. + /// + /// The human readable name shown in the launcher and taskbar. + public AppAttribute(string name) + { + Name = string.IsNullOrWhiteSpace(name) + ? throw new ArgumentException("Application name is required.", nameof(name)) + : name; + } + + /// + /// The human readable name shown in the launcher, window title bar and taskbar. + /// + public string Name { get; } + + /// + /// A stable identifier for the application. When omitted the registry derives + /// one from the component type's full name, guaranteeing uniqueness. + /// + public string? Id { get; set; } + + /// + /// A short description surfaced as a tooltip in the launcher. + /// + public string Description { get; set; } = string.Empty; + + /// + /// An icon for the application. Any short string works — an emoji glyph + /// keeps the framework dependency free while still looking thematic. + /// + public string Icon { get; set; } = "\U0001F5D4"; // ๐Ÿ—” window glyph + + /// + /// A grouping category used to organise the launcher (for example + /// System, Development or Games). + /// + public string Category { get; set; } = "Applications"; + + /// + /// The application version, useful for future package management. + /// + public string Version { get; set; } = "1.0.0"; + + /// + /// When the app is hidden from the launcher but can + /// still be launched programmatically (handy for background/helper apps). + /// + public bool HiddenFromLauncher { get; set; } + + /// + /// Ordering hint within a category (lower values appear first). + /// + public int SortOrder { get; set; } = 100; +} diff --git a/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Abstractions/AppKind.cs b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Abstractions/AppKind.cs new file mode 100644 index 0000000..e6c5621 --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Abstractions/AppKind.cs @@ -0,0 +1,21 @@ +namespace HackerOs.AppFramework.Abstractions; + +/// +/// Identifies the base surface a registered application is built on. +/// This lets the ecosystem shell reason about an application without needing +/// to know its concrete component type. +/// +public enum AppKind +{ + /// + /// A classic windowed application that derives from + /// . + /// + Window, + + /// + /// A text/console application that derives from + /// . + /// + Terminal +} diff --git a/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/AppLauncher.razor b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/AppLauncher.razor new file mode 100644 index 0000000..9f2324d --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/AppLauncher.razor @@ -0,0 +1,56 @@ +@namespace HackerOs.AppFramework.Components +@implements IAsyncDisposable +@inject AppRegistry Registry +@inject IJSRuntime JS + +
+ + + @if (_open) + { + + } +
diff --git a/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/AppLauncher.razor.cs b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/AppLauncher.razor.cs new file mode 100644 index 0000000..93ba08e --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/AppLauncher.razor.cs @@ -0,0 +1,117 @@ +using HackerOs.AppFramework.Registry; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace HackerOs.AppFramework.Components; + +/// +/// The start-menu style launcher. It lists every application discovered by the +/// grouped by category and launches the selected app +/// (which in turn appears on the taskbar via the window manager). +/// +public partial class AppLauncher : ComponentBase, IAsyncDisposable +{ + private ElementReference _root; + private IJSObjectReference? _module; + private DotNetObjectReference? _selfRef; + private bool _open; + + /// The glyph shown on the launcher button. + [Parameter] public string LogoGlyph { get; set; } = "\u26A1"; // โšก + + /// The text shown on the launcher button. + [Parameter] public string Label { get; set; } = "START"; + + /// + protected override void OnInitialized() + { + Registry.AppsChanged += OnAppsChanged; + } + + private void OnAppsChanged(object? sender, EventArgs e) => InvokeAsync(StateHasChanged); + + private IEnumerable AppsInCategory(string category) => + Registry.LauncherApps.Where(a => + string.Equals(a.Category, category, StringComparison.OrdinalIgnoreCase)); + + private async Task ToggleAsync() + { + _open = !_open; + if (_open) + { + await EnsureModuleAsync(); + await _module!.InvokeVoidAsync("registerOutsideClick", _root, _selfRef); + } + else + { + await StopOutsideClickAsync(); + } + } + + private async Task LaunchAsync(AppDescriptor app) + { + Registry.Launch(app); + _open = false; + await StopOutsideClickAsync(); + } + + /// + /// Invoked from JavaScript when a click occurs outside the launcher so the + /// menu can close itself. + /// + [JSInvokable] + public Task CloseFromOutside() + { + if (_open) + { + _open = false; + return InvokeAsync(StateHasChanged); + } + + return Task.CompletedTask; + } + + private async Task EnsureModuleAsync() + { + _selfRef ??= DotNetObjectReference.Create(this); + _module ??= await JS.InvokeAsync( + "import", + "./_content/HackerOs.AppFramework/Components/AppLauncher.razor.js"); + } + + private async Task StopOutsideClickAsync() + { + if (_module is not null) + { + try + { + await _module.InvokeVoidAsync("unregisterOutsideClick"); + } + catch (JSDisconnectedException) + { + // Circuit gone; ignore. + } + } + } + + /// + public async ValueTask DisposeAsync() + { + Registry.AppsChanged -= OnAppsChanged; + + if (_module is not null) + { + try + { + await _module.InvokeVoidAsync("unregisterOutsideClick"); + await _module.DisposeAsync(); + } + catch (JSDisconnectedException) + { + // Circuit gone; ignore. + } + } + + _selfRef?.Dispose(); + } +} diff --git a/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/AppLauncher.razor.css b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/AppLauncher.razor.css new file mode 100644 index 0000000..ccebab0 --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/AppLauncher.razor.css @@ -0,0 +1,143 @@ +.app-launcher { + position: relative; + display: inline-flex; + height: 100%; + align-items: center; +} + +.launcher-button { + display: inline-flex; + align-items: center; + gap: 8px; + height: 100%; + padding: 0 16px; + border: none; + border-right: 1px solid rgba(0, 255, 128, 0.25); + background: linear-gradient(180deg, #10251b 0%, #05100b 100%); + color: #4dff9e; + font-family: 'Consolas', 'Courier New', monospace; + font-weight: 700; + letter-spacing: 1px; + cursor: pointer; + transition: background 0.15s ease, box-shadow 0.15s ease; +} + +.launcher-button:hover, +.launcher-button.open { + background: linear-gradient(180deg, #164a30 0%, #0a1f14 100%); + box-shadow: 0 0 12px rgba(0, 255, 128, 0.35) inset; +} + +.launcher-logo { + font-size: 18px; + line-height: 1; +} + +.launcher-label { + font-size: 12px; +} + +.launcher-panel { + position: absolute; + bottom: calc(100% + 6px); + left: 0; + width: 340px; + max-height: 60vh; + overflow-y: auto; + background: rgba(6, 14, 10, 0.98); + border: 1px solid rgba(0, 255, 128, 0.35); + border-radius: 8px; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6), 0 0 20px rgba(0, 255, 128, 0.15); + z-index: 10000; + animation: launcher-rise 0.12s ease-out; +} + +@keyframes launcher-rise { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +.launcher-panel-header { + display: flex; + align-items: baseline; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid rgba(0, 255, 128, 0.2); +} + +.launcher-panel-title { + color: #7dffbd; + font-family: 'Consolas', 'Courier New', monospace; + font-weight: 700; + letter-spacing: 1px; +} + +.launcher-panel-count { + color: #4f7a66; + font-size: 11px; + font-family: 'Consolas', 'Courier New', monospace; +} + +.launcher-panel-body { + padding: 8px; +} + +.launcher-category-title { + color: #3f8f6a; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 2px; + padding: 8px 8px 4px; + font-family: 'Consolas', 'Courier New', monospace; +} + +.launcher-app { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 8px 10px; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: #cfeede; + cursor: pointer; + text-align: left; + transition: background 0.12s ease, border-color 0.12s ease; +} + +.launcher-app:hover { + background: rgba(0, 255, 128, 0.08); + border-color: rgba(0, 255, 128, 0.3); +} + +.launcher-app-icon { + font-size: 20px; + width: 28px; + text-align: center; +} + +.launcher-app-text { + display: flex; + flex-direction: column; + line-height: 1.2; +} + +.launcher-app-name { + font-family: 'Segoe UI', sans-serif; + font-size: 13px; + font-weight: 600; +} + +.launcher-app-kind { + font-size: 10px; + color: #4f7a66; + font-family: 'Consolas', 'Courier New', monospace; +} + +.launcher-empty { + padding: 24px; + text-align: center; + color: #4f7a66; + font-family: 'Consolas', 'Courier New', monospace; +} diff --git a/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/AppLauncher.razor.js b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/AppLauncher.razor.js new file mode 100644 index 0000000..ea82d04 --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/AppLauncher.razor.js @@ -0,0 +1,35 @@ +/* + * Collocated JavaScript module for the AppLauncher component. + * Registers a single document-level click handler so the start menu closes when + * the user clicks anywhere outside of it. All JS lives here rather than inline in + * markup, per the ecosystem scoped-file rule. + */ + +let handler = null; + +/** + * Registers an outside-click handler that notifies .NET when a click lands + * outside the launcher root element. + * @param {HTMLElement} root The launcher root element. + * @param {any} dotnetRef A DotNetObjectReference exposing CloseFromOutside(). + */ +export function registerOutsideClick(root, dotnetRef) { + unregisterOutsideClick(); + + handler = (event) => { + if (root && !root.contains(event.target)) { + dotnetRef.invokeMethodAsync('CloseFromOutside'); + } + }; + + // Defer to the next tick so the click that opened the menu is not counted. + setTimeout(() => document.addEventListener('click', handler, true), 0); +} + +/** Removes the outside-click handler if one is registered. */ +export function unregisterOutsideClick() { + if (handler) { + document.removeEventListener('click', handler, true); + handler = null; + } +} diff --git a/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/Desktop.razor b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/Desktop.razor new file mode 100644 index 0000000..8b95b20 --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/Desktop.razor @@ -0,0 +1,23 @@ +@namespace HackerOs.AppFramework.Components +@inject AppRegistry Registry + +
+ +
+
@Title
+
@Registry.LauncherApps.Count applications registered · open the START menu
+
+
+ + + + + + +
+ 📶 + 🔒 +
+
+
+
diff --git a/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/Desktop.razor.cs b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/Desktop.razor.cs new file mode 100644 index 0000000..baa8657 --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/Desktop.razor.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Components; + +namespace HackerOs.AppFramework.Components; + +/// +/// The top-level desktop shell of the ecosystem. It composes the window desktop +/// area, the taskbar (populated automatically as apps open) and the start-menu +/// launcher into a single drop-in component. +/// +public partial class Desktop : ComponentBase +{ + /// Title shown on the desktop watermark. + [Parameter] public string Title { get; set; } = "HackerOS"; + + /// CSS background applied to the desktop area. + [Parameter] public string Background { get; set; } = + "radial-gradient(circle at 20% 20%, #0b2018 0%, #05080a 60%, #02040a 100%)"; +} diff --git a/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/Desktop.razor.css b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/Desktop.razor.css new file mode 100644 index 0000000..ccab694 --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/Desktop.razor.css @@ -0,0 +1,51 @@ +.hos-desktop { + position: fixed; + inset: 0; + overflow: hidden; + font-family: 'Segoe UI', sans-serif; +} + +.hos-desktop ::deep .hos-desktop-area { + position: absolute; + inset: 0 0 40px 0; +} + +.hos-desktop-watermark { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + text-align: center; + pointer-events: none; + user-select: none; +} + +.hos-watermark-title { + font-family: 'Consolas', 'Courier New', monospace; + font-size: 64px; + font-weight: 800; + letter-spacing: 6px; + color: rgba(0, 255, 128, 0.12); + text-shadow: 0 0 30px rgba(0, 255, 128, 0.15); +} + +.hos-watermark-sub { + margin-top: 8px; + font-family: 'Consolas', 'Courier New', monospace; + font-size: 13px; + letter-spacing: 2px; + color: rgba(0, 255, 128, 0.28); +} + +.hos-tray { + display: flex; + align-items: center; + gap: 12px; + padding: 0 12px; +} + +.hos-tray-icon { + font-size: 15px; + cursor: default; + opacity: 0.8; +} diff --git a/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/TerminalAppBase.cs b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/TerminalAppBase.cs new file mode 100644 index 0000000..c7f2efa --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/TerminalAppBase.cs @@ -0,0 +1,126 @@ +using System.Reflection; +using BlazorWindowManager.Components; +using HackerOs.AppFramework.Abstractions; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +namespace HackerOs.AppFramework.Components; + +/// +/// Base class for terminal (console) applications in the ecosystem. +/// +/// +/// +/// A terminal application is a fully code-defined component: derive from this +/// class, decorate it with , and implement +/// to react to user input. The base renders a window +/// containing an interactive — no markup file is +/// required, which keeps command line tools terse and focused on behaviour. +/// +/// +/// +/// [App("Echo", Kind = AppKind.Terminal)] +/// public sealed class EchoApp : TerminalAppBase +/// { +/// protected override string Prompt => "echo> "; +/// protected override Task OnCommandAsync(string command) +/// { +/// WriteLine(command); +/// return Task.CompletedTask; +/// } +/// } +/// +/// +/// +public abstract class TerminalAppBase : WindowBase +{ + private TerminalHost? _host; + + /// The application metadata declared via . + protected AppAttribute? AppInfo { get; private set; } + + /// Number of terminal columns. Override to change the console size. + protected virtual int Columns => 90; + + /// Number of terminal rows. Override to change the console size. + protected virtual int Rows => 26; + + /// The prompt written before each input line. + protected virtual string Prompt => "guest@hackeros:~$ "; + + /// Optional banner written once when the terminal starts. + protected virtual string? Banner => null; + + /// + protected override void OnInitialized() + { + AppInfo = GetType().GetCustomAttribute(inherit: false); + + if (AppInfo is not null) + { + if (string.IsNullOrWhiteSpace(Title) || Title == "Window") + { + Title = AppInfo.Name; + } + + Icon ??= BuildIconFragment(AppInfo.Icon); + } + + // Terminal apps default to a compact console footprint. + InitialWidth ??= 720; + InitialHeight ??= 460; + + base.OnInitialized(); + } + + /// Writes raw text to the terminal at the cursor position. + protected void Write(string text) => _host?.Write(text); + + /// Writes a line of text to the terminal. + protected void WriteLine(string text = "") => _host?.WriteLine(text); + + /// Clears the terminal screen. + protected void ClearScreen() => _host?.ClearScreen(); + + /// + /// Called when the user submits a command. Override to implement the shell. + /// + /// The raw command line entered by the user. + protected abstract Task OnCommandAsync(string command); + + /// + /// Called once when the terminal becomes interactive. Override to print a + /// welcome message or perform start-up work. + /// + protected virtual Task OnStartedAsync() => Task.CompletedTask; + + /// + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenComponent(0); + builder.AddComponentParameter(1, nameof(WindowContent.Window), this); + builder.AddComponentParameter(2, nameof(WindowContent.ChildContent), (RenderFragment)(contentBuilder => + { + contentBuilder.OpenComponent(0); + contentBuilder.AddComponentParameter(1, nameof(TerminalHost.Columns), Columns); + contentBuilder.AddComponentParameter(2, nameof(TerminalHost.Rows), Rows); + contentBuilder.AddComponentParameter(3, nameof(TerminalHost.Prompt), Prompt); + contentBuilder.AddComponentParameter(4, nameof(TerminalHost.Banner), Banner); + contentBuilder.AddComponentParameter(5, nameof(TerminalHost.OnCommand), + EventCallback.Factory.Create(this, OnCommandAsync)); + contentBuilder.AddComponentParameter(6, nameof(TerminalHost.OnReady), + EventCallback.Factory.Create(this, OnStartedAsync)); + contentBuilder.AddComponentReferenceCapture(7, reference => _host = (TerminalHost)reference); + contentBuilder.CloseComponent(); + })); + builder.CloseComponent(); + } + + private static RenderFragment BuildIconFragment(string glyph) => builder => + { + builder.OpenElement(0, "span"); + builder.AddAttribute(1, "class", "app-icon-glyph"); + builder.AddContent(2, glyph); + builder.CloseElement(); + }; +} diff --git a/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/TerminalHost.razor b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/TerminalHost.razor new file mode 100644 index 0000000..e40e7fb --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/TerminalHost.razor @@ -0,0 +1,12 @@ +@namespace HackerOs.AppFramework.Components +@using BlazorTerminal.Components +@using BlazorTerminal.Models + +
+ +
diff --git a/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/TerminalHost.razor.cs b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/TerminalHost.razor.cs new file mode 100644 index 0000000..ccf2a4b --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/TerminalHost.razor.cs @@ -0,0 +1,150 @@ +using BlazorTerminal.Components; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace HackerOs.AppFramework.Components; + +/// +/// A reusable, dependency-free interactive console surface built on top of the +/// component. It handles character echo, line editing and +/// command submission so applications only need to +/// implement command handling. +/// +public partial class TerminalHost : ComponentBase, IAsyncDisposable +{ + private Terminal? _terminal; + private ElementReference _hostElement; + private IJSObjectReference? _module; + private string _line = string.Empty; + private bool _started; + + [Inject] private IJSRuntime JS { get; set; } = default!; + + /// Number of character columns. + [Parameter] public int Columns { get; set; } = 80; + + /// Number of character rows. + [Parameter] public int Rows { get; set; } = 24; + + /// Font size in pixels. + [Parameter] public int FontSize { get; set; } = 15; + + /// The prompt written before each input line. + [Parameter] public string Prompt { get; set; } = "$ "; + + /// Optional banner written once when the terminal starts. + [Parameter] public string? Banner { get; set; } + + /// Invoked when the user submits a command by pressing Enter. + [Parameter] public EventCallback OnCommand { get; set; } + + /// Invoked once when the terminal is ready for interaction. + [Parameter] public EventCallback OnReady { get; set; } + + /// Writes raw text to the terminal at the cursor position. + public void Write(string text) => _terminal?.Write(text); + + /// Writes a line of text followed by a CR/LF sequence. + public void WriteLine(string text = "") => _terminal?.Write(text + "\r\n"); + + /// Clears the terminal screen and resets the cursor. + public void ClearScreen() + { + _terminal?.Clear(); + StateHasChanged(); + } + + /// Writes the prompt for a fresh input line. + public void WritePrompt() => Write(Prompt); + + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender || _started) + { + return; + } + + _started = true; + + // Load the collocated JS module used for focus management. + _module = await JS.InvokeAsync( + "import", + "./_content/HackerOs.AppFramework/Components/TerminalHost.razor.js"); + + if (!string.IsNullOrEmpty(Banner)) + { + foreach (var bannerLine in Banner.Replace("\r\n", "\n").Split('\n')) + { + WriteLine(bannerLine); + } + } + + WritePrompt(); + await FocusAsync(); + + if (OnReady.HasDelegate) + { + await OnReady.InvokeAsync(); + } + } + + /// Gives keyboard focus to the underlying terminal element. + public async Task FocusAsync() + { + if (_module is not null) + { + await _module.InvokeVoidAsync("focusTerminal", _hostElement); + } + } + + private async Task HandleInputAsync(string input) + { + switch (input) + { + case "\r": // Enter -> submit the current line. + WriteLine(); + var command = _line; + _line = string.Empty; + if (OnCommand.HasDelegate) + { + await OnCommand.InvokeAsync(command); + } + WritePrompt(); + break; + + case "\b": // Backspace -> destructive erase of the last character. + if (_line.Length > 0) + { + _line = _line[..^1]; + Write("\b \b"); + } + break; + + default: + // Echo printable single characters; ignore control/escape sequences. + if (input.Length == 1 && !char.IsControl(input[0])) + { + _line += input; + Write(input); + } + break; + } + } + + /// + public async ValueTask DisposeAsync() + { + if (_module is not null) + { + try + { + await _module.DisposeAsync(); + } + catch (JSDisconnectedException) + { + // Circuit already gone; nothing to clean up. + } + } + } +} diff --git a/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/TerminalHost.razor.css b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/TerminalHost.razor.css new file mode 100644 index 0000000..291a9e8 --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/TerminalHost.razor.css @@ -0,0 +1,16 @@ +.terminal-host { + width: 100%; + height: 100%; + box-sizing: border-box; + padding: 8px; + background: #05080a; + overflow: hidden; + display: flex; +} + +.terminal-host ::deep .terminal-container { + width: 100%; + height: 100%; + outline: none; + border-radius: 4px; +} diff --git a/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/TerminalHost.razor.js b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/TerminalHost.razor.js new file mode 100644 index 0000000..7d97c47 --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/TerminalHost.razor.js @@ -0,0 +1,21 @@ +/* + * Collocated JavaScript module for the TerminalHost component. + * Kept minimal and side-effect free per the ecosystem rule that all JS lives in + * a scoped ".razor.js" file rather than inline in markup. + */ + +/** + * Moves keyboard focus to the terminal surface so the user can start typing + * immediately after an application launches. + * @param {HTMLElement} host The terminal-host container element. + */ +export function focusTerminal(host) { + if (!host) { + return; + } + + // The BlazorTerminal component renders a focusable container with + // tabindex="0"; focus it (or the host as a fallback). + const focusable = host.querySelector('.terminal-container') ?? host; + focusable.focus({ preventScroll: true }); +} diff --git a/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/WindowAppBase.cs b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/WindowAppBase.cs new file mode 100644 index 0000000..3fc7d37 --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/WindowAppBase.cs @@ -0,0 +1,65 @@ +using System.Reflection; +using BlazorWindowManager.Components; +using HackerOs.AppFramework.Abstractions; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +namespace HackerOs.AppFramework.Components; + +/// +/// Base class for windowed applications in the ecosystem. +/// +/// +/// +/// Deriving from this class (and decorating the component with +/// ) is all that is required to publish a new windowed +/// application. The base wires up the window title and icon from the attribute so +/// the taskbar and title bar are populated automatically. +/// +/// +/// A derived .razor component should place its markup inside a +/// element bound to this: +/// +/// @inherits WindowAppBase +/// <WindowContent Window="this"> +/// <!-- your UI --> +/// </WindowContent> +/// +/// +/// +public abstract class WindowAppBase : WindowBase +{ + /// The application metadata declared via . + protected AppAttribute? AppInfo { get; private set; } + + /// The icon glyph declared for this application. + protected string AppIcon => AppInfo?.Icon ?? "\U0001F5D4"; + + /// + protected override void OnInitialized() + { + AppInfo = GetType().GetCustomAttribute(inherit: false); + + if (AppInfo is not null) + { + // Only override the default title; respect a title supplied explicitly + // via window parameters. + if (string.IsNullOrWhiteSpace(Title) || Title == "Window") + { + Title = AppInfo.Name; + } + + Icon ??= BuildIconFragment(AppInfo.Icon); + } + + base.OnInitialized(); + } + + private static RenderFragment BuildIconFragment(string glyph) => builder => + { + builder.OpenElement(0, "span"); + builder.AddAttribute(1, "class", "app-icon-glyph"); + builder.AddContent(2, glyph); + builder.CloseElement(); + }; +} diff --git a/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Extensions/ServiceCollectionExtensions.cs b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..bd60137 --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,46 @@ +using System.Reflection; +using BlazorWindowManager.Extensions; +using BlazorWindowManager.Services; +using HackerOs.AppFramework.Registry; +using Microsoft.Extensions.DependencyInjection; + +namespace HackerOs.AppFramework.Extensions; + +/// +/// Dependency injection helpers for wiring up the HackerOS application framework. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Registers the window manager and the , then + /// discovers every self-registering application in the supplied module + /// assemblies. When no assemblies are supplied the calling assembly (the host + /// application) is scanned automatically. + /// + /// The service collection. + /// + /// Assemblies to scan for components decorated with + /// . + /// + public static IServiceCollection AddHackerOsAppFramework( + this IServiceCollection services, + params Assembly[] moduleAssemblies) + { + // Bring in the underlying window management stack. + services.AddBlazorWindowManager(); + + // Default to the host assembly that invoked this method. + var assemblies = moduleAssemblies is { Length: > 0 } + ? moduleAssemblies + : new[] { Assembly.GetCallingAssembly() }; + + services.AddSingleton(sp => + { + var registry = new AppRegistry(sp.GetRequiredService()); + registry.DiscoverFrom(assemblies); + return registry; + }); + + return services; + } +} diff --git a/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/HackerOs.AppFramework.csproj b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/HackerOs.AppFramework.csproj new file mode 100644 index 0000000..9321aa5 --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/HackerOs.AppFramework.csproj @@ -0,0 +1,23 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + diff --git a/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Registry/AppDescriptor.cs b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Registry/AppDescriptor.cs new file mode 100644 index 0000000..a690196 --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Registry/AppDescriptor.cs @@ -0,0 +1,96 @@ +using System.Reflection; +using HackerOs.AppFramework.Abstractions; +using HackerOs.AppFramework.Components; + +namespace HackerOs.AppFramework.Registry; + +/// +/// An immutable description of a discovered application. Produced by the +/// from a component type decorated with +/// . +/// +public sealed class AppDescriptor +{ + internal AppDescriptor(Type componentType, AppAttribute attribute, AppKind kind) + { + ComponentType = componentType; + Kind = kind; + Name = attribute.Name; + Id = string.IsNullOrWhiteSpace(attribute.Id) + ? componentType.FullName ?? componentType.Name + : attribute.Id!; + Description = attribute.Description; + Icon = attribute.Icon; + Category = attribute.Category; + Version = attribute.Version; + HiddenFromLauncher = attribute.HiddenFromLauncher; + SortOrder = attribute.SortOrder; + } + + /// The concrete Blazor component type that implements the app. + public Type ComponentType { get; } + + /// Whether the app is window- or terminal-based. + public AppKind Kind { get; } + + /// Stable unique identifier. + public string Id { get; } + + /// Human readable name. + public string Name { get; } + + /// Short description / tooltip. + public string Description { get; } + + /// Icon glyph. + public string Icon { get; } + + /// Launcher category. + public string Category { get; } + + /// App version string. + public string Version { get; } + + /// Whether the app is hidden from the launcher UI. + public bool HiddenFromLauncher { get; } + + /// Ordering hint within a category. + public int SortOrder { get; } + + /// + /// Attempts to build an from a type. Returns + /// when the type is not a valid, discoverable app. + /// + internal static AppDescriptor? TryCreate(Type type) + { + if (type.IsAbstract || !type.IsClass) + { + return null; + } + + var attribute = type.GetCustomAttribute(inherit: false); + if (attribute is null) + { + return null; + } + + AppKind kind; + if (typeof(TerminalAppBase).IsAssignableFrom(type)) + { + kind = AppKind.Terminal; + } + else if (typeof(WindowAppBase).IsAssignableFrom(type)) + { + kind = AppKind.Window; + } + else + { + // Decorated with [App] but not built on a supported base class. + throw new InvalidOperationException( + $"Type '{type.FullName}' is decorated with [App] but does not derive from " + + $"{nameof(WindowAppBase)} or {nameof(TerminalAppBase)}."); + } + + return new AppDescriptor(type, attribute, kind); + } +} diff --git a/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Registry/AppRegistry.cs b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Registry/AppRegistry.cs new file mode 100644 index 0000000..aa470fc --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Registry/AppRegistry.cs @@ -0,0 +1,148 @@ +using System.Collections.Concurrent; +using System.Reflection; +using BlazorWindowManager.Models; +using BlazorWindowManager.Services; +using HackerOs.AppFramework.Abstractions; + +namespace HackerOs.AppFramework.Registry; + +/// +/// The heart of the developer ecosystem: discovers every component decorated with +/// across the registered assemblies and exposes them as +/// launchable entries. +/// +/// +/// The registry is intentionally module oriented. A host registers one or more +/// assemblies (typically typeof(Program).Assembly and any module libraries) +/// and every qualifying component becomes available with zero additional wiring. +/// +public sealed class AppRegistry +{ + private readonly WindowManagerService _windowManager; + private readonly ConcurrentDictionary _apps = new(); + private static readonly MethodInfo CreateWindowGeneric = + typeof(WindowManagerService).GetMethod(nameof(WindowManagerService.CreateWindow)) + ?? throw new InvalidOperationException( + "WindowManagerService.CreateWindow could not be located via reflection."); + + /// + /// Creates a new registry bound to the window manager used to launch apps. + /// + public AppRegistry(WindowManagerService windowManager) + { + _windowManager = windowManager; + } + + /// Raised whenever the set of registered applications changes. + public event EventHandler? AppsChanged; + + /// All discovered applications, ordered for display. + public IReadOnlyList Apps => + _apps.Values + .OrderBy(a => a.Category, StringComparer.OrdinalIgnoreCase) + .ThenBy(a => a.SortOrder) + .ThenBy(a => a.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + /// Applications that should be shown in the launcher UI. + public IReadOnlyList LauncherApps => + Apps.Where(a => !a.HiddenFromLauncher).ToList(); + + /// The distinct categories present in the launcher, in display order. + public IReadOnlyList Categories => + LauncherApps.Select(a => a.Category) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(c => c, StringComparer.OrdinalIgnoreCase) + .ToList(); + + /// + /// Scans the supplied assemblies for components decorated with + /// and registers each one. + /// + /// The number of newly registered applications. + public int DiscoverFrom(params Assembly[] assemblies) + { + var added = 0; + foreach (var assembly in assemblies.Distinct()) + { + foreach (var type in GetLoadableTypes(assembly)) + { + var descriptor = AppDescriptor.TryCreate(type); + if (descriptor is not null && _apps.TryAdd(descriptor.Id, descriptor)) + { + added++; + } + } + } + + if (added > 0) + { + AppsChanged?.Invoke(this, EventArgs.Empty); + } + + return added; + } + + /// Registers a single application type explicitly. + public bool Register() => Register(typeof(TApp)); + + /// Registers a single application type explicitly. + public bool Register(Type appType) + { + var descriptor = AppDescriptor.TryCreate(appType) + ?? throw new InvalidOperationException( + $"Type '{appType.FullName}' is not a valid application."); + + if (_apps.TryAdd(descriptor.Id, descriptor)) + { + AppsChanged?.Invoke(this, EventArgs.Empty); + return true; + } + + return false; + } + + /// Looks up an application by its identifier. + public AppDescriptor? Find(string id) => + _apps.TryGetValue(id, out var descriptor) ? descriptor : null; + + /// + /// Launches an application by descriptor, creating a new window on the desktop + /// and, by extension, a new entry on the taskbar. + /// + public WindowInfo Launch(AppDescriptor descriptor) + { + ArgumentNullException.ThrowIfNull(descriptor); + + var parameters = new Dictionary + { + ["Title"] = descriptor.Name, + ["Name"] = descriptor.Id + }; + + var typedMethod = CreateWindowGeneric.MakeGenericMethod(descriptor.ComponentType); + var result = typedMethod.Invoke(_windowManager, new object?[] { parameters }); + return (WindowInfo)result!; + } + + /// Launches an application by identifier. + public WindowInfo Launch(string id) + { + var descriptor = Find(id) + ?? throw new KeyNotFoundException($"No application registered with id '{id}'."); + return Launch(descriptor); + } + + private static IEnumerable GetLoadableTypes(Assembly assembly) + { + try + { + return assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + // Be resilient to partially loadable module assemblies. + return ex.Types.Where(t => t is not null)!.Cast(); + } + } +} diff --git a/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/_Imports.razor b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/_Imports.razor new file mode 100644 index 0000000..d4d3f84 --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/_Imports.razor @@ -0,0 +1,12 @@ +@using System.Net.Http +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using BlazorWindowManager.Components +@using BlazorWindowManager.Services +@using BlazorWindowManager.Models +@using HackerOs.AppFramework +@using HackerOs.AppFramework.Abstractions +@using HackerOs.AppFramework.Components +@using HackerOs.AppFramework.Registry diff --git a/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/App.razor b/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/App.razor new file mode 100644 index 0000000..0191e18 --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/App.razor @@ -0,0 +1,5 @@ +@using HackerOs.AppFramework.Components + +@* The entire operating system UI is a single drop-in Desktop component. Windows + and taskbar entries are managed by the framework as applications launch. *@ + diff --git a/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/HackerOs.Ecosystem.csproj b/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/HackerOs.Ecosystem.csproj new file mode 100644 index 0000000..e907274 --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/HackerOs.Ecosystem.csproj @@ -0,0 +1,18 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + diff --git a/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/HackerShellApp.cs b/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/HackerShellApp.cs new file mode 100644 index 0000000..57d1960 --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/HackerShellApp.cs @@ -0,0 +1,109 @@ +using HackerOs.AppFramework.Abstractions; +using HackerOs.AppFramework.Components; +using HackerOs.AppFramework.Registry; +using Microsoft.AspNetCore.Components; + +namespace HackerOs.Ecosystem.Modules; + +/// +/// An interactive terminal application built purely in C# by deriving from +/// . It exposes a tiny shell that can inspect and +/// launch other registered applications, proving the framework works end to end +/// from the console side as well as the windowed side. +/// +[App("Hacker Shell", Id = "hackeros.hackershell", Icon = "\U0001F5A5", Category = "Development", + Description = "A scriptable console for exploring the ecosystem", SortOrder = 10)] +public sealed class HackerShellApp : TerminalAppBase +{ + [Inject] private AppRegistry Registry { get; set; } = default!; + + /// + protected override string Prompt => "root@hackeros:~# "; + + /// + protected override string? Banner => + "HackerOS Shell v1.0 \u2014 type 'help' for a list of commands."; + + /// + protected override Task OnCommandAsync(string command) + { + var trimmed = command.Trim(); + if (trimmed.Length == 0) + { + return Task.CompletedTask; + } + + var parts = trimmed.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + var verb = parts[0].ToLowerInvariant(); + var argument = parts.Length > 1 ? parts[1].Trim() : string.Empty; + + switch (verb) + { + case "help": + WriteLine("available commands:"); + WriteLine(" help show this help"); + WriteLine(" apps list registered applications"); + WriteLine(" launch launch an application by id or name"); + WriteLine(" echo print text back"); + WriteLine(" whoami print the current user"); + WriteLine(" date print the current date and time"); + WriteLine(" clear clear the screen"); + break; + + case "apps": + foreach (var app in Registry.Apps) + { + WriteLine($" {app.Icon} {app.Id,-26} {app.Name} [{app.Kind}]"); + } + break; + + case "launch": + LaunchApp(argument); + break; + + case "echo": + WriteLine(argument); + break; + + case "whoami": + WriteLine("root"); + break; + + case "date": + WriteLine(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); + break; + + case "clear": + ClearScreen(); + break; + + default: + WriteLine($"unknown command: {verb} (try 'help')"); + break; + } + + return Task.CompletedTask; + } + + private void LaunchApp(string idOrName) + { + if (string.IsNullOrWhiteSpace(idOrName)) + { + WriteLine("usage: launch "); + return; + } + + var descriptor = Registry.Find(idOrName) + ?? Registry.Apps.FirstOrDefault(a => + string.Equals(a.Name, idOrName, StringComparison.OrdinalIgnoreCase)); + + if (descriptor is null) + { + WriteLine($"no such application: {idOrName}"); + return; + } + + Registry.Launch(descriptor); + WriteLine($"launched {descriptor.Name}"); + } +} diff --git a/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/SystemMonitorApp.razor b/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/SystemMonitorApp.razor new file mode 100644 index 0000000..ce6298c --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/SystemMonitorApp.razor @@ -0,0 +1,55 @@ +@inherits WindowAppBase +@attribute [App("System Monitor", Id = "hackeros.sysmon", Icon = "\U0001F4C8", Category = "System", + Description = "Live view of simulated system resources", SortOrder = 20)] + + +
+
+
+
CPU
+
+
+
+
@_cpu%
+
+ +
+
MEM
+
+
+
+
@_mem%
+
+ +
+
NET
+
+
+
+
@_net Kb/s
+
+
+ +
+
// active processes
+ + + + + + @foreach (var proc in _processes) + { + + + + + + + } + +
PIDNAMECPUMEM
@proc.Pid@proc.Name@proc.Cpu%@proc.Mem MB
+
+ +
samples: @_ticks
+
+
diff --git a/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/SystemMonitorApp.razor.cs b/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/SystemMonitorApp.razor.cs new file mode 100644 index 0000000..e8db163 --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/SystemMonitorApp.razor.cs @@ -0,0 +1,106 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace HackerOs.Ecosystem.Modules; + +/// +/// A gothic/hacker themed resource monitor. It mimics a real system monitor by +/// polling a collocated JS sampler once per second and rendering CPU, memory and +/// network gauges plus a simulated process table. +/// +public partial class SystemMonitorApp +{ + private readonly List _processes = new(); + private IJSObjectReference? _module; + private System.Threading.Timer? _timer; + private int _cpu; + private int _mem; + private int _net; + private int _ticks; + + [Inject] private IJSRuntime JS { get; set; } = default!; + + private sealed record ProcessRow(int Pid, string Name, int Cpu, int Mem); + + /// + protected override void OnInitialized() + { + base.OnInitialized(); + SeedProcesses(); + } + + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + { + return; + } + + _module = await JS.InvokeAsync( + "import", "./Modules/SystemMonitorApp.razor.js"); + + await SampleAsync(); + + // Mimic a real monitor with a periodic refresh. + _timer = new System.Threading.Timer( + _ => _ = InvokeAsync(SampleAsync), null, + TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1)); + } + + private async Task SampleAsync() + { + if (_module is null) + { + return; + } + + var reading = await _module.InvokeAsync("sample"); + _cpu = reading.Cpu; + _mem = reading.Mem; + _net = reading.Net; + _ticks++; + + // Nudge the process table so it feels alive. + for (var i = 0; i < _processes.Count; i++) + { + var jitter = reading.Jitter[i % reading.Jitter.Length]; + _processes[i] = _processes[i] with + { + Cpu = Math.Clamp(_processes[i].Cpu + jitter, 0, 99) + }; + } + + StateHasChanged(); + } + + private void SeedProcesses() + { + _processes.Clear(); + _processes.Add(new ProcessRow(1337, "kerneld", 3, 128)); + _processes.Add(new ProcessRow(2048, "netscan", 12, 64)); + _processes.Add(new ProcessRow(3141, "cryptominer", 41, 512)); + _processes.Add(new ProcessRow(4096, "hackershell", 2, 32)); + _processes.Add(new ProcessRow(8192, "watchdog", 1, 16)); + } + + private sealed record Reading(int Cpu, int Mem, int Net, int[] Jitter); + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + _timer?.Dispose(); + _timer = null; + if (_module is not null) + { + // Fire and forget; the circuit may already be tearing down. + _ = _module.DisposeAsync().AsTask(); + _module = null; + } + } + + base.Dispose(disposing); + } +} diff --git a/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/SystemMonitorApp.razor.css b/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/SystemMonitorApp.razor.css new file mode 100644 index 0000000..0c0c650 --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/SystemMonitorApp.razor.css @@ -0,0 +1,91 @@ +.sysmon { + height: 100%; + box-sizing: border-box; + padding: 18px; + background: #05080a; + color: #35ff7a; + font-family: 'Consolas', 'Courier New', monospace; + overflow-y: auto; +} + +.sysmon-grid { + display: grid; + grid-template-columns: 1fr; + gap: 14px; +} + +.sysmon-gauge { + display: grid; + grid-template-columns: 48px 1fr 90px; + align-items: center; + gap: 12px; +} + +.sysmon-gauge-label { + font-size: 12px; + letter-spacing: 2px; + color: #1f9e50; +} + +.sysmon-bar { + height: 16px; + background: rgba(53, 255, 122, 0.08); + border: 1px solid rgba(53, 255, 122, 0.3); + border-radius: 3px; + overflow: hidden; +} + +.sysmon-bar-fill { + height: 100%; + transition: width 0.6s ease; + box-shadow: 0 0 10px currentColor; +} + +.sysmon-bar-fill.cpu { background: #35ff7a; color: #35ff7a; } +.sysmon-bar-fill.mem { background: #ffd23f; color: #ffd23f; } +.sysmon-bar-fill.net { background: #3fa9ff; color: #3fa9ff; } + +.sysmon-gauge-value { + text-align: right; + font-size: 13px; + color: #d6ffe6; +} + +.sysmon-processes { + margin-top: 20px; + border-top: 1px dashed rgba(53, 255, 122, 0.25); + padding-top: 12px; +} + +.sysmon-processes-title { + color: #1f9e50; + font-size: 12px; + margin-bottom: 8px; + letter-spacing: 1px; +} + +.sysmon-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.sysmon-table th { + text-align: left; + color: #1f9e50; + border-bottom: 1px solid rgba(53, 255, 122, 0.25); + padding: 4px 6px; + font-weight: 600; +} + +.sysmon-table td { + padding: 4px 6px; + color: #9dffc4; + border-bottom: 1px solid rgba(53, 255, 122, 0.08); +} + +.sysmon-ticks { + margin-top: 14px; + font-size: 11px; + color: #1f9e50; +} diff --git a/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/SystemMonitorApp.razor.js b/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/SystemMonitorApp.razor.js new file mode 100644 index 0000000..db59c2c --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/SystemMonitorApp.razor.js @@ -0,0 +1,19 @@ +/* + * Collocated JavaScript module for the System Monitor application. + * Produces pseudo-random resource samples. Keeping the sampler in a scoped + * ".razor.js" file honours the ecosystem rule of no inline scripts. + */ + +/** + * Returns a single resource reading. + * @returns {{cpu:number, mem:number, net:number, jitter:number[]}} + */ +export function sample() { + const rand = (min, max) => Math.floor(min + Math.random() * (max - min)); + return { + cpu: rand(5, 95), + mem: rand(30, 85), + net: rand(0, 2048), + jitter: [rand(-3, 4), rand(-2, 3), rand(-4, 5), rand(-1, 2), rand(-1, 2)] + }; +} diff --git a/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/WelcomeApp.razor b/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/WelcomeApp.razor new file mode 100644 index 0000000..56c1057 --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/WelcomeApp.razor @@ -0,0 +1,45 @@ +@inherits WindowAppBase +@attribute [App("Welcome", Id = "hackeros.welcome", Icon = "\U0001F44B", Category = "System", + Description = "Introduction to the HackerOS developer ecosystem", SortOrder = 10)] +@inject AppRegistry Registry + + +
+
+ +
+

Welcome to HackerOS

+

A modular Blazor WebAssembly desktop framework.

+
+
+ +
+

+ Every component that derives from WindowAppBase or + TerminalAppBase and is decorated with [App] + is discovered automatically, added to the START menu, + and shown on the taskbar — no manual registration required. +

+ +
+ Runtime + @_environment +
+ +
+ + +
+ +

+ @Registry.LauncherApps.Count applications currently registered. +

+
+
+
diff --git a/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/WelcomeApp.razor.cs b/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/WelcomeApp.razor.cs new file mode 100644 index 0000000..71304a5 --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/WelcomeApp.razor.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace HackerOs.Ecosystem.Modules; + +/// +/// A modern-styled welcome window that introduces the ecosystem and demonstrates +/// launching other applications through the . +/// +public partial class WelcomeApp +{ + private string _environment = "detecting\u2026"; + + [Inject] private IJSRuntime JS { get; set; } = default!; + + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + { + return; + } + + // Load the collocated module, use it, and dispose it immediately so no + // JS reference lingers after the window closes. + await using var module = await JS.InvokeAsync( + "import", "./Modules/WelcomeApp.razor.js"); + _environment = await module.InvokeAsync("describeEnvironment"); + StateHasChanged(); + } + + private void OpenShell() => Registry.Launch("hackeros.hackershell"); + + private void OpenMonitor() => Registry.Launch("hackeros.sysmon"); +} diff --git a/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/WelcomeApp.razor.css b/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/WelcomeApp.razor.css new file mode 100644 index 0000000..c84f94e --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/WelcomeApp.razor.css @@ -0,0 +1,111 @@ +.welcome { + height: 100%; + box-sizing: border-box; + padding: 24px; + background: linear-gradient(160deg, #101826 0%, #0a0f1a 100%); + color: #e6edf3; + font-family: 'Segoe UI', sans-serif; + overflow-y: auto; +} + +.welcome-hero { + display: flex; + align-items: center; + gap: 18px; + padding-bottom: 18px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.welcome-logo { + font-size: 46px; + line-height: 1; +} + +.welcome-title { + margin: 0; + font-size: 26px; + font-weight: 700; + background: linear-gradient(90deg, #58a6ff, #7ee787); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +.welcome-tagline { + margin: 4px 0 0; + color: #8b949e; + font-size: 14px; +} + +.welcome-body { + padding-top: 18px; + line-height: 1.55; + font-size: 14px; +} + +.welcome-body code { + background: rgba(110, 118, 129, 0.25); + padding: 1px 6px; + border-radius: 4px; + font-family: 'Consolas', 'Courier New', monospace; + color: #7ee787; +} + +.welcome-stat { + display: flex; + flex-direction: column; + gap: 2px; + margin: 18px 0; + padding: 12px 16px; + background: rgba(88, 166, 255, 0.08); + border: 1px solid rgba(88, 166, 255, 0.25); + border-radius: 8px; +} + +.welcome-stat-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 1px; + color: #58a6ff; +} + +.welcome-stat-value { + font-family: 'Consolas', 'Courier New', monospace; + font-size: 14px; + color: #e6edf3; +} + +.welcome-actions { + display: flex; + gap: 12px; + margin: 20px 0 12px; + flex-wrap: wrap; +} + +.welcome-btn { + padding: 9px 18px; + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 8px; + background: rgba(255, 255, 255, 0.04); + color: #e6edf3; + cursor: pointer; + font-size: 13px; + font-weight: 600; + transition: transform 0.1s ease, background 0.15s ease, border-color 0.15s ease; +} + +.welcome-btn:hover { + transform: translateY(-1px); + background: rgba(255, 255, 255, 0.1); +} + +.welcome-btn.primary { + background: linear-gradient(90deg, #1f6feb, #238636); + border-color: transparent; +} + +.welcome-count { + color: #8b949e; + font-size: 12px; + font-family: 'Consolas', 'Courier New', monospace; +} diff --git a/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/WelcomeApp.razor.js b/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/WelcomeApp.razor.js new file mode 100644 index 0000000..9912f36 --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/WelcomeApp.razor.js @@ -0,0 +1,14 @@ +/* + * Collocated JavaScript module for the Welcome application. + * Demonstrates the scoped ".razor.js" convention: no inline scripts in markup. + */ + +/** + * Returns a short human readable description of the current browser runtime. + * @returns {string} + */ +export function describeEnvironment() { + const platform = navigator.platform || 'unknown platform'; + const cores = navigator.hardwareConcurrency || '?'; + return `Blazor WebAssembly \u00B7 ${platform} \u00B7 ${cores} logical cores`; +} diff --git a/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Program.cs b/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Program.cs new file mode 100644 index 0000000..28d06df --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Program.cs @@ -0,0 +1,17 @@ +using HackerOs.AppFramework.Extensions; +using HackerOs.Ecosystem; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); +builder.RootComponents.Add("#app"); +builder.RootComponents.Add("head::after"); + +builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); + +// Register the HackerOS application framework and auto-discover every module in +// this assembly that is decorated with [App]. Adding a new application is as +// simple as dropping a new component into the Modules folder. +builder.Services.AddHackerOsAppFramework(typeof(App).Assembly); + +await builder.Build().RunAsync(); diff --git a/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/_Imports.razor b/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/_Imports.razor new file mode 100644 index 0000000..bc0419c --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/_Imports.razor @@ -0,0 +1,16 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.AspNetCore.Components.WebAssembly.Http +@using Microsoft.JSInterop +@using HackerOs.Ecosystem +@using BlazorWindowManager.Components +@using BlazorWindowManager.Services +@using BlazorWindowManager.Models +@using HackerOs.AppFramework +@using HackerOs.AppFramework.Abstractions +@using HackerOs.AppFramework.Components +@using HackerOs.AppFramework.Registry diff --git a/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/wwwroot/css/app.css b/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/wwwroot/css/app.css new file mode 100644 index 0000000..08b5997 --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/wwwroot/css/app.css @@ -0,0 +1,115 @@ +html, body { + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; +} + +h1:focus { + outline: none; +} + +a, .btn-link { + color: #0071c1; +} + +.btn-primary { + color: #fff; + background-color: #1b6ec2; + border-color: #1861ac; +} + +.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus { + box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb; +} + +.content { + padding-top: 1.1rem; +} + +.valid.modified:not([type=checkbox]) { + outline: 1px solid #26b050; +} + +.invalid { + outline: 1px solid red; +} + +.validation-message { + color: red; +} + +#blazor-error-ui { + color-scheme: light only; + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + box-sizing: border-box; + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } + +.loading-progress { + position: absolute; + display: block; + width: 8rem; + height: 8rem; + inset: 20vh 0 auto 0; + margin: 0 auto 0 auto; +} + + .loading-progress circle { + fill: none; + stroke: #e0e0e0; + stroke-width: 0.6rem; + transform-origin: 50% 50%; + transform: rotate(-90deg); + } + + .loading-progress circle:last-child { + stroke: #1b6ec2; + stroke-dasharray: calc(3.141 * var(--blazor-load-percentage, 0%) * 0.8), 500%; + transition: stroke-dasharray 0.05s ease-in-out; + } + +.loading-progress-text { + position: absolute; + text-align: center; + font-weight: bold; + inset: calc(20vh + 3.25rem) 0 auto 0.2rem; +} + + .loading-progress-text:after { + content: var(--blazor-load-percentage-text, "Loading"); + } + +code { + color: #c02d76; +} + +.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder { + color: var(--bs-secondary-color); + text-align: end; +} + +.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder { + text-align: start; +} \ No newline at end of file diff --git a/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/wwwroot/favicon.png b/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/wwwroot/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..8422b59695935d180d11d5dbe99653e711097819 GIT binary patch literal 1148 zcmV-?1cUpDP)9h26h2-Cs%i*@Moc3?#6qJID|D#|3|2Hn7gTIYEkr|%Xjp);YgvFmB&0#2E2b=| zkVr)lMv9=KqwN&%obTp-$<51T%rx*NCwceh-E+=&e(oLO`@Z~7gybJ#U|^tB2Pai} zRN@5%1qsZ1e@R(XC8n~)nU1S0QdzEYlWPdUpH{wJ2Pd4V8kI3BM=)sG^IkUXF2-j{ zrPTYA6sxpQ`Q1c6mtar~gG~#;lt=s^6_OccmRd>o{*=>)KS=lM zZ!)iG|8G0-9s3VLm`bsa6e ze*TlRxAjXtm^F8V`M1%s5d@tYS>&+_ga#xKGb|!oUBx3uc@mj1%=MaH4GR0tPBG_& z9OZE;->dO@`Q)nr<%dHAsEZRKl zedN6+3+uGHejJp;Q==pskSAcRcyh@6mjm2z-uG;s%dM-u0*u##7OxI7wwyCGpS?4U zBFAr(%GBv5j$jS@@t@iI8?ZqE36I^4t+P^J9D^ELbS5KMtZ z{Qn#JnSd$15nJ$ggkF%I4yUQC+BjDF^}AtB7w348EL>7#sAsLWs}ndp8^DsAcOIL9 zTOO!!0!k2`9BLk25)NeZp7ev>I1Mn={cWI3Yhx2Q#DnAo4IphoV~R^c0x&nw*MoIV zPthX?{6{u}sMS(MxD*dmd5rU(YazQE59b|TsB5Tm)I4a!VaN@HYOR)DwH1U5y(E)z zQqQU*B%MwtRQ$%x&;1p%ANmc|PkoFJZ%<-uq%PX&C!c-7ypis=eP+FCeuv+B@h#{4 zGx1m0PjS~FJt}3mdt4c!lel`1;4W|03kcZRG+DzkTy|7-F~eDsV2Tx!73dM0H0CTh zl)F-YUkE1zEzEW(;JXc|KR5{ox%YTh{$%F$a36JP6Nb<0%#NbSh$dMYF-{ z1_x(Vx)}fs?5_|!5xBTWiiIQHG<%)*e=45Fhjw_tlnmlixq;mUdC$R8v#j( zhQ$9YR-o%i5Uc`S?6EC51!bTRK=Xkyb<18FkCKnS2;o*qlij1YA@-nRpq#OMTX&RbL<^2q@0qja!uIvI;j$6>~k@IMwD42=8$$!+R^@5o6HX(*n~v0A9xRwxP|bki~~&uFk>U z#P+PQh zyZ;-jwXKqnKbb6)@RaxQz@vm={%t~VbaZrdbaZrdbaeEeXj>~BG?&`J0XrqR#sSlO zg~N5iUk*15JibvlR1f^^1czzNKWvoJtc!Sj*G37QXbZ8LeD{Fzxgdv#Q{x}ytfZ5q z+^k#NaEp>zX_8~aSaZ`O%B9C&YLHb(mNtgGD&Kezd5S@&C=n~Uy1NWHM`t07VQP^MopUXki{2^#ryd94>UJMYW|(#4qV`kb7eD)Q=~NN zaVIRi@|TJ!Rni8J=5DOutQ#bEyMVr8*;HU|)MEKmVC+IOiDi9y)vz=rdtAUHW$yjt zrj3B7v(>exU=IrzC<+?AE=2vI;%fafM}#ShGDZx=0Nus5QHKdyb9pw&4>4XCpa-o?P(Gnco1CGX|U> z$f+_tA3+V~<{MU^A%eP!8R*-sD9y<>Jc7A(;aC5hVbs;kX9&Sa$JMG!W_BLFQa*hM zri__C@0i0U1X#?)Y=)>JpvTnY6^s;fu#I}K9u>OldV}m!Ch`d1Vs@v9 zb}w(!TvOmSzmMBa9gYvD4xocL2r0ds6%Hs>Z& z#7#o9PGHDmfG%JQq`O5~dt|MAQN@2wyJw_@``7Giyy(yyk(m8U*kk5$X1^;3$a3}N^Lp6hE5!#8l z#~NYHmKAs6IAe&A;bvM8OochRmXN>`D`{N$%#dZCRxp4-dJ?*3P}}T`tYa3?zz5BA zTu7uE#GsDpZ$~j9q=Zq!LYjLbZPXFILZK4?S)C-zE1(dC2d<7nO4-nSCbV#9E|E1MM|V<9>i4h?WX*r*ul1 z5#k6;po8z=fdMiVVz*h+iaTlz#WOYmU^SX5#97H~B32s-#4wk<1NTN#g?LrYieCu> zF7pbOLR;q2D#Q`^t%QcY06*X-jM+ei7%ZuanUTH#9Y%FBi*Z#22({_}3^=BboIsbg zR0#jJ>9QR8SnmtSS6x($?$}6$x+q)697#m${Z@G6Ujf=6iO^S}7P`q8DkH!IHd4lB zDzwxt3BHsPAcXFFY^Fj}(073>NL_$A%v2sUW(CRutd%{G`5ow?L`XYSO*Qu?x+Gzv zBtR}Y6`XF4xX7)Z04D+fH;TMapdQFFameUuHL34NN)r@aF4RO%x&NApeWGtr#mG~M z6sEIZS;Uj1HB1*0hh=O@0q1=Ia@L>-tETu-3n(op+97E z#&~2xggrl(LA|giII;RwBlX2^Q`B{_t}gxNL;iB11gEPC>v` zb4SJ;;BFOB!{chn>?cCeGDKuqI0+!skyWTn*k!WiPNBf=8rn;@y%( znhq%8fj2eAe?`A5mP;TE&iLEmQ^xV%-kmC-8mWao&EUK_^=GW-Y3z ksi~={si~={skwfB0gq6itke#r1ONa407*qoM6N<$g11Kq@c;k- literal 0 HcmV?d00001 diff --git a/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/wwwroot/index.html b/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/wwwroot/index.html new file mode 100644 index 0000000..5a2847e --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/wwwroot/index.html @@ -0,0 +1,32 @@ + + + + + + + HackerOs.Ecosystem + + + + + + + + +
+ + + + +
+
+ +
+ An unhandled error has occurred. + Reload + ๐Ÿ—™ +
+ + + + diff --git a/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/wwwroot/sample-data/weather.json b/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/wwwroot/sample-data/weather.json new file mode 100644 index 0000000..f0648e7 --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/wwwroot/sample-data/weather.json @@ -0,0 +1,27 @@ +๏ปฟ[ + { + "date": "2022-01-06", + "temperatureC": 1, + "summary": "Freezing" + }, + { + "date": "2022-01-07", + "temperatureC": 14, + "summary": "Bracing" + }, + { + "date": "2022-01-08", + "temperatureC": -13, + "summary": "Freezing" + }, + { + "date": "2022-01-09", + "temperatureC": -16, + "summary": "Balmy" + }, + { + "date": "2022-01-10", + "temperatureC": -2, + "summary": "Chilly" + } +] diff --git a/wasm2/HackerOs/Ecosystem/tests/e2e/desktop.spec.ts b/wasm2/HackerOs/Ecosystem/tests/e2e/desktop.spec.ts new file mode 100644 index 0000000..10265aa --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/tests/e2e/desktop.spec.ts @@ -0,0 +1,35 @@ +import { test, expect } from '@playwright/test'; +import { boot, openStartMenu } from './helpers'; + +test.describe('Desktop shell and self-registration', () => { + test('desktop boots and the taskbar start button is present', async ({ page }) => { + await boot(page); + await expect(page.getByTestId('start-button')).toContainText('START'); + }); + + test('the START menu lists every self-registered application', async ({ page }) => { + await boot(page); + await openStartMenu(page); + + // Each [App]-decorated module must appear without any manual wiring. + await expect( + page.locator('[data-testid="app-item"][data-app-id="hackeros.welcome"]'), + ).toBeVisible(); + await expect( + page.locator('[data-testid="app-item"][data-app-id="hackeros.sysmon"]'), + ).toBeVisible(); + await expect( + page.locator('[data-testid="app-item"][data-app-id="hackeros.hackershell"]'), + ).toBeVisible(); + + const count = await page.getByTestId('app-item').count(); + expect(count).toBeGreaterThanOrEqual(3); + }); + + test('the START menu closes when clicking outside of it', async ({ page }) => { + await boot(page); + await openStartMenu(page); + await page.getByTestId('desktop').click({ position: { x: 400, y: 200 } }); + await expect(page.getByTestId('app-menu')).toBeHidden(); + }); +}); diff --git a/wasm2/HackerOs/Ecosystem/tests/e2e/hacker-shell.spec.ts b/wasm2/HackerOs/Ecosystem/tests/e2e/hacker-shell.spec.ts new file mode 100644 index 0000000..7223301 --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/tests/e2e/hacker-shell.spec.ts @@ -0,0 +1,52 @@ +import { test, expect } from '@playwright/test'; +import { boot, launchApp, taskbarButton } from './helpers'; + +test.describe('Hacker Shell terminal application', () => { + test('launches with its banner and prompt', async ({ page }) => { + await boot(page); + await launchApp(page, 'hackeros.hackershell'); + + await expect(taskbarButton(page, 'Hacker Shell')).toBeVisible(); + + const screen = page.locator('.terminal-screen').first(); + await expect(screen).toContainText('HackerOS'); + // The prompt is written for the first input line. + await expect(screen).toContainText('root@hackeros'); + }); + + test('echoes typed input and executes commands', async ({ page }) => { + await boot(page); + await launchApp(page, 'hackeros.hackershell'); + + const terminal = page.locator('.terminal-container').first(); + await expect(terminal).toBeVisible(); + await terminal.click({ force: true, noWaitAfter: true }); + + // `echo` prints a contiguous token back that is easy to assert on. + await page.keyboard.type('echo HELLOWORLD'); + await page.keyboard.press('Enter'); + + const screen = page.locator('.terminal-screen').first(); + await expect(screen).toContainText('HELLOWORLD'); + + // `whoami` is handled entirely in C#. + await page.keyboard.type('whoami'); + await page.keyboard.press('Enter'); + await expect(screen).toContainText('root'); + }); + + test('can launch other apps from the console', async ({ page }) => { + await boot(page); + await launchApp(page, 'hackeros.hackershell'); + + const terminal = page.locator('.terminal-container').first(); + await expect(terminal).toBeVisible(); + await terminal.click({ force: true, noWaitAfter: true }); + + await page.keyboard.type('launch hackeros.sysmon'); + await page.keyboard.press('Enter'); + + await expect(page.getByTestId('sysmon-app')).toBeVisible(); + await expect(taskbarButton(page, 'System Monitor')).toBeVisible(); + }); +}); diff --git a/wasm2/HackerOs/Ecosystem/tests/e2e/helpers.ts b/wasm2/HackerOs/Ecosystem/tests/e2e/helpers.ts new file mode 100644 index 0000000..c07dedf --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/tests/e2e/helpers.ts @@ -0,0 +1,37 @@ +import { Page, expect } from '@playwright/test'; + +/** + * Navigates to the desktop and waits for the Blazor WASM runtime to boot. + */ +export async function boot(page: Page): Promise { + await page.goto('/'); + // The desktop shell renders once the framework has started. + await expect(page.getByTestId('desktop')).toBeVisible({ timeout: 45_000 }); + // Wait for the start button to be interactive. + await expect(page.getByTestId('start-button')).toBeVisible(); +} + +/** + * Opens the START menu. + */ +export async function openStartMenu(page: Page): Promise { + await page.getByTestId('start-button').click(); + await expect(page.getByTestId('app-menu')).toBeVisible(); +} + +/** + * Launches an application from the START menu by its registered id. + */ +export async function launchApp(page: Page, appId: string): Promise { + await openStartMenu(page); + await page.locator(`[data-testid="app-item"][data-app-id="${appId}"]`).click({ noWaitAfter: true }); + // Menu closes after launching. + await expect(page.getByTestId('app-menu')).toBeHidden(); +} + +/** + * Returns the taskbar button locator for a window whose title matches. + */ +export function taskbarButton(page: Page, title: string) { + return page.locator('.window-button', { hasText: title }); +} diff --git a/wasm2/HackerOs/Ecosystem/tests/e2e/system-monitor.spec.ts b/wasm2/HackerOs/Ecosystem/tests/e2e/system-monitor.spec.ts new file mode 100644 index 0000000..d9c1034 --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/tests/e2e/system-monitor.spec.ts @@ -0,0 +1,32 @@ +import { test, expect } from '@playwright/test'; +import { boot, launchApp, taskbarButton } from './helpers'; + +test.describe('System Monitor window application', () => { + test('launches and renders its resource gauges', async ({ page }) => { + await boot(page); + await launchApp(page, 'hackeros.sysmon'); + + await expect(page.getByTestId('sysmon-app')).toBeVisible(); + await expect(page.getByTestId('sysmon-cpu')).toBeVisible(); + await expect(page.getByTestId('sysmon-mem')).toBeVisible(); + await expect(page.getByTestId('sysmon-net')).toBeVisible(); + await expect(taskbarButton(page, 'System Monitor')).toBeVisible(); + }); + + test('polls its collocated sampler so samples increase over time', async ({ page }) => { + await boot(page); + await launchApp(page, 'hackeros.sysmon'); + + const ticks = page.getByTestId('sysmon-ticks'); + await expect(ticks).toContainText('samples:'); + + const readCount = async () => + Number((await ticks.textContent())?.replace(/\D/g, '') ?? '0'); + + const initial = await readCount(); + // The periodic timer samples once per second. + await page.waitForTimeout(2500); + const later = await readCount(); + expect(later).toBeGreaterThan(initial); + }); +}); diff --git a/wasm2/HackerOs/Ecosystem/tests/e2e/taskbar.spec.ts b/wasm2/HackerOs/Ecosystem/tests/e2e/taskbar.spec.ts new file mode 100644 index 0000000..1be37aa --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/tests/e2e/taskbar.spec.ts @@ -0,0 +1,31 @@ +import { test, expect } from '@playwright/test'; +import { boot, launchApp, taskbarButton } from './helpers'; + +test.describe('Taskbar window management', () => { + test('minimizes and restores a window from its taskbar button', async ({ page }) => { + await boot(page); + await launchApp(page, 'hackeros.welcome'); + + await expect(page.getByTestId('welcome-app')).toBeVisible(); + const button = taskbarButton(page, 'Welcome'); + await expect(button).toBeVisible(); + + // Clicking the active window's taskbar button minimizes it. + await button.click(); + await expect(page.getByTestId('welcome-app')).toBeHidden(); + + // Clicking again restores it. + await button.click(); + await expect(page.getByTestId('welcome-app')).toBeVisible(); + }); + + test('tracks multiple windows independently on the taskbar', async ({ page }) => { + await boot(page); + await launchApp(page, 'hackeros.welcome'); + await launchApp(page, 'hackeros.sysmon'); + + await expect(taskbarButton(page, 'Welcome')).toBeVisible(); + await expect(taskbarButton(page, 'System Monitor')).toBeVisible(); + await expect(page.locator('.window-button')).toHaveCount(2); + }); +}); diff --git a/wasm2/HackerOs/Ecosystem/tests/e2e/welcome.spec.ts b/wasm2/HackerOs/Ecosystem/tests/e2e/welcome.spec.ts new file mode 100644 index 0000000..448255e --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/tests/e2e/welcome.spec.ts @@ -0,0 +1,33 @@ +import { test, expect } from '@playwright/test'; +import { boot, launchApp, taskbarButton } from './helpers'; + +test.describe('Welcome window application', () => { + test('launches, self-populates its title bar and appears on the taskbar', async ({ page }) => { + await boot(page); + await launchApp(page, 'hackeros.welcome'); + + // The window (a WindowAppBase) renders its content. + await expect(page.getByTestId('welcome-app')).toBeVisible(); + await expect(page.locator('.welcome-title')).toContainText('Welcome to HackerOS'); + + // The taskbar entry is created automatically by the window manager. + await expect(taskbarButton(page, 'Welcome')).toBeVisible(); + }); + + test('runs its collocated .razor.js module to report the runtime', async ({ page }) => { + await boot(page); + await launchApp(page, 'hackeros.welcome'); + + // describeEnvironment() from WelcomeApp.razor.js fills this in. + await expect(page.getByTestId('welcome-env')).toContainText('Blazor WebAssembly'); + }); + + test('can launch another application from within the window', async ({ page }) => { + await boot(page); + await launchApp(page, 'hackeros.welcome'); + + await page.getByTestId('welcome-open-monitor').click(); + await expect(page.getByTestId('sysmon-app')).toBeVisible(); + await expect(taskbarButton(page, 'System Monitor')).toBeVisible(); + }); +}); diff --git a/wasm2/HackerOs/Ecosystem/tests/package-lock.json b/wasm2/HackerOs/Ecosystem/tests/package-lock.json new file mode 100644 index 0000000..ba56ea2 --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/tests/package-lock.json @@ -0,0 +1,78 @@ +{ + "name": "hackeros-ecosystem-tests", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hackeros-ecosystem-tests", + "version": "1.0.0", + "devDependencies": { + "@playwright/test": "^1.47.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/wasm2/HackerOs/Ecosystem/tests/package.json b/wasm2/HackerOs/Ecosystem/tests/package.json new file mode 100644 index 0000000..78a3382 --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/tests/package.json @@ -0,0 +1,12 @@ +{ + "name": "hackeros-ecosystem-tests", + "version": "1.0.0", + "private": true, + "description": "Playwright end-to-end tests for the HackerOS developer ecosystem.", + "scripts": { + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "^1.47.0" + } +} diff --git a/wasm2/HackerOs/Ecosystem/tests/playwright.config.ts b/wasm2/HackerOs/Ecosystem/tests/playwright.config.ts new file mode 100644 index 0000000..b521b06 --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/tests/playwright.config.ts @@ -0,0 +1,38 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Playwright configuration for the HackerOS ecosystem end-to-end tests. + * + * The `webServer` block builds and serves the Blazor WebAssembly host so the + * suite is fully self contained: `npm test` will compile and launch the app, + * wait for it to respond, then drive it through a real browser. When a dev + * server is already running on the port it is reused. + */ +export default defineConfig({ + testDir: './e2e', + fullyParallel: false, + workers: 1, + timeout: 60_000, + expect: { timeout: 15_000 }, + reporter: [['list']], + use: { + baseURL: 'http://localhost:5229', + trace: 'on-first-retry', + // Blazor WASM needs a moment to download and start the runtime. + actionTimeout: 15_000, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'dotnet run --project ../HackerOs.Ecosystem --urls http://localhost:5229', + url: 'http://localhost:5229', + reuseExistingServer: true, + timeout: 240_000, + stdout: 'pipe', + stderr: 'pipe', + }, +}); diff --git a/wasm2/HackerOs/Ecosystem/tests/test-results/.last-run.json b/wasm2/HackerOs/Ecosystem/tests/test-results/.last-run.json new file mode 100644 index 0000000..cbcc1fb --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/tests/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +} \ No newline at end of file From 84d6ce7508004b23c5afe71117b914fc8fa7106f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:48:33 +0000 Subject: [PATCH 2/4] Address review feedback: safe async disposal, WASM wording, specific reflection catches; add docs --- .../Components/WindowRenderComponent.razor | 10 +- .../Components/AppLauncher.razor.cs | 4 +- .../Components/TerminalHost.razor.cs | 2 +- .../Modules/SystemMonitorApp.razor.cs | 18 +- wasm2/HackerOs/Ecosystem/README.md | 30 +++ .../HackerOs/Ecosystem/docs/app-framework.md | 172 ++++++++++++++++++ .../tests/test-results/.last-run.json | 4 - .../error-context.md | 78 ++++++++ .../error-context.md | 78 ++++++++ .../error-context.md | 78 ++++++++ .../error-context.md | 78 ++++++++ .../error-context.md | 78 ++++++++ .../error-context.md | 78 ++++++++ 13 files changed, 697 insertions(+), 11 deletions(-) create mode 100644 wasm2/HackerOs/Ecosystem/README.md create mode 100644 wasm2/HackerOs/Ecosystem/docs/app-framework.md delete mode 100644 wasm2/HackerOs/Ecosystem/tests/test-results/.last-run.json create mode 100644 wasm2/HackerOs/Ecosystem/tests/test-results/desktop-Desktop-shell-and--0eb6d-when-clicking-outside-of-it-chromium/error-context.md create mode 100644 wasm2/HackerOs/Ecosystem/tests/test-results/desktop-Desktop-shell-and--8b67e-self-registered-application-chromium/error-context.md create mode 100644 wasm2/HackerOs/Ecosystem/tests/test-results/desktop-Desktop-shell-and--d13ad-bar-start-button-is-present-chromium/error-context.md create mode 100644 wasm2/HackerOs/Ecosystem/tests/test-results/hacker-shell-Hacker-Shell--4ac31-input-and-executes-commands-chromium/error-context.md create mode 100644 wasm2/HackerOs/Ecosystem/tests/test-results/hacker-shell-Hacker-Shell--5514a--with-its-banner-and-prompt-chromium/error-context.md create mode 100644 wasm2/HackerOs/Ecosystem/tests/test-results/hacker-shell-Hacker-Shell--8227c-other-apps-from-the-console-chromium/error-context.md diff --git a/wasm2/HackerOs/BlazorWindowManager/BlazorWindowManager/Components/WindowRenderComponent.razor b/wasm2/HackerOs/BlazorWindowManager/BlazorWindowManager/Components/WindowRenderComponent.razor index 16942e2..f1e2d2b 100644 --- a/wasm2/HackerOs/BlazorWindowManager/BlazorWindowManager/Components/WindowRenderComponent.razor +++ b/wasm2/HackerOs/BlazorWindowManager/BlazorWindowManager/Components/WindowRenderComponent.razor @@ -1,6 +1,7 @@ @using BlazorWindowManager.Models @using BlazorWindowManager.Services @using System.Reflection +@using System.IO @using Microsoft.AspNetCore.Components @using Microsoft.AspNetCore.Components.Web @using Microsoft.Extensions.DependencyInjection @@ -100,8 +101,11 @@ { candidate = loadedAssembly.GetType(typeName); } - catch + catch (Exception ex) when ( + ex is TypeLoadException or FileLoadException or BadImageFormatException or IOException) { + // The assembly could not resolve the type (for example a + // dynamic or partially loaded assembly); skip it. candidate = null; } @@ -120,8 +124,10 @@ { loadedTypes = ex.Types.Where(t => t is not null).Cast().ToArray(); } - catch + catch (Exception ex) when ( + ex is FileLoadException or BadImageFormatException or IOException) { + // Assembly types could not be enumerated; skip this assembly. continue; } diff --git a/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/AppLauncher.razor.cs b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/AppLauncher.razor.cs index 93ba08e..bd07d99 100644 --- a/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/AppLauncher.razor.cs +++ b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/AppLauncher.razor.cs @@ -89,7 +89,7 @@ private async Task StopOutsideClickAsync() } catch (JSDisconnectedException) { - // Circuit gone; ignore. + // JS runtime disconnected (app shutting down); ignore. } } } @@ -108,7 +108,7 @@ public async ValueTask DisposeAsync() } catch (JSDisconnectedException) { - // Circuit gone; ignore. + // JS runtime disconnected (app shutting down); ignore. } } diff --git a/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/TerminalHost.razor.cs b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/TerminalHost.razor.cs index ccf2a4b..f3874b5 100644 --- a/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/TerminalHost.razor.cs +++ b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/TerminalHost.razor.cs @@ -143,7 +143,7 @@ public async ValueTask DisposeAsync() } catch (JSDisconnectedException) { - // Circuit already gone; nothing to clean up. + // JS runtime already disconnected (app shutting down); nothing to clean up. } } } diff --git a/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/SystemMonitorApp.razor.cs b/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/SystemMonitorApp.razor.cs index e8db163..18be08a 100644 --- a/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/SystemMonitorApp.razor.cs +++ b/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/SystemMonitorApp.razor.cs @@ -95,12 +95,26 @@ protected override void Dispose(bool disposing) _timer = null; if (_module is not null) { - // Fire and forget; the circuit may already be tearing down. - _ = _module.DisposeAsync().AsTask(); + // Fire and forget; the JS runtime may already be disconnected as + // the application shuts down. Swallow that expected failure so the + // task does not raise an unobserved exception. + _ = DisposeModuleAsync(_module); _module = null; } } base.Dispose(disposing); } + + private static async Task DisposeModuleAsync(IJSObjectReference module) + { + try + { + await module.DisposeAsync(); + } + catch (JSDisconnectedException) + { + // JS runtime already gone; nothing to clean up. + } + } } diff --git a/wasm2/HackerOs/Ecosystem/README.md b/wasm2/HackerOs/Ecosystem/README.md new file mode 100644 index 0000000..d27dfea --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/README.md @@ -0,0 +1,30 @@ +# HackerOS Ecosystem + +A modular Blazor WebAssembly framework for building a self-registering +application desktop. Write a component that inherits `WindowAppBase` or +`TerminalAppBase`, decorate it with `[App]`, and it appears in the START menu and +taskbar automatically. + +## Projects + +- **`HackerOs.AppFramework`** — the reusable framework (Razor Class Library). +- **`HackerOs.Ecosystem`** — the runnable host app with sample modules. +- **`tests`** — Playwright end-to-end tests. + +## Run + +```bash +cd HackerOs.Ecosystem +dotnet run +``` + +## Test + +```bash +cd tests +npm install +npx playwright test +``` + +See [`docs/app-framework.md`](docs/app-framework.md) for the full architecture, +API reference and how to add your own applications. diff --git a/wasm2/HackerOs/Ecosystem/docs/app-framework.md b/wasm2/HackerOs/Ecosystem/docs/app-framework.md new file mode 100644 index 0000000..7d1f677 --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/docs/app-framework.md @@ -0,0 +1,172 @@ +# HackerOS App Framework + +A modular Blazor WebAssembly framework that turns the HackerSimulator project into +a **developer ecosystem**. A developer writes a single Blazor component that +inherits from a window or terminal base class, drops it into the project, and the +component **self-registers**: it appears in the START menu, launches into a +draggable window, and shows up on the taskbar — with **no manual wiring**. + +## Purpose + +- Provide reusable base classes (`WindowAppBase`, `TerminalAppBase`) so new + applications are created by inheritance, not boilerplate. +- Discover and register applications automatically via reflection, using a single + `[App]` attribute as the only extension point. +- Compose a desktop shell (desktop surface + taskbar + START menu) that hosts any + number of self-registered applications. +- Enforce a strict **module-oriented, scoped-asset** convention: every component's + markup, styles and script live in collocated files + (`Component.razor` + `Component.razor.css` + `Component.razor.js`) with **no + inline JS or CSS in markup**. + +## Architecture + +The framework is split into two projects, both located under +`wasm2/HackerOs/Ecosystem/` per the project directory rules. + +### `HackerOs.AppFramework` (Razor Class Library) + +| Area | Type | Responsibility | +| --- | --- | --- | +| Abstractions | `AppAttribute` | Marks a component as a self-registering app (name, id, icon, category, description, sort order, visibility). | +| Abstractions | `AppKind` | Enumeration: `Window` or `Terminal`. | +| Registry | `AppDescriptor` | Immutable metadata for one application. `TryCreate(Type)` validates the base class and reads the `[App]` attribute. | +| Registry | `AppRegistry` | `DiscoverFrom(assemblies)` scans for decorated components; `Register`, `Find`, `Launch(...)` open a window via the window manager. | +| Components | `WindowAppBase` | Inherits `WindowBase`; copies `Title`/`Icon` from `[App]`. Base class for windowed apps. | +| Components | `TerminalAppBase` | Inherits `WindowBase`; renders a `TerminalHost` inside window chrome. Base class for console apps. | +| Components | `TerminalHost` | Reusable interactive console (echo, line editing, backspace, command submit). | +| Components | `AppLauncher` | The START menu; lists registered apps grouped by category. | +| Components | `Desktop` | The shell: composes `DesktopArea` + `TaskBarComponent` + `AppLauncher`. | +| Extensions | `ServiceCollectionExtensions` | `AddHackerOsAppFramework(params Assembly[])` registers the window manager, terminal services and the populated `AppRegistry`. | + +The framework builds on two existing libraries: **BlazorWindowManager** +(window base class, window manager service, desktop area, taskbar) and +**BlazorTerminal** (the terminal rendering component). + +### `HackerOs.Ecosystem` (Blazor WebAssembly host) + +The runnable application. `Program.cs` calls +`AddHackerOsAppFramework(typeof(App).Assembly)` and `App.razor` renders a single +``. Sample modules live under `Modules/`. + +### Launch flow + +``` +AppLauncher / shell command + -> AppRegistry.Launch(descriptor) + -> WindowManagerService.CreateWindow() + -> WindowRenderComponent renders the component in a window + -> TaskBarComponent adds a taskbar button automatically +``` + +## Usage + +### 1. Create a windowed application + +Add a `.razor` component that inherits `WindowAppBase` and is decorated with +`[App]`. Put its styles in `MyApp.razor.css` and any script in `MyApp.razor.js`. + +```razor +@inherits WindowAppBase +@attribute [App("My App", Id = "vendor.myapp", Icon = "\U0001F680", Category = "Development", + Description = "Does something useful")] + + +
Hello from a self-registered window!
+
+``` + +That is all: rebuild and the app appears in the START menu and taskbar. + +### 2. Create a terminal application + +Derive from `TerminalAppBase` and implement `OnCommandAsync`. This can be a pure +C# class — no markup needed. + +```csharp +[App("My Shell", Id = "vendor.myshell", Kind = AppKind.Terminal, Category = "Development")] +public sealed class MyShellApp : TerminalAppBase +{ + protected override string Prompt => "you@host:~$ "; + protected override string? Banner => "My Shell 1.0"; + + protected override Task OnCommandAsync(string command) + { + WriteLine($"you typed: {command}"); + return Task.CompletedTask; + } +} +``` + +### `[App]` attribute reference + +| Member | Meaning | +| --- | --- | +| `Name` (ctor) | Display name in launcher, title bar and taskbar. | +| `Id` | Stable identifier; defaults to the component's full type name. | +| `Description` | Tooltip text in the launcher. | +| `Icon` | Short glyph (emoji recommended, keeps the framework dependency free). | +| `Category` | Grouping used to organise the launcher. | +| `Version` | Optional version string. | +| `HiddenFromLauncher` | Hide from the START menu (still launchable by id). | +| `SortOrder` | Ordering within a category. | + +### `TerminalAppBase` API + +- `OnCommandAsync(string command)` — handle a submitted command (required). +- `OnStartedAsync()` — optional hook after the terminal is ready. +- `Prompt`, `Banner`, `Columns`, `Rows` — overridable presentation. +- `Write`, `WriteLine`, `ClearScreen` — output helpers. + +## Sample modules + +| App | Id | Kind | Theme | +| --- | --- | --- | --- | +| Welcome | `hackeros.welcome` | Window | Modern intro screen; launches other apps. | +| System Monitor | `hackeros.sysmon` | Window | Hacker-themed live gauges polled from a collocated `.razor.js` sampler. | +| Hacker Shell | `hackeros.hackershell` | Terminal | Pure C# console that can list and launch other apps. | + +## Key decisions + +- **Single extension point.** The only thing a developer touches is a component + + `[App]` attribute. Discovery is reflection-based, so there is no central list to + edit. +- **Built on the working libraries, not the legacy monolith.** The framework wraps + `BlazorWindowManager` and `BlazorTerminal`, which build cleanly, instead of the + pre-existing `HackerOs` app. +- **Scoped assets only.** Every component keeps markup/CSS/JS in collocated files; + no inline `style=` or `