Compare commits

...

24 Commits

Author SHA1 Message Date
Niels Laute
cad023a34d Host launcher status content in a Page with WindowEx, Mica, and modern titlebar
Move the WorkspacesLauncherUI status window content into StatusPage so it
can use x:Bind, and host it inside a WinUIEx WindowEx with a Mica backdrop
and a modern custom TitleBar. Swap the Dismiss/Cancel button order.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-07-03 15:10:37 +02:00
chatasweetie
d25bf607bc Fix XAML attribute ordering for CI XamlStyler check 2026-07-02 10:55:33 -07:00
chatasweetie
c31800bddf Remove redundant Foreground on app name TextBlock 2026-07-02 10:19:04 -07:00
chatasweetie
50f7e96825 Use multiples of 4 for margins in StatusWindow.xaml 2026-07-02 10:17:27 -07:00
chatasweetie
e232616ad3 Remove redundant SymbolThemeFontFamily 2026-07-02 10:15:10 -07:00
chatasweetie
b0cccf87bd Merge remote-tracking branch 'origin/main' into workspaces-launcher-ui-migration 2026-07-02 10:05:46 -07:00
chatasweetie
ab96a61aa3 Use SwitchPresenter for launcher status indicators 2026-07-02 10:00:06 -07:00
chatasweetie
0106c0ba59 Replace custom bool-to-visibility converters with CommunityToolkit.WinUI.Converters 2026-07-02 09:52:31 -07:00
Clint Rutkas
de4859454c [PowerToys] Guard TitleBar windows against an empty window title (startup fault) (#49069)
## Summary

Guard PowerToys' WinUI windows against an empty native window title, so
the WinUI `TitleBar` control can't read an empty title during startup
and fault the process. This fixes a class of bugs like
https://github.com/microsoft/PowerToys/issues/48547

## Background

Spotted while reading through the Environment Variables `MainWindow`
startup path. The WinUI `TitleBar` control (used with
`ExtendsContentIntoTitleBar`) reads the owning window's
`AppWindow.Title` during a deferred layout pass (`OnApplyTemplate` →
`UpdateTitle`). When the native window title is empty at that instant,
the windowing layer can fault while resolving the title and terminate
the process during startup.

The native title ends up empty in two ways:
1. The title is computed from `ResourceLoader.GetString(...)`, which
returns an **empty string** (it doesn't throw) when the resource map
can't be resolved at runtime.
2. The window sets `AppWindow.Title` only *later*, not before the title
bar's first layout.

## Windows fixed

Every PowerToys window that hosts the `TitleBar` control:

| Window | Fix |
|---|---|
| Environment Variables | Non-empty fallback for the resource-based
title |
| Hosts | Non-empty fallback for the resource-based title |
| File Locksmith | Non-empty fallback for the resource-based title |
| Shortcut Guide | Non-empty fallback for the resource-based title |
| Settings — shortcut-conflict window | Non-empty fallback for the
resource-based title |
| Registry Preview | Set `AppWindow.Title` to the app name in the
constructor (previously only set later in `UpdateWindowTitle`) |
| Keyboard Manager Editor | No change — already sets a hardcoded
non-empty `Title` |

## Risk

Very low. The only behavior change is that a previously-empty title
becomes a non-empty fallback; the normal (resource-resolved) paths are
unchanged.

## Validation

Each affected project builds clean (`x64 | Release`):
EnvironmentVariables, Hosts, FileLocksmithUI, ShortcutGuide.Ui,
RegistryPreview, PowerToys.Settings.

## Related

Root cause write-up (windowing/WinUI side):
microsoft/microsoft-ui-xaml#11214.

---

ADO:
https://microsoft.visualstudio.com/DefaultCollection/OS/_workitems/edit/62685601/

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 14:12:52 +02:00
Niels Laute
93669df118 TransparentWindow: opt-in Esc / focus-lost dismiss + multi-surface docs (#48950)
## Summary

Hardens the shared `TransparentWindow` (in
`src/common/Common.UI.Controls/`) with two **opt-in** dismissal
behaviors and documents multi-surface hosting. No consumer changes;
everything defaults off.

### 1. `DismissOnEscape` (default `false`)
Pressing <kbd>Esc</kbd> while the window content has keyboard focus
calls `Hide()`. Input is hooked lazily at show time because `Content` is
assigned by the consumer after construction.

### 2. `DismissOnFocusLost` (default `false`)
Light dismiss: hides when the window is deactivated. Guarded by a "seen
activated" flag (reset on each `Show()`) so the transient deactivation
that can occur during the show sequence doesn't dismiss prematurely.
This mirrors the guards that **PowerDisplay** (`_isShowingWindow`) and
**Quick Access** (`_hasSeenInteractiveActivation`) currently hand-roll —
they can drop their bespoke logic once they derive from
`TransparentWindow`.

> Both properties are no-ops unless the consumer activates the window
(it shows no-activate / `SW_SHOWNA`).

### 3. Multi-surface hosting (docs only — already works)
Added class `<remarks>` documenting that multiple `TransientSurface`s
can `SubscribeTo` one window: `HidingEventArgs` aggregates deferrals so
the window hides only after **all** surfaces finish animating out, and
plain `Show()` lets each surface play its own configured transition.

## Why no DWM "full-bleed" hardening here
The extra DWM hardening Shortcut Guide uses (NCRENDERING disabled,
`DwmExtendFrameIntoClientArea(-1)`, etc.) is only needed for
**full-monitor, edge-to-edge** overlays. Content-sized surfaces (Quick
Accent, CmdPal Toast) inset their acrylic card behind transparent
padding, so any phantom border sits in the transparent margin and is
invisible. Applying it universally would add compositing risk for zero
benefit, so it's deferred to the Shortcut Guide refactor (where it's
actually exercised) as an opt-in.

## Testing
- `Common.UI.Controls` builds clean (Debug/x64).
- Behavior is opt-in and off by default, so existing consumers are
unaffected.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 18:58:43 +08:00
Noraa Junker
56fabda79c [Chore] Remove outdated clean up tool (#48992)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request

Remove outdated clean up tool and script

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #48991
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [x] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx
2026-07-02 12:52:27 +02:00
Clint Rutkas
70555459ab [AlwaysOnTop] Guard m_frameDrawer in WindowBorder::UpdateBorderPosition (#48412)
## Summary

`WindowBorder::UpdateBorderPosition()` dereferences `m_frameDrawer`
without a null check when `GetFrameRect` fails on the tracked window:

```cpp
auto rectOpt = GetFrameRect(m_trackingWindow);
if (!rectOpt.has_value())
{
    m_frameDrawer->Hide();   // <-- AV if m_frameDrawer == nullptr
    return;
}
```

The sibling routine `WindowBorder::UpdateBorderProperties()` already
guards both `m_trackingWindow` and `m_frameDrawer` before doing any
work:

```cpp
if (!m_trackingWindow || !m_frameDrawer)
{
    return;
}
```

`UpdateBorderPosition` was the only call site that didn't match that
pattern. This PR brings it in line, and also adds a guard for `m_window`
for symmetry (the subsequent `SetWindowPos(m_window, ...)` would
otherwise fail silently with `ERROR_INVALID_WINDOW_HANDLE` but it's
cleaner to early-return).

## Why `m_frameDrawer` can be null when `UpdateBorderPosition` runs

`WindowBorder` registers itself as a `SettingsObserver` in its
base-class constructor — *before* `Init()` finishes. `Init()` is what
actually allocates `m_frameDrawer` (via `FrameDrawer::Create`). So
between those two steps there is a window where the object is alive and
observable but `m_frameDrawer == nullptr`.

The destructor has a similar shape:

```cpp
WindowBorder::~WindowBorder()
{
    if (m_frameDrawer)
    {
        m_frameDrawer->Hide();
        m_frameDrawer = nullptr;
    }

    if (m_window)
    {
        SetWindowLongPtrW(m_window, GWLP_USERDATA, 0);
        DestroyWindow(m_window);
    }
}
```

`m_frameDrawer` is nulled before `GWLP_USERDATA` is cleared and before
`DestroyWindow` is called. Any `WM_TIMER` that fires through `s_WndProc`
in that window dispatches to `WndProc → UpdateBorderPosition()` on an
instance whose `m_frameDrawer` has already been released — same null
deref, same access violation.

`UpdateBorderPosition` is also invoked from
`EVENT_OBJECT_LOCATIONCHANGE` / `EVENT_SYSTEM_MOVESIZEEND` in
`AlwaysOnTop.cpp` and from `WM_TIMER` in `WndProc`; both run on the
message-loop thread and either path can land here while the object is in
a transient half-constructed or half-destructed state.

## Change

Add `!m_frameDrawer` (and `!m_window`) to the early-return guard at the
top of `UpdateBorderPosition`. Three-line patch.

## How this was found

While reviewing the `WindowBorder` lifecycle for an unrelated
AlwaysOnTop tweak, the asymmetry between `UpdateBorderPosition` and
`UpdateBorderProperties` jumped out — the former dereferences
`m_frameDrawer` without the null check the latter performs.

## Tests

`src/modules/alwaysontop` has no test project today — `WindowBorder` is
tightly bound to Win32 (`HWND`, `DwmGetWindowAttribute`, `SetWindowPos`)
and `FrameDrawer` (Direct2D / D3D), with no abstraction layer to mock.
Adding meaningful native unit tests here would require a substantial
refactor that's well out of scope for this fix.

Manual validation:
- Build clean: `MSBuild
src\modules\alwaysontop\AlwaysOnTop\AlwaysOnTop.vcxproj
/p:Configuration=Release /p:Platform=x64` produces
`PowerToys.AlwaysOnTop.exe` with no warnings.
- Behavioural: with the guard added, the early-return path for "tracked
window has no valid extended frame bounds" simply skips the `Hide()`
call instead of crashing — same end-state for the user (border position
is left as-is until the next timer tick recovers it).

If we want a runtime regression net here, the right level is a UI /
lifecycle test that creates and pins/unpins windows under churn; that's
a separate piece of work and not gated on this fix.

## Risk

Three guarded conditions on a path that was already guarding one of
them. The behavioural delta only fires when the dereference *would* have
crashed — anywhere else the function is unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 15:08:10 +08:00
moooyo
d6319516d0 [Skills] Fix wpf-to-winui3-migration SKILL.md failing to load (#49059)
## Summary of the Pull Request

The `wpf-to-winui3-migration` agent skill failed to load. The
`description` field in its `SKILL.md` YAML frontmatter was an
**unquoted** plain scalar containing `Keywords: ` (a colon followed by a
space). YAML interprets `: ` as a mapping key/value separator, so the
skill loader failed with:

> failed to parse YAML frontmatter: mapping values are not allowed in
this context at line 2 column 651

Because `.claude/skills` is a symlink to `.github/skills`, the CLI
enumerates the same file twice, so this single defect surfaced as
**two** skill load errors (`skill_error_count: 2`).

Fix: wrap the `description` value in single quotes so the colon is
treated literally. No wording changes; the description stays 926
characters (well under the 1024 limit).

## PR Checklist

- [ ] Closes: #xxx — N/A, trivial metadata fix, no tracking issue
- [x] **Communication:** Metadata-only fix; no design discussion needed
- [ ] **Tests:** N/A — no test harness for skill frontmatter; validated
by YAML parsing (see below)
- [x] **Localization:** N/A — not end-user-facing
- [ ] **Dev docs:** N/A
- [ ] **New binaries:** N/A

## Detailed Description of the Pull Request / Additional comments

`.github/skills/wpf-to-winui3-migration/SKILL.md` line 3 changed from:

```yaml
description: Guide for migrating ... after migration. Keywords: WPF, WinUI, ... SoftwareBitmap.
```

to:

```yaml
description: 'Guide for migrating ... after migration. Keywords: WPF, WinUI, ... SoftwareBitmap.'
```

The three other top-level skills already quote (or avoid `: ` in) their
descriptions, so only this one was affected. Single quotes are used
because the description contains no quote characters, so no escaping is
required.

## Validation Steps Performed

- Reproduced the loader error from the Copilot CLI logs: `mapping values
are not allowed in this context at line 2 column 651`.
- Parsed the frontmatter of all 9 `SKILL.md` files with a YAML parser:
before = 1 failure (this file), after = **0 failures**.
- Confirmed parsed `name` (`wpf-to-winui3-migration`), `description`
(926 chars, ≤ 1024), and `license` are intact and the literal `Keywords:
WPF...` text is preserved.

Co-authored-by: Yu Leng (from Dev Box) <yuleng@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 10:48:23 +08:00
Alex Mihaiuc
53737cbe31 ZoomIt add snip and panorama save hotkeys (#49075)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request

- Fixes #45808 — ZoomIt's "Snip Save" hotkey was auto-derived by XOR'ing
the Shift modifier from the Snip hotkey, causing `Ctrl+S` to be stolen
when Snip was set to `Ctrl+Shift+S`
- The Snip Save hotkey is now a separate, independently configurable
setting (default: `Ctrl+Shift+6`, complementing the default Snip hotkey
`Ctrl+6`)
- Updated both the PowerToys Settings UI and the standalone ZoomIt
options dialog
- The same is applied for the scrolling screenshot keys

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #45808
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [x] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [x] ~~**Tests:** Added/updated and all pass~~ N/A (no ZoomIt tests in
repo)
- [x] **Localization:** All end-user-facing strings can be localized
- [x] ~~**Dev docs:** Added/updated~~ N/A in this case I think
- [x] ~~**New binaries:** Added on the required places~~ N/A
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

ZoomIt registered two hotkeys for Snip — the primary (`SNIP_HOTKEY`) and
a "save to file" variant (`SNIP_SAVE_HOTKEY`) derived by toggling the
Shift modifier via XOR:

```cpp
RegisterHotKey(hWnd, SNIP_SAVE_HOTKEY, (g_SnipToggleMod ^ MOD_SHIFT), g_SnipToggleKey & 0xFF);
```

This worked fine for the default `Ctrl+6` (save became `Ctrl+Shift+6`),
but when a user configured `Ctrl+Shift+S` as their Snip hotkey, the XOR
removed Shift, making the save variant `Ctrl+S` — stealing a ubiquitous
shortcut from every other application.

## Changes

### C++ Backend (ZoomIt core)

- **`resource.h`** — Added `IDC_SNIP_SAVE_HOTKEY` control ID for the new
dialog control
- **`ZoomItSettings.h`** — Added `g_SnipSaveToggleKey` global variable
(default: `Ctrl+Shift+6`) and its `RegSettings[]` entry for registry
persistence
- **`ZoomIt.rc`** — Added a second hotkey control ("Snip Save Toggle")
to the standalone SNIP options dialog
- **`Zoomit.cpp`** — Added `g_SnipSaveToggleMod` global; replaced all 4
XOR-derived `RegisterHotKey` calls with the new explicit setting;
updated the options dialog init, read, validation, and save logic

### Settings Interop

- **`ZoomItSettings.cpp`** — Added `SnipSaveToggleKey` to the
`settings_with_special_semantics` map so it serializes as a hotkey JSON
object

### C# Settings UI

- **`ZoomItProperties.cs`** — Added `DefaultSnipSaveToggleKey` and
`SnipSaveToggleKey` property
- **`ZoomItViewModel.cs`** — Replaced the computed XOR-derived read-only
getter with a full get/set property backed by the new
`SnipSaveToggleKey` setting
- **`ZoomItPage.xaml`** — Replaced the read-only markdown description
with an editable `ShortcutControl` for the save hotkey
- **`Resources.resw`** — Added "Save snip to file" header string;
removed the old templated description


<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed

Built and tested locally --

 App shows additional shortcut for ZoomIt

<img width="983" height="188" alt="image"
src="https://github.com/user-attachments/assets/f73c1ecc-3aee-4dee-bfbb-b95764e7eb1c"
/>

 I am able to save files via `CTRL + S` as normal

 I am able to use Snip activation with `CTRL + Shift + S`

 I am able to use Save snip to file with the assigned shortcut of `CTRL
+ Shift + 6`

 Other ZoomIt commands run as expected (e.g. LiveZoom with my mapping
of `ALT + 4`

---------

Co-authored-by: Sean Killeen <SeanKilleen@gmail.com>
2026-07-02 01:49:21 +02:00
chatasweetie
f6a81a4235 refactor Launcher UI to use CommunityToolkit.Mvvm patterns 2026-07-01 15:50:55 -07:00
Dave Rayment
bf6ff579d3 [ZoomIt] Fix issue with recording filename suffixes (#43236)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request
Fixes an issue where ZoomIt would always remove a numeric suffix from a
suggested recording filename even when it was part of a user-chosen
name.

Also: appends a timestamp instead of a numeric suffix for the default
filename, improving consistency with other tools and allowing for
correct name ordering in Explorer views.

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #43202
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments
The `GetUniqueRecordingFilename()` function used regex to strip numeric
suffixes, and incorrectly assumed that all `(N)` patterns were
ZoomIt-generated. This broke user-provided filenames like "My
Presentation (2025).mp4".

### Root cause
```cpp
    // Chop off index if it's there
    auto base = std::regex_replace( path.stem().wstring(), std::wregex( L" [(][0-9]+[)]$" ), L"" );
    path.replace_filename( base + path.extension().wstring() );
```

This code strips off _any_ numeric suffix.

### Solution
The proposed solution tracks the user's chosen filename separately,
using it as the base for the file renaming strategy. The new string
`g_RecordingSaveBaseFilename` allows for additive suffix generation
without using regex stripping.

This change also allows us to remove **regex.h** as a dependency,
reducing the application's file size.

The code retains the addition of numeric suffixes for user-chosen
filenames, but timestamp suffixes are now added when the filename is the
default `Recording.mp4`; this is more consistent with tools like Windows
Snipping Tool, and allows for correct ordering of files in Windows
Explorer (previously, `Recording (11).mp4` would be sorted before
`Recording (2).mp4`, for example).

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed

### Test Scenario 1: Default filename with timestamp suffix
1. Launch ZoomIt, start first recording with
<kbd>Ctrl</kbd>+<kbd>5</kbd>.
2. Stop the recording. The Save dialog shows "Recording.mp4".
3. Select **Save** to Accept this default. The file is saved as
"Recording 2025-11-03 180719.mp4" or similar.
4. Start and stop another recording.
5. The Save dialog shows "Recording 2025-11-03 180800.mp4" or similar,
i.e. with a distinct timestamp from the last save. Accept this
suggestion by selecting **Save**.
6. Verify both files exist on disk. By default, this will be in your
**Videos** folder.

### Test Scenario 2: Custom filename (ascending numeric suffix)
1. Launch ZoomIt, and start recording.
2. Stop the recording and change the suggested filename to "My
Presentation.mp4". Select **Save** to save the file.
3. Start and stop a second recording.
4. Confirm the dialog suggests "My Presentation (1).mp4" as the
filename. Select **Save** to accept this filename.
5. Start and stop a third recording.
6. Confirm the dialog suggests "My Presentation (2).mp4" as the
filename. Select **Save** to accept the suggestion.
7. Verify all files exist on disk with the correct names.

### Test Scenario 3: User filename with parenthetical number (bug repro)
1. Launch ZoomIt and start recording.
2. Stop the recording and change the suggested filename to "My
Presentation (2025).mp4". Select **Save** to save the recording to disk.
3. Start and stop a second recording.
4. Confirm the dialog suggests "My Presentation (2025) (1).mp4" as the
filename. (This was broken before - the prior version would suggest "My
Presentation.mp4".)
5. Accept the suggestion by selecting **Save**.
6. Start and stop a third recording.
7. Confirm the save dialog suggests "My Presentation (2025) (2).mp4".
8. Verify all files exist on disk with the correct names.

### Test Scenario 4: User modifies suggested filename
1. Save first recording as "Test.mp4".
2. Start and stop a second recording.
3. Confirm the second recording has a suggested filename of "Test
(1).mp4".
4. Change the suggested filename to "TestFinal.mp4" before saving.
5. Start and stop a third recording.
6. Confirm the save dialog suggests "TestFinal (1).mp4". (Verifies
correct updating of the base filename.)

### Test Scenario 5: Existing files at the save location
1. Manually create "Video.mp4" and "Video (1).mp4" in the Videos folder.
2. Start ZoomIt and save a recording to the same folder as "Video.mp4".
Confirm you are asked to overwrite the existing file.
3. Select "Yes" and save the file.
4. Start and stop another recording.
7. Confirm that the Save dialog's suggested filename is "Video (2).mp4",
skipping the manually-added file.

## Code updates

### Additional clean-up

- Clarified path construction in recording initialisation block
(replaced `/=` with explicit path building for a more readable
approach).
- Changed `DEFAULT_RECORDING_FILE` from a `#define` to a `constexpr`
instead. The new base filename variable is based upon it.

Co-authored-by: Niels Laute <niels.laute@live.nl>
2026-07-01 23:29:51 +02:00
chatasweetie
f94c439a8d add app icons, fix PropertyChanged/brush caching, wire up App.Dispose, and use ThemeResource 2026-06-22 10:43:38 -07:00
chatasweetie
a6f4357c94 Fix XAML formatting in StatusWindow.xaml for CI XamlStyler check 2026-06-17 12:43:06 -07:00
chatasweetie
6961fc66d0 Remove WPF launcher project, update tests and integration paths to WinUI 3 2026-06-17 11:22:21 -07:00
chatasweetie
8cd88f9817 removed WorkspacesLauncherUI, updated test project refereneces, update installer and reran test, with 129/129 passing 2026-06-16 15:51:28 -07:00
chatasweetie
57bdc9da6e add accessibility & theme support 2026-06-16 15:07:51 -07:00
chatasweetie
81ee6b6efd UI migration (controls, bindings, styles), Unit tests: 129/129 passing, visual launch: window renders with correct layout, centered, non-resizable, always-on-top 2026-06-16 14:41:46 -07:00
chatasweetie
ed1570a0e3 project foundation and window shell 2026-06-16 14:14:15 -07:00
chatasweetie
b364da81e8 Add baseline unit tests for Workspaces Launcher UI (WPF to WinUI migration). These tests establish behavioral parity requirements for the WinUI migration. All 129 tests passing on current WPF implementation. 2026-06-16 13:41:39 -07:00
76 changed files with 3241 additions and 1360 deletions

View File

@@ -1,6 +1,6 @@
---
name: wpf-to-winui3-migration
description: Guide for migrating PowerToys modules from WPF to WinUI 3 (Windows App SDK). Use when asked to migrate WPF code, convert WPF XAML to WinUI, replace System.Windows namespaces with Microsoft.UI.Xaml, update Dispatcher to DispatcherQueue, replace DynamicResource with ThemeResource, migrate imaging APIs from System.Windows.Media.Imaging to Windows.Graphics.Imaging, convert WPF Window to WinUI Window, migrate .resx to .resw resources, migrate custom Observable/RelayCommand to CommunityToolkit.Mvvm source generators, handle WPF-UI (Lepo) to WinUI native control migration, or fix installer/build pipeline issues after migration. Keywords: WPF, WinUI, WinUI3, migration, porting, convert, namespace, XAML, Dispatcher, DispatcherQueue, imaging, BitmapImage, Window, ContentDialog, ThemeResource, DynamicResource, ResourceLoader, resw, resx, CommunityToolkit, ObservableProperty, WPF-UI, SizeToContent, AppWindow, SoftwareBitmap.
description: 'Guide for migrating PowerToys modules from WPF to WinUI 3 (Windows App SDK). Use when asked to migrate WPF code, convert WPF XAML to WinUI, replace System.Windows namespaces with Microsoft.UI.Xaml, update Dispatcher to DispatcherQueue, replace DynamicResource with ThemeResource, migrate imaging APIs from System.Windows.Media.Imaging to Windows.Graphics.Imaging, convert WPF Window to WinUI Window, migrate .resx to .resw resources, migrate custom Observable/RelayCommand to CommunityToolkit.Mvvm source generators, handle WPF-UI (Lepo) to WinUI native control migration, or fix installer/build pipeline issues after migration. Keywords: WPF, WinUI, WinUI3, migration, porting, convert, namespace, XAML, Dispatcher, DispatcherQueue, imaging, BitmapImage, Window, ContentDialog, ThemeResource, DynamicResource, ResourceLoader, resw, resx, CommunityToolkit, ObservableProperty, WPF-UI, SizeToContent, AppWindow, SoftwareBitmap.'
license: Complete terms in LICENSE.txt
---

View File

@@ -234,8 +234,8 @@
"PowerToys.WorkspacesWindowArranger.exe",
"PowerToys.WorkspacesEditor.exe",
"PowerToys.WorkspacesEditor.dll",
"PowerToys.WorkspacesLauncherUI.exe",
"PowerToys.WorkspacesLauncherUI.dll",
"WinUI3Apps\\PowerToys.WorkspacesLauncherUI.exe",
"WinUI3Apps\\PowerToys.WorkspacesLauncherUI.dll",
"PowerToys.WorkspacesModuleInterface.dll",
"PowerToys.WorkspacesCsharpLibrary.dll",

View File

@@ -1027,7 +1027,7 @@
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/Workspaces/WorkspacesLauncher/WorkspacesLauncher.vcxproj" Id="2cac093e-5fcf-4102-9c2c-ac7dd5d9eb96" />
<Project Path="src/modules/Workspaces/WorkspacesLauncherUI/WorkspacesLauncherUI.csproj">
<Project Path="src/modules/Workspaces/WorkspacesLauncherUI.WinUI/WorkspacesLauncherUI.WinUI.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
@@ -1110,6 +1110,14 @@
<File Path="src/Solution.props" />
<File Path="src/Version.props" />
</Folder>
<Folder Name="/src/" />
<Folder Name="/src/modules/" />
<Folder Name="/src/modules/Workspaces/">
<Project Path="src/modules/Workspaces/WorkspacesLauncherUI.UnitTests/WorkspacesLauncherUI.UnitTests.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
</Folder>
<Project Path="src/ActionRunner/ActionRunner.vcxproj" Id="d29ddd63-e2cf-4657-9fd5-2aede4257e5d">
<BuildDependency Project="src/common/updating/updating.vcxproj" />
</Project>

View File

@@ -1,5 +0,0 @@
# [CleanUp_tool](/tools/CleanUp_tool/) and [CleanUp_tool_powershell_script](/tools/CleanUp_tool_powershell_script/CleanUp_tool.ps1)
This tool, respective this powershell script, is used to clean up the PowerToys installation. It cleans the `AppData` folder and the registry.
This tool is currently very outdated and just cleans up the registry keys of some few modules.

View File

@@ -10,7 +10,6 @@ Following tools are currently available:
* [BugReportTool](bug-report-tool.md) - A tool to collect logs and system information for bug reports.
* [Build tools](build-tools.md) - A set of scripts that help building PowerToys.
* [Clean up tool](clean-up-tool.md) - A tool to clean up the PowerToys installation.
* [Monitor info report](monitor-info-report.md) - A small diagnostic tool which helps identifying WinAPI bugs related to the physical monitor detection.
* [project template](/tools/project_template/README.md) - A Visual Studio project template for a new PowerToys project.
* [StylesReportTool](styles-report-tool.md) - A tool to collect information about an open window.

View File

@@ -5,6 +5,8 @@
using System.Runtime.InteropServices;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Input;
using Windows.Foundation;
using WinUIEx;
@@ -37,6 +39,18 @@ namespace Microsoft.PowerToys.Common.UI.Controls.Window;
/// <see cref="Microsoft.UI.Windowing.AppWindow.Hide"/> is delayed until the
/// content has finished animating out. With no listener the window simply shows
/// or hides immediately.</para>
/// <para><b>Multiple surfaces.</b> More than one <see cref="TransientSurface"/>
/// may host on the same window by each calling
/// <see cref="TransientSurface.SubscribeTo"/>. The <see cref="Showing"/> and
/// <see cref="Hiding"/> events are simply raised for every subscriber, and
/// because <see cref="HidingEventArgs"/> aggregates deferrals the underlying
/// window is hidden only after <em>all</em> surfaces have finished animating
/// out. To let each surface play its own distinct transition, call the
/// parameterless <see cref="Show()"/> (so every surface uses its configured
/// <c>ShowTransition</c>/<c>HideTransition</c>); the <see cref="Show(Transition)"/>
/// overload instead broadcasts a single transition to all surfaces. Sizing the
/// window and positioning each surface within it remain the consumer's
/// responsibility (this window owns no layout).</para>
/// </remarks>
public partial class TransparentWindow : WinUIEx.WindowEx
{
@@ -52,6 +66,9 @@ public partial class TransparentWindow : WinUIEx.WindowEx
private readonly nint _hwnd;
private bool _inputHooked;
private bool _seenActivated;
public TransparentWindow()
{
AppWindow.Hide();
@@ -74,8 +91,30 @@ public partial class TransparentWindow : WinUIEx.WindowEx
ApplyExStyleBit(WsExToolWindow, true);
SystemBackdrop = new TransparentTintBackdrop();
Activated += OnActivatedForDismiss;
}
/// <summary>
/// Gets or sets a value indicating whether pressing <c>Esc</c> while the
/// window content has keyboard focus dismisses the window (<see cref="Hide"/>).
/// Defaults to <see langword="false"/>. The window is shown without
/// activation, so the consumer must activate it for its content to receive
/// keyboard input.
/// </summary>
public bool DismissOnEscape { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the window dismisses itself
/// (<see cref="Hide"/>) when it loses focus (is deactivated), i.e. light
/// dismiss. Defaults to <see langword="false"/>. Only takes effect after the
/// window has been activated at least once since the last <see cref="Show()"/>,
/// so the transient deactivation that can occur during the show sequence does
/// not dismiss it prematurely. The window is shown without activation, so the
/// consumer must activate it for this to apply.
/// </summary>
public bool DismissOnFocusLost { get; set; }
/// <summary>
/// Raised (without activation) when <see cref="Show()"/> makes the window
/// visible. A content surface subscribes to this to play its in-animation,
@@ -112,6 +151,8 @@ public partial class TransparentWindow : WinUIEx.WindowEx
DispatcherQueuePriority.Low,
() =>
{
_seenActivated = false;
EnsureInputHooks();
_ = ShowWindow(_hwnd, SwShowNa);
Showing?.Invoke(this, new ShowingEventArgs(transition));
});
@@ -134,6 +175,41 @@ public partial class TransparentWindow : WinUIEx.WindowEx
});
}
private void OnActivatedForDismiss(object sender, WindowActivatedEventArgs args)
{
if (args.WindowActivationState == WindowActivationState.Deactivated)
{
if (DismissOnFocusLost && _seenActivated)
{
Hide();
}
return;
}
_seenActivated = true;
}
private void EnsureInputHooks()
{
if (_inputHooked || Content is not UIElement element)
{
return;
}
element.KeyDown += OnContentKeyDown;
_inputHooked = true;
}
private void OnContentKeyDown(object sender, KeyRoutedEventArgs e)
{
if (DismissOnEscape && e.Key == global::Windows.System.VirtualKey.Escape)
{
e.Handled = true;
Hide();
}
}
private void ApplyExStyleBit(int bit, bool set)
{
if (_hwnd == 0)

View File

@@ -32,6 +32,17 @@ namespace EnvironmentVariables
var loader = ResourceLoaderInstance.ResourceLoader;
var title = App.GetService<IElevationHelper>().IsElevated ? loader.GetString("WindowAdminTitle") : loader.GetString("WindowTitle");
// The WinUI TitleBar control reads the owning window's title (AppWindow.Title) during a
// deferred layout pass. If the native window title is empty at that instant, the windowing
// layer can fault while resolving it and terminate the process. ResourceLoader.GetString
// returns an empty string when the resource map can't be resolved at runtime, which would
// leave the title empty here, so fall back to a non-empty product name to keep the native
// window title populated.
if (string.IsNullOrEmpty(title))
{
title = "Environment Variables";
}
Title = title;
titleBar.Title = title;

View File

@@ -25,6 +25,15 @@ namespace FileLocksmithUI
var loader = ResourceLoaderInstance.ResourceLoader;
var title = isElevated ? loader.GetString("AppAdminTitle") : loader.GetString("AppTitle");
// Guard against an empty title: ResourceLoader.GetString returns "" when the resource
// map can't be resolved, and an empty native window title can fault the WinUI TitleBar
// control while it reads AppWindow.Title during a deferred layout pass.
if (string.IsNullOrEmpty(title))
{
title = "File Locksmith";
}
Title = title;
titleBar.Title = title;
}

View File

@@ -33,6 +33,15 @@ namespace Hosts
var loader = new ResourceLoader("PowerToys.HostsUILib.pri", "PowerToys.HostsUILib/Resources");
var title = Host.GetService<IElevationHelper>().IsElevated ? loader.GetString("WindowAdminTitle") : loader.GetString("WindowTitle");
// Guard against an empty title: ResourceLoader.GetString returns "" when the resource
// map can't be resolved, and an empty native window title can fault the WinUI TitleBar
// control while it reads AppWindow.Title during a deferred layout pass.
if (string.IsNullOrEmpty(title))
{
title = "Hosts File Editor";
}
Title = title;
titleBar.Title = title;

View File

@@ -57,7 +57,17 @@ namespace ShortcutGuide
return _currentApplicationIds;
});
Title = ResourceLoaderInstance.ResourceLoader.GetString("Title")!;
var title = ResourceLoaderInstance.ResourceLoader.GetString("Title")!;
// Guard against an empty title: ResourceLoader.GetString returns "" when the resource
// map can't be resolved, and an empty native window title can fault the WinUI TitleBar
// control while it reads AppWindow.Title during a deferred layout pass.
if (string.IsNullOrEmpty(title))
{
title = "Shortcut Guide";
}
Title = title;
ExtendsContentIntoTitleBar = true;
#if !DEBUG

View File

@@ -46,7 +46,7 @@ void LauncherUIHelper::LaunchUI()
GetModuleFileName(NULL, buffer, MAX_PATH);
std::wstring path = std::filesystem::path(buffer).parent_path();
auto res = AppLauncher::LaunchApp(path + L"\\PowerToys.WorkspacesLauncherUI.exe", L"", false);
auto res = AppLauncher::LaunchApp(path + L"\\WinUI3Apps\\PowerToys.WorkspacesLauncherUI.exe", L"", false);
if (res.isOk())
{
auto value = res.value();

View File

@@ -0,0 +1,235 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.VisualStudio.TestTools.UnitTesting;
using WorkspacesLauncherUI.Data;
namespace WorkspacesLauncherUI.UnitTests
{
/// <summary>
/// Tests for ApplicationWrapper struct field mapping.
/// All fields must be accessible and hold correct values after deserialization.
/// </summary>
[TestClass]
public class ApplicationDataModelTests
{
[TestMethod]
[TestCategory("DataModel")]
public void AppField_ApplicationName_StoresDisplayName()
{
var app = new ApplicationWrapper { Application = "Visual Studio Code" };
Assert.AreEqual("Visual Studio Code", app.Application);
}
[TestMethod]
[TestCategory("DataModel")]
public void AppField_ExecutablePath_StoresFullPathWithSpaces()
{
var app = new ApplicationWrapper { ApplicationPath = @"C:\Users\test\AppData\Local\Programs\Microsoft VS Code\Code.exe" };
Assert.AreEqual(@"C:\Users\test\AppData\Local\Programs\Microsoft VS Code\Code.exe", app.ApplicationPath);
}
[TestMethod]
[TestCategory("DataModel")]
public void AppField_WindowTitle_StoresActiveWindowTitle()
{
var app = new ApplicationWrapper { Title = "MyProject - Visual Studio Code" };
Assert.AreEqual("MyProject - Visual Studio Code", app.Title);
}
[TestMethod]
[TestCategory("DataModel")]
public void AppField_PackageFullName_StoresUwpPackageIdentifier()
{
var app = new ApplicationWrapper { PackageFullName = "Microsoft.WindowsTerminal_1.21.0.0_x64__8wekyb3d8bbwe" };
Assert.AreEqual("Microsoft.WindowsTerminal_1.21.0.0_x64__8wekyb3d8bbwe", app.PackageFullName);
}
[TestMethod]
[TestCategory("DataModel")]
public void AppField_AppUserModelId_StoresAumidForPackagedApps()
{
var app = new ApplicationWrapper { AppUserModelId = "Microsoft.WindowsTerminal_8wekyb3d8bbwe!App" };
Assert.AreEqual("Microsoft.WindowsTerminal_8wekyb3d8bbwe!App", app.AppUserModelId);
}
[TestMethod]
[TestCategory("DataModel")]
public void AppField_PwaAppId_StoresChromeOrEdgePwaIdentifier()
{
var app = new ApplicationWrapper { PwaAppId = "fmgjjmmmlfnkbppncijlocphclkkleod" };
Assert.AreEqual("fmgjjmmmlfnkbppncijlocphclkkleod", app.PwaAppId);
}
[TestMethod]
[TestCategory("DataModel")]
public void AppField_CliArguments_StoresLaunchArgumentsExactly()
{
var app = new ApplicationWrapper { CommandLineArguments = "--reuse-window --goto file.ts:42" };
Assert.AreEqual("--reuse-window --goto file.ts:42", app.CommandLineArguments);
}
[TestMethod]
[TestCategory("DataModel")]
public void AppField_IsElevated_StoresAdminRunningState()
{
var app = new ApplicationWrapper { IsElevated = true };
Assert.IsTrue(app.IsElevated);
}
[TestMethod]
[TestCategory("DataModel")]
public void AppField_CanLaunchElevated_StoresElevationCapability()
{
var app = new ApplicationWrapper { CanLaunchElevated = true };
Assert.IsTrue(app.CanLaunchElevated);
}
[TestMethod]
[TestCategory("DataModel")]
public void AppField_Minimized_StoresMinimizedWindowState()
{
var app = new ApplicationWrapper { Minimized = true };
Assert.IsTrue(app.Minimized);
}
[TestMethod]
[TestCategory("DataModel")]
public void AppField_Maximized_StoresMaximizedWindowState()
{
var app = new ApplicationWrapper { Maximized = true };
Assert.IsTrue(app.Maximized);
}
[TestMethod]
[TestCategory("DataModel")]
public void AppField_MonitorIndex_StoresTargetDisplayNumber()
{
var app = new ApplicationWrapper { Monitor = 2 };
Assert.AreEqual(2, app.Monitor);
}
[TestMethod]
[TestCategory("DataModel")]
public void AppField_WindowPosition_StoresRectangleCoordinates()
{
var pos = new PositionWrapper { X = 100, Y = 200, Width = 800, Height = 600 };
var app = new ApplicationWrapper { Position = pos };
Assert.AreEqual(100, app.Position.X);
Assert.AreEqual(200, app.Position.Y);
Assert.AreEqual(800, app.Position.Width);
Assert.AreEqual(600, app.Position.Height);
}
[TestMethod]
[TestCategory("DataModel")]
public void AppDefaults_StringFields_AreNullBeforeDeserialization()
{
ApplicationWrapper app = default;
Assert.IsNull(app.Application);
Assert.IsNull(app.ApplicationPath);
Assert.IsNull(app.Title);
Assert.IsNull(app.PackageFullName);
Assert.IsNull(app.AppUserModelId);
Assert.IsNull(app.PwaAppId);
Assert.IsNull(app.CommandLineArguments);
}
[TestMethod]
[TestCategory("DataModel")]
public void AppDefaults_BooleanFields_AreFalseBeforeDeserialization()
{
ApplicationWrapper app = default;
Assert.IsFalse(app.IsElevated);
Assert.IsFalse(app.CanLaunchElevated);
Assert.IsFalse(app.Minimized);
Assert.IsFalse(app.Maximized);
}
[TestMethod]
[TestCategory("DataModel")]
public void AppDefaults_MonitorIndex_IsZeroPrimaryMonitor()
{
ApplicationWrapper app = default;
Assert.AreEqual(0, app.Monitor);
}
[TestMethod]
[TestCategory("DataModel")]
public void AppConfig_AdminAppOnSecondMonitor_AllFieldsPopulated()
{
var app = new ApplicationWrapper
{
Application = "Registry Editor",
ApplicationPath = @"C:\Windows\regedit.exe",
Title = "Registry Editor",
PackageFullName = string.Empty,
AppUserModelId = string.Empty,
PwaAppId = string.Empty,
CommandLineArguments = string.Empty,
IsElevated = true,
CanLaunchElevated = true,
Minimized = false,
Maximized = false,
Position = new PositionWrapper { X = 1920, Y = 0, Width = 1024, Height = 768 },
Monitor = 1,
};
Assert.IsTrue(app.IsElevated);
Assert.AreEqual(1, app.Monitor);
Assert.AreEqual(1920, app.Position.X);
}
[TestMethod]
[TestCategory("DataModel")]
public void AppConfig_MinimizedOnThirdMonitor_StateAndMonitorCorrect()
{
var app = new ApplicationWrapper
{
Application = "Notepad",
ApplicationPath = @"C:\Windows\System32\notepad.exe",
Minimized = true,
Maximized = false,
Position = new PositionWrapper { X = 3840, Y = 0, Width = 800, Height = 600 },
Monitor = 2,
};
Assert.IsTrue(app.Minimized);
Assert.IsFalse(app.Maximized);
Assert.AreEqual(2, app.Monitor);
}
[TestMethod]
[TestCategory("DataModel")]
public void AppConfig_PathWithParenthesesAndSpaces_PreservedExactly()
{
string complexPath = @"C:\Program Files (x86)\Microsoft Office\root\Office16\WINWORD.EXE";
var app = new ApplicationWrapper { ApplicationPath = complexPath };
Assert.AreEqual(complexPath, app.ApplicationPath);
}
[TestMethod]
[TestCategory("DataModel")]
public void AppConfig_ExplicitEmptyStrings_AreEmptyNotNull()
{
var app = new ApplicationWrapper
{
Application = string.Empty,
ApplicationPath = string.Empty,
Title = string.Empty,
PackageFullName = string.Empty,
AppUserModelId = string.Empty,
PwaAppId = string.Empty,
CommandLineArguments = string.Empty,
};
Assert.AreEqual(string.Empty, app.Application);
Assert.AreEqual(string.Empty, app.ApplicationPath);
Assert.AreEqual(string.Empty, app.PackageFullName);
}
}
}

View File

@@ -0,0 +1,141 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.VisualStudio.TestTools.UnitTesting;
using WorkspacesLauncherUI.Utils;
namespace WorkspacesLauncherUI.UnitTests
{
/// <summary>
/// Tests for DashCaseNamingPolicy and StringUtils.
/// These utilities control JSON property name mapping for IPC messages.
/// </summary>
[TestClass]
public class IpcJsonPropertyNamingTests
{
private readonly DashCaseNamingPolicy _policy = DashCaseNamingPolicy.Instance;
[TestMethod]
[TestCategory("Serialization")]
public void NamingPolicy_ApplicationPath_MapsTo_application_path()
{
Assert.AreEqual("application-path", _policy.ConvertName("ApplicationPath"));
}
[TestMethod]
[TestCategory("Serialization")]
public void NamingPolicy_Application_MapsTo_application()
{
Assert.AreEqual("application", _policy.ConvertName("Application"));
}
[TestMethod]
[TestCategory("Serialization")]
public void NamingPolicy_AppUserModelId_MapsTo_app_user_model_id()
{
Assert.AreEqual("app-user-model-id", _policy.ConvertName("AppUserModelId"));
}
[TestMethod]
[TestCategory("Serialization")]
public void NamingPolicy_LowercaseInput_RemainsUnchanged()
{
Assert.AreEqual("title", _policy.ConvertName("title"));
}
[TestMethod]
[TestCategory("Serialization")]
public void NamingPolicy_SingleUppercaseChar_PreservedAsIs()
{
Assert.AreEqual("X", _policy.ConvertName("X"));
}
[TestMethod]
[TestCategory("Serialization")]
public void NamingPolicy_SingleLowercaseChar_PreservedAsIs()
{
Assert.AreEqual("x", _policy.ConvertName("x"));
}
// Exact IPC property names that must match the C++ side
[TestMethod]
[TestCategory("Serialization")]
public void NamingPolicy_PackageFullName_MatchesCppIpcKey()
{
Assert.AreEqual("package-full-name", _policy.ConvertName("PackageFullName"));
}
[TestMethod]
[TestCategory("Serialization")]
public void NamingPolicy_AppUserModelId_MatchesCppIpcKey()
{
Assert.AreEqual("app-user-model-id", _policy.ConvertName("AppUserModelId"));
}
[TestMethod]
[TestCategory("Serialization")]
public void NamingPolicy_PwaAppId_MatchesCppIpcKey()
{
Assert.AreEqual("pwa-app-id", _policy.ConvertName("PwaAppId"));
}
[TestMethod]
[TestCategory("Serialization")]
public void NamingPolicy_CommandLineArguments_MatchesCppIpcKey()
{
Assert.AreEqual("command-line-arguments", _policy.ConvertName("CommandLineArguments"));
}
[TestMethod]
[TestCategory("Serialization")]
public void NamingPolicy_IsElevated_MatchesCppIpcKey()
{
Assert.AreEqual("is-elevated", _policy.ConvertName("IsElevated"));
}
[TestMethod]
[TestCategory("Serialization")]
public void NamingPolicy_CanLaunchElevated_MatchesCppIpcKey()
{
Assert.AreEqual("can-launch-elevated", _policy.ConvertName("CanLaunchElevated"));
}
[TestMethod]
[TestCategory("Serialization")]
public void NamingPolicy_ApplicationPath_MatchesCppIpcKey()
{
Assert.AreEqual("application-path", _policy.ConvertName("ApplicationPath"));
}
[TestMethod]
[TestCategory("Serialization")]
public void NamingPolicy_Singleton_ReturnsSameInstanceEveryTime()
{
var instance1 = DashCaseNamingPolicy.Instance;
var instance2 = DashCaseNamingPolicy.Instance;
Assert.AreSame(instance1, instance2);
}
[TestMethod]
[TestCategory("Serialization")]
public void StringConversion_TwoUppercaseLetters_InsertsDashBetween()
{
Assert.AreEqual("a-b", "AB".UpperCamelCaseToDashCase());
}
[TestMethod]
[TestCategory("Serialization")]
public void StringConversion_AllLowercase_NoTransformation()
{
Assert.AreEqual("alllowercase", "alllowercase".UpperCamelCaseToDashCase());
}
[TestMethod]
[TestCategory("Serialization")]
public void StringConversion_NumbersInMiddle_PreservedWithDashBeforeNextUpper()
{
Assert.AreEqual("version2-test", "Version2Test".UpperCamelCaseToDashCase());
}
}
}

View File

@@ -0,0 +1,539 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Globalization;
using System.Text;
using System.Text.Json;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using WorkspacesLauncherUI.Data;
namespace WorkspacesLauncherUI.UnitTests
{
/// <summary>
/// Tests for JSON deserialization of IPC messages received from the C++ launcher engine.
/// These messages drive the entire Launcher UI state and must remain stable
/// across any future UI or data layer changes.
/// </summary>
[TestClass]
public class IpcMessageDeserializationTests
{
private const string FullIpcMessage = @"{
""processId"": 12345,
""apps"": {
""appLaunchInfos"": [
{
""application"": {
""application"": ""Visual Studio Code"",
""application-path"": ""C:\\Users\\test\\AppData\\Local\\Programs\\Microsoft VS Code\\Code.exe"",
""title"": ""MyProject - Visual Studio Code"",
""package-full-name"": """",
""app-user-model-id"": """",
""pwa-app-id"": """",
""command-line-arguments"": ""--reuse-window"",
""is-elevated"": false,
""can-launch-elevated"": true,
""minimized"": false,
""maximized"": true,
""position"": { ""X"": 0, ""Y"": 0, ""width"": 1920, ""height"": 1080 },
""monitor"": 0
},
""state"": 2
},
{
""application"": {
""application"": ""Windows Terminal"",
""application-path"": ""C:\\Program Files\\WindowsApps\\Microsoft.WindowsTerminal_1.0.0.0_x64__8wekyb3d8bbwe\\wt.exe"",
""title"": ""PowerShell"",
""package-full-name"": ""Microsoft.WindowsTerminal_1.0.0.0_x64__8wekyb3d8bbwe"",
""app-user-model-id"": ""Microsoft.WindowsTerminal_8wekyb3d8bbwe!App"",
""pwa-app-id"": """",
""command-line-arguments"": """",
""is-elevated"": false,
""can-launch-elevated"": false,
""minimized"": false,
""maximized"": false,
""position"": { ""X"": 960, ""Y"": 0, ""width"": 960, ""height"": 540 },
""monitor"": 0
},
""state"": 0
},
{
""application"": {
""application"": ""Notepad"",
""application-path"": ""C:\\Windows\\System32\\notepad.exe"",
""title"": ""Untitled - Notepad"",
""package-full-name"": """",
""app-user-model-id"": """",
""pwa-app-id"": """",
""command-line-arguments"": """",
""is-elevated"": false,
""can-launch-elevated"": true,
""minimized"": true,
""maximized"": false,
""position"": { ""X"": 100, ""Y"": 100, ""width"": 800, ""height"": 600 },
""monitor"": 1
},
""state"": 3
}
]
}
}";
[TestMethod]
[TestCategory("Deserialization")]
public void IpcMessage_WithMultipleApps_ExtractsLauncherProcessId()
{
var parser = new AppLaunchData();
var result = parser.Deserialize(FullIpcMessage);
Assert.AreEqual(12345, result.LauncherProcessID);
}
[TestMethod]
[TestCategory("Deserialization")]
public void IpcMessage_WithThreeApps_DeserializesAllAppEntries()
{
var parser = new AppLaunchData();
var result = parser.Deserialize(FullIpcMessage);
Assert.AreEqual(3, result.AppLaunchInfos.AppLaunchInfoList.Count);
}
[TestMethod]
[TestCategory("Deserialization")]
public void IpcMessage_Win32Application_DeserializesAllApplicationFields()
{
var parser = new AppLaunchData();
var result = parser.Deserialize(FullIpcMessage);
var vscode = result.AppLaunchInfos.AppLaunchInfoList[0];
Assert.AreEqual("Visual Studio Code", vscode.Application.Application);
Assert.AreEqual(@"C:\Users\test\AppData\Local\Programs\Microsoft VS Code\Code.exe", vscode.Application.ApplicationPath);
Assert.AreEqual("MyProject - Visual Studio Code", vscode.Application.Title);
Assert.AreEqual(string.Empty, vscode.Application.PackageFullName);
Assert.AreEqual(string.Empty, vscode.Application.AppUserModelId);
Assert.AreEqual(string.Empty, vscode.Application.PwaAppId);
Assert.AreEqual("--reuse-window", vscode.Application.CommandLineArguments);
Assert.IsFalse(vscode.Application.IsElevated);
Assert.IsTrue(vscode.Application.CanLaunchElevated);
Assert.IsFalse(vscode.Application.Minimized);
Assert.IsTrue(vscode.Application.Maximized);
Assert.AreEqual(0, vscode.Application.Monitor);
}
[TestMethod]
[TestCategory("Deserialization")]
public void IpcMessage_Win32Application_DeserializesWindowPosition()
{
var parser = new AppLaunchData();
var result = parser.Deserialize(FullIpcMessage);
var pos = result.AppLaunchInfos.AppLaunchInfoList[0].Application.Position;
Assert.AreEqual(0, pos.X);
Assert.AreEqual(0, pos.Y);
Assert.AreEqual(1920, pos.Width);
Assert.AreEqual(1080, pos.Height);
}
[TestMethod]
[TestCategory("Deserialization")]
public void IpcMessage_PackagedUwpApp_DeserializesPackageIdentifiers()
{
var parser = new AppLaunchData();
var result = parser.Deserialize(FullIpcMessage);
var terminal = result.AppLaunchInfos.AppLaunchInfoList[1];
Assert.AreEqual("Windows Terminal", terminal.Application.Application);
Assert.AreEqual("Microsoft.WindowsTerminal_1.0.0.0_x64__8wekyb3d8bbwe", terminal.Application.PackageFullName);
Assert.AreEqual("Microsoft.WindowsTerminal_8wekyb3d8bbwe!App", terminal.Application.AppUserModelId);
}
[TestMethod]
[TestCategory("Deserialization")]
public void IpcMessage_StateValueTwo_MapsToLaunchedAndMovedEnum()
{
var parser = new AppLaunchData();
var result = parser.Deserialize(FullIpcMessage);
Assert.AreEqual(LaunchingState.LaunchedAndMoved, result.AppLaunchInfos.AppLaunchInfoList[0].State);
}
[TestMethod]
[TestCategory("Deserialization")]
public void IpcMessage_StateValueZero_MapsToWaitingEnum()
{
var parser = new AppLaunchData();
var result = parser.Deserialize(FullIpcMessage);
Assert.AreEqual(LaunchingState.Waiting, result.AppLaunchInfos.AppLaunchInfoList[1].State);
}
[TestMethod]
[TestCategory("Deserialization")]
public void IpcMessage_StateValueThree_MapsToFailedEnum()
{
var parser = new AppLaunchData();
var result = parser.Deserialize(FullIpcMessage);
Assert.AreEqual(LaunchingState.Failed, result.AppLaunchInfos.AppLaunchInfoList[2].State);
}
[TestMethod]
[TestCategory("Deserialization")]
public void IpcMessage_MinimizedWindow_DeserializesWindowStateFlags()
{
var parser = new AppLaunchData();
var result = parser.Deserialize(FullIpcMessage);
var notepad = result.AppLaunchInfos.AppLaunchInfoList[2];
Assert.IsTrue(notepad.Application.Minimized);
Assert.IsFalse(notepad.Application.Maximized);
}
[TestMethod]
[TestCategory("Deserialization")]
public void IpcMessage_SecondaryMonitor_DeserializesMonitorIndex()
{
var parser = new AppLaunchData();
var result = parser.Deserialize(FullIpcMessage);
Assert.AreEqual(1, result.AppLaunchInfos.AppLaunchInfoList[2].Application.Monitor);
}
[TestMethod]
[TestCategory("Deserialization")]
public void IpcMessage_ProgressiveWebApp_DeserializesPwaIdentifier()
{
string pwaMessage = @"{
""processId"": 100,
""apps"": {
""appLaunchInfos"": [
{
""application"": {
""application"": ""Gmail"",
""application-path"": ""C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"",
""title"": ""Gmail"",
""package-full-name"": """",
""app-user-model-id"": """",
""pwa-app-id"": ""fmgjjmmmlfnkbppncijlocphclkkleod"",
""command-line-arguments"": """",
""is-elevated"": false,
""can-launch-elevated"": false,
""minimized"": false,
""maximized"": false,
""position"": { ""X"": 0, ""Y"": 0, ""width"": 800, ""height"": 600 },
""monitor"": 0
},
""state"": 1
}
]
}
}";
var parser = new AppLaunchData();
var result = parser.Deserialize(pwaMessage);
var gmail = result.AppLaunchInfos.AppLaunchInfoList[0];
Assert.AreEqual("fmgjjmmmlfnkbppncijlocphclkkleod", gmail.Application.PwaAppId);
Assert.AreEqual(LaunchingState.Launched, gmail.State);
}
[TestMethod]
[TestCategory("Deserialization")]
public void IpcMessage_ElevatedProcess_DeserializesAdminFlags()
{
string elevatedMessage = @"{
""processId"": 200,
""apps"": {
""appLaunchInfos"": [
{
""application"": {
""application"": ""Registry Editor"",
""application-path"": ""C:\\Windows\\regedit.exe"",
""title"": ""Registry Editor"",
""package-full-name"": """",
""app-user-model-id"": """",
""pwa-app-id"": """",
""command-line-arguments"": """",
""is-elevated"": true,
""can-launch-elevated"": true,
""minimized"": false,
""maximized"": false,
""position"": { ""X"": 100, ""Y"": 100, ""width"": 1024, ""height"": 768 },
""monitor"": 0
},
""state"": 2
}
]
}
}";
var parser = new AppLaunchData();
var result = parser.Deserialize(elevatedMessage);
var regedit = result.AppLaunchInfos.AppLaunchInfoList[0];
Assert.IsTrue(regedit.Application.IsElevated);
Assert.IsTrue(regedit.Application.CanLaunchElevated);
}
[TestMethod]
[TestCategory("Deserialization")]
public void IpcMessage_SingleAppWorkspace_DeserializesSuccessfully()
{
string singleAppMessage = @"{
""processId"": 1,
""apps"": {
""appLaunchInfos"": [
{
""application"": {
""application"": ""Notepad"",
""application-path"": ""C:\\Windows\\System32\\notepad.exe"",
""title"": """",
""package-full-name"": """",
""app-user-model-id"": """",
""pwa-app-id"": """",
""command-line-arguments"": """",
""is-elevated"": false,
""can-launch-elevated"": false,
""minimized"": false,
""maximized"": false,
""position"": { ""X"": 0, ""Y"": 0, ""width"": 400, ""height"": 300 },
""monitor"": 0
},
""state"": 0
}
]
}
}";
var parser = new AppLaunchData();
var result = parser.Deserialize(singleAppMessage);
Assert.AreEqual(1, result.AppLaunchInfos.AppLaunchInfoList.Count);
Assert.AreEqual("Notepad", result.AppLaunchInfos.AppLaunchInfoList[0].Application.Application);
}
[TestMethod]
[TestCategory("Deserialization")]
public void IpcMessage_ZeroApps_ReturnsEmptyListWithValidProcessId()
{
string emptyAppsMessage = @"{
""processId"": 42,
""apps"": {
""appLaunchInfos"": []
}
}";
var parser = new AppLaunchData();
var result = parser.Deserialize(emptyAppsMessage);
Assert.AreEqual(42, result.LauncherProcessID);
Assert.AreEqual(0, result.AppLaunchInfos.AppLaunchInfoList.Count);
}
[TestMethod]
[TestCategory("Deserialization")]
[ExpectedException(typeof(JsonException))]
public void IpcMessage_MalformedJson_ThrowsJsonException()
{
var parser = new AppLaunchData();
parser.Deserialize("not valid json {{{");
}
[TestMethod]
[TestCategory("Deserialization")]
[ExpectedException(typeof(JsonException))]
public void IpcMessage_EmptyPayload_ThrowsJsonException()
{
var parser = new AppLaunchData();
parser.Deserialize(string.Empty);
}
[TestMethod]
[TestCategory("Deserialization")]
public void IpcMessage_LeftOfPrimaryMonitor_DeserializesNegativeCoordinates()
{
string negativePositionMessage = @"{
""processId"": 1,
""apps"": {
""appLaunchInfos"": [
{
""application"": {
""application"": ""Notepad"",
""application-path"": ""C:\\Windows\\System32\\notepad.exe"",
""title"": """",
""package-full-name"": """",
""app-user-model-id"": """",
""pwa-app-id"": """",
""command-line-arguments"": """",
""is-elevated"": false,
""can-launch-elevated"": false,
""minimized"": false,
""maximized"": false,
""position"": { ""X"": -1920, ""Y"": -200, ""width"": 800, ""height"": 600 },
""monitor"": 1
},
""state"": 0
}
]
}
}";
var parser = new AppLaunchData();
var result = parser.Deserialize(negativePositionMessage);
var pos = result.AppLaunchInfos.AppLaunchInfoList[0].Application.Position;
Assert.AreEqual(-1920, pos.X);
Assert.AreEqual(-200, pos.Y);
}
[TestMethod]
[TestCategory("Deserialization")]
public void IpcMessage_FourthMonitor_DeserializesHighMonitorIndex()
{
string multiMonitorMessage = @"{
""processId"": 1,
""apps"": {
""appLaunchInfos"": [
{
""application"": {
""application"": ""App"",
""application-path"": ""C:\\app.exe"",
""title"": """",
""package-full-name"": """",
""app-user-model-id"": """",
""pwa-app-id"": """",
""command-line-arguments"": """",
""is-elevated"": false,
""can-launch-elevated"": false,
""minimized"": false,
""maximized"": false,
""position"": { ""X"": 3840, ""Y"": 0, ""width"": 1920, ""height"": 1080 },
""monitor"": 3
},
""state"": 0
}
]
}
}";
var parser = new AppLaunchData();
var result = parser.Deserialize(multiMonitorMessage);
Assert.AreEqual(3, result.AppLaunchInfos.AppLaunchInfoList[0].Application.Monitor);
}
[TestMethod]
[TestCategory("Deserialization")]
public void IpcMessage_AllFiveStateValues_MapToCorrectEnumMembers()
{
for (int stateValue = 0; stateValue <= 4; stateValue++)
{
string template = @"{""processId"": 1,""apps"": {""appLaunchInfos"": [{""application"": {""application"": ""App"",""application-path"": ""C:\\app.exe"",""title"": """",""package-full-name"": """",""app-user-model-id"": """",""pwa-app-id"": """",""command-line-arguments"": """",""is-elevated"": false,""can-launch-elevated"": false,""minimized"": false,""maximized"": false,""position"": { ""X"": 0, ""Y"": 0, ""width"": 100, ""height"": 100 },""monitor"": 0},""state"": STATE_PLACEHOLDER}]}}";
string message = template.Replace("STATE_PLACEHOLDER", stateValue.ToString(CultureInfo.InvariantCulture));
var parser = new AppLaunchData();
var result = parser.Deserialize(message);
Assert.AreEqual((LaunchingState)stateValue, result.AppLaunchInfos.AppLaunchInfoList[0].State);
}
}
[TestMethod]
[TestCategory("Deserialization")]
public void IpcMessage_CommandLineWithSpecialChars_PreservesArgumentsExactly()
{
string cliMessage = @"{
""processId"": 1,
""apps"": {
""appLaunchInfos"": [
{
""application"": {
""application"": ""VS Code"",
""application-path"": ""C:\\Code.exe"",
""title"": """",
""package-full-name"": """",
""app-user-model-id"": """",
""pwa-app-id"": """",
""command-line-arguments"": ""--new-window --goto C:\\project\\file.ts:42"",
""is-elevated"": false,
""can-launch-elevated"": false,
""minimized"": false,
""maximized"": false,
""position"": { ""X"": 0, ""Y"": 0, ""width"": 100, ""height"": 100 },
""monitor"": 0
},
""state"": 0
}
]
}
}";
var parser = new AppLaunchData();
var result = parser.Deserialize(cliMessage);
Assert.AreEqual(@"--new-window --goto C:\project\file.ts:42", result.AppLaunchInfos.AppLaunchInfoList[0].Application.CommandLineArguments);
}
[TestMethod]
[TestCategory("Deserialization")]
public void IpcMessage_JapaneseAppName_DeserializesUnicodeCorrectly()
{
string unicodeMessage = @"{
""processId"": 1,
""apps"": {
""appLaunchInfos"": [
{
""application"": {
""application"": ""\u30E1\u30E2\u5E33"",
""application-path"": ""C:\\Windows\\System32\\notepad.exe"",
""title"": ""\u7121\u984C - \u30E1\u30E2\u5E33"",
""package-full-name"": """",
""app-user-model-id"": """",
""pwa-app-id"": """",
""command-line-arguments"": """",
""is-elevated"": false,
""can-launch-elevated"": false,
""minimized"": false,
""maximized"": false,
""position"": { ""X"": 0, ""Y"": 0, ""width"": 400, ""height"": 300 },
""monitor"": 0
},
""state"": 0
}
]
}
}";
var parser = new AppLaunchData();
var result = parser.Deserialize(unicodeMessage);
Assert.AreEqual("\u30E1\u30E2\u5E33", result.AppLaunchInfos.AppLaunchInfoList[0].Application.Application);
Assert.AreEqual("\u7121\u984C - \u30E1\u30E2\u5E33", result.AppLaunchInfos.AppLaunchInfoList[0].Application.Title);
}
[TestMethod]
[TestCategory("Deserialization")]
public void IpcMessage_TenAppWorkspace_DeserializesAllWithCorrectPositionsAndStates()
{
var appEntries = new StringBuilder();
for (int i = 0; i < 10; i++)
{
if (i > 0)
{
appEntries.Append(',');
}
string entry = string.Create(CultureInfo.InvariantCulture, $@"{{""application"": {{""application"": ""App{i}"",""application-path"": ""C:\\app{i}.exe"",""title"": ""Window {i}"",""package-full-name"": """",""app-user-model-id"": """",""pwa-app-id"": """",""command-line-arguments"": """",""is-elevated"": false,""can-launch-elevated"": false,""minimized"": false,""maximized"": false,""position"": {{ ""X"": {i * 100}, ""Y"": 0, ""width"": 400, ""height"": 300 }},""monitor"": {i % 3}}},""state"": {i % 5}}}");
appEntries.Append(entry);
}
string manyAppsMessage = string.Create(CultureInfo.InvariantCulture, $@"{{""processId"": 9999,""apps"": {{""appLaunchInfos"": [{appEntries}]}}}}");
var parser = new AppLaunchData();
var result = parser.Deserialize(manyAppsMessage);
Assert.AreEqual(10, result.AppLaunchInfos.AppLaunchInfoList.Count);
for (int i = 0; i < 10; i++)
{
Assert.AreEqual(string.Create(CultureInfo.InvariantCulture, $"App{i}"), result.AppLaunchInfos.AppLaunchInfoList[i].Application.Application);
Assert.AreEqual(i * 100, result.AppLaunchInfos.AppLaunchInfoList[i].Application.Position.X);
Assert.AreEqual(i % 3, result.AppLaunchInfos.AppLaunchInfoList[i].Application.Monitor);
Assert.AreEqual((LaunchingState)(i % 5), result.AppLaunchInfos.AppLaunchInfoList[i].State);
}
}
}
}

View File

@@ -0,0 +1,73 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using WorkspacesLauncherUI.Data;
namespace WorkspacesLauncherUI.UnitTests
{
/// <summary>
/// Tests for the LaunchingState enum values and their integer mapping.
/// The C++ launcher engine sends state as integer values over IPC.
/// These integer values MUST remain stable across the migration.
/// </summary>
[TestClass]
public class LaunchStateEnumContractTests
{
[TestMethod]
[TestCategory("DataModel")]
public void EnumContract_WaitingState_MapsToIntegerZero()
{
Assert.AreEqual(0, (int)LaunchingState.Waiting);
}
[TestMethod]
[TestCategory("DataModel")]
public void EnumContract_LaunchedState_MapsToIntegerOne()
{
Assert.AreEqual(1, (int)LaunchingState.Launched);
}
[TestMethod]
[TestCategory("DataModel")]
public void EnumContract_LaunchedAndMovedState_MapsToIntegerTwo()
{
Assert.AreEqual(2, (int)LaunchingState.LaunchedAndMoved);
}
[TestMethod]
[TestCategory("DataModel")]
public void EnumContract_FailedState_MapsToIntegerThree()
{
Assert.AreEqual(3, (int)LaunchingState.Failed);
}
[TestMethod]
[TestCategory("DataModel")]
public void EnumContract_CanceledState_MapsToIntegerFour()
{
Assert.AreEqual(4, (int)LaunchingState.Canceled);
}
[TestMethod]
[TestCategory("DataModel")]
public void EnumContract_TotalMemberCount_IsExactlyFiveMatchingCppHeader()
{
var values = Enum.GetValues(typeof(LaunchingState));
Assert.AreEqual(5, values.Length, "LaunchingState must have exactly 5 values to match C++ LaunchingStateEnum.h");
}
[TestMethod]
[TestCategory("DataModel")]
public void EnumContract_IntToEnumCast_RoundTripsForAllValues()
{
for (int i = 0; i <= 4; i++)
{
var state = (LaunchingState)i;
Assert.AreEqual(i, (int)state);
}
}
}
}

View File

@@ -0,0 +1,169 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.VisualStudio.TestTools.UnitTesting;
using WorkspacesLauncherUI.Data;
using WorkspacesLauncherUI.Models;
namespace WorkspacesLauncherUI.UnitTests
{
/// <summary>
/// Tests for the AppLaunching model which drives UI display:
/// loading indicator, state glyph, and state color.
/// </summary>
[TestClass]
public class LaunchStatusDisplayLogicTests
{
[TestMethod]
[TestCategory("Model")]
public void LoadingSpinner_WhenStateIsWaiting_IsVisible()
{
var app = new AppLaunching { LaunchState = LaunchingState.Waiting };
Assert.IsTrue(app.Loading);
}
[TestMethod]
[TestCategory("Model")]
public void LoadingSpinner_WhenStateIsLaunched_RemainsVisibleUntilMoved()
{
var app = new AppLaunching { LaunchState = LaunchingState.Launched };
Assert.IsTrue(app.Loading);
}
[TestMethod]
[TestCategory("Model")]
public void LoadingSpinner_WhenStateIsLaunchedAndMoved_IsHidden()
{
var app = new AppLaunching { LaunchState = LaunchingState.LaunchedAndMoved };
Assert.IsFalse(app.Loading);
}
[TestMethod]
[TestCategory("Model")]
public void LoadingSpinner_WhenStateIsFailed_IsHidden()
{
var app = new AppLaunching { LaunchState = LaunchingState.Failed };
Assert.IsFalse(app.Loading);
}
[TestMethod]
[TestCategory("Model")]
public void LoadingSpinner_WhenStateIsCanceled_IsHidden()
{
var app = new AppLaunching { LaunchState = LaunchingState.Canceled };
Assert.IsFalse(app.Loading);
}
[TestMethod]
[TestCategory("Model")]
public void StatusIcon_WhenSuccessful_ShowsGreenCheckmarkGlyph()
{
var app = new AppLaunching { LaunchState = LaunchingState.LaunchedAndMoved };
Assert.AreEqual("\U0000F78C", app.StateGlyph, "LaunchedAndMoved should show checkmark glyph");
}
[TestMethod]
[TestCategory("Model")]
public void StatusIcon_WhenFailed_ShowsRedErrorGlyph()
{
var app = new AppLaunching { LaunchState = LaunchingState.Failed };
Assert.AreEqual("\U0000EF2C", app.StateGlyph, "Failed should show error glyph");
}
[TestMethod]
[TestCategory("Model")]
public void StatusIcon_WhenCanceled_ShowsRedErrorGlyph()
{
var app = new AppLaunching { LaunchState = LaunchingState.Canceled };
Assert.AreEqual("\U0000EF2C", app.StateGlyph, "Canceled should fall through to default error glyph");
}
[TestMethod]
[TestCategory("Model")]
public void StatusColor_WhenSuccessful_IsGreenRgb0_128_0()
{
var app = new AppLaunching { LaunchState = LaunchingState.LaunchedAndMoved };
var color = app.StateColorValue;
Assert.AreNotEqual(default(Windows.UI.Color), color);
Assert.AreEqual(0, color.R, "Green color R component");
Assert.AreEqual(128, color.G, "Green color G component");
Assert.AreEqual(0, color.B, "Green color B component");
Assert.AreEqual(255, color.A, "Green color A component");
}
[TestMethod]
[TestCategory("Model")]
public void StatusColor_WhenFailed_IsRedRgb254_0_0()
{
var app = new AppLaunching { LaunchState = LaunchingState.Failed };
var color = app.StateColorValue;
Assert.AreNotEqual(default(Windows.UI.Color), color);
Assert.AreEqual(254, color.R, "Red color R component");
Assert.AreEqual(0, color.G, "Red color G component");
Assert.AreEqual(0, color.B, "Red color B component");
}
[TestMethod]
[TestCategory("Model")]
public void StatusColor_WhenCanceled_IsRedRgb254_0_0()
{
var app = new AppLaunching { LaunchState = LaunchingState.Canceled };
var color = app.StateColorValue;
Assert.AreNotEqual(default(Windows.UI.Color), color);
Assert.AreEqual(254, color.R, "Canceled should fall through to red");
}
[TestMethod]
[TestCategory("Model")]
public void AppName_SetToString_ReturnsExactValue()
{
var app = new AppLaunching { Name = "Test Application" };
Assert.AreEqual("Test Application", app.Name);
}
[TestMethod]
[TestCategory("Model")]
public void AppName_SetToEmpty_ReturnsEmptyString()
{
var app = new AppLaunching { Name = string.Empty };
Assert.AreEqual(string.Empty, app.Name);
}
[TestMethod]
[TestCategory("Model")]
public void StateProgression_WaitingToSuccess_TransitionsSpinnerToGreenCheckmark()
{
var app = new AppLaunching { Name = "Test", LaunchState = LaunchingState.Waiting };
Assert.IsTrue(app.Loading);
app.LaunchState = LaunchingState.Launched;
Assert.IsTrue(app.Loading);
app.LaunchState = LaunchingState.LaunchedAndMoved;
Assert.IsFalse(app.Loading);
Assert.AreEqual("\U0000F78C", app.StateGlyph);
var color = app.StateColorValue;
Assert.AreEqual(0, color.R);
Assert.AreEqual(128, color.G);
}
[TestMethod]
[TestCategory("Model")]
public void StateProgression_WaitingToFailed_TransitionsSpinnerToRedError()
{
var app = new AppLaunching { Name = "Test", LaunchState = LaunchingState.Waiting };
Assert.IsTrue(app.Loading);
app.LaunchState = LaunchingState.Failed;
Assert.IsFalse(app.Loading);
Assert.AreEqual("\U0000EF2C", app.StateGlyph);
var color = app.StateColorValue;
Assert.AreEqual(254, color.R);
Assert.AreEqual(0, color.G);
}
}
}

View File

@@ -0,0 +1,292 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Specialized;
using System.ComponentModel;
using System.Globalization;
using System.Text;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using WorkspacesLauncherUI.Data;
using WorkspacesLauncherUI.Models;
using WorkspacesLauncherUI.ViewModels;
namespace WorkspacesLauncherUI.UnitTests
{
/// <summary>
/// Tests for MainViewModel IPC message handling and state management.
/// MainViewModel is the core of the Launcher UI — it receives IPC messages
/// from the C++ launcher engine and populates the AppsListed collection
/// that the UI binds to.
/// </summary>
[TestClass]
public class LauncherViewModelStateManagementTests
{
[TestMethod]
[TestCategory("ViewModel")]
public void ReceiveIpcMessage_ValidPayload_PopulatesAppsListedCollection()
{
using var vm = new MainViewModel();
string message = CreateIpcMessage(("App1", @"C:\app1.exe", LaunchingState.Waiting), ("App2", @"C:\app2.exe", LaunchingState.Launched));
SimulateIpcMessage(message);
Assert.AreEqual(2, vm.AppsListed.Count);
}
[TestMethod]
[TestCategory("ViewModel")]
public void ReceiveIpcMessage_ValidPayload_MapsAppNamesFromJson()
{
using var vm = new MainViewModel();
string message = CreateIpcMessage(("Visual Studio Code", @"C:\Code.exe", LaunchingState.Waiting), ("Windows Terminal", @"C:\wt.exe", LaunchingState.Launched));
SimulateIpcMessage(message);
Assert.AreEqual("Visual Studio Code", vm.AppsListed[0].Name);
Assert.AreEqual("Windows Terminal", vm.AppsListed[1].Name);
}
[TestMethod]
[TestCategory("ViewModel")]
public void ReceiveIpcMessage_MixedStates_MapsEachAppToCorrectState()
{
using var vm = new MainViewModel();
string message = CreateIpcMessage(
("App1", @"C:\app1.exe", LaunchingState.Waiting),
("App2", @"C:\app2.exe", LaunchingState.Launched),
("App3", @"C:\app3.exe", LaunchingState.LaunchedAndMoved),
("App4", @"C:\app4.exe", LaunchingState.Failed));
SimulateIpcMessage(message);
Assert.AreEqual(LaunchingState.Waiting, vm.AppsListed[0].LaunchState);
Assert.AreEqual(LaunchingState.Launched, vm.AppsListed[1].LaunchState);
Assert.AreEqual(LaunchingState.LaunchedAndMoved, vm.AppsListed[2].LaunchState);
Assert.AreEqual(LaunchingState.Failed, vm.AppsListed[3].LaunchState);
}
[TestMethod]
[TestCategory("ViewModel")]
public void ReceiveIpcMessage_ValidPayload_PreservesExecutablePaths()
{
using var vm = new MainViewModel();
string message = CreateIpcMessage(("Notepad", @"C:\Windows\System32\notepad.exe", LaunchingState.Waiting));
SimulateIpcMessage(message);
Assert.AreEqual(@"C:\Windows\System32\notepad.exe", vm.AppsListed[0].AppPath);
}
[TestMethod]
[TestCategory("ViewModel")]
public void ReceiveIpcMessage_PackagedApp_MapsPackageNameAndAumid()
{
using var vm = new MainViewModel();
string message = @"{
""processId"": 1,
""apps"": {
""appLaunchInfos"": [
{
""application"": {
""application"": ""Terminal"",
""application-path"": ""C:\\wt.exe"",
""title"": """",
""package-full-name"": ""Microsoft.WindowsTerminal_1.0_x64__8wekyb3d8bbwe"",
""app-user-model-id"": ""Microsoft.WindowsTerminal_8wekyb3d8bbwe!App"",
""pwa-app-id"": """",
""command-line-arguments"": """",
""is-elevated"": false,
""can-launch-elevated"": false,
""minimized"": false,
""maximized"": false,
""position"": { ""X"": 0, ""Y"": 0, ""width"": 100, ""height"": 100 },
""monitor"": 0
},
""state"": 0
}
]
}
}";
SimulateIpcMessage(message);
Assert.AreEqual("Microsoft.WindowsTerminal_1.0_x64__8wekyb3d8bbwe", vm.AppsListed[0].PackagedName);
Assert.AreEqual("Microsoft.WindowsTerminal_8wekyb3d8bbwe!App", vm.AppsListed[0].Aumid);
}
[TestMethod]
[TestCategory("ViewModel")]
public void ReceiveIpcMessage_PwaApp_MapsPwaAppIdentifier()
{
using var vm = new MainViewModel();
string message = @"{
""processId"": 1,
""apps"": {
""appLaunchInfos"": [
{
""application"": {
""application"": ""Gmail"",
""application-path"": ""C:\\chrome.exe"",
""title"": """",
""package-full-name"": """",
""app-user-model-id"": """",
""pwa-app-id"": ""abc123"",
""command-line-arguments"": """",
""is-elevated"": false,
""can-launch-elevated"": false,
""minimized"": false,
""maximized"": false,
""position"": { ""X"": 0, ""Y"": 0, ""width"": 100, ""height"": 100 },
""monitor"": 0
},
""state"": 0
}
]
}
}";
SimulateIpcMessage(message);
Assert.AreEqual("abc123", vm.AppsListed[0].PwaAppId);
}
[TestMethod]
[TestCategory("ViewModel")]
public void ReceiveIpcMessage_AnyUpdate_RaisesPropertyChangedForDataBinding()
{
using var vm = new MainViewModel();
bool propertyChangedFired = false;
string changedPropertyName = null;
vm.PropertyChanged += (sender, args) =>
{
propertyChangedFired = true;
changedPropertyName = args.PropertyName;
};
string message = CreateIpcMessage(("App", @"C:\app.exe", LaunchingState.Waiting));
SimulateIpcMessage(message);
Assert.IsTrue(propertyChangedFired, "PropertyChanged should fire when AppsListed is updated");
Assert.AreEqual("AppsListed", changedPropertyName);
}
[TestMethod]
[TestCategory("ViewModel")]
public void ReceiveIpcMessage_ProgressUpdates_ReplacesEntireCollectionEachTime()
{
using var vm = new MainViewModel();
string msg1 = CreateIpcMessage(("App1", @"C:\app1.exe", LaunchingState.Waiting), ("App2", @"C:\app2.exe", LaunchingState.Waiting));
SimulateIpcMessage(msg1);
Assert.AreEqual(2, vm.AppsListed.Count);
Assert.AreEqual(LaunchingState.Waiting, vm.AppsListed[0].LaunchState);
string msg2 = CreateIpcMessage(("App1", @"C:\app1.exe", LaunchingState.Launched), ("App2", @"C:\app2.exe", LaunchingState.Waiting));
SimulateIpcMessage(msg2);
Assert.AreEqual(2, vm.AppsListed.Count);
Assert.AreEqual(LaunchingState.Launched, vm.AppsListed[0].LaunchState);
string msg3 = CreateIpcMessage(("App1", @"C:\app1.exe", LaunchingState.LaunchedAndMoved), ("App2", @"C:\app2.exe", LaunchingState.LaunchedAndMoved));
SimulateIpcMessage(msg3);
Assert.AreEqual(LaunchingState.LaunchedAndMoved, vm.AppsListed[0].LaunchState);
Assert.AreEqual(LaunchingState.LaunchedAndMoved, vm.AppsListed[1].LaunchState);
}
[TestMethod]
[TestCategory("ViewModel")]
public void ReceiveIpcMessage_SomeAppsFail_AllowsMixedSuccessAndFailure()
{
using var vm = new MainViewModel();
string message = CreateIpcMessage(
("App1", @"C:\app1.exe", LaunchingState.LaunchedAndMoved),
("App2", @"C:\app2.exe", LaunchingState.Failed),
("App3", @"C:\app3.exe", LaunchingState.LaunchedAndMoved));
SimulateIpcMessage(message);
Assert.AreEqual(LaunchingState.LaunchedAndMoved, vm.AppsListed[0].LaunchState);
Assert.AreEqual(LaunchingState.Failed, vm.AppsListed[1].LaunchState);
Assert.AreEqual(LaunchingState.LaunchedAndMoved, vm.AppsListed[2].LaunchState);
}
[TestMethod]
[TestCategory("ViewModel")]
public void ReceiveIpcMessage_CanceledState_ReflectedInCollection()
{
using var vm = new MainViewModel();
string message = CreateIpcMessage(("App1", @"C:\app1.exe", LaunchingState.LaunchedAndMoved), ("App2", @"C:\app2.exe", LaunchingState.Canceled));
SimulateIpcMessage(message);
Assert.AreEqual(LaunchingState.Canceled, vm.AppsListed[1].LaunchState);
}
[TestMethod]
[TestCategory("ViewModel")]
public void ReceiveIpcMessage_EmptyAppList_SetsCollectionToEmpty()
{
using var vm = new MainViewModel();
string message = @"{ ""processId"": 1, ""apps"": { ""appLaunchInfos"": [] } }";
SimulateIpcMessage(message);
Assert.AreEqual(0, vm.AppsListed.Count);
}
[TestMethod]
[TestCategory("ViewModel")]
public void ReceiveIpcMessage_CorruptedPayload_GracefullyIgnoredWithoutCrash()
{
using var vm = new MainViewModel();
SimulateIpcMessage("this is not json");
Assert.AreEqual(0, vm.AppsListed.Count);
}
[TestMethod]
[TestCategory("ViewModel")]
public void ReceiveIpcMessage_EmptyString_GracefullyIgnoredWithoutCrash()
{
using var vm = new MainViewModel();
SimulateIpcMessage(string.Empty);
Assert.AreEqual(0, vm.AppsListed.Count);
}
[TestMethod]
[TestCategory("ViewModel")]
public void DisposeViewModel_SingleCall_CompletesWithoutException()
{
var vm = new MainViewModel();
vm.Dispose();
}
[TestMethod]
[TestCategory("ViewModel")]
public void DisposeViewModel_MultipleCalls_RemainsIdempotent()
{
var vm = new MainViewModel();
vm.Dispose();
vm.Dispose();
}
private static void SimulateIpcMessage(string message)
{
App.IPCMessageReceivedCallback?.Invoke(message);
}
private static string CreateIpcMessage(params (string Name, string Path, LaunchingState State)[] apps)
{
var sb = new StringBuilder();
sb.Append(@"{ ""processId"": 1, ""apps"": { ""appLaunchInfos"": [");
for (int i = 0; i < apps.Length; i++)
{
if (i > 0)
{
sb.Append(',');
}
var (name, path, state) = apps[i];
string escapedPath = path.Replace(@"\", @"\\");
string appJson = string.Create(CultureInfo.InvariantCulture, $@"{{""application"": {{""application"": ""{name}"",""application-path"": ""{escapedPath}"",""title"": """",""package-full-name"": """",""app-user-model-id"": """",""pwa-app-id"": """",""command-line-arguments"": """",""is-elevated"": false,""can-launch-elevated"": false,""minimized"": false,""maximized"": false,""position"": {{ ""X"": 0, ""Y"": 0, ""width"": 100, ""height"": 100 }},""monitor"": 0}},""state"": {(int)state}}}");
sb.Append(appJson);
}
sb.Append("]}}");
return sb.ToString();
}
}
}

View File

@@ -0,0 +1,105 @@
# WorkspacesLauncherUI Unit Tests
Unit tests for the Workspaces Launcher UI (WinUI 3). These validate the data layer, ViewModel, and display logic that drives the workspace launch progress window.
## Prerequisites
- Visual Studio 2022 17.4+ or Visual Studio 2026
- .NET SDK (see `global.json` in repo root)
- Submodules initialized: `git submodule update --init --recursive`
## Build
From this directory:
```powershell
# Quick build (auto-detects platform)
& "$env:RepoRoot\tools\build\build.cmd"
# Or with explicit options
& "$env:RepoRoot\tools\build\build.cmd" -Platform arm64 -Configuration Debug
```
If you get NuGet restore errors on first build:
```powershell
& "$env:RepoRoot\tools\build\build-essentials.cmd"
```
## Run Tests
### Option 1: dotnet test (recommended for CI)
```powershell
dotnet test "<output-dir>\tests\WorkspacesLauncherUI.Tests\PowerToys.WorkspacesLauncherUI.Tests.dll" --verbosity normal
```
The output directory depends on your platform/config. For arm64 Debug:
```powershell
dotnet test "arm64\Debug\tests\WorkspacesLauncherUI.Tests\PowerToys.WorkspacesLauncherUI.Tests.dll" --verbosity normal
```
### Option 2: Visual Studio Test Explorer
1. Open `PowerToys.slnx` in Visual Studio
2. Build the `WorkspacesLauncherUI.UnitTests` project
3. Open Test Explorer (`Ctrl+E, T`)
4. Run all tests in `PowerToys.WorkspacesLauncherUI.Tests`
### Option 3: Filter by category
```powershell
dotnet test <dll-path> --filter "TestCategory=Scenario"
dotnet test <dll-path> --filter "TestCategory=Deserialization"
dotnet test <dll-path> --filter "TestCategory=ViewModel"
dotnet test <dll-path> --filter "TestCategory=Model"
dotnet test <dll-path> --filter "TestCategory=Serialization"
dotnet test <dll-path> --filter "TestCategory=DataModel"
dotnet test <dll-path> --filter "TestCategory=Converter"
```
### Generate TRX Report
```powershell
dotnet test <dll-path> --logger "trx;LogFileName=TestResults.trx"
```
Report saved to `TestResults/TestResults.trx`.
## Test Categories
| Category | File | What It Validates |
|----------|------|-------------------|
| `Deserialization` | `IpcMessageDeserializationTests.cs` | C++ launcher engine JSON → C# data models |
| `ViewModel` | `LauncherViewModelStateManagementTests.cs` | IPC callback → ObservableCollection pipeline |
| `Model` | `LaunchStatusDisplayLogicTests.cs` | Spinner/glyph/color for each launch state |
| `Scenario` | `UserWorkflowIntegrationTests.cs` | Full user workflows (launch, cancel, fail) |
| `Serialization` | `IpcJsonPropertyNamingTests.cs` | JSON key names match C++ IPC protocol |
| `DataModel` | `WindowPositionDataTests.cs` | Window coordinates and equality |
| `DataModel` | `ApplicationDataModelTests.cs` | All application fields |
| `DataModel` | `LaunchStateEnumContractTests.cs` | Enum integers match `LaunchingStateEnum.h` |
| `Converter` | `StatusIndicatorVisibilityTests.cs` | Loading → Visibility toggle |
## When to Run
- **After IPC contract changes**: Deserialization + Serialization categories
- **After UI state changes**: Model + ViewModel categories
- **After dependency updates**: All tests to verify no regressions
## Adding New Tests
Follow the naming convention: `{WhatIsUnderTest}_{GivenCondition}_{ExpectedBehavior}`
Example:
```csharp
[TestMethod]
[TestCategory("ViewModel")]
public void ReceiveIpcMessage_NewFieldAdded_DeserializesWithoutBreakingExistingFields()
```
## Note on Color Assertions
Color tests use `AppLaunching.StateColorValue` (returns `Windows.UI.Color`) instead of
`StateColor` (returns `SolidColorBrush`) because WinUI brush creation requires a UI thread.
The `StateColorValue` property exposes the same ARGB values for headless test validation.

View File

@@ -0,0 +1,343 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Globalization;
using System.Linq;
using System.Text;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using WorkspacesLauncherUI.Data;
using WorkspacesLauncherUI.Models;
using WorkspacesLauncherUI.ViewModels;
namespace WorkspacesLauncherUI.UnitTests
{
/// <summary>
/// End-to-end scenario tests that simulate complete user workflows
/// through the Launcher UI. These verify the full pipeline:
/// IPC JSON message → Deserialization → ViewModel → Model properties.
/// </summary>
[TestClass]
public class UserWorkflowIntegrationTests
{
[TestMethod]
[TestCategory("Scenario")]
public void UserLaunchesWorkspace_ThreeApps_AllProgressFromWaitingToSuccess()
{
using var vm = new MainViewModel();
SimulateIpcMessage(BuildMessage(
1234,
App("Visual Studio Code", @"C:\Code.exe", LaunchingState.Waiting),
App("Windows Terminal", @"C:\wt.exe", LaunchingState.Waiting),
App("Microsoft Edge", @"C:\edge.exe", LaunchingState.Waiting)));
Assert.AreEqual(3, vm.AppsListed.Count);
Assert.IsTrue(vm.AppsListed.All(a => a.Loading), "All apps should show loading spinner initially");
SimulateIpcMessage(BuildMessage(
1234,
App("Visual Studio Code", @"C:\Code.exe", LaunchingState.Launched),
App("Windows Terminal", @"C:\wt.exe", LaunchingState.Waiting),
App("Microsoft Edge", @"C:\edge.exe", LaunchingState.Waiting)));
Assert.IsTrue(vm.AppsListed[0].Loading, "Launched but not yet moved — still loading");
SimulateIpcMessage(BuildMessage(
1234,
App("Visual Studio Code", @"C:\Code.exe", LaunchingState.LaunchedAndMoved),
App("Windows Terminal", @"C:\wt.exe", LaunchingState.Launched),
App("Microsoft Edge", @"C:\edge.exe", LaunchingState.Waiting)));
Assert.IsFalse(vm.AppsListed[0].Loading, "Moved app should stop loading");
Assert.AreEqual("\U0000F78C", vm.AppsListed[0].StateGlyph, "Moved app should show checkmark");
SimulateIpcMessage(BuildMessage(
1234,
App("Visual Studio Code", @"C:\Code.exe", LaunchingState.LaunchedAndMoved),
App("Windows Terminal", @"C:\wt.exe", LaunchingState.LaunchedAndMoved),
App("Microsoft Edge", @"C:\edge.exe", LaunchingState.LaunchedAndMoved)));
Assert.IsTrue(vm.AppsListed.All(a => !a.Loading), "All apps should stop loading");
Assert.IsTrue(vm.AppsListed.All(a => a.StateGlyph == "\U0000F78C"), "All apps should show checkmark");
}
[TestMethod]
[TestCategory("Scenario")]
public void UserLaunchesWorkspace_OneAppMissing_FailedShowsRedOthersShowGreen()
{
using var vm = new MainViewModel();
SimulateIpcMessage(BuildMessage(
1234,
App("Notepad", @"C:\Windows\notepad.exe", LaunchingState.LaunchedAndMoved),
App("Missing App", @"C:\nonexistent\app.exe", LaunchingState.Failed),
App("Calculator", @"C:\Windows\calc.exe", LaunchingState.LaunchedAndMoved)));
Assert.IsFalse(vm.AppsListed[0].Loading);
Assert.AreEqual("\U0000F78C", vm.AppsListed[0].StateGlyph);
Assert.IsFalse(vm.AppsListed[1].Loading);
Assert.AreEqual("\U0000EF2C", vm.AppsListed[1].StateGlyph);
var color = vm.AppsListed[1].StateColorValue;
Assert.AreEqual(254, color.R);
Assert.AreEqual("\U0000F78C", vm.AppsListed[2].StateGlyph);
}
[TestMethod]
[TestCategory("Scenario")]
public void UserCancelsLaunch_MidProgress_PartialAppsShowCanceledState()
{
using var vm = new MainViewModel();
SimulateIpcMessage(BuildMessage(
5678,
App("App1", @"C:\app1.exe", LaunchingState.LaunchedAndMoved),
App("App2", @"C:\app2.exe", LaunchingState.Canceled),
App("App3", @"C:\app3.exe", LaunchingState.Canceled)));
Assert.AreEqual(LaunchingState.LaunchedAndMoved, vm.AppsListed[0].LaunchState);
Assert.AreEqual(LaunchingState.Canceled, vm.AppsListed[1].LaunchState);
Assert.AreEqual(LaunchingState.Canceled, vm.AppsListed[2].LaunchState);
}
[TestMethod]
[TestCategory("Scenario")]
public void UserLaunchesWorkspace_SingleApp_CompletesFullLifecycle()
{
using var vm = new MainViewModel();
SimulateIpcMessage(BuildMessage(
100,
App("Notepad", @"C:\Windows\System32\notepad.exe", LaunchingState.Waiting)));
Assert.AreEqual(1, vm.AppsListed.Count);
Assert.AreEqual("Notepad", vm.AppsListed[0].Name);
Assert.IsTrue(vm.AppsListed[0].Loading);
SimulateIpcMessage(BuildMessage(
100,
App("Notepad", @"C:\Windows\System32\notepad.exe", LaunchingState.LaunchedAndMoved)));
Assert.IsFalse(vm.AppsListed[0].Loading);
}
[TestMethod]
[TestCategory("Scenario")]
public void UserLaunchesWorkspace_ChromeAndEdgePwa_PwaIdsPreserved()
{
using var vm = new MainViewModel();
SimulateIpcMessage(BuildMessageFull(
300,
AppFull("Gmail", @"C:\chrome.exe", string.Empty, string.Empty, "fmgjjmmmlfnkbppncijlocphclkkleod", LaunchingState.LaunchedAndMoved),
AppFull("Teams", @"C:\edge.exe", string.Empty, string.Empty, "cifhbcnohmdccbgoicgdjpfamggdegmo", LaunchingState.Launched)));
Assert.AreEqual("fmgjjmmmlfnkbppncijlocphclkkleod", vm.AppsListed[0].PwaAppId);
Assert.AreEqual("cifhbcnohmdccbgoicgdjpfamggdegmo", vm.AppsListed[1].PwaAppId);
}
[TestMethod]
[TestCategory("Scenario")]
public void UserLaunchesWorkspace_AdminApp_ElevatedFlagPreservedInUi()
{
using var vm = new MainViewModel();
string message = @"{
""processId"": 400,
""apps"": {
""appLaunchInfos"": [
{
""application"": {
""application"": ""Command Prompt (Admin)"",
""application-path"": ""C:\\Windows\\System32\\cmd.exe"",
""title"": ""Administrator: Command Prompt"",
""package-full-name"": """",
""app-user-model-id"": """",
""pwa-app-id"": """",
""command-line-arguments"": """",
""is-elevated"": true,
""can-launch-elevated"": true,
""minimized"": false,
""maximized"": false,
""position"": { ""X"": 0, ""Y"": 0, ""width"": 800, ""height"": 600 },
""monitor"": 0
},
""state"": 2
}
]
}
}";
SimulateIpcMessage(message);
Assert.AreEqual("Command Prompt (Admin)", vm.AppsListed[0].Name);
}
[TestMethod]
[TestCategory("Scenario")]
public void UserLaunchesWorkspace_FifteenApps_AllAppsDisplayedWithLoadingState()
{
using var vm = new MainViewModel();
var apps = new (string Name, string Path, LaunchingState State)[15];
for (int i = 0; i < 15; i++)
{
apps[i] = ($"App {i}", $@"C:\app{i}.exe", LaunchingState.Waiting);
}
SimulateIpcMessage(BuildMessage(500, apps));
Assert.AreEqual(15, vm.AppsListed.Count);
for (int i = 0; i < 15; i++)
{
Assert.AreEqual($"App {i}", vm.AppsListed[i].Name);
Assert.IsTrue(vm.AppsListed[i].Loading);
}
}
[TestMethod]
[TestCategory("Scenario")]
public void UserLaunchesWorkspace_AllAppsMissing_AllShowRedErrorState()
{
using var vm = new MainViewModel();
SimulateIpcMessage(BuildMessage(
800,
App("App1", @"C:\missing1.exe", LaunchingState.Failed),
App("App2", @"C:\missing2.exe", LaunchingState.Failed)));
Assert.IsTrue(vm.AppsListed.All(a => !a.Loading), "Failed apps should not show loading");
Assert.IsTrue(vm.AppsListed.All(a => a.StateGlyph == "\U0000EF2C"), "Failed apps should show error glyph");
}
[TestMethod]
[TestCategory("Scenario")]
public void UserLaunchesWorkspace_UwpStoreApp_PackageFieldsMappedToUi()
{
using var vm = new MainViewModel();
SimulateIpcMessage(BuildMessageFull(
900,
AppFull(
"Windows Settings",
@"C:\Program Files\WindowsApps\windows.immersivecontrolpanel\SystemSettings.exe",
"windows.immersivecontrolpanel_10.0.0.0_neutral_cw5n1h2txyewy",
"windows.immersivecontrolpanel_cw5n1h2txyewy!microsoft.windows.immersivecontrolpanel",
string.Empty,
LaunchingState.LaunchedAndMoved)));
Assert.AreEqual("Windows Settings", vm.AppsListed[0].Name);
Assert.AreEqual("windows.immersivecontrolpanel_10.0.0.0_neutral_cw5n1h2txyewy", vm.AppsListed[0].PackagedName);
}
[TestMethod]
[TestCategory("Scenario")]
public void UserLaunchesWorkspace_RapidIpcUpdates_FinalStateIsDisplayed()
{
using var vm = new MainViewModel();
for (int i = 0; i <= 4; i++)
{
SimulateIpcMessage(BuildMessage(
1000,
App("App", @"C:\app.exe", (LaunchingState)Math.Min(i, 2))));
}
Assert.AreEqual(1, vm.AppsListed.Count);
Assert.AreEqual(LaunchingState.LaunchedAndMoved, vm.AppsListed[0].LaunchState);
}
[TestMethod]
[TestCategory("Scenario")]
public void UserLaunchesWorkspace_Win32AndPackagedAndPwa_AllTypesCoexistInList()
{
using var vm = new MainViewModel();
SimulateIpcMessage(BuildMessageFull(
1100,
AppFull("Notepad", @"C:\Windows\notepad.exe", string.Empty, string.Empty, string.Empty, LaunchingState.LaunchedAndMoved),
AppFull("Terminal", @"C:\wt.exe", "Microsoft.WindowsTerminal_1.0_x64__8wekyb3d8bbwe", "Microsoft.WindowsTerminal_8wekyb3d8bbwe!App", string.Empty, LaunchingState.LaunchedAndMoved),
AppFull("Outlook", @"C:\edge.exe", string.Empty, string.Empty, "pwa_outlook_id", LaunchingState.Launched)));
Assert.AreEqual(3, vm.AppsListed.Count);
Assert.AreEqual(string.Empty, vm.AppsListed[0].PwaAppId);
Assert.AreEqual("Microsoft.WindowsTerminal_1.0_x64__8wekyb3d8bbwe", vm.AppsListed[1].PackagedName);
Assert.AreEqual("pwa_outlook_id", vm.AppsListed[2].PwaAppId);
}
[TestMethod]
[TestCategory("Scenario")]
public void UserLaunchesWorkspace_FiveUpdates_UiRefreshedOnEveryIpcMessage()
{
using var vm = new MainViewModel();
int fireCount = 0;
vm.PropertyChanged += (s, e) =>
{
if (e.PropertyName == "AppsListed")
{
fireCount++;
}
};
for (int i = 0; i < 5; i++)
{
SimulateIpcMessage(BuildMessage(
1200,
App("App", @"C:\app.exe", (LaunchingState)Math.Min(i, 2))));
}
Assert.AreEqual(5, fireCount, "PropertyChanged should fire once per IPC message");
}
private static (string Name, string Path, LaunchingState State) App(string name, string path, LaunchingState state)
{
return (name, path, state);
}
private static (string Name, string Path, string PackageFullName, string Aumid, string PwaAppId, LaunchingState State) AppFull(
string name, string path, string packageFullName, string aumid, string pwaAppId, LaunchingState state)
{
return (name, path, packageFullName, aumid, pwaAppId, state);
}
private static void SimulateIpcMessage(string message)
{
WorkspacesLauncherUI.App.IPCMessageReceivedCallback?.Invoke(message);
}
private static string BuildMessage(
int processId,
params (string Name, string Path, LaunchingState State)[] apps)
{
var fullApps = apps.Select(a => (a.Name, a.Path, string.Empty, string.Empty, string.Empty, a.State)).ToArray();
return BuildMessageFull(processId, fullApps);
}
private static string BuildMessageFull(
int processId,
params (string Name, string Path, string PackageFullName, string Aumid, string PwaAppId, LaunchingState State)[] apps)
{
var sb = new StringBuilder();
sb.Append(CultureInfo.InvariantCulture, $@"{{ ""processId"": {processId}, ""apps"": {{ ""appLaunchInfos"": [");
for (int i = 0; i < apps.Length; i++)
{
if (i > 0)
{
sb.Append(',');
}
var (name, path, packageFullName, aumid, pwaAppId, state) = apps[i];
string escapedPath = path.Replace(@"\", @"\\");
string appJson = string.Create(CultureInfo.InvariantCulture, $@"{{""application"": {{""application"": ""{name}"",""application-path"": ""{escapedPath}"",""title"": """",""package-full-name"": ""{packageFullName}"",""app-user-model-id"": ""{aumid}"",""pwa-app-id"": ""{pwaAppId}"",""command-line-arguments"": """",""is-elevated"": false,""can-launch-elevated"": false,""minimized"": false,""maximized"": false,""position"": {{ ""X"": 0, ""Y"": 0, ""width"": 100, ""height"": 100 }},""monitor"": 0}},""state"": {(int)state}}}");
sb.Append(appJson);
}
sb.Append("]}}");
return sb.ToString();
}
}
}

View File

@@ -0,0 +1,155 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.VisualStudio.TestTools.UnitTesting;
using WorkspacesLauncherUI.Data;
namespace WorkspacesLauncherUI.UnitTests
{
/// <summary>
/// Tests for PositionWrapper struct equality and operator behavior.
/// </summary>
[TestClass]
public class WindowPositionDataTests
{
[TestMethod]
[TestCategory("DataModel")]
public void PositionEquality_IdenticalCoordinates_ReturnsTrue()
{
var pos1 = new PositionWrapper { X = 100, Y = 200, Width = 800, Height = 600 };
var pos2 = new PositionWrapper { X = 100, Y = 200, Width = 800, Height = 600 };
Assert.IsTrue(pos1 == pos2);
}
[TestMethod]
[TestCategory("DataModel")]
public void PositionEquality_DifferentXCoordinate_ReturnsFalse()
{
var pos1 = new PositionWrapper { X = 100, Y = 200, Width = 800, Height = 600 };
var pos2 = new PositionWrapper { X = 101, Y = 200, Width = 800, Height = 600 };
Assert.IsFalse(pos1 == pos2);
}
[TestMethod]
[TestCategory("DataModel")]
public void PositionEquality_DifferentYCoordinate_ReturnsFalse()
{
var pos1 = new PositionWrapper { X = 100, Y = 200, Width = 800, Height = 600 };
var pos2 = new PositionWrapper { X = 100, Y = 201, Width = 800, Height = 600 };
Assert.IsFalse(pos1 == pos2);
}
[TestMethod]
[TestCategory("DataModel")]
public void PositionEquality_DifferentWidth_ReturnsFalse()
{
var pos1 = new PositionWrapper { X = 100, Y = 200, Width = 800, Height = 600 };
var pos2 = new PositionWrapper { X = 100, Y = 200, Width = 801, Height = 600 };
Assert.IsFalse(pos1 == pos2);
}
[TestMethod]
[TestCategory("DataModel")]
public void PositionEquality_DifferentHeight_ReturnsFalse()
{
var pos1 = new PositionWrapper { X = 100, Y = 200, Width = 800, Height = 600 };
var pos2 = new PositionWrapper { X = 100, Y = 200, Width = 800, Height = 601 };
Assert.IsFalse(pos1 == pos2);
}
[TestMethod]
[TestCategory("DataModel")]
public void PositionInequality_DifferentCoordinates_ReturnsTrue()
{
var pos1 = new PositionWrapper { X = 0, Y = 0, Width = 1920, Height = 1080 };
var pos2 = new PositionWrapper { X = 960, Y = 0, Width = 960, Height = 1080 };
Assert.IsTrue(pos1 != pos2);
}
[TestMethod]
[TestCategory("DataModel")]
public void PositionInequality_IdenticalCoordinates_ReturnsFalse()
{
var pos1 = new PositionWrapper { X = 0, Y = 0, Width = 1920, Height = 1080 };
var pos2 = new PositionWrapper { X = 0, Y = 0, Width = 1920, Height = 1080 };
Assert.IsFalse(pos1 != pos2);
}
[TestMethod]
[TestCategory("DataModel")]
public void PositionEquals_BoxedIdenticalValues_ReturnsTrue()
{
var pos1 = new PositionWrapper { X = 100, Y = 200, Width = 800, Height = 600 };
object pos2 = new PositionWrapper { X = 100, Y = 200, Width = 800, Height = 600 };
Assert.IsTrue(pos1.Equals(pos2));
}
[TestMethod]
[TestCategory("DataModel")]
public void PositionEquals_NullComparison_ReturnsFalse()
{
var pos = new PositionWrapper { X = 0, Y = 0, Width = 100, Height = 100 };
Assert.IsFalse(pos.Equals(null));
}
[TestMethod]
[TestCategory("DataModel")]
public void PositionEquals_DifferentObjectType_ReturnsFalse()
{
var pos = new PositionWrapper { X = 0, Y = 0, Width = 100, Height = 100 };
Assert.IsFalse(pos.Equals("not a position"));
}
[TestMethod]
[TestCategory("DataModel")]
public void WindowPosition_LeftOfPrimaryMonitor_StoresNegativeCoordinates()
{
var pos = new PositionWrapper { X = -1920, Y = -200, Width = 1920, Height = 1080 };
Assert.AreEqual(-1920, pos.X);
Assert.AreEqual(-200, pos.Y);
}
[TestMethod]
[TestCategory("DataModel")]
public void WindowPosition_AllZeroValues_IsValidState()
{
var pos = new PositionWrapper { X = 0, Y = 0, Width = 0, Height = 0 };
Assert.AreEqual(0, pos.X);
Assert.AreEqual(0, pos.Y);
Assert.AreEqual(0, pos.Width);
Assert.AreEqual(0, pos.Height);
}
[TestMethod]
[TestCategory("DataModel")]
public void WindowPosition_FourthMonitor4K_StoresLargeCoordinates()
{
var pos = new PositionWrapper { X = 11520, Y = 0, Width = 3840, Height = 2160 };
Assert.AreEqual(11520, pos.X);
Assert.AreEqual(3840, pos.Width);
Assert.AreEqual(2160, pos.Height);
}
[TestMethod]
[TestCategory("DataModel")]
public void WindowPosition_DefaultStruct_AllFieldsAreZero()
{
PositionWrapper pos = default;
Assert.AreEqual(0, pos.X);
Assert.AreEqual(0, pos.Y);
Assert.AreEqual(0, pos.Width);
Assert.AreEqual(0, pos.Height);
}
[TestMethod]
[TestCategory("DataModel")]
public void WindowPosition_TwoDefaultStructs_AreConsideredEqual()
{
PositionWrapper pos1 = default;
PositionWrapper pos2 = default;
Assert.IsTrue(pos1 == pos2);
Assert.IsTrue(pos1.Equals(pos2));
}
}
}

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
<Import Project="$(RepoRoot)src\Common.SelfContained.props" />
<PropertyGroup>
<IsPackable>false</IsPackable>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\tests\WorkspacesLauncherUI.Tests\</OutputPath>
<RootNamespace>WorkspacesLauncherUI.UnitTests</RootNamespace>
<AssemblyName>PowerToys.WorkspacesLauncherUI.Tests</AssemblyName>
<OutputType>Exe</OutputType>
<UseWinUI>true</UseWinUI>
<Platforms>x64;ARM64</Platforms>
<WindowsPackageType>None</WindowsPackageType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="Microsoft.WindowsAppSDK" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" />
<PackageReference Include="Moq" />
<PackageReference Include="MSTest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\WorkspacesCsharpLibrary\WorkspacesCsharpLibrary.csproj" />
<ProjectReference Include="..\WorkspacesLauncherUI.WinUI\WorkspacesLauncherUI.WinUI.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,50 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.ComponentModel;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
using Microsoft.UI.Xaml.Media.Imaging;
namespace WorkspacesLauncherUI.Helpers
{
internal static class IconHelper
{
public static BitmapImage TryGetExecutableIcon(string path)
{
if (string.IsNullOrEmpty(path) || !File.Exists(path))
{
return null;
}
try
{
using Icon icon = Icon.ExtractAssociatedIcon(path);
if (icon is null)
{
return null;
}
using Bitmap bitmap = icon.ToBitmap();
using MemoryStream stream = new();
bitmap.Save(stream, ImageFormat.Png);
stream.Position = 0;
BitmapImage bitmapImage = new();
bitmapImage.SetSource(stream.AsRandomAccessStream());
return bitmapImage;
}
catch (Exception ex) when (ex is FileNotFoundException
or UnauthorizedAccessException
or Win32Exception
or ArgumentException
or IOException)
{
return null;
}
}
}
}

View File

@@ -0,0 +1,35 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using ManagedCommon;
using Microsoft.Windows.ApplicationModel.Resources;
namespace WorkspacesLauncherUI
{
internal static class ResourceLoaderInstance
{
private static ResourceLoader _resourceLoader;
internal static ResourceLoader ResourceLoader
{
get
{
if (_resourceLoader == null)
{
try
{
_resourceLoader = new ResourceLoader("PowerToys.WorkspacesLauncherUI.pri");
}
catch (Exception ex)
{
Logger.LogError("Failed to load ResourceLoader: " + ex.Message);
}
}
return _resourceLoader;
}
}
}
}

View File

@@ -0,0 +1,74 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Media.Imaging;
using WorkspacesLauncherUI.Data;
namespace WorkspacesLauncherUI.Models
{
/// <summary>
/// Model representing an application's launch status in the Launcher UI.
/// Drives the display of the spinner (Loading), checkmark/X glyph (StateGlyph),
/// and color (StateColor) for each app row.
/// </summary>
public partial class AppLaunching : ObservableObject
{
public bool Loading => LaunchState == LaunchingState.Waiting || LaunchState == LaunchingState.Launched;
public string Name { get; set; }
public string AppPath { get; set; }
public BitmapImage IconImage { get; set; }
public string PackagedName { get; set; }
public string Aumid { get; set; }
public string PwaAppId { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Loading))]
[NotifyPropertyChangedFor(nameof(StateGlyph))]
[NotifyPropertyChangedFor(nameof(StateColor))]
[NotifyPropertyChangedFor(nameof(StateColorValue))]
private LaunchingState _launchState;
partial void OnLaunchStateChanged(LaunchingState value)
{
_stateColorBrush = null;
}
public string StateGlyph
{
get => LaunchState switch
{
LaunchingState.LaunchedAndMoved => "\U0000F78C",
LaunchingState.Failed => "\U0000EF2C",
_ => "\U0000EF2C",
};
}
private SolidColorBrush _stateColorBrush;
public Brush StateColor
{
get => _stateColorBrush ??= new SolidColorBrush(StateColorValue);
}
public Windows.UI.Color StateColorValue
{
get => LaunchState switch
{
LaunchingState.LaunchedAndMoved => Windows.UI.Color.FromArgb(255, 0, 128, 0),
LaunchingState.Failed => Windows.UI.Color.FromArgb(255, 254, 0, 0),
_ => Windows.UI.Color.FromArgb(255, 254, 0, 0),
};
}
}
}

View File

@@ -0,0 +1,46 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Threading;
using ManagedCommon;
using Microsoft.UI.Dispatching;
namespace WorkspacesLauncherUI
{
public static class Program
{
[STAThread]
public static void Main(string[] args)
{
Logger.InitializeLogger("\\Workspaces\\WorkspacesLauncherUI");
WinRT.ComWrappersSupport.InitializeComWrappers();
if (PowerToys.GPOWrapper.GPOWrapper.GetConfiguredWorkspacesEnabledValue() == PowerToys.GPOWrapper.GpoRuleConfigured.Disabled)
{
Logger.LogWarning("Tried to start with a GPO policy setting the utility to always be disabled. Please contact your systems administrator.");
return;
}
const string mutexName = "Local\\PowerToys_Workspaces_LauncherUI_InstanceMutex";
bool createdNew;
using var mutex = new Mutex(true, mutexName, out createdNew);
if (!createdNew)
{
Logger.LogWarning("Another instance of Workspaces Launcher UI is already running. Exiting this instance.");
return;
}
Microsoft.UI.Xaml.Application.Start((p) =>
{
var context = new DispatcherQueueSynchronizationContext(DispatcherQueue.GetForCurrentThread());
SynchronizationContext.SetSynchronizationContext(context);
_ = new App();
});
}
}
}

View File

@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="CancelButton.Content" xml:space="preserve">
<value>Cancel launch</value>
</data>
<data name="CancelButton.AutomationProperties.Name" xml:space="preserve">
<value>Cancel launch</value>
</data>
<data name="DismissButton.Content" xml:space="preserve">
<value>Dismiss</value>
</data>
<data name="DismissButton.AutomationProperties.Name" xml:space="preserve">
<value>Dismiss</value>
</data>
<data name="LauncherWindowTitle" xml:space="preserve">
<value>Your workspace is launching. Waiting on ...</value>
</data>
</root>

View File

@@ -5,35 +5,30 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ManagedCommon;
using WorkspacesCsharpLibrary;
using WorkspacesLauncherUI.Data;
using WorkspacesLauncherUI.Helpers;
using WorkspacesLauncherUI.Models;
namespace WorkspacesLauncherUI.ViewModels
{
public class MainViewModel : INotifyPropertyChanged, IDisposable
public partial class MainViewModel : ObservableObject, IDisposable
{
public ObservableCollection<AppLaunching> AppsListed { get; set; } = new ObservableCollection<AppLaunching>();
private readonly PwaHelper _pwaHelper;
private bool _isDisposed;
private StatusWindow _snapshotWindow;
private int launcherProcessID;
private PwaHelper _pwaHelper;
public event PropertyChangedEventHandler PropertyChanged;
public void OnPropertyChanged(PropertyChangedEventArgs e)
{
PropertyChanged?.Invoke(this, e);
}
[ObservableProperty]
private ObservableCollection<AppLaunching> _appsListed = new ObservableCollection<AppLaunching>();
public MainViewModel()
{
_pwaHelper = new PwaHelper();
// receive IPC Message
App.IPCMessageReceivedCallback = (string msg) =>
{
try
@@ -51,7 +46,6 @@ namespace WorkspacesLauncherUI.ViewModels
private void HandleAppLaunchingState(AppLaunchData.AppLaunchDataWrapper appLaunchData)
{
launcherProcessID = appLaunchData.LauncherProcessID;
List<AppLaunching> appLaunchingList = new List<AppLaunching>();
foreach (var app in appLaunchData.AppLaunchInfos.AppLaunchInfoList)
{
@@ -59,6 +53,7 @@ namespace WorkspacesLauncherUI.ViewModels
{
Name = app.Application.Application,
AppPath = app.Application.ApplicationPath,
IconImage = IconHelper.TryGetExecutableIcon(app.Application.ApplicationPath),
PackagedName = app.Application.PackageFullName,
Aumid = app.Application.AppUserModelId,
PwaAppId = app.Application.PwaAppId,
@@ -67,30 +62,28 @@ namespace WorkspacesLauncherUI.ViewModels
}
AppsListed = new ObservableCollection<AppLaunching>(appLaunchingList);
OnPropertyChanged(new PropertyChangedEventArgs(nameof(AppsListed)));
}
private void SelfDestroy(object source, System.Timers.ElapsedEventArgs e)
[RelayCommand]
private void CancelLaunch()
{
_snapshotWindow.Dispatcher.Invoke(() =>
{
_snapshotWindow.Close();
});
App.SendIPCMessage("cancel");
}
[RelayCommand]
private void Dismiss()
{
// Window close is handled by the view
}
public void Dispose()
{
if (!_isDisposed)
{
_isDisposed = true;
}
GC.SuppressFinalize(this);
}
internal void SetSnapshotWindow(StatusWindow snapshotWindow)
{
_snapshotWindow = snapshotWindow;
}
internal void CancelLaunch()
{
App.SendIPCMessage("cancel");
}
}
}

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8" ?>
<Application
x:Class="WorkspacesLauncherUI.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:WorkspacesLauncherUI">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>

View File

@@ -0,0 +1,96 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Globalization;
using ManagedCommon;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using PowerToys.Interop;
namespace WorkspacesLauncherUI
{
/// <summary>
/// WinUI 3 Application class for the Workspaces Launcher UI.
/// Manages the IPC pipe connection to the C++ launcher engine and hosts the status window.
/// </summary>
public partial class App : Application, IDisposable
{
private StatusWindow _mainWindow;
private TwoWayPipeMessageIPCManaged _ipcManager;
private bool _isDisposed;
public static Action<string> IPCMessageReceivedCallback { get; set; }
public static DispatcherQueue DispatcherQueue { get; private set; }
public App()
{
string languageTag = LanguageHelper.LoadLanguage();
if (!string.IsNullOrEmpty(languageTag))
{
try
{
Microsoft.Windows.Globalization.ApplicationLanguages.PrimaryLanguageOverride = languageTag;
}
catch (Exception ex)
{
Logger.LogError("Failed to set language override: " + ex.Message);
}
}
this.InitializeComponent();
this.UnhandledException += OnUnhandledException;
}
public static void SendIPCMessage(string message)
{
if ((Current as App)?._ipcManager != null)
{
(Current as App)._ipcManager.Send(message);
}
}
protected override void OnLaunched(LaunchActivatedEventArgs args)
{
DispatcherQueue = DispatcherQueue.GetForCurrentThread();
_ipcManager = new TwoWayPipeMessageIPCManaged(
"\\\\.\\pipe\\powertoys_workspaces_ui_",
"\\\\.\\pipe\\powertoys_workspaces_launcher_ui_",
(string message) =>
{
if (IPCMessageReceivedCallback != null && message.Length > 0)
{
DispatcherQueue.TryEnqueue(() =>
{
IPCMessageReceivedCallback(message);
});
}
});
_ipcManager.Start();
_mainWindow = new StatusWindow();
_mainWindow.Activate();
}
private void OnUnhandledException(object sender, Microsoft.UI.Xaml.UnhandledExceptionEventArgs e)
{
Logger.LogError("Unhandled exception occurred", e.Exception);
}
public void Dispose()
{
if (!_isDisposed)
{
_ipcManager?.End();
_ipcManager?.Dispose();
_isDisposed = true;
}
GC.SuppressFinalize(this);
}
}
}

View File

@@ -0,0 +1,106 @@
<?xml version="1.0" encoding="utf-8" ?>
<Page
x:Class="WorkspacesLauncherUI.Views.StatusPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:models="using:WorkspacesLauncherUI.Models"
xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls"
mc:Ignorable="d">
<Grid Margin="16">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<ScrollViewer
Grid.ColumnSpan="2"
AutomationProperties.Name="Application launch status list"
TabIndex="0">
<StackPanel AutomationProperties.AccessibilityView="Content" AutomationProperties.LiveSetting="Polite">
<ItemsControl AutomationProperties.Name="Applications" ItemsSource="{x:Bind ViewModel.AppsListed, Mode=OneWay}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="models:AppLaunching">
<Grid
Margin="0,4"
Padding="4"
AutomationProperties.Name="{x:Bind Name, Mode=OneWay}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Image
Width="20"
Height="20"
Margin="4,0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Source="{x:Bind IconImage}" />
<TextBlock
Grid.Column="1"
VerticalAlignment="Center"
AutomationProperties.AccessibilityView="Raw"
Text="{x:Bind Name, Mode=OneWay}" />
<tkcontrols:SwitchPresenter
Grid.Column="2"
Margin="8"
HorizontalAlignment="Center"
VerticalAlignment="Center"
TargetType="x:Boolean"
Value="{x:Bind Loading, Mode=OneWay}">
<tkcontrols:Case Value="True">
<ProgressRing
Width="20"
Height="20"
AutomationProperties.Name="Loading"
IsActive="True" />
</tkcontrols:Case>
<tkcontrols:Case Value="False">
<TextBlock
Width="20"
Height="20"
HorizontalAlignment="Center"
VerticalAlignment="Center"
AutomationProperties.AccessibilityView="Raw"
FontFamily="{ThemeResource SymbolThemeFontFamily}"
FontSize="20"
Foreground="{x:Bind StateColor, Mode=OneWay}"
Text="{x:Bind StateGlyph, Mode=OneWay}" />
</tkcontrols:Case>
</tkcontrols:SwitchPresenter>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
<Button
x:Name="DismissButton"
x:Uid="DismissButton"
Grid.Row="1"
Margin="0,16,4,0"
HorizontalAlignment="Stretch"
Click="DismissButton_Click"
Style="{ThemeResource AccentButtonStyle}"
TabIndex="1" />
<Button
x:Name="CancelButton"
x:Uid="CancelButton"
Grid.Row="1"
Grid.Column="1"
Margin="4,16,0,0"
HorizontalAlignment="Stretch"
Click="CancelButton_Click"
Command="{x:Bind ViewModel.CancelLaunchCommand}"
TabIndex="2" />
</Grid>
</Page>

View File

@@ -0,0 +1,45 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using WorkspacesLauncherUI.ViewModels;
namespace WorkspacesLauncherUI.Views
{
/// <summary>
/// Page hosting the workspace launch progress content.
/// Displays a list of apps with their launch state (loading/success/failed).
/// Hosted inside <see cref="StatusWindow"/> so the content can use x:Bind.
/// </summary>
[System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA1001:Types that own disposable fields should be disposable", Justification = "WinUI Page does not support IDisposable; ViewModel is disposed by the hosting window on close.")]
public sealed partial class StatusPage : Page
{
public MainViewModel ViewModel { get; }
/// <summary>
/// Raised when the user clicks Cancel or Dismiss and the hosting window should close.
/// </summary>
public event EventHandler CloseRequested;
public StatusPage()
{
ViewModel = new MainViewModel();
this.InitializeComponent();
}
private void CancelButton_Click(object sender, RoutedEventArgs e)
{
CloseRequested?.Invoke(this, EventArgs.Empty);
}
private void DismissButton_Click(object sender, RoutedEventArgs e)
{
CloseRequested?.Invoke(this, EventArgs.Empty);
}
}
}

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8" ?>
<winuiex:WindowEx
x:Class="WorkspacesLauncherUI.StatusWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="using:WorkspacesLauncherUI.Views"
xmlns:winuiex="using:WinUIEx"
Title="Workspaces"
Width="360"
Height="360"
IsAlwaysOnTop="True"
IsMaximizable="False"
IsMinimizable="False"
IsResizable="False"
mc:Ignorable="d">
<Window.SystemBackdrop>
<MicaBackdrop />
</Window.SystemBackdrop>
<Grid x:Name="RootGrid">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<TitleBar x:Name="AppTitleBar" IsTabStop="False">
<TitleBar.IconSource>
<ImageIconSource ImageSource="/Assets/Workspaces/Workspaces.ico" />
</TitleBar.IconSource>
</TitleBar>
<views:StatusPage x:Name="StatusPageView" Grid.Row="1" />
</Grid>
</winuiex:WindowEx>

View File

@@ -0,0 +1,62 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using ManagedCommon;
using Microsoft.UI.Xaml;
using WinUIEx;
using WorkspacesLauncherUI.Views;
namespace WorkspacesLauncherUI
{
/// <summary>
/// Status window showing workspace launch progress.
/// Hosts <see cref="StatusPage"/> which owns the ViewModel and renders the app list.
/// </summary>
public sealed partial class StatusWindow : WindowEx
{
public StatusWindow()
{
this.InitializeComponent();
ExtendsContentIntoTitleBar = true;
SetTitleBar(AppTitleBar);
AppWindow.SetIcon("Assets/Workspaces/Workspaces.ico");
// Set title from resources
string title;
try
{
title = ResourceLoaderInstance.ResourceLoader?.GetString("LauncherWindowTitle") ?? "Workspaces";
}
catch (Exception ex)
{
Logger.LogError("Failed to load window title resource: " + ex.Message);
title = "Workspaces";
}
this.Title = title;
AppTitleBar.Title = title;
StatusPageView.CloseRequested += StatusPage_CloseRequested;
this.Closed += Window_Closed;
this.CenterOnScreen();
}
private void StatusPage_CloseRequested(object sender, EventArgs e)
{
Close();
}
private void Window_Closed(object sender, WindowEventArgs args)
{
StatusPageView.ViewModel?.Dispose();
(Application.Current as IDisposable)?.Dispose();
}
}
}

View File

@@ -0,0 +1,67 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
<Import Project="$(RepoRoot)src\Common.SelfContained.props" />
<PropertyGroup>
<OutputType>WinExe</OutputType>
<RootNamespace>WorkspacesLauncherUI</RootNamespace>
<ApplicationManifest>app.manifest</ApplicationManifest>
<UseWinUI>true</UseWinUI>
<Platforms>x64;ARM64</Platforms>
<EnableMsixTooling>true</EnableMsixTooling>
<EnablePreviewMsixTooling>true</EnablePreviewMsixTooling>
<WindowsPackageType>None</WindowsPackageType>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
<DefineConstants>DISABLE_XAML_GENERATED_MAIN,TRACE</DefineConstants>
<OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps</OutputPath>
<AssemblyName>PowerToys.WorkspacesLauncherUI</AssemblyName>
<ApplicationIcon>..\Assets\Workspaces\Workspaces.ico</ApplicationIcon>
<ProjectPriFileName>PowerToys.WorkspacesLauncherUI.pri</ProjectPriFileName>
<GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
</PropertyGroup>
<ItemGroup>
<Page Remove="Views\App.xaml" />
</ItemGroup>
<ItemGroup>
<ApplicationDefinition Include="Views\App.xaml" />
</ItemGroup>
<!-- See https://learn.microsoft.com/windows/apps/develop/platform/csharp-winrt/net-projection-from-cppwinrt-component for more info -->
<PropertyGroup>
<CsWinRTIncludes>PowerToys.GPOWrapper</CsWinRTIncludes>
<CsWinRTGeneratedFilesDir>$(OutDir)</CsWinRTGeneratedFilesDir>
<ErrorOnDuplicatePublishOutputFiles>false</ErrorOnDuplicatePublishOutputFiles>
</PropertyGroup>
<ItemGroup>
<Content Include="..\Assets\**\*.*">
<Link>Assets\Workspaces\%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\common\Common.UI\Common.UI.csproj" />
<ProjectReference Include="..\..\..\common\GPOWrapper\GPOWrapper.vcxproj" />
<ProjectReference Include="..\..\..\common\ManagedCommon\ManagedCommon.csproj" />
<ProjectReference Include="..\..\..\common\ManagedTelemetry\Telemetry\ManagedTelemetry.csproj" />
<ProjectReference Include="..\..\..\common\interop\PowerToys.Interop.vcxproj" />
<ProjectReference Include="..\..\..\settings-ui\Settings.UI.Library\Settings.UI.Library.csproj" />
<ProjectReference Include="..\WorkspacesCsharpLibrary\WorkspacesCsharpLibrary.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="CommunityToolkit.WinUI.Controls.Primitives" />
<PackageReference Include="Microsoft.WindowsAppSDK" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" />
<PackageReference Include="System.Drawing.Common" />
<PackageReference Include="System.IO.Abstractions" />
<PackageReference Include="WinUIEx" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="PowerToys.WorkspacesLauncherUI.app"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10 compatibility for unpackaged WinUI 3 apps -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
</assembly>

View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2"/>
</startup>
<runtime>
<AppContextSwitchOverrides value = "Switch.System.Windows.DoNotScaleForDpiChanges=false"/>
</runtime>
</configuration>

View File

@@ -1,57 +0,0 @@
<Application
x:Class="WorkspacesLauncherUI.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:WorkspacesLauncherUI"
Exit="OnExit"
Startup="OnStartup"
ThemeMode="System">
<Application.Resources>
<ResourceDictionary>
<FontFamily x:Key="SymbolThemeFontFamily">Segoe Fluent Icons, Segoe MDL2 Assets</FontFamily>
<Style x:Key="HeadingTextBlock" TargetType="TextBlock" />
<Style
x:Key="SubtleButtonStyle"
BasedOn="{StaticResource {x:Type Button}}"
TargetType="Button">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border
x:Name="Border"
Padding="{TemplateBinding Padding}"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="4"
SnapsToDevicePixels="True">
<ContentPresenter
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
Focusable="False"
RecognizesAccessKey="True"
SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Border" Property="Background" Value="{DynamicResource SubtleFillColorSecondaryBrush}" />
<Setter TargetName="Border" Property="BorderBrush" Value="{DynamicResource SubtleFillColorSecondaryBrush}" />
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Border" Property="Background" Value="{DynamicResource SubtleFillColorTertiaryBrush}" />
<Setter TargetName="Border" Property="BorderBrush" Value="{DynamicResource SubtleFillColorTertiaryBrush}" />
<Setter Property="Foreground" Value="{DynamicResource TextFillColorSecondaryBrush}" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Foreground" Value="{DynamicResource TextFillColorDisabledBrush}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
</Application.Resources>
</Application>

View File

@@ -1,147 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Globalization;
using System.Threading;
using System.Windows;
using ManagedCommon;
using PowerToys.Interop;
using WorkspacesLauncherUI.ViewModels;
namespace WorkspacesLauncherUI
{
/// <summary>
/// Interaction logic for App.xaml
/// </summary>
public partial class App : Application, IDisposable
{
private static Mutex _instanceMutex;
// Create an instance of the IPC wrapper.
private static TwoWayPipeMessageIPCManaged ipcmanager;
private StatusWindow _mainWindow;
private MainViewModel _mainViewModel;
private bool _isDisposed;
public static Action<string> IPCMessageReceivedCallback { get; set; }
public App()
{
}
public static void SendIPCMessage(string message)
{
if (ipcmanager != null)
{
ipcmanager.Send(message);
}
}
private void OnStartup(object sender, StartupEventArgs e)
{
Logger.InitializeLogger("\\Workspaces\\WorkspacesLauncherUI");
AppDomain.CurrentDomain.UnhandledException += OnUnhandledException;
var languageTag = LanguageHelper.LoadLanguage();
if (!string.IsNullOrEmpty(languageTag))
{
try
{
System.Threading.Thread.CurrentThread.CurrentUICulture = new CultureInfo(languageTag);
}
catch (CultureNotFoundException ex)
{
Logger.LogError("CultureNotFoundException: " + ex.Message);
}
}
const string appName = "Local\\PowerToys_Workspaces_LauncherUI_InstanceMutex";
bool createdNew;
_instanceMutex = new Mutex(true, appName, out createdNew);
if (!createdNew)
{
Logger.LogWarning("Another instance of Workspaces Launcher UI is already running. Exiting this instance.");
_instanceMutex = null;
Shutdown(0);
return;
}
if (PowerToys.GPOWrapperProjection.GPOWrapper.GetConfiguredWorkspacesEnabledValue() == PowerToys.GPOWrapperProjection.GpoRuleConfigured.Disabled)
{
Logger.LogWarning("Tried to start with a GPO policy setting the utility to always be disabled. Please contact your systems administrator.");
Shutdown(0);
return;
}
ipcmanager = new TwoWayPipeMessageIPCManaged("\\\\.\\pipe\\powertoys_workspaces_ui_", "\\\\.\\pipe\\powertoys_workspaces_launcher_ui_", (string message) =>
{
if (IPCMessageReceivedCallback != null && message.Length > 0)
{
IPCMessageReceivedCallback(message);
}
});
ipcmanager.Start();
if (_mainViewModel == null)
{
_mainViewModel = new MainViewModel();
}
// normal start of editor
if (_mainWindow == null)
{
_mainWindow = new StatusWindow(_mainViewModel);
}
// reset main window owner to keep it on the top
_mainWindow.ShowActivated = true;
_mainWindow.Topmost = true;
_mainWindow.Show();
}
private void OnExit(object sender, ExitEventArgs e)
{
if (_instanceMutex != null)
{
_instanceMutex.ReleaseMutex();
}
Dispose();
}
private void OnUnhandledException(object sender, UnhandledExceptionEventArgs args)
{
Logger.LogError("Unhandled exception occurred", args.ExceptionObject as Exception);
}
protected virtual void Dispose(bool disposing)
{
if (!_isDisposed)
{
if (disposing)
{
ipcmanager?.End();
ipcmanager?.Dispose();
_instanceMutex?.Dispose();
}
_isDisposed = true;
}
}
public void Dispose()
{
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}
}

View File

@@ -1,29 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace WorkspacesLauncherUI.Converters
{
public class BooleanToInvertedVisibilityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if ((bool)value)
{
return Visibility.Collapsed;
}
return Visibility.Visible;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
}

View File

@@ -1,30 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Windows.Automation.Peers;
using System.Windows.Controls;
namespace WorkspacesLauncherUI
{
public class HeadingTextBlock : TextBlock
{
protected override AutomationPeer OnCreateAutomationPeer()
{
return new HeadingTextBlockAutomationPeer(this);
}
internal sealed class HeadingTextBlockAutomationPeer : TextBlockAutomationPeer
{
public HeadingTextBlockAutomationPeer(HeadingTextBlock owner)
: base(owner)
{
}
protected override AutomationControlType GetAutomationControlTypeCore()
{
return AutomationControlType.Header;
}
}
}
}

View File

@@ -1,46 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;
using System.IO;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using ManagedCommon;
using WorkspacesCsharpLibrary.Models;
using WorkspacesLauncherUI.Data;
namespace WorkspacesLauncherUI.Models
{
public class AppLaunching : BaseApplication, IDisposable
{
public bool Loading => LaunchState == LaunchingState.Waiting || LaunchState == LaunchingState.Launched;
public string Name { get; set; }
public LaunchingState LaunchState { get; set; }
public string StateGlyph
{
get => LaunchState switch
{
LaunchingState.LaunchedAndMoved => "\U0000F78C",
LaunchingState.Failed => "\U0000EF2C",
_ => "\U0000EF2C",
};
}
public System.Windows.Media.Brush StateColor
{
get => LaunchState switch
{
LaunchingState.LaunchedAndMoved => new SolidColorBrush(System.Windows.Media.Color.FromArgb(255, 0, 128, 0)),
LaunchingState.Failed => new SolidColorBrush(System.Windows.Media.Color.FromArgb(255, 254, 0, 0)),
_ => new SolidColorBrush(System.Windows.Media.Color.FromArgb(255, 254, 0, 0)),
};
}
}
}

View File

@@ -1,90 +0,0 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// Runtime Version:4.0.30319.42000
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
namespace WorkspacesLauncherUI.Properties {
using System;
/// <summary>
/// A strongly-typed resource class, for looking up localized strings, etc.
/// </summary>
// This class was auto-generated by the StronglyTypedResourceBuilder
// class via a tool like ResGen or Visual Studio.
// To add or remove a member, edit your .ResX file then rerun ResGen
// with the /str option, or rebuild your VS project.
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
public class Resources {
private static global::System.Resources.ResourceManager resourceMan;
private static global::System.Globalization.CultureInfo resourceCulture;
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
internal Resources() {
}
/// <summary>
/// Returns the cached ResourceManager instance used by this class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
public static global::System.Resources.ResourceManager ResourceManager {
get {
if (object.ReferenceEquals(resourceMan, null)) {
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("WorkspacesLauncherUI.Properties.Resources", typeof(Resources).Assembly);
resourceMan = temp;
}
return resourceMan;
}
}
/// <summary>
/// Overrides the current thread's CurrentUICulture property for all
/// resource lookups using this strongly typed resource class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
public static global::System.Globalization.CultureInfo Culture {
get {
return resourceCulture;
}
set {
resourceCulture = value;
}
}
/// <summary>
/// Looks up a localized string similar to Cancel launch.
/// </summary>
public static string CancelLaunch {
get {
return ResourceManager.GetString("CancelLaunch", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Dismiss.
/// </summary>
public static string Dismiss {
get {
return ResourceManager.GetString("Dismiss", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Your workspace is launching. Waiting on ....
/// </summary>
public static string LauncherWindowTitle {
get {
return ResourceManager.GetString("LauncherWindowTitle", resourceCulture);
}
}
}
}

View File

@@ -1,129 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="CancelLaunch" xml:space="preserve">
<value>Cancel launch</value>
</data>
<data name="Dismiss" xml:space="preserve">
<value>Dismiss</value>
</data>
<data name="LauncherWindowTitle" xml:space="preserve">
<value>Your workspace is launching. Waiting on ...</value>
</data>
</root>

View File

@@ -1,26 +0,0 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// Runtime Version:4.0.30319.42000
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
namespace WorkspacesLauncherUI.Properties {
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "16.1.0.0")]
internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase {
private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings())));
public static Settings Default {
get {
return defaultInstance;
}
}
}
}

View File

@@ -1,7 +0,0 @@
<?xml version='1.0' encoding='utf-8'?>
<SettingsFile xmlns="uri:settings" CurrentProfile="(Default)">
<Profiles>
<Profile Name="(Default)" />
</Profiles>
<Settings />
</SettingsFile>

View File

@@ -1,103 +0,0 @@
<Window
x:Class="WorkspacesLauncherUI.StatusWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="clr-namespace:WorkspacesLauncherUI.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:WorkspacesLauncherUI"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:props="clr-namespace:WorkspacesLauncherUI.Properties"
Title="{x:Static props:Resources.LauncherWindowTitle}"
Width="360"
Height="340"
BorderBrush="Red"
BorderThickness="4"
Closing="Window_Closing"
ResizeMode="NoResize"
Topmost="True"
WindowStartupLocation="CenterScreen"
mc:Ignorable="d">
<Window.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVis" />
<converters:BooleanToInvertedVisibilityConverter x:Key="BooleanToInvertedVisibilityConverter" />
</Window.Resources>
<Grid Margin="4" Background="Transparent">
<Grid.RowDefinitions>
<RowDefinition Height="1*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1*" />
<ColumnDefinition Width="1*" />
</Grid.ColumnDefinitions>
<ScrollViewer Grid.ColumnSpan="2">
<StackPanel>
<ItemsControl ItemsSource="{Binding AppsListed, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="1*" />
<ColumnDefinition Width="auto" />
</Grid.ColumnDefinitions>
<Image
Width="20"
Height="20"
Margin="10"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Source="{Binding IconBitmapImage}" />
<TextBlock
Grid.Column="1"
VerticalAlignment="Center"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
Text="{Binding Name, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}" />
<ProgressBar
Grid.Column="2"
Width="20"
Height="20"
Margin="10"
HorizontalAlignment="Center"
VerticalAlignment="Center"
IsIndeterminate="True"
Visibility="{Binding Loading, Mode=OneWay, Converter={StaticResource BoolToVis}, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock
Grid.Column="2"
Width="20"
Height="20"
Margin="10"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontFamily="{DynamicResource SymbolThemeFontFamily}"
FontSize="20"
Foreground="{Binding StateColor}"
Text="{Binding StateGlyph}"
Visibility="{Binding Loading, Mode=OneWay, Converter={StaticResource BooleanToInvertedVisibilityConverter}, UpdateSourceTrigger=PropertyChanged}" />
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
<Button
x:Name="CancelButton"
Grid.Row="1"
Margin="4"
HorizontalAlignment="Stretch"
AutomationProperties.Name="{x:Static props:Resources.CancelLaunch}"
Click="CancelButtonClicked"
Content="{x:Static props:Resources.CancelLaunch}" />
<Button
x:Name="DismissButton"
Grid.Row="1"
Grid.Column="1"
Margin="4"
HorizontalAlignment="Stretch"
AutomationProperties.Name="{x:Static props:Resources.Dismiss}"
Click="DismissButtonClicked"
Content="{x:Static props:Resources.Dismiss}"
Style="{DynamicResource AccentButtonStyle}" />
</Grid>
</Window>

View File

@@ -1,41 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Windows;
using WorkspacesLauncherUI.ViewModels;
namespace WorkspacesLauncherUI
{
/// <summary>
/// Interaction logic for SnapshotWindow.xaml
/// </summary>
public partial class StatusWindow : Window
{
private MainViewModel _mainViewModel;
public StatusWindow(MainViewModel mainViewModel)
{
_mainViewModel = mainViewModel;
_mainViewModel.SetSnapshotWindow(this);
this.DataContext = _mainViewModel;
InitializeComponent();
}
private void CancelButtonClicked(object sender, RoutedEventArgs e)
{
_mainViewModel.CancelLaunch();
Close();
}
private void DismissButtonClicked(object sender, RoutedEventArgs e)
{
Close();
}
private void Window_Closing(object sender, System.ComponentModel.CancelEventArgs e)
{
}
}
}

View File

@@ -1,100 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
<Import Project="$(RepoRoot)src\Common.SelfContained.props" />
<PropertyGroup>
<AssemblyTitle>PowerToys.WorkspacesLauncherUI</AssemblyTitle>
<AssemblyDescription>PowerToys Workspaces Launcher UI</AssemblyDescription>
<Description>PowerToys Workspaces Launcher UI</Description>
<OutputType>WinExe</OutputType>
<UseWPF>true</UseWPF>
<UseWindowsForms>true</UseWindowsForms>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
<OutputPath>$(RepoRoot)$(Platform)\$(Configuration)</OutputPath>
</PropertyGroup>
<PropertyGroup>
<ProjectGuid>{9C53CC25-0623-4569-95BC-B05410675EE3}</ProjectGuid>
<ProjectTypeGuids>{60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
</PropertyGroup>
<PropertyGroup>
<ApplicationIcon>..\Assets\Workspaces\Workspaces.ico</ApplicationIcon>
</PropertyGroup>
<PropertyGroup>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AssemblyName>PowerToys.WorkspacesLauncherUI</AssemblyName>
</PropertyGroup>
<ItemGroup>
<Content Include="..\Assets\**\*.*">
<Link>Assets\Workspaces\%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<COMReference Include="IWshRuntimeLibrary">
<WrapperTool>tlbimp</WrapperTool>
<VersionMinor>0</VersionMinor>
<VersionMajor>1</VersionMajor>
<Guid>f935dc20-1cf0-11d0-adb9-00c04fd58a0b</Guid>
<Lcid>0</Lcid>
<Isolated>false</Isolated>
<EmbedInteropTypes>true</EmbedInteropTypes>
</COMReference>
<COMReference Include="Shell32">
<WrapperTool>tlbimp</WrapperTool>
<VersionMinor>0</VersionMinor>
<VersionMajor>1</VersionMajor>
<Guid>50a7e9b0-70ef-11d1-b75a-00a0c90564fe</Guid>
<Lcid>0</Lcid>
<Isolated>false</Isolated>
<EmbedInteropTypes>true</EmbedInteropTypes>
</COMReference>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="Properties\Resources.resx">
<Generator>PublicResXFileCodeGenerator</Generator>
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
</EmbeddedResource>
<None Include="app.manifest" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="System.IO.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\common\Common.UI\Common.UI.csproj" />
<ProjectReference Include="..\..\..\common\GPOWrapperProjection\GPOWrapperProjection.csproj" />
<ProjectReference Include="..\..\..\common\interop\PowerToys.Interop.vcxproj" />
<ProjectReference Include="..\..\..\common\ManagedCommon\ManagedCommon.csproj" />
<ProjectReference Include="..\..\..\common\ManagedTelemetry\Telemetry\ManagedTelemetry.csproj" />
<ProjectReference Include="..\..\..\settings-ui\Settings.UI.Library\Settings.UI.Library.csproj" />
<ProjectReference Include="..\WorkspacesCsharpLibrary\WorkspacesCsharpLibrary.csproj" />
</ItemGroup>
<ItemGroup>
<Compile Update="Properties\Resources.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>Resources.resx</DependentUpon>
</Compile>
<Compile Update="Properties\Settings.Designer.cs">
<DesignTimeSharedInput>True</DesignTimeSharedInput>
<AutoGen>True</AutoGen>
<DependentUpon>Settings.settings</DependentUpon>
</Compile>
</ItemGroup>
<ItemGroup>
<None Update="Properties\Settings.settings">
<Generator>SettingsSingleFileGenerator</Generator>
<LastGenOutput>Settings.Designer.cs</LastGenOutput>
</None>
</ItemGroup>
</Project>

View File

@@ -1,74 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="MyApplication.app"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<!-- UAC Manifest Options
If you want to change the Windows User Account Control level replace the
requestedExecutionLevel node with one of the following.
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
<requestedExecutionLevel level="highestAvailable" uiAccess="false" />
Specifying requestedExecutionLevel element will disable file and registry virtualization.
Remove this element if your application requires this virtualization for backwards
compatibility.
-->
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- A list of the Windows versions that this application has been tested on
and is designed to work with. Uncomment the appropriate elements
and Windows will automatically select the most compatible environment. -->
<!-- Windows Vista -->
<!--<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}" />-->
<!-- Windows 7 -->
<!--<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}" />-->
<!-- Windows 8 -->
<!--<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}" />-->
<!-- Windows 8.1 -->
<!--<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" />-->
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<!-- Indicates that the application is DPI-aware and will not be automatically scaled by Windows at higher
DPIs. Windows Presentation Foundation (WPF) applications are automatically DPI-aware and do not need
to opt in. Windows Forms applications targeting .NET Framework 4.6 that opt into this setting, should
also set the 'EnableWindowsFormsHighDpiAutoResizing' setting to 'true' in their app.config. -->
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
<!-- Enable themes for Windows common controls and dialogs (Windows XP and later) -->
<!--
<dependency>
<dependentAssembly>
<assemblyIdentity
type="win32"
name="Microsoft.Windows.Common-Controls"
version="6.0.0.0"
processorArchitecture="*"
publicKeyToken="6595b64144ccf1df"
language="*"
/>
</dependentAssembly>
</dependency>
-->
</assembly>

View File

@@ -171,6 +171,7 @@ FONT 8, "MS Shell Dlg", 400, 0, 0x1
BEGIN
CONTROL "",IDC_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,59,57,80,12
LTEXT "After toggling ZoomIt you can zoom in with the mouse wheel or up and down arrow keys. Exit zoom mode with Escape or by pressing the right mouse button.",IDC_STATIC,7,6,230,26
LTEXT "Copy a zoomed screen with Ctrl+C or save it by typing Ctrl+S. Crop the copy or save region by entering Ctrl+Shift instead of Ctrl.",IDC_STATIC,7,34,230,18
LTEXT "Zoom Toggle:",IDC_STATIC,7,59,51,8
CONTROL "",IDC_ZOOM_SLIDER,"msctls_trackbar32",TBS_AUTOTICKS | TBS_BOTH | TBS_NOTICKS | WS_TABSTOP,53,118,150,15,WS_EX_TRANSPARENT
LTEXT "Specify the initial level of magnification when zooming in:",IDC_STATIC,7,105,230,10
@@ -182,8 +183,6 @@ BEGIN
LTEXT "4.0",IDC_STATIC,190,136,12,8
CONTROL "Animate zoom in and zoom out:",IDC_ANIMATE_ZOOM,"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,7,74,116,10
CONTROL "Smooth zoomed image:",IDC_SMOOTH_IMAGE,"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,7,88,116,10
LTEXT "Copy a zoomed screen with Ctrl+C or save it by typing Ctrl+S. Crop the copy or save region by entering Ctrl+Shift instead of Ctrl.",IDC_STATIC,7,148,230,17
LTEXT "Copy a zoomed screen with Ctrl+C or save it by typing Ctrl+S. Crop the copy or save region by entering Ctrl+Shift instead of Ctrl.",IDC_STATIC,6,34,230,18
END
DRAW DIALOGEX 0, 0, 260, 228
@@ -315,26 +314,31 @@ BEGIN
PUSHBUTTON "Cancel",IDCANCEL,162,142,50,14
END
SNIP DIALOGEX 0, 0, 260, 80
SNIP DIALOGEX 0, 0, 272, 105
STYLE DS_SETFONT | DS_FIXEDSYS | DS_CONTROL | WS_CHILD | WS_CLIPSIBLINGS | WS_SYSMENU
FONT 8, "MS Shell Dlg", 400, 0, 0x1
BEGIN
LTEXT "Copy a region of the screen to the clipboard or enter the hotkey with the Shift key in the opposite mode to save it to a file.",IDC_STATIC,7,7,230,19
LTEXT "Snip Toggle:",IDC_STATIC,7,33,45,8
CONTROL "",IDC_SNIP_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,67,32,80,12
LTEXT "Copy text from the selected region to the clipboard:",IDC_STATIC,7,50,230,10
LTEXT "Text Toggle:",IDC_STATIC,7,65,55,8
CONTROL "",IDC_SNIP_OCR_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,67,63,80,12
LTEXT "Copy a region of the screen to the clipboard, or save it to a file using the save shortcut.",IDC_STATIC,7,7,230,18
RTEXT "Snip Toggle:",IDC_STATIC,22,33,45,8
CONTROL "",IDC_SNIP_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,71,32,80,12
RTEXT "Snip Save Toggle:",IDC_STATIC,7,49,60,8
CONTROL "",IDC_SNIP_SAVE_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,71,48,80,12
LTEXT "Copy text from the selected region to the clipboard:",IDC_STATIC,7,66,230,10
RTEXT "Text Toggle:",IDC_STATIC,12,82,55,8
CONTROL "",IDC_SNIP_OCR_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,71,81,80,12
END
PANORAMA DIALOGEX 0, 0, 260, 105
PANORAMA DIALOGEX 0, 0, 260, 140
STYLE DS_SETFONT | DS_FIXEDSYS | DS_CONTROL | WS_CHILD | WS_CLIPSIBLINGS | WS_SYSMENU
FONT 8, "MS Shell Dlg", 400, 0, 0x1
BEGIN
LTEXT "Capture a scrolling panorama of a selected screen region. Select the area, then scroll the content. Move slowly and consistently, and do not rewind to previously covered areas. Press the hotkey again or with Shift to save to a file.",IDC_STATIC,7,7,245,33
LTEXT "Panorama Toggle:",IDC_STATIC,7,74,63,8
CONTROL "",IDC_SNIP_PANORAMA_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,73,72,80,12
LTEXT "For the best results, scroll slowly and at a constant rate, do not include stationary content (like scrollbars) in the capture area, and avoid content that is changing (e.g., animations or videos). ",IDC_STATIC,7,41,245,30
LTEXT "Capture a scrolling panorama of a selected screen region. Select the area, then scroll the content. Move slowly and consistently, and do not rewind to previously covered areas.",IDC_STATIC,7,7,245,30
LTEXT "Press the panorama toggle again to copy to the clipboard, or use the save shortcut to save to a file.",IDC_STATIC,7,39,245,18
LTEXT "For the best results, scroll slowly and at a constant rate, do not include stationary content (like scrollbars) in the capture area, and avoid content that is changing (e.g., animations or videos). ",IDC_STATIC,7,62,245,30
LTEXT "Panorama Toggle:",IDC_STATIC,7,95,80,8
CONTROL "",IDC_SNIP_PANORAMA_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,90,93,80,12
LTEXT "Panorama Save Toggle:",IDC_STATIC,7,111,80,8
CONTROL "",IDC_SNIP_PANORAMA_SAVE_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,90,109,80,12
END
DEMOTYPE DIALOGEX 0, 0, 260, 249
@@ -456,7 +460,9 @@ BEGIN
"SNIP", DIALOG
BEGIN
LEFTMARGIN, 7
RIGHTMARGIN, 265
TOPMARGIN, 7
BOTTOMMARGIN, 98
END
"PANORAMA", DIALOG

View File

@@ -17,7 +17,9 @@ DWORD g_BreakToggleKey = ((HOTKEYF_CONTROL) << 8)| '3';
DWORD g_DemoTypeToggleKey = ((HOTKEYF_CONTROL) << 8) | '7';
DWORD g_RecordToggleKey = ((HOTKEYF_CONTROL) << 8) | '5';
DWORD g_SnipToggleKey = ((HOTKEYF_CONTROL) << 8) | '6';
DWORD g_SnipSaveToggleKey = ((HOTKEYF_CONTROL | HOTKEYF_SHIFT) << 8) | '6';
DWORD g_SnipPanoramaToggleKey = ((HOTKEYF_CONTROL) << 8) | '8';
DWORD g_SnipPanoramaSaveToggleKey = ((HOTKEYF_CONTROL | HOTKEYF_SHIFT) << 8) | '8';
DWORD g_SnipOcrToggleKey = ((HOTKEYF_CONTROL | HOTKEYF_ALT) << 8) | '6';
DWORD g_ShowExpiredTime = 1;
@@ -80,7 +82,9 @@ REG_SETTING RegSettings[] = {
{ L"DrawToggleKey", SETTING_TYPE_DWORD, 0, &g_DrawToggleKey, static_cast<DOUBLE>(g_DrawToggleKey) },
{ L"RecordToggleKey", SETTING_TYPE_DWORD, 0, &g_RecordToggleKey, static_cast<DOUBLE>(g_RecordToggleKey) },
{ L"SnipToggleKey", SETTING_TYPE_DWORD, 0, &g_SnipToggleKey, static_cast<DOUBLE>(g_SnipToggleKey) },
{ L"SnipSaveToggleKey", SETTING_TYPE_DWORD, 0, &g_SnipSaveToggleKey, static_cast<DOUBLE>(g_SnipSaveToggleKey) },
{ L"SnipPanoramaToggleKey", SETTING_TYPE_DWORD, 0, &g_SnipPanoramaToggleKey, static_cast<DOUBLE>(g_SnipPanoramaToggleKey) },
{ L"SnipPanoramaSaveToggleKey", SETTING_TYPE_DWORD, 0, &g_SnipPanoramaSaveToggleKey, static_cast<DOUBLE>(g_SnipPanoramaSaveToggleKey) },
{ L"SnipOcrToggleKey", SETTING_TYPE_DWORD, 0, &g_SnipOcrToggleKey, static_cast<DOUBLE>(g_SnipOcrToggleKey) },
{ L"PenColor", SETTING_TYPE_DWORD, 0, &g_PenColor, static_cast<DOUBLE>(g_PenColor) },
{ L"PenWidth", SETTING_TYPE_DWORD, 0, &g_RootPenWidth, static_cast<DOUBLE>(g_RootPenWidth) },

View File

@@ -174,6 +174,8 @@ DWORD g_RecordToggleMod;
DWORD g_SnipToggleMod;
DWORD g_SnipPanoramaToggleMod;
DWORD g_SnipOcrToggleMod;
DWORD g_SnipSaveToggleMod;
DWORD g_SnipPanoramaSaveToggleMod;
BOOLEAN g_ZoomOnLiveZoom = FALSE;
DWORD g_PenWidth = PEN_WIDTH;
@@ -212,7 +214,10 @@ BOOL g_RecordToggle = FALSE;
BOOL g_RecordCropping = FALSE;
SelectRectangle g_SelectRectangle;
WebcamPreviewWindow g_WebcamPreview;
// The full path of the last saved recording file.
std::wstring g_RecordingSaveLocation;
// The last user-chosen recording filename. Used to construct unique recording filenames.
std::wstring g_RecordingSaveBaseFilename;
std::wstring g_ScreenshotSaveLocation;
winrt::IDirect3DDevice g_RecordDevice{ nullptr };
std::shared_ptr<VideoRecordingSession> g_RecordingSession = nullptr;
@@ -3582,12 +3587,16 @@ void RegisterAllHotkeys(HWND hWnd)
}
if (g_SnipToggleKey) {
registerHotkey( SNIP_HOTKEY, g_SnipToggleMod, g_SnipToggleKey & 0xFF );
registerHotkey( SNIP_SAVE_HOTKEY, ( g_SnipToggleMod ^ MOD_SHIFT ), g_SnipToggleKey & 0xFF );
}
if( g_SnipPanoramaToggleKey &&
if (g_SnipSaveToggleKey) {
registerHotkey( SNIP_SAVE_HOTKEY, g_SnipSaveToggleMod, g_SnipSaveToggleKey & 0xFF);
}
if (g_SnipPanoramaToggleKey &&
(g_SnipPanoramaToggleKey != g_SnipToggleKey || g_SnipPanoramaToggleMod != g_SnipToggleMod) ) {
registerHotkey( SNIP_PANORAMA_HOTKEY, g_SnipPanoramaToggleMod | MOD_NOREPEAT, g_SnipPanoramaToggleKey & 0xFF );
registerHotkey( SNIP_PANORAMA_SAVE_HOTKEY, ( g_SnipPanoramaToggleMod ^ MOD_SHIFT ) | MOD_NOREPEAT, g_SnipPanoramaToggleKey & 0xFF );
}
if (g_SnipPanoramaSaveToggleKey) {
registerHotkey( SNIP_PANORAMA_SAVE_HOTKEY, g_SnipPanoramaSaveToggleMod | MOD_NOREPEAT, g_SnipPanoramaSaveToggleKey & 0xFF );
}
if (g_SnipOcrToggleKey) {
registerHotkey( SNIP_OCR_HOTKEY, g_SnipOcrToggleMod, g_SnipOcrToggleKey & 0xFF );
@@ -4816,6 +4825,8 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message,
TCHAR text[32];
DWORD newToggleKey, newTimeout, newToggleMod, newBreakToggleKey, newDemoTypeToggleKey, newRecordToggleKey, newSnipToggleKey, newSnipPanoramaToggleKey, newSnipOcrToggleKey;
DWORD newDrawToggleKey, newDrawToggleMod, newBreakToggleMod, newDemoTypeToggleMod, newRecordToggleMod, newSnipToggleMod, newSnipPanoramaToggleMod, newSnipOcrToggleMod;
DWORD newSnipSaveToggleKey, newSnipSaveToggleMod;
DWORD newSnipPanoramaSaveToggleKey, newSnipPanoramaSaveToggleMod;
DWORD newLiveZoomToggleKey, newLiveZoomToggleMod;
static std::vector<std::pair<std::wstring, std::wstring>> microphones;
@@ -5050,7 +5061,9 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message,
if( g_DemoTypeToggleKey ) SendMessage( GetDlgItem( g_OptionsTabs[DEMOTYPE_PAGE].hPage, IDC_DEMOTYPE_HOTKEY ), HKM_SETHOTKEY, g_DemoTypeToggleKey, 0 );
if( g_RecordToggleKey ) SendMessage( GetDlgItem( g_OptionsTabs[RECORD_PAGE].hPage, IDC_RECORD_HOTKEY), HKM_SETHOTKEY, g_RecordToggleKey, 0 );
if( g_SnipToggleKey) SendMessage( GetDlgItem( g_OptionsTabs[SNIP_PAGE].hPage, IDC_SNIP_HOTKEY), HKM_SETHOTKEY, g_SnipToggleKey, 0 );
if( g_SnipSaveToggleKey) SendMessage( GetDlgItem( g_OptionsTabs[SNIP_PAGE].hPage, IDC_SNIP_SAVE_HOTKEY), HKM_SETHOTKEY, g_SnipSaveToggleKey, 0 );
if( g_SnipPanoramaToggleKey) SendMessage( GetDlgItem( g_OptionsTabs[PANORAMA_PAGE].hPage, IDC_SNIP_PANORAMA_HOTKEY), HKM_SETHOTKEY, g_SnipPanoramaToggleKey, 0 );
if( g_SnipPanoramaSaveToggleKey) SendMessage( GetDlgItem( g_OptionsTabs[PANORAMA_PAGE].hPage, IDC_SNIP_PANORAMA_SAVE_HOTKEY), HKM_SETHOTKEY, g_SnipPanoramaSaveToggleKey, 0 );
if( g_SnipOcrToggleKey) SendMessage( GetDlgItem( g_OptionsTabs[SNIP_PAGE].hPage, IDC_SNIP_OCR_HOTKEY), HKM_SETHOTKEY, g_SnipOcrToggleKey, 0 );
CheckDlgButton( hDlg, IDC_SHOW_TRAY_ICON,
g_ShowTrayIcon ? BST_CHECKED: BST_UNCHECKED );
@@ -5512,7 +5525,9 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message,
newDemoTypeToggleKey = static_cast<DWORD>(SendMessage( GetDlgItem( g_OptionsTabs[DEMOTYPE_PAGE].hPage, IDC_DEMOTYPE_HOTKEY ), HKM_GETHOTKEY, 0, 0 ));
newRecordToggleKey = static_cast<DWORD>(SendMessage(GetDlgItem(g_OptionsTabs[RECORD_PAGE].hPage, IDC_RECORD_HOTKEY), HKM_GETHOTKEY, 0, 0));
newSnipToggleKey = static_cast<DWORD>(SendMessage( GetDlgItem( g_OptionsTabs[SNIP_PAGE].hPage, IDC_SNIP_HOTKEY), HKM_GETHOTKEY, 0, 0 ));
newSnipSaveToggleKey = static_cast<DWORD>(SendMessage( GetDlgItem( g_OptionsTabs[SNIP_PAGE].hPage, IDC_SNIP_SAVE_HOTKEY), HKM_GETHOTKEY, 0, 0 ));
newSnipPanoramaToggleKey = static_cast<DWORD>(SendMessage( GetDlgItem( g_OptionsTabs[PANORAMA_PAGE].hPage, IDC_SNIP_PANORAMA_HOTKEY), HKM_GETHOTKEY, 0, 0 ));
newSnipPanoramaSaveToggleKey = static_cast<DWORD>(SendMessage( GetDlgItem( g_OptionsTabs[PANORAMA_PAGE].hPage, IDC_SNIP_PANORAMA_SAVE_HOTKEY), HKM_GETHOTKEY, 0, 0 ));
newSnipOcrToggleKey = static_cast<DWORD>(SendMessage( GetDlgItem( g_OptionsTabs[SNIP_PAGE].hPage, IDC_SNIP_OCR_HOTKEY), HKM_GETHOTKEY, 0, 0 ));
newToggleMod = GetKeyMod( newToggleKey );
@@ -5522,7 +5537,9 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message,
newDemoTypeToggleMod = GetKeyMod( newDemoTypeToggleKey );
newRecordToggleMod = GetKeyMod(newRecordToggleKey);
newSnipToggleMod = GetKeyMod( newSnipToggleKey );
newSnipSaveToggleMod = GetKeyMod( newSnipSaveToggleKey );
newSnipPanoramaToggleMod = GetKeyMod( newSnipPanoramaToggleKey );
newSnipPanoramaSaveToggleMod = GetKeyMod( newSnipPanoramaSaveToggleKey );
newSnipOcrToggleMod = GetKeyMod( newSnipOcrToggleKey );
g_SliderZoomLevel = static_cast<int>(SendMessage( GetDlgItem(g_OptionsTabs[ZOOM_PAGE].hPage, IDC_ZOOM_SLIDER), TBM_GETPOS, 0, 0 ));
@@ -5591,25 +5608,41 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message,
}
else if (newSnipToggleKey &&
(!RegisterHotKey(GetParent(hDlg), SNIP_HOTKEY, newSnipToggleMod, newSnipToggleKey & 0xFF) ||
!RegisterHotKey(GetParent(hDlg), SNIP_SAVE_HOTKEY, (newSnipToggleMod ^ MOD_SHIFT), newSnipToggleKey & 0xFF))) {
!RegisterHotKey(GetParent(hDlg), SNIP_HOTKEY, newSnipToggleMod, newSnipToggleKey & 0xFF)) {
MessageBox(hDlg, L"The specified snip hotkey is already in use.\nSelect a different snip hotkey.",
APPNAME, MB_ICONERROR);
UnregisterAllHotkeys(GetParent(hDlg));
break;
}
else if (newSnipSaveToggleKey &&
!RegisterHotKey(GetParent(hDlg), SNIP_SAVE_HOTKEY, newSnipSaveToggleMod, newSnipSaveToggleKey & 0xFF)) {
MessageBox(hDlg, L"The specified snip save hotkey is already in use.\nSelect a different snip save hotkey.",
APPNAME, MB_ICONERROR);
UnregisterAllHotkeys(GetParent(hDlg));
break;
}
else if (newSnipPanoramaToggleKey &&
(newSnipPanoramaToggleKey != newSnipToggleKey || newSnipPanoramaToggleMod != newSnipToggleMod) &&
(!RegisterHotKey(GetParent(hDlg), SNIP_PANORAMA_HOTKEY, newSnipPanoramaToggleMod | MOD_NOREPEAT, newSnipPanoramaToggleKey & 0xFF) ||
!RegisterHotKey(GetParent(hDlg), SNIP_PANORAMA_SAVE_HOTKEY, ( newSnipPanoramaToggleMod ^ MOD_SHIFT ) | MOD_NOREPEAT, newSnipPanoramaToggleKey & 0xFF))) {
!RegisterHotKey(GetParent(hDlg), SNIP_PANORAMA_HOTKEY, newSnipPanoramaToggleMod | MOD_NOREPEAT, newSnipPanoramaToggleKey & 0xFF)) {
MessageBox(hDlg, L"The specified panorama snip hotkey is already in use.\nSelect a different panorama snip hotkey.",
APPNAME, MB_ICONERROR);
UnregisterAllHotkeys(GetParent(hDlg));
break;
}
else if (newSnipPanoramaSaveToggleKey &&
!RegisterHotKey(GetParent(hDlg), SNIP_PANORAMA_SAVE_HOTKEY, newSnipPanoramaSaveToggleMod | MOD_NOREPEAT, newSnipPanoramaSaveToggleKey & 0xFF)) {
MessageBox(hDlg, L"The specified panorama snip save hotkey is already in use.\nSelect a different panorama snip save hotkey.",
APPNAME, MB_ICONERROR);
UnregisterAllHotkeys(GetParent(hDlg));
break;
}
else if (newSnipOcrToggleKey &&
!RegisterHotKey(GetParent(hDlg), SNIP_OCR_HOTKEY, newSnipOcrToggleMod, newSnipOcrToggleKey & 0xFF)) {
@@ -5645,8 +5678,12 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message,
g_RecordToggleMod = newRecordToggleMod;
g_SnipToggleKey = newSnipToggleKey;
g_SnipToggleMod = newSnipToggleMod;
g_SnipSaveToggleKey = newSnipSaveToggleKey;
g_SnipSaveToggleMod = newSnipSaveToggleMod;
g_SnipPanoramaToggleKey = newSnipPanoramaToggleKey;
g_SnipPanoramaToggleMod = newSnipPanoramaToggleMod;
g_SnipPanoramaSaveToggleKey = newSnipPanoramaSaveToggleKey;
g_SnipPanoramaSaveToggleMod = newSnipPanoramaSaveToggleMod;
g_SnipOcrToggleKey = newSnipOcrToggleKey;
g_SnipOcrToggleMod = newSnipOcrToggleMod;
reg.WriteRegSettings( RegSettings );
@@ -6737,6 +6774,45 @@ void StopRecording()
}
}
//----------------------------------------------------------------------------
// GetTimestampSuffix
//
// Returns a timestamp string for disambiguating filenames.
// Format: " YYYY-MM-DD HHMMSS", e.g." 2025-11-02 143000".
//
// Used as a suffix for the default recording filename. Ensures
// chronological name sorting in Explorer.
//
//----------------------------------------------------------------------------
static std::wstring GetTimestampSuffix()
{
auto const now = std::chrono::system_clock::now();
auto const in_time_t = std::chrono::system_clock::to_time_t( now );
std::tm buf{};
localtime_s( &buf, &in_time_t );
std::wstringstream ss;
ss << L" " << std::put_time( &buf, L"%Y-%m-%d %H%M%S" );
return ss.str();
}
//----------------------------------------------------------------------------
// IsDefaultRecordingFilename
//
// Determines if the provided filename matches the default recording name.
// Case-insensitive comparison.
//
// Returns:
// true if filename is the default; otherwise false.
//
//----------------------------------------------------------------------------
static bool IsDefaultRecordingFilename(const std::wstring& filename)
{
return CompareStringOrdinal( DEFAULT_RECORDING_FILE, -1, filename.c_str(), -1, TRUE ) == CSTR_EQUAL
|| CompareStringOrdinal( DEFAULT_GIF_RECORDING_FILE, -1, filename.c_str(), -1, TRUE ) == CSTR_EQUAL;
}
//----------------------------------------------------------------------------
//
@@ -6791,19 +6867,70 @@ std::wstring GetUniqueFilename(const std::wstring& lastSavePath, const wchar_t*
//
// GetUniqueRecordingFilename
//
// Gets a unique file name for recording saves, using the " (N)" suffix
// approach so that the user can hit OK without worrying about overwriting
// if they are making multiple recordings in one session or don't want to
// always see an overwrite dialog or stop to clean up files.
// Generates a unique filename to be suggested in the "Save As" recording
// dialog, based on the user's last chosen filename and save location.
// This allows the user to quickly save a recording without worrying about
// manual renaming to prevent overwriting earlier recordings.
//
// There are two distinct behaviors based on the last used filename:
//
// 1. For the default filename ("Recording.mp4"):
// Generates a more descriptive name by appending a timestamp, e.g.
// "Recording 2025-11-03 143015.mp4". This ensures chronological sorting
// in Explorer when ordered by name and is consistent with other tools.
//
// 2. For custom filenames (e.g. "Presentation.mp4"):
// Appends a numeric suffix if the file already exists, e.g.
// "Presentation (1).mp4", "Presentation (2).mp4", etc.
//
// Returns:
// A unique filename (without folder path).
//
// Relies upon the global state of `g_RecordingSaveLocation` and
// `g_RecordingSaveBaseFilename`.
//
//----------------------------------------------------------------------------
auto GetUniqueRecordingFilename()
static auto GetUniqueRecordingFilename()
{
const wchar_t* defaultFile = (g_RecordingFormat == RecordingFormat::GIF)
? DEFAULT_GIF_RECORDING_FILE
: DEFAULT_RECORDING_FILE;
return GetUniqueFilename(g_RecordingSaveLocation, defaultFile, FOLDERID_Videos);
// Without a remembered filename, suggest the default name for the current format.
std::wstring baseFilename = g_RecordingSaveBaseFilename.empty()
? std::wstring( defaultFile )
: g_RecordingSaveBaseFilename;
std::filesystem::path basePath{ baseFilename };
// For the default filename, append a timestamp so successive default saves stay
// unique and sort chronologically in Explorer.
if ( IsDefaultRecordingFilename( basePath.filename().wstring() ) )
{
return basePath.stem().wstring() + GetTimestampSuffix() + basePath.extension().wstring();
}
// For custom filenames, append a numeric suffix to avoid collisions.
std::filesystem::path directory;
if ( !g_RecordingSaveLocation.empty() )
directory = std::filesystem::path( g_RecordingSaveLocation ).parent_path();
if ( directory.empty() )
{
wil::unique_cotaskmem_string folderPath;
if ( SUCCEEDED( SHGetKnownFolderPath( FOLDERID_Videos, KF_FLAG_DEFAULT, nullptr, folderPath.put() ) ) )
directory = folderPath.get();
}
std::wstring baseStem = basePath.stem().wstring();
std::wstring baseExtension = basePath.extension().wstring();
std::filesystem::path testPath = directory / ( baseStem + baseExtension );
for ( int index = 1; std::filesystem::exists( testPath ); index++ )
{
testPath = directory / ( baseStem + L" (" + std::to_wstring( index ) + L')' + baseExtension );
}
return testPath.filename().wstring();
}
//----------------------------------------------------------------------------
@@ -6835,7 +6962,7 @@ auto GetUniqueScreenshotFilename()
//
// StartRecordingAsync
//
// Starts the screen recording.
// Initiates screen recording and handles the save dialog workflow.
//
//----------------------------------------------------------------------------
winrt::fire_and_forget StartRecordingAsync( HWND hWnd, LPRECT rcCrop, HWND hWndRecord ) try
@@ -7080,8 +7207,30 @@ winrt::fire_and_forget StartRecordingAsync( HWND hWnd, LPRECT rcCrop, HWND hWndR
if (!finalPath.empty())
{
auto path = std::filesystem::path(finalPath);
// Remember the user's chosen filename and apply a timestamp to default
// names so successive saves stay unique and sort chronologically.
std::wstring filename = path.filename().wstring();
std::wstring finalFilename = filename;
if ( IsDefaultRecordingFilename( filename ) )
{
// The user accepted or re-typed the default filename. Remember it so the
// next suggestion also uses a timestamp, and append one to this save.
g_RecordingSaveBaseFilename = filename;
finalFilename = path.stem().wstring() + GetTimestampSuffix() + path.extension().wstring();
}
else if ( CompareStringOrdinal( suggestedName.c_str(), -1, filename.c_str(), -1, TRUE ) != CSTR_EQUAL )
{
// The user chose their own filename instead of the suggested one. Remember
// it so future suggestions use numeric suffixes based on this name.
g_RecordingSaveBaseFilename = filename;
}
// The path actually written to disk (with any timestamp applied).
std::wstring savedPath = ( path.parent_path() / finalFilename ).wstring();
winrt::StorageFolder folder{ co_await winrt::StorageFolder::GetFolderFromPathAsync(path.parent_path().c_str()) };
destFile = co_await folder.CreateFileAsync(path.filename().c_str(), winrt::CreationCollisionOption::ReplaceExisting);
destFile = co_await folder.CreateFileAsync(finalFilename.c_str(), winrt::CreationCollisionOption::ReplaceExisting);
// If user trimmed, use the trimmed file
winrt::StorageFile sourceFile = file;
@@ -7099,8 +7248,8 @@ winrt::fire_and_forget StartRecordingAsync( HWND hWnd, LPRECT rcCrop, HWND hWndR
try { co_await file.DeleteAsync(); } catch (...) {}
}
// Use finalPath directly - destFile.Path() may be stale after MoveAndReplaceAsync
g_RecordingSaveLocation = finalPath;
// Use savedPath directly - destFile.Path() may be stale after MoveAndReplaceAsync
g_RecordingSaveLocation = savedPath;
// Update the registry buffer and save to persist across app restarts
wcsncpy_s(g_RecordingSaveLocationBuffer, g_RecordingSaveLocation.c_str(), _TRUNCATE);
reg.WriteRegSettings(RegSettings);
@@ -7600,7 +7749,9 @@ LRESULT APIENTRY MainWndProc(
g_BreakToggleMod = GetKeyMod( g_BreakToggleKey );
g_DemoTypeToggleMod = GetKeyMod( g_DemoTypeToggleKey );
g_SnipToggleMod = GetKeyMod( g_SnipToggleKey );
g_SnipSaveToggleMod = GetKeyMod( g_SnipSaveToggleKey );
g_SnipPanoramaToggleMod = GetKeyMod( g_SnipPanoramaToggleKey );
g_SnipPanoramaSaveToggleMod = GetKeyMod( g_SnipPanoramaSaveToggleKey );
g_SnipOcrToggleMod = GetKeyMod( g_SnipOcrToggleKey );
g_RecordToggleMod = GetKeyMod( g_RecordToggleKey );
@@ -7651,23 +7802,37 @@ LRESULT APIENTRY MainWndProc(
}
else if (g_SnipToggleKey &&
(!RegisterHotKey(hWnd, SNIP_HOTKEY, g_SnipToggleMod, g_SnipToggleKey & 0xFF) ||
!RegisterHotKey(hWnd, SNIP_SAVE_HOTKEY, (g_SnipToggleMod ^ MOD_SHIFT), g_SnipToggleKey & 0xFF))) {
!RegisterHotKey(hWnd, SNIP_HOTKEY, g_SnipToggleMod, g_SnipToggleKey & 0xFF)) {
MessageBox(hWnd, L"The specified snip hotkey is already in use.\nSelect a different snip hotkey.",
APPNAME, MB_ICONERROR);
showOptions = TRUE;
}
else if (g_SnipSaveToggleKey &&
!RegisterHotKey(hWnd, SNIP_SAVE_HOTKEY, g_SnipSaveToggleMod, g_SnipSaveToggleKey & 0xFF)) {
MessageBox(hWnd, L"The specified snip save hotkey is already in use.\nSelect a different snip save hotkey.",
APPNAME, MB_ICONERROR);
showOptions = TRUE;
}
else if (g_SnipPanoramaToggleKey &&
(g_SnipPanoramaToggleKey != g_SnipToggleKey || g_SnipPanoramaToggleMod != g_SnipToggleMod) &&
(!RegisterHotKey(hWnd, SNIP_PANORAMA_HOTKEY, g_SnipPanoramaToggleMod | MOD_NOREPEAT, g_SnipPanoramaToggleKey & 0xFF) ||
!RegisterHotKey(hWnd, SNIP_PANORAMA_SAVE_HOTKEY, ( g_SnipPanoramaToggleMod ^ MOD_SHIFT ) | MOD_NOREPEAT, g_SnipPanoramaToggleKey & 0xFF))) {
!RegisterHotKey(hWnd, SNIP_PANORAMA_HOTKEY, g_SnipPanoramaToggleMod | MOD_NOREPEAT, g_SnipPanoramaToggleKey & 0xFF)) {
MessageBox(hWnd, L"The specified panorama snip hotkey is already in use.\nSelect a different panorama snip hotkey.",
APPNAME, MB_ICONERROR);
showOptions = TRUE;
}
else if (g_SnipPanoramaSaveToggleKey &&
!RegisterHotKey(hWnd, SNIP_PANORAMA_SAVE_HOTKEY, g_SnipPanoramaSaveToggleMod | MOD_NOREPEAT, g_SnipPanoramaSaveToggleKey & 0xFF)) {
MessageBox(hWnd, L"The specified panorama snip save hotkey is already in use.\nSelect a different panorama snip save hotkey.",
APPNAME, MB_ICONERROR);
showOptions = TRUE;
}
else if (g_SnipOcrToggleKey &&
!RegisterHotKey(hWnd, SNIP_OCR_HOTKEY, g_SnipOcrToggleMod, g_SnipOcrToggleKey & 0xFF)) {
@@ -10254,7 +10419,9 @@ LRESULT APIENTRY MainWndProc(
g_BreakToggleMod = GetKeyMod(g_BreakToggleKey);
g_DemoTypeToggleMod = GetKeyMod(g_DemoTypeToggleKey);
g_SnipToggleMod = GetKeyMod(g_SnipToggleKey);
g_SnipSaveToggleMod = GetKeyMod(g_SnipSaveToggleKey);
g_SnipPanoramaToggleMod = GetKeyMod(g_SnipPanoramaToggleKey);
g_SnipPanoramaSaveToggleMod = GetKeyMod(g_SnipPanoramaSaveToggleKey);
g_SnipOcrToggleMod = GetKeyMod(g_SnipOcrToggleKey);
g_RecordToggleMod = GetKeyMod(g_RecordToggleKey);
BOOL showOptions = FALSE;
@@ -10317,8 +10484,7 @@ LRESULT APIENTRY MainWndProc(
}
if (g_SnipToggleKey)
{
if (!RegisterHotKey(hWnd, SNIP_HOTKEY, g_SnipToggleMod, g_SnipToggleKey & 0xFF) ||
!RegisterHotKey(hWnd, SNIP_SAVE_HOTKEY, (g_SnipToggleMod ^ MOD_SHIFT), g_SnipToggleKey & 0xFF))
if (!RegisterHotKey(hWnd, SNIP_HOTKEY, g_SnipToggleMod, g_SnipToggleKey & 0xFF))
{
if(!g_StartedByPowerToys)
{
@@ -10327,11 +10493,21 @@ LRESULT APIENTRY MainWndProc(
showOptions = TRUE;
}
}
if (g_SnipSaveToggleKey)
{
if (!RegisterHotKey(hWnd, SNIP_SAVE_HOTKEY, g_SnipSaveToggleMod, g_SnipSaveToggleKey & 0xFF))
{
if(!g_StartedByPowerToys)
{
MessageBox(hWnd, L"The specified snip save hotkey is already in use.\nSelect a different snip save hotkey.", APPNAME, MB_ICONERROR);
}
showOptions = TRUE;
}
}
if (g_SnipPanoramaToggleKey &&
(g_SnipPanoramaToggleKey != g_SnipToggleKey || g_SnipPanoramaToggleMod != g_SnipToggleMod))
{
if (!RegisterHotKey(hWnd, SNIP_PANORAMA_HOTKEY, g_SnipPanoramaToggleMod | MOD_NOREPEAT, g_SnipPanoramaToggleKey & 0xFF) ||
!RegisterHotKey(hWnd, SNIP_PANORAMA_SAVE_HOTKEY, ( g_SnipPanoramaToggleMod ^ MOD_SHIFT ) | MOD_NOREPEAT, g_SnipPanoramaToggleKey & 0xFF))
if (!RegisterHotKey(hWnd, SNIP_PANORAMA_HOTKEY, g_SnipPanoramaToggleMod | MOD_NOREPEAT, g_SnipPanoramaToggleKey & 0xFF))
{
if(!g_StartedByPowerToys)
{
@@ -10340,6 +10516,17 @@ LRESULT APIENTRY MainWndProc(
showOptions = TRUE;
}
}
if (g_SnipPanoramaSaveToggleKey)
{
if (!RegisterHotKey(hWnd, SNIP_PANORAMA_SAVE_HOTKEY, g_SnipPanoramaSaveToggleMod | MOD_NOREPEAT, g_SnipPanoramaSaveToggleKey & 0xFF))
{
if(!g_StartedByPowerToys)
{
MessageBox(hWnd, L"The specified panorama snip save hotkey is already in use.\nSelect a different panorama snip save hotkey.", APPNAME, MB_ICONERROR);
}
showOptions = TRUE;
}
}
if (g_SnipOcrToggleKey)
{
if (!RegisterHotKey(hWnd, SNIP_OCR_HOTKEY, g_SnipOcrToggleMod, g_SnipOcrToggleKey & 0xFF))

View File

@@ -93,7 +93,6 @@
#include <algorithm>
#include <filesystem>
#include <future>
#include <regex>
#include <fstream>
#include <sstream>

View File

@@ -137,6 +137,8 @@
#define IDC_WEBCAM_BRIGHTNESS_LABEL 1131
#define IDC_WEBCAM_BRIGHTNESS_SLIDER 1132
#define IDC_NOISE_CANCELLATION 1133
#define IDC_SNIP_SAVE_HOTKEY 1134
#define IDC_SNIP_PANORAMA_SAVE_HOTKEY 1135
#define IDC_SAVE 40002
#define IDC_COPY 40004
#define IDC_RECORD 40006
@@ -151,8 +153,8 @@
#ifdef APSTUDIO_INVOKED
#ifndef APSTUDIO_READONLY_SYMBOLS
#define _APS_NEXT_RESOURCE_VALUE 120
#define _APS_NEXT_COMMAND_VALUE 40015
#define _APS_NEXT_CONTROL_VALUE 1134
#define _APS_NEXT_COMMAND_VALUE 40012
#define _APS_NEXT_CONTROL_VALUE 1136
#define _APS_NEXT_SYMED_VALUE 101
#endif
#endif

View File

@@ -70,8 +70,10 @@ namespace winrt::PowerToys::ZoomItSettingsInterop::implementation
{ L"DrawToggleKey", SPECIAL_SEMANTICS_SHORTCUT },
{ L"RecordToggleKey", SPECIAL_SEMANTICS_SHORTCUT },
{ L"SnipToggleKey", SPECIAL_SEMANTICS_SHORTCUT },
{ L"SnipSaveToggleKey", SPECIAL_SEMANTICS_SHORTCUT },
{ L"SnipOcrToggleKey", SPECIAL_SEMANTICS_SHORTCUT },
{ L"SnipPanoramaToggleKey", SPECIAL_SEMANTICS_SHORTCUT },
{ L"SnipPanoramaSaveToggleKey", SPECIAL_SEMANTICS_SHORTCUT },
{ L"BreakTimerKey", SPECIAL_SEMANTICS_SHORTCUT },
{ L"DemoTypeToggleKey", SPECIAL_SEMANTICS_SHORTCUT },
{ L"PenColor", SPECIAL_SEMANTICS_COLOR },

View File

@@ -160,7 +160,7 @@ bool WindowBorder::Init(HINSTANCE hinstance)
void WindowBorder::UpdateBorderPosition() const
{
if (!m_trackingWindow)
if (!m_trackingWindow || !m_frameDrawer || !m_window)
{
return;
}

View File

@@ -48,6 +48,13 @@ namespace RegistryPreview
SetTitleBar(titleBar);
AppWindow.SetIcon("Assets\\RegistryPreview\\RegistryPreview.ico");
// Ensure a non-empty window title before the title bar's first layout reads it.
// UpdateWindowTitle() only runs later (on file load), so without this the native
// window title would be empty during startup, which can fault the WinUI TitleBar
// control while it reads AppWindow.Title during a deferred layout pass.
titleBar.Title = APPNAME;
AppWindow.Title = APPNAME;
// if have settings, update the location of the window
if (jsonWindowPlacement != null)
{

View File

@@ -28,12 +28,18 @@ namespace Microsoft.PowerToys.Settings.UI.Library
[CmdConfigureIgnore]
public static HotkeySettings DefaultSnipToggleKey => new HotkeySettings(false, true, false, false, '6'); // Ctrl+6
[CmdConfigureIgnore]
public static HotkeySettings DefaultSnipSaveToggleKey => new HotkeySettings(false, true, false, true, '6'); // Ctrl+Shift+6
[CmdConfigureIgnore]
public static HotkeySettings DefaultSnipOcrToggleKey => new HotkeySettings(false, true, true, false, '6'); // Ctrl+Alt+6
[CmdConfigureIgnore]
public static HotkeySettings DefaultSnipPanoramaToggleKey => new HotkeySettings(false, true, false, false, '8'); // Ctrl+8
[CmdConfigureIgnore]
public static HotkeySettings DefaultSnipPanoramaSaveToggleKey => new HotkeySettings(false, true, false, true, '8'); // Ctrl+Shift+8
[CmdConfigureIgnore]
public static HotkeySettings DefaultBreakTimerKey => new HotkeySettings(false, true, false, false, '3'); // Ctrl+3
@@ -50,10 +56,14 @@ namespace Microsoft.PowerToys.Settings.UI.Library
public KeyboardKeysProperty SnipToggleKey { get; set; }
public KeyboardKeysProperty SnipSaveToggleKey { get; set; }
public KeyboardKeysProperty SnipOcrToggleKey { get; set; }
public KeyboardKeysProperty SnipPanoramaToggleKey { get; set; }
public KeyboardKeysProperty SnipPanoramaSaveToggleKey { get; set; }
public KeyboardKeysProperty BreakTimerKey { get; set; }
public StringProperty Font { get; set; }

View File

@@ -45,7 +45,17 @@ namespace Microsoft.PowerToys.Settings.UI.SettingsXAML.Controls.Dashboard
ExtendsContentIntoTitleBar = true;
SetTitleBar(titleBar);
this.Title = resourceLoader.GetString("ShortcutConflictWindow_Title");
var windowTitle = resourceLoader.GetString("ShortcutConflictWindow_Title");
// Guard against an empty title: ResourceLoader.GetString returns "" when the resource
// map can't be resolved, and an empty native window title can fault the WinUI TitleBar
// control while it reads AppWindow.Title during a deferred layout pass.
if (string.IsNullOrEmpty(windowTitle))
{
windowTitle = "PowerToys shortcut conflicts";
}
this.Title = windowTitle;
this.CenterOnScreen();
ViewModel.OnPageLoaded();

View File

@@ -404,31 +404,26 @@
Name="ZoomItSnipShortcut"
x:Uid="ZoomIt_Snip_Shortcut"
HeaderIcon="{ui:FontIcon Glyph=&#xF7ED;}">
<tkcontrols:SettingsCard.Description>
<tkcontrols:MarkdownTextBlock Config="{StaticResource DescriptionTextMarkdownConfig}" Text="{x:Bind ViewModel.SnipToggleKeySave, Mode=OneWay, Converter={StaticResource HotkeySettingsToLocalizedStringConverter}, ConverterParameter=ZoomIt_Snip_Shortcut_Save}" />
</tkcontrols:SettingsCard.Description>
<controls:ShortcutControl HotkeySettings="{x:Bind ViewModel.SnipToggleKey, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard Name="ZoomItSnipSaveShortcut" x:Uid="ZoomIt_SnipSave_Shortcut">
<controls:ShortcutControl HotkeySettings="{x:Bind ViewModel.SnipSaveToggleKey, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard
Name="ZoomItSnipOcrShortcut"
x:Uid="ZoomIt_SnipOcr_Shortcut"
HeaderIcon="{ui:FontIcon Glyph=&#xE8AC;}">
<controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind ViewModel.SnipOcrToggleKey, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsExpander
<tkcontrols:SettingsCard
Name="ZoomItPanoramaShortcut"
x:Uid="ZoomIt_Panorama_Shortcut"
HeaderIcon="{ui:FontIcon Glyph=&#xE7C5;}"
IsExpanded="True">
HeaderIcon="{ui:FontIcon Glyph=&#xE7C5;}">
<controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind ViewModel.SnipPanoramaToggleKey, Mode=TwoWay}" />
<tkcontrols:SettingsExpander.Items>
<tkcontrols:SettingsCard>
<tkcontrols:SettingsCard.Description>
<tkcontrols:MarkdownTextBlock Config="{StaticResource DescriptionTextMarkdownConfig}" Text="{x:Bind ViewModel.SnipPanoramaToggleKeySave, Mode=OneWay, Converter={StaticResource HotkeySettingsToLocalizedStringConverter}, ConverterParameter=ZoomIt_Panorama_Shortcut_Save}" />
</tkcontrols:SettingsCard.Description>
</tkcontrols:SettingsCard>
</tkcontrols:SettingsExpander.Items>
</tkcontrols:SettingsExpander>
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard Name="ZoomItPanoramaSaveShortcut" x:Uid="ZoomIt_PanoramaSave_Shortcut">
<controls:ShortcutControl HotkeySettings="{x:Bind ViewModel.SnipPanoramaSaveToggleKey, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
</controls:SettingsGroup>
</StackPanel>
</controls:SettingsPageControl.ModuleContent>

View File

@@ -4942,11 +4942,11 @@ The break timer font matches the text font.</value>
<data name="ZoomIt_Snip_Shortcut.Header" xml:space="preserve">
<value>Snip activation</value>
</data>
<data name="ZoomIt_Snip_Shortcut_Save" xml:space="preserve">
<value>Press **{0}** to save the snip to a file</value>
<data name="ZoomIt_SnipSave_Shortcut.Header" xml:space="preserve">
<value>Save snip to file</value>
</data>
<data name="ZoomIt_Panorama_Shortcut_Save" xml:space="preserve">
<value>Press **{0}** to save the scrolling screenshot to a file</value>
<data name="ZoomIt_PanoramaSave_Shortcut.Header" xml:space="preserve">
<value>Save scrolling screenshot to file</value>
</data>
<data name="ZoomIt_SnipOcr_Shortcut.Header" xml:space="preserve">
<value>Text recognition and extraction</value>

View File

@@ -416,28 +416,22 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
{
_zoomItSettings.Properties.SnipToggleKey.Value = value ?? ZoomItProperties.DefaultSnipToggleKey;
OnPropertyChanged(nameof(SnipToggleKey));
OnPropertyChanged(nameof(SnipToggleKeySave));
NotifySettingsChanged();
}
}
}
public HotkeySettings SnipToggleKeySave
public HotkeySettings SnipSaveToggleKey
{
get
get => _zoomItSettings.Properties.SnipSaveToggleKey.Value;
set
{
var baseKey = _zoomItSettings.Properties.SnipToggleKey.Value;
if (baseKey == null)
if (_zoomItSettings.Properties.SnipSaveToggleKey.Value != value)
{
return null;
_zoomItSettings.Properties.SnipSaveToggleKey.Value = value ?? ZoomItProperties.DefaultSnipSaveToggleKey;
OnPropertyChanged(nameof(SnipSaveToggleKey));
NotifySettingsChanged();
}
return new HotkeySettings(
baseKey.Win,
baseKey.Ctrl,
baseKey.Alt,
!baseKey.Shift, // Toggle Shift: if Shift is present, remove it; if absent, add it
baseKey.Code);
}
}
@@ -464,28 +458,22 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
{
_zoomItSettings.Properties.SnipPanoramaToggleKey.Value = value ?? ZoomItProperties.DefaultSnipPanoramaToggleKey;
OnPropertyChanged(nameof(SnipPanoramaToggleKey));
OnPropertyChanged(nameof(SnipPanoramaToggleKeySave));
NotifySettingsChanged();
}
}
}
public HotkeySettings SnipPanoramaToggleKeySave
public HotkeySettings SnipPanoramaSaveToggleKey
{
get
get => _zoomItSettings.Properties.SnipPanoramaSaveToggleKey.Value;
set
{
var baseKey = _zoomItSettings.Properties.SnipPanoramaToggleKey.Value;
if (baseKey == null)
if (_zoomItSettings.Properties.SnipPanoramaSaveToggleKey.Value != value)
{
return null;
_zoomItSettings.Properties.SnipPanoramaSaveToggleKey.Value = value ?? ZoomItProperties.DefaultSnipPanoramaSaveToggleKey;
OnPropertyChanged(nameof(SnipPanoramaSaveToggleKey));
NotifySettingsChanged();
}
return new HotkeySettings(
baseKey.Win,
baseKey.Ctrl,
baseKey.Alt,
!baseKey.Shift, // Toggle Shift: if Shift is present, remove it; if absent, add it
baseKey.Code);
}
}

View File

@@ -1,25 +0,0 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.30225.117
MinimumVisualStudioVersion = 10.0.40219.1
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "CleanUp_tool", "CleanUp_tool.vcxproj", "{0995F59A-5074-42E4-AFE4-6335A0368E8E}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|x64 = Debug|x64
Release|x64 = Release|x64
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{0995F59A-5074-42E4-AFE4-6335A0368E8E}.Debug|x64.ActiveCfg = Debug|x64
{0995F59A-5074-42E4-AFE4-6335A0368E8E}.Debug|x64.Build.0 = Debug|x64
{0995F59A-5074-42E4-AFE4-6335A0368E8E}.Release|x64.ActiveCfg = Release|x64
{0995F59A-5074-42E4-AFE4-6335A0368E8E}.Release|x64.Build.0 = Release|x64
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {B025F9FC-0262-4669-BADD-BBD61121FBD3}
EndGlobalSection
EndGlobal

View File

@@ -1,75 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup Label="Globals">
<VCProjectVersion>16.0</VCProjectVersion>
<Keyword>Win32Proj</Keyword>
<ProjectGuid>{0995f59a-5074-42e4-afe4-6335a0368e8e}</ProjectGuid>
<RootNamespace>CleanUptool</RootNamespace>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
<PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>true</UseDebugLibraries>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>false</UseDebugLibraries>
<WholeProgramOptimization>true</WholeProgramOptimization>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
<ImportGroup Label="ExtensionSettings">
</ImportGroup>
<ImportGroup Label="Shared">
</ImportGroup>
<ImportGroup Label="PropertySheets">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<PropertyGroup Label="UserMacros" />
<PropertyGroup Condition="'$(Configuration)'=='Debug'">
<LinkIncremental>true</LinkIncremental>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)'=='Release'">
<LinkIncremental>false</LinkIncremental>
</PropertyGroup>
<ItemDefinitionGroup Condition="'$(Configuration)'=='Debug'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>_DEBUG;_WINDOWS;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
</ClCompile>
<Link>
<SubSystem>Windows</SubSystem>
<GenerateDebugInformation>true</GenerateDebugInformation>
<AdditionalDependencies>shlwapi.lib;%(AdditionalDependencies)</AdditionalDependencies>
</Link>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)'=='Release'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<FunctionLevelLinking>true</FunctionLevelLinking>
<IntrinsicFunctions>true</IntrinsicFunctions>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>NDEBUG;_WINDOWS;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
</ClCompile>
<Link>
<SubSystem>Windows</SubSystem>
<EnableCOMDATFolding>true</EnableCOMDATFolding>
<OptimizeReferences>true</OptimizeReferences>
<GenerateDebugInformation>true</GenerateDebugInformation>
<AdditionalDependencies>shlwapi.lib;%(AdditionalDependencies)</AdditionalDependencies>
</Link>
</ItemDefinitionGroup>
<ItemGroup>
<ClCompile Include="main.cpp" />
</ItemGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
<ImportGroup Label="ExtensionTargets">
</ImportGroup>
</Project>

View File

@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup>
<Filter Include="Source Files">
<UniqueIdentifier>{4FC737F1-C7A5-4376-A066-2A32D752A2FF}</UniqueIdentifier>
<Extensions>cpp;c;cc;cxx;c++;def;odl;idl;hpj;bat;asm;asmx</Extensions>
</Filter>
<Filter Include="Header Files">
<UniqueIdentifier>{93995380-89BD-4b04-88EB-625FBE52EBFB}</UniqueIdentifier>
<Extensions>h;hh;hpp;hxx;h++;hm;inl;inc;ipp;xsd</Extensions>
</Filter>
<Filter Include="Resource Files">
<UniqueIdentifier>{67DA6AB6-F800-4c08-8B7A-83BB121AAD01}</UniqueIdentifier>
<Extensions>rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms</Extensions>
</Filter>
</ItemGroup>
<ItemGroup>
<ClCompile Include="main.cpp">
<Filter>Source Files</Filter>
</ClCompile>
</ItemGroup>
</Project>

View File

@@ -1,169 +0,0 @@
#include <windows.h>
#include <cstdlib>
#include <cstring>
#include <shlwapi.h>
#include <shlobj.h>
static wchar_t szWindowClass[] = L"CleanUp tool";
static wchar_t szTitle[] = L"Tool to clean up FancyZones installation";
HINSTANCE hInst;
LRESULT CALLBACK WndProc(HWND, UINT, WPARAM, LPARAM);
void CleanUp();
void RemoveSettingsFolder();
void ClearRegistry();
int CALLBACK WinMain(_In_ HINSTANCE hInstance, _In_opt_ HINSTANCE hPrevInstance, _In_ LPSTR lpCmdLine, _In_ int nCmdShow)
{
WNDCLASSEX wcex;
wcex.cbSize = sizeof(WNDCLASSEX);
wcex.style = CS_HREDRAW | CS_VREDRAW;
wcex.lpfnWndProc = WndProc;
wcex.cbClsExtra = 0;
wcex.cbWndExtra = 0;
wcex.hInstance = hInstance;
wcex.hIcon = LoadIcon(hInstance, IDI_APPLICATION);
wcex.hCursor = LoadCursor(nullptr, IDC_ARROW);
wcex.hbrBackground = (HBRUSH)(COLOR_WINDOW + 1);
wcex.lpszMenuName = nullptr;
wcex.lpszClassName = szWindowClass;
wcex.hIconSm = LoadIcon(wcex.hInstance, IDI_APPLICATION);
if (!RegisterClassEx(&wcex))
{
MessageBox(nullptr, L"Call to RegisterClassEx failed!", szTitle, NULL);
return 1;
}
hInst = hInstance;
HWND hWnd = CreateWindow(
szWindowClass,
szTitle,
WS_OVERLAPPEDWINDOW,
CW_USEDEFAULT, CW_USEDEFAULT,
200, 200,
nullptr,
nullptr,
hInstance,
nullptr
);
if (!hWnd)
{
MessageBox(nullptr, L"Call to CreateWindow failed!", szTitle, NULL);
return 1;
}
ShowWindow(hWnd, nCmdShow);
UpdateWindow(hWnd);
HWND hwndButton = CreateWindow(
L"BUTTON", // Predefined class; Unicode assumed
L"Clear", // Button text
WS_TABSTOP | WS_VISIBLE | WS_CHILD | BS_DEFPUSHBUTTON, // Styles
50, // x position
50, // y position
100, // Button width
100, // Button height
hWnd, // Parent window
(HMENU) 1, // No menu.
(HINSTANCE)GetWindowLongPtr(hWnd, GWLP_HINSTANCE),
nullptr); // Pointer not needed.
MSG msg;
while (GetMessage(&msg, nullptr, 0, 0))
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}
return (int)msg.wParam;
}
LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
{
PAINTSTRUCT ps;
HDC hdc;
switch (message)
{
case WM_PAINT:
hdc = BeginPaint(hWnd, &ps);
EndPaint(hWnd, &ps);
break;
case WM_COMMAND:
if (wParam == 1)
{
CleanUp();
}
break;
case WM_DESTROY:
PostQuitMessage(0);
break;
default:
return DefWindowProc(hWnd, message, wParam, lParam);
break;
}
return 0;
}
void CleanUp()
{
RemoveSettingsFolder();
ClearRegistry();
}
void RemoveSettingsFolder()
{
wchar_t settingsPath[MAX_PATH];
if (SUCCEEDED(SHGetFolderPath(nullptr, ssfLOCALAPPDATA, nullptr, 0, settingsPath)))
{
PathAppend(settingsPath, L"\\Microsoft\\PowerToys");
}
HRESULT hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE);
if (FAILED(hr))
{
return;
}
IFileOperation* pfo;
hr = CoCreateInstance(CLSID_FileOperation, nullptr, CLSCTX_ALL, IID_PPV_ARGS(&pfo));
if (FAILED(hr))
{
return;
}
hr = pfo->SetOperationFlags(FOF_NO_UI);
if (SUCCEEDED(hr))
{
IShellItem* psiFrom = nullptr;
hr = SHCreateItemFromParsingName(settingsPath, nullptr, IID_PPV_ARGS(&psiFrom));
if (SUCCEEDED(hr))
{
if (SUCCEEDED(hr))
{
hr = pfo->DeleteItem(psiFrom, nullptr);
}
psiFrom->Release();
}
if (SUCCEEDED(hr))
{
hr = pfo->PerformOperations();
}
}
pfo->Release();
}
void ClearRegistry()
{
RegDeleteTreeW(HKEY_CURRENT_USER, L"Software\\SuperFancyZones");
RegDeleteTreeW(HKEY_CURRENT_USER, L"Software\\Microsoft\\PowerRename");
RegDeleteTreeW(HKEY_CURRENT_USER, L"Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\DontShowMeThisDialogAgain\\{e16ea82f-6d94-4f30-bb02-d6d911588afd}");
RegDeleteTreeW(HKEY_CURRENT_USER, L"Software\\Microsoft\\ImageResizer");
}

View File

@@ -1,48 +0,0 @@
#CleanUp tool 1.0
#Copyright (C) 2022 Microsoft Corporation
#Tool to clean PowerToys settings inside AppData folder and registry
#Deleting json settings files in %AppData%/Local/Microsoft/PowerToys.
[String]$SettingsPath = $Env:LOCALAPPDATA + '\Microsoft\PowerToys'
if (Test-Path -Path $SettingsPath -PathType Any)
{
Remove-Item -Path $SettingsPath -Recurse
}
#Deleting SuperFancyZones registry key
[String]$SuperFancyZones = "HKCU:\Software\SuperFancyZones"
if (Test-Path -Path $SuperFancyZones -PathType Any)
{
Remove-Item -Path $SuperFancyZones -Recurse
}
#Deleting PowerRename registry key
[String]$PowerRename = "HKCU:\Software\Microsoft\PowerRename"
if (Test-Path -Path $PowerRename -PathType Any)
{
Remove-Item -Path $PowerRename -Recurse
}
#Deleting ImageResizer registry key
[String]$ImageResizer = "HKCU:\Software\Microsoft\ImageResizer"
if (Test-Path -Path $ImageResizer -PathType Any)
{
Remove-Item -Path $ImageResizer -Recurse
}
#Deleting DontShowThisDialogAgain registry key
[String]$DontShowThisDialogAgain = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\DontShowMeThisDialogAgain\{e16ea82f-6d94-4f30-bb02-d6d911588afd}"
if (Test-Path -Path $DontShowThisDialogAgain -PathType Any)
{
Remove-Item -Path $DontShowThisDialogAgain
}

View File

@@ -445,8 +445,8 @@ function Test-CoreFiles {
'PowerToys.WorkspacesWindowArranger.exe',
'PowerToys.WorkspacesEditor.exe',
'PowerToys.WorkspacesEditor.dll',
'PowerToys.WorkspacesLauncherUI.exe',
'PowerToys.WorkspacesLauncherUI.dll',
'WinUI3Apps\PowerToys.WorkspacesLauncherUI.exe',
'WinUI3Apps\PowerToys.WorkspacesLauncherUI.dll',
'PowerToys.WorkspacesModuleInterface.dll',
'PowerToys.WorkspacesCsharpLibrary.dll',