diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json
index a11223d..3a0b1fe 100644
--- a/desktop/src-tauri/tauri.conf.json
+++ b/desktop/src-tauri/tauri.conf.json
@@ -5,8 +5,8 @@
"identifier": "com.cnjack.jcode",
"build": {
"frontendDist": "../../internal/web/dist",
- "beforeDevCommand": "pnpm --dir ../web dev --host",
- "devUrl": "http://localhost:5173",
+ "beforeDevCommand": "pnpm --dir ../web dev --host 127.0.0.1",
+ "devUrl": "http://127.0.0.1:5173",
"beforeBuildCommand": "make -C .. build-web"
},
"app": {
diff --git a/internal/web/cors_test.go b/internal/web/cors_test.go
index 954f985..6fd1d16 100644
--- a/internal/web/cors_test.go
+++ b/internal/web/cors_test.go
@@ -3,6 +3,7 @@ package web
import (
"net/http"
"net/http/httptest"
+ "strings"
"testing"
)
@@ -136,3 +137,20 @@ func TestCORSMiddlewareAllowsTrustedOriginsAndNonBrowserClients(t *testing.T) {
}
}
}
+
+func TestCORSMiddlewarePreflightAllowsPatch(t *testing.T) {
+ r := httptest.NewRequest(http.MethodOptions, "http://127.0.0.1:53913/api/account-preferences", nil)
+ r.Host = "127.0.0.1:53913"
+ r.Header.Set("Origin", "http://127.0.0.1:5173")
+ r.Header.Set("Access-Control-Request-Method", http.MethodPatch)
+
+ rec := httptest.NewRecorder()
+ corsMiddleware(http.NotFoundHandler()).ServeHTTP(rec, r)
+
+ if rec.Code != http.StatusNoContent {
+ t.Fatalf("status=%d, want %d", rec.Code, http.StatusNoContent)
+ }
+ if got := rec.Header().Get("Access-Control-Allow-Methods"); !strings.Contains(got, http.MethodPatch) {
+ t.Fatalf("Access-Control-Allow-Methods=%q, want PATCH", got)
+ }
+}
diff --git a/internal/web/server.go b/internal/web/server.go
index 121e8cf..5952cf4 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -781,7 +781,7 @@ func corsMiddleware(next http.Handler) http.Handler {
if origin != "" {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
- w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
+ w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Max-Age", "86400")
diff --git a/web/src/components/CloudMobileView.test.tsx b/web/src/components/CloudMobileView.test.tsx
new file mode 100644
index 0000000..887beac
--- /dev/null
+++ b/web/src/components/CloudMobileView.test.tsx
@@ -0,0 +1,37 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { Provider } from 'react-redux'
+import { i18n } from '../i18n'
+import { store, uiActions } from '../app/store'
+import { CloudMobileView } from './CloudMobileView'
+
+function renderView() {
+ return render(
+
+
+ ,
+ )
+}
+
+beforeEach(async () => {
+ cleanup()
+ await i18n.changeLanguage('en')
+ store.dispatch(uiActions.setView('cloud-mobile'))
+ store.dispatch(uiActions.setSettingsTab('general'))
+})
+
+describe('CloudMobileView', () => {
+ it('introduces remote access without embedding cloud settings', () => {
+ renderView()
+ expect(screen.getByRole('heading', { level: 1, name: 'Step away. Keep the work moving.' })).toBeTruthy()
+ expect(screen.getByRole('button', { name: 'Set up remote access' })).toBeTruthy()
+ expect(screen.queryByRole('button', { name: 'Log in to cloud' })).toBeNull()
+ })
+
+ it('deep-links the call to action to Settings → Cloud', () => {
+ renderView()
+ fireEvent.click(screen.getByRole('button', { name: 'Set up remote access' }))
+ expect(store.getState().ui.activeView).toBe('settings')
+ expect(store.getState().ui.settingsTab).toBe('cloud')
+ })
+})
diff --git a/web/src/components/CloudMobileView.tsx b/web/src/components/CloudMobileView.tsx
index 01bc0c7..6b1ada3 100644
--- a/web/src/components/CloudMobileView.tsx
+++ b/web/src/components/CloudMobileView.tsx
@@ -1,41 +1,111 @@
-import { CloudIcon, DevicePhoneMobileIcon, LockClosedIcon } from '@heroicons/react/24/outline'
+import {
+ ArrowRightIcon,
+ CheckIcon,
+ CloudIcon,
+ ComputerDesktopIcon,
+ DevicePhoneMobileIcon,
+ GlobeAltIcon,
+ LockClosedIcon,
+} from '@heroicons/react/24/outline'
import { useTranslation } from 'react-i18next'
-import { CloudTab } from './settings/CloudTab'
+import { useAppDispatch } from '../app/hooks'
+import { uiActions } from '../app/store'
-/** Desktop's focused control surface for cloud access and paired mobile clients. */
+/** Introduces remote access and hands configuration off to Settings → Cloud. */
export function CloudMobileView() {
const { t } = useTranslation()
+ const dispatch = useAppDispatch()
+
+ function openCloudSettings() {
+ dispatch(uiActions.setSettingsTab('cloud'))
+ dispatch(uiActions.setView('settings'))
+ }
+
+ const features = [
+ { Icon: GlobeAltIcon, title: t('cloud.remoteAnywhereTitle'), desc: t('cloud.remoteAnywhereDesc') },
+ { Icon: LockClosedIcon, title: t('cloud.remotePrivateTitle'), desc: t('cloud.remotePrivateDesc') },
+ { Icon: ComputerDesktopIcon, title: t('cloud.remoteControlTitle'), desc: t('cloud.remoteControlDesc') },
+ ]
return (
-
-
-
-
-
-
-
-
+
+
+
+
+
+ {t('cloud.remoteEyebrow')}
-
-
- {t('cloud.mobileHubTitle')}
-
-
- {t('cloud.mobileHubDesc')}
-
+
{t('cloud.remoteTitle')}
+
{t('cloud.remoteDesc')}
+
+
+
+
+ {t('cloud.remoteCtaHint')}
+
+
+
+
+
+
+
+
+
+
+
{t('cloud.remoteVisualPrompt')}
+
+
+
+ {t('cloud.remoteVisualDone')}
+
+
-
-
- {t('cloud.e2eeBadge')}
+
+
+
+
+
+ {t('cloud.remoteVisualConnected')}
+
+
+
+
+
+ {t('cloud.remoteVisualApprove')}
+
-
+
-
+
+ {features.map(({ Icon, title, desc }) => (
+
+
+
+
+ ))}
+
)
}
diff --git a/web/src/components/settings/CloudTab.test.tsx b/web/src/components/settings/CloudTab.test.tsx
new file mode 100644
index 0000000..2e60f73
--- /dev/null
+++ b/web/src/components/settings/CloudTab.test.tsx
@@ -0,0 +1,56 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { i18n } from '../../i18n'
+import { CloudTab } from './CloudTab'
+
+const mocks = vi.hoisted(() => ({
+ openUrl: vi.fn<() => Promise
>(),
+ cloudLogin: vi.fn(),
+}))
+
+vi.mock('../../lib/useDesktop', () => ({
+ openUrl: mocks.openUrl,
+}))
+
+vi.mock('../../lib/api', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ api: {
+ ...actual.api,
+ cloudStatus: vi.fn().mockResolvedValue({ logged_in: false }),
+ cloudSync: vi.fn().mockResolvedValue({ sync_default: false }),
+ cloudLoginStatus: vi.fn().mockResolvedValue({ state: 'idle' }),
+ cloudLogin: mocks.cloudLogin,
+ },
+ }
+})
+
+beforeEach(async () => {
+ cleanup()
+ vi.clearAllMocks()
+ mocks.openUrl.mockResolvedValue()
+ mocks.cloudLogin.mockResolvedValue({
+ user_code: 'ABCD-EFGH',
+ verification_uri: 'https://cloud.example.com/device',
+ expires_at: '2099-01-01T00:00:00Z',
+ })
+ await i18n.changeLanguage('en')
+})
+
+describe('CloudTab login', () => {
+ it('opens device authorization in the system browser and keeps a safe fallback button', async () => {
+ render()
+
+ const login = await screen.findByRole('button', { name: 'Log in to cloud' })
+ fireEvent.click(login)
+
+ await waitFor(() => {
+ expect(mocks.openUrl).toHaveBeenCalledWith('https://cloud.example.com/device')
+ })
+
+ const fallback = screen.getByRole('button', { name: 'https://cloud.example.com/device' })
+ fireEvent.click(fallback)
+ await waitFor(() => expect(mocks.openUrl).toHaveBeenCalledTimes(2))
+ })
+})
diff --git a/web/src/components/settings/CloudTab.tsx b/web/src/components/settings/CloudTab.tsx
index 4682aa8..6aaf3db 100644
--- a/web/src/components/settings/CloudTab.tsx
+++ b/web/src/components/settings/CloudTab.tsx
@@ -34,6 +34,7 @@ import {
type CloudPairingRecord,
type CloudStatusResponse,
} from '../../lib/api'
+import { openUrl } from '../../lib/useDesktop'
import { BTN_GHOST, BTN_PRIMARY, BTN_SECONDARY, BTN_SM, ROW, SECTION_TITLE, Switch } from './atoms'
const POLL_MS = 5000
@@ -324,6 +325,13 @@ export function CloudTab({ heading = true }: { heading?: boolean } = {}) {
verification_uri: r.verification_uri,
expires_at: r.expires_at,
})
+ if (r.verification_uri) {
+ try {
+ await openUrl(r.verification_uri)
+ } catch {
+ setActionError(t('cloud.openLoginFailed'))
+ }
+ }
} catch (err) {
setActionError(t('cloud.loginFailed', { message: err instanceof Error ? err.message : String(err) }))
} finally {
@@ -669,15 +677,18 @@ export function CloudTab({ heading = true }: { heading?: boolean } = {}) {
{t('cloud.loginPrompt')}
{loginPanel.verification_uri && (
- {
+ void openUrl(loginPanel.verification_uri!).catch(() => {
+ setActionError(t('cloud.openLoginFailed'))
+ })
+ }}
className="max-w-full truncate text-[12px] text-[var(--color-accent-neutral)] underline-offset-2 hover:underline"
title={loginPanel.verification_uri}
>
{loginPanel.verification_uri}
-
+
)}
span {
+ width: 0.45rem;
+ height: 0.45rem;
+ border-radius: 50%;
+ background: color-mix(in srgb, var(--color-muted-foreground) 30%, transparent);
+}
+.remote-window-brand {
+ display: flex;
+ align-items: center;
+ gap: 0.3rem;
+ margin-left: auto;
+ color: var(--color-muted-foreground);
+ font-family: var(--font-mono);
+ font-size: 0.66rem;
+}
+.remote-desktop-body {
+ display: flex;
+ min-height: 17.75rem;
+ flex-direction: column;
+ gap: 1.15rem;
+ padding: 2.15rem 2rem 1.5rem;
+}
+.remote-message {
+ font-size: 0.72rem;
+}
+.remote-message-user {
+ align-self: flex-end;
+ max-width: 70%;
+ padding: 0.65rem 0.8rem;
+ border-radius: var(--radius-lg) var(--radius-lg) var(--radius-xs) var(--radius-lg);
+ background: var(--color-secondary);
+ color: var(--color-foreground);
+}
+.remote-message-agent {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.65rem;
+ width: 72%;
+}
+.remote-agent-mark {
+ display: grid;
+ width: 1.5rem;
+ height: 1.5rem;
+ flex: none;
+ place-items: center;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ color: var(--color-primary);
+ font-family: var(--font-mono);
+ font-size: 0.66rem;
+ font-weight: 700;
+}
+.remote-message-agent > div {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ gap: 0.45rem;
+ padding-top: 0.25rem;
+}
+.remote-message-agent > div > span {
+ height: 0.44rem;
+ border-radius: var(--radius-xs);
+ background: var(--color-secondary);
+}
+.remote-message-agent > div > span:nth-child(2) {
+ width: 88%;
+}
+.remote-message-agent > div > span:nth-child(3) {
+ width: 58%;
+}
+.remote-run-status {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-top: auto;
+ padding-top: 0.85rem;
+ border-top: 1px solid var(--color-border);
+ color: var(--color-muted-foreground);
+ font-size: 0.65rem;
+}
+.remote-status-check {
+ display: grid;
+ width: 1.15rem;
+ height: 1.15rem;
+ place-items: center;
+ border-radius: 50%;
+ background: var(--color-success-bg);
+ color: var(--color-success-fg);
+}
+.remote-phone {
+ position: absolute;
+ right: -1%;
+ bottom: 1%;
+ width: 9rem;
+ min-height: 18.5rem;
+ padding: 1.8rem 0.8rem 0.85rem;
+ border: 0.3rem solid var(--color-foreground);
+ border-radius: 1.65rem;
+ background: var(--color-surface);
+ box-shadow: var(--shadow-xl);
+ transform: rotate(4deg);
+}
+.remote-phone-speaker {
+ position: absolute;
+ top: 0.45rem;
+ left: 50%;
+ width: 2.5rem;
+ height: 0.32rem;
+ border-radius: var(--radius-pill);
+ background: var(--color-foreground);
+ transform: translateX(-50%);
+}
+.remote-phone-header {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ color: var(--color-foreground);
+ font-size: 0.56rem;
+ font-weight: 600;
+}
+.remote-status-dot {
+ width: 0.38rem;
+ height: 0.38rem;
+ border-radius: 50%;
+ background: var(--color-success);
+}
+.remote-phone-message {
+ width: 100%;
+ height: 3.8rem;
+ margin-top: 1.15rem;
+ border-radius: var(--radius-lg);
+ background:
+ linear-gradient(
+ color-mix(in srgb, var(--color-muted-foreground) 15%, transparent) 0 0
+ ) 0.65rem 0.75rem / 68% 0.38rem no-repeat,
+ linear-gradient(
+ color-mix(in srgb, var(--color-muted-foreground) 11%, transparent) 0 0
+ ) 0.65rem 1.5rem / 80% 0.35rem no-repeat,
+ linear-gradient(
+ color-mix(in srgb, var(--color-muted-foreground) 11%, transparent) 0 0
+ ) 0.65rem 2.2rem / 48% 0.35rem no-repeat,
+ var(--color-muted);
+}
+.remote-phone-message-short {
+ width: 72%;
+ height: 2.25rem;
+ margin-top: 0.65rem;
+}
+.remote-phone-action {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.3rem;
+ min-height: 2rem;
+ margin-top: 1rem;
+ border-radius: var(--radius-md);
+ background: var(--color-foreground);
+ color: var(--color-background);
+ font-size: 0.55rem;
+ font-weight: 600;
+}
+.remote-access-features {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ width: min(70rem, calc(100% - 6rem));
+ margin: 0 auto;
+ padding: 2.25rem 0 3.5rem;
+ border-top: 1px solid var(--color-border);
+}
+.remote-access-feature {
+ display: flex;
+ gap: 0.9rem;
+ padding-right: clamp(1.2rem, 3vw, 3.5rem);
+ color: var(--color-muted-foreground);
+}
+.remote-access-feature + .remote-access-feature {
+ padding-left: clamp(1.2rem, 3vw, 3.5rem);
+ border-left: 1px solid var(--color-border);
+}
+.remote-access-feature h2 {
+ margin-bottom: 0.3rem;
+ color: var(--color-foreground);
+ font-size: 0.78rem;
+ font-weight: 600;
+}
+.remote-access-feature p {
+ font-size: 0.68rem;
+ line-height: 1.55;
+ text-wrap: pretty;
+}
+@media (max-width: 900px) {
+ .remote-access-hero {
+ grid-template-columns: 1fr;
+ gap: 2.5rem;
+ width: min(38rem, calc(100% - 3rem));
+ padding-top: 4rem;
+ }
+ .remote-access-copy h1 {
+ max-width: 13ch;
+ }
+ .remote-access-visual {
+ min-height: 23rem;
+ }
+ .remote-access-features {
+ width: min(38rem, calc(100% - 3rem));
+ }
+}
+@media (max-width: 640px) {
+ .remote-access-hero {
+ width: calc(100% - 2rem);
+ padding-top: 3rem;
+ }
+ .remote-access-copy h1 {
+ font-size: clamp(2.35rem, 12vw, 3.2rem);
+ }
+ .remote-access-visual {
+ min-height: 20rem;
+ }
+ .remote-phone {
+ right: -2%;
+ width: 7.4rem;
+ min-height: 15rem;
+ }
+ .remote-desktop-body {
+ min-height: 15rem;
+ padding: 1.5rem;
+ }
+ .remote-access-features {
+ grid-template-columns: 1fr;
+ width: calc(100% - 2rem);
+ }
+ .remote-access-feature,
+ .remote-access-feature + .remote-access-feature {
+ padding: 1.2rem 0;
+ border-left: 0;
+ }
+ .remote-access-feature + .remote-access-feature {
+ border-top: 1px solid var(--color-border);
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ .remote-access-cta,
+ .remote-access-cta svg {
+ transition: none;
+ }
+}
+
/* ─── Title-bar drag strip (Tauri desktop only) ───
In the Tauri shell the window uses an overlay title bar; this reserves the
band the traffic-light buttons float over and lets the user drag the window.