Skip to content

Commit f40c46e

Browse files
authored
Merge pull request #674 from straff2002/fix/support-report-findings
Six fixes from a tester's support report, and build 460
2 parents 9f9f57e + 631d708 commit f40c46e

22 files changed

Lines changed: 562 additions & 39 deletions

‎OpenGlasses/Info.plist‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,8 @@
122122
</array>
123123
<key>CFBundleVersion</key>
124124
<string>$(CURRENT_PROJECT_VERSION)</string>
125+
<key>AvenkinSourceCommit</key>
126+
<string>$(AVENKIN_SOURCE_COMMIT)</string>
125127
<key>LSApplicationQueriesSchemes</key>
126128
<array>
127129
<string>fb-viewapp</string>

‎OpenGlasses/Sources/App/OpenGlassesApp.swift‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,21 @@ func privacyRoute(for url: URL) -> PrivacyLog.DeepLinkRoute {
3333
}
3434
}
3535

36+
/// Which of the app's own actions a link asked for, when that is one of a fixed few. Never a
37+
/// persona or quick-action id: those are the wearer's own and identify them.
38+
func privacyAction(for url: URL) -> PrivacyToken? {
39+
switch url.host {
40+
case "connect", "disconnect":
41+
return url.host.map { PrivacyToken($0) }
42+
case "action", "listen":
43+
let action = url.lastPathComponent
44+
return ["ask", "photo", "describe", "on", "off", "toggle"].contains(action)
45+
? PrivacyToken(action) : nil
46+
default:
47+
return nil
48+
}
49+
}
50+
3651
private func processWearablesCallbackURL(_ url: URL, source: String) {
3752
let sourceToken = PrivacyToken(source)
3853
PrivacyLog.deepLink(route: .wearablesCallback, source: sourceToken, verdict: .received)
@@ -159,7 +174,18 @@ final class BackgroundSessionCompletionStore {
159174
final class OpenGlassesSceneDelegate: NSObject, UIWindowSceneDelegate {
160175
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
161176
for context in URLContexts {
162-
processWearablesCallbackURL(context.url, source: "SceneDelegate")
177+
let url = context.url
178+
// The app's own links (widget, Control Centre, Action Button) arrive here as well as
179+
// at the SwiftUI `onOpenURL`, which is what acts on them. They used to be handed to
180+
// the glasses SDK and logged as its callback, so a photo asked for from a widget read
181+
// as Meta AI calling back (support report, 2026-10-05). Named here for what they are.
182+
let route = privacyRoute(for: url)
183+
if DeepLinkScheme.isApp(url), route != .other {
184+
PrivacyLog.deepLink(route: route, source: PrivacyToken("SceneDelegate"),
185+
verdict: .received, action: privacyAction(for: url))
186+
continue
187+
}
188+
processWearablesCallbackURL(url, source: "SceneDelegate")
163189
}
164190
}
165191

@@ -2205,6 +2231,12 @@ class AppState: ObservableObject, AppStateProtocol {
22052231
// isn't preferred while offline (it'd fail the network call and fall back anyway).
22062232
speechService.reachability = reachability
22072233

2234+
// The cloud voice ran out of credit or refused the key: say so on screen, once. The
2235+
// wearer otherwise only hears a different voice (the spoken half is the service's own).
2236+
speechService.onCloudVoiceRejected = { [weak self] rejection in
2237+
self?.errorMessage = rejection.banner
2238+
}
2239+
22082240
// Wire Tier 1 services
22092241
ambientCaptions.wakeWordService = wakeWordService
22102242
ambientCaptions.glassesDisplay = glassesDisplay

‎OpenGlasses/Sources/App/SupportReporting.swift‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,19 +104,28 @@ extension AppState {
104104
/// What only the running app knows: the phone, the glasses, the app's event log and the
105105
/// debug log, and the configured secrets the report is masked with.
106106
func supportEnvironment() -> JobTranscriptExporter.Environment {
107-
let info = Bundle.main.infoDictionary
107+
let app = AppBuildIdentity.current
108108
var phone = [
109-
"App: \(info?["CFBundleShortVersionString"] as? String ?? "–") (\(info?["CFBundleVersion"] as? String ?? "–"))",
109+
"App: \(app.version) (\(app.build))",
110+
"Source: " + (app.commit.map { "\($0) when the Xcode project was generated" } ?? "not stamped"),
111+
"Installed from: \(app.channel.rawValue) (\(app.bundleID))",
110112
"System: \(UIDevice.current.systemName) \(UIDevice.current.systemVersion)",
111113
"Device: \(Self.hardwareIdentifier)",
114+
// The app's language and region, then the phone's own list: `en_MX` alone cannot say
115+
// whether the phone is in English or in a language the app does not ship.
112116
"Language: \(Locale.current.identifier)",
117+
"Phone languages: \(Locale.preferredLanguages.prefix(3).joined(separator: ", "))",
113118
"Mode: \(currentMode.rawValue)",
114119
"AI model: \(Config.activeModel?.name ?? "none set")",
115120
"Transcription preference: \(Config.asrEnginePreference.rawValue)",
116121
]
117122
if isConnected {
118123
var glasses = "Glasses: connected"
119-
if let name = glassesService.deviceName { glasses += " — \(name)" }
124+
// A pair the SDK has not named yet reports an empty name, not a missing one.
125+
if let name = glassesService.deviceName?.trimmingCharacters(in: .whitespaces),
126+
!name.isEmpty {
127+
glasses += " — \(name)"
128+
}
120129
if let battery = glassesService.batteryLevel { glasses += ", battery \(battery)%" }
121130
phone.append(glasses)
122131
phone.append("Glasses display: \(glassesDisplay.hasDisplayCapability ? "yes" : "no")")
@@ -130,7 +139,7 @@ extension AppState {
130139
let events = (ring.previousEntries + ring.entries).map {
131140
JobTranscriptExport.AppEvent(at: $0.timestamp, line: $0.line)
132141
}
133-
return .init(phone: phone, appEvents: events, debugLog: Array(debugEvents.suffix(60)),
142+
return .init(phone: phone, app: app.summary, appEvents: events, debugLog: Array(debugEvents.suffix(60)),
134143
secrets: Config.knownSecretValues)
135144
}
136145

‎OpenGlasses/Sources/Services/Camera/MetaCameraBackend.swift‎

Lines changed: 40 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,18 @@ final class MetaCameraBackend: GlassesCameraBackend {
301301
}
302302

303303
private func ensureSessionLocked() async throws {
304-
guard streamSession == nil else { return }
304+
if streamSession != nil {
305+
// A stream is only as alive as the session it hangs off. A session that has stopped
306+
// under a parked stream (device-reported 2026-10-05: an `unexpectedError` half a
307+
// minute after a capture, then nothing for sixteen minutes) leaves a stream whose
308+
// `start()` fails at once, and the next capture spent its first attempt finding that
309+
// out. Anything short of stopped is left alone — a hold lifts on its own.
310+
guard let session = deviceSession,
311+
session.state == .stopped || session.state == .stopping else { return }
312+
PrivacyLog.camera(.glasses, .staleSessionDropped,
313+
state: PrivacyToken(String(describing: session.state)))
314+
await resetSessionLocked()
315+
}
305316

306317
// Fresh attempt, fresh verdict: an error from a previous attempt must not abort this one.
307318
lastSessionError = nil
@@ -775,24 +786,13 @@ final class MetaCameraBackend: GlassesCameraBackend {
775786

776787
// MARK: - Photo Capture
777788

778-
/// Capture a photo from the glasses camera. Returns JPEG data.
789+
/// Bring the session up, waiting out the windows in which a healthy pair cannot be reached.
779790
///
780-
/// NOTE (device-reported, unverified here): `capturePhoto` also works on a stream that
781-
/// was added but never `start()`ed, and capturing on a *streaming* stream has been
782-
/// reported to race the frame flow and trip the glasses-side frame-stall watchdog.
783-
/// Our start-then-capture path is field-traced and carries the frame fallback, so it
784-
/// stays; if captures ever start stalling the stream, try the no-start discrete path
785-
/// before reaching for bigger hammers.
786-
func capturePhoto() async throws -> Data {
787-
isCaptureInProgress = true
788-
defer { isCaptureInProgress = false }
789-
// Cleared as well as cancelled: a cancelled task that is still referenced would keep
790-
// counting towards `scheduledWorkCount`, which is meant to be the truth about what is armed.
791-
idleTeardownTask?.cancel() // a capture during the idle grace keeps the session
792-
idleTeardownTask = nil
793-
794-
try await ensurePermission()
795-
791+
/// Shared by the first start of a capture and the rebuild after a failed warm-up. The rebuild
792+
/// used to get a single try with no wait, so a session the glasses refused in the first second
793+
/// back from the background ended the capture there (device-reported 2026-10-05), in exactly
794+
/// the window the first start is given twelve seconds to sit out.
795+
private func ensureSessionWithRetry() async throws {
796796
// The DAT link drops when the glasses idle (battery saving) and discovery lags app
797797
// launch by seconds — a session created in that window throws noEligibleDevice
798798
// ("all discovered devices are powered off or disconnected") even though the glasses
@@ -846,6 +846,27 @@ final class MetaCameraBackend: GlassesCameraBackend {
846846
}
847847
throw rootError
848848
}
849+
}
850+
851+
/// Capture a photo from the glasses camera. Returns JPEG data.
852+
///
853+
/// NOTE (device-reported, unverified here): `capturePhoto` also works on a stream that
854+
/// was added but never `start()`ed, and capturing on a *streaming* stream has been
855+
/// reported to race the frame flow and trip the glasses-side frame-stall watchdog.
856+
/// Our start-then-capture path is field-traced and carries the frame fallback, so it
857+
/// stays; if captures ever start stalling the stream, try the no-start discrete path
858+
/// before reaching for bigger hammers.
859+
func capturePhoto() async throws -> Data {
860+
isCaptureInProgress = true
861+
defer { isCaptureInProgress = false }
862+
// Cleared as well as cancelled: a cancelled task that is still referenced would keep
863+
// counting towards `scheduledWorkCount`, which is meant to be the truth about what is armed.
864+
idleTeardownTask?.cancel() // a capture during the idle grace keeps the session
865+
idleTeardownTask = nil
866+
867+
try await ensurePermission()
868+
869+
try await ensureSessionWithRetry()
849870

850871
// Wait for stream to be ready (start if needed). Both attempts get the full warmup window
851872
// — see `StreamRecoveryPolicy.warmupTimeout` for why a shortened first attempt punished
@@ -863,7 +884,7 @@ final class MetaCameraBackend: GlassesCameraBackend {
863884
if attempt < 2 {
864885
// Reset session and retry
865886
await resetSession()
866-
try await ensureSession()
887+
try await ensureSessionWithRetry()
867888
try await Task.sleep(nanoseconds: 1_000_000_000)
868889
}
869890
}

‎OpenGlasses/Sources/Services/FieldAssist/Job/JobTranscriptExport.swift‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,9 @@ enum JobTranscriptExport {
9090
var jobEvents: [String: [SessionLogger.Event]] = [:]
9191
/// The phone, app and glasses, one fact per line.
9292
var phone: [String] = []
93+
/// Which copy of the app wrote this (`AppBuildIdentity.summary`), for the header: the
94+
/// first thing anyone reading a report needs, and it used to sit below the transcript.
95+
var app: String?
9396
/// The app's event log (`DiagnosticRing`) within the scope's window.
9497
var appEvents: [AppEvent] = []
9598
/// The newest lines of the in-app debug log.
@@ -313,9 +316,9 @@ enum JobTranscriptExport {
313316
var out: [String] = [
314317
"Avenkin — \(title)",
315318
"Exported \(format.dateTime(exportedAt)) (\(format.offset(exportedAt)))",
316-
"",
317-
preamble,
318319
]
320+
if let app = details?.app { out.append("App \(app)") }
321+
out += ["", preamble]
319322
if troubleshooting { out += ["", troubleshootingPreamble] }
320323

321324
// Which traces belong where. A trace is claimed by the first thing it matches, so no turn
@@ -586,6 +589,7 @@ enum JobTranscriptExporter {
586589
/// the configured secrets to mask.
587590
struct Environment {
588591
var phone: [String] = []
592+
var app: String?
589593
var appEvents: [JobTranscriptExport.AppEvent] = []
590594
var debugLog: [String] = []
591595
var secrets: [String] = []
@@ -680,6 +684,7 @@ enum JobTranscriptExporter {
680684
traces: scoped,
681685
jobEvents: events,
682686
phone: environment.phone,
687+
app: environment.app,
683688
appEvents: environment.appEvents.filter { $0.at >= window.start && $0.at < window.end },
684689
debugLog: environment.debugLog)
685690
}

‎OpenGlasses/Sources/Services/GeminiLive/GeminiLiveSessionManager.swift‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -661,6 +661,9 @@ class GeminiLiveSessionManager: ObservableObject {
661661
"""
662662
}
663663

664+
// Where the assistant's own settings live — the same block Direct Mode carries.
665+
prompt += "\n\n" + SystemPromptBuilder.appGuide(appName: RegistrationFlow.appName)
666+
664667
// Add tool instructions
665668
let hasNativeTools = nativeToolRouter != nil
666669
let hasOpenClaw = Config.isOpenClawAgentActive // BK P0: don't advertise the gateway with Agent Mode off

‎OpenGlasses/Sources/Services/LLMService.swift‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,11 @@ class LLMService: ObservableObject {
484484

485485
closeBlock("vision guidance")
486486

487+
// Where the assistant's own settings live. Unconditional and in the stable head: a question
488+
// about the app matches no classifier section, and the text never changes turn to turn.
489+
prompt += "\n\n" + SystemPromptBuilder.appGuide(appName: RegistrationFlow.appName)
490+
closeBlock("app guide")
491+
487492
if includeTools && shouldInclude(.tools) {
488493
var toolSection = """
489494

‎OpenGlasses/Sources/Services/SystemPromptBuilder.swift‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,30 @@ enum SystemPromptBuilder {
3434
return rules.map(flatten).joined(separator: "\n")
3535
}
3636

37+
/// Where the assistant's own settings live in this app, so "how do I change your voice?" gets
38+
/// the real path. Without it the model has never heard of the app it runs in and answers from
39+
/// general knowledge — the iPhone's Settings, the glasses' own app, Siri — none of which hold
40+
/// these settings (support report, 2026-10-05). The category names come from `SettingsCatalog`,
41+
/// the hub's own source, so the guide cannot name a screen the hub does not draw.
42+
static func appGuide(appName: String) -> String {
43+
func title(_ id: SettingsCategoryID) -> String { SettingsCatalog.category(id).title }
44+
return """
45+
THIS APP:
46+
You run inside the \(appName) app on the user's iPhone, and the app's own Settings tab is where you are set up. \
47+
When asked how to change something about you, give the path below. These settings are NOT in the iPhone's \
48+
Settings app, the glasses' companion app or Siri — never send the user there for them.
49+
- Your voice (voice engine, ElevenLabs key and voice, built-in iOS voice): Settings > \(title(.connections)) > Services & Integrations.
50+
- Your name, the wake phrase, push-to-talk and hands-free triggers: Settings > \(title(.voice)).
51+
- The AI model, personas and system prompt: Settings > \(title(.intelligence)).
52+
- Tools and quick actions: Settings > \(title(.tools)).
53+
- The glasses, microphone and privacy: Settings > \(title(.devices)).
54+
- Theme and languages: Settings > \(title(.lookAndFeel)).
55+
The one exception: a better-sounding built-in voice is downloaded in the iPhone's Settings > Accessibility > \
56+
Spoken Content > Voices, and then picked under iOS Voice in the app.
57+
You cannot change these settings yourself. Some are hidden in Simple Mode or on a phone an organisation manages.
58+
"""
59+
}
60+
3761
/// Collapse a multi-line tool description into one line so it reads as a single bullet.
3862
private static func flatten(_ description: String) -> String {
3963
description
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import Foundation
2+
3+
/// Why the cloud voice turned a request away, when the wearer is the only one who can fix it.
4+
///
5+
/// A network blip or a server error falls through to the next engine and is nobody's business.
6+
/// These two are different: they repeat on every reply until the wearer tops up or corrects the
7+
/// key, and until now the only sign was a worse voice with no explanation (support report,
8+
/// 2026-10-05 — the wearer went looking for a voice setting to undo a change they never made).
9+
enum CloudVoiceRejection: String, Equatable, CaseIterable {
10+
/// The account has no credit left. ElevenLabs sends this as a 401 whose body names
11+
/// `quota_exceeded`, so the status alone cannot tell it from a bad key.
12+
case outOfCredit
13+
/// The key, or the voice chosen for it, was refused.
14+
case refused
15+
16+
static func classify(statusCode: Int, body: String) -> CloudVoiceRejection? {
17+
if body.contains("quota_exceeded") { return .outOfCredit }
18+
switch statusCode {
19+
case 401, 402, 403: return .refused
20+
default: return nil
21+
}
22+
}
23+
24+
/// Said once, after the reply it interrupted. The path is spoken as words: a wearer who is not
25+
/// looking at the phone has to be able to find it later from memory.
26+
var spokenLine: String {
27+
switch self {
28+
case .outOfCredit:
29+
return String(localized: "By the way, your ElevenLabs credit has run out, so I'm using the built-in voice. You can change voices in the app's Settings, under Connections, then Services and Integrations.")
30+
case .refused:
31+
return String(localized: "By the way, ElevenLabs refused the voice request, so I'm using the built-in voice. Check the key and voice in the app's Settings, under Connections, then Services and Integrations.")
32+
}
33+
}
34+
35+
/// The same news for the screen.
36+
var banner: String {
37+
switch self {
38+
case .outOfCredit:
39+
return String(localized: "ElevenLabs is out of credit, so replies use the built-in voice. Change voices in Settings › Connections › Services & Integrations.")
40+
case .refused:
41+
return String(localized: "ElevenLabs refused the voice request, so replies use the built-in voice. Check the key and voice in Settings › Connections › Services & Integrations.")
42+
}
43+
}
44+
}
45+
46+
/// When a rejection is worth saying aloud. Once per episode, not once per launch: a wearer who has
47+
/// decided to live with the built-in voice should not be told again every morning. The episode ends
48+
/// when the cloud voice speaks again or the wearer changes the key or voice.
49+
struct CloudVoiceFallbackAnnouncer {
50+
static let defaultsKey = "cloudVoiceFallbackAnnounced"
51+
52+
var defaults: UserDefaults = .standard
53+
54+
/// The line to add to this utterance, or nil when this rejection has been said already or the
55+
/// utterance is not one to append to.
56+
///
57+
/// - Parameter plainReply: an ordinary reply at neutral urgency. An alert or a warning is
58+
/// never lengthened with housekeeping, and stays unannounced until the next ordinary reply.
59+
mutating func lineToSpeak(for rejection: CloudVoiceRejection, plainReply: Bool) -> String? {
60+
guard plainReply, defaults.string(forKey: Self.defaultsKey) != rejection.rawValue else {
61+
return nil
62+
}
63+
defaults.set(rejection.rawValue, forKey: Self.defaultsKey)
64+
return rejection.spokenLine
65+
}
66+
67+
/// The cloud voice is working, or its key or voice has changed: the next rejection is news.
68+
func episodeEnded() {
69+
defaults.removeObject(forKey: Self.defaultsKey)
70+
}
71+
}

0 commit comments

Comments
 (0)