update electron android adb over browser

This commit is contained in:
Kenta420 2024-01-19 17:53:48 +07:00
parent 21109e4bf9
commit f6295a9c2f
26 changed files with 1551 additions and 172 deletions

View file

@ -34,7 +34,7 @@ We have some environment variables to control app behavior. The environment vari
## Environment Mode ## Environment Mode
In this Client project, we have two environment modes: `electron` and `web`. Business logic will be different in these two modes. you wrap the code in `if (import.meta.env.MODE === 'electron')` to run the code in electron mode. You can also use `if (import.meta.env.MODE === 'web')` to run the code in web mode. In this Client project, we have two environment modes: `electron` and `web`. Business logic will be different in these two modes. you wrap the code in `if (window.electronRuntime)` to run the code in electron mode. You can also use `if (!window.electronRuntime)` to run the code in web mode.
The reason why we have two environment modes is that in electron we have to use native modules for example `deeplink` to handle login callback. But in web, we have to use `window.location.href` to handle login callback. The reason why we have two environment modes is that in electron we have to use native modules for example `deeplink` to handle login callback. But in web, we have to use `window.location.href` to handle login callback.

View file

@ -0,0 +1,19 @@
// import type { AdbDaemonWebUsbDevice } from '@yume-chan/adb-daemon-webusb'
// import { AdbDaemonWebUsbDeviceManager } from '@yume-chan/adb-daemon-webusb'
import { webusb } from 'usb'
// const WebUsb: WebUSB = new WebUSB({ allowAllDevices: true })
// const Manager: AdbDaemonWebUsbDeviceManager = new AdbDaemonWebUsbDeviceManager(
// WebUsb
// )
export function getDevices() {
// const devices: AdbDaemonWebUsbDevice[] = await Manager.getDevices()
// if (!devices.length) {
// alert('No device connected')
// }
webusb.requestDevice({ filters: [] }).then(device => {
console.log(device)
})
}

View file

@ -0,0 +1,34 @@
import { type BrowserWindow } from 'electron'
export default function (
app: Electron.App,
win: BrowserWindow | null,
ipcMain: Electron.IpcMain,
shell: Electron.Shell
) {
ipcMain.on('deeplink', (_event, url) => {
// open browser
shell.openExternal(url)
})
app.on('open-url', (_event, url) => {
const paramsString = url.split('://')[1]
const kind = paramsString.split('?')[0]
const params = new URLSearchParams(paramsString)
if (kind === '/login') {
win?.webContents.send('loginSuccess', {
id: params.get('id'),
name: params.get('name'),
email: params.get('email'),
picture: params.get('picture'),
permissions: params.get('permissions'),
access_token: params.get('access_token'),
max_age: params.get('max_age'),
refresh_token: params.get('refresh_token')
})
}
})
}

View file

