peachsim 0.1.674 → 0.1.676

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.
Files changed (82) hide show
  1. package/dist-lib/agent-daemon-client.cjs +1 -1
  2. package/dist-lib/agent-events.cjs +1 -1
  3. package/dist-lib/agent-identity.cjs +1 -1
  4. package/dist-lib/agent-sessions.cjs +1 -1
  5. package/dist-lib/attached-projects.cjs +1 -1
  6. package/dist-lib/auth/shared-session.cjs +1 -1
  7. package/dist-lib/backend-origin.cjs +1 -1
  8. package/dist-lib/beta.cjs +1 -1
  9. package/dist-lib/beta.mjs +1 -1
  10. package/dist-lib/bridge-constants.cjs +1 -1
  11. package/dist-lib/bridge-contract-input.cjs +1 -1
  12. package/dist-lib/bridge-contract-input.mjs +1 -1
  13. package/dist-lib/bridge-contract.cjs +1 -1
  14. package/dist-lib/bridge-contract.mjs +1 -1
  15. package/dist-lib/capture-contract.cjs +1 -1
  16. package/dist-lib/capture-contract.mjs +1 -1
  17. package/dist-lib/cli-constants.cjs +1 -1
  18. package/dist-lib/cloud-contract.cjs +1 -1
  19. package/dist-lib/cloud-contract.mjs +1 -1
  20. package/dist-lib/cloud-test-args.cjs +1 -1
  21. package/dist-lib/cloud-test-args.mjs +1 -1
  22. package/dist-lib/cloud.cjs +1 -1
  23. package/dist-lib/cloud.mjs +1 -1
  24. package/dist-lib/config.cjs +1 -1
  25. package/dist-lib/detox/index.cjs +1 -1
  26. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  27. package/dist-lib/home-paths.cjs +1 -1
  28. package/dist-lib/host/bridge-host.cjs +1 -1
  29. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  30. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  31. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  32. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  33. package/dist-lib/host/websocket-proxy.cjs +1 -1
  34. package/dist-lib/index.cjs +1 -1
  35. package/dist-lib/jump-to-source-babel.cjs +1 -1
  36. package/dist-lib/jump-to-source-native.cjs +1 -1
  37. package/dist-lib/kotlin-host.cjs +78 -2
  38. package/dist-lib/menu.cjs +43 -1
  39. package/dist-lib/menu.mjs +40 -1
  40. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  41. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  42. package/dist-lib/metro-production-bundle.cjs +1 -1
  43. package/dist-lib/metro-production-bundle.mjs +1 -1
  44. package/dist-lib/metro.cjs +1 -1
  45. package/dist-lib/native-app-identity.cjs +1 -1
  46. package/dist-lib/native-app-identity.mjs +1 -1
  47. package/dist-lib/native-firebase-metadata.cjs +1 -1
  48. package/dist-lib/native-firebase-metadata.mjs +1 -1
  49. package/dist-lib/plist.cjs +1 -1
  50. package/dist-lib/plist.mjs +1 -1
  51. package/dist-lib/profiles.cjs +1 -1
  52. package/dist-lib/public-brand.cjs +1 -1
  53. package/dist-lib/react-native-host-modules.cjs +1 -1
  54. package/dist-lib/react-native-host-modules.mjs +1 -1
  55. package/dist-lib/render-mode.cjs +1 -1
  56. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  57. package/dist-lib/sdk.cjs +1 -1
  58. package/dist-lib/sdk.mjs +1 -1
  59. package/dist-lib/skills.cjs +1 -1
  60. package/dist-lib/swift-compile.cjs +1 -1
  61. package/dist-lib/swift-compile.mjs +1 -1
  62. package/dist-lib/swift-package.cjs +1 -1
  63. package/dist-lib/swift-package.mjs +1 -1
  64. package/dist-lib/swift-submit.cjs +1 -1
  65. package/dist-lib/swift-submit.mjs +1 -1
  66. package/dist-lib/swift.cjs +1 -1
  67. package/dist-lib/vite-swift.cjs +1 -1
  68. package/dist-lib/vite.cjs +1 -1
  69. package/kotlin/AppSources/android/content/Context.kt +4 -0
  70. package/kotlin/AppSources/android/content/res/Configuration.kt +4 -1
  71. package/kotlin/AppSources/android/content/res/Resources.kt +45 -1
  72. package/kotlin/AppSources/androidx/compose/ui/platform/AndroidCompositionLocals.kt +19 -0
  73. package/kotlin/AppSources/androidx/compose/ui/res/StringResources.kt +53 -0
  74. package/kotlin/AppSources/dev/peach/app/JavaFormat.kt +264 -0
  75. package/kotlin/AppSources/dev/peach/app/PeachApp.kt +23 -7
  76. package/kotlin/fixtures/kotlin-app-variants/Strings.kt +49 -0
  77. package/package.json +1 -1
  78. package/src/android-strings.ts +163 -0
  79. package/src/android-xml.ts +3 -3
  80. package/src/kotlin-app.ts +4 -0
  81. package/src/kotlin-host.ts +98 -0
  82. package/src/menu.ts +48 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peachsim",
