Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
18 changes: 18 additions & 0 deletions internal/web/cors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)

Expand Down Expand Up @@ -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)
}
}
2 changes: 1 addition & 1 deletion internal/web/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
37 changes: 37 additions & 0 deletions web/src/components/CloudMobileView.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<Provider store={store}>
<CloudMobileView />
</Provider>,
)
}

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')
})
})
124 changes: 97 additions & 27 deletions web/src/components/CloudMobileView.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="page-surface flex min-h-0 flex-1 flex-col overflow-hidden">
<header className="shrink-0 border-b border-[var(--color-border)] px-6 py-5">
<div className="mx-auto flex max-w-4xl items-start gap-4">
<div className="relative grid h-11 w-11 shrink-0 place-items-center rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-accent-neutral)] shadow-[var(--shadow-sm)]">
<CloudIcon className="h-5 w-5" />
<span className="absolute -bottom-1 -right-1 grid h-5 w-5 place-items-center rounded-full border-2 border-[var(--color-background)] bg-[var(--color-foreground)] text-[var(--color-background)]">
<DevicePhoneMobileIcon className="h-3 w-3" />
</span>
<div className="remote-access-page page-surface min-h-0 flex-1 overflow-y-auto">
<section className="remote-access-hero">
<div className="remote-access-copy">
<div className="remote-access-eyebrow">
<CloudIcon className="h-3.5 w-3.5" />
{t('cloud.remoteEyebrow')}
</div>
<div className="min-w-0">
<h1 className="text-[17px] font-semibold tracking-[-0.01em] text-[var(--color-foreground)]">
{t('cloud.mobileHubTitle')}
</h1>
<p className="mt-1 max-w-2xl text-[12px] leading-relaxed text-[var(--color-muted-foreground)]">
{t('cloud.mobileHubDesc')}
</p>
<h1>{t('cloud.remoteTitle')}</h1>
<p className="remote-access-lede">{t('cloud.remoteDesc')}</p>

<button type="button" className="remote-access-cta group" onClick={openCloudSettings}>
{t('cloud.remoteCta')}
<ArrowRightIcon className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</button>
<p className="remote-access-hint">
<LockClosedIcon className="h-3.5 w-3.5" />
{t('cloud.remoteCtaHint')}
</p>
</div>

<div className="remote-access-visual" aria-hidden="true">
<div className="remote-access-orbit remote-access-orbit-one" />
<div className="remote-access-orbit remote-access-orbit-two" />

<div className="remote-desktop">
<div className="remote-window-bar">
<span />
<span />
<span />
<div className="remote-window-brand">
<CloudIcon className="h-3.5 w-3.5" />
jcode
</div>
</div>
<div className="remote-desktop-body">
<div className="remote-message remote-message-user">{t('cloud.remoteVisualPrompt')}</div>
<div className="remote-message remote-message-agent">
<span className="remote-agent-mark">J</span>
<div>
<span />
<span />
<span />
</div>
</div>
<div className="remote-run-status">
<span className="remote-status-check"><CheckIcon className="h-3 w-3" /></span>
{t('cloud.remoteVisualDone')}
</div>
</div>
</div>
<div className="ml-auto hidden items-center gap-1.5 rounded-full border border-[var(--color-border)] bg-[var(--color-muted)] px-2.5 py-1 text-[10.5px] text-[var(--color-muted-foreground)] sm:flex">
<LockClosedIcon className="h-3 w-3" />
{t('cloud.e2eeBadge')}

<div className="remote-phone">
<div className="remote-phone-speaker" />
<div className="remote-phone-header">
<span className="remote-status-dot" />
{t('cloud.remoteVisualConnected')}
</div>
<div className="remote-phone-message" />
<div className="remote-phone-message remote-phone-message-short" />
<div className="remote-phone-action">
<DevicePhoneMobileIcon className="h-3.5 w-3.5" />
{t('cloud.remoteVisualApprove')}
</div>
</div>
</div>
</header>
</section>

