Repository navigation
Expand file tree
/
Copy pathdist.ps1
More file actions
1194 lines (1088 loc) · 67.9 KB
/
Copy pathdist.ps1
File metadata and controls
1194 lines (1088 loc) · 67.9 KB
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
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env pwsh
#
# dist.ps1 — build a framework-dependent OpenDevelop distribution for local testing.
# Mirrors the CI steps in .github/workflows/package.yml. Replaces the former dist.macos.ps1 and
# dist.windows.ps1, which were ~85% identical and drifted apart every time one side was touched.
#
# The whole publish/patch/AddIn-build pipeline is platform-neutral and lives here once. Only the
# final packaging stage differs, and it is isolated in Invoke-MacPackaging / Invoke-WindowsPackaging
# at the bottom:
#
# macOS -> OpenDevelop.app bundle, then OpenDevelop-macos.dmg
# Windows -> one OpenDevelop-win-<rid>/ payload directory and OpenDevelop-windows-<rid>.zip PER
# RuntimeIdentifier (see -RuntimeIdentifiers below)
#
# Note the app runs on LibreWPF (portable WPF) on Windows too — SharpDevelop.csproj strips the
# Microsoft.WindowsDesktop.App runtime framework on EVERY platform — which is why the LibreWinForms
# deps.json patch and the LibreWPF.Transport overlay below are not macOS-specific. The Win32
# compatibility shims (kernel32.dll, user32.dll, ...) the macOS bundle carries are emitted by
# LibreWPF.Sdk only on OSX/Linux and must NOT appear on Windows, where those names belong to the
# real OS DLLs; that split is handled by OpenDevelopDistributionRidFamily, not here.
#
# Windows: one package per architecture, not "Any CPU". ProGPU/LibreWPF ship real native
# libraries (glfw3.dll, wgpu_native.dll, vcruntime140_cor3.dll, DirectX/Vulkan interop shims, ...),
# and a native DLL is inherently architecture-specific - there is no "Any CPU" for P/Invoke'd code.
# Without an explicit -p:RuntimeIdentifier, the SDK implicitly resolves native assets for whatever
# architecture the machine RUNNING this script happens to be (dotnet --info's own RID), which is
# how a previous run on an ARM64 dev machine silently produced only a win-arm64 package. Passing
# -RuntimeIdentifiers explicitly (default: both) makes the output deterministic regardless of the
# build machine's own architecture, and produces a correct package for each target.
#
# Usage: ./dist.ps1 [-SkipPublish] [-Configuration Debug|Release]
# -SkipPublish reuse existing publish output (faster iteration on packaging)
# -Configuration Debug package the Debug configuration instead of Release.
#
# On macOS this is normally reached through ./dist.macos.sh, which only locates pwsh.
#
[CmdletBinding()]
param(
[switch]$SkipPublish,
[ValidateSet('Debug', 'Release')]
[string]$Configuration = 'Release',
# Run ONLY these phases. Order given does not matter; they always execute in canonical order
# (see -ListPhases). This is what makes patching a published payload cheap: rebuild one AddIn
# with ./build.ps1, then re-run just -Phase payload instead of the whole pipeline.
[string[]]$Phase = @(),
# Run this phase and every phase after it.
[string]$From,
# Print the phases for this platform and exit.
[switch]$ListPhases,
# Distribution AddIns build in isolated project staging directories so their legacy shared
# deployment OutputPath no longer forces serial MSBuild execution.
[ValidateRange(1, 64)]
[int]$AddInMaxCpuCount = 2,
# The isolated staging path has a cold, whole-AddIns build validation at -m:2. Keep it on
# for normal distributions; callers can still use -UseAddInStaging:$false while diagnosing.
[switch]$UseAddInStaging = $true,
# Stop anything holding the payload open before touching it. The smoke phase leaves a
# "dotnet exec OpenDevelop.dll" process behind if it did not shut down cleanly, and the
# out-of-process designer hosts outlive the IDE by design (SharedDesignerHostPool), so the
# next payload phase fails on "Access to the path ...\CodeCoverage.dll is denied".
[switch]$Kill
)
$ErrorActionPreference = 'Stop'
$repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
Import-Module (Join-Path $repoRoot 'build/common.psm1') -Force
$config = $Configuration
$tfm = 'net10.0-windows'
$sln = Join-Path $repoRoot 'OpenDevelop.Mvp.slnx'
$hostProject = Join-Path $repoRoot 'src/Main/SharpDevelop/SharpDevelop.csproj'
$dotnet = Find-DotNetHost
$patchScript = Join-Path $repoRoot 'build/patch-librewinforms-deps.ps1'
$openAvalonRoot = Join-Path (Split-Path -Parent $repoRoot) 'openavalon'
$windowsX64CanonicalFeed = Join-Path $openAvalonRoot 'artifacts/canonical-winforms-feed-x64'
$windowsArm64CanonicalFeed = Join-Path $openAvalonRoot 'artifacts/canonical-winforms-feed'
$nugetGlobalPackages = $null
# The Addin SDK's OpenDevelopPruneAddinDeploymentAssets target drops runtimes/win*, linux* and
# unix* only for the 'osx' family; on Windows those win* assets are exactly what the payload needs.
$ridFamily = if ($IsWindows) { 'win' } else { 'osx' }
# ONE platform-neutral payload/zip: the app is AnyCPU and apphost-free, and every architecture's
# native/runtime assets live under runtimes/<rid>/ in the same tree. Script-scoped because the
# payload / smoke / zip phases are separately runnable and all three refer to them.
$payloadRoot = Join-Path $repoRoot 'OpenDevelop-win'
$zipPath = Join-Path $repoRoot 'OpenDevelop-win.zip'
$publishDir = Join-Path $repoRoot "src/Main/SharpDevelop/bin/$Configuration/$tfm/publish"
$depsJson = Join-Path $publishDir 'OpenDevelop.deps.json'
function Get-NuGetGlobalPackages {
# Needed by both the host publish and the AddIns build when they run as separate phases.
# A complete distribution invokes this from host, AddIns and (on Windows) designer-host
# phases. The cache location cannot change within this script process, so avoid starting the
# dotnet CLI and resolving its SDK graph more than once.
if ($script:nugetGlobalPackages) { return $script:nugetGlobalPackages }
$line = & $dotnet nuget locals global-packages --list | Select-String '^global-packages: '
if (-not $line) { throw 'dist.ps1: cannot determine the NuGet global-packages directory' }
# A trailing directory separator escapes the closing quote when this value is forwarded to
# the external dependency-patching script on Windows. Keep it as a canonical directory path
# without a terminal separator.
$script:nugetGlobalPackages = (($line.Line -replace '^global-packages:\s*', '').Trim().TrimEnd([char[]]@('\', '/')))
return $script:nugetGlobalPackages
}
function Assert-HostPublished {
# Every phase from "addins" onward consumes the host publish output. Fail with an actionable
# message instead of silently assembling a payload around a missing or stale host.
if (-not (Test-Path $publishDir)) {
throw "dist.ps1: host publish output not found at $publishDir. Run './dist.ps1 -Phase host' first (or omit -Phase to run everything)."
}
}
function New-TempDir {
$p = Join-Path ([System.IO.Path]::GetTempPath()) ("opendevelop-" + [System.Guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $p | Out-Null
return $p
}
function Get-ProcessLockingPath {
<#
Best-effort answer to "why is this directory not empty?" on Windows. Enumerates the modules
mapped into each running process and returns the ones loaded from inside $Directory. Loaded
AddIn DLLs are exactly what keeps the AddIns deployment root undeletable, and naming the
holder is far more actionable than Remove-Item's "The directory is not empty" (which hides
that a child handle, not the directory, is the problem). A file merely opened as data (a log,
a .pdb) will not appear here, so callers must still cope with an empty result.
#>
param([Parameter(Mandatory)][string]$Directory)
$separators = [char[]]@([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)
$prefix = [System.IO.Path]::GetFullPath($Directory).TrimEnd($separators) + [System.IO.Path]::DirectorySeparatorChar
$holders = @()
foreach ($proc in Get-Process -ErrorAction SilentlyContinue) {
try {
foreach ($module in $proc.Modules) {
$file = $module.FileName
if ($file -and $file.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) {
$holders += [pscustomobject]@{ Id = $proc.Id; Name = $proc.ProcessName; File = $file }
}
}
}
catch {
# Access denied / bitness mismatch / process exited mid-enumeration. The lock, if any,
# is reported by whichever process we can still read.
}
}
return $holders
}
function Remove-RepoGeneratedDirectory {
<#
PowerShell's Remove-Item -Recurse enumerates the AddIns deployment file-by-file. On macOS
that is disproportionately slow for its multi-gigabyte dependency closure. Distribution
still needs an empty tree (an incremental project build may otherwise leave a removed
package/RID asset behind), so retain the exact clean-state contract but use the native
recursive unlink implementation there. The relative-path check makes this intentionally
incapable of deleting the repository root or anything outside it.
#>
param([Parameter(Mandatory)][string]$Directory)
if (-not (Test-Path -LiteralPath $Directory)) { return }
$directorySeparators = [char[]]@([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)
$repoFullPath = [System.IO.Path]::GetFullPath($repoRoot).TrimEnd($directorySeparators)
$fullPath = [System.IO.Path]::GetFullPath($Directory).TrimEnd($directorySeparators)
$relativePath = [System.IO.Path]::GetRelativePath($repoFullPath, $fullPath)
$parentPrefix = '..' + [System.IO.Path]::DirectorySeparatorChar
if ($relativePath -eq '.' -or $relativePath -eq '..' -or $relativePath.StartsWith($parentPrefix, [System.StringComparison]::Ordinal) -or [System.IO.Path]::IsPathRooted($relativePath)) {
throw "dist.ps1: refusing to remove non-generated repository directory: $fullPath"
}
if ($IsWindows) {
# Remove-Item -Recurse deletes children first, so if a single child is still open it aborts
# and reports the TOP directory as "The directory is not empty" - never "file in use". The
# AddIns deployment root is full of DLLs a lingering out-of-process designer host, a reused
# MSBuild node, or antivirus/indexer can still hold briefly, and that transient handle is
# exactly the failure being worked around. Retry with backoff, using robocopy /MIR from an
# empty directory between attempts to clear read-only attributes and long-path skeletons a
# plain Remove-Item can leave behind, then fail with the actual lock holder named.
$attempts = 6
for ($attempt = 1; $attempt -le $attempts; $attempt++) {
try {
Remove-Item -LiteralPath $fullPath -Recurse -Force -ErrorAction Stop
return
}
catch {
if ($attempt -eq $attempts) { break }
Start-Sleep -Milliseconds (200 * $attempt)
if (Test-Path -LiteralPath $fullPath) {
$empty = New-TempDir
try {
& robocopy.exe $empty $fullPath /MIR /COPY:DAT /DCOPY:DAT /XJ /R:0 /W:0 /NFL /NDL /NJH /NJS /NP | Out-Null
}
finally {
Remove-Item -LiteralPath $empty -Recurse -Force -ErrorAction SilentlyContinue
}
}
}
}
$advice = "Stop OpenDevelop and every lingering dotnet/MSBuild process (out-of-process designer hosts survive the IDE), then re-run with -Kill."
$holders = @(Get-ProcessLockingPath -Directory $fullPath | Sort-Object Id -Unique)
if ($holders.Count -gt 0) {
$detail = ($holders | ForEach-Object { " PID $($_.Id) $($_.Name): $($_.File)" }) -join [Environment]::NewLine
throw "dist.ps1: could not remove generated directory because process(es) still have files open inside it:`n$detail`n$advice`nDirectory: $fullPath"
}
throw "dist.ps1: could not remove generated directory after $attempts attempts (no lock holder identified; antivirus/indexer may be holding it): $fullPath`n$advice"
}
& /bin/rm -rf -- $fullPath
if ($LASTEXITCODE -ne 0) { throw "dist.ps1: failed to remove generated directory: $fullPath" }
}
function Sync-WindowsDirectoryMirror {
<#
robocopy /MIR has the same important correctness property as deleting a generated payload
before Copy-Item: target files no longer present in the source are removed. Unlike a fresh
recursive copy it retains unchanged host files, making `-Phase payload` cheap after an
AddIn-only rebuild. Robocopy exit codes 0..7 describe successful copy/difference states;
only 8 and above are errors.
#>
param(
[Parameter(Mandatory)][string]$Source,
[Parameter(Mandatory)][string]$Destination,
[Parameter(Mandatory)][string]$Description,
[string[]]$ExcludeDirectories = @(),
[string[]]$ExcludeFiles = @()
)
if (-not $IsWindows) { throw 'Sync-WindowsDirectoryMirror is Windows-only.' }
if (-not (Test-Path -LiteralPath $Source -PathType Container)) {
throw "dist.ps1: mirror source does not exist: $Source"
}
Write-Host "==> Synchronizing $Description..."
# /MIR reports every purged file as EXTRA even with /NFL and /NDL. Keep normal release output
# compact, but retain the detailed robocopy report when a locked file or I/O failure occurs.
$logPath = Join-Path ([System.IO.Path]::GetTempPath()) ("opendevelop-robocopy-" + [Guid]::NewGuid().ToString('N') + '.log')
try {
$exclusions = @()
if ($ExcludeDirectories.Count -gt 0) { $exclusions += '/XD'; $exclusions += $ExcludeDirectories }
if ($ExcludeFiles.Count -gt 0) { $exclusions += '/XF'; $exclusions += $ExcludeFiles }
& robocopy.exe $Source $Destination /MIR /COPY:DAT /DCOPY:DAT /XJ /R:2 /W:1 /NFL /NDL /NJH /NJS /NP @exclusions "/LOG:$logPath" | Out-Null
$robocopyExitCode = $LASTEXITCODE
if ($robocopyExitCode -ge 8) {
$diagnostics = if (Test-Path -LiteralPath $logPath) {
(Get-Content -LiteralPath $logPath -Tail 120) -join [Environment]::NewLine
} else { '(robocopy did not create a log)' }
throw "dist.ps1: robocopy failed while synchronizing $Description (exit code $robocopyExitCode). " +
"Something may still have the payload open. Re-run with -Kill, or stop OpenDevelop/dotnet first.`n$diagnostics"
}
}
finally {
Remove-Item -LiteralPath $logPath -Force -ErrorAction SilentlyContinue
}
}
function Write-DistributionStepElapsed {
param(
[Parameter(Mandatory)][System.Diagnostics.Stopwatch]$Stopwatch,
[Parameter(Mandatory)][string]$Step
)
$Stopwatch.Stop()
Write-Host (" {0}: {1:N1}s" -f $Step, $Stopwatch.Elapsed.TotalSeconds)
$Stopwatch.Restart()
}
# Get-PinnedGitVersionProperties now lives in build/common.psm1, shared with build.ps1.
function Build-Launchers {
<#
The distribution payload is AnyCPU/apphost-free - OpenDevelop.dll is started as
"dotnet exec OpenDevelop.dll" - EXCEPT for the one file that unavoidably has to be
architecture-specific: the native apphost the user actually double-clicks. src/Main/
OpenDevelop.Launcher is exactly that: a tiny apphost whose only job is to hand off to
"dotnet exec OpenDevelop.dll" using a dotnet.exe guaranteed to match its own architecture
(see that project's Program.cs header comment for why no path-guessing is needed - hostfxr
already solved that just by starting this very process).
Built twice, once per Windows architecture, and only the resulting .exe is kept per pass -
renaming an apphost after publish is safe (verified empirically: the companion
.dll/.deps.json/.runtimeconfig.json paths are baked in at publish time, not re-derived from
the exe's own on-disk filename at runtime), so the win-x64 pass becomes OpenDevelop.exe and
the win-arm64 pass becomes OpenDevelopARM64.exe. The companion trio itself (named
OpenDevelop.Bootstrap.* - deliberately not "OpenDevelop", which is already the real IDE's own
managed entry point sitting in the same folder) is copied from just ONE pass: the launcher
project has zero PackageReferences, so there is nothing RID-conditional that could differ
between the two builds' copies.
#>
param(
[Parameter(Mandatory)][string]$RepoRoot,
[Parameter(Mandatory)][string]$DotNet,
[ValidateSet('Debug', 'Release')]
[string]$Configuration = 'Release',
[Parameter(Mandatory)][string]$PayloadRoot
)
$launcherProject = Join-Path $RepoRoot 'src/Main/OpenDevelop.Launcher/OpenDevelop.Launcher.csproj'
$ridToExeName = [ordered]@{ 'win-x64' = 'OpenDevelop.exe'; 'win-arm64' = 'OpenDevelopARM64.exe' }
$companionCopied = $false
foreach ($rid in $ridToExeName.Keys) {
$publishDir = New-TempDir
try {
Write-Host "==> Building launcher ($rid)..."
Invoke-Native $DotNet publish $launcherProject -c $Configuration --self-contained false `
"-r" $rid "-o" $publishDir
$builtExe = Join-Path $publishDir 'OpenDevelop.Bootstrap.exe'
if (-not (Test-Path -LiteralPath $builtExe)) {
throw "Build-Launchers: expected launcher apphost not found: $builtExe"
}
Copy-Item -LiteralPath $builtExe -Destination (Join-Path $PayloadRoot $ridToExeName[$rid]) -Force
if (-not $companionCopied) {
foreach ($companionExtension in '.dll', '.deps.json', '.runtimeconfig.json') {
$companionFile = Join-Path $publishDir "OpenDevelop.Bootstrap$companionExtension"
if (-not (Test-Path -LiteralPath $companionFile)) {
throw "Build-Launchers: expected launcher companion file not found: $companionFile"
}
Copy-Item -LiteralPath $companionFile -Destination $PayloadRoot -Force
}
$companionCopied = $true
}
} finally {
Remove-Item -Recurse -Force $publishDir -ErrorAction SilentlyContinue
}
}
}
function Test-WindowsLaunchersCurrent {
<#
Launchers are independent of the host/AddIn payload and are expensive to publish twice on
every `-Phase payload`. Reuse them only when every launcher input predates every required
output. The normal payload validation still checks both PE machine types afterwards.
#>
param(
[Parameter(Mandatory)][string]$RepoRoot,
[Parameter(Mandatory)][string]$PayloadRoot
)
$requiredOutputs = @(
'OpenDevelop.exe', 'OpenDevelopARM64.exe', 'OpenDevelop.Bootstrap.dll',
'OpenDevelop.Bootstrap.deps.json', 'OpenDevelop.Bootstrap.runtimeconfig.json'
) | ForEach-Object { Join-Path $PayloadRoot $_ }
if (@($requiredOutputs | Where-Object { -not (Test-Path -LiteralPath $_ -PathType Leaf) }).Count -gt 0) {
return $false
}
$launcherRoot = Join-Path $RepoRoot 'src/Main/OpenDevelop.Launcher'
# bin/ and obj/ are outputs from the preceding launcher publish; treating them as inputs would
# make their fresh timestamps invalidate this cache forever.
$inputs = @(Get-ChildItem -LiteralPath $launcherRoot -Recurse -File | Where-Object {
$relative = $_.FullName.Substring($launcherRoot.Length).TrimStart('\', '/').Replace('\', '/')
-not ($relative.StartsWith('bin/', [System.StringComparison]::OrdinalIgnoreCase) -or
$relative.StartsWith('obj/', [System.StringComparison]::OrdinalIgnoreCase))
})
foreach ($relativeInput in 'Directory.Build.props', 'Directory.Build.targets', 'global.json', 'NuGet.config') {
$input = Join-Path $RepoRoot $relativeInput
if (Test-Path -LiteralPath $input -PathType Leaf) { $inputs += Get-Item -LiteralPath $input }
}
$newestInput = ($inputs | Measure-Object -Property LastWriteTimeUtc -Maximum).Maximum
$oldestOutput = (($requiredOutputs | ForEach-Object { Get-Item -LiteralPath $_ }) |
Measure-Object -Property LastWriteTimeUtc -Minimum).Minimum
return $newestInput -le $oldestOutput
}
function Test-PackagedAppStartup {
<#
Launch the packaged app and require it to STAY up. A distribution that is missing or
mismatching a runtime assembly does not fail the build — it throws at boot, which is exactly
what the deps.json patch exists to prevent. Dump the captured output on an early exit so the
cause is visible without re-running by hand.
The payload is apphost-free, so callers pass the user's dotnet host and the app dll - EXCEPT
when testing a launcher (OpenDevelop.exe/OpenDevelopARM64.exe): that process itself spawns a
CHILD "dotnet exec OpenDevelop.dll" and waits on it, so $FilePath's own window (if any) is
not where the real WPF window/file locks live. Pass -KillProcessTree in that case so cleanup
(below) reaches the child too - otherwise it becomes an orphan still holding the payload's
files open, which corrupts the same .zip step this function's own file-lock comment is about.
#>
param(
[Parameter(Mandatory)][string]$FilePath,
[string[]]$ArgumentList = @(),
[string]$WorkingDirectory,
[switch]$KillProcessTree
)
$tag = [System.Guid]::NewGuid().ToString('N')
# Start-Process rejects using ONE file for both streams; keep two.
$outLog = Join-Path ([System.IO.Path]::GetTempPath()) "opendevelop-smoke-$tag.out.log"
$errLog = Join-Path ([System.IO.Path]::GetTempPath()) "opendevelop-smoke-$tag.err.log"
$startArgs = @{
FilePath = $FilePath
RedirectStandardOutput = $outLog
RedirectStandardError = $errLog
PassThru = $true
}
if ($ArgumentList.Count -gt 0) { $startArgs.ArgumentList = $ArgumentList }
if ($WorkingDirectory) { $startArgs.WorkingDirectory = $WorkingDirectory }
$proc = Start-Process @startArgs
$ok = $true
for ($i = 0; $i -lt 10; $i++) {
Start-Sleep -Seconds 1
if ($proc.HasExited) { $ok = $false; break }
}
if (-not $ok) {
Write-Host "dist.ps1: packaged app exited during startup (status $($proc.ExitCode))" -ForegroundColor Red
foreach ($log in $outLog, $errLog) {
if (Test-Path $log) { Get-Content $log -TotalCount 160 | Write-Host }
}
Remove-Item -Force $outLog, $errLog -ErrorAction SilentlyContinue
exit 1
}
# Ask the message loop to shut down before forcing the process, so the app releases its file
# locks; on Windows a still-locked payload can otherwise be captured half-written by the .zip.
try { $proc.CloseMainWindow() | Out-Null } catch {}
Start-Sleep -Seconds 2
if (-not $proc.HasExited) {
if ($KillProcessTree) {
# taskkill /T reaches the whole tree (the launcher AND the "dotnet exec" child it
# spawned and is blocked on); Stop-Process alone only ever touches $proc.Id itself.
& taskkill /PID $proc.Id /T /F | Out-Null
} else {
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
}
}
Remove-Item -Force $outLog, $errLog -ErrorAction SilentlyContinue
Write-Host 'Packaged app startup smoke test passed'
}
# The payload is one download for every architecture, so every IL-only assembly in it must be
# AnyCPU: an ARM64-only one cannot even be loaded by an x64 OpenDevelop ("The assembly architecture
# is not compatible with the current process architecture"). Exempt are:
# - runtimes\ and per-RID win-x64/win-arm64/win-x86 folders, which exist precisely to carry
# several architectures side by side for the host to pick from;
# - mixed-mode C++/CLI assemblies (not ILOnly, e.g. DirectWriteForwarder.dll), architecture-
# specific by nature and selected through the patched deps.json.
# A violation is almost always stale Debug output from a developer build - clean that project.
function Test-WindowsPayloadAssemblyArchitecture {
param([Parameter(Mandatory)][string]$PayloadRoot)
$violations = foreach ($file in Get-ChildItem -LiteralPath $PayloadRoot -Recurse -File -Include '*.dll', '*.exe') {
$relative = $file.FullName.Substring($PayloadRoot.Length).TrimStart('\', '/')
if ($relative -match '(^|[\\/])(runtimes|win-x64|win-arm64|win-x86)[\\/]') { continue }
$stream = [System.IO.File]::OpenRead($file.FullName)
try {
$pe = [System.Reflection.PortableExecutable.PEReader]::new($stream)
$cor = $pe.PEHeaders.CorHeader
if ($null -eq $cor) { continue }
if (($cor.Flags -band [System.Reflection.PortableExecutable.CorFlags]::ILOnly) -eq 0) { continue }
$machine = $pe.PEHeaders.CoffHeader.Machine
$requires32Bit = ($cor.Flags -band [System.Reflection.PortableExecutable.CorFlags]::Requires32Bit) -ne 0 -and
($cor.Flags -band [System.Reflection.PortableExecutable.CorFlags]::Prefers32Bit) -eq 0
if ($machine -ne [System.Reflection.PortableExecutable.Machine]::I386 -or $requires32Bit) {
"$relative ($machine$(if ($requires32Bit) { ', 32-bit required' }))"
}
} catch [System.BadImageFormatException] {
} finally { $stream.Dispose() }
}
if ($violations) {
throw "Distribution payload contains $(@($violations).Count) architecture-specific managed assemblies; it must be AnyCPU:`n " + ($violations -join "`n ")
}
}
function Test-WindowsDistributionPayload {
param([Parameter(Mandatory)][string]$PayloadRoot)
$app = Join-Path $PayloadRoot 'OpenDevelop.dll'
if (-not (Test-Path -LiteralPath $app)) { throw "Distribution payload has no OpenDevelop.dll: $PayloadRoot" }
# OpenDevelop.dll itself is AnyCPU and apphost-free: it is started as "dotnet exec OpenDevelop.dll".
# The launcher (Build-Launchers) supplies the ONE file that is unavoidably architecture-specific -
# the native apphost the user double-clicks - as two separate files, one per Windows
# architecture, so the platform-neutral payload can still be a single download. Verify both are
# present and actually built for the architecture their name promises: a launcher of the wrong
# PE machine type would fail with a bare "not a valid Win32 application" and no other clue.
foreach ($launcherSpec in @(
@{ Name = 'OpenDevelop.exe'; ExpectedMachine = 0x8664 },
@{ Name = 'OpenDevelopARM64.exe'; ExpectedMachine = 0xAA64 }
)) {
$launcherPath = Join-Path $PayloadRoot $launcherSpec.Name
if (-not (Test-Path -LiteralPath $launcherPath)) {
throw "Distribution payload is missing the launcher: $launcherPath"
}
$stream = [System.IO.File]::OpenRead($launcherPath)
try {
$reader = [System.IO.BinaryReader]::new($stream)
$stream.Position = 0x3c
$peOffset = $reader.ReadInt32()
$stream.Position = $peOffset
if ($reader.ReadUInt32() -ne 0x00004550) { throw "Not a PE executable: $launcherPath" }
$machine = $reader.ReadUInt16()
if ($machine -ne $launcherSpec.ExpectedMachine) {
throw "Wrong launcher architecture: $launcherPath has machine 0x$('{0:X4}' -f $machine), expected 0x$('{0:X4}' -f $launcherSpec.ExpectedMachine)."
}
} finally { $stream.Dispose() }
}
Test-WindowsPayloadAssemblyArchitecture -PayloadRoot $PayloadRoot
foreach ($companionExtension in '.dll', '.deps.json', '.runtimeconfig.json') {
$companionPath = Join-Path $PayloadRoot "OpenDevelop.Bootstrap$companionExtension"
if (-not (Test-Path -LiteralPath $companionPath)) {
throw "Distribution payload is missing the launcher's companion file: $companionPath"
}
}
# Every supported architecture's native WPF runtime must be present side by side; the host picks
# the matching one from deps.json runtimeTargets at startup.
foreach ($rid in 'win-x64', 'win-arm64') {
$native = Join-Path $PayloadRoot "runtimes\$rid\native\PresentationNative_cor3.dll"
if (-not (Test-Path -LiteralPath $native)) {
throw "Distribution payload is missing the $rid WPF native runtime: $native"
}
}
$addIns = Join-Path $PayloadRoot 'AddIns'
if (-not (Test-Path -LiteralPath $addIns)) { throw "Distribution payload has no AddIns directory: $PayloadRoot" }
if (@(Get-ChildItem -LiteralPath $addIns -Recurse -File -Filter '*.addin').Count -eq 0) { throw "Distribution payload has no addin manifests: $addIns" }
# The WPF designer ships two mutually-exclusive out-of-process hosts - LibreWPF's own
# portable runtime (Host\) and the genuine Microsoft WPF host that runs against the
# machine's installed .NET Desktop Runtime (MicrosoftHost\). WpfSurfaceHostClient
# deliberately refuses to fall back from one to the other at runtime (see
# WpfSurfaceHostClient.StartAsync/AcquireSharedAsync), so a distribution missing either one
# would leave that backend's projects unable to open a designer at all - fail the package
# build now instead of shipping that silently.
# Checked as .dll, not .exe: out-of-process hosts no longer generate an apphost (UseAppHost=false
# in Directory.Build.targets) because DesignerHostProcessClient launches them as
# "dotnet exec <host>.dll". The managed assembly is what actually has to be present.
$wpfDesignRoot = Join-Path $addIns 'DisplayBindings\WpfDesign'
foreach ($hostSpec in @(
@{ SubDir = 'Host'; Exe = 'WpfDesign.SurfaceHost.dll'; Backend = 'LibreWPF' },
@{ SubDir = 'MicrosoftHost'; Exe = 'MicrosoftWpfDesign.SurfaceHost.dll'; Backend = 'Microsoft WPF' }
)) {
$hostExe = Join-Path $wpfDesignRoot "$($hostSpec.SubDir)\$($hostSpec.Exe)"
if (-not (Test-Path -LiteralPath $hostExe)) {
throw "Distribution payload is missing the $($hostSpec.Backend) design host: $hostExe. " +
"Both WPF designer backends must be built and deployed - check that every " +
"src/AddIns/DisplayBindings/WpfDesign/MicrosoftHost/*.csproj project is referenced " +
"by OpenDevelop.Mvp.slnx."
}
}
# The Windows Forms and WinUI designers each have their own genuine-Microsoft-framework
# backend, neither referenced by OpenDevelop.Mvp.slnx (WinForms.MicrosoftHost needs the
# .NET Desktop Runtime; WinUIXamlDesigner.MicrosoftHost additionally needs UseWinUI's
# MrtCore.PriGen.targets, which only ships with Visual Studio - see "Building
# WinUIXamlDesigner.MicrosoftHost" in CLAUDE.md). Both are built by a dedicated
# Build-MicrosoftDesignerHosts step, not the normal solution build, so verify their output
# lands in the payload the same way the WPF check above does - a silent gap here is exactly
# how this backend went unbuilt/unshipped before.
$formsDesignerHostExe = Join-Path $addIns 'DisplayBindings\FormsDesigner\MicrosoftHost\MicrosoftFormsDesigner.Host.dll'
if (-not (Test-Path -LiteralPath $formsDesignerHostExe)) {
throw "Distribution payload is missing the Microsoft Windows Forms design host: $formsDesignerHostExe. " +
"Check that Build-MicrosoftDesignerHosts built " +
"src/AddIns/DisplayBindings/FormsDesigner/MicrosoftHost/Host/MicrosoftFormsDesigner.Host.csproj."
}
# The real WinUI 3 child is architecture-specific, so the single payload must carry one per
# supported Windows RID under MicrosoftHost\<CLR major>\<rid>\. The bootstrap picks by CLR major
# (from the designed app's runtimeconfig) and then by process architecture.
$winUiHostRoot = Join-Path $addIns 'DisplayBindings\WinUIXamlDesigner\MicrosoftHost'
foreach ($tfmDir in 'net9.0', 'net10.0') {
foreach ($rid in 'win-x64', 'win-arm64') {
$winUiHostExe = Join-Path $winUiHostRoot "$tfmDir\$rid\WinUIXamlDesigner.MicrosoftHost.dll"
if (-not (Test-Path -LiteralPath $winUiHostExe)) {
throw "Distribution payload is missing the Microsoft WinUI design host ($tfmDir/$rid): $winUiHostExe. " +
"Check that Build-MicrosoftDesignerHosts built " +
"src/AddIns/DisplayBindings/WinUIXamlDesigner/WinUIXamlDesigner.MicrosoftHost/WinUIXamlDesigner.MicrosoftHost.csproj " +
"with Visual Studio's own MSBuild.exe (dotnet build cannot build UseWinUI projects) for both win-x64 and win-arm64."
}
}
}
# PDBs, reference assemblies and foreign native assets are build-time artifacts. Their
# presence means either an SDK target or the staging copy regressed, and makes the final ZIP
# needlessly architecture/OS-agnostic rather than deployable.
$forbidden = Get-ChildItem -LiteralPath $PayloadRoot -Recurse -File | Where-Object {
$relative = $_.FullName.Substring($PayloadRoot.Length).Replace('\', '/').ToLowerInvariant()
$_.Extension -in '.pdb', '.dylib', '.so' -or
$relative -match '/ref/' -or
$relative -match '/runtimes/(linux|unix|osx)'
}
if ($forbidden) {
$sample = ($forbidden | Select-Object -First 12 -ExpandProperty FullName) -join [Environment]::NewLine
throw "Distribution payload contains build-only or foreign assets:$([Environment]::NewLine)$sample"
}
}
function Test-WindowsDistributionZip {
param([Parameter(Mandatory)][string]$ZipPath)
Add-Type -AssemblyName System.IO.Compression.FileSystem
$archive = [System.IO.Compression.ZipFile]::OpenRead($ZipPath)
try {
$prefix = "OpenDevelop-win/"
if (-not ($archive.Entries.FullName -contains "${prefix}OpenDevelop.dll")) { throw "ZIP lacks ${prefix}OpenDevelop.dll: $ZipPath" }
foreach ($launcherName in 'OpenDevelop.exe', 'OpenDevelopARM64.exe') {
if (-not ($archive.Entries.FullName -contains "${prefix}${launcherName}")) {
throw "ZIP is missing the launcher ${launcherName}: $ZipPath"
}
}
foreach ($companionExtension in '.dll', '.deps.json', '.runtimeconfig.json') {
$companionPath = "${prefix}OpenDevelop.Bootstrap$companionExtension"
if (-not ($archive.Entries.FullName -contains $companionPath)) {
throw "ZIP is missing the launcher's companion file ${companionPath}: $ZipPath"
}
}
if (-not ($archive.Entries.FullName | Where-Object { $_ -like "${prefix}AddIns/*.addin" })) { throw "ZIP lacks addin manifests: $ZipPath" }
# Mirror the designer-host checks in Test-WindowsDistributionPayload - they must survive the
# staging/zip round-trip, not just be present in the staged payload dir.
$expected = @(
"${prefix}AddIns/DisplayBindings/WpfDesign/Host/WpfDesign.SurfaceHost.dll",
"${prefix}AddIns/DisplayBindings/WpfDesign/MicrosoftHost/MicrosoftWpfDesign.SurfaceHost.dll",
"${prefix}AddIns/DisplayBindings/FormsDesigner/MicrosoftHost/MicrosoftFormsDesigner.Host.dll"
)
foreach ($tfmDir in 'net9.0', 'net10.0') {
foreach ($rid in 'win-x64', 'win-arm64') {
$expected += "${prefix}AddIns/DisplayBindings/WinUIXamlDesigner/MicrosoftHost/$tfmDir/$rid/WinUIXamlDesigner.MicrosoftHost.dll"
}
}
foreach ($path in $expected) {
if (-not ($archive.Entries.FullName -contains $path)) {
throw "ZIP is missing a designer host: $path ($ZipPath)"
}
}
$forbidden = $archive.Entries.FullName | Where-Object {
$name = $_.ToLowerInvariant()
$name.EndsWith('.pdb') -or $name.EndsWith('.dylib') -or $name.EndsWith('.so') -or
$name -match '/ref/' -or $name -match '/runtimes/(linux|unix|osx)'
}
if ($forbidden) { throw "ZIP contains build-only or foreign assets:$([Environment]::NewLine)$(($forbidden | Select-Object -First 12) -join [Environment]::NewLine)" }
} finally { $archive.Dispose() }
}
# ---------------------------------------------------------------------------------------------
# Shared pipeline (one AnyCPU pass - see Invoke-DistributionPipeline below)
# ---------------------------------------------------------------------------------------------
#
# Restore runs up front inside Invoke-DistributionPipeline with
# -p:ProGpuWpfUseCurrentRuntimeIdentifier=false, so every project's obj/project.assets.json has a
# RID-less target. On Windows this also seeds LibreWPF.Sdk resolution for the AvalonEdit submodule,
# whose own global.json has no "msbuild-sdks" entry and shadows the repo-root one for every project
# beneath it (otherwise MSB4236 "The SDK 'LibreWPF.Sdk' specified could not be found").
# ---------------------------------------------------------------------------------------------
# Platform packaging
# ---------------------------------------------------------------------------------------------
function Invoke-MacPayload {
Write-Host "==> Building framework-dependent .app bundle ($config)..."
$env:DIST_CONFIG = $config
# Out-Host for the same reason as Invoke-MacArchive: this function's output is its return value.
& bash (Join-Path $repoRoot 'build/macos/build-application-bundle.sh') | Out-Host
if ($LASTEXITCODE -ne 0) { throw "build-application-bundle.sh exited with code $LASTEXITCODE" }
# Same as the Windows payload: out-of-process hosts resolve only from their own deps.json,
# which declares some packages (System.Windows.Extensions, ...) only under runtimes/win.
# Without the patch the host cannot load them on macOS even though the file is present.
$bundleAddIns = Join-Path $repoRoot 'OpenDevelop.app/Contents/MacOS/AddIns'
# `dotnet nuget locals global-packages --list` is process startup plus SDK resolution. All
# child manifests use the same cache, so resolve it once rather than repeating it for every
# out-of-process host manifest in the bundle.
$nugetPackageRoot = Get-NuGetGlobalPackages
$childDepsPaths = @(Get-ChildItem -LiteralPath $bundleAddIns -Recurse -File -Filter '*.deps.json' | Where-Object {
Test-Path -LiteralPath ($_.FullName -replace '\.deps\.json$', '.runtimeconfig.json')
} | ForEach-Object FullName)
if ($childDepsPaths.Count -gt 0) {
& $patchScript -DepsPath $childDepsPaths -NugetPackageRoot $nugetPackageRoot -Quiet
}
return (Join-Path $repoRoot 'OpenDevelop.app')
}
function Invoke-MacSmoke {
Write-Host '==> Smoke-testing packaged app...'
Test-PackagedAppStartup -FilePath $dotnet -ArgumentList @((Join-Path $repoRoot 'OpenDevelop.app/Contents/MacOS/OpenDevelop.dll'))
}
function Invoke-MacArchive {
Write-Host '==> Building .dmg...'
Push-Location $repoRoot
try {
# Out-Host: this function's pipeline output is its return value ($artifact), so without it
# the script's progress and retry lines were swallowed - a failure showed only hdiutil's
# final stderr line - and $artifact became those lines plus the .dmg path.
& bash (Join-Path $repoRoot 'build/macos/build-dmg.sh') OpenDevelop.app OpenDevelop-macos.dmg | Out-Host
if ($LASTEXITCODE -ne 0) { throw "build-dmg.sh exited with code $LASTEXITCODE" }
}
finally {
Pop-Location
}
return (Join-Path $repoRoot 'OpenDevelop-macos.dmg')
}
function Invoke-WindowsPayload {
<#
Assembles OpenDevelop-win/ from artifacts that ALREADY exist on disk: the host publish
output and the built AddIns/ tree. It compiles nothing except the launchers, so it is the
cheap phase to re-run after rebuilding one AddIn with ./build.ps1 - and doing that is
strictly better than hand-copying a DLL into the payload, because the by-name dedup and the
out-of-process-host exemptions below are applied consistently every time.
#>
Assert-HostPublished
Write-Host "==> Assembling the platform-neutral distribution payload ($config)..."
# Keep the exact-clean-output contract without needlessly deleting and re-copying an unchanged
# host publish closure. data/ and AddIns/ have different authoritative sources and are synced
# below, so exclude them here instead of purging/recreating thousands of unchanged files.
$payloadStepStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
Sync-WindowsDirectoryMirror -Source $publishDir -Destination $payloadRoot -Description 'host publish into Windows payload' `
-ExcludeDirectories @('AddIns', 'data') `
-ExcludeFiles @('OpenDevelop.exe', 'OpenDevelopARM64.exe', 'OpenDevelop.Bootstrap.dll', 'OpenDevelop.Bootstrap.deps.json', 'OpenDevelop.Bootstrap.runtimeconfig.json')
Write-DistributionStepElapsed $payloadStepStopwatch 'host mirror'
# `dotnet publish` may preserve symbol/reference files from a Debug (and occasionally a
# cached Release) build. They are neither loaded by the framework-dependent app nor useful
# to an end user, but previously dominated the Windows archive. Do this in staging so the
# verified publish directory remains available for diagnostics and incremental builds.
$hostBuildOnlyFiles = Get-ChildItem -LiteralPath $payloadRoot -Recurse -File | Where-Object {
$relative = $_.FullName.Substring($payloadRoot.Length).Replace('\', '/').ToLowerInvariant()
$_.Extension -eq '.pdb' -or $relative -match '/ref/' -or
($_.Extension -eq '.xml' -and (Test-Path -LiteralPath (Join-Path $_.DirectoryName "$($_.BaseName).dll")))
}
Remove-Item -LiteralPath $hostBuildOnlyFiles.FullName -Force -ErrorAction SilentlyContinue
$hostReferenceDirs = Get-ChildItem -LiteralPath $payloadRoot -Recurse -Directory -Filter ref -ErrorAction SilentlyContinue
foreach ($referenceDir in $hostReferenceDirs) { Remove-Item -LiteralPath $referenceDir.FullName -Recurse -Force }
if (Test-WindowsLaunchersCurrent -RepoRoot $repoRoot -PayloadRoot $payloadRoot) {
Write-Host '==> Reusing current Windows launchers (inputs unchanged)...'
}
else {
Write-Host '==> Building launchers (one native apphost per Windows architecture)...'
Build-Launchers -RepoRoot $repoRoot -DotNet $dotnet -Configuration $config -PayloadRoot $payloadRoot
}
Write-DistributionStepElapsed $payloadStepStopwatch 'host cleanup and launchers'
# A RID-less publish carries EVERY platform's native tree (linux/osx/unix) as well as Windows.
# The other platforms are dead weight in the Windows package, so drop everything except the
# Windows RIDs (win, win-x64, win-x86, win-arm64) before validating/zipping.
foreach ($runtimesDir in Get-ChildItem -LiteralPath $payloadRoot -Recurse -Directory -Filter 'runtimes' -ErrorAction SilentlyContinue) {
foreach ($ridDir in Get-ChildItem -LiteralPath $runtimesDir.FullName -Directory -ErrorAction SilentlyContinue) {
if ($ridDir.Name -eq 'win' -or $ridDir.Name -like 'win-*') { continue }
Remove-Item -LiteralPath $ridDir.FullName -Recurse -Force
}
}
Write-DistributionStepElapsed $payloadStepStopwatch 'runtime pruning'
# OpenDevelop locates its addins and data at runtime by walking UP from the executable looking
# for data/resources/languages/LanguageDefinition.xml (SharpDevelopMain.FindApplicationRootPath),
# then loading *.addin from <root>/AddIns. The payload must therefore contain data/ and AddIns/
# next to the executable so the walk resolves on the first step.
Sync-WindowsDirectoryMirror -Source (Join-Path $repoRoot 'data') -Destination (Join-Path $payloadRoot 'data') -Description 'data into Windows payload'
Write-DistributionStepElapsed $payloadStepStopwatch 'data mirror'
# AddIn build outputs carry their full dependency closures. Anything already supplied by the
# published host resolves from the application base directory, so skip those files by name
# instead of copying ~2 GB and pruning afterwards. This also keeps stale XML docs, satellite
# resources and native helpers from an old developer build out of the payload. The incremental
# payload path intentionally retains AddIns/ and data/ across runs, so they must not contribute
# names here: only the host-owned part of the payload is eligible for copy-local de-duplication.
$hostFiles = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
Get-ChildItem -LiteralPath $payloadRoot -Recurse -File | ForEach-Object {
$relative = $_.FullName.Substring($payloadRoot.Length).TrimStart('\', '/').Replace('\', '/')
if (-not ($relative.StartsWith('AddIns/', [System.StringComparison]::OrdinalIgnoreCase) -or
$relative.StartsWith('data/', [System.StringComparison]::OrdinalIgnoreCase))) {
[void]$hostFiles.Add($_.Name)
}
}
$addInsSource = (Resolve-Path (Join-Path $repoRoot 'AddIns')).Path
$addInsTarget = Join-Path $payloadRoot 'AddIns'
# OpenDevelopAddinKind=OutOfProcessHost projects (WinForms/WPF/WinUI design-surface hosts) run
# as their own separate "dotnet exec" child process with its own working directory - unlike an
# InProcess addin, they cannot resolve a same-named dependency from files sitting beside the
# app dll, so the by-name dedup below must not strip files out of their deployment folders.
# Missing this once (FormsDesigner's Host\ folder losing PresentationFramework.dll, every
# ProGPU.*.dll, System.Windows.Forms.dll, ...) made the WinForms designer's child host crash
# before completing its handshake, surfacing only as an opaque
# "System.TimeoutException: The operation has timed out" with no further detail. Keep this in
# sync with each OutOfProcessHost project's own DeployToAddIns destination.
$outOfProcessHostDirs = @(
'DisplayBindings\FormsDesigner\Host',
'DisplayBindings\FormsDesigner\MicrosoftHost',
'DisplayBindings\GtkDesigner\Host',
'DisplayBindings\MewUIDesigner\Host',
'DisplayBindings\WinUIXamlDesigner\UnoHost',
'DisplayBindings\WinUIXamlDesigner\MicrosoftHost',
'DisplayBindings\WpfDesign\Host',
'DisplayBindings\WpfDesign\MicrosoftHost',
'LanguageServices\XamlLanguageServer.Wpf',
'LanguageServices\WinUIXamlLanguageServer',
'LanguageServices\UnoXamlLanguageServer',
'LanguageServices\ProGpuWinUIXamlLanguageServer',
'LanguageServices\LibreWpfXamlLanguageServer'
)
# The same holds for any other program that runs as its own process from an addin folder, e.g.
# the SharpDbg.Cli debug adapter beside the in-process Debugger addin: stripping its
# ICSharpCode.Decompiler/Microsoft.CodeAnalysis/Newtonsoft.Json (all also in the host) made it
# exit with code 1 and no output, so every debug session "stopped" as soon as it started. A
# *.runtimeconfig.json marks such a program; keep exactly the assets its deps.json lists, both
# the managed ones and the per-RID native ones it has to load for itself.
# Project references also copy-local stray OpenDevelop/MSBuild/Roslyn.Host runtimeconfigs into
# in-process addin folders; those programs are host files and run from the payload root, so
# they are skipped - honouring them would duplicate the host's whole closure into each folder.
$ownProcessDependencies = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
foreach ($runtimeConfig in Get-ChildItem -LiteralPath $addInsSource -Recurse -File -Filter '*.runtimeconfig.json') {
$appDir = $runtimeConfig.DirectoryName
$appName = $runtimeConfig.Name -replace '\.runtimeconfig\.json$', ''
# A stray copy has no program beside it (FormsDesigner\ carries Host\'s runtimeconfig and would
# otherwise pull 119 host files into the in-process addin folder)
if ($hostFiles.Contains("$appName.dll") -or -not (Test-Path -LiteralPath (Join-Path $appDir "$appName.dll"))) { continue }
$deps = Join-Path $appDir "$appName.deps.json"
if (-not (Test-Path -LiteralPath $deps)) { continue }
$targets = (Get-Content -LiteralPath $deps -Raw | ConvertFrom-Json).targets
foreach ($target in $targets.PSObject.Properties.Value) {
foreach ($library in $target.PSObject.Properties.Value) {
# runtime assets are copied flat into the folder, but a runtimeTargets asset keeps
# its runtimes\<rid>\... path - dbgshim's three per-RID dbgshim.dll are exactly that
# case, and they sit under a folder the filter above deliberately keeps. Record both
# spellings of every asset: this set is only ever read with Contains against a real
# file's full path, so an entry that does not exist costs nothing and neither layout
# can be missed. Covering runtimeTargets is what stops the adapter's natives from
# depending on their name not colliding with the host publish.
foreach ($section in @($library.runtime, $library.runtimeTargets)) {
if (-not $section) { continue }
foreach ($asset in $section.PSObject.Properties.Name) {
if ($asset -like '#*') { continue } # "#_Imports" is a marker, not an asset
[void]$ownProcessDependencies.Add((Join-Path $appDir ([System.IO.Path]::GetFileName($asset))))
if ($asset -like '*/*') {
[void]$ownProcessDependencies.Add((Join-Path $appDir ($asset -replace '/', '\')))
}
}
}
}
}
}
# Index source files once. The old filter issued one Test-Path for every XML document and a
# nine-entry pipeline for every candidate; that became the dominant cost after copy itself was
# incremental. An in-memory DLL index and short-circuiting loop retain the exact filter rules.
$allAddInFiles = @(Get-ChildItem -LiteralPath $addInsSource -Recurse -File)
$addInDllPaths = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
foreach ($file in $allAddInFiles) {
if ($file.Extension -eq '.dll') { [void]$addInDllPaths.Add($file.FullName) }
}
$addInFiles = foreach ($file in $allAddInFiles) {
$name = $file.Name
if ($name -like '*.pdb' -or $name -like '*.dylib' -or $name -like '*.so' -or
$name -like 'LeXtudio.DevFlow.*' -or $name -like 'CliclickSharp*') { continue }
$relative = $file.FullName.Substring($addInsSource.Length).TrimStart('\', '/')
if ($relative -match '(^|[\\/])(ref|runtimes[\\/](linux|unix|osx))([\\/]|$)') { continue }
if ($file.Extension -eq '.xml' -and $addInDllPaths.Contains([System.IO.Path]::ChangeExtension($file.FullName, '.dll'))) { continue }
$relativeDir = Split-Path -Parent $relative
$isOutOfProcessHost = $false
foreach ($hostDir in $outOfProcessHostDirs) {
if ($relativeDir -eq $hostDir -or $relativeDir.StartsWith("$hostDir\", [System.StringComparison]::OrdinalIgnoreCase)) {
$isOutOfProcessHost = $true
break
}
}
if (-not $hostFiles.Contains($name) -or $isOutOfProcessHost -or $ownProcessDependencies.Contains($file.FullName)) { $file }
}
$expectedAddInFiles = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
$copiedAddInCount = 0
$unchangedAddInCount = 0
foreach ($file in $addInFiles) {
$relative = $file.FullName.Substring($addInsSource.Length).TrimStart('\', '/')
[void]$expectedAddInFiles.Add($relative)
$destination = Join-Path $addInsTarget $relative
$existing = Get-Item -LiteralPath $destination -ErrorAction SilentlyContinue
if ($existing -and $existing.Length -eq $file.Length -and $existing.LastWriteTimeUtc -eq $file.LastWriteTimeUtc) {
$unchangedAddInCount++
continue
}
$destinationDir = Split-Path -Parent $destination
if (-not (Test-Path -LiteralPath $destinationDir)) {
New-Item -ItemType Directory -Path $destinationDir -Force | Out-Null
}
Copy-Item -LiteralPath $file.FullName -Destination $destination -Force
$copiedAddInCount++
}
$removedAddInCount = 0
if (Test-Path -LiteralPath $addInsTarget) {
Get-ChildItem -LiteralPath $addInsTarget -Recurse -File -Force | ForEach-Object {
$relative = $_.FullName.Substring($addInsTarget.Length).TrimStart('\', '/')
if (-not $expectedAddInFiles.Contains($relative)) {
Remove-Item -LiteralPath $_.FullName -Force
$removedAddInCount++
}
}
Get-ChildItem -LiteralPath $addInsTarget -Recurse -Directory -Force |
Sort-Object { $_.FullName.Length } -Descending |
ForEach-Object {
if (-not (Get-ChildItem -LiteralPath $_.FullName -Force | Select-Object -First 1)) {
Remove-Item -LiteralPath $_.FullName -Force
}
}
}
Write-Host " AddIns: copied $copiedAddInCount, unchanged $unchangedAddInCount, stale removed $removedAddInCount"
Write-DistributionStepElapsed $payloadStepStopwatch 'AddIn synchronization'
# Out-of-process AddIns have their own deps.json and their own application base directory.
# Patch every staged dependency manifest, not only OpenDevelop.deps.json, so the Forms/WPF
# designer hosts and language-service children also select the matching x64/ARM64 mixed-mode
# LibreWPF and ProGPU runtime assemblies.
$payloadDepsPaths = @(Get-ChildItem -LiteralPath $payloadRoot -Recurse -File -Filter '*.deps.json' | Where-Object {
$relative = $_.FullName.Substring($payloadRoot.Length).TrimStart('\', '/')
$relative -eq 'OpenDevelop.deps.json' -or
($outOfProcessHostDirs | Where-Object { $relative.StartsWith("$_\", [System.StringComparison]::OrdinalIgnoreCase) })
} | ForEach-Object FullName)
if ($payloadDepsPaths.Count -gt 0) {
& $patchScript -DepsPath $payloadDepsPaths -NugetPackageRoot (Get-NuGetGlobalPackages) -WindowsX64PackageRoot $windowsX64CanonicalFeed -WindowsArm64PackageRoot $windowsArm64CanonicalFeed -Quiet
}
Write-DistributionStepElapsed $payloadStepStopwatch 'dependency manifest patching'
$appPath = Join-Path $payloadRoot 'OpenDevelop.dll'
if (-not (Test-Path $appPath)) { throw "dist.ps1: packaged app not found: $appPath" }
Test-WindowsDistributionPayload -PayloadRoot $payloadRoot
Write-DistributionStepElapsed $payloadStepStopwatch 'payload validation'
Write-Host "Payload ready: $payloadRoot"
return $payloadRoot
}
function Invoke-WindowsSmoke {
if (-not (Test-Path $payloadRoot)) {
throw "dist.ps1: no payload to smoke-test at $payloadRoot. Run './dist.ps1 -Phase payload' first."
}
$appPath = Join-Path $payloadRoot 'OpenDevelop.dll'
Write-Host '==> Smoke-testing packaged app...'
Test-PackagedAppStartup -FilePath $dotnet -ArgumentList @($appPath) -WorkingDirectory $payloadRoot
# Also exercise the ACTUAL double-click path end to end, not just "dotnet exec": the launcher
# matching this build machine's own architecture is the one this machine can run directly.
$hostArchitectureLauncher = if ([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture -eq
[System.Runtime.InteropServices.Architecture]::Arm64) { 'OpenDevelopARM64.exe' } else { 'OpenDevelop.exe' }
Write-Host "==> Smoke-testing the $hostArchitectureLauncher launcher..."
Test-PackagedAppStartup -FilePath (Join-Path $payloadRoot $hostArchitectureLauncher) -WorkingDirectory $payloadRoot -KillProcessTree
}
function Invoke-WindowsArchive {
if (-not (Test-Path $payloadRoot)) {
throw "dist.ps1: no payload to zip at $payloadRoot. Run './dist.ps1 -Phase payload' first."
}
Write-Host '==> Building .zip...'
Remove-Item -LiteralPath $zipPath -Force -ErrorAction SilentlyContinue
Add-Type -AssemblyName System.IO.Compression.FileSystem
[System.IO.Compression.ZipFile]::CreateFromDirectory(
$payloadRoot, $zipPath,
[System.IO.Compression.CompressionLevel]::Optimal,
$true)
Test-WindowsDistributionZip -ZipPath $zipPath
return $zipPath
}
function Invoke-RestorePhase {
Write-Host '==> Restoring solution...'
Restore-Solution -DotNet $dotnet -Solution $sln -ExtraProperties @('-p:ProGpuWpfUseCurrentRuntimeIdentifier=false')
# The hot-reload agents are built through the IDE's deployment targets rather than being
# solution entries. The later distribution build deliberately uses --no-restore, so make
# their assets explicit here instead of allowing a missing project.assets.json to fail the
# AddIns phase.
$hotReloadAgent = Join-Path $repoRoot 'src/Main/HotReload/WpfHotReload.Agent/WpfHotReload.Agent.csproj'
Write-Host '==> Restoring LibreWPF hot-reload agent...'
Invoke-Native $dotnet restore $hotReloadAgent '-p:ProGpuWpfUseCurrentRuntimeIdentifier=false'
$microsoftHotReloadAgent = Join-Path $repoRoot 'src/Main/HotReload/WpfHotReload.Agent.Microsoft/WpfHotReload.Agent.Microsoft.csproj'
Write-Host '==> Restoring Microsoft WPF hot-reload agent...'
Invoke-Native $dotnet restore $microsoftHotReloadAgent '-p:ProGpuWpfUseCurrentRuntimeIdentifier=false'
}
function Invoke-HostPhase {
# One AnyCPU pass: no -p:RuntimeIdentifier anywhere, so the output is a single RID-less
# (platform-neutral) payload that carries every architecture's assets under runtimes/.
#
# Clear the shared intermediate output so publish cannot reuse artifacts left by a previous
# build (of this RID, or of a differently-RID'd pass before this one). This distribution
# intentionally remains framework-dependent and uses the installed .NET runtime; the
# SDK-generated apphost is only the native entry point and does not bundle that runtime.
Write-Host '==> Cleaning intermediate outputs...'
$hostObj = Join-Path $repoRoot "src/Main/SharpDevelop/obj/$config/$tfm"
Remove-RepoGeneratedDirectory $hostObj
# Ensure clean state for ICSharpCode.Core.Presentation — its .g.resources (WPF theme