From 1a2dd2af4a40c11f10b1c32759a8b24732e5ee31 Mon Sep 17 00:00:00 2001 From: Kelvin Poon <35914355+kwp-lab@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:59:56 +0800 Subject: [PATCH 1/2] feat: show application icons in capture target picker --- CHANGELOG.md | 6 + THIRD_PARTY_LICENSES.md | 16 + THIRD_PARTY_NOTICES.txt | 163 ++++++++++ bom.cyclonedx.json | 290 ++++++++++++++++- docs/design-system.md | 17 + docs/testing.md | 21 +- package-lock.json | 290 ++++++++++++++++- package.json | 1 + src-tauri/Cargo.lock | 2 + src-tauri/Cargo.toml | 3 + src-tauri/src/audio/wasapi.rs | 6 + src-tauri/src/controller.rs | 11 +- src-tauri/src/lib.rs | 1 + src-tauri/src/models.rs | 1 + src-tauri/src/process_icons.rs | 341 ++++++++++++++++++++ src/App.test.tsx | 11 +- src/App.tsx | 39 +-- src/captureTargets.test.ts | 13 + src/captureTargets.ts | 11 +- src/components/CaptureTargetSelect.test.tsx | 146 +++++++++ src/components/CaptureTargetSelect.tsx | 155 +++++++++ src/styles.css | 44 +++ src/test/setup.ts | 1 + src/types.ts | 1 + 24 files changed, 1551 insertions(+), 39 deletions(-) create mode 100644 src-tauri/src/process_icons.rs create mode 100644 src/components/CaptureTargetSelect.test.tsx create mode 100644 src/components/CaptureTargetSelect.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 03d028f..0f3cf2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased] +### Added + +- Added local Windows application icons to the recording-source selector, with + a keyboard-accessible two-line target list and a neutral fallback when an + application does not expose an icon. + ### Changed - Reorganized recording details into a full-height reading workspace with a diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index 65df380..5b68af6 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -406,22 +406,29 @@ details for reciprocal licenses are in `THIRD_PARTY_SOURCES.md`. | @floating-ui/dom | 1.8.0 | MIT | | @floating-ui/react-dom | 2.1.9 | MIT | | @floating-ui/utils | 0.2.12 | MIT | +| @radix-ui/number | 1.1.3 | MIT | | @radix-ui/primitive | 1.1.7 | MIT | | @radix-ui/react-arrow | 1.1.15 | MIT | +| @radix-ui/react-collection | 1.1.15 | MIT | | @radix-ui/react-compose-refs | 1.1.5 | MIT | | @radix-ui/react-context | 1.2.2 | MIT | +| @radix-ui/react-direction | 1.1.4 | MIT | | @radix-ui/react-dismissable-layer | 1.1.19 | MIT | +| @radix-ui/react-focus-guards | 1.1.6 | MIT | +| @radix-ui/react-focus-scope | 1.1.16 | MIT | | @radix-ui/react-id | 1.1.4 | MIT | | @radix-ui/react-popper | 1.3.7 | MIT | | @radix-ui/react-portal | 1.1.17 | MIT | | @radix-ui/react-presence | 1.1.10 | MIT | | @radix-ui/react-primitive | 2.1.10 | MIT | +| @radix-ui/react-select | 2.3.7 | MIT | | @radix-ui/react-slot | 1.3.3 | MIT | | @radix-ui/react-tooltip | 1.2.16 | MIT | | @radix-ui/react-use-callback-ref | 1.1.4 | MIT | | @radix-ui/react-use-controllable-state | 1.2.6 | MIT | | @radix-ui/react-use-effect-event | 0.0.5 | MIT | | @radix-ui/react-use-layout-effect | 1.1.4 | MIT | +| @radix-ui/react-use-previous | 1.1.4 | MIT | | @radix-ui/react-use-rect | 1.1.4 | MIT | | @radix-ui/react-use-size | 1.1.4 | MIT | | @radix-ui/react-visually-hidden | 1.2.11 | MIT | @@ -439,6 +446,7 @@ details for reciprocal licenses are in `THIRD_PARTY_SOURCES.md`. | @types/unist | 2.0.11 | MIT | | @types/unist | 3.0.3 | MIT | | @ungap/structured-clone | 1.3.3 | ISC | +| aria-hidden | 1.2.6 | MIT | | bail | 2.0.2 | MIT | | ccount | 2.0.1 | MIT | | character-entities-html4 | 2.1.0 | MIT | @@ -450,10 +458,12 @@ details for reciprocal licenses are in `THIRD_PARTY_SOURCES.md`. | debug | 4.4.3 | MIT | | decode-named-character-reference | 1.3.0 | MIT | | dequal | 2.0.3 | MIT | +| detect-node-es | 1.1.0 | MIT | | devlop | 1.1.0 | MIT | | escape-string-regexp | 5.0.0 | MIT | | estree-util-is-identifier-name | 3.0.0 | MIT | | extend | 3.0.2 | MIT | +| get-nonce | 1.0.1 | MIT | | hast-util-to-jsx-runtime | 2.3.6 | MIT | | hast-util-whitespace | 3.0.0 | MIT | | html-url-attributes | 3.0.1 | MIT | @@ -515,6 +525,9 @@ details for reciprocal licenses are in `THIRD_PARTY_SOURCES.md`. | react-dom | 19.2.8 | MIT | | react-json-view-lite | 2.5.0 | MIT | | react-markdown | 10.1.0 | MIT | +| react-remove-scroll-bar | 2.3.8 | MIT | +| react-remove-scroll | 2.7.2 | MIT | +| react-style-singleton | 2.2.3 | MIT | | react | 19.2.8 | MIT | | remark-gfm | 4.0.1 | MIT | | remark-parse | 11.0.0 | MIT | @@ -528,12 +541,15 @@ details for reciprocal licenses are in `THIRD_PARTY_SOURCES.md`. | tokenx | 1.6.0 | MIT | | trim-lines | 3.0.1 | MIT | | trough | 2.2.0 | MIT | +| tslib | 2.8.1 | 0BSD | | unified | 11.0.5 | MIT | | unist-util-is | 6.0.1 | MIT | | unist-util-position | 5.0.0 | MIT | | unist-util-stringify-position | 4.0.0 | MIT | | unist-util-visit-parents | 6.0.2 | MIT | | unist-util-visit | 5.1.0 | MIT | +| use-callback-ref | 1.3.3 | MIT | +| use-sidecar | 1.1.3 | MIT | | vfile-message | 4.0.3 | MIT | | vfile | 6.0.3 | MIT | | zwitch | 2.0.4 | MIT | diff --git a/THIRD_PARTY_NOTICES.txt b/THIRD_PARTY_NOTICES.txt index c5768db..1f5a5f8 100644 --- a/THIRD_PARTY_NOTICES.txt +++ b/THIRD_PARTY_NOTICES.txt @@ -6959,22 +6959,29 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ------------------------------------------------------------------------------- Used by: +- @radix-ui/number@1.1.3 (MIT) - @radix-ui/primitive@1.1.7 (MIT) - @radix-ui/react-arrow@1.1.15 (MIT) +- @radix-ui/react-collection@1.1.15 (MIT) - @radix-ui/react-compose-refs@1.1.5 (MIT) - @radix-ui/react-context@1.2.2 (MIT) +- @radix-ui/react-direction@1.1.4 (MIT) - @radix-ui/react-dismissable-layer@1.1.19 (MIT) +- @radix-ui/react-focus-guards@1.1.6 (MIT) +- @radix-ui/react-focus-scope@1.1.16 (MIT) - @radix-ui/react-id@1.1.4 (MIT) - @radix-ui/react-popper@1.3.7 (MIT) - @radix-ui/react-portal@1.1.17 (MIT) - @radix-ui/react-presence@1.1.10 (MIT) - @radix-ui/react-primitive@2.1.10 (MIT) +- @radix-ui/react-select@2.3.7 (MIT) - @radix-ui/react-slot@1.3.3 (MIT) - @radix-ui/react-tooltip@1.2.16 (MIT) - @radix-ui/react-use-callback-ref@1.1.4 (MIT) - @radix-ui/react-use-controllable-state@1.2.6 (MIT) - @radix-ui/react-use-effect-event@0.0.5 (MIT) - @radix-ui/react-use-layout-effect@1.1.4 (MIT) +- @radix-ui/react-use-previous@1.1.4 (MIT) - @radix-ui/react-use-rect@1.1.4 (MIT) - @radix-ui/react-use-size@1.1.4 (MIT) - @radix-ui/react-visually-hidden@1.2.11 (MIT) @@ -7139,6 +7146,36 @@ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +------------------------------------------------------------------------------- +Used by: +- aria-hidden@1.2.6 (MIT) +- react-remove-scroll@2.7.2 (MIT) +- react-style-singleton@2.2.3 (MIT) +- use-callback-ref@1.3.3 (MIT) +- use-sidecar@1.1.3 (MIT) + +MIT License + +Copyright (c) 2017 Anton Korzunov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + ------------------------------------------------------------------------------- Used by: - bail@2.0.2 (MIT) @@ -7344,6 +7381,32 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +------------------------------------------------------------------------------- +Used by: +- detect-node-es@1.1.0 (MIT) + +MIT License + +Copyright (c) 2017 Ilya Kantor + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + ------------------------------------------------------------------------------- Used by: - devlop@1.1.0 (MIT) @@ -7452,6 +7515,32 @@ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +------------------------------------------------------------------------------- +Used by: +- get-nonce@1.0.1 (MIT) + +MIT License + +Copyright (c) 2020 Anton Korzunov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + ------------------------------------------------------------------------------- Used by: - html-url-attributes@3.0.1 (MIT) @@ -7730,6 +7819,63 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +------------------------------------------------------------------------------- +Used by: +- react-remove-scroll-bar@2.3.8 (MIT) + +

