Files
OmniMTP/src/ui/WebUI.mm
niklascfw 68353cbc56 Add startup update check against Gitea releases.
Show a native alert when a newer release is available, with menu access to check manually.
2026-07-30 16:13:02 +02:00

1469 lines
60 KiB
Plaintext

#include "WebUI.hpp"
#import <Cocoa/Cocoa.h>
#import <WebKit/WebKit.h>
#import <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
#include <string>
#include <cmath>
// ─── Locale JSON helpers ──────────────────────────────────────────────────────
static BOOL OmniLangCodeOK(NSString* code) {
if (code.length == 0 || code.length > 32) return NO;
NSCharacterSet* allowed = [NSCharacterSet characterSetWithCharactersInString:
@"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-"];
return [code rangeOfCharacterFromSet:allowed.invertedSet].location == NSNotFound;
}
static NSURL* OmniFindWebrootURL(void) {
NSBundle* bundle = NSBundle.mainBundle;
NSURL* indexURL = [bundle URLForResource:@"index" withExtension:@"html" subdirectory:@"webroot"];
if (indexURL) return indexURL.URLByDeletingLastPathComponent;
NSString* base = bundle.executableURL.URLByDeletingLastPathComponent.path;
NSArray<NSString*>* candidates = @[
[base stringByAppendingPathComponent:@"webroot/index.html"],
[base stringByAppendingPathComponent:@"../src/ui/webroot/index.html"],
[base stringByAppendingPathComponent:@"../../src/ui/webroot/index.html"],
[base stringByAppendingPathComponent:@"../../../src/ui/webroot/index.html"],
];
for (NSString* c in candidates) {
NSString* resolved = c.stringByResolvingSymlinksInPath;
if ([[NSFileManager defaultManager] fileExistsAtPath:resolved])
return [NSURL fileURLWithPath:resolved.stringByDeletingLastPathComponent];
}
return nil;
}
static NSURL* OmniBundleConfigURL(void) {
NSString* res = NSBundle.mainBundle.resourcePath;
if (!res) return nil;
return [NSURL fileURLWithPath:[res stringByAppendingPathComponent:@"config.json"]];
}
static NSURL* OmniFallbackConfigURL(void) {
NSArray* dirs = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory,
NSUserDomainMask, YES);
if (!dirs.count) return nil;
NSString* dir = [dirs[0] stringByAppendingPathComponent:@"OmniMTP"];
[[NSFileManager defaultManager] createDirectoryAtPath:dir
withIntermediateDirectories:YES
attributes:nil
error:nil];
return [NSURL fileURLWithPath:[dir stringByAppendingPathComponent:@"config.json"]];
}
static NSDictionary* OmniLoadConfig(void) {
NSMutableArray<NSURL*>* urls = [NSMutableArray array];
NSURL* bundleURL = OmniBundleConfigURL();
NSURL* fallbackURL = OmniFallbackConfigURL();
if (bundleURL) [urls addObject:bundleURL];
if (fallbackURL) [urls addObject:fallbackURL];
for (NSURL* url in urls) {
NSData* data = [NSData dataWithContentsOfURL:url];
if (!data) continue;
id obj = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
if ([obj isKindOfClass:[NSDictionary class]]) return obj;
}
return @{};
}
static BOOL OmniSaveConfig(NSDictionary* cfg) {
NSData* data = [NSJSONSerialization dataWithJSONObject:cfg
options:NSJSONWritingPrettyPrinted
error:nil];
if (!data) return NO;
// Prefer writing inside the .app bundle (Resources/config.json).
NSURL* bundleURL = OmniBundleConfigURL();
if (bundleURL && [data writeToURL:bundleURL atomically:YES]) return YES;
// Installed apps are often not writable; keep a working fallback.
NSURL* fallback = OmniFallbackConfigURL();
return fallback && [data writeToURL:fallback atomically:YES];
}
static NSString* OmniConfiguredLanguageOverride(void) {
NSString* lang = OmniLoadConfig()[@"language"];
if (![lang isKindOfClass:[NSString class]]) return nil;
if (lang.length == 0 || [lang isEqualToString:@"system"]) return nil;
return OmniLangCodeOK(lang) ? lang : nil;
}
static NSDictionary* OmniLoadLangPack(NSURL* webroot, NSString* code) {
if (!webroot || !OmniLangCodeOK(code)) return nil;
NSURL* url = [webroot URLByAppendingPathComponent:
[NSString stringWithFormat:@"lang/%@.json", code]];
NSData* data = [NSData dataWithContentsOfURL:url];
if (!data) return nil;
id obj = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
return [obj isKindOfClass:[NSDictionary class]] ? obj : nil;
}
static NSArray<NSString*>* OmniLocaleCandidates(void) {
NSString* override = OmniConfiguredLanguageOverride();
if (override) return @[override, @"en"];
NSMutableArray<NSString*>* out = [NSMutableArray array];
void (^add)(NSString*) = ^(NSString* c) {
if (c.length && ![out containsObject:c]) [out addObject:c];
};
for (NSString* raw in (NSLocale.preferredLanguages ?: @[@"en"])) {
NSString* tag = [raw stringByReplacingOccurrencesOfString:@"_" withString:@"-"];
add(tag);
NSArray* parts = [tag componentsSeparatedByString:@"-"];
NSString* primary = [(NSString*)parts.firstObject lowercaseString];
if ([primary isEqualToString:@"zh"]) {
NSString* script = parts.count > 1 ? [(NSString*)parts[1] lowercaseString] : @"";
if ([script hasPrefix:@"hant"] || [script isEqualToString:@"tw"] ||
[script isEqualToString:@"hk"] || [script isEqualToString:@"mo"])
add(@"zh-Hant");
else
add(@"zh-Hans");
add(@"zh");
} else if (primary.length) {
add(primary);
}
}
add(@"en");
return out;
}
static NSArray<NSString*>* OmniAvailableLanguageCodes(NSURL* webroot) {
NSMutableArray<NSString*>* codes = [NSMutableArray array];
NSURL* langDir = [webroot URLByAppendingPathComponent:@"lang"];
NSArray* files = [[NSFileManager defaultManager] contentsOfDirectoryAtURL:langDir
includingPropertiesForKeys:nil
options:NSDirectoryEnumerationSkipsHiddenFiles
error:nil];
for (NSURL* file in files) {
if (![file.pathExtension isEqualToString:@"json"]) continue;
NSString* code = file.URLByDeletingPathExtension.lastPathComponent;
if (OmniLangCodeOK(code)) [codes addObject:code];
}
[codes sortUsingSelector:@selector(compare:)];
if (![codes containsObject:@"en"]) [codes insertObject:@"en" atIndex:0];
return codes;
}
static NSString* OmniLanguageDisplayName(NSString* code) {
static NSDictionary* names;
static dispatch_once_t once;
dispatch_once(&once, ^{
names = @{
@"en": @"English",
@"de": @"Deutsch",
@"fr": @"Français",
@"es": @"Español",
@"it": @"Italiano",
@"nl": @"Nederlands",
@"pt": @"Português",
@"ru": @"Русский",
@"ja": @"日本語",
@"zh-Hans": @"简体中文",
@"zh-Hant": @"繁體中文",
};
});
return names[code] ?: code;
}
static NSDictionary* OmniBuildI18nPayload(NSURL* webroot) {
NSDictionary* fallback = OmniLoadLangPack(webroot, @"en") ?: @{};
NSString* lang = @"en";
NSDictionary* strings = fallback;
for (NSString* code in OmniLocaleCandidates()) {
if ([code isEqualToString:@"en"]) {
lang = @"en";
strings = fallback;
break;
}
NSDictionary* pack = OmniLoadLangPack(webroot, code);
if (pack) {
lang = code;
NSMutableDictionary* merged = [fallback mutableCopy];
[merged addEntriesFromDictionary:pack];
strings = merged;
break;
}
}
return @{
@"lang": lang,
@"fallback": fallback,
@"strings": strings,
};
}
static NSString* OmniI18nBootstrapScript(NSURL* webroot) {
NSDictionary* payload = OmniBuildI18nPayload(webroot);
NSData* data = [NSJSONSerialization dataWithJSONObject:payload options:0 error:nil];
NSString* json = data ? [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] : @"{}";
return [NSString stringWithFormat:@"window.__OMNI_I18N__=%@;", json];
}
static NSString* OmniMenuString(NSString* key) {
NSDictionary* payload = OmniBuildI18nPayload(OmniFindWebrootURL());
NSDictionary* strings = payload[@"strings"];
NSString* val = [strings isKindOfClass:[NSDictionary class]] ? strings[key] : nil;
return [val isKindOfClass:[NSString class]] ? val : key;
}
static NSString* OmniMenuFormat(NSString* key, NSDictionary<NSString*, NSString*>* vars) {
NSString* s = OmniMenuString(key);
for (NSString* k in vars)
s = [s stringByReplacingOccurrencesOfString:[NSString stringWithFormat:@"{%@}", k]
withString:vars[k] ?: @""];
return s;
}
static NSString* const kOmniReleasesLatestURL =
@"https://git.niklascfw.de/api/v1/repos/OmniNX/OmniMTP/releases/latest";
static NSString* const kOmniReleasesPageURL =
@"https://git.niklascfw.de/OmniNX/OmniMTP/releases";
static NSString* const kOmniSkippedUpdateKey = @"omni.skippedUpdateTag";
static NSArray<NSNumber*>* OmniParseVersionParts(NSString* version) {
if (![version isKindOfClass:[NSString class]] || version.length == 0)
return @[];
NSString* v = [version stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
if ([v.lowercaseString hasPrefix:@"v"])
v = [v substringFromIndex:1];
NSString* core = [[v componentsSeparatedByString:@"-"] firstObject] ?: v;
NSMutableArray<NSNumber*>* parts = [NSMutableArray array];
for (NSString* p in [core componentsSeparatedByString:@"."]) {
NSInteger n = 0;
NSScanner* scanner = [NSScanner scannerWithString:p];
[scanner scanInteger:&n];
[parts addObject:@(n)];
}
return parts;
}
static NSComparisonResult OmniCompareVersions(NSString* a, NSString* b) {
NSArray<NSNumber*>* pa = OmniParseVersionParts(a);
NSArray<NSNumber*>* pb = OmniParseVersionParts(b);
NSUInteger n = MAX(pa.count, pb.count);
for (NSUInteger i = 0; i < n; ++i) {
NSInteger va = i < pa.count ? pa[i].integerValue : 0;
NSInteger vb = i < pb.count ? pb[i].integerValue : 0;
if (va < vb) return NSOrderedAscending;
if (va > vb) return NSOrderedDescending;
}
return NSOrderedSame;
}
static NSString* OmniCurrentAppVersion(void) {
NSString* v = [NSBundle.mainBundle objectForInfoDictionaryKey:@"CFBundleShortVersionString"];
return (v.length > 0) ? v : @"0.0.0";
}
static NSString* OmniPreferredDownloadURL(NSDictionary* release) {
id assets = release[@"assets"];
if ([assets isKindOfClass:[NSArray class]]) {
for (id asset in (NSArray*)assets) {
if (![asset isKindOfClass:[NSDictionary class]]) continue;
NSString* name = asset[@"name"];
NSString* url = asset[@"browser_download_url"];
if (![name isKindOfClass:[NSString class]] || ![url isKindOfClass:[NSString class]])
continue;
NSString* lower = name.lowercaseString;
if ([lower hasSuffix:@".zip"] || [lower hasSuffix:@".dmg"])
return url;
}
for (id asset in (NSArray*)assets) {
if (![asset isKindOfClass:[NSDictionary class]]) continue;
NSString* url = asset[@"browser_download_url"];
if ([url isKindOfClass:[NSString class]] && url.length)
return url;
}
}
NSString* html = release[@"html_url"];
if ([html isKindOfClass:[NSString class]] && html.length)
return html;
return kOmniReleasesPageURL;
}
// ─── Forward declarations ─────────────────────────────────────────────────────
@class OmniBridge;
@class OmniAppDelegate;
@interface OmniWebView : WKWebView <NSDraggingSource>
- (instancetype)initWithFrame:(NSRect)frame
configuration:(WKWebViewConfiguration*)config
app:(omniMTP::App*)app;
- (void)prepareExportDragWithFiles:(NSArray<NSDictionary*>*)files
atWebPoint:(NSPoint)webPoint;
- (void)beginExportDragWithFiles:(NSArray<NSDictionary*>*)files
atWebPoint:(NSPoint)webPoint;
@end
// ─── OmniBridge: JS → C++ message handler ─────────────────────────────────────
@interface OmniBridge : NSObject <WKScriptMessageHandler, WKNavigationDelegate, WKUIDelegate>
- (instancetype)initWithApp:(omniMTP::App*)app webView:(WKWebView*)webView;
@end
@implementation OmniBridge {
omniMTP::App* _app;
WKWebView* _webView;
}
- (instancetype)initWithApp:(omniMTP::App*)app webView:(WKWebView*)webView {
if ((self = [super init])) {
_app = app;
_webView = webView;
}
return self;
}
- (void)userContentController:(WKUserContentController*)ucc
didReceiveScriptMessage:(WKScriptMessage*)message {
(void)ucc;
if (![message.name isEqualToString:@"bridge"]) return;
NSDictionary* body = message.body;
if (![body isKindOfClass:[NSDictionary class]]) return;
NSNumber* msgId = body[@"id"];
NSString* action = body[@"action"];
if (!msgId || !action) return;
long long rid = msgId.longLongValue;
// Locale + export must run immediately (sync) so JS can boot strings / arm drag.
if ([action isEqualToString:@"prepare_export_drag"] ||
[action isEqualToString:@"begin_export_drag"] ||
[action isEqualToString:@"get_preferred_languages"] ||
[action isEqualToString:@"get_lang_pack"]) {
[self handleAction:action body:body responseId:rid];
return;
}
// All dispatched on main thread already (WKWebView guarantee), but we
// use dispatch_async to avoid blocking the JS engine during heavy ops.
dispatch_async(dispatch_get_main_queue(), ^{
[self handleAction:action body:body responseId:rid];
});
}
- (void)handleAction:(NSString*)action body:(NSDictionary*)body responseId:(long long)rid {
NSString* result = nil;
if ([action isEqualToString:@"get_state"]) {
std::string json = _app->state_json();
result = [NSString stringWithUTF8String:json.c_str()];
// result is raw JSON object, send directly
[self sendRawResult:result responseId:rid];
return;
} else if ([action isEqualToString:@"navigate_remote"]) {
NSNumber* handle = body[@"handle"];
NSNumber* storageId = body[@"storageId"];
NSString* name = body[@"name"];
if (handle && storageId && name)
_app->navigate_remote(handle.unsignedIntValue,
storageId.unsignedIntValue,
name.UTF8String);
} else if ([action isEqualToString:@"navigate_remote_up"]) {
_app->navigate_remote_up();
} else if ([action isEqualToString:@"navigate_remote_to"]) {
NSNumber* index = body[@"index"];
if (index) _app->navigate_remote_to((size_t)index.unsignedIntegerValue);
} else if ([action isEqualToString:@"refresh_remote"]) {
_app->request_refresh_remote();
} else if ([action isEqualToString:@"set_storage"]) {
NSNumber* storageId = body[@"storageId"];
if (storageId) _app->set_active_storage(storageId.unsignedIntValue);
} else if ([action isEqualToString:@"start_upload"]) {
NSString* srcPath = body[@"srcPath"];
if (srcPath && srcPath.length > 0) _app->do_upload(srcPath.UTF8String);
} else if ([action isEqualToString:@"start_download"]) {
NSNumber* handle = body[@"handle"];
NSNumber* storageId = body[@"storageId"];
NSString* filename = body[@"filename"];
NSNumber* size = body[@"size"];
if (handle && storageId && filename && size)
_app->do_download(handle.unsignedIntValue,
storageId.unsignedIntValue,
filename.UTF8String,
size.unsignedLongLongValue);
} else if ([action isEqualToString:@"cancel_transfer"]) {
NSString* tid = body[@"id"];
if (tid) _app->do_cancel(tid.UTF8String);
} else if ([action isEqualToString:@"cancel_all"]) {
_app->do_cancel_all();
} else if ([action isEqualToString:@"delete_remote"]) {
NSNumber* handle = body[@"handle"];
if (handle) _app->do_delete(handle.unsignedIntValue);
} else if ([action isEqualToString:@"create_folder"]) {
NSString* name = body[@"name"];
if (name && name.length > 0) _app->do_create_folder(name.UTF8String);
} else if ([action isEqualToString:@"reconnect"]) {
_app->reconnect();
} else if ([action isEqualToString:@"open_file_picker"]) {
// Open NSOpenPanel to pick files for upload
dispatch_async(dispatch_get_main_queue(), ^{
NSOpenPanel* panel = [NSOpenPanel openPanel];
panel.canChooseFiles = YES;
panel.canChooseDirectories = NO;
panel.allowsMultipleSelection = YES;
[panel beginWithCompletionHandler:^(NSModalResponse r) {
if (r == NSModalResponseOK) {
for (NSURL* url in panel.URLs) {
NSString* path = url.path;
if (path) self->_app->do_upload(path.UTF8String);
}
}
}];
});
} else if ([action isEqualToString:@"get_preferred_languages"]) {
NSArray<NSString*>* langs = NSLocale.preferredLanguages ?: @[@"en"];
NSData* data = [NSJSONSerialization dataWithJSONObject:langs options:0 error:nil];
NSString* json = data ? [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] : @"[\"en\"]";
[self sendRawResult:json responseId:rid];
return;
} else if ([action isEqualToString:@"get_lang_pack"]) {
NSString* code = body[@"code"];
if (![code isKindOfClass:[NSString class]] || !OmniLangCodeOK(code)) {
[self sendNullResult:rid];
return;
}
NSURL* webroot = OmniFindWebrootURL();
NSDictionary* pack = OmniLoadLangPack(webroot, code);
if (!pack) {
[self sendNullResult:rid];
return;
}
NSData* data = [NSJSONSerialization dataWithJSONObject:pack options:0 error:nil];
NSString* json = data ? [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] : @"null";
[self sendRawResult:json responseId:rid];
return;
} else if ([action isEqualToString:@"confirm"]) {
NSString* message = body[@"message"] ?: @"";
NSString* okTitle = body[@"ok"] ?: @"OK";
NSString* cancelTitle = body[@"cancel"] ?: @"Cancel";
NSAlert* alert = [[NSAlert alloc] init];
alert.messageText = message;
[alert addButtonWithTitle:okTitle];
[alert addButtonWithTitle:cancelTitle];
BOOL ok = [alert runModal] == NSAlertFirstButtonReturn;
[self sendRawResult:(ok ? @"true" : @"false") responseId:rid];
return;
} else if ([action isEqualToString:@"update_drop_zones"]) {
NSDictionary* remote = body[@"remote"];
if ([remote isKindOfClass:[NSDictionary class]]) {
_app->update_drop_zone(
[remote[@"x"] doubleValue], [remote[@"y"] doubleValue],
[remote[@"w"] doubleValue], [remote[@"h"] doubleValue]);
}
} else if ([action isEqualToString:@"prepare_export_drag"] ||
[action isEqualToString:@"begin_export_drag"]) {
NSArray* files = body[@"files"];
NSNumber* x = body[@"x"];
NSNumber* y = body[@"y"];
if ([files isKindOfClass:[NSArray class]] && files.count > 0 &&
[x isKindOfClass:[NSNumber class]] && [y isKindOfClass:[NSNumber class]] &&
[_webView isKindOfClass:[OmniWebView class]]) {
NSPoint pt = NSMakePoint(x.doubleValue, y.doubleValue);
if ([action isEqualToString:@"prepare_export_drag"])
[(OmniWebView*)_webView prepareExportDragWithFiles:files atWebPoint:pt];
else
[(OmniWebView*)_webView beginExportDragWithFiles:files atWebPoint:pt];
}
}
// Send null result for void actions
[self sendNullResult:rid];
}
- (void)sendRawResult:(NSString*)json responseId:(long long)rid {
NSString* js = [NSString stringWithFormat:@"window.bridgeResponse(%lld,%@)", rid, json];
[_webView evaluateJavaScript:js completionHandler:nil];
}
- (void)sendNullResult:(long long)rid {
NSString* js = [NSString stringWithFormat:@"window.bridgeResponse(%lld,null)", rid];
[_webView evaluateJavaScript:js completionHandler:nil];
}
// WKNavigationDelegate — log errors
- (void)webView:(WKWebView*)wv didFailNavigation:(WKNavigation*)nav withError:(NSError*)err {
(void)wv; (void)nav;
NSLog(@"[OmniMTP] WebView navigation failed: %@", err.localizedDescription);
}
- (void)webView:(WKWebView*)wv didFailProvisionalNavigation:(WKNavigation*)nav withError:(NSError*)err {
(void)wv; (void)nav;
NSLog(@"[OmniMTP] WebView provisional navigation failed: %@", err.localizedDescription);
}
// WKUIDelegate — required for window.alert/confirm/prompt in WKWebView
- (void)webView:(WKWebView*)webView
runJavaScriptAlertPanelWithMessage:(NSString*)message
initiatedByFrame:(WKFrameInfo*)frame
completionHandler:(void (^)(void))completionHandler {
(void)webView; (void)frame;
NSAlert* alert = [[NSAlert alloc] init];
alert.messageText = message;
[alert addButtonWithTitle:@"OK"];
[alert runModal];
completionHandler();
}
- (void)webView:(WKWebView*)webView
runJavaScriptConfirmPanelWithMessage:(NSString*)message
initiatedByFrame:(WKFrameInfo*)frame
completionHandler:(void (^)(BOOL result))completionHandler {
(void)webView; (void)frame;
// Prefer the bridge `confirm` action from JS so button titles are localized.
NSAlert* alert = [[NSAlert alloc] init];
alert.messageText = message;
[alert addButtonWithTitle:@"OK"];
[alert addButtonWithTitle:@"Cancel"];
NSModalResponse response = [alert runModal];
completionHandler(response == NSAlertFirstButtonReturn);
}
@end
// ─── Titlebar drag strip (WKWebView ignores -webkit-app-region) ───────────────
static const CGFloat kTitlebarDragHeight = 38.0;
static const CGFloat kTrafficLightInset = 72.0;
@interface OmniTitlebarDragView : NSView
@end
@implementation OmniTitlebarDragView {
NSPoint _dragStartScreen;
NSPoint _windowStartOrigin;
}
- (BOOL)mouseDownCanMoveWindow {
return YES;
}
- (void)mouseDown:(NSEvent*)event {
(void)event;
_dragStartScreen = NSEvent.mouseLocation;
_windowStartOrigin = self.window.frame.origin;
while (YES) {
NSEvent* next = [self.window nextEventMatchingMask:
NSEventMaskLeftMouseDragged | NSEventMaskLeftMouseUp
untilDate:[NSDate distantFuture]
inMode:NSEventTrackingRunLoopMode
dequeue:YES];
if (!next || next.type == NSEventTypeLeftMouseUp) break;
NSPoint current = NSEvent.mouseLocation;
[self.window setFrameOrigin:NSMakePoint(
_windowStartOrigin.x + (current.x - _dragStartScreen.x),
_windowStartOrigin.y + (current.y - _dragStartScreen.y))];
}
}
- (BOOL)acceptsFirstMouse:(NSEvent*)event {
(void)event;
return YES;
}
@end
// ─── Finder drag-export for remote MTP files ───────────────────────────────────
@interface OmniExportPromiseDelegate : NSObject <NSFilePromiseProviderDelegate>
@property (nonatomic, assign) omniMTP::App* app;
@property (nonatomic, assign) uint32_t handle;
@property (nonatomic, assign) uint32_t storageId;
@property (nonatomic, assign) uint64_t size;
@property (nonatomic, assign) BOOL isDir;
@property (nonatomic, copy) NSString* filename;
@end
@implementation OmniExportPromiseDelegate
- (void)filePromiseProvider:(NSFilePromiseProvider*)provider
writePromiseToURL:(NSURL*)url
completionHandler:(void (^)(NSError* _Nullable))completionHandler {
(void)provider;
try {
if (_app)
_app->download_remote_to_path(_handle, _storageId, url.path.UTF8String, _size, _isDir);
completionHandler(nil);
} catch (const std::exception& ex) {
NSString* msg = [NSString stringWithUTF8String:ex.what()];
completionHandler([NSError errorWithDomain:@"OmniMTP" code:1
userInfo:@{NSLocalizedDescriptionKey: msg ?: @"Download failed"}]);
} catch (...) {
completionHandler([NSError errorWithDomain:@"OmniMTP" code:1
userInfo:@{NSLocalizedDescriptionKey: @"Download failed"}]);
}
}
- (NSString*)filePromiseProvider:(NSFilePromiseProvider*)provider
fileNameForType:(NSString*)fileType {
(void)provider; (void)fileType;
return _filename;
}
- (void)filePromiseProvider:(NSFilePromiseProvider*)provider
didFinishWritingToURL:(NSURL*)url {
(void)provider; (void)url;
}
@end
// ─── OmniWebView: native drag-and-drop ────────────────────────────────────────
@implementation OmniWebView {
omniMTP::App* _app;
BOOL _remoteDragHighlighted;
NSArray<NSDictionary*>* _exportDragEntries;
NSMutableArray* _exportDelegates; // keep alive for NSFilePromiseProvider
NSArray<NSDictionary*>* _pendingExportFiles;
NSPoint _pendingExportViewPoint; // AppKit view coords (bottom-left origin)
BOOL _exportDragStarted;
id _exportEventMonitor;
}
- (instancetype)initWithFrame:(NSRect)frame
configuration:(WKWebViewConfiguration*)config
app:(omniMTP::App*)app {
if ((self = [super initWithFrame:frame configuration:config])) {
_app = app;
_remoteDragHighlighted = NO;
_exportDelegates = [NSMutableArray array];
[self registerForDraggedTypes:@[NSPasteboardTypeFileURL]];
}
return self;
}
- (void)clearPendingExport {
if (_exportEventMonitor) {
[NSEvent removeMonitor:_exportEventMonitor];
_exportEventMonitor = nil;
}
_pendingExportFiles = nil;
_exportDragStarted = NO;
}
- (NSPoint)webPointFromDrag:(id<NSDraggingInfo>)sender {
NSPoint loc = [self convertPoint:sender.draggingLocation fromView:nil];
return NSMakePoint(loc.x, self.bounds.size.height - loc.y);
}
- (NSString*)dropTargetAtWebPoint:(NSPoint)webPoint {
if (!_app) return @"none";
std::string target = _app->drop_target_at(webPoint.x, webPoint.y);
return [NSString stringWithUTF8String:target.c_str()];
}
- (NSArray<NSURL*>*)fileURLsFromDrag:(id<NSDraggingInfo>)sender {
NSPasteboard* pb = sender.draggingPasteboard;
NSDictionary* opts = @{NSPasteboardURLReadingFileURLsOnlyKey: @YES};
if ([pb canReadObjectForClasses:@[[NSURL class]] options:opts]) {
NSArray* urls = [pb readObjectsForClasses:@[[NSURL class]] options:opts];
return urls ?: @[];
}
return @[];
}
- (void)setRemoteDragOver:(BOOL)over {
if (_remoteDragHighlighted == over) return;
_remoteDragHighlighted = over;
NSString* js = over
? @"document.getElementById('remote-files')?.classList.add('drag-over')"
: @"document.getElementById('remote-files')?.classList.remove('drag-over')";
[self evaluateJavaScript:js completionHandler:nil];
}
- (void)prepareExportDragWithFiles:(NSArray<NSDictionary*>*)files
atWebPoint:(NSPoint)webPoint {
(void)webPoint; // JS coords can disagree with AppKit; use the live mouse event.
if (!_app || !_app->connected() || files.count == 0) return;
[self clearPendingExport];
_pendingExportFiles = [files copy];
_exportDragStarted = NO;
// Prefer the real mouse location so the drag image anchors under the cursor.
NSEvent* down = [NSApp currentEvent];
if (down)
_pendingExportViewPoint = [self convertPoint:down.locationInWindow fromView:nil];
else
_pendingExportViewPoint = NSMakePoint(webPoint.x, self.bounds.size.height - webPoint.y);
__weak OmniWebView* weakSelf = self;
_exportEventMonitor = [NSEvent addLocalMonitorForEventsMatchingMask:
(NSEventMaskLeftMouseDragged | NSEventMaskLeftMouseUp)
handler:^NSEvent* (NSEvent* event) {
OmniWebView* strongSelf = weakSelf;
if (!strongSelf) return event;
if (event.type == NSEventTypeLeftMouseUp) {
[strongSelf clearPendingExport];
return event;
}
if (strongSelf->_exportDragStarted || !strongSelf->_pendingExportFiles.count)
return event;
NSPoint loc = [strongSelf convertPoint:event.locationInWindow fromView:nil];
CGFloat dx = loc.x - strongSelf->_pendingExportViewPoint.x;
CGFloat dy = loc.y - strongSelf->_pendingExportViewPoint.y;
if (std::sqrt(dx * dx + dy * dy) < 4.0) return event;
strongSelf->_exportDragStarted = YES;
NSArray* filesCopy = strongSelf->_pendingExportFiles;
[strongSelf clearPendingExport];
[strongSelf beginExportDragWithFiles:filesCopy event:event];
return nil; // consume; session owns the drag
}];
}
- (void)beginExportDragWithFiles:(NSArray<NSDictionary*>*)files
atWebPoint:(NSPoint)webPoint {
(void)webPoint;
NSEvent* event = [NSApp currentEvent];
if (!event || (event.type != NSEventTypeLeftMouseDown &&
event.type != NSEventTypeLeftMouseDragged)) {
NSPoint screen = NSEvent.mouseLocation;
NSRect windowRect = [self.window convertRectFromScreen:NSMakeRect(screen.x, screen.y, 0, 0)];
event = [NSEvent mouseEventWithType:NSEventTypeLeftMouseDragged
location:windowRect.origin
modifierFlags:0
timestamp:(NSApp.currentEvent ? NSApp.currentEvent.timestamp : 0)
windowNumber:self.window.windowNumber
context:nil
eventNumber:0
clickCount:1
pressure:1.0];
}
[self beginExportDragWithFiles:files event:event];
}
- (void)beginExportDragWithFiles:(NSArray<NSDictionary*>*)files
event:(NSEvent*)event {
if (!_app || !_app->connected() || files.count == 0 || !event) return;
[self clearPendingExport];
[self evaluateJavaScript:@"window.omniClearSelection&&window.omniClearSelection()"
completionHandler:nil];
_exportDragEntries = [files copy];
[_exportDelegates removeAllObjects];
NSMutableArray<NSDraggingItem*>* dragItems = [NSMutableArray array];
// draggingFrame must be in this view's coordinate system and match the
// mouse event, otherwise the ghost icon jumps away from the cursor.
NSPoint loc = [self convertPoint:event.locationInWindow fromView:nil];
NSRect frame = NSMakeRect(loc.x - 16, loc.y - 16, 32, 32);
for (NSDictionary* entry in files) {
NSNumber* handle = entry[@"handle"];
NSNumber* storageId = entry[@"storageId"];
NSString* name = entry[@"name"];
NSNumber* size = entry[@"size"];
BOOL isDir = [entry[@"isDir"] boolValue];
if (!handle || !storageId || !name.length) continue;
OmniExportPromiseDelegate* delegate = [OmniExportPromiseDelegate new];
delegate.app = _app;
delegate.handle = handle.unsignedIntValue;
delegate.storageId = storageId.unsignedIntValue;
delegate.size = size ? size.unsignedLongLongValue : 0;
delegate.isDir = isDir;
delegate.filename = name;
[_exportDelegates addObject:delegate];
NSString* typeId;
NSImage* icon;
if (isDir) {
typeId = UTTypeFolder.identifier;
icon = [[NSWorkspace sharedWorkspace] iconForContentType:UTTypeFolder];
} else {
NSString* ext = name.pathExtension;
UTType* ut = ext.length ? [UTType typeWithFilenameExtension:ext] : nil;
typeId = (ut && ut.identifier.length) ? ut.identifier : UTTypeData.identifier;
icon = ut
? [[NSWorkspace sharedWorkspace] iconForContentType:ut]
: [[NSWorkspace sharedWorkspace] iconForContentType:UTTypeData];
}
NSFilePromiseProvider* provider =
[[NSFilePromiseProvider alloc] initWithFileType:typeId delegate:delegate];
NSDraggingItem* dragItem = [[NSDraggingItem alloc] initWithPasteboardWriter:provider];
icon.size = NSMakeSize(32, 32);
[dragItem setDraggingFrame:frame contents:icon];
[dragItems addObject:dragItem];
}
if (dragItems.count == 0) {
_exportDragEntries = nil;
[_exportDelegates removeAllObjects];
return;
}
[self beginDraggingSessionWithItems:dragItems event:event source:self];
}
- (NSDragOperation)draggingSession:(NSDraggingSession*)session
sourceOperationMaskForDraggingContext:(NSDraggingContext)context {
(void)session;
// Outside the app (Finder) and inside both allow copy.
return NSDragOperationCopy;
}
- (void)draggingSession:(NSDraggingSession*)session
endedAtPoint:(NSPoint)screenPoint
operation:(NSDragOperation)operation {
(void)session; (void)screenPoint; (void)operation;
_exportDragEntries = nil;
[_exportDelegates removeAllObjects];
}
- (NSDragOperation)draggingUpdated:(id<NSDraggingInfo>)sender {
NSArray<NSURL*>* urls = [self fileURLsFromDrag:sender];
if (urls.count == 0) {
[self setRemoteDragOver:NO];
return NSDragOperationNone;
}
NSString* target = [self dropTargetAtWebPoint:[self webPointFromDrag:sender]];
if ([target isEqualToString:@"remote"] && _app && _app->connected()) {
[self setRemoteDragOver:YES];
return NSDragOperationCopy;
}
[self setRemoteDragOver:NO];
return NSDragOperationNone;
}
- (NSDragOperation)draggingEntered:(id<NSDraggingInfo>)sender {
return [self draggingUpdated:sender];
}
- (void)draggingExited:(id<NSDraggingInfo>)sender {
(void)sender;
[self setRemoteDragOver:NO];
}
- (BOOL)performDragOperation:(id<NSDraggingInfo>)sender {
[self setRemoteDragOver:NO];
if (!_app) return NO;
NSString* target = [self dropTargetAtWebPoint:[self webPointFromDrag:sender]];
NSArray<NSURL*>* urls = [self fileURLsFromDrag:sender];
if (![target isEqualToString:@"remote"]) return NO;
NSFileManager* fm = NSFileManager.defaultManager;
BOOL handled = NO;
for (NSURL* url in urls) {
if (!url.isFileURL) continue;
NSString* path = url.path;
if (!path) continue;
BOOL isDir = NO;
if ([fm fileExistsAtPath:path isDirectory:&isDir] && !isDir)
_app->do_upload(path.UTF8String);
handled = YES;
}
return handled;
}
@end
// ─── OmniAppDelegate ──────────────────────────────────────────────────────────
@interface OmniAppDelegate : NSObject <NSApplicationDelegate, NSWindowDelegate>
- (instancetype)initWithApp:(omniMTP::App*)app;
- (void)buildMainMenu;
- (void)selectLanguage:(id)sender;
- (void)showAbout:(id)sender;
- (void)closeAboutPanel:(id)sender;
- (void)checkForUpdates:(id)sender;
- (void)applyLanguageToWebView;
@end
@implementation OmniAppDelegate {
omniMTP::App* _app;
NSWindow* _window;
WKWebView* _webView;
OmniBridge* _bridge;
NSPanel* _aboutPanel;
NSMenuItem* _aboutItem;
NSMenuItem* _checkUpdatesItem;
NSMenuItem* _optionsItem;
NSMenuItem* _languageItem;
NSMenuItem* _quitItem;
BOOL _updateCheckInFlight;
}
- (instancetype)initWithApp:(omniMTP::App*)app {
if ((self = [super init])) {
_app = app;
}
return self;
}
- (void)buildMainMenu {
NSMenu* menuBar = [[NSMenu alloc] init];
NSMenuItem* appItem = [[NSMenuItem alloc] init];
[menuBar addItem:appItem];
NSMenu* appMenu = [[NSMenu alloc] initWithTitle:@"OmniMTP"];
_aboutItem = [[NSMenuItem alloc] initWithTitle:OmniMenuString(@"menu.about")
action:@selector(showAbout:)
keyEquivalent:@""];
_aboutItem.target = self;
[appMenu addItem:_aboutItem];
_checkUpdatesItem = [[NSMenuItem alloc] initWithTitle:OmniMenuString(@"menu.check_updates")
action:@selector(checkForUpdates:)
keyEquivalent:@""];
_checkUpdatesItem.target = self;
[appMenu addItem:_checkUpdatesItem];
[appMenu addItem:[NSMenuItem separatorItem]];
// Options → Language
_optionsItem = [[NSMenuItem alloc] initWithTitle:OmniMenuString(@"menu.options")
action:nil
keyEquivalent:@""];
if (@available(macOS 11.0, *)) {
NSImage* gear = [NSImage imageWithSystemSymbolName:@"gearshape"
accessibilityDescription:nil];
if (gear) {
gear.size = NSMakeSize(16, 16);
_optionsItem.image = gear;
}
}
NSMenu* optionsMenu = [[NSMenu alloc] initWithTitle:OmniMenuString(@"menu.options")];
_optionsItem.submenu = optionsMenu;
_languageItem = [[NSMenuItem alloc] initWithTitle:OmniMenuString(@"menu.language")
action:nil
keyEquivalent:@""];
NSMenu* langMenu = [[NSMenu alloc] initWithTitle:OmniMenuString(@"menu.language")];
_languageItem.submenu = langMenu;
[optionsMenu addItem:_languageItem];
NSString* current = OmniLoadConfig()[@"language"];
if (![current isKindOfClass:[NSString class]] || current.length == 0)
current = @"system";
NSMenuItem* systemItem = [[NSMenuItem alloc]
initWithTitle:OmniMenuString(@"menu.language_system")
action:@selector(selectLanguage:)
keyEquivalent:@""];
systemItem.target = self;
systemItem.representedObject = @"system";
systemItem.state = [current isEqualToString:@"system"] ? NSControlStateValueOn
: NSControlStateValueOff;
[langMenu addItem:systemItem];
[langMenu addItem:[NSMenuItem separatorItem]];
NSURL* webroot = OmniFindWebrootURL();
for (NSString* code in OmniAvailableLanguageCodes(webroot)) {
NSMenuItem* item = [[NSMenuItem alloc]
initWithTitle:OmniLanguageDisplayName(code)
action:@selector(selectLanguage:)
keyEquivalent:@""];
item.target = self;
item.representedObject = code;
item.state = [current isEqualToString:code] ? NSControlStateValueOn
: NSControlStateValueOff;
[langMenu addItem:item];
}
[appMenu addItem:_optionsItem];
[appMenu addItem:[NSMenuItem separatorItem]];
_quitItem = [[NSMenuItem alloc] initWithTitle:OmniMenuString(@"menu.quit")
action:@selector(terminate:)
keyEquivalent:@"q"];
[appMenu addItem:_quitItem];
appItem.submenu = appMenu;
[NSApp setMainMenu:menuBar];
}
- (void)showAbout:(id)sender {
(void)sender;
if (_aboutPanel) {
[NSApp stopModal];
[_aboutPanel orderOut:nil];
_aboutPanel = nil;
}
NSBundle* bundle = NSBundle.mainBundle;
NSString* version = [bundle objectForInfoDictionaryKey:@"CFBundleShortVersionString"] ?: @"1.0.0";
NSString* build = [bundle objectForInfoDictionaryKey:@"CFBundleVersion"] ?: version;
NSString* versionText = [version isEqualToString:build]
? version
: [NSString stringWithFormat:@"%@ (%@)", version, build];
NSPanel* panel = [[NSPanel alloc]
initWithContentRect:NSMakeRect(0, 0, 340, 320)
styleMask:(NSWindowStyleMaskTitled | NSWindowStyleMaskClosable)
backing:NSBackingStoreBuffered
defer:NO];
panel.title = OmniMenuString(@"menu.about");
panel.releasedWhenClosed = NO;
panel.delegate = self;
if (@available(macOS 10.14, *))
panel.appearance = [NSAppearance appearanceNamed:NSAppearanceNameDarkAqua];
NSView* content = panel.contentView;
NSImageView* iconView = [[NSImageView alloc] initWithFrame:NSZeroRect];
iconView.translatesAutoresizingMaskIntoConstraints = NO;
iconView.image = NSApp.applicationIconImage;
iconView.imageScaling = NSImageScaleProportionallyUpOrDown;
[content addSubview:iconView];
NSTextField* (^makeLabel)(NSString*, CGFloat, BOOL) = ^NSTextField*(NSString* text, CGFloat size, BOOL bold) {
NSTextField* label = [NSTextField labelWithString:text ?: @""];
label.translatesAutoresizingMaskIntoConstraints = NO;
label.alignment = NSTextAlignmentCenter;
label.font = bold ? [NSFont boldSystemFontOfSize:size] : [NSFont systemFontOfSize:size];
label.textColor = bold ? NSColor.labelColor : NSColor.secondaryLabelColor;
label.maximumNumberOfLines = 3;
label.lineBreakMode = NSLineBreakByWordWrapping;
[content addSubview:label];
return label;
};
NSTextField* titleLabel = makeLabel(@"OmniMTP", 22, YES);
titleLabel.textColor = NSColor.labelColor;
NSTextField* taglineLabel = makeLabel(OmniMenuString(@"about.tagline"), 13, NO);
NSTextField* versionLabel = makeLabel(OmniMenuFormat(@"about.version", @{@"version": versionText}), 12, NO);
NSTextField* copyLabel = makeLabel(OmniMenuString(@"about.copyright"), 11, NO);
NSButton* okButton = [[NSButton alloc] initWithFrame:NSZeroRect];
okButton.translatesAutoresizingMaskIntoConstraints = NO;
okButton.title = OmniMenuString(@"confirm.ok_btn");
okButton.bezelStyle = NSBezelStyleRounded;
okButton.keyEquivalent = @"\r";
okButton.target = self;
okButton.action = @selector(closeAboutPanel:);
[content addSubview:okButton];
[NSLayoutConstraint activateConstraints:@[
[iconView.topAnchor constraintEqualToAnchor:content.topAnchor constant:28],
[iconView.centerXAnchor constraintEqualToAnchor:content.centerXAnchor],
[iconView.widthAnchor constraintEqualToConstant:96],
[iconView.heightAnchor constraintEqualToConstant:96],
[titleLabel.topAnchor constraintEqualToAnchor:iconView.bottomAnchor constant:14],
[titleLabel.leadingAnchor constraintEqualToAnchor:content.leadingAnchor constant:24],
[titleLabel.trailingAnchor constraintEqualToAnchor:content.trailingAnchor constant:-24],
[taglineLabel.topAnchor constraintEqualToAnchor:titleLabel.bottomAnchor constant:8],
[taglineLabel.leadingAnchor constraintEqualToAnchor:content.leadingAnchor constant:24],
[taglineLabel.trailingAnchor constraintEqualToAnchor:content.trailingAnchor constant:-24],
[versionLabel.topAnchor constraintEqualToAnchor:taglineLabel.bottomAnchor constant:10],
[versionLabel.leadingAnchor constraintEqualToAnchor:content.leadingAnchor constant:24],
[versionLabel.trailingAnchor constraintEqualToAnchor:content.trailingAnchor constant:-24],
[copyLabel.topAnchor constraintEqualToAnchor:versionLabel.bottomAnchor constant:4],
[copyLabel.leadingAnchor constraintEqualToAnchor:content.leadingAnchor constant:24],
[copyLabel.trailingAnchor constraintEqualToAnchor:content.trailingAnchor constant:-24],
[okButton.topAnchor constraintEqualToAnchor:copyLabel.bottomAnchor constant:20],
[okButton.centerXAnchor constraintEqualToAnchor:content.centerXAnchor],
[okButton.bottomAnchor constraintEqualToAnchor:content.bottomAnchor constant:-24],
[okButton.widthAnchor constraintGreaterThanOrEqualToConstant:80],
]];
_aboutPanel = panel;
[panel center];
[NSApp runModalForWindow:panel];
}
- (void)closeAboutPanel:(id)sender {
(void)sender;
[NSApp stopModal];
[_aboutPanel orderOut:nil];
_aboutPanel = nil;
}
- (void)checkForUpdates:(id)sender {
[self startUpdateCheckManual:(sender != nil)];
}
- (void)startUpdateCheckManual:(BOOL)manual {
if (_updateCheckInFlight) return;
_updateCheckInFlight = YES;
if (_checkUpdatesItem)
_checkUpdatesItem.enabled = NO;
NSURL* url = [NSURL URLWithString:kOmniReleasesLatestURL];
NSMutableURLRequest* req = [NSMutableURLRequest requestWithURL:url
cachePolicy:NSURLRequestReloadIgnoringLocalCacheData
timeoutInterval:12.0];
[req setValue:@"OmniMTP" forHTTPHeaderField:@"User-Agent"];
[req setValue:@"application/json" forHTTPHeaderField:@"Accept"];
__weak OmniAppDelegate* weakSelf = self;
NSURLSessionDataTask* task =
[[NSURLSession sharedSession] dataTaskWithRequest:req
completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) {
dispatch_async(dispatch_get_main_queue(), ^{
OmniAppDelegate* selfStrong = weakSelf;
if (!selfStrong) return;
selfStrong->_updateCheckInFlight = NO;
if (selfStrong->_checkUpdatesItem)
selfStrong->_checkUpdatesItem.enabled = YES;
[selfStrong handleUpdateResponse:data
response:response
error:error
manual:manual];
});
}];
[task resume];
}
- (void)handleUpdateResponse:(NSData*)data
response:(NSURLResponse*)response
error:(NSError*)error
manual:(BOOL)manual {
if (error || !data) {
if (manual) {
NSAlert* alert = [[NSAlert alloc] init];
alert.messageText = OmniMenuString(@"update.check_failed_title");
alert.informativeText = error.localizedDescription.length
? error.localizedDescription
: OmniMenuString(@"update.check_failed_body");
[alert addButtonWithTitle:OmniMenuString(@"confirm.ok_btn")];
[alert beginSheetModalForWindow:_window completionHandler:nil];
}
return;
}
NSInteger status = 0;
if ([response isKindOfClass:[NSHTTPURLResponse class]])
status = ((NSHTTPURLResponse*)response).statusCode;
if (status == 404) {
// No releases published yet.
if (manual) [self showUpToDateAlert];
return;
}
if (status < 200 || status >= 300) {
if (manual) {
NSAlert* alert = [[NSAlert alloc] init];
alert.messageText = OmniMenuString(@"update.check_failed_title");
alert.informativeText = OmniMenuFormat(@"update.http_error", @{
@"code": [NSString stringWithFormat:@"%ld", (long)status]
});
[alert addButtonWithTitle:OmniMenuString(@"confirm.ok_btn")];
[alert beginSheetModalForWindow:_window completionHandler:nil];
}
return;
}
id json = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
if (![json isKindOfClass:[NSDictionary class]]) {
if (manual) {
NSAlert* alert = [[NSAlert alloc] init];
alert.messageText = OmniMenuString(@"update.check_failed_title");
alert.informativeText = OmniMenuString(@"update.check_failed_body");
[alert addButtonWithTitle:OmniMenuString(@"confirm.ok_btn")];
[alert beginSheetModalForWindow:_window completionHandler:nil];
}
return;
}
NSDictionary* release = (NSDictionary*)json;
if ([release[@"draft"] boolValue] || [release[@"prerelease"] boolValue]) {
if (manual) [self showUpToDateAlert];
return;
}
NSString* tag = release[@"tag_name"];
if (![tag isKindOfClass:[NSString class]] || tag.length == 0)
tag = release[@"name"];
if (![tag isKindOfClass:[NSString class]] || tag.length == 0) {
if (manual) [self showUpToDateAlert];
return;
}
NSString* current = OmniCurrentAppVersion();
if (OmniCompareVersions(current, tag) != NSOrderedAscending) {
if (manual) [self showUpToDateAlert];
return;
}
if (!manual) {
NSString* skipped = [NSUserDefaults.standardUserDefaults stringForKey:kOmniSkippedUpdateKey];
if (skipped.length && OmniCompareVersions(skipped, tag) != NSOrderedAscending)
return;
}
NSString* downloadURL = OmniPreferredDownloadURL(release);
[self showUpdateAvailableForTag:tag current:current downloadURL:downloadURL];
}
- (void)showUpToDateAlert {
NSAlert* alert = [[NSAlert alloc] init];
alert.messageText = OmniMenuString(@"update.up_to_date_title");
alert.informativeText = OmniMenuFormat(@"update.up_to_date_body", @{
@"version": OmniCurrentAppVersion()
});
[alert addButtonWithTitle:OmniMenuString(@"confirm.ok_btn")];
if (_window)
[alert beginSheetModalForWindow:_window completionHandler:nil];
else
[alert runModal];
}
- (void)showUpdateAvailableForTag:(NSString*)tag
current:(NSString*)current
downloadURL:(NSString*)downloadURL {
NSAlert* alert = [[NSAlert alloc] init];
alert.messageText = OmniMenuString(@"update.available_title");
alert.informativeText = OmniMenuFormat(@"update.available_body", @{
@"latest": tag,
@"current": current
});
[alert addButtonWithTitle:OmniMenuString(@"update.download")];
[alert addButtonWithTitle:OmniMenuString(@"update.later")];
[alert addButtonWithTitle:OmniMenuString(@"update.skip")];
void (^handle)(NSModalResponse) = ^(NSModalResponse response) {
if (response == NSAlertFirstButtonReturn) {
NSURL* url = [NSURL URLWithString:downloadURL];
if (url) [NSWorkspace.sharedWorkspace openURL:url];
} else if (response == NSAlertThirdButtonReturn) {
[NSUserDefaults.standardUserDefaults setObject:tag forKey:kOmniSkippedUpdateKey];
}
};
if (_window)
[alert beginSheetModalForWindow:_window completionHandler:handle];
else
handle([alert runModal]);
}
- (BOOL)windowShouldClose:(NSWindow*)sender {
if (sender == _aboutPanel) {
[NSApp stopModal];
_aboutPanel = nil;
}
return YES;
}
- (void)selectLanguage:(id)sender {
NSMenuItem* item = (NSMenuItem*)sender;
if (![item isKindOfClass:[NSMenuItem class]]) return;
NSString* code = item.representedObject;
if (![code isKindOfClass:[NSString class]]) return;
NSMutableDictionary* cfg = [OmniLoadConfig() mutableCopy] ?: [NSMutableDictionary dictionary];
cfg[@"language"] = code;
OmniSaveConfig(cfg);
[self applyLanguageToWebView];
[self buildMainMenu];
}
- (void)applyLanguageToWebView {
if (!_webView) return;
NSDictionary* payload = OmniBuildI18nPayload(OmniFindWebrootURL());
NSData* data = [NSJSONSerialization dataWithJSONObject:payload options:0 error:nil];
NSString* json = data ? [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] : @"{}";
NSString* js = [NSString stringWithFormat:
@"window.__OMNI_I18N__=%@;"
"if(window.omniApplyI18n)window.omniApplyI18n(window.__OMNI_I18N__);", json];
[_webView evaluateJavaScript:js completionHandler:nil];
}
- (void)applicationDidFinishLaunching:(NSNotification*)note {
(void)note;
[self buildMainMenu];
// ── WKWebView configuration ───────────────────────────────────────────────
WKWebViewConfiguration* config = [[WKWebViewConfiguration alloc] init];
// Enable developer tools in debug builds
#if DEBUG
if (@available(macOS 13.3, *)) {
config.preferences.elementFullscreenEnabled = YES;
[config.preferences setValue:@YES forKey:@"developerExtrasEnabled"];
}
#endif
// Disable text selection in file lists; file drag only.
WKUserScript* noTextDrag = [[WKUserScript alloc]
initWithSource:@"(function(){"
"function blockText(e){if(e.target.closest('.file-list'))e.preventDefault();}"
"document.addEventListener('selectstart',blockText,true);"
"document.addEventListener('mousedown',function(e){"
"if(e.button===0&&e.target.closest('.file-list .frow'))e.preventDefault();"
"},true);"
"document.addEventListener('dragstart',function(e){"
"if(e.target.closest('.file-list .frow'))e.preventDefault();"
"},true);"
"})();"
injectionTime:WKUserScriptInjectionTimeAtDocumentStart
forMainFrameOnly:YES];
[config.userContentController addUserScript:noTextDrag];
// Inject locale strings before page JS runs (WKWebView blocks fetch() on file://).
NSURL* webrootURL = OmniFindWebrootURL();
WKUserScript* i18nBoot = [[WKUserScript alloc]
initWithSource:OmniI18nBootstrapScript(webrootURL)
injectionTime:WKUserScriptInjectionTimeAtDocumentStart
forMainFrameOnly:YES];
[config.userContentController addUserScript:i18nBoot];
// Register the "bridge" message handler
_webView = [[OmniWebView alloc] initWithFrame:NSMakeRect(0, 0, 1280, 800)
configuration:config
app:_app];
_bridge = [[OmniBridge alloc] initWithApp:_app webView:_webView];
[config.userContentController addScriptMessageHandler:_bridge name:@"bridge"];
_webView.navigationDelegate = _bridge;
_webView.UIDelegate = _bridge;
// Allow file:// access for local resource loading
_webView.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable;
// ── NSWindow ──────────────────────────────────────────────────────────────
NSRect frame = NSMakeRect(0, 0, 1280, 800);
NSWindowStyleMask style =
NSWindowStyleMaskTitled |
NSWindowStyleMaskClosable |
NSWindowStyleMaskMiniaturizable |
NSWindowStyleMaskResizable |
NSWindowStyleMaskFullSizeContentView;
_window = [[NSWindow alloc] initWithContentRect:frame
styleMask:style
backing:NSBackingStoreBuffered
defer:NO];
_window.title = @"OmniMTP";
_window.titleVisibility = NSWindowTitleHidden;
_window.titlebarAppearsTransparent = YES;
_window.minSize = NSMakeSize(900, 600);
_window.movableByWindowBackground = YES;
// Force dark appearance
if (@available(macOS 10.14, *)) {
_window.appearance = [NSAppearance appearanceNamed:NSAppearanceNameDarkAqua];
}
// Container keeps a native drag strip above the WKWebView (web content
// would otherwise swallow titlebar mouse events).
NSView* container = [[NSView alloc] initWithFrame:frame];
_webView.frame = NSMakeRect(0, 0, frame.size.width, frame.size.height);
[container addSubview:_webView];
OmniTitlebarDragView* dragBar = [[OmniTitlebarDragView alloc]
initWithFrame:NSMakeRect(kTrafficLightInset, frame.size.height - kTitlebarDragHeight,
frame.size.width - kTrafficLightInset, kTitlebarDragHeight)];
dragBar.autoresizingMask = NSViewWidthSizable | NSViewMinYMargin;
[container addSubview:dragBar];
[_window setContentView:container];
[_window center];
[_window makeKeyAndOrderFront:nil];
// ── Load index.html from bundle (dev fallback: source tree) ───────────────
NSURL* indexURL = nil;
// 1. Try the bundle's webroot resource directory
NSBundle* bundle = [NSBundle mainBundle];
indexURL = [bundle URLForResource:@"index" withExtension:@"html" subdirectory:@"webroot"];
// 2. Dev fallback: look next to the binary in a webroot/ sibling
if (!indexURL) {
NSString* execDir = bundle.executableURL.URLByDeletingLastPathComponent.path;
NSString* devPath = [execDir stringByAppendingPathComponent:@"webroot/index.html"];
if ([[NSFileManager defaultManager] fileExistsAtPath:devPath])
indexURL = [NSURL fileURLWithPath:devPath];
}
// 3. Source tree fallback for direct cmake build without bundle
if (!indexURL) {
// Walk up from executable looking for src/ui/webroot
NSString* base = bundle.executableURL.URLByDeletingLastPathComponent.path;
NSArray<NSString*>* candidates = @[
[base stringByAppendingPathComponent:@"../src/ui/webroot/index.html"],
[base stringByAppendingPathComponent:@"../../src/ui/webroot/index.html"],
[base stringByAppendingPathComponent:@"../../../src/ui/webroot/index.html"],
];
for (NSString* c in candidates) {
NSString* resolved = c.stringByResolvingSymlinksInPath;
if ([[NSFileManager defaultManager] fileExistsAtPath:resolved]) {
indexURL = [NSURL fileURLWithPath:resolved];
break;
}
}
}
if (indexURL) {
NSURL* baseURL = indexURL.URLByDeletingLastPathComponent;
NSString* html = [NSString stringWithContentsOfURL:indexURL
encoding:NSUTF8StringEncoding
error:nil];
if (html) {
[_webView loadHTMLString:html baseURL:baseURL];
} else {
[_webView loadFileURL:indexURL allowingReadAccessToURL:baseURL];
}
} else {
// Inline fallback so the app at least opens
NSString* fallback = @"<!DOCTYPE html><html><body style='background:#1a1a1a;color:#fff;font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh'><h2>webroot/index.html not found</h2></body></html>";
[_webView loadHTMLString:fallback baseURL:nil];
NSLog(@"[OmniMTP] ERROR: webroot/index.html not found in bundle or source tree");
}
// Defer so the main window is visible before any update sheet appears.
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.5 * NSEC_PER_SEC)),
dispatch_get_main_queue(), ^{
[self startUpdateCheckManual:NO];
});
}
- (BOOL)applicationShouldTerminateAfterLastWindowClosed:(NSApplication*)app {
(void)app;
return YES;
}
- (void)applicationWillTerminate:(NSNotification*)note {
(void)note;
}
@end
// ─── WebUI ────────────────────────────────────────────────────────────────────
namespace omniMTP {
WebUI::WebUI(App& a) : app_(a) {}
WebUI::~WebUI() = default;
void WebUI::run() {
@autoreleasepool {
[NSApplication sharedApplication];
[NSApp setActivationPolicy:NSApplicationActivationPolicyRegular];
OmniAppDelegate* delegate = [[OmniAppDelegate alloc] initWithApp:&app_];
[NSApp setDelegate:delegate];
[NSApp activateIgnoringOtherApps:YES];
[NSApp run];
}
}
} // namespace omniMTP