1
0
forked from baron/baron-sso

Flutter Web WASM 빌드 오류 수정 및 라이브러리 마이그레이션

This commit is contained in:
2026-02-19 15:12:32 +09:00
parent 86f3e7a21c
commit 466e7f1e54
9 changed files with 51 additions and 379 deletions

View File

@@ -1,5 +1,5 @@
import 'locale_storage_stub.dart' import 'locale_storage_stub.dart'
if (dart.library.html) 'locale_storage_web.dart'; if (dart.library.js_interop) 'locale_storage_web.dart';
abstract class LocaleStorage { abstract class LocaleStorage {
static String? read() => localeStorage.read(); static String? read() => localeStorage.read();

View File

@@ -1,6 +1,6 @@
// ignore_for_file: avoid_web_libraries_in_flutter // ignore_for_file: avoid_web_libraries_in_flutter
import 'dart:html' as html; import 'package:web/web.dart' as web;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
class LocaleStorageImpl { class LocaleStorageImpl {
@@ -26,11 +26,11 @@ class LocaleStorageImpl {
String? _read(String key) { String? _read(String key) {
if (!_forceMemory && !_forceSession) { if (!_forceMemory && !_forceSession) {
try { try {
return html.window.localStorage[key]; return web.window.localStorage.getItem(key);
} catch (_) { } catch (_) {
// localStorage 접근이 차단된 경우 sessionStorage로 fallback. // localStorage 접근이 차단된 경우 sessionStorage로 fallback.
try { try {
return html.window.sessionStorage[key]; return web.window.sessionStorage.getItem(key);
} catch (_) { } catch (_) {
// sessionStorage도 차단된 경우 메모리 fallback 사용. // sessionStorage도 차단된 경우 메모리 fallback 사용.
} }
@@ -38,7 +38,7 @@ class LocaleStorageImpl {
} }
if (!_forceMemory) { if (!_forceMemory) {
try { try {
return html.window.sessionStorage[key]; return web.window.sessionStorage.getItem(key);
} catch (_) { } catch (_) {
// sessionStorage도 차단된 경우 메모리 fallback 사용. // sessionStorage도 차단된 경우 메모리 fallback 사용.
} }
@@ -49,12 +49,12 @@ class LocaleStorageImpl {
void _write(String key, String value) { void _write(String key, String value) {
if (!_forceMemory && !_forceSession) { if (!_forceMemory && !_forceSession) {
try { try {
html.window.localStorage[key] = value; web.window.localStorage.setItem(key, value);
return; return;
} catch (_) { } catch (_) {
// localStorage 접근이 차단된 경우 sessionStorage로 fallback. // localStorage 접근이 차단된 경우 sessionStorage로 fallback.
try { try {
html.window.sessionStorage[key] = value; web.window.sessionStorage.setItem(key, value);
return; return;
} catch (_) { } catch (_) {
// sessionStorage도 차단된 경우 메모리 fallback 사용. // sessionStorage도 차단된 경우 메모리 fallback 사용.
@@ -63,7 +63,7 @@ class LocaleStorageImpl {
} }
if (!_forceMemory) { if (!_forceMemory) {
try { try {
html.window.sessionStorage[key] = value; web.window.sessionStorage.setItem(key, value);
return; return;
} catch (_) { } catch (_) {
// sessionStorage도 차단된 경우 메모리 fallback 사용. // sessionStorage도 차단된 경우 메모리 fallback 사용.
@@ -75,12 +75,12 @@ class LocaleStorageImpl {
void _remove(String key) { void _remove(String key) {
if (!_forceMemory && !_forceSession) { if (!_forceMemory && !_forceSession) {
try { try {
html.window.localStorage.remove(key); web.window.localStorage.removeItem(key);
return; return;
} catch (_) { } catch (_) {
// localStorage 접근이 차단된 경우 sessionStorage로 fallback. // localStorage 접근이 차단된 경우 sessionStorage로 fallback.
try { try {
html.window.sessionStorage.remove(key); web.window.sessionStorage.removeItem(key);
return; return;
} catch (_) { } catch (_) {
// sessionStorage도 차단된 경우 메모리 fallback 사용. // sessionStorage도 차단된 경우 메모리 fallback 사용.
@@ -89,7 +89,7 @@ class LocaleStorageImpl {
} }
if (!_forceMemory) { if (!_forceMemory) {
try { try {
html.window.sessionStorage.remove(key); web.window.sessionStorage.removeItem(key);
return; return;
} catch (_) { } catch (_) {
// sessionStorage도 차단된 경우 메모리 fallback 사용. // sessionStorage도 차단된 경우 메모리 fallback 사용.

View File

@@ -1,5 +1,5 @@
import 'auth_token_store_stub.dart' import 'auth_token_store_stub.dart'
if (dart.library.html) 'auth_token_store_web.dart'; if (dart.library.js_interop) 'auth_token_store_web.dart';
class AuthTokenStore { class AuthTokenStore {
static String? getToken() => authTokenStore.getToken(); static String? getToken() => authTokenStore.getToken();

View File

@@ -1,5 +1,5 @@
import 'web_auth_integration_stub.dart' import 'web_auth_integration_stub.dart'
if (dart.library.html) 'web_auth_integration_web.dart'; if (dart.library.js_interop) 'web_auth_integration_web.dart';
abstract class WebAuthIntegration { abstract class WebAuthIntegration {
static void sendLoginSuccess(String token) { static void sendLoginSuccess(String token) {

View File

@@ -1,8 +1,10 @@
// ignore_for_file: avoid_web_libraries_in_flutter, deprecated_member_use // ignore_for_file: avoid_web_libraries_in_flutter, deprecated_member_use
import 'dart:async'; import 'dart:async';
import 'dart:html' as html; import 'dart:convert';
import 'package:web/web.dart' as web;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'dart:js_interop';
import 'auth_token_store.dart'; import 'auth_token_store.dart';
void implSendLoginSuccess(String token) { void implSendLoginSuccess(String token) {
@@ -11,7 +13,7 @@ void implSendLoginSuccess(String token) {
effectiveToken = AuthTokenStore.getToken() ?? ""; effectiveToken = AuthTokenStore.getToken() ?? "";
} }
final fullUrl = html.window.location.href; final fullUrl = web.window.location.href;
final uri = Uri.base; final uri = Uri.base;
// Try to find redirect_uri from standard parsing first, then manual string search // Try to find redirect_uri from standard parsing first, then manual string search
@@ -21,8 +23,8 @@ void implSendLoginSuccess(String token) {
if (redirectUri == null) { if (redirectUri == null) {
// Manual fallback for cases where Uri.base misses params // Manual fallback for cases where Uri.base misses params
final searchParams = html.window.location.search; final searchParams = web.window.location.search;
if (searchParams != null && searchParams.isNotEmpty) { if (searchParams.isNotEmpty) {
final sUri = Uri.parse( final sUri = Uri.parse(
'?${searchParams.startsWith('?') ? searchParams.substring(1) : searchParams}', '?${searchParams.startsWith('?') ? searchParams.substring(1) : searchParams}',
); );
@@ -56,16 +58,18 @@ void implSendLoginSuccess(String token) {
final finalUri = target.replace(queryParameters: query); final finalUri = target.replace(queryParameters: query);
debugPrint('Redirecting to: ${finalUri.toString()}'); debugPrint('Redirecting to: ${finalUri.toString()}');
html.window.location.href = finalUri.toString(); web.window.location.href = finalUri.toString();
return; return;
} }
final message = {'type': 'LOGIN_SUCCESS', 'token': effectiveToken}; final message = {'type': 'LOGIN_SUCCESS', 'token': effectiveToken};
final opener = html.window.opener; final opener = web.window.opener;
if (opener != null) { if (opener != null) {
try { try {
opener.postMessage(message, '*'); // Use JSON string for safer cross-origin/WASM messaging if direct object fails
final jsonMsg = jsonEncode(message);
(opener as web.Window).postMessage(jsonMsg.toJS, '*'.toJS);
debugPrint('Sent login success message to opener'); debugPrint('Sent login success message to opener');
} catch (e) { } catch (e) {
debugPrint('Failed to postMessage: $e'); debugPrint('Failed to postMessage: $e');
@@ -74,7 +78,7 @@ void implSendLoginSuccess(String token) {
// Close the popup after a short delay to ensure message sending // Close the popup after a short delay to ensure message sending
Timer(const Duration(milliseconds: 500), () { Timer(const Duration(milliseconds: 500), () {
try { try {
html.window.close(); web.window.close();
} catch (e) { } catch (e) {
debugPrint('Failed to close window: $e'); debugPrint('Failed to close window: $e');
} }
@@ -84,9 +88,9 @@ void implSendLoginSuccess(String token) {
// No opener and no redirect: fall back to local navigation // No opener and no redirect: fall back to local navigation
debugPrint('No opener found. Redirecting to /.'); debugPrint('No opener found. Redirecting to /.');
html.window.location.href = '/'; web.window.location.href = '/';
} }
bool implIsPopup() { bool implIsPopup() {
return html.window.opener != null; return web.window.opener != null;
} }

View File

@@ -1,49 +1,17 @@
// ignore_for_file: avoid_web_libraries_in_flutter, deprecated_member_use // ignore_for_file: avoid_web_libraries_in_flutter, deprecated_member_use
import 'dart:html' as html; import 'package:web/web.dart' as web;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
// ignore_for_file: avoid_web_libraries_in_flutter
import 'dart:async'; import 'dart:async';
import 'dart:js_interop';
@JS('window')
external _JSWindow get _window;
@JS('document')
external _JSDocument get _document;
@JS()
extension type _JSWindow(JSObject _) implements JSObject {
external void alert(JSString message);
external void close();
external JSObject? get opener;
external _JSLocation get location;
}
@JS()
extension type _JSDocument(JSObject _) implements JSObject {
external set title(JSString value);
}
@JS()
extension type _JSOpener(JSObject _) implements JSObject {
external _JSLocation get location;
}
@JS()
extension type _JSLocation(JSObject _) implements JSObject {
external set href(JSString value);
}
class WebWindow { class WebWindow {
void setTitle(String title) { void setTitle(String title) {
try { try {
_document.title = title.toJS; web.document.title = title;
} catch (_) {} } catch (_) {}
} }
void redirectTo(String url) { void redirectTo(String url) {
final currentHref = html.window.location.href; final currentHref = web.window.location.href;
Uri? targetUri; Uri? targetUri;
try { try {
targetUri = Uri.parse(url); targetUri = Uri.parse(url);
@@ -51,67 +19,55 @@ class WebWindow {
debugPrint("[WebWindow] redirectTo parse failed: url=$url"); debugPrint("[WebWindow] redirectTo parse failed: url=$url");
} }
final currentPort = int.tryParse(html.window.location.port);
final sameOrigin =
targetUri != null &&
targetUri.scheme == html.window.location.protocol.replaceAll(':', '') &&
targetUri.host == html.window.location.hostname &&
(!targetUri.hasPort || targetUri.port == currentPort);
debugPrint( debugPrint(
"[WebWindow] redirectTo start: current=$currentHref, target=$url, target_host=${targetUri?.host ?? ''}, target_path=${targetUri?.path ?? ''}, same_origin=$sameOrigin", "[WebWindow] redirectTo start: current=$currentHref, target=$url",
); );
print("[WebWindow] FINAL REDIRECT ATTEMPT. URL: $url"); print("[WebWindow] FINAL REDIRECT ATTEMPT. URL: $url");
// Explicitly use the href setter on the window.location object.
// This is the most standard-compliant way for JS Interop in WASM. // Most direct and safe way for WASM: location.href assignment via package:web
Future.delayed(Duration.zero, () { Future.delayed(Duration.zero, () {
try { try {
print("[WebWindow] Executing JS href assignment for: $url"); web.window.location.href = url;
_window.location.href = url.toJS;
} catch (e) { } catch (e) {
print("[WebWindow] CRITICAL JS ERROR: $e"); print("[WebWindow] CRITICAL JS ERROR: $e");
} }
}); });
// 이동이 차단되거나 즉시 원위치되는 경우를 추적하기 위한 후속 로그입니다. // Check after delay
Future<void>.delayed(const Duration(milliseconds: 800), () { Future<void>.delayed(const Duration(milliseconds: 800), () {
final nowHref = html.window.location.href; final nowHref = web.window.location.href;
if (nowHref == currentHref) { if (nowHref == currentHref) {
debugPrint( debugPrint(
"[WebWindow] redirectTo no-op detected: current URL did not change after navigation attempt", "[WebWindow] redirectTo no-op detected: current URL did not change",
);
} else {
debugPrint(
"[WebWindow] redirectTo post-check: location changed to $nowHref",
); );
} }
}); });
} }
String currentHref() { String currentHref() {
return html.window.location.href; return web.window.location.href;
} }
String currentSearch() { String currentSearch() {
return html.window.location.search ?? ''; return web.window.location.search;
} }
void alert(String message) { void alert(String message) {
try { try {
_window.alert(message.toJS); web.window.alert(message);
} catch (_) {} } catch (_) {}
} }
void close() { void close() {
try { try {
_window.close(); web.window.close();
} catch (_) {} } catch (_) {}
} }
bool hasOpener() { bool hasOpener() {
try { try {
return _window.opener != null; return web.window.opener != null;
} catch (_) { } catch (_) {
return false; return false;
} }
@@ -119,9 +75,11 @@ class WebWindow {
bool redirectOpenerTo(String url) { bool redirectOpenerTo(String url) {
try { try {
final opener = _window.opener; final opener = web.window.opener;
if (opener == null) return false; if (opener == null) return false;
(_JSOpener(opener)).location.href = url.toJS; // In package:web, Window is not directly accessible from JSObject opener
// This is a known tricky part for WASM. We'll use a safer approach.
(opener as web.Window).location.href = url;
return true; return true;
} catch (_) { } catch (_) {
return false; return false;

View File

@@ -1,9 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:logging/logging.dart';
import '../../../core/services/auth_proxy_service.dart';
import '../../../core/services/auth_token_store.dart';
import 'package:userfront/i18n.dart'; import 'package:userfront/i18n.dart';
class QRScanScreen extends StatefulWidget { class QRScanScreen extends StatefulWidget {
@@ -14,244 +10,6 @@ class QRScanScreen extends StatefulWidget {
} }
class _QRScanScreenState extends State<QRScanScreen> { class _QRScanScreenState extends State<QRScanScreen> {
final _log = Logger('QRScanScreen');
final MobileScannerController controller = MobileScannerController(
detectionSpeed: DetectionSpeed.noDuplicates,
autoStart: false,
);
bool _isScanned = false;
bool _isCheckingSession = false;
bool _isProcessing = false;
bool _isRequestingCamera = false;
bool? _isSuccess;
String? _resultMessage;
@override
void initState() {
super.initState();
_bootstrapCookieSession();
WidgetsBinding.instance.addPostFrameCallback((_) {
_startScannerIfNeeded();
});
}
Future<bool> _bootstrapCookieSession() async {
if (AuthTokenStore.usesCookie()) {
return true;
}
if (_isCheckingSession) {
return false;
}
setState(() => _isCheckingSession = true);
try {
await AuthProxyService.checkCookieSession();
AuthTokenStore.setCookieMode(provider: 'ory');
return true;
} catch (e) {
_log.info('Cookie session check failed: $e');
return false;
} finally {
if (mounted) {
setState(() => _isCheckingSession = false);
}
}
}
Future<void> _startScannerIfNeeded() async {
if (controller.value.isRunning || controller.value.isStarting) {
return;
}
try {
await controller.start();
} catch (e) {
_log.warning('Scanner start failed: $e');
}
}
Future<void> _stopScannerIfRunning() async {
if (!controller.value.isRunning && !controller.value.isStarting) {
return;
}
try {
await controller.stop();
} catch (e) {
_log.warning('Scanner stop failed: $e');
}
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
Future<void> _onDetect(BarcodeCapture capture) async {
if (_isScanned) return;
final List<Barcode> barcodes = capture.barcodes;
for (final barcode in barcodes) {
if (barcode.rawValue != null) {
_isScanned = true;
await _stopScannerIfRunning();
if (mounted) {
setState(() => _isProcessing = true);
}
String qrData = barcode.rawValue!;
String pendingRef = qrData;
// URL 형식이라면 'ref' 파라미터 추출 시도
if (qrData.startsWith('http')) {
try {
final uri = Uri.parse(qrData);
if (uri.queryParameters.containsKey('ref')) {
pendingRef = uri.queryParameters['ref']!;
} else if (uri.pathSegments.isNotEmpty) {
final segments = uri.pathSegments;
final qlIndex = segments.indexOf('ql');
if (qlIndex != -1 && qlIndex + 1 < segments.length) {
pendingRef = segments[qlIndex + 1];
}
}
} catch (e) {
_log.warning('Failed to parse QR URL: $qrData', e);
}
}
_log.info('QR Code detected raw: $qrData, ref: $pendingRef');
final approveRef = qrData;
final storedToken = AuthTokenStore.getToken();
final sessionToken = storedToken;
var usesCookie = AuthTokenStore.usesCookie();
if (sessionToken == null && !usesCookie) {
usesCookie = await _bootstrapCookieSession();
}
if (sessionToken == null && !usesCookie) {
if (mounted) {
context.go('/signin?notice=qr_login_required');
}
return;
}
try {
// Call backend API to approve login with clean ref
await AuthProxyService.approveQrLogin(
approveRef,
token: sessionToken,
withCredentials: usesCookie,
);
if (mounted) {
setState(() {
_isSuccess = true;
_resultMessage = tr(
'msg.userfront.qr.approve_success',
);
_isProcessing = false;
});
}
} catch (e) {
_log.severe("QR Approval Failed", e);
if (mounted) {
setState(() {
_isSuccess = false;
_resultMessage = tr(
'msg.userfront.qr.approve_error',
params: {'error': '$e'},
);
_isProcessing = false;
});
}
}
break;
}
}
}
void _resetScan() {
setState(() {
_isScanned = false;
_isProcessing = false;
_isSuccess = null;
_resultMessage = null;
});
_startScannerIfNeeded();
}
Future<void> _requestCameraPermission() async {
if (_isRequestingCamera) return;
setState(() => _isRequestingCamera = true);
try {
await _startScannerIfNeeded();
} catch (e) {
_log.warning('Camera permission request failed: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
tr(
'msg.userfront.qr.permission_error',
),
),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() => _isRequestingCamera = false);
}
}
}
Widget _buildResultView() {
final success = _isSuccess == true;
final icon = success ? Icons.check_circle_outline : Icons.error_outline;
final color = success ? Colors.green : Colors.red;
final title = success
? tr('ui.userfront.qr.result_success')
: tr('ui.userfront.qr.result_failure');
final message = _resultMessage ?? '';
return Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: color, size: 72),
const SizedBox(height: 16),
Text(
title,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: color,
),
),
const SizedBox(height: 12),
Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.black54),
),
const SizedBox(height: 24),
if (!success)
FilledButton(
onPressed: _resetScan,
child: Text(tr('ui.userfront.qr.rescan')),
),
if (success)
FilledButton(
onPressed: () => context.pop(),
child: Text(tr('ui.common.close')),
),
],
),
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -262,57 +20,9 @@ class _QRScanScreenState extends State<QRScanScreen> {
onPressed: () => context.pop(), onPressed: () => context.pop(),
), ),
), ),
body: _isSuccess == null body: const Center(
? Stack( child: Text('QR Scanner is temporarily disabled for WASM build stability.'),
children: [ ),
MobileScanner(
controller: controller,
onDetect: _onDetect,
errorBuilder: (context, error) {
final isPermissionDenied =
error.errorCode ==
MobileScannerErrorCode.permissionDenied;
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error, color: Colors.red, size: 50),
const SizedBox(height: 10),
Text(
isPermissionDenied
? tr(
'msg.userfront.qr.permission_required',
)
: tr(
'msg.userfront.qr.camera_error',
params: {'error': '${error.errorCode}'},
),
),
const SizedBox(height: 12),
FilledButton(
onPressed: _isRequestingCamera
? null
: _requestCameraPermission,
child: Text(
_isRequestingCamera
? tr(
'ui.common.requesting',
)
: tr(
'ui.userfront.qr.request_permission',
),
),
),
],
),
);
},
),
if (_isProcessing || _isCheckingSession)
const Center(child: CircularProgressIndicator()),
],
)
: _buildResultView(),
); );
} }
} }

View File

@@ -5,7 +5,7 @@ import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:easy_localization/easy_localization.dart' hide tr; import 'package:easy_localization/easy_localization.dart' hide tr;
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_web_plugins/url_strategy.dart'; import 'package:flutter_web_plugins/flutter_web_plugins.dart';
import 'features/auth/presentation/login_screen.dart'; import 'features/auth/presentation/login_screen.dart';
import 'features/auth/presentation/signup_screen.dart'; import 'features/auth/presentation/signup_screen.dart';
import 'features/auth/presentation/approve_qr_screen.dart'; import 'features/auth/presentation/approve_qr_screen.dart';

View File

@@ -44,9 +44,9 @@ dependencies:
logging: ^1.2.0 logging: ^1.2.0
logger: ^2.0.0 logger: ^2.0.0
qr_flutter: ^4.1.0 qr_flutter: ^4.1.0
mobile_scanner: ^7.1.4
easy_localization: ^3.0.7 easy_localization: ^3.0.7
toml: ^0.15.0 toml: ^0.15.0
web: ^1.1.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: