@sixb/desktop 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +83 -2
- package/dist/generate.d.ts +30 -0
- package/dist/generate.d.ts.map +1 -0
- package/dist/identity.d.ts +22 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +326 -0
- package/dist/index.js.map +14 -0
- package/dist/mac-icon.d.ts +7 -0
- package/dist/mac-icon.d.ts.map +1 -0
- package/dist/run.d.ts +3 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/shell/bridge.d.ts +16 -0
- package/dist/shell/bridge.d.ts.map +1 -0
- package/dist/shell/main.d.ts +2 -0
- package/dist/shell/main.d.ts.map +1 -0
- package/dist/shell/navigation.d.ts +39 -0
- package/dist/shell/navigation.d.ts.map +1 -0
- package/dist/shell/preload.d.ts +2 -0
- package/dist/shell/preload.d.ts.map +1 -0
- package/dist/shell/window.d.ts +48 -0
- package/dist/shell/window.d.ts.map +1 -0
- package/dist/tsconfig.json +5 -0
- package/package.json +57 -4
- package/src/generate.ts +133 -0
- package/src/identity.ts +83 -0
- package/src/index.ts +177 -0
- package/src/mac-icon.ts +81 -0
- package/src/packager.mjs +5 -0
- package/src/run.ts +11 -0
- package/src/shell/bridge.ts +16 -0
- package/src/shell/index.html +204 -0
- package/src/shell/main.ts +107 -0
- package/src/shell/navigation.ts +84 -0
- package/src/shell/preload.ts +44 -0
- package/src/shell/window.ts +85 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** The name a page finds the desktop app under, through `useDesktop` in `@sixb/client/hooks`. */
|
|
2
|
+
export const DESKTOP_BRIDGE_NAME = "sixbDesktop"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What the shell offers the page. `SixbDesktop` in `@sixb/client` is the page's view of this, and
|
|
6
|
+
* `tests/app.test.ts` holds the two together.
|
|
7
|
+
*
|
|
8
|
+
* The page is remote, and anything here is open to whatever runs in it. To offer more:
|
|
9
|
+
* 1. Add one narrow method here, calling `ipcRenderer.invoke`, never a general way to run a command
|
|
10
|
+
* or read a file. The page asks; the person decides, in a native dialog, what it gets.
|
|
11
|
+
* 2. Handle it in `main.ts`, refusing any sender that `isAppUrl` does not accept.
|
|
12
|
+
* 3. Add it to `SixbDesktop` as optional, because a page can outlive the shell it runs in.
|
|
13
|
+
*/
|
|
14
|
+
export function desktopBridge<const Platform extends NodeJS.Platform>(platform: Platform) {
|
|
15
|
+
return { platform }
|
|
16
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>{{name}}</title>
|
|
7
|
+
<style>
|
|
8
|
+
/* The colors mirror packages/ui/src/styles/globals.css, so the app replaces this page
|
|
9
|
+
without a change of tone. `WINDOW_BACKGROUND` in window.ts repeats --background. */
|
|
10
|
+
:root {
|
|
11
|
+
color-scheme: light dark;
|
|
12
|
+
--background: #fafafa;
|
|
13
|
+
--foreground: #101010;
|
|
14
|
+
--primary: #0a0a0a;
|
|
15
|
+
--primary-foreground: #ffffff;
|
|
16
|
+
--muted-foreground: #5f5f5f;
|
|
17
|
+
}
|
|
18
|
+
@media (prefers-color-scheme: dark) {
|
|
19
|
+
:root {
|
|
20
|
+
--background: #0a0a0a;
|
|
21
|
+
--foreground: #f4f4f4;
|
|
22
|
+
--primary: #f4f4f4;
|
|
23
|
+
--primary-foreground: #0a0a0a;
|
|
24
|
+
--muted-foreground: #8a8a8a;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
* {
|
|
28
|
+
box-sizing: border-box;
|
|
29
|
+
}
|
|
30
|
+
html,
|
|
31
|
+
body {
|
|
32
|
+
height: 100%;
|
|
33
|
+
}
|
|
34
|
+
body {
|
|
35
|
+
margin: 0;
|
|
36
|
+
/* Sits a little above dead-center. */
|
|
37
|
+
padding-bottom: 8vh;
|
|
38
|
+
display: grid;
|
|
39
|
+
place-items: center;
|
|
40
|
+
font-family: "SF Pro Text", "Segoe UI Variable Text", "Segoe UI", system-ui, sans-serif;
|
|
41
|
+
background: var(--background);
|
|
42
|
+
color: var(--foreground);
|
|
43
|
+
-webkit-font-smoothing: antialiased;
|
|
44
|
+
/* Nothing here is a document: the whole page moves the window, and no text selects. */
|
|
45
|
+
-webkit-app-region: drag;
|
|
46
|
+
user-select: none;
|
|
47
|
+
cursor: default;
|
|
48
|
+
}
|
|
49
|
+
main {
|
|
50
|
+
display: flex;
|
|
51
|
+
flex-direction: column;
|
|
52
|
+
align-items: center;
|
|
53
|
+
animation: enter 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
|
54
|
+
}
|
|
55
|
+
.mark {
|
|
56
|
+
display: grid;
|
|
57
|
+
place-items: center;
|
|
58
|
+
width: 3.5rem;
|
|
59
|
+
height: 3.5rem;
|
|
60
|
+
border-radius: 0.875rem;
|
|
61
|
+
background: var(--primary);
|
|
62
|
+
color: var(--primary-foreground);
|
|
63
|
+
font-size: 1.5rem;
|
|
64
|
+
font-weight: 600;
|
|
65
|
+
letter-spacing: -0.02em;
|
|
66
|
+
box-shadow:
|
|
67
|
+
0 1px 1px rgb(0 0 0 / 0.06),
|
|
68
|
+
0 12px 28px -12px rgb(0 0 0 / 0.35);
|
|
69
|
+
}
|
|
70
|
+
@media (prefers-color-scheme: dark) {
|
|
71
|
+
/* A shadow on black is only a smudge around the mark. */
|
|
72
|
+
.mark {
|
|
73
|
+
box-shadow: none;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/* The app's own icon brings its own shape. */
|
|
77
|
+
img.mark {
|
|
78
|
+
width: 4rem;
|
|
79
|
+
height: 4rem;
|
|
80
|
+
border-radius: 0;
|
|
81
|
+
background: none;
|
|
82
|
+
box-shadow: none;
|
|
83
|
+
}
|
|
84
|
+
h1 {
|
|
85
|
+
margin: 1.125rem 0 0;
|
|
86
|
+
font-size: 1.0625rem;
|
|
87
|
+
font-weight: 600;
|
|
88
|
+
letter-spacing: -0.01em;
|
|
89
|
+
}
|
|
90
|
+
/* One fixed height for both states, so the name does not move when one replaces the other. */
|
|
91
|
+
.status {
|
|
92
|
+
display: flex;
|
|
93
|
+
flex-direction: column;
|
|
94
|
+
align-items: center;
|
|
95
|
+
height: 4.5rem;
|
|
96
|
+
margin-top: 1.25rem;
|
|
97
|
+
}
|
|
98
|
+
p {
|
|
99
|
+
margin: 0;
|
|
100
|
+
font-size: 0.8125rem;
|
|
101
|
+
color: var(--muted-foreground);
|
|
102
|
+
}
|
|
103
|
+
.progress {
|
|
104
|
+
position: relative;
|
|
105
|
+
width: 5rem;
|
|
106
|
+
height: 3px;
|
|
107
|
+
border-radius: 3px;
|
|
108
|
+
overflow: hidden;
|
|
109
|
+
background: color-mix(in srgb, var(--foreground) 10%, transparent);
|
|
110
|
+
}
|
|
111
|
+
.progress::after {
|
|
112
|
+
content: "";
|
|
113
|
+
position: absolute;
|
|
114
|
+
inset: 0;
|
|
115
|
+
width: 40%;
|
|
116
|
+
border-radius: inherit;
|
|
117
|
+
background: var(--foreground);
|
|
118
|
+
animation: slide 1.3s cubic-bezier(0.65, 0, 0.35, 1) infinite;
|
|
119
|
+
}
|
|
120
|
+
/* A quick load never gets as far as words; a slow one says what it is waiting for. */
|
|
121
|
+
#connecting p {
|
|
122
|
+
margin-top: 1rem;
|
|
123
|
+
opacity: 0;
|
|
124
|
+
animation: appear 0.4s ease 2.5s forwards;
|
|
125
|
+
}
|
|
126
|
+
#unreachable a {
|
|
127
|
+
display: inline-flex;
|
|
128
|
+
align-items: center;
|
|
129
|
+
height: 2rem;
|
|
130
|
+
margin-top: 0.875rem;
|
|
131
|
+
padding: 0 0.875rem;
|
|
132
|
+
border-radius: 0.375rem;
|
|
133
|
+
background: var(--primary);
|
|
134
|
+
color: var(--primary-foreground);
|
|
135
|
+
font-size: 0.8125rem;
|
|
136
|
+
font-weight: 500;
|
|
137
|
+
text-decoration: none;
|
|
138
|
+
cursor: pointer;
|
|
139
|
+
-webkit-app-region: no-drag;
|
|
140
|
+
}
|
|
141
|
+
#unreachable a:hover {
|
|
142
|
+
opacity: 0.9;
|
|
143
|
+
}
|
|
144
|
+
#unreachable a:focus-visible {
|
|
145
|
+
outline: 2px solid var(--primary);
|
|
146
|
+
outline-offset: 2px;
|
|
147
|
+
}
|
|
148
|
+
/* The shell reopens this page at #unreachable when the app's server does not answer. It is
|
|
149
|
+
the same page a moment later, so it does not make its entrance twice. */
|
|
150
|
+
#unreachable:not(:target),
|
|
151
|
+
:root:has(#unreachable:target) #connecting {
|
|
152
|
+
display: none;
|
|
153
|
+
}
|
|
154
|
+
:root:has(#unreachable:target) main {
|
|
155
|
+
animation: none;
|
|
156
|
+
}
|
|
157
|
+
@keyframes enter {
|
|
158
|
+
from {
|
|
159
|
+
opacity: 0;
|
|
160
|
+
transform: translateY(4px);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
@keyframes appear {
|
|
164
|
+
to {
|
|
165
|
+
opacity: 1;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
@keyframes slide {
|
|
169
|
+
from {
|
|
170
|
+
transform: translateX(-100%);
|
|
171
|
+
}
|
|
172
|
+
to {
|
|
173
|
+
transform: translateX(250%);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
@media (prefers-reduced-motion: reduce) {
|
|
177
|
+
main,
|
|
178
|
+
#connecting p {
|
|
179
|
+
animation: none;
|
|
180
|
+
opacity: 1;
|
|
181
|
+
}
|
|
182
|
+
.progress::after {
|
|
183
|
+
animation: none;
|
|
184
|
+
width: 100%;
|
|
185
|
+
opacity: 0.35;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
</style>
|
|
189
|
+
</head>
|
|
190
|
+
<body>
|
|
191
|
+
<main>
|
|
192
|
+
{{mark}}
|
|
193
|
+
<h1>{{name}}</h1>
|
|
194
|
+
<div class="status" id="connecting">
|
|
195
|
+
<div class="progress"></div>
|
|
196
|
+
<p role="status">Connecting to {{host}}…</p>
|
|
197
|
+
</div>
|
|
198
|
+
<div class="status" id="unreachable">
|
|
199
|
+
<p>Can’t reach {{host}}</p>
|
|
200
|
+
<a href="{{appUrl}}">Try again</a>
|
|
201
|
+
</div>
|
|
202
|
+
</main>
|
|
203
|
+
</body>
|
|
204
|
+
</html>
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// The desktop shell's main process. `writeDesktopApp` compiles this file into the generated
|
|
2
|
+
// Electron app; nothing imports it.
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
import { app, BrowserWindow, type Event, nativeTheme, session, shell } from "electron"
|
|
6
|
+
import { createNavigationState, isWebUrl, nextNavigationStep, type ShellConfig } from "./navigation"
|
|
7
|
+
import {
|
|
8
|
+
HIDDEN_TITLE_BAR_CSS,
|
|
9
|
+
hasHiddenTitleBar,
|
|
10
|
+
isAppUrl,
|
|
11
|
+
mayNavigate,
|
|
12
|
+
pageWebPreferences,
|
|
13
|
+
WINDOW_BACKGROUND,
|
|
14
|
+
} from "./window"
|
|
15
|
+
|
|
16
|
+
// Chromium's code for a load that was replaced by another one, which is not a failure.
|
|
17
|
+
const ERR_ABORTED = -3
|
|
18
|
+
|
|
19
|
+
// Not `__dirname`: the bundler replaces it with this source file's directory at compile time,
|
|
20
|
+
// which is not where the compiled app lives.
|
|
21
|
+
const appDir = app.getAppPath()
|
|
22
|
+
const config = JSON.parse(readFileSync(join(appDir, "shell.json"), "utf-8")) as ShellConfig
|
|
23
|
+
const loadingPage = join(appDir, "index.html")
|
|
24
|
+
const icon = existsSync(join(appDir, "icon.png")) ? join(appDir, "icon.png") : undefined
|
|
25
|
+
const inset = hasHiddenTitleBar(process.platform)
|
|
26
|
+
const state = createNavigationState()
|
|
27
|
+
|
|
28
|
+
app.setName(config.name)
|
|
29
|
+
|
|
30
|
+
function createWindow(): void {
|
|
31
|
+
const win = new BrowserWindow({
|
|
32
|
+
title: config.name,
|
|
33
|
+
width: 1280,
|
|
34
|
+
height: 832,
|
|
35
|
+
titleBarStyle: inset ? "hiddenInset" : "default",
|
|
36
|
+
icon,
|
|
37
|
+
// Opens once the loading page has painted, over a background of that page's color.
|
|
38
|
+
show: false,
|
|
39
|
+
backgroundColor: nativeTheme.shouldUseDarkColors
|
|
40
|
+
? WINDOW_BACKGROUND.dark
|
|
41
|
+
: WINDOW_BACKGROUND.light,
|
|
42
|
+
webPreferences: pageWebPreferences(join(appDir, "preload.js")),
|
|
43
|
+
})
|
|
44
|
+
const contents = win.webContents
|
|
45
|
+
win.once("ready-to-show", () => win.show())
|
|
46
|
+
|
|
47
|
+
// A load that fails or is replaced also rejects; `did-fail-load` below is what answers it.
|
|
48
|
+
const load = (url: string) => void win.loadURL(url).catch(() => {})
|
|
49
|
+
|
|
50
|
+
function onNavigation(url: string, event?: Event): void {
|
|
51
|
+
const step = nextNavigationStep(config, state, url)
|
|
52
|
+
if (step?.kind === "load") {
|
|
53
|
+
event?.preventDefault()
|
|
54
|
+
load(step.url)
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
if (step?.kind === "open-external") void shell.openExternal(step.url)
|
|
58
|
+
if (event && !mayNavigate(config, url)) {
|
|
59
|
+
event.preventDefault()
|
|
60
|
+
if (isWebUrl(url)) void shell.openExternal(url)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
contents.on("will-navigate", (event, url) => onNavigation(url, event))
|
|
65
|
+
contents.on("will-redirect", (event, url) => onNavigation(url, event))
|
|
66
|
+
contents.on("did-navigate", (_event, url) => onNavigation(url))
|
|
67
|
+
|
|
68
|
+
// Links that ask for a new window belong in the user's browser, not in a second shell window.
|
|
69
|
+
contents.setWindowOpenHandler(({ url }) => {
|
|
70
|
+
if (isWebUrl(url)) void shell.openExternal(url)
|
|
71
|
+
return { action: "deny" }
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
if (inset) {
|
|
75
|
+
contents.on("dom-ready", () => void contents.insertCSS(HIDDEN_TITLE_BAR_CSS))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The loading page stays on screen until the app's first response replaces it. If the app
|
|
79
|
+
// cannot be reached, it comes back saying so.
|
|
80
|
+
contents.on("did-fail-load", (_event, errorCode, _description, _url, isMainFrame) => {
|
|
81
|
+
if (isMainFrame && errorCode !== ERR_ABORTED) {
|
|
82
|
+
void win.loadFile(loadingPage, { hash: "unreachable" })
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
void win.loadFile(loadingPage).then(() => load(config.appUrl))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
void app.whenReady().then(() => {
|
|
89
|
+
// A packaged app carries its icon in its bundle. Run from source, it would show Electron's.
|
|
90
|
+
if (icon && !app.isPackaged) app.dock?.setIcon(icon)
|
|
91
|
+
|
|
92
|
+
// Judged by the frame that asks, which may be another site's embedded in the app.
|
|
93
|
+
session.defaultSession.setPermissionRequestHandler(
|
|
94
|
+
(_contents, _permission, callback, details) => {
|
|
95
|
+
callback(isAppUrl(config, details.requestingUrl))
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
createWindow()
|
|
100
|
+
app.on("activate", () => {
|
|
101
|
+
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
app.on("window-all-closed", () => {
|
|
106
|
+
if (process.platform !== "darwin") app.quit()
|
|
107
|
+
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/** What the shell was built to open. Written into the app by `writeDesktopApp`. */
|
|
2
|
+
export interface ShellConfig {
|
|
3
|
+
/** Shown in the title bar, and to the user approving this window's sign-in. */
|
|
4
|
+
readonly name: string
|
|
5
|
+
/** The app, such as `https://app.example.com/`. */
|
|
6
|
+
readonly appUrl: string
|
|
7
|
+
readonly apiOrigin: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface NavigationState {
|
|
11
|
+
/** Approval pages already opened, so a waiting page that reloads opens its browser tab once. */
|
|
12
|
+
readonly openedCodes: Set<string>
|
|
13
|
+
lastSignIn: { readonly url: string; readonly at: number } | null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type NavigationStep =
|
|
17
|
+
/** Send the window somewhere else instead. */
|
|
18
|
+
| { readonly kind: "load"; readonly url: string }
|
|
19
|
+
/** Hand a URL to the system browser. */
|
|
20
|
+
| { readonly kind: "open-external"; readonly url: string }
|
|
21
|
+
|
|
22
|
+
// One sign-in redirect can be reported more than once (as it starts, and again if the server
|
|
23
|
+
// redirects to it). Answering each report would start a second sign-in request and orphan the first.
|
|
24
|
+
const SIGN_IN_REPEAT_WINDOW_MS = 2_000
|
|
25
|
+
|
|
26
|
+
export function createNavigationState(): NavigationState {
|
|
27
|
+
return { openedCodes: new Set(), lastSignIn: null }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Decides what the shell does about a navigation of its window.
|
|
32
|
+
*
|
|
33
|
+
* The window is a browser the user cannot finish a sign-in in: a magic link opens in their own
|
|
34
|
+
* browser, and identity providers turn embedded ones away. So the app's ordinary sign-in redirect
|
|
35
|
+
* becomes a request to be approved from the system browser, and the approval page opens there.
|
|
36
|
+
*/
|
|
37
|
+
export function nextNavigationStep(
|
|
38
|
+
config: ShellConfig,
|
|
39
|
+
state: NavigationState,
|
|
40
|
+
navigatedTo: string,
|
|
41
|
+
now: number = Date.now()
|
|
42
|
+
): NavigationStep | null {
|
|
43
|
+
let url: URL
|
|
44
|
+
try {
|
|
45
|
+
url = new URL(navigatedTo)
|
|
46
|
+
} catch {
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
if (url.origin !== new URL(config.apiOrigin).origin) return null
|
|
50
|
+
|
|
51
|
+
if (url.pathname === "/auth/sign-in") {
|
|
52
|
+
const last = state.lastSignIn
|
|
53
|
+
if (last && last.url === url.href && now - last.at < SIGN_IN_REPEAT_WINDOW_MS) return null
|
|
54
|
+
state.lastSignIn = { url: url.href, at: now }
|
|
55
|
+
|
|
56
|
+
const waiting = new URL("/auth/sign-in/device", url.origin)
|
|
57
|
+
const returnTo = url.searchParams.get("returnTo")
|
|
58
|
+
if (returnTo) waiting.searchParams.set("returnTo", returnTo)
|
|
59
|
+
waiting.searchParams.set("client", config.name)
|
|
60
|
+
return { kind: "load", url: waiting.href }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (url.pathname === "/auth/sign-in/device") {
|
|
64
|
+
const userCode = url.searchParams.get("user_code")
|
|
65
|
+
if (!userCode || state.openedCodes.has(userCode)) return null
|
|
66
|
+
state.openedCodes.add(userCode)
|
|
67
|
+
|
|
68
|
+
const approval = new URL("/auth/device", url.origin)
|
|
69
|
+
approval.searchParams.set("user_code", userCode)
|
|
70
|
+
return { kind: "open-external", url: approval.href }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Only web addresses leave for the system browser; a page cannot make the shell launch programs. */
|
|
77
|
+
export function isWebUrl(value: string): boolean {
|
|
78
|
+
try {
|
|
79
|
+
const { protocol } = new URL(value)
|
|
80
|
+
return protocol === "https:" || protocol === "http:"
|
|
81
|
+
} catch {
|
|
82
|
+
return false
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Runs in every page the window shows, before the page's own scripts, in a world of its own. Electron
|
|
2
|
+
// sandboxes it: of what it imports, only `electron` may remain once compiled.
|
|
3
|
+
import { contextBridge } from "electron"
|
|
4
|
+
import { DESKTOP_BRIDGE_NAME, desktopBridge } from "./bridge"
|
|
5
|
+
import { CONTROLS_HEIGHT_PX, hasHiddenTitleBar } from "./window"
|
|
6
|
+
|
|
7
|
+
contextBridge.exposeInMainWorld(DESKTOP_BRIDGE_NAME, desktopBridge(process.platform))
|
|
8
|
+
|
|
9
|
+
if (hasHiddenTitleBar(process.platform)) installHiddenTitleBar()
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Marks `<html>` the moment the element exists, so an app can read the mark on its first render,
|
|
13
|
+
* and adds the drag handle once there is a body to hold it.
|
|
14
|
+
*/
|
|
15
|
+
function installHiddenTitleBar(): void {
|
|
16
|
+
const mark = () => {
|
|
17
|
+
const root = document.documentElement
|
|
18
|
+
if (!root) return false
|
|
19
|
+
root.dataset.sixbDesktop = "inset"
|
|
20
|
+
root.style.setProperty("--sixb-desktop-controls-height", `${CONTROLS_HEIGHT_PX}px`)
|
|
21
|
+
return true
|
|
22
|
+
}
|
|
23
|
+
if (!mark()) {
|
|
24
|
+
const observer = new MutationObserver(() => {
|
|
25
|
+
if (mark()) observer.disconnect()
|
|
26
|
+
})
|
|
27
|
+
observer.observe(document, { childList: true })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const addHandle = () => {
|
|
31
|
+
if (!document.body || document.getElementById("sixb-desktop-drag")) return
|
|
32
|
+
const handle = document.createElement("div")
|
|
33
|
+
handle.id = "sixb-desktop-drag"
|
|
34
|
+
handle.setAttribute("aria-hidden", "true")
|
|
35
|
+
// First in the body, not last: the draggable area is built in document order, so a control
|
|
36
|
+
// is only cut out of it if it comes after the handle.
|
|
37
|
+
document.body.prepend(handle)
|
|
38
|
+
}
|
|
39
|
+
if (document.readyState === "loading") {
|
|
40
|
+
document.addEventListener("DOMContentLoaded", addHandle, { once: true })
|
|
41
|
+
} else {
|
|
42
|
+
addHandle()
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { ShellConfig } from "./navigation"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether the window itself may show `url`: only the app and the API that serves its sign-in
|
|
5
|
+
* pages. Anything else a page links to opens in the user's browser instead.
|
|
6
|
+
*/
|
|
7
|
+
export function mayNavigate(config: ShellConfig, url: string): boolean {
|
|
8
|
+
try {
|
|
9
|
+
const { origin } = new URL(url)
|
|
10
|
+
return origin === new URL(config.appUrl).origin || origin === new URL(config.apiOrigin).origin
|
|
11
|
+
} catch {
|
|
12
|
+
return false
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Whether `url` is on the app's own origin. Camera, location and the like are the app's to ask for,
|
|
18
|
+
* and not a frame it embeds from elsewhere.
|
|
19
|
+
*/
|
|
20
|
+
export function isAppUrl(config: ShellConfig, url: string): boolean {
|
|
21
|
+
try {
|
|
22
|
+
return new URL(url).origin === new URL(config.appUrl).origin
|
|
23
|
+
} catch {
|
|
24
|
+
return false
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* What the window paints before a page has: the loading page's own background, so the window
|
|
30
|
+
* opens, and passes from that page to the app, without a flash of white.
|
|
31
|
+
*/
|
|
32
|
+
export const WINDOW_BACKGROUND = { light: "#fafafa", dark: "#0a0a0a" } as const
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* What the window's page runs with. The page is remote: it gets no Node and a world apart from the
|
|
36
|
+
* preload's, on every platform, and reaches the shell only through what the preload exposes.
|
|
37
|
+
*/
|
|
38
|
+
export function pageWebPreferences(preload: string) {
|
|
39
|
+
return { sandbox: true, contextIsolation: true, nodeIntegration: false, preload }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Only macOS draws window controls over the page; elsewhere the title bar stays. */
|
|
43
|
+
export function hasHiddenTitleBar(platform: NodeJS.Platform): boolean {
|
|
44
|
+
return platform === "darwin"
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The band at the top of a window without a title bar where macOS draws the window controls. */
|
|
48
|
+
export const CONTROLS_HEIGHT_PX = 36
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Styles for a window without a title bar, inserted into every page it shows.
|
|
52
|
+
*
|
|
53
|
+
* The page reaches the top edge, under the window controls, so two things a title bar did have
|
|
54
|
+
* to come from the page: somewhere to grab the window, and room for the controls.
|
|
55
|
+
* - `#sixb-desktop-drag` (added by the preload) is the drag handle: a strip across the top, above
|
|
56
|
+
* the page. It ignores the pointer, and every control is cut out of the draggable area, so
|
|
57
|
+
* buttons and links under the strip still take their clicks and the space between them moves
|
|
58
|
+
* the window. Something clickable that is none of the things listed here needs
|
|
59
|
+
* `-webkit-app-region: no-drag` of its own.
|
|
60
|
+
* - The shared sidebar is moved down so most apps need nothing. Other layouts can use
|
|
61
|
+
* `html[data-sixb-desktop]` and `--sixb-desktop-controls-height`, which the preload sets.
|
|
62
|
+
*/
|
|
63
|
+
export const HIDDEN_TITLE_BAR_CSS = [
|
|
64
|
+
`#sixb-desktop-drag{position:fixed;top:0;left:0;right:0;height:${CONTROLS_HEIGHT_PX}px;`,
|
|
65
|
+
"z-index:2147483647;pointer-events:none;-webkit-app-region:drag}",
|
|
66
|
+
`${[
|
|
67
|
+
"a",
|
|
68
|
+
"button",
|
|
69
|
+
"input",
|
|
70
|
+
"select",
|
|
71
|
+
"textarea",
|
|
72
|
+
"summary",
|
|
73
|
+
"label",
|
|
74
|
+
"[role=button]",
|
|
75
|
+
"[role=link]",
|
|
76
|
+
"[role=tab]",
|
|
77
|
+
"[role=menuitem]",
|
|
78
|
+
"[role=switch]",
|
|
79
|
+
"[role=checkbox]",
|
|
80
|
+
"[role=combobox]",
|
|
81
|
+
"[contenteditable]",
|
|
82
|
+
"[tabindex]:not([tabindex='-1'])",
|
|
83
|
+
].join(",")}{-webkit-app-region:no-drag}`,
|
|
84
|
+
`[data-slot="sidebar-inner"]{padding-top:calc(${CONTROLS_HEIGHT_PX}px - 0.5rem)}`,
|
|
85
|
+
].join("")
|