refactor(services): extract TeamsEmbedHost from TeamsLauncher
TeamsLauncher.cs was 665 lines / 30KB and mixed two unrelated lifecycles: launch / hide / show / in-call orchestration (the bulk of the file), and the Phase E.4 experimental SetParent-based embedding (~160 lines of distinct Win32 surface area + its own state machine). * Services/TeamsEmbedHost.cs (177L, new) — public static class owning EmbedTeamsInto / ResizeEmbedded / RestoreEmbed / IsEmbedded plus the Win32 p/invokes specific to embedding (SetParent, GetWindowLongPtr, SetWindowLongPtr, MoveWindow, SetWindowPos), the WS_* / SWP_* style + position constants, and the embed-state fields. The whole lifecycle (reparent → resize → restore) now lives in one place; the comment about WebView2 fragility moves with the code. * Services/TeamsLauncher.cs (was 665L → now 510L) — keeps launch / stop / join / hide / show / window-title / shortcut concerns. The internal helper that enumerates Teams top-level windows is now named EnumerateTopLevelTeamsWindows (was FindTeamsTopLevelWindows) and marked `internal` so TeamsEmbedHost can call it without duplicating the EnumWindows traversal — both classes use the same process-name heuristic and the launcher's hide/show paths also consume it. * TeamsEmbedWindow.xaml.cs — call sites moved from TeamsLauncher.* to TeamsEmbedHost.* (three references). No behavior change. Build clean; 56 + 104 tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2640739bfc
commit
1f07992100
3 changed files with 191 additions and 169 deletions
177
src/TeamsISO.App/Services/TeamsEmbedHost.cs
Normal file
177
src/TeamsISO.App/Services/TeamsEmbedHost.cs
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace TeamsISO.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Phase E.4 — Embedded Teams via SetParent.
|
||||
///
|
||||
/// Reparents Teams' main top-level window into a TeamsISO-owned host
|
||||
/// (typically a Border element's HWND). Strips the captured window's
|
||||
/// caption + thick frame so it integrates flush with the host, and
|
||||
/// remembers enough about the original to restore it cleanly later.
|
||||
///
|
||||
/// The Win32 behavior is well understood for classic Win32 apps, but
|
||||
/// modern Teams runs WebView2 in its main window; WebView2's renderer is
|
||||
/// sensitive to parent changes and may flash white frames during
|
||||
/// reparent, drop input focus, or refuse to redraw until forced. We mark
|
||||
/// the feature experimental and ensure the restore path always runs (the
|
||||
/// caller wraps Embed in a finally block) so operators can fall back to
|
||||
/// auto-hide mode if embedding misbehaves on their specific Teams build.
|
||||
///
|
||||
/// Lives in its own static class — separated from <see cref="TeamsLauncher"/>
|
||||
/// because the embedding lifecycle (reparent → resize → restore) is its
|
||||
/// own thing, and the Win32 surface it requires (SetParent / window-style
|
||||
/// muck / SetWindowPos / MoveWindow) isn't reused by the launch / hide /
|
||||
/// in-call control paths.
|
||||
/// </summary>
|
||||
public static class TeamsEmbedHost
|
||||
{
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern IntPtr SetParent(IntPtr hWndChild, IntPtr hWndNewParent);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern int GetWindowLongPtr(IntPtr hWnd, int nIndex);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern int SetWindowLongPtr(IntPtr hWnd, int nIndex, int dwNewLong);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool MoveWindow(IntPtr hWnd, int X, int Y, int nWidth, int nHeight, [MarshalAs(UnmanagedType.Bool)] bool bRepaint);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern int GetWindowTextLengthW(IntPtr hWnd);
|
||||
|
||||
private const int GWL_STYLE = -16;
|
||||
private const long WS_CHILD = 0x40000000;
|
||||
private const long WS_POPUP = unchecked((long)0x80000000);
|
||||
private const long WS_CAPTION = 0x00C00000;
|
||||
private const long WS_THICKFRAME = 0x00040000;
|
||||
private const long WS_BORDER = 0x00800000;
|
||||
private const long WS_DLGFRAME = 0x00400000;
|
||||
private const uint SWP_FRAMECHANGED = 0x0020;
|
||||
private const uint SWP_NOMOVE = 0x0002;
|
||||
private const uint SWP_NOSIZE = 0x0001;
|
||||
private const uint SWP_NOZORDER = 0x0004;
|
||||
private const uint SWP_NOACTIVATE = 0x0010;
|
||||
|
||||
/// <summary>
|
||||
/// Captures the original parent + window style so embedding can be
|
||||
/// reversed cleanly. Tracked per-HWND so multiple consecutive
|
||||
/// embed / unembed cycles don't lose the original chrome.
|
||||
/// </summary>
|
||||
private static (IntPtr OriginalParent, int OriginalStyle)? _embedSavedState;
|
||||
private static IntPtr _embeddedHwnd = IntPtr.Zero;
|
||||
|
||||
/// <summary>True when a Teams window is currently parented inside a TeamsISO host.</summary>
|
||||
public static bool IsEmbedded => _embeddedHwnd != IntPtr.Zero;
|
||||
|
||||
/// <summary>
|
||||
/// Reparents Teams' most-recently-used top-level window into
|
||||
/// <paramref name="hostHwnd"/>. Strips Teams' caption + thick frame
|
||||
/// so it integrates flush with the host. Returns true on success,
|
||||
/// false if no Teams window could be found.
|
||||
///
|
||||
/// The host HWND is typically obtained via:
|
||||
/// var src = (System.Windows.Interop.HwndSource)
|
||||
/// PresentationSource.FromVisual(MyHostBorder);
|
||||
/// src.Handle // → IntPtr suitable for hostHwnd
|
||||
/// </summary>
|
||||
public static bool EmbedTeamsInto(IntPtr hostHwnd, int width, int height)
|
||||
{
|
||||
if (hostHwnd == IntPtr.Zero) return false;
|
||||
var teamsWindows = TeamsLauncher.EnumerateTopLevelTeamsWindows();
|
||||
if (teamsWindows.Count == 0) return false;
|
||||
|
||||
// Pick the longest-title window as the "main" one — same
|
||||
// heuristic GetActiveWindowTitle uses; matches the call /
|
||||
// meeting window.
|
||||
IntPtr best = IntPtr.Zero;
|
||||
int bestLen = -1;
|
||||
foreach (var w in teamsWindows)
|
||||
{
|
||||
var len = GetWindowTextLengthW(w);
|
||||
if (len > bestLen) { bestLen = len; best = w; }
|
||||
}
|
||||
if (best == IntPtr.Zero) return false;
|
||||
|
||||
// Already embedded? Unembed first to clean state.
|
||||
if (_embeddedHwnd != IntPtr.Zero) RestoreEmbed();
|
||||
|
||||
// Save original style + parent so we can fully reverse later.
|
||||
var originalStyle = GetWindowLongPtr(best, GWL_STYLE);
|
||||
var originalParent = SetParent(best, hostHwnd); // returns old parent
|
||||
|
||||
_embedSavedState = (originalParent, originalStyle);
|
||||
_embeddedHwnd = best;
|
||||
|
||||
// Strip top-level decorations + add WS_CHILD so the OS treats
|
||||
// it as a child window of the host.
|
||||
var newStyle = originalStyle;
|
||||
unchecked
|
||||
{
|
||||
newStyle &= ~(int)WS_CAPTION;
|
||||
newStyle &= ~(int)WS_THICKFRAME;
|
||||
newStyle &= ~(int)WS_BORDER;
|
||||
newStyle &= ~(int)WS_DLGFRAME;
|
||||
newStyle &= ~(int)WS_POPUP;
|
||||
newStyle |= (int)WS_CHILD;
|
||||
}
|
||||
SetWindowLongPtr(best, GWL_STYLE, newStyle);
|
||||
|
||||
// Force a non-client recalculation so the style change takes
|
||||
// effect.
|
||||
SetWindowPos(best, IntPtr.Zero, 0, 0, 0, 0,
|
||||
SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
|
||||
|
||||
// Place at top-left of host, full host size.
|
||||
MoveWindow(best, 0, 0, width, height, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resize the currently-embedded Teams window to <paramref name="width"/>
|
||||
/// × <paramref name="height"/>. Called when the host element resizes
|
||||
/// (window resize, layout change, etc.). No-op if nothing is embedded.
|
||||
/// </summary>
|
||||
public static void ResizeEmbedded(int width, int height)
|
||||
{
|
||||
if (_embeddedHwnd == IntPtr.Zero || width <= 0 || height <= 0) return;
|
||||
MoveWindow(_embeddedHwnd, 0, 0, width, height, true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reverse an active embed: SetParent back to desktop + restore the
|
||||
/// original window style so Teams looks/behaves like a normal
|
||||
/// top-level window again. Safe to call when nothing is embedded —
|
||||
/// no-op.
|
||||
/// </summary>
|
||||
public static void RestoreEmbed()
|
||||
{
|
||||
if (_embeddedHwnd == IntPtr.Zero || _embedSavedState is null) return;
|
||||
var (origParent, origStyle) = _embedSavedState.Value;
|
||||
try
|
||||
{
|
||||
// Restore original style FIRST so when we reparent the
|
||||
// window's top-level decorations come back correctly.
|
||||
SetWindowLongPtr(_embeddedHwnd, GWL_STYLE, origStyle);
|
||||
// SetParent(hwnd, Zero) returns to desktop. We could pass
|
||||
// origParent verbatim but for Teams that's always the
|
||||
// desktop anyway, and IntPtr.Zero is documented as
|
||||
// "reparent to desktop".
|
||||
SetParent(_embeddedHwnd, IntPtr.Zero);
|
||||
SetWindowPos(_embeddedHwnd, IntPtr.Zero, 0, 0, 0, 0,
|
||||
SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
|
||||
}
|
||||
catch { /* defensive — restore must never throw */ }
|
||||
finally
|
||||
{
|
||||
_embedSavedState = null;
|
||||
_embeddedHwnd = IntPtr.Zero;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -271,168 +271,6 @@ public static class TeamsLauncher
|
|||
private static extern int GetWindowTextLengthW(IntPtr hWnd);
|
||||
|
||||
private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Phase E.4 — Embedded Teams via SetParent.
|
||||
//
|
||||
// Reparents Teams' main top-level window into a TeamsISO-owned host
|
||||
// (typically a Border element's HWND). The Win32 behavior is well
|
||||
// understood for classic Win32 apps but modern Teams runs WebView2 in
|
||||
// its main window; WebView2's renderer is sensitive to parent changes
|
||||
// and may flash white frames during reparent, drop input focus, or
|
||||
// refuse to redraw until forced.
|
||||
//
|
||||
// We mark the feature experimental and provide a clean restore path
|
||||
// (SetParent back to desktop + restore the original window styles)
|
||||
// so operators can fall back to auto-hide mode if embedding misbehaves
|
||||
// on their specific Teams build.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern IntPtr SetParent(IntPtr hWndChild, IntPtr hWndNewParent);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern int GetWindowLongPtr(IntPtr hWnd, int nIndex);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern int SetWindowLongPtr(IntPtr hWnd, int nIndex, int dwNewLong);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool MoveWindow(IntPtr hWnd, int X, int Y, int nWidth, int nHeight, [MarshalAs(UnmanagedType.Bool)] bool bRepaint);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern IntPtr GetDesktopWindow();
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags);
|
||||
|
||||
private const int GWL_STYLE = -16;
|
||||
private const long WS_CHILD = 0x40000000;
|
||||
private const long WS_POPUP = unchecked((long)0x80000000);
|
||||
private const long WS_CAPTION = 0x00C00000;
|
||||
private const long WS_THICKFRAME = 0x00040000;
|
||||
private const long WS_BORDER = 0x00800000;
|
||||
private const long WS_DLGFRAME = 0x00400000;
|
||||
private const uint SWP_FRAMECHANGED = 0x0020;
|
||||
private const uint SWP_NOMOVE = 0x0002;
|
||||
private const uint SWP_NOSIZE = 0x0001;
|
||||
private const uint SWP_NOZORDER = 0x0004;
|
||||
private const uint SWP_NOACTIVATE = 0x0010;
|
||||
|
||||
/// <summary>
|
||||
/// Captures the original parent + window style so embedding can be
|
||||
/// reversed cleanly. Tracked per-HWND so multiple consecutive
|
||||
/// embed/unembed cycles don't lose the original chrome.
|
||||
/// </summary>
|
||||
private static (IntPtr OriginalParent, int OriginalStyle)? _embedSavedState;
|
||||
private static IntPtr _embeddedHwnd = IntPtr.Zero;
|
||||
|
||||
/// <summary>True when a Teams window is currently parented inside a TeamsISO host.</summary>
|
||||
public static bool IsEmbedded => _embeddedHwnd != IntPtr.Zero;
|
||||
|
||||
/// <summary>
|
||||
/// Reparents Teams' most-recently-used top-level window into
|
||||
/// <paramref name="hostHwnd"/>. Strips Teams' caption + thick frame so
|
||||
/// it integrates flush with the host. Returns true on success, false
|
||||
/// if no Teams window could be found.
|
||||
///
|
||||
/// The host HWND is typically obtained via:
|
||||
/// var src = (System.Windows.Interop.HwndSource)
|
||||
/// PresentationSource.FromVisual(MyHostBorder);
|
||||
/// src.Handle // → IntPtr suitable for hostHwnd
|
||||
/// </summary>
|
||||
public static bool EmbedTeamsInto(IntPtr hostHwnd, int width, int height)
|
||||
{
|
||||
if (hostHwnd == IntPtr.Zero) return false;
|
||||
var teamsWindows = FindTeamsTopLevelWindows();
|
||||
if (teamsWindows.Count == 0) return false;
|
||||
|
||||
// Pick the longest-title window as the "main" one — same heuristic
|
||||
// GetActiveWindowTitle uses; matches the call/meeting window.
|
||||
IntPtr best = IntPtr.Zero;
|
||||
int bestLen = -1;
|
||||
foreach (var w in teamsWindows)
|
||||
{
|
||||
var len = GetWindowTextLengthW(w);
|
||||
if (len > bestLen) { bestLen = len; best = w; }
|
||||
}
|
||||
if (best == IntPtr.Zero) return false;
|
||||
|
||||
// Already embedded? Unembed first to clean state.
|
||||
if (_embeddedHwnd != IntPtr.Zero) RestoreEmbed();
|
||||
|
||||
// Save original style + parent so we can fully reverse later.
|
||||
var originalStyle = GetWindowLongPtr(best, GWL_STYLE);
|
||||
var originalParent = SetParent(best, hostHwnd); // returns old parent
|
||||
|
||||
_embedSavedState = (originalParent, originalStyle);
|
||||
_embeddedHwnd = best;
|
||||
|
||||
// Strip top-level decorations + add WS_CHILD so the OS treats it
|
||||
// as a child window of the host.
|
||||
var newStyle = originalStyle;
|
||||
unchecked
|
||||
{
|
||||
newStyle &= ~(int)WS_CAPTION;
|
||||
newStyle &= ~(int)WS_THICKFRAME;
|
||||
newStyle &= ~(int)WS_BORDER;
|
||||
newStyle &= ~(int)WS_DLGFRAME;
|
||||
newStyle &= ~(int)WS_POPUP;
|
||||
newStyle |= (int)WS_CHILD;
|
||||
}
|
||||
SetWindowLongPtr(best, GWL_STYLE, newStyle);
|
||||
|
||||
// Force a non-client recalculation so the style change takes effect.
|
||||
SetWindowPos(best, IntPtr.Zero, 0, 0, 0, 0,
|
||||
SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
|
||||
|
||||
// Place at top-left of host, full host size.
|
||||
MoveWindow(best, 0, 0, width, height, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resize the currently-embedded Teams window to <paramref name="width"/>
|
||||
/// × <paramref name="height"/>. Called when the host element resizes
|
||||
/// (window resize, layout change, etc.). No-op if nothing is embedded.
|
||||
/// </summary>
|
||||
public static void ResizeEmbedded(int width, int height)
|
||||
{
|
||||
if (_embeddedHwnd == IntPtr.Zero || width <= 0 || height <= 0) return;
|
||||
MoveWindow(_embeddedHwnd, 0, 0, width, height, true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reverse an active embed: SetParent back to desktop + restore the
|
||||
/// original window style so Teams looks/behaves like a normal top-level
|
||||
/// window again. Safe to call when nothing is embedded — no-op.
|
||||
/// </summary>
|
||||
public static void RestoreEmbed()
|
||||
{
|
||||
if (_embeddedHwnd == IntPtr.Zero || _embedSavedState is null) return;
|
||||
var (origParent, origStyle) = _embedSavedState.Value;
|
||||
try
|
||||
{
|
||||
// Restore original style FIRST so when we reparent the window's
|
||||
// top-level decorations come back correctly.
|
||||
SetWindowLongPtr(_embeddedHwnd, GWL_STYLE, origStyle);
|
||||
// SetParent(hwnd, Zero) returns to desktop. We could pass
|
||||
// origParent verbatim but for Teams that's always the desktop
|
||||
// anyway, and IntPtr.Zero is documented as "reparent to desktop".
|
||||
SetParent(_embeddedHwnd, IntPtr.Zero);
|
||||
SetWindowPos(_embeddedHwnd, IntPtr.Zero, 0, 0, 0, 0,
|
||||
SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
|
||||
}
|
||||
catch { /* defensive — restore must never throw */ }
|
||||
finally
|
||||
{
|
||||
_embedSavedState = null;
|
||||
_embeddedHwnd = IntPtr.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the title bar text of Teams' most-recently-used top-level
|
||||
/// window, or empty string if Teams isn't running. Modern Teams puts
|
||||
|
|
@ -482,7 +320,14 @@ public static class TeamsLauncher
|
|||
/// process. "Top-level" means GetWindow(GW_OWNER) is null (the window is
|
||||
/// not a tooltip or popup of another). Used by Hide/Show.
|
||||
/// </summary>
|
||||
private static List<IntPtr> FindTeamsTopLevelWindows()
|
||||
/// <summary>
|
||||
/// Return the visible top-level windows owned by any Teams process.
|
||||
/// Exposed internal so <see cref="TeamsEmbedHost"/> can pick the
|
||||
/// "best" candidate to reparent without re-implementing the
|
||||
/// enumeration. Keep this in TeamsLauncher because the launch /
|
||||
/// hide / show paths use the same list.
|
||||
/// </summary>
|
||||
internal static List<IntPtr> EnumerateTopLevelTeamsWindows()
|
||||
{
|
||||
var teamsPids = new HashSet<uint>(
|
||||
TeamsProcessNames
|
||||
|
|
@ -509,7 +354,7 @@ public static class TeamsLauncher
|
|||
/// </summary>
|
||||
public static int HideWindows()
|
||||
{
|
||||
var windows = FindTeamsTopLevelWindows();
|
||||
var windows = EnumerateTopLevelTeamsWindows();
|
||||
foreach (var w in windows) ShowWindow(w, SW_HIDE);
|
||||
return windows.Count;
|
||||
}
|
||||
|
|
@ -610,7 +455,7 @@ public static class TeamsLauncher
|
|||
/// </summary>
|
||||
public static bool SendShortcut(ShortcutModifiers modifiers, int virtualKey)
|
||||
{
|
||||
var windows = FindTeamsTopLevelWindows();
|
||||
var windows = EnumerateTopLevelTeamsWindows();
|
||||
if (windows.Count == 0) return false;
|
||||
var hwnd = windows[^1];
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ namespace TeamsISO.App;
|
|||
/// instead of leaving the host blank.
|
||||
/// • Restore-on-close runs in a finally block so a crash mid-host
|
||||
/// can't leave Teams orphaned with stripped window styles.
|
||||
/// • TeamsLauncher.RestoreEmbed is idempotent — safe to call even if
|
||||
/// • TeamsEmbedHost.RestoreEmbed is idempotent — safe to call even if
|
||||
/// embedding never succeeded.
|
||||
/// </summary>
|
||||
public partial class TeamsEmbedWindow : Window
|
||||
|
|
@ -43,7 +43,7 @@ public partial class TeamsEmbedWindow : Window
|
|||
|
||||
var w = (int)EmbedHost.ActualWidth;
|
||||
var h = (int)EmbedHost.ActualHeight;
|
||||
if (!TeamsLauncher.EmbedTeamsInto(src.Handle, w, h))
|
||||
if (!TeamsEmbedHost.EmbedTeamsInto(src.Handle, w, h))
|
||||
{
|
||||
MessageBox.Show(
|
||||
"Couldn't find a Microsoft Teams window to embed. " +
|
||||
|
|
@ -57,14 +57,14 @@ public partial class TeamsEmbedWindow : Window
|
|||
{
|
||||
// Keep Teams sized to match the host as the embed window resizes.
|
||||
// No-op when nothing is embedded.
|
||||
TeamsLauncher.ResizeEmbedded((int)e.NewSize.Width, (int)e.NewSize.Height);
|
||||
TeamsEmbedHost.ResizeEmbedded((int)e.NewSize.Width, (int)e.NewSize.Height);
|
||||
}
|
||||
|
||||
private void OnWindowClosed(object? sender, EventArgs e)
|
||||
{
|
||||
// ALWAYS restore Teams to top-level state when this window closes,
|
||||
// even if the embed never succeeded. Idempotent.
|
||||
try { TeamsLauncher.RestoreEmbed(); }
|
||||
try { TeamsEmbedHost.RestoreEmbed(); }
|
||||
catch { /* defensive — restore is best-effort */ }
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue