mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
feat(app): use channel-specific favicon and PWA icons (#45543)
This commit is contained in:
@@ -7,10 +7,8 @@
|
||||
content="width=device-width, initial-scale=1, interactive-widget=resizes-content, viewport-fit=cover"
|
||||
/>
|
||||
<title>OpenCode</title>
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96-v3.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon-v3.svg" />
|
||||
<link rel="shortcut icon" href="/favicon-v3.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-v3.png" />
|
||||
<link rel="icon" type="image/x-icon" href="%OPENCODE_FAVICON%" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="%OPENCODE_APPLE_TOUCH_ICON%" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<meta name="theme-color" content="#fafafa" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "OpenCode",
|
||||
"short_name": "OpenCode",
|
||||
"id": "/",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
],
|
||||
"theme_color": "#080808",
|
||||
"background_color": "#080808",
|
||||
"display": "standalone"
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
../../ui/src/assets/favicon/site.webmanifest
|
||||
@@ -1,7 +1,8 @@
|
||||
import { sentryVitePlugin } from "@sentry/vite-plugin"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { defineConfig } from "vite"
|
||||
import desktopPlugin from "./vite.js"
|
||||
import desktopPlugin, { channel } from "./vite.js"
|
||||
import { icons } from "./vite.icons"
|
||||
import { serviceWorker } from "./vite.pwa"
|
||||
|
||||
const sentry =
|
||||
@@ -22,7 +23,12 @@ const sentry =
|
||||
: false
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [desktopPlugin, serviceWorker(fileURLToPath(new URL("./dist", import.meta.url))), sentry] as any,
|
||||
plugins: [
|
||||
desktopPlugin,
|
||||
icons(channel),
|
||||
serviceWorker(fileURLToPath(new URL("./dist", import.meta.url))),
|
||||
sentry,
|
||||
] as any,
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
allowedHosts: true,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { fetch } from "bun"
|
||||
import { build, createServer } from "vite"
|
||||
import { icons } from "./vite.icons"
|
||||
import manifest from "./manifest.json" with { type: "json" }
|
||||
|
||||
test.each(["dev", "beta", "prod", "local"])("bundles %s app icons", async (channel) => {
|
||||
const result = await build({
|
||||
root: import.meta.dirname,
|
||||
configFile: false,
|
||||
logLevel: "silent",
|
||||
plugins: [
|
||||
icons(channel),
|
||||
{
|
||||
name: "icons-only-fixture",
|
||||
transformIndexHtml: {
|
||||
order: "pre",
|
||||
handler: (html) => html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/g, ""),
|
||||
},
|
||||
},
|
||||
],
|
||||
build: { write: false, copyPublicDir: false },
|
||||
})
|
||||
if (!("output" in result)) throw new Error("Expected a single build output")
|
||||
|
||||
await check(channel === "local" ? "dev" : channel, async (path) => {
|
||||
const file = result.output.find((file) => `/${file.fileName}` === path)
|
||||
if (file?.type !== "asset") throw new Error(`Missing asset: ${path}`)
|
||||
return typeof file.source === "string" ? new TextEncoder().encode(file.source) : file.source
|
||||
})
|
||||
})
|
||||
|
||||
test.each(["dev", "beta", "prod"])("serves %s app icons", async (channel) => {
|
||||
const server = await createServer({
|
||||
root: import.meta.dirname,
|
||||
configFile: false,
|
||||
logLevel: "silent",
|
||||
plugins: [icons(channel)],
|
||||
optimizeDeps: { noDiscovery: true, include: [] },
|
||||
server: { host: "127.0.0.1", port: 0, hmr: false, preTransformRequests: false, watch: null },
|
||||
})
|
||||
try {
|
||||
await server.listen()
|
||||
const address = server.httpServer?.address()
|
||||
if (!address || typeof address === "string") throw new Error("Expected an HTTP port")
|
||||
|
||||
await check(channel, async (path) => {
|
||||
const response = await fetch(`http://127.0.0.1:${address.port}${path}`)
|
||||
expect(response.status).toBe(200)
|
||||
if (path.endsWith(".webmanifest")) expect(response.headers.get("content-type")).toBe("application/manifest+json")
|
||||
if (path.endsWith(".png")) expect(response.headers.get("content-type")).toBe("image/png")
|
||||
return new Uint8Array(await response.arrayBuffer())
|
||||
})
|
||||
} finally {
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
|
||||
async function check(channel: string, read: (path: string) => Promise<Uint8Array>) {
|
||||
const html = new TextDecoder().decode(await read("/index.html"))
|
||||
const actual: typeof manifest = JSON.parse(new TextDecoder().decode(await read("/site.webmanifest")))
|
||||
expect(actual).toEqual({
|
||||
...manifest,
|
||||
icons: manifest.icons.map((icon) => ({ ...icon, src: `/icons/${channel}${icon.src}` })),
|
||||
})
|
||||
expect(html).toContain(`href="/icons/${channel}/favicon.ico"`)
|
||||
expect(html).toContain(`href="/icons/${channel}/apple-touch-icon.png"`)
|
||||
expect(html).toContain(`href="/site.webmanifest"`)
|
||||
expect(html).not.toContain("%OPENCODE_")
|
||||
|
||||
await Promise.all(
|
||||
Object.entries({
|
||||
"favicon.ico": "icon.ico",
|
||||
"apple-touch-icon.png": "ios/AppIcon-60x60@3x.png",
|
||||
"web-app-manifest-192x192.png": "android/mipmap-xxxhdpi/ic_launcher.png",
|
||||
"web-app-manifest-512x512.png": "icon.png",
|
||||
}).map(async ([name, source]) => {
|
||||
const bytes = await read(`/icons/${channel}/${name}`)
|
||||
expect(bytes).toEqual(await Bun.file(new URL(`../desktop/icons/${channel}/${source}`, import.meta.url)).bytes())
|
||||
if (!name.endsWith(".png")) return
|
||||
const size = name === "apple-touch-icon.png" ? 180 : Number(name.match(/(192|512)/)?.[0])
|
||||
expect(new DataView(bytes.buffer, bytes.byteOffset).getUint32(16)).toBe(size)
|
||||
expect(new DataView(bytes.buffer, bytes.byteOffset).getUint32(20)).toBe(size)
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { readFileSync } from "node:fs"
|
||||
import type { Plugin } from "vite"
|
||||
import manifest from "./manifest.json" with { type: "json" }
|
||||
|
||||
export function icons(channel: string): Plugin {
|
||||
const selected = channel === "beta" || channel === "prod" ? channel : "dev"
|
||||
const prefix = `icons/${selected}`
|
||||
const files = [
|
||||
...Object.entries({
|
||||
"favicon.ico": "icon.ico",
|
||||
"apple-touch-icon.png": "ios/AppIcon-60x60@3x.png",
|
||||
"web-app-manifest-192x192.png": "android/mipmap-xxxhdpi/ic_launcher.png",
|
||||
"web-app-manifest-512x512.png": "icon.png",
|
||||
}).map(([name, source]) => ({
|
||||
fileName: `${prefix}/${name}`,
|
||||
source: readFileSync(new URL(`../desktop/icons/${selected}/${source}`, import.meta.url)),
|
||||
type: name.endsWith(".ico") ? "image/x-icon" : "image/png",
|
||||
})),
|
||||
{
|
||||
fileName: "site.webmanifest",
|
||||
source: JSON.stringify({
|
||||
...manifest,
|
||||
icons: manifest.icons.map((icon) => ({ ...icon, src: `/${prefix}${icon.src}` })),
|
||||
}),
|
||||
type: "application/manifest+json",
|
||||
},
|
||||
]
|
||||
|
||||
return {
|
||||
name: "opencode-app:icons",
|
||||
generateBundle() {
|
||||
files.forEach((file) => this.emitFile({ type: "asset", fileName: file.fileName, source: file.source }))
|
||||
},
|
||||
configureServer(server) {
|
||||
server.middlewares.use((request, response, next) => {
|
||||
const file = files.find((file) => `/${file.fileName}` === request.url?.split("?")[0])
|
||||
if (!file) return next()
|
||||
response.setHeader("Content-Type", file.type)
|
||||
response.end(file.source)
|
||||
})
|
||||
},
|
||||
transformIndexHtml: {
|
||||
order: "pre",
|
||||
handler(html) {
|
||||
return html
|
||||
.replace("%OPENCODE_FAVICON%", `/${prefix}/favicon.ico`)
|
||||
.replace("%OPENCODE_APPLE_TOUCH_ICON%", `/${prefix}/apple-touch-icon.png`)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ if (tailwindGenerate && typeof tailwindHotUpdate === "function") {
|
||||
}
|
||||
}
|
||||
|
||||
const channel = (() => {
|
||||
export const channel = (() => {
|
||||
const raw = process.env.OPENCODE_CHANNEL
|
||||
if (raw === "local" || raw === "dev" || raw === "beta" || raw === "prod") return raw
|
||||
if (process.env.OPENCODE_CHANNEL === "latest") return "prod"
|
||||
|
||||
Reference in New Issue
Block a user