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: 4 additions & 0 deletions apps/mobile/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ yarn-error.*

# local env files
.env*.local
.env
.env.production
.env.development
.env.staging

# typescript
*.tsbuildinfo
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/app.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,8 @@ module.exports = ({ config }) => {
config.updates = {
...(config.updates || {}),
url: "https://u.expo.dev/3a001439-9712-4716-9865-47413eac1995",
checkAutomatically: "ON_LOAD",
fallbackToCacheTimeout: 0,
checkAutomatically: "ON_ERROR_RECOVERY",
fallbackToCacheTimeout: 30000,
};
config.runtimeVersion = config.runtimeVersion || { policy: "appVersion" };

Expand Down
10 changes: 7 additions & 3 deletions apps/mobile/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ export default function RootLayout() {

React.useEffect(() => {
if (isUpdatePending) {
Updates.reloadAsync()
// Never auto-reload mid-session: a pending OTA is applied on next
// cold start / manual update check instead of killing streaming state.
Updates.reloadAsync().catch(() => undefined)
}
}, [isUpdatePending])

Expand All @@ -69,9 +71,11 @@ export default function RootLayout() {
let timer: ReturnType<typeof setTimeout> | undefined
const schedule = () => {
if (timer) clearTimeout(timer)
// Throttled so per-keystroke store writes (drafts/streaming) don't
// rebuild widgets multiple times per second.
timer = setTimeout(() => {
refreshWidgets()
}, 800)
refreshWidgets().catch(() => undefined)
}, 5000)
}
const unsubSessions = useSessions.subscribe(schedule)
const unsubConnections = useConnections.subscribe(schedule)
Expand Down
64 changes: 46 additions & 18 deletions apps/mobile/app/connect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,14 @@ import XIcon from "lucide-react-native/dist/esm/icons/x"
import { cn, getAuthHeader } from "@/lib/utils"
import { THEME } from "@/lib/theme"
import { useColorScheme } from "nativewind"
import { consumePendingConnection, peekPendingConnection } from "@/lib/pending-connection"
import { validateConnectionUrl, validateAuthToken } from "@/lib/security"

export default function Connect() {
const { url, token } = useLocalSearchParams<{ url: string, token: string }>()
const params = useLocalSearchParams<{ url?: string, token?: string }>()
const pending = peekPendingConnection()
const url = pending?.url ?? params.url ?? ""
const token = pending?.token ?? params.token ?? ""
const { colorScheme } = useColorScheme()
const router = useRouter()
const insets = useSafeAreaInsets()
Expand All @@ -30,22 +35,31 @@ export default function Connect() {
setTesting(true)

try {
const res = await fetch(`${url}/global/health`, {
method: "GET",
headers: {
"Authorization": getAuthHeader(token)
}
})
if (res.ok) {
setTested({
msg: "Remote server reachable",
error: false
})
} else {
setTested({
msg: "Remote server unreachable",
error: true
validateConnectionUrl(url)
validateAuthToken(token)
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 10000)
try {
const res = await fetch(`${url}/global/health`, {
method: "GET",
headers: {
"Authorization": getAuthHeader(token)
},
signal: controller.signal,
})
if (res.ok) {
setTested({
msg: "Remote server reachable",
error: false
})
} else {
setTested({
msg: "Remote server unreachable",
error: true
})
}
} finally {
clearTimeout(timer)
}
} catch {
setTested({
Expand All @@ -58,6 +72,13 @@ export default function Connect() {
}, [url, token])

function save() {
try {
validateConnectionUrl(url)
validateAuthToken(token)
} catch (error) {
setTested({ msg: error instanceof Error ? error.message : "Invalid connection", error: true })
return
}
const tier = useAuth.getState().user?.tier ?? "free"
if (isAtTunnelLimit(tier, connections.length)) {
void requestPaywall("connection_limit")
Expand All @@ -68,6 +89,7 @@ export default function Connect() {
name,
token
})
consumePendingConnection()
router.replace("/")
}

Expand All @@ -78,7 +100,10 @@ export default function Connect() {
return (
<View className="flex-1 bg-background" style={{ paddingTop: insets.top }}>
<View className="p-4">
<Button variant="ghost" className="w-10 h-10 text-white" onPress={() => router.push("/new-connection")}>
<Button variant="ghost" className="w-10 h-10 text-white" onPress={() => {
consumePendingConnection()
router.replace("/new-connection")
}}>
<XIcon size={25} color={THEME[theme].foreground} />
</Button>
</View>
Expand Down Expand Up @@ -143,7 +168,10 @@ export default function Connect() {

<Button
className="mt-5 rounded-full"
onPress={tested?.error ? () => router.push("/new-connection") : save}
onPress={tested?.error ? () => {
consumePendingConnection()
router.replace("/new-connection")
} : save}
>
<Text>
{tested?.error ? "Scan again" : "Connect to OpenCode"}
Expand Down
75 changes: 54 additions & 21 deletions apps/mobile/app/login-scanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import { useColorScheme } from "nativewind"
import { useAuth } from "@/store/auth.store"
import { registerPushDevice } from "@/lib/account-notifications"
import { useSettings } from "@/store/settings.store"
import { validateAuthToken, validateServerUrl, secureFetch } from "@/lib/security"

const AUTH_SERVER_URL = "https://crosscode.site"

export default function LoginScannerScreen() {
const router = useRouter()
Expand All @@ -37,19 +40,25 @@ export default function LoginScannerScreen() {

const handleScan = async (data: string) => {
if (navigated.current || claiming || importing) return
navigated.current = true
try {
const payloadType = detectQrPayloadType(data)

if (payloadType === "device-link") {
const payload = decodeDeviceLinkQrPayload(data)
navigated.current = true
validateAuthToken(payload.token)
// Login QRs must resolve against the pinned auth server unless the
// payload carries an explicit self-hosted URL.
const serverUrl = payload.url ? validateServerUrl(payload.url) : AUTH_SERVER_URL
setClaiming(true)
await claimDevice(payload.token, payload.url)
await claimDevice(payload.token, serverUrl)
} else {
navigated.current = false
Alert.alert("Invalid QR", "Please scan a login QR code from the web dashboard.")
}
} catch {
Alert.alert("Invalid QR", "Please scan a valid login QR code.")
} catch (error) {
navigated.current = false
Alert.alert("Invalid QR", error instanceof Error ? error.message : "Please scan a valid login QR code.")
}
}

Expand All @@ -69,35 +78,59 @@ export default function LoginScannerScreen() {

const claimDevice = async (token: string, serverUrl: string) => {
try {
const res = await fetch(`${serverUrl}/api/auth/device-link/claim?token=${token}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ deviceName: "Mobile Device" }),
})
validateAuthToken(token)
const safeServerUrl = validateServerUrl(serverUrl)
const res = await secureFetch(
safeServerUrl,
`/api/auth/device-link/claim?token=${encodeURIComponent(token)}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ deviceName: "Mobile Device" }),
},
15000
)

if (!res.ok) {
const error = await res.json()
throw new Error(error.error || "Failed to claim device")
const error = await res.json().catch(() => ({}))
throw new Error((error as { error?: string }).error || "Failed to claim device")
}

const accountRes = await fetch(`${serverUrl}/api/account`, {
headers: { Authorization: `Bearer ${token}` },
})
await res.json().catch(() => ({}))

const accountRes = await secureFetch(
safeServerUrl,
"/api/account",
{
headers: { Authorization: `Bearer ${token}` },
},
15000
)

if (accountRes.ok) {
const accountData = await accountRes.json()
const accountData = await accountRes.json().catch(() => null)
const user = (accountData as { user?: { id?: unknown; email?: unknown; name?: unknown; tier?: unknown } } | null)?.user
if (
!user ||
typeof user.id !== "string" ||
typeof user.email !== "string" ||
typeof user.name !== "string" ||
typeof user.tier !== "string"
) {
throw new Error("Failed to fetch account")
}
login(
{
id: accountData.user.id,
email: accountData.user.email,
name: accountData.user.name,
tier: accountData.user.tier,
id: user.id,
email: user.email,
name: user.name,
tier: user.tier,
},
token,
serverUrl
safeServerUrl
)
if (useSettings.getState().notifications) {
await registerPushDevice(serverUrl, token)
await registerPushDevice(safeServerUrl, token)
}
Alert.alert("Logged In", "You have been successfully logged in!", [
{ text: "OK", onPress: () => router.replace("/(tabs)/user") },
Expand Down
Loading
Loading