Last active
April 23, 2025 15:02
-
-
Save nyteshade/9450d62a1d147191b74ba10ae4578e51 to your computer and use it in GitHub Desktop.
Launcher
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
<?xml version="1.0" encoding="UTF-8"?> | |
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |
<plist version="1.0"> | |
<dict> | |
<key>QEMU</key> | |
<string>qemu-system-ppc</string> | |
<key>Arguments</key> | |
<array> | |
<string>-M</string> | |
<string>mac99,via=pmu</string> | |
<string>-m</string> | |
<string>2048</string> | |
<string>-drive</string> | |
<string>file=$RESOURCES/disk.img,format=raw</string> | |
<string>-boot</string> | |
<string>c</string> | |
</array> | |
</dict> | |
</plist> |
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 Foundation | |
import SwiftUI | |
import AppKit | |
import UniformTypeIdentifiers | |
/// QEMULauncher: Reads QEMU options from a plist file and launches QEMU with those options | |
/// | |
/// This application is designed to be placed in App.app/Contents/MacOS/Launcher and will | |
/// read options from App.app/Contents/Resources/options.plist to launch QEMU. | |
class QEMULauncher: ObservableObject { | |
/// Path to the plist file containing QEMU options | |
let plistPath: String | |
/// Path to the Resources directory | |
let resourcesPath: String | |
/// Which qemu command to invoke with the supplied arguments. Defaults to | |
/// `qemu-system-ppc` but other variants can be specified instead. | |
var qemuSystemCmd: String = "qemu-system-ppc" | |
/// The running QEMU process | |
private var qemuProcess: Process? | |
/// The running Proxy process | |
private var proxyProcess: Process? | |
/// Process is running | |
@Published var isRunning: Bool = false | |
/// Proxy is running | |
@Published var isProxyRunning: Bool = false | |
/// Captured output from QEMU | |
@Published var capturedOutput: String = "" | |
/// Captured output from Proxy | |
@Published var proxyOutput: String = "" | |
/// Exit code from QEMU | |
@Published var exitCode: Int32 = 0 | |
/// Status message | |
@Published var statusMessage: String = "Initializing..." | |
/// Error message | |
@Published var errorMessage: String = "" | |
/// Initializes the launcher with paths to required resources | |
/// | |
/// - Parameters: | |
/// - plistPath: Path to the plist file containing QEMU options | |
/// - resourcesPath: Path to the Resources directory | |
init(plistPath: String, resourcesPath: String) { | |
self.plistPath = plistPath | |
self.resourcesPath = resourcesPath | |
} | |
/// Reads QEMU options from the plist file | |
/// | |
/// - Returns: Array of command line arguments | |
/// - Throws: Error if the plist cannot be read or parsed | |
func readOptionsFromPlist() throws -> [String] { | |
guard let plistData = FileManager.default.contents(atPath: plistPath) else { | |
throw NSError(domain: "QEMULauncher", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to read plist file at \(plistPath)"]) | |
} | |
var format = PropertyListSerialization.PropertyListFormat.xml | |
guard let plistDict = try PropertyListSerialization.propertyList(from: plistData, options: .mutableContainers, format: &format) as? [String: Any] else { | |
throw NSError(domain: "QEMULauncher", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to parse plist file as dictionary"]) | |
} | |
qemuSystemCmd = plistDict["QEMU"] as? String ?? "qemu-system-ppc" | |
// Get the command line arguments array | |
guard let arguments = plistDict["Arguments"] as? [String] else { | |
throw NSError(domain: "QEMULauncher", code: 3, userInfo: [NSLocalizedDescriptionKey: "No 'Arguments' array found in plist"]) | |
} | |
// Process arguments to replace any placeholders with full resource paths | |
return arguments.map { arg in | |
if arg.contains("$RESOURCES") { | |
return arg.replacingOccurrences(of: "$RESOURCES", with: resourcesPath) | |
} | |
return arg | |
} | |
} | |
/// Finds the full path to an executable by searching in PATH | |
/// | |
/// - Parameter command: The command to find | |
/// - Returns: The full path to the command, or nil if not found | |
private func findExecutablePath(forCommand command: String) -> String? { | |
// Check if the command is already a full path | |
if FileManager.default.fileExists(atPath: command) { | |
return command | |
} | |
// Common locations to check for QEMU | |
let commonLocations = [ | |
"/usr/local/bin/", | |
"/opt/homebrew/bin/", | |
"/usr/bin/", | |
"/bin/" | |
] | |
// Check common locations | |
for location in commonLocations { | |
let path = location + command | |
if FileManager.default.fileExists(atPath: path) { | |
return path | |
} | |
} | |
// Use which command | |
let process = Process() | |
process.executableURL = URL(fileURLWithPath: "/usr/bin/which") | |
process.arguments = [command] | |
let pipe = Pipe() | |
process.standardOutput = pipe | |
do { | |
try process.run() | |
process.waitUntilExit() | |
if process.terminationStatus == 0 { | |
let data = pipe.fileHandleForReading.readDataToEndOfFile() | |
if let path = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), !path.isEmpty { | |
return path | |
} | |
} | |
} catch { | |
print("Error finding path for \(command): \(error.localizedDescription)") | |
} | |
return nil | |
} | |
/// Launches the proxy server for old SSL compatibility | |
/// | |
/// - Throws: Error if the proxy server fails to launch | |
func launchProxyServer() throws { | |
print("DEBUG: Attempting to launch proxy server") | |
// Path to the proxy server binary in the Resources directory | |
let proxyPath = resourcesPath + "/oldssl-proxy" | |
// Check if the proxy server binary exists | |
if !FileManager.default.fileExists(atPath: proxyPath) { | |
print("DEBUG: oldssl-proxy binary not found, using script-based approach") | |
// Create a script to run squid if the binary doesn't exist | |
let scriptPath = resourcesPath + "/start-proxy.sh" | |
// Simple script to check and start squid if available | |
let scriptContent = """ | |
#!/bin/bash | |
export SQUID_CONFIG_DIR="\(resourcesPath)/squid" | |
export CERT_DIR="\(resourcesPath)/squid/ssl_cert" | |
echo "Starting proxy server script..." | |
echo "SQUID_CONFIG_DIR=$SQUID_CONFIG_DIR" | |
echo "CERT_DIR=$CERT_DIR" | |
# Check if squid is installed | |
if ! command -v squid &> /dev/null; then | |
echo "ERROR: Squid is not installed. Please install squid via homebrew: brew install squid" | |
exit 1 | |
fi | |
echo "Found squid in PATH" | |
# Create directories if they don't exist | |
echo "Creating config directories..." | |
mkdir -p "$SQUID_CONFIG_DIR" | |
mkdir -p "$CERT_DIR" | |
mkdir -p "$CERT_DIR/public" | |
# Generate SSL certificate if it doesn't exist | |
if [ ! -f "$CERT_DIR/myCA.pem" ]; then | |
echo "Generating SSL certificate..." | |
openssl req -new -newkey rsa:1024 -sha1 -days 1825 -nodes -x509 -extensions v3_ca \ | |
-subj '/C=AU/ST=Some-State/O=OldSSL Proxy' -keyout "$CERT_DIR/myCA.pem" \ | |
-out "$CERT_DIR/myCA.pem" -batch | |
openssl x509 -in "$CERT_DIR/myCA.pem" -outform DER -out "$CERT_DIR/public/OldSSL.der" | |
openssl x509 -in "$CERT_DIR/myCA.pem" -outform PEM -out "$CERT_DIR/public/OldSSL.crt" | |
else | |
echo "SSL certificate already exists" | |
fi | |
# Create squid config file | |
echo "Creating squid configuration..." | |
cat > "$SQUID_CONFIG_DIR/squid.conf" << 'EOL' | |
http_port 3128 ssl-bump \ | |
cert=$CERT_DIR/myCA.pem \ | |
cipher=HIGH:MEDIUM:!LOW:!aNULL:!eNULL:!MD5:!EXP:!PSK:!SRP:!DSS \ | |
options=NO_TICKET,ALL \ | |
generate-host-certificates=on dynamic_cert_mem_cache_size=4MB | |
visible_hostname squid-oldssl-proxy | |
ssl_bump bump all | |
tcp_outgoing_address 0.0.0.0 | |
sslproxy_cert_sign_hash sha1 | |
http_access allow all | |
EOL | |
# Replace $CERT_DIR with actual path | |
echo "Updating paths in config..." | |
sed -i.bak "s|\\$CERT_DIR|$CERT_DIR|g" "$SQUID_CONFIG_DIR/squid.conf" | |
# Start squid | |
echo "Starting squid proxy server..." | |
squid -f "$SQUID_CONFIG_DIR/squid.conf" -N | |
""" | |
// Write the script to the file | |
do { | |
try scriptContent.write(toFile: scriptPath, atomically: true, encoding: String.Encoding.utf8) | |
try FileManager.default.setAttributes([FileAttributeKey.posixPermissions: 0o755], ofItemAtPath: scriptPath) | |
print("DEBUG: Created proxy script at \(scriptPath)") | |
} catch { | |
print("DEBUG: Failed to create proxy script: \(error.localizedDescription)") | |
throw NSError(domain: "QEMULauncher", code: 6, userInfo: [NSLocalizedDescriptionKey: "Failed to create proxy script: \(error.localizedDescription)"]) | |
} | |
// Create the process | |
let process = Process() | |
process.executableURL = URL(fileURLWithPath: "/bin/bash") | |
process.arguments = [scriptPath] | |
// Set up pipes for standard output and error | |
let outputPipe = Pipe() | |
let errorPipe = Pipe() | |
process.standardOutput = outputPipe | |
process.standardError = errorPipe | |
// Set up asynchronous reading of output and error | |
let outputHandle = outputPipe.fileHandleForReading | |
let errorHandle = errorPipe.fileHandleForReading | |
// Setup output handling | |
outputHandle.readabilityHandler = { [weak self] handle in | |
guard let self = self else { return } | |
let data = handle.availableData | |
if !data.isEmpty, let output = String(data: data, encoding: .utf8) { | |
DispatchQueue.main.async { | |
self.proxyOutput += output | |
print("PROXY OUTPUT: \(output)") | |
} | |
} | |
} | |
// Setup error handling | |
errorHandle.readabilityHandler = { [weak self] handle in | |
guard let self = self else { return } | |
let data = handle.availableData | |
if !data.isEmpty, let output = String(data: data, encoding: .utf8) { | |
DispatchQueue.main.async { | |
self.proxyOutput += "ERROR: " + output | |
print("PROXY ERROR: \(output)") | |
} | |
} | |
} | |
// Set up process termination handler | |
process.terminationHandler = { [weak self] process in | |
guard let self = self else { return } | |
// Clean up file handles | |
outputHandle.readabilityHandler = nil | |
errorHandle.readabilityHandler = nil | |
DispatchQueue.main.async { | |
self.isProxyRunning = false | |
print("DEBUG: Proxy server exited with code: \(process.terminationStatus)") | |
} | |
} | |
// Start the process | |
do { | |
print("DEBUG: Launching proxy script") | |
try process.run() | |
self.proxyProcess = process | |
self.isProxyRunning = true | |
print("DEBUG: Proxy server started on port 3128") | |
} catch { | |
print("DEBUG: Failed to launch proxy server: \(error.localizedDescription)") | |
throw NSError(domain: "QEMULauncher", code: 7, userInfo: [NSLocalizedDescriptionKey: "Failed to launch proxy server: \(error.localizedDescription)"]) | |
} | |
} else { | |
print("DEBUG: Using pre-compiled oldssl-proxy binary") | |
// Use pre-compiled binary | |
let process = Process() | |
process.executableURL = URL(fileURLWithPath: proxyPath) | |
// Set up pipes for standard output and error | |
let outputPipe = Pipe() | |
let errorPipe = Pipe() | |
process.standardOutput = outputPipe | |
process.standardError = errorPipe | |
// Set up output handler | |
let outputHandle = outputPipe.fileHandleForReading | |
outputHandle.readabilityHandler = { [weak self] handle in | |
guard let self = self else { return } | |
let data = handle.availableData | |
if !data.isEmpty, let output = String(data: data, encoding: .utf8) { | |
DispatchQueue.main.async { | |
self.proxyOutput += output | |
print("PROXY OUTPUT: \(output)") | |
} | |
} | |
} | |
// Set up error handler | |
let errorHandle = errorPipe.fileHandleForReading | |
errorHandle.readabilityHandler = { [weak self] handle in | |
guard let self = self else { return } | |
let data = handle.availableData | |
if !data.isEmpty, let output = String(data: data, encoding: .utf8) { | |
DispatchQueue.main.async { | |
self.proxyOutput += "ERROR: " + output | |
print("PROXY ERROR: \(output)") | |
} | |
} | |
} | |
// Set up termination handler | |
process.terminationHandler = { [weak self] process in | |
guard let self = self else { return } | |
// Clean up file handles | |
outputHandle.readabilityHandler = nil | |
errorHandle.readabilityHandler = nil | |
DispatchQueue.main.async { | |
self.isProxyRunning = false | |
print("DEBUG: Binary proxy server exited with code: \(process.terminationStatus)") | |
} | |
} | |
// Start the process | |
do { | |
print("DEBUG: Launching binary proxy") | |
try process.run() | |
self.proxyProcess = process | |
self.isProxyRunning = true | |
print("DEBUG: Binary proxy server started from binary on port 3128") | |
} catch { | |
print("DEBUG: Failed to launch proxy server binary: \(error.localizedDescription)") | |
throw NSError(domain: "QEMULauncher", code: 7, userInfo: [NSLocalizedDescriptionKey: "Failed to launch proxy server binary: \(error.localizedDescription)"]) | |
} | |
} | |
} | |
/// Terminates the proxy server if it's running | |
func terminateProxyServer() { | |
guard let process = proxyProcess, isProxyRunning else { return } | |
process.terminate() | |
isProxyRunning = false | |
} | |
/// Launches QEMU with the specified arguments | |
/// | |
/// - Parameters: | |
/// - arguments: Array of command line arguments | |
/// - outputCallback: Optional callback to receive output from QEMU | |
/// - Throws: Error if QEMU fails to launch | |
func launchQEMU(with arguments: [String]) throws { | |
guard !isRunning else { | |
throw NSError(domain: "QEMULauncher", code: 5, userInfo: [NSLocalizedDescriptionKey: "QEMU is already running"]) | |
} | |
// Create a process | |
let process = Process() | |
// Get the path to QEMU executable from PATH or use a default path | |
let qemuPath = findExecutablePath(forCommand: self.qemuSystemCmd) | |
if qemuPath != nil { | |
process.executableURL = URL(fileURLWithPath: qemuPath!) | |
process.arguments = arguments | |
} else { | |
// Fallback to using env if we can't find the executable directly | |
process.executableURL = URL(fileURLWithPath: "/usr/bin/env") | |
// Combine the QEMU command with its arguments | |
var fullArguments = [self.qemuSystemCmd] | |
fullArguments.append(contentsOf: arguments) | |
process.arguments = fullArguments | |
} | |
// Set up environment variables | |
var environment = ProcessInfo.processInfo.environment | |
// Ensure PATH includes common locations where QEMU might be installed | |
if var path = environment["PATH"] { | |
if !path.contains("/usr/local/bin") { | |
path = "/usr/local/bin:" + path | |
} | |
if !path.contains("/opt/homebrew/bin") { | |
path = "/opt/homebrew/bin:" + path | |
} | |
environment["PATH"] = path | |
} else { | |
environment["PATH"] = "/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/homebrew/bin" | |
} | |
// Add proxy-related environment variables if the proxy is running | |
if isProxyRunning { | |
environment["http_proxy"] = "http://localhost:3128" | |
environment["https_proxy"] = "http://localhost:3128" | |
environment["HTTP_PROXY"] = "http://localhost:3128" | |
environment["HTTPS_PROXY"] = "http://localhost:3128" | |
} | |
process.environment = environment | |
// Set up pipes for standard output and error | |
let outputPipe = Pipe() | |
let errorPipe = Pipe() | |
process.standardOutput = outputPipe | |
process.standardError = errorPipe | |
// Set up asynchronous reading of output and error | |
let outputHandle = outputPipe.fileHandleForReading | |
let errorHandle = errorPipe.fileHandleForReading | |
// Setup output handling | |
outputHandle.readabilityHandler = { [weak self] handle in | |
guard let self = self else { return } | |
let data = handle.availableData | |
if !data.isEmpty, let output = String(data: data, encoding: .utf8) { | |
DispatchQueue.main.async { | |
self.capturedOutput += output | |
} | |
} | |
} | |
// Setup error handling | |
errorHandle.readabilityHandler = { [weak self] handle in | |
guard let self = self else { return } | |
let data = handle.availableData | |
if !data.isEmpty, let output = String(data: data, encoding: .utf8) { | |
DispatchQueue.main.async { | |
self.capturedOutput += "ERROR: " + output | |
} | |
} | |
} | |
// Set up process termination handler | |
process.terminationHandler = { [weak self] process in | |
guard let self = self else { return } | |
// Clean up file handles | |
outputHandle.readabilityHandler = nil | |
errorHandle.readabilityHandler = nil | |
DispatchQueue.main.async { | |
self.isRunning = false | |
self.exitCode = process.terminationStatus | |
self.statusMessage = "QEMU exited with code: \(process.terminationStatus)" | |
print("QEMU exited with code: \(process.terminationStatus)") | |
// Terminate the proxy server when QEMU exits | |
self.terminateProxyServer() | |
} | |
} | |
// Start the process | |
do { | |
self.statusMessage = "Launching QEMU..." | |
print("Launching QEMU at path: \(process.executableURL?.path ?? "unknown")") | |
try process.run() | |
self.qemuProcess = process | |
self.isRunning = true | |
self.statusMessage = "QEMU is running..." | |
} catch { | |
self.statusMessage = "Failed to launch QEMU" | |
self.errorMessage = error.localizedDescription | |
throw NSError(domain: "QEMULauncher", code: 4, userInfo: [NSLocalizedDescriptionKey: "Failed to launch QEMU: \(error.localizedDescription)"]) | |
} | |
} | |
/// Terminates the QEMU process if it's running | |
func terminateQEMU() { | |
guard let process = qemuProcess, isRunning else { return } | |
process.terminate() | |
// The terminationHandler will update isRunning and exitCode | |
} | |
/// Asks the user for confirmation before terminating QEMU | |
/// | |
/// - Parameter completionHandler: Called with true if the user confirms, false otherwise | |
func confirmTerminateQEMU(completionHandler: @escaping (Bool) -> Void) { | |
guard isRunning else { | |
completionHandler(true) | |
return | |
} | |
let alert = NSAlert() | |
alert.messageText = "QEMU is still running" | |
alert.informativeText = "Do you want to terminate QEMU and quit?" | |
alert.alertStyle = .warning | |
alert.addButton(withTitle: "Terminate and Quit") | |
alert.addButton(withTitle: "Cancel") | |
let response = alert.runModal() | |
if response == .alertFirstButtonReturn { | |
terminateQEMU() | |
completionHandler(true) | |
} else { | |
completionHandler(false) | |
} | |
} | |
/// Main entry point for the launcher | |
/// | |
/// - Throws: Error if any step fails | |
func run() throws { | |
do { | |
// First, try to launch the proxy server | |
try launchProxyServer() | |
// Then, launch QEMU | |
let arguments = try readOptionsFromPlist() | |
print("Launching QEMU with arguments: \(arguments.joined(separator: " "))") | |
try launchQEMU(with: arguments) | |
} catch { | |
errorMessage = error.localizedDescription | |
print("Error: \(error.localizedDescription)") | |
throw error | |
} | |
} | |
} | |
/// The main content view for the QEMU launcher | |
struct QEMULauncherView: NSViewRepresentable { | |
@ObservedObject var launcher: QEMULauncher | |
func makeNSView(context: Context) -> NSScrollView { | |
// Create a text field for output | |
let textView = NSTextView() | |
textView.isEditable = false | |
textView.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) | |
textView.textColor = NSColor.textColor | |
textView.backgroundColor = NSColor.textBackgroundColor | |
textView.drawsBackground = true | |
textView.isAutomaticQuoteSubstitutionEnabled = false | |
textView.isAutomaticLinkDetectionEnabled = true | |
textView.isAutomaticTextCompletionEnabled = false | |
textView.isAutomaticTextReplacementEnabled = false | |
textView.isAutomaticDashSubstitutionEnabled = false | |
textView.isAutomaticSpellingCorrectionEnabled = false | |
// Create a scroll view to contain the text view | |
let scrollView = NSScrollView() | |
scrollView.documentView = textView | |
scrollView.hasVerticalScroller = true | |
scrollView.hasHorizontalScroller = true | |
scrollView.autohidesScrollers = true | |
return scrollView | |
} | |
func updateNSView(_ scrollView: NSScrollView, context: Context) { | |
guard let textView = scrollView.documentView as? NSTextView else { return } | |
// Update the text view content | |
textView.string = launcher.capturedOutput | |
// Scroll to the bottom | |
textView.scrollToEndOfDocument(nil) | |
} | |
} | |
/// The app delegate to handle application lifecycle events | |
class AppDelegate: NSObject, NSApplicationDelegate { | |
var launcher: QEMULauncher! | |
var window: NSWindow! | |
var statusTextField: NSTextField! | |
var errorTextField: NSTextField! | |
var contentView: NSView! | |
var terminateButton: NSButton! | |
var proxyToggleButton: NSButton! | |
// Timer for UI updates | |
private var uiTimer: Timer? | |
/// Checks if either squid or oldssl-proxy is available | |
@objc func checkProxyAvailable() -> Bool { | |
// Safety check - if launcher is not initialized, return false | |
guard launcher != nil else { | |
print("DEBUG: checkProxyAvailable called but launcher is nil") | |
return false | |
} | |
print("DEBUG: Checking for proxy in \(launcher.resourcesPath)") | |
// Check for oldssl-proxy binary in Resources | |
let proxyPath = launcher.resourcesPath + "/oldssl-proxy" | |
if FileManager.default.fileExists(atPath: proxyPath) { | |
print("DEBUG: Found oldssl-proxy binary at \(proxyPath)") | |
return true | |
} else { | |
print("DEBUG: oldssl-proxy binary not found at \(proxyPath)") | |
} | |
// Check for squid in PATH | |
print("DEBUG: Checking for squid in PATH") | |
// Create process safely | |
let process = Process() | |
process.executableURL = URL(fileURLWithPath: "/usr/bin/which") | |
process.arguments = ["squid"] | |
let pipe = Pipe() | |
process.standardOutput = pipe | |
do { | |
try process.run() | |
process.waitUntilExit() | |
if process.terminationStatus == 0 { | |
let data = pipe.fileHandleForReading.readDataToEndOfFile() | |
if let path = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), !path.isEmpty { | |
print("DEBUG: Found squid at \(path)") | |
return true | |
} | |
} else { | |
print("DEBUG: squid not found in PATH (exit code \(process.terminationStatus))") | |
} | |
} catch { | |
print("DEBUG: Error checking for squid: \(error.localizedDescription)") | |
} | |
print("DEBUG: No proxy server found") | |
return false | |
} | |
/// Terminates QEMU | |
@objc func terminateQEMU() { | |
launcher.terminateQEMU() | |
} | |
/// Toggles the proxy server from the button | |
@objc func toggleProxyButton(_ sender: NSButton) { | |
if launcher.isProxyRunning { | |
launcher.terminateProxyServer() | |
sender.state = .off | |
} else { | |
do { | |
try launcher.launchProxyServer() | |
sender.state = .on | |
} catch { | |
let alert = NSAlert() | |
alert.messageText = "Failed to Start Proxy" | |
alert.informativeText = error.localizedDescription | |
alert.alertStyle = .warning | |
alert.addButton(withTitle: "OK") | |
alert.runModal() | |
} | |
} | |
// Update the proxy menu item state to match | |
updateUI() | |
} | |
/// Toggles the proxy server from menu | |
@objc func toggleProxy(_ sender: NSMenuItem) { | |
if launcher.isProxyRunning { | |
launcher.terminateProxyServer() | |
print("Terminating proxy server from menu item") | |
} else { | |
do { | |
try launcher.launchProxyServer() | |
print("Launching proxy server from menu item") | |
} catch { | |
let alert = NSAlert() | |
alert.messageText = "Failed to Start Proxy" | |
alert.informativeText = error.localizedDescription | |
alert.alertStyle = .warning | |
alert.addButton(withTitle: "OK") | |
alert.runModal() | |
} | |
} | |
// Update UI to reflect changes | |
DispatchQueue.main.async { | |
self.updateUI() | |
} | |
} | |
/// Exports the SSL certificate | |
@objc func exportCertificate(_ sender: NSMenuItem) { | |
// Path to the certificate | |
let certPath = launcher.resourcesPath + "/squid/ssl_cert/public/OldSSL.crt" | |
let derPath = launcher.resourcesPath + "/squid/ssl_cert/public/OldSSL.der" | |
// Check if the certificate exists | |
if !FileManager.default.fileExists(atPath: certPath) && !FileManager.default.fileExists(atPath: derPath) { | |
let alert = NSAlert() | |
alert.messageText = "Certificate Not Found" | |
alert.informativeText = "The SSL certificate has not been generated yet." | |
alert.alertStyle = .warning | |
alert.addButton(withTitle: "OK") | |
alert.runModal() | |
return | |
} | |
// Create save panel | |
let savePanel = NSSavePanel() | |
savePanel.title = "Export SSL Certificate" | |
savePanel.nameFieldStringValue = "OldSSL" | |
// Use modern UTType approach | |
if #available(macOS 11.0, *) { | |
savePanel.allowedContentTypes = [.data] | |
} else { | |
// Fallback for older macOS versions | |
savePanel.allowedFileTypes = ["crt", "der"] | |
} | |
savePanel.allowsOtherFileTypes = true | |
savePanel.isExtensionHidden = false | |
// Show save panel | |
savePanel.beginSheetModal(for: window) { response in | |
if response == .OK, let url = savePanel.url { | |
do { | |
if FileManager.default.fileExists(atPath: certPath) { | |
try FileManager.default.copyItem(atPath: certPath, toPath: url.path) | |
} else if FileManager.default.fileExists(atPath: derPath) { | |
try FileManager.default.copyItem(atPath: derPath, toPath: url.path) | |
} | |
// Show success dialog | |
let alert = NSAlert() | |
alert.messageText = "Certificate Exported" | |
alert.informativeText = "The certificate has been exported. You can now import it into your operating system." | |
alert.alertStyle = .informational | |
alert.addButton(withTitle: "OK") | |
alert.runModal() | |
} catch { | |
// Show error dialog | |
let alert = NSAlert() | |
alert.messageText = "Export Failed" | |
alert.informativeText = error.localizedDescription | |
alert.alertStyle = .warning | |
alert.addButton(withTitle: "OK") | |
alert.runModal() | |
} | |
} | |
} | |
} | |
/// Shows proxy information | |
@objc func showProxyInfo(_ sender: NSMenuItem) { | |
// Create info window with proper ownership | |
let infoWindow = NSWindow( | |
contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), | |
styleMask: [.titled, .closable], | |
backing: .buffered, | |
defer: false | |
) | |
infoWindow.title = "Proxy Information" | |
// Make window automatically release when closed | |
infoWindow.isReleasedWhenClosed = true | |
// Create text view | |
let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: 380, height: 280)) | |
textView.isEditable = false | |
textView.font = NSFont.systemFont(ofSize: 12) | |
textView.textContainerInset = NSSize(width: 10, height: 10) | |
// Prepare info text | |
var infoText = "Proxy Status: \(launcher.isProxyRunning ? "Running" : "Not Running")\n\n" | |
infoText += "Proxy Address: localhost\n" | |
infoText += "Proxy Port: 3128\n\n" | |
infoText += "Configuration:\n" | |
infoText += "- Set HTTP Proxy to localhost:3128\n" | |
infoText += "- Set HTTPS Proxy to localhost:3128\n\n" | |
infoText += "This proxy enables older versions of Mac OS X to access modern HTTPS websites by downgrading SSL/TLS protocols and adding support for older cipher suites. You must import the certificate into your system keychain to avoid certificate warnings." | |
// Set text | |
textView.string = infoText | |
// Create scroll view | |
let scrollView = NSScrollView(frame: NSRect(x: 10, y: 10, width: 380, height: 280)) | |
scrollView.documentView = textView | |
scrollView.hasVerticalScroller = true | |
scrollView.hasHorizontalScroller = false | |
scrollView.autoresizingMask = [.width, .height] | |
// Add to window | |
infoWindow.contentView?.addSubview(scrollView) | |
// Center and show window | |
infoWindow.center() | |
infoWindow.makeKeyAndOrderFront(nil) | |
} | |
/// Updates the UI based on launcher state | |
@objc func updateUI() { | |
// Update text fields | |
statusTextField.stringValue = launcher.statusMessage | |
errorTextField.stringValue = launcher.errorMessage | |
terminateButton.isHidden = !launcher.isRunning | |
// Check proxy availability | |
let proxyAvailable = checkProxyAvailable() | |
// Update proxy toggle button | |
proxyToggleButton.isEnabled = proxyAvailable | |
proxyToggleButton.state = launcher.isProxyRunning ? .on : .off | |
// Update proxy menu if it exists | |
if let mainMenu = NSApplication.shared.mainMenu { | |
// Find the proxy menu | |
for menuItem in mainMenu.items { | |
if menuItem.title == "Proxy", let proxyMenu = menuItem.submenu { | |
// Update Enable Proxy item | |
if let enableProxyItem = proxyMenu.items.first(where: { $0.title == "Enable Old SSL Proxy" }) { | |
enableProxyItem.isEnabled = proxyAvailable | |
enableProxyItem.state = launcher.isProxyRunning ? .on : .off | |
} | |
// Update Export Certificate item | |
if let exportCertItem = proxyMenu.items.first(where: { $0.title == "Export SSL Certificate..." }) { | |
exportCertItem.isEnabled = launcher.isProxyRunning | |
} | |
break | |
} | |
} | |
} | |
// Debug output | |
print("UI updated: Proxy running: \(launcher.isProxyRunning), Proxy available: \(proxyAvailable)") | |
} | |
func applicationDidFinishLaunching(_ notification: Notification) { | |
do { | |
// Configure the application menu first (without relying on launcher) | |
setupMenus() | |
// Determine the bundle and resource paths | |
let bundleURL = Bundle.main.bundleURL | |
let resourcesPath = bundleURL.appendingPathComponent("Contents/Resources").path | |
let plistPath = resourcesPath + "/options.plist" | |
// Check if plist file exists | |
if !FileManager.default.fileExists(atPath: plistPath) { | |
throw NSError(domain: "QEMULauncher", code: 100, | |
userInfo: [NSLocalizedDescriptionKey: "Options file not found: \(plistPath). Make sure options.plist exists in the app's Resources directory."]) | |
} | |
// Create the launcher | |
launcher = QEMULauncher(plistPath: plistPath, resourcesPath: resourcesPath) | |
// Create the window | |
window = NSWindow( | |
contentRect: NSRect(x: 0, y: 0, width: 600, height: 400), | |
styleMask: [.titled, .closable, .miniaturizable, .resizable], | |
backing: .buffered, | |
defer: false | |
) | |
window.title = "QEMU Launcher" | |
window.center() | |
window.minSize = NSSize(width: 500, height: 300) | |
// Create content view | |
contentView = NSView(frame: window.contentView!.bounds) | |
contentView.autoresizingMask = [.width, .height] | |
window.contentView = contentView | |
// Create status text field | |
statusTextField = NSTextField(frame: NSRect(x: 20, y: window.contentView!.bounds.height - 60, width: window.contentView!.bounds.width - 40, height: 30)) | |
statusTextField.isEditable = false | |
statusTextField.isBordered = false | |
statusTextField.backgroundColor = .clear | |
statusTextField.alignment = .center | |
statusTextField.font = NSFont.systemFont(ofSize: 14, weight: .bold) | |
statusTextField.stringValue = "Initializing..." | |
statusTextField.autoresizingMask = [.width, .maxYMargin] | |
contentView.addSubview(statusTextField) | |
// Create error text field | |
errorTextField = NSTextField(frame: NSRect(x: 20, y: window.contentView!.bounds.height - 90, width: window.contentView!.bounds.width - 40, height: 20)) | |
errorTextField.isEditable = false | |
errorTextField.isBordered = false | |
errorTextField.backgroundColor = .clear | |
errorTextField.alignment = .center | |
errorTextField.textColor = .red | |
errorTextField.font = NSFont.systemFont(ofSize: 12) | |
errorTextField.stringValue = "" | |
errorTextField.autoresizingMask = [.width, .maxYMargin] | |
contentView.addSubview(errorTextField) | |
// Create output view | |
let outputView = NSHostingView(rootView: QEMULauncherView(launcher: launcher)) | |
outputView.frame = NSRect(x: 20, y: 50, width: window.contentView!.bounds.width - 40, height: window.contentView!.bounds.height - 150) | |
outputView.autoresizingMask = [.width, .height] | |
contentView.addSubview(outputView) | |
// Create terminate button | |
terminateButton = NSButton(frame: NSRect(x: window.contentView!.bounds.width / 2 - 120, y: 15, width: 120, height: 24)) | |
terminateButton.title = "Terminate QEMU" | |
terminateButton.bezelStyle = .rounded | |
terminateButton.target = self | |
terminateButton.action = #selector(terminateQEMU) | |
terminateButton.isHidden = true | |
terminateButton.autoresizingMask = [NSView.AutoresizingMask.minXMargin, NSView.AutoresizingMask.maxXMargin, NSView.AutoresizingMask.maxYMargin] | |
contentView.addSubview(terminateButton) | |
// Create proxy toggle button | |
proxyToggleButton = NSButton(frame: NSRect(x: window.contentView!.bounds.width / 2 + 5, y: 15, width: 120, height: 24)) | |
proxyToggleButton.title = "Toggle Proxy" | |
proxyToggleButton.bezelStyle = .rounded | |
proxyToggleButton.target = self | |
proxyToggleButton.action = #selector(toggleProxyButton(_:)) | |
proxyToggleButton.isEnabled = false // Will enable in updateUI if proxy is available | |
proxyToggleButton.autoresizingMask = [NSView.AutoresizingMask.minXMargin, NSView.AutoresizingMask.maxXMargin, NSView.AutoresizingMask.maxYMargin] | |
contentView.addSubview(proxyToggleButton) | |
// Set the window delegate to handle close events | |
window.delegate = self | |
// Show the window | |
window.makeKeyAndOrderFront(nil) | |
// Now that everything is initialized, update the UI which will check proxy availability | |
updateUI() | |
// Launch QEMU in a background thread | |
DispatchQueue.global(qos: .userInitiated).async { [weak self] in | |
guard let self = self else { return } | |
do { | |
try self.launcher.run() | |
} catch { | |
// Display error in the UI | |
DispatchQueue.main.async { | |
self.errorTextField.stringValue = "Error launching QEMU: \(error.localizedDescription)" | |
self.statusTextField.stringValue = "Failed to start" | |
// Show an alert with the error details | |
let alert = NSAlert(error: error) | |
alert.runModal() | |
print("Error launching QEMU: \(error.localizedDescription)") | |
} | |
} | |
} | |
// Set up a timer to periodically update the UI | |
uiTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in | |
DispatchQueue.main.async { | |
self?.updateUI() | |
} | |
} | |
} catch { | |
// Show error alert for initialization errors | |
let alert = NSAlert(error: error) | |
alert.runModal() | |
// Log error details | |
print("Failed to initialize application: \(error.localizedDescription)") | |
// Exit with error code | |
exit(1) | |
} | |
} | |
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { | |
// Check if QEMU is running and ask for confirmation if needed | |
if launcher.isRunning { | |
launcher.confirmTerminateQEMU { shouldTerminate in | |
if shouldTerminate { | |
NSApplication.shared.reply(toApplicationShouldTerminate: true) | |
} else { | |
NSApplication.shared.reply(toApplicationShouldTerminate: false) | |
} | |
} | |
return .terminateLater | |
} | |
return .terminateNow | |
} | |
/// Sets up the application menus | |
private func setupMenus() { | |
let mainMenu = NSMenu() | |
// Application menu | |
let appMenu = NSMenu() | |
let appMenuItem = NSMenuItem() | |
appMenuItem.submenu = appMenu | |
let appName = ProcessInfo.processInfo.processName | |
// About item | |
let aboutMenuItem = NSMenuItem( | |
title: "About \(appName)", | |
action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), | |
keyEquivalent: "" | |
) | |
// Separator | |
let separator1 = NSMenuItem.separator() | |
// Quit item | |
let quitMenuItem = NSMenuItem( | |
title: "Quit \(appName)", | |
action: #selector(NSApplication.terminate(_:)), | |
keyEquivalent: "q" | |
) | |
// Add items to app menu | |
appMenu.addItem(aboutMenuItem) | |
appMenu.addItem(separator1) | |
appMenu.addItem(quitMenuItem) | |
// Add app menu to main menu | |
mainMenu.addItem(appMenuItem) | |
// Create proxy menu | |
let proxyMenu = NSMenu() | |
proxyMenu.title = "Proxy" | |
let proxyMenuItem = NSMenuItem() | |
proxyMenuItem.title = "Proxy" | |
proxyMenuItem.submenu = proxyMenu | |
// Enable Proxy item - initially disabled, will be updated in updateUI | |
let enableProxyItem = NSMenuItem( | |
title: "Enable Old SSL Proxy", | |
action: #selector(toggleProxy(_:)), | |
keyEquivalent: "p" | |
) | |
enableProxyItem.isEnabled = false // Will update this after launcher is initialized | |
// Export Certificate item | |
let exportCertItem = NSMenuItem( | |
title: "Export SSL Certificate...", | |
action: #selector(exportCertificate(_:)), | |
keyEquivalent: "e" | |
) | |
exportCertItem.isEnabled = false // Will update this after launcher is initialized | |
// Show Proxy Info item | |
let proxyInfoItem = NSMenuItem( | |
title: "Show Proxy Information", | |
action: #selector(showProxyInfo(_:)), | |
keyEquivalent: "i" | |
) | |
// Add items to proxy menu | |
proxyMenu.addItem(enableProxyItem) | |
proxyMenu.addItem(exportCertItem) | |
proxyMenu.addItem(NSMenuItem.separator()) | |
proxyMenu.addItem(proxyInfoItem) | |
// Add proxy menu to main menu | |
mainMenu.addItem(proxyMenuItem) | |
// Set the menu | |
NSApplication.shared.mainMenu = mainMenu | |
} | |
} | |
// Make AppDelegate conform to NSWindowDelegate | |
extension AppDelegate: NSWindowDelegate { | |
func windowShouldClose(_ sender: NSWindow) -> Bool { | |
// Check if QEMU is running and ask for confirmation if needed | |
if launcher.isRunning { | |
launcher.confirmTerminateQEMU { shouldClose in | |
if shouldClose { | |
NSApplication.shared.terminate(nil) | |
} | |
} | |
return false | |
} | |
// If QEMU is not running, close the window and terminate the app | |
NSApplication.shared.terminate(nil) | |
return false | |
} | |
} | |
// Main function for the application entry point | |
func main() { | |
let app = NSApplication.shared | |
let delegate = AppDelegate() | |
// Set up top-level exception handler | |
NSSetUncaughtExceptionHandler { exception in | |
let alert = NSAlert() | |
alert.messageText = "An unhandled exception occurred" | |
alert.informativeText = "Details: \(exception.name) - \(exception.reason ?? "")\n\nStack Trace:\n\(exception.callStackSymbols.joined(separator: "\n"))" | |
alert.alertStyle = .critical | |
alert.addButton(withTitle: "OK") | |
alert.runModal() | |
// Log to console | |
print("FATAL ERROR: \(exception.name) - \(exception.reason ?? "")") | |
print("Stack Trace:\n\(exception.callStackSymbols.joined(separator: "\n"))") | |
// Write to log file in app bundle resources | |
do { | |
let bundleURL = Bundle.main.bundleURL | |
let resourcesPath = bundleURL.appendingPathComponent("Contents/Resources").path | |
let logPath = resourcesPath + "/error.log" | |
let logContent = """ | |
Time: \(Date()) | |
Error: \(exception.name) - \(exception.reason ?? "") | |
Stack Trace: | |
\(exception.callStackSymbols.joined(separator: "\n")) | |
""" | |
try logContent.write(toFile: logPath, atomically: true, encoding: .utf8) | |
} catch { | |
print("Failed to write error log: \(error)") | |
} | |
} | |
// Set the delegate and run the app | |
app.delegate = delegate | |
do { | |
// Instead of calling app.run() directly, wrap it in a try-catch block | |
try { | |
app.run() | |
return 0 // Never reached in normal operation | |
}() | |
} catch { | |
// Show error alert | |
let alert = NSAlert(error: error) | |
alert.runModal() | |
// Log the error | |
print("FATAL ERROR: \(error)") | |
// Write to log file | |
do { | |
let bundleURL = Bundle.main.bundleURL | |
let resourcesPath = bundleURL.appendingPathComponent("Contents/Resources").path | |
let logPath = resourcesPath + "/error.log" | |
let logContent = """ | |
Time: \(Date()) | |
Error: \(error) | |
""" | |
try logContent.write(toFile: logPath, atomically: true, encoding: .utf8) | |
} catch { | |
print("Failed to write error log: \(error)") | |
} | |
return 1 // Return error code | |
} | |
} | |
// Run the application | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment