增加完整的应用图标支持

This commit is contained in:
mid
2026-08-08 19:41:43 +08:00
parent 41398fcf3d
commit de8c6541e5
16 changed files with 227 additions and 1 deletions
+2
View File
@@ -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
+4 -1
View File
@@ -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
+1
View File
@@ -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
+3
View File
@@ -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
+4
View File
@@ -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,不启用离线缓存、安装提示或启动画面,避免部署更新后继续加载过期构建。
## 核心功能契约
+4
View File
@@ -5,6 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f5f6f7" />
<meta name="description" content="浏览器本地运行的个人 Web 工具箱" />
<link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" sizes="any" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" />
<link rel="manifest" href="/site.webmanifest" />
<title>Web Tools</title>
</head>
<body>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect x="2" y="2" width="60" height="60" rx="14" fill="#1d2428" />
<path
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z"
transform="translate(10 10) scale(1.833333)"
fill="none"
stroke="#ffffff"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>

After

Width:  |  Height:  |  Size: 514 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+30
View File
@@ -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"
}
]
}
+1
View File
@@ -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'],
])
+157
View File
@@ -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(/<script\b|\bhref\s*=|\burl\s*\(/i)
})
it.each([
{ filename: 'apple-touch-icon.png', size: 180, opaque: true },
{ filename: 'icon-192.png', size: 192, opaque: false },
{ filename: 'icon-512.png', size: 512, opaque: false },
{ filename: 'icon-maskable-512.png', size: 512, opaque: true },
])('validates $filename dimensions, pixels and transparency', async ({ filename, size, opaque }) => {
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('<link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48" />')
expect(html).toContain('<link rel="icon" href="/favicon.svg" type="image/svg+xml" sizes="any" />')
expect(html).toContain('<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" />')
expect(html).toContain('<link rel="manifest" href="/site.webmanifest" />')
})
it('includes public assets in container builds', async () => {
expect((await readProjectFile('Dockerfile')).toString('utf8')).toContain('COPY public ./public')
})
})
+9
View File
@@ -36,6 +36,7 @@ async function createFixtureServer() {
await mkdir(staticRoot, { recursive: true })
await writeFile(path.join(staticRoot, 'index.html'), '<!doctype html><title>Web Tools</title>')
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`)