参考: 工作目录 WinIsland-master.zip(Rust/Direct2D 的 Windows 动态岛),
逐项提取的设计 token 归档在 docs/winisland-design-tokens.md。
视觉与造型:
- 连续圆角 squircle(新增 island/squircle.ts,超椭圆角,对齐 WinIsland utils/shape.rs),
胶囊半径 = min(圆角, h/2)(满圆角),展开卡 = min(48*圆角/20, w/2, h/2)(WinIsland expanded_island_radius)
- 三种底: 纯黑(WinIsland default)/ 毛玻璃(win.setBackgroundMaterial('acrylic'),等价 HostBackdropBrush)/
主题色渐变;描边 1px 白 alpha .118(纯黑)/.157(玻璃),展开投影 0 2px 3px rgba(0,0,0,.11)
- Apple 系统色: 强调蓝 #0a84ff、警告橙 #ff9f0a、危险红 #ff453a;白字 alpha 分级 .92/.72/.58
- 状态色只做"内层微染 + 描边 + 徽标",不外溢(保持外圈偏红 = 0)
排版(本次重点):
- 基础字号 11–20px,岛内**所有文本** = 基础字号 × 排版系数
(标题 1.08 / 正文 0.95 / 次要 0.78 / 徽标与按钮 0.76 / 胶囊标题 0.92 / 胶囊副题 0.74),
与 WinIsland 的 font_size 模型一致;实测 13→19px 全部文本倍率 1.462(=19/13)
- 图标、按钮高度、间距也由字号推导,改字号不会溢出
形态与设置:
- 尺寸基准对齐 WinIsland 比例(胶囊 216x34 基准、展开 +156/+186 等),卡片 = 窗口 - 2*5px 且水平居中
- 6 个停靠位(顶/底 × 左/中/右,对齐 WinIsland DockPosition)
- 新增"空闲细缝"(WinIsland hidden_width):空闲留一条 6px 宽竖条,来消息自动展开
- 所有设置依旧"改即生效";顺带修掉 hide() 用 skipWindow 绕过 syncWindow 导致细缝不生效的问题
验证(verify:desktop 49 → 53 项,全绿):
- 字号即时生效: 13px→19px 五项文本倍率全部 1.462
- 风格即时生效: 纯黑 fill=rgb(0,0,0)/材质 none;毛玻璃 fill=rgba(10,10,14,.59)/材质 acrylic;主题色 fill=rgb(11,11,15)
- 空闲细缝: 16x36 → 来消息 268x44;关闭后完全隐藏
- 6 锚点、开合不震动(0.00px / 无过冲)、截图 6 张(比例偏差 0.000、外圈偏红 0)全部保持
- verify:e2e 133/133、verify:web 19/19 不受影响;截图归档到 docs/screenshots/island/
工程发现: Windows 对透明窗口有约 36px 最小高度(请求 32px 实际 36px),
空闲细缝高度据此设下限并写入注释与 README。
157 lines
5.6 KiB
TypeScript
157 lines
5.6 KiB
TypeScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { app, safeStorage } from 'electron';
|
|
import { DEFAULT_ISLAND_APPEARANCE, DEFAULT_SERVER_URL, type IslandAppearance } from '@classhelper/shared';
|
|
import type { DesktopStoredConfig } from '../types/desktop.js';
|
|
|
|
interface PersistedConfig {
|
|
serverUrl: string;
|
|
username: string;
|
|
/** 加密后的 token(base64)或明文(当系统不支持加密时) */
|
|
token: string | null;
|
|
tokenEncrypted: boolean;
|
|
/** 个性化设置:灵动岛外观(向后兼容:旧配置文件没有该字段时用默认值补齐) */
|
|
island?: Partial<IslandAppearance>;
|
|
}
|
|
|
|
const DEFAULT_CONFIG: PersistedConfig = {
|
|
serverUrl: DEFAULT_SERVER_URL,
|
|
username: '',
|
|
token: null,
|
|
tokenEncrypted: false,
|
|
island: { ...DEFAULT_ISLAND_APPEARANCE },
|
|
};
|
|
|
|
/** 外观字段的合法区间校验(越界值直接夹紧,避免用户配置破坏灵动岛布局) */
|
|
function normalizeIslandAppearance(input?: Partial<IslandAppearance>): IslandAppearance {
|
|
const clamp = (value: unknown, min: number, max: number, fallback: number): number => {
|
|
const numeric = typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
|
return Math.min(max, Math.max(min, Math.round(numeric * 100) / 100));
|
|
};
|
|
const positions: IslandAppearance['position'][] = [
|
|
'top-center',
|
|
'top-left',
|
|
'top-right',
|
|
'bottom-center',
|
|
'bottom-left',
|
|
'bottom-right',
|
|
];
|
|
const styles: IslandAppearance['style'][] = ['black', 'glass', 'tinted'];
|
|
return {
|
|
height: clamp(input?.height, 36, 72, DEFAULT_ISLAND_APPEARANCE.height),
|
|
width: clamp(input?.width, 220, 420, DEFAULT_ISLAND_APPEARANCE.width),
|
|
radius: clamp(input?.radius, 8, 32, DEFAULT_ISLAND_APPEARANCE.radius),
|
|
opacity: clamp(input?.opacity, 0.4, 1, DEFAULT_ISLAND_APPEARANCE.opacity),
|
|
accent:
|
|
typeof input?.accent === 'string' && /^#[0-9a-fA-F]{6}$/.test(input.accent)
|
|
? input.accent
|
|
: DEFAULT_ISLAND_APPEARANCE.accent,
|
|
fontSize: clamp(input?.fontSize, 11, 20, DEFAULT_ISLAND_APPEARANCE.fontSize),
|
|
animations: input?.animations !== false,
|
|
speed: clamp(input?.speed, 0.5, 2, DEFAULT_ISLAND_APPEARANCE.speed),
|
|
position: positions.includes(input?.position as IslandAppearance['position'])
|
|
? (input?.position as IslandAppearance['position'])
|
|
: DEFAULT_ISLAND_APPEARANCE.position,
|
|
alwaysOnTop: input?.alwaysOnTop !== false,
|
|
style: styles.includes(input?.style as IslandAppearance['style'])
|
|
? (input?.style as IslandAppearance['style'])
|
|
: DEFAULT_ISLAND_APPEARANCE.style,
|
|
idleSliver: input?.idleSliver === true,
|
|
};
|
|
}
|
|
|
|
function configFilePath(): string {
|
|
return path.join(app.getPath('userData'), 'config.json');
|
|
}
|
|
|
|
function encryptToken(token: string | null): { token: string | null; tokenEncrypted: boolean } {
|
|
if (!token) return { token: null, tokenEncrypted: false };
|
|
try {
|
|
if (safeStorage.isEncryptionAvailable()) {
|
|
return { token: safeStorage.encryptString(token).toString('base64'), tokenEncrypted: true };
|
|
}
|
|
} catch {
|
|
// 某些环境下 DPAPI 不可用,降级为明文存储(仅本地开发场景)
|
|
}
|
|
return { token, tokenEncrypted: false };
|
|
}
|
|
|
|
function decryptToken(raw: PersistedConfig): string | null {
|
|
if (!raw.token) return null;
|
|
if (!raw.tokenEncrypted) return raw.token;
|
|
try {
|
|
return safeStorage.decryptString(Buffer.from(raw.token, 'base64'));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function readPersisted(): PersistedConfig {
|
|
try {
|
|
const content = fs.readFileSync(configFilePath(), 'utf8');
|
|
const parsed = JSON.parse(content) as Partial<PersistedConfig>;
|
|
return {
|
|
serverUrl:
|
|
typeof parsed.serverUrl === 'string' && parsed.serverUrl
|
|
? parsed.serverUrl
|
|
: DEFAULT_CONFIG.serverUrl,
|
|
username: typeof parsed.username === 'string' ? parsed.username : '',
|
|
token: typeof parsed.token === 'string' ? parsed.token : null,
|
|
tokenEncrypted: parsed.tokenEncrypted === true,
|
|
island: normalizeIslandAppearance(parsed.island),
|
|
};
|
|
} catch {
|
|
return { ...DEFAULT_CONFIG };
|
|
}
|
|
}
|
|
|
|
function writePersisted(config: PersistedConfig): void {
|
|
const target = configFilePath();
|
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
fs.writeFileSync(target, JSON.stringify(config, null, 2), 'utf8');
|
|
}
|
|
|
|
/** 读取配置(token 已解密),供渲染进程使用 */
|
|
export function getConfig(): DesktopStoredConfig {
|
|
const persisted = readPersisted();
|
|
return {
|
|
serverUrl: persisted.serverUrl,
|
|
username: persisted.username,
|
|
token: decryptToken(persisted),
|
|
island: normalizeIslandAppearance(persisted.island),
|
|
};
|
|
}
|
|
|
|
/** 局部更新配置:只覆盖传入的字段 */
|
|
export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredConfig {
|
|
const persisted = readPersisted();
|
|
|
|
if (typeof patch.serverUrl === 'string' && patch.serverUrl.trim()) {
|
|
persisted.serverUrl = patch.serverUrl.trim().replace(/\/+$/, '');
|
|
}
|
|
if (typeof patch.username === 'string') {
|
|
persisted.username = patch.username;
|
|
}
|
|
if (patch.island !== undefined) {
|
|
persisted.island = normalizeIslandAppearance({ ...persisted.island, ...patch.island });
|
|
}
|
|
if (patch.token !== undefined) {
|
|
const encrypted = encryptToken(patch.token);
|
|
persisted.token = encrypted.token;
|
|
persisted.tokenEncrypted = encrypted.tokenEncrypted;
|
|
}
|
|
|
|
writePersisted(persisted);
|
|
return getConfig();
|
|
}
|
|
|
|
/** 清空为默认配置(退出登录 / 重置) */
|
|
export function clearConfig(): DesktopStoredConfig {
|
|
writePersisted({ ...DEFAULT_CONFIG });
|
|
return getConfig();
|
|
}
|
|
|
|
export function getConfigPath(): string {
|
|
return configFilePath();
|
|
}
|