Skip to content

Commit 55a849d

Browse files
committed
feat: report where the user is typing, so a client can keep it on screen
A phone's on-screen keyboard covers half the picture, and the client cannot tell which half matters — the field being typed into is usually the hidden one. The host can tell, so GET /caret reports it. The answer is fractions of the streamed display, so the client needs to know nothing about resolutions, and "source" says which of two things it is: - "caret", the focused application's insertion point, read through Accessibility. macOS only, because no other platform exposes one. - "pointer", where the cursor is. The fallback for the applications that report no caret, which is most of them, and a good stand-in: you click into a field to type in it. It is also the only thing that helps in trackpad mode, where the client sends relative motion and never learns where the pointer ended up. An empty object means neither was available and the client should leave the picture where it is. Served over HTTPS only, so it reaches paired and enabled clients alone. Where someone is typing, and the pointer position it falls back to, describe what the user is doing closely enough to belong behind the same verification as the rest of the session. platf::pointer_location() is declared in platform/common.h and implemented on each platform. get_mouse_loc() was the obvious thing to reuse and is not usable here: it is documented as existing only for tests, and it takes the input backend of a running stream, which an HTTP handler does not have. Windows reads GetCursorPos and normalises against the primary monitor. Linux queries X11 and normalises against the X screen, which is what x11grab captures; Wayland offers no way to ask, so it reports nothing. Three details on the macOS side, each found by probing a machine rather than from documentation: - Ask the application, not the system. AXUIElementCreateSystemWide() with kAXFocusedUIElementAttribute returns nothing for applications that answer when asked directly, iTerm2 among them. - Filter by display. Accessibility works in whole-desktop coordinates, so a display above the main one gives negative values, and a caret on a display that is not being streamed means nothing to the client. - An empty rect at the origin means no caret. Elements without an insertion point return that rather than an error, and taking it at face value puts the caret in the top-left corner. Verified on macOS 26.6.1: iTerm2 and Xcode report a caret; Brave, VS Code, Sublime Text and Telegram do not and fall back to the pointer.
1 parent 687e12d commit 55a849d

6 files changed

Lines changed: 280 additions & 0 deletions

File tree

src/nvhttp.cpp

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@
2828
#include "logging.h"
2929
#include "network.h"
3030
#include "nvhttp.h"
31+
#ifdef __APPLE__
32+
#include "src/platform/macos/misc.h"
33+
#endif
3134
#include "platform/common.h"
3235
#include "process.h"
3336
#include "rtsp.h"
@@ -981,6 +984,57 @@ namespace nvhttp {
981984
}
982985
}
983986

