diff --git a/AGENTS.md b/AGENTS.md index e365f74..3e7f1ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,6 +62,8 @@ This file is mandatory for AI and coding agents working in this repository. Read - Prevent horizontal page overflow, clipped controls, nested scrolling accidents, text overlap, layout shifts, and unbounded result columns. Long Hex, hashes, filenames, and table cells require explicit wrapping or contained scrolling. - Version information remains at the bottom of the sidebar and is read from `package.json`. Every page footer displays exactly `Copyright © mid All Rights Reserved.` - Do not add a visible link to `/setting`, explanatory marketing copy, login UI, favorites, or sponsorship UI. +- Preserve the application icon contract under `public/`: `favicon.svg`, a 16/32/48-frame `favicon.ico`, `apple-touch-icon.png` at 180x180, `icon-192.png`, `icon-512.png`, `icon-maskable-512.png`, and `site.webmanifest`. Keep the icon visually aligned with the sidebar brand and keep the maskable artwork inside its safe zone. +- The manifest is metadata/install-ready only. Do not add a Service Worker, offline cache, install prompt, splash-screen bundle, Safari mask icon, legacy `browserconfig.xml`, redundant Apple sizes, or separate 16/32 PNG favicons unless requirements explicitly change. - For UI work, verify a wide desktop, an intermediate width around 801-1040 px, and mobile portrait around 390 x 844 in a real browser. Check direct routes, refresh, navigation, search, `/setting`, both themes, console errors, and network requests. ## Adding a tool diff --git a/CHANGELOG.md b/CHANGELOG.md index fd347c8..0f9f88e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ ### Added +- 增加与侧边栏品牌一致的浏览器图标体系:SVG、内含 16/32/48 像素帧的 ICO、180 像素 Apple 图标、192/512 像素 Web App 图标、512 像素 maskable 图标及 Web App Manifest。 - 增加统一的 Windows PowerShell 启动与更新脚本,提供固定的生产配置、Git 快进更新、锁定依赖重装和构建后启动流程。 - 增加 Windows Server 非 Docker 部署、启动和更新说明。 - 增加独立的固件 Hash 工具,计算 Intel HEX 总有效数据和各 block 的 SHA-256 / SHA-512。 @@ -13,6 +14,7 @@ ### Fixed +- 修复 Docker 构建未复制 `public/` 资源的问题,并为 `.webmanifest` 增加标准响应类型。 - Intel HEX 解析器增加全部非空记录上限、显式 uint32 地址空间检查和无符号扩展地址运算,拒绝越过 `0xFFFFFFFF` 的数据。 - 固件 Hash 在普通 HTTP 局域网访问时增加本地 SHA-256/SHA-512 回退,不再依赖 `SubtleCrypto` 必须可用。 - 生产服务在监听端口前检查 `dist/index.html`,缺少构建产物时立即给出构建命令,不再启动后按请求重复返回 500。 @@ -21,6 +23,7 @@ ### Documentation +- 明确应用图标资源契约,并记录不启用 Service Worker、离线缓存和冗余旧平台图标的边界。 - 补充 `dist/index.html` 缺失、Node 进程未重启、浏览器页面未刷新和前台 PowerShell 退出等启动故障排查说明。 - 将 Node、Docker Compose、容器健康检查、Windows 脚本和示例配置的生产默认端口统一为 `9000`。 - 增加通用 `README.md` 入口并强化 `AGENTS.md` 新会话接手流程,便于其他 AI 在不依赖历史对话的情况下继续维护。 @@ -29,7 +32,7 @@ ### Verification -- 74 条 Vitest 测试通过,新增标准 SHA 向量、Block 头大小端、地址绑定、最高 uint32 地址、地址溢出、控制记录边界、无 Web Crypto 回退、生产构建预检和部署配置一致性覆盖。 +- 85 条 Vitest 测试通过,新增标准 SHA 向量、Block 头大小端、地址绑定、最高 uint32 地址、地址溢出、控制记录边界、无 Web Crypto 回退、生产构建预检、部署配置一致性和应用图标完整性覆盖。 - Vue/TypeScript 类型检查和生产构建通过。 ## 0.0.5 - 2026-08-07 diff --git a/Dockerfile b/Dockerfile index 9e32fb7..ce2f686 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,7 @@ COPY package.json package-lock.json ./ RUN npm ci COPY index.html tsconfig*.json vite.config.ts ./ +COPY public ./public COPY src ./src COPY shared ./shared RUN npm run build diff --git a/README.md b/README.md index ff9ff87..8b4a616 100644 --- a/README.md +++ b/README.md @@ -112,10 +112,13 @@ server/ static server and navigation configuration API shared/tool-catalog.json authoritative tool metadata shared/default-navigation.json first-run navigation order tests/ unit and server integration tests +public/ favicon, Apple touch icon, and Web App Manifest assets ``` New tools must keep their computation independent, register the same stable ID in the catalog, default navigation, and lazy-load registry, reuse the shared UI, define resource limits where appropriate, and include normal, invalid, boundary, and known-vector tests. Routes are generated from the tool registry; ordinary tools do not add routes manually. +The browser icon set follows the sidebar brand and includes SVG, multi-frame ICO, Apple touch, 192/512, and maskable assets. The manifest supplies metadata only: the project deliberately has no Service Worker, offline cache, install prompt, or splash-screen bundle, so a deployment update cannot be hidden by application-managed stale caches. + For the complete extension procedure and AI handoff rules, read [AGENTS.md](AGENTS.md) and [README.zh-CN.md](README.zh-CN.md) before editing. Inspect the Git worktree first and preserve existing changes. ## Documentation diff --git a/README.zh-CN.md b/README.zh-CN.md index 7ef57db..34a7a4e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -35,6 +35,7 @@ shared/tool-catalog.json 工具与分类的权威元数据目录 shared/default-navigation.json 新部署的默认分类和工具顺序 tests/ Vitest 单元测试 dist/ npm run build 生成的生产前端 +public/ 浏览器标签页、Apple 主屏幕和 Web App Manifest 图标资源 Dockerfile 多阶段容器构建 docker-compose.yml 默认部署配置 THIRD_PARTY_NOTICES.md 第三方依赖许可说明 @@ -54,6 +55,9 @@ CHANGELOG.md 版本变更、修复和验证记录 - 服务端只持久化导航顺序,文件为 `/data/navigation.json`,写入采用临时文件和原子替换。 - `/setting` 是固定但不公开显示的管理入口;项目没有登录、收藏、赞助或用户系统。 - 页面必须保持统一 UI,并兼容桌面端和移动端竖屏。 +- 浏览器图标与左侧品牌标记保持一致。`favicon.svg` 是现代浏览器主图标,`favicon.ico` 内含 16/32/48 像素回退图,另提供 180 像素 Apple 图标、192/512 像素 Web App 图标和 512 像素 maskable 图标。 + +`site.webmanifest` 只提供应用名称、颜色和图标元数据,便于浏览器添加到主屏幕。项目不注册 Service Worker,不启用离线缓存、安装提示或启动画面,避免部署更新后继续加载过期构建。 ## 核心功能契约 diff --git a/index.html b/index.html index f20257c..0a67709 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,10 @@ + + + + Web Tools diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000..e5648e2 Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..41f6c05 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..72d5e83 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,12 @@ + + + + diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 0000000..3f3d614 Binary files /dev/null and b/public/icon-192.png differ diff --git a/public/icon-512.png b/public/icon-512.png new file mode 100644 index 0000000..b666d2e Binary files /dev/null and b/public/icon-512.png differ diff --git a/public/icon-maskable-512.png b/public/icon-maskable-512.png new file mode 100644 index 0000000..847f749 Binary files /dev/null and b/public/icon-maskable-512.png differ diff --git a/public/site.webmanifest b/public/site.webmanifest new file mode 100644 index 0000000..65eb3f0 --- /dev/null +++ b/public/site.webmanifest @@ -0,0 +1,30 @@ +{ + "name": "Web Tools", + "short_name": "Web Tools", + "description": "浏览器本地运行的个人 Web 工具箱", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#f5f6f7", + "icons": [ + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/server/index.mjs b/server/index.mjs index 91971c6..2b104d0 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -28,6 +28,7 @@ const mimeTypes = new Map([ ['.png', 'image/png'], ['.svg', 'image/svg+xml'], ['.txt', 'text/plain; charset=utf-8'], + ['.webmanifest', 'application/manifest+json; charset=utf-8'], ['.woff2', 'font/woff2'], ]) diff --git a/tests/appIcons.test.mjs b/tests/appIcons.test.mjs new file mode 100644 index 0000000..9b42d57 --- /dev/null +++ b/tests/appIcons.test.mjs @@ -0,0 +1,157 @@ +import { access, readFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { inflateSync } from 'node:zlib' +import { describe, expect, it } from 'vitest' + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const publicRoot = path.join(projectRoot, 'public') +const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + +async function readProjectFile(relativePath) { + return readFile(path.join(projectRoot, relativePath)) +} + +function pngDimensions(buffer) { + expect(buffer.subarray(0, pngSignature.length)).toEqual(pngSignature) + expect(buffer.subarray(12, 16).toString('ascii')).toBe('IHDR') + return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) } +} + +function paethPredictor(left, above, upperLeft) { + const estimate = left + above - upperLeft + const leftDistance = Math.abs(estimate - left) + const aboveDistance = Math.abs(estimate - above) + const upperLeftDistance = Math.abs(estimate - upperLeft) + if (leftDistance <= aboveDistance && leftDistance <= upperLeftDistance) return left + return aboveDistance <= upperLeftDistance ? above : upperLeft +} + +function decodeRgbaPng(buffer) { + const { width, height } = pngDimensions(buffer) + expect(buffer[24]).toBe(8) + expect(buffer[25]).toBe(6) + + const idatChunks = [] + for (let offset = 8; offset < buffer.length;) { + const length = buffer.readUInt32BE(offset) + const type = buffer.subarray(offset + 4, offset + 8).toString('ascii') + if (type === 'IDAT') idatChunks.push(buffer.subarray(offset + 8, offset + 8 + length)) + offset += 12 + length + } + + const encoded = inflateSync(Buffer.concat(idatChunks)) + const rowLength = width * 4 + const pixels = Buffer.alloc(rowLength * height) + let encodedOffset = 0 + for (let row = 0; row < height; row += 1) { + const filter = encoded[encodedOffset] + expect(filter).toBeLessThanOrEqual(4) + encodedOffset += 1 + const rowOffset = row * rowLength + const previousRowOffset = rowOffset - rowLength + for (let column = 0; column < rowLength; column += 1) { + const left = column >= 4 ? pixels[rowOffset + column - 4] : 0 + const above = row > 0 ? pixels[previousRowOffset + column] : 0 + const upperLeft = row > 0 && column >= 4 ? pixels[previousRowOffset + column - 4] : 0 + const predictor = filter === 1 ? left + : filter === 2 ? above + : filter === 3 ? Math.floor((left + above) / 2) + : filter === 4 ? paethPredictor(left, above, upperLeft) + : 0 + pixels[rowOffset + column] = (encoded[encodedOffset] + predictor) & 0xff + encodedOffset += 1 + } + } + expect(encodedOffset).toBe(encoded.length) + return { width, height, pixels } +} + +describe('application icon assets', () => { + it('keeps the vector favicon self-contained', async () => { + const svg = await readFile(path.join(publicRoot, 'favicon.svg'), 'utf8') + + expect(svg).toContain('viewBox="0 0 64 64"') + expect(svg).toContain('#1d2428') + expect(svg).toContain('stroke="#ffffff"') + expect(svg).not.toMatch(/ { + const { width, height, pixels } = decodeRgbaPng(await readFile(path.join(publicRoot, filename))) + let minimumAlpha = 255 + let maximumAlpha = 0 + let hasLightArtwork = false + for (let offset = 0; offset < pixels.length; offset += 4) { + minimumAlpha = Math.min(minimumAlpha, pixels[offset + 3]) + maximumAlpha = Math.max(maximumAlpha, pixels[offset + 3]) + if (pixels[offset] > 240 && pixels[offset + 1] > 240 && pixels[offset + 2] > 240) hasLightArtwork = true + } + + expect({ width, height }).toEqual({ width: size, height: size }) + expect([minimumAlpha, maximumAlpha]).toEqual(opaque ? [255, 255] : [0, 255]) + expect(hasLightArtwork).toBe(true) + }) + + it('packs 16, 32 and 48 pixel PNG frames into the ICO fallback', async () => { + const ico = await readFile(path.join(publicRoot, 'favicon.ico')) + + expect(ico.readUInt16LE(0)).toBe(0) + expect(ico.readUInt16LE(2)).toBe(1) + expect(ico.readUInt16LE(4)).toBe(3) + + const sizes = [] + for (let index = 0; index < 3; index += 1) { + const entryOffset = 6 + index * 16 + const width = ico[entryOffset] || 256 + const height = ico[entryOffset + 1] || 256 + const length = ico.readUInt32LE(entryOffset + 8) + const imageOffset = ico.readUInt32LE(entryOffset + 12) + const frame = ico.subarray(imageOffset, imageOffset + length) + + expect(height).toBe(width) + expect(imageOffset + length).toBeLessThanOrEqual(ico.length) + expect(pngDimensions(frame)).toEqual({ width, height }) + sizes.push(width) + } + expect(sizes).toEqual([16, 32, 48]) + }) + + it('references complete install metadata and existing icon files', async () => { + const manifest = JSON.parse(await readFile(path.join(publicRoot, 'site.webmanifest'), 'utf8')) + + expect(manifest).toMatchObject({ + name: 'Web Tools', + short_name: 'Web Tools', + start_url: '/', + scope: '/', + display: 'standalone', + background_color: '#ffffff', + theme_color: '#f5f6f7', + }) + expect(manifest.icons).toEqual([ + { src: '/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' }, + { src: '/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' }, + { src: '/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, + ]) + await Promise.all(manifest.icons.map((icon) => access(path.join(publicRoot, icon.src.slice(1))))) + }) + + it('declares modern, legacy and Apple browser icon entry points', async () => { + const html = (await readProjectFile('index.html')).toString('utf8') + + expect(html).toContain('') + expect(html).toContain('') + expect(html).toContain('') + expect(html).toContain('') + }) + + it('includes public assets in container builds', async () => { + expect((await readProjectFile('Dockerfile')).toString('utf8')).toContain('COPY public ./public') + }) +}) diff --git a/tests/server.test.mjs b/tests/server.test.mjs index dc72173..d73ca30 100644 --- a/tests/server.test.mjs +++ b/tests/server.test.mjs @@ -36,6 +36,7 @@ async function createFixtureServer() { await mkdir(staticRoot, { recursive: true }) await writeFile(path.join(staticRoot, 'index.html'), 'Web Tools') await writeFile(path.join(staticRoot, 'app.js'), 'console.log("ok")') + await writeFile(path.join(staticRoot, 'site.webmanifest'), '{}') const server = await createWebToolsServer({ staticRoot, dataDir, catalog, defaults, logger: { error: () => undefined } }) servers.push(server) await new Promise((resolve, reject) => { @@ -78,6 +79,14 @@ describe('HTTP server', () => { expect(missingApi.headers.get('cache-control')).toBe('no-store') }) + it('serves the Web App Manifest with its registered media type', async () => { + const { baseUrl } = await createFixtureServer() + const response = await fetch(`${baseUrl}/site.webmanifest`) + + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe('application/manifest+json; charset=utf-8') + }) + it('creates, reads and updates navigation through the API', async () => { const { baseUrl, dataDir } = await createFixtureServer() const initial = await fetch(`${baseUrl}/api/navigation`)