1import * as androidenterprise from "@distilled.cloud/gcp/androidenterprise_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 findOwnedWebapp,
13 getWebapp,
14 hasOwnershipMarker,
15 jsonEqual,
16 listOwnedWebapps,
17 ownedByAlchemy,
18 parseOwnership,
19 sameText,
20 defaultTitle,
21 toDisplayName,
22} from "./internal.ts";
23
24export type WebappProps = {
25 /**
26 * Play EMM enterprise id. Immutable — changing it replaces the web app.
27 */
28 enterpriseId: string;
29 /**
30 * Server-assigned web app id (`app:com.google.enterprise.webapp.…`).
31 * Immutable — changing it replaces the web app.
32 */
33 webAppId?: string;
34 /**
35 * Title shown to users, as given. Defaults to a generated name.
36 */
37 title?: string;
38 /**
39 * Start URL loaded when the user opens the app. HTTP URLs require
40 * `displayMode: "minimalUi"`.
41 */
42 startUrl: string;
43 /**
44 * Display mode (`minimalUi`, `standalone`, or `fullScreen`).
45 * @default "standalone"
46 */
47 displayMode?: androidenterprise.WebAppDisplayModeEnum | (string & {});
48 /**
49 * Icons for the web app. PNG or JPEG, ideally 512x512, as base64url
50 * `imageData`.
51 */
52 icons?: androidenterprise.WebAppIcon[];
53 /**
54 * Whether the app is published to managed Google Play.
55 */
56 isPublished?: boolean;
57};
58
59export type Webapp = Resource<
60 "GCP.AndroidEnterprise.Webapp",
61 WebappProps,
62 {
63 /** Server-assigned web app id. */
64 webAppId: string;
65 /** Play EMM enterprise id. */
66 enterpriseId: string;
67 /** Project id used when the web app was reconciled. */
68 project: string;
69 /** User-facing title with the Alchemy ownership prefix stripped. */
70 title: string | undefined;
71 /** Start URL. */
72 startUrl: string | undefined;
73 /** Display mode. */
74 displayMode: string | undefined;
75 /** Whether the app is published. */
76 isPublished: boolean | undefined;
77 /** Current version code. */
78 versionCode: string | undefined;
79 },
80 never,
81 Providers
82>;
83
84/**
85 * A managed Google Play web app (`webapps`).
86 *
87 * Web apps have no labels field and their title is shown to users, so
88 * Alchemy tracks an app by its recorded id only (`list` finds apps from
89 * older releases that carried a title marker). `enterpriseId` and `webAppId` are identity
90 * — changing either replaces the app. Title, start URL, display mode,
91 * icons, and publish state update in place.
92 *
93 * ### Creating a Web App
94 * **Example:** Standalone HTTPS app
95 * ```typescript
96 * const app = yield* GCP.AndroidEnterprise.Webapp("Portal", {
97 * enterpriseId: "123456789",
98 * startUrl: "https://intranet.example.com/",
99 * title: "Portal",
100 * });
101 * ```
102 *
103 * **Example:** Minimal UI for an HTTP URL
104 * ```typescript
105 * const app = yield* GCP.AndroidEnterprise.Webapp("Legacy", {
106 * enterpriseId: "123456789",
107 * startUrl: "http://intranet.example.com/",
108 * displayMode: "minimalUi",
109 * title: "Legacy portal",
110 * });
111 * ```
112 *
113 * ### Updating a Web App
114 * Re-declare the same logical id with changed props; the engine keeps the
115 * physical resource and updates it in place.
116 *
117 * **Example:** Rename
118 * ```typescript
119 * const app = yield* GCP.AndroidEnterprise.Webapp("Portal", {
120 * enterpriseId: "123456789",
121 * startUrl: "https://intranet.example.com/",
122 * title: "Employee portal",
123 * });
124 * ```
125 *
126 * @resource
127 * @category AndroidEnterprise
128 */
129export const Webapp = Resource<Webapp>("GCP.AndroidEnterprise.Webapp");
130
131export class WebappNotResolved extends Data.TaggedError(
132 "GCP.AndroidEnterprise.WebappNotResolved",
133)<{
134 enterpriseId: string;
135 webAppId: string;
136}> {}
137
138const toAttrs = (
139 webapp: androidenterprise.WebApp,
140 enterpriseId: string,
141 project: string,
142) => ({
143 webAppId: webapp.webAppId ?? "",
144 enterpriseId,
145 project,
146 title: parseOwnership(webapp.title).text,
147 startUrl: webapp.startUrl,
148 displayMode: webapp.displayMode,
149 isPublished: webapp.isPublished,
150 versionCode: webapp.versionCode,
151});
152
153const desiredBody = (input: {
154 webAppId?: string;
155 title: string;
156 news: WebappProps;
157 current?: androidenterprise.WebApp;
158}): androidenterprise.WebApp => ({
159 webAppId: input.webAppId,
160 title: input.title,
161 startUrl: input.news.startUrl,
162 displayMode:
163 input.news.displayMode ??
164 input.current?.displayMode ??
165 DEFAULT_DISPLAY_MODE,
166 icons: input.news.icons ?? input.current?.icons,
167 isPublished: input.news.isPublished ?? input.current?.isPublished,
168});
169
170const needsSync = (
171 current: androidenterprise.WebApp,
172 desired: androidenterprise.WebApp,
173) =>
174 !sameText(current.title, desired.title) ||
175 !sameText(current.startUrl, desired.startUrl) ||
176 (desired.displayMode !== undefined &&
177 !sameText(current.displayMode, desired.displayMode)) ||
178 (desired.icons !== undefined && !jsonEqual(current.icons, desired.icons)) ||
179 (desired.isPublished !== undefined &&
180 current.isPublished !== desired.isPublished);
181
182export const WebappProvider = () =>
183 Provider.succeed(Webapp, {
184 stables: ["webAppId", "enterpriseId", "project"],
185
186 diff: Effect.fn(function* ({ news, olds, output }) {
187 if (!isResolved(news)) return undefined;
188 const previousEnterprise = olds?.enterpriseId ?? output?.enterpriseId;
189 if (
190 previousEnterprise !== undefined &&
191 news.enterpriseId !== previousEnterprise
192 ) {
193 return { action: "replace" as const, deleteFirst: false };
194 }
195 const previousId = olds?.webAppId ?? output?.webAppId;
196 if (
197 previousId !== undefined &&
198 news.webAppId !== undefined &&
199 news.webAppId !== previousId
200 ) {
201 return { action: "replace" as const, deleteFirst: false };
202 }
203 return undefined;
204 }),
205
206 read: Effect.fn(function* ({ id, olds, output }) {
207 const env = yield* GcpEnvironment.current;
208 const enterpriseId = olds?.enterpriseId ?? output?.enterpriseId ?? "";
209 const webAppId = olds?.webAppId ?? output?.webAppId ?? "";
210 let existing = yield* getWebapp(enterpriseId, webAppId);
211 if (existing === undefined && enterpriseId.length > 0) {
212 existing = yield* findOwnedWebapp(id, enterpriseId);
213 }
214 if (existing === undefined) return undefined;
215 const attrs = toAttrs(existing, enterpriseId, env.project);
216 // Only a recorded id (or a legacy ownership marker) proves ownership;
217 // names and titles are user-visible and carry no marker.
218 return output !== undefined || (yield* ownedByAlchemy(id, existing.title))
219 ? attrs
220 : Unowned(attrs);
221 }),
222
223 list: () =>
224 Effect.gen(function* () {
225 const env = yield* GcpEnvironment.current;
226 const webapps = yield* listOwnedWebapps();
227 return webapps
228 .filter(({ webapp }) => hasOwnershipMarker(webapp.title))
229 .map(({ webapp, enterpriseId }) =>
230 toAttrs(webapp, enterpriseId, env.project),
231 );
232 }),
233
234 reconcile: Effect.fn(function* ({ id, news, output }) {
235 const env = yield* GcpEnvironment.current;
236 const enterpriseId = news.enterpriseId;
237 const displayName = yield* toDisplayName(id, news.title, output?.title);
238 const title = defaultTitle(news.title, displayName);
239
240 let current = yield* getWebapp(
241 enterpriseId,
242 news.webAppId ?? output?.webAppId ?? "",
243 );
244 if (current === undefined) {
245 current = yield* findOwnedWebapp(id, enterpriseId);
246 }
247
248 if (current === undefined) {
249 const created = yield* androidenterprise
250 .insertWebapps({
251 enterpriseId,
252 body: desiredBody({ title, news }),
253 })
254 .pipe(
255 Effect.catchTag("Conflict", () =>
256 findOwnedWebapp(id, enterpriseId),
257 ),
258 );
259 current = created ?? undefined;
260 }
261
262 if (current === undefined) {
263 return yield* new WebappNotResolved({
264 enterpriseId,
265 webAppId: news.webAppId ?? output?.webAppId ?? displayName,
266 });
267 }
268
269 const webAppId =
270 current.webAppId ?? news.webAppId ?? output?.webAppId ?? "";
271 const desired = desiredBody({
272 webAppId,
273 title,
274 news,
275 current,
276 });
277 if (needsSync(current, desired)) {
278 current = yield* androidenterprise.updateWebapps({
279 enterpriseId,
280 webAppId,
281 body: desired,
282 });
283 }
284
285 return toAttrs(current, enterpriseId, env.project);
286 }),
287
288 delete: Effect.fn(function* ({ output }) {
289 if (!output.enterpriseId || !output.webAppId) return;
290 yield* androidenterprise
291 .deleteWebapps({
292 enterpriseId: output.enterpriseId,
293 webAppId: output.webAppId,
294 })
295 .pipe(
296 Effect.catchTag("NotFound", () => Effect.void),
297 Effect.catchTag("BadRequest", () => Effect.void),
298 Effect.catchTag("Conflict", () => Effect.void),
299 );
300 }),
301 });