filebase 0.2.1

Query a directory of Slipcase containers by their flyleaf and look at what comes back
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
# Assemble the MSIX package the Microsoft Store distributes: the release
# executable, the manifest with the identity and the version substituted into
# it, and the six images the manifest names.
#
# The rule this works to is the one that matters: refuse loudly rather than
# produce something subtly wrong. A package quietly built from a debug binary
# says nothing at the moment it is made and everything days later. Every check
# below exists because the thing it checks cannot be seen by looking at the
# finished package.
#
#   powershell -ExecutionPolicy Bypass -File packaging\windows\build-msix.ps1
#   ...\build-msix.ps1 -SelfSign            # sign it, so it can be installed here
#   ...\build-msix.ps1 -SelfSign -Certify   # and run the certification kit
#
# WHAT THIS DOES NOT DO
#
# It does not submit, and it does not produce a signature that goes anywhere
# near a submission. The Store signs what it distributes, so `-SelfSign` exists
# only so that a package can be installed on this machine and looked at. The
# certificate it makes is a throwaway.
#
# This is slipcase-desktop's script with the names changed, six assets where it
# checks five, and an empty findings baseline. Every measurement in the comments
# below was taken there unless it says otherwise.
#
# Author: David M. Anderson
# Built with AI assistance (Claude, Anthropic)

[CmdletBinding()]
param(
    # The executable to package. With neither this nor a release build present
    # this refuses rather than building one: `cargo build --release` is the
    # caller's to run, the way it is for every other packaging script here.
    [string] $Binary,
    # Where to write the package and its staging tree. Defaulted in the body
    # rather than here: $PSScriptRoot is empty while parameters are being bound
    # in Windows PowerShell 5.1, so a default built from it is a refusal before
    # the script has run a line. `install.ps1` reads its own path in the body
    # for the same reason.
    [string] $OutDir,
    # Sign with a throwaway certificate whose subject is the manifest's
    # Publisher, so that the package can be installed here. Not for submission.
    [switch] $SelfSign,
    # Run the Windows App Certification Kit and fail on it. Needs elevation, and
    # needs the package to be installable, so it needs -SelfSign as well.
    [switch] $Certify,
    # Apply the -Certify gate to a report that already exists and do nothing
    # else. Needs no elevation and builds nothing, which is what makes the gate
    # checkable: breaking KNOWN_FINDINGS deliberately and watching this refuse
    # is the only way to know it still bites, and a kit run costs an elevated
    # session and several minutes.
    [string] $ReadReport
)

$ErrorActionPreference = 'Stop'

# What the Windows App Certification Kit says about this application every time,
# so that `-Certify` can be quiet about those and loud about anything else.
#
# **This is a record of what is known, not a claim that it is acceptable.**
# Whether to submit with `Blocked executables` failing is a decision, it is
# David's. Recording a finding here does not take
# it.
#
# One entry, from the first kit run, 2026-09-04, against
# `Filebase-0.1.0.0-x64.msix`. It was left empty until that run rather than copied
# from slipcase-desktop's, because a baseline copied from another application is
# a list of things somebody else measured. Traced rather than tolerated, and all
# four messages come from two places:
#
#   Blocked executables     Five messages. `kernel32.dll!CreateProcessW`, the
#                           `cmd.exe /e:ON /v:OFF /d /c` argument string and
#                           `\cmd.exe` are the Rust standard library's
#                           batch-file spawn path in `std::process`, linked in
#                           because `webbrowser` is - it arrives under
#                           `egui-winit` and is what egui opens a hyperlink
#                           with. Nothing in this repository calls
#                           `Command::new`; `grep` finds no occurrence in any of
#                           the three crates.
#
#                           The fourth and fifth, references to "Csi" and
#                           "CMd", are a substring scan hitting bytes that are
#                           not a name. The binary holds `Csinhf`, the
#                           statically linked UCRT's complex-sinh symbol, and a
#                           three-byte run inside `.text` between two
#                           instruction fragments. Neither is csi.exe and there
#                           is nothing to remove.
#
#                           "CMd" arrived on 2026-09-06, against the v0.1.0
#                           package; the 2026-09-04 run reported four messages.
#                           Its three occurrences were located: the
#                           displacement bytes of a `lea rax, [rip+...]` in
#                           `.text`, and twice inside the embedded font data.
#                           A displacement moves when any code above it moves,
#                           so a new coincidental match is expected rather than
#                           alarming.
#
#                           Note what this list does and does not gate. The
#                           comparison below is on a test's name and verdict;
#                           the messages are printed for a person to read. A
#                           new message inside a known finding passes quietly,
#                           which is why the printed messages are read at each
#                           release rather than trusted.
#
#                           The test is `OPTIONAL="TRUE"` in the report and the
#                           package is `APP_TYPE="Centennial"`, which is why an
#                           overall of PASS sits over a test reading FAIL.
#
# `DPIAwarenessValidation` is not here and that is worth saying, because it is
# slipcase-desktop's second entry: the kit reads the PE application manifest,
# and that application had none until `build.rs` was written, so it was reported
# as not DPI aware. This one embedded the manifest from the first build and the
# test passed on the first run - it is the only non-optional test in the
# report's last requirement, so it would have been a real refusal.
#
# Shrink this list when a finding goes away; the run says so when one does.
$KNOWN_FINDINGS = @{
    'Blocked executables' = 'FAIL'
}

