Last active
March 25, 2025 15:05
-
-
Save abdusco/5bf371bac3043c78793d5b4d8d5af793 to your computer and use it in GitHub Desktop.
Show HTML as popup on Mac
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Cocoa | |
import WebKit | |
struct Options { | |
var html: String = "" | |
var url: URL? = nil | |
var title: String = "HTML Viewer" | |
var width: CGFloat = 800 | |
var height: CGFloat = 600 | |
var env: String = "{}" // Default empty JSON object | |
} | |
func readStdin() -> String { | |
var input = "" | |
while let line = readLine() { | |
input += line + "\n" | |
} | |
return input | |
} | |
func parseArguments() -> Options? { | |
var options = Options() | |
var args = Array(CommandLine.arguments.dropFirst()) | |
guard !args.isEmpty else { | |
print("Usage: htmlpopup <html_content_or_file|url|-) [--title title] [--width width] [--height height] [--env json_string]") | |
print("Use - to read HTML from stdin") | |
return nil | |
} | |
let firstArg = args.removeFirst() | |
if firstArg == "-" { | |
options.html = readStdin() | |
options.title = "stdin" | |
} else if FileManager.default.fileExists(atPath: firstArg) { | |
do { | |
options.html = try String(contentsOfFile: firstArg, encoding: .utf8) | |
options.title = (firstArg as NSString).lastPathComponent | |
} catch { | |
print("Error reading file: \(error)") | |
return nil | |
} | |
} else if let url = URL(string: firstArg) { | |
options.url = url | |
options.title = url.lastPathComponent | |
} else { | |
// If none of the above match, assume it's HTML | |
options.html = firstArg | |
} | |
while args.count >= 2 { | |
let flag = args.removeFirst() | |
let value = args.removeFirst() | |
switch flag { | |
case "--title": | |
options.title = value | |
case "--width": | |
if let width = Double(value) { | |
options.width = CGFloat(width) | |
} else { | |
print("Invalid width value: \(value)") | |
return nil | |
} | |
case "--height": | |
if let height = Double(value) { | |
options.height = CGFloat(height) | |
} else { | |
print("Invalid height value: \(value)") | |
return nil | |
} | |
case "--env": | |
// Verify that the JSON is valid | |
if let data = value.data(using: .utf8), | |
(try? JSONSerialization.jsonObject(with: data)) != nil { | |
options.env = value | |
} else { | |
print("Invalid JSON string for --env") | |
return nil | |
} | |
default: | |
print("Unknown option: \(flag)") | |
return nil | |
} | |
} | |
if !args.isEmpty { | |
print("Unpaired argument: \(args[0])") | |
return nil | |
} | |
return options | |
} | |
class WindowController: NSWindowController, NSWindowDelegate { | |
private var pinButton: NSButton! | |
public var isPinned: Bool { | |
get { | |
return window?.level == .floating | |
} | |
set { | |
window?.level = newValue ? .floating : .normal | |
updatePinButtonImage() | |
} | |
} | |
init(width: CGFloat, height: CGFloat, title: String) { | |
let window = NSWindow( | |
contentRect: NSRect(x: 0, y: 0, width: width, height: height), | |
styleMask: [.titled, .closable, .miniaturizable, .resizable], | |
backing: .buffered, | |
defer: false | |
) | |
window.title = title | |
window.level = .floating // Window starts as floating | |
window.center() | |
window.appearance = NSAppearance(named: .aqua) | |
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] | |
window.isReleasedWhenClosed = false | |
super.init(window: window) | |
window.delegate = self | |
setupPinButton() | |
} | |
required init?(coder: NSCoder) { | |
fatalError("init(coder:) has not been implemented") | |
} | |
private func setupPinButton() { | |
guard let window = self.window else { return } | |
pinButton = NSButton(frame: NSRect(x: 0, y: 0, width: 16, height: 16)) | |
pinButton.bezelStyle = .texturedRounded | |
pinButton.isBordered = false | |
pinButton.imagePosition = .imageOnly | |
pinButton.state = isPinned ? .on : .off // Set initial state based on window level | |
updatePinButtonImage() // Set initial image | |
pinButton.toolTip = "Keep window floating on top" | |
pinButton.target = self | |
pinButton.action = #selector(togglePin) | |
// Position the button in the titlebar | |
if let titlebarView = window.standardWindowButton(.closeButton)?.superview { | |
titlebarView.addSubview(pinButton) | |
if let closeButton = window.standardWindowButton(.closeButton) { | |
let margin: CGFloat = 6 | |
let pinButtonX = titlebarView.frame.width - pinButton.frame.width - margin | |
let pinButtonY = closeButton.frame.minY | |
pinButton.frame.origin = CGPoint(x: pinButtonX, y: pinButtonY) | |
pinButton.autoresizingMask = [.minXMargin] | |
} | |
} | |
} | |
private func updatePinButtonImage() { | |
let imageName = isPinned ? "pin.fill" : "pin" | |
pinButton.image = NSImage(systemSymbolName: imageName, accessibilityDescription: isPinned ? "Unpin Window" : "Pin Window") | |
} | |
@objc private func togglePin() { | |
isPinned.toggle() | |
} | |
override func keyDown(with event: NSEvent) { | |
if event.keyCode == 53 { // ESC key | |
// NSApplication.shared.terminate(nil) | |
} | |
} | |
} | |
class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKScriptMessageHandler { | |
var windowController: WindowController? | |
var webView: WKWebView? | |
var closeString: String? | |
func applicationDidFinishLaunching(_ notification: Notification) { | |
NSApp.setActivationPolicy(.regular) | |
// Create the menu bar | |
let menuBar = NSMenu() | |
NSApp.mainMenu = menuBar | |
// File menu | |
let fileMenuItem = NSMenuItem() | |
menuBar.addItem(fileMenuItem) | |
let fileMenu = NSMenu(title: "File") | |
fileMenuItem.submenu = fileMenu | |
fileMenu.addItem(NSMenuItem(title: "Close Window", | |
action: #selector(NSWindow.performClose(_:)), | |
keyEquivalent: "w")) | |
fileMenu.addItem(NSMenuItem.separator()) | |
fileMenu.addItem(NSMenuItem(title: "Quit", | |
action: #selector(NSApplication.terminate(_:)), | |
keyEquivalent: "q")) | |
// View menu | |
let viewMenuItem = NSMenuItem() | |
menuBar.addItem(viewMenuItem) | |
let viewMenu = NSMenu(title: "View") | |
viewMenuItem.submenu = viewMenu | |
viewMenu.addItem(NSMenuItem(title: "Toggle Web Inspector", | |
action: #selector(toggleWebInspector), | |
keyEquivalent: "i")) | |
viewMenu.items.last?.keyEquivalentModifierMask = [.command, .option] | |
// Add Edit menu | |
let editMenuItem = NSMenuItem() | |
menuBar.addItem(editMenuItem) | |
let editMenu = NSMenu(title: "Edit") | |
editMenuItem.submenu = editMenu | |
editMenu.addItem(NSMenuItem(title: "Undo", action: Selector(("undo:")), keyEquivalent: "z")) | |
editMenu.addItem(NSMenuItem(title: "Redo", action: Selector(("redo:")), keyEquivalent: "Z")) | |
editMenu.addItem(NSMenuItem.separator()) | |
editMenu.addItem(NSMenuItem(title: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x")) | |
editMenu.addItem(NSMenuItem(title: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")) | |
editMenu.addItem(NSMenuItem(title: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")) | |
editMenu.addItem(NSMenuItem(title: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")) | |
guard let options = parseArguments() else { | |
NSApplication.shared.terminate(nil) | |
return | |
} | |
windowController = WindowController( | |
width: options.width, | |
height: options.height, | |
title: options.title | |
) | |
// Crucial fix: Create the WKWebView *before* the windowController | |
let userContentController = WKUserContentController() | |
userContentController.add(self, name: "app") | |
setupUserScripts(userContentController: userContentController, envJson: options.env) | |
let config = WKWebViewConfiguration() | |
config.userContentController = userContentController | |
config.preferences.setValue(true, forKey: "developerExtrasEnabled") | |
webView = WKWebView(frame: windowController!.window!.contentView!.bounds, configuration: config) | |
webView?.autoresizingMask = [.width, .height] | |
webView?.navigationDelegate = self | |
if #available(macOS 10.14, *) { | |
webView?.setValue(false, forKey: "drawsBackground") | |
} | |
if let url = options.url { | |
webView?.load(URLRequest(url: url)) | |
} else if !options.html.isEmpty { | |
webView?.loadHTMLString(options.html, baseURL: nil) | |
} else { | |
print("No content to load.") | |
return | |
} | |
// Add the WKWebView to the contentView | |
windowController!.window!.contentView?.addSubview(webView!) | |
windowController!.showWindow(nil) | |
NSApp.activate(ignoringOtherApps: true) | |
windowController!.window!.makeKey() | |
windowController!.window!.orderFront(nil) | |
} | |
func webView(_ webView: WKWebView, | |
decidePolicyFor navigationAction: WKNavigationAction, | |
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { | |
if let url = navigationAction.request.url { | |
// Check if the URL scheme is different from http/https | |
if let scheme = url.scheme?.lowercased(), | |
scheme != "http" && scheme != "https" && scheme != "about" { | |
// Handle external protocol | |
if !NSWorkspace.shared.open(url) { | |
print("Failed to open URL: \(url)") | |
} | |
// Cancel the navigation in WebView | |
decisionHandler(.cancel) | |
return | |
} | |
} | |
// Allow normal http/https navigation | |
decisionHandler(.allow) | |
} | |
func setupUserScripts(userContentController: WKUserContentController, envJson: String = "{}") { | |
// First inject ENV | |
let envScript = WKUserScript( | |
source: "window.env = \(envJson);", | |
injectionTime: .atDocumentStart, | |
forMainFrameOnly: true | |
) | |
// Then inject app API | |
let appScript = WKUserScript( | |
source: """ | |
window.app = { | |
finish: function(message) { | |
window.webkit.messageHandlers.app.postMessage({ action: "finish", message: message }); | |
}, | |
setSize: function(width, height) { | |
window.webkit.messageHandlers.app.postMessage({ action: "setSize", width: width, height: height }); | |
}, | |
setFullscreen: function(enabled) { | |
window.webkit.messageHandlers.app.postMessage({ action: "setFullscreen", enabled: enabled }); | |
}, | |
setFloating: function(enabled) { | |
window.webkit.messageHandlers.app.postMessage({ action: "setFloating", enabled: enabled }); | |
}, | |
selectFolder: function() { | |
return new Promise((resolve, reject) => { | |
const callbackId = 'callback_' + Math.random().toString(36).substr(2, 9); | |
window[callbackId] = { | |
resolve: resolve, | |
reject: reject | |
}; | |
window.webkit.messageHandlers.app.postMessage({ | |
action: "selectFolder", | |
callbackId: callbackId | |
}); | |
}); | |
} | |
}; | |
""", | |
injectionTime: .atDocumentStart, | |
forMainFrameOnly: true | |
) | |
userContentController.addUserScript(envScript) | |
userContentController.addUserScript(appScript) | |
} | |
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { | |
if message.name == "app" { | |
if let messageBody = message.body as? [String: Any] { | |
switch messageBody["action"] as? String { | |
case "finish": | |
appFinish(messageBody["message"] as? String ?? "") | |
case "setSize": | |
if let width = messageBody["width"] as? CGFloat, let height = messageBody["height"] as? CGFloat { | |
appSetSize(width, height) | |
} | |
case "setFullscreen": | |
appSetFullscreen(messageBody["enabled"] as? Bool ?? false) | |
case "setFloating": | |
appSetFloating(messageBody["enabled"] as? Bool ?? false) | |
case "selectFolder": | |
if let callbackId = messageBody["callbackId"] as? String { | |
appSelectFolder(callbackId: callbackId) | |
} | |
default: | |
print("Unknown action: \(messageBody["action"] ?? "")") | |
} | |
} | |
} | |
} | |
func appSelectFolder(callbackId: String) { | |
let openPanel = NSOpenPanel() | |
openPanel.canChooseDirectories = true | |
openPanel.canChooseFiles = false | |
openPanel.allowsMultipleSelection = false | |
openPanel.level = .floating + 1 | |
openPanel.begin { [weak self] response in | |
guard let webView = self?.webView else { return } | |
if response == .OK { | |
if let url = openPanel.url { | |
let path = url.path | |
let jsCallback = """ | |
{ | |
const callback = window['\(callbackId)']; | |
callback.resolve('\(path)'); | |
delete window['\(callbackId)']; | |
} | |
""" | |
webView.evaluateJavaScript(jsCallback) | |
} | |
} else { | |
let jsCallback = """ | |
{ | |
const callback = window['\(callbackId)']; | |
callback.reject('Folder selection cancelled'); | |
delete window['\(callbackId)']; | |
} | |
""" | |
webView.evaluateJavaScript(jsCallback) | |
} | |
} | |
} | |
func applicationWillTerminate(_ aNotification: Notification) { | |
if let closeMessage = closeString { | |
print(closeMessage) | |
} | |
} | |
// JavaScript binding function | |
func appFinish(_ message: String) { | |
closeString = message | |
NSApplication.shared.terminate(nil) | |
} | |
func appSetSize(_ width: CGFloat, _ height: CGFloat) { | |
guard let window = windowController?.window else { return } | |
window.setContentSize(NSSize(width: width, height: height)) | |
} | |
func appSetFullscreen(_ enabled: Bool) { | |
guard let window = windowController?.window else { return } | |
window.toggleFullScreen(nil) | |
} | |
func appSetFloating(_ enabled: Bool) { | |
windowController?.isPinned.toggle() | |
} | |
@objc func toggleWebInspector(_ sender: Any?) { | |
webView?.toggleInspector(nil) | |
} | |
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { | |
return true | |
} | |
} | |
extension WKWebView { | |
func toggleInspector(_ sender: Any?) { | |
guard responds(to: Selector(("_inspector"))) else { return } | |
let inspector = value(forKey: "_inspector") as AnyObject | |
let selector = Selector(("show:")) | |
if inspector.responds(to: selector) { | |
_ = inspector.perform(selector, with: nil as Any?) | |
} | |
} | |
} | |
let app = NSApplication.shared | |
let delegate = AppDelegate() | |
app.delegate = delegate | |
app.run() |
Author
abdusco
commented
Dec 25, 2024
•
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment