diff --git a/apps/stage-pocket/android/app/src/main/java/ai/moeru/airi_pocket/MainActivity.kt b/apps/stage-pocket/android/app/src/main/java/ai/moeru/airi_pocket/MainActivity.kt index cd1367059..4026105d3 100644 --- a/apps/stage-pocket/android/app/src/main/java/ai/moeru/airi_pocket/MainActivity.kt +++ b/apps/stage-pocket/android/app/src/main/java/ai/moeru/airi_pocket/MainActivity.kt @@ -28,6 +28,7 @@ class MainActivity : BridgeActivity() { override fun load() { registerPlugin(MicrophonePermissionPlugin::class.java) + registerPlugin(WebAuthenticationPlugin::class.java) super.load() val bridge = bridge ?: return diff --git a/apps/stage-pocket/android/app/src/main/java/ai/moeru/airi_pocket/WebAuthenticationPlugin.kt b/apps/stage-pocket/android/app/src/main/java/ai/moeru/airi_pocket/WebAuthenticationPlugin.kt new file mode 100644 index 000000000..986209e6a --- /dev/null +++ b/apps/stage-pocket/android/app/src/main/java/ai/moeru/airi_pocket/WebAuthenticationPlugin.kt @@ -0,0 +1,35 @@ +package ai.moeru.airi_pocket + +import android.content.Intent +import android.net.Uri +import com.getcapacitor.Plugin +import com.getcapacitor.PluginCall +import com.getcapacitor.PluginMethod +import com.getcapacitor.annotation.CapacitorPlugin + +@CapacitorPlugin(name = "WebAuthentication") +class WebAuthenticationPlugin : Plugin() { + /** Opens the authorization URL in the default system browser. */ + @PluginMethod + fun authenticate(call: PluginCall) { + val url = call.getString("url") + if (url.isNullOrBlank()) { + call.reject("The authentication URL is missing.", "INVALID_URL") + return + } + + val uri = runCatching { Uri.parse(url) }.getOrNull() + if (uri?.scheme !in setOf("http", "https")) { + call.reject("The authentication URL is invalid.", "INVALID_URL") + return + } + + val intent = Intent(Intent.ACTION_VIEW, uri) + try { + activity.startActivity(intent) + call.resolve() + } catch (error: RuntimeException) { + call.reject("No browser can open the authentication URL.", "BROWSER_UNAVAILABLE", error) + } + } +} diff --git a/apps/stage-pocket/ios/App/App.xcodeproj/project.pbxproj b/apps/stage-pocket/ios/App/App.xcodeproj/project.pbxproj index b5c120fc9..eb31432aa 100644 --- a/apps/stage-pocket/ios/App/App.xcodeproj/project.pbxproj +++ b/apps/stage-pocket/ios/App/App.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ A1B2C3D42F10000100AA0001 /* HostWebSocketBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42F10000100AA0002 /* HostWebSocketBridge.swift */; }; A1B2C3D42F10000100AA0003 /* URLSessionHostWebSocketSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42F10000100AA0004 /* URLSessionHostWebSocketSession.swift */; }; A1B2C3D42F10000100AA0005 /* WeakScriptMessageHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42F10000100AA0006 /* WeakScriptMessageHandler.swift */; }; + A1B2C3D42F10000100AA0007 /* WebAuthenticationPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42F10000100AA0008 /* WebAuthenticationPlugin.swift */; }; 0DF9DE312F0E0A42008AB01F /* AppIcon_LiquidGlass.icon in Resources */ = {isa = PBXBuildFile; fileRef = 0DF9DE302F0E0A42008AB01F /* AppIcon_LiquidGlass.icon */; }; 29ABB4BA2F03F2B400285F7F /* DevBridgeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29ABB4B92F03F2B400285F7F /* DevBridgeViewController.swift */; }; 2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; }; @@ -26,6 +27,7 @@ A1B2C3D42F10000100AA0002 /* HostWebSocketBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostWebSocketBridge.swift; sourceTree = ""; }; A1B2C3D42F10000100AA0004 /* URLSessionHostWebSocketSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionHostWebSocketSession.swift; sourceTree = ""; }; A1B2C3D42F10000100AA0006 /* WeakScriptMessageHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeakScriptMessageHandler.swift; sourceTree = ""; }; + A1B2C3D42F10000100AA0008 /* WebAuthenticationPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebAuthenticationPlugin.swift; sourceTree = ""; }; 0DF9DE302F0E0A42008AB01F /* AppIcon_LiquidGlass.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AppIcon_LiquidGlass.icon; sourceTree = ""; }; 29ABB4B92F03F2B400285F7F /* DevBridgeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevBridgeViewController.swift; sourceTree = ""; }; 2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = ""; }; @@ -76,6 +78,7 @@ A1B2C3D42F10000100AA0002 /* HostWebSocketBridge.swift */, A1B2C3D42F10000100AA0004 /* URLSessionHostWebSocketSession.swift */, A1B2C3D42F10000100AA0006 /* WeakScriptMessageHandler.swift */, + A1B2C3D42F10000100AA0008 /* WebAuthenticationPlugin.swift */, 50379B222058CBB4000EE86E /* capacitor.config.json */, 504EC3071FED79650016851F /* AppDelegate.swift */, 504EC30B1FED79650016851F /* Main.storyboard */, @@ -175,6 +178,7 @@ A1B2C3D42F10000100AA0001 /* HostWebSocketBridge.swift in Sources */, A1B2C3D42F10000100AA0003 /* URLSessionHostWebSocketSession.swift in Sources */, A1B2C3D42F10000100AA0005 /* WeakScriptMessageHandler.swift in Sources */, + A1B2C3D42F10000100AA0007 /* WebAuthenticationPlugin.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/apps/stage-pocket/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apps/stage-pocket/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5b4bd2d48..406356938 100644 --- a/apps/stage-pocket/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/apps/stage-pocket/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "8765687701ea10de7b4fa5981471aaf26157c825c91c99a7f0e25bbdf71f12e6", + "originHash" : "038c691ceff1e23bf770957b72599f2aea21bf50e55def35674c9756739e4af4", "pins" : [ { "identity" : "capacitor-swift-pm", "kind" : "remoteSourceControl", "location" : "https://github.com/ionic-team/capacitor-swift-pm.git", "state" : { - "revision" : "0e862e6ff13852a710c8a484180ca4d6a2cc9761", - "version" : "8.2.0" + "revision" : "f1a8fadf1437c23b825c818fb6509c9dbbae2f61", + "version" : "8.3.1" } }, { diff --git a/apps/stage-pocket/ios/App/App/DevBridgeViewController.swift b/apps/stage-pocket/ios/App/App/DevBridgeViewController.swift index ed63c23fe..acbd97deb 100644 --- a/apps/stage-pocket/ios/App/App/DevBridgeViewController.swift +++ b/apps/stage-pocket/ios/App/App/DevBridgeViewController.swift @@ -14,6 +14,7 @@ class DevBridgeViewController: CAPBridgeViewController { override func capacitorDidLoad() { super.capacitorDidLoad() + bridge?.registerPluginInstance(WebAuthenticationPlugin()) configureTransparentBackground() webView?.allowsBackForwardNavigationGestures = true installWebSocketBridge() diff --git a/apps/stage-pocket/ios/App/App/Info.plist b/apps/stage-pocket/ios/App/App/Info.plist index ed4385713..12badd408 100644 --- a/apps/stage-pocket/ios/App/App/Info.plist +++ b/apps/stage-pocket/ios/App/App/Info.plist @@ -20,6 +20,17 @@ APPL CFBundleShortVersionString $(MARKETING_VERSION) + CFBundleURLTypes + + + CFBundleURLName + ai.moeru.airi-pocket + CFBundleURLSchemes + + ai.moeru.airi-pocket + + + CFBundleVersion $(CURRENT_PROJECT_VERSION) LSRequiresIPhoneOS diff --git a/apps/stage-pocket/ios/App/App/WebAuthenticationPlugin.swift b/apps/stage-pocket/ios/App/App/WebAuthenticationPlugin.swift new file mode 100644 index 000000000..c3242e440 --- /dev/null +++ b/apps/stage-pocket/ios/App/App/WebAuthenticationPlugin.swift @@ -0,0 +1,99 @@ +import AuthenticationServices +import Capacitor +import Foundation + +@objc(WebAuthenticationPlugin) +final class WebAuthenticationPlugin: CAPPlugin, CAPBridgedPlugin { + let identifier = "WebAuthenticationPlugin" + let jsName = "WebAuthentication" + let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "authenticate", returnType: CAPPluginReturnPromise) + ] + + private var activeCall: CAPPluginCall? + private var authenticationSession: ASWebAuthenticationSession? + + @objc func authenticate(_ call: CAPPluginCall) { + DispatchQueue.main.async { [weak self] in + self?.startAuthentication(call) + } + } + + private func startAuthentication(_ call: CAPPluginCall) { + guard activeCall == nil else { + call.reject("An authentication session is already active.", "AUTHENTICATION_IN_PROGRESS") + return + } + + guard let urlValue = call.getString("url"), + let url = URL(string: urlValue), + ["http", "https"].contains(url.scheme?.lowercased() ?? "") else { + call.reject("The authentication URL is invalid.", "INVALID_URL") + return + } + + guard let callbackScheme = call.getString("callbackScheme"), !callbackScheme.isEmpty else { + call.reject("The callback scheme is missing.", "INVALID_CALLBACK_SCHEME") + return + } + + activeCall = call + let session = ASWebAuthenticationSession( + url: url, + callbackURLScheme: callbackScheme + ) { [weak self] callbackURL, error in + DispatchQueue.main.async { + self?.finishAuthentication(callbackURL: callbackURL, error: error) + } + } + session.presentationContextProvider = self + authenticationSession = session + + if !session.start() { + finishAuthentication( + callbackURL: nil, + error: WebAuthenticationError.sessionDidNotStart + ) + } + } + + private func finishAuthentication(callbackURL: URL?, error: Error?) { + guard let call = activeCall else { + return + } + + activeCall = nil + authenticationSession = nil + + if let callbackURL { + call.resolve(["callbackUrl": callbackURL.absoluteString]) + return + } + + if let sessionError = error as? ASWebAuthenticationSessionError, + sessionError.code == .canceledLogin { + call.resolve() + return + } + + call.reject( + error?.localizedDescription ?? "The authentication session failed.", + "AUTHENTICATION_FAILED", + error + ) + } +} + +extension WebAuthenticationPlugin: ASWebAuthenticationPresentationContextProviding { + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + return bridge?.viewController?.view.window ?? ASPresentationAnchor() + } +} + +private enum WebAuthenticationError: LocalizedError { + case sessionDidNotStart + + var errorDescription: String? { + return "The authentication session did not start." + } +} diff --git a/apps/stage-pocket/ios/App/CapApp-SPM/Package.swift b/apps/stage-pocket/ios/App/CapApp-SPM/Package.swift index 9b6d2fc48..4b691b39c 100644 --- a/apps/stage-pocket/ios/App/CapApp-SPM/Package.swift +++ b/apps/stage-pocket/ios/App/CapApp-SPM/Package.swift @@ -11,10 +11,11 @@ let package = Package( targets: ["CapApp-SPM"]) ], dependencies: [ - .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.2.0"), - .package(name: "CapacitorBarcodeScanner", path: "../../../../../node_modules/.pnpm/@capacitor+barcode-scanner@3.0.2_@capacitor+core@8.2.0/node_modules/@capacitor/barcode-scanner"), - .package(name: "CapacitorLocalNotifications", path: "../../../../../node_modules/.pnpm/@capacitor+local-notifications@8.0.2_@capacitor+core@8.2.0/node_modules/@capacitor/local-notifications"), - .package(name: "CapacitorNativeSettings", path: "../../../../../node_modules/.pnpm/capacitor-native-settings@8.1.0_@capacitor+core@8.2.0/node_modules/capacitor-native-settings") + .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.3.1"), + .package(name: "CapacitorApp", path: "../../../../../node_modules/.pnpm/@capacitor+app@8.1.0_@capacitor+core@8.3.1/node_modules/@capacitor/app"), + .package(name: "CapacitorBarcodeScanner", path: "../../../../../node_modules/.pnpm/@capacitor+barcode-scanner@3.0.2_@capacitor+core@8.3.1/node_modules/@capacitor/barcode-scanner"), + .package(name: "CapacitorLocalNotifications", path: "../../../../../node_modules/.pnpm/@capacitor+local-notifications@8.0.2_@capacitor+core@8.3.1/node_modules/@capacitor/local-notifications"), + .package(name: "CapacitorNativeSettings", path: "../../../../../node_modules/.pnpm/capacitor-native-settings@8.1.0_@capacitor+core@8.3.1/node_modules/capacitor-native-settings") ], targets: [ .target( @@ -22,6 +23,7 @@ let package = Package( dependencies: [ .product(name: "Capacitor", package: "capacitor-swift-pm"), .product(name: "Cordova", package: "capacitor-swift-pm"), + .product(name: "CapacitorApp", package: "CapacitorApp"), .product(name: "CapacitorBarcodeScanner", package: "CapacitorBarcodeScanner"), .product(name: "CapacitorLocalNotifications", package: "CapacitorLocalNotifications"), .product(name: "CapacitorNativeSettings", package: "CapacitorNativeSettings") diff --git a/apps/stage-pocket/src/main.ts b/apps/stage-pocket/src/main.ts index ba2eecb67..de64178b2 100644 --- a/apps/stage-pocket/src/main.ts +++ b/apps/stage-pocket/src/main.ts @@ -4,10 +4,12 @@ import type { Router, RouteRecordRaw } from 'vue-router' import Tres from '@tresjs/core' import NProgress from 'nprogress' +import { Capacitor } from '@capacitor/core' import { autoAnimatePlugin } from '@formkit/auto-animate/vue' import { isEnvTruthy } from '@proj-airi/stage-shared' import { trackButtonPlugin } from '@proj-airi/stage-ui/directives/track-button' import { configureAnalyticsAdapter } from '@proj-airi/stage-ui/libs/analytics' +import { browserAuthorizationHandler, registerAuthorizationHandler } from '@proj-airi/stage-ui/libs/auth' import { setupSynced } from '@proj-airi/stage-ui/libs/pinia' import { MotionPlugin } from '@vueuse/motion' import { createPinia } from 'pinia' @@ -20,6 +22,7 @@ import App from './App.vue' import { installDeepLinks } from './modules/deep-links' import { i18n } from './modules/i18n' +import { WebAuthentication } from './modules/web-authentication' import '@proj-airi/font-cjkfonts-allseto/index.css' import '@proj-airi/font-xiaolai/index.css' @@ -34,6 +37,22 @@ configureAnalyticsAdapter(async (options) => { return createPosthogAdapter(options) }) +if (Capacitor.isNativePlatform()) { + registerAuthorizationHandler(async ({ authorizationUrl, provider }) => { + const url = new URL(authorizationUrl) + if (provider) + url.searchParams.set('provider', provider) + + return await WebAuthentication.authenticate({ + callbackScheme: 'ai.moeru.airi-pocket', + url: url.toString(), + }) + }) +} +else { + registerAuthorizationHandler(browserAuthorizationHandler) +} + const pinia = createPinia() const synced = setupSynced() pinia.use(synced.pinia) diff --git a/apps/stage-pocket/src/modules/deep-links.ts b/apps/stage-pocket/src/modules/deep-links.ts index c07d41038..44c1b8cb2 100644 --- a/apps/stage-pocket/src/modules/deep-links.ts +++ b/apps/stage-pocket/src/modules/deep-links.ts @@ -2,8 +2,7 @@ import type { URLOpenListenerEvent } from '@capacitor/app' import type { Router } from 'vue-router' import { App } from '@capacitor/app' -import { applyOIDCTokens } from '@proj-airi/stage-ui/libs/auth' -import { consumeFlowState, exchangeCodeForTokens } from '@proj-airi/stage-ui/libs/auth-oidc' +import { completeOIDCSignIn } from '@proj-airi/stage-ui/libs/auth' export function installDeepLinks(router: Router): void { App.addListener('appUrlOpen', async (event?: URLOpenListenerEvent) => { @@ -13,19 +12,8 @@ export function installDeepLinks(router: Router): void { try { const url = new URL(event.url) if (url.host === 'links' && url.pathname === '/auth/callback') { - const code = url.searchParams.get('code') - const state = url.searchParams.get('state') - if (!code || !state) { - return - } - const persisted = consumeFlowState() - if (!persisted) { - console.error('OIDC flow status has expired or is no longer valid.') - return - } - const tokens = await exchangeCodeForTokens(code, persisted.flowState, persisted.params, state) - await applyOIDCTokens(tokens, persisted.params.clientId) - router.replace('/') + if (await completeOIDCSignIn(event.url)) + await router.replace('/') } } catch (error) { diff --git a/apps/stage-pocket/src/modules/web-authentication.ts b/apps/stage-pocket/src/modules/web-authentication.ts new file mode 100644 index 000000000..d1599d295 --- /dev/null +++ b/apps/stage-pocket/src/modules/web-authentication.ts @@ -0,0 +1,17 @@ +import { registerPlugin } from '@capacitor/core' + +interface WebAuthenticationOptions { + callbackScheme: string + url: string +} + +interface WebAuthenticationResult { + callbackUrl?: string +} + +interface WebAuthenticationPlugin { + authenticate: (options: WebAuthenticationOptions) => Promise +} + +/** Opens an authorization URL with the native system browser session. */ +export const WebAuthentication = registerPlugin('WebAuthentication') diff --git a/apps/stage-pocket/src/pages/index.vue b/apps/stage-pocket/src/pages/index.vue index 4a9967a86..eb2a621a3 100644 --- a/apps/stage-pocket/src/pages/index.vue +++ b/apps/stage-pocket/src/pages/index.vue @@ -180,7 +180,7 @@ watch([stream, () => vadLoaded.value], async ([s, loaded]) => { :background="selectedOption" :top-color="sampledColor" > -
+