react-remove-scroll-bar

+ +[![npm](https://img.shields.io/npm/v/react-remove-scroll-bar.svg)](https://www.npmjs.com/package/react-remove-scroll-bar) +[![bundle size](https://badgen.net/bundlephobia/minzip/react-remove-scroll-bar)](https://bundlephobia.com/result?p=react-remove-scroll-bar) +[![downloads](https://badgen.net/npm/dm/react-remove-scroll-bar)](https://www.npmtrends.com/react-remove-scroll-bar) + +
+ +> v1+ for React 15, v2+ requires React 16.8+ + +Removes scroll bar (by setting `overflow: hidden` on body), and preserves the scroll bar "gap". + +Read - it just makes scroll bar invisible. + +Does nothing if scroll bar does not consume any space. + +# Usage + +```js +import {RemoveScrollBar} from 'react-remove-scroll-bar'; + + -> no scroll bar +``` + +### The Right Border +To prevent content jumps __position:fixed__ elements with `right:0` should have additional classname applied. +It will just provide a _non-zero_ right, when it needed, to maintain the right "gap". +```js +import {zeroRightClassName,fullWidthClassName, noScrollbarsClassName} from 'react-remove-scroll-bar'; + +// to set `right:0` on an element +
+ +// to set `width:100%` on an element +
+ +// to remove scrollbar from an element +
+ +``` + +# Size +500b after compression (excluding tslib). + +# Scroll-Locky +All code is a result of a [react-scroll-locky](https://github.com/theKashey/react-scroll-locky) refactoring. + +# Article +There is a medium article about preventing the body scroll - [How to fight the scroll](https://medium.com/@antonkorzunov/how-to-fight-the-body-scroll-2b00267b37ac) + +# License +MIT + ------------------------------------------------------------------------------- Used by: - remark-parse@11.0.0 (MIT) @@ -7863,6 +8009,23 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +------------------------------------------------------------------------------- +Used by: +- tslib@2.8.1 (0BSD) + +Copyright (c) Microsoft Corporation. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. + ------------------------------------------------------------------------------- Used by: - unified@11.0.5 (MIT) diff --git a/bom.cyclonedx.json b/bom.cyclonedx.json index b98ec48..5ac4f89 100644 --- a/bom.cyclonedx.json +++ b/bom.cyclonedx.json @@ -1,7 +1,7 @@ { "bomFormat": "CycloneDX", "specVersion": "1.6", - "serialNumber": "urn:uuid:1a4f4ce6-ac43-4dee-a45d-8d03c8231185", + "serialNumber": "urn:uuid:54ad63fe-3c3a-4af4-ac2f-91d239fb104a", "version": 1, "metadata": { "component": { @@ -7125,6 +7125,24 @@ } ] }, + { + "type": "library", + "bom-ref": "pkg:npm/@radix-ui%2Fnumber@1.1.3", + "name": "@radix-ui/number", + "version": "1.1.3", + "licenses": [ + { + "expression": "MIT" + } + ], + "purl": "pkg:npm/@radix-ui%2Fnumber@1.1.3", + "externalReferences": [ + { + "type": "website", + "url": "https://www.npmjs.com/package/%40radix-ui/number/v/1.1.3" + } + ] + }, { "type": "library", "bom-ref": "pkg:npm/@radix-ui%2Fprimitive@1.1.7", @@ -7161,6 +7179,24 @@ } ] }, + { + "type": "library", + "bom-ref": "pkg:npm/@radix-ui%2Freact-collection@1.1.15", + "name": "@radix-ui/react-collection", + "version": "1.1.15", + "licenses": [ + { + "expression": "MIT" + } + ], + "purl": "pkg:npm/@radix-ui%2Freact-collection@1.1.15", + "externalReferences": [ + { + "type": "website", + "url": "https://www.npmjs.com/package/%40radix-ui/react-collection/v/1.1.15" + } + ] + }, { "type": "library", "bom-ref": "pkg:npm/@radix-ui%2Freact-compose-refs@1.1.5", @@ -7197,6 +7233,24 @@ } ] }, + { + "type": "library", + "bom-ref": "pkg:npm/@radix-ui%2Freact-direction@1.1.4", + "name": "@radix-ui/react-direction", + "version": "1.1.4", + "licenses": [ + { + "expression": "MIT" + } + ], + "purl": "pkg:npm/@radix-ui%2Freact-direction@1.1.4", + "externalReferences": [ + { + "type": "website", + "url": "https://www.npmjs.com/package/%40radix-ui/react-direction/v/1.1.4" + } + ] + }, { "type": "library", "bom-ref": "pkg:npm/@radix-ui%2Freact-dismissable-layer@1.1.19", @@ -7215,6 +7269,42 @@ } ] }, + { + "type": "library", + "bom-ref": "pkg:npm/@radix-ui%2Freact-focus-guards@1.1.6", + "name": "@radix-ui/react-focus-guards", + "version": "1.1.6", + "licenses": [ + { + "expression": "MIT" + } + ], + "purl": "pkg:npm/@radix-ui%2Freact-focus-guards@1.1.6", + "externalReferences": [ + { + "type": "website", + "url": "https://www.npmjs.com/package/%40radix-ui/react-focus-guards/v/1.1.6" + } + ] + }, + { + "type": "library", + "bom-ref": "pkg:npm/@radix-ui%2Freact-focus-scope@1.1.16", + "name": "@radix-ui/react-focus-scope", + "version": "1.1.16", + "licenses": [ + { + "expression": "MIT" + } + ], + "purl": "pkg:npm/@radix-ui%2Freact-focus-scope@1.1.16", + "externalReferences": [ + { + "type": "website", + "url": "https://www.npmjs.com/package/%40radix-ui/react-focus-scope/v/1.1.16" + } + ] + }, { "type": "library", "bom-ref": "pkg:npm/@radix-ui%2Freact-id@1.1.4", @@ -7305,6 +7395,24 @@ } ] }, + { + "type": "library", + "bom-ref": "pkg:npm/@radix-ui%2Freact-select@2.3.7", + "name": "@radix-ui/react-select", + "version": "2.3.7", + "licenses": [ + { + "expression": "MIT" + } + ], + "purl": "pkg:npm/@radix-ui%2Freact-select@2.3.7", + "externalReferences": [ + { + "type": "website", + "url": "https://www.npmjs.com/package/%40radix-ui/react-select/v/2.3.7" + } + ] + }, { "type": "library", "bom-ref": "pkg:npm/@radix-ui%2Freact-slot@1.3.3", @@ -7413,6 +7521,24 @@ } ] }, + { + "type": "library", + "bom-ref": "pkg:npm/@radix-ui%2Freact-use-previous@1.1.4", + "name": "@radix-ui/react-use-previous", + "version": "1.1.4", + "licenses": [ + { + "expression": "MIT" + } + ], + "purl": "pkg:npm/@radix-ui%2Freact-use-previous@1.1.4", + "externalReferences": [ + { + "type": "website", + "url": "https://www.npmjs.com/package/%40radix-ui/react-use-previous/v/1.1.4" + } + ] + }, { "type": "library", "bom-ref": "pkg:npm/@radix-ui%2Freact-use-rect@1.1.4", @@ -7719,6 +7845,24 @@ } ] }, + { + "type": "library", + "bom-ref": "pkg:npm/aria-hidden@1.2.6", + "name": "aria-hidden", + "version": "1.2.6", + "licenses": [ + { + "expression": "MIT" + } + ], + "purl": "pkg:npm/aria-hidden@1.2.6", + "externalReferences": [ + { + "type": "website", + "url": "https://www.npmjs.com/package/aria-hidden/v/1.2.6" + } + ] + }, { "type": "library", "bom-ref": "pkg:npm/bail@2.0.2", @@ -7917,6 +8061,24 @@ } ] }, + { + "type": "library", + "bom-ref": "pkg:npm/detect-node-es@1.1.0", + "name": "detect-node-es", + "version": "1.1.0", + "licenses": [ + { + "expression": "MIT" + } + ], + "purl": "pkg:npm/detect-node-es@1.1.0", + "externalReferences": [ + { + "type": "website", + "url": "https://www.npmjs.com/package/detect-node-es/v/1.1.0" + } + ] + }, { "type": "library", "bom-ref": "pkg:npm/devlop@1.1.0", @@ -7989,6 +8151,24 @@ } ] }, + { + "type": "library", + "bom-ref": "pkg:npm/get-nonce@1.0.1", + "name": "get-nonce", + "version": "1.0.1", + "licenses": [ + { + "expression": "MIT" + } + ], + "purl": "pkg:npm/get-nonce@1.0.1", + "externalReferences": [ + { + "type": "website", + "url": "https://www.npmjs.com/package/get-nonce/v/1.0.1" + } + ] + }, { "type": "library", "bom-ref": "pkg:npm/hast-util-to-jsx-runtime@2.3.6", @@ -9087,6 +9267,60 @@ } ] }, + { + "type": "library", + "bom-ref": "pkg:npm/react-remove-scroll-bar@2.3.8", + "name": "react-remove-scroll-bar", + "version": "2.3.8", + "licenses": [ + { + "expression": "MIT" + } + ], + "purl": "pkg:npm/react-remove-scroll-bar@2.3.8", + "externalReferences": [ + { + "type": "website", + "url": "https://www.npmjs.com/package/react-remove-scroll-bar/v/2.3.8" + } + ] + }, + { + "type": "library", + "bom-ref": "pkg:npm/react-remove-scroll@2.7.2", + "name": "react-remove-scroll", + "version": "2.7.2", + "licenses": [ + { + "expression": "MIT" + } + ], + "purl": "pkg:npm/react-remove-scroll@2.7.2", + "externalReferences": [ + { + "type": "website", + "url": "https://www.npmjs.com/package/react-remove-scroll/v/2.7.2" + } + ] + }, + { + "type": "library", + "bom-ref": "pkg:npm/react-style-singleton@2.2.3", + "name": "react-style-singleton", + "version": "2.2.3", + "licenses": [ + { + "expression": "MIT" + } + ], + "purl": "pkg:npm/react-style-singleton@2.2.3", + "externalReferences": [ + { + "type": "website", + "url": "https://www.npmjs.com/package/react-style-singleton/v/2.2.3" + } + ] + }, { "type": "library", "bom-ref": "pkg:npm/react@19.2.8", @@ -9321,6 +9555,24 @@ } ] }, + { + "type": "library", + "bom-ref": "pkg:npm/tslib@2.8.1", + "name": "tslib", + "version": "2.8.1", + "licenses": [ + { + "expression": "0BSD" + } + ], + "purl": "pkg:npm/tslib@2.8.1", + "externalReferences": [ + { + "type": "website", + "url": "https://www.npmjs.com/package/tslib/v/2.8.1" + } + ] + }, { "type": "library", "bom-ref": "pkg:npm/unified@11.0.5", @@ -9429,6 +9681,42 @@ } ] }, + { + "type": "library", + "bom-ref": "pkg:npm/use-callback-ref@1.3.3", + "name": "use-callback-ref", + "version": "1.3.3", + "licenses": [ + { + "expression": "MIT" + } + ], + "purl": "pkg:npm/use-callback-ref@1.3.3", + "externalReferences": [ + { + "type": "website", + "url": "https://www.npmjs.com/package/use-callback-ref/v/1.3.3" + } + ] + }, + { + "type": "library", + "bom-ref": "pkg:npm/use-sidecar@1.1.3", + "name": "use-sidecar", + "version": "1.1.3", + "licenses": [ + { + "expression": "MIT" + } + ], + "purl": "pkg:npm/use-sidecar@1.1.3", + "externalReferences": [ + { + "type": "website", + "url": "https://www.npmjs.com/package/use-sidecar/v/1.1.3" + } + ] + }, { "type": "library", "bom-ref": "pkg:npm/vfile-message@4.0.3", diff --git a/docs/design-system.md b/docs/design-system.md index 1a96cd1..64a8df1 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -166,6 +166,23 @@ page-specific CSS. and must appear above scroll containers without being clipped. - Keyboard focus must be visible. Hover-only behavior is not sufficient. +### Capture Target Select + +The application capture picker uses a Radix select primitive so its custom +visual treatment retains listbox keyboard navigation, focus management, and +typeahead behavior. The closed trigger remains one line and shows the selected +application icon beside the existing executable/title label. The portalled +list shows the window title as Body text and the executable filename as Caption +metadata; both must truncate without displacing the icon or selected marker. + +Application icons use the existing 20 px icon role with a subtle structural +border. They are decorative to assistive technology. When Windows cannot +provide an icon, use the neutral Lucide `AppWindow` fallback rather than an +empty or broken image. A target that stops running retains its last in-memory +icon and appends “(未运行)”; this visual state must not select another target. +The picker must continue to expose a visible focus ring, an accessible label, +and distinct highlighted, selected, empty, and refreshing states. + ### Settings Center Settings is a full-height workspace inside the application shell. The global diff --git a/docs/testing.md b/docs/testing.md index bb4a654..044cd24 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -99,6 +99,20 @@ server-restart, window-recovery, diarization, and final response contract tests. Client and server tests complement each other; neither repository should duplicate the other's internal implementation fixtures. +## Application Capture Target Regression Matrix + +Automated coverage must include: + +- local PNG icon data crossing Rust/TypeScript IPC without exposing native + window handles; +- application-icon extraction failure degrading to a neutral UI fallback + without failing target enumeration or entering the recording-start path; +- selected-target icons, two-line title/executable options, long-text + truncation, and the retained “未运行” preference state; +- Enter/Space opening, arrow-key navigation, typeahead, Enter selection, Escape + dismissal, focus restoration, and an accessible name for every option; +- PID rebinding by executable path and no silent switch to another application. + ## Audio Import Regression Matrix Automated coverage must include: @@ -289,8 +303,11 @@ ordinary technical logs. Before a release that changes audio capture or ASR behavior, exercise relevant scenarios on Windows 11: -1. Record and play back a normal meeting with application capture and a - microphone. +1. Open the application selector with Zoom, Tencent Meeting, Feishu, Chrome or + Edge, and one ordinary Win32 application running. Confirm software icons, + the fallback icon, long-title truncation, keyboard selection, app exit and + PID restart at 100%, 125%, and 150% display scaling. Then record and play + back a normal meeting with application capture and a microphone. 2. Start a FunASR transcription, interrupt upload, and confirm it resumes from the server offset. 3. Exit Nota while the server is processing, relaunch, resume, and confirm the diff --git a/package-lock.json b/package-lock.json index 822a34f..703ad5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.9.0", "license": "MIT", "dependencies": { + "@radix-ui/react-select": "^2.3.7", "@radix-ui/react-tooltip": "^1.2.16", "@tauri-apps/api": "^2.11.0", "@tauri-apps/plugin-dialog": "^2.7.0", @@ -1557,6 +1558,12 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@radix-ui/number": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz", + "integrity": "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==", + "license": "MIT" + }, "node_modules/@radix-ui/primitive": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", @@ -1586,6 +1593,32 @@ } } }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz", + "integrity": "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", @@ -1616,6 +1649,21 @@ } } }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz", + "integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-dismissable-layer": { "version": "1.1.19", "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", @@ -1643,6 +1691,46 @@ } } }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", + "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz", + "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-id": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", @@ -1763,6 +1851,50 @@ } } }, + "node_modules/@radix-ui/react-select": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.7.tgz", + "integrity": "sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", @@ -1884,6 +2016,21 @@ } } }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.4.tgz", + "integrity": "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-rect": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz", @@ -3002,6 +3149,18 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", @@ -3498,6 +3657,12 @@ "node": ">=8" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -3719,6 +3884,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/glob": { "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", @@ -6166,6 +6340,75 @@ "node": ">=0.10.0" } }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/read-cmd-shim": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-6.0.0.tgz", @@ -6802,9 +7045,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/tuf-js": { "version": "4.1.0", @@ -6963,6 +7204,49 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index dbea148..7fd3c36 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "tauri": "tauri" }, "dependencies": { + "@radix-ui/react-select": "^2.3.7", "@radix-ui/react-tooltip": "^1.2.16", "@tauri-apps/api": "^2.11.0", "@tauri-apps/plugin-dialog": "^2.7.0", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1644d5c..75d3551 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2303,6 +2303,7 @@ version = "0.9.0" dependencies = [ "anyhow", "arboard", + "base64 0.22.1", "chrono", "crossbeam-channel", "dirs", @@ -2311,6 +2312,7 @@ dependencies = [ "ogg", "opus", "parking_lot", + "png 0.18.1", "rand 0.9.5", "reqwest 0.12.28", "rubato", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8a2c204..990680f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -18,6 +18,7 @@ tauri-build = { version = "2.6.3", features = [] } [dependencies] anyhow = "1.0" arboard = "3.6" +base64 = "0.22.1" chrono = { version = "0.4", features = ["serde"] } crossbeam-channel = "0.5" dirs = "6.0" @@ -26,6 +27,7 @@ log = { version = "0.4", features = ["std"] } ogg = "0.9.2" opus = "0.3.1" parking_lot = "0.12" +png = "0.18.1" rand = "0.9" reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "multipart", "rustls-tls"] } rubato = { version = "0.16.2", default-features = false } @@ -45,6 +47,7 @@ uuid = { version = "1.18", features = ["v4", "serde"] } windows = { version = "0.62.2", features = [ "Win32_Devices_FunctionDiscovery", "Win32_Foundation", + "Win32_Graphics_Gdi", "Win32_Media_Audio", "Win32_Media_KernelStreaming", "Win32_Media_Multimedia", diff --git a/src-tauri/src/audio/wasapi.rs b/src-tauri/src/audio/wasapi.rs index fd50967..b144fe6 100644 --- a/src-tauri/src/audio/wasapi.rs +++ b/src-tauri/src/audio/wasapi.rs @@ -1344,6 +1344,7 @@ pub fn list_capture_targets() -> Result> { }, process_id: pid, executable_path, + icon_data_url: None, window_handle: window_handles.get(&pid).copied(), browser, priority, @@ -1608,6 +1609,7 @@ mod tests { display_name: "Meeting".into(), process_id: 2, executable_path: r"C:\Apps\Meeting.exe".into(), + icon_data_url: Some("data:image/png;base64,aWNvbg==".into()), window_handle: Some(22), browser: false, priority: 90, @@ -1615,6 +1617,10 @@ mod tests { let json = serde_json::to_value(target).expect("capture target should serialize"); assert!(json.get("windowHandle").is_none()); + assert_eq!( + json.get("iconDataUrl").and_then(|value| value.as_str()), + Some("data:image/png;base64,aWNvbg==") + ); } fn test_tone_wav() -> Vec { diff --git a/src-tauri/src/controller.rs b/src-tauri/src/controller.rs index 952b2c6..6611a7a 100644 --- a/src-tauri/src/controller.rs +++ b/src-tauri/src/controller.rs @@ -19,6 +19,7 @@ use crate::importer::AudioImportManager; use crate::logging::{self, Field, FieldKey}; use crate::models::*; use crate::paths::AppPaths; +use crate::process_icons::attach_capture_target_icons; use crate::state_machine::{RecordingEvent, transition}; use crate::storage::Storage; use crate::voiceprints::VoiceprintManager; @@ -1331,8 +1332,14 @@ fn stop_in_background(app: &AppHandle, recorder: Arc) { } #[tauri::command] -fn list_capture_targets() -> std::result::Result, String> { - command_result(enumerate_capture_targets()) +async fn list_capture_targets() -> std::result::Result, String> { + tauri::async_runtime::spawn_blocking(|| { + let mut targets = command_result(enumerate_capture_targets())?; + attach_capture_target_icons(&mut targets); + Ok(targets) + }) + .await + .map_err(|error| format!("无法刷新应用列表:{error}"))? } #[tauri::command] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8d8f1b5..d620d03 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ mod importer; mod logging; mod models; mod paths; +mod process_icons; mod state_machine; mod storage; mod voiceprints; diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 2905b8a..ccead64 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -10,6 +10,7 @@ pub struct CaptureTarget { pub display_name: String, pub process_id: u32, pub executable_path: String, + pub icon_data_url: Option, #[serde(skip)] pub window_handle: Option, pub browser: bool, diff --git a/src-tauri/src/process_icons.rs b/src-tauri/src/process_icons.rs new file mode 100644 index 0000000..ca8e074 --- /dev/null +++ b/src-tauri/src/process_icons.rs @@ -0,0 +1,341 @@ +use crate::models::CaptureTarget; +use anyhow::{Result, anyhow}; +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use std::collections::HashMap; +use std::ffi::c_void; +use std::fs; +use std::mem::size_of; +use std::slice; +use std::sync::{Mutex, OnceLock}; +use std::time::SystemTime; +use windows::Win32::Foundation::{HWND, LPARAM, RPC_E_CHANGED_MODE, WPARAM}; +use windows::Win32::Graphics::Gdi::{ + BI_RGB, BITMAPINFO, CreateCompatibleDC, CreateDIBSection, DIB_RGB_COLORS, DeleteDC, + DeleteObject, HBITMAP, HDC, HGDIOBJ, SelectObject, +}; +use windows::Win32::Storage::FileSystem::FILE_FLAGS_AND_ATTRIBUTES; +use windows::Win32::System::Com::{COINIT_APARTMENTTHREADED, CoInitializeEx, CoUninitialize}; +use windows::Win32::UI::Shell::{SHFILEINFOW, SHGFI_ICON, SHGFI_LARGEICON, SHGetFileInfoW}; +use windows::Win32::UI::WindowsAndMessaging::{ + CopyIcon, DI_NORMAL, DestroyIcon, DrawIconEx, GCLP_HICON, GCLP_HICONSM, GetClassLongPtrW, + HICON, ICON_BIG, ICON_SMALL, ICON_SMALL2, SMTO_ABORTIFHUNG, SendMessageTimeoutW, WM_GETICON, +}; +use windows::core::PCWSTR; + +const ICON_SIZE: u32 = 32; +const WINDOW_ICON_TIMEOUT_MS: u32 = 100; + +#[derive(Clone)] +struct CachedIcon { + modified_at: Option, + data_url: String, +} + +static ICON_CACHE: OnceLock>> = OnceLock::new(); + +pub fn attach_capture_target_icons(targets: &mut [CaptureTarget]) { + let _com = ComApartment::initialize(); + for target in targets { + target.icon_data_url = icon_data_url(target); + } +} + +fn icon_data_url(target: &CaptureTarget) -> Option { + let cache_key = normalize_cache_key(&target.executable_path); + let modified_at = fs::metadata(&target.executable_path) + .and_then(|metadata| metadata.modified()) + .ok(); + + if !cache_key.is_empty() + && let Some(cached) = icon_cache() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get(&cache_key) + .filter(|cached| cached.modified_at == modified_at) + { + return Some(cached.data_url.clone()); + } + + let icon = shell_icon(&target.executable_path).or_else(|| window_icon(target.window_handle)); + let data_url = icon + .as_ref() + .and_then(|icon| render_icon_rgba(icon.0).ok()) + .and_then(|rgba| encode_png_data_url(ICON_SIZE, ICON_SIZE, &rgba).ok())?; + + if !cache_key.is_empty() { + icon_cache() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert( + cache_key, + CachedIcon { + modified_at, + data_url: data_url.clone(), + }, + ); + } + Some(data_url) +} + +fn icon_cache() -> &'static Mutex> { + ICON_CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn normalize_cache_key(path: &str) -> String { + path.replace('/', "\\").to_lowercase() +} + +fn shell_icon(path: &str) -> Option { + if path.is_empty() { + return None; + } + let wide_path = path.encode_utf16().chain(Some(0)).collect::>(); + let mut info = SHFILEINFOW::default(); + let result = unsafe { + SHGetFileInfoW( + PCWSTR(wide_path.as_ptr()), + FILE_FLAGS_AND_ATTRIBUTES(0), + Some(&mut info), + size_of::() as u32, + SHGFI_ICON | SHGFI_LARGEICON, + ) + }; + if result == 0 || info.hIcon.0.is_null() { + return None; + } + Some(OwnedIcon(info.hIcon)) +} + +fn window_icon(window_handle: Option) -> Option { + let hwnd = HWND(window_handle? as *mut c_void); + for kind in [ICON_BIG, ICON_SMALL2, ICON_SMALL] { + let mut icon_result = 0usize; + let message_result = unsafe { + SendMessageTimeoutW( + hwnd, + WM_GETICON, + WPARAM(kind as usize), + LPARAM(96), + SMTO_ABORTIFHUNG, + WINDOW_ICON_TIMEOUT_MS, + Some(&mut icon_result), + ) + }; + if message_result.0 != 0 + && icon_result != 0 + && let Ok(icon) = unsafe { CopyIcon(HICON(icon_result as *mut c_void)) } + { + return Some(OwnedIcon(icon)); + } + } + + for class_index in [GCLP_HICON, GCLP_HICONSM] { + let icon_result = unsafe { GetClassLongPtrW(hwnd, class_index) }; + if icon_result != 0 + && let Ok(icon) = unsafe { CopyIcon(HICON(icon_result as *mut c_void)) } + { + return Some(OwnedIcon(icon)); + } + } + None +} + +fn render_icon_rgba(icon: HICON) -> Result> { + let black = draw_icon_on_background(icon, 0)?; + let white = draw_icon_on_background(icon, 255)?; + let mut rgba = Vec::with_capacity((ICON_SIZE * ICON_SIZE * 4) as usize); + for (black_pixel, white_pixel) in black.chunks_exact(4).zip(white.chunks_exact(4)) { + let alpha = (0..3) + .map(|channel| { + 255u8.saturating_sub(white_pixel[channel].saturating_sub(black_pixel[channel])) + }) + .min() + .unwrap_or(0); + for channel in [2usize, 1, 0] { + let value = if alpha == 0 { + 0 + } else { + ((u16::from(black_pixel[channel]) * 255 + u16::from(alpha) / 2) / u16::from(alpha)) + .min(255) as u8 + }; + rgba.push(value); + } + rgba.push(alpha); + } + Ok(rgba) +} + +fn draw_icon_on_background(icon: HICON, background: u8) -> Result> { + let dc = OwnedDc::new()?; + let mut bitmap_info = BITMAPINFO::default(); + bitmap_info.bmiHeader.biSize = + size_of::() as u32; + bitmap_info.bmiHeader.biWidth = ICON_SIZE as i32; + bitmap_info.bmiHeader.biHeight = -(ICON_SIZE as i32); + bitmap_info.bmiHeader.biPlanes = 1; + bitmap_info.bmiHeader.biBitCount = 32; + bitmap_info.bmiHeader.biCompression = BI_RGB.0; + + let mut bits = std::ptr::null_mut(); + let bitmap = + unsafe { CreateDIBSection(Some(dc.0), &bitmap_info, DIB_RGB_COLORS, &mut bits, None, 0) } + .map(OwnedBitmap)?; + if bits.is_null() { + return Err(anyhow!("Windows did not return icon bitmap pixels")); + } + + let previous = unsafe { SelectObject(dc.0, HGDIOBJ(bitmap.0.0)) }; + if previous.0.is_null() { + return Err(anyhow!("Windows could not select the icon bitmap")); + } + + let byte_len = (ICON_SIZE * ICON_SIZE * 4) as usize; + let result = (|| { + let pixels = unsafe { slice::from_raw_parts_mut(bits.cast::(), byte_len) }; + for pixel in pixels.chunks_exact_mut(4) { + pixel.fill(background); + } + unsafe { + DrawIconEx( + dc.0, + 0, + 0, + icon, + ICON_SIZE as i32, + ICON_SIZE as i32, + 0, + None, + DI_NORMAL, + )?; + } + Ok::<_, anyhow::Error>(pixels.to_vec()) + })(); + unsafe { + SelectObject(dc.0, previous); + } + result +} + +fn encode_png_data_url(width: u32, height: u32, rgba: &[u8]) -> Result { + if rgba.len() != (width * height * 4) as usize { + return Err(anyhow!("invalid RGBA icon buffer length")); + } + let mut encoded = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut encoded, width, height); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder.write_header()?.write_image_data(rgba)?; + } + Ok(format!( + "data:image/png;base64,{}", + STANDARD.encode(encoded) + )) +} + +struct ComApartment { + uninitialize: bool, +} + +impl ComApartment { + fn initialize() -> Self { + let result = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) }; + Self { + uninitialize: result.is_ok() && result != RPC_E_CHANGED_MODE, + } + } +} + +impl Drop for ComApartment { + fn drop(&mut self) { + if self.uninitialize { + unsafe { CoUninitialize() }; + } + } +} + +struct OwnedIcon(HICON); + +impl Drop for OwnedIcon { + fn drop(&mut self) { + let _ = unsafe { DestroyIcon(self.0) }; + } +} + +struct OwnedDc(HDC); + +impl OwnedDc { + fn new() -> Result { + let dc = unsafe { CreateCompatibleDC(None) }; + if dc.0.is_null() { + return Err(anyhow!("Windows could not create an icon device context")); + } + Ok(Self(dc)) + } +} + +impl Drop for OwnedDc { + fn drop(&mut self) { + unsafe { + let _ = DeleteDC(self.0); + } + } +} + +struct OwnedBitmap(HBITMAP); + +impl Drop for OwnedBitmap { + fn drop(&mut self) { + unsafe { + let _ = DeleteObject(HGDIOBJ(self.0.0)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_windows_icon_cache_keys() { + assert_eq!( + normalize_cache_key("C:/Program Files/Meeting/App.EXE"), + "c:\\program files\\meeting\\app.exe" + ); + } + + #[test] + fn encodes_rgba_as_png_data_url() { + let rgba = vec![255u8; (ICON_SIZE * ICON_SIZE * 4) as usize]; + let data_url = encode_png_data_url(ICON_SIZE, ICON_SIZE, &rgba).unwrap(); + let encoded = data_url.strip_prefix("data:image/png;base64,").unwrap(); + let decoded = STANDARD.decode(encoded).unwrap(); + assert_eq!(&decoded[..8], b"\x89PNG\r\n\x1a\n"); + } + + #[test] + fn missing_icon_source_degrades_without_error() { + let target = CaptureTarget { + id: "process:1".into(), + kind: "process".into(), + display_name: "Missing".into(), + process_id: 1, + executable_path: r"Z:\nota-does-not-exist\missing.exe".into(), + icon_data_url: None, + window_handle: None, + browser: false, + priority: 0, + }; + assert!(icon_data_url(&target).is_none()); + } + + #[test] + fn extracts_and_renders_the_current_windows_executable_icon() { + let _com = ComApartment::initialize(); + let executable = std::env::current_exe().unwrap(); + let icon = shell_icon(executable.to_string_lossy().as_ref()).unwrap(); + let rgba = render_icon_rgba(icon.0).unwrap(); + assert_eq!(rgba.len(), (ICON_SIZE * ICON_SIZE * 4) as usize); + assert!(rgba.chunks_exact(4).any(|pixel| pixel[3] > 0)); + } +} diff --git a/src/App.test.tsx b/src/App.test.tsx index 48d7207..18cad4b 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -51,6 +51,7 @@ const testState = vi.hoisted(() => ({ displayName: string; processId: number; executablePath: string; + iconDataUrl: string | null; browser: boolean; priority: number; }>, @@ -333,6 +334,7 @@ describe("Nota UI states", () => { displayName: "Zoom", processId: 42, executablePath: "C:\\Zoom.exe", + iconDataUrl: "data:image/png;base64,em9vbQ==", browser: false, priority: 100, }, @@ -914,6 +916,9 @@ describe("Nota UI states", () => { it("refreshes applications when the window regains focus", async () => { render(); + const source = await screen.findByRole("combobox", { name: "录音来源" }); + expect(source).toHaveTextContent("[Zoom.exe]: Zoom"); + fireEvent.click(source); expect( await screen.findByRole("option", { name: "[Zoom.exe]: Zoom" }), ).toBeInTheDocument(); @@ -924,6 +929,7 @@ describe("Nota UI states", () => { displayName: "腾讯会议", processId: 77, executablePath: "C:\\Program Files\\Tencent\\wemeetapp.exe", + iconDataUrl: null, browser: false, priority: 90, }, @@ -941,7 +947,7 @@ describe("Nota UI states", () => { it("rebinds the selected application after its process id changes", async () => { render(); const source = await screen.findByRole("combobox", { name: "录音来源" }); - expect(source).toHaveValue("process:42"); + expect(source).toHaveTextContent("[Zoom.exe]: Zoom"); testState.targets = [ { id: "process:84", @@ -949,13 +955,14 @@ describe("Nota UI states", () => { displayName: "Zoom Meeting", processId: 84, executablePath: "c:\\zoom.exe", + iconDataUrl: "data:image/png;base64,em9vbQ==", browser: false, priority: 100, }, ]; act(() => window.dispatchEvent(new Event("focus"))); - await waitFor(() => expect(source).toHaveValue("process:84")); + await waitFor(() => expect(source).toHaveTextContent("[zoom.exe]: Zoom Meeting")); }); }); diff --git a/src/App.tsx b/src/App.tsx index b563dd7..9b436a3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,13 +20,13 @@ import { } from "lucide-react"; import { api, type UnlistenFn } from "./api"; import { - captureTargetLabel, preferenceForTarget, resolveCaptureTarget, type CaptureTargetPreference, } from "./captureTargets"; import { LevelMeter } from "./components/LevelMeter"; import { AppTooltip } from "./components/AppTooltip"; +import { CaptureTargetSelect } from "./components/CaptureTargetSelect"; import { RecordingsWorkspace } from "./components/RecordingsWorkspace"; import { SettingsWorkspace, @@ -999,32 +999,21 @@ export default function App() {
- + {captureMode === "process" ? (
-
- - -
+ { + if (open) void refreshTargets().catch(() => undefined); + }} + />