Compare commits

...
Author SHA1 Message Date
Brendonovich facc7c5836 fix(app): prevent iOS input zoom without resizing controls 2026-09-11 13:59:22 +00:00
8 changed files with 97 additions and 26 deletions
+1
View File
@@ -6,6 +6,7 @@
name="viewport"
content="width=device-width, initial-scale=1, interactive-widget=resizes-content, viewport-fit=cover"
/>
<script id="oc-ios-input-zoom-script"></script>
<title>OpenCode</title>
<link rel="icon" type="image/x-icon" href="%OPENCODE_FAVICON%" />
<link rel="apple-touch-icon" sizes="180x180" href="%OPENCODE_APPLE_TOUCH_ICON%" />
+15
View File
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"
import solidPlugin from "vite-plugin-solid"
import tailwindcss from "@tailwindcss/vite"
import { fileURLToPath } from "url"
import { IOS_INPUT_ZOOM_SCRIPT } from "@opencode/ui/ios-input-zoom"
const theme = fileURLToPath(new URL("./public/oc-theme-preload.js", import.meta.url))
const themeScript = readFileSync(theme, "utf8")
@@ -50,6 +51,13 @@ export default [
}
},
},
{
name: "opencode-desktop:ios-input-zoom",
transformIndexHtml: {
order: "pre",
handler: inlineIosInputZoom,
},
},
{
name: "opencode-desktop:theme-preload",
transformIndexHtml: {
@@ -61,6 +69,13 @@ export default [
solidPlugin(),
]
export function inlineIosInputZoom(html) {
return html.replace(
/<script id="oc-ios-input-zoom-script"><\/script>/,
`<script id="oc-ios-input-zoom-script">${IOS_INPUT_ZOOM_SCRIPT}</script>`,
)
}
export function inlineThemePreload(html) {
return html.replace(
/<script id="oc-theme-preload-script" src="(?:\.\/|\/)oc-theme-preload\.js"><\/script>/,
+5 -5
View File
@@ -54,15 +54,15 @@ function isRouteNotFound(error: unknown) {
return error instanceof HttpServerError.HttpServerError && error.reason._tag === "RouteNotFound"
}
function csp(hash = "") {
return `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; media-src 'self' data:; connect-src * data: blob:`
function csp(hashes: string[] = []) {
return `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hashes.map((hash) => ` 'sha256-${hash}'`).join("")}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; media-src 'self' data:; connect-src * data: blob:`
}
function cspForHtml(body: string) {
const match = body.match(
/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(["'])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
const scripts = body.matchAll(
/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(["'])oc-(?:theme-preload|ios-input-zoom)-script\1[^>]*>([\s\S]*?)<\/script>/gi,
)
return csp(match ? createHash("sha256").update(match[2]).digest("base64") : "")
return csp(Array.from(scripts, (match) => createHash("sha256").update(match[2]).digest("base64")))
}
export * as WebUi from "./web-ui"
+12 -2
View File
@@ -2,8 +2,9 @@ import { NodeFileSystem, NodeHttpServer } from "@effect/platform-node"
import { afterAll, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { HttpServer, HttpServerError, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { createServer } from "node:http"
import { createHash } from "node:crypto"
import { mkdtemp, rm, writeFile } from "node:fs/promises"
import { createServer } from "node:http"
import { tmpdir } from "node:os"
import path from "node:path"
import { WebUi } from "../src/services/web-ui"
@@ -15,7 +16,11 @@ describe("web UI", () => {
test("falls back from API routes to assets and the SPA index", async () => {
const index = path.join(root, "index.html")
const asset = path.join(root, "app.js")
await writeFile(index, "<html><body>embedded</body></html>")
const inline = ["theme()", "iosInputZoom()"]
await writeFile(
index,
`<html><head><script id="oc-theme-preload-script">${inline[0]}</script><script id="oc-ios-input-zoom-script">${inline[1]}</script></head><body>embedded</body></html>`,
)
await writeFile(asset, "console.log('embedded')")
const assets = {
"index.html": await Bun.file(index).text(),
@@ -83,6 +88,11 @@ describe("web UI", () => {
expect(yield* Effect.promise(() => fallback.text())).toContain("embedded")
expect(fallback.headers.get("content-security-policy")).toContain("default-src 'self'")
expect(fallback.headers.get("content-security-policy")).toContain("connect-src * data: blob:")
inline.forEach((script) =>
expect(fallback.headers.get("content-security-policy")).toContain(
`'sha256-${createHash("sha256").update(script).digest("base64")}'`,
),
)
const dotted = yield* Effect.promise(() => fetch(`${origin}/workspace/example.js`))
expect(dotted.status).toBe(200)
+2
View File
@@ -1,6 +1,7 @@
// @refresh reload
import { createHandler, StartServer } from "@solidjs/start/server"
import { getRequestEvent } from "solid-js/web"
import { IOS_INPUT_ZOOM_SCRIPT } from "@opencode/ui/ios-input-zoom"
export default createHandler(() => (
<StartServer
@@ -23,6 +24,7 @@ export default createHandler(() => (
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script id="oc-ios-input-zoom-script" innerHTML={IOS_INPUT_ZOOM_SCRIPT} />
<title>OpenCode</title>
<meta name="theme-color" content="#F8F7F7" />
{assets}
+1
View File
@@ -71,6 +71,7 @@
"./*": "./src/components/*.tsx",
"./i18n/*": "./src/i18n/*.ts",
"./hooks": "./src/hooks/index.ts",
"./ios-input-zoom": "./src/ios-input-zoom.ts",
"./context": "./src/context/index.ts",
"./context/*": "./src/context/*.tsx",
"./storybook/scaffold": "./src/storybook/scaffold.tsx",
+61
View File
@@ -0,0 +1,61 @@
export const IOS_INPUT_ZOOM_SCRIPT = `(function () {
if (!/iPhone|iPod/.test(navigator.userAgent)) return;
var root = document.documentElement;
if (root.hasAttribute("data-ios-input-zoom")) return;
root.setAttribute("data-ios-input-zoom", "");
var OWN = "data-ios-input-zoom-meta";
var own = null;
function sources() {
var list = document.querySelectorAll('meta[name="viewport"]');
var out = [];
for (var i = 0; i < list.length; i++) {
if (!list[i].hasAttribute(OWN)) out.push(list[i]);
}
return out;
}
function derive(content) {
var parts = content.split(",");
var kept = [];
for (var i = 0; i < parts.length; i++) {
var part = parts[i].trim();
if (part && part.split("=")[0].trim() !== "maximum-scale") kept.push(part);
}
kept.push("maximum-scale=1");
return kept.join(", ");
}
function apply() {
var metas = sources();
for (var i = 0; i < metas.length; i++) {
if (metas[i].getAttribute("data-ios-input-zoom") === "keep") {
if (own && own.parentNode) {
own.parentNode.removeChild(own);
metas[i].setAttribute("content", metas[i].getAttribute("content") || "");
}
return;
}
}
var last = metas.length ? metas[metas.length - 1] : null;
var content = derive(last ? last.getAttribute("content") || "" : "");
if (!own) {
own = document.createElement("meta");
own.setAttribute("name", "viewport");
own.setAttribute(OWN, "");
}
if (document.head.lastElementChild !== own) document.head.appendChild(own);
own.setAttribute("content", content);
}
apply();
new MutationObserver(function (records) {
for (var i = 0; i < records.length; i++) {
if (records[i].target !== own) {
apply();
return;
}
}
}).observe(document.head, {
attributeFilter: ["content", "data-ios-input-zoom"],
attributes: true,
childList: true,
subtree: true,
});
})();`
-19
View File
@@ -388,22 +388,3 @@ input:where([type="button"], [type="reset"], [type="submit"]),
[hidden]:where(:not([hidden="until-found"])) {
display: none !important;
}
/*
Prevent iOS Safari from auto-zooming on input focus.
iOS WebKit zooms on any input with font-size < 16px as an accessibility feature.
*/
@media (hover: none) and (pointer: coarse) {
input,
select,
textarea,
[contenteditable="true"] {
font-size: 16px !important;
}
input::placeholder,
textarea::placeholder {
font-size: 13px;
}
}