@glassly/engine 0.3.5-beta.734 → 0.3.5-beta.760

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 (95) hide show
  1. package/build/engine.d.ts +3 -0
  2. package/build/engine.d.ts.map +1 -1
  3. package/build/engine.js +4 -0
  4. package/build/engine.js.map +1 -1
  5. package/build/facades/glasses.d.ts +5 -1
  6. package/build/facades/glasses.d.ts.map +1 -1
  7. package/build/facades/glasses.js +5 -0
  8. package/build/facades/glasses.js.map +1 -1
  9. package/build/generated/releaseMetadata.js +3 -3
  10. package/build/generated/releaseMetadata.js.map +1 -1
  11. package/build/services/GestureDotsService.d.ts.map +1 -1
  12. package/build/services/GestureDotsService.js +2 -1
  13. package/build/services/GestureDotsService.js.map +1 -1
  14. package/build/services/GlassesTouchInput.d.ts +2 -1
  15. package/build/services/GlassesTouchInput.d.ts.map +1 -1
  16. package/build/services/GlassesTouchInput.js +64 -26
  17. package/build/services/GlassesTouchInput.js.map +1 -1
  18. package/build/services/LocalLlmModelManager.d.ts +3 -1
  19. package/build/services/LocalLlmModelManager.d.ts.map +1 -1
  20. package/build/services/LocalLlmModelManager.js +13 -5
  21. package/build/services/LocalLlmModelManager.js.map +1 -1
  22. package/build/services/LocalLlmPrompt.d.ts +10 -9
  23. package/build/services/LocalLlmPrompt.d.ts.map +1 -1
  24. package/build/services/LocalLlmPrompt.js +43 -32
  25. package/build/services/LocalLlmPrompt.js.map +1 -1
  26. package/build/services/LocalLlmProvider.d.ts.map +1 -1
  27. package/build/services/LocalLlmProvider.js +39 -20
  28. package/build/services/LocalLlmProvider.js.map +1 -1
  29. package/build/services/LocalMiniappRuntime.d.ts +19 -4
  30. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  31. package/build/services/LocalMiniappRuntime.js +68 -125
  32. package/build/services/LocalMiniappRuntime.js.map +1 -1
  33. package/build/services/SceneRenderer.d.ts.map +1 -1
  34. package/build/services/SceneRenderer.js +3 -1
  35. package/build/services/SceneRenderer.js.map +1 -1
  36. package/build/services/actionInvoke.d.ts +37 -0
  37. package/build/services/actionInvoke.d.ts.map +1 -0
  38. package/build/services/actionInvoke.js +89 -0
  39. package/build/services/actionInvoke.js.map +1 -0
  40. package/build/services/appleWatch/AppleWatchLink.d.ts +42 -0
  41. package/build/services/appleWatch/AppleWatchLink.d.ts.map +1 -0
  42. package/build/services/appleWatch/AppleWatchLink.js +259 -0
  43. package/build/services/appleWatch/AppleWatchLink.js.map +1 -0
  44. package/build/services/controllers.d.ts.map +1 -1
  45. package/build/services/controllers.js +16 -0
  46. package/build/services/controllers.js.map +1 -1
  47. package/build/services/settingsBridge.d.ts +4 -4
  48. package/build/services/settingsBridge.d.ts.map +1 -1
  49. package/build/services/settingsBridge.js +9 -4
  50. package/build/services/settingsBridge.js.map +1 -1
  51. package/build/stores/bluetoothSettingKeys.d.ts +2 -0
  52. package/build/stores/bluetoothSettingKeys.d.ts.map +1 -1
  53. package/build/stores/bluetoothSettingKeys.js +17 -0
  54. package/build/stores/bluetoothSettingKeys.js.map +1 -1
  55. package/build/stores/settings.d.ts.map +1 -1
  56. package/build/stores/settings.js +32 -2
  57. package/build/stores/settings.js.map +1 -1
  58. package/build/types/capabilities/rayneo-io.d.ts.map +1 -1
  59. package/build/types/capabilities/rayneo-io.js +2 -0
  60. package/build/types/capabilities/rayneo-io.js.map +1 -1
  61. package/build/types/enums.d.ts +2 -1
  62. package/build/types/enums.d.ts.map +1 -1
  63. package/build/types/enums.js +1 -0
  64. package/build/types/enums.js.map +1 -1
  65. package/build/utils/display/scene/index.d.ts +1 -1
  66. package/build/utils/display/scene/index.d.ts.map +1 -1
  67. package/build/utils/display/scene/index.js.map +1 -1
  68. package/build/utils/display/scene/process.d.ts.map +1 -1
  69. package/build/utils/display/scene/process.js +20 -1
  70. package/build/utils/display/scene/process.js.map +1 -1
  71. package/build/utils/display/scene/types.d.ts +1 -1
  72. package/build/utils/display/scene/types.d.ts.map +1 -1
  73. package/build/utils/display/scene/types.js.map +1 -1
  74. package/package.json +7 -7
  75. package/src/engine.ts +4 -0
  76. package/src/facades/glasses.ts +7 -0
  77. package/src/generated/releaseMetadata.ts +3 -3
  78. package/src/services/GestureDotsService.ts +2 -1
  79. package/src/services/GlassesTouchInput.ts +69 -23
  80. package/src/services/LocalLlmModelManager.ts +16 -6
  81. package/src/services/LocalLlmPrompt.ts +56 -37
  82. package/src/services/LocalLlmProvider.ts +49 -22
  83. package/src/services/LocalMiniappRuntime.ts +103 -125
  84. package/src/services/SceneRenderer.ts +3 -1
  85. package/src/services/actionInvoke.ts +105 -0
  86. package/src/services/appleWatch/AppleWatchLink.ts +297 -0
  87. package/src/services/controllers.ts +17 -0
  88. package/src/services/settingsBridge.ts +9 -4
  89. package/src/stores/bluetoothSettingKeys.ts +20 -0
  90. package/src/stores/settings.ts +34 -2
  91. package/src/types/capabilities/rayneo-io.ts +2 -0
  92. package/src/types/enums.ts +1 -0
  93. package/src/utils/display/scene/index.ts +1 -0
  94. package/src/utils/display/scene/process.ts +17 -1
  95. package/src/utils/display/scene/types.ts +1 -0
