From ab097788e7f7c9f2c8fa2723234295d46d88232f Mon Sep 17 00:00:00 2001 From: amerganim Date: Thu, 30 Jul 2026 21:40:48 +0600 Subject: [PATCH 1/3] Fix tray missing after shutdown+power-on (Fast Startup) The service launched the tray then stopped itself. Windows Fast Startup (the default for "Shut down") hibernates session 0 and restores it on the next power-on instead of cold-booting, so auto-start services are not re-run. A self-stopped service therefore stays stopped after power-on and never receives the logon event, so the tray never appears. A full "Restart" bypasses Fast Startup, which is why restarting worked. - Remove StopSelf(): the service now stays running (idle) and relaunches the tray on SessionLogon/Unlock/Connect, including after a Fast-Startup resume. - Add IsTrayRunningInSession() guard so repeated session events do not spawn duplicate tray icons. Co-Authored-By: Claude Opus 4.8 --- .../SmartThings.Service/TrayService.cs | 94 ++++++++++++------- 1 file changed, 61 insertions(+), 33 deletions(-) diff --git a/DesktopBridge/SmartThings.Service/TrayService.cs b/DesktopBridge/SmartThings.Service/TrayService.cs index 535ad05..6b066c6 100644 --- a/DesktopBridge/SmartThings.Service/TrayService.cs +++ b/DesktopBridge/SmartThings.Service/TrayService.cs @@ -8,8 +8,16 @@ namespace TrayLauncherService /// logon it launches, in the interactive user session: /// - the native TrayHelper (stays running - owns the tray icon), and /// - the WPF process (only needed briefly). - /// After a short delay it kills WPF, then stops itself (its work is done). It is auto-start, so - /// it runs again on the next boot; if no user is signed in yet it waits for logon. + /// After a short delay it kills WPF. The service then stays running (idle) and listens for + /// session changes so it can relaunch the tray on the next logon / unlock / reconnect. + /// + /// IMPORTANT: the service must NOT stop itself after launching. Windows Fast Startup (the default + /// for "Shut down") hibernates session 0 and restores it on the next power-on instead of doing a + /// cold boot, so auto-start services are NOT re-run. A service that had stopped itself stays + /// stopped after a Fast-Startup boot and never receives the logon event -> the tray never appears. + /// (A full "Restart" bypasses Fast Startup, which is why restarting worked but shutdown+power-on + /// did not.) Staying running means the live service receives SessionLogon after the resume and + /// relaunches the tray. The idle cost is negligible. /// public sealed class TrayService : ServiceBase { @@ -22,6 +30,10 @@ public sealed class TrayService : ServiceBase // Process image name (no .exe) of the player - the AssemblyName of SmartThings.AVplayer. private const string PlayerProcessName = "SmartThings.AVplayer"; + // Process image name (no .exe) of the native tray helper - the Executable in the manifest's + // SmartThings.Tray application entry. Used to avoid launching a second tray icon. + private const string TrayProcessName = "SmartThings.Tray"; + public TrayService() { ServiceName = ServiceNameConst; @@ -37,7 +49,7 @@ protected override void OnStart(string[] args) { if (SessionLauncher.TryGetActiveSession(out uint sessionId)) { - LaunchThenStop(sessionId); + EnsureTrayRunning(sessionId); } else { @@ -57,17 +69,25 @@ protected override void OnSessionChange(SessionChangeDescription changeDescripti case SessionChangeReason.ConsoleConnect: case SessionChangeReason.RemoteConnect: uint sessionId = (uint)changeDescription.SessionId; - ThreadPool.QueueUserWorkItem(_ => LaunchThenStop(sessionId)); + ThreadPool.QueueUserWorkItem(_ => EnsureTrayRunning(sessionId)); break; } } /// - /// Launches TrayHelper + WPF in the session, then (after a delay) kills WPF and stops the - /// service. Only proceeds to kill/stop if the tray helper actually launched. + /// Ensures the tray helper is running in the given session. If it is already running there, + /// this is a no-op (so repeated logon/unlock events don't spawn duplicate tray icons). + /// Otherwise it launches TrayHelper + WPF, then (after a delay) kills WPF. The service stays + /// running either way - see the class summary for why it must not stop itself. /// - private void LaunchThenStop(uint sessionId) + private void EnsureTrayRunning(uint sessionId) { + if (IsTrayRunningInSession(sessionId)) + { + ServiceLog.Write($"Tray already running in session {sessionId}; nothing to do."); + return; + } + bool trayLaunched = SessionLauncher.LaunchInSession(sessionId, SessionLauncher.TrayAlias); bool wpfLaunched = SessionLauncher.LaunchInSession(sessionId, SessionLauncher.WpfAlias); ServiceLog.Write($"Launch results: tray={trayLaunched}, wpf={wpfLaunched}."); @@ -81,8 +101,41 @@ private void LaunchThenStop(uint sessionId) // WPF was only needed briefly; give it a moment, then terminate it. Thread.Sleep(WpfLifetime); KillWpf(); + } - StopSelf(); + /// + /// True if a tray helper process is already running in the given session. The service runs in + /// session 0 and can see processes in every session, so we filter by SessionId to avoid + /// treating a tray in another user's session as ours. + /// + private static bool IsTrayRunningInSession(uint sessionId) + { + Process[] trays = Process.GetProcessesByName(TrayProcessName); + try + { + foreach (Process process in trays) + { + try + { + if ((uint)process.SessionId == sessionId) + { + return true; + } + } + catch + { + // Process may have exited between enumeration and access; ignore it. + } + } + return false; + } + finally + { + foreach (Process process in trays) + { + process.Dispose(); + } + } } /// @@ -119,31 +172,6 @@ protected override void OnStop() ServiceLog.Write("Service stopping."); } - /// - /// Stops this service (nothing left to do after launching the tray). Runs on a background - /// thread, so the service has already reported Running to the SCM. - /// - private void StopSelf() - { - try - { - using var controller = new ServiceController(ServiceNameConst); - if (controller.Status is ServiceControllerStatus.StartPending) - { - controller.WaitForStatus(ServiceControllerStatus.Running, TimeSpan.FromSeconds(10)); - } - if (controller.Status is ServiceControllerStatus.Running) - { - ServiceLog.Write("Work done; stopping the service."); - controller.Stop(); - } - } - catch (Exception ex) - { - ServiceLog.Write($"StopSelf failed: {ex.Message}"); - } - } - /// /// Runs the launch logic once without the SCM, for manual testing from an elevated console. /// From 6bca4c3281806a582385b1e386ff40dff3ce5b4e Mon Sep 17 00:00:00 2001 From: amerganim Date: Thu, 30 Jul 2026 22:14:26 +0600 Subject: [PATCH 2/3] Keep StopSelf: use a logon Scheduled Task to survive Fast Startup Reverts to the "launch tray then stop the service" design (near-zero idle memory) while still fixing the shutdown+power-on case. The service now registers a Task Scheduler "at log on" task whose action is `sc start SmartThings.Service`. Logon triggers fire on the interactive logon that follows a Fast-Startup resume (unlike auto-start services, which are skipped on a hybrid resume), so on every sign-in the task restarts the service, it launches the tray, then stops itself again. - Re-add StopSelf() and call it after launching (and when the tray is already running). - EnsureLogonTaskRegistered() creates/refreshes the task on OnStart (idempotent, best effort). - Keep IsTrayRunningInSession() guard so the task + boot path can't spawn duplicate tray icons. - Uninstall.ps1 deletes the logon task so it isn't orphaned. Co-Authored-By: Claude Opus 4.8 --- .../SmartThings.Service/TrayService.cs | 137 +++++++++++++++--- build/Uninstall.ps1 | 4 + 2 files changed, 122 insertions(+), 19 deletions(-) diff --git a/DesktopBridge/SmartThings.Service/TrayService.cs b/DesktopBridge/SmartThings.Service/TrayService.cs index 6b066c6..0aab08b 100644 --- a/DesktopBridge/SmartThings.Service/TrayService.cs +++ b/DesktopBridge/SmartThings.Service/TrayService.cs @@ -4,26 +4,34 @@ namespace TrayLauncherService { /// - /// Windows service (LocalSystem, auto-start). On start (after install / at boot) and on user - /// logon it launches, in the interactive user session: + /// Windows service (LocalSystem, auto-start). On start (after install / at boot / at logon) it + /// launches, in the interactive user session: /// - the native TrayHelper (stays running - owns the tray icon), and /// - the WPF process (only needed briefly). - /// After a short delay it kills WPF. The service then stays running (idle) and listens for - /// session changes so it can relaunch the tray on the next logon / unlock / reconnect. + /// After a short delay it kills WPF, then STOPS ITSELF - its work is done, so it consumes no + /// memory while idle. /// - /// IMPORTANT: the service must NOT stop itself after launching. Windows Fast Startup (the default - /// for "Shut down") hibernates session 0 and restores it on the next power-on instead of doing a - /// cold boot, so auto-start services are NOT re-run. A service that had stopped itself stays - /// stopped after a Fast-Startup boot and never receives the logon event -> the tray never appears. - /// (A full "Restart" bypasses Fast Startup, which is why restarting worked but shutdown+power-on - /// did not.) Staying running means the live service receives SessionLogon after the resume and - /// relaunches the tray. The idle cost is negligible. + /// FAST STARTUP: Windows Fast Startup (the default for "Shut down") hibernates session 0 and + /// restores it on the next power-on instead of doing a cold boot, so auto-start services are NOT + /// re-run. A self-stopped service therefore stays stopped after a shutdown+power-on and would + /// never see the logon - so the tray would not appear (a full "Restart" bypasses Fast Startup, + /// which is why restarting worked but shutdown+power-on did not). + /// + /// To fix that WITHOUT keeping the service resident, the service registers a Task Scheduler + /// "at log on" task (see ) whose action is to start this + /// service. Logon triggers DO fire on the interactive logon that follows a Fast-Startup resume, + /// so on every sign-in the task starts the service, the service launches the tray, then stops + /// again. The task is removed by Uninstall.ps1. /// public sealed class TrayService : ServiceBase { // Must match the desktop6:Service Name in SmartThings.WAPP/Package.appxmanifest. public const string ServiceNameConst = "SmartThings.Service"; + // Name of the Task Scheduler logon task that restarts this service on each sign-in. + // Keep in sync with build/Uninstall.ps1. + private const string LogonTaskName = "DesktopBridge Tray Logon"; + // How long the player is allowed to run before the service kills it. private static readonly TimeSpan WpfLifetime = TimeSpan.FromSeconds(5); @@ -47,9 +55,13 @@ protected override void OnStart(string[] args) ServiceLog.Write("Service starting."); ThreadPool.QueueUserWorkItem(_ => { + // Make sure the logon task exists so we are restarted on future sign-ins (this is + // what survives Fast Startup). Idempotent and cheap. + EnsureLogonTaskRegistered(); + if (SessionLauncher.TryGetActiveSession(out uint sessionId)) { - EnsureTrayRunning(sessionId); + LaunchThenStop(sessionId); } else { @@ -69,22 +81,23 @@ protected override void OnSessionChange(SessionChangeDescription changeDescripti case SessionChangeReason.ConsoleConnect: case SessionChangeReason.RemoteConnect: uint sessionId = (uint)changeDescription.SessionId; - ThreadPool.QueueUserWorkItem(_ => EnsureTrayRunning(sessionId)); + ThreadPool.QueueUserWorkItem(_ => LaunchThenStop(sessionId)); break; } } /// - /// Ensures the tray helper is running in the given session. If it is already running there, - /// this is a no-op (so repeated logon/unlock events don't spawn duplicate tray icons). - /// Otherwise it launches TrayHelper + WPF, then (after a delay) kills WPF. The service stays - /// running either way - see the class summary for why it must not stop itself. + /// Launches the tray helper (+ WPF) in the session if it isn't already there, kills WPF after + /// a short delay, then stops the service. If the tray is already running in the session this + /// is a no-op except for stopping the service. The logon task (registered in OnStart) restarts + /// the service on the next sign-in, including after a Fast-Startup resume. /// - private void EnsureTrayRunning(uint sessionId) + private void LaunchThenStop(uint sessionId) { if (IsTrayRunningInSession(sessionId)) { - ServiceLog.Write($"Tray already running in session {sessionId}; nothing to do."); + ServiceLog.Write($"Tray already running in session {sessionId}; nothing to launch."); + StopSelf(); return; } @@ -101,6 +114,8 @@ private void EnsureTrayRunning(uint sessionId) // WPF was only needed briefly; give it a moment, then terminate it. Thread.Sleep(WpfLifetime); KillWpf(); + + StopSelf(); } /// @@ -138,6 +153,64 @@ private static bool IsTrayRunningInSession(uint sessionId) } } + /// + /// Registers (or refreshes) a Task Scheduler task that starts this service at every user + /// logon. Runs as SYSTEM so it can start the service. This is the piece that makes the tray + /// reappear after a Fast-Startup "Shut down -> power on", where auto-start services are not + /// re-run. Best effort: if it fails the service still works for the current session. + /// + private static void EnsureLogonTaskRegistered() + { + try + { + string scPath = Path.Combine(Environment.SystemDirectory, "sc.exe"); + var startInfo = new ProcessStartInfo + { + FileName = Path.Combine(Environment.SystemDirectory, "schtasks.exe"), + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + // schtasks /Create /TN "" /TR " start " /SC ONLOGON + // /RU SYSTEM /RL HIGHEST /F + startInfo.ArgumentList.Add("/Create"); + startInfo.ArgumentList.Add("/TN"); + startInfo.ArgumentList.Add(LogonTaskName); + startInfo.ArgumentList.Add("/TR"); + startInfo.ArgumentList.Add($"{scPath} start {ServiceNameConst}"); + startInfo.ArgumentList.Add("/SC"); + startInfo.ArgumentList.Add("ONLOGON"); + startInfo.ArgumentList.Add("/RU"); + startInfo.ArgumentList.Add("SYSTEM"); + startInfo.ArgumentList.Add("/RL"); + startInfo.ArgumentList.Add("HIGHEST"); + startInfo.ArgumentList.Add("/F"); + + using Process? proc = Process.Start(startInfo); + if (proc is null) + { + ServiceLog.Write("Could not start schtasks.exe to register the logon task."); + return; + } + + proc.WaitForExit(TimeSpan.FromSeconds(15)); + if (proc.ExitCode == 0) + { + ServiceLog.Write($"Logon task '{LogonTaskName}' registered."); + } + else + { + string err = proc.StandardError.ReadToEnd().Trim(); + ServiceLog.Write($"schtasks returned {proc.ExitCode} registering the logon task. {err}"); + } + } + catch (Exception ex) + { + ServiceLog.Write($"EnsureLogonTaskRegistered failed: {ex.Message}"); + } + } + /// /// Terminates the player process (image name "SmartThings.AVplayer.exe"). LocalSystem can /// terminate the user's process. Best effort. @@ -172,6 +245,32 @@ protected override void OnStop() ServiceLog.Write("Service stopping."); } + /// + /// Stops this service (nothing left to do after launching the tray - the logon task will + /// restart it on the next sign-in). Runs on a background thread, so the service has already + /// reported Running to the SCM. + /// + private void StopSelf() + { + try + { + using var controller = new ServiceController(ServiceNameConst); + if (controller.Status is ServiceControllerStatus.StartPending) + { + controller.WaitForStatus(ServiceControllerStatus.Running, TimeSpan.FromSeconds(10)); + } + if (controller.Status is ServiceControllerStatus.Running) + { + ServiceLog.Write("Work done; stopping the service."); + controller.Stop(); + } + } + catch (Exception ex) + { + ServiceLog.Write($"StopSelf failed: {ex.Message}"); + } + } + /// /// Runs the launch logic once without the SCM, for manual testing from an elevated console. /// diff --git a/build/Uninstall.ps1 b/build/Uninstall.ps1 index ecfa90e..063634a 100644 --- a/build/Uninstall.ps1 +++ b/build/Uninstall.ps1 @@ -14,6 +14,10 @@ Get-Process -Name 'TrayHelper' -ErrorAction SilentlyContinue | Stop-Process -For # Remove the logon autostart entry. Remove-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -Name $runName -ErrorAction SilentlyContinue +# Remove the Task Scheduler logon task the service registers (must match LogonTaskName in +# SmartThings.Service/TrayService.cs). Otherwise it lingers and tries to start a removed service. +schtasks.exe /Delete /TN 'DesktopBridge Tray Logon' /F 2>$null + # Uninstall the package. Get-AppxPackage -Name $identityName | Remove-AppxPackage From c9fc0ef6c60bb799ad170062cc3645268e5d69c3 Mon Sep 17 00:00:00 2001 From: amerganim Date: Thu, 30 Jul 2026 22:53:01 +0600 Subject: [PATCH 3/3] Use manifest windows.startupTask for Fast Startup (self-cleans on uninstall) The self-registered logon Scheduled Task was not part of the MSIX package, so a right-click / Store uninstall (the real distribution path - no Uninstall.ps1) would leave it orphaned. Replace it with a package-declared windows.startupTask, which is added and removed with the package. - Package.appxmanifest: add desktop:StartupTask on the TrayHelper app so the tray helper is launched at every logon, including the logon after a Fast-Startup "Shut down -> power on" (auto-start services are not re-run on a hybrid resume; logon startup tasks are). - TrayService.cs: revert to the plain StopSelf design and drop the schtasks-based logon-task registration. The service still launches the tray at install / cold boot (and on preloaded devices, where the app may never be opened and the startup task's "run once" gate would otherwise apply). - The native tray helper is already single-instance (mutex in main.cpp), so a service launch and a startup-task launch never yield duplicate icons. - Revert the Uninstall.ps1 scheduled-task cleanup (no longer needed). Co-Authored-By: Claude Opus 4.8 --- .../SmartThings.Service/TrayService.cs | 84 ++----------------- .../SmartThings.WAPP/Package.appxmanifest | 15 ++++ build/Uninstall.ps1 | 4 - 3 files changed, 24 insertions(+), 79 deletions(-) diff --git a/DesktopBridge/SmartThings.Service/TrayService.cs b/DesktopBridge/SmartThings.Service/TrayService.cs index 0aab08b..9619c45 100644 --- a/DesktopBridge/SmartThings.Service/TrayService.cs +++ b/DesktopBridge/SmartThings.Service/TrayService.cs @@ -4,8 +4,8 @@ namespace TrayLauncherService { /// - /// Windows service (LocalSystem, auto-start). On start (after install / at boot / at logon) it - /// launches, in the interactive user session: + /// Windows service (LocalSystem, auto-start). On start (after install / at boot) and on user + /// logon it launches, in the interactive user session: /// - the native TrayHelper (stays running - owns the tray icon), and /// - the WPF process (only needed briefly). /// After a short delay it kills WPF, then STOPS ITSELF - its work is done, so it consumes no @@ -14,24 +14,20 @@ namespace TrayLauncherService /// FAST STARTUP: Windows Fast Startup (the default for "Shut down") hibernates session 0 and /// restores it on the next power-on instead of doing a cold boot, so auto-start services are NOT /// re-run. A self-stopped service therefore stays stopped after a shutdown+power-on and would - /// never see the logon - so the tray would not appear (a full "Restart" bypasses Fast Startup, - /// which is why restarting worked but shutdown+power-on did not). + /// never see the logon (a full "Restart" bypasses Fast Startup, which is why restarting worked + /// but shutdown+power-on did not). That gap is covered by the manifest windows.startupTask + /// (SmartThings.WAPP/Package.appxmanifest), which launches the tray helper directly at every + /// logon - including the logon after a Fast-Startup resume. The tray helper is single-instance, + /// so a service-initiated launch and a startup-task launch never produce two icons. /// - /// To fix that WITHOUT keeping the service resident, the service registers a Task Scheduler - /// "at log on" task (see ) whose action is to start this - /// service. Logon triggers DO fire on the interactive logon that follows a Fast-Startup resume, - /// so on every sign-in the task starts the service, the service launches the tray, then stops - /// again. The task is removed by Uninstall.ps1. + /// This service still handles install-time and cold-boot launches (and preloaded devices, where + /// the app may never be opened and the startup task's "run once" gate would otherwise apply). /// public sealed class TrayService : ServiceBase { // Must match the desktop6:Service Name in SmartThings.WAPP/Package.appxmanifest. public const string ServiceNameConst = "SmartThings.Service"; - // Name of the Task Scheduler logon task that restarts this service on each sign-in. - // Keep in sync with build/Uninstall.ps1. - private const string LogonTaskName = "DesktopBridge Tray Logon"; - // How long the player is allowed to run before the service kills it. private static readonly TimeSpan WpfLifetime = TimeSpan.FromSeconds(5); @@ -55,10 +51,6 @@ protected override void OnStart(string[] args) ServiceLog.Write("Service starting."); ThreadPool.QueueUserWorkItem(_ => { - // Make sure the logon task exists so we are restarted on future sign-ins (this is - // what survives Fast Startup). Idempotent and cheap. - EnsureLogonTaskRegistered(); - if (SessionLauncher.TryGetActiveSession(out uint sessionId)) { LaunchThenStop(sessionId); @@ -153,64 +145,6 @@ private static bool IsTrayRunningInSession(uint sessionId) } } - /// - /// Registers (or refreshes) a Task Scheduler task that starts this service at every user - /// logon. Runs as SYSTEM so it can start the service. This is the piece that makes the tray - /// reappear after a Fast-Startup "Shut down -> power on", where auto-start services are not - /// re-run. Best effort: if it fails the service still works for the current session. - /// - private static void EnsureLogonTaskRegistered() - { - try - { - string scPath = Path.Combine(Environment.SystemDirectory, "sc.exe"); - var startInfo = new ProcessStartInfo - { - FileName = Path.Combine(Environment.SystemDirectory, "schtasks.exe"), - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - }; - // schtasks /Create /TN "" /TR " start " /SC ONLOGON - // /RU SYSTEM /RL HIGHEST /F - startInfo.ArgumentList.Add("/Create"); - startInfo.ArgumentList.Add("/TN"); - startInfo.ArgumentList.Add(LogonTaskName); - startInfo.ArgumentList.Add("/TR"); - startInfo.ArgumentList.Add($"{scPath} start {ServiceNameConst}"); - startInfo.ArgumentList.Add("/SC"); - startInfo.ArgumentList.Add("ONLOGON"); - startInfo.ArgumentList.Add("/RU"); - startInfo.ArgumentList.Add("SYSTEM"); - startInfo.ArgumentList.Add("/RL"); - startInfo.ArgumentList.Add("HIGHEST"); - startInfo.ArgumentList.Add("/F"); - - using Process? proc = Process.Start(startInfo); - if (proc is null) - { - ServiceLog.Write("Could not start schtasks.exe to register the logon task."); - return; - } - - proc.WaitForExit(TimeSpan.FromSeconds(15)); - if (proc.ExitCode == 0) - { - ServiceLog.Write($"Logon task '{LogonTaskName}' registered."); - } - else - { - string err = proc.StandardError.ReadToEnd().Trim(); - ServiceLog.Write($"schtasks returned {proc.ExitCode} registering the logon task. {err}"); - } - } - catch (Exception ex) - { - ServiceLog.Write($"EnsureLogonTaskRegistered failed: {ex.Message}"); - } - } - /// /// Terminates the player process (image name "SmartThings.AVplayer.exe"). LocalSystem can /// terminate the user's process. Best effort. diff --git a/DesktopBridge/SmartThings.WAPP/Package.appxmanifest b/DesktopBridge/SmartThings.WAPP/Package.appxmanifest index 6bdf0ed..381d81a 100644 --- a/DesktopBridge/SmartThings.WAPP/Package.appxmanifest +++ b/DesktopBridge/SmartThings.WAPP/Package.appxmanifest @@ -109,6 +109,21 @@ + + + + diff --git a/build/Uninstall.ps1 b/build/Uninstall.ps1 index 063634a..ecfa90e 100644 --- a/build/Uninstall.ps1 +++ b/build/Uninstall.ps1 @@ -14,10 +14,6 @@ Get-Process -Name 'TrayHelper' -ErrorAction SilentlyContinue | Stop-Process -For # Remove the logon autostart entry. Remove-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -Name $runName -ErrorAction SilentlyContinue -# Remove the Task Scheduler logon task the service registers (must match LogonTaskName in -# SmartThings.Service/TrayService.cs). Otherwise it lingers and tries to start a removed service. -schtasks.exe /Delete /TN 'DesktopBridge Tray Logon' /F 2>$null - # Uninstall the package. Get-AppxPackage -Name $identityName | Remove-AppxPackage