987+
/**
988+
* @brief Report where the focused application is expecting text.
989+
*
990+
* A phone's on-screen keyboard covers half the picture, and the client has no way of knowing
991+
* which half matters. The host does. Coordinates are fractions of the streamed display so the
992+
* client needs to know nothing about resolutions, and "source" says whether the answer is the
993+
* insertion point itself or the pointer standing in for it. An empty body means neither was
994+
* available, and the client should leave the picture where it is.
995+
*
996+
* Served only over HTTPS, so it reaches paired and enabled clients alone. Where someone is
997+
* typing, and the pointer position it falls back to, describe what the user is doing closely
998+
* enough that they belong behind the same verification as the rest of the session.
999+
*
1000+
* @param response HTTP response object to populate.
1001+
* @param request HTTP request data from the client.
1002+
*/
1003+
void caret(resp_https_t response, req_https_t request) {
1004+
print_req<SunshineHTTPS>(request);
1005+
1006+
SimpleWeb::CaseInsensitiveMultimap headers;
1007+
headers.emplace("Content-Type", "application/json");
1008+
1009+
// The caret when the focused application will say where it is, the pointer when it will not,
1010+
// which is most of them. Accessibility is the only interface that reports an insertion point
1011+
// and it is macOS-only, so elsewhere the pointer is the whole answer.
1012+
#ifdef __APPLE__
1013+
if (const auto rect = platf::focused_caret()) {
1014+
const auto body = "{\"x\":" + std::to_string((*rect)[0]) +
1015+
",\"y\":" + std::to_string((*rect)[1]) +
1016+
",\"w\":" + std::to_string((*rect)[2]) +
1017+
",\"h\":" + std::to_string((*rect)[3]) +
1018+
",\"source\":\"caret\"}";
1019+
response->write(SimpleWeb::StatusCode::success_ok, body, headers);
1020+
return;
1021+
}
1022+
#endif
1023+
1024+
// Where you clicked to start typing, so it is close enough to the field to be worth moving
1025+
// the picture for, and in trackpad mode it is the only thing the client cannot work out for
1026+
// itself: it sends relative motion and never learns where the pointer ended up.
1027+
if (const auto point = platf::pointer_location()) {
1028+
const auto body = "{\"x\":" + std::to_string((*point)[0]) +
1029+
",\"y\":" + std::to_string((*point)[1]) +
1030+
",\"w\":0,\"h\":0,\"source\":\"pointer\"}";
1031+
response->write(SimpleWeb::StatusCode::success_ok, body, headers);
1032+
return;
1033+
}
1034+
1035+
response->write(SimpleWeb::StatusCode::success_ok, "{}", headers);
1036+
}
1037+
9841038
/**
9851039
* @brief Launch the requested application for a GameStream session.
9861040
*
@@ -1362,6 +1416,7 @@ namespace nvhttp {
13621416
pair<SunshineHTTPS>(add_cert, resp, req);
13631417
};
13641418
https_server.resource["^/applist$"]["GET"] = applist;
1419+
https_server.resource["^/caret$"]["GET"] = caret;
13651420
https_server.resource["^/appasset$"]["GET"] = appasset;
13661421
https_server.resource["^/launch$"]["GET"] = [&host_audio](auto resp, auto req) {
13671422
launch(host_audio, resp, req);

src/platform/common.h

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
#pragma once
66

77
// standard includes
8+
#include <array>
89
#include <bitset>
910
#include <filesystem>
1011
#include <functional>
@@ -1123,6 +1124,23 @@ namespace platf {
11231124
* @examples_end
11241125
*/
11251126
std::optional<util::point_t> get_mouse_loc(input_t &input);
1127+
1128+
/**
1129+
* @brief Where the pointer is, as a fraction of the display being streamed.
1130+
*
1131+
* Unlike `get_mouse_loc()` this takes no input backend and is meant to be read outside a
1132+
* session, and it answers in fractions rather than screen coordinates so a caller can use it
1133+
* without knowing the host's resolution.
1134+
*
1135+
* Every implementation measures against the primary display rather than resolving the one a
1136+
* session was configured to capture. That is the default in every case, and a pointer on any
1137+
* other display reports nothing rather than a number the client would misplace.
1138+
*
1139+
* @return `{x, y}` in 0..1, or `std::nullopt` when the pointer is on another display or the
1140+
* platform cannot observe it.
1141+
*/
1142+
std::optional<std::array<double, 2>> pointer_location();
1143+
11261144
/**
11271145
* @brief Move mouse using the backend coordinate system.
11281146
*

src/platform/linux/input/virtualhid.cpp

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,47 @@ namespace platf {
6666
#endif
6767
}
6868

69+
// Kept beside get_mouse_loc() rather than in misc.cpp, which is where the other two platforms
70+
// put it, because this is the only Linux translation unit with an X11 connection already set up.
71+
std::optional<std::array<double, 2>> pointer_location() {
72+
#ifdef SUNSHINE_BUILD_X11
73+
auto *display = XOpenDisplay(nullptr);
74+
if (!display) {
75+
return std::nullopt;
76+
}
77+
78+
const auto screen = DefaultScreen(display);
79+
const auto width = static_cast<double>(DisplayWidth(display, screen));
80+
const auto height = static_cast<double>(DisplayHeight(display, screen));
81+
82+
const auto root = DefaultRootWindow(display);
83+
Window root_return {};
84+
Window child_return {};
85+
int root_x = 0;
86+
int root_y = 0;
87+
int window_x = 0;
88+
int window_y = 0;
89+
unsigned int mask = 0;
90+
const auto queried = XQueryPointer(display, root, &root_return, &child_return, &root_x, &root_y, &window_x, &window_y, &mask);
91+
XCloseDisplay(display);
92+
93+
if (!queried || width <= 0 || height <= 0) {
94+
return std::nullopt;
95+
}
96+
97+
// The X screen, which spans every output. That is also what x11grab captures, so the two
98+
// agree by default; a session capturing one output of several would need the pointer placed
99+
// against that output instead.
100+
return std::array<double, 2> {
101+
root_x / width,
102+
root_y / height
103+
};
104+
#else
105+
// Wayland gives no way to ask, so the client falls back to leaving the picture alone.
106+
return std::nullopt;
107+
#endif
108+
}
109+
69110
std::vector<supported_gamepad_t> &supported_gamepads(input_t *input) {
70111
static std::vector<supported_gamepad_t> gamepads;
71112
if (!input || !input->get()) {

src/platform/macos/misc.h

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
#pragma once
66

77
// standard includes
8+
#include <array>
9+
#include <optional>
810
#include <vector>
911

1012
// platform includes
@@ -17,6 +19,21 @@ namespace platf {
1719
* @return True when Sunshine can capture the screen.
1820
*/
1921
bool is_screen_capture_allowed();
22+
23+
/**
24+
* @brief Where the focused application is expecting text, as a fraction of the streamed display.
25+
*
26+
* A client whose on-screen keyboard covers half the picture has no way of knowing which half
27+
* matters. The host does: the focused element knows where its insertion point is, and
28+
* Accessibility will say so. Normalised to 0..1 of the display so the client needs to know
29+
* nothing about resolutions.
30+
*
31+
* Empty when the focused application does not report an insertion point — which is most of
32+
* them — or when the caret is on a display other than the one being streamed.
33+
*
34+
* @return {x, y, width, height} in 0..1 of the streamed display, or nothing.
35+
*/
36+
std::optional<std::array<double, 4>> focused_caret();
2037
} // namespace platf
2138

