Fix sidebar text cutoff + Teams launch ambush dialog

Two user-reported bugs:

1) CheckBox content was clipping in the 380px settings panel ('Control surface (Stream Deck / Companion / w...' / 'LAN-reachable (allow other machines on yo...'). The Wd.CheckBox template used a horizontal StackPanel which doesn't bound child width, so long Content strings ran off the column without wrapping. Replaced StackPanel with a Grid (Auto + *) and injected a TextBlock style with TextWrapping=Wrap into the ContentPresenter resources — when WPF auto-wraps a string Content in a TextBlock, the resource lookup gives it Wrap.

2) The rail Launch Teams button ambushed operators: clicking with Teams already running (which is common when the eye-toggle has hidden Teams' windows) opened a 'Close all Teams windows now?' dialog. Operators expect Launch to mean 'show me Teams', not 'stop Teams'. Split the actions:

   - Left-click: Teams not running → launch; Teams hidden → restore + foreground; Teams visible → bring to front. Always idempotent-progressive.

   - Right-click: ask to stop Teams (preserves the kill path for those who want it).

TeamsLauncher.TryLaunch now collects per-attempt errors instead of swallowing them — a real failure surfaces 'ms-teams: URI → <reason>' / 'AppsFolder shell → <reason>' / 'classic Update.exe → not found at <path>' so 'No Teams found' isn't a black box.

Also added a 2nd path: explorer.exe shell:appsFolder\\\\MSTeams_8wekyb3d8bbwe!MSTeams (AppX activation via the OS's own Start-menu verb) as a fallback if the URI handler is misconfigured. Removed the broken bare-stub call to %LOCALAPPDATA%\\\\Microsoft\\\\WindowsApps\\\\ms-teams.exe — that's a 0-byte AppX placeholder that never worked outside an AppX context.
This commit is contained in:
Zac Gaetano 2026-05-10 14:39:04 -04:00
parent e020d1c2ac
commit 598938ede5
4 changed files with 143 additions and 41 deletions

View file

@ -123,11 +123,18 @@
</Grid>
</Button>
<!-- Nav: Launch Teams. Subprocess-launches the MS Teams desktop client. -->
<!-- Nav: Launch Teams. Click = launch / surface; right-click = stop.
The previous "left-click toggles" behavior ambushed operators
who'd hidden Teams' windows via the eye toggle next door —
they'd hit Launch expecting Teams to come back and instead
get a "Close all Teams windows now?" dialog. Now click is
always idempotent-progressive (running but hidden → show;
not running → launch). Right-click for stop. -->
<Button DockPanel.Dock="Top"
Style="{StaticResource Wd.Button.RailIcon}"
Click="OnLaunchTeamsClick"
ToolTip="Launch Microsoft Teams">
MouseRightButtonUp="OnLaunchTeamsRightClick"
ToolTip="Launch Microsoft Teams (or surface its window). Right-click to stop Teams.">
<!-- Stylized 'video meeting' camera icon -->
<Path Data="M 4,8 L 16,8 L 16,16 L 4,16 Z M 16,11 L 22,8 L 22,16 L 16,13 Z"
Stroke="{DynamicResource Wd.Text.Secondary}"

View file

@ -1,5 +1,6 @@
using System.Linq;
using System.Windows;
using System.Windows.Input;
using System.Windows.Shapes;
using TeamsISO.App.Services;
using TeamsISO.App.ViewModels;
@ -113,43 +114,78 @@ public partial class MainWindow : Window
}
/// <summary>
/// Toggle behavior: if Teams is already running, ask to stop it; otherwise
/// launch via TeamsLauncher's fallback chain. First step toward the
/// Embedded-Teams roadmap (Phase E.1).
/// Three-state click behavior matching operator intuition:
/// 1. Teams not running → launch it via TeamsLauncher's fallback chain.
/// 2. Teams running but its windows are hidden (we toggled them off, OR
/// it launched into the tray) → restore the windows + foreground them.
/// This is the case the previous "ask to stop" dialog was ambushing —
/// operators don't think of a hidden Teams as "running and ready to
/// stop", they think of it as "I clicked Launch and nothing happened".
/// 3. Teams running with visible windows → bring the most recent one to
/// the foreground. (Stopping Teams is now a right-click action;
/// see OnLaunchTeamsRightClick.)
/// </summary>
private void OnLaunchTeamsClick(object sender, RoutedEventArgs e)
{
if (TeamsLauncher.IsRunning())
{
var confirm = MessageBox.Show(
"Microsoft Teams is currently running.\n\nClose all Teams windows now?",
"TeamsISO — Stop Teams",
MessageBoxButton.YesNo,
MessageBoxImage.Question);
if (confirm != MessageBoxResult.Yes) return;
var toast = (DataContext as MainViewModel)?.Toast;
var asked = TeamsLauncher.StopAll();
if (TeamsLauncher.IsRunning())
if (!TeamsLauncher.IsRunning())
{
if (!TeamsLauncher.TryLaunch(out var error))
{
MessageBox.Show(
asked == 0
? "No Teams windows responded to close."
: $"Sent close to {asked} Teams window(s); some may still be exiting.",
"TeamsISO — Stop Teams",
$"Could not launch Microsoft Teams.\n\n{error}",
"TeamsISO — Launch Teams",
MessageBoxButton.OK,
MessageBoxImage.Information);
MessageBoxImage.Warning);
}
else
{
toast?.Show("Launching Microsoft Teams…");
_teamsWindowsHidden = false;
}
return;
}
if (!TeamsLauncher.TryLaunch(out var error))
// Teams is running. Always try to restore + foreground its window —
// if windows are already visible, ShowWindows is a SetForegroundWindow
// no-op besides bringing them forward; if they were hidden by our
// own toggle, this is the operator's intuitive "show me Teams" path.
var shown = TeamsLauncher.ShowWindows();
_teamsWindowsHidden = false;
toast?.Show(shown > 0
? $"Teams is already running — surfaced {shown} window(s)"
: "Teams is running but has no visible windows yet");
}
/// <summary>
/// Right-click on the rail Launch button asks to stop Teams. Split out
/// from the left-click so a normal click is "open / surface" rather than
/// the previous "open OR ambush you with a stop dialog".
/// </summary>
private void OnLaunchTeamsRightClick(object sender, MouseButtonEventArgs e)
{
if (!TeamsLauncher.IsRunning()) return;
var confirm = MessageBox.Show(
"Microsoft Teams is currently running.\n\nClose all Teams windows now?",
"TeamsISO — Stop Teams",
MessageBoxButton.YesNo,
MessageBoxImage.Question);
if (confirm != MessageBoxResult.Yes) return;
var asked = TeamsLauncher.StopAll();
if (TeamsLauncher.IsRunning())
{
MessageBox.Show(
$"Could not launch Microsoft Teams.\n\n{error}",
"TeamsISO — Launch Teams",
asked == 0
? "No Teams windows responded to close."
: $"Sent close to {asked} Teams window(s); some may still be exiting.",
"TeamsISO — Stop Teams",
MessageBoxButton.OK,
MessageBoxImage.Warning);
MessageBoxImage.Information);
}
e.Handled = true;
}
/// <summary>

