diff --git a/.gitmodules b/.gitmodules index c421499b2fc6bcf339ab8aaad4add2762ad25848..b2afcdb8fc24dd233d26882f2a1dafb09a6d9b24 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,7 @@ [submodule "plugins/win-dshow/libdshowcapture"] path = plugins/win-dshow/libdshowcapture url = https://github.com/jp9000/libdshowcapture.git + +[submodule "plugins/mac-syphon/syphon-framework"] + path = plugins/mac-syphon/syphon-framework + url = https://github.com/palana/Syphon-Framework.git diff --git a/plugins/CMakeLists.txt b/plugins/CMakeLists.txt index 4e8664441ac7abcc5aa61dd87302c6c6d805b922..010871eae9a83067b5b86944c45c2aa0b5b07752 100644 --- a/plugins/CMakeLists.txt +++ b/plugins/CMakeLists.txt @@ -6,6 +6,7 @@ if(WIN32) elseif(APPLE) add_subdirectory(mac-avcapture) add_subdirectory(mac-capture) + add_subdirectory(mac-syphon) elseif("${CMAKE_SYSTEM_NAME}" MATCHES "Linux") add_subdirectory(linux-capture) add_subdirectory(linux-pulseaudio) diff --git a/plugins/mac-syphon/CMakeLists.txt b/plugins/mac-syphon/CMakeLists.txt new file mode 100644 index 0000000000000000000000000000000000000000..66ca8aed59c1230c1942fe8cedd3b40cfb5e637b --- /dev/null +++ b/plugins/mac-syphon/CMakeLists.txt @@ -0,0 +1,84 @@ +project(mac-syphon) + +find_library(COCOA Cocoa) +find_library(IOSURF IOSurface) +find_library(SCRIPTINGBRIDGE ScriptingBridge) +find_package(OpenGL REQUIRED) + +include_directories(${COCOA} + ${IOSURF} + ${SCRIPTINGBIRDGE} + ${OPENGL_INCLUDE_DIR}) + +set(syphon_HEADERS + syphon-framework/Syphon_Prefix.pch + syphon-framework/Syphon.h + syphon-framework/SyphonBuildMacros.h + syphon-framework/SyphonCFMessageReceiver.h + syphon-framework/SyphonCFMessageSender.h + syphon-framework/SyphonClient.h + syphon-framework/SyphonClientConnectionManager.h + syphon-framework/SyphonDispatch.h + syphon-framework/SyphonIOSurfaceImage.h + syphon-framework/SyphonImage.h + syphon-framework/SyphonMachMessageReceiver.h + syphon-framework/SyphonMachMessageSender.h + syphon-framework/SyphonMessageQueue.h + syphon-framework/SyphonMessageReceiver.h + syphon-framework/SyphonMessageSender.h + syphon-framework/SyphonMessaging.h + syphon-framework/SyphonOpenGLFunctions.h + syphon-framework/SyphonPrivate.h + syphon-framework/SyphonServer.h + syphon-framework/SyphonServerConnectionManager.h + syphon-framework/SyphonServerDirectory.h + ) + +set(syphon_SOURCES + syphon-framework/SyphonCFMessageReceiver.m + syphon-framework/SyphonCFMessageSender.m + syphon-framework/SyphonClient.m + syphon-framework/SyphonClientConnectionManager.m + syphon-framework/SyphonDispatch.c + syphon-framework/SyphonImage.m + syphon-framework/SyphonIOSurfaceImage.m + syphon-framework/SyphonMachMessageReceiver.m + syphon-framework/SyphonMachMessageSender.m + syphon-framework/SyphonMessageQueue.m + syphon-framework/SyphonMessageReceiver.m + syphon-framework/SyphonMessageSender.m + syphon-framework/SyphonMessaging.m + syphon-framework/SyphonOpenGLFunctions.c + syphon-framework/SyphonPrivate.m + syphon-framework/SyphonServer.m + syphon-framework/SyphonServerConnectionManager.m + syphon-framework/SyphonServerDirectory.m + ) + +set(mac-syphon_HEADERS + ) + +set(mac-syphon_SOURCES + syphon.m + plugin-main.c) + +set_source_files_properties(${mac-syphon_SOURCES} ${syphon_SOURCES} + PROPERTIES LANGUAGE C) + +add_definitions(-DSYPHON_UNIQUE_CLASS_NAME_PREFIX=OBS_ -include + ${PROJECT_SOURCE_DIR}/syphon-framework/Syphon_Prefix.pch) + +add_library(mac-syphon MODULE + ${mac-syphon_SOURCES} + ${mac-syphon_HEADERS} + ${syphon_HEADERS} + ${syphon_SOURCES}) + +target_link_libraries(mac-syphon + libobs + ${COCOA} + ${IOSURF} + ${SCRIPTINGBRIDGE} + ${OPENGL_gl_LIBRARY}) + +install_obs_plugin_with_data(mac-syphon data) diff --git a/plugins/mac-syphon/data/locale/en-US.ini b/plugins/mac-syphon/data/locale/en-US.ini new file mode 100644 index 0000000000000000000000000000000000000000..f09cdfbca71cc1de9fcec5704b008579d08affc5 --- /dev/null +++ b/plugins/mac-syphon/data/locale/en-US.ini @@ -0,0 +1,10 @@ +Source="Source" +LaunchSyphonInject="Launch SyphonInject" +Inject="Inject" +Application="Application" +SyphonLicense="Syphon License" +Crop="Crop" +Crop.origin.x="Crop left" +Crop.origin.y="Crop top" +Crop.size.width="Crop right" +Crop.size.height="Crop bottom" diff --git a/plugins/mac-syphon/data/syphon_license.txt b/plugins/mac-syphon/data/syphon_license.txt new file mode 100644 index 0000000000000000000000000000000000000000..4b2a68ebd9468ad71d3ad1614454dd715fe0033c --- /dev/null +++ b/plugins/mac-syphon/data/syphon_license.txt @@ -0,0 +1,26 @@ +Syphon + +Copyright 2010-2011 bangnoise (Tom Butterworth) & vade (Anton Marini). +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + diff --git a/plugins/mac-syphon/plugin-main.c b/plugins/mac-syphon/plugin-main.c new file mode 100644 index 0000000000000000000000000000000000000000..011e711f8e312c4d3a11111edbfa9e20f82585a8 --- /dev/null +++ b/plugins/mac-syphon/plugin-main.c @@ -0,0 +1,12 @@ +#include <obs-module.h> + +OBS_DECLARE_MODULE() +OBS_MODULE_USE_DEFAULT_LOCALE("syphon", "en-US") + +extern struct obs_source_info syphon_info; + +bool obs_module_load(void) +{ + obs_register_source(&syphon_info); + return true; +} diff --git a/plugins/mac-syphon/syphon-framework b/plugins/mac-syphon/syphon-framework new file mode 160000 index 0000000000000000000000000000000000000000..01b144811f6f7080b70b2d7cc729da071f86f9d7 --- /dev/null +++ b/plugins/mac-syphon/syphon-framework @@ -0,0 +1 @@ +Subproject commit 01b144811f6f7080b70b2d7cc729da071f86f9d7 diff --git a/plugins/mac-syphon/syphon.m b/plugins/mac-syphon/syphon.m new file mode 100644 index 0000000000000000000000000000000000000000..aaf3373ba9540210ef6fda52faca48e49f0f96c9 --- /dev/null +++ b/plugins/mac-syphon/syphon.m @@ -0,0 +1,1125 @@ +#import <Cocoa/Cocoa.h> +#import <ScriptingBridge/ScriptingBridge.h> +#import "syphon-framework/Syphon.h" +#include <obs-module.h> + +#define LOG(level, message, ...) \ + blog(level, "%s: " message, obs_source_get_name(s->source), ##__VA_ARGS__) + +struct syphon { + SYPHON_CLIENT_UNIQUE_CLASS_NAME *client; + IOSurfaceRef ref; + + gs_samplerstate_t *sampler; + gs_effect_t *effect; + gs_vertbuffer_t *vertbuffer; + gs_texture_t *tex; + uint32_t width, height; + bool crop; + CGRect crop_rect; + + obs_source_t *source; + + bool active; + bool uuid_changed; + id new_server_listener; + id retire_listener; + + NSString *app_name; + NSString *name; + NSString *uuid; + + obs_data_t *inject_info; + bool inject_active; + id launch_listener; +}; +typedef struct syphon *syphon_t; + +static inline void update_properties(syphon_t s) +{ + obs_source_update_properties(s->source); +} + +static inline void find_and_inject_target(syphon_t s, NSArray *arr); + +@interface OBSSyphonKVObserver : NSObject +- (void)observeValueForKeyPath:(NSString *)keyPath + ofObject:(id)object + change:(NSDictionary *)change + context:(void *)context; +@end + +static inline void handle_application_launch(syphon_t s, NSArray *new) +{ + if (!s->inject_active) + return; + + if (!new) + return; + + find_and_inject_target(s, new); +} + +@implementation OBSSyphonKVObserver +- (void)observeValueForKeyPath:(NSString *)keyPath + ofObject:(id)object + change:(NSDictionary *)change + context:(void *)context +{ + UNUSED_PARAMETER(keyPath); + UNUSED_PARAMETER(object); + + syphon_t s = context; + if (!s) + return; + + handle_application_launch(s, change[NSKeyValueChangeNewKey]); + update_properties(s); +} +@end + +static const char *syphon_get_name() +{ + return "Syphon"; +} + +static void stop_client(syphon_t s) +{ + obs_enter_graphics(); + + if (s->client) { + @autoreleasepool { + [s->client stop]; + [s->client release]; + s->client = nil; + } + } + + if (s->tex) { + gs_texture_destroy(s->tex); + s->tex = NULL; + } + + if (s->ref) { + IOSurfaceDecrementUseCount(s->ref); + CFRelease(s->ref); + s->ref = NULL; + } + + s->width = 0; + s->height = 0; + + obs_leave_graphics(); +} + +static inline NSDictionary *find_by_uuid(NSArray *arr, NSString *uuid) +{ + for (NSDictionary *dict in arr) { + if ([dict[SyphonServerDescriptionUUIDKey] isEqual:uuid]) + return dict; + } + + return nil; +} + +static inline void check_version(syphon_t s, NSDictionary *desc) +{ + extern const NSString *SyphonServerDescriptionDictionaryVersionKey; + + NSNumber *version = desc[SyphonServerDescriptionDictionaryVersionKey]; + if (!version) + return LOG(LOG_WARNING, "Server description does not contain " + "VersionKey"); + + if (version.unsignedIntValue > 0) + LOG(LOG_WARNING, "Got server description version %d, " + "expected 0", version.unsignedIntValue); +} + +static inline void check_description(syphon_t s, NSDictionary *desc) +{ + extern const NSString *SyphonSurfaceType; + extern const NSString *SyphonSurfaceTypeIOSurface; + extern const NSString *SyphonServerDescriptionSurfacesKey; + + NSArray *surfaces = desc[SyphonServerDescriptionSurfacesKey]; + if (!surfaces) + return LOG(LOG_WARNING, "Server description does not contain " + "SyphonServerDescriptionSurfacesKey"); + + if (!surfaces.count) + return LOG(LOG_WARNING, "Server description contains empty " + "SyphonServerDescriptionSurfacesKey"); + + for (NSDictionary *surface in surfaces) { + NSString *type = surface[SyphonSurfaceType]; + if (type && [type isEqual:SyphonSurfaceTypeIOSurface]) + return; + } + + NSString *surfaces_string = [NSString stringWithFormat:@"%@", surfaces]; + LOG(LOG_WARNING, "SyphonSurfaces does not contain" + "'SyphonSurfaceTypeIOSurface': %s", + surfaces_string.UTF8String); +} + +static inline bool update_string(NSString **str, NSString *new) +{ + if (!new) + return false; + + [*str release]; + *str = [new retain]; + return true; +} + +static inline void handle_new_frame(syphon_t s, + SYPHON_CLIENT_UNIQUE_CLASS_NAME *client) +{ + IOSurfaceRef ref = [client IOSurface]; + + if (!ref) + return; + + if (ref == s->ref) { + CFRelease(ref); + return; + } + + IOSurfaceIncrementUseCount(ref); + + obs_enter_graphics(); + if (s->ref) { + gs_texture_destroy(s->tex); + IOSurfaceDecrementUseCount(s->ref); + CFRelease(s->ref); + } + + s->ref = ref; + s->tex = gs_texture_create_from_iosurface(s->ref); + s->width = gs_texture_get_width(s->tex); + s->height = gs_texture_get_height(s->tex); + obs_leave_graphics(); +} + +static void create_client(syphon_t s) +{ + stop_client(s); + + if (!s->app_name.length && !s->name.length && !s->uuid.length) + return; + + SyphonServerDirectory *ssd = [SyphonServerDirectory sharedDirectory]; + NSArray *servers = [ssd serversMatchingName:s->name + appName:s->app_name]; + if (!servers.count) + return; + + NSDictionary *desc = find_by_uuid(servers, s->uuid); + if (!desc) { + desc = servers[0]; + if (update_string(&s->uuid, + desc[SyphonServerDescriptionUUIDKey])) + s->uuid_changed = true; + } + + check_version(s, desc); + check_description(s, desc); + + @autoreleasepool { + s->client = [[SYPHON_CLIENT_UNIQUE_CLASS_NAME alloc] + initWithServerDescription:desc + options:nil + newFrameHandler:^ + (SYPHON_CLIENT_UNIQUE_CLASS_NAME *client) + { + handle_new_frame(s, client); + }]; + } + + s->active = true; +} + +static inline void release_settings(syphon_t s) +{ + [s->app_name release]; + [s->name release]; + [s->uuid release]; +} + +static inline bool load_syphon_settings(syphon_t s, obs_data_t *settings) +{ + NSString *app_name = @(obs_data_get_string(settings, "app_name")); + NSString *name = @(obs_data_get_string(settings, "name")); + bool equal_names = [app_name isEqual:s->app_name] && + [name isEqual:s->name]; + if (s->uuid_changed && equal_names) + return false; + + NSString *uuid = @(obs_data_get_string(settings, "uuid")); + if ([uuid isEqual:s->uuid] && equal_names) + return false; + + release_settings(s); + s->app_name = [app_name retain]; + s->name = [name retain]; + s->uuid = [uuid retain]; + s->uuid_changed = false; + return true; +} + +static inline void update_from_announce(syphon_t s, NSDictionary *info) +{ + if (s->active) + return; + + if (!info) + return; + + NSString *app_name = info[SyphonServerDescriptionAppNameKey]; + NSString *name = info[SyphonServerDescriptionNameKey]; + NSString *uuid = info[SyphonServerDescriptionUUIDKey]; + + if (![uuid isEqual:s->uuid] && + !([app_name isEqual:s->app_name] && [name isEqual:s->name])) + return; + + update_string(&s->app_name, app_name); + update_string(&s->name, name); + if (update_string(&s->uuid, uuid)) + s->uuid_changed = true; + + create_client(s); +} + +static inline void handle_announce(syphon_t s, NSNotification *note) +{ + if (!note) + return; + + update_from_announce(s, note.object); + update_properties(s); +} + +static inline void update_from_retire(syphon_t s, NSDictionary *info) +{ + if (!info) + return; + + NSString *uuid = info[SyphonServerDescriptionUUIDKey]; + if (!uuid) + return; + + if (![uuid isEqual:s->uuid]) + return; + + s->active = false; +} + +static inline void handle_retire(syphon_t s, NSNotification *note) +{ + if (!note) + return; + + update_from_retire(s, note.object); + update_properties(s); +} + +static inline gs_vertbuffer_t *create_vertbuffer() +{ + struct gs_vb_data *vb_data = gs_vbdata_create(); + vb_data->num = 4; + vb_data->points = bzalloc(sizeof(struct vec3) * 4); + if (!vb_data->points) + return NULL; + + vb_data->num_tex = 1; + vb_data->tvarray = bzalloc(sizeof(struct gs_tvertarray)); + if (!vb_data->tvarray) + goto fail_tvarray; + + vb_data->tvarray[0].width = 2; + vb_data->tvarray[0].array = bzalloc(sizeof(struct vec2) * 4); + if (!vb_data->tvarray[0].array) + goto fail_array; + + gs_vertbuffer_t *vbuff = gs_vertexbuffer_create(vb_data, GS_DYNAMIC); + if (vbuff) + return vbuff; + + bfree(vb_data->tvarray[0].array); +fail_array: + bfree(vb_data->tvarray); +fail_tvarray: + bfree(vb_data->points); + + return NULL; +} + +static inline bool init_obs_graphics_objects(syphon_t s) +{ + struct gs_sampler_info info = { + .filter = GS_FILTER_LINEAR, + .address_u = GS_ADDRESS_CLAMP, + .address_v = GS_ADDRESS_CLAMP, + .address_w = GS_ADDRESS_CLAMP, + .max_anisotropy = 1, + }; + + obs_enter_graphics(); + s->sampler = gs_samplerstate_create(&info); + s->vertbuffer = create_vertbuffer(); + obs_leave_graphics(); + + s->effect = obs_get_default_rect_effect(); + + return s->sampler != NULL && s->vertbuffer != NULL && s->effect != NULL; +} + +static inline bool create_syphon_listeners(syphon_t s) +{ + NSNotificationCenter *nc = [NSNotificationCenter defaultCenter]; + s->new_server_listener = [nc + addObserverForName:SyphonServerAnnounceNotification + object:nil + queue:[NSOperationQueue mainQueue] + usingBlock:^(NSNotification *note) + { + handle_announce(s, note); + } + ]; + + s->retire_listener = [nc + addObserverForName:SyphonServerRetireNotification + object:nil + queue:[NSOperationQueue mainQueue] + usingBlock:^(NSNotification *note) + { + handle_retire(s, note); + } + ]; + + return s->new_server_listener != nil && s->retire_listener != nil; +} + +static inline bool create_applications_observer(syphon_t s, NSWorkspace *ws) +{ + s->launch_listener = [[OBSSyphonKVObserver alloc] init]; + if (!s->launch_listener) + return false; + + [ws addObserver:s->launch_listener + forKeyPath:NSStringFromSelector(@selector(runningApplications)) + options:NSKeyValueObservingOptionNew + context:s]; + + return true; +} + +static inline void load_crop(syphon_t s, obs_data_t *settings) +{ + s->crop = obs_data_get_bool(settings, "crop"); + +#define LOAD_CROP(x) \ + s->crop_rect.x = obs_data_get_double(settings, "crop." #x) + LOAD_CROP(origin.x); + LOAD_CROP(origin.y); + LOAD_CROP(size.width); + LOAD_CROP(size.height); +#undef LOAD_CROP +} + +static inline void syphon_destroy_internal(syphon_t s); + +static void *syphon_create_internal(obs_data_t *settings, obs_source_t *source) +{ + UNUSED_PARAMETER(source); + + syphon_t s = bzalloc(sizeof(struct syphon)); + if (!s) + return s; + + s->source = source; + + if (!init_obs_graphics_objects(s)) + goto fail; + + if (!load_syphon_settings(s, settings)) + goto fail; + + const char *inject_info = obs_data_get_string(settings, "application"); + s->inject_info = obs_data_create_from_json(inject_info); + s->inject_active = obs_data_get_bool(settings, "inject"); + + if (!create_syphon_listeners(s)) + goto fail; + + NSWorkspace *ws = [NSWorkspace sharedWorkspace]; + if (!create_applications_observer(s, ws)) + goto fail; + + if (s->inject_active) + find_and_inject_target(s, ws.runningApplications); + + create_client(s); + + load_crop(s, settings); + + return s; + +fail: + syphon_destroy_internal(s); + return NULL; +} + +static void *syphon_create(obs_data_t *settings, obs_source_t *source) +{ + @autoreleasepool { + return syphon_create_internal(settings, source); + } +} + +static inline void stop_listener(id listener) +{ + if (!listener) + return; + + NSNotificationCenter *nc = [NSNotificationCenter defaultCenter]; + [nc removeObserver:listener]; +} + +static inline void syphon_destroy_internal(syphon_t s) +{ + stop_listener(s->new_server_listener); + stop_listener(s->retire_listener); + + NSWorkspace *ws = [NSWorkspace sharedWorkspace]; + [ws removeObserver:s->launch_listener + forKeyPath:NSStringFromSelector(@selector(runningApplications))]; + [s->launch_listener release]; + + obs_data_release(s->inject_info); + + release_settings(s); + + obs_enter_graphics(); + stop_client(s); + + if (s->sampler) + gs_samplerstate_destroy(s->sampler); + if (s->vertbuffer) + gs_vertexbuffer_destroy(s->vertbuffer); + obs_leave_graphics(); + + bfree(s); +} + +static void syphon_destroy(void *data) +{ + @autoreleasepool { + syphon_destroy_internal(data); + } +} + +static inline NSString *get_string(obs_data_t *settings, const char *name) +{ + if (!settings) + return nil; + + return @(obs_data_get_string(settings, name)); +} + +static inline void update_strings_from_context(syphon_t s, obs_data_t *settings, + NSString **app, NSString **name, NSString **uuid) +{ + if (!s || !s->uuid_changed) + return; + + s->uuid_changed = false; + *app = s->app_name; + *name = s->name; + *uuid = s->uuid; + + obs_data_set_string(settings, "app_name", s->app_name.UTF8String); + obs_data_set_string(settings, "name", s->name.UTF8String); + obs_data_set_string(settings, "uuid", s->uuid.UTF8String); +} + +static inline void add_servers(syphon_t s, obs_property_t *list, + obs_data_t *settings) +{ + bool found_current = settings == NULL; + + NSString *set_app = get_string(settings, "app_name"); + NSString *set_name = get_string(settings, "name"); + NSString *set_uuid = get_string(settings, "uuid"); + + update_strings_from_context(s, settings, + &set_app, &set_name, &set_uuid); + + obs_property_list_add_string(list, "", ""); + NSArray *arr = [[SyphonServerDirectory sharedDirectory] servers]; + for (NSDictionary *server in arr) { + NSString *app = server[SyphonServerDescriptionAppNameKey]; + NSString *name = server[SyphonServerDescriptionNameKey]; + NSString *uuid = server[SyphonServerDescriptionUUIDKey]; + NSString *serv = [NSString stringWithFormat:@"[%@] %@", + app, name]; + + obs_property_list_add_string(list, + serv.UTF8String, uuid.UTF8String); + + if (!found_current) + found_current = [uuid isEqual:set_uuid]; + } + + if (found_current || !set_uuid.length || !set_app.length) + return; + + NSString *serv = [NSString stringWithFormat:@"[%@] %@", + set_app, set_name]; + size_t idx = obs_property_list_add_string(list, + serv.UTF8String, set_uuid.UTF8String); + obs_property_list_item_disable(list, idx, true); +} + +static bool servers_changed(obs_properties_t *props, obs_property_t *list, + obs_data_t *settings) +{ + @autoreleasepool { + obs_property_list_clear(list); + add_servers(obs_properties_get_param(props), list, settings); + return true; + } +} + +static inline NSString *get_inject_application_path() +{ + static NSString *ident = @"zakk.lol.SyphonInject"; + NSWorkspace *ws = [NSWorkspace sharedWorkspace]; + return [ws absolutePathForAppBundleWithIdentifier:ident]; +} + +static inline bool is_inject_available_in_lib_dir(NSFileManager *fm, NSURL *url) +{ + if (!url.isFileURL) + return false; + + for (NSString *path in [fm + contentsOfDirectoryAtPath:url.path + error:nil]) { + NSURL *bundle_url = [url URLByAppendingPathComponent:path]; + NSBundle *bundle = [NSBundle bundleWithURL:bundle_url]; + if (!bundle) + continue; + + if ([bundle.bundleIdentifier + isEqual:@"zakk.lol.SASyphonInjector"]) + return true; + } + + return false; +} + +static inline bool is_inject_available() +{ + if (get_inject_application_path()) + return true; + + NSFileManager *fm = [NSFileManager defaultManager]; + for (NSURL *url in [fm URLsForDirectory:NSLibraryDirectory + inDomains:NSAllDomainsMask]) { + NSURL *scripting = [url + URLByAppendingPathComponent:@"ScriptingAdditions" + isDirectory:true]; + if (is_inject_available_in_lib_dir(fm, scripting)) + return true; + } + + return false; +} + +static inline void launch_syphon_inject_internal() +{ + NSString *path = get_inject_application_path(); + NSWorkspace *ws = [NSWorkspace sharedWorkspace]; + if (path) + [ws launchApplication:path]; +} + +static bool launch_syphon_inject(obs_properties_t *props, obs_property_t *prop, + void *data) +{ + UNUSED_PARAMETER(props); + UNUSED_PARAMETER(prop); + UNUSED_PARAMETER(data); + + @autoreleasepool { + launch_syphon_inject_internal(); + return false; + } +} + +static int describes_app(obs_data_t *info, NSRunningApplication *app) +{ + int score = 0; + if ([app.localizedName isEqual:get_string(info, "name")]) + score += 1; + + if ([app.bundleIdentifier isEqual:get_string(info, "bundle")]) + score += 1; + + if ([app.executableURL isEqual:get_string(info, "executable")]) + score += 1; + + if (score && app.processIdentifier == obs_data_get_int(info, "pid")) + score += 1; + + return score; +} + +static inline void app_to_data(NSRunningApplication *app, obs_data_t *app_data) +{ + obs_data_set_string(app_data, "name", app.localizedName.UTF8String); + obs_data_set_string(app_data, "bundle", + app.bundleIdentifier.UTF8String); + obs_data_set_string(app_data, "executable", + app.executableURL.fileSystemRepresentation); + obs_data_set_int(app_data, "pid", app.processIdentifier); +} + +static void update_inject_list_internal(obs_properties_t *props, + obs_property_t *prop, obs_data_t *settings) +{ + UNUSED_PARAMETER(props); + + const char *current_str = obs_data_get_string(settings, "application"); + obs_data_t *current = obs_data_create_from_json(current_str); + + bool current_found = !obs_data_has_user_value(current, "name"); + + obs_property_list_clear(prop); + obs_property_list_add_string(prop, "", ""); + + NSMapTable *candidates = [NSMapTable weakToStrongObjectsMapTable]; + + obs_data_t *app_data = obs_data_create(); + NSWorkspace *ws = [NSWorkspace sharedWorkspace]; + for (NSRunningApplication *app in ws.runningApplications) { + app_to_data(app, app_data); + + obs_property_list_add_string(prop, app.localizedName.UTF8String, + obs_data_get_json(app_data)); + + int score = describes_app(current, app); + if (score) { + [candidates setObject:@(score) forKey:app]; + current_found = true; + } + } + obs_data_release(app_data); + + if (!current_found) { + size_t idx = obs_property_list_add_string(prop, + obs_data_get_string(current, "name"), + current_str); + obs_property_list_item_disable(prop, idx, true); + + } else if (candidates.count > 0) { + NSRunningApplication *best_match = nil; + NSNumber *best_match_score = @(0); + + for (NSRunningApplication *app in candidates.keyEnumerator) { + NSNumber *score = [candidates objectForKey:app]; + if ([score compare:best_match_score] == + NSOrderedDescending) { + best_match = app; + best_match_score = score; + } + } + + app_to_data(best_match, current); + obs_data_set_string(settings, "application", + obs_data_get_json(current)); + } + + obs_data_release(current); +} + +static void toggle_inject_internal(obs_properties_t *props, + obs_property_t *prop, obs_data_t *settings) +{ + bool enabled = obs_data_get_bool(settings, "inject"); + obs_property_t *inject_list = obs_properties_get(props, "application"); + + bool inject_enabled = obs_property_enabled(prop); + obs_property_set_enabled(inject_list, enabled && inject_enabled); +} + +static bool toggle_inject(obs_properties_t *props, obs_property_t *prop, + obs_data_t *settings) +{ + @autoreleasepool { + toggle_inject_internal(props, prop, settings); + return true; + } +} + +static bool update_inject_list(obs_properties_t *props, obs_property_t *prop, + obs_data_t *settings) +{ + @autoreleasepool { + update_inject_list_internal(props, prop, settings); + return true; + } +} + +static bool update_crop(obs_properties_t *props, obs_property_t *prop, + obs_data_t *settings) +{ + bool enabled = obs_data_get_bool(settings, "crop"); + +#define LOAD_CROP(x) \ + prop = obs_properties_get(props, "crop." #x); \ + obs_property_set_enabled(prop, enabled); + LOAD_CROP(origin.x); + LOAD_CROP(origin.y); + LOAD_CROP(size.width); + LOAD_CROP(size.height); +#undef LOAD_CROP + + return true; +} + +static void show_syphon_license_internal(void) +{ + char *path = obs_module_file("syphon_license.txt"); + if (!path) + return; + + NSWorkspace *ws = [NSWorkspace sharedWorkspace]; + [ws openFile:@(path)]; + bfree(path); +} + +static bool show_syphon_license(obs_properties_t *props, obs_property_t *prop, + void *data) +{ + UNUSED_PARAMETER(props); + UNUSED_PARAMETER(prop); + UNUSED_PARAMETER(data); + + @autoreleasepool { + show_syphon_license_internal(); + return false; + } +} + +static void syphon_release(void *param) +{ + if (!param) + return; + + obs_source_release(((syphon_t)param)->source); +} + +static inline obs_properties_t *syphon_properties_internal(syphon_t s) +{ + if (s) + obs_source_addref(s->source); + + obs_properties_t *props = obs_properties_create_param(s, + syphon_release); + + obs_property_t *list = obs_properties_add_list(props, + "uuid", obs_module_text("Source"), + OBS_COMBO_TYPE_LIST, OBS_COMBO_FORMAT_STRING); + obs_property_set_modified_callback(list, servers_changed); + + obs_property_t *launch = obs_properties_add_button(props, + "launch inject", obs_module_text("LaunchSyphonInject"), + launch_syphon_inject); + + obs_property_t *inject = obs_properties_add_bool(props, + "inject", obs_module_text("Inject")); + obs_property_set_modified_callback(inject, toggle_inject); + + obs_property_t *inject_list = obs_properties_add_list(props, + "application", obs_module_text("Application"), + OBS_COMBO_TYPE_LIST, OBS_COMBO_FORMAT_STRING); + obs_property_set_modified_callback(inject_list, update_inject_list); + + if (!get_inject_application_path()) + obs_property_set_enabled(launch, false); + + if (!is_inject_available()) { + obs_property_set_enabled(inject, false); + obs_property_set_enabled(inject_list, false); + } + + obs_property_t *crop = obs_properties_add_bool(props, "crop", + obs_module_text("Crop")); + obs_property_set_modified_callback(crop, update_crop); + +#define LOAD_CROP(x) \ + obs_properties_add_float(props, "crop." #x, \ + obs_module_text("Crop." #x), 0., 4096.f, .5f); + LOAD_CROP(origin.x); + LOAD_CROP(origin.y); + LOAD_CROP(size.width); + LOAD_CROP(size.height); +#undef LOAD_CROP + + obs_properties_add_button(props, "syphon license", + obs_module_text("SyphonLicense"), + show_syphon_license); + + return props; +} + +static obs_properties_t *syphon_properties(void *data) +{ + @autoreleasepool { + return syphon_properties_internal(data); + } +} + +static inline void syphon_save_internal(syphon_t s, obs_data_t *settings) +{ + if (!s->uuid_changed) + return; + + obs_data_set_string(settings, "app_name", s->app_name.UTF8String); + obs_data_set_string(settings, "name", s->name.UTF8String); + obs_data_set_string(settings, "uuid", s->uuid.UTF8String); +} + +static void syphon_save(void *data, obs_data_t *settings) +{ + @autoreleasepool { + syphon_save_internal(data, settings); + } +} + +static inline void build_sprite(struct gs_vb_data *data, float fcx, float fcy, + float start_u, float end_u, float start_v, float end_v) +{ + struct vec2 *tvarray = data->tvarray[0].array; + + vec3_set(data->points+1, fcx, 0.0f, 0.0f); + vec3_set(data->points+2, 0.0f, fcy, 0.0f); + vec3_set(data->points+3, fcx, fcy, 0.0f); + vec2_set(tvarray, start_u, start_v); + vec2_set(tvarray+1, end_u, start_v); + vec2_set(tvarray+2, start_u, end_v); + vec2_set(tvarray+3, end_u, end_v); +} + +static inline void build_sprite_rect(struct gs_vb_data *data, + float origin_x, float origin_y, float end_x, float end_y) +{ + build_sprite(data, fabs(end_x - origin_x), fabs(end_y - origin_y), + origin_x, end_x, + origin_y, end_y); +} + +static void syphon_video_tick(void *data, float seconds) +{ + UNUSED_PARAMETER(seconds); + + syphon_t s = data; + if (!s->tex) + return; + + static const CGRect null_crop = { { 0.f } }; + const CGRect *crop = &null_crop; + if (s->crop) + crop = &s->crop_rect; + + obs_enter_graphics(); + build_sprite_rect(gs_vertexbuffer_get_data(s->vertbuffer), + crop->origin.x, + s->height - crop->origin.y, + s->width - crop->size.width, + crop->size.height); + obs_leave_graphics(); +} + +static void syphon_video_render(void *data, gs_effect_t *effect) +{ + UNUSED_PARAMETER(effect); + + syphon_t s = data; + + if (!s->tex) + return; + + gs_vertexbuffer_flush(s->vertbuffer); + gs_load_vertexbuffer(s->vertbuffer); + gs_load_indexbuffer(NULL); + gs_load_samplerstate(s->sampler, 0); + gs_technique_t *tech = gs_effect_get_technique(s->effect, "Draw"); + gs_effect_set_texture(gs_effect_get_param_by_name(s->effect, "image"), + s->tex); + gs_technique_begin(tech); + gs_technique_begin_pass(tech, 0); + + gs_draw(GS_TRISTRIP, 0, 4); + + gs_technique_end_pass(tech); + gs_technique_end(tech); +} + +static uint32_t syphon_get_width(void *data) +{ + syphon_t s = (syphon_t)data; + if (!s->crop) + return s->width; + int32_t width = s->width + - s->crop_rect.origin.x + - s->crop_rect.size.width; + return MAX(0, width); +} + +static uint32_t syphon_get_height(void *data) +{ + syphon_t s = (syphon_t)data; + if (!s->crop) + return s->height; + int32_t height = s->height + - s->crop_rect.origin.y + - s->crop_rect.size.height; + return MAX(0, height); +} + +static inline void inject_app(syphon_t s, NSRunningApplication *app) +{ + SBApplication *sbapp = nil; + if (app.processIdentifier != -1) + sbapp = [SBApplication + applicationWithProcessIdentifier:app.processIdentifier]; + else if (app.bundleIdentifier) + sbapp = [SBApplication + applicationWithBundleIdentifier:app.bundleIdentifier]; + + if (!sbapp) + return LOG(LOG_ERROR, "Could not inject %s", + app.localizedName.UTF8String); + + sbapp.timeout = 10*60; + sbapp.sendMode = kAEWaitReply; + [sbapp sendEvent:'ascr' id:'gdut' parameters:0]; + sbapp.sendMode = kAENoReply; + [sbapp sendEvent:'SASI' id:'injc' parameters:0]; + + LOG(LOG_INFO, "Injected '%s' (%d, '%s')", + app.localizedName.UTF8String, + app.processIdentifier, app.bundleIdentifier.UTF8String); +} + +static inline void find_and_inject_target(syphon_t s, NSArray *arr) +{ + for (NSRunningApplication *app in arr) { + if (describes_app(s->inject_info, app)) + inject_app(s, app); + } +} + +static inline bool inject_info_equal(obs_data_t *prev, obs_data_t *new) +{ + if (![get_string(prev, "name") + isEqual:get_string(new, "name")]) + return false; + + if (![get_string(prev, "bundle") + isEqual:get_string(new, "bundle")]) + return false; + + if (![get_string(prev, "executable") + isEqual:get_string(new, "executable")]) + return false; + + if (![get_string(prev, "pid") + isEqual:get_string(new, "pid")]) + return false; + + return true; +} + +static inline void update_inject(syphon_t s, obs_data_t *settings) +{ + bool try_injecting = s->inject_active; + s->inject_active = obs_data_get_bool(settings, "inject"); + const char *inject_str = obs_data_get_string(settings, "application"); + + try_injecting = !try_injecting && s->inject_active; + + obs_data_t *prev = s->inject_info; + s->inject_info = obs_data_create_from_json(inject_str); + if (!try_injecting) + try_injecting = s->inject_active && + !inject_info_equal(prev, s->inject_info); + obs_data_release(prev); + + if (!try_injecting) + return; + + NSWorkspace *ws = [NSWorkspace sharedWorkspace]; + find_and_inject_target(s, ws.runningApplications); +} + +static inline bool update_syphon(syphon_t s, obs_data_t *settings) +{ + NSArray *arr = [[SyphonServerDirectory sharedDirectory] servers]; + + if (!load_syphon_settings(s, settings)) + return false; + + NSDictionary *dict = find_by_uuid(arr, s->uuid); + if (dict) { + NSString *app = dict[SyphonServerDescriptionAppNameKey]; + NSString *name = dict[SyphonServerDescriptionNameKey]; + obs_data_set_string(settings, "app_name", app.UTF8String); + obs_data_set_string(settings, "name", name.UTF8String); + load_syphon_settings(s, settings); + + } else if (!dict && !s->uuid.length) { + obs_data_set_string(settings, "app_name", ""); + obs_data_set_string(settings, "name", ""); + load_syphon_settings(s, settings); + } + + return true; +} + +static void syphon_update_internal(syphon_t s, obs_data_t *settings) +{ + load_crop(s, settings); + update_inject(s, settings); + if (update_syphon(s, settings)) + create_client(s); +} + +static void syphon_update(void *data, obs_data_t *settings) +{ + @autoreleasepool { + syphon_update_internal(data, settings); + } +} + +struct obs_source_info syphon_info = { + .id = "syphon-input", + .type = OBS_SOURCE_TYPE_INPUT, + .output_flags = OBS_SOURCE_VIDEO | OBS_SOURCE_CUSTOM_DRAW, + .get_name = syphon_get_name, + .create = syphon_create, + .destroy = syphon_destroy, + .video_render = syphon_video_render, + .video_tick = syphon_video_tick, + .get_properties = syphon_properties, + .get_width = syphon_get_width, + .get_height = syphon_get_height, + .update = syphon_update, + .save = syphon_save, +}; +