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
11 changes: 9 additions & 2 deletions src/apps/mobile/android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,17 @@ Provisional source layout:
- `app/src/main/kotlin/`: Kotlin application code.
- `app/src/main/res/`: Android resources.

Build debug and unsigned release artifacts with:
Build a debug artifact with:

```bash
JAVA_HOME='/Applications/Android Studio.app/Contents/jbr/Contents/Home' ./gradlew :app:assembleDebug :app:assembleRelease
JAVA_HOME='/Applications/Android Studio.app/Contents/jbr/Contents/Home' ./gradlew :app:assembleDebug
```

An unsigned release is available only for local inspection and must be
requested explicitly:

```bash
JAVA_HOME='/Applications/Android Studio.app/Contents/jbr/Contents/Home' ./gradlew -PallowUnsignedRelease=true :app:assembleRelease
```

For a signed release, set `OPENBITFUN_ANDROID_KEYSTORE`,
Expand Down
16 changes: 16 additions & 0 deletions src/apps/mobile/android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,22 @@ val hasReleaseSigning = listOf(
releaseKeyAlias,
releaseKeyPassword,
).all { !it.isNullOrBlank() }
val allowUnsignedRelease = providers.gradleProperty("allowUnsignedRelease")
.map { it.equals("true", ignoreCase = true) }
.orElse(false)
.get()
val releaseTaskRequested = gradle.startParameter.taskNames.any {
it.contains("release", ignoreCase = true)
}

if (releaseTaskRequested && !hasReleaseSigning && !allowUnsignedRelease) {
throw GradleException(
"Release signing credentials are missing. Set OPENBITFUN_ANDROID_KEYSTORE, " +
"OPENBITFUN_ANDROID_KEYSTORE_PASSWORD, OPENBITFUN_ANDROID_KEY_ALIAS, and " +
"OPENBITFUN_ANDROID_KEY_PASSWORD, or explicitly pass " +
"-PallowUnsignedRelease=true for a non-distributable local artifact.",
)
}