View file

@ -44,21 +44,39 @@ public static class TeamsLauncher
/// <summary>
/// Launches Teams. Returns true if a launch was started successfully (the
/// process may take a few seconds to actually appear). False if every
/// fallback path failed.
/// fallback path failed; <paramref name="errorMessage"/> includes the
/// reasons each attempt was rejected so the operator can see why.
///
/// Path order matters:
/// 1. <c>ms-teams:</c> URI — new Teams (MSTeams AppX) registers this
/// handler at install. Activates through the AppX shell so the
/// stub <c>ms-teams.exe</c> in WindowsApps gets the right context.
/// 2. AppsFolder shell verb — direct AppX activation. Belt-and-braces
/// fallback if a misconfigured registry breaks the URI handler.
/// 3. Classic Teams Update.exe — pre-2024 Teams installations.
/// We deliberately DON'T try the bare <c>ms-teams.exe</c> WindowsApps
/// path: it's a 0-byte AppX placeholder that fails silently when invoked
/// without AppX activation context. Looked plausible, never worked.
/// </summary>
public static bool TryLaunch(out string? errorMessage)
{
errorMessage = null;
var attempts = new List<string>();
// Path 1: URI scheme. The shell handler picks whichever Teams client
// is registered (new MSTeams.exe takes priority on modern Windows).
if (TryStart("ms-teams:", useShell: true)) return true;
// Path 1: URI scheme. The shell handler picks the registered Teams
// (new MSTeams takes priority on modern Windows). UseShellExecute=true
// is required — Win32 Process creation can't open URIs directly.
if (TryStart("ms-teams:", useShell: true, out var err1)) return true;
attempts.Add($"ms-teams: URI → {err1}");
// Path 2: new Teams' WindowsApps shim.
var newTeams = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Microsoft", "WindowsApps", "ms-teams.exe");
if (File.Exists(newTeams) && TryStart(newTeams, useShell: false)) return true;
// Path 2: AppX activation via the explorer.exe shell. Modern Teams
// ships as MSTeams_8wekyb3d8bbwe; if other code on the box has
// clobbered the URI registration, this still works because it goes
// through the AppsFolder verb the OS itself uses for Start menu launches.
if (TryStart("explorer.exe", false, out var err2,
arguments: "shell:appsFolder\\MSTeams_8wekyb3d8bbwe!MSTeams"))
return true;
attempts.Add($"AppsFolder shell → {err2}");
// Path 3: classic Teams Update.exe with --processStart hands off to
// the actual Teams.exe via Squirrel.
@ -80,11 +98,17 @@ public static class TeamsLauncher
}
catch (Exception ex)
{
errorMessage = ex.Message;
attempts.Add($"classic Update.exe → {ex.Message}");
}
}
else
{
attempts.Add($"classic Update.exe → not found at {classicUpdater}");
}
errorMessage ??= "No Microsoft Teams installation was found. Install Teams from https://www.microsoft.com/microsoft-teams and try again.";
errorMessage = "No Microsoft Teams installation could be launched. " +
"Install Teams from https://www.microsoft.com/microsoft-teams and try again.\n\n" +
"Attempts:\n • " + string.Join("\n • ", attempts);
return false;
}
@ -123,8 +147,9 @@ public static class TeamsLauncher
return asked;
}
private static bool TryStart(string target, bool useShell)
private static bool TryStart(string target, bool useShell, out string error, string? arguments = null)
{
error = string.Empty;
try
{
var info = new ProcessStartInfo
@ -133,11 +158,13 @@ public static class TeamsLauncher
UseShellExecute = useShell,
CreateNoWindow = true,
};
if (arguments is not null) info.Arguments = arguments;
Process.Start(info);
return true;
}
catch
catch (Exception ex)
{
error = ex.Message;
return false;
}
}

