diff --git a/packages/app/index.html b/packages/app/index.html index 6035771a0f8..e07a15b4121 100644 --- a/packages/app/index.html +++ b/packages/app/index.html @@ -7,10 +7,8 @@ content="width=device-width, initial-scale=1, interactive-widget=resizes-content, viewport-fit=cover" /> OpenCode - - - - + + diff --git a/packages/app/manifest.json b/packages/app/manifest.json new file mode 100644 index 00000000000..91bba66009a --- /dev/null +++ b/packages/app/manifest.json @@ -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" +} diff --git a/packages/app/public/site.webmanifest b/packages/app/public/site.webmanifest deleted file mode 120000 index a116d787962..00000000000 --- a/packages/app/public/site.webmanifest +++ /dev/null @@ -1 +0,0 @@ -../../ui/src/assets/favicon/site.webmanifest \ No newline at end of file diff --git a/packages/app/vite.config.ts b/packages/app/vite.config.ts index a06a8fa4077..e7be5f9b397 100644 --- a/packages/app/vite.config.ts +++ b/packages/app/vite.config.ts @@ -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, diff --git a/packages/app/vite.icons.test.ts b/packages/app/vite.icons.test.ts new file mode 100644 index 00000000000..e55dad870ce --- /dev/null +++ b/packages/app/vite.icons.test.ts @@ -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(/]*>[\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) { + 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) + }), + ) +} diff --git a/packages/app/vite.icons.ts b/packages/app/vite.icons.ts new file mode 100644 index 00000000000..b74d11a4401 --- /dev/null +++ b/packages/app/vite.icons.ts @@ -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`) + }, + }, + } +} diff --git a/packages/app/vite.js b/packages/app/vite.js index 52604b64c22..3c75bbd0227 100644 --- a/packages/app/vite.js +++ b/packages/app/vite.js @@ -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"