Evan's Blog
EvanZhu0721 / 公开仓库

SBMS

镜像已同步

SBMS complete the calculation rules for the default logical desktop topology relationship and the physical dimensions of the displays in the Windows multi-screen collaboration system.

上游 GitHub
READ-ONLY MIRROR

README、源码与 Release 已保存到本站;源码视图固定到 commit a37d6c4b。

main/ root / installer
a37d6c4b· 固定源码快照
installer/manage-sunshine-instance.ps127.9 KB
1[CmdletBinding()]
2param(
3 [Parameter(Mandatory)]
4 [ValidateSet('Start', 'Restart', 'Stop', 'StopAll')]
5 [string]$Action,
6
7 [string]$GroupId = '',
8
9 [AllowNull()]
10 [AllowEmptyString()]
11 [string]$DisplayId,
12
13 [ValidateRange(0, 65514)]
14 [int]$Port = 0,
15
16 [ValidateSet('auto', 'ddx', 'wgc')]
17 [string]$Capture = 'auto'
18)
19
20$ErrorActionPreference = 'Stop'
21$ProgressPreference = 'SilentlyContinue'
22$WarningPreference = 'SilentlyContinue'
23Set-StrictMode -Version Latest
24
25$script:Utf8NoBom = [Text.UTF8Encoding]::new($false)
26$script:ManagerName = 'SBMS'
27$script:ManifestSchema = 1
28
29function Write-JsonResult {
30 param(
31 [Parameter(Mandatory)]
32 [hashtable]$Value
33 )
34
35 $Value | ConvertTo-Json -Compress -Depth 5
36}
37
38function Assert-GroupId {
39 param(
40 [AllowNull()]
41 [AllowEmptyString()]
42 [string]$Id
43 )
44
45 if (
46 [string]::IsNullOrWhiteSpace($Id) -or
47 $Id -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$'
48 ) {
49 throw 'GroupId must contain 1-64 ASCII letters, digits, dots, underscores, or hyphens.'
50 }
51}
52
53function Get-FullPath {
54 param(
55 [Parameter(Mandatory)]
56 [string]$Path
57 )
58
59 return [IO.Path]::GetFullPath($Path)
60}
61
62function Test-SamePath {
63 param(
64 [Parameter(Mandatory)]
65 [string]$Left,
66
67 [Parameter(Mandatory)]
68 [string]$Right
69 )
70
71 $leftPath = Get-FullPath -Path $Left
72 $rightPath = Get-FullPath -Path $Right
73 return [StringComparer]::OrdinalIgnoreCase.Equals($leftPath, $rightPath)
74}
75
76function Get-InstanceLayout {
77 param(
78 [Parameter(Mandatory)]
79 [string]$Id
80 )
81
82 if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) {
83 throw 'LOCALAPPDATA is unavailable; the Sunshine instance root cannot be resolved.'
84 }
85
86 $root = Get-FullPath -Path (Join-Path $env:LOCALAPPDATA 'SBMS\sunshine')
87 $instance = Get-FullPath -Path (Join-Path $root "group-$Id")
88 $rootPrefix = $root.TrimEnd(
89 [IO.Path]::DirectorySeparatorChar,
90 [IO.Path]::AltDirectorySeparatorChar
91 ) + [IO.Path]::DirectorySeparatorChar
92
93 if (-not $instance.StartsWith($rootPrefix, [StringComparison]::OrdinalIgnoreCase)) {
94 throw "The resolved instance path is outside the SBMS Sunshine root: $instance"
95 }
96
97 $credentials = Join-Path $instance 'credentials'
98 return [pscustomobject]@{
99 Root = $root
100 Instance = $instance
101 Credentials = $credentials
102 Config = Join-Path $instance 'sunshine.conf'
103 State = Join-Path $instance 'sunshine_state.json'
104 WebCredentials = Join-Path $instance 'web_credentials.json'
105 PrivateKey = Join-Path $credentials 'cakey.pem'
106 Certificate = Join-Path $credentials 'cacert.pem'
107 Apps = Join-Path $instance 'apps.json'
108 Log = Join-Path $instance 'sunshine.log'
109 Manifest = Join-Path $instance 'instance.json'
110 }
111}
112
113function Resolve-SunshineExecutable {
114 if (-not [string]::IsNullOrWhiteSpace($env:SBMS_SUNSHINE_EXE)) {
115 $override = Get-FullPath -Path $env:SBMS_SUNSHINE_EXE
116 if (-not (Test-Path -LiteralPath $override -PathType Leaf)) {
117 throw "SBMS_SUNSHINE_EXE does not point to a file: $override"
118 }
119 return $override
120 }
121
122 $programFiles = [Environment]::GetFolderPath(
123 [Environment+SpecialFolder]::ProgramFiles
124 )
125 $candidates = @(
126 (Join-Path $programFiles 'Sunshine\sunshine.exe')
127 )
128
129 if (-not [string]::IsNullOrWhiteSpace(${env:ProgramFiles(x86)})) {
130 $candidates += Join-Path ${env:ProgramFiles(x86)} 'Sunshine\sunshine.exe'
131 }
132
133 foreach ($candidate in $candidates) {
134 if (Test-Path -LiteralPath $candidate -PathType Leaf) {
135 return Get-FullPath -Path $candidate
136 }
137 }
138
139 throw 'Sunshine was not found under Program Files.'
140}
141
142function ConvertTo-SunshinePath {
143 param(
144 [Parameter(Mandatory)]
145 [string]$Path
146 )
147
148 return (Get-FullPath -Path $Path).Replace('\', '/')
149}
150
151function Write-ManagedTextFile {
152 param(
153 [Parameter(Mandatory)]
154 [string]$Path,
155
156 [Parameter(Mandatory)]
157 [string]$Contents
158 )
159
160 [IO.File]::WriteAllText($Path, $Contents, $script:Utf8NoBom)
161}
162
163function Ensure-AppsConfiguration {
164 param(
165 [Parameter(Mandatory)]
166 [pscustomobject]$Layout,
167
168 [Parameter(Mandatory)]
169 [string]$SunshineExecutable
170 )
171
172 if (Test-Path -LiteralPath $Layout.Apps -PathType Leaf) {
173 return
174 }
175
176 $sunshineRoot = Split-Path -Parent $SunshineExecutable
177 $source = Join-Path $sunshineRoot 'config\apps.json'
178 if (Test-Path -LiteralPath $source -PathType Leaf) {
179 Copy-Item -LiteralPath $source -Destination $Layout.Apps
180 return
181 }
182
183 Write-ManagedTextFile -Path $Layout.Apps -Contents '{"env":{},"apps":[]}'
184}
185
186function Write-InstanceConfiguration {
187 param(
188 [Parameter(Mandatory)]
189 [pscustomobject]$Layout,
190
191 [Parameter(Mandatory)]
192 [string]$NormalizedDisplayId,
193
194 [Parameter(Mandatory)]
195 [int]$BasePort,
196
197 [Parameter(Mandatory)]
198 [string]$Id
199 )
200
201 $outputLabel = $Id
202 $numericId = 0
203 if ([int]::TryParse($Id, [ref]$numericId)) {
204 $outputLabel = ($numericId + 1).ToString()
205 }
206 $lines = @(
207 '# Managed by SBMS. Runtime state is isolated per mapping group.'
208 "port = $BasePort"
209 "sunshine_name = SBMS Output $outputLabel"
210 "output_name = $NormalizedDisplayId"
211 'upnp = disabled'
212 'dd_configuration_option = disabled'
213 'system_tray = disabled'
214 "file_state = $(ConvertTo-SunshinePath -Path $Layout.State)"
215 "credentials_file = $(ConvertTo-SunshinePath -Path $Layout.WebCredentials)"
216 "pkey = $(ConvertTo-SunshinePath -Path $Layout.PrivateKey)"
217 "cert = $(ConvertTo-SunshinePath -Path $Layout.Certificate)"
218 "file_apps = $(ConvertTo-SunshinePath -Path $Layout.Apps)"
219 "log_path = $(ConvertTo-SunshinePath -Path $Layout.Log)"
220 )
221 $contents = ($lines -join [Environment]::NewLine) +
222 [Environment]::NewLine
223 Write-ManagedTextFile -Path $Layout.Config -Contents $contents
224}
225
226function Write-InstanceManifest {
227 param(
228 [Parameter(Mandatory)]
229 [pscustomobject]$Layout,
230
231 [Parameter(Mandatory)]
232 [string]$Id,
233
234 [Parameter(Mandatory)]
235 [string]$NormalizedDisplayId,
236
237 [Parameter(Mandatory)]
238 [int]$BasePort,
239
240 [Parameter(Mandatory)]
241 [int]$ProcessId,
242
243 [Parameter(Mandatory)]
244 [string]$SunshineExecutable
245 )
246
247 $manifest = [ordered]@{
248 schema_version = $script:ManifestSchema
249 managed_by = $script:ManagerName
250 group_id = $Id
251 display_id = $NormalizedDisplayId
252 port = $BasePort
253 pid = $ProcessId
254 sunshine_exe = Get-FullPath -Path $SunshineExecutable
255 config_path = Get-FullPath -Path $Layout.Config
256 log_path = Get-FullPath -Path $Layout.Log
257 started_at = [DateTimeOffset]::Now.ToString('o')
258 }
259 $json = $manifest | ConvertTo-Json -Compress -Depth 4
260 Write-ManagedTextFile -Path $Layout.Manifest -Contents $json
261}
262
263function Read-InstanceManifest {
264 param(
265 [Parameter(Mandatory)]
266 [pscustomobject]$Layout,
267
268 [Parameter(Mandatory)]
269 [string]$Id
270 )
271
272 if (-not (Test-Path -LiteralPath $Layout.Manifest -PathType Leaf)) {
273 return $null
274 }
275
276 try {
277 $json = [IO.File]::ReadAllText(
278 $Layout.Manifest,
279 [Text.Encoding]::UTF8
280 )
281 $manifest = $json | ConvertFrom-Json
282 } catch {
283 throw "The Sunshine instance manifest is invalid: $($_.Exception.Message)"
284 }
285
286 if (
287 $null -eq $manifest.schema_version -or
288 [int]$manifest.schema_version -ne $script:ManifestSchema -or
289 [string]$manifest.managed_by -ne $script:ManagerName -or
290 [string]$manifest.group_id -cne $Id
291 ) {
292 throw 'The Sunshine instance manifest is not owned by this SBMS group.'
293 }
294
295 if (
296 [string]::IsNullOrWhiteSpace([string]$manifest.config_path) -or
297 -not (Test-SamePath -Left ([string]$manifest.config_path) -Right $Layout.Config)
298 ) {
299 throw 'The Sunshine instance manifest points outside this group configuration.'
300 }
301
302 if (
303 $null -eq $manifest.pid -or
304 [int64]$manifest.pid -lt 1 -or
305 [int64]$manifest.pid -gt [int]::MaxValue -or
306 [string]::IsNullOrWhiteSpace([string]$manifest.sunshine_exe)
307 ) {
308 throw 'The Sunshine instance manifest does not contain a valid managed process.'
309 }
310
311 return $manifest
312}
313
314function Get-ManagedProcessRecord {
315 param(
316 [Parameter(Mandatory)]
317 [pscustomobject]$Manifest,
318
319 [Parameter(Mandatory)]
320 [string]$ExpectedConfig
321 )
322
323 $processId = [int]$Manifest.pid
324 $record = Get-CimInstance -ClassName Win32_Process `
325 -Filter "ProcessId = $processId" `
326 -ErrorAction Stop |
327 Select-Object -First 1
328 if ($null -eq $record) {
329 return $null
330 }
331
332 $actualExecutable = [string]$record.ExecutablePath
333 if ([string]::IsNullOrWhiteSpace($actualExecutable)) {
334 try {
335 $actualExecutable = (
336 Get-Process -Id $processId -ErrorAction Stop
337 ).MainModule.FileName
338 } catch {
339 throw "PID $processId exists, but its executable path cannot be verified."
340 }
341 }
342
343 if (
344 -not (Test-SamePath `
345 -Left $actualExecutable `
346 -Right ([string]$Manifest.sunshine_exe))
347 ) {
348 throw "PID $processId is not the Sunshine executable recorded for this group."
349 }
350
351 $commandLine = [string]$record.CommandLine
352 if ([string]::IsNullOrWhiteSpace($commandLine)) {
353 throw "PID $processId exists, but its command line cannot be verified."
354 }
355
356 $escapedConfig = [regex]::Escape((Get-FullPath -Path $ExpectedConfig))
357 $configArgument = "(?i)(?:^|\s)`"?$escapedConfig`"?(?=\s|$)"
358 if ($commandLine -notmatch $configArgument) {
359 throw "PID $processId is not using this group's Sunshine configuration."
360 }
361
362 return $record
363}
364
365function Get-CaptureBackendFromCommandLine {
366 param(
367 [Parameter(Mandatory)]
368 [string]$CommandLine
369 )
370
371 $captureArguments = [regex]::Matches(
372 $CommandLine,
373 '(?i)(?:^|\s)"?capture=([^\s"]+)"?(?=\s|$)'
374 )
375 if ($captureArguments.Count -eq 0) {
376 return 'auto'
377 }
378 if ($captureArguments.Count -ne 1) {
379 throw 'The managed Sunshine process has multiple capture overrides.'
380 }
381
382 $capture = $captureArguments[0].Groups[1].Value.ToLowerInvariant()
383 if ($capture -notin @('auto', 'ddx', 'wgc')) {
384 throw "The managed Sunshine process has an unknown capture override: $capture"
385 }
386 return $capture
387}
388
389function Remove-StaleManifest {
390 param(
391 [Parameter(Mandatory)]
392 [pscustomobject]$Layout
393 )
394
395 if (Test-Path -LiteralPath $Layout.Manifest -PathType Leaf) {
396 Remove-Item -LiteralPath $Layout.Manifest -Force
397 }
398}
399
400function Stop-ManagedInstance {
401 param(
402 [Parameter(Mandatory)]
403 [pscustomobject]$Layout,
404
405 [Parameter(Mandatory)]
406 [string]$Id
407 )
408
409 $manifest = Read-InstanceManifest -Layout $Layout -Id $Id
410 if ($null -eq $manifest) {
411 return [ordered]@{
412 status = 'not_running'
413 pid = $null
414 port = $null
415 display_id = $null
416 log_path = Get-FullPath -Path $Layout.Log
417 }
418 }
419
420 $record = Get-ManagedProcessRecord `
421 -Manifest $manifest `
422 -ExpectedConfig $Layout.Config
423 if ($null -eq $record) {
424 Remove-StaleManifest -Layout $Layout
425 return [ordered]@{
426 status = 'not_running'
427 pid = $null
428 port = [int]$manifest.port
429 display_id = [string]$manifest.display_id
430 log_path = [string]$manifest.log_path
431 }
432 }
433
434 $processId = [int]$manifest.pid
435 $process = Get-Process -Id $processId -ErrorAction Stop
436 $closed = $false
437 try {
438 $closed = $process.CloseMainWindow()
439 } catch {
440 $closed = $false
441 }
442
443 if ($closed) {
444 $null = $process.WaitForExit(2000)
445 }
446 if (-not $process.HasExited) {
447 Stop-Process -Id $processId -Force -ErrorAction Stop
448 $null = $process.WaitForExit(10000)
449 }
450 if (-not $process.HasExited) {
451 throw "Managed Sunshine PID $processId did not stop within 10 seconds."
452 }
453
454 Remove-StaleManifest -Layout $Layout
455 return [ordered]@{
456 status = 'stopped'
457 pid = $processId
458 port = [int]$manifest.port
459 display_id = [string]$manifest.display_id
460 log_path = [string]$manifest.log_path
461 }
462}
463
464function Stop-AllManagedInstances {
465 if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) {
466 throw 'LOCALAPPDATA is unavailable; the Sunshine instance root cannot be resolved.'
467 }
468
469 $root = Get-FullPath -Path (Join-Path $env:LOCALAPPDATA 'SBMS\sunshine')
470 if (-not (Test-Path -LiteralPath $root -PathType Container)) {
471 return [ordered]@{
472 status = 'not_running'
473 pid = $null
474 port = $null
475 display_id = $null
476 log_path = $root
477 stoppedCount = 0
478 }
479 }
480
481 $stoppedCount = 0
482 $failures = [Collections.Generic.List[string]]::new()
483 $directories = @(
484 Get-ChildItem `
485 -LiteralPath $root `
486 -Directory `
487 -Filter 'group-*' `
488 -ErrorAction Stop
489 )
490 foreach ($directory in $directories) {
491 $id = $directory.Name.Substring('group-'.Length)
492 if ($id -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$') {
493 continue
494 }
495
496 $layout = Get-InstanceLayout -Id $id
497 if (-not (Test-Path -LiteralPath $layout.Manifest -PathType Leaf)) {
498 continue
499 }
500
501 try {
502 $operation = Stop-ManagedInstance -Layout $layout -Id $id
503 if ($operation.status -eq 'stopped') {
504 $stoppedCount++
505 }
506 } catch {
507 $failures.Add("$id`: $($_.Exception.Message)")
508 }
509 }
510
511 if ($failures.Count -gt 0) {
512 $details = $failures -join '; '
513 throw "Stopped $stoppedCount managed Sunshine instance(s), but some could not be verified or stopped: $details"
514 }
515
516 return [ordered]@{
517 status = if ($stoppedCount -gt 0) {
518 'stopped_all'
519 } else {
520 'not_running'
521 }
522 pid = $null
523 port = $null
524 display_id = $null
525 log_path = $root
526 stoppedCount = $stoppedCount
527 }
528}
529
530function Test-TcpPortAvailable {
531 param(
532 [Parameter(Mandatory)]
533 [int]$CandidatePort
534 )
535
536 $listener = $null
537 try {
538 $listener = [Net.Sockets.TcpListener]::new(
539 [Net.IPAddress]::Any,
540 $CandidatePort
541 )
542 $listener.Server.ExclusiveAddressUse = $true
543 $listener.Start()
544 return $true
545 } catch {
546 return $false
547 } finally {
548 if ($null -ne $listener) {
549 $listener.Stop()
550 }
551 }
552}
553
554function Test-UdpPortAvailable {
555 param(
556 [Parameter(Mandatory)]
557 [int]$CandidatePort
558 )
559
560 $client = $null
561 try {
562 $client = [Net.Sockets.UdpClient]::new()
563 $client.Client.ExclusiveAddressUse = $true
564 $endpoint = [Net.IPEndPoint]::new(
565 [Net.IPAddress]::Any,
566 $CandidatePort
567 )
568 $client.Client.Bind($endpoint)
569 return $true
570 } catch {
571 return $false
572 } finally {
573 if ($null -ne $client) {
574 $client.Dispose()
575 }
576 }
577}
578
579function Test-PortFamilyAvailable {
580 param(
581 [Parameter(Mandatory)]
582 [int]$BasePort
583 )
584
585 $tcpOffsets = @(-5, 0, 1, 21)
586 foreach ($offset in $tcpOffsets) {
587 $candidate = $BasePort + $offset
588 if (-not (Test-TcpPortAvailable -CandidatePort $candidate)) {
589 return $false
590 }
591 }
592
593 $udpOffsets = @(9, 10, 11)
594 foreach ($offset in $udpOffsets) {
595 $candidate = $BasePort + $offset
596 if (-not (Test-UdpPortAvailable -CandidatePort $candidate)) {
597 return $false
598 }
599 }
600
601 return $true
602}
603
604function Find-AvailableSunshinePort {
605 param(
606 [Parameter(Mandatory)]
607 [int]$PreferredPort
608 )
609
610 $candidate = [int64]$PreferredPort
611 while ($candidate -le 65514) {
612 if (Test-PortFamilyAvailable -BasePort ([int]$candidate)) {
613 return [int]$candidate
614 }
615 $candidate += 27
616 }
617
618 throw "No complete Sunshine port family is available from base port $PreferredPort in +27 increments."
619}
620
621function Format-SunshineArguments {
622 param(
623 [Parameter(Mandatory)]
624 [string]$Configuration,
625
626 [Parameter(Mandatory)]
627 [ValidateSet('auto', 'ddx', 'wgc')]
628 [string]$CaptureBackend
629 )
630
631 if ($Configuration.Contains('"')) {
632 throw 'The Sunshine configuration path contains an unsupported quote.'
633 }
634
635 $arguments = '"' + $Configuration + '"'
636 if ($CaptureBackend -ne 'auto') {
637 $arguments += ' capture=' + $CaptureBackend
638 }
639 return $arguments
640}
641
642function Start-HiddenSunshine {
643 param(
644 [Parameter(Mandatory)]
645 [string]$SunshineExecutable,
646
647 [Parameter(Mandatory)]
648 [string]$Configuration,
649
650 [Parameter(Mandatory)]
651 [string]$WorkingDirectory,
652
653 [Parameter(Mandatory)]
654 [ValidateSet('auto', 'ddx', 'wgc')]
655 [string]$CaptureBackend
656 )
657
658 $startInfo = [Diagnostics.ProcessStartInfo]::new()
659 $startInfo.FileName = $SunshineExecutable
660 $startInfo.WorkingDirectory = $WorkingDirectory
661 $startInfo.UseShellExecute = $true
662 $startInfo.WindowStyle = [Diagnostics.ProcessWindowStyle]::Hidden
663 $startInfo.ErrorDialog = $false
664
665 # Windows PowerShell 5.1 has no ProcessStartInfo.ArgumentList. Build the
666 # native argument string once so the configuration remains one exact arg.
667 $startInfo.Arguments = Format-SunshineArguments `
668 -Configuration $Configuration `
669 -CaptureBackend $CaptureBackend
670
671 $process = [Diagnostics.Process]::Start($startInfo)
672 if ($null -eq $process) {
673 throw 'Windows did not return a Sunshine process handle.'
674 }
675 return $process
676}
677
678function Test-TcpEndpoint {
679 param(
680 [Parameter(Mandatory)]
681 [int]$EndpointPort,
682
683 [int]$TimeoutMilliseconds = 250
684 )
685
686 $client = [Net.Sockets.TcpClient]::new()
687 $asyncResult = $null
688 try {
689 $asyncResult = $client.BeginConnect(
690 [Net.IPAddress]::Loopback,
691 $EndpointPort,
692 $null,
693 $null
694 )
695 if (-not $asyncResult.AsyncWaitHandle.WaitOne($TimeoutMilliseconds)) {
696 return $false
697 }
698 $client.EndConnect($asyncResult)
699 return $client.Connected
700 } catch {
701 return $false
702 } finally {
703 if ($null -ne $asyncResult) {
704 $asyncResult.AsyncWaitHandle.Close()
705 }
706 $client.Dispose()
707 }
708}
709
710function Wait-SunshineReady {
711 param(
712 [Parameter(Mandatory)]
713 [Diagnostics.Process]$Process,
714
715 [Parameter(Mandatory)]
716 [int]$BasePort,
717
718 [Parameter(Mandatory)]
719 [string]$LogPath
720 )
721
722 $deadline = [DateTimeOffset]::UtcNow.AddSeconds(20)
723 do {
724 $Process.Refresh()
725 if ($Process.HasExited) {
726 throw "Sunshine exited before its port opened (exit code $($Process.ExitCode)); see $LogPath"
727 }
728
729 if (
730 (Test-TcpEndpoint -EndpointPort ($BasePort + 1)) -or
731 (Test-TcpEndpoint -EndpointPort $BasePort)
732 ) {
733 return
734 }
735
736 Start-Sleep -Milliseconds 250
737 } while ([DateTimeOffset]::UtcNow -lt $deadline)
738
739 throw "Sunshine did not open TCP port $BasePort or $($BasePort + 1) within 20 seconds; see $LogPath"
740}
741
742function Normalize-DisplayId {
743 param(
744 [AllowNull()]
745 [AllowEmptyString()]
746 [string]$Value
747 )
748
749 if ([string]::IsNullOrWhiteSpace($Value)) {
750 throw 'DisplayId is required for Start and Restart.'
751 }
752
753 $parsed = [Guid]::Empty
754 if (-not [Guid]::TryParseExact($Value, 'B', [ref]$parsed)) {
755 throw 'DisplayId must be a brace-enclosed GUID.'
756 }
757 return $parsed.ToString('B')
758}
759
760function Start-ManagedInstance {
761 param(
762 [Parameter(Mandatory)]
763 [pscustomobject]$Layout,
764
765 [Parameter(Mandatory)]
766 [string]$Id,
767
768 [Parameter(Mandatory)]
769 [string]$NormalizedDisplayId,
770
771 [Parameter(Mandatory)]
772 [int]$BasePort,
773
774 [Parameter(Mandatory)]
775 [string]$SunshineExecutable,
776
777 [Parameter(Mandatory)]
778 [ValidateSet('auto', 'ddx', 'wgc')]
779 [string]$CaptureBackend,
780
781 [switch]$AllowAlreadyRunning
782 )
783
784 $existing = Read-InstanceManifest -Layout $Layout -Id $Id
785 if ($null -ne $existing) {
786 $record = Get-ManagedProcessRecord `
787 -Manifest $existing `
788 -ExpectedConfig $Layout.Config
789 if ($null -ne $record) {
790 $sameDisplay = [string]$existing.display_id -ieq $NormalizedDisplayId
791 $existingPort = [int]$existing.port
792 $isRequestedSequence = (
793 $existingPort -ge $BasePort -and
794 (($existingPort - $BasePort) % 27) -eq 0
795 )
796 $existingCapture = Get-CaptureBackendFromCommandLine `
797 -CommandLine ([string]$record.CommandLine)
798 $sameCapture = $existingCapture -eq $CaptureBackend
799 if (
800 $AllowAlreadyRunning -and
801 $sameDisplay -and
802 $isRequestedSequence -and
803 $sameCapture
804 ) {
805 return [ordered]@{
806 status = 'already_running'
807 pid = [int]$existing.pid
808 port = [int]$existing.port
809 display_id = [string]$existing.display_id
810 config_path = [string]$existing.config_path
811 log_path = [string]$existing.log_path
812 }
813 }
814 throw 'A managed Sunshine instance is already running for this group; use Restart.'
815 }
816 Remove-StaleManifest -Layout $Layout
817 }
818
819 $actualPort = Find-AvailableSunshinePort -PreferredPort $BasePort
820 New-Item -ItemType Directory -Path $Layout.Credentials -Force | Out-Null
821 Ensure-AppsConfiguration `
822 -Layout $Layout `
823 -SunshineExecutable $SunshineExecutable
824 Write-InstanceConfiguration `
825 -Layout $Layout `
826 -NormalizedDisplayId $NormalizedDisplayId `
827 -BasePort $actualPort `
828 -Id $Id
829
830 $process = $null
831 try {
832 $process = Start-HiddenSunshine `
833 -SunshineExecutable $SunshineExecutable `
834 -Configuration $Layout.Config `
835 -WorkingDirectory (Split-Path -Parent $SunshineExecutable) `
836 -CaptureBackend $CaptureBackend
837 Write-InstanceManifest `
838 -Layout $Layout `
839 -Id $Id `
840 -NormalizedDisplayId $NormalizedDisplayId `
841 -BasePort $actualPort `
842 -ProcessId $process.Id `
843 -SunshineExecutable $SunshineExecutable
844 Wait-SunshineReady `
845 -Process $process `
846 -BasePort $actualPort `
847 -LogPath $Layout.Log
848 } catch {
849 if ($null -ne $process) {
850 try {
851 $process.Refresh()
852 if (-not $process.HasExited) {
853 $process.Kill()
854 $null = $process.WaitForExit(5000)
855 }
856 } catch {
857 # The original startup error is more useful than cleanup noise.
858 }
859 }
860 Remove-StaleManifest -Layout $Layout
861 throw
862 }
863
864 return [ordered]@{
865 status = 'started'
866 pid = $process.Id
867 port = $actualPort
868 display_id = $NormalizedDisplayId
869 config_path = Get-FullPath -Path $Layout.Config
870 log_path = Get-FullPath -Path $Layout.Log
871 }
872}
873
874$layout = $null
875$mutex = $null
876$lockTaken = $false
877$exitCode = 0
878$result = $null
879$normalizedAction = $Action.ToLowerInvariant()
880try {
881 $mutex = [Threading.Mutex]::new(
882 $false,
883 'Local\SBMSSunshineInstanceManager-v1'
884 )
885 try {
886 $lockTaken = $mutex.WaitOne([TimeSpan]::FromSeconds(30))
887 } catch [Threading.AbandonedMutexException] {
888 $lockTaken = $true
889 }
890 if (-not $lockTaken) {
891 throw 'Timed out waiting for the SBMS Sunshine instance manager lock.'
892 }
893
894 if ($Action -eq 'StopAll') {
895 $operation = Stop-AllManagedInstances
896 } else {
897 Assert-GroupId -Id $GroupId
898 $layout = Get-InstanceLayout -Id $GroupId
899
900 if ($Action -eq 'Stop') {
901 $operation = Stop-ManagedInstance -Layout $layout -Id $GroupId
902 } else {
903 if ($Port -lt 1029) {
904 throw 'Port must be in Sunshine''s supported base-port range 1029-65514.'
905 }
906 $normalizedDisplayId = Normalize-DisplayId -Value $DisplayId
907 $sunshineExecutable = Resolve-SunshineExecutable
908
909 if ($Action -eq 'Restart') {
910 $null = Stop-ManagedInstance -Layout $layout -Id $GroupId
911 }
912
913 $startParameters = @{
914 Layout = $layout
915 Id = $GroupId
916 NormalizedDisplayId = $normalizedDisplayId
917 BasePort = $Port
918 SunshineExecutable = $sunshineExecutable
919 CaptureBackend = $Capture.ToLowerInvariant()
920 AllowAlreadyRunning = ($Action -eq 'Start')
921 }
922 $operation = Start-ManagedInstance @startParameters
923 }
924 }
925
926 $message = switch ($operation.status) {
927 'started' {
928 "Sunshine instance started on base port $($operation.port)."
929 }
930 'already_running' {
931 "Sunshine instance is already running on base port $($operation.port)."
932 }
933 'stopped' {
934 'Sunshine instance stopped.'
935 }
936 'stopped_all' {
937 "Stopped $($operation.stoppedCount) managed Sunshine instance(s)."
938 }
939 default {
940 'No managed Sunshine instance was running.'
941 }
942 }
943
944 $configPath = if (
945 $null -ne $layout -and
946 $operation.Contains('config_path')
947 ) {
948 $operation.config_path
949 } elseif ($null -ne $layout) {
950 Get-FullPath -Path $layout.Config
951 } else {
952 $null
953 }
954 $result = [ordered]@{
955 ok = $true
956 action = $normalizedAction
957 status = $operation.status
958 groupId = if ($Action -eq 'StopAll') { $null } else { $GroupId }
959 displayId = $operation.display_id
960 port = $operation.port
961 pid = $operation.pid
962 message = $message
963 configPath = $configPath
964 logPath = $operation.log_path
965 stoppedCount = if ($operation.Contains('stoppedCount')) {
966 $operation.stoppedCount
967 } else {
968 $null
969 }
970 }
971} catch {
972 $exitCode = 1
973 $logPath = if ($null -ne $layout) {
974 Get-FullPath -Path $layout.Log
975 } else {
976 $null
977 }
978 $result = [ordered]@{
979 ok = $false
980 action = $normalizedAction
981 groupId = if ([string]::IsNullOrWhiteSpace($GroupId)) {
982 $null
983 } else {
984 $GroupId
985 }
986 displayId = $null
987 port = $null
988 pid = $null
989 message = $_.Exception.Message
990 configPath = if ($null -ne $layout) {
991 Get-FullPath -Path $layout.Config
992 } else {
993 $null
994 }
995 logPath = $logPath
996 }
997} finally {
998 if ($lockTaken -and $null -ne $mutex) {
999 $mutex.ReleaseMutex()
1000 }
1001 if ($null -ne $mutex) {
1002 $mutex.Dispose()
1003 }
1004}
1005
1006Write-JsonResult -Value $result
1007exit $exitCode
1008