kasper / phoenix

A lightweight macOS window and app manager scriptable with JavaScript
https://kasper.github.io/phoenix/
Other
4.39k stars 128 forks source link

Sending events to Phoenix #140

Open sheki opened 8 years ago

sheki commented 8 years ago

I would like to tirgger Phoenix events via and API / library.

This way I can trigger Phoenix actions via "Alfred" or some other tool instead of relying on keyboard shortcuts. ( I am bad at remembering).

Is this a valid feature request or out of scope for this project ? I can look into an implementation if there is consensus.

kasper commented 8 years ago

If there’s a simple way of integrating this to the current Event API, then perhaps. For example by using NSDistributedNotificationCenter. (I personally haven’t used launchers, so I’m not that familiar how they interoperate between apps.) I would rather not add too much complexity to keep the project simple.

mafredri commented 8 years ago

I've thought about the same thing, for example changing phoenix layouts and behaviour based on other system events than what is available, or generally passing any information to phoenix. This could be emulated somewhat through tasks (by polling) though.

kasper commented 8 years ago

Adding support for new system events is certainly possibly, if there’s a clear use case for it.

kasper commented 8 years ago

Could be implemented along with #53.

WybeBosch commented 1 year ago

If anyone else is looking for a cheeky solution i have right now. Just set your phoenix script up with a hotkey, and then trigger the hotkey via osascript in the terminal

osascript -e 'tell application "System Events" to keystroke "l" using {command down, option down}'

can turn that into a bash alias if your want it even easier by adding this to your .zshrc or .bashrc

alias triggerPhoenixHotkey='osascript -e "tell application \"System Events\" to keystroke \"l\" using {command down, option down}"'
albinekb commented 2 months ago

I have been playing with an PoC implementation of this, to be able to use phoenix as a backend for window/workspace management from Raycast and the terminal, inspired by iTerm2 and the RPC API it exposes.

JS api to enable/disable the "bridge" ```ts /** * Use the Bridge to manage the socket server. */ interface Bridge { /** * Enables the bridge and returns the version and socket path. * This makes it possible to call the Phoenix APIs remotely. */ enable(): { version: string; socketPath: string } disable(): void isEnabled(): boolean } ```

By adding Bridge.enable() in my config, Phoenix sets up and starts listening on a unix socket, this gives access the full Phoenix API.

Setting up the unix socket in obj-c ```objc listen(serverSocket, 5); dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ while (true) { int clientSocket = accept(serverSocket, NULL, NULL); if (clientSocket != -1) { NSLog(@"Connection established"); // Create the event info dictionary NSDictionary *eventInfo = @{@"type": @"internal", @"event": @"connect"}; // Call the JavaScript function with the event info [self callJavaScriptFunctionWithMessage:eventInfo]; // Handle the client connection in a new dispatch queue dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ [self handleClientConnection:clientSocket]; }); } } }); ```

It's a bit of a hack, phoenix calls handleBridgeEvent when there's a message on the socket:


