diff --git a/.env.example b/.env.example
new file mode 100644
index 00000000000..210f4cf4bab
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,10 @@
+# Google Drive API Configuration
+# See docs/google-drive-setup.md for setup instructions
+
+# Google OAuth 2.0 Client ID (Web Application)
+# Example: 123456789012-abcdefghijklmnopqrstuvwxyz123456.apps.googleusercontent.com
+GOOGLE_CLIENT_ID=your-client-id-here.apps.googleusercontent.com
+
+# Google API Key (for Picker API)
+# Example: AIzaSyAbCdEfGhIjKlMnOpQrStUvWxYz1234567
+GOOGLE_API_KEY=your-api-key-here
diff --git a/.gitignore b/.gitignore
index 9f18dfb39a4..b5dec787d9c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,4 +27,7 @@ npm-*
# for act
.secrets
+# Environment variables (contains sensitive API keys)
+.env
+
/tmp
diff --git a/cspell.json b/cspell.json
new file mode 100644
index 00000000000..e1782e61c54
--- /dev/null
+++ b/cspell.json
@@ -0,0 +1,15 @@
+{
+ "version": "0.2",
+ "language": "en",
+ "words": [
+ "gapi",
+ "googleusercontent"
+ ],
+ "ignorePaths": [
+ "node_modules",
+ "build",
+ "dist",
+ "coverage",
+ ".nyc_output"
+ ]
+}
diff --git a/docs/google-drive-setup.md b/docs/google-drive-setup.md
new file mode 100644
index 00000000000..a8dee6bc1af
--- /dev/null
+++ b/docs/google-drive-setup.md
@@ -0,0 +1,197 @@
+# Google Drive連携機能のセットアップ手順
+
+このドキュメントでは、Smalruby3-GUIでGoogle Driveからファイルを読み込むための機能を有効にするために必要なGoogle Cloud Platform (GCP)の設定手順を説明します。
+
+## 前提条件
+
+- Googleアカウントを持っていること
+- Google Cloud Platformへのアクセス権限があること
+
+## 1. Google Cloud Platformプロジェクトの作成
+
+1. [Google Cloud Console](https://console.cloud.google.com/)にアクセス
+2. 画面上部の「プロジェクトを選択」をクリック
+3. 「新しいプロジェクト」をクリック
+4. プロジェクト名を入力(例: `smalruby3-gui`)
+5. 「作成」をクリック
+
+## 2. 必要なAPIの有効化
+
+### Google Drive APIを有効化
+
+1. Google Cloud Consoleで作成したプロジェクトを選択
+2. 左側のメニューから「APIとサービス」→「ライブラリ」を選択
+3. 検索ボックスに「Google Drive API」と入力
+4. 「Google Drive API」を選択
+5. 「有効にする」をクリック
+
+### Google Picker APIを有効化
+
+1. 同じく「ライブラリ」画面で検索ボックスに「Google Picker API」と入力
+2. 「Google Picker API」を選択
+3. 「有効にする」をクリック
+
+## 3. OAuth 2.0 認証情報の作成
+
+### OAuth同意画面の設定
+
+1. 左側のメニューから「APIとサービス」→「OAuth同意画面」を選択
+2. ユーザータイプで「外部」を選択(テスト用途の場合)
+3. 「作成」をクリック
+4. アプリ情報を入力:
+ - **アプリ名**: `Smalruby3 GUI`
+ - **ユーザーサポートメール**: あなたのメールアドレス
+ - **デベロッパーの連絡先情報**: あなたのメールアドレス
+5. 「保存して次へ」をクリック
+6. 「スコープ」画面で「スコープを追加または削除」をクリック
+7. 以下のスコープを追加:
+ - `https://www.googleapis.com/auth/drive.file` (**推奨**: ファイルの読み込みとアップロードの両方に対応)
+8. 「更新」→「保存して次へ」をクリック
+9. テストユーザー画面で「保存して次へ」をクリック
+10. 確認画面で「ダッシュボードに戻る」をクリック
+
+### OAuth 2.0 クライアントIDの作成
+
+1. 左側のメニューから「APIとサービス」→「認証情報」を選択
+2. 画面上部の「認証情報を作成」→「OAuth クライアント ID」をクリック
+3. アプリケーションの種類で「ウェブアプリケーション」を選択
+4. 名前を入力(例: `Smalruby3 GUI Web Client`)
+5. 「承認済みのJavaScript生成元」に以下を追加:
+ - `http://localhost:8601` (開発環境)
+ - 本番環境のURL(例: `https://smalruby.github.io`)
+6. 「承認済みのリダイレクトURI」は空欄でOK(Picker APIでは不要)
+7. 「作成」をクリック
+8. 表示されたダイアログで「クライアントID」をコピーして保存
+
+**重要**: クライアントシークレットは今回の実装では使用しません(フロントエンドのみの実装のため)
+
+## 4. APIキーの作成(Picker API用)
+
+1. 「認証情報」画面で「認証情報を作成」→「APIキー」をクリック
+2. APIキーが作成されるので、コピーして保存
+3. (推奨)「キーを制限」をクリック
+4. 「アプリケーションの制限」で「HTTPリファラー(ウェブサイト)」を選択
+5. 「ウェブサイトの制限」に以下を追加:
+ - `http://localhost:8601/*`
+ - 本番環境のURL(例: `https://smalruby.github.io/*`)
+6. 「APIの制限」で「キーを制限」を選択
+7. 「Google Picker API」を選択
+8. 「保存」をクリック
+
+## 5. 環境変数の設定
+
+### 開発環境(Docker使用時)
+
+**重要**: このプロジェクトはDockerを使用しているため、プロジェクトルート(`smalruby3-develop/`)に `.env` ファイルを作成します。
+
+1. プロジェクトルートに `.env` ファイルを作成:
+
+```bash
+# Google Drive API設定
+GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
+GOOGLE_API_KEY=your-api-key
+```
+
+2. **注意事項**:
+ - `.env` ファイルは `.gitignore` に含まれているため、Gitにコミットされません
+ - `docker-compose.yml` がこの `.env` ファイルを自動的に読み込みます
+ - 環境変数を変更した場合は、`docker compose restart gui` でサービスを再起動してください
+
+3. サービスの再起動:
+
+```bash
+# 環境変数を反映させるため再起動
+docker compose restart gui
+```
+
+### 開発環境(Dockerを使用しない場合)
+
+Dockerを使用せず、直接npmコマンドを実行する場合:
+
+```bash
+# gui/smalruby3-gui/ ディレクトリに .env ファイルを作成
+cd gui/smalruby3-gui
+cat > .env << 'EOF'
+GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
+GOOGLE_API_KEY=your-api-key
+EOF
+
+# 開発サーバー起動
+npm start
+```
+
+### 本番環境
+
+本番環境では、ビルド時に環境変数を設定します:
+
+```bash
+GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com \
+GOOGLE_API_KEY=your-api-key \
+npm run build
+```
+
+または、GitHub ActionsなどのCI/CD環境では、シークレット変数として設定します。
+
+## 6. セキュリティに関する注意事項
+
+### クライアントIDとAPIキーの管理
+
+- **クライアントID**: 公開しても問題ありませんが、承認済みのJavaScript生成元で制限することを推奨
+- **APIキー**: HTTPリファラーで制限し、API制限を有効にすることを強く推奨
+- **クライアントシークレット**: フロントエンドでは使用しないこと(セキュリティリスク)
+
+### OAuth 2.0 スコープの選択
+
+このアプリケーションでは以下のスコープを使用します:
+
+- `drive.file` (**推奨・使用中**):
+ - アプリが作成したファイルの読み書き
+ - ユーザーがGoogle Pickerで選択したファイルへのアクセス
+ - ファイルのアップロード機能に必要
+ - 完全なDriveアクセスより制限されているため安全
+
+**注意**: `drive.readonly`(読み取り専用)では、Google Pickerのアップロードタブが機能しません。
+
+## 7. 動作確認
+
+1. 開発サーバーを起動:
+ ```bash
+ docker compose up gui
+ ```
+
+2. ブラウザで `http://localhost:8601` を開く
+
+3. ファイルメニューから「Google ドライブから読み込む」を選択
+
+4. Google認証画面が表示されることを確認
+
+5. 認証後、Google Driveのファイル選択画面が表示されることを確認
+
+## トラブルシューティング
+
+### "Access blocked: This app's request is invalid"
+
+- OAuth同意画面の設定が完了していない可能性があります
+- テストユーザーに自分のアカウントが追加されているか確認してください
+
+### "API key not valid. Please pass a valid API key."
+
+- APIキーが正しく設定されていない可能性があります
+- APIキーの制限設定を確認してください
+- Picker APIが有効になっているか確認してください
+
+### "The origin http://localhost:8601 is not allowed"
+
+- OAuth クライアントIDの「承認済みのJavaScript生成元」に `http://localhost:8601` が追加されているか確認してください
+
+### 動的スクリプトのロードエラー
+
+- ブラウザのコンソールでエラー内容を確認してください
+- Content Security Policy (CSP) の設定を確認してください
+
+## 参考リンク
+
+- [Google Cloud Console](https://console.cloud.google.com/)
+- [Google Picker API Documentation](https://developers.google.com/picker/api)
+- [Google Identity Services](https://developers.google.com/identity/gsi/web/guides/overview)
+- [Google Drive API v3](https://developers.google.com/drive/api/v3/about-sdk)
diff --git a/src/components/gui/gui.jsx b/src/components/gui/gui.jsx
index 9a9b690fdec..c73bacf4e12 100644
--- a/src/components/gui/gui.jsx
+++ b/src/components/gui/gui.jsx
@@ -140,6 +140,7 @@ const GUIComponent = props => {
vm,
// Exclude Redux-related props from being passed to DOM
setSelectedBlocks: _setSelectedBlocks,
+ openUrlLoaderModal: _openUrlLoaderModal,
...componentProps
} = omit(props, 'dispatch');
if (children) {
diff --git a/src/components/menu-bar/menu-bar.jsx b/src/components/menu-bar/menu-bar.jsx
index 47b9926b3f5..fa71ccb5f0b 100644
--- a/src/components/menu-bar/menu-bar.jsx
+++ b/src/components/menu-bar/menu-bar.jsx
@@ -27,6 +27,7 @@ import SB3Downloader from '../../containers/sb3-downloader.jsx';
import DeletionRestorer from '../../containers/deletion-restorer.jsx';
import TurboMode from '../../containers/turbo-mode.jsx';
import MenuBarHOC from '../../containers/menu-bar-hoc.jsx';
+import GoogleDriveLoaderHOC from '../../containers/google-drive-loader-hoc.jsx';
import SettingsMenu from './settings-menu.jsx';
import {openTipsLibrary, openDebugModal} from '../../reducers/modals';
@@ -519,6 +520,15 @@ class MenuBar extends React.Component {
id="gui.menuBar.loadFromUrl"
/>
+
@@ -946,6 +956,7 @@ MenuBar.propTypes = {
onSetTimeTravelMode: PropTypes.func,
onShare: PropTypes.func,
onStartSelectingFileUpload: PropTypes.func,
+ onStartSelectingGoogleDrive: PropTypes.func,
onStartSelectingUrlLoad: PropTypes.func,
onToggleLoginOpen: PropTypes.func,
projectTitle: PropTypes.string,
@@ -1026,6 +1037,7 @@ const mapDispatchToProps = dispatch => ({
export default compose(
injectIntl,
MenuBarHOC,
+ GoogleDriveLoaderHOC,
connect(
mapStateToProps,
mapDispatchToProps
diff --git a/src/containers/google-drive-loader-hoc.jsx b/src/containers/google-drive-loader-hoc.jsx
new file mode 100644
index 00000000000..cbf814af965
--- /dev/null
+++ b/src/containers/google-drive-loader-hoc.jsx
@@ -0,0 +1,244 @@
+import bindAll from 'lodash.bindall';
+import React from 'react';
+import PropTypes from 'prop-types';
+import {defineMessages, intlShape, injectIntl} from 'react-intl';
+import {connect} from 'react-redux';
+import log from '../lib/log';
+
+import googleDriveAPI from '../lib/google-drive-api';
+import {
+ LoadingStates,
+ getIsLoadingUpload,
+ onLoadedProject
+} from '../reducers/project-state';
+import {setProjectTitle} from '../reducers/project-title';
+import {
+ openLoadingProject,
+ closeLoadingProject
+} from '../reducers/modals';
+import {
+ closeFileMenu
+} from '../reducers/menus';
+
+const messages = defineMessages({
+ loadError: {
+ id: 'gui.googleDriveLoader.loadError',
+ defaultMessage: 'Failed to load project from Google Drive.',
+ description: 'An error that displays when a Google Drive project file fails to load.'
+ },
+ authError: {
+ id: 'gui.googleDriveLoader.authError',
+ defaultMessage: 'Failed to authenticate with Google Drive. Please try again.',
+ description: 'An error that displays when Google Drive authentication fails.'
+ },
+ configError: {
+ id: 'gui.googleDriveLoader.configError',
+ defaultMessage: 'Google Drive is not configured. Please contact the administrator.',
+ description: 'An error that displays when Google Drive API is not configured.'
+ },
+ pickerTitle: {
+ id: 'gui.googleDriveLoader.pickerTitle',
+ defaultMessage: 'Select a Scratch 3.0 project (.sb3) from Google Drive',
+ description: 'Title for Google Drive file picker dialog.'
+ }
+});
+
+/**
+ * Higher Order Component to provide behavior for loading projects from Google Drive.
+ * @param {React.Component} WrappedComponent the component to add Google Drive loading functionality to
+ * @returns {React.Component} WrappedComponent with Google Drive loading functionality added
+ *
+ *
+ *
+ *
+ */
+const GoogleDriveLoaderHOC = function (WrappedComponent) {
+ class GoogleDriveLoaderComponent extends React.Component {
+ constructor (props) {
+ super(props);
+ bindAll(this, [
+ 'handleStartSelectingGoogleDrive',
+ 'handlePickerCallback',
+ 'handleFinishedLoadingUpload',
+ 'getProjectTitleFromFilename'
+ ]);
+ }
+
+ componentDidUpdate (prevProps) {
+ if (this.props.isLoadingUpload && !prevProps.isLoadingUpload) {
+ this.handleFinishedLoadingUpload();
+ }
+ }
+
+ /**
+ * Start Google Drive file selection process
+ */
+ handleStartSelectingGoogleDrive () {
+ // Check if Google Drive is configured
+ if (!googleDriveAPI.constructor.isConfigured()) {
+ alert(this.props.intl.formatMessage(messages.configError)); // eslint-disable-line no-alert
+ log.warn('Google Drive API is not configured');
+ return;
+ }
+
+ // Close file menu
+ this.props.closeFileMenu();
+
+ // Get localized title
+ const title = this.props.intl.formatMessage(messages.pickerTitle);
+
+ // Initialize and show Google Picker
+ // Don't show loading modal yet - wait until user selects a file
+ googleDriveAPI.showPicker(this.handlePickerCallback, this.props.locale, title)
+ .catch(error => {
+ log.error('Failed to show Google Picker:', error);
+ alert(this.props.intl.formatMessage(messages.authError)); // eslint-disable-line no-alert
+ });
+ }
+
+ /**
+ * Handle Google Picker callback
+ * @param {object} result - Picker result
+ */
+ handlePickerCallback (result) {
+ if (result.cancelled) {
+ // User cancelled picker
+ this.props.onCloseLoadingProject();
+ return;
+ }
+
+ if (result.error) {
+ // Error occurred
+ log.error('Google Drive picker error:', result.error);
+ this.props.onCloseLoadingProject();
+ alert(result.error); // eslint-disable-line no-alert
+ return;
+ }
+
+ if (result.selected) {
+ // File selected - show loading modal immediately (before download)
+ const {fileName} = result;
+
+ // Update project title
+ const projectTitle = this.getProjectTitleFromFilename(fileName);
+ this.props.onSetProjectTitle(projectTitle);
+
+ // Show loading modal
+ this.props.onLoadingStarted();
+ return;
+ }
+
+ if (result.success) {
+ // File downloaded successfully - load the project
+ const {fileData} = result;
+
+ // Convert ArrayBuffer to Uint8Array
+ const content = new Uint8Array(fileData);
+
+ // Load the project
+ this.props.vm.loadProject(content)
+ .then(() => {
+ this.props.onLoadingFinished(this.props.loadingState, true);
+ this.props.onCloseLoadingProject();
+ })
+ .catch(error => {
+ console.error('[GoogleDriveLoader] Project load failed:', {
+ error: error,
+ errorType: typeof error,
+ errorMessage: error && error.message,
+ errorStack: error && error.stack
+ });
+ log.error('Failed to load project from Google Drive:', error);
+ this.props.onCloseLoadingProject();
+ alert(this.props.intl.formatMessage(messages.loadError)); // eslint-disable-line no-alert
+ });
+ }
+ }
+
+ /**
+ * Extract project title from filename
+ * @param {string} filename - File name
+ * @returns {string} Project title
+ */
+ getProjectTitleFromFilename (filename) {
+ return filename.replace(/\.sb3$/, '');
+ }
+
+ /**
+ * Handle finished loading upload
+ */
+ handleFinishedLoadingUpload () {
+ this.props.onLoadingFinished(this.props.loadingState, true);
+ }
+
+ render () {
+ const {
+ /* eslint-disable no-unused-vars */
+ closeFileMenu: closeFileMenuProp,
+ intl,
+ isLoadingUpload,
+ loadingState,
+ locale,
+ onCloseLoadingProject,
+ onLoadingFinished,
+ onLoadingStarted,
+ onSetProjectTitle,
+ openUrlLoaderModal,
+ vm,
+ /* eslint-enable no-unused-vars */
+ ...componentProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+ }
+
+ GoogleDriveLoaderComponent.propTypes = {
+ closeFileMenu: PropTypes.func,
+ intl: intlShape.isRequired,
+ isLoadingUpload: PropTypes.bool,
+ loadingState: PropTypes.oneOf(LoadingStates),
+ locale: PropTypes.string,
+ onCloseLoadingProject: PropTypes.func,
+ onLoadingFinished: PropTypes.func,
+ onLoadingStarted: PropTypes.func,
+ onSetProjectTitle: PropTypes.func,
+ openUrlLoaderModal: PropTypes.func,
+ vm: PropTypes.shape({
+ loadProject: PropTypes.func
+ })
+ };
+
+ const mapStateToProps = state => ({
+ isLoadingUpload: getIsLoadingUpload(state.scratchGui.projectState.loadingState),
+ loadingState: state.scratchGui.projectState.loadingState,
+ locale: state.locales.locale,
+ vm: state.scratchGui.vm
+ });
+
+ const mapDispatchToProps = dispatch => ({
+ closeFileMenu: () => dispatch(closeFileMenu()),
+ onCloseLoadingProject: () => dispatch(closeLoadingProject()),
+ onLoadingFinished: (loadingState, success) => {
+ dispatch(onLoadedProject(loadingState, false, success));
+ dispatch(closeLoadingProject());
+ },
+ onLoadingStarted: () => dispatch(openLoadingProject()),
+ onSetProjectTitle: title => dispatch(setProjectTitle(title))
+ });
+
+ return injectIntl(connect(
+ mapStateToProps,
+ mapDispatchToProps
+ )(GoogleDriveLoaderComponent));
+};
+
+export {
+ GoogleDriveLoaderHOC as default
+};
diff --git a/src/lib/google-drive-api.js b/src/lib/google-drive-api.js
new file mode 100644
index 00000000000..1f641621a0c
--- /dev/null
+++ b/src/lib/google-drive-api.js
@@ -0,0 +1,288 @@
+/**
+ * Google Drive API Integration
+ *
+ * This module handles Google Drive authentication and file picking using:
+ * - Google Identity Services for OAuth 2.0 authentication
+ * - Google Picker API for file selection UI
+ */
+
+import {loadAllGoogleScripts} from './google-script-loader';
+
+// OAuth 2.0 scopes
+// Using 'drive.file' scope to allow:
+// - Reading files selected by the user via Picker
+// - Uploading new files to Google Drive
+const SCOPES = 'https://www.googleapis.com/auth/drive.file';
+
+// Discovery docs for Google Drive API
+const DISCOVERY_DOCS = ['https://www.googleapis.com/discovery/v1/apis/drive/v3/rest'];
+
+// Google API configuration (loaded from environment variables)
+const CLIENT_ID = process.env.GOOGLE_CLIENT_ID;
+const API_KEY = process.env.GOOGLE_API_KEY;
+
+/**
+ * GoogleDriveAPI class
+ * Manages authentication and file operations with Google Drive
+ */
+class GoogleDriveAPI {
+ constructor () {
+ this.isInitialized = false;
+ this.tokenClient = null;
+ this.accessToken = null;
+ this.pickerCallback = null;
+ }
+
+ /**
+ * Initialize Google API and Identity Services
+ * @returns {Promise} Promise that resolves when initialization is complete
+ */
+ async initialize () {
+ if (this.isInitialized) {
+ return;
+ }
+
+ // Validate configuration
+ if (!CLIENT_ID || !API_KEY) {
+ throw new Error(
+ 'Google Drive API credentials not configured. ' +
+ 'Please set GOOGLE_CLIENT_ID and GOOGLE_API_KEY environment variables. ' +
+ 'See docs/google-drive-setup.md for setup instructions.'
+ );
+ }
+
+ try {
+ // Load Google scripts dynamically
+ await loadAllGoogleScripts();
+
+ // Initialize gapi client
+ await new Promise((resolve, reject) => {
+ window.gapi.load('client:picker', {
+ callback: resolve,
+ onerror: reject
+ });
+ });
+
+ // Initialize gapi client with API key and discovery docs
+ await window.gapi.client.init({
+ apiKey: API_KEY,
+ discoveryDocs: DISCOVERY_DOCS
+ });
+
+ // Initialize Google Identity Services token client
+ this.tokenClient = window.google.accounts.oauth2.initTokenClient({
+ client_id: CLIENT_ID,
+ scope: SCOPES,
+ callback: '' // Will be set dynamically when requesting access
+ });
+
+ this.isInitialized = true;
+ } catch (error) {
+ console.error('Failed to initialize Google Drive API:', error);
+ throw new Error('Google Drive API initialization failed. Please check your configuration.');
+ }
+ }
+
+ /**
+ * Request access token
+ * @returns {Promise} Promise that resolves with access token
+ */
+ requestAccessToken () {
+ return new Promise((resolve, reject) => {
+ this.tokenClient.callback = response => {
+ if (response.error) {
+ reject(new Error(`Authentication failed: ${response.error}`));
+ return;
+ }
+ this.accessToken = response.access_token;
+ resolve(response.access_token);
+ };
+
+ // Check if user already has valid token
+ if (this.accessToken && window.gapi.client.getToken()) {
+ resolve(this.accessToken);
+ return;
+ }
+
+ // Request new token
+ this.tokenClient.requestAccessToken({prompt: ''});
+ });
+ }
+
+ /**
+ * Show Google Picker to select a file
+ * @param {Function} callback - Called when user selects a file
+ * @param {string} locale - Locale code (e.g., 'en', 'ja') for picker UI language
+ * @param {string} title - Title for the picker dialog
+ * @returns {Promise} Promise that resolves when picker is shown
+ */
+ async showPicker (callback, locale = 'en', title = 'Select a Scratch 3.0 project (.sb3) from Google Drive') {
+ if (!this.isInitialized) {
+ await this.initialize();
+ }
+
+ try {
+ // Request access token if not already available
+ const token = await this.requestAccessToken();
+
+ this.pickerCallback = callback;
+
+ // Create DocsView with .sb3 query filter
+ const docsView = new window.google.picker.DocsView()
+ .setIncludeFolders(true)
+ .setQuery('.sb3');
+
+ const picker = new window.google.picker.PickerBuilder()
+ .addView(docsView)
+ .addView(
+ new window.google.picker.DocsUploadView()
+ .setIncludeFolders(true)
+ )
+ .setOAuthToken(token)
+ .setDeveloperKey(API_KEY)
+ .setCallback(this.handlePickerResponse.bind(this))
+ .setTitle(title)
+ .setLocale(locale)
+ .build();
+
+ picker.setVisible(true);
+ } catch (error) {
+ console.error('Failed to show Google Picker:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Handle picker response
+ * @param {object} data - Picker response data
+ */
+ handlePickerResponse (data) {
+ const action = data[window.google.picker.Response.ACTION];
+
+ if (action === window.google.picker.Action.PICKED) {
+ const doc = data[window.google.picker.Response.DOCUMENTS][0];
+ const fileId = doc[window.google.picker.Document.ID];
+ const fileName = doc[window.google.picker.Document.NAME];
+
+ // Validate file type by extension (more reliable than MIME type for .sb3 files)
+ if (!fileName.endsWith('.sb3')) {
+ if (this.pickerCallback) {
+ this.pickerCallback({
+ error: 'Invalid file type. Please select a .sb3 file.'
+ });
+ }
+ return;
+ }
+
+ // Notify that file has been selected (before download starts)
+ if (this.pickerCallback) {
+ try {
+ this.pickerCallback({
+ selected: true,
+ fileName: fileName
+ });
+ } catch (callbackError) {
+ console.error('[GoogleDriveAPI] Error in file selected callback:', callbackError);
+ // Continue with download even if callback fails
+ }
+ }
+
+ // Download file
+ this.downloadFile(fileId, fileName)
+ .then(fileData => {
+ if (this.pickerCallback) {
+ // Wrap callback invocation in try-catch to prevent callback errors
+ // from being caught as download errors
+ try {
+ this.pickerCallback({
+ success: true,
+ fileId: fileId,
+ fileName: fileName,
+ fileData: fileData
+ });
+ } catch (callbackError) {
+ console.error(
+ '[GoogleDriveAPI] Error in picker callback (not a download error):',
+ callbackError
+ );
+ // Don't re-throw - this is a callback error, not a download error
+ }
+ }
+ })
+ .catch(error => {
+ console.error('[GoogleDriveAPI] Download error caught in handlePickerResponse:', {
+ error: error,
+ errorType: typeof error,
+ hasMessage: error && 'message' in error,
+ errorString: String(error)
+ });
+
+ if (this.pickerCallback) {
+ const errorMessage = error && error.message ? error.message : String(error);
+ try {
+ this.pickerCallback({
+ error: `Failed to download file: ${errorMessage}`
+ });
+ } catch (callbackError) {
+ console.error('[GoogleDriveAPI] Error in error callback:', callbackError);
+ }
+ }
+ });
+ } else if (action === window.google.picker.Action.CANCEL) {
+ if (this.pickerCallback) {
+ this.pickerCallback({
+ cancelled: true
+ });
+ }
+ }
+ }
+
+ /**
+ * Download file from Google Drive
+ * @param {string} fileId - Google Drive file ID
+ * @param {string} fileName - File name (for debugging)
+ * @returns {Promise} Promise that resolves with file data as ArrayBuffer
+ */
+ async downloadFile (fileId, fileName) {
+ try {
+ const response = await window.gapi.client.drive.files.get({
+ fileId: fileId,
+ alt: 'media'
+ });
+
+ // Convert response to ArrayBuffer
+ // gapi returns the file content as a string for binary files
+ const binaryString = response.body;
+ const len = binaryString.length;
+ const bytes = new Uint8Array(len);
+ for (let i = 0; i < len; i++) {
+ bytes[i] = binaryString.charCodeAt(i);
+ }
+
+ return bytes.buffer;
+ } catch (error) {
+ console.error(`[GoogleDriveAPI] Download failed for ${fileName}:`, {
+ error: error,
+ errorType: typeof error,
+ errorConstructor: error ? error.constructor.name : 'N/A',
+ hasMessage: error && 'message' in error,
+ message: error && error.message,
+ hasStatus: error && 'status' in error,
+ status: error && error.status,
+ keys: error ? Object.keys(error) : []
+ });
+ throw error;
+ }
+ }
+
+ /**
+ * Check if API is configured
+ * @returns {boolean} True if API credentials are configured
+ */
+ static isConfigured () {
+ return !!(CLIENT_ID && API_KEY);
+ }
+}
+
+// Export singleton instance
+export default new GoogleDriveAPI();
diff --git a/src/lib/google-script-loader.js b/src/lib/google-script-loader.js
new file mode 100644
index 00000000000..fdf1507e2b5
--- /dev/null
+++ b/src/lib/google-script-loader.js
@@ -0,0 +1,94 @@
+/**
+ * Google API Dynamic Script Loader
+ *
+ * This module provides functions to dynamically load Google API scripts
+ * only when needed (on first use of Google Drive functionality).
+ * This improves initial page load performance by avoiding unnecessary script loads.
+ */
+
+/**
+ * Load a script dynamically
+ * @param {string} src - The script source URL
+ * @param {string} id - Unique identifier for the script tag
+ * @returns {Promise} Promise that resolves when script is loaded, rejects on error
+ */
+const loadScript = (src, id) => new Promise((resolve, reject) => {
+ // Check if script is already loaded
+ if (document.getElementById(id)) {
+ resolve();
+ return;
+ }
+
+ const script = document.createElement('script');
+ script.id = id;
+ script.src = src;
+ script.async = true;
+ script.defer = true;
+
+ script.onload = () => {
+ resolve();
+ };
+
+ script.onerror = () => {
+ reject(new Error(`Failed to load script: ${src}`));
+ };
+
+ document.head.appendChild(script);
+});
+
+/**
+ * Load Google API Client Library (gapi)
+ * Required for Google Picker API
+ * @returns {Promise} Promise that resolves when API is loaded
+ */
+export const loadGoogleAPI = () => loadScript(
+ 'https://apis.google.com/js/api.js',
+ 'google-api-script'
+);
+
+/**
+ * Load Google Identity Services (GIS)
+ * Required for OAuth 2.0 authentication
+ * @returns {Promise} Promise that resolves when GIS is loaded
+ */
+export const loadGoogleIdentity = () => loadScript(
+ 'https://accounts.google.com/gsi/client',
+ 'google-identity-script'
+);
+
+/**
+ * Load all required Google scripts
+ * This is the main entry point for loading Google APIs
+ * @returns {Promise} Promise that resolves when all scripts are loaded
+ */
+export const loadAllGoogleScripts = async () => {
+ try {
+ // Load both scripts in parallel
+ await Promise.all([
+ loadGoogleAPI(),
+ loadGoogleIdentity()
+ ]);
+ } catch (error) {
+ console.error('Failed to load Google scripts:', error);
+ throw error;
+ }
+};
+
+/**
+ * Check if Google API is loaded
+ * @returns {boolean} True if Google API is loaded
+ */
+export const isGoogleAPILoaded = () => typeof window.gapi !== 'undefined';
+
+/**
+ * Check if Google Identity Services is loaded
+ * @returns {boolean} True if Google Identity Services is loaded
+ */
+export const isGoogleIdentityLoaded = () => typeof window.google !== 'undefined';
+
+/**
+ * Check if all required Google scripts are loaded
+ * @returns {boolean} True if all scripts are loaded
+ */
+export const areAllGoogleScriptsLoaded = () =>
+ isGoogleAPILoaded() && isGoogleIdentityLoaded();
diff --git a/src/locales/ja.js b/src/locales/ja.js
index 850c525489d..7959ca70258 100644
--- a/src/locales/ja.js
+++ b/src/locales/ja.js
@@ -1,5 +1,10 @@
export default {
'gui.menuBar.loadFromUrl': 'URLから読み込む',
+ 'gui.menuBar.loadFromGoogleDrive': 'Google ドライブから読み込む',
+ 'gui.googleDriveLoader.loadError': 'Google ドライブからプロジェクトの読み込みに失敗しました。',
+ 'gui.googleDriveLoader.authError': 'Google ドライブの認証に失敗しました。もう一度お試しください。',
+ 'gui.googleDriveLoader.configError': 'Google ドライブが設定されていません。管理者に連絡してください。',
+ 'gui.googleDriveLoader.pickerTitle': 'Google ドライブから Scratch 3.0 プロジェクト (.sb3) を選択',
'gui.urlLoader.loadError': 'プロジェクトURLの読み込みに失敗しました。',
'gui.urlLoader.invalidUrl': '有効なScratchプロジェクトURLまたはGoogle DriveのURLを入力してください。',
'gui.urlLoader.title': 'URLから読み込む',
diff --git a/webpack.config.js b/webpack.config.js
index 0ffb68b6a60..da90a5eb22e 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -57,7 +57,9 @@ const baseConfig = new ScratchWebpackConfigBuilder(
'process.env.DEBUG': Boolean(process.env.DEBUG),
'process.env.GA_ID': `"${process.env.GA_ID || 'UA-000000-01'}"`,
'process.env.GTM_ENV_AUTH': `"${process.env.GTM_ENV_AUTH || ''}"`,
- 'process.env.GTM_ID': process.env.GTM_ID ? `"${process.env.GTM_ID}"` : null
+ 'process.env.GTM_ID': process.env.GTM_ID ? `"${process.env.GTM_ID}"` : null,
+ 'process.env.GOOGLE_CLIENT_ID': `"${process.env.GOOGLE_CLIENT_ID || ''}"`,
+ 'process.env.GOOGLE_API_KEY': `"${process.env.GOOGLE_API_KEY || ''}"`
}))
.addPlugin(new CopyWebpackPlugin({
patterns: [
@@ -214,6 +216,19 @@ const buildWithPwaConfig = buildConfig.clone()
// `BUILD_MODE=dist npm run build`
const buildDist = process.env.NODE_ENV === 'production' || process.env.BUILD_MODE === 'dist';
-module.exports = buildDist ?
+// Get the webpack config
+const finalConfig = buildDist ?
[buildWithPwaConfig.get(), distConfig.get()] :
buildConfig.get();
+
+// Override devServer headers to allow Google Picker API to work
+// Must be done after .get() to ensure it's not overridden by ScratchWebpackConfigBuilder
+if (!buildDist && finalConfig.devServer) {
+ finalConfig.devServer.headers = {
+ ...finalConfig.devServer.headers,
+ 'Cross-Origin-Opener-Policy': 'unsafe-none',
+ 'Cross-Origin-Embedder-Policy': 'unsafe-none'
+ };
+}
+
+module.exports = finalConfig;