Add startup update check against Gitea releases.

Show a native alert when a newer release is available, with menu access to check manually.
This commit is contained in:
2026-07-30 16:13:02 +02:00
parent 86fa763eda
commit 68353cbc56
11 changed files with 359 additions and 10 deletions

View File

@@ -211,6 +211,73 @@ static NSString* OmniMenuFormat(NSString* key, NSDictionary<NSString*, NSString*
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;
@@ -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 {

View File

@@ -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}."
}

View File

@@ -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": "Youre 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}."
}

View File

@@ -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}."
}

View File

@@ -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}."
}

View File

@@ -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} è lultima 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}."
}

View File

@@ -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} を返しました。"
}

View File

@@ -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."
}

View File

@@ -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}."
}

View File

@@ -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}."
}

View File

@@ -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}。"
}