function handleBridgeEvent(data: any) {
  const start = perf.now()
  try {
    if (data.type === 'internal') {
      handleInternalEvent(data)
      return
    }
    if ('batch' in data) {
      const results = data.batch.map((d: any) => handleCommand(d))
      return JSON.stringify({
        type: 'batch',
        data: results,
        __duration: perf.now() - start,
      })
    }
    // Process the event data
    if ('command' in data) {
      return JSON.stringify(handleCommand(data))
    }
  } catch (e) {
    console.log('Error handling global event', e)
    return JSON.stringify({
      error: 'Error handling global event',
      debug: String(e),
    })
  }

  if ('type' in data) {
    return JSON.stringify({
      rpcId: data.rpcId,
      type: typeof data,
      error: `${data.command} not found`,
    })
  }

  return JSON.stringify({ type: typeof data, error: 'Invalid command' })
}
Mapping code in JS ```ts import { assert } from './helpers' import { MapInstance, RegistryMap } from './registry-map' let m: Modal export const maps = { Window: new RegistryMap(), App: new RegistryMap(), Screen: new RegistryMap(), Space: new RegistryMap(), Modal: new RegistryMap(undefined, { beforeDelete: (_, modal) => modal.close(), }), } export function mapsGC() { for (const [name, map] of Object.entries(maps)) { console.log(`Clearing ${name}`) map.clear() } } const normalizeInstance = (instance: any): keyof typeof maps => { if (!instance) { throw new Error('Invalid instance, null or undefined') } const str = String(instance) if (!str.includes('PH')) { throw new Error('Invalid instance, does not contain PH') } const stripped = str .replace('PH', '') .replace('[object', '') .replace(']', '') .replace('ModalWindowController', 'Modal') .trim() if (!stripped || !(stripped in maps)) { throw new Error('Invalid instance, not in maps: ' + stripped) } return stripped as keyof typeof maps } export function isMapObject(str: string): str is keyof typeof maps { return str in maps } export const parseCommand = ( command: string, ): { obj: string; method: string } => { const [obj, method] = command.split('.') assert(obj, 'Invalid command') assert(method, 'Invalid command') return { obj, method } } function storeInstance(inst: MapInstance): { id: number type: keyof typeof maps } { const instName = normalizeInstance(inst) const map = maps[instName] const id = inst.hash() map.set(id, inst) return { id, type: instName } } export function mapResultToInst( result: any, ): | undefined | { id: number; type: keyof typeof maps }[] | { id: number; type: keyof typeof maps } { if (!result) return if (Array.isArray(result)) { const inst = result .filter((r) => r && typeof r === 'object' && typeof r.hash === 'function') .map(storeInstance) return inst.length ? inst : undefined } if (typeof result === 'object' && typeof result.hash === 'function') { return storeInstance(result) } } ```
Listening for the socket in objc ```objc - (void)callJavaScriptFunctionWithMessage:(NSDictionary *)eventInfo { if (self.context) { dispatch_async(dispatch_get_main_queue(), ^{ JSValue *jsFunction = self.context[@"handleBridgeEvent"]; if ([jsFunction isObject]) { JSValue *response = [jsFunction callWithArguments:@[eventInfo]]; if (!response.isUndefined) { NSString *responseString = [response toString]; NSLog(@"Response: %@", responseString); } } else { NSLog(@"JavaScript function handleBridgeEvent not found"); } }); } else { NSLog(@"JSContext is not available"); } } - (void)callJavaScriptFunctionWithEventInfo:(NSDictionary *)eventInfo clientSocket:(int)clientSocket { if (self.context) { dispatch_async(dispatch_get_main_queue(), ^{ JSValue *jsFunction = self.context[@"handleBridgeEvent"]; if ([jsFunction isObject]) { JSValue *response = [jsFunction callWithArguments:@[eventInfo]]; if (!response.isUndefined) { NSString *responseString = [response toString]; NSLog(@"Response: %@", responseString); // Send the response to the socket const char *utfString = [responseString UTF8String]; ssize_t bytesWritten = write(clientSocket, utfString, strlen(utfString)); if (bytesWritten == -1) { perror("Error writing to socket"); } else { NSLog(@"Response successfully written to socket"); } } else { NSLog(@"JavaScript function handleBridgeEvent returned undefined"); } } else { NSLog(@"JavaScript function handleBridgeEvent not found"); } }); } else { NSLog(@"JSContext is not available"); } } static const char* socket_path = "/tmp/phoenix.socket "; - (NSString *)getSocketPath { return [NSString stringWithUTF8String:socket_path]; } void printLastCharacterOfSocketPath(const char* path) { size_t length = strlen(path); if (length > 0) { char last_char = path[length - 1]; printf("Last Char: %c\n", last_char); } else { printf("Socket path is empty.\n"); } } - (void)setupUnixDomainSocket { if (!self.bridgeEnabled) { return; } atexit(cleanUpSocket); int sock = 0; struct sockaddr_un local; unlink(socket_path); unlink("/tmp/phoenix.socket"); if ((sock = socket(AF_UNIX, SOCK_STREAM, 0)) == -1) { printf("Client: Error on socket() call \n"); exit(1); } local.sun_family = AF_UNIX; strcpy(local.sun_path, socket_path); printf("Socket Path: %s\n", local.sun_path); printLastCharacterOfSocketPath(local.sun_path); if (access(local.sun_path, F_OK) != -1) { // File exists, attempt to unlink if (unlink(local.sun_path) == -1) { perror("unlink error"); exit(1); } } int serverSocket = socket(AF_UNIX, SOCK_STREAM, 0); if (serverSocket == -1) { perror("socket error"); exit(1); } int len = (int)(strlen(local.sun_path) + sizeof(local.sun_family)); if (bind(serverSocket, (struct sockaddr *)&local, len) == -1) { perror("bind error"); exit(1); } listen(serverSocket, 5); dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ while (true) { int clientSocket = accept(serverSocket, NULL, NULL); if (clientSocket != -1) { NSLog(@"Connection established"); // Create the event info dictionary NSDictionary *eventInfo = @{@"type": @"internal", @"event": @"connect"}; // Call the JavaScript function with the event info [self callJavaScriptFunctionWithMessage:eventInfo]; // Handle the client connection in a new dispatch queue dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ [self handleClientConnection:clientSocket]; }); } } }); } - (void)handleClientConnection:(int)clientSocket { NSMutableData *receivedData = [NSMutableData data]; size_t bufferSize = 4096; // Adjust the buffer size as needed char buffer[bufferSize]; while (true) { // Keep the connection open ssize_t bytesRead = read(clientSocket, buffer, bufferSize); if (bytesRead <= 0) break; // Close connection on error or no data [receivedData appendBytes:buffer length:bytesRead]; // Accumulate incoming data while ([receivedData length] > 0) { NSError *error = nil; NSRange validJSONRange = [self extractValidJSONRangeFromData:receivedData error:&error]; if (validJSONRange.length > 0 && !error) { NSData *jsonData = [receivedData subdataWithRange:validJSONRange]; NSDictionary *eventInfo = [NSJSONSerialization JSONObjectWithData:jsonData options:0 error:&error]; if (!error && [eventInfo isKindOfClass:[NSDictionary class]]) { [self callJavaScriptFunctionWithEventInfo:eventInfo clientSocket:clientSocket]; // Remove processed data from the buffer [receivedData replaceBytesInRange:validJSONRange withBytes:NULL length:0]; } else { NSLog(@"Error parsing received data: %@", error); [receivedData setLength:0]; // Clear buffer to avoid processing the same erroneous data again break; // Break the loop and handle next incoming data } } else { // If there's no valid JSON range found or an error occurs, wait for more data break; } } } NSDictionary *disconnectEventInfo = @{@"type": @"internal", @"event": @"disconnect"}; // Call the JavaScript function with the disconnect event info [self callJavaScriptFunctionWithMessage:disconnectEventInfo]; NSLog(@"Connection closed"); close(clientSocket); } static const NSInteger CustomJSONSerializationErrorCode = 1001; - (NSRange)extractValidJSONRangeFromData:(NSData *)data error:(NSError **)error { NSString *dataString = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; if (!dataString) { return NSMakeRange(NSNotFound, 0); } NSUInteger openBracesCount = 0; NSUInteger closeBracesCount = 0; NSRange validJSONRange = NSMakeRange(NSNotFound, 0); for (NSUInteger i = 0; i < dataString.length; i++) { unichar character = [dataString characterAtIndex:i]; if (character == '{') { openBracesCount++; } else if (character == '}') { closeBracesCount++; } if (openBracesCount > 0 && openBracesCount == closeBracesCount) { validJSONRange = NSMakeRange(0, i + 1); break; } } if (validJSONRange.location != NSNotFound) { return validJSONRange; } if (error) { NSDictionary *userInfo = @{ NSLocalizedDescriptionKey : @"Could not find valid JSON range." }; *error = [NSError errorWithDomain:NSCocoaErrorDomain code:CustomJSONSerializationErrorCode userInfo:userInfo]; } return NSMakeRange(NSNotFound, 0); } - (void)enableBridge { if (!self.bridgeEnabled) { self.bridgeEnabled = YES; [self setupUnixDomainSocket]; [self startBridge]; } } - (void)disableBridge { if (self.bridgeEnabled) { self.bridgeEnabled = NO; [self tearDownUnixDomainSocket]; [self stopBridge]; } } - (BOOL)isBridgeEnabled { return self.bridgeEnabled; } - (void)tearDownUnixDomainSocket { // Implement the logic to close and clean up the Unix domain socket // This might involve closing the server socket and any client connections if (self.serverSocket != -1) { close(self.serverSocket); self.serverSocket = -1; } unlink(socket_path); } - (void)startBridge { dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ [self runBridge]; }); } - (void)stopBridge { self.bridgeRunning = NO; // Add any necessary cleanup code here } - (void)runBridge { self.bridgeRunning = YES; while (self.bridgeRunning) { // Implement the bridge logic here // This could involve listening for connections, forwarding messages, etc. // Make sure to check self.bridgeRunning periodically to allow for clean shutdown if (self.serverSocket != -1) { int clientSocket = accept(self.serverSocket, NULL, NULL); if (clientSocket != -1) { [self handleClientConnection:clientSocket]; } } // Add a small delay to prevent tight looping usleep(10000); // 10ms delay } } ```
IPC manager (to connect to the socket) ```ts import { asyncExitHook } from 'exit-hook' import fs from 'fs' import ipc from 'node-ipc' import PQueue from 'p-queue' import pDefer, { DeferredPromise } from 'p-defer' // import pDefer, { // DeferredPerfPromise as DeferredPromise, // } from './defer/pDeferPerf.js' import { performance } from 'node:perf_hooks' import { exists } from '../utils/fs.js' import { PBatch } from '../utils/p-batch.js' import createDebug from 'debug' const debug = createDebug('phoenix:manager') type RPCId = number type PerfStats = { connectStart?: number disconnectStart?: number } type ClientMessage = { type: 'command' command: string args?: any rpcId: RPCId id?: string } type ServerResponse = { rpcId: RPCId value?: any error?: string } type ServerResponseBatch = { type: 'batch' data: ServerResponse[] __duration?: number } type ServerResponseError = { rpcId: RPCId error: string } type ServerMessage = ServerResponse | ServerResponseBatch | ServerResponseError function isErrorResponse( response: ServerMessage, ): response is ServerResponseError { return 'error' in response } function isBatchResponse( response: ServerMessage, ): response is ServerResponseBatch { return 'type' in response && response.type === 'batch' } function isSingleResponse(response: ServerMessage): response is ServerResponse { return 'rpcId' in response && 'value' in response } type ConnectionResult = { took: string } const IS_TEST = process.env.NODE_ENV === 'test' const SILENT = !IS_TEST class IPCManager { private socketPath: string // private queue = new PQueue({ concurrency: 1 }) private rpcs: Map> = new Map() private rpcCounter: number = 0 private removeExitHook: (() => void) | null = null private connectDeferred = pDefer() private disconnectDeferred = pDefer() private perfStats: PerfStats = {} private batch: PBatch = new PBatch((data) => { // console.log('Batch', data) if (data.length === 1) { ipc.of.world.emit(JSON.stringify(data[0])) } else { debug('Batching', data.length) ipc.of.world.emit(JSON.stringify({ type: 'batch', batch: data })) } }) private constructor(socketPath: string) { this.socketPath = socketPath ipc.config.retry = 1000 ipc.config.maxRetries = 1 ipc.config.rawBuffer = true ipc.config.silent = SILENT if (!SILENT) { ipc.config.logger = (msg) => { if ( IS_TEST && (msg.endsWith('stopRetrying flag set.') || msg.startsWith('connection closed world') || msg.startsWith('retrying reset')) ) { return } console.log(msg) } } else { ipc.config.logger = console.log.bind(console) } ipc.config.sync = true } static async create(socketPath: string) { if (!(await exists(socketPath))) { throw new Error(`Socket file ${socketPath} does not exist.`) } const manager = new IPCManager(socketPath) await manager.connect() return manager } static createSync(socketPath: string) { const manager = new IPCManager(socketPath) manager.connectSync() return manager } private connectSync() { this.connect().catch((e) => { console.error('Error connecting', e) this.connectDeferred.reject(e) }) } private handleError(e: any) { const brk = '\n\n-------------\n\n\n' console.error(brk, 'ipc.of.world.on(error):', e, brk) } public async disconnect() { this.perfStats.disconnectStart = performance.now() ipc.disconnect('world') await this.disconnectDeferred.promise const duration = performance.now() - this.perfStats.disconnectStart debug(`Disconnected from phoenix (${duration.toFixed(2)}ms)`) return duration } private handleConnect() { if (this._connected) { console.error('Already connected') } this._connected = true const duration = performance.now() - this.perfStats.connectStart! const connectRes = { took: `${duration.toFixed(2)}ms` } // ipc.of.world.on('disconnect', () => { // console.error('Disconnected from phoenix') // }) this.removeExitHook = asyncExitHook( async () => { console.error('Disconnecting (exitHook)') this.perfStats.disconnectStart = performance.now() ipc.disconnect('world') await this.disconnectDeferred.promise const duration = performance.now() - this.perfStats.disconnectStart debug(`Disconnected from phoenix (${duration.toFixed(2)}ms)`) }, { wait: 1000 }, ) this.connectDeferred.resolve(connectRes) } public waitForConnect(): Promise< Awaited > { return this.connectDeferred.promise } public getInfo() { return { connected: this._connected, rpcs: this.rpcs.size, rpcCounter: this.rpcCounter, batch: this.batch.stats, stopRetrying: ipc.config.stopRetrying, } } private handleDisconnect() { if (!this._connected) { this.connectDeferred.reject(new Error('Disconnected before connected')) } this._connected = false if (this.removeExitHook) { this.removeExitHook() } this.rpcs.forEach((_, id) => { this.rpcs.delete(id) }) ipc.config.silent = SILENT this.disconnectDeferred.resolve(true) } private _connectCalled = false private _connected = false private async connect() { if (this._connected) { throw new Error('Already connected') } if (this._connectCalled) { throw new Error('Connect already called') } this._connectCalled = true this.perfStats.connectStart = performance.now() ipc.connectTo('world', this.socketPath) ipc.of.world.on('error', this.handleError.bind(this)) ipc.of.world.on('data', this.readWorker.bind(this)) ipc.of.world.on('connect', this.handleConnect.bind(this)) ipc.of.world.on('disconnect', this.handleDisconnect.bind(this)) await this.connectDeferred.promise } private readWorker(data: Buffer) { // console.log('got a message from world : ', data.toString()) try { const json = JSON.parse(data.toString()) as ServerMessage if (isBatchResponse(json)) { const durations = [] as number[] json.data.forEach((d) => { const { rpcId, value, error } = d const rpc = this.rpcs.get(rpcId) if (rpc) { if (error) { rpc.reject(new Error(error)) } else { // durations.push(rpc.timeNow()) rpc.resolve(value) } this.rpcs.delete(rpcId) } else { console.error('No RPC found for', rpcId) } }) if (durations.length) { const avg = durations.reduce((a, b) => a + b, 0) / durations.length if (json.__duration) { debug( `Batch took ${json.__duration.toFixed( 2, )}ms, avg RPC took ${avg.toFixed(2)}ms`, ) } } } else if (isErrorResponse(json)) { const { rpcId, error } = json const rpc = this.rpcs.get(rpcId) if (rpc) { rpc.reject(new Error(error)) this.rpcs.delete(rpcId) } else { console.error('No RPC found for', rpcId) } } else { const { rpcId, value } = json const rpc = this.rpcs.get(rpcId) if (rpc) { rpc.resolve(value) this.rpcs.delete(rpcId) } else { console.error('No RPC found for', rpcId) } } } catch (e) { const brk = '\n\n-------------\n\n\n' console.error(brk, 'Error parsing JSON', e, brk) } } private getRPCId(): RPCId { return this.rpcCounter++ } public command(command: string, args: any): Promise { const rpcId = this.getRPCId() const query: ClientMessage = { type: 'command', command, rpcId } if (args && Array.isArray(args)) { if (args.length) { query.args = args } // Empty args } else if (args && typeof args === 'object') { if (args.id) { const { id, ...rest } = args query.id = id const keys = Object.keys(rest) if (keys.length) { if (keys.length === 1 && keys[0] === 'args') { if (rest.args.length === 1 && rest.args[0] === undefined) { } else { query.args = rest.args } } else { query.args = rest } } } else if (Object.keys(args).length) { query.args = args } } else { console.warn('Unknown args', args) } debug('Query', query) // console.log('Query', query) const deferred = pDefer() this.rpcs.set(rpcId, deferred) this.batch.call(query) return deferred.promise } } type InstanceOrArray = T extends (new (...args: any[]) => any)[] ? InstanceType[] : T extends new (...args: any[]) => any ? InstanceType : undefined export function createProxy< T extends new (...args: any[]) => any, U extends any[] | any, >(cls: T, result: U): U { if (Array.isArray(result)) { return result.flatMap((r) => createProxy(cls, r)) as InstanceOrArray } if (!result || (typeof result === 'object' && 'error' in result)) { return undefined as InstanceOrArray } return new cls(result) as InstanceOrArray } export const manager: IPCManager = IPCManager.createSync('/tmp/phoenix.socket') ```

There's a few things to clear up before it's ready for a PR:

  • Unix socket implementation is not great, it doesn't handle parallel calls, I had to set up some batching logic to get better troughput. Need a lib to do websocket over unix-socket like iTerm2.
  • Needs some auth? iTerm2 has a cool way of using macOS permissions to generate "cookies" for the api: https://iterm2.com/python-api-auth.html
  • The jump from native->js->native feels dirty, but it also brings a lot of cool stuff like being able to config/intercept messages from the config, trigger different already defined snippets etc...

Happy to discuss and try out suggestions! I have been running this for a few months now and its really neat! I can now connect and use Phoenix apis from Raycast, listing windows, recently opened etc, combined with the iTerm2 api and some local LLMs, it can enable really cool stuff:

Raycast example image