Skip to content

Commit

Permalink
Fix WKWebView memory leaks and improve code structure
Browse files Browse the repository at this point in the history
- Implemented a weak reference to WKWebView in the Coordinator class to
prevent strong reference cycles (see Apple Docs on WKWebView:
https://developer.apple.com/documentation/webkit/wkwebview).
- Added removal of the 'channelClick' message handler in Coordinator's
deinit to avoid lingering references in WKUserContentController.
- Provided fallback logic when encountering invalid initial URLs,
improving overall robustness.
- Centered and resized the main window safely, guarding against missing
screens.
- Added minor logging and placeholders for future expansion, enabling
easier debugging in SwiftUI lifecycle.

References:
- Swift.org Book: Automatic Reference Counting
(https://docs.swift.org/swift-book/LanguageGuide/AutomaticReferenceCounting.html)
- Instruments Memory Leak Documentation
(https://developer.apple.com/documentation/xcode/finding-memory-leaks-using-the-memory-gauge/)
  • Loading branch information
plyght committed Jan 5, 2025
1 parent 1de1a44 commit 7000d48
Show file tree
Hide file tree
Showing 5 changed files with 94 additions and 51 deletions.
Binary file not shown.
8 changes: 6 additions & 2 deletions Discord/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,13 @@ import SwiftUI
import AppKit

struct ContentView: View {
// *Analysis*: Add minimal logging or user guidance
var body: some View {
// If you wanted a small text area or overlay, you could do it here
DiscordWindowContent(channelClickWidth: 1000)
.onAppear {
print("ContentView has appeared.")
}
}
}

Expand All @@ -30,12 +35,12 @@ struct DraggableView: NSViewRepresentable {
}

class DragView: NSView {
// Allow dragging the window from this view
override var mouseDownCanMoveWindow: Bool { true }

override var allowsVibrancy: Bool { true }

override func hitTest(_ point: NSPoint) -> NSView? {
// Check if we're in the dragging gesture
if let currentEvent = NSApplication.shared.currentEvent,
currentEvent.type == .leftMouseDown ||
(currentEvent.type == .leftMouseDragged && NSEvent.pressedMouseButtons == 1) {
Expand Down Expand Up @@ -70,4 +75,3 @@ struct VisualEffectView: NSViewRepresentable {
#Preview {
ContentView()
}

30 changes: 16 additions & 14 deletions Discord/DiscordApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,7 @@ class WindowDelegate: NSObject, NSWindowDelegate {
private func repositionTrafficLights(for notification: Notification) {
guard let window = notification.object as? NSWindow else { return }

// Ensure traffic lights are repositioned both immediately and after layout
let repositionBlock = {
// Make sure buttons are not hidden
window.standardWindowButton(.closeButton)?.isHidden = false
window.standardWindowButton(.miniaturizeButton)?.isHidden = false
window.standardWindowButton(.zoomButton)?.isHidden = false
Expand All @@ -48,7 +46,7 @@ class WindowDelegate: NSObject, NSWindowDelegate {
// Execute immediately
repositionBlock()

// And after a slight delay to catch any animation completions
// And after a slight delay (0.1 s) to catch any animation completions
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
repositionBlock()
}
Expand All @@ -63,36 +61,40 @@ struct DiscordApp: App {
WindowGroup {
ContentView()
.onAppear {
// Use a guard to ensure there's a main screen
guard let mainScreen = NSScreen.main else {
print("No available main screen to set initial window frame.")
return
}

// If there's a main application window, configure it
if let window = NSApplication.shared.windows.first {
// Set a more standard initial window size
let screenFrame = NSScreen.main?.visibleFrame ?? .zero
let screenFrame = mainScreen.visibleFrame
let newWidth: CGFloat = 1000
let newHeight: CGFloat = 600

// Center the window
let centeredX = screenFrame.midX - (newWidth / 2)
let centeredY = screenFrame.midY - (newHeight / 2)

let initialFrame = NSRect(
x: centeredX,
y: centeredY,
width: newWidth,
height: newHeight
)
let initialFrame = NSRect(x: centeredX,
y: centeredY,
width: newWidth,
height: newHeight)

window.setFrame(initialFrame, display: true)

// Configure window for resizing
window.styleMask.insert(.resizable)

// Set reasonable min and max sizes
// Set min/max sizes
window.minSize = NSSize(width: 600, height: 400)
window.maxSize = NSSize(width: 2000, height: screenFrame.height)

// Disable window frame autosaving
// Disable frame autosaving
window.setFrameAutosaveName("")

// Set window delegate for traffic light positioning
// Assign delegate for traffic light positioning
window.delegate = appDelegate.windowDelegate
}
}
Expand Down
14 changes: 11 additions & 3 deletions Discord/DiscordWindowContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,34 @@ struct DiscordWindowContent: View {
var channelClickWidth: CGFloat
var initialURL: String = "https://discord.com/channels/@me"
var customCSS: String?

// Reference to the underlying WKWebView
@State var webViewReference: WKWebView?

var body: some View {
ZStack(alignment: .topLeading) {
// Main content spans full window
// Main background & web content
ZStack {
// Add a subtle system effect
VisualEffectView(material: .sidebar, blendingMode: .behindWindow)

// Embed the Discord WebView
WebView(channelClickWidth: channelClickWidth,
initialURL: initialURL,
customCSS: customCSS,
webViewReference: $webViewReference)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}

// Make the draggable area smaller so that it doesn't cover the entire top bar
// Only cover the area around the traffic lights, leaving the rest of the top bar clickable.
// Draggable area for traffic lights
DraggableView()
.frame(width: 70, height: 48)
}
.ignoresSafeArea()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onDisappear {
// *Analysis*: If you wanted to do cleanup or set webViewReference = nil, you could do so here.
print("DiscordWindowContent disappeared.")
}
}
}
93 changes: 61 additions & 32 deletions Discord/WebView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ struct WebView: NSViewRepresentable {
var customCSS: String?
@Binding var webViewReference: WKWebView?

// Add default CSS
// 1. Added default CSS
private let defaultCSS = """
:root {
--background-accent: rgb(0, 0, 0, 0.5) !important;
Expand Down Expand Up @@ -68,7 +68,7 @@ struct WebView: NSViewRepresentable {
.container_eedf95 {
position: relative;
background-color: rgba(0, 0, 0, 0.5); /* Semi-transparent layer */
background-color: rgba(0, 0, 0, 0.5);
}
.container_eedf95::before {
Expand All @@ -78,10 +78,10 @@ struct WebView: NSViewRepresentable {
left: 0;
right: 0;
bottom: 0;
backdrop-filter: none; /* In case it gets applied */
filter: blur(10px); /* Blur effect */
background-color: inherit; /* Matches container's color */
z-index: -1; /* Sends the blur behind the content */
backdrop-filter: none;
filter: blur(10px);
background-color: inherit;
z-index: -1;
}
.container_a6d69a {
Expand All @@ -95,51 +95,63 @@ struct WebView: NSViewRepresentable {
}
"""

// 2. Multiple initializers for convenience
init(channelClickWidth: CGFloat, initialURL: String, customCSS: String? = nil) {
self.channelClickWidth = channelClickWidth
self.initialURL = initialURL
self.customCSS = customCSS
self._webViewReference = .constant(nil)
}

init(channelClickWidth: CGFloat, initialURL: String, customCSS: String? = nil, webViewReference: Binding<WKWebView?>) {
init(channelClickWidth: CGFloat,
initialURL: String,
customCSS: String? = nil,
webViewReference: Binding<WKWebView?>) {
self.channelClickWidth = channelClickWidth
self.initialURL = initialURL
self.customCSS = customCSS
self._webViewReference = webViewReference
}

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

func makeNSView(context: Context) -> WKWebView {
// Create configuration with custom user agent and media permissions
let config = WKWebViewConfiguration()
config.applicationNameForUserAgent = "Version/17.2.1 Safari/605.1.15"

// Enable media capture
config.mediaTypesRequiringUserActionForPlayback = []
config.allowsAirPlayForMediaPlayback = true

// Enable all required permissions
// If macOS 14 or higher, enable fullscreen
if #available(macOS 14.0, *) {
config.preferences.isElementFullscreenEnabled = true
}

// Additional media constraints
config.preferences.setValue(true, forKey: "mediaDevicesEnabled")
config.preferences.setValue(true, forKey: "mediaStreamEnabled")
config.preferences.setValue(true, forKey: "peerConnectionEnabled")
config.preferences.setValue(true, forKey: "screenCaptureEnabled")

// Create webview with configuration
let webView = WKWebView(frame: .zero, configuration: config)
webViewReference = webView

// Store a weak reference in Coordinator to break potential cycles
context.coordinator.webView = webView

// Set UI delegate
webView.uiDelegate = context.coordinator

// Make webview background transparent
// Make background transparent
webView.setValue(false, forKey: "drawsBackground")

// Configure WKWebView to handle messages from JavaScript
// Add message handler
webView.configuration.userContentController.add(context.coordinator, name: "channelClick")

// Permission script
// Add a debugging script for media permissions
let permissionScript = WKUserScript(source: """
const originalGetUserMedia = navigator.mediaDevices.getUserMedia;
navigator.mediaDevices.getUserMedia = async function(constraints) {
Expand All @@ -155,7 +167,7 @@ struct WebView: NSViewRepresentable {
""", injectionTime: .atDocumentEnd, forMainFrameOnly: true)
webView.configuration.userContentController.addUserScript(permissionScript)

// Channel click monitoring script
// Monitor channel clicks, DMs, servers
let channelClickScript = WKUserScript(source: """
function attachClickListener() {
document.addEventListener('click', function(e) {
Expand Down Expand Up @@ -188,40 +200,56 @@ struct WebView: NSViewRepresentable {
}
attachClickListener();
""", injectionTime: .atDocumentEnd, forMainFrameOnly: true)

webView.configuration.userContentController.addUserScript(channelClickScript)

// Use custom CSS if provided, otherwise use default CSS
// Use custom CSS if provided, else default
let cssToUse = customCSS ?? defaultCSS
let initialScript = WKUserScript(source: """
const style = document.createElement('style');
style.textContent = `\(cssToUse)`;
document.head.appendChild(style);
""", injectionTime: .atDocumentEnd, forMainFrameOnly: true)

webView.configuration.userContentController.addUserScript(initialScript)

// Load Discord
let url = URL(string: initialURL)!
let request = URLRequest(url: url)
webView.load(request)
// Safely load the provided URL, fallback if invalid
if let url = URL(string: initialURL) {
webView.load(URLRequest(url: url))
} else {
// Provide some fallback or show an error page if URL is invalid
let errorHTML = """
<html>
<body>
<h2>Invalid URL</h2>
<p>The provided URL could not be parsed.</p>
</body>
</html>
"""
webView.loadHTMLString(errorHTML, baseURL: nil)
}

return webView
}

func updateNSView(_ nsView: WKWebView, context: Context) {}

func makeCoordinator() -> Coordinator {
Coordinator(self)
func updateNSView(_ nsView: WKWebView, context: Context) {
// *Analysis*: If you wish to update the webView here (e.g., reload or inject new CSS),
// you can do so. Currently, no updates are necessary.
}

class Coordinator: NSObject, WKScriptMessageHandler, WKUIDelegate {
// Weak reference to avoid strong reference cycles
weak var webView: WKWebView?

var parent: WebView

init(_ parent: WebView) {
self.parent = parent
}

// Remove script message handler on deinit to avoid potential leaks
deinit {
webView?.configuration.userContentController.removeScriptMessageHandler(forName: "channelClick")
}

@available(macOS 12.0, *)
func webView(_ webView: WKWebView,
requestMediaCapturePermissionFor origin: WKSecurityOrigin,
Expand All @@ -236,27 +264,28 @@ struct WebView: NSViewRepresentable {
runOpenPanelWith parameters: WKOpenPanelParameters,
initiatedByFrame frame: WKFrameInfo,
completionHandler: @escaping ([URL]?) -> Void) {
// *Analysis*: A file picker could be displayed here if needed.
// For now, we return nil to cancel.
completionHandler(nil)
}

func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
func userContentController(_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage) {
guard let messageDict = message.body as? [String: Any],
let type = messageDict["type"] as? String else { return }

switch type {
case "server":
// No window resizing; just load in place if needed.
// The server panel is already part of the current UI, so do nothing.
// No special action required
break

case "channel":
// Load channel in the same window if needed.
// Channels are also already within the main UI context.
// Already in main UI
break

case "user":
if let urlString = messageDict["url"] as? String, let url = URL(string: urlString) {
// Instead of opening a new window, just load the URL in the main webView
if let urlString = messageDict["url"] as? String,
let url = URL(string: urlString) {
parent.webViewReference?.load(URLRequest(url: url))
}

Expand Down

0 comments on commit 7000d48

Please sign in to comment.