@ -25,4 +25,6 @@ declare namespace NodeJS {
// Used in Renderer process, expose in `preload.ts` // Used in Renderer process, expose in `preload.ts`
interface Window { interface Window {
ipcRenderer: import('electron').IpcRenderer ipcRenderer: import('electron').IpcRenderer
electronRuntime: boolean
platform: NodeJS.Platform
} }

View file

@ -0,0 +1,22 @@
import { findCredentials, getPassword, setPassword } from '@postman/node-keytar'
export function eventGetKeyChain(icpMain: Electron.IpcMain) {
icpMain.on('get-keyChain', (event, serviceName, account) => {
getPassword(serviceName, account).then(password => {
event.returnValue = password
})
})
icpMain.on('set-keyChain', (_event, serviceName, account, password) => {
setPassword(serviceName, account, password)
})
icpMain.on('delete-keyChain', (_event, serviceName, account) => {
setPassword(serviceName, account, '')
})
icpMain.handle('keyChainSync', async (_event, serviceName) => {
const credentials = await findCredentials(serviceName)
return credentials
})
}

View file

@ -1,5 +1,7 @@
import { app, BrowserWindow, ipcMain, shell } from 'electron' import { app, BrowserWindow, ipcMain, shell } from 'electron'
import path from 'node:path' import path from 'node:path'
import deeplink from './deeplink'
import { eventGetKeyChain } from './keychain'
// The built directory structure // The built directory structure
// //
@ -10,7 +12,7 @@ import path from 'node:path'
// │ │ ├── main.js // │ │ ├── main.js
// │ │ └── preload.js // │ │ └── preload.js
// │ // │
process.env.DIST = path.join(__dirname, '../dist') process.env.DIST = path.join(__dirname, '../dist-renderer')
process.env.VITE_PUBLIC = app.isPackaged process.env.VITE_PUBLIC = app.isPackaged
? process.env.DIST ? process.env.DIST
: path.join(process.env.DIST, '../public') : path.join(process.env.DIST, '../public')
@ -42,6 +44,61 @@ function createWindow() {
app.setAsDefaultProtocolClient('taobin-electron') app.setAsDefaultProtocolClient('taobin-electron')
} }
let grantedDeviceThroughPermHandler: Electron.USBDevice
win.webContents.session.on(
'select-usb-device',
(event, details, callback) => {
// Add events to handle devices being added or removed before the callback on
// `select-usb-device` is called.
win?.webContents.session.on('usb-device-added', (_event, device) => {
console.log('usb-device-added FIRED WITH', device)
// Optionally update details.deviceList
})
win?.webContents.session.on('usb-device-removed', (_event, device) => {
console.log('usb-device-removed FIRED WITH', device)
// Optionally update details.deviceList
})
event.preventDefault()
if (details.deviceList && details.deviceList.length > 0) {
const deviceToReturn = details.deviceList.find(
device => device.vendorId === 1478
)
if (deviceToReturn) {
callback(deviceToReturn.deviceId)
} else {
callback()
}
}
}
)
win.webContents.session.setPermissionCheckHandler(
(_webContents, permission) => {
if (permission === 'usb') {
return true
}
return false
}
)
win.webContents.session.setDevicePermissionHandler(details => {
console.log(details)
if (details.deviceType === 'usb' && details.device.vendorId == 1478) {
if (!grantedDeviceThroughPermHandler) {
grantedDeviceThroughPermHandler = details.device as Electron.USBDevice
return true
} else if (grantedDeviceThroughPermHandler.vendorId === 1478) {
return true
} else {
return false
}
}
return false
})
if (VITE_DEV_SERVER_URL) { if (VITE_DEV_SERVER_URL) {
win.loadURL(VITE_DEV_SERVER_URL) win.loadURL(VITE_DEV_SERVER_URL)
} else { } else {
@ -68,17 +125,12 @@ app.on('activate', () => {
} }
}) })
app.on('open-url', (_event, url) => { // Create MainWindow, load the rest of the app, etc...
win?.webContents.send('deeplink', url)
})
// Create mainWindow, load the rest of the app, etc...
app.whenReady().then(() => { app.whenReady().then(() => {
createWindow() createWindow()
}) // deeplink
deeplink(app, win, ipcMain, shell)
// deeplink //keychain
ipcMain.on('deeplink', (_event, url) => { eventGetKeyChain(ipcMain)
// open browser
shell.openExternal(url)
}) })

View file

