diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 4446cb53f80..eab15594e1f 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -35,6 +35,7 @@ src/main/ # main process (bundled to dist/main.cjs) browser-sites/ # imported site directory, safeStorage at rest browser-import/ # one-shot import of profiles, cookies and passwords src/preload/ # contextBridge IPC bridge (bundled to dist/preload.cjs) +native/ # Node-API/AppKit bridge for native macOS Help docs search static/ # bundled local pages (offline.html) e2e/ # Playwright _electron smoke suite ``` diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index b7e130d82de..f7e226725e1 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -13,11 +13,12 @@ files: asar: true -# Native modules cannot be dlopen'd from inside an asar. -# `scripts/ensure-pty-prebuilds.ts` guarantees both arch prebuilds are present -# before packaging; see mac.x64ArchFiles for how the universal merge treats them. +# Native modules cannot be dlopen'd from inside an asar. The Help-search addon +# is already universal; `scripts/ensure-pty-prebuilds.ts` guarantees both pty +# prebuilds are present before packaging. asarUnpack: - "**/node_modules/@lydell/node-pty-*/prebuilds/**" + - "**/dist/native/*.node" # Space-free regardless of productName ("Sim Dev" etc.): GitHub rewrites # asset names containing spaces, which would desync the electron-updater diff --git a/apps/desktop/native/help-search.mm b/apps/desktop/native/help-search.mm new file mode 100644 index 00000000000..b82e044e12c --- /dev/null +++ b/apps/desktop/native/help-search.mm @@ -0,0 +1,439 @@ +#import +#import + +#include + +#include +#include +#include +#include +#include + +constexpr NSInteger kMaximumResultCount = 20; +constexpr NSUInteger kMaximumQueryLength = 256; +constexpr NSUInteger kMaximumResponseLength = 512 * 1024; +constexpr NSUInteger kMaximumDisplayTextLength = 240; +constexpr int64_t kSearchDebounceNanoseconds = 200 * NSEC_PER_MSEC; + +/** + * This native trust anchor intentionally duplicates the JS-configured origin: + * caller input must not widen where Help can fetch or open results. + */ +static NSString* const kDocumentationBaseURL = @"https://docs.sim.ai/"; +static NSString* const kDocumentationHost = @"docs.sim.ai"; +static NSString* const kDocumentationSearchPath = @"/api/search"; + +static bool IsTrustedDocumentationURL(NSURL* URL) { + NSURLComponents* components = + URL ? [NSURLComponents componentsWithURL:URL resolvingAgainstBaseURL:NO] : nil; + return components && [components.scheme.lowercaseString isEqualToString:@"https"] && + [components.host.lowercaseString isEqualToString:kDocumentationHost] && + components.user == nil && components.password == nil && components.port == nil; +} + +static bool IsTrustedSearchEndpoint(NSURL* URL, bool allowsQuery) { + if (!IsTrustedDocumentationURL(URL)) { + return false; + } + NSURLComponents* components = + [NSURLComponents componentsWithURL:URL resolvingAgainstBaseURL:NO]; + return [components.path isEqualToString:kDocumentationSearchPath] && + components.fragment == nil && (allowsQuery || components.query == nil); +} + +static NSString* SanitizeDisplayText(id rawText) { + if (![rawText isKindOfClass:[NSString class]]) { + return nil; + } + NSString* text = [[(NSString*)rawText + componentsSeparatedByCharactersInSet:[NSCharacterSet controlCharacterSet]] + componentsJoinedByString:@" "]; + text = [text + stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if (text.length > kMaximumDisplayTextLength) { + NSRange safeRange = [text + rangeOfComposedCharacterSequencesForRange:NSMakeRange(0, kMaximumDisplayTextLength)]; + text = [text substringWithRange:safeRange]; + } + return text.length > 0 ? text : nil; +} + +@interface SIMDocumentationHelpItem : NSObject + +@property(nonatomic, copy) NSArray* localizedTitles; +@property(nonatomic, strong) NSURL* URL; + +- (instancetype)initWithLocalizedTitles:(NSArray*)localizedTitles URL:(NSURL*)URL; + +@end + +@implementation SIMDocumentationHelpItem + +- (instancetype)initWithLocalizedTitles:(NSArray*)localizedTitles URL:(NSURL*)URL { + self = [super init]; + if (self) { + _localizedTitles = [localizedTitles copy]; + _URL = URL; + } + return self; +} + +@end + +@interface SIMDocumentationHelpSearchProvider + : NSObject + +- (instancetype)initWithEndpoint:(NSURL*)endpoint; +- (void)invalidate; + +@end + + +@interface SIMDocumentationHelpSearchProvider () + +@property(nonatomic, strong) NSURL* endpoint; +@property(nonatomic, strong, nullable) NSURLSession* session; +@property(nonatomic, strong, nullable) NSURLSessionDataTask* currentTask; +@property(nonatomic) NSUInteger searchGeneration; + +@end + + +@implementation SIMDocumentationHelpSearchProvider + +- (instancetype)initWithEndpoint:(NSURL*)endpoint { + self = [super init]; + if (self) { + _endpoint = endpoint; + + NSURLSessionConfiguration* configuration = + [NSURLSessionConfiguration ephemeralSessionConfiguration]; + configuration.requestCachePolicy = NSURLRequestReloadIgnoringLocalCacheData; + configuration.timeoutIntervalForRequest = 8; + configuration.timeoutIntervalForResource = 10; + configuration.HTTPMaximumConnectionsPerHost = 1; + configuration.URLCache = nil; + configuration.HTTPCookieStorage = nil; + configuration.URLCredentialStorage = nil; + configuration.HTTPShouldSetCookies = NO; + configuration.HTTPAdditionalHeaders = @{ + @"Accept" : @"application/json", + @"User-Agent" : @"Sim Desktop Help Search" + }; + _session = [NSURLSession sessionWithConfiguration:configuration + delegate:self + delegateQueue:nil]; + } + return self; +} + +- (void)invalidate { + NSURLSession* session = nil; + @synchronized(self) { + self.searchGeneration += 1; + [self.currentTask cancel]; + self.currentTask = nil; + session = self.session; + self.session = nil; + } + [session invalidateAndCancel]; +} + +- (void)URLSession:(NSURLSession*)session + task:(NSURLSessionTask*)task + willPerformHTTPRedirection:(NSHTTPURLResponse*)response + newRequest:(NSURLRequest*)request + completionHandler:(void (^)(NSURLRequest* _Nullable))completionHandler { + (void)session; + (void)task; + (void)response; + (void)request; + completionHandler(nil); +} + +- (void)searchForItemsWithSearchString:(NSString*)searchString + resultLimit:(NSInteger)resultLimit + matchedItemHandler: + (void (^)(NSArray* items))handleMatchedItems { + void (^handleNoMatches)(void) = ^{ + handleMatchedItems(@[]); + }; + NSString* query = [searchString + stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if (query.length == 0) { + handleNoMatches(); + return; + } + if (resultLimit <= 0) { + handleNoMatches(); + return; + } + if (query.length > kMaximumQueryLength) { + NSRange safeRange = [query + rangeOfComposedCharacterSequencesForRange:NSMakeRange(0, kMaximumQueryLength)]; + query = [query substringWithRange:safeRange]; + } + + NSInteger boundedLimit = + std::clamp(resultLimit, static_cast(1), kMaximumResultCount); + __block NSUInteger generation; + @synchronized(self) { + self.searchGeneration += 1; + generation = self.searchGeneration; + [self.currentTask cancel]; + self.currentTask = nil; + } + + dispatch_after( + dispatch_time(DISPATCH_TIME_NOW, kSearchDebounceNanoseconds), + dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + @synchronized(self) { + if (generation != self.searchGeneration) { + return; + } + } + + NSURLComponents* components = + [NSURLComponents componentsWithURL:self.endpoint resolvingAgainstBaseURL:NO]; + components.queryItems = @[ + [NSURLQueryItem queryItemWithName:@"query" value:query], + [NSURLQueryItem queryItemWithName:@"locale" value:@"en"], + [NSURLQueryItem queryItemWithName:@"limit" + value:[NSString stringWithFormat:@"%ld", + boundedLimit]], + ]; + NSURL* requestURL = components.URL; + if (!requestURL) { + handleNoMatches(); + return; + } + + __block __weak NSURLSessionDataTask* task = nil; + void (^completionHandler)(NSData*, NSURLResponse*, NSError*) = + ^(NSData* data, NSURLResponse* response, NSError* error) { + @synchronized(self) { + if (generation != self.searchGeneration || self.currentTask != task) { + return; + } + self.currentTask = nil; + } + + if (error || !data || data.length > kMaximumResponseLength) { + handleNoMatches(); + return; + } + + NSHTTPURLResponse* httpResponse = + [response isKindOfClass:[NSHTTPURLResponse class]] + ? (NSHTTPURLResponse*)response + : nil; + if (!httpResponse || httpResponse.statusCode < 200 || + httpResponse.statusCode >= 300 || + !IsTrustedSearchEndpoint(httpResponse.URL, true)) { + handleNoMatches(); + return; + } + + NSError* jsonError = nil; + id payload = [NSJSONSerialization JSONObjectWithData:data options:0 error:&jsonError]; + if (jsonError || ![payload isKindOfClass:[NSArray class]]) { + handleNoMatches(); + return; + } + + NSMutableArray* items = [NSMutableArray array]; + for (id rawResult in (NSArray*)payload) { + if (items.count >= static_cast(boundedLimit)) { + break; + } + if (![rawResult isKindOfClass:[NSDictionary class]]) { + continue; + } + + NSDictionary* result = (NSDictionary*)rawResult; + id rawURL = result[@"url"]; + if (![rawURL isKindOfClass:[NSString class]]) { + continue; + } + + NSString* title = SanitizeDisplayText(result[@"content"]); + if (!title) { + continue; + } + + NSURL* resultURL = [NSURL URLWithString:(NSString*)rawURL + relativeToURL:[NSURL URLWithString:kDocumentationBaseURL]]; + resultURL = resultURL.absoluteURL; + if (!IsTrustedDocumentationURL(resultURL)) { + continue; + } + + NSMutableArray* localizedTitles = + [NSMutableArray arrayWithObject:@"Sim Documentation"]; + id rawBreadcrumbs = result[@"breadcrumbs"]; + if ([rawBreadcrumbs isKindOfClass:[NSArray class]]) { + for (id rawBreadcrumb in (NSArray*)rawBreadcrumbs) { + if (localizedTitles.count >= 5) { + break; + } + NSString* breadcrumb = SanitizeDisplayText(rawBreadcrumb); + if (breadcrumb && ![breadcrumb isEqualToString:title] && + ![localizedTitles containsObject:breadcrumb]) { + [localizedTitles addObject:breadcrumb]; + } + } + } + [localizedTitles addObject:title]; + + [items addObject:[[SIMDocumentationHelpItem alloc] + initWithLocalizedTitles:localizedTitles + URL:resultURL]]; + } + + handleMatchedItems(items); + }; + + @synchronized(self) { + if (generation != self.searchGeneration || !self.session) { + return; + } + task = [self.session dataTaskWithURL:requestURL + completionHandler:completionHandler]; + self.currentTask = task; + } + [task resume]; + }); +} + +- (NSArray*)localizedTitlesForItem:(id)item { + if (![item isKindOfClass:[SIMDocumentationHelpItem class]]) { + return @[]; + } + SIMDocumentationHelpItem* result = (SIMDocumentationHelpItem*)item; + return result.localizedTitles; +} + +- (void)performActionForItem:(id)item { + if (![item isKindOfClass:[SIMDocumentationHelpItem class]]) { + return; + } + SIMDocumentationHelpItem* result = (SIMDocumentationHelpItem*)item; + [[NSWorkspace sharedWorkspace] openURL:result.URL]; +} + +@end + +static __strong SIMDocumentationHelpSearchProvider* gProvider = nil; +static std::atomic gOwnerEnvironment{nullptr}; + +static bool ReadStringArgument(napi_env env, + napi_callback_info info, + std::string* value) { + size_t argument_count = 1; + napi_value arguments[1]; + if (napi_get_cb_info(env, info, &argument_count, arguments, nullptr, nullptr) != napi_ok || + argument_count != 1) { + napi_throw_type_error(env, nullptr, "install expects one endpoint URL"); + return false; + } + + napi_valuetype type; + if (napi_typeof(env, arguments[0], &type) != napi_ok || type != napi_string) { + napi_throw_type_error(env, nullptr, "endpoint URL must be a string"); + return false; + } + + size_t length = 0; + if (napi_get_value_string_utf8(env, arguments[0], nullptr, 0, &length) != napi_ok) { + napi_throw_type_error(env, nullptr, "could not read endpoint URL"); + return false; + } + std::vector buffer(length + 1); + if (napi_get_value_string_utf8(env, arguments[0], buffer.data(), buffer.size(), &length) != + napi_ok) { + napi_throw_type_error(env, nullptr, "could not read endpoint URL"); + return false; + } + value->assign(buffer.data(), length); + return true; +} + +static void UnregisterProvider() { + @autoreleasepool { + if (!gProvider) { + gOwnerEnvironment.store(nullptr); + return; + } + [gProvider invalidate]; + [NSApp unregisterUserInterfaceItemSearchHandler:gProvider]; + gProvider = nil; + gOwnerEnvironment.store(nullptr); + } +} + +static void UnregisterProviderOnMainThread() { + if ([NSThread isMainThread]) { + UnregisterProvider(); + return; + } + dispatch_sync(dispatch_get_main_queue(), ^{ + UnregisterProvider(); + }); +} + +static napi_value BooleanResult(napi_env env, bool value) { + napi_value result; + napi_get_boolean(env, value, &result); + return result; +} + +static napi_value Install(napi_env env, napi_callback_info info) { + @autoreleasepool { + std::string endpoint_string; + if (!ReadStringArgument(env, info, &endpoint_string)) { + return nullptr; + } + + NSString* endpoint_text = + [[NSString alloc] initWithBytes:endpoint_string.data() + length:endpoint_string.size() + encoding:NSUTF8StringEncoding]; + NSURL* endpoint = endpoint_text ? [NSURL URLWithString:endpoint_text] : nil; + if (!IsTrustedSearchEndpoint(endpoint, false) || NSApp == nil || + ![NSThread isMainThread]) { + return BooleanResult(env, false); + } + + UnregisterProvider(); + gProvider = [[SIMDocumentationHelpSearchProvider alloc] initWithEndpoint:endpoint]; + gOwnerEnvironment.store(env); + [NSApp registerUserInterfaceItemSearchHandler:gProvider]; + return BooleanResult(env, true); + } +} + +static napi_value Uninstall(napi_env env, napi_callback_info info) { + (void)info; + if (env == gOwnerEnvironment.load()) { + UnregisterProviderOnMainThread(); + } + napi_value undefined; + napi_get_undefined(env, &undefined); + return undefined; +} + +static void Cleanup(void* data) { + if (data == gOwnerEnvironment.load()) { + UnregisterProviderOnMainThread(); + } +} + +NAPI_MODULE_INIT() { + napi_property_descriptor properties[] = { + {"install", nullptr, Install, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"uninstall", nullptr, Uninstall, nullptr, nullptr, nullptr, napi_default, nullptr}, + }; + napi_define_properties(env, exports, std::size(properties), properties); + napi_add_env_cleanup_hook(env, Cleanup, env); + return exports; +} diff --git a/apps/desktop/scripts/build.ts b/apps/desktop/scripts/build.ts index f60371058c9..f2b553661d2 100644 --- a/apps/desktop/scripts/build.ts +++ b/apps/desktop/scripts/build.ts @@ -1,4 +1,6 @@ -import { cpSync, rmSync } from 'node:fs' +import { execFileSync } from 'node:child_process' +import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs' +import { dirname, join } from 'node:path' import { build } from 'esbuild' import { identityForOrigin } from './channels' @@ -30,6 +32,52 @@ rmSync(generatedIcon, { force: true, recursive: true }) cpSync(appIcon, generatedIcon, { recursive: true }) console.log(`• Selecting desktop icon: ${appIcon}`) +function compileNativeHelpSearch(): void { + const outputDirectory = 'dist/native' + rmSync(outputDirectory, { force: true, recursive: true }) + if (process.platform !== 'darwin') return + + const nodeExecutable = execFileSync('node', ['-p', 'process.execPath'], { + encoding: 'utf8', + }).trim() + const nodeIncludeDirectory = join(dirname(nodeExecutable), '..', 'include', 'node') + const nodeApiHeader = join(nodeIncludeDirectory, 'node_api.h') + if (!existsSync(nodeApiHeader)) { + throw new Error(`Could not find Node-API headers at ${nodeApiHeader}`) + } + + mkdirSync(outputDirectory, { recursive: true }) + execFileSync( + 'xcrun', + [ + 'clang++', + '-std=c++17', + '-DNAPI_VERSION=8', + '-fobjc-arc', + '-fblocks', + '-bundle', + '-undefined', + 'dynamic_lookup', + '-mmacosx-version-min=12.0', + '-arch', + 'arm64', + '-arch', + 'x86_64', + '-I', + nodeIncludeDirectory, + '-framework', + 'AppKit', + '-framework', + 'Foundation', + '-o', + join(outputDirectory, 'help-search.node'), + 'native/help-search.mm', + ], + { stdio: 'inherit' } + ) + console.log('• Compiled native macOS documentation Help search') +} + const common = { bundle: true, platform: 'node' as const, @@ -47,6 +95,7 @@ const common = { } async function run(): Promise { + compileNativeHelpSearch() if (watch) { const { context } = await import('esbuild') const mainCtx = await context({ diff --git a/apps/desktop/src/main/external-links.ts b/apps/desktop/src/main/external-links.ts new file mode 100644 index 00000000000..4e061888493 --- /dev/null +++ b/apps/desktop/src/main/external-links.ts @@ -0,0 +1,3 @@ +export const DOCS_URL = 'https://docs.sim.ai' +export const DOCS_SEARCH_ENDPOINT = `${DOCS_URL}/api/search` +export const STATUS_URL = 'https://status.sim.ai' diff --git a/apps/desktop/src/main/help-search.test.ts b/apps/desktop/src/main/help-search.test.ts new file mode 100644 index 00000000000..52016cdecd5 --- /dev/null +++ b/apps/desktop/src/main/help-search.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + installDocumentationHelpSearch, + uninstallDocumentationHelpSearch, +} from '@/main/help-search' + +describe('documentation Help search', () => { + afterEach(() => { + uninstallDocumentationHelpSearch() + }) + + it('registers the native provider with the canonical docs endpoint', () => { + const bridge = { + install: vi.fn(() => true), + uninstall: vi.fn(), + } + + expect( + installDocumentationHelpSearch({ + isElectron: true, + isMacOS: true, + loadBridge: () => bridge, + }) + ).toBe(true) + expect(bridge.install).toHaveBeenCalledWith('https://docs.sim.ai/api/search') + + uninstallDocumentationHelpSearch() + expect(bridge.uninstall).toHaveBeenCalledOnce() + }) + + it('does not load the bridge outside Electron on macOS', () => { + const loadBridge = vi.fn() + + expect( + installDocumentationHelpSearch({ + isElectron: false, + isMacOS: true, + loadBridge, + }) + ).toBe(false) + expect(loadBridge).not.toHaveBeenCalled() + }) + + it('fails closed for an invalid or refusing bridge', () => { + expect( + installDocumentationHelpSearch({ + isElectron: true, + isMacOS: true, + loadBridge: () => ({ install: () => true }), + }) + ).toBe(false) + expect( + installDocumentationHelpSearch({ + isElectron: true, + isMacOS: true, + loadBridge: () => ({ install: () => false, uninstall: vi.fn() }), + }) + ).toBe(false) + }) +}) diff --git a/apps/desktop/src/main/help-search.ts b/apps/desktop/src/main/help-search.ts new file mode 100644 index 00000000000..8b9eca5810d --- /dev/null +++ b/apps/desktop/src/main/help-search.ts @@ -0,0 +1,75 @@ +import { join } from 'node:path' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { DOCS_SEARCH_ENDPOINT } from '@/main/external-links' + +const logger = createLogger('DesktopHelpSearch') + +interface NativeHelpSearchBridge { + install: (endpoint: string) => boolean + uninstall: () => void +} + +interface HelpSearchRuntime { + isElectron: boolean + isMacOS: boolean + loadBridge: () => unknown +} + +let activeBridge: NativeHelpSearchBridge | null = null + +function isNativeHelpSearchBridge(value: unknown): value is NativeHelpSearchBridge { + if (!value || typeof value !== 'object') return false + const candidate = value as Partial + return typeof candidate.install === 'function' && typeof candidate.uninstall === 'function' +} + +function loadNativeBridge(): unknown { + return require(join(__dirname, 'native', 'help-search.node')) +} + +const nativeRuntime: HelpSearchRuntime = { + isElectron: Boolean(process.versions.electron), + isMacOS: process.platform === 'darwin', + loadBridge: loadNativeBridge, +} + +/** + * Registers Sim's documentation index with the native macOS Help search field. + * The bridge is optional so non-macOS development and unit tests remain portable. + */ +export function installDocumentationHelpSearch(runtime = nativeRuntime): boolean { + if (!runtime.isMacOS || !runtime.isElectron) return false + + try { + const bridge = runtime.loadBridge() + if (!isNativeHelpSearchBridge(bridge)) { + logger.warn('Native documentation Help search bridge has an invalid interface') + return false + } + if (!bridge.install(DOCS_SEARCH_ENDPOINT)) { + logger.warn('Native documentation Help search bridge refused its endpoint') + return false + } + activeBridge = bridge + return true + } catch (error) { + logger.warn('Could not install native documentation Help search', { + error: getErrorMessage(error), + }) + return false + } +} + +/** Unregisters the native provider before Electron tears AppKit down. */ +export function uninstallDocumentationHelpSearch(): void { + try { + activeBridge?.uninstall() + } catch (error) { + logger.warn('Could not uninstall native documentation Help search', { + error: getErrorMessage(error), + }) + } finally { + activeBridge = null + } +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 48762e5c9c4..cf82af3da67 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -38,6 +38,10 @@ import { DesktopChatSessionStore } from '@/main/desktop-chat-session-store' import { createDesktopSettingsService } from '@/main/desktop-settings' import { attachDownloadHandling } from '@/main/downloads' import { createAuthFlow, createConnectFlow, createHandoffManager } from '@/main/handoff' +import { + installDocumentationHelpSearch, + uninstallDocumentationHelpSearch, +} from '@/main/help-search' import { registerIpcHandlers } from '@/main/ipc' import { attachLoadHealth, type LoadHealthHandle } from '@/main/load-health' import { LocalFilesystemService } from '@/main/local-filesystem' @@ -500,6 +504,7 @@ function main(): void { // set. This prevents a navigation event racing the synchronous quit flush. quiesceBrowserSessions() terminal.dispose() + uninstallDocumentationHelpSearch() flushDesktopChatSessions('before-quit') // Settings writes coalesce, so a change made in the last moments before // quit is still pending here. @@ -667,10 +672,12 @@ function main(): void { handleFocusedBrowserShortcut(shortcut, win) || terminal.handleFocusedShortcut(win, shortcut), toggleSidebar: () => getMainWindow()?.webContents.send('desktop:command', 'toggle-sidebar'), + openSearch: () => getMainWindow()?.webContents.send('desktop:command', 'open-search'), signOut: signOutFromMenu, checkForUpdates: () => checkForUpdatesInteractive({ getWindow: getMainWindow, events, handle: updater }), }) + installDocumentationHelpSearch() setTrayEnabled(config.get('trayEnabled') ?? true) updater = initUpdater({ getWindow: getMainWindow, diff --git a/apps/desktop/src/main/menu.test.ts b/apps/desktop/src/main/menu.test.ts index 714ed9f0d4c..faf1648b413 100644 --- a/apps/desktop/src/main/menu.test.ts +++ b/apps/desktop/src/main/menu.test.ts @@ -22,6 +22,7 @@ function makeDeps(): MenuDeps { newChat: vi.fn(), handleFocusedResourceShortcut: vi.fn(() => false), toggleSidebar: vi.fn(), + openSearch: vi.fn(), signOut: vi.fn(), checkForUpdates: vi.fn(), } @@ -77,6 +78,7 @@ describe('buildMenuTemplate', () => { 'Close Window', ]) expect(submenu(template, 'View').map((item) => item.label ?? item.role ?? item.type)).toEqual([ + 'Search', 'Toggle Sidebar', 'separator', 'Back', @@ -265,6 +267,18 @@ describe('buildMenuTemplate', () => { expect(deps.config.set).toHaveBeenCalledWith('zoomLevel', 0) }) + it('opens the search palette from View with the platform Mod+K accelerator', () => { + const deps = makeDeps() + const item = submenu(buildMenuTemplate(deps), 'View').find( + (entry) => entry.accelerator === 'CmdOrCtrl+K' + ) + + expect(item).toMatchObject({ label: 'Search' }) + ;(item?.click as unknown as () => void)() + expect(deps.openSearch).toHaveBeenCalledOnce() + expect(deps.handleFocusedResourceShortcut).not.toHaveBeenCalled() + }) + it('offers the standard new-window command', () => { const deps = makeDeps() const item = submenu(buildMenuTemplate(deps), 'File').find( diff --git a/apps/desktop/src/main/menu.ts b/apps/desktop/src/main/menu.ts index 26121ed077b..25d4a4c6025 100644 --- a/apps/desktop/src/main/menu.ts +++ b/apps/desktop/src/main/menu.ts @@ -1,14 +1,13 @@ import type { MenuItemConstructorOptions } from 'electron' import { app, BrowserWindow, Menu } from 'electron' import type { ConfigStore } from '@/main/config' +import { DOCS_URL, STATUS_URL } from '@/main/external-links' import { openExternalSafe } from '@/main/navigation' import type { FocusedResourceShortcut, ResourceTabSelectionShortcut, } from '@/main/resource-shortcuts' -const DOCS_URL = 'https://docs.sim.ai' -const STATUS_URL = 'https://status.sim.ai' const ZOOM_STEP = 0.5 export interface MenuDeps { @@ -28,6 +27,7 @@ export interface MenuDeps { shortcut: FocusedResourceShortcut ) => boolean toggleSidebar: () => void + openSearch: () => void signOut: () => void checkForUpdates: () => void } @@ -83,6 +83,16 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] } const viewSubmenu: MenuItemConstructorOptions[] = [ + /** + * The command palette is the web app's own `Mod+K` command; claiming the + * accelerator here means the menu, not the renderer, resolves it — so the + * click must drive the same palette the page would have opened. + */ + { + label: 'Search', + accelerator: 'CmdOrCtrl+K', + click: deps.openSearch, + }, { label: 'Toggle Sidebar', accelerator: 'CmdOrCtrl+B', diff --git a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx index 072797a4534..38caceaca27 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx @@ -214,6 +214,13 @@ export function WorkspaceChrome({ return getDesktopBridge()?.onCommand?.((command) => { if (command === 'toggle-sidebar') { useSidebarStore.getState().toggleCollapsed() + return + } + // The shell's View > Search claims `Mod+K` before the renderer sees it, so + // this must mirror the `open-search` global command — a toggle, not an open. + if (command === 'open-search') { + const searchModal = useSearchModalStore.getState() + searchModal.setOpen(!searchModal.isOpen) } }) }, []) diff --git a/packages/desktop-bridge/src/index.ts b/packages/desktop-bridge/src/index.ts index 425a65669e7..e32ba6ac21a 100644 --- a/packages/desktop-bridge/src/index.ts +++ b/packages/desktop-bridge/src/index.ts @@ -977,7 +977,7 @@ export interface SimDesktopUpdatesApi { onState(callback: (state: DesktopUpdateState) => void): () => void } -export type DesktopCommand = 'toggle-sidebar' +export type DesktopCommand = 'toggle-sidebar' | 'open-search' export interface DesktopWindowState { isFullScreen: boolean