feat: release desktop 0.1.9 with device dedupe and new branding

This commit is contained in:
2026-02-19 20:33:54 +08:00
parent 71a19e9e87
commit 099ba3e3e0
61 changed files with 161 additions and 20 deletions

View File

@@ -2750,6 +2750,62 @@ const DeviceSessionDB = {
`).all(Math.floor(uid), safeLimit);
},
listActiveByDevice(userId, options = {}) {
const uid = Number(userId);
if (!Number.isFinite(uid) || uid <= 0) return [];
const clientType = this._normalizeClientType(options.clientType);
const deviceId = this._normalizeText(options.deviceId, 128);
const deviceName = this._normalizeText(options.deviceName, 120);
const platform = this._normalizeText(options.platform, 80);
const safeLimit = Math.max(1, Math.min(50, Math.floor(Number(options.limit) || 20)));
if (deviceId) {
return db.prepare(`
SELECT *
FROM user_device_sessions
WHERE user_id = ?
AND client_type = ?
AND device_id = ?
AND revoked_at IS NULL
AND expires_at > datetime('now', 'localtime')
ORDER BY datetime(COALESCE(last_active_at, created_at)) DESC, created_at DESC
LIMIT ?
`).all(Math.floor(uid), clientType, deviceId, safeLimit);
}
if (clientType !== 'desktop') {
return [];
}
const extraConditions = [];
const params = [Math.floor(uid), clientType];
if (deviceName) {
extraConditions.push("COALESCE(device_name, '') = ?");
params.push(deviceName);
}
if (platform) {
extraConditions.push("COALESCE(platform, '') = ?");
params.push(platform);
}
if (extraConditions.length === 0) {
return [];
}
params.push(safeLimit);
return db.prepare(`
SELECT *
FROM user_device_sessions
WHERE user_id = ?
AND client_type = ?
AND ${extraConditions.join(' AND ')}
AND revoked_at IS NULL
AND expires_at > datetime('now', 'localtime')
ORDER BY datetime(COALESCE(last_active_at, created_at)) DESC, created_at DESC
LIMIT ?
`).all(...params);
},
touch(sessionId, options = {}) {
const sid = this._normalizeSessionId(sessionId);
if (!sid) return { changes: 0 };

View File

@@ -112,11 +112,11 @@ const DOWNLOAD_SIGNED_URL_EXPIRES_SECONDS = Math.max(
10,
Math.min(3600, Number(process.env.DOWNLOAD_SIGNED_URL_EXPIRES_SECONDS || 30))
);
const DEFAULT_DESKTOP_VERSION = process.env.DESKTOP_LATEST_VERSION || '0.1.8';
const DEFAULT_DESKTOP_VERSION = process.env.DESKTOP_LATEST_VERSION || '0.1.9';
const DEFAULT_DESKTOP_INSTALLER_URL = process.env.DESKTOP_INSTALLER_URL || '';
const DEFAULT_DESKTOP_RELEASE_NOTES = process.env.DESKTOP_RELEASE_NOTES || '';
const DESKTOP_INSTALLERS_DIR = path.resolve(__dirname, '../frontend/downloads');
const DESKTOP_INSTALLER_FILE_PATTERN = /^wanwan-cloud-desktop_.*_x64-setup\.exe$/i;
const DESKTOP_INSTALLER_FILE_PATTERN = /^(wanwan-cloud-desktop|玩玩云)_.*_x64-setup\.exe$/i;
const RESUMABLE_UPLOAD_SESSION_TTL_MS = Number(process.env.RESUMABLE_UPLOAD_SESSION_TTL_MS || (24 * 60 * 60 * 1000)); // 24小时
const RESUMABLE_UPLOAD_CHUNK_SIZE_BYTES = Number(process.env.RESUMABLE_UPLOAD_CHUNK_SIZE_BYTES || (4 * 1024 * 1024)); // 4MB
const RESUMABLE_UPLOAD_MAX_CHUNK_SIZE_BYTES = Number(process.env.RESUMABLE_UPLOAD_MAX_CHUNK_SIZE_BYTES || (32 * 1024 * 1024)); // 32MB
@@ -2931,7 +2931,7 @@ function resolveClientType(clientType, userAgent = '') {
const normalized = normalizeClientType(clientType);
if (normalized) return normalized;
const ua = String(userAgent || '').toLowerCase();
if (ua.includes('tauri') || ua.includes('electron') || ua.includes('wanwan-cloud-desktop')) {
if (ua.includes('tauri') || ua.includes('electron') || ua.includes('wanwan-cloud-desktop') || ua.includes('玩玩云')) {
return 'desktop';
}
return detectDeviceTypeFromUserAgent(ua) === 'mobile' ? 'mobile' : 'web';
@@ -2987,6 +2987,46 @@ function formatOnlineDeviceRecord(row, currentSessionId = '') {
};
}
function buildDeviceDedupKey(row = {}) {
const sessionId = String(row?.session_id || '').trim();
const clientType = String(row?.client_type || 'web').trim().toLowerCase() || 'web';
const deviceId = String(row?.device_id || '').trim();
if (deviceId) {
return `${clientType}:id:${deviceId}`;
}
if (clientType === 'desktop') {
const deviceName = String(row?.device_name || '').trim().toLowerCase();
const platform = String(row?.platform || '').trim().toLowerCase();
return `${clientType}:fallback:${deviceName}|${platform}`;
}
return `session:${sessionId}`;
}
function dedupeOnlineDeviceRows(rows = [], currentSessionId = '') {
if (!Array.isArray(rows) || rows.length <= 1) return Array.isArray(rows) ? rows : [];
const currentSid = String(currentSessionId || '').trim();
const deduped = new Map();
for (const row of rows) {
const key = buildDeviceDedupKey(row);
const sid = String(row?.session_id || '').trim();
if (!deduped.has(key)) {
deduped.set(key, row);
continue;
}
const currentPicked = deduped.get(key);
const pickedSid = String(currentPicked?.session_id || '').trim();
const incomingIsCurrent = !!currentSid && sid === currentSid;
const pickedIsCurrent = !!currentSid && pickedSid === currentSid;
if (incomingIsCurrent && !pickedIsCurrent) {
deduped.set(key, row);
}
}
return Array.from(deduped.values());
}
function normalizeTimeHHmm(value) {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
@@ -4228,8 +4268,35 @@ app.post('/api/login',
});
let sessionId = null;
try {
let reusedSessionId = '';
const shouldReuseDeviceSession = Boolean(deviceContext.deviceId) || deviceContext.clientType === 'desktop';
if (shouldReuseDeviceSession) {
const matchedDeviceSessions = DeviceSessionDB.listActiveByDevice(user.id, {
clientType: deviceContext.clientType,
deviceId: deviceContext.deviceId,
deviceName: deviceContext.deviceName,
platform: deviceContext.platform,
limit: 20
});
if (matchedDeviceSessions.length > 0) {
reusedSessionId = String(matchedDeviceSessions[0]?.session_id || '').trim();
let revokedCount = 0;
for (const matched of matchedDeviceSessions) {
const duplicateSid = String(matched?.session_id || '').trim();
if (!duplicateSid || duplicateSid === reusedSessionId) continue;
const revokeResult = DeviceSessionDB.revoke(duplicateSid, user.id, 'dedupe_login_same_device');
if (Number(revokeResult?.changes || 0) > 0) {
revokedCount += 1;
}
}
if (revokedCount > 0) {
console.log(`[在线设备] 登录会话去重 user=${user.id}, revoked=${revokedCount}`);
}
}
}
const createdSession = DeviceSessionDB.create({
sessionId: crypto.randomBytes(24).toString('hex'),
sessionId: reusedSessionId || crypto.randomBytes(24).toString('hex'),
userId: user.id,
clientType: deviceContext.clientType,
deviceId: deviceContext.deviceId,
@@ -4426,9 +4493,9 @@ app.get('/api/user/profile', authMiddleware, (req, res) => {
app.get('/api/user/online-devices', authMiddleware, (req, res) => {
try {
const currentSessionId = String(req.authSessionId || '').trim();
const devices = DeviceSessionDB
.listActiveByUser(req.user.id, 80)
.map((row) => formatOnlineDeviceRecord(row, currentSessionId));
const rawRows = DeviceSessionDB.listActiveByUser(req.user.id, 80);
const dedupedRows = dedupeOnlineDeviceRows(rawRows, currentSessionId);
const devices = dedupedRows.map((row) => formatOnlineDeviceRecord(row, currentSessionId));
res.json({
success: true,

View File

@@ -1,7 +1,7 @@
{
"name": "desktop-client",
"private": true,
"version": "0.1.8",
"version": "0.1.9",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -693,7 +693,7 @@ dependencies = [
[[package]]
name = "desktop-client"
version = "0.1.8"
version = "0.1.9"
dependencies = [
"reqwest 0.12.28",
"rusqlite",

View File

@@ -1,6 +1,6 @@
[package]
name = "desktop-client"
version = "0.1.8"
version = "0.1.9"
description = "A Tauri App"
authors = ["you"]
edition = "2021"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

View File

@@ -7,11 +7,16 @@ use std::env;
use std::fs;
use std::io::Write;
use std::io::{Read, Seek, SeekFrom};
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tauri::Emitter;
#[cfg(target_os = "windows")]
const CREATE_NO_WINDOW: u32 = 0x08000000;
struct ApiState {
client: reqwest::Client,
}
@@ -1122,21 +1127,25 @@ fn api_silent_install_and_restart(installer_path: String) -> Result<BridgeRespon
let app_text = current_exe.to_string_lossy().replace('"', "\"\"");
let script_content = format!(
"@echo off\r\n\
setlocal\r\n\
setlocal enableextensions\r\n\
set \"INSTALLER={installer}\"\r\n\
set \"APP_EXE={app_exe}\"\r\n\
if not exist \"%INSTALLER%\" exit /b 1\r\n\
timeout /t 2 /nobreak >nul\r\n\
start \"\" /wait \"%INSTALLER%\" /S\r\n\
start \"\" \"%APP_EXE%\"\r\n\
if exist \"%APP_EXE%\" start \"\" \"%APP_EXE%\"\r\n\
del \"%~f0\" >nul 2>nul\r\n",
installer = installer_text,
app_exe = app_text
);
fs::write(&script_path, script_content).map_err(|err| format!("写入更新脚本失败: {}", err))?;
let script_arg = script_path.to_string_lossy().to_string();
Command::new("cmd")
.args(["/C", "start", "", "/min", &script_arg])
let script_arg = format!("\"{}\"", script_path.to_string_lossy().replace('\"', "\"\""));
let mut updater_cmd = Command::new("cmd");
updater_cmd
.args(["/D", "/C", &script_arg])
.current_dir(&temp_dir)
.creation_flags(CREATE_NO_WINDOW)
.spawn()
.map_err(|err| format!("启动静默更新流程失败: {}", err))?;
}

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "wanwan-cloud-desktop",
"version": "0.1.8",
"productName": "玩玩云",
"version": "0.1.9",
"identifier": "cn.workyai.wanwancloud.desktop",
"build": {
"beforeDevCommand": "npm run dev",
@@ -12,7 +12,7 @@
"app": {
"windows": [
{
"title": "Wanwan Cloud Desktop",
"title": "玩玩云",
"width": 1360,
"height": 860,
"minWidth": 1120,

View File

@@ -153,7 +153,7 @@ const syncState = reactive({
nextRunAt: "",
});
const updateState = reactive({
currentVersion: "0.1.8",
currentVersion: "0.1.9",
latestVersion: "",
available: false,
mandatory: false,
@@ -927,7 +927,7 @@ async function installLatestUpdate(): Promise<boolean> {
resetUpdateRuntime();
updateRuntime.downloading = true;
const taskId = `UPD-${Date.now()}`;
const installerName = `wanwan-cloud-desktop_v${updateState.latestVersion || updateState.currentVersion}.exe`;
const installerName = `玩玩云_v${updateState.latestVersion || updateState.currentVersion}.exe`;
updateRuntime.taskId = taskId;
updateRuntime.progress = 1;
updateRuntime.speed = "准备下载";