From d341128d4dced987cfb0aac3f2d4f5f8079e1054 Mon Sep 17 00:00:00 2001 From: Yoan Huang Date: Thu, 23 Jul 2026 23:09:54 +0800 Subject: [PATCH] fix: repair Windows open-with menu --- resources/open-with/windows-open-with.txt | 34 +++++++---- src/main/services/openWith/OpenWithService.ts | 24 ++++---- src/renderer/src/assets/base.css | 14 +++-- .../workbench/WorkbenchContentPanel.tsx | 2 +- .../unit/workbench/open-with-panel.test.ts | 58 ++++++++++++++----- .../unit/workbench/open-with-service.test.ts | 25 +++++++- .../preview/file-preview-actions.e2e.test.ts | 6 ++ 7 files changed, 122 insertions(+), 41 deletions(-) diff --git a/resources/open-with/windows-open-with.txt b/resources/open-with/windows-open-with.txt index 63627053..4f1a038e 100644 --- a/resources/open-with/windows-open-with.txt +++ b/resources/open-with/windows-open-with.txt @@ -8,6 +8,9 @@ param( ) $ErrorActionPreference = 'Stop' +$utf8 = New-Object System.Text.UTF8Encoding($false) +[Console]::OutputEncoding = $utf8 +$OutputEncoding = $utf8 Add-Type -TypeDefinition @' using System; @@ -37,6 +40,17 @@ namespace NCurator.OpenWith { void Invoke([MarshalAs(UnmanagedType.Interface)] object itemArray); } + [ComImport] + [Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + interface IShellItem { + void BindToHandler(IntPtr bindingContext, ref Guid handlerId, ref Guid interfaceId, [MarshalAs(UnmanagedType.Interface)] out object result); + void GetParent([MarshalAs(UnmanagedType.Interface)] out object parent); + void GetDisplayName(uint displayNameType, out IntPtr displayName); + void GetAttributes(uint attributeMask, out uint attributes); + void Compare([MarshalAs(UnmanagedType.Interface)] object shellItem, uint hint, out int order); + } + public sealed class ApplicationInfo { public string id { get; set; } public string name { get; set; } @@ -52,12 +66,9 @@ namespace NCurator.OpenWith { static extern int AssocQueryString(AssocFlags flags, AssocString value, string association, string extra, System.Text.StringBuilder output, ref uint outputLength); [DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = false)] - static extern void SHCreateItemFromParsingName(string path, IntPtr bindingContext, ref Guid interfaceId, [MarshalAs(UnmanagedType.Interface)] out object shellItem); + static extern void SHCreateItemFromParsingName(string path, IntPtr bindingContext, ref Guid interfaceId, out IShellItem shellItem); - [DllImport("shell32.dll", PreserveSig = false)] - static extern void SHCreateShellItemArrayFromShellItem([MarshalAs(UnmanagedType.Interface)] object shellItem, ref Guid interfaceId, [MarshalAs(UnmanagedType.Interface)] out object itemArray); - - enum AssocFilter : uint { Recommended = 0 } + enum AssocFilter : uint { Recommended = 1 } [Flags] enum AssocFlags : uint { None = 0 } enum AssocString : uint { Executable = 2 } @@ -136,18 +147,19 @@ namespace NCurator.OpenWith { string id; handler.GetName(out id); if (!String.Equals(id, applicationId, StringComparison.OrdinalIgnoreCase)) continue; - object shellItem; + IShellItem shellItem; var shellItemId = new Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE"); SHCreateItemFromParsingName(filePath, IntPtr.Zero, ref shellItemId, out shellItem); try { - object itemArray; - var itemArrayId = new Guid("B63EA76D-1F85-456F-A19C-48159EFA858B"); - SHCreateShellItemArrayFromShellItem(shellItem, ref itemArrayId, out itemArray); + object dataObject; + var dataObjectHandlerId = new Guid("B8C0BD9F-ED24-455C-83E6-D5390C4FE8C4"); + var dataObjectId = new Guid("0000010E-0000-0000-C000-000000000046"); + shellItem.BindToHandler(IntPtr.Zero, ref dataObjectHandlerId, ref dataObjectId, out dataObject); try { - handler.Invoke(itemArray); + handler.Invoke(dataObject); return; } finally { - Marshal.ReleaseComObject(itemArray); + Marshal.ReleaseComObject(dataObject); } } finally { Marshal.ReleaseComObject(shellItem); diff --git a/src/main/services/openWith/OpenWithService.ts b/src/main/services/openWith/OpenWithService.ts index 641a5fae..7f7facc8 100644 --- a/src/main/services/openWith/OpenWithService.ts +++ b/src/main/services/openWith/OpenWithService.ts @@ -12,6 +12,7 @@ import type { const execFileAsync = promisify(execFile) const OPEN_WITH_CACHE_TTL_MS = 5 * 60 * 1_000 +const MAX_OPEN_WITH_APPLICATIONS = 5 const NATIVE_QUERY_MAX_BUFFER = 10 * 1024 * 1024 export type NativeOpenWithApplication = { @@ -100,8 +101,14 @@ export class OpenWithService { const native = await (this.options.runNativeList?.(resolvedPath) ?? this.runNativeList(resolvedPath)) if (native.error) return { success: false, applications: [], error: native.error } + const nativeApplications = (native.applications || []) + .filter( + (item, index, items) => items.findIndex((candidate) => candidate.id === item.id) === index + ) + .sort((left, right) => Number(right.isDefault) - Number(left.isDefault)) + .slice(0, MAX_OPEN_WITH_APPLICATIONS) const applications = await Promise.all( - (native.applications || []).map( + nativeApplications.map( async (item): Promise => ({ id: item.id, name: item.name, @@ -112,19 +119,16 @@ export class OpenWithService { }) ) ) - const normalized = applications - .filter( - (item, index, items) => items.findIndex((candidate) => candidate.id === item.id) === index - ) - .sort((left, right) => Number(right.isDefault) - Number(left.isDefault)) this.handlerCache.set(resolvedPath, { - applications: native.applications || [], + applications: nativeApplications, expiresAt: (this.options.now?.() ?? Date.now()) + OPEN_WITH_CACHE_TTL_MS }) return { success: true, - applications: normalized, - defaultApplicationId: native.defaultApplicationId + applications, + defaultApplicationId: + nativeApplications.find((application) => application.isDefault)?.id || + native.defaultApplicationId } } catch (error) { return { @@ -219,7 +223,7 @@ export class OpenWithService { '& ([scriptblock]::Create([IO.File]::ReadAllText($env:NCURATOR_OPEN_WITH_SCRIPT))) -Mode $env:NCURATOR_OPEN_WITH_MODE -FilePath $env:NCURATOR_OPEN_WITH_FILE -ApplicationId $env:NCURATOR_OPEN_WITH_APPLICATION' return execFileAsync( 'powershell.exe', - ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', command], + ['-NoProfile', '-NonInteractive', '-Sta', '-ExecutionPolicy', 'Bypass', '-Command', command], { env, maxBuffer: NATIVE_QUERY_MAX_BUFFER } ) } diff --git a/src/renderer/src/assets/base.css b/src/renderer/src/assets/base.css index 0ba14d85..0ac1b182 100644 --- a/src/renderer/src/assets/base.css +++ b/src/renderer/src/assets/base.css @@ -202,10 +202,10 @@ textarea, } .workbench-open-with-menu .ant-dropdown-menu { - min-width: 220px; - max-width: 300px; - max-height: 360px; - overflow-y: auto; + width: 260px; + min-width: 0; + max-width: calc(100vw - 16px); + overflow: hidden; padding: 6px; } @@ -215,6 +215,12 @@ textarea, align-items: center; gap: 10px; padding: 7px 10px; + min-width: 0; +} + +.workbench-open-with-menu .ant-dropdown-menu-title-content { + min-width: 0; + overflow: hidden; } .ui-panel { diff --git a/src/renderer/src/components/workbench/WorkbenchContentPanel.tsx b/src/renderer/src/components/workbench/WorkbenchContentPanel.tsx index c4f2b4ef..1c856fce 100644 --- a/src/renderer/src/components/workbench/WorkbenchContentPanel.tsx +++ b/src/renderer/src/components/workbench/WorkbenchContentPanel.tsx @@ -329,7 +329,7 @@ export const WorkbenchDocumentPreviewPanelBase: React.FC { +const renderPanel = ( + openFileWithApplication = vi.fn(), + applications: Array<{ + id: string + name: string + iconDataUrl?: string + isDefault: boolean + }> = [ + { + id: 'com.example.editor', + name: 'System Editor', + iconDataUrl: 'data:image/png;base64,AA==', + isDefault: true + }, + { + id: 'com.example.viewer', + name: 'Viewer', + isDefault: false + } + ] +) => { const onOpenPath = vi.fn() Object.defineProperty(window, 'matchMedia', { configurable: true, @@ -23,19 +43,7 @@ const renderPanel = (openFileWithApplication = vi.fn()) => { value: { listOpenWithApplications: vi.fn().mockResolvedValue({ success: true, - applications: [ - { - id: 'com.example.editor', - name: 'System Editor', - iconDataUrl: 'data:image/png;base64,AA==', - isDefault: true - }, - { - id: 'com.example.viewer', - name: 'Viewer', - isDefault: false - } - ], + applications, defaultApplicationId: 'com.example.editor' }), openFileWithApplication @@ -106,4 +114,26 @@ describe('workbench open-with panel', () => { ) expect(onOpenPath).not.toHaveBeenCalled() }) + + it('renders no more than five application choices', async () => { + renderPanel( + vi.fn(), + Array.from({ length: 7 }, (_, index) => ({ + id: `application-${index}`, + name: `Application ${index}`, + isDefault: index === 0 + })) + ) + + await waitFor(() => + expect(screen.getByTestId(workbenchSelectors.openWithButton).textContent).toContain( + 'Application 0' + ) + ) + fireEvent.click(screen.getByRole('button', { name: '选择打开应用' })) + + expect(await screen.findByText('Application 4')).toBeTruthy() + expect(screen.queryByText('Application 5')).toBeNull() + expect(screen.queryByText('Application 6')).toBeNull() + }) }) diff --git a/tests/case/unit/workbench/open-with-service.test.ts b/tests/case/unit/workbench/open-with-service.test.ts index 1f87d62c..408c1274 100644 --- a/tests/case/unit/workbench/open-with-service.test.ts +++ b/tests/case/unit/workbench/open-with-service.test.ts @@ -76,6 +76,24 @@ describe('OpenWithService', () => { expect(result.applications[0]?.iconDataUrl).toBe('icon:/Applications/Editor.app') }) + it('returns at most five system-recommended handlers', async () => { + const filePath = await createFile() + const service = new OpenWithService({ + runNativeList: vi.fn().mockResolvedValue({ + applications: Array.from({ length: 7 }, (_, index) => ({ + id: `application-${index}`, + name: `Application ${index}`, + isDefault: index === 6 + })) + }) + }) + + const result = await service.list(filePath) + + expect(result.applications).toHaveLength(5) + expect(result.applications[0]).toMatchObject({ id: 'application-6', isDefault: true }) + }) + it('only opens a handler returned by the system query and reuses its cache', async () => { const filePath = await createFile() const runNativeList = vi.fn().mockResolvedValue({ @@ -116,7 +134,12 @@ describe('OpenWithService', () => { ) expect(helper).toContain('SHAssocEnumHandlers') expect(helper).toContain('IAssocHandler') - expect(helper).toContain('handler.Invoke(itemArray)') + expect(helper).toContain('enum AssocFilter : uint { Recommended = 1 }') + expect(helper).toContain('[Console]::OutputEncoding = $utf8') + expect(helper).toContain('interface IShellItem') + expect(helper).toContain('shellItem.BindToHandler') + expect(helper).toContain('B8C0BD9F-ED24-455C-83E6-D5390C4FE8C4') + expect(helper).toContain('handler.Invoke(dataObject)') expect(helper).not.toContain('Visual Studio Code') expect(helper).not.toContain('Microsoft Word') }) diff --git a/tests/case/workbench/result/preview/file-preview-actions.e2e.test.ts b/tests/case/workbench/result/preview/file-preview-actions.e2e.test.ts index 2d45ee8c..ec9b18b2 100644 --- a/tests/case/workbench/result/preview/file-preview-actions.e2e.test.ts +++ b/tests/case/workbench/result/preview/file-preview-actions.e2e.test.ts @@ -193,6 +193,9 @@ describe('workbench result e2e', () => { await openWithApplicationItems.first().waitFor({ state: 'visible', timeout: 15_000 }) const openWithApplicationCount = await openWithApplicationItems.count() const visibleOpenWithMenu = page.locator('[role="menu"]:visible').last() + const openWithMenuHasHorizontalOverflow = await visibleOpenWithMenu.evaluate( + (element) => element.scrollWidth > element.clientWidth + ) const defaultApplicationMarkerVisible = await visibleOpenWithMenu .getByText('默认', { exact: true }) .isVisible() @@ -222,6 +225,7 @@ describe('workbench result e2e', () => { docxPreviewStableWhileTyping, systemOpenVisible, openWithApplicationCount, + openWithMenuHasHorizontalOverflow, defaultApplicationMarkerVisible, markdownPreviewVisible, plainTextPreviewVisible, @@ -244,6 +248,8 @@ describe('workbench result e2e', () => { result.docxPreviewStableWhileTyping && result.systemOpenVisible && result.openWithApplicationCount >= 1 && + result.openWithApplicationCount <= 5 && + !result.openWithMenuHasHorizontalOverflow && result.defaultApplicationMarkerVisible && result.openWithQueryCalls >= 1 && result.markdownPreviewVisible &&