Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit f897981

Browse files
chore: extract app access logic for reuse (#17724)
We are starting to add app links in many places in the UI, and to make it consistent, this PR extracts the most core logic into the modules/apps for reuse. Related to #17311
1 parent 2696926 commit f897981

File tree

10 files changed

+457
-397
lines changed

10 files changed

+457
-397
lines changed

site/src/modules/apps/apps.test.ts

+119
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import {
2+
MockWorkspace,
3+
MockWorkspaceAgent,
4+
MockWorkspaceApp,
5+
} from "testHelpers/entities";
6+
import { SESSION_TOKEN_PLACEHOLDER, getAppHref } from "./apps";
7+
8+
describe("getAppHref", () => {
9+
it("returns the URL without changes when external app has regular URL", () => {
10+
const externalApp = {
11+
...MockWorkspaceApp,
12+
external: true,
13+
url: "https://example.com",
14+
};
15+
const href = getAppHref(externalApp, {
16+
host: "*.apps-host.tld",
17+
path: "/path-base",
18+
agent: MockWorkspaceAgent,
19+
workspace: MockWorkspace,
20+
});
21+
expect(href).toBe(externalApp.url);
22+
});
23+
24+
it("returns the URL with the session token replaced when external app needs session token", () => {
25+
const externalApp = {
26+
...MockWorkspaceApp,
27+
external: true,
28+
url: `vscode://example.com?token=${SESSION_TOKEN_PLACEHOLDER}`,
29+
};
30+
const href = getAppHref(externalApp, {
31+
host: "*.apps-host.tld",
32+
path: "/path-base",
33+
agent: MockWorkspaceAgent,
34+
workspace: MockWorkspace,
35+
token: "user-session-token",
36+
});
37+
expect(href).toBe("vscode://example.com?token=user-session-token");
38+
});
39+
40+
it("doesn't return the URL with the session token replaced when using the HTTP protocol", () => {
41+
const externalApp = {
42+
...MockWorkspaceApp,
43+
external: true,
44+
url: `https://example.com?token=${SESSION_TOKEN_PLACEHOLDER}`,
45+
};
46+
const href = getAppHref(externalApp, {
47+
host: "*.apps-host.tld",
48+
path: "/path-base",
49+
agent: MockWorkspaceAgent,
50+
workspace: MockWorkspace,
51+
token: "user-session-token",
52+
});
53+
expect(href).toBe(externalApp.url);
54+
});
55+
56+
it("returns a path when app doesn't use a subdomain", () => {
57+
const app = {
58+
...MockWorkspaceApp,
59+
subdomain: false,
60+
};
61+
const href = getAppHref(app, {
62+
host: "*.apps-host.tld",
63+
agent: MockWorkspaceAgent,
64+
workspace: MockWorkspace,
65+
path: "/path-base",
66+
});
67+
expect(href).toBe(
68+
`/path-base/@${MockWorkspace.owner_name}/Test-Workspace.a-workspace-agent/apps/${app.slug}/`,
69+
);
70+
});
71+
72+
it("includes the command in the URL when app has a command", () => {
73+
const app = {
74+
...MockWorkspaceApp,
75+
command: "ls -la",
76+
};
77+
const href = getAppHref(app, {
78+
host: "*.apps-host.tld",
79+
agent: MockWorkspaceAgent,
80+
workspace: MockWorkspace,
81+
path: "",
82+
});
83+
expect(href).toBe(
84+
`/@${MockWorkspace.owner_name}/Test-Workspace.a-workspace-agent/terminal?command=ls%20-la`,
85+
);
86+
});
87+
88+
it("uses the subdomain when app has a subdomain", () => {
89+
const app = {
90+
...MockWorkspaceApp,
91+
subdomain: true,
92+
subdomain_name: "hellocoder",
93+
};
94+
const href = getAppHref(app, {
95+
host: "*.apps-host.tld",
96+
agent: MockWorkspaceAgent,
97+
workspace: MockWorkspace,
98+
path: "/path-base",
99+
});
100+
expect(href).toBe("http://hellocoder.apps-host.tld/");
101+
});
102+
103+
it("returns a path when app has a subdomain but no subdomain name", () => {
104+
const app = {
105+
...MockWorkspaceApp,
106+
subdomain: true,
107+
subdomain_name: undefined,
108+
};
109+
const href = getAppHref(app, {
110+
host: "*.apps-host.tld",
111+
agent: MockWorkspaceAgent,
112+
workspace: MockWorkspace,
113+
path: "/path-base",
114+
});
115+
expect(href).toBe(
116+
`/path-base/@${MockWorkspace.owner_name}/Test-Workspace.a-workspace-agent/apps/${app.slug}/`,
117+
);
118+
});
119+
});

site/src/modules/apps/apps.ts

+80-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,15 @@
1+
import type {
2+
Workspace,
3+
WorkspaceAgent,
4+
WorkspaceApp,
5+
} from "api/typesGenerated";
6+
7+
// This is a magic undocumented string that is replaced
8+
// with a brand-new session token from the backend.
9+
// This only exists for external URLs, and should only
10+
// be used internally, and is highly subject to break.
11+
export const SESSION_TOKEN_PLACEHOLDER = "$SESSION_TOKEN";
12+
113
type GetVSCodeHrefParams = {
214
owner: string;
315
workspace: string;
@@ -49,6 +61,73 @@ export const getTerminalHref = ({
4961
}/terminal?${params}`;
5062
};
5163

52-
export const openAppInNewWindow = (name: string, href: string) => {
64+
export const openAppInNewWindow = (href: string) => {
5365
window.open(href, "_blank", "width=900,height=600");
5466
};
67+
68+
export type GetAppHrefParams = {
69+
path: string;
70+
host: string;
71+
workspace: Workspace;
72+
agent: WorkspaceAgent;
73+
token?: string;
74+
};
75+
76+
export const getAppHref = (
77+
app: WorkspaceApp,
78+
{ path, token, workspace, agent, host }: GetAppHrefParams,
79+
): string => {
80+
if (isExternalApp(app)) {
81+
return needsSessionToken(app)
82+
? app.url.replaceAll(SESSION_TOKEN_PLACEHOLDER, token ?? "")
83+
: app.url;
84+
}
85+
86+
// The backend redirects if the trailing slash isn't included, so we add it
87+
// here to avoid extra roundtrips.
88+
let href = `${path}/@${workspace.owner_name}/${workspace.name}.${
89+
agent.name
90+
}/apps/${encodeURIComponent(app.slug)}/`;
91+
92+
if (app.command) {
93+
// Terminal links are relative. The terminal page knows how
94+
// to select the correct workspace proxy for the websocket
95+
// connection.
96+
href = `/@${workspace.owner_name}/${workspace.name}.${
97+
agent.name
98+
}/terminal?command=${encodeURIComponent(app.command)}`;
99+
}
100+
101+
if (host && app.subdomain && app.subdomain_name) {
102+
const baseUrl = `${window.location.protocol}//${host.replace(/\*/g, app.subdomain_name)}`;
103+
const url = new URL(baseUrl);
104+
url.pathname = "/";
105+
href = url.toString();
106+
}
107+
108+
return href;
109+
};
110+
111+
export const needsSessionToken = (app: WorkspaceApp) => {
112+
if (!isExternalApp(app)) {
113+
return false;
114+
}
115+
116+
// HTTP links should never need the session token, since Cookies
117+
// handle sharing it when you access the Coder Dashboard. We should
118+
// never be forwarding the bare session token to other domains!
119+
const isHttp = app.url.startsWith("http");
120+
const requiresSessionToken = app.url.includes(SESSION_TOKEN_PLACEHOLDER);
121+
return requiresSessionToken && !isHttp;
122+
};
123+
124+
type ExternalWorkspaceApp = WorkspaceApp & {
125+
external: true;
126+
url: string;
127+
};
128+
129+
export const isExternalApp = (
130+
app: WorkspaceApp,
131+
): app is ExternalWorkspaceApp => {
132+
return app.external && app.url !== undefined;
133+
};

site/src/modules/apps/useAppLink.ts

+75
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { apiKey } from "api/queries/users";
2+
import type {
3+
Workspace,
4+
WorkspaceAgent,
5+
WorkspaceApp,
6+
} from "api/typesGenerated";
7+
import { displayError } from "components/GlobalSnackbar/utils";
8+
import { useProxy } from "contexts/ProxyContext";
9+
import type React from "react";
10+
import { useQuery } from "react-query";
11+
import {
12+
getAppHref,
13+
isExternalApp,
14+
needsSessionToken,
15+
openAppInNewWindow,
16+
} from "./apps";
17+
18+
type UseAppLinkParams = {
19+
workspace: Workspace;
20+
agent: WorkspaceAgent;
21+
};
22+
23+
export const useAppLink = (
24+
app: WorkspaceApp,
25+
{ agent, workspace }: UseAppLinkParams,
26+
) => {
27+
const label = app.display_name ?? app.slug;
28+
const { proxy } = useProxy();
29+
const { data: apiKeyResponse } = useQuery({
30+
...apiKey(),
31+
enabled: isExternalApp(app) && needsSessionToken(app),
32+
});
33+
34+
const href = getAppHref(app, {
35+
agent,
36+
workspace,
37+
token: apiKeyResponse?.key ?? "",
38+
path: proxy.preferredPathAppURL,
39+
host: proxy.preferredWildcardHostname,
40+
});
41+
42+
const onClick = (e: React.MouseEvent) => {
43+
if (!e.currentTarget.getAttribute("href")) {
44+
return;
45+
}
46+
47+
if (app.external) {
48+
// When browser recognizes the protocol and is able to navigate to the app,
49+
// it will blur away, and will stop the timer. Otherwise,
50+
// an error message will be displayed.
51+
const openAppExternallyFailedTimeout = 500;
52+
const openAppExternallyFailed = setTimeout(() => {
53+
displayError(`${label} must be installed first.`);
54+
}, openAppExternallyFailedTimeout);
55+
window.addEventListener("blur", () => {
56+
clearTimeout(openAppExternallyFailed);
57+
});
58+
}
59+
60+
switch (app.open_in) {
61+
case "slim-window": {
62+
e.preventDefault();
63+
openAppInNewWindow(href);
64+
return;
65+
}
66+
}
67+
};
68+
69+
return {
70+
href,
71+
onClick,
72+
label,
73+
hasToken: !!apiKeyResponse?.key,
74+
};
75+
};

0 commit comments

Comments
 (0)