$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$root = Split-Path -Parent (Split-Path -Parent $here)
if (-not $OutDir) { $OutDir = Join-Path $root 'dist' }

function Refuse([string] $message) {
    Write-Error "build-msix.ps1: $message"
}

# Read a certification report and apply the gate. A function so that it can
# be run against a report on its own, which is the only way to check that the
# gate bites without an elevated session and a fresh kit run: `-ReadReport`
# takes that path.
function Test-CertificationReport([string] $report) {
    # The verdict is read out of the report rather than out of an exit code. A
    # kit that ran and failed and a kit that never ran are different things, and
    # this must never call the second one a pass: a missing verdict is a refusal
    # too.
    [xml] $xml = Get-Content $report
    $overall = $xml.REPORT.OVERALL_RESULT
    if (-not $overall) {
        Refuse "the certification kit's report at $report has no OVERALL_RESULT - read it rather than trusting this script"
    }
    # Read out of `<TEST><RESULT>` and not out of an `OVERALL_RESULT` attribute.
    # Only the report element carries that attribute; every individual test
    # states its verdict in a child element, so the first version of this printed
    # nothing at all while a test was failing, and said only "WARNING". Worse
    # than useless: the kit's own overall verdict does not escalate a failing
    # test, so a test can read FAIL under an overall of WARNING.
    # Whatever this refuses on, it now says what.
    $unexpected = @()
    $seen = @{}
    foreach ($test in $xml.SelectNodes('//TEST')) {
        $node = $test.SelectSingleNode('RESULT')
        if (-not $node) { continue }
        $verdict = $node.InnerText.Trim()
        if ($verdict -eq 'PASS') { continue }
        $name = $test.GetAttribute('NAME')
        $seen[$name] = $verdict
        $expected = $KNOWN_FINDINGS[$name]
        if ($expected -eq $verdict) {
            Write-Host "$verdict  $name  (known - baselined)"
        } else {
            $unexpected += "$verdict $name"
            Write-Host "$verdict  $name  ** NOT IN THE KNOWN LIST **"
        }
        foreach ($message in $test.SelectNodes('.//MESSAGE')) {
            $text = $message.GetAttribute('TEXT')
            if ($text) { Write-Host "        $text" }
        }
    }
    # A known finding that stopped being reported is good news and not a
    # refusal, but it is said out loud, because a baseline nobody ever shrinks
    # becomes a list of things that used to be true.
    foreach ($name in $KNOWN_FINDINGS.Keys) {
        if (-not $seen.ContainsKey($name)) {
            Write-Host "gone   $name is no longer reported - take it out of KNOWN_FINDINGS"
        }
    }
    Write-Host "certification kit: $overall  ($report)"

    # The gate is the comparison against the list, not the count of things that
    # are not PASS. A finding that will be reported on every run this project
    # ever does would, if refused on, make `-Certify` refuse always - and
    # CLAUDE.md has the name for that, about the check for compiled C: a check
    # whose red is the normal state announces nothing. This one is quiet when
    # the kit says what it said last time and loud when it says anything else.
    #
    # An overall of FAIL is still a refusal on its own. The kit does not
    # escalate a failing test into it, so if it does say FAIL it has decided
    # something the per-test list does not cover.
    if ($unexpected) {
        Refuse "the certification kit reported $($unexpected.Count) finding(s) not in the known list: $($unexpected -join '; ') - certification runs it too, so this comes back"
    }
    if ($overall -eq 'FAIL') {
        Refuse 'the Windows App Certification Kit says FAIL overall'
    }
}


