Compare commits

...
11 Commits
Author SHA1 Message Date
Junkun Di 852710f428 Merge pull request #111 from lamugu/fix/recent-tooltip-layer
最近连接悬浮框不再压住连接弹窗的确认按钮
2026-10-11 17:22:58 +08:00
Junkun Di 2d51936ca8 Merge pull request #110 from lamugu/fix/dtls-handshake-timeout
[fix] time out stalled DTLS handshakes so reconnects can recover
2026-10-11 17:03:37 +08:00
dijunkun 3baedac6df [chore] update MiniRTC after removing deadline tests 2026-10-11 16:59:41 +08:00
dijunkun a667fe3b31 [fix] preserve current MiniRTC fixes with bounded DTLS recovery 2026-10-11 16:57:26 +08:00
dijunkun 05f6d3d90a [fix] show truthful signaling recovery state and pin safe retries 2026-10-11 15:35:11 +08:00
lamagu 0e7d175631 [feat] add manual signaling reconnect action 2026-10-11 15:35:11 +08:00
lamagu 52ca253980 [fix] delay signaling offline indication during reconnects 2026-10-11 15:35:11 +08:00
lamagu 1925e5ffcc [fix] time out stalled DTLS handshakes so reconnects can recover
Without a deadline a half-negotiated session stayed CONNECTED forever;
every further reconnect reused that dead state until the app restarted.
2026-10-10 21:03:14 +08:00
dijunkun 3aa0470510 [fix] restore password prompts and handle join authentication failures 2026-10-10 17:00:58 +08:00
dijunkun fbbb8c9e2c [fix] align Android versions with release tags, refs #107 2026-10-10 15:10:03 +08:00
lamagu 3750687e66 最近连接悬浮框不再压住连接弹窗的确认按钮
悬浮框当初为了盖住 ScrollView 给了 z:50,正好和模态层(z 39~51)同层,
鼠标停在列表上时黑框会盖住"对等连接已建立"的确认按钮。降到 z:20(模态层
之下、标题菜单之上),并在连接弹窗打开时直接不显示。
2026-10-10 14:01:44 +08:00
21 changed files with 388 additions and 74 deletions
+56 -26
View File
@@ -2,6 +2,12 @@ name: Build Android
on:
workflow_dispatch:
inputs:
patch:
description: "Optional patch number, matching the main build workflow."
required: false
type: string
default: "0"
workflow_call:
inputs:
source_tag:
@@ -9,9 +15,14 @@ on:
required: false
type: string
default: ""
patch:
description: "Optional patch number from the main build workflow."
required: false
type: string
default: "0"
outputs:
version:
description: "Independent Android version and build date."
description: "Android artifact version; combined releases match the desktop tag."
value: ${{ jobs.build.outputs.version }}
pull_request:
paths:
@@ -147,51 +158,70 @@ jobs:
- name: Verify shared license catalog
run: python3 -B apps/ios/scripts/generate_third_party_licenses.py
- name: Resolve Android version
shell: python
env:
PATCH_NUMBER: ${{ inputs.patch || '0' }}
run: |
import datetime
import os
from pathlib import Path
import re
import runpy
resolver = Path("apps/android/scripts/release_version.py")
if resolver.exists():
runpy.run_path(str(resolver), run_name="__main__")
else:
# Older standalone releases predate the resolver and Gradle properties.
# Keep their original package version when retrying an existing tag.
tag = os.environ["CROSSDESK_SOURCE_TAG"]
match = re.fullmatch(r"android-v([0-9]+\.[0-9]+\.[0-9]+)-([0-9]{8})", tag)
config = Path("apps/android/app/build.gradle").read_text()
name = re.search(r"versionName\s+'([^']+)'", config)
code = re.search(r"versionCode\s+([0-9]+)", config)
if not match or not name or not code or match[1] != name[1]:
raise ValueError("Legacy Android sources require a matching standalone release tag")
datetime.datetime.strptime(match[2], "%Y%m%d")
with open(os.environ["GITHUB_ENV"], "a") as stream:
stream.write(f"ANDROID_VERSION_NAME={name[1]}\nANDROID_VERSION_CODE={code[1]}\n"
f"ANDROID_ARTIFACT_VERSION={tag.removeprefix('android-')}\n")
- name: Build APKs
working-directory: apps/android
run: |
./gradlew --no-daemon --stacktrace \
-PcrossdeskVersionName="${ANDROID_VERSION_NAME}" \
-PcrossdeskVersionCode="${ANDROID_VERSION_CODE}" \
:app:assembleDebug :app:assembleRelease
- name: Verify APKs
working-directory: apps/android
run: |
python3 -B test/verify_apk.py app/build/outputs/apk/debug/app-debug.apk
python3 -B test/verify_apk.py app/build/outputs/apk/release/app-release-unsigned.apk
for APK in app/build/outputs/apk/debug/app-debug.apk app/build/outputs/apk/release/app-release-unsigned.apk; do
python3 -B test/verify_apk.py "${APK}" \
--aapt2 "${ANDROID_HOME}/build-tools/36.0.0/aapt2" \
--version-name "${ANDROID_VERSION_NAME}" --version-code "${ANDROID_VERSION_CODE}"
done
"${ANDROID_HOME}/build-tools/36.0.0/apksigner" verify app/build/outputs/apk/debug/app-debug.apk
- name: Package APKs with the independent Android version
- name: Package APKs with the resolved Android version
id: package
shell: python
run: |
import datetime
import json
import os
from pathlib import Path
import re
import shutil
from zoneinfo import ZoneInfo
apk_dir = Path("apps/android/app/build/outputs/apk")
metadata = json.loads((apk_dir / "release/output-metadata.json").read_text())
version = metadata["elements"][0]["versionName"]
if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version):
raise ValueError(f"Invalid Android version: {version!r}")
build_date = datetime.datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d")
# Match the dated release tags used by the desktop and iOS jobs.
tag = os.environ.get("CROSSDESK_SOURCE_TAG")
if not tag and os.environ["GITHUB_REF_TYPE"] == "tag":
tag = os.environ["GITHUB_REF_NAME"]
if tag:
tag = tag.removeprefix("android-")
match = re.fullmatch(r"v?[0-9]+(?:\.[0-9]+){1,3}-(?:[0-9]+-)?([0-9]{8})(?:-[0-9]+)?", tag)
if match:
build_date = match[1]
datetime.datetime.strptime(build_date, "%Y%m%d")
version = f"v{version}-{build_date}"
source_tag = os.environ.get("CROSSDESK_SOURCE_TAG", "")
if source_tag.startswith("android-v") and source_tag.removeprefix("android-") != version:
raise ValueError(f"Android source tag does not match the app version: {source_tag} vs {version}")
version = os.environ["ANDROID_ARTIFACT_VERSION"]
for variant in ("debug", "release"):
metadata = json.loads((apk_dir / variant / "output-metadata.json").read_text())
for element in metadata["elements"]:
if (element["versionName"] != os.environ["ANDROID_VERSION_NAME"] or
element["versionCode"] != int(os.environ["ANDROID_VERSION_CODE"])):
raise ValueError(f"Unexpected Android {variant} version: {element}")
output = Path(os.environ["RUNNER_TEMP"]) / "crossdesk-android"
for variant, source in (
("unsigned", "release/app-release-unsigned.apk"),
+2
View File
@@ -346,6 +346,8 @@ jobs:
build-android:
name: Build Android
uses: ./.github/workflows/build-android.yml
with:
patch: ${{ inputs.patch || '0' }}
permissions:
contents: read
+7 -7
View File
@@ -1,6 +1,6 @@
# CrossDesk Android
原生 Android 控制端,使用 Java 界面、JNI 和 MiniRTC,与桌面端使用相同的信令、ICE、SRTP 和输入协议。没有 WebView。当前版本为 1.6.0,支持 Android 8.0(API 26)及以上的 arm64 手机和平板。
原生 Android 控制端,使用 Java 界面、JNI 和 MiniRTC,与桌面端使用相同的信令、ICE、SRTP 和输入协议。没有 WebView。当前版本为 1.6.1,支持 Android 8.0(API 26)及以上的 arm64 手机和平板。
## 当前功能
@@ -83,11 +83,11 @@ Release 构建使用 `./gradlew :app:assembleRelease`,默认输出未签名 AP
[`Build Android`](../../.github/workflows/build-android.yml) 可在 Actions 页面单独手动运行;修改 Android、MiniRTC、共享协议或许可证资料的 Pull Request 也会触发。仓库原有的 [`Build and Release`](../../.github/workflows/build.yml) 在分支推送、标签推送及手动运行时调用同一工作流,`v` 标签发布会等待安卓构建及签名成功,并将已签名 Release APK 加入 GitHub Release 和现有下载服务器的产物集合。推送主仓库前,先确保它引用的 MiniRTC 提交已推送到子模块远程仓库,否则 CI 无法完成检出。
单独发布使用 [`Release Mobile`](../../.github/workflows/release-mobile.yml)。先提交 `app/build.gradle` 中需要发布的 `versionName`,再为该提交创建 `android-v<versionName>-YYYYMMDD` 标签,例如:
单独发布使用 [`Release Mobile`](../../.github/workflows/release-mobile.yml)。先更新并提交 `gradle.properties` 中的 `crossdeskVersionName` 和递增的 `crossdeskVersionCode`,再为该提交创建 `android-v<versionName>-YYYYMMDD` 标签,例如:
```sh
git tag android-v1.6.0-20261002
git push origin android-v1.6.0-20261002
git tag android-v1.6.1-20261010
git push origin android-v1.6.1-20261010
```
该标签只触发安卓构建,使用固定发布密钥签名并验证 Release APK 后,再发布到独立的 GitHub Release 和现有下载服务器。签名失败或缺少签名 Secrets 时停止发布,不回退到未签名 APK。标签版本必须与应用版本一致,日期必须有效。重试发布时,在 Actions 页面手动运行 `Release Mobile`,将已有标签填入 `source_tag`;即使从其他分支触发,也会检出该标签的源码。APK 的源码资料记录安卓标签及准确的应用、MiniRTC 提交。
@@ -100,15 +100,15 @@ git push origin android-v1.6.0-20261002
Gradle 缓存与 Android 原生依赖缓存分别管理;原生缓存按宿主架构、构建脚本及 MiniRTC 配方区分,不复用桌面或 iOS 的配置和目标文件。GitHub 托管 runner 每次启动仍是新环境;第一次运行或缓存失效时需要安装。不同分支和标签可读取默认分支的缓存,因此建议先在默认分支运行一次 `Build Android` 预热,再创建发布标签。
CI 仅构建和打包 Debug、Release APK,并校验 APK 的 arm64 ABI、16 KB ELF / ZIP 对齐、完整许可证及 Debug 签名。单元测试、本机 C++ 协议与画面队列测试、Lint 和设备测试改为按需在本地执行;设备测试使用下面的设备验证流程在 arm64 手机或模拟器上执行。
CI 仅构建和打包 Debug、Release APK,并校验 APK 的包名、`versionName` / `versionCode`、arm64 ABI、16 KB ELF / ZIP 对齐、完整许可证及 Debug 签名。单元测试、本机 C++ 协议与画面队列测试、Lint 和设备测试改为按需在本地执行;设备测试使用下面的设备验证流程在 arm64 手机或模拟器上执行。
产物使用 Android 工程自己的 `versionName`,例如 `v1.6.0-20261002`,不跟随桌面版本号。构建日期采用上海时区;带日期的版本标签沿用标签中的日期,与 iOS 一致。Actions 提供:
统一 `v*` 发布从标签提取数字版本,通过 Gradle 属性写入 APK 的 `versionName`;App 内版本展示读取同一包信息。产物版本、日期及可选热修复编号与桌面端保持一致,例如 `v1.6.1-20261010` 或 `v1.6.1-2-20261010`。分支构建和独立 `android-v*` 发布使用 `gradle.properties` 中的版本,独立发布会检查标签与配置一致。构建日期采用上海时区;带日期的版本标签沿用标签中的日期,与 iOS 一致。Actions 提供:
- `crossdesk-android-arm64-<版本>.apk`:已签名 Release APK,独立安卓发布和全平台标签发布收集此 APK,可直接安装。
- `crossdesk-android-arm64-unsigned-<版本>.apk`:构建工作流的中间产物,也可用于自行签名和验证修改后的构建。
- `crossdesk-android-arm64-debug-<版本>.apk`:可安装的调试应用,保留 14 天。
Debug APK 使用每次运行的临时调试证书,可能无法覆盖手机上已有的其他签名版本;卸载原应用会清除本机连接记录和密码。需要持续覆盖升级时,应使用同一私钥签名 Release APK,并在发布新版本时递增 `app/build.gradle` 中的 `versionCode`。未签名 APK 已完成对齐,可通过 SDK 的 `apksigner sign --ks <自己的密钥库> --out <已签名.apk> <未签名.apk>` 签名,再用 `apksigner verify` 检查。
Debug APK 使用每次运行的临时调试证书,可能无法覆盖手机上已有的其他签名版本;卸载原应用会清除本机连接记录和密码。需要持续覆盖升级时,应使用同一私钥签名 Release APK,并在每次发布新版本(含统一发布及热修复)时递增 `gradle.properties` 中的 `crossdeskVersionCode`。该安装升级序号独立于显示版本,不从日期或 CI 运行编号推算。未签名 APK 已完成对齐,可通过 SDK 的 `apksigner sign --ks <自己的密钥库> --out <已签名.apk> <未签名.apk>` 签名,再用 `apksigner verify` 检查。
### Release 签名
+2 -2
View File
@@ -8,8 +8,8 @@ android {
applicationId 'cn.crossdesk.mobile'
minSdk 26
targetSdk 36
versionCode 2
versionName '1.6.0'
versionCode providers.gradleProperty('crossdeskVersionCode').get().toInteger()
versionName providers.gradleProperty('crossdeskVersionName').get()
ndk { abiFilters 'arm64-v8a' }
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
}
+2
View File
@@ -1,2 +1,4 @@
org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8
android.useAndroidX=true
crossdeskVersionName=1.6.1
crossdeskVersionCode=3
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Resolve Android package and artifact versions before building the APKs."""
import datetime
import os
from pathlib import Path
import re
from zoneinfo import ZoneInfo
def resolve_version(version_name, version_code, tag="", patch="0", today=None):
build_date = today or datetime.datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d")
patch = patch if re.fullmatch(r"[0-9]+", patch) else "0"
if tag:
independent = tag.startswith("android-v")
source_version = tag.removeprefix("android-")
base = r"(?P<base>[0-9]+\.[0-9]+\.[0-9]+)"
for pattern in (
rf"v{base}-(?P<patch>[0-9]+)-(?P<date>[0-9]{{8}})",
rf"v{base}-(?P<date>[0-9]{{8}})(?:-(?P<patch>[0-9]+))?",
rf"v{base}(?:-(?P<patch>[0-9]+))?",
):
match = re.fullmatch(pattern, source_version)
if match:
fields = match.groupdict()
if independent and fields["base"] != version_name:
raise ValueError(f"Android source tag does not match the app version: {tag} vs {version_name}")
version_name = fields["base"]
build_date = fields.get("date") or build_date
patch = fields.get("patch") or patch
break
else:
raise ValueError(f"Invalid Android release tag: {tag!r}")
if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version_name):
raise ValueError(f"Invalid Android versionName: {version_name!r}")
if not re.fullmatch(r"[0-9]+", version_code) or not 0 < int(version_code) <= 2100000000:
raise ValueError(f"Invalid Android versionCode: {version_code!r}")
datetime.datetime.strptime(build_date, "%Y%m%d")
suffix = f"-{patch}" if patch != "0" else ""
artifact_version = f"v{version_name}{suffix}-{build_date}"
if tag.startswith("android-v") and tag.removeprefix("android-") != artifact_version:
raise ValueError(f"Android source tag does not match the artifact version: {tag} vs {artifact_version}")
return {"ANDROID_VERSION_NAME": version_name, "ANDROID_VERSION_CODE": str(int(version_code)),
"ANDROID_ARTIFACT_VERSION": artifact_version}
def main():
properties = dict(line.split("=", 1) for line in
(Path(__file__).resolve().parents[1] / "gradle.properties").read_text().splitlines()
if "=" in line and not line.lstrip().startswith("#"))
tag = os.environ.get("CROSSDESK_SOURCE_TAG", "")
if not tag and os.environ.get("GITHUB_REF_TYPE") == "tag":
ref_name = os.environ["GITHUB_REF_NAME"]
# Moving aliases such as "latest" are builds, not versioned releases.
if ref_name.startswith(("v", "android-v")):
tag = ref_name
values = resolve_version(properties["crossdeskVersionName"], properties["crossdeskVersionCode"],
tag, os.environ.get("PATCH_NUMBER", "0"))
text = "".join(f"{key}={value}\n" for key, value in values.items())
if os.environ.get("GITHUB_ENV"):
with open(os.environ["GITHUB_ENV"], "a") as stream:
stream.write(text)
print(text, end="")
if __name__ == "__main__":
main()
+25 -2
View File
@@ -1,13 +1,24 @@
#!/usr/bin/env python3
"""Check the shipped native ABI, ELF load alignment and APK zip alignment."""
from pathlib import Path
import argparse
import hashlib
import json
import re
import struct
import sys
import subprocess
import zipfile
def verify_package_version(badging, version_name, version_code):
package = next((line for line in badging.splitlines() if line.startswith('package: ')), '')
attributes = dict(re.findall(r"(\w+)='([^']*)'", package))
expected = {'name': 'cn.crossdesk.mobile', 'versionName': version_name, 'versionCode': str(version_code)}
for key, value in expected.items():
if attributes.get(key) != value:
raise ValueError(f'Unexpected APK {key}: expected {value!r}, got {attributes.get(key)!r}')
def verify(path):
with zipfile.ZipFile(path) as archive, open(path, 'rb') as raw:
libraries = [i for i in archive.infolist() if i.filename.endswith('.so')]
@@ -59,4 +70,16 @@ def verify(path):
if __name__ == '__main__':
verify(sys.argv[1])
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('apk', type=Path)
parser.add_argument('--aapt2', type=Path)
parser.add_argument('--version-name')
parser.add_argument('--version-code', type=int)
args = parser.parse_args()
if any(value is not None for value in (args.aapt2, args.version_name, args.version_code)):
if any(value is None for value in (args.aapt2, args.version_name, args.version_code)):
parser.error('--aapt2, --version-name and --version-code must be supplied together')
badging = subprocess.check_output([str(args.aapt2), 'dump', 'badging', str(args.apk)], text=True)
verify_package_version(badging, args.version_name, args.version_code)
print(f'PASS: {args.apk.name}: versionName={args.version_name}, versionCode={args.version_code}')
verify(args.apk)
@@ -72,8 +72,6 @@ struct ServerWindowState {
};
struct UiState {
bool password_validating_ = false;
uint32_t password_validating_time_ = 0;
bool rejoin_ = false;
bool show_password_ = true;
bool show_connection_status_window_ = false;
@@ -1363,6 +1363,12 @@ void GuiApplication::BindMainCallbacks() {
LOG_WARN("Copy local id failed: {}", SDL_GetError());
}
});
main->on_signal_reconnect_requested([this] {
LOG_INFO("Signal reconnect requested from the status bar");
if (ForceSignalReconnect() != 0) {
LOG_WARN("Signal reconnect could not be started");
}
});
main->on_toggle_password_visibility([this] {
show_password_ = !show_password_;
ui_->main->set_password_visible(show_password_);
@@ -1451,14 +1457,8 @@ void GuiApplication::BindMainCallbacks() {
show_connection_status_window_ = false;
return;
}
const ConnectionStatus status = props->connection_status_.load();
if (status == ConnectionStatus::IncorrectPassword) {
std::memset(props->remote_password_, 0, sizeof(props->remote_password_));
password_validating_ = false;
} else {
CloseStreamTab(props->remote_id_);
re_enter_remote_id_ = true;
}
CloseStreamTab(props->remote_id_);
re_enter_remote_id_ = true;
show_connection_status_window_ = false;
});
main->on_connection_acknowledge([this] {
@@ -1480,11 +1480,11 @@ void GuiApplication::BindMainCallbacks() {
if (!props || password.empty() || password.size() > 6) {
return;
}
if (!props->connection_auth_.Begin(password)) return;
std::memset(props->remote_password_, 0, sizeof(props->remote_password_));
std::memcpy(props->remote_password_, password.data(), password.size());
props->remember_password_ = remember;
props->rejoin_ = true;
password_validating_ = true;
need_to_rejoin_ = true;
});
main->on_request_screen_recording_permission([this] {
@@ -1886,6 +1886,7 @@ void GuiApplication::Tick() {
HandlePasswordChangeResult();
HandleCredentialRecovery();
if (!peer_) {
signal_connection_indicator_.Reset();
CreateConnectionPeer();
}
#ifdef __linux__
@@ -2477,7 +2478,10 @@ void GuiApplication::SyncMainWindow() {
std::lock_guard<std::mutex> lock(password_change_mutex_);
ui_->main->set_password_change_pending(password_change_pending_);
}
ui_->main->set_signal_connected(signal_connected_);
const bool signal_online = signal_connected_.load();
ui_->main->set_signal_connected(signal_online);
ui_->main->set_signal_reconnecting(
signal_connection_indicator_.Update(signal_online));
ui_->main->set_signal_tls_error(signal_status_ ==
SignalStatus::SignalTlsCertError);
ui_->main->set_update_available(update_available_);
@@ -2580,26 +2584,41 @@ void GuiApplication::SyncConnectionDialog() {
}
ui_->connection_dialog_remote_id = props->remote_id_;
const ConnectionStatus status = props->connection_status_.load();
const bool password_required = status == ConnectionStatus::IncorrectPassword;
const bool pending = status == ConnectionStatus::Connecting ||
status == ConnectionStatus::Gathering;
const auto auth = props->connection_auth_.Get();
const bool password_required = auth.password_required ||
(status == ConnectionStatus::IncorrectPassword && auth.validating);
const bool pending = !password_required &&
(status == ConnectionStatus::Connecting || status == ConnectionStatus::Gathering);
std::string text;
if (password_required) {
text = password_validating_
text = auth.validating
? localization::validate_password[localization_language_index_]
: password_validating_time_ <= 1
? localization::input_password[localization_language_index_]
: localization::reinput_password[localization_language_index_];
: auth.incorrect_password
? localization::reinput_password[localization_language_index_]
: localization::input_password[localization_language_index_];
} else if (auth.error == minirtc::JoinFailureKind::Throttled) {
text = localization::connection_auth_throttled[localization_language_index_];
} else if (auth.error == minirtc::JoinFailureKind::Busy) {
text = localization::connection_service_busy[localization_language_index_];
} else if (auth.error == minirtc::JoinFailureKind::NotAuthenticated) {
text = localization::connection_not_authenticated[localization_language_index_];
} else {
text = ConnectionStatusText(status, localization_language_index_);
}
if (auth.retry_after > 0) {
char retry_text[256];
std::snprintf(retry_text, sizeof(retry_text),
localization::connection_retry_after[localization_language_index_].c_str(),
auth.retry_after);
text += "\n" + std::string(retry_text);
}
ui_->main->set_connection_dialog_open(true);
ui_->main->set_connection_status_text(UiText(text));
ui_->main->set_connection_pending(pending);
ui_->main->set_connection_password_required(password_required);
ui_->main->set_connection_validating(password_validating_);
if (password_required && !password_validating_ &&
ui_->main->set_connection_validating(auth.validating);
if (password_required && !auth.validating &&
!ui_->connection_password_initialized) {
ui_->main->set_connection_password(props->remote_password_);
ui_->main->set_connection_remember_password(props->remember_password_);
@@ -14,6 +14,7 @@
#include <remote_action.h>
#include "application/signal_connection_indicator.h"
#include "runtime/cursor_state_provider.h"
#include "runtime/gui_runtime.h"
@@ -106,6 +107,7 @@ private:
cursor_delivery_states_;
uint32_t cursor_state_sequence_ = 0;
std::chrono::steady_clock::time_point next_video_frame_time_{};
SignalConnectionIndicator signal_connection_indicator_;
#if defined(__linux__) && !defined(__APPLE__)
bool use_xwayland_gui_ = false;
bool use_x11_custom_titlebar_ = false;
@@ -0,0 +1,38 @@
#ifndef CROSSDESK_SIGNAL_CONNECTION_INDICATOR_H_
#define CROSSDESK_SIGNAL_CONNECTION_INDICATOR_H_
#include <chrono>
#include <optional>
namespace crossdesk {
// Presentation state only. The actual connected flag remains authoritative
// for actions and other UI consumers, including the announcement inbox.
class SignalConnectionIndicator {
public:
using Clock = std::chrono::steady_clock;
bool Update(bool connected, Clock::time_point now = Clock::now()) {
if (connected) {
disconnected_at_.reset();
} else if (was_connected_) {
disconnected_at_ = now;
}
was_connected_ = connected;
return disconnected_at_ &&
now - *disconnected_at_ < std::chrono::seconds(10);
}
void Reset() {
was_connected_ = false;
disconnected_at_.reset();
}
private:
bool was_connected_ = false;
std::optional<Clock::time_point> disconnected_at_;
};
} // namespace crossdesk
#endif
@@ -258,6 +258,9 @@ struct TranslationRow {
X(signal_connected, u8"已连接服务器", "Connected", u8"Подключено к серверу") \
X(signal_disconnected, u8"未连接服务器", "Disconnected", \
u8"Нет подключения к серверу") \
X(signal_reconnect, u8"重新连接", "Reconnect", u8"Переподключиться") \
X(signal_reconnecting, u8"正在重新连接...", "Reconnecting...", \
u8"Переподключение...") \
X(signal_tls_cert_error, u8"证书验证失败,请重新安装自托管根证书", \
"Certificate verification failed. Reinstall the self-hosted root " \
"certificate.", \
@@ -272,6 +275,10 @@ struct TranslationRow {
X(receiving_screen, u8"画面接收中...", "Receiving screen...", \
u8"Получение изображения...") \
X(p2p_failed, u8"对等连接失败", "P2P Failed", u8"Сбой P2P") \
X(connection_auth_throttled, u8"验证尝试过于频繁,请稍后重试。", "Too many authentication attempts. Try again later.", u8"Слишком много попыток. Повторите позже.") \
X(connection_service_busy, u8"认证服务繁忙,请稍后重试。", "Authentication service busy. Try again later.", u8"Сервис проверки занят. Повторите позже.") \
X(connection_not_authenticated, u8"登录状态已失效,请重新连接。", "Signaling login expired. Please reconnect.", u8"Сеанс входа истёк. Подключитесь заново.") \
X(connection_retry_after, u8"请在 %d 秒后重试。", "Retry in %d seconds.", u8"Повторите через %d сек.") \
X(p2p_closed, u8"对等连接已关闭", "P2P closed", u8"P2P закрыто") \
X(no_such_id, u8"无此ID", "No such ID", u8"ID не найден") \
X(about, u8"关于", "About", u8"О программе") \
@@ -0,0 +1,72 @@
#ifndef CROSSDESK_CONNECTION_AUTH_STATE_H_
#define CROSSDESK_CONNECTION_AUTH_STATE_H_
#include <chrono>
#include <mutex>
#include <string_view>
#include "join_failure.h"
namespace crossdesk {
// One remote session's password prompt and server-requested retry delay.
// Signaling callbacks and the UI access a single synchronized snapshot.
class ConnectionAuthState {
public:
using Clock = std::chrono::steady_clock;
struct Snapshot {
bool password_required = false;
bool validating = false;
bool incorrect_password = false;
minirtc::JoinFailureKind error = minirtc::JoinFailureKind::Unknown;
int retry_after = 0;
};
bool Begin(std::string_view password, Clock::time_point now = Clock::now()) {
std::lock_guard lock(mutex_);
if (retry_at_ > now) return false;
state_ = {};
retry_at_ = {};
state_.password_required = password.empty();
state_.validating = !password.empty();
return state_.validating;
}
void Rejected(const nlohmann::json& reply,
Clock::time_point now = Clock::now()) {
const auto failure = minirtc::ParseJoinFailure(reply);
std::lock_guard lock(mutex_);
state_ = {};
state_.error = failure.kind;
state_.incorrect_password =
failure.kind == minirtc::JoinFailureKind::AuthenticationFailed;
state_.password_required =
state_.incorrect_password ||
failure.kind == minirtc::JoinFailureKind::PasswordRequired;
retry_at_ = now + std::chrono::seconds(failure.retry_after);
}
void Complete() {
std::lock_guard lock(mutex_);
state_.validating = false;
}
Snapshot Get(Clock::time_point now = Clock::now()) const {
std::lock_guard lock(mutex_);
auto snapshot = state_;
if (retry_at_ > now) {
snapshot.retry_after = static_cast<int>(
std::chrono::ceil<std::chrono::seconds>(retry_at_ - now).count());
}
return snapshot;
}
private:
mutable std::mutex mutex_;
Snapshot state_;
Clock::time_point retry_at_{};
};
} // namespace crossdesk
#endif
@@ -227,6 +227,14 @@ int GuiRuntime::RequestSingleDevicePresence(const std::string& remote_id,
return ret;
}
int GuiRuntime::ForceSignalReconnect() {
if (!peer_) {
LOG_WARN("Signal reconnect requested with no connection peer");
return -1;
}
return ReconnectSignal(peer_);
}
void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
if (!props || props->closing_.exchange(true)) return;
if (props->peer_events_) props->peer_events_->Deactivate();
@@ -479,6 +487,14 @@ std::shared_ptr<GuiRuntime::RemoteSession> GuiRuntime::FindRemoteSession(
int GuiRuntime::ConnectTo(const std::string& remote_id, const char* password,
bool remember_password, bool bypass_presence_check) {
// Do not recreate a failed peer or send another probe during a server cooldown.
if (const auto current = FindRemoteSession(remote_id);
current && current->connection_auth_.Get().retry_after > 0) {
focused_remote_id_ = remote_id;
show_connection_status_window_ = true;
current->rejoin_ = false;
return 0;
}
if (session_cleanup_tasks_.count(remote_id)) {
pending_reconnects_[remote_id] = {password ? password : "", remember_password};
LOG_INFO("[{}] Reconnect queued until previous peer is destroyed", remote_id);
@@ -595,18 +611,23 @@ int GuiRuntime::ConnectTo(const std::string& remote_id, const char* password,
auto props = FindRemoteSession(remote_id);
if (!props || props->closing_) return -1;
if (!props->connection_established_) {
props->connection_status_.store(ConnectionStatus::Connecting);
show_connection_status_window_ = true;
props->remember_password_ = remember_password;
if (strcmp(password, "") != 0 &&
if (password && *password &&
strcmp(password, props->remote_password_) != 0) {
strncpy(props->remote_password_, password,
sizeof(props->remote_password_) - 1);
props->remote_password_[sizeof(props->remote_password_) - 1] = '\0';
}
std::string remote_id_with_pwd = remote_id + "@" + password;
// Ask locally before Join; an empty-password round trip is no longer needed.
if (!props->connection_auth_.Begin(props->remote_password_)) {
props->rejoin_ = false;
return 0;
}
props->connection_status_.store(ConnectionStatus::Connecting);
std::string remote_id_with_pwd = remote_id + "@" + props->remote_password_;
if (props->peer_) {
ret = JoinConnection(props->peer_, remote_id_with_pwd.c_str());
if (0 == ret) {
@@ -54,6 +54,7 @@ class GuiRuntime : protected gui_detail::GuiState {
bool remember_password, bool bypass_presence_check = false);
int RequestSingleDevicePresence(const std::string& remote_id,
const char* password, bool remember_password);
int ForceSignalReconnect();
void HandleRecentConnections();
void HandleConnectionStatusChange();
@@ -79,7 +79,12 @@ void PeerEventHandler::OnSignalMessage(const char* message, size_t size,
return;
}
std::string type = j["type"].get<std::string>();
if (type == "announcements_changed") {
if (type == "user_join_transmission" && j.contains("status") &&
j["status"] == "failed" && j.contains("transmission_id") &&
j["transmission_id"].is_string()) {
const auto props = runtime->FindRemoteSession(j["transmission_id"].get<std::string>());
if (props && !props->closing_) props->connection_auth_.Rejected(j);
} else if (type == "announcements_changed") {
runtime->announcements_.Refresh();
} else if (type == "announcements") {
runtime->announcements_.Receive(j);
@@ -265,6 +270,8 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
runtime->is_client_mode_ = true;
runtime->show_connection_status_window_ = true;
props->connection_status_.store(status);
if (status != ConnectionStatus::Connecting && status != ConnectionStatus::Gathering)
props->connection_auth_.Complete();
switch (status) {
case ConnectionStatus::Connected: {
@@ -385,11 +392,6 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
break;
}
case ConnectionStatus::IncorrectPassword: {
runtime->password_validating_ = false;
runtime->password_validating_time_++;
break;
}
default:
break;
}
@@ -26,6 +26,7 @@
#include "minirtc.h"
#include "rendering/video_latency.h"
#include "runtime/net_traffic_stats_cache.h"
#include "runtime/connection_auth_state.h"
namespace crossdesk { class PeerEventHandler; }
@@ -100,6 +101,7 @@ struct RemoteSession {
bool p2p_mode_ = true;
bool remember_password_ = false;
char remote_password_[7] = "";
ConnectionAuthState connection_auth_;
// Reused across frames by the decode callback thread when a CPU NV12
// native frame has to be materialized for a renderer that rejects it.
std::vector<unsigned char> native_cpu_fallback_;
+29 -3
View File
@@ -65,6 +65,8 @@ export global UiStrings {
in-out property <string> signal-connected: "Signal server connected";
in-out property <string> signal-disconnected: "Signal server disconnected";
in-out property <string> tls-error: "Signal server TLS certificate error";
in-out property <string> signal-reconnect: "Reconnect";
in-out property <string> signal-reconnecting: "Reconnecting...";
in-out property <string> update-available: "A new version is available";
in-out property <string> new-version: "New version";
in-out property <string> check-for-updates: "Check for updates";
@@ -305,6 +307,7 @@ export component MainWindow inherits Window {
in property <bool> password-visible: true;
in property <bool> password-change-pending: false;
in property <bool> signal-connected: false;
in property <bool> signal-reconnecting: false;
in property <bool> signal-tls-error: false;
in property <[RecentConnection]> recent-connections;
in property <[AnnouncementItem]> announcements;
@@ -402,6 +405,7 @@ export component MainWindow inherits Window {
callback browse-save-path;
callback open-download;
callback check-for-updates;
callback signal-reconnect-requested;
callback connection-cancel;
callback connection-acknowledge;
callback connection-submit-password(string, bool);
@@ -1209,7 +1213,7 @@ export component MainWindow inherits Window {
width: 8px;
height: 8px;
border-radius: 4px;
background: root.signal-tls-error ? #f59e0b : #ef4444;
background: root.signal-tls-error || root.signal-reconnecting ? #f59e0b : #ef4444;
}
Text {
x: 28px;
@@ -1217,17 +1221,39 @@ export component MainWindow inherits Window {
height: 18px;
text: root.signal-tls-error ? UiStrings.tls-error
: root.signal-connected ? UiStrings.signal-connected
: root.signal-reconnecting ? UiStrings.signal-reconnecting
: UiStrings.signal-disconnected;
color: #343941;
font-size: ImGuiFontStyle.base;
vertical-alignment: center;
}
if !root.signal-connected && !root.signal-tls-error: Rectangle {
x: parent.width - self.width - 12px;
y: 3px;
height: 18px;
width: reconnect-label.preferred-width;
reconnect-label := Text {
text: UiStrings.signal-reconnect;
color: #2563eb;
font-size: ImGuiFontStyle.base;
horizontal-alignment: center;
vertical-alignment: center;
}
TouchArea {
mouse-cursor: pointer;
clicked => {
root.signal-reconnect-requested();
}
}
}
}
}
// Match the ImGui recent-connection tooltip while keeping it outside the
// clipped ScrollView. It is deliberately limited to the thumbnail area.
if root.recent-tooltip-visible: Rectangle {
// Stay under the modal layer (z >= 39): a hover tooltip that outlives the
// pointer can otherwise cover a dialog button and hide it from the user.
if root.recent-tooltip-visible && !root.connection-dialog-open: Rectangle {
x: root.recent-tooltip-x;
y: root.recent-tooltip-y;
width: root.recent-tooltip-width;
@@ -1236,7 +1262,7 @@ export component MainWindow inherits Window {
border-width: 1px;
border-color: #ffffff38;
border-radius: 5px;
z: 50;
z: 20;
VerticalLayout {
padding: 8px;
@@ -110,6 +110,10 @@ inline int ApplyMainWindowStrings(
strings.set_signal_disconnected(
Text(localization::signal_disconnected[language]));
strings.set_tls_error(Text(localization::signal_tls_cert_error[language]));
strings.set_signal_reconnect(
Text(localization::signal_reconnect[language]));
strings.set_signal_reconnecting(
Text(localization::signal_reconnecting[language]));
strings.set_update_available(
Text(localization::new_version_available[language]));
strings.set_new_version(Text(localization::new_version[language]));
@@ -83,9 +83,7 @@ int main() {
ok &= ExpectContains("minirtc.h", minirtc_api, "RemoteUnavailable");
ok &= ExpectNotContains("minirtc.h", minirtc_api, "DeviceOffline");
ok &= ExpectContains("peer_connection.cpp", peer_connection,
"\"Remote unavailable\"");
ok &= ExpectContains("peer_connection.cpp", peer_connection,
"ConnectionStatus::RemoteUnavailable");
"on_connection_status_(ParseJoinFailure(j).status,");
ok &= ExpectContains("peer_connection.cpp", peer_connection,
"IsCurrentPeerConnection");
ok &= ExpectContains(