3
- "version": "0.1.674",
3
+ "version": "0.1.676",
4
4
  "description": "Vite and Metro plugins, testing drivers, and SDK exports for peach.",
5
5
  "author": "Tamagui LLC",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -0,0 +1,163 @@
1
+ // an Android module's string resources as aapt2 compiles them: each
2
+ // <string>, <plurals> and <string-array> under res/values and its locale
3
+ // qualified directories, the text with its style tags removed (getString
4
+ // returns the unstyled text) and aapt2's StringBuilder rules applied. a
5
+ // qualifier Peach does not resolve yet, or a value that is a reference, is
6
+ // rejected by name, never read as the default or as text.
7
+ import fs from 'node:fs'
8
+ import path from 'node:path'
9
+ import { decodeXmlText } from './android-xml'
10
+
11
+ // one resource's value per locale: '' for values, then a language or a
12
+ // language and region as a BCP 47 tag (values-en-rGB is en-GB). the device
13
+ // resolves a whole plurals or array resource to one locale, never per item
14
+ export interface AndroidResource<Value> {
15
+ name: string
16
+ values: Record<string, Value>
17
+ }
18
+
19
+ export type PluralQuantity = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other'
20
+
21
+ export interface AndroidStringResources {
22
+ strings: AndroidResource<string>[]
23
+ plurals: AndroidResource<Partial<Record<PluralQuantity, string>>>[]
24
+ arrays: AndroidResource<string[]>[]
25
+ }
26
+
27
+ const VALUES_DIR = /^values(?:-([a-z]{2,3})(?:-r([A-Z]{2}))?)?$/
28
+ const ELEMENT =
29
+ /<(string|plurals|string-array)(?=[\s/>])([^>]*?)(?:\/>|>([\s\S]*?)<\/\1\s*>)/g
30
+ const ITEM = /<item(?=[\s/>])([^>]*?)(?:\/>|>([\s\S]*?)<\/item\s*>)/g
31
+ const NAME = /\bname\s*=\s*"([^"]*)"/
32
+ const QUANTITY = /\bquantity\s*=\s*"([^"]*)"/
33
+ const QUANTITIES: readonly PluralQuantity[] = [
34
+ 'zero',
35
+ 'one',
36
+ 'two',
37
+ 'few',
38
+ 'many',
39
+ 'other',
40
+ ]
41
+
42
+ const isQuantity = (value: string): value is PluralQuantity =>
43
+ QUANTITIES.some((quantity) => quantity === value)
44
+
45
+ export function readAndroidStrings(moduleDir: string): AndroidStringResources {
46
+ const strings = new Map<string, Record<string, string>>()
47
+ const plurals = new Map<
48
+ string,
49
+ Record<string, Partial<Record<PluralQuantity, string>>>
50
+ >()
51
+ const arrays = new Map<string, Record<string, string[]>>()
52
+ const res = path.join(moduleDir, 'src/main/res')
53
+ const dirs = fs.existsSync(res) ? fs.readdirSync(res).sort() : []
54
+ for (const dir of dirs) {
55
+ if (!dir.startsWith('values')) continue
56
+ const qualifier = VALUES_DIR.exec(dir)
57
+ for (const entry of fs.readdirSync(path.join(res, dir)).sort()) {
58
+ if (!entry.endsWith('.xml')) continue
59
+ const file = path.join(res, dir, entry)
60
+ const text = fs.readFileSync(file, 'utf8').replace(/<!--[\s\S]*?-->/g, '')
61
+ for (const match of text.matchAll(ELEMENT)) {
62
+ const kind = match[1] ?? ''
63
+ const name = NAME.exec(match[2] ?? '')?.[1]
64
+ if (!name) throw new Error(`${file}: a <${kind}> without a name`)
65
+ if (!qualifier)
66
+ throw new Error(
67
+ `${file}: ${kind} ${name} under ${dir} is not resolved by peach kotlin yet (only values, values-<language> and values-<language>-r<REGION>)`,
68
+ )
69
+ const locale = qualifier[2]
70
+ ? `${qualifier[1]}-${qualifier[2]}`
71
+ : (qualifier[1] ?? '')
72
+ const body = match[3] ?? ''
73
+ const where = `${file}: ${kind} ${name}`
74
+ if (kind === 'string')
75
+ add(strings, name, locale, compileString(body, where), where)
76
+ else if (kind === 'plurals') {
77
+ const items: Partial<Record<PluralQuantity, string>> = {}
78
+ for (const item of body.matchAll(ITEM)) {
79
+ const quantity = QUANTITY.exec(item[1] ?? '')?.[1] ?? ''
80
+ if (!isQuantity(quantity))
81
+ throw new Error(`${where} has an item with quantity "${quantity}"`)
82
+ if (quantity in items) throw new Error(`${where} defines ${quantity} twice`)
83
+ items[quantity] = compileString(item[2] ?? '', `${where} ${quantity}`)
84
+ }
85
+ add(plurals, name, locale, items, where)
86
+ } else
87
+ add(
88
+ arrays,
89
+ name,
90
+ locale,
91
+ [...body.matchAll(ITEM)].map((item, index) =>
92
+ compileString(item[2] ?? '', `${where} item ${index}`),
93
+ ),
94
+ where,
95
+ )
96
+ }
97
+ }
98
+ }
99
+ return { strings: sorted(strings), plurals: sorted(plurals), arrays: sorted(arrays) }
100
+ }
101
+
102
+ function add<Value>(
103
+ byName: Map<string, Record<string, Value>>,
104
+ name: string,
105
+ locale: string,
106
+ value: Value,
107
+ where: string,
108
+ ) {
109
+ const values = byName.get(name) ?? {}
110
+ if (locale in values) throw new Error(`${where} is defined twice for one locale`)
111
+ values[locale] = value
112
+ byName.set(name, values)
113
+ }
114
+
115
+ // the R class code compiles against lists only the resources the default
116
+ // values define (AGP's ParseLibraryResourcesTask reads the unqualified
117
+ // directories), so a resource only a qualified directory defines has no field
118
+ const sorted = <Value>(
119
+ byName: Map<string, Record<string, Value>>,
120
+ ): AndroidResource<Value>[] =>
121
+ [...byName.entries()]
122
+ .filter(([, values]) => '' in values)
123
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
124
+ .map(([name, values]) => ({ name, values }))
125
+
126
+ // aapt2's StringBuilder over the decoded text: outside double quotes a run of
127
+ // whitespace is one space and the ends are trimmed, an unescaped apostrophe is
128
+ // an error; a double quote toggles quoting and is dropped; a backslash escapes
129
+ // the next character (\n, \t and \uXXXX, any other character as itself). a
130
+ // value beginning with @ or ? is a reference aapt2 resolves, which Peach does
131
+ // not yet
132
+ function compileString(raw: string, where: string): string {
133
+ const text = decodeXmlText(raw.replace(/<[^>]*>/g, ''), where)
134
+ if (/^\s*[@?]/.test(text))
135
+ throw new Error(`${where} is a resource reference, not resolved by peach kotlin yet`)
136
+ const out: { char: string; soft: boolean }[] = []
137
+ let quoted = false
138
+ for (let index = 0; index < text.length; index++) {
139
+ const char = text[index] ?? ''
140
+ if (char === '\\') {
141
+ const next = text[index + 1]
142
+ if (next === undefined) break
143
+ index++
144
+ if (next === 'n') out.push({ char: '\n', soft: false })
145
+ else if (next === 't') out.push({ char: '\t', soft: false })
146
+ else if (next === 'u') {
147
+ const hex = text.slice(index + 1, index + 5)
148
+ if (!/^[0-9a-fA-F]{4}$/.test(hex))
149
+ throw new Error(`${where} has an invalid unicode escape \\u${hex}`)
150
+ out.push({ char: String.fromCharCode(Number.parseInt(hex, 16)), soft: false })
151
+ index += 4
152
+ } else out.push({ char: next, soft: false })
153
+ } else if (char === '"') quoted = !quoted
154
+ else if (char === "'" && !quoted)
155
+ throw new Error(`${where} has an unescaped apostrophe`)
156
+ else if (!quoted && /\s/.test(char)) {
157
+ if (out.at(-1)?.soft !== true) out.push({ char: ' ', soft: true })
158
+ } else out.push({ char, soft: false })
159
+ }
160
+ while (out[0]?.soft) out.shift()
161
+ while (out.at(-1)?.soft) out.pop()
162
+ return out.map((part) => part.char).join('')
163
+ }
@@ -21,7 +21,7 @@ const ENTITIES: Record<string, string> = {
21
21
  const TAG = /<(\/?)([\w:.-]+)((?:\s+[\w:.-]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*(\/?)>/y
22
22
  const ATTRIBUTE = /([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g
23
23
 
24
- function decode(value: string, file: string): string {
24
+ export function decodeXmlText(value: string, file: string): string {
25
25
  return value.replace(/&(#x[0-9a-fA-F]+|#\d+|\w+);/g, (_match, entity: string) => {
26
26
  if (entity.startsWith('#x'))
27
27
  return String.fromCodePoint(Number.parseInt(entity.slice(2), 16))
@@ -42,7 +42,7 @@ export function parseXml(text: string, file: string): XmlElement {
42
42
  const open = body.indexOf('<', index)
43
43
  const content = body.slice(index, open === -1 ? body.length : open)
44
44
  const parent = stack.at(-1)
45
- if (parent && content.trim()) parent.text += decode(content, file)
45
+ if (parent && content.trim()) parent.text += decodeXmlText(content, file)
46
46
  if (open === -1) break
47
47
  TAG.lastIndex = open
48
48
  const match = TAG.exec(body)
@@ -59,7 +59,7 @@ export function parseXml(text: string, file: string): XmlElement {
59
59
  }
60
60
  const attributes: Record<string, string> = {}
61
61
  for (const [, key, double, single] of (rawAttributes ?? '').matchAll(ATTRIBUTE))
62
- if (key) attributes[key] = decode(double ?? single ?? '', file)
62
+ if (key) attributes[key] = decodeXmlText(double ?? single ?? '', file)
63
63
  const element: XmlElement = { name, attributes, children: [], text: '' }
64
64
  stack.at(-1)?.children.push(element)
65
65
  if (!selfClosing) stack.push(element)
package/src/kotlin-app.ts CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  import fs from 'node:fs'
7
7
  import path from 'node:path'
8
+ import { readAndroidStrings, type AndroidStringResources } from './android-strings'
8
9
  import {
9
10
  frameworkColor,
10
11
  readResourceValues,
@@ -47,6 +48,8 @@ export interface KotlinAppProject {
47
48
  // how the activity's window meets the keyboard: pan over a covered focused
48
49
  // view, resize above the keyboard, or nothing
49
50
  softInputAdjust: 'pan' | 'resize' | 'nothing'
51
+ // the module's string resources, which its R class names
52
+ strings: AndroidStringResources
50
53
  }
51
54
 
52
55
  export interface AndroidPins {
@@ -501,5 +504,6 @@ export function readKotlinApp(
501
504
  predictiveBack,
502
505
  handlesUiMode,
503
506
  softInputAdjust,
507
+ strings: readAndroidStrings(moduleDir),
504
508
  }
505
509
  }
@@ -410,6 +410,7 @@ export async function kotlinAppArtifact(
410
410
  'PeachEntry.kt': entrySource([]),
411
411
  'PeachViews.kt': viewsSource([]),
412
412
  'PeachAppEntry.kt': appEntry([]),
413
+ ...appResourceSources(app),
413
414
  }
414
415
  const shims = [
415
416
  ...kotlinSources(path.join(moduleRoot, 'Sources')),
@@ -435,6 +436,103 @@ export async function kotlinAppArtifact(
435
436
  )
436
437
  }
437
438
 
439
+ // the module's R class in its namespace and the string, plurals and array
440
+ // tables Resources reads. an id is 0x7f, the type, then the entry in name
441
+ // order. a device's ids also count the module's other resource types and the
442
+ // resources its libraries merge in, which Peach does not build, so the
443
+ // numbers themselves differ
444
+ function appResourceSources(app: KotlinAppProject): Record<string, string> {
445
+ const escapes: Record<string, string> = {
446
+ '\\': '\\\\',
447
+ '"': '\\"',
448
+ $: '\\$',
449
+ '\n': '\\n',
450
+ '\r': '\\r',
451
+ '\t': '\\t',
452
+ }
453
+ const literal = (value: string) =>
454
+ `"${value.replace(
455
+ /[\\"$\u0000-\u001f]/g,
456
+ (char) => escapes[char] ?? `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`,
457
+ )}"`
458
+ // aapt2 writes a resource name's dots and dashes as underscores in R
459
+ const field = (name: string) => `\`${name.replace(/[.-]/g, '_')}\``
460
+ const id = (type: number, entry: number) =>
461
+ `0x7f${type.toString(16).padStart(2, '0')}${entry.toString(16).padStart(4, '0')}`
462
+ const kinds = [
463
+ {
464
+ type: 'string',
465
+ table: 'peachAppStrings',
466
+ value: 'String',
467
+ resources: app.strings.strings,
468
+ },
469
+ {
470
+ type: 'plurals',
471
+ table: 'peachAppPlurals',
472
+ value: 'Map<String, String>',
473
+ resources: app.strings.plurals,
474
+ },
475
+ {
476
+ type: 'array',
477
+ table: 'peachAppArrays',
478
+ value: 'List<String>',
479
+ resources: app.strings.arrays,
480
+ },
481
+ ].map((kind, index) => ({
482
+ ...kind,
483
+ entries: kind.resources.map((resource, entry) => ({
484
+ name: resource.name,
485
+ id: id(index + 1, entry),
486
+ values: Object.entries(resource.values).map(
487
+ ([locale, value]: [
488
+ string,
489
+ string | string[] | Partial<Record<string, string>>,
490
+ ]) =>
491
+ `${literal(locale)} to ${
492
+ typeof value === 'string'
493
+ ? literal(value)
494
+ : Array.isArray(value)
495
+ ? `listOf(${value.map(literal).join(', ')})`
496
+ : `mapOf(${Object.entries(value)
497
+ .map(
498
+ ([quantity, text]) =>
499
+ `${literal(quantity)} to ${literal(text ?? '')}`,
500
+ )
501
+ .join(', ')})`
502
+ }`,
503
+ ),
504
+ })),
505
+ }))
506
+ return {
507
+ 'R.kt': [
508
+ `package ${app.namespace}`,
509
+ '',
510
+ 'object R {',
511
+ ...kinds.flatMap((kind) => [
512
+ ` object ${kind.type} {`,
513
+ ...kind.entries.map(
514
+ (entry) => ` const val ${field(entry.name)}: Int = ${entry.id}`,
515
+ ),
516
+ ' }',
517
+ ]),
518
+ '}',
519
+ '',
520
+ ].join('\n'),
521
+ 'PeachAppResources.kt': [
522
+ 'package dev.peach.app',
523
+ '',
524
+ ...kinds.flatMap((kind) => [
525
+ `internal val ${kind.table}: Map<Int, Map<String, ${kind.value}>> = mapOf(`,
526
+ ...kind.entries.map(
527
+ (entry) => ` ${entry.id} to mapOf(${entry.values.join(', ')}),`,
528
+ ),
529
+ ')',
530
+ '',
531
+ ]),
532
+ ].join('\n'),
533
+ }
534
+ }
535
+
438
536
  async function compileKotlin(
439
537
  input: {
440
538
  root: string
package/src/menu.ts CHANGED
@@ -29,6 +29,25 @@ export const SOOTSIM_AMBIENT_GLOW_LABELS: Record<SootSimAmbientGlowLevel, string
29
29
  full: 'Full',
30
30
  }
31
31
 
32
+ // the stage behind the device: system follows the mac's appearance (white in
33
+ // light mode, black in dark), or one pinned color. one value per host, like
34
+ // the glow level above.
35
+ export type SootSimStageBackground = 'system' | 'light' | 'dark' | 'gray'
36
+ export const SOOTSIM_STAGE_BACKGROUNDS = ['system', 'light', 'dark', 'gray'] as const
37
+
38
+ export function isSootSimStageBackground(
39
+ value: unknown,
40
+ ): value is SootSimStageBackground {
41
+ return value === 'system' || value === 'light' || value === 'dark' || value === 'gray'
42
+ }
43
+
44
+ export const SOOTSIM_STAGE_BACKGROUND_LABELS: Record<SootSimStageBackground, string> = {
45
+ system: 'System',
46
+ light: 'Light',
47
+ dark: 'Dark',
48
+ gray: 'Gray',
49
+ }
50
+
32
51
  // render resolution: how many pixels peach renders per iphone point. one
33
52
  // setting with three states, carried by every host exactly like the ambient
34
53
  // glow level above: the menus, the persisted setting, the ?renderResolution=
@@ -86,6 +105,8 @@ export interface SootSimMenuState {
86
105
  showRail: boolean
87
106
  solidWindow: boolean
88
107
  ambientGlow: SootSimAmbientGlowLevel
108
+ stageBackground: SootSimStageBackground
109
+ stageChecker: boolean
89
110
  renderResolution: SootSimRenderResolution
90
111
  // presentation: the real rendering, or the clean structural wireframe
91
112
  wireframe: boolean
@@ -134,6 +155,7 @@ export type MenuItemDef = SootSimMenuItemDef
134
155
  export type MenuDef = SootSimMenuDef
135
156
  export type MenuState = SootSimMenuState
136
157
  export type AmbientGlowLevel = SootSimAmbientGlowLevel
158
+ export type StageBackground = SootSimStageBackground
137
159
  export type RenderResolution = SootSimRenderResolution
138
160
  export type ShortcutPlatform = SootSimMenuPlatform
139
161
  export type MenuShortcutEntry = SootSimMenuShortcutEntry
@@ -550,6 +572,32 @@ function windowMenu(state: SootSimMenuState): SootSimMenuDef {
550
572
  actionPayload: level,
551
573
  })),
552
574
  },
575
+ {
576
+ // browser only, with the glow: the stage, its dropped wallpaper and
577
+ // the glow all live around the device in the browser shell. the
578
+ // wallpaper actions run in the shell, which owns the image.
579
+ label: 'Background',
580
+ hiddenIn: 'electron',
581
+ items: [
582
+ ...SOOTSIM_STAGE_BACKGROUNDS.map((background) => ({
583
+ label: SOOTSIM_STAGE_BACKGROUND_LABELS[background],
584
+ type: 'radio' as const,
585
+ checked: state.stageBackground === background,
586
+ action: 'set-stage-background',
587
+ actionPayload: background,
588
+ })),
589
+ { type: 'separator' as const },
590
+ {
591
+ label: 'Checker',
592
+ type: 'checkbox' as const,
593
+ checked: state.stageChecker,
594
+ action: 'toggle-stage-checker',
595
+ },
596
+ { type: 'separator' as const },
597
+ { label: 'Choose Wallpaper…', action: 'choose-wallpaper' },
598
+ { label: 'Clear Wallpaper', action: 'clear-wallpaper' },
599
+ ],
600
+ },
553
601
  {
554
602
  // every host: the level only changes backing-store sizes, so the
555
603
  // device-shaped electron window shows it the same as the browser.