feat: support custom instance icons and refresh release
This commit is contained in:
@@ -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=',
|
||||
|
||||
Reference in New Issue
Block a user