diff --git a/app/NativeMacReceiverSelector/AppDelegate.swift b/app/NativeMacReceiverSelector/AppDelegate.swift new file mode 100644 index 0000000..939eca7 --- /dev/null +++ b/app/NativeMacReceiverSelector/AppDelegate.swift @@ -0,0 +1,47 @@ +import Cocoa + + +class AppDelegate: NSObject { + var mainWindow: NSWindow? + var mainWindowController: NSWindowController? + var mainWindowViewController: ViewController? +} + +extension AppDelegate: NSApplicationDelegate { + func applicationDidFinishLaunching(_ aNotification: Notification) { + let window = NSPanel( + contentRect: NSZeroRect + , styleMask: [ + .titled + , .closable + , .hudWindow + , .utilityWindow + , .nonactivatingPanel + ] + , backing: .buffered + , defer: false) + + window.title = "fx_cast" + window.orderFrontRegardless() + window.center() + + let windowController = NSWindowController(window: window) + windowController.showWindow(window) + + let viewController = ViewController() + window.contentViewController = viewController + window.makeKeyAndOrderFront(self) + + self.mainWindow = window + self.mainWindowController = windowController + self.mainWindowViewController = viewController + + NSApp.activate(ignoringOtherApps: true) + } + + func applicationWillTerminate(_ aNotification: Notification) {} + + func applicationShouldTerminateAfterLastWindowClosed(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/app/NativeMacReceiverSelector/ReceiverView.swift b/app/NativeMacReceiverSelector/ReceiverView.swift new file mode 100644 index 0000000..ea84fa1 --- /dev/null +++ b/app/NativeMacReceiverSelector/ReceiverView.swift @@ -0,0 +1,79 @@ +import Cocoa + +protocol ReceiverViewDelegate: AnyObject { + func didCast (_ receiver: Receiver) +} + +class ReceiverView: NSStackView { + weak var receiverViewDelegate: ReceiverViewDelegate? + + var receiver: Receiver! + var constraintsSet = false + + + override init (frame: CGRect) { + super.init(frame: frame) + } + required init? (coder: NSCoder) { + super.init(coder: coder) + } + + init (receiver: Receiver) { + super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 0)) + + self.receiver = receiver + + let metaStackView = NSStackView(views: [ + makeLabel(receiver.friendlyName, size: 14) + , makeLabel("\(receiver.host):\(receiver.port)" + , size: NSFont.smallSystemFontSize + , color: .secondaryLabelColor) + ]) + + metaStackView.alignment = .leading + metaStackView.orientation = .vertical + metaStackView.spacing = 4 + + + let castButton = WideButton( + title: "Cast" + , target: self + , action: #selector(ReceiverView.onCast)) + + castButton.bezelStyle = .rounded + + + self.addArrangedSubview(metaStackView) + self.addArrangedSubview(castButton) + self.distribution = .fill + } + + override func updateConstraints () { + super.updateConstraints() + + if !constraintsSet { + self.translatesAutoresizingMaskIntoConstraints = false + self.leadingAnchor.constraint(equalTo: superview!.leadingAnchor, constant: 8).isActive = true + self.trailingAnchor.constraint(equalTo: superview!.trailingAnchor, constant: -8).isActive = true + + constraintsSet = true + } + } + + @objc + func onCast () { + self.receiverViewDelegate?.didCast(self.receiver) + } +} + + +class WideButton: NSButton { + override var intrinsicContentSize: NSSize { + var size = super.intrinsicContentSize + if size.width < 100 { + size.width = 100 + } + + return size + } +} diff --git a/app/NativeMacReceiverSelector/ViewController.swift b/app/NativeMacReceiverSelector/ViewController.swift new file mode 100644 index 0000000..903355f --- /dev/null +++ b/app/NativeMacReceiverSelector/ViewController.swift @@ -0,0 +1,152 @@ +import Cocoa +import Foundation + + +func makeLabel(_ text: String, + size: CGFloat = 0, + color: NSColor = NSColor.textColor) -> NSTextField { + + let textField = NSTextField() + textField.stringValue = text + textField.backgroundColor = .clear + textField.isEditable = false + textField.isBezeled = false + textField.sizeToFit() + + // Text + textField.font = NSFont.systemFont(ofSize: size) + textField.textColor = color + + return textField +} + + +struct InitData: Codable { + let receivers: [Receiver] + let defaultMediaType: MediaType + + let i18n_mediaTypeApp: String + let i18n_mediaTypeTab: String + let i18n_mediaTypeScreen: String +} + +struct ReceiverSelection: Codable { + let receiver: Receiver + let mediaType: MediaType +} + +enum MediaType: Int, Codable { + case app + case tab + case screen +} + +struct Receiver: Codable { + let friendlyName: String + let host: String + let id: String + let port: Int +} + + +class ViewController: NSViewController { + var mediaTypePopUpButton: NSPopUpButton! + + override func loadView () { + let visualEffectView = NSVisualEffectView() + visualEffectView.blendingMode = .behindWindow + visualEffectView.state = .active + + self.view = visualEffectView + } + + override func viewDidLoad () { + super.viewDidLoad() + + if (CommandLine.argc < 2) { + fputs("Error: Not enough args\n", stderr) + exit(1) + } + + guard let data = CommandLine.arguments[1].data(using: .utf8) else { + fputs("Error: Failed to convert input to data\n", stderr) + exit(1) + } + + + let initData: InitData! + + do { + initData = try JSONDecoder().decode(InitData.self, from: data) + } catch { + fputs("Error: Failed to parse input data\n", stderr) + exit(1) + } + + + let stackView = NSStackView() + stackView.orientation = .vertical + stackView.alignment = .leading + stackView.autoresizingMask = [ .width, .height ] + stackView.edgeInsets = NSEdgeInsetsMake(8, 8, 8, 8) + + + self.mediaTypePopUpButton = NSPopUpButton() + self.mediaTypePopUpButton.addItems(withTitles: [ + initData.i18n_mediaTypeApp + , initData.i18n_mediaTypeTab + , initData.i18n_mediaTypeScreen + ]) + + self.mediaTypePopUpButton.selectItem(at: initData.defaultMediaType.rawValue) + + let mediaTypeStackView = NSStackView(views: [ + makeLabel("Cast"), + self.mediaTypePopUpButton, + makeLabel("to:") + ]) + + + stackView.addArrangedSubview(mediaTypeStackView) + + + for receiver in initData.receivers { + let receiverSeparator = NSBox() + receiverSeparator.boxType = .separator + + let receiverView = ReceiverView(receiver: receiver) + receiverView.receiverViewDelegate = self + + stackView.addArrangedSubview(receiverSeparator) + stackView.addArrangedSubview(receiverView) + } + + + self.view.addSubview(stackView) + self.view.autoresizesSubviews = true + self.view.frame.size.width = 350 + } +} + + +extension ViewController: ReceiverViewDelegate { + func didCast (_ receiver: Receiver) { + do { + let mediaType = MediaType( + rawValue: self.mediaTypePopUpButton.indexOfSelectedItem)! + + let selection = ReceiverSelection( + receiver: receiver + , mediaType: mediaType) + + let jsonData = try JSONEncoder().encode(selection) + let jsonString = String(data: jsonData, encoding: .utf8) + + print(jsonString!) + fflush(stdout) + } catch { + fputs("Error: Failed to encode output data", stderr) + exit(1) + } + } +} diff --git a/app/NativeMacReceiverSelector/main.swift b/app/NativeMacReceiverSelector/main.swift new file mode 100644 index 0000000..934759f --- /dev/null +++ b/app/NativeMacReceiverSelector/main.swift @@ -0,0 +1,10 @@ +import Cocoa + + +let app = NSApplication.shared + +let delegate = AppDelegate() +app.delegate = delegate + +app.setActivationPolicy(.regular) +app.run() diff --git a/app/bin/build.js b/app/bin/build.js index ad6c776..e7ff86c 100644 --- a/app/bin/build.js +++ b/app/bin/build.js @@ -23,6 +23,7 @@ const { executableName , executablePath , manifestName , manifestPath + , selectorExecutableName , pkgPlatform , DIST_PATH , LICENSE_PATH @@ -73,6 +74,10 @@ const ROOT_PATH = path.join(__dirname, ".."); const SRC_PATH = path.join(ROOT_PATH, "src"); const BUILD_PATH = path.join(ROOT_PATH, "build"); +const spawnOptions = { + shell: true + , stdio: [ process.stdin, process.stdout, process.stderr ] +}; /** * Shouldn't exist, but cleanup and re-create any existing @@ -88,10 +93,7 @@ async function build () { // Run tsc spawnSync(`tsc --project ${ROOT_PATH} \ --outDir ${BUILD_PATH}` - , { - shell: true - , stdio: [ process.stdin, process.stdout, process.stderr ] - }); + , spawnOptions); // Move tsc output to build dir fs.moveSync(path.join(BUILD_PATH, "src"), BUILD_PATH); @@ -164,6 +166,22 @@ async function build () { , "--output", path.join(BUILD_PATH, executableName[argv.platform]) ]); + // Build NativeMacReceiverSelector + if (argv.platform === "darwin") { + const sourceFiles = glob.sync("*.swift", { + cwd: path.join(__dirname, "../NativeMacReceiverSelector") + , absolute: true + }); + + const formattedSourceFiles = sourceFiles + .map(fileName => `"${fileName}"`) + .join(" "); + + spawnSync(`swiftc -o "${path.join(BUILD_PATH, selectorExecutableName)}" \ + ${formattedSourceFiles}` + , spawnOptions); + } + /** * If packaging, create an installer package and move it to @@ -191,6 +209,13 @@ async function build () { path.join(BUILD_PATH, builtExecutableName) , path.join(DIST_PATH, builtExecutableName) , { overwrite: true }); + + if (argv.platform === "darwin") { + fs.moveSync( + path.join(BUILD_PATH, selectorExecutableName) + , path.join(DIST_PATH, selectorExecutableName) + , { overwrite: true }); + } } // Remove build directory @@ -275,6 +300,10 @@ function packageDarwin ( fs.moveSync(path.join(BUILD_PATH, manifestName) , path.join(rootManifestPath, manifestName)); + // Move selector executable alongside main executable + fs.moveSync(path.join(BUILD_PATH, selectorExecutableName) + , path.join(rootExecutablePath, selectorExecutableName)); + // Copy static files to be processed fs.copySync(packagingDir, packagingOutputDir); diff --git a/app/bin/lib/paths.js b/app/bin/lib/paths.js index 33261b2..465ec37 100644 --- a/app/bin/lib/paths.js +++ b/app/bin/lib/paths.js @@ -34,6 +34,8 @@ exports.manifestPath = { } }; +exports.selectorExecutableName = "selector"; + exports.pkgPlatform = { win32: "win" , darwin: "macos" diff --git a/app/src/main.ts b/app/src/main.ts index a762d4f..dae16a8 100755 --- a/app/src/main.ts +++ b/app/src/main.ts @@ -1,5 +1,6 @@ import dnssd from "dnssd"; +import child_process from "child_process"; import events from "events"; import fs from "fs"; import http from "http"; @@ -61,6 +62,8 @@ interface InitializeOptions { const existingSessions: Map = new Map(); const existingMedia: Map = new Map(); +let receiverSelectorApp: child_process.ChildProcess; + /** * Handle incoming messages from the extension and forward * them to the appropriate handlers. @@ -126,6 +129,59 @@ async function handleMessage (message: Message) { } + case "bridge:/receiverSelector/open": { + const receiverSelectorData = message.data; + + if (process.platform !== "darwin") { + console.error("Invalid platform for native selector."); + process.exit(1); + } + + if (!receiverSelectorData) { + console.error("Missing native selector data."); + process.exit(1); + } else { + try { + JSON.parse(receiverSelectorData); + } catch (err) { + console.error("Invalid native selector data.") + } + } + + receiverSelectorApp = child_process.spawn( + path.join(process.cwd(), "selector") + , [ receiverSelectorData ]); + + receiverSelectorApp.stdout.setEncoding("utf8") + receiverSelectorApp.stdout.on("data", data => { + sendMessage({ + subject: "main:/receiverSelector/selected" + , data: JSON.parse(data) + }) + }); + + receiverSelectorApp.addListener("error", err => { + sendMessage({ + subject: "main:/receiverSelector/error" + , data: err.message + }) + }); + + receiverSelectorApp.on("close", () => { + sendMessage({ + subject: "main:/receiverSelector/close" + }); + }); + + break; + } + + case "bridge:/receiverSelector/close": { + receiverSelectorApp.kill(); + break; + } + + case "bridge:/startHttpServer": { const { filePath, port } = message.data; @@ -183,69 +239,86 @@ async function handleMessage (message: Message) { } } + function initialize (options: InitializeOptions) { - const statusListeners = new Map(); + if (options.shouldWatchStatus) { + browser.on("serviceUp", onStatusBrowserServiceUp); + browser.on("serviceDown", onStatusBrowserServiceDown); + } - browser.on("serviceUp", (service: dnssd.Service) => { - const host = service.addresses[0]; - const port = service.port; - const id = service.txt.id; + browser.on("serviceUp", onBrowserServiceUp); + browser.on("servicedown", onBrowserServiceDown); + browser.start(); - if (options.shouldWatchStatus) { - const listener = new StatusListener(host, port); - - listener.on("receiverStatus", (status: ReceiverStatus) => { - const receiverStatusMessage: any = { - subject: "receiverStatus" - , data: { - id - , status: { - volume: { - level: status.volume.level - , muted: status.volume.muted - } - } - } - }; - - if ("applications" in status) { - const application = status.applications[0]; - - receiverStatusMessage.data.status.application = { - displayName: application.displayName - , isIdleScreen: application.isIdleScreen - , statusText: application.statusText - }; - } - - sendMessage(receiverStatusMessage); - }); - - statusListeners.set(id, listener); - } + function onBrowserServiceUp (service: dnssd.Service) { sendMessage({ subject: "shim:/serviceUp" , data: { - host, port, id + host: service.addresses[0] + , port: service.port + , id: service.txt.id , friendlyName: service.txt.fn } }); - }); - - browser.on("serviceDown", (service: dnssd.Service) => { - const id = service.txt.id; - - // De-register status listener - if (options.shouldWatchStatus && statusListeners.has(id)) { - statusListeners.get(id).deregister(); - } + } + function onBrowserServiceDown (service: dnssd.Service) { sendMessage({ subject: "shim:/serviceDown" - , data: { id } + , data: { + id: service.txt.id + } }); - }); + } - browser.start(); + + // Receiver status listeners for status mode + const statusListeners = new Map(); + + function onStatusBrowserServiceUp (service: dnssd.Service) { + const { id } = service.txt; + + const listener = new StatusListener( + service.addresses[0] + , service.port); + + listener.on("receiverStatus", (status: ReceiverStatus) => { + const receiverStatusMessage: any = { + subject: "receiverStatus" + , data: { + id + , status: { + volume: { + level: status.volume.level + , muted: status.volume.muted + } + } + } + }; + + if ("applications" in status) { + const application = status.applications[0]; + + receiverStatusMessage.data.status.application = { + displayName: application.displayName + , isIdleScreen: application.isIdleScreen + , statusText: application.statusText + }; + } + + sendMessage(receiverStatusMessage); + }); + + statusListeners.set(id, listener); + } + + function onStatusBrowserServiceDown (service: dnssd.Service) { + const { id } = service.txt; + + if (statusListeners.has(id)) { + statusListeners.get(id).deregister(); + statusListeners.delete(id); + } + } } diff --git a/ext/src/main.ts b/ext/src/main.ts index 93ec10b..a4f954d 100755 --- a/ext/src/main.ts +++ b/ext/src/main.ts @@ -9,7 +9,8 @@ import { getWindowCenteredProps } from "./lib/utils"; import { ReceiverSelectorMediaType , ReceiverSelectorSelectedEvent - , PopupReceiverSelectorManager } from "./receiverSelectorManager"; + , PopupReceiverSelectorManager + , NativeMacReceiverSelectorManager } from "./receiverSelectorManager"; import { Message, Receiver } from "./types"; @@ -430,12 +431,11 @@ statusBridge.onMessage.addListener(async (message: Message) => { statusBridge.postMessage({ subject: "bridge:/initialize" , data: { - shouldWatchStatus: true + mode: "status" } }); - async function onConnectShim (port: browser.runtime.Port) { const bridgeInfo = await getBridgeInfo(); if (bridgeInfo && !bridgeInfo.isVersionCompatible) { @@ -512,16 +512,16 @@ async function onConnectShim (port: browser.runtime.Port) { } case "main:/sessionCreated": { - PopupReceiverSelectorManager.close(); + NativeMacReceiverSelectorManager.close(); break; } case "main:/selectReceiverBegin": { - PopupReceiverSelectorManager.open( + NativeMacReceiverSelectorManager.open( Array.from(statusBridgeReceivers.values()) , message.data.defaultMediaType); - PopupReceiverSelectorManager.addEventListener("selected" + NativeMacReceiverSelectorManager.addEventListener("selected" , (ev: ReceiverSelectorSelectedEvent) => { port.postMessage({ @@ -532,13 +532,13 @@ async function onConnectShim (port: browser.runtime.Port) { }); }); - PopupReceiverSelectorManager.addEventListener("cancelled", () => { + NativeMacReceiverSelectorManager.addEventListener("cancelled", () => { port.postMessage({ subject: "shim:/selectReceiverCancelled" }); }); - PopupReceiverSelectorManager.addEventListener("error", () => { + NativeMacReceiverSelectorManager.addEventListener("error", () => { // TODO: Report errors properly port.postMessage({ subject: "shim:/selectReceiverCancelled" diff --git a/ext/src/messageTypes.ts b/ext/src/messageTypes.ts index 72cefae..a0ca657 100644 --- a/ext/src/messageTypes.ts +++ b/ext/src/messageTypes.ts @@ -23,3 +23,18 @@ export interface ServiceUpMessage extends Message { data: Receiver; } + +export interface NativeReceiverSelectorSelectedMessage extends Message { + subject: "main:/receiverSelector/selected" + , data: Receiver +} + +export interface NativeReceiverSelectorCloseMessage extends Message { + subject: "main:/receiverSelector/error" + , data: string +} + +export interface NativeReceiverSelectorErrorMessage extends Message { + subject: "main:/receiverSelector/error" + , data: string +} diff --git a/ext/src/receiverSelectorManager/selectorManagers/NativeMacReceiverSelectorManager.ts b/ext/src/receiverSelectorManager/selectorManagers/NativeMacReceiverSelectorManager.ts index d01f6c5..52b619b 100644 --- a/ext/src/receiverSelectorManager/selectorManagers/NativeMacReceiverSelectorManager.ts +++ b/ext/src/receiverSelectorManager/selectorManagers/NativeMacReceiverSelectorManager.ts @@ -5,19 +5,102 @@ import ReceiverSelectorManager, { import { Message, Receiver } from "../../types"; +import { NativeReceiverSelectorSelectedMessage + , NativeReceiverSelectorErrorMessage + , NativeReceiverSelectorCloseMessage } from "../../messageTypes"; + + +const _ = browser.i18n.getMessage; + class NativeMacReceiverSelectorManager extends EventTarget implements ReceiverSelectorManager { + private bridgePort: browser.runtime.Port; + private bridgePortDisconnected: boolean = false; + + private wasReceiverSelected: boolean = false; + + public async open ( receivers: Receiver[] , defaultMediaType: ReceiverSelectorMediaType): Promise { - console.info("STUB :: NativeMacReceiverSelectorManager.open"); + + this.bridgePort = browser.runtime.connectNative(APPLICATION_NAME); + + this.bridgePort.onMessage.addListener((message: Message) => { + switch (message.subject) { + case "main:/receiverSelector/selected": { + this.onBridgePortMessageSelected( + message as NativeReceiverSelectorSelectedMessage); + break; + } + case "main:/receiverSelector/error": { + this.onBridgePortMessageError( + message as NativeReceiverSelectorErrorMessage); + break; + } + case "main:/receiverSelector/close": { + this.onBridgePortMessageClose( + message as NativeReceiverSelectorCloseMessage); + break; + } + } + }); + + this.bridgePort.onDisconnect.addListener(() => { + this.bridgePortDisconnected = true; + }); + + this.bridgePort.postMessage({ + subject: "bridge:/receiverSelector/open" + , data: JSON.stringify({ + receivers + , defaultMediaType + , i18n_mediaTypeApp: _("popupMediaTypeApp") + , i18n_mediaTypeTab: _("popupMediaTypeTab") + , i18n_mediaTypeScreen: _("popupMediaTypeScreen") + }) + }); } public close (): void { - console.info("STUB :: NativeMacReceiverSelectorManager.close"); + if (this.bridgePort && !this.bridgePortDisconnected) { + this.bridgePort.postMessage({ + subject: "bridge:/receiverSelector/close" + }); + } + } + + + private onBridgePortMessageSelected ( + message: NativeReceiverSelectorSelectedMessage) { + this.wasReceiverSelected = true; + this.dispatchEvent(new CustomEvent("selected", { + detail: message.data + })); + } + + private onBridgePortMessageError ( + message: NativeReceiverSelectorErrorMessage) { + this.dispatchEvent(new CustomEvent("error")); + } + + private onBridgePortMessageClose ( + message: NativeReceiverSelectorCloseMessage) { + + if (!this.wasReceiverSelected) { + this.dispatchEvent(new CustomEvent("cancelled")); + } + + if (!this.bridgePortDisconnected) { + this.bridgePort.disconnect(); + } + + this.bridgePort = null; + this.bridgePortDisconnected = false; + this.wasReceiverSelected = false; } }