From 4a7c181e5947530bbc700ea5ed0075e9310c0ddd Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 24 Jul 2026 15:11:04 +0800 Subject: [PATCH] feat(web): redesign remote access setup --- desktop/src-tauri/tauri.conf.json | 4 +- internal/web/cors_test.go | 18 + internal/web/server.go | 2 +- web/src/components/CloudMobileView.test.tsx | 37 ++ web/src/components/CloudMobileView.tsx | 124 ++++-- web/src/components/settings/CloudTab.test.tsx | 56 +++ web/src/components/settings/CloudTab.tsx | 21 +- web/src/i18n/locales/en.ts | 23 +- web/src/i18n/locales/ja.ts | 23 +- web/src/i18n/locales/ko.ts | 23 +- web/src/i18n/locales/zh-Hans.ts | 23 +- web/src/i18n/locales/zh-Hant.ts | 23 +- web/src/styles.css | 383 ++++++++++++++++++ 13 files changed, 710 insertions(+), 50 deletions(-) create mode 100644 web/src/components/CloudMobileView.test.tsx create mode 100644 web/src/components/settings/CloudTab.test.tsx diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index a11223de..3a0b1fef 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 954f9850..6fd1d163 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 121e8cff..5952cf45 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 00000000..887beac9 --- /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 01bc0c74..6b1ada3d 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')} +

+
+ +
+ -
-
- -
-
+
+ {features.map(({ Icon, title, desc }) => ( +
+ +
+

{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 00000000..2e60f730 --- /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 4682aa82..6aaf3dbb 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.