diff --git a/CHANGELOG.md b/CHANGELOG.md
index 61f1bcae..36971d72 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,11 +5,28 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [0.9.12] - 2026-07-21
+
+### Added
+
+- 📱 **Unsaved file edits now get a proper warning.** Closing an edited file tab now shows Computer's own confirmation dialog, so mobile users and desktop users can choose to keep editing or close without saving.
+- 🎛️ **Browser streaming now has an encoder setting.** Admins can leave streaming on Auto, force hardware encoding, or force software encoding from Admin > Web > Streaming quality > Advanced.
+
+### Changed
+
+- 🌍 **More of Computer is translated.** Admin screens, chat controls, diff settings, file editor labels, skill controls, and planning prompts now use the same language as the rest of the app across all supported languages.
+- 📜 **The license is clearer for hosted use.** The old multi-tenant hosting restriction was removed while the attribution requirements remain in place.
+
+### Fixed
+
+- 🌐 **Browser tabs work on servers without video hardware.** Streaming now defaults to Auto, so headless servers, virtual machines, and other GPU-light hosts can fall back to software encoding instead of failing to open the browser stream.
+- 🤖 **Claude Code appears when it comes from the Claude desktop app.** If Claude Code was installed through Claude desktop on macOS or Windows, Computer can now find it automatically and show it as an available coding agent.
+
## [0.9.11] - 2026-07-19
### Added
-- 🤖 **Gemini is now its own coding agent.** Choose Gemini as an agent and Computer will start the Gemini CLI with its Google sign-in flow, keep the chat resumable, and handle image attachments like the other coding agents.
+- 🤖 **Gemini is now its own coding agent.** Choose Gemini as an agent and Computer will start it with its Google sign-in flow, keep the chat resumable, and handle image attachments like the other coding agents.
### Fixed
diff --git a/LICENSE b/LICENSE
index bdc1bfd9..2cfb6da4 100644
--- a/LICENSE
+++ b/LICENSE
@@ -18,11 +18,6 @@ Attribution elements are any elements identifying the origin of the software,
such as logos, icons, visual marks, the product name, copyright notices,
attribution statements, and about screens or version information.
-Multi-Tenant Use
-
-You may not host, deploy, manage, or otherwise facilitate multiple instances
-of the software or its derivative works as a service.
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
diff --git a/cptr/frontend/src/lib/components/Admin/CreateBotModal.svelte b/cptr/frontend/src/lib/components/Admin/CreateBotModal.svelte
index 52fe31a2..1796c4df 100644
--- a/cptr/frontend/src/lib/components/Admin/CreateBotModal.svelte
+++ b/cptr/frontend/src/lib/components/Admin/CreateBotModal.svelte
@@ -78,7 +78,7 @@
try {
verifyResult = await verifyBotToken(platform, token.trim());
} catch (e: any) {
- verifyResult = { ok: false, error: e.message || 'Verification failed' };
+ verifyResult = { ok: false, error: e.message || $t('messaging.verificationFailed') };
} finally {
verifying = false;
}
@@ -149,7 +149,7 @@
- Token {#if bot}({bot.token_masked}) {/if}
+ {$t('messaging.token')}
+ {#if bot}({bot.token_masked}) {/if}
- Provider Type
+ {$t('connections.providerType')}
- Default
+ {$t('general.default')}
llama.cpp
{/if}
diff --git a/cptr/frontend/src/lib/components/Admin/EditConnectionModal.svelte b/cptr/frontend/src/lib/components/Admin/EditConnectionModal.svelte
index e208414b..d124e598 100644
--- a/cptr/frontend/src/lib/components/Admin/EditConnectionModal.svelte
+++ b/cptr/frontend/src/lib/components/Admin/EditConnectionModal.svelte
@@ -156,18 +156,15 @@
{$t('connections.responses')}
-
- Provider Type
+
+ {$t('connections.providerType')}
- Default
+ {$t('general.default')}
llama.cpp
{/if}
diff --git a/cptr/frontend/src/lib/components/Admin/Skills.svelte b/cptr/frontend/src/lib/components/Admin/Skills.svelte
index 3e301a72..f6d9600a 100644
--- a/cptr/frontend/src/lib/components/Admin/Skills.svelte
+++ b/cptr/frontend/src/lib/components/Admin/Skills.svelte
@@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import { getAdminConfig, updateConfig } from '$lib/apis/admin';
+ import { t } from '$lib/i18n';
import ToggleSwitch from '$lib/components/common/ToggleSwitch.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
@@ -23,7 +24,7 @@
config['skills.background_review_enabled'] !== 'false';
reviewInterval = Number(config['skills.review_interval_turns']) || 10;
} catch {
- toast.error('Failed to load config');
+ toast.error($t('admin.failedToLoadConfig'));
}
loading = false;
});
@@ -37,9 +38,9 @@
'skills.background_review_enabled': backgroundReview,
'skills.review_interval_turns': Math.max(1, Number(reviewInterval) || 10)
});
- toast.success('Saved');
+ toast.success($t('settings.saved'));
} catch {
- toast.error('Failed to save');
+ toast.error($t('admin.failedToSave'));
} finally {
saving = false;
}
@@ -51,12 +52,16 @@
{:else}
diff --git a/cptr/frontend/src/lib/components/Admin/Web.svelte b/cptr/frontend/src/lib/components/Admin/Web.svelte
index a399ea3c..2543a872 100644
--- a/cptr/frontend/src/lib/components/Admin/Web.svelte
+++ b/cptr/frontend/src/lib/components/Admin/Web.svelte
@@ -51,7 +51,10 @@
let firecrawlBaseUrl = $state('https://api.firecrawl.dev');
let browserUseApiKey = $state('');
let browserUseBaseUrl = $state('https://api.browser-use.com');
+ type BrowserEncoderHardwareAcceleration = 'no-preference' | 'prefer-hardware' | 'prefer-software';
let browserQualityDefault = $state<'low' | 'balanced' | 'crisp'>('balanced');
+ let browserEncoderHardwareAcceleration =
+ $state('no-preference');
let browserQualityProfiles = $state({
low: { bitrate: 3000000, frame_rate: 15 },
balanced: { bitrate: 6000000, frame_rate: 24 },
@@ -118,6 +121,14 @@
}
browserQualityMaxResolution = Number(config['browser.quality.max_resolution']) || 1080;
browserQualityMaxBitrate = Number(config['browser.quality.max_bitrate']) || 12000000;
+ if (
+ config['browser.encoder.hardware_acceleration'] === 'prefer-hardware' ||
+ config['browser.encoder.hardware_acceleration'] === 'prefer-software'
+ ) {
+ browserEncoderHardwareAcceleration = config[
+ 'browser.encoder.hardware_acceleration'
+ ] as BrowserEncoderHardwareAcceleration;
+ }
} catch {
toast.error($t('admin.failedToLoadConfig'));
}
@@ -159,6 +170,7 @@
'browser.browser_use_api_key': browserUseApiKey,
'browser.browser_use_base_url': browserUseBaseUrl,
'browser.quality.default': browserQualityDefault,
+ 'browser.encoder.hardware_acceleration': browserEncoderHardwareAcceleration,
'browser.quality.profiles': browserQualityProfiles,
'browser.quality.max_resolution': browserQualityMaxResolution,
'browser.quality.max_bitrate': browserQualityMaxBitrate
@@ -571,7 +583,9 @@
class="h-7 w-24 rounded-lg border border-gray-200 bg-gray-100 px-2 text-xs text-gray-700 outline-none dark:border-white/8 dark:bg-white/6 dark:text-gray-300"
/>
-
+
@@ -587,6 +601,30 @@
class="h-7 w-24 rounded-lg border border-gray-200 bg-gray-100 px-2 text-xs text-gray-700 outline-none dark:border-white/8 dark:bg-white/6 dark:text-gray-300"
/>
+
+
+
{$t('admin.browserEncoderHardwareAcceleration')}
+
+ {$t('admin.browserEncoderHardwareAccelerationHint')}
+
+
+
+ {$t('admin.browserEncoderAccelerationAuto')}
+ {$t('admin.browserEncoderAccelerationHardware')}
+ {$t('admin.browserEncoderAccelerationSoftware')}
+
+
{:else}
diff --git a/cptr/frontend/src/lib/components/ChromeBrowser.svelte b/cptr/frontend/src/lib/components/ChromeBrowser.svelte
index ea599393..4c4c3d0a 100644
--- a/cptr/frontend/src/lib/components/ChromeBrowser.svelte
+++ b/cptr/frontend/src/lib/components/ChromeBrowser.svelte
@@ -1,5 +1,6 @@
+
+
+
+{#if $confirmDialog}
+
+ settleConfirm(false)}
+ onkeydown={() => {}}
+ >
+
+
event.stopPropagation()}
+ onkeydown={() => {}}
+ >
+
+
+ {$confirmDialog.title}
+
+
+ {$confirmDialog.message}
+
+
+
+
+ settleConfirm(false)}
+ >
+ {$confirmDialog.cancelLabel}
+
+ settleConfirm(true)}
+ >
+ {$confirmDialog.confirmLabel}
+
+
+
+
+{/if}
+
+
diff --git a/cptr/frontend/src/lib/components/DiffSettingsMenu.svelte b/cptr/frontend/src/lib/components/DiffSettingsMenu.svelte
index a67a1908..e115d657 100644
--- a/cptr/frontend/src/lib/components/DiffSettingsMenu.svelte
+++ b/cptr/frontend/src/lib/components/DiffSettingsMenu.svelte
@@ -1,5 +1,6 @@
{#if loading}
- Loading...
+ {$t('common.loading')}
{:else if error}
{error}
{:else if kind === 'image'}
diff --git a/cptr/frontend/src/lib/components/chat/ChatInput.svelte b/cptr/frontend/src/lib/components/chat/ChatInput.svelte
index 884cb828..e7458b6d 100644
--- a/cptr/frontend/src/lib/components/chat/ChatInput.svelte
+++ b/cptr/frontend/src/lib/components/chat/ChatInput.svelte
@@ -1177,9 +1177,9 @@
{#if slashCommandIds.includes('compact')}
- List skills
+ {$t('chat.commandListSkills')}
/skills:list
@@ -1359,9 +1359,9 @@
{#if slashCommandIds.includes('skills:create')}
- Create skill
+ {$t('chat.commandCreateSkill')}
/skills:create
{/if}
{#if slashSkillSuggestions.length > 0}
- Skills
+
+ {$t('chat.skills')}
+
{#each slashSkillSuggestions as skill (skill.id)}
import type { SkillInfo } from '$lib/apis/skills';
+ import { t } from '$lib/i18n';
import Modal from '../Modal.svelte';
import Icon from '../Icon.svelte';
@@ -22,10 +23,10 @@
onclick={onclose}
>
- Close
+ {$t('common.close')}
- Skills
+ {$t('chat.skills')}
@@ -39,7 +40,7 @@
{item.name}
- {item.managed ? 'managed' : 'read-only'}
+ {item.managed ? $t('chat.skillManaged') : $t('chat.skillReadOnly')}
@@ -49,7 +50,9 @@
{/each}
{:else}
- No skills found
+
+ {$t('chat.noSkillsFound')}
+
{/if}
diff --git a/cptr/frontend/src/lib/components/chat/ToolCallCollapsible.svelte b/cptr/frontend/src/lib/components/chat/ToolCallCollapsible.svelte
index 155b215d..14a96f2f 100644
--- a/cptr/frontend/src/lib/components/chat/ToolCallCollapsible.svelte
+++ b/cptr/frontend/src/lib/components/chat/ToolCallCollapsible.svelte
@@ -235,8 +235,8 @@
{question.question}
- {answer || 'No answer recorded'}{answer === question.options?.[0]?.label
- ? ' (Recommended)'
+ {answer || $t('chat.noAnswerRecorded')}{answer === question.options?.[0]?.label
+ ? ` (${$t('chat.recommended')})`
: ''}
diff --git a/cptr/frontend/src/lib/components/chat/UserMessage.svelte b/cptr/frontend/src/lib/components/chat/UserMessage.svelte
index 82757f21..fc01c05a 100644
--- a/cptr/frontend/src/lib/components/chat/UserMessage.svelte
+++ b/cptr/frontend/src/lib/components/chat/UserMessage.svelte
@@ -134,7 +134,7 @@
aria-expanded={timerExpanded}
onclick={() => (timerExpanded = !timerExpanded)}
>
- Timer
+ {$t('chat.timer')}
{content}
{
const ws = get(currentWorkspace);
- if (!ws) return;
+ if (!ws) return false;
// Find the group containing this tab
const gid =
groupId ?? ws.groups.find((g) => g.tabs.some((t) => t.id === tabId))?.id ?? ws.activeGroupId;
const group = ws.groups.find((g) => g.id === gid);
- if (!group) return;
+ if (!group) return false;
const tab = group.tabs.find((t) => t.id === tabId);
- if (!tab || tab.permanent) return;
+ if (!tab || tab.permanent) return false;
+
+ if (tab.unsaved && !options.skipUnsavedPrompt) {
+ const confirmed = await requestConfirm({
+ title: i18next.t('editor.closeUnsavedTitle', {
+ defaultValue: 'Unsaved changes'
+ }),
+ message: i18next.t('editor.closeUnsavedConfirm', {
+ name: tab.label,
+ defaultValue: 'Close "{{name}}" without saving changes?'
+ }),
+ cancelLabel: i18next.t('common.cancel', { defaultValue: 'Cancel' }),
+ confirmLabel: i18next.t('editor.closeWithoutSaving', {
+ defaultValue: 'Close without saving'
+ })
+ });
+ if (!confirmed) return false;
+ }
if (tab.type === 'terminal' && tab.sessionId) {
deleteSession(tab.sessionId);
@@ -1296,6 +1318,7 @@ export function closeTab(tabId: string, groupId?: string): void {
activeGroupId: activeGroupStillExists ? ws.activeGroupId : newGroups[0].id
};
});
+ return true;
}
export function setActiveTab(tabId: string, groupId?: string): void {
diff --git a/cptr/frontend/src/lib/stores/confirm.ts b/cptr/frontend/src/lib/stores/confirm.ts
new file mode 100644
index 00000000..fed77509
--- /dev/null
+++ b/cptr/frontend/src/lib/stores/confirm.ts
@@ -0,0 +1,42 @@
+import { get, writable } from 'svelte/store';
+
+export interface ConfirmOptions {
+ title: string;
+ message: string;
+ confirmLabel?: string;
+ cancelLabel?: string;
+}
+
+export interface ConfirmRequest extends Required {
+ id: number;
+ resolve: (confirmed: boolean) => void;
+}
+
+export const confirmDialog = writable(null);
+
+let nextId = 0;
+
+export function requestConfirm(options: ConfirmOptions): Promise {
+ if (typeof window === 'undefined') return Promise.resolve(false);
+
+ const existing = get(confirmDialog);
+ if (existing) existing.resolve(false);
+
+ return new Promise((resolve) => {
+ confirmDialog.set({
+ id: ++nextId,
+ title: options.title,
+ message: options.message,
+ confirmLabel: options.confirmLabel ?? 'Confirm',
+ cancelLabel: options.cancelLabel ?? 'Cancel',
+ resolve
+ });
+ });
+}
+
+export function settleConfirm(confirmed: boolean): void {
+ const request = get(confirmDialog);
+ if (!request) return;
+ confirmDialog.set(null);
+ request.resolve(confirmed);
+}
diff --git a/cptr/frontend/src/routes/+layout.svelte b/cptr/frontend/src/routes/+layout.svelte
index 4700a977..de498be9 100644
--- a/cptr/frontend/src/routes/+layout.svelte
+++ b/cptr/frontend/src/routes/+layout.svelte
@@ -12,6 +12,7 @@
import SettingsModal from '$lib/components/SettingsModal.svelte';
import AuthScreen from '$lib/components/AuthScreen.svelte';
import ChangelogModal from '$lib/components/ChangelogModal.svelte';
+ import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import UpdateToast from '$lib/components/UpdateToast.svelte';
import { Toaster, toast } from 'svelte-sonner';
import {
@@ -507,3 +508,4 @@
style: 'font-size: 0.75rem; font-family: var(--font-sans); border-radius: 0.5rem;'
}}
/>
+
diff --git a/cptr/frontend/static/browser-encoder.js b/cptr/frontend/static/browser-encoder.js
index accb4421..7a81b370 100644
--- a/cptr/frontend/static/browser-encoder.js
+++ b/cptr/frontend/static/browser-encoder.js
@@ -2,6 +2,11 @@
const params = new URLSearchParams(location.hash.slice(1));
const windowCapture = params.get('window') === '1';
const audioEnabled = params.get('audio') === '1';
+ const accelerationOptions = new Set(['no-preference', 'prefer-hardware', 'prefer-software']);
+ const requestedAcceleration = params.get('hardwareAcceleration') || 'no-preference';
+ const hardwareAcceleration = accelerationOptions.has(requestedAcceleration)
+ ? requestedAcceleration
+ : 'no-preference';
let socket;
let encoder;
let audioEncoder;
@@ -65,7 +70,7 @@
}
async function configure(width, height) {
- const key = `${width}x${height}:${quality.bitrate}:${quality.frame_rate}`;
+ const key = `${width}x${height}:${quality.bitrate}:${quality.frame_rate}:${hardwareAcceleration}`;
if (configured === key) return;
const config = {
codec: 'avc1.42E028',
@@ -73,12 +78,12 @@
height,
bitrate: quality.bitrate,
framerate: quality.frame_rate,
- hardwareAcceleration: 'prefer-hardware',
+ hardwareAcceleration,
latencyMode: 'realtime',
avc: { format: 'annexb' }
};
const support = await VideoEncoder.isConfigSupported(config);
- if (!support.supported) throw new Error(`H.264 WebCodecs encoding is unavailable at ${width}×${height}; lower Admin > Web > Streaming quality > Advanced > Max height`);
+ if (!support.supported) throw new Error(`H.264 WebCodecs encoding is unavailable at ${width}×${height} with ${hardwareAcceleration}; lower Admin > Web > Streaming quality > Advanced > Max height or change encoder acceleration`);
if (encoder) {
try { await encoder.flush(); } catch {}
encoder.close();
diff --git a/cptr/utils/agents/detection.py b/cptr/utils/agents/detection.py
index 3ef05614..98dbb567 100644
--- a/cptr/utils/agents/detection.py
+++ b/cptr/utils/agents/detection.py
@@ -9,8 +9,8 @@
import re
import shutil
import socket
-from dataclasses import dataclass, asdict
from contextlib import suppress
+from dataclasses import dataclass, asdict
from typing import Any
import httpx
@@ -59,6 +59,39 @@ def _resolve_command(command: str) -> str | None:
return shutil.which(command)
+def _find_claude_desktop_command() -> str | None:
+ if os.name == "nt":
+ appdata = os.environ.get("APPDATA")
+ root = os.path.join(appdata, "Claude", "claude-code") if appdata else None
+ relative_paths = (("claude.exe",),)
+ else:
+ root = os.path.join(
+ os.path.expanduser("~"),
+ "Library",
+ "Application Support",
+ "Claude",
+ "claude-code",
+ )
+ relative_paths = (
+ ("claude.app", "Contents", "MacOS", "claude"),
+ ("claude",),
+ )
+
+ candidates: list[tuple[tuple[int, int, int], str]] = []
+ if root is not None and os.path.isdir(root):
+ for name in os.listdir(root):
+ version_dir = os.path.join(root, name)
+ version = _parse_version_tuple(name)
+ if version is None or not os.path.isdir(version_dir):
+ continue
+ for relative_path in relative_paths:
+ candidate = os.path.join(version_dir, *relative_path)
+ if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
+ candidates.append((version, candidate))
+ break
+ return max(candidates)[1] if candidates else None
+
+
async def _run_probe(
argv: list[str],
timeout: float = 3.0,
@@ -82,7 +115,10 @@ async def _run_probe(
async def detect_profile(profile: dict[str, Any]) -> AgentDetection:
- command = _resolve_command(str(profile.get("command") or ""))
+ raw_command = str(profile.get("command") or "").strip()
+ command = _resolve_command(raw_command)
+ if command is None and profile.get("agent") == "claude_code" and raw_command == "claude":
+ command = _find_claude_desktop_command()
if command is None:
return AgentDetection("not_found", None, None, "Command not found")
@@ -549,6 +585,12 @@ async def get_agent_status(app_state=None, refresh: bool = False) -> dict[str, A
and (mode != "auto" or detected.status == "ready")
)
effective_profile = dict(profile)
+ if (
+ detected.command
+ and profile.get("agent") == "claude_code"
+ and _resolve_command(str(profile.get("command") or "")) is None
+ ):
+ effective_profile["command"] = detected.command
effective_profile["models"] = models
if models and effective_profile.get("default_model") not in models:
effective_profile["default_model"] = models[0]
diff --git a/cptr/utils/browser/viewer.py b/cptr/utils/browser/viewer.py
index 83a7de33..46a9b0ff 100644
--- a/cptr/utils/browser/viewer.py
+++ b/cptr/utils/browser/viewer.py
@@ -32,6 +32,7 @@
EventHandler = Callable[[dict[str, Any]], Awaitable[None]]
QUALITY_PRESETS = ("low", "balanced", "crisp")
+ENCODER_HARDWARE_ACCELERATION = ("no-preference", "prefer-hardware", "prefer-software")
DEFAULT_QUALITY_PROFILES = {
"low": {"bitrate": 3_000_000, "frame_rate": 15},
"balanced": {"bitrate": 6_000_000, "frame_rate": 24},
@@ -616,8 +617,17 @@ async def start(self, session: Any, origin: str, *, cdp_url: str = "") -> Chrome
base = local_origin(origin)
ws_scheme = "wss" if base.startswith("https:") else "ws"
ws_url = f"{ws_scheme}://{urlsplit(base).netloc}/api/browser/sessions/{session.session_id}/encoder"
+ hardware_acceleration = await Config.get("browser.encoder.hardware_acceleration")
+ if hardware_acceleration not in ENCODER_HARDWARE_ACCELERATION:
+ hardware_acceleration = "no-preference"
fragment = urlencode(
- {"session": session.session_id, "token": token, "ws": ws_url, "audio": "1"}
+ {
+ "session": session.session_id,
+ "token": token,
+ "ws": ws_url,
+ "audio": "1",
+ "hardwareAcceleration": hardware_acceleration,
+ }
)
controller_url = f"{base}/browser-encoder.html#{fragment}"
controller_id, controller_cdp = await host.create_target(controller_url)
@@ -684,7 +694,17 @@ async def connect_personal(self, owner: str, origin: str, cdp_url: str) -> Perso
base = local_origin(origin)
ws_scheme = "wss" if base.startswith("https:") else "ws"
ws_url = f"{ws_scheme}://{urlsplit(base).netloc}/api/browser/sessions/personal/encoder"
- fragment = urlencode({"token": token, "ws": ws_url, "window": "1"})
+ hardware_acceleration = await Config.get("browser.encoder.hardware_acceleration")
+ if hardware_acceleration not in ENCODER_HARDWARE_ACCELERATION:
+ hardware_acceleration = "no-preference"
+ fragment = urlencode(
+ {
+ "token": token,
+ "ws": ws_url,
+ "window": "1",
+ "hardwareAcceleration": hardware_acceleration,
+ }
+ )
controller_id, controller_cdp = await host.create_target(
f"{base}/browser-encoder.html#{fragment}", new_window=True
)
@@ -1041,12 +1061,16 @@ async def _restart_encoder(self, viewer: ChromeViewer) -> None:
f"{ws_scheme}://{urlsplit(base).netloc}/api/browser/sessions/"
f"{viewer.session.session_id}/encoder"
)
+ hardware_acceleration = await Config.get("browser.encoder.hardware_acceleration")
+ if hardware_acceleration not in ENCODER_HARDWARE_ACCELERATION:
+ hardware_acceleration = "no-preference"
fragment = urlencode(
{
"session": viewer.session.session_id,
"token": viewer.encoder_token,
"ws": ws_url,
"audio": "1",
+ "hardwareAcceleration": hardware_acceleration,
}
)
viewer.controller_id, viewer.controller_cdp = await viewer.host.create_target(
diff --git a/pyproject.toml b/pyproject.toml
index 1db23441..487e0747 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "cptr"
-version = "0.9.11"
+version = "0.9.12"
description = "Your computer, from anywhere. Code, manage, and control your machine from the web."
license = {file = "LICENSE"}
readme = "README.md"
diff --git a/uv.lock b/uv.lock
index 6cdcc6e9..030bf8c9 100644
--- a/uv.lock
+++ b/uv.lock
@@ -284,7 +284,7 @@ wheels = [
[[package]]
name = "cptr"
-version = "0.9.11"
+version = "0.9.12"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },