feat: support custom instance icons and refresh release

This commit is contained in:
shiyue
2026-08-25 22:11:07 +08:00
parent bd20a79d4b
commit 05480e511d
20 changed files with 1104 additions and 23 deletions

View File

@@ -4773,6 +4773,220 @@ function assertFrontendAssetVersionContract() {
}
}
function createRegressionPng(width = 512, height = 512, marker = 'instance-icon') {
const buffer = Buffer.alloc(8 + 25 + marker.length);
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(buffer, 0);
buffer.writeUInt32BE(13, 8);
buffer.write('IHDR', 12, 4, 'ascii');
buffer.writeUInt32BE(width, 16);
buffer.writeUInt32BE(height, 20);
buffer[24] = 8;
buffer[25] = 6;
buffer[26] = 0;
buffer[27] = 0;
buffer[28] = 0;
buffer.writeUInt32BE(0, 29);
buffer.write(marker, 33, marker.length, 'ascii');
return buffer;
}
async function readResponseBuffer(response) {
return Buffer.from(await response.arrayBuffer());
}
async function fetchInstanceIconConfig(port) {
const response = await fetch(`http://127.0.0.1:${port}/api/instance-icon/config`, {
cache: 'no-store',
});
const payload = await response.json();
assert(response.ok && payload.ok, `Instance icon config request failed: ${payload.message || response.status}`);
return payload;
}
async function uploadInstanceIconForRegression(port, token, buffer, mime = 'image/png') {
const response = await fetch(`http://127.0.0.1:${port}/api/instance-icon`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': mime,
},
body: buffer,
});
const text = await response.text();
let payload = {};
try { payload = text ? JSON.parse(text) : {}; } catch {}
return { response, payload };
}
function assertInstanceIconStaticContract() {
const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8');
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const styleSource = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8');
const swSource = fs.readFileSync(path.join(PUBLIC_DIR, 'sw.js'), 'utf8');
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
const gitignoreSource = fs.readFileSync(path.join(REPO_DIR, '.gitignore'), 'utf8');
const failures = [];
const record = (label, check) => {
try {
check();
} catch (err) {
failures.push(`${label}: ${err?.message || err}`);
}
};
record('index icon consumers', () => {
assert(indexSource.includes('data-instance-icon-link'), 'Index icon links should be marked for runtime updates');
assert(indexSource.includes('href="/api/instance-icon?v=default"'), 'Index favicon links should point at the dynamic instance icon endpoint');
assert(indexSource.includes('href="/api/site.webmanifest"'), 'Index should load the dynamic manifest endpoint');
assert(indexSource.includes('data-instance-icon') && indexSource.includes('src="/api/instance-icon?v=default"'), 'Login logo should consume the dynamic instance icon');
assert(!indexSource.includes('href="site.webmanifest"'), 'Index should not pin the static site.webmanifest');
});
record('frontend runtime hooks', () => {
for (const name of [
'instanceIconUrl',
'loadInstanceIconConfig',
'applyInstanceIconConfig',
'cropImageFileToInstancePng',
'uploadInstanceIcon',
'resetInstanceIconToDefault',
'mountInstanceIconSettings',
]) {
assert(frontendSource.includes(`function ${name}`), `Frontend should define ${name}`);
}
assert(frontendSource.includes('[data-instance-icon]'), 'Frontend should update all runtime image consumers');
assert(frontendSource.includes('[data-instance-icon-link]'), 'Frontend should update head icon links');
assert(frontendSource.includes('data-instance-icon-file'), 'Settings panel should include a file chooser');
assert(frontendSource.includes('data-instance-icon-reset'), 'Settings panel should include a restore-default action');
assert(/image\/png['"][\s\S]*image\/jpeg['"][\s\S]*image\/webp['"]/.test(frontendSource), 'Frontend should allow PNG/JPEG/WebP selection');
assert(/drawImage\(/.test(frontendSource), 'Frontend should crop selected images through canvas drawImage');
assert(!/icon:\s*['"]\/icon-192\.png['"]/.test(frontendSource), 'Browser notifications should not hard-code icon-192.png');
});
record('service worker icon consumer', () => {
assert(swSource.includes("'/api/instance-icon?v=default'"), 'Service worker should default notifications to the dynamic icon endpoint');
assert(!swSource.includes("'/icon-192.png'"), 'Service worker should not hard-code icon-192.png');
});
record('server and storage contract', () => {
assert(serverSource.includes("path.join(CONFIG_DIR, 'instance-icon.png')"), 'Server should store the custom icon in CONFIG_DIR/instance-icon.png');
assert(serverSource.includes("path.join(CONFIG_DIR, '.instance-icon.png.tmp')"), 'Server should use the fixed temporary icon path');
assert(serverSource.includes('function validateInstanceIconPng'), 'Server should validate PNG signature and dimensions');
assert(serverSource.includes('function sendInstanceIconConfig'), 'Server should expose instance icon config');
assert(serverSource.includes("url.pathname === '/api/instance-icon/config'"), 'Server should route GET /api/instance-icon/config');
assert(serverSource.includes("url.pathname === '/api/instance-icon'"), 'Server should route /api/instance-icon');
assert(serverSource.includes("url.pathname === '/api/site.webmanifest'"), 'Server should route GET /api/site.webmanifest');
});
record('styles and git ignore', () => {
assert(styleSource.includes('.instance-icon-settings'), 'Styles should cover the instance icon settings block');
assert(styleSource.includes('.instance-icon-preview'), 'Styles should cover the instance icon preview');
assert(gitignoreSource.includes('config/instance-icon.png'), 'Git ignore should exclude the runtime instance icon');
assert(gitignoreSource.includes('config/.instance-icon.png.tmp'), 'Git ignore should exclude the temporary instance icon');
});
if (failures.length > 0) {
throw new Error(`Instance icon static contract failed:\n- ${failures.join('\n- ')}`);
}
}
async function runInstanceIconRegression() {
assertInstanceIconStaticContract();
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-instance-icon-'));
const configDir = path.join(tempRoot, 'config');
const sessionsDir = path.join(tempRoot, 'sessions');
const logsDir = path.join(tempRoot, 'logs');
mkdirp(configDir);
mkdirp(sessionsDir);
mkdirp(logsDir);
const port = await getFreePort();
const password = 'InstanceIcon!234';
await withServer({
PORT: String(port),
CC_WEB_PASSWORD: password,
CC_WEB_CONFIG_DIR: configDir,
CC_WEB_SESSIONS_DIR: sessionsDir,
CC_WEB_LOGS_DIR: logsDir,
CC_WEB_PUBLIC_DIR: PUBLIC_DIR,
HOME: tempRoot,
CLAUDE_PATH: MOCK_CLAUDE,
CODEX_PATH: MOCK_CODEX_APP_SERVER,
}, async () => {
const defaultConfig = await fetchInstanceIconConfig(port);
assert(defaultConfig.custom === false, 'Default instance icon config should report custom=false');
assert(defaultConfig.version === 'default', 'Default instance icon version should be default');
assert(defaultConfig.url === '/api/instance-icon?v=default', 'Default instance icon URL should use the unified endpoint');
const defaultIconResponse = await fetch(`http://127.0.0.1:${port}/api/instance-icon`);
const defaultIcon = await readResponseBuffer(defaultIconResponse);
const sourceDefaultIcon = fs.readFileSync(path.join(PUBLIC_DIR, 'icon-192.png'));
assert(defaultIconResponse.ok, `Default icon response should succeed, got ${defaultIconResponse.status}`);
assert(/^image\/png/.test(defaultIconResponse.headers.get('content-type') || ''), 'Default icon should be served as image/png');
assert(defaultIconResponse.headers.get('x-content-type-options') === 'nosniff', 'Default icon should set nosniff');
assert(defaultIcon.equals(sourceDefaultIcon), 'Default instance icon should fall back to public/icon-192.png');
const defaultManifestResponse = await fetch(`http://127.0.0.1:${port}/api/site.webmanifest`);
const defaultManifest = await defaultManifestResponse.json();
const defaultIconSrcs = defaultManifest.icons.map((icon) => icon.src);
assert(defaultIconSrcs.includes('/icon-192.png') && defaultIconSrcs.includes('/icon-512.png'), 'Default manifest should keep existing 192/512 icons');
const unauth = await uploadInstanceIconForRegression(port, '', createRegressionPng());
assert(unauth.response.status === 401, 'POST /api/instance-icon should require Bearer auth');
const { ws, messages, token } = await connectWs(port, password);
await nextMessage(messages, ws, (msg) => msg.type === 'session_list');
const badMime = await uploadInstanceIconForRegression(port, token, createRegressionPng(), 'image/jpeg');
assert(badMime.response.status === 400 && /PNG/.test(badMime.payload.message || ''), 'Server should reject non-PNG uploads');
const badSignature = await uploadInstanceIconForRegression(port, token, Buffer.from('not-png'), 'image/png');
assert(badSignature.response.status === 400, 'Server should reject invalid PNG data');
const badSize = await uploadInstanceIconForRegression(port, token, createRegressionPng(256, 512), 'image/png');
assert(badSize.response.status === 400 && /512/.test(badSize.payload.message || ''), 'Server should reject non-512x512 PNG uploads');
const tooLarge = await uploadInstanceIconForRegression(port, token, Buffer.alloc(4 * 1024 * 1024 + 1, 1), 'image/png');
assert(tooLarge.response.status === 413, 'Server should reject uploads larger than 4 MiB');
const firstUpload = await uploadInstanceIconForRegression(port, token, createRegressionPng(512, 512, 'first'), 'image/png');
assert(firstUpload.response.ok && firstUpload.payload.custom === true, `First custom icon upload should succeed: ${firstUpload.payload.message || firstUpload.response.status}`);
assert(fs.existsSync(path.join(configDir, 'instance-icon.png')), 'Custom icon should be written to CC_WEB_CONFIG_DIR');
assert(!fs.existsSync(path.join(configDir, '.instance-icon.png.tmp')), 'Temporary icon file should not remain after successful save');
assert(!firstUpload.payload.url.includes('default') && /\/api\/instance-icon\?v=/.test(firstUpload.payload.url), 'Upload should return a versioned icon URL');
const firstVersion = firstUpload.payload.version;
const customIconResponse = await fetch(`http://127.0.0.1:${port}${firstUpload.payload.url}`);
const customIcon = await readResponseBuffer(customIconResponse);
assert(customIcon.equals(createRegressionPng(512, 512, 'first')), 'GET /api/instance-icon should return the uploaded custom icon');
assert(customIconResponse.headers.get('etag')?.includes(firstVersion), 'Custom icon response should include an ETag based on the content version');
const persistedConfig = await fetchInstanceIconConfig(port);
assert(persistedConfig.custom === true && persistedConfig.version === firstVersion, 'Custom icon config should persist after upload');
const secondUpload = await uploadInstanceIconForRegression(port, token, createRegressionPng(512, 512, 'second'), 'image/png');
assert(secondUpload.response.ok && secondUpload.payload.version !== firstVersion, 'Replacing the icon should update the content version');
const customManifestResponse = await fetch(`http://127.0.0.1:${port}/api/site.webmanifest`);
const customManifest = await customManifestResponse.json();
assert(customManifest.icons.length === 1, 'Custom manifest should expose one normalized 512 icon');
assert(customManifest.icons[0].src === secondUpload.payload.url && customManifest.icons[0].sizes === '512x512', 'Custom manifest should point at the versioned instance icon');
const deleteResponse = await fetch(`http://127.0.0.1:${port}/api/instance-icon`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
const deletePayload = await deleteResponse.json();
assert(deleteResponse.ok && deletePayload.custom === false && deletePayload.version === 'default', 'DELETE should restore the default icon config');
assert(!fs.existsSync(path.join(configDir, 'instance-icon.png')), 'DELETE should remove the runtime custom icon file');
const deleteAgain = await fetch(`http://127.0.0.1:${port}/api/instance-icon`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
assert(deleteAgain.ok, 'DELETE /api/instance-icon should be idempotent');
ws.close();
});
}
async function runFrontendAssetVersionRegression() {
assertFrontendAssetVersionContract();
@@ -6110,6 +6324,11 @@ async function main() {
console.log('Frontend asset version regression checks passed.');
return;
}
if (regressionTarget === 'instance-icon') {
await runInstanceIconRegression();
console.log('Instance icon regression checks passed.');
return;
}
if (regressionTarget === 'session-item-tooltip') {
assertSessionItemTooltipContract();
console.log('Session item tooltip regression checks passed.');
@@ -8136,16 +8355,68 @@ async function main() {
sourceHopCount: 0,
args: {
targetConversationId: codexAppSession.sessionId,
content: 'running codexapp target should reject this',
content: 'running codexapp target should steer this',
replyMode: 'one_way',
},
});
assert(codexAppRunningMcp.status === 400 && codexAppRunningMcp.body?.code === 'target_running', 'MCP cross send should reject running Codex App targets');
assert(codexAppRunningMcp.status === 200 && codexAppRunningMcp.body?.ok, `MCP cross send should steer running Codex App targets: ${JSON.stringify(codexAppRunningMcp.body)}`);
assert(codexAppRunningMcp.body.deliveryStatus === 'steering', 'Running Codex App cross send should report steering delivery');
const runningCrossBubble = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_message' &&
msg.sessionId === codexAppSession.sessionId &&
msg.message?.crossConversation?.messageId === codexAppRunningMcp.body.messageId
));
assert(runningCrossBubble.message.content === 'running codexapp target should steer this', 'Running cross send should persist the original display text');
const runningCrossDelta = await nextMessage(messages, ws, (msg) => (
msg.type === 'text_delta' &&
msg.sessionId === codexAppSession.sessionId &&
/running codexapp target should steer this/.test(msg.text || '')
));
assert(/steer accepted/.test(runningCrossDelta.text || ''), 'Running cross send should reach turn/steer');
const codexAppRunningReplyMcp = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_send_message',
sourceSessionId: codexSession.sessionId,
sourceHopCount: 0,
args: {
targetConversationId: codexAppSession.sessionId,
content: 'running codexapp reply should steer and return',
replyMode: 'return_and_continue',
},
});
assert(codexAppRunningReplyMcp.status === 200 && codexAppRunningReplyMcp.body?.ok, `MCP running Codex App reply should steer: ${JSON.stringify(codexAppRunningReplyMcp.body)}`);
assert(codexAppRunningReplyMcp.body.deliveryStatus === 'steering', 'Running Codex App reply request should report steering delivery');
assert(codexAppRunningReplyMcp.body.requestId && codexAppRunningReplyMcp.body.status === 'waiting', 'Running Codex App reply request should keep a waiting request id');
const runningReplyBubble = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_message' &&
msg.sessionId === codexAppSession.sessionId &&
msg.message?.crossConversation?.replyRequestId === codexAppRunningReplyMcp.body.requestId
));
assert(runningReplyBubble.message.crossConversation.expectsReply === true, 'Running steer reply metadata should preserve expectsReply');
const runningReplyDelta = await nextMessage(messages, ws, (msg) => (
msg.type === 'text_delta' &&
msg.sessionId === codexAppSession.sessionId &&
/running codexapp reply should steer and return/.test(msg.text || '')
));
assert(/steer accepted/.test(runningReplyDelta.text || ''), 'Running reply request should reach turn/steer');
await sleep(150);
ws.send(JSON.stringify({ type: 'detach_view' }));
await sleep(50);
ws.send(JSON.stringify({ type: 'abort', sessionId: codexAppSession.sessionId }));
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
await waitForJsonCondition(path.join(sessionsDir, `${codexSession.sessionId}.json`), (session) => (
Array.isArray(session.messages) &&
session.messages.some((message) => (
message.crossConversation?.replyToRequestId === codexAppRunningReplyMcp.body.requestId &&
message.crossConversation?.processed === true
))
));
const runningReturnedReply = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_get_pending_reply',
sourceSessionId: codexSession.sessionId,
args: { requestId: codexAppRunningReplyMcp.body.requestId },
});
assert(runningReturnedReply.status === 200 && runningReturnedReply.body?.status === 'returned', 'Running steer reply should return to the source after target completion');
await nextMessage(messages, ws, (msg) => isSessionCompletionMessage(msg, codexSession.sessionId), 8000);
const tinyPng = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',