diff --git a/packages/vscode-extension/src/common/EasConfig.ts b/packages/vscode-extension/src/common/EasConfig.ts new file mode 100644 index 000000000..0cf815516 --- /dev/null +++ b/packages/vscode-extension/src/common/EasConfig.ts @@ -0,0 +1,65 @@ +// Describes the shape of the `eas.json` config file's content. +// Currently, we only include the parts of the object we care about. +// See https://docs.expo.dev/eas/json/ for details. +export interface EasConfig { + build?: EasBuildConfig; +} + +export type EasBuildConfig = { + [key: string]: EasBuildProfile; +}; + +export type EasBuildDistributionType = "internal" | "store"; + +export interface EasBuildProfileIOSSpecific { + simulator?: boolean; +} + +export interface EasBuildProfile { + distribution?: EasBuildDistributionType; + ios?: EasBuildProfileIOSSpecific; +} + +export function isEasConfig(obj: unknown): obj is EasConfig { + if (typeof obj !== "object" || obj === null) { + return false; + } + + return !("build" in obj) || obj.build === undefined || isEasBuildConfig(obj.build); +} + +export function isEasBuildConfig(obj: unknown): obj is EasBuildConfig { + if (typeof obj !== "object" || obj === null) { + return false; + } + + return Object.values(obj).every((v) => v === undefined || isEasBuildProfile(v)); +} + +export function isEasBuildProfile(obj: unknown): obj is EasBuildProfile { + if (typeof obj !== "object" || obj === null) { + return false; + } + + if ( + "distribution" in obj && + (typeof obj.distribution !== "string" || !["internal", "store"].includes(obj.distribution)) + ) { + return false; + } + + if ("ios" in obj && obj.ios !== undefined) { + if (typeof obj.ios !== "object" || obj.ios === null) { + return false; + } + if ( + "simulator" in obj.ios && + obj.ios.simulator !== undefined && + !(typeof obj.ios.simulator === "boolean") + ) { + return false; + } + } + + return true; +} diff --git a/packages/vscode-extension/src/common/LaunchConfig.ts b/packages/vscode-extension/src/common/LaunchConfig.ts index 9637f30e9..d44435d86 100644 --- a/packages/vscode-extension/src/common/LaunchConfig.ts +++ b/packages/vscode-extension/src/common/LaunchConfig.ts @@ -1,3 +1,5 @@ +import { EasBuildConfig } from "./EasConfig"; + export type EasConfig = { profile: string; buildUUID?: string }; export type CustomBuild = { buildCommand?: string; @@ -48,6 +50,7 @@ export interface LaunchConfig { getConfig(): Promise; update: LaunchConfigUpdater; getAvailableXcodeSchemes(): Promise; + getAvailableEasProfiles(): Promise; addListener( eventType: K, listener: LaunchConfigEventListener diff --git a/packages/vscode-extension/src/panels/LaunchConfigController.ts b/packages/vscode-extension/src/panels/LaunchConfigController.ts index a8864220c..30a15127e 100644 --- a/packages/vscode-extension/src/panels/LaunchConfigController.ts +++ b/packages/vscode-extension/src/panels/LaunchConfigController.ts @@ -10,6 +10,8 @@ import { getAppRootFolder } from "../utilities/extensionContext"; import { findXcodeProject, findXcodeScheme } from "../utilities/xcode"; import { Logger } from "../Logger"; import { getIosSourceDir } from "../builders/buildIOS"; +import { readEasConfig } from "../utilities/eas"; +import { EasBuildConfig } from "../common/EasConfig"; export class LaunchConfigController implements Disposable, LaunchConfig { private config: LaunchConfigurationOptions; @@ -106,6 +108,14 @@ export class LaunchConfigController implements Disposable, LaunchConfig { ); return await findXcodeScheme(xcodeProject); } + + async getAvailableEasProfiles(): Promise { + const appRootFolder = getAppRootFolder(); + const easConfig = await readEasConfig(appRootFolder); + const easBuildConfig = easConfig?.build ?? {}; + return easBuildConfig; + } + async addListener( eventType: K, listener: LaunchConfigEventListener diff --git a/packages/vscode-extension/src/utilities/eas.ts b/packages/vscode-extension/src/utilities/eas.ts new file mode 100644 index 000000000..611f834cf --- /dev/null +++ b/packages/vscode-extension/src/utilities/eas.ts @@ -0,0 +1,23 @@ +import { RelativePattern, Uri, workspace } from "vscode"; +import { EasConfig, isEasConfig } from "../common/EasConfig"; + +export async function readEasConfig(appRootFolder: string | Uri): Promise { + const easConfigUri = await workspace.findFiles( + new RelativePattern(appRootFolder, "eas.json"), + null, + 1 + ); + if (easConfigUri.length === 0) { + return null; + } + try { + const easConfigData = await workspace.fs.readFile(easConfigUri[0]); + const easConfig = JSON.parse(new TextDecoder().decode(easConfigData)); + if (isEasConfig(easConfig)) { + return easConfig; + } + return null; + } catch (err) { + return null; + } +} diff --git a/packages/vscode-extension/src/webview/providers/LaunchConfigProvider.tsx b/packages/vscode-extension/src/webview/providers/LaunchConfigProvider.tsx index f2a5c7639..5a41118d0 100644 --- a/packages/vscode-extension/src/webview/providers/LaunchConfigProvider.tsx +++ b/packages/vscode-extension/src/webview/providers/LaunchConfigProvider.tsx @@ -14,12 +14,14 @@ import { LaunchConfigUpdater, LaunchConfigurationOptions, } from "../../common/LaunchConfig"; +import { EasBuildConfig } from "../../common/EasConfig"; const launchConfig = makeProxy("LaunchConfig"); type LaunchConfigContextType = LaunchConfigurationOptions & { update: LaunchConfigUpdater; xcodeSchemes: string[]; + easBuildProfiles: EasBuildConfig; eas?: { ios?: EasConfig; android?: EasConfig; @@ -29,17 +31,20 @@ type LaunchConfigContextType = LaunchConfigurationOptions & { const LaunchConfigContext = createContext({ update: () => {}, xcodeSchemes: [], + easBuildProfiles: {}, }); export default function LaunchConfigProvider({ children }: PropsWithChildren) { const [config, setConfig] = useState({}); const [xcodeSchemes, setXcodeSchemes] = useState([]); + const [easBuildProfiles, setEasBuildProfiles] = useState({}); useEffect(() => { launchConfig.getConfig().then(setConfig); launchConfig.addListener("launchConfigChange", setConfig); launchConfig.getAvailableXcodeSchemes().then(setXcodeSchemes); + launchConfig.getAvailableEasProfiles().then(setEasBuildProfiles); return () => { launchConfig.removeListener("launchConfigChange", setConfig); @@ -59,8 +64,8 @@ export default function LaunchConfigProvider({ children }: PropsWithChildren) { ); const contextValue = useMemo(() => { - return { ...config, update, xcodeSchemes }; - }, [config, update, xcodeSchemes]); + return { ...config, update, xcodeSchemes, easBuildProfiles }; + }, [config, update, xcodeSchemes, easBuildProfiles]); return ( {children} diff --git a/packages/vscode-extension/src/webview/views/LaunchConfigurationView.tsx b/packages/vscode-extension/src/webview/views/LaunchConfigurationView.tsx index 91b16b76f..b735f5a36 100644 --- a/packages/vscode-extension/src/webview/views/LaunchConfigurationView.tsx +++ b/packages/vscode-extension/src/webview/views/LaunchConfigurationView.tsx @@ -1,15 +1,29 @@ import "./View.css"; import "./LaunchConfigurationView.css"; -import { useRef } from "react"; +import { useRef, useState } from "react"; import Label from "../components/shared/Label"; import { useLaunchConfig } from "../providers/LaunchConfigProvider"; -import { LaunchConfigUpdater } from "../../common/LaunchConfig"; +import { + EasConfig, + LaunchConfigUpdater, + LaunchConfigurationOptions, +} from "../../common/LaunchConfig"; import Select from "../components/shared/Select"; import { Input } from "../components/shared/Input"; +import { EasBuildConfig } from "../../common/EasConfig"; function LaunchConfigurationView() { - const { android, appRoot, ios, isExpo, metroConfigPath, update, xcodeSchemes } = - useLaunchConfig(); + const { + android, + appRoot, + ios, + eas, + isExpo, + metroConfigPath, + update, + xcodeSchemes, + easBuildProfiles, + } = useLaunchConfig(); return ( <> @@ -44,6 +58,26 @@ function LaunchConfigurationView() {
+ + {!!easBuildProfiles && ( + <> + + + + +
+ + )} ); } @@ -241,4 +275,142 @@ function IsExpoConfiguration({ isExpo, update }: isExpoConfigurationProps) { ); } +type EasLaunchConfig = NonNullable; +type EasPlatform = keyof EasLaunchConfig; + +function prettyPlatformName(platform: EasPlatform): string { + switch (platform) { + case "ios": + return "iOS"; + case "android": + return "Android"; + } +} + +interface easBuildConfigurationProps { + eas?: EasLaunchConfig; + platform: EasPlatform; + update: LaunchConfigUpdater; + easBuildProfiles: EasBuildConfig; +} + +function EasBuildConfiguration({ + eas, + platform, + update, + easBuildProfiles, +}: easBuildConfigurationProps) { + const DISABLED = "Disabled" as const; + const CUSTOM = "Custom" as const; + + const profile = eas?.[platform]?.profile; + const buildUUID = eas?.[platform]?.buildUUID; + + const [selectedProfile, setSelectedProfile] = useState(() => { + if (profile === undefined) { + return DISABLED; + } + if (!(profile in easBuildProfiles)) { + return CUSTOM; + } + return profile; + }); + + const buildUUIDInputRef = useRef(null); + const customBuildProfileInputRef = useRef(null); + + const updateEasConfig = (configUpdate: Partial) => { + const currentPlaftormConfig = eas?.[platform] ?? {}; + const newPlatformConfig = Object.fromEntries( + Object.entries({ ...currentPlaftormConfig, ...configUpdate }).filter(([_k, v]) => !!v) + ); + if ("profile" in newPlatformConfig) { + update("eas", { ...eas, [platform]: newPlatformConfig }); + } else { + update("eas", { ...eas, [platform]: undefined }); + } + }; + + const updateProfile = (newProfile: string | undefined) => { + const newBuildUUID = buildUUIDInputRef.current?.value || undefined; + updateEasConfig({ profile: newProfile, buildUUID: newBuildUUID }); + }; + + const onProfileSelectionChange = (newProfile: string) => { + setSelectedProfile(newProfile); + + if (newProfile === DISABLED) { + updateEasConfig({ profile: undefined, buildUUID: undefined }); + return; + } + + if (newProfile === CUSTOM) { + newProfile = customBuildProfileInputRef.current?.value ?? profile ?? ""; + } + updateProfile(newProfile); + }; + + const onCustomBuildProfileInputBlur = () => { + const newCustomProfile = customBuildProfileInputRef.current?.value ?? ""; + updateProfile(newCustomProfile); + }; + + const onBuildUUIDInputBlur = () => { + const newBuildUUID = buildUUIDInputRef.current?.value || undefined; + updateEasConfig({ buildUUID: newBuildUUID }); + }; + + const availableEasBuildProfiles = Object.entries(easBuildProfiles).map( + ([buildProfile, config]) => { + const canRunInSimulator = + config.distribution === "internal" && + (platform !== "ios" || config.ios?.simulator === true); + return { value: buildProfile, label: buildProfile, disabled: !canRunInSimulator }; + } + ); + + availableEasBuildProfiles.push({ value: DISABLED, label: DISABLED, disabled: false }); + availableEasBuildProfiles.push({ value: CUSTOM, label: CUSTOM, disabled: false }); + + return ( +
+
{prettyPlatformName(platform)} Build Profile:
+ + + )} + {selectedProfile !== DISABLED && ( + <> +
{prettyPlatformName(platform)} Build UUID:
+ + + )} +
+ ); +} + export default LaunchConfigurationView;