<div className="min-h-0 flex-1 overflow-y-auto px-6 py-6">
<div className="mx-auto max-w-4xl rounded-[var(--radius-2xl)] border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-[var(--shadow-sm)]">
<CloudTab heading={false} />
</div>
</div>
<section className="remote-access-features" aria-label={t('cloud.remoteFeaturesLabel')}>
{features.map(({ Icon, title, desc }) => (
<article key={title} className="remote-access-feature">
<Icon className="h-5 w-5 shrink-0" />
<div>
<h2>{title}</h2>
<p>{desc}</p>
</div>
</article>
))}
</section>
</div>
)
}
56 changes: 56 additions & 0 deletions web/src/components/settings/CloudTab.test.tsx
Original file line number Diff line number Diff line change
@@ -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<void>>(),
cloudLogin: vi.fn(),
}))

vi.mock('../../lib/useDesktop', () => ({
openUrl: mocks.openUrl,
}))

vi.mock('../../lib/api', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../lib/api')>()
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(<CloudTab />)

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))
})
})
21 changes: 16 additions & 5 deletions web/src/components/settings/CloudTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -669,15 +677,18 @@ export function CloudTab({ heading = true }: { heading?: boolean } = {}) {
{t('cloud.loginPrompt')}
</div>
{loginPanel.verification_uri && (
<a
href={loginPanel.verification_uri}
target="_blank"
rel="noreferrer"
<button
type="button"
onClick={() => {
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}
</a>
</button>
)}
<div className="flex items-center gap-1.5">
<div
Expand Down
23 changes: 20 additions & 3 deletions web/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export default {
nav: {
newTask: 'New task',
automations: 'Automations',
cloudMobile: 'Cloud & Mobile',
cloudMobile: 'Remote Access',
workspace: 'Workspace',
projects: 'Projects',
openFolder: 'Open Folder',
Expand Down Expand Up @@ -1029,8 +1029,24 @@ export default {

// Cloud relay status badge + popover (Sidebar footer).
cloud: {
mobileHubTitle: 'Cloud & Mobile',
mobileHubDesc: 'Connect this Desktop to your account, review every client approval, and pair phones or browsers without exposing conversation deletion.',
mobileHubTitle: 'Remote Access',
mobileHubDesc: 'Connect securely to this Desktop from your phone or browser.',
remoteEyebrow: 'JCODE, WITHIN REACH',
remoteTitle: 'Step away. Keep the work moving.',
remoteDesc: 'Connect this Desktop to the cloud to continue conversations, start tasks, and approve actions from your phone or browser. Your code and runtime stay on this computer.',
remoteCta: 'Set up remote access',
remoteCtaHint: 'Open Settings to sign in, pair devices, and choose sync options',
remoteFeaturesLabel: 'Remote access capabilities',
remoteAnywhereTitle: 'Continue anywhere',
remoteAnywhereDesc: 'Pick up active tasks from your phone or browser.',
remotePrivateTitle: 'End-to-end encrypted',
remotePrivateDesc: 'Conversations and configuration are encrypted on-device.',
remoteControlTitle: 'Desktop in control',
remoteControlDesc: 'Sensitive actions still run and require approval here.',
remoteVisualPrompt: 'Check the build results',
remoteVisualDone: 'Task completed on Desktop',
remoteVisualConnected: 'Desktop online',
remoteVisualApprove: 'Approve action',
e2eeBadge: 'End-to-end encrypted',
syncing: 'Cloud on',
syncDisabled: 'Cloud off',
Expand All @@ -1047,6 +1063,7 @@ export default {
loginIntro: 'Log in to use this device remotely from the cloud console and mobile app.',
login: 'Log in to cloud',
loginPrompt: 'Open this page and enter the code:',
openLoginFailed: 'Could not open your system browser. Copy the link below and open it manually.',
loginWaiting: 'Waiting for authorization…',
loginSuccess: 'Logged in to the cloud',
loginFailed: 'Login failed: {message}',
Expand Down
Loading
Loading