1import * as androidmanagement from "@distilled.cloud/gcp/androidmanagement_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 DEFAULT_DISPLAY_MODE,
12 defaultWebAppIcons,
13 findWebAppByTitle,
14 getWebApp,
15 jsonEqual,
16 lastSegment,
17 MAX_WEB_APP_TITLE_LENGTH,
18 parentOf,
19 replaceOnIdentity,
20 sameText,
21 toDisplayName,
22 toEnterpriseName,
23 toWebAppName,
24 updateMaskOf,
25} from "./internal.ts";
26
27export type EnterprisesWebAppProps = {
28 /**
29 * Parent enterprise (`enterprises/{enterprise}` or `{enterprise}`).
30 * Immutable — changing it replaces the web app.
31 */
32 parent: string;
33 /**
34 * Start URL loaded when the user opens the app.
35 */
36 startUrl: string;
37 /**
38 * Title shown to users. Web apps have no labels field, so without
39 * state Alchemy finds the web app by this title.
40 * @default a unique name generated from the stack, stage, and logical id
41 */
42 title?: string;
43 /**
44 * Display mode (`MINIMAL_UI`, `STANDALONE`, or `FULL_SCREEN`).
45 * @default "STANDALONE"
46 */
47 displayMode?: androidmanagement.WebAppDisplayModeEnum | (string & {});
48 /**
49 * Icons for the web app. At least one is required; a 1x1 PNG is used
50 * when omitted.
51 */
52 icons?: androidmanagement.WebAppIconList;
53};
54
55export type EnterprisesWebApp = Resource<
56 "GCP.AndroidManagement.EnterprisesWebApp",
57 EnterprisesWebAppProps,
58 {
59 /** Resource name `enterprises/{enterprise}/webApps/{package}`. */
60 name: string;
61 /** Web app package id (last path segment). */
62 webAppId: string;
63 /** Parent enterprise name. */
64 parent: string;
65 /** Project id used when the web app was reconciled. */
66 project: string;
67 /** Title shown to users. */
68 title: string | undefined;
69 /** Start URL. */
70 startUrl: string | undefined;
71 /** Display mode. */
72 displayMode: string | undefined;
73 /** Icons. */
74 icons: androidmanagement.WebAppIconList | undefined;
75 /** Current version code. */
76 versionCode: string | undefined;
77 },
78 never,
79 Providers
80>;
81
82/**
83 * An Android Management web app.
84 *
85 * Web apps have no labels field, so Alchemy tracks the web app by its
86 * resource name; without state it is found by title, and one found under
87 * an explicit (non-generated) title is reported as unowned. `parent` is
88 * identity — changing it
89 * replaces the web app. Title, start URL, display mode, and icons
90 * update in place. At least one icon is required by the API; a 1x1 PNG
91 * is sent when `icons` is omitted.
92 *
93 * ### Creating a Web App
94 * **Example:** Standalone shortcut
95 * ```typescript
96 * const app = yield* GCP.AndroidManagement.EnterprisesWebApp("Docs", {
97 * parent: enterprise.name,
98 * startUrl: "https://docs.example.com/",
99 * title: "Docs",
100 * });
101 * ```
102 *
103 * **Example:** Full-screen kiosk URL
104 * ```typescript
105 * const app = yield* GCP.AndroidManagement.EnterprisesWebApp("Kiosk", {
106 * parent: enterprise.name,
107 * startUrl: "https://kiosk.example.com/",
108 * displayMode: "FULL_SCREEN",
109 * });
110 * ```
111 *
112 * ### Updating a Web App
113 * Change props on the same logical id; the engine keeps the physical id.
114 *
115 * **Example:** Change the title
116 * ```typescript
117 * const app = yield* GCP.AndroidManagement.EnterprisesWebApp("Docs", {
118 * parent: enterprise.name,
119 * startUrl: "https://docs.example.com/",
120 * title: "Internal docs",
121 * });
122 * ```
123 *
124 * @resource
125 * @category AndroidManagement
126 */
127export const EnterprisesWebApp = Resource<EnterprisesWebApp>(
128 "GCP.AndroidManagement.EnterprisesWebApp",
129);
130
131export class EnterprisesWebAppNotResolved extends Data.TaggedError(
132 "GCP.AndroidManagement.EnterprisesWebAppNotResolved",
133)<{
134 name: string;
135}> {}
136
137const toAttrs = (app: androidmanagement.WebApp, project: string) => {
138 const name = app.name ?? "";
139 return {
140 name,
141 webAppId: lastSegment(name),
142 parent: parentOf(name),
143 project,
144 title: app.title,
145 startUrl: app.startUrl,
146 displayMode: app.displayMode,
147 icons: app.icons,
148 versionCode: app.versionCode,
149 };
150};
151
152export const EnterprisesWebAppProvider = () =>
153 Provider.succeed(EnterprisesWebApp, {
154 stables: ["name", "webAppId", "parent", "project"],
155
156 diff: Effect.fn(function* ({ news, olds, output }) {
157 if (!isResolved(news)) return undefined;
158 return replaceOnIdentity({
159 previousParent: olds?.parent ?? output?.parent,
160 nextParent: news.parent,
161 });
162 }),
163
164 read: Effect.fn(function* ({ id, olds, output }) {
165 const env = yield* GcpEnvironment.current;
166 const parent = olds?.parent ?? output?.parent ?? "";
167 const byName = yield* getWebApp(
168 toWebAppName(parent, output?.name ?? output?.webAppId),
169 );
170 if (byName !== undefined) return toAttrs(byName, env.project);
171 if (parent.length === 0) return undefined;
172 const generated = yield* toDisplayName(
173 id,
174 undefined,
175 undefined,
176 MAX_WEB_APP_TITLE_LENGTH,
177 );
178 const title = olds?.title ?? generated;
179 const found = yield* findWebAppByTitle(toEnterpriseName(parent), title);
180 if (found === undefined) return undefined;
181 const attrs = toAttrs(found, env.project);
182 // A generated title is unique to this stack, stage and id.
183 return title === generated ? attrs : Unowned(attrs);
184 }),
185
186 reconcile: Effect.fn(function* ({ id, news, output }) {
187 const env = yield* GcpEnvironment.current;
188 const parent = toEnterpriseName(news.parent);
189 const title = yield* toDisplayName(
190 id,
191 news.title,
192 output?.title,
193 MAX_WEB_APP_TITLE_LENGTH,
194 );
195 const displayMode =
196 news.displayMode ?? output?.displayMode ?? DEFAULT_DISPLAY_MODE;
197 const icons = defaultWebAppIcons(news.icons ?? output?.icons);
198 const desired: androidmanagement.WebApp = {
199 title,
200 startUrl: news.startUrl,
201 displayMode,
202 icons,
203 };
204
205 let current = yield* getWebApp(
206 toWebAppName(parent, output?.name ?? output?.webAppId),
207 );
208 if (current === undefined) {
209 current = yield* findWebAppByTitle(parent, title);
210 }
211
212 if (current === undefined) {
213 const created = yield* androidmanagement
214 .createEnterprisesWebApps({
215 parent,
216 body: desired,
217 })
218 .pipe(
219 Effect.catchTag("Conflict", () => findWebAppByTitle(parent, title)),
220 );
221 current = created ?? undefined;
222 }
223
224 if (current === undefined) {
225 return yield* new EnterprisesWebAppNotResolved({
226 name: toWebAppName(parent, output?.name) || news.startUrl,
227 });
228 }
229
230 const name = current.name ?? output?.name ?? "";
231 const titleChanged = !sameText(current.title, title);
232 const urlChanged = !sameText(current.startUrl, news.startUrl);
233 const modeChanged = !sameText(current.displayMode, displayMode);
234 const iconsChanged =
235 news.icons !== undefined && !jsonEqual(current.icons, icons);
236
237 const updateMask = updateMaskOf(
238 titleChanged ? "title" : undefined,
239 urlChanged ? "startUrl" : undefined,
240 modeChanged ? "displayMode" : undefined,
241 iconsChanged ? "icons" : undefined,
242 );
243
244 if (updateMask.length > 0 && name.length > 0) {
245 current = yield* androidmanagement.patchEnterprisesWebApps({
246 name,
247 updateMask,
248 body: desired,
249 });
250 }
251
252 const fresh = (yield* getWebApp(name)) ?? current;
253 return toAttrs(fresh, env.project);
254 }),
255
256 delete: Effect.fn(function* ({ output }) {
257 if (output.name.length === 0) return;
258 yield* androidmanagement
259 .deleteEnterprisesWebApps({ name: output.name })
260 .pipe(Effect.catchTag("NotFound", () => Effect.void));
261 }),
262 });