Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement GUI for selecting eas build configuration #932

Merged
merged 6 commits into from
Feb 12, 2025
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions packages/vscode-extension/src/common/EasConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is elaborate would you mind commenting why this check is "correct"

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's just checking if the shape of the object matches what we expect it to be -- is it really that puzzling?
I suppose you might want a reference to some schema here for the eas.json file -- previously this was in the same place that read that file, so the context was there to see that's what it's doing, but I suppose now that it's an exported part of a module, some comment might be useful.

}

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));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

again would you mind adding some comments linking to eas documentation or explaining this checks?

}

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;
}
3 changes: 3 additions & 0 deletions packages/vscode-extension/src/common/LaunchConfig.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { EasBuildConfig } from "./EasConfig";

export type EasConfig = { profile: string; buildUUID?: string };
export type CustomBuild = {
buildCommand?: string;
Expand Down Expand Up @@ -48,6 +50,7 @@ export interface LaunchConfig {
getConfig(): Promise<LaunchConfigurationOptions>;
update: LaunchConfigUpdater;
getAvailableXcodeSchemes(): Promise<string[]>;
getAvailableEasProfiles(): Promise<EasBuildConfig>;
addListener<K extends keyof LaunchConfigEventMap>(
eventType: K,
listener: LaunchConfigEventListener<LaunchConfigEventMap[K]>
Expand Down
10 changes: 10 additions & 0 deletions packages/vscode-extension/src/panels/LaunchConfigController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -106,6 +108,14 @@ export class LaunchConfigController implements Disposable, LaunchConfig {
);
return await findXcodeScheme(xcodeProject);
}

async getAvailableEasProfiles(): Promise<EasBuildConfig> {
const appRootFolder = getAppRootFolder();
const easConfig = await readEasConfig(appRootFolder);
const easBuildConfig = easConfig?.build ?? {};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal",
      "ios": {
        "simulator": true
      }
    },
    "preview": {
      "distribution": "internal"
    },
    "production": {
      "autoIncrement": true
    }

this is an example eas configuration (from eas.json) we should only show profiles that have distribution field set to "internal". And simulator set to true. It is not necessary true, that profiles with those flag will generate dev builds runnable on simulators, but expo asumes so and you would have to do a lot of manula configuration to "cheat" that.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've thought about it. I'm not sure we should hide the "invalid" configs.

I feel like ideally we'd show all configs, but disable selecting the "invalid" ones with some information to the user why they are not selectable, but I'm not sure how much work it is to add that functionality to our components.
I'd leave this out of this PR.

return easBuildConfig;
}

async addListener<K extends keyof LaunchConfigEventMap>(
eventType: K,
listener: LaunchConfigEventListener<LaunchConfigEventMap[K]>
Expand Down
23 changes: 23 additions & 0 deletions packages/vscode-extension/src/utilities/eas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { RelativePattern, Uri, workspace } from "vscode";
import { EasConfig, isEasConfig } from "../common/EasConfig";

export async function readEasConfig(appRootFolder: string | Uri): Promise<EasConfig | null> {
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@ import {
LaunchConfigUpdater,
LaunchConfigurationOptions,
} from "../../common/LaunchConfig";
import { EasBuildConfig } from "../../common/EasConfig";

const launchConfig = makeProxy<LaunchConfig>("LaunchConfig");

type LaunchConfigContextType = LaunchConfigurationOptions & {
update: LaunchConfigUpdater;
xcodeSchemes: string[];
easBuildProfiles: EasBuildConfig;
eas?: {
ios?: EasConfig;
android?: EasConfig;
Expand All @@ -29,17 +31,20 @@ type LaunchConfigContextType = LaunchConfigurationOptions & {
const LaunchConfigContext = createContext<LaunchConfigContextType>({
update: () => {},
xcodeSchemes: [],
easBuildProfiles: {},
});

export default function LaunchConfigProvider({ children }: PropsWithChildren) {
const [config, setConfig] = useState<LaunchConfigurationOptions>({});
const [xcodeSchemes, setXcodeSchemes] = useState<string[]>([]);
const [easBuildProfiles, setEasBuildProfiles] = useState<EasBuildConfig>({});

useEffect(() => {
launchConfig.getConfig().then(setConfig);
launchConfig.addListener("launchConfigChange", setConfig);

launchConfig.getAvailableXcodeSchemes().then(setXcodeSchemes);
launchConfig.getAvailableEasProfiles().then(setEasBuildProfiles);

return () => {
launchConfig.removeListener("launchConfigChange", setConfig);
Expand All @@ -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 (
<LaunchConfigContext.Provider value={contextValue}>{children}</LaunchConfigContext.Provider>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<>
Expand Down Expand Up @@ -44,6 +58,26 @@ function LaunchConfigurationView() {
<IsExpoConfiguration isExpo={isExpo} update={update} />

<div className="launch-configuration-section-margin" />

{!!easBuildProfiles && (
<>
<Label>EAS Build</Label>
<EasBuildConfiguration
platform="ios"
eas={eas}
update={update}
easBuildProfiles={easBuildProfiles}
/>
<EasBuildConfiguration
platform="android"
eas={eas}
update={update}
easBuildProfiles={easBuildProfiles}
/>

<div className="launch-configuration-section-margin" />
</>
)}
</>
);
}
Expand Down Expand Up @@ -241,4 +275,142 @@ function IsExpoConfiguration({ isExpo, update }: isExpoConfigurationProps) {
);
}

type EasLaunchConfig = NonNullable<LaunchConfigurationOptions["eas"]>;
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({
maciekstosio marked this conversation as resolved.
Show resolved Hide resolved
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<string>(() => {
if (profile === undefined) {
return DISABLED;
}
if (!(profile in easBuildProfiles)) {
return CUSTOM;
}
return profile;
});

const buildUUIDInputRef = useRef<HTMLInputElement>(null);
const customBuildProfileInputRef = useRef<HTMLInputElement>(null);

const updateEasConfig = (configUpdate: Partial<EasConfig>) => {
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 (
<div className="launch-configuration-container">
<div className="setting-description">{prettyPlatformName(platform)} Build Profile:</div>
<Select
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should allow additional manual input of a value that is not on the list for users with exotic configurations, the button that does it might be hidden as a last psoittion in the select list for example.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possibly.
I feel like if you have an "exotic configuration", you can be asked to edit the launch.json manually, and I fear that adding a "Custom" option may clutter the UI for the other users who, I think, are in vast majority here.

value={selectedProfile}
onChange={onProfileSelectionChange}
items={availableEasBuildProfiles}
className="scheme"
/>
{selectedProfile === CUSTOM && (
<>
<div className="setting-description">
{prettyPlatformName(platform)} Custom Build Profile:
</div>
<Input
ref={customBuildProfileInputRef}
className="input-configuration"
type="string"
defaultValue={profile ?? ""}
placeholder="Enter the build profile to be used"
onBlur={onCustomBuildProfileInputBlur}
/>
</>
)}
{selectedProfile !== DISABLED && (
<>
<div className="setting-description">{prettyPlatformName(platform)} Build UUID:</div>
<Input
ref={buildUUIDInputRef}
className="input-configuration"
type="string"
defaultValue={buildUUID ?? ""}
placeholder="Auto (the latest available build will be used)"
onBlur={onBuildUUIDInputBlur}
/>
</>
)}
</div>
);
}

export default LaunchConfigurationView;