@@ -85,55 +85,74 @@ export function toTemplateTools(tools?: LlmTool[]): TemplateTool[] | undefined {
85
85
  }))
86
86
  }
87
87
 
88
- /** Rough tokens-per-character for a BPE vocabulary over mixed prose/JSON. */
89
- const CHARS_PER_TOKEN = 3.2
90
- /** Template framing per message (`<|im_start|>role … <|im_end|>`). */
91
- const TOKENS_PER_MESSAGE = 6
92
-
93
- export function estimateTokens(text: string): number {
94
- return Math.ceil(text.length / CHARS_PER_TOKEN)
95
- }
96
-
97
- export function estimateMessageTokens(message: TemplateMessage): number {
98
- let total = TOKENS_PER_MESSAGE + estimateTokens(message.content)
99
- if (message.role === "assistant" && message.tool_calls) {
100
- for (const call of message.tool_calls) total += estimateTokens(JSON.stringify(call.function)) + 8
101
- }
102
- return total
88
+ export interface FittedLocalPrompt {
89
+ prompt: string
90
+ promptTokens: number
91
+ truncated: boolean
103
92
  }
104
93
 
105
- const MIN_TOOL_CONTENT_CHARS = 1_200
106
-
107
94
  /**
108
- * Fit the conversation into `budgetTokens`. History is grouped into turns
109
- * (a user message plus everything up to the next user message, so a tool
110
- * round stays with the question it answers); the oldest turns go first and
111
- * the last turn is never dropped — the model must at least see the question.
112
- * When that last turn alone still does not fit, tool outputs are cut to
113
- * their head.
95
+ * Measure the complete rendered prompt with the model's tokenizer, including
96
+ * template framing, system instructions and tool schemas. Drop old turns first,
97
+ * then shorten tool results / message text in the final turn. Never cut template
98
+ * delimiters, system instructions, or structured tool calls to make them fit.
114
99
  */
115
- export function fitToBudget(messages: TemplateMessage[], budgetTokens: number, toolTokens = 0): TemplateMessage[] {
116
- const total = (list: TemplateMessage[]) => list.reduce((sum, m) => sum + estimateMessageTokens(m), toolTokens)
100
+ export async function fitPromptToContext(
101
+ messages: TemplateMessage[],
102
+ budgetTokens: number,
103
+ render: (messages: TemplateMessage[]) => string,
104
+ countTokens: (prompt: string) => Promise<number>,
105
+ ): Promise<FittedLocalPrompt> {
106
+ if (!Number.isInteger(budgetTokens) || budgetTokens < 1) {
107
+ throw new Error("On-device model has no room for a prompt after reserving output tokens")
108
+ }
117
109
  const system = messages[0]?.role === "system" ? [messages[0]] : []
118
110
  const turns: TemplateMessage[][] = []
119
111
  for (const message of messages.slice(system.length)) {
120
112
  if (message.role === "user" || turns.length === 0) turns.push([message])
121
113
  else turns[turns.length - 1]!.push(message)
122
114
  }
123
-
124
- while (turns.length > 1 && total([...system, ...turns.flat()]) > budgetTokens) {
125
- turns.shift()
126
- }
127
-
128
115
  let fitted = [...system, ...turns.flat()]
129
- if (total(fitted) > budgetTokens) {
130
- fitted = fitted.map((m) =>
131
- m.role === "tool" && m.content.length > MIN_TOOL_CONTENT_CHARS
132
- ? {...m, content: `${m.content.slice(0, MIN_TOOL_CONTENT_CHARS)}\n…[truncated]`}
133
- : m,
134
- )
116
+ let truncated = false
117
+ const marker = "\n…[truncated]…\n"
118
+ while (true) {
119
+ const prompt = render(fitted)
120
+ const promptTokens = await countTokens(prompt)
121
+ if (!Number.isInteger(promptTokens) || promptTokens < 1) {
122
+ throw new Error("On-device tokenizer returned an invalid prompt token count")
123
+ }
124
+ if (promptTokens <= budgetTokens) return {prompt, promptTokens, truncated}
125
+ truncated = true
126
+ if (turns.length > 1) {
127
+ turns.shift()
128
+ fitted = [...system, ...turns.flat()]
129
+ continue
130
+ }
131
+ // Preserve the user's question while tool output can still be shortened.
132
+ const candidates = fitted
133
+ .map((message, index) => ({message, index}))
134
+ .filter(({message}) => message.role !== "system" && Array.from(message.content).length > marker.length * 2)
135
+ .sort(
136
+ (a, b) =>
137
+ Number(b.message.role === "tool") - Number(a.message.role === "tool") ||
138
+ b.message.content.length - a.message.content.length,
139
+ )
140
+ const candidate = candidates[0]
141
+ if (!candidate) {
142
+ throw new Error(
143
+ `On-device prompt instructions and tool definitions exceed the context budget (${promptTokens} > ${budgetTokens} tokens)`,
144
+ )
145
+ }
146
+ // Keep both ends of long transcripts and questions, without splitting a
147
+ // Unicode code point. Each pass shrinks strictly, then remeasures the whole
148
+ // rendered prompt; character ratios are never used as a safety check.
149
+ const chars = Array.from(candidate.message.content)
150
+ const keep = Math.floor(chars.length / 4)
151
+ fitted[candidate.index] = {
152
+ ...candidate.message,
153
+ content: `${chars.slice(0, keep).join("")}${marker}${chars.slice(-keep).join("")}`,
154
+ }
135
155
  }
136
- return fitted
137
156
  }
138
157
 
139
158
  /** Remove a leading `<think>…</think>` block (or an unterminated one). */
@@ -22,9 +22,8 @@ import {BgTimer} from "../utils/timers"
22
22
  import localLlmModelManager, {type LocalLlmModelId} from "./LocalLlmModelManager"
23
23
  import {
24
24
  buildTemplateMessages,
25
- estimateTokens,
26
25
  finalizeLocalResult,
27
- fitToBudget,
26
+ fitPromptToContext,
28
27
  toTemplateTools,
29
28
  type LocalCompletion,
30
29
  } from "./LocalLlmPrompt"
@@ -33,6 +32,7 @@ import {SETTINGS, useSettingsStore} from "../stores/settings"
33
32
  type Rne = typeof import("react-native-executorch")
34
33
  // The runner API lives on the `llm` namespace; derive its types from there so
35
34
  // the import stays a type-only reference (no native module at load time).
35
+ type Tokenizer = ReturnType<Rne["nlp"]["loadTokenizer"]>
36
36
  type LLMRunner = ReturnType<Rne["llm"]["createLLMRunner"]>
37
37
  type LLMGenerationStats = ReturnType<LLMRunner["generate"]>
38
38
  type LLMGenerationConfig = NonNullable<Parameters<LLMRunner["generate"]>[1]>
@@ -45,12 +45,14 @@ const IDLE_UNLOAD_MS = 5 * 60_000
45
45
  const GENERATION_DEADLINE_MS = 100_000
46
46
  const DEFAULT_MAX_NEW_TOKENS = 512
47
47
  const MAX_NEW_TOKENS_CEILING = 1_024
48
- /** Headroom between the estimated prompt and the context end; estimates are rough. */
48
+ /** Reserve space for runner-added BOS/EOS tokens and context boundary bookkeeping. */
49
49
  const CONTEXT_MARGIN_TOKENS = 96
50
50
 
51
51
  interface LoadedModel {
52
52
  modelId: LocalLlmModelId
53
53
  runner: LLMRunner
54
+ tokenizer: Tokenizer
55
+ countTokens: (tokenizer: Tokenizer, prompt: string) => Promise<number>
54
56
  template: Template
55
57
  stopTokens: string[]
56
58
  maxSeqLen: number
@@ -85,6 +87,11 @@ function generateWorklet(
85
87
  return {text, stats}
86
88
  }
87
89
 
90
+ function countTokensWorklet(tokenizer: Tokenizer, prompt: string): number {
91
+ "worklet"
92
+ return tokenizer.encode(prompt).length
93
+ }
94
+
88
95
  class LocalLlmProvider {
89
96
  private static instance: LocalLlmProvider
90
97
 
@@ -150,6 +157,8 @@ class LocalLlmProvider {
150
157
  loaded.runner.dispose()
151
158
  } catch (error) {
152
159
  console.warn(`${LOG_TAG}: runner dispose failed:`, error)
160
+ } finally {
161
+ loaded.tokenizer.dispose()
153
162
  }
154
163
  }
155
164
 
@@ -159,21 +168,21 @@ class LocalLlmProvider {
159
168
  const model = await this.ensureLoaded()
160
169
  const jsonMode = request.jsonMode === true
161
170
  const tools = toTemplateTools(request.tools)
162
- const toolTokens = tools ? estimateTokens(JSON.stringify(tools)) + 64 : 0
163
171
 
164
172
  const maxNewTokens = Math.max(
165
173
  16,
166
174
  Math.min(request.maxTokens ?? DEFAULT_MAX_NEW_TOKENS, MAX_NEW_TOKENS_CEILING, Math.floor(model.maxSeqLen / 4)),
167
175
  )
168
176
  const promptBudget = model.maxSeqLen - maxNewTokens - CONTEXT_MARGIN_TOKENS
169
- const messages = fitToBudget(buildTemplateMessages({...request, jsonMode}), promptBudget, toolTokens)
170
-
171
- const prompt = model.template.render({
172
- messages,
173
- tools,
174
- add_generation_prompt: true,
175
- enable_thinking: false,
176
- })
177
+ const {prompt, promptTokens, truncated} = await fitPromptToContext(
178
+ buildTemplateMessages({...request, jsonMode}),
179
+ promptBudget,
180
+ (messages) => model.template.render({messages, tools, add_generation_prompt: true, enable_thinking: false}),
181
+ (text) => model.countTokens(model.tokenizer, text),
182
+ )
183
+ console.log(
184
+ `${LOG_TAG}: ${model.modelId} prompt=${promptTokens} budget=${promptBudget} context=${model.maxSeqLen} maxNewTokens=${maxNewTokens} truncated=${truncated}`,
185
+ )
177
186
 
178
187
  model.runner.reset(0)
179
188
  const deadline = BgTimer.setTimeout(() => {
@@ -189,6 +198,12 @@ class LocalLlmProvider {
189
198
  {maxNewTokens, temperature: request.temperature ?? 0.7},
190
199
  model.stopTokens,
191
200
  )
201
+ } catch (error) {
202
+ console.warn(
203
+ `${LOG_TAG}: generation failed: model=${model.modelId} prompt=${promptTokens} context=${model.maxSeqLen} maxNewTokens=${maxNewTokens}`,
204
+ error,
205
+ )
206
+ throw error
192
207
  } finally {
193
208
  BgTimer.clearTimeout(deadline)
194
209
  }
@@ -246,16 +261,28 @@ class LocalLlmProvider {
246
261
  const startedAt = Date.now()
247
262
  const createRunner = rne.wrapAsync(rne.llm.createLLMRunner)
248
263
  const runner = await createRunner(paths.model, paths.tokenizer, [])
249
- const maxSeqLen = runner.getKVCacheState().maxSeqLen
250
- console.log(`${LOG_TAG}: loaded ${modelId} (context ${maxSeqLen}) in ${Date.now() - startedAt} ms`)
251
-
252
- return {
253
- modelId,
254
- runner,
255
- template,
256
- stopTokens: [...tokenizerConfig.stopTokens],
257
- maxSeqLen,
258
- generate: rne.wrapAsync(generateWorklet),
264
+ let tokenizer: Tokenizer | undefined
265
+ try {
266
+ const maxSeqLen = runner.getKVCacheState().maxSeqLen
267
+ if (!Number.isInteger(maxSeqLen) || maxSeqLen <= CONTEXT_MARGIN_TOKENS + 16) {
268
+ throw new Error(`On-device model ${modelId} reported an invalid context size: ${maxSeqLen}`)
269
+ }
270
+ tokenizer = await rne.wrapAsync(rne.nlp.loadTokenizer)(paths.tokenizer)
271
+ console.log(`${LOG_TAG}: loaded ${modelId} (context ${maxSeqLen}) in ${Date.now() - startedAt} ms`)
272
+ return {
273
+ modelId,
274
+ runner,
275
+ tokenizer,
276
+ countTokens: rne.wrapAsync(countTokensWorklet),
277
+ template,
278
+ stopTokens: [...tokenizerConfig.stopTokens],
279
+ maxSeqLen,
280
+ generate: rne.wrapAsync(generateWorklet),
281
+ }
282
+ } catch (error) {
283
+ tokenizer?.dispose()
284
+ runner.dispose()
285
+ throw error
259
286
  }
260
287
  }
261
288
 
@@ -162,6 +162,13 @@ import {invokeScanQrSeam} from "../runtime/scanQrSeam"
162
162
  import {invokeWebLoginSeam} from "../runtime/webLoginSeam"
163
163
  import {normalizeStreamAudioConfig, normalizeStreamVideoConfig} from "../runtime/streamConfig"
164
164
  import {visibleActionRows} from "./manifestActions"
165
+ import {
166
+ actionFailure,
167
+ appDeclaresAction,
168
+ checkActionAccess,
169
+ PendingActionCalls,
170
+ type ActionOutcome,
171
+ } from "./actionInvoke"
165
172
  import {toLanguageHint} from "@glassly/cloud-protocol/languages"
166
173
  import {buildMiniappManifestSnapshot, type MiniappRuntimeDiagnosticSnapshot} from "../utils/miniappDiagnostics"
167
174
  import ttsModelManager from "./TTSModelManager"
@@ -6021,10 +6028,7 @@ class LocalMiniappRuntime {
6021
6028
  private static readonly ACTION_PAYLOAD_CAP = 256 * 1024
6022
6029
 
6023
6030
  /** Outstanding action invocations, keyed by host-generated callId. */
6024
- private actionCalls = new Map<
6025
- string,
6026
- {callerPackageName: string; targetPackageName: string; callerRequestId?: string; timer: number}
6027
- >()
6031
+ private actionCalls = new PendingActionCalls(BgTimer, (value) => this.actionPayloadTooLarge(value))
6028
6032
  private actionCallSeq = 0
6029
6033
 
6030
6034
  private interopApps(): ClientApp[] {
@@ -6596,111 +6600,118 @@ class LocalMiniappRuntime {
6596
6600
  }
6597
6601
  }
6598
6602
 
6603
+ /** Floored at 6s: a freshly-woken target buffers an undelivered ACTION_CALL for 5s (HANDLER_WAIT_MS in @glassly/miniapp's actions module) + 1s for the reply. Keep in sync. */
6604
+ private static clampActionTimeout(raw: unknown): number {
6605
+ return Math.min(Math.max(Number(raw) || 30_000, 6_000), 120_000)
6606
+ }
6607
+
6608
+ /** Caller identity the host presents for host-initiated actions (a system caller). */
6609
+ private static readonly HOST_ACTION_CALLER = "host"
6610
+
6611
+ /** Installed, compatible, and declares the action — the same gates `invokeActionFromHost` applies up front. */
6612
+ public hostActionAvailable(targetPackageName: string, actionId: string): boolean {
6613
+ return appDeclaresAction(
6614
+ this.interopApps().find((a) => a.packageName === targetPackageName),
6615
+ actionId,
6616
+ )
6617
+ }
6618
+
6599
6619
  /**
6600
- * session.actions.invoke → wake the target headlessly, deliver an ACTION_CALL,
6601
- * and correlate its ACTION_RESULT back to the caller's pending request.
6620
+ * Host-initiated action call (e.g. the watch remote): same gates, headless
6621
+ * wake and ACTION_CALL/ACTION_RESULT correlation as a miniapp's
6622
+ * session.actions.invoke, with the host as a system caller.
6623
+ * Rejects with an Error whose `code` is a MiniappErrorCode.
6602
6624
  */
6625
+ public async invokeActionFromHost(
6626
+ targetPackageName: string,
6627
+ actionId: string,
6628
+ params: Record<string, unknown> = {},
6629
+ timeoutMs?: number,
6630
+ ): Promise<unknown> {
6631
+ const outcome = await this.runActionInvoke(
6632
+ LocalMiniappRuntime.HOST_ACTION_CALLER,
6633
+ true,
6634
+ targetPackageName,
6635
+ actionId,
6636
+ params,
6637
+ LocalMiniappRuntime.clampActionTimeout(timeoutMs),
6638
+ )
6639
+ if (outcome.ok) return outcome.data
6640
+ throw Object.assign(new Error(outcome.error.message), {code: outcome.error.code})
6641
+ }
6642
+
6643
+ /** session.actions.invoke → run the call, then relay the outcome to the caller's pending request. */
6603
6644
  private async handleActionInvoke(
6604
6645
  callerPackageName: string,
6605
6646
  payload: Record<string, unknown>,
6606
6647
  requestId?: string,
6607
6648
  ): Promise<void> {
6608
- const target = payload.targetPackageName as string | undefined
6609
- const actionId = payload.actionId as string | undefined
6610
- const params = (payload.params as Record<string, unknown> | undefined) ?? {}
6611
- // Floor the timeout at 6s so the host never rejects with ACTION_TIMEOUT before
6612
- // the target SDK's handler-registration window closes. A freshly-woken miniapp
6613
- // buffers an undelivered ACTION_CALL for HANDLER_WAIT_MS (5s in @glassly/miniapp's
6614
- // actions module) waiting for session.actions.handle; a shorter host timeout
6615
- // could fire while the target is still registering, then the action would run
6616
- // with no caller left to receive its result. 6s = that 5s buffer + 1s for the
6617
- // ACTION_RESULT/NO_ACTION_HANDLER reply to travel back. Keep these in sync.
6618
- const timeoutMs = Math.min(Math.max(Number(payload.timeoutMs) || 30_000, 6_000), 120_000)
6649
+ const outcome = await this.runActionInvoke(
6650
+ callerPackageName,
6651
+ this.isSystemPackage(callerPackageName),
6652
+ payload.targetPackageName as string | undefined,
6653
+ payload.actionId as string | undefined,
6654
+ (payload.params as Record<string, unknown> | undefined) ?? {},
6655
+ LocalMiniappRuntime.clampActionTimeout(payload.timeoutMs),
6656
+ )
6657
+ if (outcome.ok) this.sendResult(callerPackageName, requestId, true, outcome.data)
6658
+ else this.sendResult(callerPackageName, requestId, false, undefined, outcome.error)
6659
+ }
6660
+
6661
+ /**
6662
+ * Gate, wake the target headlessly, deliver an ACTION_CALL, and resolve when
6663
+ * its ACTION_RESULT (or the timeout) arrives. Never rejects.
6664
+ */
6665
+ private async runActionInvoke(
6666
+ callerPackageName: string,
6667
+ callerIsSystem: boolean,
6668
+ target: string | undefined,
6669
+ actionId: string | undefined,
6670
+ params: Record<string, unknown>,
6671
+ timeoutMs: number,
6672
+ ): Promise<ActionOutcome> {
6673
+ const fail = actionFailure
6619
6674
 
6620
6675
  if (this.actionPayloadTooLarge(params)) {
6621
- this.sendResult(callerPackageName, requestId, false, undefined, {
6622
- code: MiniappErrorCode.PAYLOAD_TOO_LARGE,
6623
- message: "action params exceeded the 256 KB cap",
6624
- })
6625
- return
6676
+ return fail(MiniappErrorCode.PAYLOAD_TOO_LARGE, "action params exceeded the 256 KB cap")
6626
6677
  }
6627
6678
 
6628
6679
  const app = target ? this.interopApps().find((a) => a.packageName === target) : undefined
6629
- if (!target || !app) {
6630
- this.sendResult(callerPackageName, requestId, false, undefined, {
6631
- code: MiniappErrorCode.APP_NOT_FOUND,
6632
- message: `Miniapp not found: ${target ?? "(missing targetPackageName)"}`,
6633
- })
6634
- return
6635
- }
6636
- if (!actionId) {
6637
- this.sendResult(callerPackageName, requestId, false, undefined, {
6638
- code: MiniappErrorCode.ACTION_NOT_FOUND,
6639
- message: "invoke requires an actionId",
6640
- })
6641
- return
6642
- }
6643
- if (app.compatibility && app.compatibility.isCompatible === false) {
6644
- this.sendResult(callerPackageName, requestId, false, undefined, {
6645
- code: MiniappErrorCode.APP_NOT_COMPATIBLE,
6646
- message: `${target} is not compatible with the connected glasses`,
6647
- })
6648
- islandNotifications.emit({
6649
- kind: "version_incompatible",
6650
- packageName: target,
6651
- reason: `${target} is not compatible with the connected glasses`,
6652
- metadata: {missingRequired: app.compatibility.missingRequired ?? [], via: "miniapps.invoke"},
6653
- timestamp: Date.now(),
6654
- })
6655
- return
6656
- }
6657
- // Declared-action gate: the target must declare the action in its manifest.
6658
- const declared = (app.actions ?? []).find((a) => a.id === actionId)
6659
- if (!declared) {
6660
- this.sendResult(callerPackageName, requestId, false, undefined, {
6661
- code: MiniappErrorCode.ACTION_NOT_FOUND,
6662
- message: `${target} does not declare action "${actionId}"`,
6663
- })
6664
- return
6665
- }
6666
- // Visibility gate: declared actions are public by default; ones marked
6667
- // `visibility: "system"` stay restricted to system/dev callers.
6668
- if (declared.visibility === "system" && !this.isSystemPackage(callerPackageName)) {
6669
- this.auditInterop({
6670
- caller: callerPackageName,
6671
- op: "invoke",
6672
- target,
6673
- actionId,
6674
- ok: false,
6675
- errorCode: MiniappErrorCode.NOT_PERMITTED,
6676
- })
6677
- this.sendResult(callerPackageName, requestId, false, undefined, {
6678
- code: MiniappErrorCode.NOT_PERMITTED,
6679
- message: `Action "${actionId}" on ${target} is restricted to system apps`,
6680
- })
6681
- return
6680
+ const denied = checkActionAccess(app, target, actionId, callerIsSystem)
6681
+ if (denied) {
6682
+ if (denied.code === MiniappErrorCode.APP_NOT_COMPATIBLE && target && app) {
6683
+ islandNotifications.emit({
6684
+ kind: "version_incompatible",
6685
+ packageName: target,
6686
+ reason: denied.message,
6687
+ metadata: {missingRequired: app.compatibility?.missingRequired ?? [], via: "miniapps.invoke"},
6688
+ timestamp: Date.now(),
6689
+ })
6690
+ } else if (denied.code === MiniappErrorCode.NOT_PERMITTED) {
6691
+ this.auditInterop({
6692
+ caller: callerPackageName,
6693
+ op: "invoke",
6694
+ target,
6695
+ actionId,
6696
+ ok: false,
6697
+ errorCode: MiniappErrorCode.NOT_PERMITTED,
6698
+ })
6699
+ }
6700
+ return {ok: false, error: denied}
6682
6701
  }
6702
+ if (!target || !actionId) return fail(MiniappErrorCode.INTERNAL, "unreachable")
6683
6703
 
6684
6704
  // Headless wake + wait for CONNECT (idempotent / fast if already connected).
6685
6705
  try {
6686
6706
  await this.wakeInteropApp(target)
6687
6707
  } catch (e) {
6688
- this.sendResult(callerPackageName, requestId, false, undefined, {
6689
- code: MiniappErrorCode.WAKE_FAILED,
6690
- message: (e as Error)?.message ?? `failed to wake ${target}`,
6691
- })
6692
- return
6708
+ return fail(MiniappErrorCode.WAKE_FAILED, (e as Error)?.message ?? `failed to wake ${target}`)
6693
6709
  }
6694
6710
 
6695
6711
  // The wake waited for CONNECT, but the target could have dropped in the gap
6696
6712
  // before delivery — fail fast rather than arming a timer for a call that
6697
- // sendToMiniapp would silently drop (the caller would otherwise wait the
6698
- // full invoke timeout for an ACTION_CALL that was never delivered).
6713
+ // sendToMiniapp would silently drop.
6699
6714
  if (!this.connectedApps.has(target)) {
6700
- this.sendResult(callerPackageName, requestId, false, undefined, {
6701
- code: MiniappErrorCode.WAKE_FAILED,
6702
- message: `${target} disconnected before the action could be delivered`,
6703
- })
6704
6715
  this.auditInterop({
6705
6716
  caller: callerPackageName,
6706
6717
  op: "invoke",
@@ -6709,19 +6720,12 @@ class LocalMiniappRuntime {
6709
6720
  ok: false,
6710
6721
  errorCode: MiniappErrorCode.WAKE_FAILED,
6711
6722
  })
6712
- return
6723
+ return fail(MiniappErrorCode.WAKE_FAILED, `${target} disconnected before the action could be delivered`)
6713
6724
  }
6714
6725
 
6715
- // Deliver the call and arm the handler timeout; ACTION_RESULT resolves it.
6726
+ // Deliver the call and arm the handler timeout; ACTION_RESULT settles it.
6716
6727
  const callId = `act-${Date.now().toString(36)}-${this.actionCallSeq++}`
6717
- const timer = BgTimer.setTimeout(() => {
6718
- this.actionCalls.delete(callId)
6719
- this.sendResult(callerPackageName, requestId, false, undefined, {
6720
- code: MiniappErrorCode.ACTION_TIMEOUT,
6721
- message: `action "${actionId}" timed out after ${timeoutMs}ms`,
6722
- })
6723
- }, timeoutMs)
6724
- this.actionCalls.set(callId, {callerPackageName, targetPackageName: target, callerRequestId: requestId, timer})
6728
+ const outcome = this.actionCalls.register(callId, target, actionId, timeoutMs)
6725
6729
  this.sendToMiniapp(target, {
6726
6730
  type: MiniappResponseType.ACTION_CALL,
6727
6731
  callId,
@@ -6730,38 +6734,12 @@ class LocalMiniappRuntime {
6730
6734
  callerPackageName,
6731
6735
  })
6732
6736
  this.auditInterop({caller: callerPackageName, op: "invoke", target, actionId, ok: true})
6737
+ return outcome
6733
6738
  }
6734
6739
 
6735
- /** Target → host: forward an ACTION_RESULT back to the caller's pending invoke. */
6740
+ /** Target → host: settle the pending invoke for this ACTION_RESULT. */
6736
6741
  private handleActionResult(targetPackageName: string, payload: Record<string, unknown>): void {
6737
- const callId = payload.callId as string | undefined
6738
- if (!callId) return
6739
- const pending = this.actionCalls.get(callId)
6740
- if (!pending) return
6741
- // Only the invoked target may resolve this call. callIds are guessable
6742
- // (timestamp + seq), so a different connected miniapp must not be able to
6743
- // spoof a result/error for someone else's invoke.
6744
- if (pending.targetPackageName !== targetPackageName) return
6745
- this.actionCalls.delete(callId)
6746
- BgTimer.clearTimeout(pending.timer)
6747
-
6748
- if (payload.ok === true) {
6749
- const result = payload.result
6750
- if (this.actionPayloadTooLarge(result)) {
6751
- this.sendResult(pending.callerPackageName, pending.callerRequestId, false, undefined, {
6752
- code: MiniappErrorCode.PAYLOAD_TOO_LARGE,
6753
- message: "action result exceeded the 256 KB cap",
6754
- })
6755
- return
6756
- }
6757
- this.sendResult(pending.callerPackageName, pending.callerRequestId, true, result ?? null)
6758
- } else {
6759
- const error = (payload.error as ({code: string; message: string} & Record<string, unknown>) | undefined) ?? {
6760
- code: MiniappErrorCode.INTERNAL,
6761
- message: "action handler error",
6762
- }
6763
- this.sendResult(pending.callerPackageName, pending.callerRequestId, false, undefined, error)
6764
- }
6742
+ this.actionCalls.handleResult(targetPackageName, payload)
6765
6743
  }
6766
6744
 
6767
6745
  private sendResult(
@@ -66,7 +66,9 @@ const MAX_SETTLE_MS = 255 * 33
66
66
  function settleTimeOf(elements: FrameElement[]): number {
67
67
  let ms = 0
68
68
  for (const el of elements) {
69
- if (el.change !== "updated" && el.change !== "moved") continue
69
+ // Entering and leaving elements tween too, from or to their offset.
70
+ const edge = el.transition?.exit !== undefined || (el.change === "created" && el.transition?.enter !== undefined)
71
+ if (el.change !== "updated" && el.change !== "moved" && !edge) continue
70
72
  const t = el.transition?.durationMs ?? 0
71
73
  // Rotation has its own millisecond clock, beyond the geometry frame limit.
72
74
  const duration = Math.min(t, el.rotation !== undefined ? 65535 : MAX_SETTLE_MS)
@@ -0,0 +1,105 @@
1
+ import {MiniappErrorCode} from "@glassly/miniapp"
2
+
3
+ import type {ClientApp} from "../types/applet"
4
+
5
+ export type ActionError = {code: string; message: string} & Record<string, unknown>
6
+ export type ActionOutcome = {ok: true; data: unknown} | {ok: false; error: ActionError}
7
+
8
+ export const actionFailure = (code: string, message: string): ActionOutcome => ({ok: false, error: {code, message}})
9
+
10
+ /** Installed, compatible, and declares the action. */
11
+ export function appDeclaresAction(app: ClientApp | undefined, actionId: string): boolean {
12
+ if (!app) return false
13
+ if (app.compatibility && app.compatibility.isCompatible === false) return false
14
+ return (app.actions ?? []).some((a) => a.id === actionId)
15
+ }
16
+
17
+ /**
18
+ * Gates shared by miniapp->miniapp invoke and host invoke. Declared actions are
19
+ * public by default; `visibility: "system"` ones need a system caller.
20
+ */
21
+ export function checkActionAccess(
22
+ app: ClientApp | undefined,
23
+ target: string | undefined,
24
+ actionId: string | undefined,
25
+ callerIsSystem: boolean,
26
+ ): ActionError | null {
27
+ if (!target || !app) {
28
+ return {
29
+ code: MiniappErrorCode.APP_NOT_FOUND,
30
+ message: `Miniapp not found: ${target ?? "(missing targetPackageName)"}`,
31
+ }
32
+ }
33
+ if (!actionId) return {code: MiniappErrorCode.ACTION_NOT_FOUND, message: "invoke requires an actionId"}
34
+ if (app.compatibility && app.compatibility.isCompatible === false) {
35
+ return {
36
+ code: MiniappErrorCode.APP_NOT_COMPATIBLE,
37
+ message: `${target} is not compatible with the connected glasses`,
38
+ }
39
+ }
40
+ const declared = (app.actions ?? []).find((a) => a.id === actionId)
41
+ if (!declared) {
42
+ return {code: MiniappErrorCode.ACTION_NOT_FOUND, message: `${target} does not declare action "${actionId}"`}
43
+ }
44
+ if (declared.visibility === "system" && !callerIsSystem) {
45
+ return {
46
+ code: MiniappErrorCode.NOT_PERMITTED,
47
+ message: `Action "${actionId}" on ${target} is restricted to system apps`,
48
+ }
49
+ }
50
+ return null
51
+ }
52
+
53
+ /** BgTimer's shape; passed in so this module stays loadable without the native timer. */
54
+ export interface ActionTimers {
55
+ setTimeout: (cb: () => void, ms: number) => number
56
+ clearTimeout: (id: number) => void
57
+ }
58
+
59
+ /** Outstanding ACTION_CALLs awaiting an ACTION_RESULT, keyed by host-generated callId. */
60
+ export class PendingActionCalls {
61
+ private readonly calls = new Map<string, {target: string; settle: (o: ActionOutcome) => void; timer: number}>()
62
+
63
+ constructor(
64
+ private readonly timers: ActionTimers,
65
+ private readonly payloadTooLarge: (value: unknown) => boolean,
66
+ ) {}
67
+
68
+ /** Register a call; resolves with its outcome, or ACTION_TIMEOUT. */
69
+ register(callId: string, target: string, actionId: string, timeoutMs: number): Promise<ActionOutcome> {
70
+ return new Promise((resolve) => {
71
+ const timer = this.timers.setTimeout(() => {
72
+ this.calls.delete(callId)
73
+ resolve(actionFailure(MiniappErrorCode.ACTION_TIMEOUT, `action "${actionId}" timed out after ${timeoutMs}ms`))
74
+ }, timeoutMs)
75
+ this.calls.set(callId, {target, settle: resolve, timer})
76
+ })
77
+ }
78
+
79
+ /** Settle from a target's ACTION_RESULT payload. Only the invoked target may resolve a call. */
80
+ handleResult(targetPackageName: string, payload: Record<string, unknown>): void {
81
+ const callId = payload.callId as string | undefined
82
+ if (!callId) return
83
+ const pending = this.calls.get(callId)
84
+ // callIds are guessable (timestamp + seq): a different miniapp must not spoof someone else's result.
85
+ if (!pending || pending.target !== targetPackageName) return
86
+ this.calls.delete(callId)
87
+ this.timers.clearTimeout(pending.timer)
88
+
89
+ if (payload.ok === true) {
90
+ if (this.payloadTooLarge(payload.result)) {
91
+ pending.settle(actionFailure(MiniappErrorCode.PAYLOAD_TOO_LARGE, "action result exceeded the 256 KB cap"))
92
+ return
93
+ }
94
+ pending.settle({ok: true, data: payload.result ?? null})
95
+ return
96
+ }
97
+ pending.settle({
98
+ ok: false,
99
+ error: (payload.error as ActionError | undefined) ?? {
100
+ code: MiniappErrorCode.INTERNAL,
101
+ message: "action handler error",
102
+ },
103
+ })
104
+ }
105
+ }