View file

@ -538,11 +538,25 @@
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Primary}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Padding" Value="10,0,0,0"/>
<!--
Stretch + a Grid template lets long Content strings wrap rather
than get clipped by the 380px settings panel. Without this, the
previous StackPanel template let the ContentPresenter grow
unbounded horizontally and the parent's clip-to-bounds chopped
off the right side.
-->
<Setter Property="HorizontalAlignment" Value="Stretch"/>
<Setter Property="HorizontalContentAlignment" Value="Left"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="CheckBox">
<StackPanel Orientation="Horizontal">
<Grid HorizontalAlignment="Stretch">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border x:Name="Box"
Grid.Column="0"
Width="18" Height="18"
BorderBrush="{StaticResource Wd.BorderStrong}"
BorderThickness="1"
@ -556,9 +570,27 @@
StrokeEndLineCap="Round"
Visibility="Collapsed"/>
</Border>
<ContentPresenter Margin="{TemplateBinding Padding}"
VerticalAlignment="Center"/>
</StackPanel>
<!--
ContentPresenter resources inject a TextBlock
style with TextWrapping=Wrap. When CheckBox's
Content is a plain string (the common case here),
WPF wraps it in an auto-generated TextBlock; the
resource lookup applies our wrapping default so
long labels flow onto multiple lines instead of
being clipped at the column edge.
-->
<ContentPresenter Grid.Column="1"
Margin="{TemplateBinding Padding}"
VerticalAlignment="Center"
HorizontalAlignment="Stretch"
RecognizesAccessKey="True">
<ContentPresenter.Resources>
<Style TargetType="TextBlock">
<Setter Property="TextWrapping" Value="Wrap"/>
</Style>
</ContentPresenter.Resources>
</ContentPresenter>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Box" Property="Background" Value="{StaticResource Wd.Accent.Cyan}"/>