From 39bf65dcd03a912b3a4559d055f3ba490d0b5886 Mon Sep 17 00:00:00 2001 From: kxn Date: Thu, 10 Sep 2026 13:03:27 +0800 Subject: [PATCH 1/2] fix(stream): preserve end-game intent and allow delayed host confirmation --- app/platforms/common/runtime.c | 37 ++++++++++++++++--------- app/resources/i18n/en.json | 2 ++ app/resources/i18n/zh-CN.json | 2 ++ app/src/services/launch_watch.h | 14 ++++++++++ app/src/ui/ui_layout.c | 9 ++++-- app/src/ui/ui_model.c | 9 ++++-- app/tests/test_launch_watch.c | 11 ++++++++ app/tests/test_ui.c | 49 +++++++++++++++++++++++++++++++++ docs/STEAMLINK_PROTOCOL_RE.md | 23 +++++++++++++++- 9 files changed, 138 insertions(+), 18 deletions(-) diff --git a/app/platforms/common/runtime.c b/app/platforms/common/runtime.c index 0ba6943..e2815e1 100644 --- a/app/platforms/common/runtime.c +++ b/app/platforms/common/runtime.c @@ -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) { @@ -462,6 +460,7 @@ 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); @@ -469,6 +468,8 @@ static void stop_session(sl_runtime *r) { 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; @@ -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); @@ -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); } @@ -986,8 +988,10 @@ 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) @@ -995,15 +999,22 @@ static void *worker_main(void *ctx) { 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}); diff --git a/app/resources/i18n/en.json b/app/resources/i18n/en.json index 9d62ed3..5a7cd5b 100644 --- a/app/resources/i18n/en.json +++ b/app/resources/i18n/en.json @@ -118,6 +118,8 @@ "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 Stop waiting", + "ENDING_GAME_WAIT": "Waiting for PC. Stop waiting disconnects.", "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.", diff --git a/app/resources/i18n/zh-CN.json b/app/resources/i18n/zh-CN.json index 4739da6..118ad90 100644 --- a/app/resources/i18n/zh-CN.json +++ b/app/resources/i18n/zh-CN.json @@ -118,6 +118,8 @@ "END_GAME_QUESTION": "结束电脑上的游戏?", "END_GAME_EXPLANATION": "游戏将关闭,请先保存进度。", "END_GAME_FAILED": "未能确认游戏已结束,请检查电脑。", + "STOP_WAITING_KEY": "B 停止等待", + "ENDING_GAME_WAIT": "等待电脑确认;停止等待只会断开串流。", "ENDING_GAME": "正在结束游戏", "APPLET_UNSUPPORTED": "不支持 applet 模式。", "APPLET_INSTRUCTION": "按住 R 启动游戏进入 hbmenu,\n再打开 NSteamLink。", diff --git a/app/src/services/launch_watch.h b/app/src/services/launch_watch.h index 7c8d857..27cb5e8 100644 --- a/app/src/services/launch_watch.h +++ b/app/src/services/launch_watch.h @@ -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; +} diff --git a/app/src/ui/ui_layout.c b/app/src/ui/ui_layout.c index f754332..e75480b 100644 --- a/app/src/ui/ui_layout.c +++ b/app/src/ui/ui_layout.c @@ -160,6 +160,10 @@ 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) { + text(l, 320, 282, 26, sl_tr(SL_T_ENDING_GAME_WAIT)); + 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)); @@ -206,9 +210,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: diff --git a/app/src/ui/ui_model.c b/app/src/ui/ui_model.c index bd513af..34307f0 100644 --- a/app/src/ui/ui_model.c +++ b/app/src/ui/ui_model.c @@ -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) { @@ -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; @@ -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; @@ -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; diff --git a/app/tests/test_launch_watch.c b/app/tests/test_launch_watch.c index 9d34d45..8936897 100644 --- a/app/tests/test_launch_watch.c +++ b/app/tests/test_launch_watch.c @@ -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); diff --git a/app/tests/test_ui.c b/app/tests/test_ui.c index 739c254..92b88f0 100644 --- a/app/tests/test_ui.c +++ b/app/tests/test_ui.c @@ -353,7 +353,56 @@ 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 */ + 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(); diff --git a/docs/STEAMLINK_PROTOCOL_RE.md b/docs/STEAMLINK_PROTOCOL_RE.md index 46ef051..04694d9 100644 --- a/docs/STEAMLINK_PROTOCOL_RE.md +++ b/docs/STEAMLINK_PROTOCOL_RE.md @@ -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 事件失效。 From f59d4a0532d12962214e406e97aab3cf74b16466 Mon Sep 17 00:00:00 2001 From: kxn Date: Thu, 10 Sep 2026 13:18:49 +0800 Subject: [PATCH 2/2] fix(ui): keep end-game disconnect button inside its dialog --- app/resources/i18n/en.json | 3 +-- app/resources/i18n/zh-CN.json | 3 +-- app/src/ui/ui_layout.c | 9 ++++++++- app/tests/test_native.c | 1 + app/tests/test_ui.c | 5 +++++ 5 files changed, 16 insertions(+), 5 deletions(-) diff --git a/app/resources/i18n/en.json b/app/resources/i18n/en.json index 5a7cd5b..e56afc2 100644 --- a/app/resources/i18n/en.json +++ b/app/resources/i18n/en.json @@ -118,8 +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 Stop waiting", - "ENDING_GAME_WAIT": "Waiting for PC. Stop waiting disconnects.", + "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.", diff --git a/app/resources/i18n/zh-CN.json b/app/resources/i18n/zh-CN.json index 118ad90..d710b45 100644 --- a/app/resources/i18n/zh-CN.json +++ b/app/resources/i18n/zh-CN.json @@ -118,8 +118,7 @@ "END_GAME_QUESTION": "结束电脑上的游戏?", "END_GAME_EXPLANATION": "游戏将关闭,请先保存进度。", "END_GAME_FAILED": "未能确认游戏已结束,请检查电脑。", - "STOP_WAITING_KEY": "B 停止等待", - "ENDING_GAME_WAIT": "等待电脑确认;停止等待只会断开串流。", + "STOP_WAITING_KEY": "B 断开", "ENDING_GAME": "正在结束游戏", "APPLET_UNSUPPORTED": "不支持 applet 模式。", "APPLET_INSTRUCTION": "按住 R 启动游戏进入 hbmenu,\n再打开 NSteamLink。", diff --git a/app/src/ui/ui_layout.c b/app/src/ui/ui_layout.c index e75480b..eee1cdb 100644 --- a/app/src/ui/ui_layout.c +++ b/app/src/ui/ui_layout.c @@ -161,7 +161,6 @@ void sl_ui_layout(sl_ui_model *m) { case SL_STOPPING: strcpy(l->title, sl_tr(m->ending_game ? SL_T_ENDING_GAME : SL_T_DISCONNECTING)); if (m->ending_game) { - text(l, 320, 282, 26, sl_tr(SL_T_ENDING_GAME_WAIT)); button(l, 9, 320, 596, 640, 64, sl_tr(SL_T_STOP_WAITING_KEY), SL_BACK, 0, false); } break; @@ -279,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; diff --git a/app/tests/test_native.c b/app/tests/test_native.c index db9c0be..6f8dae3 100644 --- a/app/tests/test_native.c +++ b/app/tests/test_native.c @@ -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; diff --git a/app/tests/test_ui.c b/app/tests/test_ui.c index 92b88f0..4a6e930 100644 --- a/app/tests/test_ui.c +++ b/app/tests/test_ui.c @@ -366,6 +366,11 @@ static void end_game_failure(void) { 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");