fix: repair Windows open-with menu

This commit is contained in:
Yoan Huang committed 2026-07-23 23:09:54 +08:00
1 parent 11222f814b
commit d341128d4d
7 files changed
+122 -41

No files matched your search

+23 -11
View File
@@ -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);
+14 -10
View File
@@ -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<OpenWithApplication> => ({
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 }
)
}
+10 -4
View File
@@ -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 {
@@ -329,7 +329,7 @@ export const WorkbenchDocumentPreviewPanelBase: React.FC<WorkbenchDocumentPrevie
setOpenWithError(response.error || '无法获取可用应用')
return
}
setOpenWithApplications(response.applications)
setOpenWithApplications(response.applications.slice(0, 5))
} catch (error) {
setOpenWithApplications([])
setOpenWithError(error instanceof Error ? error.message : '无法获取可用应用')
@@ -12,7 +12,27 @@ import { workbenchSelectors } from '../../../../src/renderer/src/testing/selecto
afterEach(cleanup)
const renderPanel = (openFileWithApplication = vi.fn()) => {
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()
})
})
@@ -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')
})
@@ -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 &&