From 1b46c5da2ed86d840cababa1762a074a4c12f3ae Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Tue, 6 Aug 2024 21:57:05 +0200 Subject: [PATCH] feat: Cluster + Global settings (with overrides!) --- apps/desktop/src/api/commands/cluster.rs | 24 +- apps/desktop/src/api/commands/mod.rs | 3 +- .../src/ui/components/AnimatedRoutes.tsx | 4 +- .../src/ui/components/SettingsRow.tsx | 2 +- .../src/ui/components/WindowFrame.tsx | 4 +- .../ui/components/base/TextField.module.scss | 6 +- .../src/ui/components/base/Toggle.tsx | 15 +- apps/frontend/src/ui/hooks/useSettings.tsx | 52 +-- .../src/ui/pages/cluster/ClusterLogs.tsx | 4 +- .../src/ui/pages/cluster/ClusterOverview.tsx | 6 +- .../src/ui/pages/cluster/ClusterSettings.tsx | 303 ++++++++++++++- .../settings/about/SettingsDeveloper.tsx | 6 +- .../pages/settings/game/SettingsMinecraft.tsx | 349 ++++++++++++++---- apps/frontend/src/utils/index.ts | 16 + 14 files changed, 662 insertions(+), 132 deletions(-) diff --git a/apps/desktop/src/api/commands/cluster.rs b/apps/desktop/src/api/commands/cluster.rs index 65d86853..dd3724dd 100644 --- a/apps/desktop/src/api/commands/cluster.rs +++ b/apps/desktop/src/api/commands/cluster.rs @@ -68,9 +68,31 @@ pub async fn create_cluster(props: CreateCluster) -> Result { } } +/// Updates the cluster with the given UUID. The cluster only updates game setting fields #[specta::specta] #[tauri::command] -pub async fn edit_cluster( +pub async fn edit_game_settings(uuid: Uuid, new_cluster: Cluster) -> Result<(), String> { + let cluster_path = ClusterPath::find_by_uuid(uuid).await?; + + cluster::edit(&cluster_path, |old| { + // Game + old.force_fullscreen = new_cluster.force_fullscreen; + old.resolution = new_cluster.resolution; + old.memory = new_cluster.memory; + + // Process + + async move { Ok(()) } + }).await?; + + State::sync().await?; + + Ok(()) +} + +#[specta::specta] +#[tauri::command] +pub async fn edit_cluster_meta( uuid: Uuid, name: Option, icon_path: Option, diff --git a/apps/desktop/src/api/commands/mod.rs b/apps/desktop/src/api/commands/mod.rs index 1b641a16..f5ecda31 100644 --- a/apps/desktop/src/api/commands/mod.rs +++ b/apps/desktop/src/api/commands/mod.rs @@ -29,7 +29,8 @@ macro_rules! collect_commands { remove_user, // Cluster create_cluster, - edit_cluster, + edit_game_settings, + edit_cluster_meta, remove_cluster, get_cluster, get_clusters, diff --git a/apps/frontend/src/ui/components/AnimatedRoutes.tsx b/apps/frontend/src/ui/components/AnimatedRoutes.tsx index e23d3ae9..8c2121ca 100644 --- a/apps/frontend/src/ui/components/AnimatedRoutes.tsx +++ b/apps/frontend/src/ui/components/AnimatedRoutes.tsx @@ -3,7 +3,7 @@ import { Transition, type TransitionProps } from 'solid-transition-group'; import useSettingsContext from '~ui/hooks/useSettings'; function AnimatedRoutes(props: TransitionProps & ParentProps) { - const settings = useSettingsContext(); + const { settings } = useSettingsContext(); const defaultProps: TransitionProps = { mode: 'outin', @@ -20,7 +20,7 @@ function AnimatedRoutes(props: TransitionProps & ParentProps) { return ( {split.children} )} diff --git a/apps/frontend/src/ui/components/SettingsRow.tsx b/apps/frontend/src/ui/components/SettingsRow.tsx index cbc4f58c..86d7216f 100644 --- a/apps/frontend/src/ui/components/SettingsRow.tsx +++ b/apps/frontend/src/ui/components/SettingsRow.tsx @@ -1,6 +1,6 @@ import type { JSX, ParentProps } from 'solid-js'; -type SettingsRowProps = ParentProps & { +export type SettingsRowProps = ParentProps & { title: JSX.Element; description: JSX.Element; icon: JSX.Element; diff --git a/apps/frontend/src/ui/components/WindowFrame.tsx b/apps/frontend/src/ui/components/WindowFrame.tsx index 74cfaf63..c2560b59 100644 --- a/apps/frontend/src/ui/components/WindowFrame.tsx +++ b/apps/frontend/src/ui/components/WindowFrame.tsx @@ -23,7 +23,7 @@ function TitlebarButton(props: TitlebarButtonProps) { } function WindowFrame() { - const settings = useSettingsContext(); + const { settings } = useSettingsContext(); const maximize = () => Window.getCurrent().toggleMaximize(); const minimize = () => Window.getCurrent().minimize(); @@ -43,7 +43,7 @@ function WindowFrame() { onMount(() => { Window.getCurrent().onCloseRequested((event) => { - if (settings.hide_close_prompt !== true) { + if (settings().hide_close_prompt !== true) { event.preventDefault(); modal.show(); } diff --git a/apps/frontend/src/ui/components/base/TextField.module.scss b/apps/frontend/src/ui/components/base/TextField.module.scss index f9b5848c..f5c4f256 100644 --- a/apps/frontend/src/ui/components/base/TextField.module.scss +++ b/apps/frontend/src/ui/components/base/TextField.module.scss @@ -14,7 +14,11 @@ @apply bg-brand/30; } - @apply bg-transparent outline-none appearance-none p-0 m-0 box-border w-full; + @apply bg-transparent outline-none appearance-none p-0 m-0 box-border w-full font-medium; + + &::placeholder { + @apply text-fg-secondary/40 font-normal; + } } & span { diff --git a/apps/frontend/src/ui/components/base/Toggle.tsx b/apps/frontend/src/ui/components/base/Toggle.tsx index 09357bc5..c25494c0 100644 --- a/apps/frontend/src/ui/components/base/Toggle.tsx +++ b/apps/frontend/src/ui/components/base/Toggle.tsx @@ -1,13 +1,20 @@ -import { type JSX, createSignal, splitProps } from 'solid-js'; +import { type Accessor, type JSX, createEffect, createSignal, splitProps } from 'solid-js'; type ToggleProps = JSX.HTMLAttributes & { - defaultChecked?: boolean; + checked?: Accessor; onChecked?: (checked: boolean) => void; }; function Toggle(props: ToggleProps) { - const [checked, setChecked] = createSignal(props.defaultChecked || false); - const [split, rest] = splitProps(props, ['class', 'defaultChecked', 'onChecked', 'text']); + // eslint-disable-next-line solid/reactivity -- ok + const [checked, setChecked] = createSignal(props.checked?.()); + const [split, rest] = splitProps(props, ['class', 'checked', 'onChecked', 'text']); + + // eslint-disable-next-line solid/reactivity -- i hate mylife + if (props.checked) + createEffect(() => { + setChecked(props.checked!()); + }); function toggle() { const newValue = !checked(); diff --git a/apps/frontend/src/ui/hooks/useSettings.tsx b/apps/frontend/src/ui/hooks/useSettings.tsx index 898a4153..e7fe81ae 100644 --- a/apps/frontend/src/ui/hooks/useSettings.tsx +++ b/apps/frontend/src/ui/hooks/useSettings.tsx @@ -1,10 +1,9 @@ -import { type Context, type ParentProps, type Resource, Show, createContext, createEffect, useContext } from 'solid-js'; +import { type Context, type ParentProps, Show, createContext, createEffect, useContext } from 'solid-js'; +import { useBeforeLeave } from '@solidjs/router'; import useCommand from './useCommand'; import type { Settings } from '~bindings'; import { bridge } from '~imports'; -const SettingsContext = createContext() as Context; - /** * Sync the launcher state (CSS, text etc) with the current settings */ @@ -12,22 +11,43 @@ export function syncSettings(settings: Settings) { document.body.classList.toggle('reduce-motion', settings.disable_animations); } -export function getSettings(): Resource | undefined { - const [resource] = useCommand(bridge.commands.getSettings); - return resource; +interface SettingsControllerType { + settings: () => Settings; + saveOnLeave: (settings: () => Partial) => void; + save: (settings: Settings) => void; } +const SettingsContext = createContext() as Context; + export function SettingsProvider(props: ParentProps) { - const settings = getSettings(); + const [settings, { refetch }] = useCommand(bridge.commands.getSettings); createEffect(() => { if (settings !== undefined && settings() !== undefined) syncSettings(settings!()!); }); + const controller: SettingsControllerType = { + settings: () => settings!()!, + saveOnLeave: (settings) => { + useBeforeLeave(() => { + controller.save({ + ...controller.settings(), + ...settings(), + }); + }); + }, + save: (settings) => { + bridge.commands.setSettings(settings).then(() => { + syncSettings(settings); + refetch(); + }); + }, + }; + return ( - + {props.children} @@ -40,21 +60,7 @@ export function useSettingsContext() { if (!context) throw new Error('useSettingsContext should be called inside its SettingsProvider'); - const contextProxy = new Proxy(context, { - set(target, key, value) { - // @ts-expect-error typescript - target[key] = value; - save(target); // TODO: Possibly make a "Save confirmation" instead of writing to the settings file every time? - return true; - }, - }); - - return contextProxy; -} - -function save(settings: Settings) { - bridge.commands.setSettings(settings); - syncSettings(settings); + return context; } export default useSettingsContext; diff --git a/apps/frontend/src/ui/pages/cluster/ClusterLogs.tsx b/apps/frontend/src/ui/pages/cluster/ClusterLogs.tsx index 4c5ae8c7..21d2ab43 100644 --- a/apps/frontend/src/ui/pages/cluster/ClusterLogs.tsx +++ b/apps/frontend/src/ui/pages/cluster/ClusterLogs.tsx @@ -13,7 +13,7 @@ import FormattedLog from '~ui/components/content/FormattedLog'; function ClusterLogs() { const [cluster] = useClusterContext(); const [logs] = useCommand(bridge.commands.getClusterLogs, cluster()!.uuid); - const settings = useSettingsContext(); + const { settings } = useSettingsContext(); const [activeLogFile, setActiveLogFile] = createSignal(null); const [logContent, setLogContent] = createSignal(null); @@ -31,7 +31,7 @@ function ClusterLogs() { } async function openFolder() { - const root = settings.config_dir; + const root = settings().config_dir; const path = cluster()!.path; if (root === null || root === undefined || path === null || path === undefined) return; diff --git a/apps/frontend/src/ui/pages/cluster/ClusterOverview.tsx b/apps/frontend/src/ui/pages/cluster/ClusterOverview.tsx index dfb320b3..8edae391 100644 --- a/apps/frontend/src/ui/pages/cluster/ClusterOverview.tsx +++ b/apps/frontend/src/ui/pages/cluster/ClusterOverview.tsx @@ -21,7 +21,7 @@ import type { ModalProps } from '~ui/components/overlay/Modal'; import Modal, { createModal } from '~ui/components/overlay/Modal'; function ClusterOverview() { - const settings = useSettingsContext(); + const { settings } = useSettingsContext(); const [cluster, { refetch }] = useClusterContext(); const [editMode, setEditMode] = createSignal(false); @@ -47,7 +47,7 @@ function ClusterOverview() { function getPath() { const clusterPath = cluster()?.path; - const configDir = settings.config_dir; + const configDir = settings().config_dir; if (typeof clusterPath !== 'string' || typeof configDir !== 'string') return ''; @@ -95,7 +95,7 @@ function ClusterOverview() { if (!c) return; - await bridge.commands.editCluster( + await bridge.commands.editClusterMeta( c.uuid, newName().length > 0 ? newName() : null, newCover().length > 0 ? newCover() : c.meta.icon_url || c.meta.icon || null, diff --git a/apps/frontend/src/ui/pages/cluster/ClusterSettings.tsx b/apps/frontend/src/ui/pages/cluster/ClusterSettings.tsx index d2cea237..c37635be 100644 --- a/apps/frontend/src/ui/pages/cluster/ClusterSettings.tsx +++ b/apps/frontend/src/ui/pages/cluster/ClusterSettings.tsx @@ -1,34 +1,305 @@ +import { useBeforeLeave } from '@solidjs/router'; +import { type Accessor, type Setter, Show, createEffect, createSignal, splitProps, untrack } from 'solid-js'; +import { CpuChip01Icon, Database01Icon, EyeIcon, FilePlus02Icon, FileX02Icon, LayoutTopIcon, Maximize01Icon, ParagraphWrapIcon, VariableIcon, XIcon } from '@untitled-theme/icons-solid'; +import { createSetting } from '../settings/game/SettingsMinecraft'; import ScrollableContainer from '~ui/components/ScrollableContainer'; +import type { SettingsRowProps } from '~ui/components/SettingsRow'; import Sidebar from '~ui/components/Sidebar'; import useClusterContext from '~ui/hooks/useCluster'; +import BaseSettingsRow from '~ui/components/SettingsRow'; +import type { Cluster } from '~bindings'; import useSettingsContext from '~ui/hooks/useSettings'; -import SettingsMinecraft from '~ui/pages/settings/game/SettingsMinecraft'; +import Toggle from '~ui/components/base/Toggle'; +import TextField from '~ui/components/base/TextField'; +import { asEnvVariables } from '~utils'; +import { tryResult } from '~ui/hooks/useCommand'; +import { bridge } from '~imports'; function ClusterSettings() { const [cluster] = useClusterContext(); - const settings = useSettingsContext(); - - // TODO: Save cluster settings return (