@ -4,6 +4,8 @@ import { contextBridge, ipcRenderer } from 'electron'
// --------- Expose some API to the Renderer process --------- // --------- Expose some API to the Renderer process ---------
contextBridge.exposeInMainWorld('ipcRenderer', withPrototype(ipcRenderer)) contextBridge.exposeInMainWorld('ipcRenderer', withPrototype(ipcRenderer))
contextBridge.exposeInMainWorld('electronRuntime', true)
contextBridge.exposeInMainWorld('platform', process.platform)
// `exposeInMainWorld` can't detect attributes and methods of `prototype`, manually patching it. // `exposeInMainWorld` can't detect attributes and methods of `prototype`, manually patching it.
function withPrototype(obj: Record<string, any>) { function withPrototype(obj: Record<string, any>) {

File diff suppressed because it is too large Load diff

View file

@ -2,24 +2,42 @@
"name": "client-electron", "name": "client-electron",
"private": true, "private": true,
"version": "0.0.1", "version": "0.0.1",
"author": {
"name": "Kenta420",
"email": "poomipat.c@forth.ac.th"
},
"description": "An Application for Taobin to manage recipes and ingredients",
"scripts": { "scripts": {
"dev": "concurrently \"npm run dev:electron\" \"npm run dev:web\"", "dev": "concurrently \"APP_KIND=electron npm run dev:electron\" \"APP_KIND=web npm run dev:web\"",
"dev:electron": "vite -c vite.config.electron.ts", "dev:electron": "vite -c vite.config.electron.ts",
"dev:web": "vite -c vite.config.web.ts --open", "dev:web": "vite -c vite.config.web.ts --open",
"build": "concurrently \"npm run build:electron\" \"npm run build:web\"", "build": "concurrently \"npm run build:electron\" \"npm run build:web\"",
"build:electron": "tsc && vite build -c vite.config.electron.ts --mode=production && electron-builder", "build:electron": "npm run prebuild:electron && tsc && vite build -c vite.config.electron.ts --mode=production && electron-builder",
"build:web": "tsc && vite build -c vite.config.web.ts --mode=production", "build:web": "npm run prebuild:web && tsc && vite build -c vite.config.web.ts --mode=production",
"prebuild:electron": "rm -rf dist-renderer && rm -rf dist-electron && vite optimize -c vite.config.electron.ts -m production",
"prebuild:web": "rm -rf dist-web && vite optimize -c vite.config.web.ts -m production",
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@postman/node-keytar": "^7.9.3",
"@yume-chan/adb": "^0.0.22",
"@yume-chan/adb-credential-web": "^0.0.22",
"@yume-chan/adb-daemon-webusb": "^0.0.22",
"@yume-chan/adb-scrcpy": "^0.0.22",
"@yume-chan/scrcpy": "^0.0.22",
"@yume-chan/scrcpy-decoder-webcodecs": "^0.0.22",
"@yume-chan/stream-extra": "^0.0.22",
"axios": "^1.6.5",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.21.1", "react-router-dom": "^6.21.1",
"usb": "^2.11.0",
"zustand": "^4.4.7" "zustand": "^4.4.7"
}, },
"devDependencies": { "devDependencies": {
"@electron-forge/cli": "^7.2.0", "@electron-forge/cli": "^7.2.0",
"@electron/rebuild": "^3.5.0",
"@types/react": "^18.2.21", "@types/react": "^18.2.21",
"@types/react-dom": "^18.2.7", "@types/react-dom": "^18.2.7",
"@typescript-eslint/eslint-plugin": "^6.18.1", "@typescript-eslint/eslint-plugin": "^6.18.1",

View file

@ -6,26 +6,36 @@ import {
} from 'react-router-dom' } from 'react-router-dom'
import AuthCallBack from './AuthCallBack' import AuthCallBack from './AuthCallBack'
import MainLayout from './layouts/MainLayout' import MainLayout from './layouts/MainLayout'
import Home from './pages/Home' import HomePage from './pages/Home'
import LoginPage from './pages/Login'
import AndroidPage from './pages/Android'
function router() { function router() {
const routes: RouteObject[] = [ const routes: RouteObject[] = [
{ {
path: '/', path: '/',
element: ( element: <MainLayout />,
<MainLayout>
<Home />
</MainLayout>
),
children: [ children: [
{
path: '/',
element: <HomePage />
},
{ {
path: 'recipes', path: 'recipes',
element: <div>Recipes</div> element: <div>Recipes</div>
},
{
path: 'android',
element: <AndroidPage />
} }
] ]
}, },
{ {
path: '/auth/callback', path: '/login',
element: <LoginPage />
},
{
path: '/callback',
element: <AuthCallBack /> element: <AuthCallBack />
}, },
{ {
@ -38,8 +48,7 @@ function router() {
) )
} }
] ]
if (window.electronRuntime) {
if (import.meta.env.MODE == 'electron') {
return createHashRouter(routes) return createHashRouter(routes)
} }
return createBrowserRouter(routes) return createBrowserRouter(routes)

View file

@ -3,16 +3,24 @@
// then emit a message to the main process to close the window // then emit a message to the main process to close the window
const AuthCallBack: React.FC = () => { const AuthCallBack: React.FC = () => {
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
// emit message to main process // emit message to main process
window.opener.postMessage( window.opener.postMessage(
{ {
payload: 'loginSuccess', payload: 'loginSuccess',
data: params data: {
id: params.get('id'),
name: params.get('name'),
email: params.get('email'),
picture: params.get('picture'),
permissions: params.get('permissions')
}
}, },
'*' '*'
) )
window.close()
return <div>it just call back</div> return <div>it just call back</div>
} }

Binary file not shown.

View file

@ -0,0 +1,19 @@
import {
type AdbDaemonWebUsbDevice,
AdbDaemonWebUsbDeviceManager
} from '@yume-chan/adb-daemon-webusb'
import { create } from 'zustand'
interface ADB {
manager: AdbDaemonWebUsbDeviceManager | undefined
device: AdbDaemonWebUsbDevice | undefined
setDevice: (device: AdbDaemonWebUsbDevice | undefined) => void
}
const useAdb = create<ADB>(set => ({
manager: AdbDaemonWebUsbDeviceManager.BROWSER,
device: undefined,
setDevice: device => set({ device })
}))
export default useAdb

View file

@ -1,14 +1,40 @@
import { create } from 'zustand' import { create } from 'zustand'
import customAxios from '../utils/customAxios'
interface UserAuth { export interface UserInfo {
username: string | undefined id: string
email: string | undefined name: string
email: string
picture: string
permissions: string[]
} }
const userAuthStore = create<UserAuth>(() => ({ interface UserAuth {
username: undefined, userInfo: UserInfo | null
email: undefined setUserInfo: (userInfo: UserInfo | null) => void
getUserInfo: () => Promise<UserInfo | null>
logout: () => void
}
const userAuthStore = create<UserAuth>(set => ({
userInfo: null,
setUserInfo: userInfo => set({ userInfo }),
getUserInfo: () => {
return customAxios
.get<UserInfo>('/user/me')
.then(res => {
set({ userInfo: res.data })
return res.data
})
.catch(() => {
set({ userInfo: null })
return null
})
},
logout: () => {
customAxios.post('/auth/logout')
set({ userInfo: null })
}
})) }))
export const getUserName = userAuthStore(state => state.username) export default userAuthStore
export const getUserEmail = userAuthStore(state => state.email)

View file

@ -1,12 +1,41 @@
import { Outlet, useNavigate } from 'react-router-dom'
import Header from '../components/Header' import Header from '../components/Header'
import Sidebar from '../components/Sidebar' import Sidebar from '../components/Sidebar'
import userAuthStore from '../hooks/userAuth'
import { useShallow } from 'zustand/react/shallow'
import { useCallback } from 'react'
const MainLayout = () => {
const { userInfo, getUserInfo } = userAuthStore(
useShallow(state => ({
userInfo: state.userInfo,
getUserInfo: state.getUserInfo
}))
)
const navigate = useNavigate()
// get current path
const currentPath = window.location.pathname
useCallback(() => {
console.log(import.meta.env.NODE_ENV)
if (!userInfo && import.meta.env.NODE_ENV !== 'development') {
getUserInfo().then(userInfo => {
// if still not login then redirect to login page
if (!userInfo) {
navigate('/login?redirect=' + currentPath)
}
})
}
}, [userInfo, getUserInfo, navigate, currentPath])
const MainLayout = ({ children }: React.PropsWithChildren) => {
return ( return (
<div> <div>
<Sidebar /> <Sidebar />
<Header /> <Header />
<main className="fixed pt-14 px-4 pl-[21rem]">{children}</main> <main className="fixed pt-14 px-4 pl-[21rem]">
<Outlet />
</main>
</div> </div>
) )
} }

View file

@ -9,9 +9,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
</React.StrictMode> </React.StrictMode>
) )
console.log(import.meta.env.MODE) if (window.electronRuntime) {
if (import.meta.env.MODE === 'electron') {
// Remove Preload scripts loading // Remove Preload scripts loading
postMessage({ payload: 'removeLoading' }, '*') postMessage({ payload: 'removeLoading' }, '*')
@ -20,6 +18,41 @@ if (import.meta.env.MODE === 'electron') {
console.log(message) console.log(message)
}) })
window.ipcRenderer
.invoke(
'keyChainSync',
import.meta.env.TAOBIN_RECIPE_MANAGER_KEY_CHAIN_SERVICE_NAME
)
.then(result => {
console.log(result)
})
// getPassword(
// import.meta.env.TAOBIN_RECIPE_MANAGER_KEY_CHAIN_SERVICE_NAME,
// import.meta.env.TAOBIN_RECIPE_MANAGER_KEY_CHAIN_ACCOUNT_ACCESS_TOKEN
// ).then(tokenMaxAge => {
// if (tokenMaxAge) {
// const [token, max_age] = tokenMaxAge.split(';')
// document.cookie =
// 'access_token=' +
// token +
// '; Path=/; HttpOnly; SameSite=None; Secure; Max-Age=' +
// max_age
// }
// })
// getPassword(
// import.meta.env.TAOBIN_RECIPE_MANAGER_KEY_CHAIN_SERVICE_NAME,
// import.meta.env.TAOBIN_RECIPE_MANAGER_KEY_CHAIN_ACCOUNT_REFRESH_TOKEN
// ).then(refreshToken => {
// if (refreshToken) {
// document.cookie =
// 'refresh_token=' +
// refreshToken +
// '; Path=/; HttpOnly; SameSite=None; Secure;'
// }
// })
// Use deep link // Use deep link
window.ipcRenderer.on('deeplink', (_event, url) => { window.ipcRenderer.on('deeplink', (_event, url) => {
console.log(url) console.log(url)

View file

@ -0,0 +1,178 @@
import { useShallow } from 'zustand/react/shallow'
import { ADB_DEFAULT_DEVICE_FILTER } from '@yume-chan/adb-daemon-webusb'
import AdbWebCredentialStore from '@yume-chan/adb-credential-web'
import { Adb, AdbDaemonTransport } from '@yume-chan/adb'
import useAdb from '../hooks/adb'
import type { AdbScrcpyVideoStream } from '@yume-chan/adb-scrcpy'
import { AdbScrcpyClient, AdbScrcpyOptions2_1 } from '@yume-chan/adb-scrcpy'
import { WebCodecsDecoder } from '@yume-chan/scrcpy-decoder-webcodecs'
import {
ScrcpyLogLevel1_18,
ScrcpyOptions2_1,
ScrcpyVideoCodecId
} from '@yume-chan/scrcpy'
import { useState } from 'react'
const ScreenStream: React.FC<{ child: HTMLCanvasElement }> = ({
child
}: {
child: HTMLCanvasElement
}) => {
return <div ref={ref => ref?.appendChild(child)}></div>
}
const AndroidPage: React.FC = () => {
const { manager, device, setDevice } = useAdb(
useShallow(state => ({
manager: state.manager,
device: state.device,
setDevice: state.setDevice
}))
)
const [decoder, setDecoder] = useState<WebCodecsDecoder | undefined>()
const [client, setClient] = useState<AdbScrcpyClient | undefined>()
const [adbClient, setAdbClient] = useState<Adb | undefined>(undefined)
function attachDevice() {
manager
?.requestDevice({
filters: [
{
...ADB_DEFAULT_DEVICE_FILTER,
vendorId: 1478
}
]
})
.then(selectedDevice => {
if (!selectedDevice) {
return
} else {
setDevice(selectedDevice)
}
})
}
function disConnectDevice() {
device?.raw.forget()
setDevice(undefined)
}
// function reboot() {
// device?.connect().then(connection => {
// const credentialStore: AdbWebCredentialStore = new AdbWebCredentialStore()
// AdbDaemonTransport.authenticate({
// serial: device?.serial,
// connection,
// credentialStore: credentialStore
// }).then(transport => {
// const adb: Adb = new Adb(transport)
// adb.power.reboot().then(() => {
// console.log('reboot success')
// })
// })
// })
// }
async function scrcpyConnect() {
// const url = new URL('../bin/scrcpy-server.bin', import.meta.url)
// const server: ArrayBuffer = await fetch(url).then(res => res.arrayBuffer())
const connection = await device?.connect()
const credentialStore: AdbWebCredentialStore = new AdbWebCredentialStore()
const transport = await AdbDaemonTransport.authenticate({
serial: device!.serial,
connection: connection!,
credentialStore: credentialStore
})
const adb: Adb = new Adb(transport)
setAdbClient(adb)
// await AdbScrcpyClient.pushServer(
// adb,
// new ReadableStream({
// start(controller) {
// controller.enqueue(new Consumable(new Uint8Array(server)))
// controller.close()
// }
// })
// )
await adb.subprocess.spawn(
'CLASSPATH=/data/local/tmp/scrcpy-server.jar app_process / com.genymobile.scrcpy.Server 2.1'
)
const scrcpyOption = new ScrcpyOptions2_1({
audio: false,
maxFps: 60,
control: false,
video: true,
logLevel: ScrcpyLogLevel1_18.Debug,
videoCodec: 'h264',
stayAwake: true,
cleanup: true
})
const _client = await AdbScrcpyClient.start(
adb,
'/data/local/tmp/scrcpy-server.jar',
'2.1',
new AdbScrcpyOptions2_1(scrcpyOption)
)
setClient(_client)
}
async function scrcpyStream() {
const videoStream: AdbScrcpyVideoStream | undefined =
await client?.videoStream
const _decoder = new WebCodecsDecoder(ScrcpyVideoCodecId.H264)
videoStream?.stream.pipeTo(_decoder.writable)
setDecoder(_decoder)
}
function scrcpyDisconnect() {
client?.close()
setClient(undefined)
setDecoder(undefined)
}
function rebootDevice() {
adbClient?.power.reboot()
}
return (
<div>
<h1>This is Android Page!!!!!!!</h1>
{device ? <h2>Device: {device.name}</h2> : <h2>No Device</h2>}
<div className="flex flex-row">
<button className="btn btn-primary" onClick={attachDevice}>
Attach Device
</button>
<button className="btn btn-primary" onClick={disConnectDevice}>
Disconnect Device
</button>
<button className="btn btn-primary" onClick={rebootDevice}>
Reboot Device
</button>
</div>
<div className="flex flex-row">
<button className="btn btn-primary" onClick={scrcpyConnect}>
Scrcpy Connect
</button>
<button className="btn btn-primary" onClick={scrcpyStream}>
Scrcpy Stream
</button>
<button className="btn btn-primary" onClick={scrcpyDisconnect}>
Scrcpy Disconnect
</button>
</div>
{decoder ? <ScreenStream child={decoder.renderer} /> : ''}
</div>
)
}
export default AndroidPage

View file

@ -1,51 +1,15 @@
import React from 'react' import React from 'react'
import googleLogo from '../assets/google-color.svg' import { Link } from 'react-router-dom'
const Home: React.FC = () => {
console.log(import.meta.env.TAOBIN_RECIPE_MANAGER_SERVER_URL)
const loginWithGoogle = () => {
// if is web mode then use window.open
// if is electron mode then use ipcRenderer.send to use deep link method
if (import.meta.env.MODE === 'web') {
// open new window and listen to message from window.opener
const newWindow = window.open(
import.meta.env.TAOBIN_RECIPE_MANAGER_SERVER_URL,
'_blank',
'width=500,height=600'
)
// listen to message from new window
window.addEventListener('message', event => {
if (event.data.payload === 'loginSuccess') {
// close new window
newWindow?.close()
console.log(event.data)
}
})
} else {
window.ipcRenderer.send('deeplink', 'http://127.0.0.1:5500/test.html')
}
}
const HomePage: React.FC = () => {
return ( return (
<div> <div>
<h1>This is Home Page!!!!!!!</h1> <h1>This is Home Page!!!!!!!</h1>
<button <button className="btn btn-primary">
onClick={loginWithGoogle} <Link to="/android">Go to Android Page</Link>
className="bg-white px-4 py-2 border flex gap-2 border-slate-200 rounded-lg text-slate-700 hover:border-slate-400 hover:text-slate-900 hover:shadow transition duration-150"
>
<img
className="w-6 h-6"
src={googleLogo}
alt="google logo"
loading="eager"
/>
<span>Login with @forth.co.th Google account</span>
</button> </button>
</div> </div>
) )
} }
export default Home export default HomePage

View file

@ -0,0 +1,85 @@
import { useNavigate } from 'react-router-dom'
import googleLogo from '../assets/google-color.svg'
import userAuthStore, { type UserInfo } from '../hooks/userAuth'
const LoginPage: React.FC = () => {
const setUserInfo = userAuthStore(state => state.setUserInfo)
const navigate = useNavigate()
const redirectUrl =
new URLSearchParams(window.location.search).get('redirect') ?? '/'
const loginWithGoogle = () => {
// if is web mode then use window.open
// if is electron mode then use ipcRenderer.send to use deep link method
if (window.electronRuntime) {
window.ipcRenderer.send(
'deeplink',
import.meta.env.TAOBIN_RECIPE_MANAGER_SERVER_URL +
'/auth/google?redirect_to=' +
redirectUrl +
'&kind=electron'
)
window.ipcRenderer.on('loginSuccess', (_event, data) => {
console.log(data)
setUserInfo(data satisfies UserInfo)
navigate(redirectUrl)
})
} else {
// open new window and listen to message from window.opener
window.open(
import.meta.env.TAOBIN_RECIPE_MANAGER_SERVER_URL +
'/auth/google?redirect_to=' +
redirectUrl,
'_blank',
'width=500,height=600'
)
// listen to message from new window
window.addEventListener('message', event => {
if (event.data.payload === 'loginSuccess') {
// const { access_token, max_age, refresh_token } = event.data.data
// setPassword(
// import.meta.env.TAOBIN_RECIPE_MANAGER_KEY_CHAIN_SERVICE_NAME,
// import.meta.env
// .TAOBIN_RECIPE_MANAGER_KEY_CHAIN_ACCOUNT_ACCESS_TOKEN,
// access_token + ';' + max_age
// )
// setPassword(
// import.meta.env.TAOBIN_RECIPE_MANAGER_KEY_CHAIN_SERVICE_NAME,
// import.meta.env
// .TAOBIN_RECIPE_MANAGER_KEY_CHAIN_ACCOUNT_REFRESH_TOKEN,
// refresh_token
// )
setUserInfo(event.data.data satisfies UserInfo)
navigate(redirectUrl)
}
})
}
}
return (
<div className="flex flex-col items-center justify-center h-screen">
<button
onClick={loginWithGoogle}
className="bg-white px-4 py-2 border flex gap-2 border-slate-200 rounded-lg text-slate-700 hover:border-slate-400 hover:text-slate-900 hover:shadow transition duration-150"
>
<img
className="w-6 h-6"
src={googleLogo}
alt="google logo"
loading="eager"
/>
<span>Login with @forth.co.th Google account</span>
</button>
</div>
)
}
export default LoginPage

View file

@ -0,0 +1,14 @@
import { AdbDaemonWebUsbDeviceManager } from '@yume-chan/adb-daemon-webusb'
const Manager: AdbDaemonWebUsbDeviceManager | undefined =
AdbDaemonWebUsbDeviceManager.BROWSER
export async function getDevices() {
if (!Manager) {
alert('WebUSB is not supported in this browser')
return
}
const devices = await Manager.getDevices()
return devices
}

View file

@ -0,0 +1,26 @@
import axios from 'axios'
const customAxios = axios.create({
baseURL: import.meta.env.TAOBIN_RECIPE_MANAGER_SERVER_URL,
withCredentials: true
})
customAxios.interceptors.response.use(
res => res,
err => {
const originalRequest = err.config
if (err.response.status === 401 && !originalRequest._retry) {
originalRequest._retry = true
return customAxios.get('/auth/refresh').then(res => {
if (res.status === 200) {
return customAxios(originalRequest)
}
})
}
return Promise.reject(err)
}
)
export default customAxios

View file

@ -1 +1,9 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface ImportMetaEnv {
TAOBIN_RECIPE_MANAGER_SERVER_URL: string
TAOBIN_RECIPE_MANAGER_KEY_CHAIN_SERVICE_NAME: string
TAOBIN_RECIPE_MANAGER_KEY_CHAIN_ACCOUNT_ACCESS_TOKEN: string
TAOBIN_RECIPE_MANAGER_KEY_CHAIN_ACCOUNT_REFRESH_TOKEN: string
MAIN_VITE_TEST: boolean
}

View file

@ -5,14 +5,20 @@ import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
mode: 'electron',
envPrefix: 'TAOBIN_RECIPE_MANAGER_', envPrefix: 'TAOBIN_RECIPE_MANAGER_',
plugins: [ plugins: [
react(), react(),
electron({ electron({
main: { main: {
// Shortcut of `build.lib.entry`. // Shortcut of `build.lib.entry`.
entry: 'electron/main.ts' entry: 'electron/main.ts',
vite: {
build: {
rollupOptions: {
external: ['@postman/node-keytar']
}
}
}
}, },
preload: { preload: {
// Shortcut of `build.rollupOptions.input`. // Shortcut of `build.rollupOptions.input`.

View file

@ -3,12 +3,14 @@ import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
mode: 'web',
envPrefix: 'TAOBIN_RECIPE_MANAGER_', envPrefix: 'TAOBIN_RECIPE_MANAGER_',
plugins: [react()], plugins: [react()],
build: { build: {
outDir: 'dist-web' outDir: 'dist-web'
}, },
optimizeDeps: {
exclude: ['node-keytar']
},
server: { server: {
port: 4200 port: 4200
} }

View file

@ -1,10 +1,11 @@
package config package config
type ServerConfig struct { type ServerConfig struct {
ServerPort uint `mapstructure:"SERVER_PORT"` ServerPort uint `mapstructure:"SERVER_PORT"`
AllowedOrigins string `mapstructure:"ALLOWED_ORIGINS"` AllowedOrigins string `mapstructure:"ALLOWED_ORIGINS"`
ClientRedirectURL string `mapstructure:"CLIENT_REDIRECT_URL"` ClientRedirectURL string `mapstructure:"CLIENT_REDIRECT_URL"`
ServerDomain string `mapstructure:"SERVER_DOMAIN"` ClientElectronRedirectURL string `mapstructure:"CLIENT_ELECTRON_REDIRECT_URL"`
APIKey string `mapstructure:"API_KEY"` ServerDomain string `mapstructure:"SERVER_DOMAIN"`
Debug bool `mapstructure:"DEBUG_MODE"` APIKey string `mapstructure:"API_KEY"`
Debug bool `mapstructure:"DEBUG_MODE"`
} }

View file

@ -4,9 +4,7 @@ import (
"context" "context"
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"
"github.com/go-chi/chi/v5" "encoding/json"
"go.uber.org/zap"
"golang.org/x/oauth2"
"net/http" "net/http"
"net/url" "net/url"
"recipe-manager/config" "recipe-manager/config"
@ -15,6 +13,10 @@ import (
"recipe-manager/services/user" "recipe-manager/services/user"
"strconv" "strconv"
"time" "time"
"github.com/go-chi/chi/v5"
"go.uber.org/zap"
"golang.org/x/oauth2"
) )
type AuthRouter struct { type AuthRouter struct {
@ -42,6 +44,10 @@ func (ar *AuthRouter) Route(r chi.Router) {
stateMap["redirect_to"] = r.URL.Query().Get("redirect_to") stateMap["redirect_to"] = r.URL.Query().Get("redirect_to")
} }
if r.URL.Query().Get("kind") != "" {
stateMap["kind"] = r.URL.Query().Get("kind")
}
authURL := ar.oauth.AuthURL(state, stateMap) authURL := ar.oauth.AuthURL(state, stateMap)
http.Redirect(w, r, authURL, http.StatusTemporaryRedirect) http.Redirect(w, r, authURL, http.StatusTemporaryRedirect)
}) })
@ -52,6 +58,7 @@ func (ar *AuthRouter) Route(r chi.Router) {
defer cancel() defer cancel()
var redirectTo string var redirectTo string
var kind string
state := r.URL.Query().Get("state") state := r.URL.Query().Get("state")
if state == "" { if state == "" {
http.Error(w, "State not found", http.StatusBadRequest) http.Error(w, "State not found", http.StatusBadRequest)
@ -65,6 +72,7 @@ func (ar *AuthRouter) Route(r chi.Router) {
} }
redirectTo = val["redirect_to"] redirectTo = val["redirect_to"]
kind = val["kind"]
ar.oauth.RemoveState(state) ar.oauth.RemoveState(state)
} }
@ -115,12 +123,62 @@ func (ar *AuthRouter) Route(r chi.Router) {
ar.taoLogger.Log.Info("User Log-In Success", zap.String("userInfo", userInfo.Name), zap.String("email", userInfo.Email)) ar.taoLogger.Log.Info("User Log-In Success", zap.String("userInfo", userInfo.Name), zap.String("email", userInfo.Email))
if kind == "electron" {
value.Add("access_token", token.AccessToken)
value.Add("max_age", "3600")
value.Add("refresh_token", token.RefreshToken)
http.Redirect(w, r, ar.cfg.ClientElectronRedirectURL+"login?"+value.Encode(), http.StatusTemporaryRedirect)
return
}
// redirect to frontend with token and refresh token // redirect to frontend with token and refresh token
w.Header().Add("set-cookie", "access_token="+token.AccessToken+"; Path=/; HttpOnly; SameSite=None; Secure; Max-Age=3600") w.Header().Add("set-cookie", "access_token="+token.AccessToken+"; Path=/; HttpOnly; SameSite=None; Secure; Max-Age=3600")
w.Header().Add("set-cookie", "refresh_token="+token.RefreshToken+"; Path=/; HttpOnly; SameSite=None; Secure") w.Header().Add("set-cookie", "refresh_token="+token.RefreshToken+"; Path=/; HttpOnly; SameSite=None; Secure")
http.Redirect(w, r, ar.cfg.ClientRedirectURL+"/?"+value.Encode(), http.StatusTemporaryRedirect) http.Redirect(w, r, ar.cfg.ClientRedirectURL+"/?"+value.Encode(), http.StatusTemporaryRedirect)
}) })
r.Get("/me", func(w http.ResponseWriter, r *http.Request) {
// get access token from cookie
cookie, err := r.Cookie("access_token")
if err != nil {
http.Error(w, "Access token not found", http.StatusBadRequest)
return
}
// get userInfo info
userInfo, err := ar.oauth.GetUserInfo(r.Context(), &oauth2.Token{AccessToken: cookie.Value})
if err != nil {
http.Error(w, "Error getting userInfo info", http.StatusBadRequest)
return
}
// map with database
userFromDb, err := ar.userService.GetUserByEmail(r.Context(), userInfo.Email)
if err != nil {
http.Error(w, "Error while getting user data from database.", http.StatusInternalServerError)
return
}
if userFromDb == nil {
http.Error(w, "Unauthorized, We not found your email, Please contact admin.", http.StatusUnauthorized)
return
}
picture := userInfo.Picture
if userFromDb.Picture != "" {
picture = userFromDb.Picture
}
value := url.Values{
"id": {userFromDb.ID},
"name": {userFromDb.Name},
"email": {userInfo.Email},
"picture": {picture},
"permissions": {strconv.Itoa(int(userFromDb.Permissions))},
}
json.NewEncoder(w).Encode(value)
})
r.Get("/refresh", func(w http.ResponseWriter, r *http.Request) { r.Get("/refresh", func(w http.ResponseWriter, r *http.Request) {
// get refresh token from query string // get refresh token from query string
refreshToken := r.URL.Query().Get("refresh_token") refreshToken := r.URL.Query().Get("refresh_token")