mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-28 00:33:06 +08:00
[feat] add mobile privacy controls and Windows secure text input
This commit is contained in:
@@ -26,6 +26,7 @@ apps/harmony/.native/mobile-project/entry/build/default/outputs/default/entry-de
|
||||
| Android 功能 | 鸿蒙实现 |
|
||||
| --- | --- |
|
||||
| 前台自动登录、空闲后台停止、服务器隔离 | 独立 identity peer;只有主动远控才创建 controller peer;拒绝任何入站被控连接 |
|
||||
| 电脑隐私屏 | 悬浮菜单直接开关,无详情页;按钮按电脑确认的状态显示“隐私屏已开启”或“隐私屏已关闭”,开启与关闭使用相同配色,正常切换不显示额外文字,仅在异常时显示提示;开启时与桌面端一致屏蔽电脑本地键鼠;5 秒状态失效、15 秒指令超时与跨会话隔离 |
|
||||
| 首次隐私确认、撤回与离线阅读 | 同意前不发起身份、公告、在线状态或版本网络请求;撤回结束会话并清除预览 |
|
||||
| 加密身份、可选记住访问密码 | 系统 Asset Store,设备首次解锁后可读,禁止跨设备同步;普通 Preferences 不存密码 |
|
||||
| 最近连接、删除、在线状态 | 最近 20 台、在线优先、真实信令订阅、30 秒刷新和 60 秒过期;删除同步清理密码与预览 |
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { RemotePrivacyScreen, PrivacyStatus } from './RemotePrivacyScreen';
|
||||
import bridge from 'libcrossdesk.so';
|
||||
import { common, bundleManager } from '@kit.AbilityKit';
|
||||
import { display, promptAction } from '@kit.ArkUI';
|
||||
@@ -13,7 +14,7 @@ interface Command {
|
||||
logs?: string; certificates?: string; directory?: string; previewDirectory?: string; remote?: string; password?: string; version?: string;
|
||||
id?: string; x?: number; y?: number; flag?: number; scroll?: number; code?: number; down?: boolean;
|
||||
index?: number; muted?: boolean; visible?: boolean; text?: string; message?: Object; quality?: number; frameRate?: number;
|
||||
preference?: number; request?: number; path?: string; name?: string; body?: string; local?: boolean;
|
||||
preference?: number; blockLocalInput?: boolean; request?: number; path?: string; name?: string; body?: string; local?: boolean;
|
||||
}
|
||||
interface NativeEvent {
|
||||
type: string; session: number; connection?: number; status?: number; controller?: boolean; generation?: number;
|
||||
@@ -25,7 +26,7 @@ export interface RemoteDisplay { name: string; }
|
||||
interface HostInfo { host_name: string; platform: string; app_version: string; supports_video_settings: boolean; displays: RemoteDisplay[]; }
|
||||
interface SettingAck { quality: number; frame_rate: number; preference: number; request_id: number; accepted: boolean; }
|
||||
interface ServiceStatus { available: boolean; }
|
||||
interface ControlData { type: number; host_info?: HostInfo; video_settings?: SettingAck; service_status?: ServiceStatus; }
|
||||
interface ControlData { type: number; host_info?: HostInfo; video_settings?: SettingAck; service_status?: ServiceStatus; privacy_status?: PrivacyStatus; }
|
||||
interface PresenceRequest { type: string; devices: string[]; subscribe: boolean; }
|
||||
interface SignalType { type: string; }
|
||||
|
||||
@@ -73,6 +74,12 @@ export class MobileState {
|
||||
videoSelectedPreference: number = 1;
|
||||
videoSupported: boolean = false;
|
||||
videoFeedback: string = '';
|
||||
privacyStatus: string = '';
|
||||
privacyButtonTitle: string = '隐私屏';
|
||||
privacyFeedback: string = '';
|
||||
privacyEnabled: boolean = false;
|
||||
privacyActive: boolean = false;
|
||||
privacyCanToggle: boolean = false;
|
||||
networkTime: number = 0;
|
||||
unread: number = 0;
|
||||
announcements: AnnouncementItem[] = [];
|
||||
@@ -86,6 +93,7 @@ export class MobileController {
|
||||
store: MobileStore;
|
||||
platform: MobilePlatform;
|
||||
video: VideoSettings = new VideoSettings();
|
||||
private privacyScreen: RemotePrivacyScreen = new RemotePrivacyScreen();
|
||||
network: NetworkStatistics = new NetworkStatistics();
|
||||
presence: RecentPresence;
|
||||
inbox: AnnouncementInbox;
|
||||
@@ -159,7 +167,7 @@ export class MobileController {
|
||||
this.state.update = ''; this.state.updateStatus = ''; this.state.page = 'home'; this.state.modal = 'consent';
|
||||
}
|
||||
resume(): void {
|
||||
this.foreground = true; this.platform.resume();
|
||||
this.foreground = true; this.platform.resume(); this.syncPrivacy();
|
||||
if (this.state.consent) { this.ensureSignal(); this.checkLocal(); this.inbox.connect(this.state.signal === 1); }
|
||||
else this.state.modal = 'consent';
|
||||
}
|
||||
@@ -236,6 +244,7 @@ export class MobileController {
|
||||
this.network = new NetworkStatistics(); let edge = 1920;
|
||||
try { const screen = display.getDefaultDisplaySync(); edge = Math.max(screen.width, screen.height); } catch {}
|
||||
this.video = new VideoSettings(this.state.videoPreference, edge); this.syncVideo();
|
||||
this.privacyScreen = new RemotePrivacyScreen(); this.syncPrivacy();
|
||||
this.store.put('lastRemote', this.state.remote); this.state.password = '';
|
||||
this.ensureSignal(); this.joinIfReady();
|
||||
}
|
||||
@@ -247,6 +256,7 @@ export class MobileController {
|
||||
end(reason: string): void {
|
||||
this.release(); this.send({ op: 'disconnect' }); this.platform.disconnect();
|
||||
this.connection++; this.uiEpoch++; this.pendingPassword = ''; this.joinSent = false; this.sendingFile = false;
|
||||
this.privacyScreen = new RemotePrivacyScreen(); this.syncPrivacy();
|
||||
this.state.connecting = this.state.ready = false; this.state.page = 'home'; this.state.modal = ''; this.state.password = '';
|
||||
this.state.receivedPath = this.state.receivedName = this.state.remoteClipboard = this.state.clipboardText = ''; this.remoteVersionRequest = 0;
|
||||
if (this.settingsTimeout >= 0) clearTimeout(this.settingsTimeout); this.settingsTimeout = -1;
|
||||
@@ -339,6 +349,21 @@ export class MobileController {
|
||||
this.state.documentTitle = title; this.state.documentBack = back; this.state.page = 'document'; this.state.modal = '';
|
||||
} catch { this.toast('无法读取文档'); }
|
||||
}
|
||||
togglePrivacy(): void {
|
||||
if (!this.state.ready || !this.state.consent) return;
|
||||
const enable = !this.privacyScreen.active(), request = this.privacyScreen.begin(this.now());
|
||||
if (request < 0) return;
|
||||
this.send({ op: 'privacy', flag: enable ? 1 : 2, request: request,
|
||||
blockLocalInput: true });
|
||||
this.syncPrivacy();
|
||||
}
|
||||
private syncPrivacy(): void {
|
||||
const now = this.now(); this.privacyScreen.tick(now);
|
||||
this.state.privacyStatus = this.privacyScreen.status(now); this.state.privacyFeedback = this.privacyScreen.feedback(now);
|
||||
this.state.privacyButtonTitle = this.privacyScreen.buttonTitle(now);
|
||||
this.state.privacyEnabled = this.privacyScreen.enabled(now);
|
||||
this.state.privacyActive = this.privacyScreen.active(); this.state.privacyCanToggle = this.privacyScreen.canToggle(now);
|
||||
}
|
||||
private syncVideo(): void {
|
||||
this.state.videoQuality = this.video.selection.quality; this.state.videoRate = this.video.selection.frameRate;
|
||||
this.state.videoSelectedPreference = this.video.selection.preference; this.state.videoSupported = this.video.supported;
|
||||
@@ -348,6 +373,7 @@ export class MobileController {
|
||||
try { const events = JSON.parse(bridge.mobilePoll()) as NativeEvent[]; events.forEach((event: NativeEvent) => this.event(event)); }
|
||||
catch { this.state.signalText = '原生通信库不可用,请重新构建并安装'; }
|
||||
if (++this.seconds % 20 === 0) {
|
||||
if (this.state.ready) this.syncPrivacy();
|
||||
this.state.networkTime = this.now(); this.presence.tick(this.state.networkTime); this.history();
|
||||
}
|
||||
}
|
||||
@@ -395,7 +421,8 @@ export class MobileController {
|
||||
if (e.sending && ((e.progress ?? 0) < 0 || (e.progress ?? 0) >= 1)) this.sendingFile = false;
|
||||
this.state.transfer = (e.progress ?? 0) < 0 ? `${e.name} 传输失败` : `${e.sending ? '发送' : '接收'} ${e.name} · ${(e.progress ?? 0) >= 1 ? '已完成' : Math.floor((e.progress ?? 0) * 100) + '%'}`;
|
||||
if (e.path) { this.state.receivedPath = e.path; this.state.receivedName = e.name ?? 'received-file'; }
|
||||
} else if (e.type === 'settingsFailed') { this.video.expire(Number(e.request)); this.syncVideo(); }
|
||||
} else if (e.type === 'privacyFailed') { this.privacyScreen.failed(Number(e.request)); this.syncPrivacy(); }
|
||||
else if (e.type === 'settingsFailed') { this.video.expire(Number(e.request)); this.syncVideo(); }
|
||||
else if (e.type === 'preview') {
|
||||
this.previewPending = false;
|
||||
if (e.ok && e.path && this.previewEligible && this.state.previews) {
|
||||
@@ -435,6 +462,7 @@ export class MobileController {
|
||||
if (this.receivedHostInfo) this.checkRemote(this.remoteVersion);
|
||||
}
|
||||
private data(message: ControlData): void {
|
||||
if (message.type === 10 && message.privacy_status) { this.privacyScreen.receive(message.privacy_status, this.now()); this.syncPrivacy(); return; }
|
||||
if (message.type === 5) { this.serviceAvailable = message.service_status?.available === true; return; }
|
||||
if (message.type === 12 && message.video_settings) {
|
||||
const v = message.video_settings;
|
||||
|
||||
@@ -133,9 +133,10 @@ export struct MobileRemoteView {
|
||||
if (index === 7) this.menu = 'network';
|
||||
if (index === 8) this.controller?.sas();
|
||||
if (index === 9) { this.state.modal = 'clipboard'; this.menu = ''; }
|
||||
if (index === 10) this.controller?.togglePrivacy();
|
||||
}
|
||||
private labels(): string[] { return ['键盘', this.state.relative ? '相对鼠标' : '绝对鼠标', this.mouseVisible ? '收起鼠标' : '虚拟鼠标', '显示器', '画面设置', this.state.muted ? '静音' : '声音', '发送文件', '网络状态', 'Ctrl+Alt+Del', '剪贴板']; }
|
||||
private icons(): string[] { return ['keyboard', 'mouse', 'mouse', 'display', 'sliders', this.state.muted ? 'muted' : 'audio', 'folder', 'chart', 'lock', 'clipboard']; }
|
||||
private labels(): string[] { return ['键盘', this.state.relative ? '相对鼠标' : '绝对鼠标', this.mouseVisible ? '收起鼠标' : '虚拟鼠标', '显示器', '画面设置', this.state.muted ? '静音' : '声音', '发送文件', '网络状态', 'Ctrl+Alt+Del', '剪贴板', this.state.privacyButtonTitle]; }
|
||||
private icons(): string[] { return ['keyboard', 'mouse', 'mouse', 'display', 'sliders', this.state.muted ? 'muted' : 'audio', 'folder', 'chart', 'lock', 'clipboard', 'lock']; }
|
||||
private columns(): number { return this.viewHeight < 400 && this.viewWidth >= 400 ? 4 : 3; }
|
||||
private menuWidth(): number { return Math.max(1, Math.min(this.viewWidth - 16, this.columns() === 4 ? 380 : Math.max(280, Math.min(340, this.viewWidth - 160)))); }
|
||||
private menuX(): number { return clamp(this.orbX > this.viewWidth / 2 ? this.orbX - this.menuWidth() - 12 : this.orbX + 66, 8, this.viewWidth - this.menuWidth() - 8); }
|
||||
@@ -212,12 +213,20 @@ export struct MobileRemoteView {
|
||||
GridRow({ columns: this.columns(), gutter: 7 }) {
|
||||
ForEach(this.labels(), (label: string, index: number) => {
|
||||
GridCol() {
|
||||
Column({ space: 4 }) { MobileIcon({ name: this.icons()[index], iconExtent: 19 }); Text(label).fontSize(10).fontColor(MobileTheme.ink).maxLines(1) }
|
||||
.height(52).width('100%').justifyContent(FlexAlign.Center).backgroundColor('#F0F0F2').borderRadius(10)
|
||||
.accessibilityText(label).accessibilityLevel('yes').onClick(() => this.action(index))
|
||||
Column({ space: 4 }) {
|
||||
MobileIcon({ name: this.icons()[index], iconExtent: 19 })
|
||||
Text(label).fontSize(10).fontColor(MobileTheme.ink).maxLines(1)
|
||||
}
|
||||
.height(52).width('100%').justifyContent(FlexAlign.Center)
|
||||
.backgroundColor('#F0F0F2').borderRadius(10)
|
||||
.enabled(index !== 10 || this.state.ready && this.state.privacyCanToggle)
|
||||
.opacity(index === 10 && (!this.state.ready || !this.state.privacyCanToggle) ? 0.45 : 1)
|
||||
.accessibilitySelected(index === 10 && this.state.privacyEnabled)
|
||||
.accessibilityText(index === 10 ? `${label},${this.state.privacyStatus}` : label).accessibilityLevel('yes').onClick(() => this.action(index))
|
||||
}
|
||||
}, (label: string) => label)
|
||||
}
|
||||
if (this.state.privacyFeedback) { Text(this.state.privacyFeedback).fontSize(11).fontColor(MobileTheme.secondary).width('100%') }
|
||||
if (this.state.transfer) { Text(this.state.transfer).fontSize(11).fontColor(MobileTheme.secondary).width('100%') }
|
||||
if (this.state.receivedPath) { Button('保存收到的文件', { type: ButtonType.Normal }).fontSize(14).fontColor(MobileTheme.blue).backgroundColor(Color.White).height(42).width('100%').onClick(() => this.controller?.saveFile()) }
|
||||
} else if (this.menu === 'video') {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
export interface PrivacyStatus {
|
||||
state: number; supported: boolean; input_block_supported: boolean;
|
||||
overlay_active: boolean; input_blocked: boolean; remote_paused: boolean;
|
||||
revision: number; reason: string;
|
||||
}
|
||||
|
||||
export class RemotePrivacyScreen {
|
||||
snapshot?: PrivacyStatus;
|
||||
pending: boolean = false;
|
||||
requestId: number = 0;
|
||||
private requestRevision: number = 0;
|
||||
private receivedAt: number = 0;
|
||||
private requestedAt: number = 0;
|
||||
private failure: string = '';
|
||||
|
||||
receive(s: PrivacyStatus, now: number): void {
|
||||
if (!Number.isInteger(s.state) || s.state < 0 || s.state > 5 ||
|
||||
!Number.isInteger(s.revision) || s.revision < 0 || s.revision > 0xffffffff ||
|
||||
typeof s.supported !== 'boolean' || typeof s.input_block_supported !== 'boolean' ||
|
||||
typeof s.overlay_active !== 'boolean' || typeof s.input_blocked !== 'boolean' ||
|
||||
typeof s.remote_paused !== 'boolean' || typeof s.reason !== 'string' || s.reason.includes('\0')) return;
|
||||
if (s.state === 3 && (!s.supported || !s.overlay_active || s.remote_paused)) return;
|
||||
if (this.snapshot && ((s.revision - this.snapshot.revision) | 0) < 0) return;
|
||||
this.snapshot = s; this.receivedAt = now; this.failure = '';
|
||||
if (this.pending && ((s.revision - this.requestRevision) | 0) > 0 && s.state !== 2 && s.state !== 4) this.pending = false;
|
||||
}
|
||||
active(): boolean {
|
||||
const s = this.snapshot;
|
||||
return s !== undefined && (s.overlay_active || s.remote_paused || s.state === 2 || s.state === 3 || s.state === 4);
|
||||
}
|
||||
fresh(now: number): boolean { return this.snapshot !== undefined && now - this.receivedAt <= 5000; }
|
||||
enabled(now: number): boolean {
|
||||
const s = this.snapshot;
|
||||
return s !== undefined && this.fresh(now) && s.state === 3 && s.overlay_active && !s.remote_paused;
|
||||
}
|
||||
buttonTitle(now: number): string {
|
||||
if (this.enabled(now)) return '隐私屏已开启';
|
||||
const s = this.snapshot;
|
||||
if (this.fresh(now) && s?.supported && s.state === 1 && !this.active()) return '隐私屏已关闭';
|
||||
return '隐私屏';
|
||||
}
|
||||
feedback(now: number): string {
|
||||
const s = this.snapshot;
|
||||
if (!this.failure && (this.pending || this.fresh(now) && (s?.state === 2 || s?.state === 4))) return '';
|
||||
if (!this.failure && !this.pending && this.fresh(now) && s?.supported &&
|
||||
(this.enabled(now) || s.state === 1 && !this.active())) return '';
|
||||
const detail = this.detail(now);
|
||||
return this.status(now) + (detail ? ':' + detail : '');
|
||||
}
|
||||
canToggle(now: number): boolean {
|
||||
const s = this.snapshot;
|
||||
return s !== undefined && !this.pending && this.fresh(now) && s.state !== 4 && (this.active() || s.supported && s.state === 1);
|
||||
}
|
||||
begin(now: number): number {
|
||||
this.tick(now);
|
||||
if (!this.canToggle(now) || !this.snapshot) return -1;
|
||||
this.pending = true; this.requestedAt = now; this.requestRevision = this.snapshot.revision; this.failure = '';
|
||||
this.requestId = (this.requestId + 1) >>> 0;
|
||||
return this.requestId;
|
||||
}
|
||||
failed(id: number): void {
|
||||
if (this.pending && this.requestId === id) { this.pending = false; this.failure = '指令发送失败,请重试'; }
|
||||
}
|
||||
tick(now: number): void {
|
||||
if (this.pending && now - this.requestedAt >= 15000) {
|
||||
this.pending = false; this.failure = '请求超时,尚未确认电脑的隐私屏状态';
|
||||
}
|
||||
}
|
||||
status(now: number): string {
|
||||
if (this.failure) return this.failure;
|
||||
const s = this.snapshot;
|
||||
if (!s) return '等待电脑返回隐私屏状态;旧版电脑端可能不支持';
|
||||
if (!this.fresh(now)) return '隐私屏状态已过期,等待电脑确认';
|
||||
if (s.state === 2) return '正在开启隐私屏…';
|
||||
if (s.state === 4) return '正在关闭隐私屏…';
|
||||
if (this.pending) return '指令已发送,等待电脑确认…';
|
||||
if (s.state === 5) return '隐私屏异常';
|
||||
if (!s.supported || s.state === 0) return '隐私屏暂不可用';
|
||||
if (s.state === 3 && s.overlay_active && !s.remote_paused)
|
||||
return '隐私屏已开启';
|
||||
return s.state === 1 && !this.active() ? '隐私屏已关闭' : '隐私屏状态待确认';
|
||||
}
|
||||
detail(now: number): string {
|
||||
const s = this.snapshot;
|
||||
return s && this.fresh(now) && (s.state === 0 || s.state === 5 || !s.supported) ? s.reason : '';
|
||||
}
|
||||
}
|
||||
@@ -149,7 +149,7 @@ struct Mobile {
|
||||
.padding({ left: 14, right: 14 }).onFocus(() => this.idFocused = true).onBlur(() => this.idFocused = false)
|
||||
.onChange((value: string) => this.state.remote = this.deviceId(value)).onSubmit(() => this.controller?.requestConnection(this.state.remote))
|
||||
Column() {
|
||||
MobilePrimaryButton({ label: '连接', arrow: true, buttonHeight: 54, available: this.state.remote.length > 0,
|
||||
MobilePrimaryButton({ label: '连接', buttonHeight: 54, available: this.state.remote.length > 0,
|
||||
action: () => this.controller?.requestConnection(this.state.remote) })
|
||||
}.width(96)
|
||||
}.width('100%')
|
||||
@@ -486,7 +486,7 @@ struct Mobile {
|
||||
Toggle({ type: ToggleType.Switch, isOn: this.state.remember }).selectedColor(MobileTheme.green)
|
||||
.onChange((value: boolean) => this.state.remember = value).accessibilityText('保存密码')
|
||||
}.width('100%').margin({ top: 18, bottom: 18 })
|
||||
MobilePrimaryButton({ label: '连接', arrow: true, available: this.state.password.length > 0, action: () => this.controller?.begin(this.state.password) })
|
||||
MobilePrimaryButton({ label: '连接', available: this.state.password.length > 0, action: () => this.controller?.begin(this.state.password) })
|
||||
}.padding({ left: 22, right: 22, top: 10, bottom: 22 }).width('100%')
|
||||
}.width(this.viewWidth < 600 ? '100%' : Math.min(520, this.viewWidth - 48)).constraintSize({ maxHeight: this.viewHeight * 0.92 })
|
||||
.backgroundColor(MobileTheme.background).borderRadius(24).scrollBar(BarState.Off)
|
||||
|
||||
@@ -143,7 +143,7 @@ class MobileSession {
|
||||
s->Event({{"type", "connection"}, {"status", status}}, true);
|
||||
if (status == Connected && !s->connected_) {
|
||||
s->connecting_ = false; s->connected_ = true;
|
||||
auto info = MakeHostInformation("CrossDesk HarmonyOS Mobile", {}, false, s->version_, HostPlatform::HarmonyOS);
|
||||
auto info = MakeHostInformation("CrossDesk HarmonyOS Mobile", {}, true, s->version_, HostPlatform::HarmonyOS);
|
||||
s->Send(info); FreeRemoteAction(info);
|
||||
RemoteAction audio{}; audio.type = audio_capture; audio.a = !s->muted_; s->Send(audio);
|
||||
s->audio_.Start();
|
||||
@@ -328,6 +328,12 @@ class MobileSession {
|
||||
auto bytes=mobile_controller::VideoSettingsMessage(q,fps,pref,v.at("request").get<uint32_t>());
|
||||
if(SendReliableDataFrame(controller_,bytes.data(),bytes.size(),kControlStream)!=0)
|
||||
Event({{"type","settingsFailed"},{"request",v.at("request")}},true);
|
||||
} else if (op=="privacy") {
|
||||
const auto bytes = mobile_controller::PrivacyMessage(
|
||||
v.at("flag").get<int>(), v.at("blockLocalInput").get<bool>());
|
||||
if (bytes.empty() ||
|
||||
SendReliableDataFrame(controller_, bytes.data(), bytes.size(), kControlStream) != 0)
|
||||
Event({{"type", "privacyFailed"}, {"request", v.at("request")}}, true);
|
||||
} else if (op=="sas") {
|
||||
const auto bytes=mobile_controller::SecureAttentionMessage();
|
||||
const bool ok=SendReliableDataFrame(controller_,bytes.data(),bytes.size(),kControlStream)==0;
|
||||
|
||||
@@ -61,11 +61,14 @@ def bootstrap(env):
|
||||
seed = Path.home() / '.xmake/repositories/xmake-repo'
|
||||
source = seed if (seed / '.git').exists() else 'https://github.com/xmake-io/xmake-repo.git'
|
||||
run(['git', 'clone', '--no-hardlinks', source, repository], env=env)
|
||||
run(['git', 'remote', 'set-url', 'origin', 'https://github.com/xmake-io/xmake-repo.git'], cwd=repository, env=env)
|
||||
run(['git', 'remote', 'set-url', 'origin', 'https://github.com/xmake-io/xmake-repo.git'], cwd=repository, env=env)
|
||||
revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=repository, text=True).strip()
|
||||
if revision != PACKAGE_REVISION:
|
||||
run(['git', 'fetch', 'origin', PACKAGE_REVISION], cwd=repository, env=env)
|
||||
run(['git', 'checkout', '--detach', PACKAGE_REVISION], cwd=repository, env=env)
|
||||
# Mark this pinned checkout as ready so Xmake's first-run repository update
|
||||
# cannot replace it with a mirror or advance it beyond PACKAGE_REVISION.
|
||||
(repository / 'updated').touch()
|
||||
|
||||
|
||||
def mobile_project():
|
||||
|
||||
Reference in New Issue
Block a user