@solidrt/core 0.0.61 → 0.0.62
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/agents/performance.md +35 -2
- package/docs/index.md +13 -0
- package/package.json +2 -2
- package/src/environment.ts +27 -0
- package/src/index.ts +1 -1
- package/src/runtime-modules.d.ts +23 -0
- package/src/text-input.ts +99 -12
- package/src/window.ts +32 -1
package/agents/performance.md
CHANGED
|
@@ -172,8 +172,13 @@ Rules, in order of leverage:
|
|
|
172
172
|
every frame with prepareText + layoutNextLine (a shape that breathes, an
|
|
173
173
|
obstacle that moves, an editor on every keystroke) is cheap - the lines'
|
|
174
174
|
d-texts hit the same cache - while changing a text's font, size or
|
|
175
|
-
weight re-shapes it.
|
|
176
|
-
|
|
175
|
+
weight re-shapes it. Painting costs one paragraph draw per line run of
|
|
176
|
+
same-styled words (the joined line goes through the same cache, so a
|
|
177
|
+
line composition seen before is a lookup): a paragraph draws as its
|
|
178
|
+
lines, not its words, and a re-breaking one shapes only the lines whose
|
|
179
|
+
breaks moved. A justified line or a per-word style change draws per
|
|
180
|
+
word. Animate text with transforms and paint (rule 5), or by
|
|
181
|
+
re-breaking; not by resizing it per frame.
|
|
177
182
|
|
|
178
183
|
## Isolates: heavy work off the JS thread
|
|
179
184
|
|
|
@@ -235,6 +240,34 @@ not infer it from the desktop number.
|
|
|
235
240
|
content-independent floor: if a trivial scene and a heavy one present at
|
|
236
241
|
nearly the same rate, you are compositor-bound and tuning the scene is
|
|
237
242
|
wasted effort.
|
|
243
|
+
- **Some paints cost a frame each on a tiled mobile GPU.** Measured on a
|
|
244
|
+
mid-range 2020 tablet (Adreno 610, Impeller GLES) with ten panes sliding
|
|
245
|
+
and resizing on a layout transition, each row by subtraction from the
|
|
246
|
+
compositor's own present record (`srt android --census`), against a
|
|
247
|
+
frame that ran at 60 fps with a 13 ms GPU span:
|
|
248
|
+
- a 3-stop linear-gradient fill per pane: +15 ms, 20 fps. A gradient is
|
|
249
|
+
shaded per pixel per frame; the same tint as a flat `d-rect` costs
|
|
250
|
+
nothing, and even a once-rendered 32x32 texture stretched over the pane
|
|
251
|
+
costs +5 ms over the flat fill.
|
|
252
|
+
- a rounded clip (`overflow="hidden"` with `clipRadius`) on a box whose
|
|
253
|
+
size is in flight: ~8 ms for one pane, ~13 ms for ten, most of it when
|
|
254
|
+
an image is under the clip. A static rounded clip is nearly free. Round
|
|
255
|
+
the paint instead (`radius` on `d-rect`, `d-texture` and `<texture>`)
|
|
256
|
+
and keep the clip rectangular.
|
|
257
|
+
- four 16 px draws per pane with `destination-out` and `destination-over`
|
|
258
|
+
blends: +16 ms. Any blend but source-over leaves the fast path,
|
|
259
|
+
whatever the size.
|
|
260
|
+
- seventy small source-over texture draws in place of ten pane-sized
|
|
261
|
+
ones: +10 ms. Draw count costs on its own.
|
|
262
|
+
- five 230-character paragraphs painted per word, 180 paragraph draws:
|
|
263
|
+
20 fps; painted per line run, 30 draws (rule 10, what the runtime does
|
|
264
|
+
now): 60 fps.
|
|
265
|
+
- a laid-out `TextInput` inside a resizing box re-checks its breaks and
|
|
266
|
+
scroll against the box per frame, ~0.2 ms per field on that tablet;
|
|
267
|
+
a fixed box, or a paragraph while not editing, costs nothing per frame.
|
|
268
|
+
`get_stats`' `paintOps` (draws, paragraphs, clips, roundedClips,
|
|
269
|
+
saveLayers, blends, gradients) says which of these a frame carries, per
|
|
270
|
+
rebuild and for the window's worst frame.
|
|
238
271
|
- **Per-frame writes are gated on the raster thread, not on vsync**, so a pass
|
|
239
272
|
that costs more than a refresh period does not silently pile up. If
|
|
240
273
|
`rasterQueue` climbs across queries while fps drops the raster thread is
|
package/docs/index.md
CHANGED
|
@@ -164,6 +164,19 @@ launch the user started. Whether to load what `onSuspend` saved is the app's
|
|
|
164
164
|
call. `env.visibility` is only whether anything is on screen, for pausing
|
|
165
165
|
animation and audio, not the persistence moment.
|
|
166
166
|
|
|
167
|
+
A link from outside (a custom scheme link the OS routed to the app, or one
|
|
168
|
+
a dev tool sent) arrives raw: `env.launchLink` is the one this run of the
|
|
169
|
+
app was started with (null without; a hot reload starts at the location
|
|
170
|
+
the app last reported, see `reportLocation` in `srt:dev`), `onLink` fires
|
|
171
|
+
for one arriving while the app runs. What a link names is the app's to decide, and it is untrusted input:
|
|
172
|
+
validate before acting on any part of it. A router turns links into screens
|
|
173
|
+
for you; without one, parse it yourself. The scheme a packed app answers to
|
|
174
|
+
is its appId (`com.example.app://settings`); `registerProtocolHandler()`
|
|
175
|
+
makes the install the handler for it on Linux and Windows, where nothing
|
|
176
|
+
installs a single executable, and does nothing on Android, whose package
|
|
177
|
+
declares the scheme. A second desktop instance started with a link hands
|
|
178
|
+
it to the running one and exits.
|
|
179
|
+
|
|
167
180
|
```tsx
|
|
168
181
|
onSuspend(() => file("session.json").write(JSON.stringify(state)))
|
|
169
182
|
if (env.launch === "restored") state = JSON.parse(await file("session.json").text())
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solidrt/core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.62",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"funding": "https://github.com/sponsors/wellawaretech",
|
|
6
6
|
"author": "Antoine van Wel",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"AGENTS.md"
|
|
33
33
|
],
|
|
34
34
|
"devDependencies": {
|
|
35
|
-
"@solidrt/flux-types": "0.0.
|
|
35
|
+
"@solidrt/flux-types": "0.0.62"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
38
|
"@solidjs/signals": "2.0.0-rc.9",
|
package/src/environment.ts
CHANGED
|
@@ -83,6 +83,21 @@ function ensureLaunchState() {
|
|
|
83
83
|
launchValue ??= "fresh"
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
// Fixed for the process like the launch fact (sticky `launchLink`, emitted
|
|
87
|
+
// before any app code runs), so a plain value rather than a signal.
|
|
88
|
+
let launchLinkValue: string | null | undefined
|
|
89
|
+
|
|
90
|
+
function ensureLaunchLinkState() {
|
|
91
|
+
if (launchLinkValue !== undefined) return
|
|
92
|
+
runWithOwner(null, () => {
|
|
93
|
+
on("launchLink", (e: { link?: string | null }) => {
|
|
94
|
+
launchLinkValue = typeof e.link === "string" ? e.link : null
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
// A runtime that reports no launch link launched without one.
|
|
98
|
+
launchLinkValue ??= null
|
|
99
|
+
}
|
|
100
|
+
|
|
86
101
|
let visibilityAccessor: (() => Visibility) | undefined
|
|
87
102
|
|
|
88
103
|
function ensureVisibilityState() {
|
|
@@ -257,6 +272,18 @@ export let env = {
|
|
|
257
272
|
ensureLaunchState()
|
|
258
273
|
return launchValue!
|
|
259
274
|
},
|
|
275
|
+
/**
|
|
276
|
+
* The link this process was started with, or null: a custom scheme link
|
|
277
|
+
* the OS routed to the app (Android intent data; `srt render --link`),
|
|
278
|
+
* raw, exactly as it arrived. What it names is the app's to decide, and
|
|
279
|
+
* it is untrusted input: validate before acting on any part of it. A link
|
|
280
|
+
* arriving while the app runs is an event instead: `onLink`. Fixed for the
|
|
281
|
+
* process, so it needs no tracked scope.
|
|
282
|
+
*/
|
|
283
|
+
get launchLink(): string | null {
|
|
284
|
+
ensureLaunchLinkState()
|
|
285
|
+
return launchLinkValue!
|
|
286
|
+
},
|
|
260
287
|
/** Orientation of the display the window is on. */
|
|
261
288
|
get orientation(): Orientation {
|
|
262
289
|
ensureOrientationState()
|
package/src/index.ts
CHANGED
|
@@ -3,7 +3,7 @@ export { setFocus, focusedNode, startTextInput, textInputActive, getFocusables,
|
|
|
3
3
|
export type { BoundingBox, GlobalPointerEvent, TextLine } from "./core"
|
|
4
4
|
export { parseColor, mixColors, withAlpha, brightness, createLinearGradient, createRadialGradient } from "./color"
|
|
5
5
|
export type { Gradient, GradientStop } from "./color"
|
|
6
|
-
export { onFrame, onLayout, onResize, onWindowFocus, onWindowBlur, onBack, onSuspend, onQuit, exit, background } from "./window"
|
|
6
|
+
export { onFrame, onLayout, onResize, onWindowFocus, onWindowBlur, onBack, onLink, onSuspend, onQuit, exit, background, registerProtocolHandler } from "./window"
|
|
7
7
|
export type { BackEvent, LifecycleHandler } from "./window"
|
|
8
8
|
export { windowSize, safeArea, displayScale, windowFocused, keyboardHeight, lockPointer, pointerLocked } from "./window"
|
|
9
9
|
export { env } from "./environment"
|
package/src/runtime-modules.d.ts
CHANGED
|
@@ -83,6 +83,18 @@ declare module "srt:app" {
|
|
|
83
83
|
* `back` on Android; exposed there as `background`.
|
|
84
84
|
*/
|
|
85
85
|
export function background(): void
|
|
86
|
+
/**
|
|
87
|
+
* Make this install the handler for the app's own scheme (its appId, as
|
|
88
|
+
* in `com.example.app://settings`), so links of it open the app: on Linux
|
|
89
|
+
* a desktop entry plus the mimeapps.list default, on Windows the
|
|
90
|
+
* HKCU\Software\Classes protocol key, both for the current user. Throws
|
|
91
|
+
* when the registration cannot be written. Android and macOS packages
|
|
92
|
+
* declare the scheme themselves, so the call has nothing to do there; a
|
|
93
|
+
* dev client registers nothing and logs a warning. Prefer the
|
|
94
|
+
* @solidrt/core `registerProtocolHandler`, the same call documented with
|
|
95
|
+
* the rest of the link surface.
|
|
96
|
+
*/
|
|
97
|
+
export function registerProtocolHandler(): void
|
|
86
98
|
}
|
|
87
99
|
|
|
88
100
|
// Dev-server control surface (lattice). Present only in dev/go builds; in other
|
|
@@ -110,6 +122,17 @@ declare module "srt:dev" {
|
|
|
110
122
|
* clients ever invoke commands.
|
|
111
123
|
*/
|
|
112
124
|
export function registerDebug(name: string, fn: (args?: any) => unknown): void
|
|
125
|
+
/**
|
|
126
|
+
* Report where the app is, for tooling: a string naming the current
|
|
127
|
+
* location (a router's path; an app routing by hand may report its own),
|
|
128
|
+
* called again on every change. The runtime stores the value and never
|
|
129
|
+
* interprets it. The dev server reads it (`GET /__control__/link`, the
|
|
130
|
+
* get_location MCP tool), and a hot reload of the same app starts with the
|
|
131
|
+
* last reported location as its `env.launchLink`, so a rebuild comes back
|
|
132
|
+
* to the screen it left. null withdraws it. Callable in every build, but
|
|
133
|
+
* only dev clients ever read it.
|
|
134
|
+
*/
|
|
135
|
+
export function reportLocation(location: string | null): void
|
|
113
136
|
}
|
|
114
137
|
|
|
115
138
|
// Installed-app management (lattice), the player's surface over the client's
|
package/src/text-input.ts
CHANGED
|
@@ -256,9 +256,11 @@ export type TextEditorLayout = {
|
|
|
256
256
|
* frame. A size change that re-breaks nothing (an empty field, lines that
|
|
257
257
|
* still fit, a height-only change) stops at the line placement: the lines,
|
|
258
258
|
* the caret and everything the caller derives from them keep their values
|
|
259
|
-
* and re-run nothing,
|
|
260
|
-
*
|
|
261
|
-
*
|
|
259
|
+
* and re-run nothing, and the placement reads the break inputs through a
|
|
260
|
+
* memo of its own rather than `input()` (which a caller builds per read),
|
|
261
|
+
* so a field inside a resizing box costs one placement of its units per
|
|
262
|
+
* frame, not a flush of its whole graph. Pure geometry: no caret rendering
|
|
263
|
+
* and no placeholder/visual policy.
|
|
262
264
|
*/
|
|
263
265
|
export function createTextEditorLayout(
|
|
264
266
|
viewport: () => { id: number } | undefined,
|
|
@@ -283,6 +285,19 @@ export function createTextEditorLayout(
|
|
|
283
285
|
return prepareText(text, { ...font(), runs, carets: true })
|
|
284
286
|
})
|
|
285
287
|
|
|
288
|
+
// What the breaks depend on, apart from the rest of the input: the caller
|
|
289
|
+
// builds input() per read (a field's value, font, caret and runs - some
|
|
290
|
+
// twenty reactive reads), and a placement re-run for a viewport width
|
|
291
|
+
// change must not pay for that. Equal by field, so only a real change
|
|
292
|
+
// reaches the placement.
|
|
293
|
+
let breaking = createMemo(
|
|
294
|
+
(): BreakInput => {
|
|
295
|
+
let { text, wrap, caretWidth = 0 } = input()
|
|
296
|
+
return { text, wrap, caretWidth }
|
|
297
|
+
},
|
|
298
|
+
{ equals: sameBreakInput },
|
|
299
|
+
)
|
|
300
|
+
|
|
286
301
|
// Lines carry their unit range so the caret math walks only their units.
|
|
287
302
|
// Equal placements (same units, same breaks and metrics) keep the previous
|
|
288
303
|
// value, so nothing downstream re-runs for a width change that changed
|
|
@@ -290,11 +305,13 @@ export function createTextEditorLayout(
|
|
|
290
305
|
// keeps the previous object: a row bound to it (a non-keyed <For> item)
|
|
291
306
|
// sees no change and writes nothing, so a moved break costs the lines it
|
|
292
307
|
// touched, not every line of the field.
|
|
293
|
-
type PlacedLine = EditorLine & { from: number; to: number }
|
|
294
|
-
|
|
308
|
+
type PlacedLine = EditorLine & { from: number; to: number; hardBreak: boolean }
|
|
309
|
+
// `holds` is the range of wrap widths [min, max) the breaker yields these
|
|
310
|
+
// same lines at (see holdRange); the layout handler skips a width inside it.
|
|
311
|
+
type Placement = { units: TextUnit[]; lines: PlacedLine[]; holds: [number, number] }
|
|
295
312
|
let placed = createMemo(
|
|
296
313
|
(prev): Placement => {
|
|
297
|
-
let { text, wrap, caretWidth
|
|
314
|
+
let { text, wrap, caretWidth } = breaking()
|
|
298
315
|
// Wrapped lines leave room for the caret at the end of a full line, so a
|
|
299
316
|
// wrapping editor never scrolls horizontally.
|
|
300
317
|
let width = wrap ? Math.max(0, viewportWidth() - caretWidth) : Infinity
|
|
@@ -305,21 +322,33 @@ export function createTextEditorLayout(
|
|
|
305
322
|
let line = layoutNextLine(units, cursor, width)
|
|
306
323
|
let hardBreak = false
|
|
307
324
|
while (line) {
|
|
308
|
-
out.push({
|
|
325
|
+
out.push({
|
|
326
|
+
start: line.start,
|
|
327
|
+
end: line.end,
|
|
328
|
+
y,
|
|
329
|
+
height: line.height,
|
|
330
|
+
width: line.width,
|
|
331
|
+
from: line.from,
|
|
332
|
+
to: line.to,
|
|
333
|
+
hardBreak: line.hardBreak,
|
|
334
|
+
})
|
|
309
335
|
y += line.height
|
|
310
336
|
hardBreak = line.hardBreak
|
|
311
337
|
line = layoutNextLine(units, line.cursor, width)
|
|
312
338
|
}
|
|
313
339
|
if (out.length === 0 || hardBreak) {
|
|
314
340
|
let n = units.units.length
|
|
315
|
-
out.push({ start: text.length, end: text.length, y, height: space().height, width: 0, from: n, to: n })
|
|
341
|
+
out.push({ start: text.length, end: text.length, y, height: space().height, width: 0, from: n, to: n, hardBreak: false })
|
|
316
342
|
}
|
|
317
343
|
if (prev && prev.units === units.units) {
|
|
318
344
|
for (let i = 0; i < out.length && i < prev.lines.length; i++) {
|
|
319
345
|
if (sameLine(prev.lines[i]!, out[i]!)) out[i] = prev.lines[i]!
|
|
320
346
|
}
|
|
321
347
|
}
|
|
322
|
-
|
|
348
|
+
// Split units are the width's own (a unit wider than it), so those
|
|
349
|
+
// lines hold for no other width.
|
|
350
|
+
let holds: [number, number] = units.units === prepared().units ? holdRange(units.units, out) : [Infinity, Infinity]
|
|
351
|
+
return { units: units.units, lines: out, holds }
|
|
323
352
|
},
|
|
324
353
|
{ equals: samePlacement },
|
|
325
354
|
)
|
|
@@ -452,7 +481,7 @@ export function createTextEditorLayout(
|
|
|
452
481
|
// when the caret has left the visible range, so the memo carries the
|
|
453
482
|
// retained position across recomputes.
|
|
454
483
|
let scrollX = createMemo((prev: number | undefined): number => {
|
|
455
|
-
let { caretWidth
|
|
484
|
+
let { caretWidth, wrap } = breaking()
|
|
456
485
|
if (wrap) return 0
|
|
457
486
|
let contentWidth = lines().reduce((w, l) => Math.max(w, l.width), 0)
|
|
458
487
|
let c = caret()
|
|
@@ -465,12 +494,43 @@ export function createTextEditorLayout(
|
|
|
465
494
|
return follow(prev ?? 0, c.y, c.height, viewportHeight(), last.y + last.height)
|
|
466
495
|
})
|
|
467
496
|
|
|
497
|
+
// Whether the lines placed at the last written width are what the breaker
|
|
498
|
+
// yields at `width` too. Such a width is not written: the write would mark
|
|
499
|
+
// the whole graph below the placement for a re-check that finds nothing,
|
|
500
|
+
// which is what a field inside a resizing box paid per frame. So
|
|
501
|
+
// viewportWidth lags the box by less than a break while wrapping, and a
|
|
502
|
+
// placement re-run for a text change at the lagging width is checked here
|
|
503
|
+
// again on that frame and corrected before paint (writes from onLayout
|
|
504
|
+
// land in the same frame's re-layout; the same holds for the height
|
|
505
|
+
// below). Unwrapped, the width is the horizontal scroll's extent and is
|
|
506
|
+
// always written.
|
|
507
|
+
let keepsBreaks = (width: number): boolean => {
|
|
508
|
+
let { wrap, caretWidth } = breaking()
|
|
509
|
+
if (!wrap) return false
|
|
510
|
+
let [min, max] = placed().holds
|
|
511
|
+
let w = Math.max(0, width - caretWidth)
|
|
512
|
+
return w >= min && w < max
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// The same for the height: a height at which the retained scroll offset
|
|
516
|
+
// still keeps the caret in view is not written, so the scroll graph stays
|
|
517
|
+
// untouched while a short field's box grows and shrinks.
|
|
518
|
+
let keepsScroll = (height: number): boolean => {
|
|
519
|
+
let ls = lines()
|
|
520
|
+
let last = ls[ls.length - 1]!
|
|
521
|
+
let c = caret()
|
|
522
|
+
let current = scrollY()
|
|
523
|
+
return follow(current, c.y, c.height, height, last.y + last.height) === current
|
|
524
|
+
}
|
|
525
|
+
|
|
468
526
|
onLayout(() => {
|
|
469
527
|
let node = viewport()
|
|
470
528
|
if (!node) return
|
|
471
529
|
let box = getLayoutBox(node)
|
|
472
|
-
|
|
473
|
-
|
|
530
|
+
let width = box?.width ?? 0
|
|
531
|
+
let height = box?.height ?? 0
|
|
532
|
+
if (!keepsBreaks(width)) setViewportWidth(width)
|
|
533
|
+
if (!keepsScroll(height)) setViewportHeight(height)
|
|
474
534
|
})
|
|
475
535
|
|
|
476
536
|
return { lines, caret, caretLine, offsetAtX, selectionRects, lineAtY, step, scrollX, scrollY }
|
|
@@ -519,6 +579,33 @@ function splitWide(prepared: PreparedText, width: number): PreparedText {
|
|
|
519
579
|
return { text: prepared.text, units }
|
|
520
580
|
}
|
|
521
581
|
|
|
582
|
+
// The wrap widths [min, max) at which the greedy breaker yields exactly
|
|
583
|
+
// `lines` over `units`: every line's ink fits (min), and no line's first
|
|
584
|
+
// unit fits at the end of the line before it (max), except across a hard
|
|
585
|
+
// break, where it never joins. Greedy breaking is decided line by line on
|
|
586
|
+
// just those two tests, so inside the range the placement is unchanged.
|
|
587
|
+
function holdRange(units: TextUnit[], lines: { from: number; to: number; width: number; hardBreak: boolean }[]): [number, number] {
|
|
588
|
+
let min = 0
|
|
589
|
+
let max = Infinity
|
|
590
|
+
for (let i = 0; i < lines.length; i++) {
|
|
591
|
+
let line = lines[i]!
|
|
592
|
+
if (line.width > min) min = line.width
|
|
593
|
+
let next = lines[i + 1]
|
|
594
|
+
if (!next || line.hardBreak || next.from >= units.length) continue
|
|
595
|
+
let pen = 0
|
|
596
|
+
for (let u = line.from; u < line.to; u++) pen += units[u]!.advance
|
|
597
|
+
let join = pen + unitInk(units, next.from)
|
|
598
|
+
if (join < max) max = join
|
|
599
|
+
}
|
|
600
|
+
return [min, max]
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
type BreakInput = { text: string; wrap: boolean; caretWidth: number }
|
|
604
|
+
|
|
605
|
+
function sameBreakInput(a: BreakInput, b: BreakInput): boolean {
|
|
606
|
+
return a.text === b.text && a.wrap === b.wrap && a.caretWidth === b.caretWidth
|
|
607
|
+
}
|
|
608
|
+
|
|
522
609
|
// Font options compared field by field: the same keys with the same values.
|
|
523
610
|
function sameOptions(a: MeasureTextOptions, b: MeasureTextOptions): boolean {
|
|
524
611
|
let ka = Object.keys(a) as (keyof MeasureTextOptions)[]
|
package/src/window.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { createSignal, getOwner, onCleanup, onSettled, runWithOwner, flush } fro
|
|
|
2
2
|
import { requestFrame, setPointerLock } from "flux:rendertree"
|
|
3
3
|
import { renderFrame } from "srt:render"
|
|
4
4
|
import { on, once } from "srt:events"
|
|
5
|
-
import { exit as nativeExit, background as nativeBackground } from "srt:app"
|
|
5
|
+
import { exit as nativeExit, background as nativeBackground, registerProtocolHandler as nativeRegisterProtocolHandler } from "srt:app"
|
|
6
6
|
import { platform } from "flux:process"
|
|
7
7
|
import { getEventHandler, focusedNode, setFocus, activateTextInput, setInterestRoot } from "./core"
|
|
8
8
|
import { scanForOrphans, getNodePath } from "./renderer"
|
|
@@ -141,6 +141,20 @@ export function background() {
|
|
|
141
141
|
nativeBackground()
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Makes this install the handler for the app's own scheme, so links like
|
|
146
|
+
* `com.example.app://settings` (the scheme is the appId) open the app from a
|
|
147
|
+
* browser, another app or the shell: `env.launchLink` when it starts the
|
|
148
|
+
* app, `onLink` when the app already runs. The web's name, simplified: no
|
|
149
|
+
* arguments, the scheme is fixed. A packed desktop app has to call it,
|
|
150
|
+
* since nothing installs a single executable (call it again after moving
|
|
151
|
+
* the executable); Android and macOS packages declare the scheme, so the
|
|
152
|
+
* call does nothing there. Throws when the registration cannot be written.
|
|
153
|
+
*/
|
|
154
|
+
export function registerProtocolHandler() {
|
|
155
|
+
nativeRegisterProtocolHandler()
|
|
156
|
+
}
|
|
157
|
+
|
|
144
158
|
// The default action of an unprevented back event: what the platform itself
|
|
145
159
|
// does at the root of an app. Android backgrounds (since Android 12);
|
|
146
160
|
// everywhere else back at the root ends the app.
|
|
@@ -417,6 +431,23 @@ export function onWindowFocus(fn: () => void) {
|
|
|
417
431
|
return unsubscribe
|
|
418
432
|
}
|
|
419
433
|
|
|
434
|
+
/**
|
|
435
|
+
* Calls `fn` with a link handed to the running app from outside: a custom
|
|
436
|
+
* scheme link the OS routed here (another app, a QR code, a notification),
|
|
437
|
+
* or one a dev tool sent (`open_link` over MCP, `POST /__control__/link`).
|
|
438
|
+
* The raw string, exactly as it arrived; the app decides what it means, and
|
|
439
|
+
* it is untrusted input: validate before acting on any part of it. The link
|
|
440
|
+
* the app was *started* with is not an event (nothing was listening yet) but
|
|
441
|
+
* a fact: `env.launchLink`.
|
|
442
|
+
*
|
|
443
|
+
* Returns a cleanup function; also auto-cleans within an owned scope.
|
|
444
|
+
*/
|
|
445
|
+
export function onLink(fn: (link: string) => void) {
|
|
446
|
+
let unsubscribe = on("link", (e: { link: string }) => fn(e.link))
|
|
447
|
+
if (getOwner()) onCleanup(unsubscribe)
|
|
448
|
+
return unsubscribe
|
|
449
|
+
}
|
|
450
|
+
|
|
420
451
|
export function onWindowBlur(fn: () => void) {
|
|
421
452
|
let unsubscribe = on("windowBlur", fn)
|
|
422
453
|
if (getOwner()) onCleanup(unsubscribe)
|