improvement(updates): validate update path and fix native mac help (#6580)

* Desktop update check

* Update script
This commit is contained in:
Siddharth Ganesan
2026-08-11 19:16:48 -07:00
committed by GitHub
parent 7f39678264
commit 2a5e29ad3f
12 changed files with 673 additions and 7 deletions
+1
View File
@@ -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
```
+4 -3
View File
@@ -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
+439
View File
@@ -0,0 +1,439 @@
#import <AppKit/AppKit.h>
#import <Foundation/Foundation.h>
#include <node_api.h>
#include <algorithm>
#include <atomic>
#include <iterator>
#include <string>
#include <vector>
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<NSString*>* localizedTitles;
@property(nonatomic, strong) NSURL* URL;
- (instancetype)initWithLocalizedTitles:(NSArray<NSString*>*)localizedTitles URL:(NSURL*)URL;
@end
@implementation SIMDocumentationHelpItem
- (instancetype)initWithLocalizedTitles:(NSArray<NSString*>*)localizedTitles URL:(NSURL*)URL {
self = [super init];
if (self) {
_localizedTitles = [localizedTitles copy];
_URL = URL;
}
return self;
}
@end
@interface SIMDocumentationHelpSearchProvider
: NSObject <NSUserInterfaceItemSearching, NSURLSessionTaskDelegate>
- (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<NSInteger>(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<SIMDocumentationHelpItem*>* items = [NSMutableArray array];
for (id rawResult in (NSArray*)payload) {
if (items.count >= static_cast<NSUInteger>(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<NSString*>* 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<NSString*>*)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<napi_env> 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<char> 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;
}
+50 -1
View File
@@ -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<void> {
compileNativeHelpSearch()
if (watch) {
const { context } = await import('esbuild')
const mainCtx = await context({
+3
View File
@@ -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'
+60
View File
@@ -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)
})
})
+75
View File
@@ -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<NativeHelpSearchBridge>
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
}
}
+7
View File
@@ -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,
+14
View File
@@ -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(
+12 -2
View File
@@ -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',
@@ -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)
}
})
}, [])