fix: repair Windows open-with menu
This commit is contained in:
1 parent
11222f814b
commit
d341128d4d
7 files changed
+122
-41
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
Reference in new issue
Block a user