diff --git a/README.md b/README.md index af3e20a..56ad7cf 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,84 @@ reusable, self-contained Avalonia terminal control package (it bundles Avalonia applications. See the "NuGet package" section of [docs/release.md](docs/release.md) for packaging and consumption details. +## Per-tab asciicast recording + +Recording actions operate on the active terminal pane. `startRecording` captures +future PTY output, `stopRecording` retains the completed recording, +`saveRecording` opens a native Save As dialog and exports an asciicast `.cast` +file, and `replayRecording` replays the active pane's latest capture in a +separate read-only tab with its original timing. The recorder writes v2 by +default and the core API can also export v3 with +`recording.ToJson(AsciicastFormat.V3)`. `openRecording` detects and replays both +v2 and v3 files, even when the current pane has no recording. Pickers start in +the `recordings` directory beside `settings.json`. + +All path-taking actions also accept an explicit `path` for keybindings, +command-line activation, and automation. A path on `startRecording` enables an +auto-save workflow: stopping the recording writes directly to that path. +Exported v2 files are compatible with v2 players such as `asciinema play`; +imported v3 files retain their relative event timing during replay. + +To stream the active tab, open the command palette and choose **Start +streaming...**, paste the producer `ws://` or `wss://` URL, and choose +**Asciinema v3** (recommended), **Asciinema v2**, or **DVLS v2**. The recording +bar changes to **Live** while connected. Choose **Stop streaming** from the +palette (or use the bar's **Stop** button) to flush and close the stream. DT +remembers the URL only for the current process because producer URLs may +contain access tokens. + +NuGet consumers can also stream an active recording to an asciicast-compatible +WebSocket push endpoint: + +```csharp +await terminal.StartRecordingAsync( + new Uri("wss://gateway.example/jet/jrec/push/session?token=signed-token")); +// Use the terminal normally. +await terminal.StopRecordingAsync(); +``` + +The control preserves authentication query parameters, adds +`fileType=asciicast`, and sends the visible starting screen plus subsequent +output as asciicast v2 JSONL text messages. Endpoint discovery and token +acquisition stay in the embedding application. + +Official asciinema servers are also supported. Create a live stream through the +server API, then pass its `ws_producer_url` and select v2 or v3: + +```csharp +await terminal.StartRecordingAsync( + new Uri(streamResponse.WsProducerUrl), + AsciicastFormat.V3); +``` + +This negotiates the matching `v2.asciicast` or `v3.asciicast` WebSocket +subprotocol. V3 output uses relative event deltas; the legacy URI-only overload +remains compatible with DVLS push URLs and server-side first-message detection. + +While a recording is active, its tab shows a red recording indicator and the +active pane displays elapsed time, format, destination, and Stop/Save controls. +Stopped captures remain available from the same bar for saving or replay. A +yellow tab indicator marks an unsaved capture, and closing its pane, tab, or +window asks for confirmation even when normal close confirmations are disabled. + +Replay tabs include pause/resume, restart, progress, and 0.5x-2x playback speed +controls. Replay remains read-only and can be closed without affecting the +original terminal session or recording. + +The actions can be invoked from the command palette or assigned in +`settings.json`; for example: + +```json +{ + "command": { + "action": "startRecording", + "path": "%USERPROFILE%\\Desktop\\demo.cast", + "format": "v3" + }, + "keys": "ctrl+shift+r" +} +``` + ## Compatibility inventory ### Safety and compatibility settings diff --git a/src/Devolutions.Terminal.App/Actions/ActionScopeCatalog.cs b/src/Devolutions.Terminal.App/Actions/ActionScopeCatalog.cs index 0d9ac83..9c88a43 100644 --- a/src/Devolutions.Terminal.App/Actions/ActionScopeCatalog.cs +++ b/src/Devolutions.Terminal.App/Actions/ActionScopeCatalog.cs @@ -17,6 +17,7 @@ public static class ActionScopeCatalog "identifyWindow" or "newWindow" or "openAbout" or + "openRecording" or "openNewTabDropdown" or "openSettings" or "openSystemMenu" or diff --git a/src/Devolutions.Terminal.App/Actions/CloseConfirmationPolicy.cs b/src/Devolutions.Terminal.App/Actions/CloseConfirmationPolicy.cs index a900d7f..3c9ccc5 100644 --- a/src/Devolutions.Terminal.App/Actions/CloseConfirmationPolicy.cs +++ b/src/Devolutions.Terminal.App/Actions/CloseConfirmationPolicy.cs @@ -4,11 +4,16 @@ namespace Devolutions.Terminal.App.Actions; public static class CloseConfirmationPolicy { - public static bool RequiresConfirmation(ConfirmOnClose policy, int runningSessions, bool automaticExit = false) => - !automaticExit && policy switch + public static bool RequiresConfirmation( + ConfirmOnClose policy, + int runningSessions, + int unsavedRecordings = 0, + bool automaticExit = false) => + unsavedRecordings > 0 || + (!automaticExit && policy switch { ConfirmOnClose.Always => runningSessions > 0, ConfirmOnClose.Automatic => runningSessions > 1, _ => false, - }; + }); } diff --git a/src/Devolutions.Terminal.App/Views/MainWindow.Lifecycle.cs b/src/Devolutions.Terminal.App/Views/MainWindow.Lifecycle.cs index f86fee2..a60eb9a 100644 --- a/src/Devolutions.Terminal.App/Views/MainWindow.Lifecycle.cs +++ b/src/Devolutions.Terminal.App/Views/MainWindow.Lifecycle.cs @@ -34,13 +34,27 @@ private async Task ConfirmCloseAsync(IEnumerable panes, bool var snapshot = panes.ToArray(); var sessions = snapshot.Select(pane => pane.Control.ProcessMetadata).ToArray(); var running = snapshot.Count(pane => pane.Control.IsRunning); - if (!CloseConfirmationPolicy.RequiresConfirmation(_settings.ConfirmOnClose, running, automaticExit)) + var unsavedRecordings = snapshot.Count(pane => pane.Control.HasUnsavedRecording); + if (!CloseConfirmationPolicy.RequiresConfirmation( + _settings.ConfirmOnClose, + running, + unsavedRecordings, + automaticExit)) { return true; } + var message = (running, unsavedRecordings) switch + { + (> 0, > 0) => + $"Close {running} running terminal session(s)? " + + $"{unsavedRecordings} unsaved recording(s) will be discarded.", + (_, > 0) => + $"Close terminal sessions? {unsavedRecordings} unsaved recording(s) will be discarded.", + _ => $"Close {running} running terminal session(s)? Unsaved work may be lost.", + }; return await _confirmationDialog.ShowAsync(this, "Close terminal sessions", - $"Close {running} running terminal session(s)? Unsaved work may be lost.", "Close sessions").ConfigureAwait(true) && + message, "Close sessions").ConfigureAwait(true) && panes.SequenceEqual(snapshot) && snapshot.Select(pane => pane.Control.ProcessMetadata).SequenceEqual(sessions); } @@ -74,9 +88,11 @@ protected override void OnClosing(WindowClosingEventArgs e) } base.OnClosing(e); + var panes = _tabs.SelectMany(tab => tab.Panes.Leaves()).ToArray(); if (!e.Cancel && !_closeApproved && CloseConfirmationPolicy.RequiresConfirmation(_settings.ConfirmOnClose, - _tabs.SelectMany(tab => tab.Panes.Leaves()).Count(pane => pane.Control.IsRunning))) + panes.Count(pane => pane.Control.IsRunning), + panes.Count(pane => pane.Control.HasUnsavedRecording))) { e.Cancel = true; if (!_closeConfirmationPending) @@ -89,6 +105,7 @@ protected override void OnClosing(WindowClosingEventArgs e) protected override async void OnClosed(EventArgs e) { _isClosed = true; + _recordingUiTimer.Stop(); if (!_layoutPersisted && _tabs.Count > 0) { TryPersistCurrentLayout(CaptureLayout()); diff --git a/src/Devolutions.Terminal.App/Views/MainWindow.axaml b/src/Devolutions.Terminal.App/Views/MainWindow.axaml index 33a74d9..8c555a6 100644 --- a/src/Devolutions.Terminal.App/Views/MainWindow.axaml +++ b/src/Devolutions.Terminal.App/Views/MainWindow.axaml @@ -99,6 +99,64 @@ + + + + + + +