diff --git a/.playwright-mcp/page-2026-07-01T15-50-40-357Z.yml b/.playwright-mcp/page-2026-07-01T15-50-40-357Z.yml new file mode 100644 index 0000000..eaaefa3 --- /dev/null +++ b/.playwright-mcp/page-2026-07-01T15-50-40-357Z.yml @@ -0,0 +1,4 @@ +- generic [active]: + - generic: + - img [ref=e1] + - generic [ref=e4]: Loading \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-01T15-52-05-604Z.yml b/.playwright-mcp/page-2026-07-01T15-52-05-604Z.yml new file mode 100644 index 0000000..1e67a72 --- /dev/null +++ b/.playwright-mcp/page-2026-07-01T15-52-05-604Z.yml @@ -0,0 +1,40 @@ +- generic [ref=e5]: + - generic [ref=e6]: + - generic: + - generic: HackerOS + - generic: 3 applications registered ยท open the START menu + - generic [ref=e7]: + - generic [ref=e9]: + - button "โšก START" [active] [ref=e10] [cursor=pointer]: + - generic [ref=e11]: โšก + - generic [ref=e12]: START + - menu [ref=e19]: + - generic [ref=e20]: + - generic [ref=e21]: Applications + - generic [ref=e22]: 3 apps + - generic [ref=e23]: + - generic [ref=e24]: + - generic [ref=e25]: Development + - menuitem "๐Ÿ–ฅ Hacker Shell Terminal" [ref=e27] [cursor=pointer]: + - generic [ref=e28]: ๐Ÿ–ฅ + - generic [ref=e29]: + - generic [ref=e30]: Hacker Shell + - generic [ref=e31]: Terminal + - generic [ref=e32]: + - generic [ref=e33]: System + - generic [ref=e34]: + - menuitem "๐Ÿ‘‹ Welcome Window" [ref=e35] [cursor=pointer]: + - generic [ref=e36]: ๐Ÿ‘‹ + - generic [ref=e37]: + - generic [ref=e38]: Welcome + - generic [ref=e39]: Window + - menuitem "๐Ÿ“ˆ System Monitor Window" [ref=e40] [cursor=pointer]: + - generic [ref=e41]: ๐Ÿ“ˆ + - generic [ref=e42]: + - generic [ref=e43]: System Monitor + - generic [ref=e44]: Window + - generic [ref=e13]: + - generic [ref=e14]: + - generic "Network" [ref=e15]: ๐Ÿ“ถ + - generic "Secure" [ref=e16]: ๐Ÿ”’ + - generic [ref=e18]: 11:52 \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-01T15-52-16-906Z.yml b/.playwright-mcp/page-2026-07-01T15-52-16-906Z.yml new file mode 100644 index 0000000..774681b --- /dev/null +++ b/.playwright-mcp/page-2026-07-01T15-52-16-906Z.yml @@ -0,0 +1,53 @@ +- generic [ref=e5]: + - generic [ref=e6]: + - dialog "Welcome" [ref=e45]: + - generic [ref=e46]: + - generic [ref=e48]: ๐Ÿ‘‹ + - generic [ref=e49]: Welcome + - generic [ref=e50]: + - button "Minimize window" [ref=e51] [cursor=pointer]: + - generic [ref=e52]: โˆ’ + - button "Maximize window" [ref=e53] [cursor=pointer]: + - generic [ref=e54]: โ–ก + - button "Close window" [ref=e55] [cursor=pointer]: + - generic [ref=e56]: ร— + - main [ref=e57]: + - generic [ref=e58]: + - generic [ref=e59]: + - generic [ref=e60]: ๐Ÿ‘‹ + - generic [ref=e61]: + - heading "Welcome to HackerOS" [level=1] [ref=e62] + - paragraph [ref=e63]: A modular Blazor WebAssembly desktop framework. + - generic [ref=e64]: + - paragraph [ref=e65]: + - text: Every component that derives from + - code [ref=e66]: WindowAppBase + - text: or + - code [ref=e67]: TerminalAppBase + - text: and is decorated with + - code [ref=e68]: "[App]" + - text: is discovered automatically, added to the + - strong [ref=e69]: START + - text: menu, and shown on the taskbar โ€” no manual registration required. + - generic [ref=e70]: + - generic [ref=e71]: Runtime + - generic [ref=e72]: Blazor WebAssembly ยท Win32 ยท 16 logical cores + - generic [ref=e73]: + - button "Open Hacker Shell" [ref=e74] [cursor=pointer] + - button "Open System Monitor" [ref=e75] [cursor=pointer] + - paragraph [ref=e76]: 3 applications currently registered. + - generic: + - generic: HackerOS + - generic: 3 applications registered ยท open the START menu + - generic [ref=e7]: + - button "โšก START" [ref=e10] [cursor=pointer]: + - generic [ref=e11]: โšก + - generic [ref=e12]: START + - generic "Welcome" [ref=e87] [cursor=pointer]: + - generic [ref=e89]: ๐Ÿ“„ + - generic [ref=e90]: Welcome + - generic [ref=e13]: + - generic [ref=e14]: + - generic "Network" [ref=e15]: ๐Ÿ“ถ + - generic "Secure" [ref=e16]: ๐Ÿ”’ + - generic [ref=e18]: 11:52 \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-01T15-52-26-751Z.yml b/.playwright-mcp/page-2026-07-01T15-52-26-751Z.yml new file mode 100644 index 0000000..730a747 --- /dev/null +++ b/.playwright-mcp/page-2026-07-01T15-52-26-751Z.yml @@ -0,0 +1,17 @@ +- generic [ref=e5]: + - generic [ref=e6]: + - generic: + - generic: HackerOS + - generic: 3 applications registered ยท open the START menu + - generic [ref=e7]: + - button "โšก START" [ref=e10] [cursor=pointer]: + - generic [ref=e11]: โšก + - generic [ref=e12]: START + - generic "Welcome" [ref=e87] [cursor=pointer]: + - generic [ref=e89]: ๐Ÿ“„ + - generic [ref=e90]: Welcome + - generic [ref=e13]: + - generic [ref=e14]: + - generic "Network" [ref=e15]: ๐Ÿ“ถ + - generic "Secure" [ref=e16]: ๐Ÿ”’ + - generic [ref=e18]: 11:52 \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-01T15-52-40-206Z.yml b/.playwright-mcp/page-2026-07-01T15-52-40-206Z.yml new file mode 100644 index 0000000..774681b --- /dev/null +++ b/.playwright-mcp/page-2026-07-01T15-52-40-206Z.yml @@ -0,0 +1,53 @@ +- generic [ref=e5]: + - generic [ref=e6]: + - dialog "Welcome" [ref=e45]: + - generic [ref=e46]: + - generic [ref=e48]: ๐Ÿ‘‹ + - generic [ref=e49]: Welcome + - generic [ref=e50]: + - button "Minimize window" [ref=e51] [cursor=pointer]: + - generic [ref=e52]: โˆ’ + - button "Maximize window" [ref=e53] [cursor=pointer]: + - generic [ref=e54]: โ–ก + - button "Close window" [ref=e55] [cursor=pointer]: + - generic [ref=e56]: ร— + - main [ref=e57]: + - generic [ref=e58]: + - generic [ref=e59]: + - generic [ref=e60]: ๐Ÿ‘‹ + - generic [ref=e61]: + - heading "Welcome to HackerOS" [level=1] [ref=e62] + - paragraph [ref=e63]: A modular Blazor WebAssembly desktop framework. + - generic [ref=e64]: + - paragraph [ref=e65]: + - text: Every component that derives from + - code [ref=e66]: WindowAppBase + - text: or + - code [ref=e67]: TerminalAppBase + - text: and is decorated with + - code [ref=e68]: "[App]" + - text: is discovered automatically, added to the + - strong [ref=e69]: START + - text: menu, and shown on the taskbar โ€” no manual registration required. + - generic [ref=e70]: + - generic [ref=e71]: Runtime + - generic [ref=e72]: Blazor WebAssembly ยท Win32 ยท 16 logical cores + - generic [ref=e73]: + - button "Open Hacker Shell" [ref=e74] [cursor=pointer] + - button "Open System Monitor" [ref=e75] [cursor=pointer] + - paragraph [ref=e76]: 3 applications currently registered. + - generic: + - generic: HackerOS + - generic: 3 applications registered ยท open the START menu + - generic [ref=e7]: + - button "โšก START" [ref=e10] [cursor=pointer]: + - generic [ref=e11]: โšก + - generic [ref=e12]: START + - generic "Welcome" [ref=e87] [cursor=pointer]: + - generic [ref=e89]: ๐Ÿ“„ + - generic [ref=e90]: Welcome + - generic [ref=e13]: + - generic [ref=e14]: + - generic "Network" [ref=e15]: ๐Ÿ“ถ + - generic "Secure" [ref=e16]: ๐Ÿ”’ + - generic [ref=e18]: 11:52 \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-01T15-52-49-719Z.yml b/.playwright-mcp/page-2026-07-01T15-52-49-719Z.yml new file mode 100644 index 0000000..243db0c --- /dev/null +++ b/.playwright-mcp/page-2026-07-01T15-52-49-719Z.yml @@ -0,0 +1,53 @@ +- generic [ref=e5]: + - generic [ref=e6]: + - dialog "Welcome" [ref=e45]: + - generic [ref=e46]: + - generic [ref=e48]: ๐Ÿ‘‹ + - generic [ref=e49]: Welcome + - generic [ref=e50]: + - button "Minimize window" [ref=e51] [cursor=pointer]: + - generic [ref=e52]: โˆ’ + - button "Restore window" [active] [ref=e91] [cursor=pointer]: + - generic [ref=e54]: โ + - button "Close window" [ref=e55] [cursor=pointer]: + - generic [ref=e56]: ร— + - main [ref=e57]: + - generic [ref=e58]: + - generic [ref=e59]: + - generic [ref=e60]: ๐Ÿ‘‹ + - generic [ref=e61]: + - heading "Welcome to HackerOS" [level=1] [ref=e62] + - paragraph [ref=e63]: A modular Blazor WebAssembly desktop framework. + - generic [ref=e64]: + - paragraph [ref=e65]: + - text: Every component that derives from + - code [ref=e66]: WindowAppBase + - text: or + - code [ref=e67]: TerminalAppBase + - text: and is decorated with + - code [ref=e68]: "[App]" + - text: is discovered automatically, added to the + - strong [ref=e69]: START + - text: menu, and shown on the taskbar โ€” no manual registration required. + - generic [ref=e70]: + - generic [ref=e71]: Runtime + - generic [ref=e72]: Blazor WebAssembly ยท Win32 ยท 16 logical cores + - generic [ref=e73]: + - button "Open Hacker Shell" [ref=e74] [cursor=pointer] + - button "Open System Monitor" [ref=e75] [cursor=pointer] + - paragraph [ref=e76]: 3 applications currently registered. + - generic: + - generic: HackerOS + - generic: 3 applications registered ยท open the START menu + - generic [ref=e7]: + - button "โšก START" [ref=e10] [cursor=pointer]: + - generic [ref=e11]: โšก + - generic [ref=e12]: START + - generic "Welcome" [ref=e87] [cursor=pointer]: + - generic [ref=e89]: ๐Ÿ“„ + - generic [ref=e90]: Welcome + - generic [ref=e13]: + - generic [ref=e14]: + - generic "Network" [ref=e15]: ๐Ÿ“ถ + - generic "Secure" [ref=e16]: ๐Ÿ”’ + - generic [ref=e18]: 11:52 \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-01T15-53-07-908Z.yml b/.playwright-mcp/page-2026-07-01T15-53-07-908Z.yml new file mode 100644 index 0000000..a5ea8fa --- /dev/null +++ b/.playwright-mcp/page-2026-07-01T15-53-07-908Z.yml @@ -0,0 +1,53 @@ +- generic [ref=e5]: + - generic [ref=e6]: + - dialog "Welcome" [ref=e45]: + - generic [ref=e46]: + - generic [ref=e48]: ๐Ÿ‘‹ + - generic [ref=e49]: Welcome + - generic [ref=e50]: + - button "Minimize window" [ref=e51] [cursor=pointer]: + - generic [ref=e52]: โˆ’ + - button "Maximize window" [active] [ref=e92] [cursor=pointer]: + - generic [ref=e54]: โ–ก + - button "Close window" [ref=e55] [cursor=pointer]: + - generic [ref=e56]: ร— + - main [ref=e57]: + - generic [ref=e58]: + - generic [ref=e59]: + - generic [ref=e60]: ๐Ÿ‘‹ + - generic [ref=e61]: + - heading "Welcome to HackerOS" [level=1] [ref=e62] + - paragraph [ref=e63]: A modular Blazor WebAssembly desktop framework. + - generic [ref=e64]: + - paragraph [ref=e65]: + - text: Every component that derives from + - code [ref=e66]: WindowAppBase + - text: or + - code [ref=e67]: TerminalAppBase + - text: and is decorated with + - code [ref=e68]: "[App]" + - text: is discovered automatically, added to the + - strong [ref=e69]: START + - text: menu, and shown on the taskbar โ€” no manual registration required. + - generic [ref=e70]: + - generic [ref=e71]: Runtime + - generic [ref=e72]: Blazor WebAssembly ยท Win32 ยท 16 logical cores + - generic [ref=e73]: + - button "Open Hacker Shell" [ref=e74] [cursor=pointer] + - button "Open System Monitor" [ref=e75] [cursor=pointer] + - paragraph [ref=e76]: 3 applications currently registered. + - generic: + - generic: HackerOS + - generic: 3 applications registered ยท open the START menu + - generic [ref=e7]: + - button "โšก START" [ref=e10] [cursor=pointer]: + - generic [ref=e11]: โšก + - generic [ref=e12]: START + - generic "Welcome" [ref=e87] [cursor=pointer]: + - generic [ref=e89]: ๐Ÿ“„ + - generic [ref=e90]: Welcome + - generic [ref=e13]: + - generic [ref=e14]: + - generic "Network" [ref=e15]: ๐Ÿ“ถ + - generic "Secure" [ref=e16]: ๐Ÿ”’ + - generic [ref=e18]: 11:53 \ No newline at end of file diff --git a/wasm2/HackerOs/BlazorWindowManager/BlazorWindowManager/Components/TaskBarComponent.razor b/wasm2/HackerOs/BlazorWindowManager/BlazorWindowManager/Components/TaskBarComponent.razor index ad90cd9..a515537 100644 --- a/wasm2/HackerOs/BlazorWindowManager/BlazorWindowManager/Components/TaskBarComponent.razor +++ b/wasm2/HackerOs/BlazorWindowManager/BlazorWindowManager/Components/TaskBarComponent.razor @@ -10,18 +10,21 @@
- @foreach (var window in _openWindows) + @foreach (var groupKey in _groupOrder) { -
+ title="@representative.Title">
- @if (window.Icon != null) + @if (representative.Icon != null) { - @window.Icon + @representative.Icon } else { @@ -30,13 +33,13 @@
- @GetDisplayTitle(window.Title) + @GetDisplayTitle(representative.Title)
- @if (GroupedWindows && _groupedWindows.ContainsKey(GetGroupKey(window)) && _groupedWindows[GetGroupKey(window)].Count > 1) + @if (group.Count > 1) {
- @_groupedWindows[GetGroupKey(window)].Count + @group.Count
}
@@ -74,3 +77,32 @@
} + +@if (_showGroupPopup && _groupPopupKey != null && _groupedWindows.TryGetValue(_groupPopupKey, out var popupGroup)) +{ +
+ @foreach (var window in popupGroup) + { +
+
+ @if (window.Icon != null) + { + @window.Icon + } + else + { +
๐Ÿ“„
+ } +
+
@GetDisplayTitle(window.Title)
+ @if (window.State == WindowState.Minimized) + { + minimized + } +
+ } +
+ +
+} diff --git a/wasm2/HackerOs/BlazorWindowManager/BlazorWindowManager/Components/TaskBarComponent.razor.cs b/wasm2/HackerOs/BlazorWindowManager/BlazorWindowManager/Components/TaskBarComponent.razor.cs index cf5bd09..4529611 100644 --- a/wasm2/HackerOs/BlazorWindowManager/BlazorWindowManager/Components/TaskBarComponent.razor.cs +++ b/wasm2/HackerOs/BlazorWindowManager/BlazorWindowManager/Components/TaskBarComponent.razor.cs @@ -34,12 +34,20 @@ public partial class TaskBarComponent : ComponentBase, IDisposable private List _openWindows = new(); private Dictionary> _groupedWindows = new(); + private List _groupOrder = new(); private bool _showContextMenu = false; private double _contextMenuX = 0; private double _contextMenuY = 0; private WindowInfo? _contextMenuWindow = null; private Timer? _clockTimer; + // Grouped-window popup state: shown when a taskbar button represents more + // than one open window of the same app so the user can pick which one. + private bool _showGroupPopup = false; + private string? _groupPopupKey; + private double _groupPopupX = 0; + private double _groupPopupY = 0; + private enum WindowAction { Restore, @@ -114,17 +122,29 @@ private void OnWindowFocused(object? sender, WindowEventArgs e) private void UpdateGroupedWindows() { - if (!GroupedWindows) return; - _groupedWindows.Clear(); + _groupOrder.Clear(); + foreach (var window in _openWindows) { - var groupKey = GetGroupKey(window); - if (!_groupedWindows.ContainsKey(groupKey)) + // When grouping is disabled every window gets its own unique key + // so the render loop can treat both modes identically. + var groupKey = GroupedWindows ? GetGroupKey(window) : window.Id.ToString(); + + if (!_groupedWindows.TryGetValue(groupKey, out var group)) { - _groupedWindows[groupKey] = new List(); + group = new List(); + _groupedWindows[groupKey] = group; + _groupOrder.Add(groupKey); } - _groupedWindows[groupKey].Add(window); + + group.Add(window); + } + + // Drop the popup if its group no longer exists (e.g. all instances closed). + if (_groupPopupKey != null && !_groupedWindows.ContainsKey(_groupPopupKey)) + { + CloseGroupPopup(); } } @@ -134,16 +154,36 @@ private string GetGroupKey(WindowInfo window) return !string.IsNullOrEmpty(window.Name) ? window.Name : window.Title; } - private string GetWindowButtonClass(WindowInfo window) + /// + /// Picks which window in a group is shown as the taskbar button's icon/title + /// (the active one if it belongs to the group, otherwise the most recent). + /// + private WindowInfo GetRepresentativeWindow(List group) + { + var activeId = WindowManager.GetActiveWindow()?.Id; + if (activeId.HasValue) + { + var active = group.FirstOrDefault(w => w.Id == activeId.Value); + if (active != null) return active; + } + + return group[^1]; + } + + private string GetWindowButtonClass(List group) { var classes = new List(); - - if (window.State == WindowState.Minimized) + + if (group.All(w => w.State == WindowState.Minimized)) classes.Add("minimized"); - - if (WindowManager.GetActiveWindow()?.Id == window.Id) + + var activeId = WindowManager.GetActiveWindow()?.Id; + if (activeId.HasValue && group.Any(w => w.Id == activeId.Value)) classes.Add("active"); - + + if (group.Count > 1) + classes.Add("grouped"); + return string.Join(" ", classes); } @@ -156,10 +196,35 @@ private string GetDisplayTitle(string title) : title; } - private async Task OnWindowButtonClick(WindowInfo window) + private async Task OnWindowButtonClick(MouseEventArgs e, string groupKey) { CloseContextMenu(); - + + if (!_groupedWindows.TryGetValue(groupKey, out var group) || group.Count == 0) + { + return; + } + + if (group.Count > 1) + { + // Multiple windows share this button; let the user pick which one + // instead of guessing. + ToggleGroupPopup(groupKey, e.ClientX, e.ClientY); + return; + } + + CloseGroupPopup(); + await FocusOrToggleWindow(group[0]); + } + + private async Task OnGroupPopupItemClick(WindowInfo window) + { + CloseGroupPopup(); + await FocusOrToggleWindow(window); + } + + private async Task FocusOrToggleWindow(WindowInfo window) + { if (window.State == WindowState.Minimized) { // Restore minimized window @@ -177,8 +242,30 @@ private async Task OnWindowButtonClick(WindowInfo window) } } + private void ToggleGroupPopup(string groupKey, double x, double y) + { + if (_showGroupPopup && _groupPopupKey == groupKey) + { + CloseGroupPopup(); + return; + } + + _groupPopupKey = groupKey; + _groupPopupX = x; + _groupPopupY = y - 160; // position the popup above the taskbar button + _showGroupPopup = true; + StateHasChanged(); + } + + private void CloseGroupPopup() + { + _showGroupPopup = false; + _groupPopupKey = null; + } + private void OnWindowButtonRightClick(MouseEventArgs e, WindowInfo window) { + CloseGroupPopup(); _contextMenuWindow = window; _contextMenuX = e.ClientX; _contextMenuY = e.ClientY - 120; // Position above taskbar diff --git a/wasm2/HackerOs/BlazorWindowManager/BlazorWindowManager/Components/TaskBarComponent.razor.css b/wasm2/HackerOs/BlazorWindowManager/BlazorWindowManager/Components/TaskBarComponent.razor.css index 1170bbf..4acb9b5 100644 --- a/wasm2/HackerOs/BlazorWindowManager/BlazorWindowManager/Components/TaskBarComponent.razor.css +++ b/wasm2/HackerOs/BlazorWindowManager/BlazorWindowManager/Components/TaskBarComponent.razor.css @@ -211,6 +211,66 @@ background: transparent; } +/* Grouped-window popup: shown when a taskbar button represents more than + one open window of the same app so the user can pick which instance. */ +.group-popup { + position: fixed; + background: linear-gradient(180deg, #2a2a2a 0%, #1a1a1a 100%); + border: 1px solid #00ff41; + border-radius: 4px; + padding: 4px; + z-index: 10001; + min-width: 180px; + max-width: 260px; + box-shadow: 0 4px 12px rgba(0, 255, 65, 0.4); + animation: contextMenuFadeIn 0.15s ease-out; +} + +.group-popup-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border-radius: 3px; + color: #00ff41; + cursor: pointer; + font-size: 11px; + user-select: none; + transition: background-color 0.15s ease; +} + +.group-popup-item:hover { + background: rgba(0, 255, 65, 0.1); +} + +.group-popup-item.active { + background: rgba(0, 255, 65, 0.15); + box-shadow: inset 0 0 0 1px rgba(0, 255, 65, 0.4); +} + +.group-popup-icon { + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.group-popup-text { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.group-popup-state { + font-size: 9px; + font-style: italic; + opacity: 0.7; + flex-shrink: 0; +} + /* Scrollbar styling for taskbar center */ .taskbar-center::-webkit-scrollbar { height: 4px; diff --git a/wasm2/HackerOs/BlazorWindowManager/BlazorWindowManager/Components/WindowBase.razor.Interactions.cs b/wasm2/HackerOs/BlazorWindowManager/BlazorWindowManager/Components/WindowBase.razor.Interactions.cs index ae6bf6a..90e1bcc 100644 --- a/wasm2/HackerOs/BlazorWindowManager/BlazorWindowManager/Components/WindowBase.razor.Interactions.cs +++ b/wasm2/HackerOs/BlazorWindowManager/BlazorWindowManager/Components/WindowBase.razor.Interactions.cs @@ -16,6 +16,7 @@ public partial class WindowBase private double _dragStartX; private double _dragStartY; private WindowBounds _dragStartBounds = new(); + private WindowBounds? _dragContainerBounds; // Resizing state private bool _isResizing = false; @@ -58,6 +59,10 @@ public async Task OnTitleBarMouseDown(Microsoft.AspNetCore.Components.Web.MouseE _dragStartX = e.ClientX; _dragStartY = e.ClientY; _dragStartBounds = CurrentBounds.Clone(); + + // Fetch the container bounds once for the whole drag instead of on + // every mousemove; the reference frame doesn't change mid-drag. + _dragContainerBounds = await GetContainerBounds(); // Setup global mouse events through JavaScript if (_jsModule != null && _dotNetRef != null) @@ -129,27 +134,37 @@ public async Task StartResize(Microsoft.AspNetCore.Components.Web.MouseEventArgs public async Task OnDragMove(double clientX, double clientY) { if (!_isDragging) return; - var deltaX = clientX - _dragStartX; - var deltaY = clientY - _dragStartY; - - var newBounds = _dragStartBounds.Clone(); - newBounds.Left += deltaX; - newBounds.Top += deltaY; - // Get container bounds for snapping calculations - var containerBounds = await GetContainerBounds(); - - // Calculate snap targets - var snapTarget = SnappingService.CalculateSnapTarget(Id, newBounds, containerBounds); - - // Show snap preview if a target is found - SnappingService.UpdateSnapPreview(snapTarget); - - // Update window position (snap will be applied on drag end) - CurrentBounds = newBounds; - WindowManager.UpdateWindowBounds(Id, newBounds); - - await OnMoving.InvokeAsync(newBounds); - await UpdateWindowDisplay(); + + // Raw mousemove events can fire far more often than the UI needs to + // repaint (sometimes 100+ times/sec). Throttle the expensive part + // (snap calculation + full re-render) to roughly the screen refresh + // rate instead of doing it on every single event. + await PerformanceService.ThrottleAsync($"window-drag-{Id}", async () => + { + var deltaX = clientX - _dragStartX; + var deltaY = clientY - _dragStartY; + + var newBounds = _dragStartBounds.Clone(); + newBounds.Left += deltaX; + newBounds.Top += deltaY; + + // Reuse the container bounds captured when the drag started + // instead of an async JS interop round-trip on every move. + var containerBounds = _dragContainerBounds ?? await GetContainerBounds(); + + // Calculate snap targets + var snapTarget = SnappingService.CalculateSnapTarget(Id, newBounds, containerBounds); + + // Show snap preview if a target is found + SnappingService.UpdateSnapPreview(snapTarget); + + // Update window position (snap will be applied on drag end) + CurrentBounds = newBounds; + WindowManager.UpdateWindowBounds(Id, newBounds); + + await OnMoving.InvokeAsync(newBounds); + await UpdateWindowDisplay(); + }); } /// @@ -163,7 +178,7 @@ public async Task OnDragEnd() _isDragging = false; // Apply snapping if enabled - var containerBounds = await GetContainerBounds(); + var containerBounds = _dragContainerBounds ?? await GetContainerBounds(); var snappedBounds = SnappingService.ApplySnapping(Id, CurrentBounds, containerBounds); // If snapping occurred, update the window position @@ -177,6 +192,7 @@ public async Task OnDragEnd() // Hide snap preview SnappingService.HideSnapPreview(); + _dragContainerBounds = null; await OnMoved.InvokeAsync(CurrentBounds); } @@ -189,20 +205,25 @@ public async Task OnDragEnd() public async Task OnResizeMove(double clientX, double clientY) { if (!_isResizing) return; - - var deltaX = clientX - _dragStartX; - var deltaY = clientY - _dragStartY; - - var newBounds = CalculateNewBounds(_dragStartBounds, deltaX, deltaY, _resizeDirection); - - // Apply size constraints - ApplySizeConstraints(newBounds); - - CurrentBounds = newBounds; - WindowManager.UpdateWindowBounds(Id, newBounds); - - await OnResizing.InvokeAsync(newBounds); - await UpdateWindowDisplay(); + + // Same throttling rationale as OnDragMove: avoid a full re-render for + // every single raw mousemove event during a resize. + await PerformanceService.ThrottleAsync($"window-resize-{Id}", async () => + { + var deltaX = clientX - _dragStartX; + var deltaY = clientY - _dragStartY; + + var newBounds = CalculateNewBounds(_dragStartBounds, deltaX, deltaY, _resizeDirection); + + // Apply size constraints + ApplySizeConstraints(newBounds); + + CurrentBounds = newBounds; + WindowManager.UpdateWindowBounds(Id, newBounds); + + await OnResizing.InvokeAsync(newBounds); + await UpdateWindowDisplay(); + }); } /// diff --git a/wasm2/HackerOs/BlazorWindowManager/BlazorWindowManager/Components/WindowContent.razor b/wasm2/HackerOs/BlazorWindowManager/BlazorWindowManager/Components/WindowContent.razor index e985278..b391d99 100644 --- a/wasm2/HackerOs/BlazorWindowManager/BlazorWindowManager/Components/WindowContent.razor +++ b/wasm2/HackerOs/BlazorWindowManager/BlazorWindowManager/Components/WindowContent.razor @@ -31,10 +31,11 @@
-
+
+ + @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..bd07d99 --- /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) + { + // JS runtime disconnected (app shutting down); ignore. + } + } + } + + /// + public async ValueTask DisposeAsync() + { + Registry.AppsChanged -= OnAppsChanged; + + if (_module is not null) + { + try + { + await _module.InvokeVoidAsync("unregisterOutsideClick"); + await _module.DisposeAsync(); + } + catch (JSDisconnectedException) + { + // JS runtime disconnected (app shutting down); 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..f3874b5 --- /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) + { + // JS runtime already disconnected (app shutting down); 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..8e3574b --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/HackerOs.AppFramework/Components/TerminalHost.razor.js @@ -0,0 +1,46 @@ +/* + * 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. + * + * The window that hosts the terminal can still be animating into place (see + * the `windowAppear` CSS animation) or settling its layout right after this + * component's first render, so a single synchronous `.focus()` call can + * silently lose the race and leave the terminal impossible to type into + * until the user clicks it manually. Retry across a few animation frames + * until the element actually reports itself as the active element. + * @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; + + const maxAttempts = 10; + let attempts = 0; + + const tryFocus = () => { + attempts++; + + if (document.activeElement === focusable) { + return; + } + + focusable.focus({ preventScroll: true }); + + if (document.activeElement !== focusable && attempts < maxAttempts) { + requestAnimationFrame(tryFocus); + } + }; + + tryFocus(); +} 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..e955834 --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/SystemMonitorApp.razor.cs @@ -0,0 +1,123 @@ +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) + { + _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)); + } + + // IMPORTANT: WindowAppBase/WindowBase relies on OnAfterRenderAsync to load + // its own JS interop module (drag/resize/etc.). Skipping this call breaks + // window dragging and resizing for this app. + await base.OnAfterRenderAsync(firstRender); + } + + 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 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/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..ac9b3d8 --- /dev/null +++ b/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/Modules/WelcomeApp.razor.cs @@ -0,0 +1,38 @@ +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) + { + // 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(); + } + + // IMPORTANT: WindowAppBase/WindowBase relies on OnAfterRenderAsync to load + // its own JS interop module (drag/resize/etc.). Skipping this call breaks + // window dragging and resizing for this app. + await base.OnAfterRenderAsync(firstRender); + } + + 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 0000000..8422b59 Binary files /dev/null and b/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/wwwroot/favicon.png differ diff --git a/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/wwwroot/icon-192.png b/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/wwwroot/icon-192.png new file mode 100644 index 0000000..166f56d Binary files /dev/null and b/wasm2/HackerOs/Ecosystem/HackerOs.Ecosystem/wwwroot/icon-192.png differ 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/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 `