Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 46 additions & 4 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ jobs:
- uses: microsoft/setup-WinAppCli@b93bbddc1f7abc061ca0d3a8119e3a0c7dd71495
with:
version: v0.3.1
- run: dotnet restore Snaply.slnx --locked-mode
# Restore with ReadyToRun on so the crossgen2 runtime pack is fetched here; the
# publish below runs --no-restore, so without it R2R fails (NETSDK1094).
- run: dotnet restore Snaply.slnx --locked-mode -p:PublishReadyToRun=true
- run: dotnet test tests/Snaply.Tests/Snaply.Tests.csproj -c Release --no-restore
- if: matrix.architecture == 'x64'
run: dotnet test tests/Snaply.App.Tests/Snaply.App.Tests.csproj -c Release --no-restore
Expand All @@ -55,10 +57,19 @@ jobs:
--self-contained true
-o build/native
--no-restore
- name: Repeat UI journeys five times
# UI journeys are x64-only. On windows-11-arm the shell keeps the foreground for
# itself — the image boots with a Microsoft-account sign-in prompt (WWAHost), and
# closing it just hands the foreground to SearchHost — so the capture overlay never
# comes forward and synthetic input never reaches it. Even the AttachThreadInput
# handoff in ui-tests.ps1 loses that fight there, so the journeys measured the
# runner image rather than Snaply. arm64 still builds, unit-tests and publishes.
# Two passes, not five: x64 clears two reliably, and beyond that the same
# foreground contention starts costing passes. See the tracking issue.
- name: Repeat UI journeys twice
if: matrix.architecture == 'x64'
shell: pwsh
run: |
1..5 | ForEach-Object {
1..2 | ForEach-Object {
$process = Start-Process build/native/Snaply.exe -PassThru
try {
./src/Snaply.App/ui-tests.ps1 `
Expand All @@ -74,6 +85,7 @@ jobs:
}
}
- name: Run 100-capture soak
if: matrix.architecture == 'x64'
shell: pwsh
run: |
$process = Start-Process build/native/Snaply.exe -PassThru
Expand All @@ -93,8 +105,38 @@ jobs:
Stop-Process -Id $process.Id -Force
}
}
# A UI failure otherwise reports only "element did not appear"; Snaply's own log
# carries the exception behind it, so ship it alongside the results.
- name: Collect app logs
if: always() && matrix.architecture == 'x64'
shell: pwsh
run: |
$logs = Join-Path $env:LOCALAPPDATA 'Snaply\Logs'
# An empty folder is dropped by upload-artifact, so say out loud which case
# this is: no folder means the app never got as far as configuring logging,
# an empty one means it ran and logged nothing.
if (-not (Test-Path $logs)) {
Write-Host "No log directory at $logs."
exit 0
}
$files = @(Get-ChildItem $logs -File)
Write-Host "Log directory holds $($files.Count) file(s)."
foreach ($file in $files) {
Write-Host "--- $($file.Name) ---"
Get-Content $file.FullName | Write-Host
}
New-Item -ItemType Directory -Force -Path artifacts/ui/app-logs | Out-Null
Copy-Item "$logs\*" artifacts/ui/app-logs -Recurse -Force
# Only reached when everything gating this architecture passed: on x64 that includes
# the UI journeys and the soak, on arm64 the build and the unit tests.
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: nightly-${{ matrix.architecture }}
path: build/native
retention-days: 14
if-no-files-found: error
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always() && matrix.architecture == 'x64'
with:
name: ui-${{ matrix.architecture }}
path: artifacts/ui
Expand Down
4 changes: 2 additions & 2 deletions src/Snaply.App/MainPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@
<SplitButton
x:Name="CaptureButton"
AutomationProperties.AutomationId="CaptureButton"
Click="CaptureButton_Click">
Command="{x:Bind ViewModel.CaptureCommand}">
<StackPanel Orientation="Horizontal" Spacing="{StaticResource SpacingSmall}">
<FontIcon
x:Name="PrimaryCaptureGlyph"
Expand Down Expand Up @@ -164,7 +164,7 @@
Height="{StaticResource ProgressRingSize}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
IsActive="{x:Bind ViewModel.IsBusy, Mode=OneWay}" />
IsActive="{x:Bind ViewModel.CaptureCommand.IsRunning, Mode=OneWay}" />
</Grid>
</Grid>
</Border>
Expand Down
21 changes: 9 additions & 12 deletions src/Snaply.App/MainPage.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,6 @@ public sealed partial class MainPage : Page
private const int WindowGlyph = 0xE737;
private const int DesktopGlyph = 0xE7F4;

// The mode the Capture pill runs. The flyout only changes this (it never captures on its own);
// pressing the pill body captures with it. Defaults to the whole desktop.
private CaptureMode _selectedMode = CaptureMode.Desktop;

internal MainPage(MainViewModel viewModel)
{
ViewModel = viewModel;
Expand All @@ -39,11 +35,8 @@ internal MainPage(MainViewModel viewModel)

internal MainViewModel ViewModel { get; }

// Capture pill body: run the currently selected mode.
private async void CaptureButton_Click(SplitButton sender, SplitButtonClickEventArgs args) =>
await ViewModel.CaptureAsync(_selectedMode);

// Flyout items: change the selected mode only (the capture happens on the pill body click).
// Flyout items: change the selected mode only. The pill body is bound to CaptureCommand,
// which runs whichever mode is selected.
private void RegionCaptureItem_Click(object sender, RoutedEventArgs args) => SelectMode(CaptureMode.Region);

private void WindowCaptureItem_Click(object sender, RoutedEventArgs args) => SelectMode(CaptureMode.Window);
Expand All @@ -54,21 +47,25 @@ private async void CaptureButton_Click(SplitButton sender, SplitButtonClickEvent

private void SelectMode(CaptureMode mode)
{
_selectedMode = mode;
ViewModel.SelectedMode = mode;
UpdatePrimaryCapture();
}

// Reflect the selected mode on the Capture pill (label + glyph). Kept in code-behind so the
// view model stays free of presentation strings.
private void UpdatePrimaryCapture()
{
PrimaryCaptureLabel.Text = ResourceText.Get(_selectedMode switch
string label = ResourceText.Get(ViewModel.SelectedMode switch
{
CaptureMode.Region => "CaptureRegion",
CaptureMode.Window => "CaptureWindow",
_ => "CaptureDesktop",
});
PrimaryCaptureGlyph.Glyph = char.ConvertFromUtf32(_selectedMode switch
PrimaryCaptureLabel.Text = label;
// The pill's content is a panel, so it derives no automation name of its own and
// screen readers announce it unnamed. Name it after the mode it will run.
AutomationProperties.SetName(CaptureButton, label);
PrimaryCaptureGlyph.Glyph = char.ConvertFromUtf32(ViewModel.SelectedMode switch
{
CaptureMode.Region => RegionGlyph,
CaptureMode.Window => WindowGlyph,
Expand Down
23 changes: 9 additions & 14 deletions src/Snaply.App/ViewModels/MainViewModel.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Runtime.InteropServices.WindowsRuntime;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.UI.Xaml.Media.Imaging;
using Serilog;
using Windows.Graphics.Imaging;
Expand All @@ -16,8 +17,9 @@ internal sealed partial class MainViewModel : ObservableObject, IDisposable
[ObservableProperty]
internal partial WriteableBitmap? Preview { get; set; }

// The capture pill picks the mode; CaptureCommand runs whatever is selected.
[ObservableProperty]
internal partial bool IsBusy { get; set; }
internal partial CaptureMode SelectedMode { get; set; } = CaptureMode.Desktop;

[ObservableProperty]
internal partial bool HasImage { get; set; }
Expand All @@ -41,24 +43,19 @@ internal MainViewModel(
_export = export;
}

internal CaptureMode LastCaptureMode { get; private set; } = CaptureMode.Region;

internal async Task CaptureAsync(CaptureMode mode)
// AsyncRelayCommand refuses to run while an execution is in flight and reports that
// through CanExecute, so the bound pill disables itself for the duration and the view
// needs no separate busy flag or re-entrancy guard.
[RelayCommand]
private async Task CaptureAsync()
{
if (IsBusy)
{
return;
}

LastCaptureMode = mode;
HasError = false;
IsBusy = true;
using var operation = new CancellationTokenSource();
_operation = operation;

try
{
using CapturedFrame? frame = await _capture.CaptureAsync(mode, operation.Token);
using CapturedFrame? frame = await _capture.CaptureAsync(SelectedMode, operation.Token);
if (frame is null)
{
return;
Expand Down Expand Up @@ -92,8 +89,6 @@ internal async Task CaptureAsync(CaptureMode mode)
{
_operation = null;
}

IsBusy = false;
}
}

Expand Down
Loading
Loading