# Nothing above this line has run yet, which is the point: a report is read on
# its own, without building or signing anything.
if ($ReadReport) {
    if (-not (Test-Path $ReadReport)) { Refuse "no report at $ReadReport" }
    Test-CertificationReport (Resolve-Path $ReadReport).Path
    exit 0
}


# --- the identity, from one place -------------------------------------------

# `identity.psd1` beside this holds what is public: the reserved name, the
# publisher display name, and the calculated forms. `Publisher` is the X.500
# string Partner Center assigns per account, the same for every Excelano
# product, and it comes from the environment so that a public repository does
# not carry an account identifier: `windows.yml` passes the organisation
# secret STORE_PUBLISHER, and a Windows machine sets STORE_PUBLISHER in its
# own environment before running this.
$identityFile = Join-Path $here 'identity.psd1'
if (-not (Test-Path $identityFile)) {
    Refuse "no identity at $identityFile - it is committed beside this script and should not be missing"
}
$identity = Import-PowerShellDataFile $identityFile
foreach ($field in 'Name', 'PublisherDisplayName') {
    if (-not $identity.$field) { Refuse "identity.psd1 has no $field" }
}
$publisher = $env:STORE_PUBLISHER
if (-not $publisher) {
    Refuse 'no STORE_PUBLISHER in the environment - it is the X.500 string Partner Center shows under Product management, Product identity, as Package/Identity/Publisher, and it is the excelano organisation secret of that name'
}
# The one value with a shape worth checking. `Publisher` is an X.500 string and
# the display name is what gets put there by mistake; a package whose Publisher
# does not match the reservation is rejected at upload, which is the most
# expensive place to find out.
if ($publisher -notmatch '^CN=') {
    Refuse "STORE_PUBLISHER is '$publisher', which is not an X.500 string - Partner Center's Package/Identity/Publisher begins CN="
}

# --- the version, from the one parser ---------------------------------------

