Skip to content

Commit 9e67987

Browse files
authored
feat(client): add bilingual UI and Unicode-safe Windows validation (#351)
* fix(windows): preserve bundled Node paths under PowerShell 5.1 * feat(client): add bilingual interface and Windows validation * docs(windows): cover complete PR validation * test(windows): load localization in community checks * test(windows): isolate localized startup fixtures * fix(renderer): account for transparent accent contrast * test(renderer): cover clamped alpha composition * fix(renderer): model composer surface contrast
1 parent 6f789be commit 9e67987

40 files changed

Lines changed: 2268 additions & 366 deletions

docs/pr-351-windows-validation.md

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
# PR #351 Windows Validation
2+
3+
This is the complete Windows handoff for
4+
[PR #351](https://github.com/Fei-Away/Codex-Dream-Skin/pull/351). A Windows
5+
agent should pull that PR and validate the exact PR head. Do not test the
6+
published v1.5.12 installer: it predates every change in this PR.
7+
8+
The PR combines three related candidate areas that need one real-Windows pass:
9+
10+
1. Windows client localization and language persistence (`System`, `English`,
11+
and `中文`), including tray actions, dialogs, notifications, installer
12+
payload repair, update checks, theme import/apply, and restore paths.
13+
2. Shared renderer/runtime changes used by both macOS and Windows.
14+
3. Issue #337: PowerShell 5.1 must preserve a bundled Node executable path
15+
when Inno Setup extracts it below a non-ASCII temporary directory. The old
16+
failure text was `Node.js executable path could not be validated`.
17+
18+
This document authorizes validation only. The Windows agent must not merge the
19+
PR, bump a version, create a tag or Release, publish an installer, upload its
20+
local build, edit WindowsApps ACLs, modify `app.asar`, disable Defender, or use
21+
`ExecutionPolicy Bypass`.
22+
23+
## 1. Check Out The Exact PR Head
24+
25+
Use a fresh clone or a clean worktree. These commands deliberately detach at
26+
the current PR head so the tested commit cannot move locally during the run:
27+
28+
```powershell
29+
git fetch origin pull/351/head
30+
git switch --detach FETCH_HEAD
31+
32+
$candidateSha = (git rev-parse HEAD).Trim()
33+
$onlineSha = (gh pr view 351 --repo Fei-Away/Codex-Dream-Skin `
34+
--json headRefOid --jq .headRefOid).Trim()
35+
if ($candidateSha -cne $onlineSha) {
36+
throw "PR head changed during checkout: local=$candidateSha online=$onlineSha"
37+
}
38+
if (git status --porcelain) {
39+
throw 'The validation worktree is dirty.'
40+
}
41+
"CANDIDATE_SHA=$candidateSha"
42+
```
43+
44+
Record the printed full SHA. Before sending the final report, run the online
45+
SHA comparison again. If the PR moved, stop and rerun against its new head.
46+
47+
## 2. Record The Host
48+
49+
Use a real Windows 10/11 x64 host with the official Microsoft Store Codex
50+
package installed for the current user. Record:
51+
52+
```powershell
53+
$PSVersionTable | Format-List PSVersion, PSEdition, OS, OSVersion
54+
[Environment]::OSVersion.Version
55+
node --version
56+
git --version
57+
gh --version
58+
Get-AppxPackage OpenAI.Codex | Select-Object Name, Version, Architecture
59+
```
60+
61+
Windows PowerShell 5.1 is mandatory. PowerShell 7 is an additional gate when
62+
available; it cannot replace the 5.1 result.
63+
64+
## 3. Run Automated Gates
65+
66+
Run from the repository root. Every command must exit `0`:
67+
68+
```powershell
69+
node .\tools\sync-runtime-assets.mjs --check
70+
71+
$portableTests = @(
72+
Get-ChildItem .\macos\tests\*.test.mjs,
73+
.\windows\tests\*.test.mjs,
74+
.\tools\*.test.mjs
75+
) | ForEach-Object FullName
76+
node --test @portableTests
77+
78+
powershell.exe -NoLogo -NoProfile -ExecutionPolicy RemoteSigned `
79+
-File .\windows\tests\run-tests.ps1
80+
powershell.exe -NoLogo -NoProfile -ExecutionPolicy RemoteSigned `
81+
-File .\windows\tests\installer-static.tests.ps1
82+
```
83+
84+
When `pwsh.exe` is installed, rerun both PowerShell test entry points with it:
85+
86+
```powershell
87+
pwsh.exe -NoLogo -NoProfile -ExecutionPolicy RemoteSigned `
88+
-File .\windows\tests\run-tests.ps1
89+
pwsh.exe -NoLogo -NoProfile -ExecutionPolicy RemoteSigned `
90+
-File .\windows\tests\installer-static.tests.ps1
91+
```
92+
93+
Preserve full output for any failure. Do not weaken or skip a failing test.
94+
95+
## 4. Build The Candidate Setup
96+
97+
Use Inno Setup 6.7.1. Confirm `ISCC.exe` exists, then build from this exact
98+
detached PR tree:
99+
100+
```powershell
101+
$iscc = Join-Path ${env:ProgramFiles(x86)} 'Inno Setup 6\ISCC.exe'
102+
if (-not (Test-Path -LiteralPath $iscc -PathType Leaf)) {
103+
$iscc = Join-Path $env:ProgramFiles 'Inno Setup 6\ISCC.exe'
104+
}
105+
if (-not (Test-Path -LiteralPath $iscc -PathType Leaf)) {
106+
throw 'Install official Inno Setup 6.7.1 before continuing.'
107+
}
108+
109+
$candidateOutput = Join-Path $env:LOCALAPPDATA 'DreamSkin-PR351-Build'
110+
powershell.exe -NoLogo -NoProfile -ExecutionPolicy RemoteSigned `
111+
-File .\windows\installer\build-release.ps1 `
112+
-OutputDirectory $candidateOutput -IsccPath $iscc
113+
114+
$candidateSetup = Join-Path $candidateOutput 'CodexDreamSkin-Setup-v1.5.12.exe'
115+
if (-not (Test-Path -LiteralPath $candidateSetup -PathType Leaf) -or
116+
(Get-Item -LiteralPath $candidateSetup).Length -le 0) {
117+
throw 'The candidate Setup.exe was not built.'
118+
}
119+
Get-FileHash -LiteralPath $candidateSetup -Algorithm SHA256
120+
```
121+
122+
The `v1.5.12` filename identifies the unchanged candidate base only. This
123+
local file is not the public v1.5.12 asset and must not be distributed.
124+
125+
## 5. Exercise Issue #337 With A Real CJK Temp Path
126+
127+
Exit Codex and the Dream Skin tray. Start the candidate Setup from a process
128+
whose real `TEMP` and `TMP` point to a CJK path. Keep the Inno log for evidence:
129+
130+
```powershell
131+
$cjkTemp = Join-Path $env:LOCALAPPDATA 'DreamSkin-验证-临时目录'
132+
$setupLog = Join-Path $env:LOCALAPPDATA 'DreamSkin-PR351-Setup.log'
133+
New-Item -ItemType Directory -Path $cjkTemp -Force | Out-Null
134+
$oldTemp = $env:TEMP
135+
$oldTmp = $env:TMP
136+
try {
137+
$env:TEMP = $cjkTemp
138+
$env:TMP = $cjkTemp
139+
$process = Start-Process -FilePath $candidateSetup `
140+
-ArgumentList "/LOG=$setupLog" -Wait -PassThru
141+
if ($process.ExitCode -ne 0) {
142+
throw "Candidate Setup exited with $($process.ExitCode)."
143+
}
144+
} finally {
145+
$env:TEMP = $oldTemp
146+
$env:TMP = $oldTmp
147+
}
148+
```
149+
150+
In the GUI, complete a normal current-user install. Pass only when all of these
151+
are true:
152+
153+
- Setup reaches completion without
154+
`Node.js executable path could not be validated`.
155+
- The sanitized Inno log proves its temporary extraction path was below the
156+
exact CJK directory above. If it used another directory, this test did not
157+
exercise #337 and must be repeated.
158+
- No administrator elevation, ACL change, security bypass, or untrusted Node
159+
fallback was needed.
160+
- The installed engine contains the exact PR sources below:
161+
162+
```powershell
163+
$sourceRoot = (Resolve-Path .\windows).Path
164+
$engineRoot = Join-Path $env:LOCALAPPDATA 'CodexDreamSkin\engine'
165+
foreach ($relative in @(
166+
'VERSION',
167+
'assets\renderer-inject.js',
168+
'scripts\common-windows.ps1',
169+
'scripts\localization-windows.ps1',
170+
'scripts\injector.mjs',
171+
'scripts\theme-windows.ps1',
172+
'scripts\tray-dream-skin.ps1'
173+
)) {
174+
$source = Join-Path $sourceRoot $relative
175+
$installed = Join-Path $engineRoot $relative
176+
if (-not (Test-Path -LiteralPath $installed -PathType Leaf)) {
177+
throw "Installed engine is missing: $relative"
178+
}
179+
if ((Get-FileHash -LiteralPath $source -Algorithm SHA256).Hash -cne
180+
(Get-FileHash -LiteralPath $installed -Algorithm SHA256).Hash) {
181+
throw "Installed engine differs from PR head: $relative"
182+
}
183+
}
184+
'ENGINE_HASH_BINDING=PASS'
185+
```
186+
187+
## 6. Validate The Windows Language Workflows
188+
189+
Use the tray UI for the primary test. Do not substitute an environment
190+
variable for the menu selection.
191+
192+
1. Select `Language / 语言` -> `English`. Close and reopen the tray. Confirm the
193+
checked selection persists and the status, apply/reapply, pause/resume,
194+
background, import, saved themes, links, update, restore, and exit labels
195+
are English.
196+
2. Select `Language / 语言` -> `中文`. Reopen the tray and repeat the same check
197+
in Chinese.
198+
3. Select `System / 系统`. Reopen the tray and confirm it follows Windows UI
199+
culture. The preference override must be removed when System is selected.
200+
4. In both English and Chinese, exercise apply/reapply, pause/resume, background
201+
picker cancellation, invalid-image failure, theme ZIP import, duplicate or
202+
update result, save/switch theme, update check, and restore cancellation.
203+
5. Confirm cancellation is never reported as success and a failure does not
204+
leave a false active state. Machine-readable status/state JSON must remain
205+
unchanged; only human-facing copy is localized.
206+
6. Apply one test theme with an explicit white accent and one with an explicit
207+
black accent. On both light and dark Codex appearances, accent-filled
208+
controls must remain readable: black text on white and white text on black.
209+
Reapply an adaptive/default accent afterward and confirm no stale explicit
210+
foreground remains.
211+
212+
Also test one normal apply and one restore against the real Store Codex app.
213+
Run the installed verifier on Home and one normal task route:
214+
215+
```powershell
216+
powershell.exe -NoLogo -NoProfile -ExecutionPolicy RemoteSigned `
217+
-File "$engineRoot\scripts\verify-dream-skin.ps1" `
218+
-ScreenshotPath "$env:TEMP\dreamskin-pr351-real-codex.png"
219+
```
220+
221+
Pass only with `scope.level=L1`, an empty `missingL1`, an interactive real
222+
`app://` Codex renderer, no horizontal overflow, and no console/runtime error
223+
introduced by this PR. The screenshot must be from the real installed Codex
224+
app, not a fixture.
225+
226+
## 7. Return One Result
227+
228+
Return exactly one consolidated report to the PR owner:
229+
230+
```text
231+
PR: #351
232+
CANDIDATE_SHA: <40-character SHA>
233+
HOST: <Windows edition/build; x64>
234+
CODEX: <Store package version>
235+
POWERSHELL_5_1: PASS|FAIL <version>
236+
POWERSHELL_7: PASS|FAIL|NOT_INSTALLED <version>
237+
PORTABLE_TESTS: PASS|FAIL <count>
238+
INSTALLER_STATIC: PASS|FAIL
239+
SETUP_BUILD: PASS|FAIL <SHA-256>
240+
CJK_TEMP_SETUP_337: PASS|FAIL
241+
ENGINE_HASH_BINDING: PASS|FAIL
242+
LANGUAGE_SYSTEM: PASS|FAIL
243+
LANGUAGE_ENGLISH: PASS|FAIL
244+
LANGUAGE_CHINESE: PASS|FAIL
245+
REAL_CODEX_L1: PASS|FAIL <scope.level; missingL1>
246+
SCREENSHOT: <local path>
247+
SANITIZED_FAILURES: <none or exact excerpts without private paths/tokens>
248+
FINAL: PASS|FAIL
249+
```
250+
251+
Any mandatory failure makes `FINAL: FAIL`. Include sanitized excerpts from the
252+
Setup log and `%LOCALAPPDATA%\CodexDreamSkin\logs` for failures, but redact user
253+
names and private paths. Do not post secrets or upload the locally built Setup.

macos/assets/renderer-inject.js

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
? THEME.artMetadata : null;
2323
const ANALYSIS_CACHE_KEY = "__CODEX_DREAM_SKIN_ANALYSIS_CACHE__";
2424
const THEME_VARIABLES = [
25-
"--ds-bg", "--ds-panel", "--ds-panel-2", "--ds-green", "--ds-lime",
25+
"--ds-bg", "--ds-panel", "--ds-panel-2", "--ds-green", "--ds-lime", "--ds-on-accent",
2626
"--ds-cyan", "--ds-purple", "--ds-text", "--ds-muted", "--ds-line",
2727
"--ds-bg-rgb", "--ds-panel-rgb", "--ds-panel-2-rgb", "--ds-accent-rgb",
2828
"--ds-accent-alt-rgb", "--ds-secondary-rgb", "--ds-highlight-rgb",
@@ -118,15 +118,31 @@
118118
if (!value || value === "transparent") return null;
119119
const hex = String(value).trim().match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
120120
if (hex) {
121-
const rgbHex = hex[1].length <= 4
122-
? hex[1].slice(0, 3).split("").map((digit) => `${digit}${digit}`).join("")
123-
: hex[1].slice(0, 6);
121+
const digits = hex[1];
122+
const rgbHex = digits.length <= 4
123+
? digits.slice(0, 3).split("").map((digit) => `${digit}${digit}`).join("")
124+
: digits.slice(0, 6);
125+
const alphaHex = digits.length === 4
126+
? `${digits[3]}${digits[3]}`
127+
: digits.length === 8 ? digits.slice(6, 8) : "ff";
124128
const number = Number.parseInt(rgbHex, 16);
125-
return { r: number >> 16, g: (number >> 8) & 255, b: number & 255 };
129+
return {
130+
r: number >> 16,
131+
g: (number >> 8) & 255,
132+
b: number & 255,
133+
alpha: Number.parseInt(alphaHex, 16) / 255,
134+
};
126135
}
127-
const m = String(value).match(/rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i);
136+
const m = String(value).trim().match(
137+
/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/i,
138+
);
128139
if (!m) return null;
129-
return { r: Number(m[1]), g: Number(m[2]), b: Number(m[3]) };
140+
return {
141+
r: Number(m[1]),
142+
g: Number(m[2]),
143+
b: Number(m[3]),
144+
alpha: m[4] === undefined ? 1 : Math.min(1, Math.max(0, Number(m[4]))),
145+
};
130146
};
131147

132148
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
@@ -142,6 +158,44 @@
142158
.map((value) => clamp(Math.round(value), 0, 255).toString(16).padStart(2, "0"))
143159
.join("")}`;
144160

161+
const relativeLuminance = ({ r, g, b }) => {
162+
const channels = [r, g, b].map((value) => {
163+
const normalized = clamp(value, 0, 255) / 255;
164+
return normalized <= 0.04045
165+
? normalized / 12.92
166+
: ((normalized + 0.055) / 1.055) ** 2.4;
167+
});
168+
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
169+
};
170+
171+
const compositeColor = (value, background, alphaOverride = null) => {
172+
const foreground = parseRgb(value);
173+
if (!foreground) return background;
174+
const alpha = clamp(alphaOverride ?? foreground.alpha ?? 1, 0, 1);
175+
return {
176+
r: clamp(foreground.r, 0, 255) * alpha + background.r * (1 - alpha),
177+
g: clamp(foreground.g, 0, 255) * alpha + background.g * (1 - alpha),
178+
b: clamp(foreground.b, 0, 255) * alpha + background.b * (1 - alpha),
179+
};
180+
};
181+
182+
const readableAccentInk = (accent, panel) => {
183+
// The send button sits on the composer surface, which renders panel RGB
184+
// at 94% regardless of the panel color's declared alpha. Compare against
185+
// both possible backdrop extremes so artwork cannot flip the decision.
186+
const luminances = [0, 255].map((backdrop) => {
187+
const surface = compositeColor(
188+
panel,
189+
{ r: backdrop, g: backdrop, b: backdrop },
190+
0.94,
191+
);
192+
return relativeLuminance(compositeColor(accent, surface));
193+
});
194+
const whiteContrast = Math.min(...luminances.map((value) => 1.05 / (value + 0.05)));
195+
const blackContrast = Math.min(...luminances.map((value) => (value + 0.05) / 0.05));
196+
return whiteContrast >= blackContrast ? "rgb(255 255 255)" : "rgb(0 0 0)";
197+
};
198+
145199
const rgbToHsl = ({ r, g, b }) => {
146200
const values = [r, g, b].map((value) => value / 255);
147201
const max = Math.max(...values);
@@ -266,6 +320,13 @@
266320
for (const [name, value] of Object.entries(variables)) {
267321
if (typeof value === "string" && value) setStyleProperty(root, name, value);
268322
}
323+
if (explicit.has("accent")) {
324+
const accentInk = readableAccentInk(
325+
accent,
326+
variables["--ds-panel"],
327+
);
328+
if (accentInk) setStyleProperty(root, "--ds-on-accent", accentInk);
329+
}
269330
const publicColors = {
270331
"--ds-theme-color-background": variables["--ds-bg"],
271332
"--ds-theme-color-panel": variables["--ds-panel"],

0 commit comments

Comments
 (0)