diff --git a/.github/workflows/windows-push.yml b/.github/workflows/windows-push.yml index 6e3946d..2097218 100644 --- a/.github/workflows/windows-push.yml +++ b/.github/workflows/windows-push.yml @@ -43,3 +43,49 @@ jobs: WNS_OBJECT_ID: ${{ secrets.WNS_OBJECT_ID }} WNS_CLIENT_SECRET: ${{ secrets.WNS_CLIENT_SECRET }} run: ./ci/windows/w0.ps1 + example: + name: Example (${{ matrix.example }}) + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: windows-latest + timeout-minutes: 90 + strategy: + matrix: + example: [dioxus, tauri, winit] + steps: + - uses: actions/checkout@v7 + - run: rustup toolchain install stable --profile minimal + - name: Install the Windows App SDK 2.5 runtime + shell: pwsh + run: | + $installer = Join-Path $env:RUNNER_TEMP 'WindowsAppRuntimeInstall-x64.exe' + Invoke-WebRequest -TimeoutSec 600 -OutFile $installer 'https://aka.ms/windowsappsdk/2.5/latest/windowsappruntimeinstall-x64.exe' + $install = Start-Process $installer -ArgumentList '--quiet' -NoNewWindow -PassThru + # The handle is taken now so the exit code stays readable. + $null = $install.Handle + if (-not $install.WaitForExit(600000)) { $install.Kill(); throw 'the runtime installer did not finish within 10 minutes' } + if ($install.ExitCode -ne 0) { throw "the runtime installer exited with $($install.ExitCode)" } + - name: Build the ${{ matrix.example }} example + shell: pwsh + env: + EXAMPLE: ${{ matrix.example }} + PUSHUPS_WNS_REMOTE_ID: ${{ secrets.WNS_OBJECT_ID }} + run: | + # The Dioxus example's `firebase_config!` reads this file on every target. + Copy-Item tests/fixtures/google-services.json "examples/$env:EXAMPLE/google-services.json" + $map = @{ + dioxus = @{ Manifest = 'examples/dioxus/Cargo.toml'; Args = @('--no-default-features', '--features', 'desktop') } + tauri = @{ Manifest = 'examples/tauri/Cargo.toml'; Args = @() } + winit = @{ Manifest = 'examples/winit/Cargo.toml'; Args = @() } + } + $entry = $map[$env:EXAMPLE] + $buildArgs = $entry.Args + cargo +stable build --release --manifest-path $entry.Manifest @buildArgs + - name: Run the ${{ matrix.example }} example with WNS pushes + shell: pwsh + env: + WNS_TENANT_ID: ${{ secrets.WNS_TENANT_ID }} + WNS_APP_ID: ${{ secrets.WNS_APP_ID }} + WNS_OBJECT_ID: ${{ secrets.WNS_OBJECT_ID }} + WNS_CLIENT_SECRET: ${{ secrets.WNS_CLIENT_SECRET }} + run: | + ./ci/windows/example.ps1 "$env:GITHUB_WORKSPACE\examples\${{ matrix.example }}\target\release\pushups-${{ matrix.example }}-example.exe" diff --git a/assets/brand/icon-macos.ico b/assets/brand/icon-macos.ico new file mode 100644 index 0000000..a46b8bf Binary files /dev/null and b/assets/brand/icon-macos.ico differ diff --git a/assets/brand/render.sh b/assets/brand/render.sh index e6bfc8b..54ea6a0 100755 --- a/assets/brand/render.sh +++ b/assets/brand/render.sh @@ -9,6 +9,13 @@ mkdir -p "$brand/png" for size in 32 128 256 512 1024; do rsvg-convert --width "$size" --height "$size" "$brand/icon-macos.svg" --output "$brand/png/icon-macos-$size.png" done +# The Windows resource icon `tauri-build` compiles into the Tauri example, which it reads only from an `.ico`. +ico=$(mktemp -d) +for size in 16 32 48 256; do + rsvg-convert --width "$size" --height "$size" "$brand/icon-macos.svg" --output "$ico/$size.png" +done +convert "$ico/16.png" "$ico/32.png" "$ico/48.png" "$ico/256.png" "$brand/icon-macos.ico" +rm -r "$ico" # The repository's social preview, uploaded in the GitHub settings. rsvg-convert --width 1280 --height 640 "$brand/social.svg" --output "$brand/png/social.png" diff --git a/ci/windows/example.ps1 b/ci/windows/example.ps1 new file mode 100644 index 0000000..18cf5e4 --- /dev/null +++ b/ci/windows/example.ps1 @@ -0,0 +1,177 @@ +# Runs one pushups example exe (Dioxus, Tauri or winit) with real WNS pushes, for the X1 proofs. +# The exe must have been built with PUSHUPS_WNS_REMOTE_ID set, so a process Windows starts for a +# push knows the remote id without any file to read. +# Run from the repository root after building the example, with WNS_TENANT_ID, WNS_APP_ID, +# WNS_OBJECT_ID and WNS_CLIENT_SECRET set. Takes the built exe's path. +param([Parameter(Mandatory)][string] $Exe) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +# The Foundation package metapackage 2.5.1 depends on, the release ci/windows/bindings.sh pins. +$Foundation = '2.3.12' +# WNS may take minutes to answer a channel request. +$ChannelBound = [TimeSpan]::FromSeconds(240) +$PushBound = [TimeSpan]::FromSeconds(90) +# A push-started process also starts a window and a web view environment. +$ActivationBound = [TimeSpan]::FromSeconds(180) +$ExitBound = [TimeSpan]::FromSeconds(20) + +foreach ($name in 'WNS_TENANT_ID', 'WNS_APP_ID', 'WNS_OBJECT_ID', 'WNS_CLIENT_SECRET') { + if (-not [Environment]::GetEnvironmentVariable($name)) { throw "$name is not set" } +} + +$exe = (Resolve-Path $Exe).Path +$app = [IO.Path]::GetFileNameWithoutExtension($exe) +$dir = Split-Path $exe +$log = Join-Path $env:TEMP 'pushups-example.log' +$work = Join-Path ($env:RUNNER_TEMP ?? [IO.Path]::GetTempPath()) "example-$app" +$verdict = [Collections.Generic.List[string]]::new() +$passed = $true + +function Note([string] $line) { + Write-Host $line + $verdict.Add($line) +} + +# Runs `$done` until it returns something or `$bound` passes on a monotonic clock. +function Wait-Until([TimeSpan] $bound, [scriptblock] $done) { + $clock = [Diagnostics.Stopwatch]::StartNew() + while ($clock.Elapsed -lt $bound) { + $result = & $done + if ($result) { return $result } + Start-Sleep -Milliseconds 250 + } + return $null +} + +function First-Line([string] $file, [string] $pattern) { + Get-Content $file -ErrorAction SilentlyContinue | Where-Object { $_ -match $pattern } | Select-Object -First 1 +} + +function Send-Push([string] $uri, [string] $token, [string] $payload) { + $response = Invoke-WebRequest -Method Post -Uri $uri -TimeoutSec 60 -SkipHttpErrorCheck ` + -Headers @{ Authorization = "Bearer $token"; 'X-WNS-Type' = 'wns/raw' } ` + -ContentType 'application/octet-stream' -Body ([Text.Encoding]::UTF8.GetBytes($payload)) + $wns = foreach ($header in 'X-WNS-Status', 'X-WNS-NotificationStatus', 'X-WNS-Error-Description') { + if ($response.Headers.ContainsKey($header)) { "$header=$($response.Headers[$header] -join ',')" } + } + "HTTP $($response.StatusCode) $($wns -join ' ')" +} + +# Ends the app's process and reports whether it left within the bound. +function Stop-App([string] $label, [System.Diagnostics.Process] $p) { + $p.Kill() | Out-Null + $exited = $p.WaitForExit([int] $ExitBound.TotalMilliseconds) + if (-not $exited) { Note "- the $label app did not exit within $ExitBound" } + $exited +} + +Note "## X1 on $((Get-CimInstance Win32_OperatingSystem).Caption) $([Environment]::OSVersion.Version), $app" + +# The bootstrapper the 2.5 channel install loads, which a desktop machine ships nowhere, fetched as w0.ps1 does. +$bootstrap = Join-Path $dir 'Microsoft.WindowsAppRuntime.Bootstrap.dll' +if (Test-Path $bootstrap) { + Note '- the bootstrapper already beside the exe' +} else { + New-Item -ItemType Directory -Force $work | Out-Null + $package = Join-Path $work 'foundation.zip' + Invoke-WebRequest -TimeoutSec 600 -OutFile $package ` + "https://api.nuget.org/v3-flatcontainer/microsoft.windowsappsdk.foundation/$Foundation/microsoft.windowsappsdk.foundation.$Foundation.nupkg" + Expand-Archive -Force $package (Join-Path $work 'foundation') + Copy-Item (Join-Path $work 'foundation/runtimes/win-x64/native/Microsoft.WindowsAppRuntime.Bootstrap.dll') $bootstrap + Note '- the bootstrapper copied beside the exe' +} + +# The temp log a process started with nothing reading its output writes to. +Remove-Item $log -ErrorAction SilentlyContinue + +$process = Start-Process -FilePath $exe -WorkingDirectory $dir -NoNewWindow -PassThru + +$uri = $null +$tokenLine = Wait-Until $ChannelBound { First-Line $log 'ui token at_ms=\d+ Wns \{ channel_uri: "([^"]+)"' } +if (-not $tokenLine) { + Note "- no token within $ChannelBound" + $passed = $false +} elseif ($tokenLine -match 'channel_uri: "([^"]+)"') { + $uri = $matches[1] + Write-Host "::add-mask::$uri" + Note "- channel created on $(([Uri] $uri).Host), $($tokenLine -replace '^.*ui token at_ms=\d+ ', '')" +} else { + Note "- the token line has no channel uri: $tokenLine" + $passed = $false +} + +$token = $null +if ($uri) { + try { + $token = (Invoke-RestMethod -Method Post -TimeoutSec 60 ` + -Uri "https://login.microsoftonline.com/$env:WNS_TENANT_ID/oauth2/v2.0/token" ` + -Body @{ + grant_type = 'client_credentials' + client_id = $env:WNS_APP_ID + client_secret = $env:WNS_CLIENT_SECRET + scope = 'https://wns.windows.com/.default' + }).access_token + Write-Host "::add-mask::$token" + Note '- access token issued for https://wns.windows.com/.default' + } catch { + $reason = if ($_.ErrorDetails) { $_.ErrorDetails.Message } else { $_.Exception.Message } + Note "- access token refused: $reason" + $token = $null + $passed = $false + } +} + +if ($uri -and $token) { + $sent = [DateTimeOffset]::Now.ToUnixTimeMilliseconds() + $payload = '{"seq":"1","sent_at_ms":"' + $sent + '"}' + try { + $result = Send-Push $uri $token $payload + } catch { + $result = "failed: $($_.Exception.Message)" + } + Note "- push 1 to the running app: $result" + $got = Wait-Until $PushBound { + $ui = First-Line $log 'ui message at_ms=\d+ started_app=false seq=1 ' + $handler = First-Line $log 'handler started_app=false .*seq=1 ' + if ($ui -and $handler) { [pscustomobject]@{ Ui = $ui; Handler = $handler } } + } + if ($got) { + Note "- running: $($got.Ui)" + Note "- running: $($got.Handler)" + } else { + Note "- the running app did not get seq=1 within $PushBound" + $passed = $false + } + if (-not (Stop-App 'running' $process)) { $passed = $false } + + # The same channel still reaches the app once the process is gone. + $sent = [DateTimeOffset]::Now.ToUnixTimeMilliseconds() + $payload = '{"seq":"2","sent_at_ms":"' + $sent + '"}' + try { + $result = Send-Push $uri $token $payload + } catch { + $result = "failed: $($_.Exception.Message)" + } + Note "- push 2 with the app stopped: $result" + $got2 = Wait-Until $ActivationBound { First-Line $log 'handler started_app=true .*seq=2 ' } + if ($got2) { + Note "- push-started: $got2" + $started = Wait-Until $ExitBound { Get-Process -Name $app -ErrorAction SilentlyContinue | Select-Object -First 1 } + if ($started) { + if (-not (Stop-App 'push-started' $started)) { $passed = $false } + } else { + Note '- the push-started process was already gone when it was looked for' + } + } else { + Note "- the stopped app was not started with seq=2 within $ActivationBound" + $passed = $false + } +} + +Write-Host "--- $app pushups-example.log" +Get-Content $log -ErrorAction SilentlyContinue | Write-Host + +if ($env:GITHUB_STEP_SUMMARY) { $verdict | Add-Content $env:GITHUB_STEP_SUMMARY } +exit ([int] (-not $passed)) diff --git a/examples/tauri/tauri.conf.json b/examples/tauri/tauri.conf.json index bad4533..73f16be 100644 --- a/examples/tauri/tauri.conf.json +++ b/examples/tauri/tauri.conf.json @@ -18,7 +18,8 @@ "icon": [ "../../assets/brand/png/icon-macos-512.png", "../../assets/brand/png/icon-macos-128.png", - "../../assets/brand/png/icon-macos-32.png" + "../../assets/brand/png/icon-macos-32.png", + "../../assets/brand/icon-macos.ico" ] } }