1import * as androidmanagement from "@distilled.cloud/gcp/androidmanagement_v1";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { createPhysicalName } from "../../PhysicalName.ts";
5
6export const ENTERPRISE_PREFIX = "enterprises/";
7export const MAX_DISPLAY_NAME_LENGTH = 100;
8export const MAX_WEB_APP_TITLE_LENGTH = 100;
9export const MAX_ADDITIONAL_DATA_LENGTH = 1024;
10export const DEFAULT_DURATION = "315360000s";
11export const DEFAULT_DISPLAY_MODE = "STANDALONE";
12export const DEFAULT_START_URL = "https://example.com/";
13
14export const DEFAULT_WEB_APP_ICON: androidmanagement.WebAppIcon = {
15 imageData:
16 "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
17};
18
19export const lastSegment = (value: string) => {
20 const trimmed = value.replace(/\/+$/, "");
21 const parts = trimmed.split("/");
22 return parts[parts.length - 1] || trimmed;
23};
24
25export const parentOf = (name: string) => {
26 const parts = name.split("/").filter((part) => part.length > 0);
27 return parts.slice(0, -2).join("/");
28};
29
30export const normalizeName = (value: string) => value.replace(/\/+$/, "");
31
32export const toEnterpriseName = (value: string) => {
33 const trimmed = normalizeName(value);
34 if (trimmed.length === 0) return "";
35 if (trimmed.startsWith(ENTERPRISE_PREFIX)) return trimmed;
36 return `${ENTERPRISE_PREFIX}${trimmed}`;
37};
38
39export const toEnrollmentTokenName = (parent: string, tokenId?: string) => {
40 if (tokenId !== undefined && tokenId.includes("/enrollmentTokens/")) {
41 return normalizeName(tokenId);
42 }
43 if (tokenId !== undefined && tokenId.length > 0 && parent.length > 0) {
44 return `${toEnterpriseName(parent)}/enrollmentTokens/${lastSegment(tokenId)}`;
45 }
46 return "";
47};
48
49export const toWebAppName = (parent: string, webAppId?: string) => {
50 if (webAppId !== undefined && webAppId.includes("/webApps/")) {
51 return normalizeName(webAppId);
52 }
53 if (webAppId !== undefined && webAppId.length > 0 && parent.length > 0) {
54 return `${toEnterpriseName(parent)}/webApps/${lastSegment(webAppId)}`;
55 }
56 return "";
57};
58
59export const sameText = (left: string | undefined, right: string | undefined) =>
60 (left ?? "") === (right ?? "");
61
62export const sameBoolean = (
63 left: boolean | undefined,
64 right: boolean | undefined,
65) => (left ?? false) === (right ?? false);
66
67export const jsonEqual = (left: unknown, right: unknown) =>
68 JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
69
70export const sameStringList = (
71 left: readonly string[] | undefined,
72 right: readonly string[] | undefined,
73) =>
74 jsonEqual(
75 [...(left ?? [])].slice().sort(),
76 [...(right ?? [])].slice().sort(),
77 );
78
79export const updateMaskOf = (...fields: Array<string | undefined>) =>
80 fields.filter((field): field is string => field !== undefined).join(",");
81
82export const replaceOnIdentity = (input: {
83 previousId?: string;
84 nextId?: string;
85 previousParent?: string;
86 nextParent?: string;
87 extra?: boolean;
88}) => {
89 if (input.extra === true) {
90 return { action: "replace" as const, deleteFirst: false };
91 }
92 if (
93 input.previousId !== undefined &&
94 input.nextId !== undefined &&
95 input.previousId !== input.nextId
96 ) {
97 return { action: "replace" as const, deleteFirst: false };
98 }
99 if (
100 input.previousParent !== undefined &&
101 input.nextParent !== undefined &&
102 toEnterpriseName(input.previousParent) !==
103 toEnterpriseName(input.nextParent)
104 ) {
105 return { action: "replace" as const, deleteFirst: true };
106 }
107 return undefined;
108};
109
110export const toDisplayName = (
111 id: string,
112 requested: string | undefined,
113 existing: string | undefined,
114 maxLength = MAX_DISPLAY_NAME_LENGTH,
115) =>
116 Effect.gen(function* () {
117 if (requested !== undefined && requested.length > 0) {
118 return requested.slice(0, maxLength);
119 }
120 if (existing !== undefined && existing.length > 0) {
121 return existing.slice(0, maxLength);
122 }
123 return yield* createPhysicalName({
124 id,
125 maxLength: Math.min(40, maxLength),
126 lowercase: true,
127 });
128 });
129
130const emptyList = <A>() => Effect.succeed([] as A[]);
131
132export const collectPages = <Page, A, E, R>(
133 pages: Stream.Stream<Page, E, R>,
134 items: (page: Page) => readonly A[] | undefined,
135) =>
136 pages.pipe(
137 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
138 Stream.runCollect,
139 Effect.map((chunk) => Array.from(chunk)),
140 );
141
142export const getEnterprise = (name: string) =>
143 name.length === 0
144 ? Effect.succeed(undefined)
145 : androidmanagement
146 .getEnterprises({ name: toEnterpriseName(name) })
147 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
148
149export const getEnrollmentToken = (name: string) =>
150 name.length === 0
151 ? Effect.succeed(undefined)
152 : androidmanagement
153 .getEnterprisesEnrollmentTokens({ name })
154 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
155
156export const getWebApp = (name: string) =>
157 name.length === 0
158 ? Effect.succeed(undefined)
159 : androidmanagement
160 .getEnterprisesWebApps({ name })
161 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
162
163export const listEnterprisesAt = (projectId: string) =>
164 projectId.length === 0
165 ? emptyList<androidmanagement.Enterprise>()
166 : collectPages(
167 androidmanagement.listEnterprises.pages({
168 projectId,
169 view: "BASIC",
170 pageSize: 100,
171 }),
172 (page) => page.enterprises,
173 ).pipe(
174 // A missing parent has no children.
175 Effect.catchTag("NotFound", () =>
176 emptyList<androidmanagement.Enterprise>(),
177 ),
178 );
179
180export const listWebAppsAt = (parent: string) =>
181 parent.length === 0
182 ? emptyList<androidmanagement.WebApp>()
183 : collectPages(
184 androidmanagement.listEnterprisesWebApps.pages({
185 parent: toEnterpriseName(parent),
186 pageSize: 100,
187 }),
188 (page) => page.webApps,
189 ).pipe(
190 // A missing parent has no children.
191 Effect.catchTag("NotFound", () =>
192 emptyList<androidmanagement.WebApp>(),
193 ),
194 );
195
196const hydrateEnterprise = (enterprise: androidmanagement.Enterprise) =>
197 Effect.gen(function* () {
198 if (
199 enterprise.enterpriseDisplayName !== undefined &&
200 enterprise.enterpriseDisplayName.length > 0
201 ) {
202 return enterprise;
203 }
204 const name = enterprise.name ?? "";
205 if (name.length === 0) return enterprise;
206 return (yield* getEnterprise(name)) ?? enterprise;
207 });
208
209/** Find an enterprise of `projectId` by its display name. */
210export const findEnterpriseByDisplayName = (
211 projectId: string,
212 displayName: string,
213) =>
214 Effect.gen(function* () {
215 const listed = yield* listEnterprisesAt(projectId);
216 for (const enterprise of listed) {
217 const hydrated = yield* hydrateEnterprise(enterprise);
218 if (hydrated.enterpriseDisplayName === displayName) return hydrated;
219 }
220 return undefined;
221 });
222
223/** Find a web app of `parent` by its title. */
224export const findWebAppByTitle = (parent: string, title: string) =>
225 listWebAppsAt(parent).pipe(
226 Effect.map((apps) => apps.find((app) => app.title === title)),
227 );
228
229export const defaultWebAppIcons = (
230 icons: readonly androidmanagement.WebAppIcon[] | undefined,
231): androidmanagement.WebAppIcon[] =>
232 icons && icons.length > 0 ? [...icons] : [DEFAULT_WEB_APP_ICON];