android {
sourceSets.getByName("main").assets.srcDir(file("../../../../shared/terminal/webview/generated"))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.openbitfun.mobile.app

import android.content.Intent
import android.os.Bundle
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.mutableStateOf
Expand Down Expand Up @@ -35,9 +36,11 @@ class MainActivity : ComponentActivity() {
private var showStartupBrand by mutableStateOf(true)
private var showColdStart by mutableStateOf(false)
private var allowColdStart by mutableStateOf(false)
private var authorizationCallbackPending = false
override fun onCreate(savedInstanceState: Bundle?) {
AppLocaleController.applySaved(this)
super.onCreate(savedInstanceState)
authorizationCallbackPending = isAuthorizationCallbackIntent(intent)
val coldStartCandidate = !processLaunchClaimed
&& !intent.getBooleanExtra(DESIGN_PREVIEW_EXTRA, false)
processLaunchClaimed = true
Expand Down Expand Up @@ -88,13 +91,19 @@ class MainActivity : ComponentActivity() {

override fun onStart() {
super.onStart()
if (!intent.getBooleanExtra(DESIGN_PREVIEW_EXTRA, false)) accountModel().setBackground(false)
if (!intent.getBooleanExtra(DESIGN_PREVIEW_EXTRA, false)) {
accountModel().setBackground(false)
if (authorizationCallbackPending) {
authorizationCallbackPending = false
accountModel().notifyAuthorizationCallback()
}
}
}

override fun onNewIntent(intent: android.content.Intent) {
super.onNewIntent(intent)
setIntent(intent)
accountModel().notifyAuthorizationCallback()
if (isAuthorizationCallbackIntent(intent)) accountModel().notifyAuthorizationCallback()
}

override fun onStop() {
Expand All @@ -108,6 +117,16 @@ class MainActivity : ComponentActivity() {
private fun accountModel() = androidx.lifecycle.ViewModelProvider(this,
com.openbitfun.mobile.app.viewmodel.AccountViewModel.Factory)[com.openbitfun.mobile.app.viewmodel.AccountViewModel::class.java]

private fun isAuthorizationCallbackIntent(intent: Intent): Boolean {
val uri = intent.data ?: return false
return intent.action == Intent.ACTION_VIEW &&
uri.scheme == "openbitfun" &&
uri.authority == "auth" &&
uri.path == "/callback" &&
uri.query == null &&
uri.fragment == null
}

private companion object {
var processLaunchClaimed = false
const val DESIGN_PREVIEW_EXTRA = "openbitfun.design_preview"
Expand Down
9 changes: 8 additions & 1 deletion src/apps/mobile/ios/OpenBitFun/App/OpenBitFunApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,18 @@ struct OpenBitFunApp: App {
if phase == .background { Self.coldStartProcessClaimed = true; coldStartConsumed = true; showStartupBrand = false; showColdStart = false }
model.handleScenePhase(phase)
}
.onOpenURL { _ in
.onOpenURL { url in
// The auth page redirects to this scheme after the
// browser completes. The shared account poller is
// already waiting; opening the URL brings this scene
// foreground and the next poll is immediate.
guard url.scheme == "openbitfun",
url.host == "auth",
url.path == "/callback",
url.user == nil,
url.password == nil,
url.query == nil,
url.fragment == nil else { return }
model.notifyAuthorizationCallback()
}
.environment(\.locale, Locale(identifier: model.appLanguage.rawValue))
Expand Down
16 changes: 16 additions & 0 deletions src/miniapp-market-web/src/AccountSignIn.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ let root: ReturnType<typeof createRoot>;
beforeEach(async () => {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
vi.resetAllMocks();
sessionStorage.clear();
window.history.replaceState(null, '', '/sign-in?locale=en-US#ticket=device-ticket');
api.config.mockResolvedValue({ emailAuthConfigured: true, githubAuthConfigured: true });
api.sendEmailCode.mockResolvedValue({ challengeId: 'challenge', retryAfterSeconds: 60 });
Expand All @@ -22,6 +23,7 @@ beforeEach(async () => {
afterEach(() => {
act(() => root.unmount());
container.remove();
sessionStorage.clear();
});
async function input(id: string, value: string) {
const element = container.querySelector<HTMLInputElement>(`#${id}`)!;
Expand Down Expand Up @@ -64,3 +66,17 @@ it('distinguishes mail delivery failures from invalid verification codes', async
expect(container.querySelector('[role="alert"]')?.textContent).toContain("couldn't send the email");
expect(container.querySelector('form')).not.toBeNull();
});

it('remembers only the exact native app callback target', async () => {
window.history.replaceState(null, '', '/sign-in?returnTo=openbitfun%3A%2F%2Fauth%2Fcallback#ticket=device-ticket');
await act(async () => root.unmount());
root = createRoot(container);
await act(async () => root.render(<AccountSignIn />));
expect(sessionStorage.getItem('openbitfun.auth.returnTo')).toBe('openbitfun://auth/callback');

window.history.replaceState(null, '', '/sign-in?returnTo=https%3A%2F%2Fevil.example%2F#ticket=device-ticket');
await act(async () => root.unmount());
root = createRoot(container);
await act(async () => root.render(<AccountSignIn />));
expect(sessionStorage.getItem('openbitfun.auth.returnTo')).toBeNull();
});
5 changes: 4 additions & 1 deletion src/miniapp-market-web/src/AccountSignIn.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState, type FormEvent } from 'react';
import { ArrowRight, ChevronDown, Globe, Github, KeyRound, Mail, ShieldCheck } from 'lucide-react';
import { marketApi } from './api';
import { rememberNativeAuthReturnTo } from './authReturnTo';
import { useLocale, type MessageKey } from './i18n';

export function AccountSignIn() {
Expand All @@ -19,6 +20,8 @@ export function AccountSignIn() {
const initial = useRef<Promise<void> | null>(null);
useEffect(() => { document.title = `OpenBitFun · ${t('signIn')}`; }, [t]);
useEffect(() => {
const requestedReturnTo = new URLSearchParams(window.location.search).get('returnTo');
const nativeReturnTo = rememberNativeAuthReturnTo(requestedReturnTo);
// Preserve a desktop ticket across reloads without putting its polling secret in the browser.
if (!initial.current) initial.current = (async () => {
const fragmentTicket = new URLSearchParams(window.location.hash.slice(1)).get('ticket');
Expand All @@ -28,7 +31,7 @@ export function AccountSignIn() {
setEmailEnabled(config.emailAuthConfigured === true);
setGithubEnabled(config.githubAuthConfigured);
} else {
const start = await marketApi.startLogin(new URLSearchParams(window.location.search).get('returnTo') || '/miniapp/');
const start = await marketApi.startLogin(nativeReturnTo || requestedReturnTo || '/miniapp/');
window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}#ticket=${encodeURIComponent(start.ticket)}`);
setTicket(start.ticket); setEmailEnabled(start.emailEnabled); setGithubEnabled(start.githubEnabled);
}
Expand Down
10 changes: 10 additions & 0 deletions src/miniapp-market-web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
X,
} from 'lucide-react';
import { AccountSignIn } from './AccountSignIn';
import { takeNativeAuthReturnTo } from './authReturnTo';
import { downloadUrl, loginUrl, marketApi, MarketApiError } from './api';
import { formatCompactNumber, formatMarketDate, formatMarketDateTime } from './format';
import { GetOpenBitFunCta } from './GetOpenBitFunCta';
Expand Down Expand Up @@ -1483,6 +1484,14 @@ function AdminPage({
}

function DesktopComplete({ t }: { t: (key: MessageKey) => string }) {
const [returnTo, setReturnTo] = useState<string | null>(null);
useEffect(() => {
const target = takeNativeAuthReturnTo();
if (!target) return;
setReturnTo(target);
const timer = window.setTimeout(() => window.location.replace(target), 120);
return () => window.clearTimeout(timer);
}, []);
return (
<main className="form-page">
<section className="auth-gate">
Expand All @@ -1491,6 +1500,7 @@ function DesktopComplete({ t }: { t: (key: MessageKey) => string }) {
</span>
<h1>{t('authComplete')}</h1>
<p>{t('authCompleteBody')}</p>
{returnTo && <a className="button" href={returnTo}>{t('authReturnToApp')}</a>}
</section>
</main>
);
Expand Down
47 changes: 47 additions & 0 deletions src/miniapp-market-web/src/authReturnTo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
const AUTH_RETURN_TO_KEY = 'openbitfun.auth.returnTo';
export const OPENBITFUN_AUTH_CALLBACK = 'openbitfun://auth/callback';

/**
* The return target is only a wake-up signal. Keep the allowlist exact so a
* query parameter cannot turn the completion page into an open redirect.
*/
export function parseNativeAuthReturnTo(value: string | null): string | null {
if (!value) return null;
try {
const target = new URL(value);
if (
target.protocol !== 'openbitfun:' ||
target.hostname !== 'auth' ||
target.pathname !== '/callback' ||
target.username ||
target.password ||
target.search ||
target.hash
) return null;
return OPENBITFUN_AUTH_CALLBACK;
} catch {
return null;
}
}

export function rememberNativeAuthReturnTo(value: string | null): string | null {
const target = parseNativeAuthReturnTo(value);
try {
if (target) sessionStorage.setItem(AUTH_RETURN_TO_KEY, target);
else sessionStorage.removeItem(AUTH_RETURN_TO_KEY);
} catch {
// Private browsing modes may deny sessionStorage. The manual fallback is
// still available when the marker can be read from the current URL.
}
return target;
}

export function takeNativeAuthReturnTo(): string | null {
try {
const target = parseNativeAuthReturnTo(sessionStorage.getItem(AUTH_RETURN_TO_KEY));
sessionStorage.removeItem(AUTH_RETURN_TO_KEY);
return target;
} catch {
return null;
}
}
3 changes: 3 additions & 0 deletions src/miniapp-market-web/src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ const messages = {
back: 'Back to market',
authComplete: 'Sign-in complete',
authCompleteBody: 'Return to the app or browser window where you started signing in. You can close this tab.',
authReturnToApp: 'Return to OpenBitFun app',
authSharedIdentity: 'Use the same OpenBitFun account for the marketplaces and remote device control.',
openbitfunHome: 'OpenBitFun home',
getOpenBitFunTitle: 'New to OpenBitFun?',
Expand Down Expand Up @@ -262,6 +263,7 @@ const messages = {
back: '返回市场',
authComplete: '登录完成',
authCompleteBody: '请返回发起登录的应用或浏览器窗口。你可以关闭此标签页。',
authReturnToApp: '返回 OpenBitFun 应用',
authSharedIdentity: '市场与远程设备控制共用你的 OpenBitFun 账号。',
openbitfunHome: 'OpenBitFun 官网',
getOpenBitFunTitle: '没有 OpenBitFun?',
Expand Down Expand Up @@ -436,6 +438,7 @@ const messages = {
back: '返回市場',
authComplete: '登入完成',
authCompleteBody: '請返回發起登入的應用程式或瀏覽器視窗。你可以關閉此分頁。',
authReturnToApp: '返回 OpenBitFun 應用程式',
authSharedIdentity: '市場與遠端裝置控制共用你的 OpenBitFun 帳號。',
openbitfunHome: 'OpenBitFun 官網',
getOpenBitFunTitle: '還沒有 OpenBitFun?',
Expand Down
Loading