# `packaging/version.sh` is the only thing that reads Cargo.toml's version, and
# it is asked here rather than copied, which is the whole reason it takes an
# argument. It is POSIX sh, so it needs a shell, and Git for Windows ships one.
#
# Not `bash` off PATH. On a machine with WSL that name resolves to
# C:\Windows\System32\bash.exe, which runs inside a Linux distribution where
# this checkout is at a different path, so version.sh would read a Cargo.toml
# that is not this one - or nothing at all.
$git = Get-Command git -ErrorAction SilentlyContinue
if (-not $git) { Refuse 'git is not on PATH, and version.sh needs the shell Git for Windows ships' }
$gitRoot = Split-Path -Parent (Split-Path -Parent $git.Source)
$sh = Join-Path $gitRoot 'bin\bash.exe'
if (-not (Test-Path $sh)) { $sh = Join-Path $gitRoot 'usr\bin\sh.exe' }
if (-not (Test-Path $sh)) {
    Refuse "no shell found beside $($git.Source) - version.sh is POSIX sh and needs the one Git for Windows installs"
}
$versionScript = (Join-Path $here '..\version.sh').Replace('\', '/')
$version = & $sh $versionScript --appx
if ($LASTEXITCODE -ne 0 -or -not $version) {
    Refuse 'version.sh --appx would not answer'
}
$version = ($version | Select-Object -First 1).Trim()
# The Store requires four parts with the fourth 0, and version.sh says so too.
# This is the check that shelling out produced what was asked for rather than a
# message on standard output.
if ($version -notmatch '^\d+\.\d+\.\d+\.0$') {
    Refuse "version.sh --appx said '$version', which is not four parts ending in 0"
}

# --- the executable ---------------------------------------------------------

# Cargo is asked where its target directory is rather than guessed at, because
# `[build] target-dir` in a Cargo configuration file moves it and no
# environment variable then says so. Every packaging script here asks.
if (-not $Binary) {
    $targetDir = $null
    if (Get-Command cargo -ErrorAction SilentlyContinue) {
        Push-Location $root
        try {
            $meta = cargo metadata --format-version 1 --no-deps 2>$null | ConvertFrom-Json
            if ($meta) { $targetDir = $meta.target_directory }
        } finally { Pop-Location }
    }
    if (-not $targetDir) { $targetDir = Join-Path $root 'target' }
    $Binary = Join-Path $targetDir 'release\filebase.exe'
}
if (-not (Test-Path $Binary)) {
    Refuse "no executable at $Binary - run 'cargo build --release' first"
}
$Binary = (Resolve-Path $Binary).Path

# Two things read straight out of the PE header, because neither is visible in
# a finished package and both are shipping defects.
#
# The architecture, because the manifest declares x64, and a package whose
# declaration disagrees with its executable installs and then fails to launch.
#
# The subsystem, because `main.rs` carries `windows_subsystem = "windows"` only
# when `debug_assertions` is off - so a debug binary packaged by mistake is a
# console subsystem one, and a console window behind the application is a defect
# slipcase-desktop found by eye once already. It is the cheapest check in this
# file and it guards the one thing here that cost an eye to notice.
$pe = [System.IO.File]::ReadAllBytes($Binary)
$peOffset = [BitConverter]::ToInt32($pe, 0x3C)
$machine = [BitConverter]::ToUInt16($pe, $peOffset + 4)
$subsystem = [BitConverter]::ToUInt16($pe, $peOffset + 92)
if ($machine -ne 0x8664) {
    Refuse ("$Binary is machine 0x{0:X4}, and AppxManifest declares x64" -f $machine)
}
if ($subsystem -ne 2) {
    Refuse "$Binary is not a Windows GUI subsystem executable (subsystem $subsystem) - a debug build is a console one, and packaging that puts a console window behind the application"
}

# The imports, because a DLL that is not part of Windows has to already be on
# the machine before the application will start, and the machine that matters
# is a certification tester's rather than this one. Slipcase 0.1.1 was packaged,
# certified, submitted and failed on exactly that: it imported VCRUNTIME140.dll
# from the Visual C++ Redistributable, which every machine here has and a clean
# Windows does not. The check is its own script because CI runs it too, and it
# is here because this is the last place a bad binary can still be stopped.
& (Join-Path $here 'check-imports.ps1') -Binary $Binary
if ($LASTEXITCODE -ne 0) {
    Refuse "$Binary imports a DLL that does not ship with Windows - see above"
}

# --- the tools --------------------------------------------------------------

# Neither is on PATH on a stock machine and both are stock in the SDK. The
# newest SDK is taken, and the x64 build of the tool because that is the
# architecture everything else here is.
function Find-SdkTool([string] $name) {
    $kits = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin'
    if (-not (Test-Path $kits)) { return $null }
    Get-ChildItem $kits -Directory |
        Where-Object { $_.Name -match '^10\.' } |
        Sort-Object { [version] $_.Name } -Descending |
        ForEach-Object { Join-Path $_.FullName "x64\$name" } |
        Where-Object { Test-Path $_ } |
        Select-Object -First 1
}
$makeappx = Find-SdkTool 'makeappx.exe'
if (-not $makeappx) { Refuse 'no makeappx.exe in any Windows SDK - install the Windows 10/11 SDK' }

# --- the staging tree -------------------------------------------------------

if (-not (Test-Path $OutDir)) { New-Item -ItemType Directory -Path $OutDir -Force | Out-Null }
$OutDir = (Resolve-Path $OutDir).Path
$stage = Join-Path $OutDir 'msix-stage'
if (Test-Path $stage) { Remove-Item $stage -Recurse -Force }
New-Item -ItemType Directory -Path (Join-Path $stage 'Assets') -Force | Out-Null

Copy-Item $Binary (Join-Path $stage 'filebase.exe')

# Only the window. The `filebase` command-line tool is in the same workspace and
# in the same Debian package, and it is deliberately not in this one: a packaged
# application's executables live under `WindowsApps` behind an app-execution
# alias, declaring one is a manifest extension nobody has asked for, and a
# command-line tool a person cannot type the name of is worse than one they
# install another way. The CLI ships through cargo-dist.
$assets = Join-Path $here 'assets'
Copy-Item (Join-Path $assets '*.png') (Join-Path $stage 'Assets')
# The whole directory is copied and then the six the manifest names are
# checked, rather than the six being copied by name. The qualified variants
# beside them are resolved by `resources.pri` and never named anywhere, so a
# copy-by-name list would silently stop shipping them the day one was added.
foreach ($image in 'StoreLogo.png', 'Square150x150Logo.png', 'Square44x44Logo.png',
                   'Wide310x150Logo.png') {
    if (-not (Test-Path (Join-Path $stage "Assets\$image"))) {
        Refuse "no $image in $assets - run 'cargo run --release' in packaging/windows/make-ico"
    }
}

# --- the manifest -----------------------------------------------------------

$manifest = Get-Content (Join-Path $here 'AppxManifest.xml.in') -Raw
$manifest = $manifest.
    Replace('@IDENTITY_NAME@', $identity.Name).
    Replace('@PUBLISHER@', $publisher).
    Replace('@PUBLISHER_DISPLAY_NAME@', $identity.PublisherDisplayName).
    Replace('@VERSION_APPX@', $version)

# A placeholder that survived substitution is a package that installs and is
# wrong, so it is looked for rather than assumed away. This catches a
# placeholder added to the template and not to this script, which is the
# realistic way the two part company.
$left = [regex]::Matches($manifest, '@[A-Z_]+@') |
    ForEach-Object { $_.Value } | Sort-Object -Unique
if ($left) {
    Refuse "AppxManifest.xml.in has placeholders this script does not substitute: $($left -join ', ')"
}

# UTF-8 with no byte order mark. `Out-File -Encoding utf8` in Windows
# PowerShell 5.1 writes one, and a manifest beginning with a BOM is malformed
# XML as far as makeappx is concerned.
[System.IO.File]::WriteAllText(
    (Join-Path $stage 'AppxManifest.xml'),
    $manifest,
    (New-Object System.Text.UTF8Encoding $false))

# --- the resource index -----------------------------------------------------

# Without this the package ships the images and the shell reads only the six
# the manifest names by literal path: every `scale-` and `altform-` qualifier
# beside them is inert, because a qualifier is resolved through the resource
# index and nowhere else.
#
# The visible cost of not having one was the taskbar. `BackgroundColor` is
# `transparent`, so Windows plates the icon in the user's accent colour, and on
# slipcase-desktop that drew the application on a purple square while the
# side-loaded install drew the same icon unplated. The `altform-unplated` asset
# is what stops it, and it was in that package and doing nothing until this step
# existed.
#
# The configuration is written outside the staging tree on purpose. `makepri`
# indexes the directory it is given, so a configuration file left inside it
# becomes a resource of the package.
$makepri = Find-SdkTool 'makepri.exe'
if (-not $makepri) { Refuse 'no makepri.exe in any Windows SDK' }
$priConfig = Join-Path $OutDir 'priconfig.xml'
# `en-US_de-DE` matches the `<Resource Language="en-us" />` and
# `<Resource Language="de-de" />` the manifest declares - makepri joins the
# tags with an underscore, and `createconfig` writes them as the Language
# qualifier `en-US;de-DE`. If the two sides disagree the index has no default
# language and the shell falls back to the literal paths, which is the failure
# this whole step exists to remove - and it fails silently, so it is spelled
# once here from the manifest's values. It was `en-GB` until 2026-09-06 and
# `en-US` alone until 2026-09-10; the manifest carries the why.
& $makepri createconfig /cf $priConfig /dq en-US_de-DE /o | Out-Null
if ($LASTEXITCODE -ne 0) { Refuse "makepri createconfig failed ($LASTEXITCODE)" }

# The default configuration splits qualified resources into *resource packages*,
# which is right for a bundle and wrong for one monolithic package. Left alone,
# `makepri` wrote `resources.scale-125.pri` and four siblings and left the scale
# variants out of the main index entirely: `makepri dump` of the installed
# package found no `scale-125` anywhere in it, so every one of those images
# shipped and resolved to nothing. This is a package, not a bundle, so the
# splitting is turned off and everything lands in one index.
[xml] $priXml = Get-Content $priConfig
foreach ($split in @($priXml.SelectNodes('//autoResourcePackage'))) {
    $split.ParentNode.RemoveChild($split) | Out-Null
}
$priXml.Save($priConfig)

& $makepri new /pr $stage /cf $priConfig /of (Join-Path $stage 'resources.pri') /o | Out-Null
if ($LASTEXITCODE -ne 0) { Refuse "makepri new failed ($LASTEXITCODE)" }
Remove-Item $priConfig -Force
if (-not (Test-Path (Join-Path $stage 'resources.pri'))) {
    Refuse 'makepri reported success and wrote no resources.pri'
}
# Nothing should have been split out. If a `resources.<qualifier>.pri` appears
# beside the main one, the configuration edit above stopped working and the
# variants are silently unresolvable again - which is a thing that looks like a
# working package right up until somebody photographs a taskbar.
$split = Get-ChildItem (Join-Path $stage 'resources.*.pri') -ErrorAction SilentlyContinue
if ($split) {
    Refuse "makepri split resources into $($split.Name -join ', ') - those belong to a bundle, and this is one package"
}

# --- the package ------------------------------------------------------------

$package = Join-Path $OutDir "Filebase-$version-x64.msix"
& $makeappx pack /d $stage /p $package /o
if ($LASTEXITCODE -ne 0) { Refuse "makeappx pack failed ($LASTEXITCODE)" }

Write-Host ''
Write-Host "built $package"
Write-Host "  identity  $($identity.Name)"
Write-Host "  publisher $($publisher)"
Write-Host "  version   $version"
Write-Host "  from      $Binary"
# Said out loud because the package name is deterministic, so a plain run
# overwrites a signed package of the same version with an unsigned one and says
# nothing about it. Deployment then fails 0x800B0100, "no signature was present",
# which reads like a signing problem rather than like the last build having been
# a different build.
if (-not $SelfSign) {
    Write-Host '  unsigned  - pass -SelfSign to install it here'
}

# --- signing, for a local install and nothing else --------------------------

if ($SelfSign) {
    $signtool = Find-SdkTool 'signtool.exe'
    if (-not $signtool) { Refuse 'no signtool.exe in any Windows SDK' }

    # signtool refuses a package whose manifest Publisher and whose certificate
    # subject differ, so the subject is built from the identity rather than
    # typed a second time.
    #
    # Filebase's Publisher is the Excelano account's and is the same X.500 string
    # slipcase-desktop's identity carries, so a certificate left in this store
    # by that application's test signing matches this one and is reused. That is
    # correct rather than a coincidence to guard against: the subject is what
    # signtool checks, both packages are this account's, and the certificate is
    # a throwaway either way.
    $cert = Get-ChildItem Cert:\CurrentUser\My |
        Where-Object { $_.Subject -eq $publisher -and $_.HasPrivateKey } |
        Sort-Object NotAfter -Descending |
        Select-Object -First 1
    if (-not $cert) {
        Write-Host "making a throwaway signing certificate for $($publisher)"
        $cert = New-SelfSignedCertificate -Type CodeSigningCert `
            -Subject $publisher `
            -KeyUsage DigitalSignature `
            -FriendlyName 'Excelano MSIX test signing (throwaway)' `
            -CertStoreLocation Cert:\CurrentUser\My `
            -TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.3')
    }
    & $signtool sign /fd SHA256 /sha1 $cert.Thumbprint $package
    if ($LASTEXITCODE -ne 0) { Refuse "signtool failed ($LASTEXITCODE)" }
    Write-Host "signed with $($cert.Thumbprint) - a throwaway, and not what the Store distributes"

    # Deployment reads LocalMachine\TrustedPeople and not the per-user store:
    # importing into CurrentUser\TrustedPeople leaves Add-AppxPackage failing
    # 0x800B0109 just the same. That import is the one administrator action in
    # this whole path, so it is printed rather than attempted.
    $trusted = Get-ChildItem Cert:\LocalMachine\TrustedPeople -ErrorAction SilentlyContinue |
        Where-Object { $_.Thumbprint -eq $cert.Thumbprint }
    Write-Host ''
    if ($trusted) {
        Write-Host 'install it:'
        Write-Host "  Add-AppxPackage $package"
        # Deployment refuses 0x80073CFB for a package whose identity and version
        # match one already installed but whose contents differ, which is every
        # rebuild during a day's work. The version is not bumped for that - it
        # is one number with three spellings and a release decision - so the old
        # one comes off first.
        Write-Host '  # rebuilding the same version? remove the installed one first:'
        Write-Host "  Get-AppxPackage $($identity.Name) | Remove-AppxPackage"
    } else {
        Write-Host 'to install it, this certificate has to be trusted, which needs administrator once:'
        Write-Host "  Export-Certificate -Cert Cert:\CurrentUser\My\$($cert.Thumbprint) -FilePath `$env:TEMP\excelano-test.cer"
        Write-Host '  # then, from an elevated prompt:'
        Write-Host "  Import-Certificate -FilePath `$env:TEMP\excelano-test.cer -CertStoreLocation Cert:\LocalMachine\TrustedPeople"
        Write-Host "  Add-AppxPackage $package"
    }
}

# --- the certification kit --------------------------------------------------

if ($Certify) {
    if (-not $SelfSign) {
        Refuse '-Certify needs -SelfSign: the kit installs the package it tests, and an unsigned one will not install'
    }
    $elevated = ([Security.Principal.WindowsPrincipal] `
            [Security.Principal.WindowsIdentity]::GetCurrent()
        ).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
    if (-not $elevated) {
        Refuse 'the Windows App Certification Kit needs an elevated session - rerun this from an administrator prompt'
    }
    $appcert = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\App Certification Kit\appcert.exe'
    if (-not (Test-Path $appcert)) {
        Refuse "no appcert.exe at $appcert - the App Certification Kit is a separate SDK feature"
    }

    $report = Join-Path $OutDir "wack-$version.xml"

    # The report is removed first, and then the one that appears is checked for
    # being newer than this run. Both, because the first version of this did
    # neither and reported a previous run's verdict as though it were this one's.
    #
    # `appcert` refuses to overwrite a report: given a path that exists it prints
    # "Please specify a unique report file name" and stops before running a
    # single test. The file was still there, `Test-Path` was satisfied, and the
    # findings printed were the previous package's - on a run whose whole
    # purpose was to test a different package under the same version number.
    #
    # This is the failure the comment above already claimed to guard against,
    # which is worth reading twice: a kit that ran and failed and a kit that
    # never ran must not come out the same, and *stale* is a third state neither
    # of those words covers.
    if (Test-Path $report) { Remove-Item $report -Force }
    $startedAt = Get-Date
    & $appcert reset | Out-Null
    & $appcert test -appxpackagepath $package -reportoutputpath $report
    if (-not (Test-Path $report)) {
        Refuse "the certification kit wrote no report to $report"
    }
    if ((Get-Item $report).LastWriteTime -lt $startedAt) {
        Refuse "the report at $report is older than this run - the kit did not write it, so nothing below would be about this package"
    }

    Test-CertificationReport $report
}