From 7a656de7ce242ffd2261798d38cf3da195a8aa08 Mon Sep 17 00:00:00 2001 From: Twyco Date: Thu, 15 Oct 2026 08:56:29 +0200 Subject: [PATCH] fix: restore macOS navigation with unsaved forms --- native/package.json | 4 +- native/src-tauri/Cargo.lock | 2 +- native/src-tauri/Cargo.toml | 2 +- .../project.pbxproj | 4 +- native/src-tauri/gen/apple/project.yml | 2 +- native/src-tauri/src/navigation.m | 49 ++++++++++ native/src-tauri/tauri.conf.json | 4 +- native/tests/navigation-macos.m | 93 ++++++++++++++++++- native/tests/navigation-macos.test.js | 4 +- 9 files changed, 152 insertions(+), 12 deletions(-) diff --git a/native/package.json b/native/package.json index 2f88839..a28d172 100644 --- a/native/package.json +++ b/native/package.json @@ -1,6 +1,6 @@ { "name": "kanbangit-native", - "version": "0.2.6", + "version": "0.2.7", "private": true, "type": "module", "scripts": { @@ -25,5 +25,5 @@ "devDependencies": { "@tauri-apps/cli": "2.11.4" }, - "releaseNotes": "Manage local workspace settings in the web app on macOS and iOS, with shared macOS update controls and native recovery for older or unavailable servers." + "releaseNotes": "Fix blocked navigation after changing project creation options or other forms on macOS by displaying native confirmations for unsaved changes." } diff --git a/native/src-tauri/Cargo.lock b/native/src-tauri/Cargo.lock index 723cf59..933aaef 100644 --- a/native/src-tauri/Cargo.lock +++ b/native/src-tauri/Cargo.lock @@ -1770,7 +1770,7 @@ dependencies = [ [[package]] name = "kanbangit-native" -version = "0.2.6" +version = "0.2.7" dependencies = [ "semver", "serde", diff --git a/native/src-tauri/Cargo.toml b/native/src-tauri/Cargo.toml index eb243a9..9a70304 100644 --- a/native/src-tauri/Cargo.toml +++ b/native/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kanbangit-native" -version = "0.2.6" +version = "0.2.7" description = "KanbanGit native application" edition = "2021" publish = false diff --git a/native/src-tauri/gen/apple/kanbangit-native.xcodeproj/project.pbxproj b/native/src-tauri/gen/apple/kanbangit-native.xcodeproj/project.pbxproj index c6933ea..89a28cf 100644 --- a/native/src-tauri/gen/apple/kanbangit-native.xcodeproj/project.pbxproj +++ b/native/src-tauri/gen/apple/kanbangit-native.xcodeproj/project.pbxproj @@ -467,7 +467,7 @@ ); "LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)"; "LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)"; - MARKETING_VERSION = 0.2.6; + MARKETING_VERSION = 0.2.7; PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios; PRODUCT_MODULE_NAME = KanbanGitNative; PRODUCT_NAME = KanbanGit; @@ -644,7 +644,7 @@ ); "LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)"; "LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)"; - MARKETING_VERSION = 0.2.6; + MARKETING_VERSION = 0.2.7; PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios; PRODUCT_MODULE_NAME = KanbanGitNative; PRODUCT_NAME = KanbanGit; diff --git a/native/src-tauri/gen/apple/project.yml b/native/src-tauri/gen/apple/project.yml index 37e093b..2a040fe 100644 --- a/native/src-tauri/gen/apple/project.yml +++ b/native/src-tauri/gen/apple/project.yml @@ -18,7 +18,7 @@ settingGroups: DEVELOPMENT_TEAM: ZZX2KZGTHU ENABLE_USER_SCRIPT_SANDBOXING: false PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios - MARKETING_VERSION: "0.2.6" + MARKETING_VERSION: "0.2.7" CURRENT_PROJECT_VERSION: "6" configs: debug: diff --git a/native/src-tauri/src/navigation.m b/native/src-tauri/src/navigation.m index 4711f2c..c6a6f84 100644 --- a/native/src-tauri/src/navigation.m +++ b/native/src-tauri/src/navigation.m @@ -1,6 +1,55 @@ +#import #import +#import + +@interface KanbanWorkspaceUIDelegate : NSObject +@property(nonatomic, strong) id originalDelegate; +@end + +@implementation KanbanWorkspaceUIDelegate +- (BOOL)respondsToSelector:(SEL)selector { + return [super respondsToSelector:selector] || [self.originalDelegate respondsToSelector:selector]; +} + +- (id)forwardingTargetForSelector:(SEL)selector { + if ([self.originalDelegate respondsToSelector:selector]) { + return self.originalDelegate; + } + return [super forwardingTargetForSelector:selector]; +} + +- (void)webView:(WKWebView *)webView runJavaScriptConfirmPanelWithMessage:(NSString *)message + initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(BOOL))completionHandler { + NSWindow *window = webView.window; + if (!window || window.attachedSheet) { + completionHandler(NO); + return; + } + + NSAlert *alert = [NSAlert new]; + alert.alertStyle = NSAlertStyleWarning; + alert.messageText = frame.request.URL.host ?: @"KanbanGit"; + alert.informativeText = message; + [alert addButtonWithTitle:@"OK"]; + [alert addButtonWithTitle:@"Cancel"]; + [alert beginSheetModalForWindow:window completionHandler:^(NSModalResponse response) { + completionHandler(response == NSAlertFirstButtonReturn); + }]; +} +@end + +static char workspaceUIDelegateKey; void install_workspace_navigation(void *pointer) { WKWebView *webview = (__bridge WKWebView *)pointer; webview.allowsBackForwardNavigationGestures = YES; + if ([webview.UIDelegate isKindOfClass:KanbanWorkspaceUIDelegate.class]) { + return; + } + + KanbanWorkspaceUIDelegate *delegate = [KanbanWorkspaceUIDelegate new]; + delegate.originalDelegate = webview.UIDelegate; + // WKWebView keeps a weak UI delegate, so retain the forwarding adapter with its view. + objc_setAssociatedObject(webview, &workspaceUIDelegateKey, delegate, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + webview.UIDelegate = delegate; } diff --git a/native/src-tauri/tauri.conf.json b/native/src-tauri/tauri.conf.json index 370c611..132686f 100644 --- a/native/src-tauri/tauri.conf.json +++ b/native/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "KanbanGit", - "version": "0.2.6", + "version": "0.2.7", "identifier": "de.kanbangit.desktop", "build": { "frontendDist": "../ui" @@ -25,7 +25,7 @@ ], "macOS": { "minimumSystemVersion": "14.0", - "bundleVersion": "11", + "bundleVersion": "12", "dmg": { "windowSize": { "width": 660, diff --git a/native/tests/navigation-macos.m b/native/tests/navigation-macos.m index 390c50f..e9b6294 100644 --- a/native/tests/navigation-macos.m +++ b/native/tests/navigation-macos.m @@ -17,6 +17,53 @@ static void waitUntil(BOOL (^condition)(void), NSString *message) { check(condition(), message); } +static id evaluate(WKWebView *webview, NSString *script) { + __block BOOL finished = NO; + __block id result; + [webview evaluateJavaScript:script completionHandler:^(id value, NSError *error) { + check(error == nil, error.localizedDescription); + result = value; + finished = YES; + }]; + waitUntil(^BOOL { return finished; }, @"JavaScript must finish after the confirmation is dismissed"); + return result; +} + +static NSButton *findButton(NSView *view, NSString *title) { + if ([view isKindOfClass:NSButton.class] && [((NSButton *)view).title isEqualToString:title]) { + return (NSButton *)view; + } + for (NSView *child in view.subviews) { + NSButton *button = findButton(child, title); + if (button) { return button; } + } + return nil; +} + +static BOOL containsText(NSView *view, NSString *text) { + if ([view isKindOfClass:NSTextField.class] && [((NSTextField *)view).stringValue isEqualToString:text]) { + return YES; + } + for (NSView *child in view.subviews) { + if (containsText(child, text)) { return YES; } + } + return NO; +} + +@interface WorkspaceUI : NSObject +@property(nonatomic) BOOL closed; +@property(nonatomic) BOOL uploaded; +@end + +@implementation WorkspaceUI +- (void)webViewDidClose:(WKWebView *)webView { self.closed = YES; } +- (void)webView:(WKWebView *)webView runOpenPanelWithParameters:(WKOpenPanelParameters *)parameters + initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(NSArray *))completionHandler { + self.uploaded = YES; + completionHandler(nil); +} +@end + @interface WorkspacePages : NSObject @property(nonatomic) NSUInteger finished; @end @@ -46,9 +93,18 @@ int main(void) { [configuration setURLSchemeHandler:pages forURLScheme:@"kanban-test"]; WKWebView *webview = [[WKWebView alloc] initWithFrame:NSMakeRect(0, 0, 800, 600) configuration:configuration]; webview.navigationDelegate = pages; + WorkspaceUI *originalUI = [WorkspaceUI new]; + webview.UIDelegate = originalUI; check(!webview.allowsBackForwardNavigationGestures, @"WebKit defaults to disabled gestures"); install_workspace_navigation((__bridge void *)webview); + id installedUI = webview.UIDelegate; install_workspace_navigation((__bridge void *)webview); + check(webview.UIDelegate == installedUI, @"Installing workspace behavior twice must retain the same delegate"); + check([installedUI respondsToSelector:@selector(webViewDidClose:)], @"Existing popup callbacks must remain available"); + [installedUI webViewDidClose:webview]; + check(originalUI.closed, @"Existing popup callbacks must reach the original delegate"); + check([installedUI respondsToSelector:@selector(webView:runOpenPanelWithParameters:initiatedByFrame:completionHandler:)], + @"Existing file upload callbacks must remain available"); check(webview.allowsBackForwardNavigationGestures, @"The workspace must enable WebKit's interactive gestures"); check(webview.configuration.userContentController.userScripts.count == 0, @"Enabling gestures must not inject a history adapter"); check([webview goBack] == nil && [webview goForward] == nil, @"Empty history must not navigate"); @@ -69,6 +125,41 @@ int main(void) { check([webview.URL.path isEqualToString:@"/second"] && webview.canGoBack && !webview.canGoForward, @"Forward must restore the second page"); check(webview.allowsBackForwardNavigationGestures, @"Native gestures must remain enabled across page navigation"); - puts("PASS: WebKit interactive gesture configuration, full-page back/forward and empty history"); + + NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 800, 600) + styleMask:NSWindowStyleMaskTitled backing:NSBackingStoreBuffered defer:NO]; + window.releasedWhenClosed = NO; + window.title = @"KanbanGit navigation test"; + window.contentView = webview; + [window orderFront:nil]; + evaluate(webview, @"document.body.innerHTML = `Back`; window.visits = 0; document.querySelector('#back').onclick = event => { event.preventDefault(); if (!document.querySelector('#duplicate').checked || confirm('Discard unsaved changes?')) window.visits++; }; true;"); + evaluate(webview, @"document.querySelector('#upload').click()"); + waitUntil(^BOOL { return originalUI.uploaded; }, @"File selection must still reach the original UI delegate"); + evaluate(webview, @"document.querySelector('#back').click()"); + check([evaluate(webview, @"window.visits") intValue] == 1, @"Pristine forms must navigate without a confirmation"); + evaluate(webview, @"document.querySelector('#duplicate').click()"); + for (NSString *choice in @[@"Cancel", @"OK", @"Cancel", @"OK"]) { + __block BOOL finished = NO; + [webview evaluateJavaScript:@"document.querySelector('#back').click()" completionHandler:^(id result, NSError *error) { + check(error == nil, error.localizedDescription); + finished = YES; + }]; + waitUntil(^BOOL { return window.attachedSheet != nil; }, @"Dirty form navigation must show a native confirmation"); + check(!finished, @"The original click must wait for the user's decision"); + check(containsText(window.attachedSheet.contentView, @"workspace"), @"The confirmation must identify the requesting website"); + check(containsText(window.attachedSheet.contentView, @"Discard unsaved changes?"), @"The confirmation must show the application's message"); + NSButton *button = findButton(window.attachedSheet.contentView, choice); + check(button != nil, @"The native confirmation must offer OK and Cancel"); + [button performClick:nil]; + waitUntil(^BOOL { return finished && window.attachedSheet == nil; }, @"Dismissal must release the original navigation"); + check([evaluate(webview, @"document.querySelector('#draft').value") isEqualToString:@"Unsaved project"], + @"Cancelling a confirmation must preserve the draft"); + } + check([evaluate(webview, @"window.visits") intValue] == 3, @"Only accepted visits must run, exactly once each"); + window.contentView = nil; + check(![evaluate(webview, @"confirm('Detached view')") boolValue], @"A detached view must cancel and release JavaScript immediately"); + [window orderOut:nil]; + [window close]; + puts("PASS: WebKit interactive gesture configuration, full-page back/forward, native confirmations and delegate forwarding"); } } diff --git a/native/tests/navigation-macos.test.js b/native/tests/navigation-macos.test.js index 43c5f3b..8e6988c 100644 --- a/native/tests/navigation-macos.test.js +++ b/native/tests/navigation-macos.test.js @@ -8,7 +8,7 @@ import test from 'node:test'; import { fileURLToPath } from 'node:url'; test( - 'WebKit owns interactive navigation and preserves native back/forward history', + 'WebKit preserves history and confirms dirty form navigation without losing delegate callbacks', { skip: process.platform !== 'darwin' }, () => { const directory = mkdtempSync(join(tmpdir(), 'kanbangit-navigation-')); @@ -48,7 +48,7 @@ test( assert.equal( result.status, 0, - result.stderr || result.error?.message, + result.stderr || result.error?.message || result.signal, ); assert.match(result.stdout, /PASS: WebKit/); } finally {