├── .env ├── .gitignore ├── .vscode └── settings.json ├── README.md ├── app.json ├── bun.lockb ├── metro.config.js ├── package.json ├── patches └── react-server-dom-webpack+19.0.0-rc-6230622a1a-20240610.patch ├── postcss.config.js ├── public └── index.html ├── src ├── app │ ├── _layout.tsx │ ├── device │ │ └── [device].tsx │ └── index.tsx ├── assets │ ├── evan.jpeg │ ├── expo.png │ ├── fonts │ │ └── anonymous_pro │ │ │ ├── AnonymousPro-Bold.ttf │ │ │ ├── AnonymousPro-Italic.ttf │ │ │ ├── AnonymousPro-Regular.ttf │ │ │ └── OFL.txt │ ├── icon.png │ └── splash.png ├── components │ ├── api.tsx │ ├── nest │ │ ├── camera-history.tsx │ │ ├── nest-actions.tsx │ │ ├── nest-auth-button.tsx │ │ ├── nest-brand-button.tsx │ │ ├── nest-camera-detail.tsx │ │ ├── nest-device-cards.tsx │ │ ├── nest-devices-fixture.json │ │ ├── nest-server-actions.tsx │ │ ├── thermostat-detail.tsx │ │ └── webrtc-dom-view.tsx │ ├── svg │ │ └── nest.tsx │ ├── thermostat-skeleton.tsx │ ├── ui │ │ ├── FadeIn.tsx │ │ ├── TouchableBounce.native.tsx │ │ ├── TouchableBounce.tsx │ │ └── body.tsx │ └── user-playlists.tsx ├── global.css ├── hooks │ └── useHeaderSearch.ts └── lib │ ├── local-storage.ts │ ├── local-storage.web.ts │ ├── nest-auth │ ├── auth-server-actions.tsx │ ├── discovery.tsx │ ├── index.ts │ ├── nest-auth-session-provider.tsx │ ├── nest-client-provider.tsx │ └── nest-validation.tsx │ ├── skeleton.tsx │ └── skeleton.web.tsx ├── tailwind.config.js ├── tsconfig.json └── yarn.lock /.env: -------------------------------------------------------------------------------- 1 | EXPO_UNSTABLE_SERVER_ACTIONS=1 2 | 3 | # Nest 4 | # https://console.nest.google.com/device-access/project/0fe1e2fa-6f3d-4def-8dcd-0d865762ca22/information 5 | EXPO_PUBLIC_NEST_PROJECT_ID=xxx 6 | EXPO_PUBLIC_GOOGLE_OAUTH_CLIENT_ID_IOS=xxx 7 | NEST_GOOGLE_CLIENT_SECRET=xxx 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .expo/ 3 | dist/ 4 | npm-debug.* 5 | *.jks 6 | *.p8 7 | *.p12 8 | *.key 9 | *.mobileprovision 10 | *.orig.* 11 | web-build/ 12 | 13 | # macOS 14 | .DS_Store 15 | 16 | # @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb 17 | # The following patterns were generated by expo-cli 18 | 19 | expo-env.d.ts 20 | # @end expo-cli 21 | 22 | /.env.local 23 | /ios -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // use local typescript 3 | "typescript.tsdk": "node_modules/typescript/lib" 4 | } 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Expo Router Google Home 2 | 3 | This is an Expo Router app for interacting with Google Home / Nest devices. 4 | 5 | This app uses the new Expo React Server Components (developer preview) and Expo DOM Components features. 6 | 7 | To get running, you'll need to register for an API key in [Google Nest](https://console.nest.google.com/device-access/project-list). 8 | 9 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "rsc-nest", 4 | "slug": "rsc-nest", 5 | "version": "1.0.0", 6 | "orientation": "portrait", 7 | "scheme": "exai", 8 | "userInterfaceStyle": "automatic", 9 | "newArchEnabled": true, 10 | "splash": { 11 | "resizeMode": "contain", 12 | "backgroundColor": "#ffffff" 13 | }, 14 | "ios": { 15 | "scheme": [ 16 | "com.googleusercontent.apps.549323343471-esl81iea3g398omh5e5nmadnda5700p7" 17 | ], 18 | "infoPlist": { 19 | "ITSAppUsesNonExemptEncryption": false 20 | }, 21 | "supportsTablet": true, 22 | "bundleIdentifier": "com.bacon.nest" 23 | }, 24 | "android": { 25 | "adaptiveIcon": { 26 | "backgroundColor": "#ffffff" 27 | } 28 | }, 29 | "plugins": ["expo-router"], 30 | "experiments": { 31 | "typedRoutes": true, 32 | "reactServerFunctions": true 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanBacon/expo-router-google-home/6e1bf5baa03fb944061319542acdee6af7d35cc8/bun.lockb -------------------------------------------------------------------------------- /metro.config.js: -------------------------------------------------------------------------------- 1 | // Learn more https://docs.expo.io/guides/customizing-metro 2 | const { getDefaultConfig } = require('expo/metro-config'); 3 | 4 | /** @type {import('expo/metro-config').MetroConfig} */ 5 | const config = getDefaultConfig(__dirname); 6 | 7 | config.resolver.unstable_enablePackageExports = true; 8 | module.exports = config; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rsc-nest", 3 | "license": "0BSD", 4 | "main": "expo-router/entry", 5 | "version": "1.0.0", 6 | "scripts": { 7 | "start": "expo start", 8 | "android": "expo run:android", 9 | "ios": "expo run:ios", 10 | "web": "expo start --web", 11 | "lint": "expo lint", 12 | "prepare": "npx patch-package" 13 | }, 14 | "dependencies": { 15 | "@bacons/apple-colors": "^0.0.6", 16 | "@expo/vector-icons": "^14.0.2", 17 | "@react-navigation/native": "7.0.0-rc.20", 18 | "expo": "^52", 19 | "expo-auth-session": "~6.0.0", 20 | "expo-blur": "~14.0.1", 21 | "expo-constants": "~17.0.1", 22 | "expo-font": "~13.0.0", 23 | "expo-haptics": "~14.0.0", 24 | "expo-linking": "~7.0.2", 25 | "expo-router": "~4.0.2", 26 | "expo-splash-screen": "~0.29.7", 27 | "expo-sqlite": "~15.0.1", 28 | "expo-status-bar": "~2.0.0", 29 | "expo-symbols": "~0.2.0", 30 | "expo-system-ui": "~4.0.1", 31 | "expo-web-browser": "~14.0.0", 32 | "postcss": "^8.4.49", 33 | "react": "18.3.1", 34 | "react-dom": "18.3.1", 35 | "react-native": "0.76.1", 36 | "react-native-gesture-handler": "~2.20.2", 37 | "react-native-maps": "1.18.0", 38 | "react-native-reanimated": "~3.16.1", 39 | "react-native-safe-area-context": "4.12.0", 40 | "react-native-screens": "~4.0.0", 41 | "react-native-svg": "15.8.0", 42 | "react-native-web": "~0.19.13", 43 | "react-native-webview": "13.12.2", 44 | "react-server-dom-webpack": "19.0.0-rc-6230622a1a-20240610", 45 | "zod": "^3.23.8" 46 | }, 47 | "devDependencies": { 48 | "@babel/core": "^7.20.0", 49 | "@types/react": "~18.3.12", 50 | "autoprefixer": "^10.4.20", 51 | "tailwindcss": "^3.4.15", 52 | "typescript": "^5.4.5" 53 | }, 54 | "private": true 55 | } 56 | -------------------------------------------------------------------------------- /patches/react-server-dom-webpack+19.0.0-rc-6230622a1a-20240610.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.development.js b/node_modules/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.development.js 2 | index c9bc9ea..cf4eebd 100644 3 | --- a/node_modules/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.development.js 4 | +++ b/node_modules/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.development.js 5 | @@ -2113,7 +2113,7 @@ function createModelReject(chunk) { 6 | function createServerReferenceProxy(response, metaData) { 7 | var callServer = response._callServer; 8 | 9 | - var proxy = function () { 10 | + var proxy = async function () { 11 | // $FlowFixMe[method-unbinding] 12 | var args = Array.prototype.slice.call(arguments); 13 | var p = metaData.bound; 14 | @@ -2128,10 +2128,10 @@ function createServerReferenceProxy(response, metaData) { 15 | } // Since this is a fake Promise whose .then doesn't chain, we have to wrap it. 16 | // TODO: Remove the wrapper once that's fixed. 17 | 18 | - 19 | - return Promise.resolve(p).then(function (bound) { 20 | - return callServer(metaData.id, bound.concat(args)); 21 | - }); 22 | + // HACK: This is required to make native server actions return a non-undefined value. 23 | + // Seems like a bug in the Hermes engine since the same babel transforms work in Chrome/web. 24 | + const _bound = await p; 25 | + return callServer(metaData.id, _bound.concat(args)); 26 | }; 27 | 28 | registerServerReference(proxy, metaData); -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %WEB_TITLE% 8 | 9 | 12 | 13 | 14 | 15 | 16 | 19 | 20 |
21 | 22 | 23 | -------------------------------------------------------------------------------- /src/app/_layout.tsx: -------------------------------------------------------------------------------- 1 | import { Stack } from "expo-router"; 2 | import { 3 | NestClientAuthProvider, 4 | useNestAuth, 5 | } from "@/lib/nest-auth/nest-client-provider"; 6 | import { makeRedirectUri } from "expo-auth-session"; 7 | import { NestActionsProvider } from "@/components/api"; 8 | 9 | import "@/global.css"; 10 | import { Platform } from "react-native"; 11 | 12 | const redirectUri = makeRedirectUri({ 13 | scheme: 14 | "com.googleusercontent.apps.549323343471-esl81iea3g398omh5e5nmadnda5700p7", 15 | }); 16 | 17 | export default function Page() { 18 | return ( 19 | 48 | 49 | 50 | ); 51 | } 52 | 53 | function InnerAuth() { 54 | return ( 55 | 56 | 72 | 73 | 81 | 82 | 83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /src/app/device/[device].tsx: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { useNestActions } from "@/components/api"; 4 | import ThermostatSkeleton from "@/components/thermostat-skeleton"; 5 | import { BodyScrollView } from "@/components/ui/body"; 6 | import { useLocalSearchParams } from "expo-router"; 7 | import { Suspense, useMemo } from "react"; 8 | import { ActivityIndicator, Text } from "react-native"; 9 | 10 | export { ErrorBoundary } from "expo-router"; 11 | 12 | export default function PlaylistScreen() { 13 | const { device } = useLocalSearchParams<{ device: string }>(); 14 | 15 | const actions = useNestActions(); 16 | 17 | const view = useMemo( 18 | () => actions.getDeviceInfoAsync({ deviceId: device }), 19 | [device] 20 | ); 21 | 22 | return ( 23 | <> 24 | 25 | }>{view} 26 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/app/index.tsx: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | "use client"; 4 | 5 | import { Stack } from "expo-router"; 6 | import * as React from "react"; 7 | import { 8 | Text, 9 | Button, 10 | View, 11 | ActivityIndicator, 12 | RefreshControl, 13 | } from "react-native"; 14 | 15 | import NestButton from "@/components/nest/nest-auth-button"; 16 | import { useNestAuth } from "@/lib/nest-auth"; 17 | import { BodyScrollView } from "@/components/ui/body"; 18 | import { UserPlaylists } from "@/components/user-playlists"; 19 | 20 | export default function NestCard() { 21 | const nestAuth = useNestAuth(); 22 | 23 | if (!nestAuth.accessToken) { 24 | return ; 25 | } 26 | 27 | return ( 28 | <> 29 | nestAuth.clearAccessToken()} 37 | /> 38 | ); 39 | }, 40 | }} 41 | /> 42 | 43 | 44 | ); 45 | } 46 | 47 | function AuthenticatedPage() { 48 | const [refreshing, setRefreshing] = React.useState(false); 49 | const [key, setKey] = React.useState(0); 50 | 51 | const onRefresh = React.useCallback(() => { 52 | setRefreshing(true); 53 | setKey((prevKey) => prevKey + 1); 54 | setRefreshing(false); 55 | }, []); 56 | 57 | return ( 58 | 61 | } 62 | > 63 | 64 | 65 | ); 66 | } 67 | 68 | export { NestError as ErrorBoundary }; 69 | 70 | // NOTE: This won't get called because server action invocation happens at the root :( 71 | function NestError({ error, retry }: { error: Error; retry: () => void }) { 72 | const nestAuth = useNestAuth(); 73 | 74 | console.log("NestError:", error); 75 | React.useEffect(() => { 76 | if (error.message.includes("access token expired")) { 77 | nestAuth?.clearAccessToken(); 78 | } 79 | }, [error, nestAuth]); 80 | 81 | return ( 82 | 83 | {error.toString()} 84 |