From 68353cbc560134c3fbaa823c61e8061a556bdc4b Mon Sep 17 00:00:00 2001 From: niklascfw Date: Thu, 30 Jul 2026 16:13:02 +0200 Subject: [PATCH] Add startup update check against Gitea releases. Show a native alert when a newer release is available, with menu access to check manually. --- src/ui/WebUI.mm | 239 +++++++++++++++++++++++++++++++ src/ui/webroot/lang/de.json | 13 +- src/ui/webroot/lang/en.json | 13 +- src/ui/webroot/lang/es.json | 13 +- src/ui/webroot/lang/fr.json | 13 +- src/ui/webroot/lang/it.json | 13 +- src/ui/webroot/lang/ja.json | 13 +- src/ui/webroot/lang/nl.json | 13 +- src/ui/webroot/lang/pt.json | 13 +- src/ui/webroot/lang/ru.json | 13 +- src/ui/webroot/lang/zh-Hans.json | 13 +- 11 files changed, 359 insertions(+), 10 deletions(-) diff --git a/src/ui/WebUI.mm b/src/ui/WebUI.mm index 91755a0..b094613 100644 --- a/src/ui/WebUI.mm +++ b/src/ui/WebUI.mm @@ -211,6 +211,73 @@ static NSString* OmniMenuFormat(NSString* key, NSDictionary* 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* 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* pa = OmniParseVersionParts(a); + NSArray* 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; @@ -821,6 +888,7 @@ static const CGFloat kTrafficLightInset = 72.0; - (void)selectLanguage:(id)sender; - (void)showAbout:(id)sender; - (void)closeAboutPanel:(id)sender; +- (void)checkForUpdates:(id)sender; - (void)applyLanguageToWebView; @end @@ -831,9 +899,11 @@ static const CGFloat kTrafficLightInset = 72.0; OmniBridge* _bridge; NSPanel* _aboutPanel; NSMenuItem* _aboutItem; + NSMenuItem* _checkUpdatesItem; NSMenuItem* _optionsItem; NSMenuItem* _languageItem; NSMenuItem* _quitItem; + BOOL _updateCheckInFlight; } - (instancetype)initWithApp:(omniMTP::App*)app { @@ -855,6 +925,12 @@ static const CGFloat kTrafficLightInset = 72.0; 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 @@ -1020,6 +1096,163 @@ static const CGFloat kTrafficLightInset = 72.0; _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]; @@ -1194,6 +1427,12 @@ static const CGFloat kTrafficLightInset = 72.0; [_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 { diff --git a/src/ui/webroot/lang/de.json b/src/ui/webroot/lang/de.json index d328a99..c23e3b6 100644 --- a/src/ui/webroot/lang/de.json +++ b/src/ui/webroot/lang/de.json @@ -47,5 +47,16 @@ "about.tagline": "Nintendo Switch MTP-Client für macOS", "about.version": "Version {version}", "about.copyright": "Copyright © 2026 NiklasCFW", - "status.usb_recovering": "USB wird nach dem Ruhezustand neu verbunden…" + "status.usb_recovering": "USB wird nach dem Ruhezustand neu verbunden…", + "menu.check_updates": "Nach Updates suchen…", + "update.available_title": "Update verfügbar", + "update.available_body": "OmniMTP {latest} ist verfügbar.\nDu hast {current}.", + "update.download": "Herunterladen", + "update.later": "Später", + "update.skip": "Diese Version überspringen", + "update.up_to_date_title": "Du bist auf dem neuesten Stand", + "update.up_to_date_body": "OmniMTP {version} ist die aktuelle Version.", + "update.check_failed_title": "Update-Prüfung fehlgeschlagen", + "update.check_failed_body": "Der Update-Server konnte nicht erreicht werden.", + "update.http_error": "Server antwortete mit HTTP {code}." } diff --git a/src/ui/webroot/lang/en.json b/src/ui/webroot/lang/en.json index 651ec19..4a9e51a 100644 --- a/src/ui/webroot/lang/en.json +++ b/src/ui/webroot/lang/en.json @@ -47,5 +47,16 @@ "about.tagline": "Nintendo Switch MTP Client for macOS", "about.version": "Version {version}", "about.copyright": "Copyright © 2026 NiklasCFW", - "status.usb_recovering": "Reconnecting USB after sleep…" + "status.usb_recovering": "Reconnecting USB after sleep…", + "menu.check_updates": "Check for Updates…", + "update.available_title": "Update Available", + "update.available_body": "OmniMTP {latest} is available.\nYou have {current}.", + "update.download": "Download", + "update.later": "Later", + "update.skip": "Skip This Version", + "update.up_to_date_title": "You’re Up to Date", + "update.up_to_date_body": "OmniMTP {version} is the latest release.", + "update.check_failed_title": "Update Check Failed", + "update.check_failed_body": "Could not reach the update server.", + "update.http_error": "Server returned HTTP {code}." } diff --git a/src/ui/webroot/lang/es.json b/src/ui/webroot/lang/es.json index 22d2a9a..3b513af 100644 --- a/src/ui/webroot/lang/es.json +++ b/src/ui/webroot/lang/es.json @@ -47,5 +47,16 @@ "about.tagline": "Cliente MTP de Nintendo Switch para macOS", "about.version": "Versión {version}", "about.copyright": "Copyright © 2026 NiklasCFW", - "status.usb_recovering": "Reconectando USB tras la suspensión…" + "status.usb_recovering": "Reconectando USB tras la suspensión…", + "menu.check_updates": "Buscar actualizaciones…", + "update.available_title": "Actualización disponible", + "update.available_body": "OmniMTP {latest} está disponible.\nTienes {current}.", + "update.download": "Descargar", + "update.later": "Más tarde", + "update.skip": "Omitir esta versión", + "update.up_to_date_title": "Estás al día", + "update.up_to_date_body": "OmniMTP {version} es la última versión.", + "update.check_failed_title": "Error al buscar actualizaciones", + "update.check_failed_body": "No se pudo contactar con el servidor de actualizaciones.", + "update.http_error": "El servidor respondió HTTP {code}." } diff --git a/src/ui/webroot/lang/fr.json b/src/ui/webroot/lang/fr.json index 27596fe..4180ba6 100644 --- a/src/ui/webroot/lang/fr.json +++ b/src/ui/webroot/lang/fr.json @@ -47,5 +47,16 @@ "about.tagline": "Client MTP Nintendo Switch pour macOS", "about.version": "Version {version}", "about.copyright": "Copyright © 2026 NiklasCFW", - "status.usb_recovering": "Reconnexion USB après veille…" + "status.usb_recovering": "Reconnexion USB après veille…", + "menu.check_updates": "Rechercher des mises à jour…", + "update.available_title": "Mise à jour disponible", + "update.available_body": "OmniMTP {latest} est disponible.\nVous avez {current}.", + "update.download": "Télécharger", + "update.later": "Plus tard", + "update.skip": "Ignorer cette version", + "update.up_to_date_title": "À jour", + "update.up_to_date_body": "OmniMTP {version} est la dernière version.", + "update.check_failed_title": "Échec de la vérification", + "update.check_failed_body": "Impossible de joindre le serveur de mises à jour.", + "update.http_error": "Le serveur a renvoyé HTTP {code}." } diff --git a/src/ui/webroot/lang/it.json b/src/ui/webroot/lang/it.json index 8d7f920..b3012b6 100644 --- a/src/ui/webroot/lang/it.json +++ b/src/ui/webroot/lang/it.json @@ -47,5 +47,16 @@ "about.tagline": "Client MTP Nintendo Switch per macOS", "about.version": "Versione {version}", "about.copyright": "Copyright © 2026 NiklasCFW", - "status.usb_recovering": "Riconnessione USB dopo lo standby…" + "status.usb_recovering": "Riconnessione USB dopo lo standby…", + "menu.check_updates": "Verifica aggiornamenti…", + "update.available_title": "Aggiornamento disponibile", + "update.available_body": "OmniMTP {latest} è disponibile.\nHai {current}.", + "update.download": "Scarica", + "update.later": "Dopo", + "update.skip": "Salta questa versione", + "update.up_to_date_title": "Sei aggiornato", + "update.up_to_date_body": "OmniMTP {version} è l’ultima versione.", + "update.check_failed_title": "Verifica aggiornamenti non riuscita", + "update.check_failed_body": "Impossibile raggiungere il server degli aggiornamenti.", + "update.http_error": "Il server ha risposto HTTP {code}." } diff --git a/src/ui/webroot/lang/ja.json b/src/ui/webroot/lang/ja.json index 51c80aa..f0ecee0 100644 --- a/src/ui/webroot/lang/ja.json +++ b/src/ui/webroot/lang/ja.json @@ -47,5 +47,16 @@ "about.tagline": "macOS向け Nintendo Switch MTPクライアント", "about.version": "バージョン {version}", "about.copyright": "Copyright © 2026 NiklasCFW", - "status.usb_recovering": "スリープ後、USBを再接続しています…" + "status.usb_recovering": "スリープ後、USBを再接続しています…", + "menu.check_updates": "アップデートを確認…", + "update.available_title": "アップデートがあります", + "update.available_body": "OmniMTP {latest} が利用可能です。\n現在のバージョンは {current} です。", + "update.download": "ダウンロード", + "update.later": "後で", + "update.skip": "このバージョンをスキップ", + "update.up_to_date_title": "最新です", + "update.up_to_date_body": "OmniMTP {version} が最新リリースです。", + "update.check_failed_title": "アップデート確認に失敗", + "update.check_failed_body": "アップデートサーバーに接続できませんでした。", + "update.http_error": "サーバーが HTTP {code} を返しました。" } diff --git a/src/ui/webroot/lang/nl.json b/src/ui/webroot/lang/nl.json index 839efda..6d5f58e 100644 --- a/src/ui/webroot/lang/nl.json +++ b/src/ui/webroot/lang/nl.json @@ -47,5 +47,16 @@ "about.tagline": "Nintendo Switch MTP-client voor macOS", "about.version": "Versie {version}", "about.copyright": "Copyright © 2026 NiklasCFW", - "status.usb_recovering": "USB opnieuw verbinden na slaapstand…" + "status.usb_recovering": "USB opnieuw verbinden na slaapstand…", + "menu.check_updates": "Controleren op updates…", + "update.available_title": "Update beschikbaar", + "update.available_body": "OmniMTP {latest} is beschikbaar.\nJe hebt {current}.", + "update.download": "Downloaden", + "update.later": "Later", + "update.skip": "Deze versie overslaan", + "update.up_to_date_title": "Je bent up-to-date", + "update.up_to_date_body": "OmniMTP {version} is de nieuwste release.", + "update.check_failed_title": "Updatecontrole mislukt", + "update.check_failed_body": "Kon de updateserver niet bereiken.", + "update.http_error": "Server gaf HTTP {code} terug." } diff --git a/src/ui/webroot/lang/pt.json b/src/ui/webroot/lang/pt.json index 34c5331..1902483 100644 --- a/src/ui/webroot/lang/pt.json +++ b/src/ui/webroot/lang/pt.json @@ -47,5 +47,16 @@ "about.tagline": "Cliente MTP Nintendo Switch para macOS", "about.version": "Versão {version}", "about.copyright": "Copyright © 2026 NiklasCFW", - "status.usb_recovering": "Reconectando USB após a suspensão…" + "status.usb_recovering": "Reconectando USB após a suspensão…", + "menu.check_updates": "Procurar atualizações…", + "update.available_title": "Atualização disponível", + "update.available_body": "OmniMTP {latest} está disponível.\nVocê tem {current}.", + "update.download": "Baixar", + "update.later": "Mais tarde", + "update.skip": "Ignorar esta versão", + "update.up_to_date_title": "Você está atualizado", + "update.up_to_date_body": "OmniMTP {version} é a versão mais recente.", + "update.check_failed_title": "Falha ao verificar atualizações", + "update.check_failed_body": "Não foi possível contactar o servidor de atualizações.", + "update.http_error": "O servidor retornou HTTP {code}." } diff --git a/src/ui/webroot/lang/ru.json b/src/ui/webroot/lang/ru.json index 54f7459..025af9a 100644 --- a/src/ui/webroot/lang/ru.json +++ b/src/ui/webroot/lang/ru.json @@ -47,5 +47,16 @@ "about.tagline": "MTP-клиент Nintendo Switch для macOS", "about.version": "Версия {version}", "about.copyright": "Copyright © 2026 NiklasCFW", - "status.usb_recovering": "Повторное подключение USB после сна…" + "status.usb_recovering": "Повторное подключение USB после сна…", + "menu.check_updates": "Проверить обновления…", + "update.available_title": "Доступно обновление", + "update.available_body": "Доступна OmniMTP {latest}.\nУ вас {current}.", + "update.download": "Скачать", + "update.later": "Позже", + "update.skip": "Пропустить эту версию", + "update.up_to_date_title": "У вас актуальная версия", + "update.up_to_date_body": "OmniMTP {version} — последний выпуск.", + "update.check_failed_title": "Не удалось проверить обновления", + "update.check_failed_body": "Не удалось связаться с сервером обновлений.", + "update.http_error": "Сервер вернул HTTP {code}." } diff --git a/src/ui/webroot/lang/zh-Hans.json b/src/ui/webroot/lang/zh-Hans.json index f32b639..c9fde95 100644 --- a/src/ui/webroot/lang/zh-Hans.json +++ b/src/ui/webroot/lang/zh-Hans.json @@ -47,5 +47,16 @@ "about.tagline": "适用于 macOS 的 Nintendo Switch MTP 客户端", "about.version": "版本 {version}", "about.copyright": "Copyright © 2026 NiklasCFW", - "status.usb_recovering": "休眠后正在重新连接 USB…" + "status.usb_recovering": "休眠后正在重新连接 USB…", + "menu.check_updates": "检查更新…", + "update.available_title": "有可用更新", + "update.available_body": "OmniMTP {latest} 可用。\n当前版本为 {current}。", + "update.download": "下载", + "update.later": "稍后", + "update.skip": "跳过此版本", + "update.up_to_date_title": "已是最新", + "update.up_to_date_body": "OmniMTP {version} 已是最新版本。", + "update.check_failed_title": "检查更新失败", + "update.check_failed_body": "无法连接更新服务器。", + "update.http_error": "服务器返回 HTTP {code}。" }