Overview

- cluster() && (cluster()!.force_fullscreen = value), - isGlobal: cluster()?.force_fullscreen === null, - }} - resolution={{ - get: cluster()?.resolution ?? settings.resolution, - set: value => cluster() && (cluster()!.resolution = value), - isGlobal: cluster()?.resolution === undefined || cluster()?.resolution === null, - }} - /> + + {PageSettings(() => cluster()!)} +
); } +function SettingsRow(props: SettingsRowProps & { + reset: () => any; + isGlobal: Accessor; +}) { + const [split, rest] = splitProps(props, ['children', 'reset', 'isGlobal']); + + // TODO: Reset button doesn't show up immediately after changing a setting + + return ( + + +
split.reset()} + > + Reset +
+
+ + {split.children} +
+ ); +} + +function PageSettings(cluster: Accessor) { + const { settings } = useSettingsContext(); + + // Game + const fullscreen = createSetting(cluster().force_fullscreen, settings().force_fullscreen ?? false); + const resolution = createSetting(cluster().resolution, settings().resolution); + const memory = createSetting(cluster().memory, settings().memory); + + // Launcher + + // Process + const preCommand = createSetting(cluster().init_hooks?.pre, settings().init_hooks.pre ?? ''); + const wrapperCommand = createSetting(cluster().init_hooks?.wrapper, settings().init_hooks.wrapper ?? ''); + const postCommand = createSetting(cluster().init_hooks?.post, settings().init_hooks.post ?? ''); + + // JVM + const javaArgs = createSetting(cluster().java?.custom_arguments, settings().custom_java_args); + const envVars = createSetting(cluster().java?.custom_env_arguments, settings().custom_env_args); + + useBeforeLeave(() => { + tryResult(bridge.commands.editGameSettings, cluster().uuid, { + ...cluster(), + + // Game + force_fullscreen: fullscreen.getRaw(), + resolution: resolution.getRaw(), + memory: memory.getRaw(), + + // Process + init_hooks: { + pre: preCommand.getRaw(), + wrapper: wrapperCommand.getRaw(), + post: postCommand.getRaw(), + }, + + // JVM + java: { + custom_arguments: javaArgs.getRaw(), + custom_env_arguments: envVars.getRaw(), + }, + }); + }); + + const GameSettings = () => ( + <> + Game + + } + isGlobal={fullscreen.isGlobal} + reset={fullscreen.resetToFallback} + > + + + + } + isGlobal={resolution.isGlobal} + reset={resolution.resetToFallback} + > +
+ { + resolution.set([Number.parseInt(value), resolution.get()[1]]); + }} + /> + + { + resolution.set([resolution.get()[0], Number.parseInt(value)]); + }} + /> +
+
+ + } + isGlobal={memory.isGlobal} + reset={memory.resetToFallback} + > +
+
+ Min: + { + memory.set({ minimum: Number.parseInt(value), maximum: memory.get().maximum }); + }} + /> +
+ +
+ Max: + { + memory.set({ minimum: memory.get().minimum, maximum: Number.parseInt(value) }); + }} + /> +
+
+
+ + ); + + const LauncherSettings = () => ( + <> + {/* Launcher + + } + > + + */} + + ); + + const ProcessSettings = () => ( + <> + Process + + } + isGlobal={preCommand.isGlobal} + reset={preCommand.resetToFallback} + > + + + + } + isGlobal={wrapperCommand.isGlobal} + reset={wrapperCommand.resetToFallback} + > + + + + } + isGlobal={postCommand.isGlobal} + reset={postCommand.resetToFallback} + > + + + + ); + + const JvmSettings = () => ( + <> + Java + + } + isGlobal={() => true} + reset={() => {}} + /> + + } + isGlobal={javaArgs.isGlobal} + reset={javaArgs.resetToFallback} + > + { + javaArgs.set(value.split(' ')); + }} + /> + + + } + isGlobal={envVars.isGlobal} + reset={envVars.resetToFallback} + > + { + envVars.set(asEnvVariables(value)); + }} + /> + + + ); + + return ( + <> + + + + + + ); +} + export default ClusterSettings; diff --git a/apps/frontend/src/ui/pages/settings/about/SettingsDeveloper.tsx b/apps/frontend/src/ui/pages/settings/about/SettingsDeveloper.tsx index 2e61d784..5a5babdd 100644 --- a/apps/frontend/src/ui/pages/settings/about/SettingsDeveloper.tsx +++ b/apps/frontend/src/ui/pages/settings/about/SettingsDeveloper.tsx @@ -7,7 +7,7 @@ import Sidebar from '~ui/components/Sidebar'; import useSettingsContext from '~ui/hooks/useSettings'; function SettingsDeveloper() { - const settings = useSettingsContext(); + const { settings } = useSettingsContext(); return ( @@ -31,8 +31,8 @@ function SettingsDeveloper() { icon={} > settings.debug_mode = value} + checked={() => settings().debug_mode ?? false} + onChecked={value => settings().debug_mode = value} /> diff --git a/apps/frontend/src/ui/pages/settings/game/SettingsMinecraft.tsx b/apps/frontend/src/ui/pages/settings/game/SettingsMinecraft.tsx index f3db52da..27aea2ce 100644 --- a/apps/frontend/src/ui/pages/settings/game/SettingsMinecraft.tsx +++ b/apps/frontend/src/ui/pages/settings/game/SettingsMinecraft.tsx @@ -1,106 +1,309 @@ -import { EyeIcon, LayoutTopIcon, Maximize01Icon, XIcon } from '@untitled-theme/icons-solid'; -import { Show } from 'solid-js'; +import { CpuChip01Icon, Database01Icon, EyeIcon, FilePlus02Icon, FileX02Icon, LayoutTopIcon, Maximize01Icon, ParagraphWrapIcon, VariableIcon, XIcon } from '@untitled-theme/icons-solid'; +import { type Accessor, type Setter, createSignal, onMount, untrack } from 'solid-js'; import TextField from '~ui/components/base/TextField'; import Toggle from '~ui/components/base/Toggle'; import ScrollableContainer from '~ui/components/ScrollableContainer'; import SettingsRow from '~ui/components/SettingsRow'; import Sidebar from '~ui/components/Sidebar'; import useSettingsContext from '~ui/hooks/useSettings'; +import { asEnvVariables } from '~utils'; + +// TODO: REFACTOR THIS ENTIRE FILE AND POSSIBLY `apps/frontend/src/ui/pages/cluster/ClusterSettings.tsx` TOO function SettingsMinecraft() { - const settings = useSettingsContext(); - return (

Global Minecraft Settings

- settings.force_fullscreen = value, - }} - resolution={{ - get: settings.resolution, - set: value => settings.resolution = value, - }} - hide_on_launch={{ - get: settings.hide_on_launch ?? false, - set: value => settings.hide_on_launch = value, - }} - /> +
); } -interface SettingsEntryType { - get: T; - set: (value: T) => any; - isGlobal?: boolean; // TODO: Ask how "global settings sync" should work on pages like ClusterSettings +interface CreateSetting { + get: Accessor; + getRaw: () => T; + set: Setter; + isGlobal: Accessor; + resetToFallback: (raw?: any) => void; }; +export function createSetting(initial: T | undefined | null, fallback?: T): CreateSetting { + const [value, setValue] = createSignal(initial || fallback as T); + const [raw, setRaw] = createSignal(initial as T); + const [isGlobal, setIsGlobal] = createSignal(true); -interface SettingsProps { - fullscreen: SettingsEntryType; - resolution: SettingsEntryType<[number, number]>; - hide_on_launch: SettingsEntryType; -}; + const checkGlobal = () => { + const check_one = untrack(() => value()) === undefined || untrack(() => value()) === null; + const check_two = initial === undefined || initial === null; + // const check_three = untrack(() => raw()) === undefined || untrack(() => raw()) === null; -SettingsMinecraft.Settings = function (props: Partial) { - return ( + setIsGlobal(check_one || check_two); + }; + + onMount(() => { + checkGlobal(); + }); + + // @ts-expect-error -- aaa + const set: Setter = (value) => { + // @ts-expect-error -- aaa + setRaw(value); + // @ts-expect-error -- aaa + setValue(value); + + checkGlobal(); + }; + + const resetToFallback = (raw?: T) => { + if (fallback === undefined || fallback === null) + throw new Error('No fallback value provided'); + + setValue(() => fallback); + + // @ts-expect-error -- aaa + setRaw(() => (raw || null)); + + checkGlobal(); + }; + + const getRaw = () => raw(); + + return { + get: value, + getRaw, + set, + isGlobal, + resetToFallback, + }; +} + +function PageSettings() { + const { settings, saveOnLeave } = useSettingsContext(); + + // Game + const fullscreen = createSetting(settings().force_fullscreen ?? false); + const resolution = createSetting(settings().resolution); + const memory = createSetting(settings().memory); + + // Launcher + const hideOnLaunch = createSetting(settings().hide_on_launch ?? false); + + // Process + const preCommand = createSetting(settings().init_hooks.pre ?? ''); + const wrapperCommand = createSetting(settings().init_hooks.wrapper ?? ''); + const postCommand = createSetting(settings().init_hooks.post ?? ''); + + // JVM + const javaArgs = createSetting(settings().custom_java_args); + const envVars = createSetting(settings().custom_env_args); + + saveOnLeave(() => ({ + // Game + force_fullscreen: fullscreen.get(), + resolution: resolution.get(), + memory: memory.get(), + + // Launcher + hide_on_launch: hideOnLaunch.get(), + + // Process + init_hooks: { + pre: preCommand.get(), + wrapper: wrapperCommand.get(), + post: postCommand.get(), + }, + + // JVM + custom_java_args: javaArgs.get(), + custom_env_args: envVars.get(), + })); + + const GameSettings = () => ( <> - - } - > - - - + Game - - } - > -
+ } + > + + + + } + > +
+ { + resolution.set([Number.parseInt(value), resolution.get()[1]]); + }} + /> + + { + resolution.set([resolution.get()[0], Number.parseInt(value)]); + }} + /> +
+
+ + } + > +
+
+ Min: { - props.resolution!.set([Number.parseInt(value), props.resolution!.get[1]]); - }} - /> - - { - props.resolution!.set([props.resolution!.get[0], Number.parseInt(value)]); + memory.set({ minimum: Number.parseInt(value), maximum: memory.get().maximum }); }} />
- - - - } - > - - - +
+ Max: + { + memory.set({ minimum: memory.get().minimum, maximum: Number.parseInt(value) }); + }} + /> +
+
+
); -}; + + const LauncherSettings = () => ( + <> + Launcher + + } + > + + + + ); + + const ProcessSettings = () => ( + <> + Process + + } + > + + + + } + > + + + + } + > + + + + ); + + const JvmSettings = () => ( + <> + Java + + } + /> + + } + > + { + javaArgs.set(value.split(' ')); + }} + /> + + + } + > + { + envVars.set(asEnvVariables(value)); + }} + /> + + + ); + + return ( + <> + + + + + + ); +} export default SettingsMinecraft; diff --git a/apps/frontend/src/utils/index.ts b/apps/frontend/src/utils/index.ts index 27edc56b..0f2cb4a3 100644 --- a/apps/frontend/src/utils/index.ts +++ b/apps/frontend/src/utils/index.ts @@ -80,3 +80,19 @@ export function secondsToWords(seconds: number | bigint, plural: boolean = true) return str; } + +export function asEnvVariables(str: string): [string, string][] { + return str + .split(' ') + .map((pair) => { + const split = pair.split('=', 1); + + if (split.length === 0) + return null; + else if (split.length === 1) + return [split[0]!, ''] as [string, string]; + else + return [split[0]!, split[1]!] as [string, string]; + }) + .filter(pair => pair !== null); +}