2239
namespace dyn {

src/platform/macos/misc.mm

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
// platform includes
2020
#include <arpa/inet.h>
2121
#include <dlfcn.h>
22+
#include <ApplicationServices/ApplicationServices.h>
23+
#include <AppKit/AppKit.h>
2224
#include <Foundation/Foundation.h>
2325
#include <mach-o/dyld.h>
2426
#include <net/if_dl.h>
@@ -32,6 +34,7 @@
3234

3335
// local includes
3436
#include "misc.h"
37+
#include "src/utility.h"
3538
#include "src/entry_handler.h"
3639
#include "src/logging.h"
3740
#include "src/platform/common.h"
@@ -69,6 +72,119 @@
6972
/**
7073
* @brief Check whether screen capture allowed.
7174
*/
75+
namespace {
76+
/// Reads an Accessibility attribute, returning nothing rather than an error code.
77+
CFTypeRef copy_attribute(AXUIElementRef element, CFStringRef name) {
78+
CFTypeRef value = nullptr;
79+
if (AXUIElementCopyAttributeValue(element, name, &value) != kAXErrorSuccess) {
80+
return nullptr;
81+
}
82+
return value;
83+
}
84+
} // namespace
85+
86+
std::optional<std::array<double, 4>> focused_caret() {
87+
if (!AXIsProcessTrusted()) {
88+
return std::nullopt;
89+
}
90+
91+
NSRunningApplication *front = [[NSWorkspace sharedWorkspace] frontmostApplication];
92+
if (front == nil) {
93+
return std::nullopt;
94+
}
95+
96+
// Ask the application, not the system. AXUIElementCreateSystemWide() with
97+
// kAXFocusedUIElementAttribute comes back empty for applications that answer perfectly well
98+
// when asked directly — iTerm2 among them, which is the case this exists for.
99+
AXUIElementRef app = AXUIElementCreateApplication(front.processIdentifier);
100+
if (!app) {
101+
return std::nullopt;
102+
}
103+
// A busy or hung application must not stall the request that asked for this.
104+
AXUIElementSetMessagingTimeout(app, 0.1f);
105+
106+
const auto release_app = util::fail_guard([app]() {
107+
CFRelease(app);
108+
});
109+
110+
CFTypeRef focused = copy_attribute(app, kAXFocusedUIElementAttribute);
111+
if (!focused) {
112+
return std::nullopt;
113+
}
114+
const auto release_focused = util::fail_guard([focused]() {
115+
CFRelease(focused);
116+
});
117+
118+
CFTypeRef range = copy_attribute(static_cast<AXUIElementRef>(focused), kAXSelectedTextRangeAttribute);
119+
if (!range) {
120+
return std::nullopt;
121+
}
122+
const auto release_range = util::fail_guard([range]() {
123+
CFRelease(range);
124+
});
125+
126+
CFTypeRef bounds = nullptr;
127+
if (AXUIElementCopyParameterizedAttributeValue(
128+
static_cast<AXUIElementRef>(focused),
129+
kAXBoundsForRangeParameterizedAttribute,
130+
range,
131+
&bounds
132+
) != kAXErrorSuccess ||
133+
!bounds) {
134+
return std::nullopt;
135+
}
136+
const auto release_bounds = util::fail_guard([bounds]() {
137+
CFRelease(bounds);
138+
});
139+
140+
CGRect caret = CGRectZero;
141+
if (!AXValueGetValue(static_cast<AXValueRef>(bounds), kAXValueTypeCGRect, &caret)) {
142+
return std::nullopt;
143+
}
144+
// An element that does not really have an insertion point answers with an empty rect at the
145+
// origin rather than with an error.
146+
if (caret.size.width == 0 && caret.size.height == 0) {
147+
return std::nullopt;
148+
}
149+
150+
// Accessibility works in the coordinates of the whole desktop arrangement, which on a second
151+
// display can be negative or larger than the streamed display. Only a caret on the display
152+
// being streamed means anything to the client.
153+
const CGRect display = CGDisplayBounds(CGMainDisplayID());
154+
const CGPoint anchor = CGPointMake(CGRectGetMidX(caret), CGRectGetMidY(caret));
155+
if (!CGRectContainsPoint(display, anchor)) {
156+
return std::nullopt;
157+
}
158+
159+
return std::array<double, 4> {
160+
(caret.origin.x - display.origin.x) / display.size.width,
161+
(caret.origin.y - display.origin.y) / display.size.height,
162+
caret.size.width / display.size.width,
163+
caret.size.height / display.size.height
164+
};
165+
}
166+
167+
std::optional<std::array<double, 2>> pointer_location() {
168+
// A fresh event every time rather than a reused one, as get_mouse_loc() does: the location
169+
// on a reused event is whatever it was when the event was made.
170+
CGEventRef snapshot = CGEventCreate(nullptr);
171+
if (!snapshot) {
172+
return std::nullopt;
173+
}
174+
const CGPoint location = CGEventGetLocation(snapshot);
175+
CFRelease(snapshot);
176+
177+
const CGRect display = CGDisplayBounds(CGMainDisplayID());
178+
if (!CGRectContainsPoint(display, location)) {
179+
return std::nullopt;
180+
}
181+
182+
return std::array<double, 2> {
183+
(location.x - display.origin.x) / display.size.width,
184+
(location.y - display.origin.y) / display.size.height
185+
};
186+
}
187+
72188
bool is_screen_capture_allowed() {
73189
return screen_capture_allowed;
74190
}

src/platform/windows/misc.cpp

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1877,4 +1877,37 @@ namespace platf {
18771877
std::string resolve_render_device() {
18781878
return {};
18791879
}
1880+
1881+
std::optional<std::array<double, 2>> pointer_location() {
1882+
POINT cursor {};
1883+
if (!GetCursorPos(&cursor)) {
1884+
return std::nullopt;
1885+
}
1886+
1887+
// GetCursorPos answers in virtual-screen coordinates, which span every monitor, so a pointer
1888+
// on a second one lands outside the streamed display rather than nowhere. Only a pointer on
1889+
// the display being streamed means anything to the client.
1890+
MONITORINFO info {};
1891+
info.cbSize = sizeof(info);
1892+
const auto monitor = MonitorFromPoint(POINT {0, 0}, MONITOR_DEFAULTTOPRIMARY);
1893+
if (!monitor || !GetMonitorInfo(monitor, &info)) {
1894+
return std::nullopt;
1895+
}
1896+
1897+
const auto &bounds = info.rcMonitor;
1898+
const auto width = static_cast<double>(bounds.right - bounds.left);
1899+
const auto height = static_cast<double>(bounds.bottom - bounds.top);
1900+
if (width <= 0 || height <= 0) {
1901+
return std::nullopt;
1902+
}
1903+
1904+
if (cursor.x < bounds.left || cursor.x >= bounds.right || cursor.y < bounds.top || cursor.y >= bounds.bottom) {
1905+
return std::nullopt;
1906+
}
1907+
1908+
return std::array<double, 2> {
1909+
(cursor.x - bounds.left) / width,
1910+
(cursor.y - bounds.top) / height
1911+
};
1912+
}
18801913
} // namespace platf

0 commit comments

Comments
 (0)