mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
6d75fac69e
Adds a microphone button beside the composer. Click to record, click to stop; the audio is resampled to 16 kHz mono PCM16 WAV, posted to the local server and transcribed by a SenseVoice worker process, and the text lands in the draft without being sent. If the draft changed or an IME is composing, the result is held behind an insert button instead of overwriting the user's text. Server: a small provider registry behind /api/voice/* (catalog, preferences, prepare/cancel/status/remove, transcribe). The engine and model are downloaded at runtime to <config>/cc-haha/voice with pinned sha256/sha512, HuggingFace plus hf-mirror and npm plus npmmirror, HTTP Range resume, automatic retry after interruptions, and a partial file kept across cancels. Recognition runs in a separate worker process (sidecar --voice-worker), started on demand and reclaimed when idle. Desktop: an independent Voice input settings tab with enable switch, model download progress and resume, language, microphone selection and a transcription test with a live waveform. Uses the shared Dropdown/Card/Button components; adds a danger-ghost Button variant. Preferences live in desktop-ui.json (schemaVersion 6); the microphone device id stays in localStorage. Electron: main-window media permission handler limited to app pages, main frame and audio only, plus the audio-input entitlement and NSMicrophoneUsageDescription. Scope: Electron desktop only. Not verified on Windows or Linux, with a real microphone, or in a signed and notarized package.
233 lines
11 KiB
HTML
233 lines
11 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<!--
|
|
Conservative CSP: hardens default/object/base-uri without breaking the app.
|
|
script/style keep 'unsafe-inline' + 'unsafe-eval' on purpose - Emotion CSS-in-JS
|
|
injects runtime <style> tags, the startup watchdog below is inline, and shiki/
|
|
mermaid/Vite rely on eval. connect-src stays permissive (localhost sidecar + ws +
|
|
https) so renderer fetches and dev HMR keep working. Tighten only after verifying
|
|
on a packaged build.
|
|
-->
|
|
<meta
|
|
http-equiv="Content-Security-Policy"
|
|
content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob: https: http://127.0.0.1:* http://localhost:*; connect-src 'self' https: http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:*; worker-src 'self' blob:; media-src 'self' blob:; object-src 'none'; base-uri 'self'"
|
|
/>
|
|
<!--
|
|
`viewport-fit=cover` MUST live here, in the markup. iOS WebKit reads the
|
|
viewport meta once while parsing the document; rewriting it later from JS
|
|
(touchH5.ts does, to pin the scale) never re-runs that decision. Without
|
|
cover the page is laid out inside the safe area, every
|
|
env(safe-area-inset-*) resolves to 0px, and the browser paints its own
|
|
gray bar behind the status bar instead of the app's own background.
|
|
-->
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
<!--
|
|
Colors the iOS status bar / Android address bar. Kept in step with the
|
|
resolved palette by the theme script below and by applyTheme() at runtime,
|
|
so a dark palette does not sit under light browser chrome. Safari only
|
|
guesses from the page otherwise, and guesses gray here.
|
|
-->
|
|
<meta name="theme-color" content="#FFFFFF" />
|
|
<title>Claude Code Haha</title>
|
|
<!--
|
|
The Electron window draws its own chrome, but this same document is what
|
|
the H5 remote client loads in a phone browser, where a missing favicon
|
|
means a blank tab and a screenshot for "add to home screen".
|
|
-->
|
|
<link rel="icon" href="/app-icon.png" type="image/png" />
|
|
<!-- Fonts are self-hosted in /fonts/ and declared via @font-face in globals.css -->
|
|
<script>
|
|
if (new URLSearchParams(window.location.search).get('petWindow') === '1') {
|
|
document.documentElement.dataset.windowKind = 'pet'
|
|
}
|
|
</script>
|
|
<script>
|
|
/*
|
|
Resolve the theme synchronously, before any stylesheet is parsed.
|
|
The app bundle loads as a module and only reaches initializeTheme()
|
|
after its dynamic imports resolve, which is long enough for a dark
|
|
mode user to get flashed by a white window on every launch.
|
|
|
|
Mirrors resolveAppliedTheme() in src/theme/systemAppearance.ts;
|
|
src/theme/bootstrapScript.test.ts checks the two agree.
|
|
*/
|
|
(function () {
|
|
try {
|
|
var read = function (key) {
|
|
try { return window.localStorage.getItem(key) } catch (_) { return null }
|
|
}
|
|
// The six palettes, split by ground. Keep in step with
|
|
// LIGHT_THEME_MODES / DARK_THEME_MODES in src/types/settings.ts.
|
|
var LIGHT = ['white', 'paper', 'warm-classic', 'celadon']
|
|
var DARK = ['dark', 'ink-blue']
|
|
var has = function (list, value) { return list.indexOf(value) !== -1 }
|
|
var isDark = function (value) { return has(DARK, value) }
|
|
// Mirrors THEME_BACKGROUNDS in src/theme/systemAppearance.ts; the
|
|
// <style> block below and bootstrapScript.test.ts hold all three
|
|
// copies to the same values.
|
|
var BACKGROUND = {
|
|
white: '#FFFFFF',
|
|
paper: '#FBF9F4',
|
|
'warm-classic': '#F6F0E1',
|
|
celadon: '#F5F8F5',
|
|
dark: '#201D17',
|
|
'ink-blue': '#1A1D24'
|
|
}
|
|
var paintBrowserChrome = function (value) {
|
|
var meta = document.querySelector('meta[name="theme-color"]')
|
|
if (meta && BACKGROUND[value]) meta.setAttribute('content', BACKGROUND[value])
|
|
}
|
|
|
|
var stored = read('cc-haha-theme')
|
|
var theme = (has(LIGHT, stored) || isDark(stored)) ? stored : 'white'
|
|
|
|
var followRaw = read('cc-haha-follow-system-theme')
|
|
// Unset means: new install follows the system, existing install
|
|
// (recognised by having a stored theme) keeps its fixed pick.
|
|
var follow = followRaw === '1' || (followRaw !== '0' && stored === null)
|
|
|
|
if (follow) {
|
|
var storedLight = read('cc-haha-light-theme')
|
|
var lightTheme = has(LIGHT, storedLight)
|
|
? storedLight
|
|
: (has(LIGHT, theme) ? theme : 'white')
|
|
var storedDark = read('cc-haha-dark-theme')
|
|
var darkTheme = isDark(storedDark) ? storedDark : (isDark(theme) ? theme : 'dark')
|
|
var prefersDark = false
|
|
try {
|
|
// Some WebViews reject the query outright. Treating that as
|
|
// "light" matches getSystemAppearance() in the app bundle.
|
|
prefersDark = !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
|
} catch (_) { /* no color-scheme signal available */ }
|
|
theme = prefersDark ? darkTheme : lightTheme
|
|
}
|
|
|
|
document.documentElement.setAttribute('data-theme', theme)
|
|
document.documentElement.style.colorScheme = isDark(theme) ? 'dark' : 'light'
|
|
paintBrowserChrome(theme)
|
|
} catch (_) {
|
|
// `theme` is function-scoped, so a failure after it was resolved
|
|
// still applies the user's stored choice rather than flashing white.
|
|
document.documentElement.setAttribute('data-theme', theme || 'white')
|
|
document.documentElement.style.colorScheme =
|
|
(theme === 'dark' || theme === 'ink-blue') ? 'dark' : 'light'
|
|
}
|
|
})()
|
|
</script>
|
|
<style>
|
|
/* Paint the resolved theme's ground before globals.css arrives, so the
|
|
window never shows a palette it is about to replace. Values track
|
|
--cc-bg per theme in src/theme/globals.css.
|
|
The bare `html` rule only applies if the script above never ran (a CSP
|
|
that strips it would remove the element entirely, so its try/catch is
|
|
no help there) — it matches the default palette in globals.css rather
|
|
than a fixed white, so that degraded case stays self-consistent. */
|
|
html { background: #FFFFFF; }
|
|
html[data-theme="white"] { background: #FFFFFF; }
|
|
html[data-theme="paper"] { background: #FBF9F4; }
|
|
html[data-theme="warm-classic"] { background: #F6F0E1; }
|
|
html[data-theme="celadon"] { background: #F5F8F5; }
|
|
html[data-theme="dark"] { background: #201D17; }
|
|
html[data-theme="ink-blue"] { background: #1A1D24; }
|
|
html[data-window-kind="pet"],
|
|
html[data-window-kind="pet"] body,
|
|
html[data-window-kind="pet"] #root { background: transparent !important; }
|
|
html, body, #root { min-height: 100%; margin: 0; }
|
|
</style>
|
|
<script>
|
|
(function () {
|
|
var bootDeadlineMs = 8000
|
|
|
|
function summarize(value) {
|
|
if (!value) return 'Unknown startup error'
|
|
if (typeof value === 'string') return value
|
|
if (value.message) return value.message
|
|
try {
|
|
return JSON.stringify(value)
|
|
} catch (_) {
|
|
return String(value)
|
|
}
|
|
}
|
|
|
|
function renderStartupError(reason) {
|
|
if (window.__CC_HAHA_BOOTSTRAPPED__) return
|
|
var root = document.getElementById('root')
|
|
if (!root) return
|
|
|
|
var summary = summarize(reason)
|
|
var details = [
|
|
summary,
|
|
'',
|
|
'URL: ' + window.location.href,
|
|
'User Agent: ' + navigator.userAgent,
|
|
'Time: ' + new Date().toISOString()
|
|
].join('\n')
|
|
|
|
root.innerHTML = ''
|
|
var shell = document.createElement('main')
|
|
shell.style.cssText = [
|
|
'box-sizing:border-box',
|
|
'min-height:100vh',
|
|
'padding:32px',
|
|
'background:#fff',
|
|
'color:#151515',
|
|
'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
|
|
'display:flex',
|
|
'align-items:center',
|
|
'justify-content:center'
|
|
].join(';')
|
|
|
|
var panel = document.createElement('section')
|
|
panel.style.cssText = 'width:min(760px,100%);border:1px solid #d8d8d8;border-radius:8px;padding:24px;background:#fff'
|
|
|
|
var title = document.createElement('h1')
|
|
title.textContent = 'Desktop startup failed'
|
|
title.style.cssText = 'margin:0 0 8px;font-size:20px;line-height:1.3;font-weight:700;color:#111'
|
|
|
|
var body = document.createElement('p')
|
|
body.textContent = 'The app could not finish booting. Keep this text when reporting the issue.'
|
|
body.style.cssText = 'margin:0 0 16px;font-size:14px;line-height:1.6;color:#555'
|
|
|
|
var pre = document.createElement('pre')
|
|
pre.textContent = details
|
|
pre.style.cssText = 'box-sizing:border-box;max-height:360px;overflow:auto;white-space:pre-wrap;word-break:break-word;margin:0;padding:14px;border-radius:6px;background:#f6f6f6;color:#222;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace'
|
|
|
|
panel.appendChild(title)
|
|
panel.appendChild(body)
|
|
panel.appendChild(pre)
|
|
shell.appendChild(panel)
|
|
root.appendChild(shell)
|
|
}
|
|
|
|
window.__CC_HAHA_SHOW_STARTUP_ERROR__ = renderStartupError
|
|
|
|
window.addEventListener('error', function (event) {
|
|
var target = event.target
|
|
if (target && target !== window) {
|
|
var tagName = target.tagName
|
|
if (tagName === 'SCRIPT' || tagName === 'LINK') {
|
|
renderStartupError('Startup resource failed to load: ' + (target.src || target.href || tagName))
|
|
}
|
|
return
|
|
}
|
|
renderStartupError(event.message || event.error || 'Startup script error')
|
|
}, true)
|
|
|
|
window.addEventListener('unhandledrejection', function (event) {
|
|
renderStartupError(event.reason || 'Unhandled startup promise rejection')
|
|
})
|
|
|
|
window.setTimeout(function () {
|
|
renderStartupError('Desktop app did not finish bootstrapping within ' + bootDeadlineMs + 'ms. This usually means the WebView could not execute the app bundle or startup code stopped before React mounted.')
|
|
}, bootDeadlineMs)
|
|
})()
|
|
</script>
|
|
</head>
|
|
<body>
|
|
<div id="root"></div>
|
|
<script type="module" src="/src/main.tsx"></script>
|
|
</body>
|
|
</html>
|