Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 24 additions & 13 deletions app/platforms/common/runtime.c
Original file line number Diff line number Diff line change
Expand Up @@ -447,13 +447,11 @@ static bool start_client(sl_runtime *r) {
sl_log(started ? "discovery: periodic broadcast registered" : "discovery: registration failed");
return started;
}
static void stop_session(sl_runtime *r) {
/* Return completion observed before/during join, before destroying its owner. */
static bool stop_session(sl_runtime *r) {
bool host_completed = false;
sl_media_close_video();
r->waiting_video = true;
pthread_mutex_lock(&r->lock);
r->end_game.at = 0;
r->end_game.empty = 0;
pthread_mutex_unlock(&r->lock);
sl_log("cleanup: stop/join HID worker");
stream_media_set_hid_session(NULL, false);
if (r->session) {
Expand All @@ -462,13 +460,16 @@ static void stop_session(sl_runtime *r) {
/* Discovery disconnect retries are bounded to ~1.1 s. Let the session
* worker send them before joining; immediate interrupt drops the goodbye. */
IHS_SessionThreadedJoin(r->session);
host_completed = IHS_SessionHostRequestedStop(r->session);
/* Workers are joined. Detached frame tickets can outlive the session. */
stream_media_audio_stop(r->session);
IHS_SessionDestroy(r->session);
r->session = NULL;
sl_log("cleanup: session destroyed");
}
pthread_mutex_lock(&r->lock);
host_completed = host_completed || (r->end_game.at && r->end_game.empty >= 2);
r->end_game = (sl_end_game_watch){0};
r->connected = r->finished = r->host_stopped = r->session_ready = false;
r->facts.connected = false;
r->facts.requests_closed = true;
Expand All @@ -477,6 +478,7 @@ static void stop_session(sl_runtime *r) {
r->first_reported = false;
r->video_suspended = false;
r->request_at = 0;
return host_completed;
}
static bool launch_session(sl_runtime *r, IHS_SessionInfo info) {
r->session = IHS_SessionCreate(&r->config, &info);
Expand Down Expand Up @@ -953,8 +955,8 @@ static void *worker_main(void *ctx) {
IHS_SessionHIDNotifyDeviceChange(r->session);
}
if (finished && r->session) {
bool normal = host_stopped && r->first_reported;
stop_session(r);
bool had_video = r->first_reported;
bool normal = (stop_session(r) || host_stopped) && had_video;
post(r, (sl_runtime_event){.type = SL_EVENT_STOPPED, .account = normal ? 0 : 1});
IHS_ClientStartDiscovery(r->client, 3000);
}
Expand Down Expand Up @@ -986,24 +988,33 @@ static void *worker_main(void *ctx) {
r->activity_changed = false;
bool desktop = r->launch.kind == 3 || r->launch.kind == 4;
bool ending_game = r->end_game.at != 0;
bool end_game_done = ending_game && r->end_game.empty >= 2;
bool end_game_timeout = ending_game && now - r->end_game.at >= 15000;
sl_end_game_result end_result = sl_end_game_poll(&r->end_game, now,
r->session && IHS_SessionHostRequestedStop(r->session));
bool end_game_done = end_result == SL_END_GAME_DONE;
bool end_game_timeout = end_result == SL_END_GAME_TIMEOUT;
bool probe_launch =
r->session && (r->launch.target || ending_game) && now - r->launch_probe_at >= 1000;
if (probe_launch)
r->launch_probe_at = now;
pthread_mutex_unlock(&r->lock);
if (activity_changed && r->first_reported)
r->frame_at = now; /* Desktop -> Game starts a fresh video wait window. */
if (end_game_timeout && !end_game_done && !launch_done && r->session) {
stop_session(r);
fail(r, sl_tr(SL_T_END_GAME_FAILED));
if (end_game_timeout && !launch_done && r->session) {
/* The callback may arrive after poll above, even during join.
* Do not destroy/reset the completion evidence before checking it. */
if (stop_session(r)) {
sl_log("user end game: host completion during timeout cleanup");
post(r, (sl_runtime_event){.type = SL_EVENT_STOPPED});
IHS_ClientStartDiscovery(r->client, 3000);
} else {
fail(r, sl_tr(SL_T_END_GAME_FAILED));
}
continue;
}
if ((launch_done || end_game_done) && r->session) {
sl_log(
end_game_done
? "user end game: fresh host reports no games; normal return"
? "user end game: host stop or fresh no-games reports; normal return"
: "launch: target played, Desktop, fresh host reports no games; normal return");
stop_session(r);
post(r, (sl_runtime_event){.type = SL_EVENT_STOPPED});
Expand Down
1 change: 1 addition & 0 deletions app/resources/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
"END_GAME_QUESTION": "End the game on your PC?",
"END_GAME_EXPLANATION": "Save your game before ending it.",
"END_GAME_FAILED": "Could not confirm the game ended. Check your PC.",
"STOP_WAITING_KEY": "B Disconnect",
"ENDING_GAME": "Ending game",
"APPLET_UNSUPPORTED": "Applet mode is not supported.",
"APPLET_INSTRUCTION": "Hold R while launching a game to open hbmenu.\nThen open NSteamLink again.",
Expand Down
1 change: 1 addition & 0 deletions app/resources/i18n/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
"END_GAME_QUESTION": "结束电脑上的游戏?",
"END_GAME_EXPLANATION": "游戏将关闭,请先保存进度。",
"END_GAME_FAILED": "未能确认游戏已结束,请检查电脑。",
"STOP_WAITING_KEY": "B 断开",
"ENDING_GAME": "正在结束游戏",
"APPLET_UNSUPPORTED": "不支持 applet 模式。",
"APPLET_INSTRUCTION": "按住 R 启动游戏进入 hbmenu,\n再打开 NSteamLink。",
Expand Down
14 changes: 14 additions & 0 deletions app/src/services/launch_watch.h
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,17 @@ static inline void sl_end_game_status(sl_end_game_watch *s, bool present, bool r
} else
s->empty = 0;
}

/* Local patience limit, not a protocol deadline or evidence of completion. */
#define SL_END_GAME_TIMEOUT_MS 60000ULL
typedef enum { SL_END_GAME_WAIT, SL_END_GAME_DONE, SL_END_GAME_TIMEOUT } sl_end_game_result;
static inline sl_end_game_result sl_end_game_poll(const sl_end_game_watch *s, uint64_t now,
bool host_stopped) {
if (!s->at)
return SL_END_GAME_WAIT;
/* Positive evidence always wins, including at the timeout boundary. */
if (host_stopped || s->empty >= 2)
return SL_END_GAME_DONE;
return now >= s->at && now - s->at >= SL_END_GAME_TIMEOUT_MS
? SL_END_GAME_TIMEOUT : SL_END_GAME_WAIT;
}
16 changes: 14 additions & 2 deletions app/src/ui/ui_layout.c
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,9 @@ void sl_ui_layout(sl_ui_model *m) {
break;
case SL_STOPPING:
strcpy(l->title, sl_tr(m->ending_game ? SL_T_ENDING_GAME : SL_T_DISCONNECTING));
if (m->ending_game) {
button(l, 9, 320, 596, 640, 64, sl_tr(SL_T_STOP_WAITING_KEY), SL_BACK, 0, false);
}
break;
case SL_CLOSING:
strcpy(l->title, sl_tr(SL_T_EXITING));
Expand Down Expand Up @@ -206,9 +209,10 @@ void sl_ui_layout(sl_ui_model *m) {
break;
case SL_ERROR:
strcpy(l->title,
m->had_stream ? sl_tr(SL_T_STREAM_INTERRUPTED) : sl_tr(SL_T_CONNECT_FAILED));
m->ending_game ? sl_tr(SL_T_END_GAME)
: m->had_stream ? sl_tr(SL_T_STREAM_INTERRUPTED) : sl_tr(SL_T_CONNECT_FAILED));
text(l, 320, 232, 30, m->error);
if (m->intent.host.id)
if (m->intent.host.id && !m->ending_game)
row(l, 2, sl_tr(SL_T_RETRY), SL_RETRY, 0);
break;
default:
Expand Down Expand Up @@ -274,6 +278,14 @@ void sl_ui_layout(sl_ui_model *m) {
l->panel_y = 256;
l->panel_w = 560;
l->panel_h = 208;
if (m->page == SL_STOPPING && m->ending_game) {
l->panel_y = 208;
l->panel_h = 304;
sl_control *c = &l->controls[0];
c->x = l->panel_x + (l->panel_w - c->w) / 2;
c->y = l->panel_y + 192;
strcpy(c->label, sl_tr(SL_T_STOP_WAITING_KEY));
}
}
if (m->page == SL_EXIT) {
l->panel_y = 230;
Expand Down
9 changes: 7 additions & 2 deletions app/src/ui/ui_model.c
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,10 @@ void sl_ui_init(sl_ui_model *m, const sl_auth_store *s) {
sl_ui_layout(m);
}
void sl_ui_error(sl_ui_model *m, const char *message) {
bool ending_game = m->ending_game;
snprintf(m->error, sizeof(m->error), "%s", message);
page(m, SL_ERROR);
m->ending_game = ending_game; /* keep the failed operation, not just launch intent */
sl_ui_layout(m);
}
bool sl_ui_remote(const sl_ui_model *m) {
Expand All @@ -131,6 +133,8 @@ void sl_ui_connected(sl_ui_model *m) {
sl_ui_layout(m);
}
void sl_ui_stopped(sl_ui_model *m, bool unexpected) {
if (m->ending_game && !unexpected)
++m->generation; /* retire late errors from the completed end-game operation */
m->streaming = false;
m->debug = false;
m->depth = 0;
Expand Down Expand Up @@ -390,7 +394,8 @@ static void action(sl_ui_model *m, sl_action a, int arg) {
if (m->page == SL_HOME)
push(m, SL_EXIT);
else if (m->page == SL_PAIRING || m->page == SL_SAVING || m->page == SL_CONNECTING ||
m->page == SL_PIN || m->page == SL_ERROR) {
m->page == SL_PIN || m->page == SL_ERROR ||
(m->page == SL_STOPPING && m->ending_game)) {
++m->generation;
emit(m, SL_CMD_CANCEL);
m->depth = 0;
Expand Down Expand Up @@ -438,7 +443,7 @@ static void action(sl_ui_model *m, sl_action a, int arg) {
emit(m, SL_CMD_SAVE);
break;
case SL_RETRY:
if (m->page == SL_ERROR) {
if (m->page == SL_ERROR && !m->ending_game) {
m->repair_attempted = !m->intent.host.paired;
m->pairing_code[0] = 0;
++m->generation;
Expand Down
11 changes: 11 additions & 0 deletions app/tests/test_launch_watch.c
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,17 @@ int main(void) {
sl_end_game_status(&end, true, false, true, 0);
sl_end_game_status(&end, true, false, true, 1);
assert(end.empty == 2); /* timestamp wrap */
end = (sl_end_game_watch){.at = 100};
assert(sl_end_game_poll(&end, 15100, false) == SL_END_GAME_WAIT);
assert(sl_end_game_poll(&end, 60099, false) == SL_END_GAME_WAIT);
assert(sl_end_game_poll(&end, 60100, false) == SL_END_GAME_TIMEOUT);
assert(sl_end_game_poll(&end, 60100, true) == SL_END_GAME_DONE);
end.empty = 2;
assert(sl_end_game_poll(&end, 60101, false) == SL_END_GAME_DONE);
end.empty = 0;
assert(sl_end_game_poll(&end, 99, false) == SL_END_GAME_WAIT); /* no underflow */
end = (sl_end_game_watch){0};
assert(sl_end_game_poll(&end, 90000, true) == SL_END_GAME_WAIT); /* canceled */
sl_launch_watch s = {.target = 7};
sl_launch_activity(&s, 3, 413080, 0);
sl_launch_status(&s, true, false, true, 1);
Expand Down
1 change: 1 addition & 0 deletions app/tests/test_native.c
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ int main(int argc, char **argv) {
ui.depth = 0;
ui.leaving = false;
ui.streaming = p == SL_STREAM || p == SL_MENU;
ui.ending_game = p == SL_STOPPING;
ui.debug = p == SL_STREAM;
ui.stream_started_at = ui.now;
ui.entered_at = ui.now > 500 ? ui.now - 500 : 0;
Expand Down
54 changes: 54 additions & 0 deletions app/tests/test_ui.c
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,61 @@ static void actionable_settings(void) {
sl_ui_action(&m, SL_OPEN_MANUAL, 0);
assert(m.page == SL_SETTINGS); /* No empty advanced page or stream-time host entry. */
}
static void end_game_failure(void) {
sl_ui_model m = model();
add(&m, 3, "HOST");
m.store.registry.hosts[0].paired = true;
sl_ui_action(&m, SL_START, 0);
sl_command cmd;
assert(sl_ui_take_command(&m, &cmd) && cmd.type == SL_CMD_STREAM);
sl_ui_connected(&m);
sl_ui_action(&m, SL_OPEN_END_GAME, 0);
sl_ui_action(&m, SL_CONFIRM_END_GAME, 0);
assert(sl_ui_take_command(&m, &cmd) && cmd.type == SL_CMD_END_GAME);
sl_ui_action(&m, SL_CONFIRM_END_GAME, 0);
assert(!sl_ui_take_command(&m, &cmd)); /* repeated confirm is inert */
assert(m.layout.label_count == 0 && m.layout.count == 1);
const sl_control *disconnect = &m.layout.controls[0];
assert(disconnect->x >= m.layout.panel_x && disconnect->y >= m.layout.panel_y);
assert(disconnect->x + disconnect->w <= m.layout.panel_x + m.layout.panel_w);
assert(disconnect->y + disconnect->h <= m.layout.panel_y + m.layout.panel_h);
sl_ui_model waiting = m;
sl_runtime_event error = {.type = SL_EVENT_FAILURE, .generation = m.generation};
strcpy(error.text, "End confirmation timed out");
sl_ui_runtime_event(&m, &error);
assert(m.page == SL_ERROR && m.ending_game && !m.streaming);
for (int i = 0; i < m.layout.count; ++i)
assert(m.layout.controls[i].action != SL_RETRY);
for (int i = 0; i < 3; ++i) {
sl_ui_action(&m, SL_ACCEPT, 0);
sl_ui_action(&m, SL_RETRY, 0); /* direct/stale action is guarded too */
assert(!sl_ui_take_command(&m, &cmd));
}
sl_ui_action(&m, SL_BACK, 0);
assert(sl_ui_take_command(&m, &cmd) && cmd.type == SL_CMD_CANCEL);
sl_ui_runtime_event(&m, &error); /* late failure after cancel */
assert(m.page == SL_STOPPING && !m.ending_game);
sl_ui_stopped(&m, false);
assert(m.page == SL_HOME);
sl_ui_action(&m, SL_START, 0); /* an explicit new launch remains possible */
assert(sl_ui_take_command(&m, &cmd) && cmd.type == SL_CMD_STREAM);

m = waiting;
sl_runtime_event stopped = {.type = SL_EVENT_STOPPED, .generation = m.generation};
sl_ui_runtime_event(&m, &stopped);
sl_ui_runtime_event(&m, &error); /* completion wins over a queued old error */
assert(m.page == SL_HOME && !m.ending_game);

m = waiting;
sl_ui_action(&m, SL_ACCEPT, 0); /* don't activate B while waiting */
assert(!sl_ui_take_command(&m, &cmd));
sl_ui_action(&m, SL_BACK, 0); /* bounded user escape from the longer wait */
assert(sl_ui_take_command(&m, &cmd) && cmd.type == SL_CMD_CANCEL);
sl_ui_runtime_event(&m, &stopped); /* old completion must not end a new operation */
assert(m.page == SL_STOPPING && !m.ending_game);
}
int main(void) {
end_game_failure();
carousel();
session_end_events();
wait_screen_cancel_shortcut();
Expand Down
23 changes: 22 additions & 1 deletion docs/STEAMLINK_PROTOCOL_RE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1395,5 +1395,26 @@ Evidence:§22.2 的 `menu_stop_game` 调用控制 129,且发送函数具有
本地产品策略:串流菜单确认“结束游戏”后保持会话接收与重传,等待明确主机结束,
或来自同一 IP/client/instance 的两条递增主机时间戳、明确 gamesRunning=false
的发现状态,再断开并返回首页。请求前已知时间戳作为下界;未知时第一条只建下界。
这不是对官方菜单完整退出时序的复刻。15 秒只作为失败上限,不作为游戏结束的证据;
这不是对官方菜单完整退出时序的复刻。60 秒只作为本地等待上限,不作为游戏结束的证据;
该等待期间不触发普通画面超时。没有强杀进程,也不复用启动取消或 QuitRequest。


### 22.7 结束确认延迟与错误页隔离(2026-09-10,#27)

Evidence:用户在 #27 新评论观察到游戏先退出,Steam 大屏模式稍后关闭,客户端确认接近后者;
原 15 秒超时经常略早于大屏退出。该观察不能单独确定对应哪条通知报文。
复核官方 Android 1.3.32 `/tmp/slink/main.disasm`:SendStopRequest @7abb4c 的
@7abb68–90 只在 state=6 发空 CStopRequest(129),不返回游戏进程状态;
OnSetActivity @7aef08 的 @7aef54–80 委托 activity/id/name;OnStreamDisconnected
@7b00d0 的 @7b014c–54 切换会话停止态。§21 的 StopVideoData 仅停止视频。
本地 `remoteplay.proto` 的 CStopRequest 为空,未定义与它配套的 StopResponse。

Conclusion:在已核对的消息和官方调用路径中,没有证明可用 Desktop、视频停止或可靠包 ACK
提前确认游戏进程退出。保留明确会话结束/两条新鲜无游戏状态的确认依据,不盲加消息。
“主机要等大屏关闭才发确认”的具体时序仍待真实报文证实,不作为新的协议保证。

本地策略撤回 15 秒失败上限,改为 60 秒,允许 B 停止等待并断开;不是撤销已发送的结束请求。
确认与超时同轮发生时确认优先,清理时保留 watch 至 worker join 完成,销毁 session 前读取
host-stop,避免丢失清理期间到达的完成事实。这个检查只覆盖客户端实际收到的证据。
结束失败保留操作类型;会话已销毁时不显示连接“重试”,也不能由迟到/直接重试动作启动游戏。
返回首页后用户仍可主动重新启动。成功结束和取消都使该操作的迟到 UI 事件失效。
Loading