Show a native alert when a newer release is available, with menu access to check manually.
1469 lines
60 KiB
Plaintext
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
|