用户问「为什么窗口没有圆角」。核实:组件层圆角齐,但窗口外框**零处理**——代码里没有任何
Dwm* / SetWindowRgn。本 mission 先做实测,再按平台分开落策略。
判据(纯函数、可真值表):
- `platform::plan_corner(策略 × 系统版本 × 渲染档)` → `source=dwm|square` + `excluded_reason`。
Win11 / Server 2025(build ≥ 22000)→ DWM;Win10 及更早 → 直角(不做 SetWindowRgn 毛边默认);
探测不到版本(macOS / X11 / Wayland)→ 直角 + `unknown-os`(「不承诺」而不是「做不到」)。
CPU 档在 Win11 上照样用 DWM(D36 限制的是我们自己的合成技巧,不是系统合成器)。
落点:`platform/win32/transparency.rs` 仍是全仓唯一 unsafe 文件,新增 DWM 的 FFI 与安全包装;
`alpha.rs` / `dwm.rs` / `probe.rs` 三个新子模块零 unsafe、零 extern(沿 exstyle.rs 的既有形状)。
实测(本机 Server 2025 build 26100、RDP 会话断开、同进程三窗口对照):
- `DwmSetWindowAttribute(DWMWA_WINDOW_CORNER_PREFERENCE)` 在 WS_OVERLAPPEDWINDOW / WS_POPUP /
WS_POPUP|WS_EX_LAYERED(α=0.8) 上**全部成功** ⇒ 无边框与整窗 α 不会让属性设不上;
- `DwmGetWindowAttribute` 同一属性三种样式**全部 E_INVALIDARG** ⇒ 本机不支持回读,与样式无关;
- `DWMWA_EXTENDED_FRAME_BOUNDS − GetWindowRect`:有边框 = +0,−7,popup = +0,+0
⇒ DWM 只给有边框窗口加不可见调整带,我们的窗口没有可画的圆角区域(结构性解释)。
- 像素(角上透明/方块)**未验**:RDP 断连导致 CopyFromScreen / gdigrab 全废。
同时修掉一个自己踩到的判据错误:`i32::unsigned_abs()` 不能用来打印 HRESULT
(E_INVALIDARG 0x80070057 会被印成 0x7FF8FFA9)。现在 HRESULT 用 `hr as u32` + 严重性位,
GetLastError 的 DWORD 语义保持 `unsigned_abs` 并写死注释(两者不同源,别统一)。
配置:新键 `appearance.window_corner ∈ {system, square}`(出厂 system),schema 1.5.0 只推 minor、
不写迁移:老文件缺键 = 取默认 = 跟随系统;旧版本读新文件时未知键被登记并原样写回(`RenderMode::Full`),
只在显式 `store.set` 该键时才 `UnknownKey`。
设置 UI:外观组只留两档(跟随系统 / 直角)+ 一句按平台说清的说明,文案走 i18n 中英。
日志:`[corner] <ctx> corner=policy=… source=… os=… excluded_reason=… radius=system pref=… readback=… frame=… layered=…`
(形状由 `corner::format_line` 的格式化单测钉住,用探针窗口真实读数构造)。
证据:DevDocs/artifacts/m116/{README.md,probe-table.md}
252 lines
9.5 KiB
Rust
252 lines
9.5 KiB
Rust
//! D20 隔离键的 schema 契约:全局 `instances.mode`、实例 `isolation`、`lastModeChange`、
|
||
//! 恒共享层不可配、旧配置缺键即默认值(不需要迁移步骤)。
|
||
|
||
use serde_json::json;
|
||
use unlucky_core::game::install::instance::{InstanceConfig, Isolation, OverrideKey};
|
||
use unlucky_core::game::isolation::{observe_mode, IsolationMode};
|
||
use unlucky_core::game::GameLayout;
|
||
use unlucky_core::setting::document::{LoadProblem, RenderMode, SettingDocument};
|
||
use unlucky_core::setting::instance_keys::{
|
||
instance_schema, instance_version, ISOLATION_OVERRIDES, ISOLATION_RESOLVED, OVERRIDE_KEYS,
|
||
};
|
||
use unlucky_core::setting::keys::{launcher_schema, launcher_version, INSTANCE_MODES};
|
||
use unlucky_core::setting::schema::{SchemaId, SchemaVersion};
|
||
use unlucky_core::setting::spec::{Sensitivity, ValueKind};
|
||
use unlucky_core::setting::store::SettingStore;
|
||
|
||
use super::common::TempDir;
|
||
|
||
#[test]
|
||
fn global_mode_key_defaults_to_isolated() {
|
||
let schema = launcher_schema();
|
||
let spec = schema.spec("instances.mode").expect("instances.mode 键");
|
||
assert_eq!(spec.default, json!("isolated"), "D20 默认隔离");
|
||
assert_eq!(spec.kind, ValueKind::Enum(INSTANCE_MODES));
|
||
assert_eq!(
|
||
spec.ai.sensitivity,
|
||
Sensitivity::High,
|
||
"切档会改变存档可见性,AI 改必须弹窗确认"
|
||
);
|
||
assert!(schema.validate("instances.mode", &json!("shared")).is_ok());
|
||
assert!(schema
|
||
.validate("instances.mode", &json!("isolated"))
|
||
.is_ok());
|
||
assert!(schema
|
||
.validate("instances.mode", &json!("per_version"))
|
||
.is_err());
|
||
assert!(schema.validate("instances.mode", &json!("custom")).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn shared_layers_are_not_configurable() {
|
||
let schema = launcher_schema();
|
||
for key in ["shared.versions", "shared.assets", "shared.libraries"] {
|
||
assert!(
|
||
!schema.is_known(key),
|
||
"{key} 是恒共享层,不该出现在可配键表里"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn instance_isolation_defaults_to_inherit() {
|
||
let schema = instance_schema();
|
||
let spec = schema.spec("isolation").expect("isolation 键");
|
||
assert_eq!(spec.default, json!("inherit"));
|
||
assert_eq!(spec.kind, ValueKind::Enum(ISOLATION_OVERRIDES));
|
||
for value in ["inherit", "isolated", "shared", "custom"] {
|
||
assert!(
|
||
schema.validate("isolation", &json!(value)).is_ok(),
|
||
"{value}"
|
||
);
|
||
}
|
||
assert!(schema.validate("isolation", &json!("per_version")).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn inherit_never_reaches_the_file() {
|
||
// 默认值不落盘:空文档渲染出来只应该有 $schema / schemaVersion。
|
||
let schema = instance_schema();
|
||
let (document, problems) = SettingDocument::load(&schema, &json!({}));
|
||
assert!(problems.is_empty(), "{problems:?}");
|
||
let rendered = document.render(RenderMode::Full);
|
||
assert!(rendered.get("isolation").is_none(), "{rendered}");
|
||
assert!(rendered.get("lastModeChange").is_none(), "{rendered}");
|
||
let dirty = document.render(RenderMode::DirtyOnly);
|
||
assert!(dirty.get("isolation").is_none(), "{dirty}");
|
||
}
|
||
|
||
#[test]
|
||
fn last_mode_change_keys_are_bookkeeping_only() {
|
||
let schema = instance_schema();
|
||
for key in [
|
||
"lastModeChange.at",
|
||
"lastModeChange.from",
|
||
"lastModeChange.to",
|
||
"lastModeChange.probe.isolatedSide",
|
||
"lastModeChange.probe.sharedSide",
|
||
"lastModeChange.warned",
|
||
] {
|
||
assert!(schema.is_known(key), "{key} 必须登记");
|
||
assert!(!schema.accepts_ai_write(key), "{key} 不许 AI 写");
|
||
}
|
||
assert!(schema
|
||
.validate("lastModeChange.probe.isolatedSide", &json!(12))
|
||
.is_ok());
|
||
assert!(schema
|
||
.validate("lastModeChange.probe.isolatedSide", &json!(-1))
|
||
.is_err());
|
||
assert!(schema
|
||
.validate("lastModeChange.warned", &json!(true))
|
||
.is_ok());
|
||
assert!(schema.validate("lastModeChange.warned", &json!(1)).is_err());
|
||
assert!(schema
|
||
.validate("lastModeChange.from", &json!("shared"))
|
||
.is_ok());
|
||
assert!(schema
|
||
.validate("lastModeChange.from", &json!("inherit"))
|
||
.is_err());
|
||
assert_eq!(ISOLATION_RESOLVED, ["isolated", "shared", "custom"]);
|
||
}
|
||
|
||
#[test]
|
||
fn overrides_key_is_an_enum_whitelist() {
|
||
let schema = instance_schema();
|
||
assert!(schema.validate("overrides", &json!([])).is_ok());
|
||
assert!(schema
|
||
.validate("overrides", &json!(["maxMemory", "runningDirectory"]))
|
||
.is_ok());
|
||
assert!(
|
||
schema
|
||
.validate("overrides", &json!(["max_memory"]))
|
||
.is_err(),
|
||
"旧 snake_case 拼写不许过(拼错会静默失效)"
|
||
);
|
||
assert!(schema.validate("overrides", &json!([1])).is_err());
|
||
assert_eq!(OVERRIDE_KEYS, ["maxMemory", "javaType", "runningDirectory"]);
|
||
}
|
||
|
||
#[test]
|
||
fn schema_versions_register_the_new_keys_without_migration() {
|
||
// 1.5.0 登记 M116 的窗口圆角策略键(1.4.0 = M91 外观下层三键,1.3.0 = D17「外观(高级)」,
|
||
// 1.2.0 = D23 的 JDK 落点/优先级键)。
|
||
assert_eq!(launcher_version(), SchemaVersion::new(1, 5, 0));
|
||
assert_eq!(instance_version(), SchemaVersion::new(1, 1, 0));
|
||
assert_eq!(launcher_schema().id(), SchemaId::LAUNCHER);
|
||
// major 仍是 1:迁移链(按 major 走)不需要新步骤,老文件缺键即默认值。
|
||
assert_eq!(launcher_version().major, 1);
|
||
assert_eq!(instance_version().major, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn old_launcher_file_without_new_keys_loads_with_defaults() {
|
||
let temp = std::env::temp_dir().join(format!(
|
||
"ull-setting-d20-{}-{}",
|
||
std::process::id(),
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.map_or(0, |d| d.as_nanos())
|
||
));
|
||
std::fs::create_dir_all(&temp).expect("临时目录");
|
||
let path = temp.join("launcher.json");
|
||
// 旧版本写的文件:只有 $schema 1.0.0 和一个老键,没有 instances.mode。
|
||
std::fs::write(
|
||
&path,
|
||
serde_json::to_vec_pretty(&json!({
|
||
"$schema": "https://schemas.unlucky-launcher.dev/ull/launcher/1.0.0",
|
||
"schemaVersion": 1,
|
||
"ui": { "scale": 1.25 }
|
||
}))
|
||
.expect("序列化"),
|
||
)
|
||
.expect("写文件");
|
||
|
||
let store = SettingStore::load(&path, launcher_schema());
|
||
assert!(store.access().can_read(), "1.0.0 文件必须可读");
|
||
assert!(store.problems().is_empty(), "{:?}", store.problems());
|
||
assert_eq!(
|
||
store.doc().effective_str("instances.mode"),
|
||
Some("isolated"),
|
||
"缺键取 D20 默认值"
|
||
);
|
||
assert_eq!(store.doc().effective_f64("ui.scale"), Some(1.25));
|
||
let _ = std::fs::remove_dir_all(&temp);
|
||
}
|
||
|
||
#[test]
|
||
fn setting_lists_agree_with_the_game_enums() {
|
||
// schema 白名单与 game 侧枚举必须一一对应(两处字面量漂移就是静默不生效)。
|
||
let mut modes: Vec<&str> = [IsolationMode::Isolated, IsolationMode::Shared]
|
||
.iter()
|
||
.map(|mode| mode.as_str())
|
||
.collect();
|
||
modes.sort_unstable();
|
||
let mut declared = INSTANCE_MODES.to_vec();
|
||
declared.sort_unstable();
|
||
assert_eq!(modes, declared);
|
||
|
||
for value in ISOLATION_OVERRIDES
|
||
.iter()
|
||
.filter(|value| **value != "inherit")
|
||
{
|
||
assert!(
|
||
Isolation::parse(value).is_some(),
|
||
"{value} 在 game 侧解析不出来"
|
||
);
|
||
assert_eq!(Isolation::parse(value).map(Isolation::as_str), Some(*value));
|
||
}
|
||
for value in ISOLATION_RESOLVED {
|
||
assert!(Isolation::parse(value).is_some(), "{value}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn writer_output_loads_without_unknown_or_invalid_keys() {
|
||
// 回归 P2-1(review r1):写入者(InstanceConfig + observe_mode)产出的键必须被 schema 全认。
|
||
// 之前 `lastModeChange.warned` 没登记,document 层会把真实文件标成 UnknownKey——
|
||
// 这正是「schema 只做写入者白名单视图」这条契约的裂缝。
|
||
let temp = TempDir::new("d20-writer-shape");
|
||
let game = GameLayout::new(temp.child("game"));
|
||
let mut config = InstanceConfig::new("11111111-2222-4333-8444-555555555555", "1.20.1");
|
||
config.isolation = Some(Isolation::Custom);
|
||
config.game_dir = Some(temp.child("elsewhere"));
|
||
config.overrides = vec![OverrideKey::MaxMemory];
|
||
observe_mode(
|
||
&mut config,
|
||
&game,
|
||
"2026-10-30T00:00:00Z",
|
||
Isolation::Custom,
|
||
)
|
||
.expect("观察");
|
||
|
||
let raw: serde_json::Value =
|
||
serde_json::from_str(&config.to_json_string().expect("序列化")).expect("合法 JSON");
|
||
// 写入者确实输出了 warned(P2-1 的前提)。
|
||
assert_eq!(raw["lastModeChange"]["warned"], json!(true));
|
||
|
||
let (document, problems) = SettingDocument::load(&instance_schema(), &raw);
|
||
let mut unknown: Vec<String> = Vec::new();
|
||
let mut invalid: Vec<String> = Vec::new();
|
||
for problem in &problems {
|
||
match problem {
|
||
LoadProblem::UnknownKey { key } => unknown.push(key.clone()),
|
||
LoadProblem::InvalidValue { key, reason } => invalid.push(format!("{key}: {reason}")),
|
||
_ => {}
|
||
}
|
||
}
|
||
assert!(unknown.is_empty(), "写入者的键没登记进 schema:{unknown:?}");
|
||
assert!(
|
||
invalid.is_empty(),
|
||
"写入者的值过不了 schema 校验:{invalid:?}"
|
||
);
|
||
assert_eq!(
|
||
document.get("lastModeChange.warned"),
|
||
Some(&json!(true)),
|
||
"记账键要能读回"
|
||
);
|
||
// 落盘视图不能把它当「未知键」重复输出。
|
||
let rendered = document.render(RenderMode::Full);
|
||
assert_eq!(rendered["lastModeChange"]["warned"], json!(true));
|
||
assert_eq!(rendered["overrides"], json!(["maxMemory"]));
|
||
}
|