Skip to content

fix: support multi-window and XAML Island application pinning - #59

Open
amirf147 wants to merge 1 commit into
mirober:masterfrom
amirf147:fix/multi-window-app-pinning
Open

amirf147 wants to merge 1 commit into
mirober:masterfrom
amirf147:fix/multi-window-app-pinning

Conversation

@amirf147

Copy link
Copy Markdown

Summary of Changes

Resolves an issue where pinning modern multi-window and XAML Island applications (such as Windows Terminal, modern File Explorer instances, and hosted Chromium/Electron frames) only pins isolated window instances rather than the entire application.


Problem

Modern Windows applications hosting XAML controls inside Win32 frames assign per-window sub-AppUserModelIDs formatted as <Package>!App~Wh~w<HEX_HWND> (for example, Microsoft.WindowsTerminal_8wekyb3d8bbwe!App~Wh~w010E0A34).

Because Windows Shell's internal IVirtualDesktopPinnedApps COM interface performs exact string matching (wcscmp) against registered AppIDs:

  1. Sub-AUMID Isolation: Calling PinAppID() with the raw window ID registers only that single transient window handle. Sibling windows under the same application remain unpinned, and closing the window leaves orphaned registry entries under HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\VirtualDesktops\PinnedApps.
  2. Shell String Mismatch: Pinning only the base application ID fails to display existing secondary windows across desktops because their view identities carry the ~Wh~ suffix.
  3. Pointer Conversion Discrepancy: On 64-bit runtimes, GetAppUserModelId() can return a raw pointer address rather than a decoded string, causing type errors downstream.

Solution

  1. Decoded App ID & Canonical Base ID:

    • In AppView.app_id, ensure raw pointer values are resolved to strings using ctypes.wstring_at(raw).
    • Added AppView.base_app_id, which strips ~Wh~ window-hosting tokens to extract the canonical application identifier.
  2. Hierarchical Pinning and Unpinning:

    • In AppView.pin_app(): Registers the base_app_id with PinAppID() and explicitly pins active sub-views belonging to that base ID via in-memory PinView(). This prevents orphaned registry keys while ensuring all active windows appear across workspaces.
    • In AppView.unpin_app(): Unpins both the base ID and window-specific IDs, and unpins matching active views.
    • In AppView.is_app_pinned(): Evaluates both base_app_id and raw app_id against IsAppIdPinned().
  3. Desktop Transition Synchronization:

    • Added sync_pinned_apps(), exported in pyvda.__init__. It inspects open views and pins individual sub-views for applications whose base ID is pinned.
    • Called sync_pinned_apps() inside VirtualDesktop.go() prior to switching desktops so secondary windows stay visible across desktops.
  4. Testing:

    • Added unit tests in tests/test_desktop_functions.py validating base_app_id parsing and sync_pinned_apps() execution.

Background Context & Deeper Architectural Notes

This fix is scoped to resolve the immediate XAML Island sub-AUMID bug cleanly with zero breaking changes.

For maintainers interested in deeper architectural context, we have documented empirical investigations covering the Windows Virtual Desktop subsystem:

  • Native Shell Mechanics: An analysis of how explorer.exe (twinui.pcshell.dll) manages Task View, the boundary between Microsoft's public SDK (IVirtualDesktopManager) and undocumented interfaces (IVirtualDesktopManagerInternal), and why out-of-process ALPC calls behave differently than in-process shell code.
  • Cross-Repository Analysis: Comparative findings across pyvda, VirtualDesktopAccessor (Rust), WinStasis (C# .NET 10), and ADCE (Active Desktop Context Engine), noting that VirtualDesktopAccessor currently shares this exact ~Wh~ sub-AUMID pinning limitation.
  • COM Lifecycle Models: Analysis comparing reactive exception retry decorators (@_com_retry) and broadcast listeners (TaskbarCreated) against call-scoped, zero-cached-state transient invocation models.

Research documents:

@amirf147

Copy link
Copy Markdown
Author

Technical Note: Windows Shell Reverse Engineering & Task View Parity

To provide additional architectural context for maintainers evaluating this change, we disassembled the Windows Shell implementation in twinui.pcshell.dll using Microsoft symbol data (twinui.pcshell.pdb, Build 26200) to inspect how native Task View handles multi-window and XAML Island application pinning.

1. Why PinAppID Fails on Hosted Windows

In twinui.pcshell.dll, VirtualPinnedAppsHandler::PinAppID delegates view lookup to CApplicationViewManager::GetViewsByAppUserModelId. This routine iterates active views and compares IDs via IApplicationView::IsEqualByAppUserModelId (RVA 0x180130ba0), which calls:

0x180130c42: call CompareStringOrdinal ; Invoked with lengths (-1, -1)
0x180130c50: cmp  eax, 2               ; CSTR_EQUAL == 2
0x180130c53: sete cl                   ; Strict equality check

Applications using XAML Islands or detached frames append dynamic sub-identifiers formatted as <Package>!App~Wh~w<HEX_HWND>. Because CompareStringOrdinal requires exact string equality without prefix matching:

  • Passing the canonical AppID causes string comparison to fail against views bearing ~Wh~w<HWND>, returning zero views to pin.
  • Passing the raw AppID pins only that single transient window handle, polluting HKCU\...\VirtualDesktops\PinnedApps with stale HWND values upon exit.

Furthermore, VirtualPinnedAppsHandler::ViewAddedInternal (RVA 0x1801b91c0) also executes CompareStringOrdinal against the pinned list when a new window spawns. Because the new instance carries a distinct HWND suffix, the OS never matches it to a previously pinned base AppID.

2. How Native Task View Handles This

When selecting "Show windows from this app on all desktops" in Task View, modern Windows does not rely on PinAppID alone. Task View routes to DesktopTaskGroupsSwitchItemController::PinUnpinToAllDesktops (RVA 0x1805bfb74):

0x1805bfbf5: mov  r15, qword ptr [rbp - 0x10] ; Window array from SnapAssistSnappedWindows
0x1805bfc10: mov  ebx, dword ptr [r14]        ; Current HWND in group
0x1805bfc35: call qword ptr [rax + 0x30]      ; GetViewForHwnd
0x1805bfc71: mov  rax, qword ptr [rax + 0x38] ; Vtable slot 7: PinView
0x1805bfc75: call rax                         ; PinView invoked on each window view
0x1805bfc84: add  r14, 4                      ; Loop across all windows in group

Task View resolves this by enumerating the application's active window handles and invoking PinView individually on each view.

3. Parity in PR #59

This PR mirrors the native Task View execution path:

  1. PinAppID(base_app_id) registers the canonical package identity persistently without transient HWND tokens.
  2. view.pin() (PinView) is called on active sub-views sharing that base ID.
  3. sync_pinned_apps() synchronizes newly opened secondary windows during desktop switches, bridging the gap left by CompareStringOrdinal in ViewAddedInternal.

Detailed disassembly notes and vtable mappings are documented here:
https://github.com/amirf147/caster-user-directory-and-notes/blob/master/docs/pyvda/005_task_view_pinning_internals_and_shell_reverse_engineering.md

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant