Ver código fonte

Google Picker (#5443)

* initial poc

* improvements

- split into two plugins
- implement photos picker
- auto login
- save access token in local storage
- document
- handle photos/files picked and send to companion
- add new hook useStore for making it easier to use localStorage data in react
- add new hook useUppyState for making it easier to use uppy state from react
- add new hook useUppyPluginState for making it easier to plugin state from react
- fix css error

* implement picker in companion

* type todo

* fix ts error

which occurs in dev when js has been built before build:ts gets called

* reuse docs

* imrpve type safety

* simplify async wrapper

* improve doc

* fix lint

* fix build error

* check if token is valid

* fix broken logging code

* pull logic out from react component

* remove docs

* improve auth ui

* fix bug

* remove unused useUppyState

* try to fix build error
Mikael Finstad 4 meses atrás
pai
commit
afd4befee2
62 arquivos alterados com 1679 adições e 184 exclusões
  1. 10 0
      .env.example
  2. 2 0
      e2e/package.json
  3. 10 8
      packages/@uppy/box/src/Box.tsx
  4. 2 2
      packages/@uppy/companion-client/src/CompanionPluginOptions.ts
  5. 1 4
      packages/@uppy/companion-client/src/Provider.ts
  6. 4 4
      packages/@uppy/companion-client/src/RequestClient.ts
  7. 7 12
      packages/@uppy/companion-client/src/tokenStorage.ts
  8. 2 0
      packages/@uppy/companion/src/companion.js
  9. 1 0
      packages/@uppy/companion/src/config/companion.js
  10. 57 0
      packages/@uppy/companion/src/server/controllers/googlePicker.js
  11. 2 23
      packages/@uppy/companion/src/server/controllers/url.js
  12. 28 0
      packages/@uppy/companion/src/server/download.js
  13. 2 2
      packages/@uppy/companion/src/server/helpers/request.js
  14. 57 44
      packages/@uppy/companion/src/server/provider/google/drive/index.js
  15. 2 2
      packages/@uppy/companion/src/server/provider/index.js
  16. 1 0
      packages/@uppy/companion/src/standalone/helper.js
  17. 29 20
      packages/@uppy/core/src/Uppy.ts
  18. 4 0
      packages/@uppy/core/src/locale.ts
  19. 28 0
      packages/@uppy/core/src/useStore.ts
  20. 10 8
      packages/@uppy/dropbox/src/Dropbox.tsx
  21. 10 8
      packages/@uppy/facebook/src/Facebook.tsx
  22. 1 0
      packages/@uppy/google-drive-picker/.npmignore
  23. 1 0
      packages/@uppy/google-drive-picker/CHANGELOG.md
  24. 21 0
      packages/@uppy/google-drive-picker/LICENSE
  25. 18 0
      packages/@uppy/google-drive-picker/README.md
  26. 33 0
      packages/@uppy/google-drive-picker/package.json
  27. 115 0
      packages/@uppy/google-drive-picker/src/GoogleDrivePicker.tsx
  28. 1 0
      packages/@uppy/google-drive-picker/src/index.ts
  29. 3 0
      packages/@uppy/google-drive-picker/src/locale.ts
  30. 35 0
      packages/@uppy/google-drive-picker/tsconfig.build.json
  31. 31 0
      packages/@uppy/google-drive-picker/tsconfig.json
  32. 10 6
      packages/@uppy/google-drive/src/GoogleDrive.tsx
  33. 1 0
      packages/@uppy/google-photos-picker/.npmignore
  34. 1 0
      packages/@uppy/google-photos-picker/CHANGELOG.md
  35. 21 0
      packages/@uppy/google-photos-picker/LICENSE
  36. 18 0
      packages/@uppy/google-photos-picker/README.md
  37. 33 0
      packages/@uppy/google-photos-picker/package.json
  38. 111 0
      packages/@uppy/google-photos-picker/src/GooglePhotosPicker.tsx
  39. 1 0
      packages/@uppy/google-photos-picker/src/index.ts
  40. 3 0
      packages/@uppy/google-photos-picker/src/locale.ts
  41. 35 0
      packages/@uppy/google-photos-picker/tsconfig.build.json
  42. 31 0
      packages/@uppy/google-photos-picker/tsconfig.json
  43. 10 6
      packages/@uppy/google-photos/src/GooglePhotos.tsx
  44. 10 8
      packages/@uppy/instagram/src/Instagram.tsx
  45. 10 8
      packages/@uppy/onedrive/src/OneDrive.tsx
  46. 3 0
      packages/@uppy/provider-views/package.json
  47. 234 0
      packages/@uppy/provider-views/src/GooglePicker/GooglePickerView.tsx
  48. 425 0
      packages/@uppy/provider-views/src/GooglePicker/googlePicker.ts
  49. 70 0
      packages/@uppy/provider-views/src/GooglePicker/icons.tsx
  50. 2 0
      packages/@uppy/provider-views/src/index.ts
  51. 8 0
      packages/@uppy/provider-views/src/style.scss
  52. 0 1
      packages/@uppy/provider-views/src/utils/getTagFile.ts
  53. 1 0
      packages/@uppy/provider-views/tsconfig.json
  54. 2 0
      packages/@uppy/transloadit/src/index.ts
  55. 10 8
      packages/@uppy/unsplash/src/Unsplash.tsx
  56. 2 0
      packages/@uppy/utils/src/CompanionClientProvider.ts
  57. 10 8
      packages/@uppy/zoom/src/Zoom.tsx
  58. 2 0
      packages/uppy/package.json
  59. 8 2
      packages/uppy/src/bundle.ts
  60. 19 0
      private/dev/Dashboard.js
  61. 6 0
      tsconfig.json
  62. 54 0
      yarn.lock

+ 10 - 0
.env.example

@@ -15,6 +15,9 @@ COMPANION_PREAUTH_SECRET=development2
 # NOTE: Only enable this in development. Enabling it in production is a security risk
 COMPANION_ALLOW_LOCAL_URLS=true
 
+COMPANION_ENABLE_URL_ENDPOINT=true
+COMPANION_ENABLE_GOOGLE_PICKER_ENDPOINT=true
+
 # to enable S3
 COMPANION_AWS_KEY="YOUR AWS KEY"
 COMPANION_AWS_SECRET="YOUR AWS SECRET"
@@ -89,3 +92,10 @@ VITE_TRANSLOADIT_TEMPLATE=***
 VITE_TRANSLOADIT_SERVICE_URL=https://api2.transloadit.com
 # Fill in if you want requests sent to Transloadit to be signed:
 # VITE_TRANSLOADIT_SECRET=***
+
+# For Google Photos Picker and Google Drive Picker:
+VITE_GOOGLE_PICKER_CLIENT_ID=***
+
+# For Google Drive Picker
+VITE_GOOGLE_PICKER_API_KEY=***
+VITE_GOOGLE_PICKER_APP_ID=***

+ 2 - 0
e2e/package.json

@@ -25,7 +25,9 @@
     "@uppy/form": "workspace:^",
     "@uppy/golden-retriever": "workspace:^",
     "@uppy/google-drive": "workspace:^",
+    "@uppy/google-drive-picker": "workspace:^",
     "@uppy/google-photos": "workspace:^",
+    "@uppy/google-photos-picker": "workspace:^",
     "@uppy/image-editor": "workspace:^",
     "@uppy/informer": "workspace:^",
     "@uppy/instagram": "workspace:^",

+ 10 - 8
packages/@uppy/box/src/Box.tsx

@@ -9,7 +9,11 @@ import { ProviderViews } from '@uppy/provider-views'
 import { h, type ComponentChild } from 'preact'
 
 import type { UppyFile, Body, Meta } from '@uppy/utils/lib/UppyFile'
-import type { UnknownProviderPluginState } from '@uppy/core/lib/Uppy.js'
+import type {
+  AsyncStore,
+  UnknownProviderPlugin,
+  UnknownProviderPluginState,
+} from '@uppy/core/lib/Uppy.js'
 import locale from './locale.ts'
 // eslint-disable-next-line @typescript-eslint/ban-ts-comment
 // @ts-ignore We don't want TS to generate types for the package.json
@@ -17,12 +21,10 @@ import packageJson from '../package.json'
 
 export type BoxOptions = CompanionPluginOptions
 
-export default class Box<M extends Meta, B extends Body> extends UIPlugin<
-  BoxOptions,
-  M,
-  B,
-  UnknownProviderPluginState
-> {
+export default class Box<M extends Meta, B extends Body>
+  extends UIPlugin<BoxOptions, M, B, UnknownProviderPluginState>
+  implements UnknownProviderPlugin<M, B>
+{
   static VERSION = packageJson.version
 
   icon: () => h.JSX.Element
@@ -31,7 +33,7 @@ export default class Box<M extends Meta, B extends Body> extends UIPlugin<
 
   view!: ProviderViews<M, B>
 
-  storage: typeof tokenStorage
+  storage: AsyncStore
 
   files: UppyFile<M, B>[]
 

+ 2 - 2
packages/@uppy/companion-client/src/CompanionPluginOptions.ts

@@ -1,8 +1,8 @@
 import type { UIPluginOptions } from '@uppy/core'
-import type { tokenStorage } from './index.ts'
+import type { AsyncStore } from '@uppy/core/lib/Uppy.js'
 
 export interface CompanionPluginOptions extends UIPluginOptions {
-  storage?: typeof tokenStorage
+  storage?: AsyncStore
   companionUrl: string
   companionHeaders?: Record<string, string>
   companionKeysParams?: { key: string; credentialsName: string }

+ 1 - 4
packages/@uppy/companion-client/src/Provider.ts

@@ -320,10 +320,7 @@ export default class Provider<M extends Meta, B extends Body>
         // Once a refresh token operation has started, we need all other request to wait for this operation (atomically)
         this.#refreshingTokenPromise = (async () => {
           try {
-            this.uppy.log(
-              `[CompanionClient] Refreshing expired auth token`,
-              'info',
-            )
+            this.uppy.log(`[CompanionClient] Refreshing expired auth token`)
             const response = await super.request<{ uppyAuthToken: string }>({
               path: this.refreshTokenUrl(),
               method: 'POST',

+ 4 - 4
packages/@uppy/companion-client/src/RequestClient.ts

@@ -505,7 +505,7 @@ export default class RequestClient<M extends Meta, B extends Body> {
                     })
 
                     const closeSocket = () => {
-                      this.uppy.log(`Closing socket ${file.id}`, 'info')
+                      this.uppy.log(`Closing socket ${file.id}`)
                       clearTimeout(activityTimeout)
                       if (socket) socket.close()
                       socket = undefined
@@ -524,7 +524,7 @@ export default class RequestClient<M extends Meta, B extends Body> {
                   signal: socketAbortController.signal,
                   onFailedAttempt: () => {
                     if (socketAbortController.signal.aborted) return // don't log in this case
-                    this.uppy.log(`Retrying websocket ${file.id}`, 'info')
+                    this.uppy.log(`Retrying websocket ${file.id}`)
                   },
                 })
               })()
@@ -547,14 +547,14 @@ export default class RequestClient<M extends Meta, B extends Body> {
           if (targetFile.id !== file.id) return
           socketSend('cancel')
           socketAbortController?.abort?.()
-          this.uppy.log(`upload ${file.id} was removed`, 'info')
+          this.uppy.log(`upload ${file.id} was removed`)
           resolve()
         }
 
         const onCancelAll = () => {
           socketSend('cancel')
           socketAbortController?.abort?.()
-          this.uppy.log(`upload ${file.id} was canceled`, 'info')
+          this.uppy.log(`upload ${file.id} was canceled`)
           resolve()
         }
 

+ 7 - 12
packages/@uppy/companion-client/src/tokenStorage.ts

@@ -1,20 +1,15 @@
 /**
  * This module serves as an Async wrapper for LocalStorage
+ * Why? Because the Provider API `storage` option allows an async storage
  */
-export function setItem(key: string, value: string): Promise<void> {
-  return new Promise((resolve) => {
-    localStorage.setItem(key, value)
-    resolve()
-  })
+export async function setItem(key: string, value: string): Promise<void> {
+  localStorage.setItem(key, value)
 }
 
-export function getItem(key: string): Promise<string | null> {
-  return Promise.resolve(localStorage.getItem(key))
+export async function getItem(key: string): Promise<string | null> {
+  return localStorage.getItem(key)
 }
 
-export function removeItem(key: string): Promise<void> {
-  return new Promise((resolve) => {
-    localStorage.removeItem(key)
-    resolve()
-  })
+export async function removeItem(key: string): Promise<void> {
+  localStorage.removeItem(key)
 }

+ 2 - 0
packages/@uppy/companion/src/companion.js

@@ -10,6 +10,7 @@ const providerManager = require('./server/provider')
 const controllers = require('./server/controllers')
 const s3 = require('./server/controllers/s3')
 const url = require('./server/controllers/url')
+const googlePicker = require('./server/controllers/googlePicker')
 const createEmitter = require('./server/emitter')
 const redis = require('./server/redis')
 const jobs = require('./server/jobs')
@@ -120,6 +121,7 @@ module.exports.app = (optionsArg = {}) => {
   app.use('*', middlewares.getCompanionMiddleware(options))
   app.use('/s3', s3(options.s3))
   if (options.enableUrlEndpoint) app.use('/url', url())
+  if (options.enableGooglePickerEndpoint) app.use('/google-picker', googlePicker())
 
   app.post('/:providerName/preauth', express.json(), express.urlencoded({ extended: false }), middlewares.hasSessionAndProvider, middlewares.hasBody, middlewares.hasOAuthProvider, controllers.preauth)
   app.get('/:providerName/connect', middlewares.hasSessionAndProvider, middlewares.hasOAuthProvider, controllers.connect)

+ 1 - 0
packages/@uppy/companion/src/config/companion.js

@@ -17,6 +17,7 @@ const defaultOptions = {
     expires: 800, // seconds
   },
   enableUrlEndpoint: false,
+  enableGooglePickerEndpoint: false,
   allowLocalUrls: false,
   periodicPingUrls: [],
   streamingUpload: true,

+ 57 - 0
packages/@uppy/companion/src/server/controllers/googlePicker.js

@@ -0,0 +1,57 @@
+const express = require('express')
+const assert = require('node:assert')
+
+const { startDownUpload } = require('../helpers/upload')
+const { validateURL } = require('../helpers/request')
+const { getURLMeta } = require('../helpers/request')
+const logger = require('../logger')
+const { downloadURL } = require('../download')
+const { getGoogleFileSize, streamGoogleFile } = require('../provider/google/drive');
+
+
+const getAuthHeader = (token) => ({ authorization: `Bearer ${token}` });
+
+/**
+ *
+ * @param {object} req expressJS request object
+ * @param {object} res expressJS response object
+ */
+const get = async (req, res) => {
+  try {
+    logger.debug('Google Picker file import handler running', null, req.id)
+
+    const allowLocalUrls = false
+  
+    const { accessToken, platform, fileId } = req.body
+
+    assert(platform === 'drive' || platform === 'photos');
+
+    const getSize = async () => {
+      if (platform === 'drive') {
+        return getGoogleFileSize({ id: fileId, token: accessToken })
+      }
+      const { size } = await getURLMeta(req.body.url, allowLocalUrls, { headers: getAuthHeader(accessToken) })
+      return size
+    }
+    
+    if (platform === 'photos' && !validateURL(req.body.url, allowLocalUrls)) {
+      res.status(400).json({ error: 'Invalid URL' })
+      return
+    }
+
+    const download = () => {
+      if (platform === 'drive') {
+        return streamGoogleFile({ token: accessToken, id: fileId })
+      }
+      return downloadURL(req.body.url, allowLocalUrls, req.id, { headers: getAuthHeader(accessToken) })
+    }
+
+    await startDownUpload({ req, res, getSize, download })
+  } catch (err) {
+    logger.error(err, 'controller.googlePicker.error', req.id)
+    res.status(err.status || 500).json({ message: 'failed to fetch Google Picker URL' })
+  }
+}
+
+module.exports = () => express.Router()
+  .post('/get', express.json(), get)

+ 2 - 23
packages/@uppy/companion/src/server/controllers/url.js

@@ -1,9 +1,9 @@
 const express = require('express')
 
 const { startDownUpload } = require('../helpers/upload')
-const { prepareStream } = require('../helpers/utils')
+const { downloadURL } = require('../download')
 const { validateURL } = require('../helpers/request')
-const { getURLMeta, getProtectedGot } = require('../helpers/request')
+const { getURLMeta } = require('../helpers/request')
 const logger = require('../logger')
 
 /**
@@ -12,27 +12,6 @@ const logger = require('../logger')
  * @param {string | Buffer | Buffer[]} chunk
  */
 
-/**
- * Downloads the content in the specified url, and passes the data
- * to the callback chunk by chunk.
- *
- * @param {string} url
- * @param {boolean} allowLocalIPs
- * @param {string} traceId
- * @returns {Promise}
- */
-const downloadURL = async (url, allowLocalIPs, traceId) => {
-  try {
-    const protectedGot = await getProtectedGot({ allowLocalIPs })
-    const stream = protectedGot.stream.get(url, { responseType: 'json' })
-    const { size } = await prepareStream(stream)
-    return { stream, size }
-  } catch (err) {
-    logger.error(err, 'controller.url.download.error', traceId)
-    throw err
-  }
-}
-
 /**
  * Fetches the size and content type of a URL
  *

+ 28 - 0
packages/@uppy/companion/src/server/download.js

@@ -0,0 +1,28 @@
+const logger = require('./logger')
+const { getProtectedGot } = require('./helpers/request')
+const { prepareStream } = require('./helpers/utils')
+
+/**
+ * Downloads the content in the specified url, and passes the data
+ * to the callback chunk by chunk.
+ *
+ * @param {string} url
+ * @param {boolean} allowLocalIPs
+ * @param {string} traceId
+ * @returns {Promise}
+ */
+const downloadURL = async (url, allowLocalIPs, traceId, options) => {
+  try {
+    const protectedGot = await getProtectedGot({ allowLocalIPs })
+    const stream = protectedGot.stream.get(url, { responseType: 'json', ...options })
+    const { size } = await prepareStream(stream)
+    return { stream, size }
+  } catch (err) {
+    logger.error(err, 'controller.url.download.error', traceId)
+    throw err
+  }
+}
+
+module.exports = {
+  downloadURL,
+}

+ 2 - 2
packages/@uppy/companion/src/server/helpers/request.js

@@ -105,10 +105,10 @@ module.exports.getProtectedGot = getProtectedGot
  * @param {boolean} allowLocalIPs
  * @returns {Promise<{name: string, type: string, size: number}>}
  */
-exports.getURLMeta = async (url, allowLocalIPs = false) => {
+exports.getURLMeta = async (url, allowLocalIPs = false, options = undefined) => {
   async function requestWithMethod (method) {
     const protectedGot = await getProtectedGot({ allowLocalIPs })
-    const stream = protectedGot.stream(url, { method, throwHttpErrors: false })
+    const stream = protectedGot.stream(url, { method, throwHttpErrors: false, ...options })
 
     return new Promise((resolve, reject) => (
       stream

+ 57 - 44
packages/@uppy/companion/src/server/provider/google/drive/index.js

@@ -43,6 +43,53 @@ async function getStats ({ id, token }) {
   return stats
 }
 
+
+async function streamGoogleFile({ token, id: idIn }) {
+  const client = await getClient({ token })
+
+  const { mimeType, id, exportLinks } = await getStats({ id: idIn, token })
+
+  let stream
+
+  if (isGsuiteFile(mimeType)) {
+    const mimeType2 = getGsuiteExportType(mimeType)
+    logger.info(`calling google file export for ${id} to ${mimeType2}`, 'provider.drive.export')
+
+    // GSuite files exported with large converted size results in error using standard export method.
+    // Error message: "This file is too large to be exported.".
+    // Issue logged in Google APIs: https://github.com/googleapis/google-api-nodejs-client/issues/3446
+    // Implemented based on the answer from StackOverflow: https://stackoverflow.com/a/59168288
+    const mimeTypeExportLink = exportLinks?.[mimeType2]
+    if (mimeTypeExportLink) {
+      const gSuiteFilesClient = (await got).extend({
+        headers: {
+          authorization: `Bearer ${token}`,
+        },
+      })
+      stream = gSuiteFilesClient.stream.get(mimeTypeExportLink, { responseType: 'json' })
+    } else {
+      stream = client.stream.get(`files/${encodeURIComponent(id)}/export`, { searchParams: { supportsAllDrives: true, mimeType: mimeType2 }, responseType: 'json' })
+    }
+  } else {
+    stream = client.stream.get(`files/${encodeURIComponent(id)}`, { searchParams: { alt: 'media', supportsAllDrives: true }, responseType: 'json' })
+  }
+
+  await prepareStream(stream)
+  return { stream }
+}
+
+async function getGoogleFileSize({ id, token }) {
+  const { mimeType, size } = await getStats({ id, token })
+
+  if (isGsuiteFile(mimeType)) {
+    // GSuite file sizes cannot be predetermined (but are max 10MB)
+    // e.g. Transfer-Encoding: chunked
+    return undefined
+  }
+
+  return parseInt(size, 10)
+}
+
 /**
  * Adapter for API https://developers.google.com/drive/api/v3/
  */
@@ -124,7 +171,7 @@ class Drive extends Provider {
   }
 
   // eslint-disable-next-line class-methods-use-this
-  async download ({ id: idIn, token }) {
+  async download ({ id, token }) {
     if (mockAccessTokenExpiredError != null) {
       logger.warn(`Access token: ${token}`)
 
@@ -135,57 +182,23 @@ class Drive extends Provider {
     }
 
     return withGoogleErrorHandling(Drive.oauthProvider, 'provider.drive.download.error', async () => {
-      const client = await getClient({ token })
-
-      const { mimeType, id, exportLinks } = await getStats({ id: idIn, token })
-
-      let stream
-
-      if (isGsuiteFile(mimeType)) {
-        const mimeType2 = getGsuiteExportType(mimeType)
-        logger.info(`calling google file export for ${id} to ${mimeType2}`, 'provider.drive.export')
-
-        // GSuite files exported with large converted size results in error using standard export method.
-        // Error message: "This file is too large to be exported.".
-        // Issue logged in Google APIs: https://github.com/googleapis/google-api-nodejs-client/issues/3446
-        // Implemented based on the answer from StackOverflow: https://stackoverflow.com/a/59168288
-        const mimeTypeExportLink = exportLinks?.[mimeType2]
-        if (mimeTypeExportLink) {
-          const gSuiteFilesClient = (await got).extend({
-            headers: {
-              authorization: `Bearer ${token}`,
-            },
-          })
-          stream = gSuiteFilesClient.stream.get(mimeTypeExportLink, { responseType: 'json' })
-        } else {
-          stream = client.stream.get(`files/${encodeURIComponent(id)}/export`, { searchParams: { supportsAllDrives: true, mimeType: mimeType2 }, responseType: 'json' })
-        }
-      } else {
-        stream = client.stream.get(`files/${encodeURIComponent(id)}`, { searchParams: { alt: 'media', supportsAllDrives: true }, responseType: 'json' })
-      }
-
-      await prepareStream(stream)
-      return { stream }
+      return streamGoogleFile({ token, id })
     })
   }
 
   // eslint-disable-next-line class-methods-use-this
   async size ({ id, token }) {
-    return withGoogleErrorHandling(Drive.oauthProvider, 'provider.drive.size.error', async () => {
-      const { mimeType, size } = await getStats({ id, token })
-
-      if (isGsuiteFile(mimeType)) {
-        // GSuite file sizes cannot be predetermined (but are max 10MB)
-        // e.g. Transfer-Encoding: chunked
-        return undefined
-      }
-
-      return parseInt(size, 10)
-    })
+    return withGoogleErrorHandling(Drive.oauthProvider, 'provider.drive.size.error', async () => (
+      getGoogleFileSize({ id, token })
+    ))
   }
 }
 
 Drive.prototype.logout = logout
 Drive.prototype.refreshToken = refreshToken
 
-module.exports = Drive
+module.exports = {
+  Drive,
+  streamGoogleFile,
+  getGoogleFileSize,
+}

+ 2 - 2
packages/@uppy/companion/src/server/provider/index.js

@@ -3,7 +3,7 @@
  */
 const dropbox = require('./dropbox')
 const box = require('./box')
-const drive = require('./google/drive')
+const { Drive } = require('./google/drive')
 const googlephotos = require('./google/googlephotos')
 const instagram = require('./instagram/graph')
 const facebook = require('./facebook')
@@ -68,7 +68,7 @@ module.exports.getProviderMiddleware = (providers, grantConfig) => {
  * @returns {Record<string, typeof Provider>}
  */
 module.exports.getDefaultProviders = () => {
-  const providers = { dropbox, box, drive, googlephotos, facebook, onedrive, zoom, instagram, unsplash }
+  const providers = { dropbox, box, drive: Drive, googlephotos, facebook, onedrive, zoom, instagram, unsplash }
 
   return providers
 }

+ 1 - 0
packages/@uppy/companion/src/standalone/helper.js

@@ -152,6 +152,7 @@ const getConfigFromEnv = () => {
       validHosts,
     },
     enableUrlEndpoint: process.env.COMPANION_ENABLE_URL_ENDPOINT === 'true',
+    enableGooglePickerEndpoint: process.env.COMPANION_ENABLE_GOOGLE_PICKER_ENDPOINT === 'true',
     periodicPingUrls: process.env.COMPANION_PERIODIC_PING_URLS ? process.env.COMPANION_PERIODIC_PING_URLS.split(',') : [],
     periodicPingInterval: process.env.COMPANION_PERIODIC_PING_INTERVAL
       ? parseInt(process.env.COMPANION_PERIODIC_PING_INTERVAL, 10) : undefined,

+ 29 - 20
packages/@uppy/core/src/Uppy.ts

@@ -142,8 +142,25 @@ export type UnknownProviderPluginState = {
   currentFolderId: PartialTreeId
   username: string | null
 }
+
+export interface AsyncStore {
+  getItem: (key: string) => Promise<string | null>
+  setItem: (key: string, value: string) => Promise<void>
+  removeItem: (key: string) => Promise<void>
+}
+
+/**
+ * This is a base for a provider that does not necessarily use the Companion-assisted OAuth2 flow
+ */
+export interface BaseProviderPlugin {
+  title: string
+  icon: () => h.JSX.Element
+  storage: AsyncStore
+}
+
 /*
- * UnknownProviderPlugin can be any Companion plugin (such as Google Drive).
+ * UnknownProviderPlugin can be any Companion plugin (such as Google Drive)
+ * that uses the Companion-assisted OAuth flow.
  * As the plugins are passed around throughout Uppy we need a generic type for this.
  * It may seems like duplication, but this type safe. Changing the type of `storage`
  * will error in the `Provider` class of @uppy/companion-client and vice versa.
@@ -154,18 +171,12 @@ export type UnknownProviderPluginState = {
 export type UnknownProviderPlugin<
   M extends Meta,
   B extends Body,
-> = UnknownPlugin<M, B, UnknownProviderPluginState> & {
-  title: string
-  rootFolderId: string | null
-  files: UppyFile<M, B>[]
-  icon: () => h.JSX.Element
-  provider: CompanionClientProvider
-  storage: {
-    getItem: (key: string) => Promise<string | null>
-    setItem: (key: string, value: string) => Promise<void>
-    removeItem: (key: string) => Promise<void>
+> = UnknownPlugin<M, B, UnknownProviderPluginState> &
+  BaseProviderPlugin & {
+    rootFolderId: string | null
+    files: UppyFile<M, B>[]
+    provider: CompanionClientProvider
   }
-}
 
 /*
  * UnknownSearchProviderPlugin can be any search Companion plugin (such as Unsplash).
@@ -185,11 +196,10 @@ export type UnknownSearchProviderPluginState = {
 export type UnknownSearchProviderPlugin<
   M extends Meta,
   B extends Body,
-> = UnknownPlugin<M, B, UnknownSearchProviderPluginState> & {
-  title: string
-  icon: () => h.JSX.Element
-  provider: CompanionClientSearchProvider
-}
+> = UnknownPlugin<M, B, UnknownSearchProviderPluginState> &
+  BaseProviderPlugin & {
+    provider: CompanionClientSearchProvider
+  }
 
 export interface UploadResult<M extends Meta, B extends Body> {
   successful?: UppyFile<M, B>[]
@@ -712,8 +722,7 @@ export class Uppy<
     const updatedFiles = { ...this.getState().files }
     if (!updatedFiles[fileID]) {
       this.log(
-        'Was trying to set metadata for a file that has been removed: ',
-        fileID,
+        `Was trying to set metadata for a file that has been removed: ${fileID}`,
       )
       return
     }
@@ -1948,7 +1957,7 @@ export class Uppy<
    * Passes messages to a function, provided in `opts.logger`.
    * If `opts.logger: Uppy.debugLogger` or `opts.debug: true`, logs to the browser console.
    */
-  log(message: string | Record<any, any> | Error, type?: string): void {
+  log(message: unknown, type?: 'error' | 'warning'): void {
     const { logger } = this.opts
     switch (type) {
       case 'error':

+ 4 - 0
packages/@uppy/core/src/locale.ts

@@ -41,6 +41,9 @@ export default {
     openFolderNamed: 'Open folder %{name}',
     cancel: 'Cancel',
     logOut: 'Log out',
+    logIn: 'Log in',
+    pickFiles: 'Pick files',
+    pickPhotos: 'Pick photos',
     filter: 'Filter',
     resetFilter: 'Reset filter',
     loading: 'Loading...',
@@ -63,5 +66,6 @@ export default {
     additionalRestrictionsFailed:
       '%{count} additional restrictions were not fulfilled',
     unnamed: 'Unnamed',
+    pleaseWait: 'Please wait',
   },
 }

+ 28 - 0
packages/@uppy/core/src/useStore.ts

@@ -0,0 +1,28 @@
+import { useCallback, useEffect, useState } from 'preact/hooks'
+
+import type { AsyncStore } from './Uppy'
+
+export default function useStore(
+  store: AsyncStore,
+  key: string,
+): [string | undefined | null, (v: string | null) => Promise<void>] {
+  const [value, setValueState] = useState<string | null | undefined>()
+  useEffect(() => {
+    ;(async () => {
+      setValueState(await store.getItem(key))
+    })()
+  }, [key, store])
+
+  const setValue = useCallback(
+    async (v: string | null) => {
+      setValueState(v)
+      if (v == null) {
+        return store.removeItem(key)
+      }
+      return store.setItem(key, v)
+    },
+    [key, store],
+  )
+
+  return [value, setValue]
+}

+ 10 - 8
packages/@uppy/dropbox/src/Dropbox.tsx

@@ -9,7 +9,11 @@ import { ProviderViews } from '@uppy/provider-views'
 import { h, type ComponentChild } from 'preact'
 
 import type { UppyFile, Body, Meta } from '@uppy/utils/lib/UppyFile'
-import type { UnknownProviderPluginState } from '@uppy/core/lib/Uppy.js'
+import type {
+  AsyncStore,
+  UnknownProviderPlugin,
+  UnknownProviderPluginState,
+} from '@uppy/core/lib/Uppy.js'
 import locale from './locale.ts'
 // eslint-disable-next-line @typescript-eslint/ban-ts-comment
 // @ts-ignore We don't want TS to generate types for the package.json
@@ -17,12 +21,10 @@ import packageJson from '../package.json'
 
 export type DropboxOptions = CompanionPluginOptions
 
-export default class Dropbox<M extends Meta, B extends Body> extends UIPlugin<
-  DropboxOptions,
-  M,
-  B,
-  UnknownProviderPluginState
-> {
+export default class Dropbox<M extends Meta, B extends Body>
+  extends UIPlugin<DropboxOptions, M, B, UnknownProviderPluginState>
+  implements UnknownProviderPlugin<M, B>
+{
   static VERSION = packageJson.version
 
   icon: () => h.JSX.Element
@@ -31,7 +33,7 @@ export default class Dropbox<M extends Meta, B extends Body> extends UIPlugin<
 
   view!: ProviderViews<M, B>
 
-  storage: typeof tokenStorage
+  storage: AsyncStore
 
   files: UppyFile<M, B>[]
 

+ 10 - 8
packages/@uppy/facebook/src/Facebook.tsx

@@ -9,7 +9,11 @@ import { ProviderViews } from '@uppy/provider-views'
 import { h, type ComponentChild } from 'preact'
 
 import type { UppyFile, Body, Meta } from '@uppy/utils/lib/UppyFile'
-import type { UnknownProviderPluginState } from '@uppy/core/lib/Uppy.js'
+import type {
+  AsyncStore,
+  UnknownProviderPlugin,
+  UnknownProviderPluginState,
+} from '@uppy/core/lib/Uppy.js'
 import locale from './locale.ts'
 // eslint-disable-next-line @typescript-eslint/ban-ts-comment
 // @ts-ignore We don't want TS to generate types for the package.json
@@ -17,12 +21,10 @@ import packageJson from '../package.json'
 
 export type FacebookOptions = CompanionPluginOptions
 
-export default class Facebook<M extends Meta, B extends Body> extends UIPlugin<
-  FacebookOptions,
-  M,
-  B,
-  UnknownProviderPluginState
-> {
+export default class Facebook<M extends Meta, B extends Body>
+  extends UIPlugin<FacebookOptions, M, B, UnknownProviderPluginState>
+  implements UnknownProviderPlugin<M, B>
+{
   static VERSION = packageJson.version
 
   icon: () => h.JSX.Element
@@ -31,7 +33,7 @@ export default class Facebook<M extends Meta, B extends Body> extends UIPlugin<
 
   view!: ProviderViews<M, B>
 
-  storage: typeof tokenStorage
+  storage: AsyncStore
 
   files: UppyFile<M, B>[]
 

+ 1 - 0
packages/@uppy/google-drive-picker/.npmignore

@@ -0,0 +1 @@
+tsconfig.*

+ 1 - 0
packages/@uppy/google-drive-picker/CHANGELOG.md

@@ -0,0 +1 @@
+# @uppy/google-drive-picker

+ 21 - 0
packages/@uppy/google-drive-picker/LICENSE

@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2024 Transloadit
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 18 - 0
packages/@uppy/google-drive-picker/README.md

@@ -0,0 +1,18 @@
+# @uppy/google-drive-picker
+
+<img src="https://uppy.io/img/logo.svg" width="120" alt="Uppy logo: a smiling puppy above a pink upwards arrow" align="right">
+
+[![npm version](https://img.shields.io/npm/v/@uppy/google-drive-picker.svg?style=flat-square)](https://www.npmjs.com/package/@uppy/google-drive-picker)
+![CI status for Uppy tests](https://github.com/transloadit/uppy/workflows/Tests/badge.svg)
+![CI status for Companion tests](https://github.com/transloadit/uppy/workflows/Companion/badge.svg)
+![CI status for browser tests](https://github.com/transloadit/uppy/workflows/End-to-end%20tests/badge.svg)
+
+The Google Drive Picker plugin for Uppy lets users import files from their
+Google Drive account using the new Picker API.
+
+Documentation for this plugin can be found on the
+[Uppy website](https://uppy.io/docs/google-drive-picker).
+
+## License
+
+The [MIT License](./LICENSE).

+ 33 - 0
packages/@uppy/google-drive-picker/package.json

@@ -0,0 +1,33 @@
+{
+  "name": "@uppy/google-drive-picker",
+  "description": "The Google Drive Picker plugin for Uppy lets users import files from their Google Drive account",
+  "version": "0.1.0",
+  "license": "MIT",
+  "main": "lib/index.js",
+  "type": "module",
+  "keywords": [
+    "file uploader",
+    "google drive",
+    "google picker",
+    "cloud storage",
+    "uppy",
+    "uppy-plugin"
+  ],
+  "homepage": "https://uppy.io",
+  "bugs": {
+    "url": "https://github.com/transloadit/uppy/issues"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/transloadit/uppy.git"
+  },
+  "dependencies": {
+    "@uppy/companion-client": "workspace:^",
+    "@uppy/provider-views": "workspace:^",
+    "@uppy/utils": "workspace:^",
+    "preact": "^10.5.13"
+  },
+  "peerDependencies": {
+    "@uppy/core": "workspace:^"
+  }
+}

+ 115 - 0
packages/@uppy/google-drive-picker/src/GoogleDrivePicker.tsx

@@ -0,0 +1,115 @@
+import { h } from 'preact'
+import { UIPlugin, Uppy } from '@uppy/core'
+import { GooglePickerView } from '@uppy/provider-views'
+import { GoogleDriveIcon } from '@uppy/provider-views/lib/GooglePicker/icons.js'
+import {
+  RequestClient,
+  type CompanionPluginOptions,
+  tokenStorage,
+} from '@uppy/companion-client'
+
+import type { PickedItem } from '@uppy/provider-views/lib/GooglePicker/googlePicker.js'
+import type { Body, Meta } from '@uppy/utils/lib/UppyFile'
+import type { AsyncStore, BaseProviderPlugin } from '@uppy/core/lib/Uppy.js'
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore We don't want TS to generate types for the package.json
+import packageJson from '../package.json'
+import locale from './locale.ts'
+
+export type GoogleDrivePickerOptions = CompanionPluginOptions & {
+  clientId: string
+  apiKey: string
+  appId: string
+}
+
+export default class GoogleDrivePicker<
+    M extends Meta & { width: number; height: number },
+    B extends Body,
+  >
+  extends UIPlugin<GoogleDrivePickerOptions, M, B>
+  implements BaseProviderPlugin
+{
+  static VERSION = packageJson.version
+
+  static requestClientId = GoogleDrivePicker.name
+
+  type = 'acquirer'
+
+  icon = GoogleDriveIcon
+
+  storage: AsyncStore
+
+  defaultLocale = locale
+
+  constructor(uppy: Uppy<M, B>, opts: GoogleDrivePickerOptions) {
+    super(uppy, opts)
+    this.id = this.opts.id || 'GoogleDrivePicker'
+    this.storage = this.opts.storage || tokenStorage
+
+    this.i18nInit()
+    this.title = this.i18n('pluginNameGoogleDrive')
+
+    const client = new RequestClient(uppy, {
+      pluginId: this.id,
+      provider: 'url',
+      companionUrl: this.opts.companionUrl,
+      companionHeaders: this.opts.companionHeaders,
+      companionCookiesRule: this.opts.companionCookiesRule,
+    })
+
+    this.uppy.registerRequestClient(GoogleDrivePicker.requestClientId, client)
+  }
+
+  install(): void {
+    const { target } = this.opts
+    if (target) {
+      this.mount(target, this)
+    }
+  }
+
+  uninstall(): void {
+    this.unmount()
+  }
+
+  private handleFilesPicked = async (
+    files: PickedItem[],
+    accessToken: string,
+  ) => {
+    this.uppy.addFiles(
+      files.map(({ id, mimeType, name, ...rest }) => {
+        return {
+          source: this.id,
+          name,
+          type: mimeType,
+          data: {
+            size: null, // defer to companion to determine size
+          },
+          isRemote: true,
+          remote: {
+            companionUrl: this.opts.companionUrl,
+            url: `${this.opts.companionUrl}/google-picker/get`,
+            body: {
+              fileId: id,
+              accessToken,
+              ...rest,
+            },
+            requestClientId: GoogleDrivePicker.requestClientId,
+          },
+        }
+      }),
+    )
+  }
+
+  render = () => (
+    <GooglePickerView
+      storage={this.storage}
+      pickerType="drive"
+      uppy={this.uppy}
+      clientId={this.opts.clientId}
+      apiKey={this.opts.apiKey}
+      appId={this.opts.appId}
+      onFilesPicked={this.handleFilesPicked}
+    />
+  )
+}

+ 1 - 0
packages/@uppy/google-drive-picker/src/index.ts

@@ -0,0 +1 @@
+export { default } from './GoogleDrivePicker.tsx'

+ 3 - 0
packages/@uppy/google-drive-picker/src/locale.ts

@@ -0,0 +1,3 @@
+export default {
+  strings: {},
+}

+ 35 - 0
packages/@uppy/google-drive-picker/tsconfig.build.json

@@ -0,0 +1,35 @@
+{
+  "extends": "../../../tsconfig.shared",
+  "compilerOptions": {
+    "noImplicitAny": false,
+    "outDir": "./lib",
+    "paths": {
+      "@uppy/companion-client": ["../companion-client/src/index.js"],
+      "@uppy/companion-client/lib/*": ["../companion-client/src/*"],
+      "@uppy/provider-views": ["../provider-views/src/index.js"],
+      "@uppy/provider-views/lib/*": ["../provider-views/src/*"],
+      "@uppy/utils/lib/*": ["../utils/src/*"],
+      "@uppy/core": ["../core/src/index.js"],
+      "@uppy/core/lib/*": ["../core/src/*"]
+    },
+    "resolveJsonModule": false,
+    "rootDir": "./src",
+    "skipLibCheck": true
+  },
+  "include": ["./src/**/*.*"],
+  "exclude": ["./src/**/*.test.ts"],
+  "references": [
+    {
+      "path": "../companion-client/tsconfig.build.json"
+    },
+    {
+      "path": "../provider-views/tsconfig.build.json"
+    },
+    {
+      "path": "../utils/tsconfig.build.json"
+    },
+    {
+      "path": "../core/tsconfig.build.json"
+    }
+  ]
+}

+ 31 - 0
packages/@uppy/google-drive-picker/tsconfig.json

@@ -0,0 +1,31 @@
+{
+  "extends": "../../../tsconfig.shared",
+  "compilerOptions": {
+    "emitDeclarationOnly": false,
+    "noEmit": true,
+    "paths": {
+      "@uppy/companion-client": ["../companion-client/src/index.js"],
+      "@uppy/companion-client/lib/*": ["../companion-client/src/*"],
+      "@uppy/provider-views": ["../provider-views/src/index.js"],
+      "@uppy/provider-views/lib/*": ["../provider-views/src/*"],
+      "@uppy/utils/lib/*": ["../utils/src/*"],
+      "@uppy/core": ["../core/src/index.js"],
+      "@uppy/core/lib/*": ["../core/src/*"],
+    },
+  },
+  "include": ["./package.json", "./src/**/*.*"],
+  "references": [
+    {
+      "path": "../companion-client/tsconfig.build.json",
+    },
+    {
+      "path": "../provider-views/tsconfig.build.json",
+    },
+    {
+      "path": "../utils/tsconfig.build.json",
+    },
+    {
+      "path": "../core/tsconfig.build.json",
+    },
+  ],
+}

+ 10 - 6
packages/@uppy/google-drive/src/GoogleDrive.tsx

@@ -9,7 +9,11 @@ import { ProviderViews } from '@uppy/provider-views'
 import { h, type ComponentChild } from 'preact'
 
 import type { UppyFile, Body, Meta } from '@uppy/utils/lib/UppyFile'
-import type { UnknownProviderPluginState } from '@uppy/core/lib/Uppy.js'
+import type {
+  AsyncStore,
+  UnknownProviderPlugin,
+  UnknownProviderPluginState,
+} from '@uppy/core/lib/Uppy.js'
 import DriveProviderViews from './DriveProviderViews.ts'
 import locale from './locale.ts'
 // eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -18,10 +22,10 @@ import packageJson from '../package.json'
 
 export type GoogleDriveOptions = CompanionPluginOptions
 
-export default class GoogleDrive<
-  M extends Meta,
-  B extends Body,
-> extends UIPlugin<GoogleDriveOptions, M, B, UnknownProviderPluginState> {
+export default class GoogleDrive<M extends Meta, B extends Body>
+  extends UIPlugin<GoogleDriveOptions, M, B, UnknownProviderPluginState>
+  implements UnknownProviderPlugin<M, B>
+{
   static VERSION = packageJson.version
 
   icon: () => h.JSX.Element
@@ -30,7 +34,7 @@ export default class GoogleDrive<
 
   view!: ProviderViews<M, B>
 
-  storage: typeof tokenStorage
+  storage: AsyncStore
 
   files: UppyFile<M, B>[]
 

+ 1 - 0
packages/@uppy/google-photos-picker/.npmignore

@@ -0,0 +1 @@
+tsconfig.*

+ 1 - 0
packages/@uppy/google-photos-picker/CHANGELOG.md

@@ -0,0 +1 @@
+# @uppy/google-photos-picker

+ 21 - 0
packages/@uppy/google-photos-picker/LICENSE

@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2024 Transloadit
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 18 - 0
packages/@uppy/google-photos-picker/README.md

@@ -0,0 +1,18 @@
+# @uppy/google-photos-picker
+
+<img src="https://uppy.io/img/logo.svg" width="120" alt="Uppy logo: a smiling puppy above a pink upwards arrow" align="right">
+
+[![npm version](https://img.shields.io/npm/v/@uppy/google-photos-picker.svg?style=flat-square)](https://www.npmjs.com/package/@uppy/google-photos-picker)
+![CI status for Uppy tests](https://github.com/transloadit/uppy/workflows/Tests/badge.svg)
+![CI status for Companion tests](https://github.com/transloadit/uppy/workflows/Companion/badge.svg)
+![CI status for browser tests](https://github.com/transloadit/uppy/workflows/End-to-end%20tests/badge.svg)
+
+The Google Photos Picker plugin for Uppy lets users import photos from their
+Google Photos account using the new Picker API.
+
+Documentation for this plugin can be found on the
+[Uppy website](https://uppy.io/docs/google-photos-picker).
+
+## License
+
+The [MIT License](./LICENSE).

+ 33 - 0
packages/@uppy/google-photos-picker/package.json

@@ -0,0 +1,33 @@
+{
+  "name": "@uppy/google-photos-picker",
+  "description": "The Google Photos Picker plugin for Uppy lets users import files from their Google Photos account",
+  "version": "0.1.0",
+  "license": "MIT",
+  "main": "lib/index.js",
+  "type": "module",
+  "keywords": [
+    "file uploader",
+    "google photos",
+    "google picker",
+    "cloud storage",
+    "uppy",
+    "uppy-plugin"
+  ],
+  "homepage": "https://uppy.io",
+  "bugs": {
+    "url": "https://github.com/transloadit/uppy/issues"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/transloadit/uppy.git"
+  },
+  "dependencies": {
+    "@uppy/companion-client": "workspace:^",
+    "@uppy/provider-views": "workspace:^",
+    "@uppy/utils": "workspace:^",
+    "preact": "^10.5.13"
+  },
+  "peerDependencies": {
+    "@uppy/core": "workspace:^"
+  }
+}

+ 111 - 0
packages/@uppy/google-photos-picker/src/GooglePhotosPicker.tsx

@@ -0,0 +1,111 @@
+import { h } from 'preact'
+import { UIPlugin, Uppy } from '@uppy/core'
+import { GooglePickerView } from '@uppy/provider-views'
+import { GooglePhotosIcon } from '@uppy/provider-views/lib/GooglePicker/icons.js'
+import {
+  RequestClient,
+  type CompanionPluginOptions,
+  tokenStorage,
+} from '@uppy/companion-client'
+
+import type { PickedItem } from '@uppy/provider-views/lib/GooglePicker/googlePicker.js'
+import type { Body, Meta } from '@uppy/utils/lib/UppyFile'
+import type { AsyncStore, BaseProviderPlugin } from '@uppy/core/lib/Uppy.js'
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore We don't want TS to generate types for the package.json
+import packageJson from '../package.json'
+import locale from './locale.ts'
+
+export type GooglePhotosPickerOptions = CompanionPluginOptions & {
+  clientId: string
+}
+
+export default class GooglePhotosPicker<
+    M extends Meta & { width: number; height: number },
+    B extends Body,
+  >
+  extends UIPlugin<GooglePhotosPickerOptions, M, B>
+  implements BaseProviderPlugin
+{
+  static VERSION = packageJson.version
+
+  static requestClientId = GooglePhotosPicker.name
+
+  type = 'acquirer'
+
+  icon = GooglePhotosIcon
+
+  storage: AsyncStore
+
+  defaultLocale = locale
+
+  constructor(uppy: Uppy<M, B>, opts: GooglePhotosPickerOptions) {
+    super(uppy, opts)
+    this.id = this.opts.id || 'GooglePhotosPicker'
+    this.storage = this.opts.storage || tokenStorage
+
+    this.i18nInit()
+    this.title = this.i18n('pluginNameGooglePhotos')
+
+    const client = new RequestClient(uppy, {
+      pluginId: this.id,
+      provider: 'url',
+      companionUrl: this.opts.companionUrl,
+      companionHeaders: this.opts.companionHeaders,
+      companionCookiesRule: this.opts.companionCookiesRule,
+    })
+
+    this.uppy.registerRequestClient(GooglePhotosPicker.requestClientId, client)
+  }
+
+  install(): void {
+    const { target } = this.opts
+    if (target) {
+      this.mount(target, this)
+    }
+  }
+
+  uninstall(): void {
+    this.unmount()
+  }
+
+  private handleFilesPicked = async (
+    files: PickedItem[],
+    accessToken: string,
+  ) => {
+    this.uppy.addFiles(
+      files.map(({ id, mimeType, name, ...rest }) => {
+        return {
+          source: this.id,
+          name,
+          type: mimeType,
+          data: {
+            size: null, // defer to companion to determine size
+          },
+          isRemote: true,
+          remote: {
+            companionUrl: this.opts.companionUrl,
+            url: `${this.opts.companionUrl}/google-picker/get`,
+            body: {
+              fileId: id,
+              accessToken,
+              ...rest,
+            },
+            requestClientId: GooglePhotosPicker.requestClientId,
+          },
+        }
+      }),
+    )
+  }
+
+  render = () => (
+    <GooglePickerView
+      storage={this.storage}
+      pickerType="photos"
+      uppy={this.uppy}
+      clientId={this.opts.clientId}
+      onFilesPicked={this.handleFilesPicked}
+    />
+  )
+}

+ 1 - 0
packages/@uppy/google-photos-picker/src/index.ts

@@ -0,0 +1 @@
+export { default } from './GooglePhotosPicker.tsx'

+ 3 - 0
packages/@uppy/google-photos-picker/src/locale.ts

@@ -0,0 +1,3 @@
+export default {
+  strings: {},
+}

+ 35 - 0
packages/@uppy/google-photos-picker/tsconfig.build.json

@@ -0,0 +1,35 @@
+{
+  "extends": "../../../tsconfig.shared",
+  "compilerOptions": {
+    "noImplicitAny": false,
+    "outDir": "./lib",
+    "paths": {
+      "@uppy/companion-client": ["../companion-client/src/index.js"],
+      "@uppy/companion-client/lib/*": ["../companion-client/src/*"],
+      "@uppy/provider-views": ["../provider-views/src/index.js"],
+      "@uppy/provider-views/lib/*": ["../provider-views/src/*"],
+      "@uppy/utils/lib/*": ["../utils/src/*"],
+      "@uppy/core": ["../core/src/index.js"],
+      "@uppy/core/lib/*": ["../core/src/*"]
+    },
+    "resolveJsonModule": false,
+    "rootDir": "./src",
+    "skipLibCheck": true
+  },
+  "include": ["./src/**/*.*"],
+  "exclude": ["./src/**/*.test.ts"],
+  "references": [
+    {
+      "path": "../companion-client/tsconfig.build.json"
+    },
+    {
+      "path": "../provider-views/tsconfig.build.json"
+    },
+    {
+      "path": "../utils/tsconfig.build.json"
+    },
+    {
+      "path": "../core/tsconfig.build.json"
+    }
+  ]
+}

+ 31 - 0
packages/@uppy/google-photos-picker/tsconfig.json

@@ -0,0 +1,31 @@
+{
+  "extends": "../../../tsconfig.shared",
+  "compilerOptions": {
+    "emitDeclarationOnly": false,
+    "noEmit": true,
+    "paths": {
+      "@uppy/companion-client": ["../companion-client/src/index.js"],
+      "@uppy/companion-client/lib/*": ["../companion-client/src/*"],
+      "@uppy/provider-views": ["../provider-views/src/index.js"],
+      "@uppy/provider-views/lib/*": ["../provider-views/src/*"],
+      "@uppy/utils/lib/*": ["../utils/src/*"],
+      "@uppy/core": ["../core/src/index.js"],
+      "@uppy/core/lib/*": ["../core/src/*"],
+    },
+  },
+  "include": ["./package.json", "./src/**/*.*"],
+  "references": [
+    {
+      "path": "../companion-client/tsconfig.build.json",
+    },
+    {
+      "path": "../provider-views/tsconfig.build.json",
+    },
+    {
+      "path": "../utils/tsconfig.build.json",
+    },
+    {
+      "path": "../core/tsconfig.build.json",
+    },
+  ],
+}

+ 10 - 6
packages/@uppy/google-photos/src/GooglePhotos.tsx

@@ -9,7 +9,11 @@ import {
 import { h, type ComponentChild } from 'preact'
 
 import type { UppyFile, Body, Meta } from '@uppy/utils/lib/UppyFile'
-import type { UnknownProviderPluginState } from '@uppy/core/lib/Uppy.js'
+import type {
+  AsyncStore,
+  UnknownProviderPlugin,
+  UnknownProviderPluginState,
+} from '@uppy/core/lib/Uppy.js'
 
 // eslint-disable-next-line @typescript-eslint/ban-ts-comment
 // @ts-ignore We don't want TS to generate types for the package.json
@@ -18,10 +22,10 @@ import locale from './locale.ts'
 
 export type GooglePhotosOptions = CompanionPluginOptions
 
-export default class GooglePhotos<
-  M extends Meta,
-  B extends Body,
-> extends UIPlugin<GooglePhotosOptions, M, B, UnknownProviderPluginState> {
+export default class GooglePhotos<M extends Meta, B extends Body>
+  extends UIPlugin<GooglePhotosOptions, M, B, UnknownProviderPluginState>
+  implements UnknownProviderPlugin<M, B>
+{
   static VERSION = packageJson.version
 
   icon: () => h.JSX.Element
@@ -30,7 +34,7 @@ export default class GooglePhotos<
 
   view!: ProviderViews<M, B>
 
-  storage: typeof tokenStorage
+  storage: AsyncStore
 
   files: UppyFile<M, B>[]
 

+ 10 - 8
packages/@uppy/instagram/src/Instagram.tsx

@@ -9,7 +9,11 @@ import { ProviderViews } from '@uppy/provider-views'
 import { h, type ComponentChild } from 'preact'
 
 import type { UppyFile, Body, Meta } from '@uppy/utils/lib/UppyFile'
-import type { UnknownProviderPluginState } from '@uppy/core/lib/Uppy.js'
+import type {
+  AsyncStore,
+  UnknownProviderPlugin,
+  UnknownProviderPluginState,
+} from '@uppy/core/lib/Uppy.js'
 import locale from './locale.ts'
 // eslint-disable-next-line @typescript-eslint/ban-ts-comment
 // @ts-ignore We don't want TS to generate types for the package.json
@@ -17,12 +21,10 @@ import packageJson from '../package.json'
 
 export type InstagramOptions = CompanionPluginOptions
 
-export default class Instagram<M extends Meta, B extends Body> extends UIPlugin<
-  InstagramOptions,
-  M,
-  B,
-  UnknownProviderPluginState
-> {
+export default class Instagram<M extends Meta, B extends Body>
+  extends UIPlugin<InstagramOptions, M, B, UnknownProviderPluginState>
+  implements UnknownProviderPlugin<M, B>
+{
   static VERSION = packageJson.version
 
   icon: () => h.JSX.Element
@@ -31,7 +33,7 @@ export default class Instagram<M extends Meta, B extends Body> extends UIPlugin<
 
   view!: ProviderViews<M, B>
 
-  storage: typeof tokenStorage
+  storage: AsyncStore
 
   files: UppyFile<M, B>[]
 

+ 10 - 8
packages/@uppy/onedrive/src/OneDrive.tsx

@@ -8,8 +8,12 @@ import { UIPlugin, Uppy } from '@uppy/core'
 import { ProviderViews } from '@uppy/provider-views'
 import { h, type ComponentChild } from 'preact'
 
+import type { AsyncStore } from '@uppy/core/src/Uppy.js'
 import type { UppyFile, Body, Meta } from '@uppy/utils/lib/UppyFile'
-import type { UnknownProviderPluginState } from '@uppy/core/lib/Uppy.js'
+import type {
+  UnknownProviderPlugin,
+  UnknownProviderPluginState,
+} from '@uppy/core/lib/Uppy.js'
 import locale from './locale.ts'
 // eslint-disable-next-line @typescript-eslint/ban-ts-comment
 // @ts-ignore We don't want TS to generate types for the package.json
@@ -17,12 +21,10 @@ import packageJson from '../package.json'
 
 export type OneDriveOptions = CompanionPluginOptions
 
-export default class OneDrive<M extends Meta, B extends Body> extends UIPlugin<
-  OneDriveOptions,
-  M,
-  B,
-  UnknownProviderPluginState
-> {
+export default class OneDrive<M extends Meta, B extends Body>
+  extends UIPlugin<OneDriveOptions, M, B, UnknownProviderPluginState>
+  implements UnknownProviderPlugin<M, B>
+{
   static VERSION = packageJson.version
 
   icon: () => h.JSX.Element
@@ -31,7 +33,7 @@ export default class OneDrive<M extends Meta, B extends Body> extends UIPlugin<
 
   view!: ProviderViews<M, B>
 
-  storage: typeof tokenStorage
+  storage: AsyncStore
 
   files: UppyFile<M, B>[]
 

+ 3 - 0
packages/@uppy/provider-views/package.json

@@ -26,6 +26,9 @@
     "preact": "^10.5.13"
   },
   "devDependencies": {
+    "@types/gapi": "^0.0.47",
+    "@types/google.accounts": "^0.0.14",
+    "@types/google.picker": "^0.0.42",
     "vitest": "^1.6.0"
   },
   "peerDependencies": {

+ 234 - 0
packages/@uppy/provider-views/src/GooglePicker/GooglePickerView.tsx

@@ -0,0 +1,234 @@
+import { h } from 'preact'
+import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
+
+import type { Uppy } from '@uppy/core'
+import useStore from '@uppy/core/lib/useStore.js'
+import type { AsyncStore } from '@uppy/core/lib/Uppy.js'
+
+import {
+  authorize,
+  ensureScriptsInjected,
+  InvalidTokenError,
+  logout,
+  pollPickingSession,
+  showDrivePicker,
+  showPhotosPicker,
+  type PickedItem,
+  type PickingSession,
+} from './googlePicker.js'
+import AuthView from '../ProviderView/AuthView.js'
+import { GoogleDriveIcon, GooglePhotosIcon } from './icons.js'
+
+export type GooglePickerViewProps = {
+  uppy: Uppy<any, any>
+  clientId: string
+  onFilesPicked: (files: PickedItem[], accessToken: string) => void
+  storage: AsyncStore
+} & (
+  | {
+      pickerType: 'drive'
+      apiKey: string
+      appId: string
+    }
+  | {
+      pickerType: 'photos'
+      apiKey?: undefined
+      appId?: undefined
+    }
+)
+
+export default function GooglePickerView({
+  uppy,
+  clientId,
+  onFilesPicked,
+  pickerType,
+  apiKey,
+  appId,
+  storage,
+}: GooglePickerViewProps) {
+  const [loading, setLoading] = useState(false)
+  const [accessToken, setAccessTokenStored] = useStore(
+    storage,
+    `uppy:google-${pickerType}-picker:accessToken`,
+  )
+
+  const pickingSessionRef = useRef<PickingSession>()
+  const accessTokenRef = useRef(accessToken)
+  const shownPickerRef = useRef(false)
+
+  const setAccessToken = useCallback(
+    (t: string | null) => {
+      uppy.log('Access token updated')
+      setAccessTokenStored(t)
+      accessTokenRef.current = t
+    },
+    [setAccessTokenStored, uppy],
+  )
+
+  // keep access token in sync with the ref
+  useEffect(() => {
+    accessTokenRef.current = accessToken
+  }, [accessToken])
+
+  const showPicker = useCallback(
+    async (signal?: AbortSignal) => {
+      let newAccessToken = accessToken
+
+      const doShowPicker = async (token: string) => {
+        if (pickerType === 'drive') {
+          await showDrivePicker({ token, apiKey, appId, onFilesPicked, signal })
+        } else {
+          // photos
+          const onPickingSessionChange = (
+            newPickingSession: PickingSession,
+          ) => {
+            pickingSessionRef.current = newPickingSession
+          }
+          await showPhotosPicker({
+            token,
+            pickingSession: pickingSessionRef.current,
+            onPickingSessionChange,
+            signal,
+          })
+        }
+      }
+
+      setLoading(true)
+      try {
+        try {
+          await ensureScriptsInjected(pickerType)
+
+          if (newAccessToken == null) {
+            newAccessToken = await authorize({ clientId, pickerType })
+          }
+          if (newAccessToken == null) throw new Error()
+
+          await doShowPicker(newAccessToken)
+          shownPickerRef.current = true
+          setAccessToken(newAccessToken)
+        } catch (err) {
+          if (err instanceof InvalidTokenError) {
+            uppy.log('Token is invalid or expired, reauthenticating')
+            newAccessToken = await authorize({
+              pickerType,
+              accessToken: newAccessToken,
+              clientId,
+            })
+            // now try again:
+            await doShowPicker(newAccessToken)
+            shownPickerRef.current = true
+            setAccessToken(newAccessToken)
+          } else {
+            throw err
+          }
+        }
+      } catch (err) {
+        if (
+          err instanceof Error &&
+          'type' in err &&
+          err.type === 'popup_closed'
+        ) {
+          // user closed the auth popup, ignore
+        } else {
+          setAccessToken(null)
+          uppy.log(err)
+        }
+      } finally {
+        setLoading(false)
+      }
+    },
+    [
+      accessToken,
+      apiKey,
+      appId,
+      clientId,
+      onFilesPicked,
+      pickerType,
+      setAccessToken,
+      uppy,
+    ],
+  )
+
+  useEffect(() => {
+    const abortController = new AbortController()
+
+    pollPickingSession({
+      pickingSessionRef,
+      accessTokenRef,
+      signal: abortController.signal,
+      onFilesPicked,
+      onError: (err) => uppy.log(err),
+    })
+
+    return () => abortController.abort()
+  }, [onFilesPicked, uppy])
+
+  useEffect(() => {
+    // when mounting, once we have a token, be nice to the user and automatically show the picker
+    // accessToken === undefined means not yet loaded from storage, so wait for that first
+    if (accessToken === undefined || shownPickerRef.current) {
+      return undefined
+    }
+
+    const abortController = new AbortController()
+
+    showPicker(abortController.signal)
+
+    return () => {
+      // only abort the picker if it's not yet shown
+      if (!shownPickerRef.current) abortController.abort()
+    }
+  }, [accessToken, showPicker])
+
+  const handleLogoutClick = useCallback(async () => {
+    if (accessToken) {
+      await logout(accessToken)
+      setAccessToken(null)
+      pickingSessionRef.current = undefined
+    }
+  }, [accessToken, setAccessToken])
+
+  if (loading) {
+    return <div>{uppy.i18n('pleaseWait')}...</div>
+  }
+
+  if (accessToken == null) {
+    return (
+      <AuthView
+        pluginName={
+          pickerType === 'drive' ?
+            uppy.i18n('pluginNameGoogleDrive')
+          : uppy.i18n('pluginNameGooglePhotos')
+        }
+        pluginIcon={pickerType === 'drive' ? GoogleDriveIcon : GooglePhotosIcon}
+        handleAuth={showPicker}
+        i18n={uppy.i18nArray}
+        loading={loading}
+      />
+    )
+  }
+
+  return (
+    <div style={{ textAlign: 'center' }}>
+      <button
+        type="button"
+        className="uppy-u-reset uppy-c-btn uppy-c-btn-primary"
+        style={{ display: 'block', marginBottom: '1em' }}
+        disabled={loading}
+        onClick={() => showPicker()}
+      >
+        {pickerType === 'drive' ?
+          uppy.i18n('pickFiles')
+        : uppy.i18n('pickPhotos')}
+      </button>
+      <button
+        type="button"
+        className="uppy-u-reset uppy-c-btn"
+        disabled={loading}
+        onClick={handleLogoutClick}
+      >
+        {uppy.i18n('logOut')}
+      </button>
+    </div>
+  )
+}

+ 425 - 0
packages/@uppy/provider-views/src/GooglePicker/googlePicker.ts

@@ -0,0 +1,425 @@
+import { type MutableRef } from 'preact/hooks'
+
+// https://developers.google.com/photos/picker/reference/rest/v1/mediaItems
+export interface MediaItemBase {
+  id: string
+  createTime: string
+}
+
+interface MediaFileMetadataBase {
+  width: number
+  height: number
+  cameraMake: string
+  cameraModel: string
+}
+
+interface MediaFileBase {
+  baseUrl: string
+  mimeType: string
+  filename: string
+}
+
+export interface VideoMediaItem extends MediaItemBase {
+  type: 'VIDEO'
+  mediaFile: MediaFileBase & {
+    mediaFileMetadata: MediaFileMetadataBase & {
+      videoMetadata: {
+        fps: number
+        processingStatus: 'UNSPECIFIED' | 'PROCESSING' | 'READY' | 'FAILED'
+      }
+    }
+  }
+}
+
+export interface PhotoMediaItem extends MediaItemBase {
+  type: 'PHOTO'
+  mediaFile: MediaFileBase & {
+    mediaFileMetadata: MediaFileMetadataBase & {
+      photoMetadata: {
+        focalLength: number
+        apertureFNumber: number
+        isoEquivalent: number
+        exposureTime: string
+      }
+    }
+  }
+}
+
+export interface UnspecifiedMediaItem extends MediaItemBase {
+  type: 'TYPE_UNSPECIFIED'
+  mediaFile: MediaFileBase
+}
+
+export type MediaItem = VideoMediaItem | PhotoMediaItem | UnspecifiedMediaItem
+
+// https://developers.google.com/photos/picker/reference/rest/v1/sessions
+export interface PickingSession {
+  id: string
+  pickerUri: string
+  pollingConfig: {
+    pollInterval: string
+    timeoutIn: string
+  }
+  expireTime: string
+  mediaItemsSet: boolean
+}
+
+export interface PickedItemBase {
+  id: string
+  mimeType: string
+  name: string
+}
+
+export interface PickedDriveItem extends PickedItemBase {
+  platform: 'drive'
+}
+
+export interface PickedPhotosItem extends PickedItemBase {
+  platform: 'photos'
+  url: string
+}
+
+export type PickedItem = PickedPhotosItem | PickedDriveItem
+
+type PickerType = 'drive' | 'photos'
+
+const getAuthHeader = (token: string) => ({
+  authorization: `Bearer ${token}`,
+})
+
+const injectedScripts = new Set<string>()
+let driveApiLoaded = false
+
+// https://stackoverflow.com/a/39008859/6519037
+async function injectScript(src: string) {
+  if (injectedScripts.has(src)) return
+
+  await new Promise<void>((resolve, reject) => {
+    const script = document.createElement('script')
+    script.src = src
+    script.addEventListener('load', () => resolve())
+    script.addEventListener('error', (e) => reject(e.error))
+    document.head.appendChild(script)
+  })
+  injectedScripts.add(src)
+}
+
+export async function ensureScriptsInjected(
+  pickerType: PickerType,
+): Promise<void> {
+  await Promise.all([
+    injectScript('https://accounts.google.com/gsi/client'), // Google Identity Services
+    (async () => {
+      await injectScript('https://apis.google.com/js/api.js')
+
+      if (pickerType === 'drive' && !driveApiLoaded) {
+        await new Promise<void>((resolve) =>
+          gapi.load('client:picker', () => resolve()),
+        )
+        await gapi.client.load(
+          'https://www.googleapis.com/discovery/v1/apis/drive/v3/rest',
+        )
+        driveApiLoaded = true
+      }
+    })(),
+  ])
+}
+
+async function isTokenValid(
+  accessToken: string,
+  signal: AbortSignal | undefined,
+) {
+  const response = await fetch(
+    `https://www.googleapis.com/oauth2/v1/tokeninfo?access_token=${encodeURIComponent(accessToken)}`,
+    { signal },
+  )
+  if (response.ok) {
+    return true
+  }
+  // console.warn('Token is invalid or expired:', response.status, await response.text());
+  // Token is invalid or expired
+  return false
+}
+
+export async function authorize({
+  pickerType,
+  clientId,
+  accessToken,
+}: {
+  pickerType: PickerType
+  clientId: string
+  accessToken?: string | null | undefined
+}): Promise<string> {
+  const response = await new Promise<google.accounts.oauth2.TokenResponse>(
+    (resolve, reject) => {
+      const scopes =
+        pickerType === 'drive' ?
+          ['https://www.googleapis.com/auth/drive.readonly']
+        : ['https://www.googleapis.com/auth/photospicker.mediaitems.readonly']
+
+      const tokenClient = google.accounts.oauth2.initTokenClient({
+        client_id: clientId,
+        // Authorization scopes required by the API; multiple scopes can be included, separated by spaces.
+        scope: scopes.join(' '),
+        callback: resolve,
+        error_callback: reject,
+      })
+
+      if (accessToken === null) {
+        // Prompt the user to select a Google Account and ask for consent to share their data
+        // when establishing a new session.
+        tokenClient.requestAccessToken({ prompt: 'consent' })
+      } else {
+        // Skip display of account chooser and consent dialog for an existing session.
+        tokenClient.requestAccessToken({ prompt: '' })
+      }
+    },
+  )
+
+  if (response.error) {
+    throw new Error(`OAuth2 error: ${response.error}`)
+  }
+  return response.access_token
+}
+
+export async function logout(accessToken: string): Promise<void> {
+  await new Promise<void>((resolve) =>
+    google.accounts.oauth2.revoke(accessToken, resolve),
+  )
+}
+
+export class InvalidTokenError extends Error {
+  constructor() {
+    super('Invalid or expired token')
+    this.name = 'InvalidTokenError'
+  }
+}
+
+export async function showDrivePicker({
+  token,
+  apiKey,
+  appId,
+  onFilesPicked,
+  signal,
+}: {
+  token: string
+  apiKey: string
+  appId: string
+  onFilesPicked: (files: PickedItem[], accessToken: string) => void
+  signal: AbortSignal | undefined
+}): Promise<void> {
+  // google drive picker will crash hard if given an invalid token, so we need to check it first
+  // https://github.com/transloadit/uppy/pull/5443#pullrequestreview-2452439265
+  if (!(await isTokenValid(token, signal))) {
+    throw new InvalidTokenError()
+  }
+
+  const onPicked = (picked: google.picker.ResponseObject) => {
+    if (picked.action === google.picker.Action.PICKED) {
+      // console.log('Picker response', JSON.stringify(picked, null, 2));
+      onFilesPicked(
+        picked['docs'].map((doc) => ({
+          platform: 'drive',
+          id: doc['id'],
+          name: doc['name'],
+          mimeType: doc['mimeType'],
+        })),
+        token,
+      )
+    }
+  }
+
+  const picker = new google.picker.PickerBuilder()
+    .enableFeature(google.picker.Feature.NAV_HIDDEN)
+    .enableFeature(google.picker.Feature.MULTISELECT_ENABLED)
+    .setDeveloperKey(apiKey)
+    .setAppId(appId)
+    .setOAuthToken(token)
+    .addView(
+      new google.picker.DocsView(google.picker.ViewId.DOCS)
+        .setIncludeFolders(true)
+        // Note: setEnableDrives doesn't seem to work
+        // .setEnableDrives(true)
+        .setSelectFolderEnabled(false),
+    )
+    // NOTE: photos is broken and results in an error being returned from Google
+    // I think it's the old Picasa photos
+    // .addView(google.picker.ViewId.PHOTOS)
+    .setCallback(onPicked)
+    .build()
+
+  picker.setVisible(true)
+  signal?.addEventListener('abort', () => picker.dispose())
+}
+
+export async function showPhotosPicker({
+  token,
+  pickingSession,
+  onPickingSessionChange,
+  signal,
+}: {
+  token: string
+  pickingSession: PickingSession | undefined
+  onPickingSessionChange: (ps: PickingSession) => void
+  signal: AbortSignal | undefined
+}): Promise<void> {
+  // https://developers.google.com/photos/picker/guides/get-started-picker
+  const headers = getAuthHeader(token)
+
+  let newPickingSession = pickingSession
+  if (newPickingSession == null) {
+    const createSessionResponse = await fetch(
+      'https://photospicker.googleapis.com/v1/sessions',
+      { method: 'post', headers, signal },
+    )
+
+    if (createSessionResponse.status === 401) {
+      const resp = await createSessionResponse.json()
+      if (resp.error?.status === 'UNAUTHENTICATED') {
+        throw new InvalidTokenError()
+      }
+    }
+
+    if (!createSessionResponse.ok) {
+      throw new Error('Failed to create a session')
+    }
+    newPickingSession = (await createSessionResponse.json()) as PickingSession
+
+    onPickingSessionChange(newPickingSession)
+  }
+
+  const w = window.open(newPickingSession.pickerUri)
+  signal?.addEventListener('abort', () => w?.close())
+}
+
+async function resolvePickedPhotos({
+  accessToken,
+  pickingSession,
+  signal,
+}: {
+  accessToken: string
+  pickingSession: PickingSession
+  signal: AbortSignal
+}) {
+  const headers = getAuthHeader(accessToken)
+
+  let pageToken: string | undefined
+  let mediaItems: MediaItem[] = []
+  do {
+    const pageSize = 100
+    const response = await fetch(
+      `https://photospicker.googleapis.com/v1/mediaItems?${new URLSearchParams({ sessionId: pickingSession.id, pageSize: String(pageSize) }).toString()}`,
+      { headers, signal },
+    )
+    if (!response.ok) throw new Error('Failed to get a media items')
+    const {
+      mediaItems: batchMediaItems,
+      nextPageToken,
+    }: { mediaItems: MediaItem[]; nextPageToken?: string } =
+      await response.json()
+    pageToken = nextPageToken
+    mediaItems.push(...batchMediaItems)
+  } while (pageToken)
+
+  // todo show alert instead about invalid picked files?
+  mediaItems = mediaItems.flatMap((i) =>
+    (
+      i.type === 'PHOTO' ||
+      (i.type === 'VIDEO' &&
+        i.mediaFile.mediaFileMetadata.videoMetadata.processingStatus ===
+          'READY')
+    ) ?
+      [i]
+    : [],
+  )
+
+  return mediaItems.map(
+    ({
+      id,
+      // we want the original resolution, so we don't append any parameter to the baseUrl
+      // https://developers.google.com/photos/library/guides/access-media-items#base-urls
+      mediaFile: { mimeType, filename, baseUrl },
+    }) => ({
+      platform: 'photos' as const,
+      id,
+      mimeType,
+      url: baseUrl,
+      name: filename,
+    }),
+  )
+}
+
+export async function pollPickingSession({
+  pickingSessionRef,
+  accessTokenRef,
+  signal,
+  onFilesPicked,
+  onError,
+}: {
+  pickingSessionRef: MutableRef<PickingSession | undefined>
+  accessTokenRef: MutableRef<string | null | undefined>
+  signal: AbortSignal
+  onFilesPicked: (files: PickedItem[], accessToken: string) => void
+  onError: (err: unknown) => void
+}): Promise<void> {
+  // if we have an active session, poll it until it either times out, or the user selects some photos.
+  // Note that the user can also just close the page, but we get no indication of that from Google when polling,
+  // so we just have to continue polling in the background, so we can react to it
+  // in case the user opens the photo selector again. Hence the infinite for loop
+  for (let interval = 1; ; ) {
+    try {
+      if (pickingSessionRef.current != null) {
+        interval = parseFloat(
+          pickingSessionRef.current.pollingConfig.pollInterval,
+        )
+      } else {
+        interval = 1
+      }
+
+      await Promise.race([
+        new Promise((resolve) => setTimeout(resolve, interval * 1000)),
+        new Promise((_resolve, reject) => {
+          signal.addEventListener('abort', reject)
+        }),
+      ])
+
+      signal.throwIfAborted()
+
+      const accessToken = accessTokenRef.current
+      const pickingSession = pickingSessionRef.current
+
+      if (pickingSession != null && accessToken != null) {
+        const headers = getAuthHeader(accessToken)
+
+        // https://developers.google.com/photos/picker/reference/rest/v1/sessions
+        const response = await fetch(
+          `https://photospicker.googleapis.com/v1/sessions/${encodeURIComponent(pickingSession.id)}`,
+          { headers, signal },
+        )
+        if (!response.ok) throw new Error('Failed to get session')
+        const json: PickingSession = await response.json()
+        if (json.mediaItemsSet) {
+          // console.log('User picked!', json)
+          const resolvedPhotos = await resolvePickedPhotos({
+            accessToken,
+            pickingSession,
+            signal,
+          })
+          // eslint-disable-next-line no-param-reassign
+          pickingSessionRef.current = undefined
+          onFilesPicked(resolvedPhotos, accessToken)
+        }
+        if (pickingSession.pollingConfig.timeoutIn === '0s') {
+          // eslint-disable-next-line no-param-reassign
+          pickingSessionRef.current = undefined
+        }
+      }
+    } catch (err) {
+      if (err instanceof Error && err.name === 'AbortError') {
+        return
+      }
+      // just report the error and continue polling
+      onError(err)
+    }
+  }
+}

+ 70 - 0
packages/@uppy/provider-views/src/GooglePicker/icons.tsx

@@ -0,0 +1,70 @@
+import { h } from 'preact'
+
+export const GooglePhotosIcon = () => (
+  <svg
+    aria-hidden="true"
+    focusable="false"
+    width="32"
+    height="32"
+    viewBox="-7 -7 73 73"
+  >
+    <g fill="none" fill-rule="evenodd">
+      <path d="M-3-3h64v64H-3z" />
+      <g fill-rule="nonzero">
+        <path
+          fill="#FBBC04"
+          d="M14.8 13.4c8.1 0 14.7 6.6 14.7 14.8v1.3H1.3c-.7 0-1.3-.6-1.3-1.3C0 20 6.6 13.4 14.8 13.4z"
+        />
+        <path
+          fill="#EA4335"
+          d="M45.6 14.8c0 8.1-6.6 14.7-14.8 14.7h-1.3V1.3c0-.7.6-1.3 1.3-1.3C39 0 45.6 6.6 45.6 14.8z"
+        />
+        <path
+          fill="#4285F4"
+          d="M44.3 45.6c-8.2 0-14.8-6.6-14.8-14.8v-1.3h28.2c.7 0 1.3.6 1.3 1.3 0 8.2-6.6 14.8-14.8 14.8z"
+        />
+        <path
+          fill="#34A853"
+          d="M13.4 44.3c0-8.2 6.6-14.8 14.8-14.8h1.3v28.2c0 .7-.6 1.3-1.3 1.3-8.2 0-14.8-6.6-14.8-14.8z"
+        />
+      </g>
+    </g>
+  </svg>
+)
+
+export const GoogleDriveIcon = () => (
+  <svg
+    aria-hidden="true"
+    focusable="false"
+    width="32"
+    height="32"
+    viewBox="0 0 32 32"
+  >
+    <g fillRule="nonzero" fill="none">
+      <path
+        d="M6.663 22.284l.97 1.62c.202.34.492.609.832.804l3.465-5.798H5c0 .378.1.755.302 1.096l1.361 2.278z"
+        fill="#0066DA"
+      />
+      <path
+        d="M16 12.09l-3.465-5.798c-.34.195-.63.463-.832.804l-6.4 10.718A2.15 2.15 0 005 18.91h6.93L16 12.09z"
+        fill="#00AC47"
+      />
+      <path
+        d="M23.535 24.708c.34-.195.63-.463.832-.804l.403-.67 1.928-3.228c.201-.34.302-.718.302-1.096h-6.93l1.474 2.802 1.991 2.996z"
+        fill="#EA4335"
+      />
+      <path
+        d="M16 12.09l3.465-5.798A2.274 2.274 0 0018.331 6h-4.662c-.403 0-.794.11-1.134.292L16 12.09z"
+        fill="#00832D"
+      />
+      <path
+        d="M20.07 18.91h-8.14l-3.465 5.798c.34.195.73.292 1.134.292h12.802c.403 0 .794-.11 1.134-.292L20.07 18.91z"
+        fill="#2684FC"
+      />
+      <path
+        d="M23.497 12.455l-3.2-5.359a2.252 2.252 0 00-.832-.804L16 12.09l4.07 6.82h6.917c0-.377-.1-.755-.302-1.096l-3.188-5.359z"
+        fill="#FFBA00"
+      />
+    </g>
+  </svg>
+)

+ 2 - 0
packages/@uppy/provider-views/src/index.ts

@@ -4,3 +4,5 @@ export {
 } from './ProviderView/index.ts'
 
 export { default as SearchProviderViews } from './SearchProviderView/index.ts'
+
+export { default as GooglePickerView } from './GooglePicker/GooglePickerView.tsx'

+ 8 - 0
packages/@uppy/provider-views/src/style.scss

@@ -379,3 +379,11 @@
     padding-bottom: 10px;
   }
 }
+
+/* https://stackoverflow.com/a/33082658/6519037 */
+.picker-dialog-bg {
+  z-index: 20000 !important;
+}
+.picker-dialog {
+  z-index: 20001 !important;
+}

+ 0 - 1
packages/@uppy/provider-views/src/utils/getTagFile.ts

@@ -34,7 +34,6 @@ const getTagFile = <M extends Meta, B extends Body>(
     },
     remote: {
       companionUrl: plugin.opts.companionUrl,
-      // @ts-expect-error untyped for now
       url: `${provider.fileUrl(file.requestPath)}`,
       body: {
         fileId: file.id,

+ 1 - 0
packages/@uppy/provider-views/tsconfig.json

@@ -8,6 +8,7 @@
       "@uppy/core": ["../core/src/index.js"],
       "@uppy/core/lib/*": ["../core/src/*"],
     },
+    "types": ["google.accounts", "google.picker", "gapi"],
   },
   "include": ["./package.json", "./src/**/*.*"],
   "references": [

+ 2 - 0
packages/@uppy/transloadit/src/index.ts

@@ -334,6 +334,8 @@ export default class Transloadit<
     addPluginVersion('Facebook', 'uppy-facebook')
     addPluginVersion('GoogleDrive', 'uppy-google-drive')
     addPluginVersion('GooglePhotos', 'uppy-google-photos')
+    addPluginVersion('GoogleDrivePicker', 'uppy-google-drive-picker')
+    addPluginVersion('GooglePhotosPicker', 'uppy-google-photos-picker')
     addPluginVersion('Instagram', 'uppy-instagram')
     addPluginVersion('OneDrive', 'uppy-onedrive')
     addPluginVersion('Zoom', 'uppy-zoom')

+ 10 - 8
packages/@uppy/unsplash/src/Unsplash.tsx

@@ -9,7 +9,11 @@ import { SearchProviderViews } from '@uppy/provider-views'
 import { h, type ComponentChild } from 'preact'
 
 import type { UppyFile, Body, Meta } from '@uppy/utils/lib/UppyFile'
-import type { UnknownSearchProviderPluginState } from '@uppy/core/lib/Uppy.js'
+import type {
+  AsyncStore,
+  UnknownSearchProviderPlugin,
+  UnknownSearchProviderPluginState,
+} from '@uppy/core/lib/Uppy.js'
 import locale from './locale.ts'
 // eslint-disable-next-line @typescript-eslint/ban-ts-comment
 // @ts-ignore We don't want TS to generate types for the package.json
@@ -17,12 +21,10 @@ import packageJson from '../package.json'
 
 export type UnsplashOptions = CompanionPluginOptions
 
-export default class Unsplash<M extends Meta, B extends Body> extends UIPlugin<
-  UnsplashOptions,
-  M,
-  B,
-  UnknownSearchProviderPluginState
-> {
+export default class Unsplash<M extends Meta, B extends Body>
+  extends UIPlugin<UnsplashOptions, M, B, UnknownSearchProviderPluginState>
+  implements UnknownSearchProviderPlugin<M, B>
+{
   static VERSION = packageJson.version
 
   icon: () => h.JSX.Element
@@ -31,7 +33,7 @@ export default class Unsplash<M extends Meta, B extends Body> extends UIPlugin<
 
   view!: SearchProviderViews<M, B>
 
-  storage: typeof tokenStorage
+  storage: AsyncStore
 
   files: UppyFile<M, B>[]
 

+ 2 - 0
packages/@uppy/utils/src/CompanionClientProvider.ts

@@ -26,6 +26,7 @@ export interface CompanionClientProvider {
   login(options?: RequestOptions): Promise<void>
   logout<ResBody>(options?: RequestOptions): Promise<ResBody>
   fetchPreAuthToken(): Promise<void>
+  fileUrl: (a: string) => string
   list(
     directory: string | null,
     options: RequestOptions,
@@ -38,5 +39,6 @@ export interface CompanionClientProvider {
 export interface CompanionClientSearchProvider {
   name: string
   provider: string
+  fileUrl: (a: string) => string
   search<ResBody>(text: string, queries?: string): Promise<ResBody>
 }

+ 10 - 8
packages/@uppy/zoom/src/Zoom.tsx

@@ -9,7 +9,11 @@ import { ProviderViews } from '@uppy/provider-views'
 import { h, type ComponentChild } from 'preact'
 
 import type { UppyFile, Body, Meta } from '@uppy/utils/lib/UppyFile'
-import type { UnknownProviderPluginState } from '@uppy/core/lib/Uppy.js'
+import type {
+  AsyncStore,
+  UnknownProviderPlugin,
+  UnknownProviderPluginState,
+} from '@uppy/core/lib/Uppy.js'
 import locale from './locale.ts'
 // eslint-disable-next-line @typescript-eslint/ban-ts-comment
 // @ts-ignore We don't want TS to generate types for the package.json
@@ -17,12 +21,10 @@ import packageJson from '../package.json'
 
 export type ZoomOptions = CompanionPluginOptions
 
-export default class Zoom<M extends Meta, B extends Body> extends UIPlugin<
-  ZoomOptions,
-  M,
-  B,
-  UnknownProviderPluginState
-> {
+export default class Zoom<M extends Meta, B extends Body>
+  extends UIPlugin<ZoomOptions, M, B, UnknownProviderPluginState>
+  implements UnknownProviderPlugin<M, B>
+{
   static VERSION = packageJson.version
 
   icon: () => h.JSX.Element
@@ -31,7 +33,7 @@ export default class Zoom<M extends Meta, B extends Body> extends UIPlugin<
 
   view!: ProviderViews<M, B>
 
-  storage: typeof tokenStorage
+  storage: AsyncStore
 
   files: UppyFile<M, B>[]
 

+ 2 - 0
packages/uppy/package.json

@@ -46,7 +46,9 @@
     "@uppy/form": "workspace:^",
     "@uppy/golden-retriever": "workspace:^",
     "@uppy/google-drive": "workspace:^",
+    "@uppy/google-drive-picker": "workspace:^",
     "@uppy/google-photos": "workspace:^",
+    "@uppy/google-photos-picker": "workspace:^",
     "@uppy/image-editor": "workspace:^",
     "@uppy/informer": "workspace:^",
     "@uppy/instagram": "workspace:^",

+ 8 - 2
packages/uppy/src/bundle.ts

@@ -22,7 +22,9 @@ export const views = { ProviderView }
 
 // Stores
 export { default as DefaultStore } from '@uppy/store-default'
-// @ts-expect-error untyped
+// not yet typed
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore
 export { default as ReduxStore } from '@uppy/store-redux'
 
 // UI plugins
@@ -42,6 +44,8 @@ export { default as Dropbox } from '@uppy/dropbox'
 export { default as Facebook } from '@uppy/facebook'
 export { default as GoogleDrive } from '@uppy/google-drive'
 export { default as GooglePhotos } from '@uppy/google-photos'
+export { default as GoogleDrivePicker } from '@uppy/google-drive-picker'
+export { default as GooglePhotosPicker } from '@uppy/google-photos-picker'
 export { default as Instagram } from '@uppy/instagram'
 export { default as OneDrive } from '@uppy/onedrive'
 export { default as RemoteSources } from '@uppy/remote-sources'
@@ -61,7 +65,9 @@ export { default as XHRUpload } from '@uppy/xhr-upload'
 export { default as Compressor } from '@uppy/compressor'
 export { default as Form } from '@uppy/form'
 export { default as GoldenRetriever } from '@uppy/golden-retriever'
-// @ts-expect-error untyped
+// not yet typed
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore
 export { default as ReduxDevTools } from '@uppy/redux-dev-tools'
 export { default as ThumbnailGenerator } from '@uppy/thumbnail-generator'
 

+ 19 - 0
private/dev/Dashboard.js

@@ -16,6 +16,8 @@ import Audio from '@uppy/audio'
 import Compressor from '@uppy/compressor'
 import GoogleDrive from '@uppy/google-drive'
 import english from '@uppy/locales/lib/en_US.js'
+import GoogleDrivePicker from '@uppy/google-drive-picker'
+import GooglePhotosPicker from '@uppy/google-photos-picker'
 /* eslint-enable import/no-extraneous-dependencies */
 
 import generateSignatureIfSecret from './generateSignatureIfSecret.js'
@@ -30,6 +32,9 @@ const {
   VITE_TRANSLOADIT_SECRET: TRANSLOADIT_SECRET,
   VITE_TRANSLOADIT_TEMPLATE: TRANSLOADIT_TEMPLATE,
   VITE_TRANSLOADIT_SERVICE_URL: TRANSLOADIT_SERVICE_URL,
+  VITE_GOOGLE_PICKER_API_KEY: GOOGLE_PICKER_API_KEY,
+  VITE_GOOGLE_PICKER_CLIENT_ID: GOOGLE_PICKER_CLIENT_ID,
+  VITE_GOOGLE_PICKER_APP_ID: GOOGLE_PICKER_APP_ID,
 } = import.meta.env
 
 const companionAllowedHosts =
@@ -125,6 +130,20 @@ export default () => {
     // .use(Zoom, { target: Dashboard, companionUrl: COMPANION_URL, companionAllowedHosts })
     // .use(Url, { target: Dashboard, companionUrl: COMPANION_URL, companionAllowedHosts })
     // .use(Unsplash, { target: Dashboard, companionUrl: COMPANION_URL, companionAllowedHosts })
+    .use(GoogleDrivePicker, {
+      target: Dashboard,
+      companionUrl: COMPANION_URL,
+      companionAllowedHosts,
+      clientId: GOOGLE_PICKER_CLIENT_ID,
+      apiKey: GOOGLE_PICKER_API_KEY,
+      appId: GOOGLE_PICKER_APP_ID,
+    })
+    .use(GooglePhotosPicker, {
+      target: Dashboard,
+      companionUrl: COMPANION_URL,
+      companionAllowedHosts,
+      clientId: GOOGLE_PICKER_CLIENT_ID,
+    })
     .use(RemoteSources, {
       companionUrl: COMPANION_URL,
       sources: [

+ 6 - 0
tsconfig.json

@@ -49,6 +49,12 @@
     {
       "path": "./packages/@uppy/google-photos/tsconfig.build.json",
     },
+    {
+      "path": "./packages/@uppy/google-drive-picker/tsconfig.build.json",
+    },
+    {
+      "path": "./packages/@uppy/google-photos-picker/tsconfig.build.json",
+    },
     {
       "path": "./packages/@uppy/image-editor/tsconfig.build.json",
     },

+ 54 - 0
yarn.lock

@@ -7492,6 +7492,27 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@types/gapi@npm:^0.0.47":
+  version: 0.0.47
+  resolution: "@types/gapi@npm:0.0.47"
+  checksum: 10/b8104688ef132190cb661b461b912a3f6f07ce589eb90ab4bff4acdfaa9bbb8a6321be1119e865db89bf46dfc00cab2141764839535518cf63a8e2caa19f475e
+  languageName: node
+  linkType: hard
+
+"@types/google.accounts@npm:^0.0.14":
+  version: 0.0.14
+  resolution: "@types/google.accounts@npm:0.0.14"
+  checksum: 10/0332acd210eaad1904d28a9de2081da796cb8c22e4f61bbe0768729c71d1de1606355abc0615907505b9f4ac28694911b9722a6a4e6ee563c21f747e9e1c32b5
+  languageName: node
+  linkType: hard
+
+"@types/google.picker@npm:^0.0.42":
+  version: 0.0.42
+  resolution: "@types/google.picker@npm:0.0.42"
+  checksum: 10/7e428495807c840f30ff3eab63fbfc4b9760ba20cbf977b94915f2222f678bb29c5bf73eff6f285f661b293127ddfbbedd4d8b2075d102c272ab941f63fa7d78
+  languageName: node
+  linkType: hard
+
 "@types/graceful-fs@npm:^4.1.2, @types/graceful-fs@npm:^4.1.3":
   version: 4.1.9
   resolution: "@types/graceful-fs@npm:4.1.9"
@@ -8872,6 +8893,19 @@ __metadata:
   languageName: unknown
   linkType: soft
 
+"@uppy/google-drive-picker@workspace:^, @uppy/google-drive-picker@workspace:packages/@uppy/google-drive-picker":
+  version: 0.0.0-use.local
+  resolution: "@uppy/google-drive-picker@workspace:packages/@uppy/google-drive-picker"
+  dependencies:
+    "@uppy/companion-client": "workspace:^"
+    "@uppy/provider-views": "workspace:^"
+    "@uppy/utils": "workspace:^"
+    preact: "npm:^10.5.13"
+  peerDependencies:
+    "@uppy/core": "workspace:^"
+  languageName: unknown
+  linkType: soft
+
 "@uppy/google-drive@workspace:*, @uppy/google-drive@workspace:^, @uppy/google-drive@workspace:packages/@uppy/google-drive":
   version: 0.0.0-use.local
   resolution: "@uppy/google-drive@workspace:packages/@uppy/google-drive"
@@ -8885,6 +8919,19 @@ __metadata:
   languageName: unknown
   linkType: soft
 
+"@uppy/google-photos-picker@workspace:^, @uppy/google-photos-picker@workspace:packages/@uppy/google-photos-picker":
+  version: 0.0.0-use.local
+  resolution: "@uppy/google-photos-picker@workspace:packages/@uppy/google-photos-picker"
+  dependencies:
+    "@uppy/companion-client": "workspace:^"
+    "@uppy/provider-views": "workspace:^"
+    "@uppy/utils": "workspace:^"
+    preact: "npm:^10.5.13"
+  peerDependencies:
+    "@uppy/core": "workspace:^"
+  languageName: unknown
+  linkType: soft
+
 "@uppy/google-photos@workspace:*, @uppy/google-photos@workspace:^, @uppy/google-photos@workspace:packages/@uppy/google-photos":
   version: 0.0.0-use.local
   resolution: "@uppy/google-photos@workspace:packages/@uppy/google-photos"
@@ -8970,6 +9017,9 @@ __metadata:
   version: 0.0.0-use.local
   resolution: "@uppy/provider-views@workspace:packages/@uppy/provider-views"
   dependencies:
+    "@types/gapi": "npm:^0.0.47"
+    "@types/google.accounts": "npm:^0.0.14"
+    "@types/google.picker": "npm:^0.0.42"
     "@uppy/utils": "workspace:^"
     classnames: "npm:^2.2.6"
     nanoid: "npm:^5.0.0"
@@ -13510,7 +13560,9 @@ __metadata:
     "@uppy/form": "workspace:^"
     "@uppy/golden-retriever": "workspace:^"
     "@uppy/google-drive": "workspace:^"
+    "@uppy/google-drive-picker": "workspace:^"
     "@uppy/google-photos": "workspace:^"
+    "@uppy/google-photos-picker": "workspace:^"
     "@uppy/image-editor": "workspace:^"
     "@uppy/informer": "workspace:^"
     "@uppy/instagram": "workspace:^"
@@ -29643,7 +29695,9 @@ __metadata:
     "@uppy/form": "workspace:^"
     "@uppy/golden-retriever": "workspace:^"
     "@uppy/google-drive": "workspace:^"
+    "@uppy/google-drive-picker": "workspace:^"
     "@uppy/google-photos": "workspace:^"
+    "@uppy/google-photos-picker": "workspace:^"
     "@uppy/image-editor": "workspace:^"
     "@uppy/informer": "workspace:^"
     "@uppy/instagram": "workspace:^"