|
@@ -36,6 +36,19 @@
|
|
|
@property (nonatomic, readwrite, assign) NSInteger monitorID;
|
|
|
@property (nonatomic, nullable) SpiceDisplayChannel *display;
|
|
|
@property (nonatomic, nullable) SpiceMainChannel *main;
|
|
|
+@property (nonatomic, nullable) SpiceCursorChannel *cursor;
|
|
|
+@property (nonatomic, readwrite) CGPoint cursorHotspot;
|
|
|
+@property (nonatomic, readwrite) BOOL cursorHidden;
|
|
|
+@property (nonatomic, readwrite) BOOL hasCursor;
|
|
|
+
|
|
|
+// UTMRenderSource properties
|
|
|
+@property (nonatomic, readwrite) dispatch_semaphore_t drawLock;
|
|
|
+@property (nonatomic, nullable, readwrite) id<MTLTexture> displayTexture;
|
|
|
+@property (nonatomic, nullable, readwrite) id<MTLTexture> cursorTexture;
|
|
|
+@property (nonatomic, readwrite) NSUInteger displayNumVertices;
|
|
|
+@property (nonatomic, readwrite) NSUInteger cursorNumVertices;
|
|
|
+@property (nonatomic, nullable, readwrite) id<MTLBuffer> displayVertices;
|
|
|
+@property (nonatomic, nullable, readwrite) id<MTLBuffer> cursorVertices;
|
|
|
|
|
|
@end
|
|
|
|
|
@@ -47,13 +60,11 @@
|
|
|
CGRect _canvasArea;
|
|
|
CGRect _visibleArea;
|
|
|
GWeakRef _overlay_weak_ref;
|
|
|
- id<MTLDevice> _device;
|
|
|
- id<MTLTexture> _texture;
|
|
|
- id<MTLBuffer> _vertices;
|
|
|
- NSUInteger _numVertices;
|
|
|
- dispatch_semaphore_t _drawLock;
|
|
|
+ CGPoint _mouse_guest;
|
|
|
}
|
|
|
|
|
|
+#pragma mark - Display events
|
|
|
+
|
|
|
static void cs_primary_create(SpiceChannel *channel, gint format,
|
|
|
gint width, gint height, gint stride,
|
|
|
gint shmid, gpointer imgdata, gpointer data) {
|
|
@@ -159,6 +170,90 @@ whole:
|
|
|
self.ready = YES;
|
|
|
}
|
|
|
|
|
|
+#pragma mark - Cursor events
|
|
|
+
|
|
|
+static void cs_update_mouse_mode(SpiceChannel *channel, gpointer data)
|
|
|
+{
|
|
|
+ CSDisplayMetal *self = (__bridge CSDisplayMetal *)data;
|
|
|
+ enum SpiceMouseMode mouse_mode;
|
|
|
+
|
|
|
+ g_object_get(channel, "mouse-mode", &mouse_mode, NULL);
|
|
|
+ DISPLAY_DEBUG(self, "mouse mode %u", mouse_mode);
|
|
|
+
|
|
|
+ if (mouse_mode == SPICE_MOUSE_MODE_SERVER) {
|
|
|
+ self->_mouse_guest.x = -1;
|
|
|
+ self->_mouse_guest.y = -1;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+static void cs_cursor_invalidate(CSDisplayMetal *self)
|
|
|
+{
|
|
|
+ // We implement two different textures so invalidate is not needed
|
|
|
+}
|
|
|
+
|
|
|
+static void cs_cursor_set(SpiceCursorChannel *channel,
|
|
|
+ G_GNUC_UNUSED GParamSpec *pspec,
|
|
|
+ gpointer data)
|
|
|
+{
|
|
|
+ CSDisplayMetal *self = (__bridge CSDisplayMetal *)data;
|
|
|
+ SpiceCursorShape *cursor_shape;
|
|
|
+
|
|
|
+ g_object_get(G_OBJECT(channel), "cursor", &cursor_shape, NULL);
|
|
|
+ if (G_UNLIKELY(cursor_shape == NULL || cursor_shape->data == NULL)) {
|
|
|
+ if (cursor_shape != NULL) {
|
|
|
+ g_boxed_free(SPICE_TYPE_CURSOR_SHAPE, cursor_shape);
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ cs_cursor_invalidate(self);
|
|
|
+
|
|
|
+ CGPoint hotspot = CGPointMake(cursor_shape->hot_spot_x, cursor_shape->hot_spot_y);
|
|
|
+ CGSize newSize = CGSizeMake(cursor_shape->width, cursor_shape->height);
|
|
|
+ if (!CGSizeEqualToSize(newSize, self.cursorSize) || !CGPointEqualToPoint(hotspot, self.cursorHotspot)) {
|
|
|
+ [self rebuildCursorWithSize:newSize center:hotspot];
|
|
|
+ }
|
|
|
+ [self drawCursor:cursor_shape->data];
|
|
|
+ self.cursorHidden = NO;
|
|
|
+ cs_cursor_invalidate(self);
|
|
|
+}
|
|
|
+
|
|
|
+static void cs_cursor_move(SpiceCursorChannel *channel, gint x, gint y, gpointer data)
|
|
|
+{
|
|
|
+ CSDisplayMetal *self = (__bridge CSDisplayMetal *)data;
|
|
|
+
|
|
|
+ cs_cursor_invalidate(self); // old pointer buffer
|
|
|
+
|
|
|
+ self->_mouse_guest.x = x;
|
|
|
+ self->_mouse_guest.y = y;
|
|
|
+
|
|
|
+ cs_cursor_invalidate(self); // new pointer buffer
|
|
|
+
|
|
|
+ /* apparently we have to restore cursor when "cursor_move" */
|
|
|
+ if (self.hasCursor) {
|
|
|
+ self.cursorHidden = NO;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+static void cs_cursor_hide(SpiceCursorChannel *channel, gpointer data)
|
|
|
+{
|
|
|
+ CSDisplayMetal *self = (__bridge CSDisplayMetal *)data;
|
|
|
+
|
|
|
+ self.cursorHidden = YES;
|
|
|
+ cs_cursor_invalidate(self);
|
|
|
+}
|
|
|
+
|
|
|
+static void cs_cursor_reset(SpiceCursorChannel *channel, gpointer data)
|
|
|
+{
|
|
|
+ CSDisplayMetal *self = (__bridge CSDisplayMetal *)data;
|
|
|
+
|
|
|
+ DISPLAY_DEBUG(self, "%s", __FUNCTION__);
|
|
|
+ [self destroyCursor];
|
|
|
+ cs_cursor_invalidate(self);
|
|
|
+}
|
|
|
+
|
|
|
+#pragma mark - Channel events
|
|
|
+
|
|
|
static void cs_channel_new(SpiceSession *s, SpiceChannel *channel, gpointer data) {
|
|
|
CSDisplayMetal *self = (__bridge CSDisplayMetal *)data;
|
|
|
gint channel_id;
|
|
@@ -194,8 +289,35 @@ static void cs_channel_new(SpiceSession *s, SpiceChannel *channel, gpointer data
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
+ if (SPICE_IS_CURSOR_CHANNEL(channel)) {
|
|
|
+ gpointer cursor_shape;
|
|
|
+ if (channel_id != self.channelID) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ self.cursor = SPICE_CURSOR_CHANNEL(channel);
|
|
|
+ g_signal_connect(channel, "notify::cursor",
|
|
|
+ G_CALLBACK(cs_cursor_set), GLIB_OBJC_RETAIN(self));
|
|
|
+ g_signal_connect(channel, "cursor-move",
|
|
|
+ G_CALLBACK(cs_cursor_move), GLIB_OBJC_RETAIN(self));
|
|
|
+ g_signal_connect(channel, "cursor-hide",
|
|
|
+ G_CALLBACK(cs_cursor_hide), GLIB_OBJC_RETAIN(self));
|
|
|
+ g_signal_connect(channel, "cursor-reset",
|
|
|
+ G_CALLBACK(cs_cursor_reset), GLIB_OBJC_RETAIN(self));
|
|
|
+ spice_channel_connect(channel);
|
|
|
+
|
|
|
+ g_object_get(G_OBJECT(channel), "cursor", &cursor_shape, NULL);
|
|
|
+ if (cursor_shape != NULL) {
|
|
|
+ g_boxed_free(SPICE_TYPE_CURSOR_SHAPE, cursor_shape);
|
|
|
+ cs_cursor_set(self.cursor, NULL, (__bridge void *)self);
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
if (SPICE_IS_MAIN_CHANNEL(channel)) {
|
|
|
self.main = SPICE_MAIN_CHANNEL(channel);
|
|
|
+ g_signal_connect(channel, "main-mouse-update",
|
|
|
+ G_CALLBACK(cs_update_mouse_mode), GLIB_OBJC_RETAIN(self));
|
|
|
+ cs_update_mouse_mode(channel, data);
|
|
|
return;
|
|
|
}
|
|
|
}
|
|
@@ -223,17 +345,31 @@ static void cs_channel_destroy(SpiceSession *s, SpiceChannel *channel, gpointer
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
+ if (SPICE_IS_CURSOR_CHANNEL(channel)) {
|
|
|
+ if (channel_id != self.channelID) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ self.cursor = NULL;
|
|
|
+ g_signal_handlers_disconnect_by_func(channel, G_CALLBACK(cs_cursor_set), GLIB_OBJC_RELEASE(self));
|
|
|
+ g_signal_handlers_disconnect_by_func(channel, G_CALLBACK(cs_cursor_move), GLIB_OBJC_RELEASE(self));
|
|
|
+ g_signal_handlers_disconnect_by_func(channel, G_CALLBACK(cs_cursor_hide), GLIB_OBJC_RELEASE(self));
|
|
|
+ g_signal_handlers_disconnect_by_func(channel, G_CALLBACK(cs_cursor_reset), GLIB_OBJC_RELEASE(self));
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (SPICE_IS_MAIN_CHANNEL(channel)) {
|
|
|
+ self.main = NULL;
|
|
|
+ g_signal_handlers_disconnect_by_func(channel, G_CALLBACK(cs_update_mouse_mode), GLIB_OBJC_RELEASE(self));
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
- (void)setDevice:(id<MTLDevice>)device {
|
|
|
_device = device;
|
|
|
- [self rebuildTexture];
|
|
|
- [self rebuildVertices];
|
|
|
-}
|
|
|
-
|
|
|
-- (id<MTLDevice>)device {
|
|
|
- return _device;
|
|
|
+ [self rebuildDisplayTexture];
|
|
|
+ [self rebuildDisplayVertices];
|
|
|
}
|
|
|
|
|
|
- (UTMScreenshot *)screenshot {
|
|
@@ -265,29 +401,16 @@ static void cs_channel_destroy(SpiceSession *s, SpiceChannel *channel, gpointer
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-@synthesize drawLock = _drawLock;
|
|
|
-@synthesize texture = _texture;
|
|
|
-@synthesize numVertices = _numVertices;
|
|
|
-@synthesize vertices = _vertices;
|
|
|
-@synthesize viewportOrigin;
|
|
|
-@synthesize viewportScale;
|
|
|
+#pragma mark - Methods
|
|
|
|
|
|
-- (id)init {
|
|
|
- self = [super init];
|
|
|
- if (self) {
|
|
|
- _drawLock = dispatch_semaphore_create(1);
|
|
|
- self.viewportScale = 1.0f;
|
|
|
- self.viewportOrigin = CGPointMake(0, 0);
|
|
|
- }
|
|
|
- return self;
|
|
|
-}
|
|
|
-
|
|
|
-- (id)initWithSession:(nonnull SpiceSession *)session channelID:(NSInteger)channelID monitorID:(NSInteger)monitorID {
|
|
|
- self = [self init];
|
|
|
- if (self) {
|
|
|
+- (instancetype)initWithSession:(nonnull SpiceSession *)session channelID:(NSInteger)channelID monitorID:(NSInteger)monitorID {
|
|
|
+ if (self = [super init]) {
|
|
|
GList *list;
|
|
|
GList *it;
|
|
|
|
|
|
+ self.drawLock = dispatch_semaphore_create(1);
|
|
|
+ self.viewportScale = 1.0f;
|
|
|
+ self.viewportOrigin = CGPointMake(0, 0);
|
|
|
self.channelID = channelID;
|
|
|
self.monitorID = monitorID;
|
|
|
self.session = session;
|
|
@@ -314,7 +437,7 @@ static void cs_channel_destroy(SpiceSession *s, SpiceChannel *channel, gpointer
|
|
|
return self;
|
|
|
}
|
|
|
|
|
|
-- (id)initWithSession:(nonnull SpiceSession *)session channelID:(NSInteger)channelID {
|
|
|
+- (instancetype)initWithSession:(nonnull SpiceSession *)session channelID:(NSInteger)channelID {
|
|
|
return [self initWithSession:session channelID:channelID monitorID:0];
|
|
|
}
|
|
|
|
|
@@ -322,6 +445,9 @@ static void cs_channel_destroy(SpiceSession *s, SpiceChannel *channel, gpointer
|
|
|
if (self.display) {
|
|
|
cs_channel_destroy(self.session, SPICE_CHANNEL(self.display), (__bridge void *)self);
|
|
|
}
|
|
|
+ if (_cursor) {
|
|
|
+ cs_channel_destroy(self.session, SPICE_CHANNEL(_cursor), (__bridge void *)self);
|
|
|
+ }
|
|
|
UTMLog(@"%s:%d", __FUNCTION__, __LINE__);
|
|
|
g_signal_handlers_disconnect_by_func(self.session, G_CALLBACK(cs_channel_new), GLIB_OBJC_RELEASE(self));
|
|
|
g_signal_handlers_disconnect_by_func(self.session, G_CALLBACK(cs_channel_destroy), GLIB_OBJC_RELEASE(self));
|
|
@@ -339,12 +465,12 @@ static void cs_channel_destroy(SpiceSession *s, SpiceChannel *channel, gpointer
|
|
|
_visibleArea = visible;
|
|
|
}
|
|
|
self.displaySize = _visibleArea.size;
|
|
|
- [self rebuildTexture];
|
|
|
- [self rebuildVertices];
|
|
|
+ [self rebuildDisplayTexture];
|
|
|
+ [self rebuildDisplayVertices];
|
|
|
}
|
|
|
|
|
|
-- (void)rebuildTexture {
|
|
|
- if (CGRectIsEmpty(_canvasArea) || !_device) {
|
|
|
+- (void)rebuildDisplayTexture {
|
|
|
+ if (CGRectIsEmpty(_canvasArea) || !self.device) {
|
|
|
return;
|
|
|
}
|
|
|
MTLTextureDescriptor *textureDescriptor = [[MTLTextureDescriptor alloc] init];
|
|
@@ -352,13 +478,13 @@ static void cs_channel_destroy(SpiceSession *s, SpiceChannel *channel, gpointer
|
|
|
textureDescriptor.pixelFormat = (_canvasFormat == SPICE_SURFACE_FMT_32_xRGB) ? MTLPixelFormatBGRA8Unorm : (MTLPixelFormat)43;// FIXME: MTLPixelFormatBGR5A1Unorm is supposed to be available.
|
|
|
textureDescriptor.width = _visibleArea.size.width;
|
|
|
textureDescriptor.height = _visibleArea.size.height;
|
|
|
- dispatch_semaphore_wait(_drawLock, DISPATCH_TIME_FOREVER);
|
|
|
- _texture = [_device newTextureWithDescriptor:textureDescriptor];
|
|
|
- dispatch_semaphore_signal(_drawLock);
|
|
|
+ dispatch_semaphore_wait(self.drawLock, DISPATCH_TIME_FOREVER);
|
|
|
+ self.displayTexture = [self.device newTextureWithDescriptor:textureDescriptor];
|
|
|
+ dispatch_semaphore_signal(self.drawLock);
|
|
|
[self drawRegion:_visibleArea];
|
|
|
}
|
|
|
|
|
|
-- (void)rebuildVertices {
|
|
|
+- (void)rebuildDisplayVertices {
|
|
|
// We flip the y-coordinates because pixman renders flipped
|
|
|
UTMVertex quadVertices[] =
|
|
|
{
|
|
@@ -372,15 +498,15 @@ static void cs_channel_destroy(SpiceSession *s, SpiceChannel *channel, gpointer
|
|
|
{ { _visibleArea.size.width/2, -_visibleArea.size.height/2 }, { 1.f, 1.f } },
|
|
|
};
|
|
|
|
|
|
- dispatch_semaphore_wait(_drawLock, DISPATCH_TIME_FOREVER);
|
|
|
+ dispatch_semaphore_wait(self.drawLock, DISPATCH_TIME_FOREVER);
|
|
|
// Create our vertex buffer, and initialize it with our quadVertices array
|
|
|
- _vertices = [_device newBufferWithBytes:quadVertices
|
|
|
- length:sizeof(quadVertices)
|
|
|
- options:MTLResourceStorageModeShared];
|
|
|
+ self.displayVertices = [self.device newBufferWithBytes:quadVertices
|
|
|
+ length:sizeof(quadVertices)
|
|
|
+ options:MTLResourceStorageModeShared];
|
|
|
|
|
|
// Calculate the number of vertices by dividing the byte length by the size of each vertex
|
|
|
- _numVertices = sizeof(quadVertices) / sizeof(UTMVertex);
|
|
|
- dispatch_semaphore_signal(_drawLock);
|
|
|
+ self.displayNumVertices = sizeof(quadVertices) / sizeof(UTMVertex);
|
|
|
+ dispatch_semaphore_signal(self.drawLock);
|
|
|
}
|
|
|
|
|
|
- (void)drawRegion:(CGRect)rect {
|
|
@@ -390,14 +516,14 @@ static void cs_channel_destroy(SpiceSession *s, SpiceChannel *channel, gpointer
|
|
|
{ rect.origin.x-_visibleArea.origin.x, rect.origin.y-_visibleArea.origin.y, 0 }, // MTLOrigin
|
|
|
{ rect.size.width, rect.size.height, 1} // MTLSize
|
|
|
};
|
|
|
- dispatch_semaphore_wait(_drawLock, DISPATCH_TIME_FOREVER);
|
|
|
+ dispatch_semaphore_wait(self.drawLock, DISPATCH_TIME_FOREVER);
|
|
|
if (_canvasData != NULL) { // canvas may be destroyed by this time
|
|
|
- [_texture replaceRegion:region
|
|
|
- mipmapLevel:0
|
|
|
- withBytes:(const char *)_canvasData + (NSUInteger)(rect.origin.y*_canvasStride + rect.origin.x*pixelSize)
|
|
|
- bytesPerRow:_canvasStride];
|
|
|
+ [self.displayTexture replaceRegion:region
|
|
|
+ mipmapLevel:0
|
|
|
+ withBytes:(const char *)_canvasData + (NSUInteger)(rect.origin.y*_canvasStride + rect.origin.x*pixelSize)
|
|
|
+ bytesPerRow:_canvasStride];
|
|
|
}
|
|
|
- dispatch_semaphore_signal(_drawLock);
|
|
|
+ dispatch_semaphore_signal(self.drawLock);
|
|
|
}
|
|
|
|
|
|
- (BOOL)visible {
|
|
@@ -420,4 +546,88 @@ static void cs_channel_destroy(SpiceSession *s, SpiceChannel *channel, gpointer
|
|
|
spice_main_channel_send_monitor_config(self.main);
|
|
|
}
|
|
|
|
|
|
+#pragma mark - Cursor drawing
|
|
|
+
|
|
|
+- (void)rebuildCursorWithSize:(CGSize)size center:(CGPoint)hotspot {
|
|
|
+ // hotspot is the offset in buffer for the center of the pointer
|
|
|
+ if (!self.device) {
|
|
|
+ UTMLog(@"MTL device not ready for cursor draw");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ dispatch_semaphore_wait(self.drawLock, DISPATCH_TIME_FOREVER);
|
|
|
+ MTLTextureDescriptor *textureDescriptor = [[MTLTextureDescriptor alloc] init];
|
|
|
+ // don't worry that that components are reversed, we fix it in shaders
|
|
|
+ textureDescriptor.pixelFormat = MTLPixelFormatBGRA8Unorm;
|
|
|
+ textureDescriptor.width = size.width;
|
|
|
+ textureDescriptor.height = size.height;
|
|
|
+ self.cursorTexture = [self.device newTextureWithDescriptor:textureDescriptor];
|
|
|
+
|
|
|
+ // We flip the y-coordinates because pixman renders flipped
|
|
|
+ UTMVertex quadVertices[] =
|
|
|
+ {
|
|
|
+ // Pixel positions, Texture coordinates
|
|
|
+ { { -hotspot.x + size.width, hotspot.y }, { 1.f, 0.f } },
|
|
|
+ { { -hotspot.x , hotspot.y }, { 0.f, 0.f } },
|
|
|
+ { { -hotspot.x , hotspot.y - size.height }, { 0.f, 1.f } },
|
|
|
+
|
|
|
+ { { -hotspot.x + size.width, hotspot.y }, { 1.f, 0.f } },
|
|
|
+ { { -hotspot.x , hotspot.y - size.height }, { 0.f, 1.f } },
|
|
|
+ { { -hotspot.x + size.width, hotspot.y - size.height }, { 1.f, 1.f } },
|
|
|
+ };
|
|
|
+
|
|
|
+ // Create our vertex buffer, and initialize it with our quadVertices array
|
|
|
+ self.cursorVertices = [self.device newBufferWithBytes:quadVertices
|
|
|
+ length:sizeof(quadVertices)
|
|
|
+ options:MTLResourceStorageModeShared];
|
|
|
+
|
|
|
+ // Calculate the number of vertices by dividing the byte length by the size of each vertex
|
|
|
+ self.cursorNumVertices = sizeof(quadVertices) / sizeof(UTMVertex);
|
|
|
+ self.cursorSize = size;
|
|
|
+ self.cursorHotspot = hotspot;
|
|
|
+ self.hasCursor = YES;
|
|
|
+ dispatch_semaphore_signal(self.drawLock);
|
|
|
+}
|
|
|
+
|
|
|
+- (void)destroyCursor {
|
|
|
+ dispatch_semaphore_wait(self.drawLock, DISPATCH_TIME_FOREVER);
|
|
|
+ self.cursorNumVertices = 0;
|
|
|
+ self.cursorVertices = nil;
|
|
|
+ self.cursorTexture = nil;
|
|
|
+ self.cursorSize = CGSizeZero;
|
|
|
+ self.cursorHotspot = CGPointZero;
|
|
|
+ self.hasCursor = NO;
|
|
|
+ dispatch_semaphore_signal(self.drawLock);
|
|
|
+}
|
|
|
+
|
|
|
+- (void)drawCursor:(const void *)buffer {
|
|
|
+ const NSInteger pixelSize = 4;
|
|
|
+ MTLRegion region = {
|
|
|
+ { 0, 0 }, // MTLOrigin
|
|
|
+ { self.cursorSize.width, self.cursorSize.height, 1} // MTLSize
|
|
|
+ };
|
|
|
+ dispatch_semaphore_wait(self.drawLock, DISPATCH_TIME_FOREVER);
|
|
|
+ [self.cursorTexture replaceRegion:region
|
|
|
+ mipmapLevel:0
|
|
|
+ withBytes:buffer
|
|
|
+ bytesPerRow:self.cursorSize.width*pixelSize];
|
|
|
+ dispatch_semaphore_signal(self.drawLock);
|
|
|
+}
|
|
|
+
|
|
|
+- (BOOL)cursorVisible {
|
|
|
+ return !self.inhibitCursor && self.hasCursor && !self.cursorHidden;
|
|
|
+}
|
|
|
+
|
|
|
+- (CGPoint)cursorOrigin {
|
|
|
+ CGPoint point = _mouse_guest;
|
|
|
+ point.x -= self.displaySize.width/2;
|
|
|
+ point.y -= self.displaySize.height/2;
|
|
|
+ point.x *= self.viewportScale;
|
|
|
+ point.y *= self.viewportScale;
|
|
|
+ return point;
|
|
|
+}
|
|
|
+
|
|
|
+- (void)forceCursorPosition:(CGPoint)pos {
|
|
|
+ _mouse_guest = pos;
|
|
|
+}
|
|
|
+
|
|
|
@end
|