From 23897d9e5669a9d7ef5427145f58f1fdfd52c66e Mon Sep 17 00:00:00 2001 From: treeform Date: Sun, 6 Sep 2026 07:05:04 -0700 Subject: [PATCH 1/9] fix macOS CPU image ownership --- .github/workflows/build.yml | 4 +++ src/windy/platforms/macos/platform.nim | 25 +++++++++++------ tests/test_macos.nim | 37 ++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 8 deletions(-) create mode 100644 tests/test_macos.nim diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d939cac..47a8857 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,6 +26,10 @@ jobs: # Run tests. - run: nim c tests/test.nim - run: nim r tests/test_openurl.nim + - run: nim r tests/test_macos.nim + if: matrix.os == 'macos-latest' + - run: nim r -d:useCpu tests/test_macos.nim + if: matrix.os == 'macos-latest' - run: nim r -d:useCpu tests/test_cpu_pixels.nim if: matrix.os == 'windows-latest' diff --git a/src/windy/platforms/macos/platform.nim b/src/windy/platforms/macos/platform.nim index 822f540..6083eef 100644 --- a/src/windy/platforms/macos/platform.nim +++ b/src/windy/platforms/macos/platform.nim @@ -1232,19 +1232,28 @@ proc swapBuffers*(window: Window) = proc presentPixels*(window: Window, image: Image) = ## Presents a CPU-rendered Pixie image into the macOS window content view. when defined(useCpu): - if image == nil or image.width <= 0 or image.height <= 0: - return - let encodedPng = image.encodePng() - window.cpuImage = NSImage.alloc().initWithData(NSData.dataWithBytes( - encodedPng[0].unsafeAddr, - encodedPng.len - )) - window.inner.contentView.setNeedsDisplay(true) + if window.state.closed or image == nil or + image.width <= 0 or image.height <= 0: + return + autoreleasepool: + let + encodedPng = image.encodePng() + nativeImage = NSImage.alloc().initWithData(NSData.dataWithBytes( + encodedPng[0].unsafeAddr, + encodedPng.len + )) + if nativeImage.int == 0: + raise newException(WindyError, "Unable to create CPU frame image") + window.cpuImage.ID.release() + window.cpuImage = nativeImage + window.inner.contentView.setNeedsDisplay(true) else: discard proc close*(window: Window) = window.releaseMouse() + window.cpuImage.ID.release() + window.cpuImage = 0.NSImage window.onCloseRequest = nil window.onFrame = nil window.onMove = nil diff --git a/tests/test_macos.nim b/tests/test_macos.nim new file mode 100644 index 0000000..c1cb0ea --- /dev/null +++ b/tests/test_macos.nim @@ -0,0 +1,37 @@ +when defined(macosx): + include ../src/windy/platforms/macos/platform + + objc: + proc retainCount(self: ID): uint + + when defined(useCpu): + proc testCpuImages() = + ## Checks image ownership after replacement and repeated close. + let + window = newWindow("CPU ownership", ivec2(32, 32), visible = false) + image = newImage(32, 32) + window.presentPixels(image) + let first = window.cpuImage.ID + first.retain() + defer: + first.release() + let firstCount = first.retainCount() + window.presentPixels(image) + doAssert first.retainCount() == firstCount - 1 + let last = window.cpuImage.ID + last.retain() + defer: + last.release() + let lastCount = last.retainCount() + window.close() + doAssert last.retainCount() == lastCount - 1 + doAssert window.cpuImage.int == 0 + window.close() + window.presentPixels(image) + doAssert window.cpuImage.int == 0 + + testCpuImages() + + echo "Windy macOS regression tests passed" +else: + echo "Windy macOS regression tests skipped" From 0fb8b92f3a64e63c3ef94b66e51abe8f3bbda73d Mon Sep 17 00:00:00 2001 From: treeform Date: Sun, 6 Sep 2026 07:05:44 -0700 Subject: [PATCH 2/9] fix macOS temporary file command injection --- .github/workflows/build.yml | 2 ++ src/windy/platforms/macos/platform.nim | 17 +++++++-- tests/test_tempfiles.nim | 49 ++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 tests/test_tempfiles.nim diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 47a8857..27b6ab8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -28,6 +28,8 @@ jobs: - run: nim r tests/test_openurl.nim - run: nim r tests/test_macos.nim if: matrix.os == 'macos-latest' + - run: nim r tests/test_tempfiles.nim + if: matrix.os == 'macos-latest' - run: nim r -d:useCpu tests/test_macos.nim if: matrix.os == 'macos-latest' - run: nim r -d:useCpu tests/test_cpu_pixels.nim diff --git a/src/windy/platforms/macos/platform.nim b/src/windy/platforms/macos/platform.nim index 6083eef..9c5e137 100644 --- a/src/windy/platforms/macos/platform.nim +++ b/src/windy/platforms/macos/platform.nim @@ -1574,10 +1574,21 @@ proc setConfig*(appName: string, fileName: string, content: string) = proc openTempTextFile*(title, text: string) = ## Open a text file in the default text editor. - if not dirExists("tmp"): + try: createDir("tmp") - writeFile("tmp/" & title, text) - discard execShellCmd("open -a TextEdit tmp/" & title) + let path = "tmp" / title + writeFile(path, text) + let process = startProcess( + "open", + args = ["-a", "TextEdit", "--", path], + options = {poUsePath, poParentStreams} + ) + defer: + process.close() + if process.waitForExit() != 0: + raise newException(WindyError, "Unable to open temporary text file") + except IOError, OSError: + raise newException(WindyError, getCurrentExceptionMsg()) proc openUrl*(url: string) = ## Open a URL in the default web browser. diff --git a/tests/test_tempfiles.nim b/tests/test_tempfiles.nim new file mode 100644 index 0000000..fabc8a8 --- /dev/null +++ b/tests/test_tempfiles.nim @@ -0,0 +1,49 @@ +when defined(macosx): + import + std/[json, os, tempfiles], + windy + + if getEnv("WINDY_TEMPFILE_RECORD") != "": + writeFile(getEnv("WINDY_TEMPFILE_RECORD"), $(%commandLineParams())) + quit(0) + + proc testTempFiles() = + ## Checks that filenames reach TextEdit without shell interpretation. + let + tempDir = createTempDir("windy-tempfiles-", "") + originalDir = getCurrentDir() + originalPath = getEnv("PATH") + recordPath = tempDir / "args.json" + opener = tempDir / "open" + titles = [ + "ordinary.txt", + "two words.txt", + "quotes'\".txt", + "$(touch windy-injected)", + "`touch windy-injected`", + "title; touch windy-injected", + "title\ntouch windy-injected", + "日本語.txt" + ] + defer: + setCurrentDir(originalDir) + putEnv("PATH", originalPath) + delEnv("WINDY_TEMPFILE_RECORD") + removeDir(tempDir) + copyFile(getAppFilename(), opener) + setFilePermissions(opener, {fpUserRead, fpUserWrite, fpUserExec}) + setCurrentDir(tempDir) + putEnv("PATH", tempDir & ":" & originalPath) + putEnv("WINDY_TEMPFILE_RECORD", recordPath) + for title in titles: + openTempTextFile(title, "Text contents.") + doAssert parseFile(recordPath) == %[ + "-a", "TextEdit", "--", "tmp" / title + ] + doAssert readFile("tmp" / title) == "Text contents." + doAssert not fileExists("windy-injected") + + testTempFiles() + echo "Windy temporary file regression test passed" +else: + echo "Windy temporary file regression test skipped" From f18e6b98124db25b416016de6e2e4d60d77d010d Mon Sep 17 00:00:00 2001 From: treeform Date: Sun, 6 Sep 2026 07:06:29 -0700 Subject: [PATCH 3/9] fix macOS frame callback window mutations --- src/windy/platforms/macos/platform.nim | 10 ++++---- tests/test_macos.nim | 32 ++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/windy/platforms/macos/platform.nim b/src/windy/platforms/macos/platform.nim index 9c5e137..9b74d1c 100644 --- a/src/windy/platforms/macos/platform.nim +++ b/src/windy/platforms/macos/platform.nim @@ -1193,10 +1193,12 @@ proc pollEvents*() = autoreleasepool: rescueActivation() - # Draw first (in case a message closes a window or similar) - for window in windows: - if window.onFrame != nil: - window.onFrame() + # Callbacks may close or create windows while this frame is being drawn. + let frameWindows = windows + for window in frameWindows: + let onFrame = window.onFrame + if not window.state.closed and onFrame != nil: + onFrame() # Clear all per-frame data for window in windows: diff --git a/tests/test_macos.nim b/tests/test_macos.nim index c1cb0ea..eaaaac7 100644 --- a/tests/test_macos.nim +++ b/tests/test_macos.nim @@ -32,6 +32,38 @@ when defined(macosx): testCpuImages() + proc testFrameClosures() = + ## Checks closing and creating windows during frame callbacks. + let + first = newWindow("First", ivec2(32, 32), visible = false) + second = newWindow("Second", ivec2(32, 32), visible = false) + third = newWindow("Third", ivec2(32, 32), visible = false) + var + child: Window + thirdFrames, childFrames: int + first.onFrame = proc() = + ## Closes the current and next windows and creates another window. + first.close() + second.close() + child = newWindow("Child", ivec2(32, 32), visible = false) + child.onFrame = proc() = + ## Counts frames for the newly created window. + inc childFrames + second.onFrame = proc() = + ## Rejects callbacks for a window closed earlier in this frame. + doAssert false, "Closed window received a frame" + third.onFrame = proc() = + ## Counts frames for the surviving window. + inc thirdFrames + pollEvents() + doAssert first.closed and second.closed + doAssert thirdFrames == 1 and childFrames == 0 + pollEvents() + doAssert thirdFrames == 2 and childFrames == 1 + third.close() + child.close() + + testFrameClosures() echo "Windy macOS regression tests passed" else: echo "Windy macOS regression tests skipped" From 5e49e948f9f57c456357f5b18ba0c4063b98f557 Mon Sep 17 00:00:00 2001 From: treeform Date: Sun, 6 Sep 2026 07:08:04 -0700 Subject: [PATCH 4/9] fix macOS custom cursor ownership --- src/windy/platforms/macos/platform.nim | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/windy/platforms/macos/platform.nim b/src/windy/platforms/macos/platform.nim index 9b74d1c..0532254 100644 --- a/src/windy/platforms/macos/platform.nim +++ b/src/windy/platforms/macos/platform.nim @@ -955,9 +955,13 @@ proc resetCursorRects(self: ID, cmd: SEL): ID {.cdecl.} = window.state.cursor.hotspot.x.float, window.state.cursor.hotspot.y.float ) + defer: + image.ID.release() NSCursor.alloc().initWithImage(image, hotspot) self.NSView.addCursorRect(self.NSView.bounds, cursor) + if window.state.cursor.kind == CustomCursor: + cursor.ID.release() proc drawRect(self: ID, cmd: SEL, dirtyRect: NSRect): ID {.cdecl.} = when defined(useCpu): From 0f0b874e97719726057cc15c63f9a15e5c7a6018 Mon Sep 17 00:00:00 2001 From: treeform Date: Sun, 6 Sep 2026 07:08:11 -0700 Subject: [PATCH 5/9] fix macOS window resource cleanup --- src/windy/platforms/macos/platform.nim | 13 +++++++++++++ tests/test_macos.nim | 24 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/windy/platforms/macos/platform.nim b/src/windy/platforms/macos/platform.nim index 0532254..48e6f9d 100644 --- a/src/windy/platforms/macos/platform.nim +++ b/src/windy/platforms/macos/platform.nim @@ -1275,6 +1275,13 @@ proc close*(window: Window) = if window.inner.int != 0: autoreleasepool: + window.inner.setDelegate(0.ID) + if window.trackingArea.int != 0: + window.inner.contentView.removeTrackingArea(window.trackingArea) + window.trackingArea.ID.release() + window.trackingArea = 0.NSTrackingArea + window.markedText.ID.release() + window.markedText = 0.NSString window.inner.close() let index = windows.indexForNSWindow(window.inner) @@ -1320,6 +1327,8 @@ proc newWindow*( let nativeView = WindyView.alloc().NSView.initWithFrame( result.inner.contentView.frame ) + defer: + nativeView.ID.release() result.inner.setDelegate(result.inner.ID) result.inner.setContentView(nativeView) discard result.inner.makeFirstResponder(nativeView) @@ -1344,11 +1353,15 @@ proc newWindow*( pixelFormat = NSOpenGLPixelFormat.alloc().initWithAttributes( pixelFormatAttribs[0].unsafeAddr ) + defer: + pixelFormat.ID.release() let openglView = WindyView.alloc().NSOpenGLView.initWithFrame( result.inner.contentView.frame, pixelFormat ) + defer: + openglView.ID.release() openglView.setWantsBestResolutionOpenGLSurface(true) openglView.openGLContext.makeCurrentContext() diff --git a/tests/test_macos.nim b/tests/test_macos.nim index eaaaac7..4f6b8ad 100644 --- a/tests/test_macos.nim +++ b/tests/test_macos.nim @@ -64,6 +64,30 @@ when defined(macosx): child.close() testFrameClosures() + + proc testWindowOwnership() = + ## Checks that closing releases the view and its tracking area. + let + window = newWindow("View ownership", ivec2(32, 32), visible = false) + view = window.inner.contentView.ID + view.retain() + defer: + view.release() + autoreleasepool: + discard updateTrackingAreas(view, s"updateTrackingAreas") + let tracking = window.trackingArea.ID + doAssert tracking.int != 0 + tracking.retain() + defer: + tracking.release() + window.close() + for i in 0 ..< 5: + drainEvents() + doAssert view.retainCount() == 1 + doAssert tracking.retainCount() == 1 + doAssert window.trackingArea.int == 0 + + testWindowOwnership() echo "Windy macOS regression tests passed" else: echo "Windy macOS regression tests skipped" From fdb608346f7ed87469566a209d568fd57b8152af Mon Sep 17 00:00:00 2001 From: treeform Date: Sun, 6 Sep 2026 07:09:39 -0700 Subject: [PATCH 6/9] fix macOS clipboard bitmap ownership --- src/windy/platforms/macos/platform.nim | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/windy/platforms/macos/platform.nim b/src/windy/platforms/macos/platform.nim index 48e6f9d..00d8038 100644 --- a/src/windy/platforms/macos/platform.nim +++ b/src/windy/platforms/macos/platform.nim @@ -1509,6 +1509,8 @@ proc getClipboardImage*(): Image = let bitmap = NSBitmapImageRep.alloc().initWithData(data) if bitmap.int == 0: return + defer: + bitmap.ID.release() let pngData = bitmap.representationUsingType( NSBitmapImageFileTypePNG, From 16318ac29ed65a045c1260974825b1d005eae975 Mon Sep 17 00:00:00 2001 From: treeform Date: Sun, 6 Sep 2026 07:09:45 -0700 Subject: [PATCH 7/9] drain all pending macOS keyboard events --- src/windy/platforms/macos/platform.nim | 6 +-- tests/test_macos.nim | 53 ++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/windy/platforms/macos/platform.nim b/src/windy/platforms/macos/platform.nim index 00d8038..dc8645d 100644 --- a/src/windy/platforms/macos/platform.nim +++ b/src/windy/platforms/macos/platform.nim @@ -1157,13 +1157,13 @@ proc drainEvents() = # - https://github.com/andlabs/ui/blob/bc848f5c4078b999dbe6ef1cd90e16290a0d1c3a/delegateuitask_darwin.m#L46 if event.`type`() == NSEventTypeKeyDown: processKeyDown(event) - break + continue elif event.`type`() == NSEventTypeKeyUp: processKeyUp(event) - break + continue elif event.`type`() == NSEventTypeFlagsChanged: processFlagsChanged(event) - break + continue # Forward event for app to handle. NSApp.sendEvent(event) diff --git a/tests/test_macos.nim b/tests/test_macos.nim index 4f6b8ad..f74d1c6 100644 --- a/tests/test_macos.nim +++ b/tests/test_macos.nim @@ -3,6 +3,21 @@ when defined(macosx): objc: proc retainCount(self: ID): uint + proc windowNumber(self: NSWindow): int + proc postEvent(self: NSApplication, x: NSEvent, atStart: bool) + proc keyEventWithType( + class: typedesc[NSEvent], + x: uint, + location: NSPoint, + modifierFlags: uint, + timestamp: float64, + windowNumber: int, + context: ID, + characters: NSString, + charactersIgnoringModifiers: NSString, + isARepeat: bool, + keyCode: uint16 + ): NSEvent when defined(useCpu): proc testCpuImages() = @@ -88,6 +103,44 @@ when defined(macosx): doAssert window.trackingArea.int == 0 testWindowOwnership() + + proc testKeyboardQueue() = + ## Checks that one poll delivers all queued key transitions in order. + let window = newWindow("Keyboard queue", ivec2(32, 32), visible = false) + defer: + window.close() + var presses, releases: seq[Button] + window.onButtonPress = proc(button: Button) = + ## Records press order. + presses.add(button) + window.onButtonRelease = proc(button: Button) = + ## Records release order. + releases.add(button) + autoreleasepool: + for keyCode in [0.uint16, 11, 8]: + for eventType in [10.uint, 11]: + let event = NSEvent.keyEventWithType( + eventType, + NSMakePoint(0, 0), + 0, + 0, + window.inner.windowNumber(), + 0.ID, + @"a", + @"a", + false, + keyCode + ) + NSApp.postEvent(event, false) + pollEvents() + doAssert presses == @[KeyA, KeyB, KeyC] + doAssert releases == presses + for button in presses: + doAssert window.buttonPressed[button] + doAssert window.buttonReleased[button] + doAssert not window.buttonDown[button] + + testKeyboardQueue() echo "Windy macOS regression tests passed" else: echo "Windy macOS regression tests skipped" From 0667a3765121ade73fc0b3cc88fefae91ce8f8e8 Mon Sep 17 00:00:00 2001 From: treeform Date: Sun, 6 Sep 2026 07:10:57 -0700 Subject: [PATCH 8/9] fix macOS Retina window centering --- src/windy/platforms/macos/platform.nim | 23 +++++++++++++---------- tests/test_macos.nim | 23 +++++++++++++++++++++++ 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/src/windy/platforms/macos/platform.nim b/src/windy/platforms/macos/platform.nim index dc8645d..74979f6 100644 --- a/src/windy/platforms/macos/platform.nim +++ b/src/windy/platforms/macos/platform.nim @@ -1213,15 +1213,18 @@ proc pollEvents*() = pollHttp() proc centerWindow(window: Window) = - ## Calculate centered position for a window on the primary screen. - let - screenFrame = window.inner.screen.frame - screenWidth = screenFrame.size.width.int - screenHeight = screenFrame.size.height.int - # Calculate center position. - x = screenFrame.origin.x.int + (screenWidth - window.size.x) div 2 - y = screenFrame.origin.y.int + (screenHeight - window.size.y) div 2 - window.pos = ivec2(x.int32, y.int32) + ## Centers the native window frame using screen point coordinates. + autoreleasepool: + let + screenFrame = window.inner.screen.frame + windowFrame = window.inner.frame + origin = NSMakePoint( + screenFrame.origin.x + + (screenFrame.size.width - windowFrame.size.width) / 2, + screenFrame.origin.y + + (screenFrame.size.height - windowFrame.size.height) / 2 + ) + window.inner.setFrameOrigin(origin) proc makeContextCurrent*(window: Window) = when defined(useMetal4) or defined(useCpu): @@ -1393,11 +1396,11 @@ proc newWindow*( result.title = title result.size = size + result.style = style # Center window on screen by default (macOS standard behavior). result.centerWindow() - result.style = style result.visible = visible result.minimizedState = result.inner.isMiniaturized diff --git a/tests/test_macos.nim b/tests/test_macos.nim index f74d1c6..62da950 100644 --- a/tests/test_macos.nim +++ b/tests/test_macos.nim @@ -141,6 +141,29 @@ when defined(macosx): doAssert not window.buttonDown[button] testKeyboardQueue() + + proc testWindowCenter() = + ## Checks that native frame centers agree on Retina and 1x screens. + for size in [ivec2(320, 200), ivec2(800, 600)]: + for style in [DecoratedResizable, Decorated, Undecorated]: + let window = newWindow( + "Centered window", + size, + style = style, + visible = false + ) + defer: + window.close() + let + frame = window.inner.frame + screen = window.inner.screen.frame + dx = frame.origin.x + frame.size.width / 2 - + (screen.origin.x + screen.size.width / 2) + dy = frame.origin.y + frame.size.height / 2 - + (screen.origin.y + screen.size.height / 2) + doAssert abs(dx) <= 1 and abs(dy) <= 1 + + testWindowCenter() echo "Windy macOS regression tests passed" else: echo "Windy macOS regression tests skipped" From 90d318a51683c90891d11ef9149482e7f21ceaf6 Mon Sep 17 00:00:00 2001 From: treeform Date: Sun, 6 Sep 2026 07:12:03 -0700 Subject: [PATCH 9/9] fix macOS mouse containment during drags --- src/windy/platforms/macos/platform.nim | 13 +++++++++---- tests/test_macos.nim | 25 +++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/windy/platforms/macos/platform.nim b/src/windy/platforms/macos/platform.nim index 74979f6..ccf833d 100644 --- a/src/windy/platforms/macos/platform.nim +++ b/src/windy/platforms/macos/platform.nim @@ -328,13 +328,18 @@ proc url*(window: Window): string = warn "Url cannot be gotten on macOS windows" proc handleMouseMove(window: Window, location: NSPoint) = + ## Updates pixel coordinates and containment from unrounded view points. let - x = round(location.x) - y = round(window.inner.contentView.bounds.size.height - location.y) + bounds = window.inner.contentView.bounds + x = location.x + y = bounds.size.height - location.y window.state.mousePrevPos = window.state.mousePos - window.state.mousePos = (vec2(x, y) * window.contentScale).ivec2 - window.state.mouseInside = true + window.state.mousePos = + (vec2(round(x), round(y)) * window.contentScale).ivec2 + window.state.mouseInside = + x >= 0 and x < bounds.size.width and + y >= 0 and y < bounds.size.height # Prevent a jump in the mouse delta when focusing a window. if window.state.hasPrevMouse: diff --git a/tests/test_macos.nim b/tests/test_macos.nim index 62da950..06fb35c 100644 --- a/tests/test_macos.nim +++ b/tests/test_macos.nim @@ -164,6 +164,31 @@ when defined(macosx): doAssert abs(dx) <= 1 and abs(dy) <= 1 testWindowCenter() + + proc testMouseInside() = + ## Checks containment for drags outside each edge and focus updates. + let + window = newWindow("Mouse bounds", ivec2(100, 100), visible = false) + bounds = window.inner.contentView.bounds + width = bounds.size.width + height = bounds.size.height + defer: + window.close() + for point in [ + NSMakePoint(-0.1, height / 2), + NSMakePoint(width, height / 2), + NSMakePoint(width / 2, height + 0.1), + NSMakePoint(width / 2, 0) + ]: + handleMouseMove(window, NSMakePoint(width / 2, height / 2)) + doAssert window.mouseInside + handleMouseMove(window, point) + doAssert not window.mouseInside + handleMouseMove(window, NSMakePoint(0, height)) + doAssert window.mouseInside + doAssert window.mousePos == ivec2(0, 0) + + testMouseInside() echo "Windy macOS regression tests passed" else: echo "Windy macOS regression tests skipped"