mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
improvement(updates): validate update path and fix native mac help (#6580)
* Desktop update check * Update script
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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'
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
Reference in New Issue
Block a user