mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-15 16:30:50 +08:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
852710f428 | ||
|
|
2d51936ca8 | ||
|
|
3baedac6df | ||
|
|
a667fe3b31 | ||
|
|
05f6d3d90a | ||
|
|
0e7d175631 | ||
|
|
52ca253980 | ||
|
|
1925e5ffcc | ||
|
|
3aa0470510 | ||
|
|
fbbb8c9e2c | ||
|
|
3750687e66 |
@@ -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"),
|
||||
|
||||
@@ -346,6 +346,8 @@ jobs:
|
||||
build-android:
|
||||
name: Build Android
|
||||
uses: ./.github/workflows/build-android.yml
|
||||
with:
|
||||
patch: ${{ inputs.patch || '0' }}
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
||||
@@ -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 签名
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
crossdeskVersionName=1.6.1
|
||||
crossdeskVersionCode=3
|
||||
|
||||
@@ -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()
|
||||
@@ -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_;
|
||||
|
||||
@@ -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(
|
||||
|
||||
Vendored
+1
-1
Submodule deps/submodules/minirtc updated: a017ce97f2...0d55a84c58
Reference in New Issue
Block a user