gloomberb 0.18.2 → 0.18.3
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/package.json +1 -1
- package/patches/electrobun@1.18.1.patch +106 -5
- package/src/api-client/data.ts +15 -0
- package/src/api-client/index.ts +2 -0
- package/src/api-client/request.ts +5 -3
- package/src/api-client/telemetry.ts +17 -0
- package/src/app/global-shortcuts.ts +1 -16
- package/src/app/runtime/update.ts +28 -8
- package/src/architecture/pane-tabs-allowlist.json +65 -0
- package/src/cli/commands/market.ts +73 -33
- package/src/cli/commands/ticker.ts +82 -25
- package/src/cli/help.ts +1 -1
- package/src/cli/index.ts +8 -0
- package/src/cli/issuer-membership.ts +103 -0
- package/src/cli/listing-arg.ts +15 -2
- package/src/cli/name-entries.ts +51 -12
- package/src/cli/pane-functions/capabilities.ts +2 -0
- package/src/cli/pane-functions/catalog.ts +16 -0
- package/src/cli/pane-functions/dom.ts +2 -1
- package/src/cli/pane-functions/headless.ts +25 -6
- package/src/cli/pane-functions/index.ts +64 -5
- package/src/cli/pane-functions/resolver.ts +19 -5
- package/src/cli/report-tables.ts +136 -18
- package/src/cli/result.ts +12 -4
- package/src/cli/symbol-list.ts +47 -11
- package/src/components/chart/composite/composite-chart.tsx +14 -5
- package/src/components/chart/composite/drawing-store.ts +26 -6
- package/src/components/chart/composite/interactions.ts +2 -1
- package/src/components/chart/composite/panel-surface.tsx +11 -0
- package/src/components/chart/composite/types.ts +6 -0
- package/src/components/chart/composite/use-composite-chart-keyboard.ts +1 -1
- package/src/components/command-bar/routes/root/command-items.ts +32 -6
- package/src/components/data-table/view.tsx +23 -3
- package/src/components/index.ts +1 -0
- package/src/components/layout/detached-pane-shell.tsx +1 -0
- package/src/components/layout/floating-pane.tsx +1 -0
- package/src/components/layout/header.tsx +228 -55
- package/src/components/layout/pane/header.tsx +10 -3
- package/src/components/layout/pane/index.tsx +1 -0
- package/src/components/layout/pane/pane-tabs.tsx +9 -1
- package/src/components/ui/stat-grid.tsx +28 -3
- package/src/components/ui/status.tsx +3 -1
- package/src/core/app-services.ts +4 -0
- package/src/i18n/es.ts +22 -0
- package/src/i18n/ja.ts +22 -0
- package/src/i18n/ko.ts +22 -0
- package/src/i18n/zh-cn.ts +22 -0
- package/src/i18n/zh-tw.ts +22 -0
- package/src/plugins/builtin/alerts/alert-engine.ts +3 -1
- package/src/plugins/builtin/alerts/events.ts +1 -0
- package/src/plugins/builtin/alerts/research-rules.ts +1 -0
- package/src/plugins/builtin/analytics/risk-pane.tsx +10 -7
- package/src/plugins/builtin/attention/pane.tsx +16 -14
- package/src/plugins/builtin/awards/pane.tsx +6 -6
- package/src/plugins/builtin/backtest/export.ts +1 -1
- package/src/plugins/builtin/broker-trading/dock.tsx +4 -2
- package/src/plugins/builtin/broker-trading/pane.tsx +6 -4
- package/src/plugins/builtin/broker-trading/ticket.tsx +10 -5
- package/src/plugins/builtin/buildout/pane/index.tsx +9 -6
- package/src/plugins/builtin/catalysts/client.ts +2 -2
- package/src/plugins/builtin/catalysts/pane.tsx +3 -3
- package/src/plugins/builtin/chart-composer/pane.tsx +1 -0
- package/src/plugins/builtin/commodity-balances/pane.tsx +3 -4
- package/src/plugins/builtin/company-attention/app-rank.tsx +4 -5
- package/src/plugins/builtin/company-attention/pane.tsx +13 -11
- package/src/plugins/builtin/company-kpis/pane.tsx +4 -3
- package/src/plugins/builtin/correlation/headless.ts +2 -1
- package/src/plugins/builtin/correlation/relationship/model.ts +1 -0
- package/src/plugins/builtin/correlation/settings.ts +1 -0
- package/src/plugins/builtin/cot/pane.tsx +4 -3
- package/src/plugins/builtin/credit-documents/pane.tsx +8 -7
- package/src/plugins/builtin/debt-maturities/pane.tsx +4 -3
- package/src/plugins/builtin/doe/pane.tsx +1 -1
- package/src/plugins/builtin/equity-screener/headless.ts +10 -0
- package/src/plugins/builtin/equity-screener/model.ts +28 -0
- package/src/plugins/builtin/equity-screener/pane.tsx +105 -98
- package/src/plugins/builtin/exposure/pane.tsx +10 -9
- package/src/plugins/builtin/gpu/board.tsx +4 -1
- package/src/plugins/builtin/gpu/changes.tsx +10 -10
- package/src/plugins/builtin/gpu/equities.tsx +4 -1
- package/src/plugins/builtin/gpu/history.tsx +4 -2
- package/src/plugins/builtin/gpu/pane.tsx +5 -3
- package/src/plugins/builtin/holders/client.ts +2 -1
- package/src/plugins/builtin/holders/headless.ts +2 -1
- package/src/plugins/builtin/holders/thirteenf-match.ts +3 -1
- package/src/plugins/builtin/insider/base-values.ts +11 -7
- package/src/plugins/builtin/insider/client.ts +3 -0
- package/src/plugins/builtin/insider/headless.ts +19 -6
- package/src/plugins/builtin/insider/index.tsx +17 -8
- package/src/plugins/builtin/insider/issuer.ts +34 -4
- package/src/plugins/builtin/insider/signals-client.ts +2 -2
- package/src/plugins/builtin/insider/table-model.ts +35 -13
- package/src/plugins/builtin/insider/table.tsx +5 -2
- package/src/plugins/builtin/iv-history/events.ts +145 -0
- package/src/plugins/builtin/iv-history/format.ts +3 -0
- package/src/plugins/builtin/iv-history/headless.ts +91 -7
- package/src/plugins/builtin/jodi/pane.tsx +4 -4
- package/src/plugins/builtin/market-movers/client.ts +4 -3
- package/src/plugins/builtin/market-movers/screener.ts +4 -3
- package/src/plugins/builtin/notes/cli.ts +1 -1
- package/src/plugins/builtin/notes/export.ts +17 -6
- package/src/plugins/builtin/notes/files.ts +16 -5
- package/src/plugins/builtin/options/enrichment-model.ts +10 -9
- package/src/plugins/builtin/options/model-spot.ts +70 -4
- package/src/plugins/builtin/options/view.tsx +41 -13
- package/src/plugins/builtin/options-calculator/pane.tsx +3 -2
- package/src/plugins/builtin/options-positioning/headless.ts +11 -3
- package/src/plugins/builtin/options-positioning/model.ts +24 -0
- package/src/plugins/builtin/options-positioning/pane.tsx +10 -12
- package/src/plugins/builtin/options-scenario/client.ts +19 -1
- package/src/plugins/builtin/options-scenario/headless.ts +13 -6
- package/src/plugins/builtin/options-scenario/pane.tsx +13 -5
- package/src/plugins/builtin/plugin-marketplace/pane.tsx +5 -4
- package/src/plugins/builtin/portfolio-list/cli/bulk-symbols.ts +8 -5
- package/src/plugins/builtin/power/client.ts +2 -2
- package/src/plugins/builtin/power/pane.tsx +36 -31
- package/src/plugins/builtin/reverse-dcf/client.ts +2 -1
- package/src/plugins/builtin/reverse-dcf/model.ts +4 -3
- package/src/plugins/builtin/seasonality/client.ts +2 -1
- package/src/plugins/builtin/sectors/client.ts +4 -3
- package/src/plugins/builtin/shared/cloud-resource.ts +15 -2
- package/src/plugins/builtin/shared/research-trading.tsx +2 -0
- package/src/plugins/builtin/shared/series-list-detail.tsx +3 -1
- package/src/plugins/builtin/shared/volatility/options.ts +2 -1
- package/src/plugins/builtin/social-mentions/client.ts +2 -1
- package/src/plugins/builtin/vol-surface/client.ts +23 -3
- package/src/plugins/builtin/vol-surface/headless.ts +33 -14
- package/src/plugins/builtin/vol-surface/model.ts +30 -10
- package/src/plugins/builtin/vol-surface/pane.tsx +33 -13
- package/src/plugins/builtin/vol-surface/stored.ts +34 -3
- package/src/plugins/builtin/vol-surface/tables.ts +14 -0
- package/src/renderers/dom/desktop/controls.tsx +1 -1
- package/src/renderers/dom/desktop/query-bar.tsx +2 -0
- package/src/renderers/dom/host/tabs.tsx +16 -5
- package/src/renderers/dom/host/top-surface.ts +127 -0
- package/src/renderers/dom/styles.css +39 -0
- package/src/renderers/electrobun/bun/core-capabilities.ts +9 -1
- package/src/renderers/electrobun/bun/desktop/backend.ts +10 -0
- package/src/renderers/electrobun/bun/desktop/update.ts +210 -30
- package/src/renderers/electrobun/view/news-source.ts +4 -2
- package/src/renderers/electrobun/view/remote/asset-data-client.ts +8 -1
- package/src/renderers/opentui/start.tsx +7 -0
- package/src/sources/gloomberb-cloud/index.ts +7 -0
- package/src/sources/provider-router/index.ts +16 -0
- package/src/telemetry/crash-reports.ts +4 -0
- package/src/telemetry/update-failures-node.ts +59 -0
- package/src/telemetry/update-failures.ts +307 -0
- package/src/tickers/company-name-listing.ts +9 -7
- package/src/tickers/company-name.ts +43 -0
- package/src/tickers/listing-fate.ts +30 -0
- package/src/types/data-provider.ts +6 -0
- package/src/types/headless.ts +7 -0
- package/src/types/issuer-listings.ts +28 -0
- package/src/updater/failure.ts +163 -0
- package/src/updater/index.ts +87 -23
- package/src/utils/currency-pair.ts +26 -0
- package/src/utils/sec.ts +68 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import type { UpdateFailureReport } from "../api-client";
|
|
2
|
+
import type { UpdateChannel } from "../types/config";
|
|
3
|
+
import type { UpdateFailure } from "../updater/failure";
|
|
4
|
+
import { logFailure } from "../utils/log-failure";
|
|
5
|
+
import { compareSemver } from "../utils/semver";
|
|
6
|
+
import { VERSION } from "../version";
|
|
7
|
+
import { reportCrash, type CrashReportContext } from "./crash-reports";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Failed update steps, sent to error tracking through the crash reporter (and
|
|
11
|
+
* so behind the same off switch). A report is the step, a fixed code, the
|
|
12
|
+
* versions, the channel, the platform and how the app was installed: never the
|
|
13
|
+
* error's text, which can hold a path, a user name or a signed URL.
|
|
14
|
+
*
|
|
15
|
+
* The process that runs the update reports it: the terminal app, and the
|
|
16
|
+
* desktop app's Bun process. The desktop view and the web install no host, so
|
|
17
|
+
* the same failure is never sent twice. One report per step, code and app
|
|
18
|
+
* version a day is enough to see a stuck updater; the hourly check that keeps
|
|
19
|
+
* failing offline adds nothing after the first.
|
|
20
|
+
*
|
|
21
|
+
* The desktop host also keeps what an install was about to do, so the next
|
|
22
|
+
* launch can tell an install that never took, or an update downloaded and
|
|
23
|
+
* then quit without, from one that worked (`reviewLastUpdateAttempt`).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Where the running app sits. macOS installs replace the app bundle in place,
|
|
28
|
+
* which only works from a folder the user can write: not from a disk image,
|
|
29
|
+
* and not from the read-only copy App Translocation runs a quarantined app from.
|
|
30
|
+
*/
|
|
31
|
+
export type UpdateInstallKind =
|
|
32
|
+
| "desktop-applications"
|
|
33
|
+
| "desktop-translocated"
|
|
34
|
+
| "desktop-volume"
|
|
35
|
+
| "desktop-elsewhere"
|
|
36
|
+
/** Windows and Linux: the app folder the installer created. */
|
|
37
|
+
| "desktop-app-data"
|
|
38
|
+
| "terminal-binary"
|
|
39
|
+
| "terminal-managed"
|
|
40
|
+
| "terminal-package-manager"
|
|
41
|
+
| "terminal-source"
|
|
42
|
+
| "unknown";
|
|
43
|
+
|
|
44
|
+
/** One update the app downloaded or started installing, and when. */
|
|
45
|
+
export interface UpdateAttempt {
|
|
46
|
+
from: string;
|
|
47
|
+
to: string;
|
|
48
|
+
channel: UpdateChannel;
|
|
49
|
+
at: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** What the host keeps on disk between launches. */
|
|
53
|
+
export interface UpdateReportState {
|
|
54
|
+
/** When each step, code and app version was last reported, in ms. */
|
|
55
|
+
sent: Record<string, number>;
|
|
56
|
+
/** A downloaded update waiting for the user's Restart. */
|
|
57
|
+
staged?: UpdateAttempt;
|
|
58
|
+
/** An install the user started; the next launch says whether it took. */
|
|
59
|
+
applying?: UpdateAttempt;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface UpdateFailureReporterHost {
|
|
63
|
+
installKind: UpdateInstallKind;
|
|
64
|
+
/** The running version; defaults to this build's. */
|
|
65
|
+
appVersion?: string;
|
|
66
|
+
/** `process.platform` and `process.arch`. */
|
|
67
|
+
platform: string;
|
|
68
|
+
arch: string;
|
|
69
|
+
load(): UpdateReportState;
|
|
70
|
+
save(state: UpdateReportState): void;
|
|
71
|
+
now?(): number;
|
|
72
|
+
/** Defaults to the crash reporter. */
|
|
73
|
+
send?(report: UpdateFailureReport): void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface UpdateFailureContext {
|
|
77
|
+
/** The version the update was going to, when one was known. */
|
|
78
|
+
targetVersion?: string | null;
|
|
79
|
+
channel?: UpdateChannel | null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
83
|
+
/** Older attempts say nothing about this launch: the user installed by hand, or reinstalled. */
|
|
84
|
+
const ATTEMPT_MAX_AGE_MS = 30 * DAY_MS;
|
|
85
|
+
/**
|
|
86
|
+
* The install quits the app and opens the new one as soon as it is gone. A new
|
|
87
|
+
* version first seen this long after is one the user opened again by hand.
|
|
88
|
+
*/
|
|
89
|
+
const RELAUNCH_GRACE_MS = 10 * 60 * 1000;
|
|
90
|
+
const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]{1,60})?$/;
|
|
91
|
+
|
|
92
|
+
let host: UpdateFailureReporterHost | null = null;
|
|
93
|
+
|
|
94
|
+
function cleanVersion(value: string | null | undefined): string | undefined {
|
|
95
|
+
const version = value?.trim().replace(/^v/, "");
|
|
96
|
+
return version && VERSION_PATTERN.test(version) ? version : undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function reportPlatform(platform: string): UpdateFailureReport["platform"] {
|
|
100
|
+
if (platform === "darwin") return "macos";
|
|
101
|
+
if (platform === "win32") return "windows";
|
|
102
|
+
if (platform === "linux") return "linux";
|
|
103
|
+
return "other";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function reportArch(arch: string): UpdateFailureReport["arch"] {
|
|
107
|
+
return arch === "arm64" || arch === "x64" ? arch : "other";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The report, from fixed values only: anything that is not a version, a
|
|
112
|
+
* known channel, platform or install kind is left out or named `other`.
|
|
113
|
+
*/
|
|
114
|
+
function buildUpdateFailureReport(
|
|
115
|
+
failure: UpdateFailure,
|
|
116
|
+
fields: UpdateFailureContext & {
|
|
117
|
+
appVersion: string;
|
|
118
|
+
platform: string;
|
|
119
|
+
arch: string;
|
|
120
|
+
installKind: UpdateInstallKind;
|
|
121
|
+
},
|
|
122
|
+
): UpdateFailureReport {
|
|
123
|
+
const targetVersion = cleanVersion(fields.targetVersion);
|
|
124
|
+
return {
|
|
125
|
+
phase: failure.phase,
|
|
126
|
+
code: failure.code,
|
|
127
|
+
appVersion: cleanVersion(fields.appVersion) ?? "unknown",
|
|
128
|
+
...(targetVersion ? { targetVersion } : {}),
|
|
129
|
+
channel: fields.channel === "nightly" ? "nightly" : "stable",
|
|
130
|
+
platform: reportPlatform(fields.platform),
|
|
131
|
+
arch: reportArch(fields.arch),
|
|
132
|
+
installKind: fields.installKind,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The report as the crash reporter sends it. Today's server keeps the error's
|
|
138
|
+
* type, message, plugin and pane: the message is the step and code, so error
|
|
139
|
+
* tracking groups one issue per failure, and the pane holds the rest as
|
|
140
|
+
* `key=value` pairs. The `update` field carries the same values for a server
|
|
141
|
+
* that reads it.
|
|
142
|
+
*/
|
|
143
|
+
function updateFailureCrashReport(report: UpdateFailureReport): {
|
|
144
|
+
error: { name: string; message: string };
|
|
145
|
+
context: CrashReportContext;
|
|
146
|
+
} {
|
|
147
|
+
const details = [
|
|
148
|
+
`to=${report.targetVersion ?? "none"}`,
|
|
149
|
+
`channel=${report.channel}`,
|
|
150
|
+
`platform=${report.platform}-${report.arch}`,
|
|
151
|
+
`install=${report.installKind}`,
|
|
152
|
+
].join(" ");
|
|
153
|
+
return {
|
|
154
|
+
// A plain object, not an Error: it has no stack, so no file path goes along.
|
|
155
|
+
error: { name: "UpdateFailed", message: `${report.phase}: ${report.code}` },
|
|
156
|
+
context: { kind: "plugin", plugin: "updater", pane: details, update: report },
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function sendThroughCrashReporter(report: UpdateFailureReport): void {
|
|
161
|
+
const { error, context } = updateFailureCrashReport(report);
|
|
162
|
+
reportCrash(error, context);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Takes the day's slot for `key`, or says it is used: one report per step,
|
|
167
|
+
* code and version a day. Drops what is older than a day while at it.
|
|
168
|
+
*/
|
|
169
|
+
function takeReportSlot(state: UpdateReportState, key: string, now: number): boolean {
|
|
170
|
+
for (const [entry, at] of Object.entries(state.sent)) {
|
|
171
|
+
if (!(now - at < DAY_MS) || at > now) delete state.sent[entry];
|
|
172
|
+
}
|
|
173
|
+
if (state.sent[key] !== undefined) return false;
|
|
174
|
+
state.sent[key] = now;
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function loadState(current: UpdateFailureReporterHost): UpdateReportState {
|
|
179
|
+
try {
|
|
180
|
+
const state = current.load();
|
|
181
|
+
return { ...state, sent: { ...state.sent } };
|
|
182
|
+
} catch (error) {
|
|
183
|
+
logFailure("telemetry-update-failures", "Could not read update report state")(error);
|
|
184
|
+
return { sent: {} };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function saveState(current: UpdateFailureReporterHost, state: UpdateReportState): void {
|
|
189
|
+
try {
|
|
190
|
+
current.save(state);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
logFailure("telemetry-update-failures", "Could not save update report state")(error);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function sendIfDue(
|
|
197
|
+
current: UpdateFailureReporterHost,
|
|
198
|
+
state: UpdateReportState,
|
|
199
|
+
failure: UpdateFailure,
|
|
200
|
+
context: UpdateFailureContext,
|
|
201
|
+
now: number,
|
|
202
|
+
): void {
|
|
203
|
+
const report = buildUpdateFailureReport(failure, {
|
|
204
|
+
...context,
|
|
205
|
+
appVersion: current.appVersion ?? VERSION,
|
|
206
|
+
platform: current.platform,
|
|
207
|
+
arch: current.arch,
|
|
208
|
+
installKind: current.installKind,
|
|
209
|
+
});
|
|
210
|
+
if (!takeReportSlot(state, `${report.phase}|${report.code}|${report.appVersion}`, now)) return;
|
|
211
|
+
(current.send ?? sendThroughCrashReporter)(report);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function installUpdateFailureReporter(next: UpdateFailureReporterHost): () => void {
|
|
215
|
+
host = next;
|
|
216
|
+
return () => {
|
|
217
|
+
if (host === next) host = null;
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Reports one failed step. Never throws; does nothing where no host is installed. */
|
|
222
|
+
export function reportUpdateFailure(failure: UpdateFailure, context: UpdateFailureContext = {}): void {
|
|
223
|
+
const current = host;
|
|
224
|
+
if (!current) return;
|
|
225
|
+
try {
|
|
226
|
+
const now = current.now?.() ?? Date.now();
|
|
227
|
+
const state = loadState(current);
|
|
228
|
+
// An install that failed in this run is reported here, not again at the next launch.
|
|
229
|
+
if (failure.phase === "apply" || failure.phase === "relaunch") delete state.applying;
|
|
230
|
+
sendIfDue(current, state, failure, context, now);
|
|
231
|
+
saveState(current, state);
|
|
232
|
+
} catch (error) {
|
|
233
|
+
logFailure("telemetry-update-failures", "Could not report update failure")(error);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function recordAttempt(field: "staged" | "applying", to: string, channel: UpdateChannel): void {
|
|
238
|
+
const current = host;
|
|
239
|
+
if (!current) return;
|
|
240
|
+
const state = loadState(current);
|
|
241
|
+
const attempt: UpdateAttempt = { from: current.appVersion ?? VERSION, to, channel, at: current.now?.() ?? Date.now() };
|
|
242
|
+
if (field === "applying") delete state.staged;
|
|
243
|
+
state[field] = attempt;
|
|
244
|
+
saveState(current, state);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** A downloaded update is waiting for the user's Restart. */
|
|
248
|
+
export function noteUpdateStaged(to: string, channel: UpdateChannel): void {
|
|
249
|
+
recordAttempt("staged", to, channel);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** The user pressed Restart: the next launch should be `to`. */
|
|
253
|
+
export function noteUpdateApplying(to: string, channel: UpdateChannel): void {
|
|
254
|
+
recordAttempt("applying", to, channel);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** The waiting update was taken back (a channel switch); quitting without it is no longer news. */
|
|
258
|
+
export function forgetStagedUpdate(): void {
|
|
259
|
+
const current = host;
|
|
260
|
+
if (!current) return;
|
|
261
|
+
const state = loadState(current);
|
|
262
|
+
if (!state.staged) return;
|
|
263
|
+
delete state.staged;
|
|
264
|
+
saveState(current, state);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Run once at launch: what came of the last update this app started.
|
|
269
|
+
*
|
|
270
|
+
* - Restarted into the new version: nothing to report, unless the new version
|
|
271
|
+
* only came up long after, which is the app failing to reopen itself.
|
|
272
|
+
* - Still the old version after an install: it never took.
|
|
273
|
+
* - Still the old version with a download waiting: the app was quit without
|
|
274
|
+
* restarting into it, which the stuck installs may come down to.
|
|
275
|
+
*/
|
|
276
|
+
export function reviewLastUpdateAttempt(): void {
|
|
277
|
+
const current = host;
|
|
278
|
+
if (!current) return;
|
|
279
|
+
const currentVersion = current.appVersion ?? VERSION;
|
|
280
|
+
try {
|
|
281
|
+
const state = loadState(current);
|
|
282
|
+
const { applying, staged } = state;
|
|
283
|
+
if (!applying && !staged) return;
|
|
284
|
+
delete state.applying;
|
|
285
|
+
delete state.staged;
|
|
286
|
+
const now = current.now?.() ?? Date.now();
|
|
287
|
+
const fresh = (attempt: UpdateAttempt | undefined) => !!attempt && now - attempt.at < ATTEMPT_MAX_AGE_MS && attempt.at <= now;
|
|
288
|
+
if (applying && fresh(applying)) {
|
|
289
|
+
const context = { targetVersion: applying.to, channel: applying.channel };
|
|
290
|
+
if (applying.from === currentVersion) {
|
|
291
|
+
sendIfDue(current, state, { phase: "apply", code: "not_applied" }, context, now);
|
|
292
|
+
} else if ((compareSemver(currentVersion, applying.to) ?? -1) >= 0 && now - applying.at > RELAUNCH_GRACE_MS) {
|
|
293
|
+
sendIfDue(current, state, { phase: "relaunch", code: "relaunch_missed" }, context, now);
|
|
294
|
+
}
|
|
295
|
+
} else if (staged && fresh(staged) && staged.from === currentVersion) {
|
|
296
|
+
sendIfDue(current, state, { phase: "apply", code: "not_restarted" }, { targetVersion: staged.to, channel: staged.channel }, now);
|
|
297
|
+
}
|
|
298
|
+
saveState(current, state);
|
|
299
|
+
} catch (error) {
|
|
300
|
+
logFailure("telemetry-update-failures", "Could not review the last update")(error);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Test seam. */
|
|
305
|
+
export function resetUpdateFailureReporterForTests(): void {
|
|
306
|
+
host = null;
|
|
307
|
+
}
|
|
@@ -2,7 +2,7 @@ import { logFailure } from "../utils/log-failure";
|
|
|
2
2
|
import type { DataProvider } from "../types/data-provider";
|
|
3
3
|
import type { InstrumentSearchResult } from "../types/instrument";
|
|
4
4
|
import { canonicalExchange, isUsListingExchange, parsePublicTickerKey } from "../utils/exchanges";
|
|
5
|
-
import {
|
|
5
|
+
import { companyNameMatch, shortCompanyName } from "./company-name";
|
|
6
6
|
import { LISTING_SEARCH_LIMIT } from "./search";
|
|
7
7
|
import { classifyInstrumentKind } from "./search/ranking";
|
|
8
8
|
import { isProviderBusy, type ProviderBusyError } from "../sources/provider-errors";
|
|
@@ -28,10 +28,12 @@ export function isCompanyNameInput(input: string): boolean {
|
|
|
28
28
|
/**
|
|
29
29
|
* The listing a company's name stands for when it is typed where a symbol
|
|
30
30
|
* goes, as an entry of a comma list ("Whirlpool Corporation" is WHR on
|
|
31
|
-
* NYSE). Of search's listings whose names have every word of it
|
|
32
|
-
*
|
|
31
|
+
* NYSE). Of search's listings whose names have every word of it, or a word
|
|
32
|
+
* it shortens ("philip morris intl" is Philip Morris International; the name
|
|
33
|
+
* is also searched without its legal form, which can find more), the company
|
|
33
34
|
* whose name says no more than the one typed goes first, so a subsidiary that
|
|
34
|
-
* shares the word (Whirlpool of India) does not
|
|
35
|
+
* shares the word (Whirlpool of India) does not, and of those a name matched
|
|
36
|
+
* word for word before one a short form matched; then its line on a US
|
|
35
37
|
* exchange, then a plain line before a depositary receipt, the shortest
|
|
36
38
|
* symbol (WHR before WHR-A) and search's order. Contracts, preferred shares
|
|
37
39
|
* and notes are never the listing. Null when none matches; when none does
|
|
@@ -55,10 +57,10 @@ export async function findListingOfCompanyName(
|
|
|
55
57
|
const ranked = results.flatMap((result, order) => {
|
|
56
58
|
const exchange = canonicalExchange(result.exchange === "SMART" ? result.primaryExchange : result.exchange || result.primaryExchange);
|
|
57
59
|
const type = result.brokerContract?.secType || result.type || "";
|
|
58
|
-
const
|
|
59
|
-
if (!exchange ||
|
|
60
|
+
const match = companyNameMatch(name, result.name);
|
|
61
|
+
if (!exchange || match == null || classifyInstrumentKind(type) === "derivative" || NOT_COMMON.test(type)
|
|
60
62
|
|| parsePublicTickerKey(result.symbol).exchange) return [];
|
|
61
|
-
return [{ result, exchange, rank: [extra, isUsListingExchange(exchange) ? 0 : 1, RECEIPT.test(type) ? 1 : 0, result.symbol.length, order] }];
|
|
63
|
+
return [{ result, exchange, rank: [match.extra, match.abbreviated, isUsListingExchange(exchange) ? 0 : 1, RECEIPT.test(type) ? 1 : 0, result.symbol.length, order] }];
|
|
62
64
|
}).sort((left, right) => {
|
|
63
65
|
const at = left.rank.findIndex((value, index) => value !== right.rank[index]);
|
|
64
66
|
return at < 0 ? 0 : left.rank[at]! - right.rank[at]!;
|
|
@@ -56,6 +56,49 @@ export function companyNameExtraWords(typed: string, name: string | null | undef
|
|
|
56
56
|
return words.size - wanted.size;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Whether a typed word is a short form of a name's word, as a list writes
|
|
61
|
+
* one: its first letter and the rest in order, at least three letters
|
|
62
|
+
* (`intl` for international, `hldgs` for holdings, `grp` for group).
|
|
63
|
+
*/
|
|
64
|
+
function abbreviates(short: string, word: string): boolean {
|
|
65
|
+
if (short.length < 3 || short.length >= word.length || short[0] !== word[0] || !/^\p{L}+$/u.test(short)) return false;
|
|
66
|
+
let at = 0;
|
|
67
|
+
for (const char of word) {
|
|
68
|
+
if (char === short[at]) at += 1;
|
|
69
|
+
if (at === short.length) return true;
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* `companyNameExtraWords` for a name typed with short forms, as a list or a
|
|
76
|
+
* spreadsheet writes it ("philip morris intl" for Philip Morris
|
|
77
|
+
* International Inc.): each typed word is one of the name's, or a short form
|
|
78
|
+
* of another of its words (`abbreviates`). `extra` counts the name's words
|
|
79
|
+
* left unmatched, `abbreviated` the typed words read as short forms, so an
|
|
80
|
+
* exact name ranks before one only a short form reaches. Null when a typed
|
|
81
|
+
* word is in the name neither way, or nothing was typed.
|
|
82
|
+
*/
|
|
83
|
+
export function companyNameMatch(typed: string, name: string | null | undefined): { extra: number; abbreviated: number } | null {
|
|
84
|
+
const wanted = companyWords(typed);
|
|
85
|
+
const words = companyWords(name);
|
|
86
|
+
if (wanted.size === 0) return null;
|
|
87
|
+
const matched = new Set<string>();
|
|
88
|
+
let abbreviated = 0;
|
|
89
|
+
for (const word of wanted) {
|
|
90
|
+
if (words.has(word)) {
|
|
91
|
+
matched.add(word);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const long = [...words].find((candidate) => !matched.has(candidate) && !wanted.has(candidate) && abbreviates(word, candidate));
|
|
95
|
+
if (!long) return null;
|
|
96
|
+
matched.add(long);
|
|
97
|
+
abbreviated += 1;
|
|
98
|
+
}
|
|
99
|
+
return { extra: words.size - matched.size, abbreviated };
|
|
100
|
+
}
|
|
101
|
+
|
|
59
102
|
/** Items grouped by the company their names are, in the order each company first appears. Unnamed items are left out. */
|
|
60
103
|
export function groupByCompany<T>(items: readonly T[], nameOf: (item: T) => string | null | undefined): T[][] {
|
|
61
104
|
const groups: T[][] = [];
|
|
@@ -235,6 +235,36 @@ export function listingFateNotes(fate: ListingFate, served: { served: boolean; f
|
|
|
235
235
|
].filter((line): line is string => !!line);
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
+
/**
|
|
239
|
+
* A report's notes and errors beside what became of its symbol, worded as
|
|
240
|
+
* `quote`, `history` and `options` word it: with nothing served, the fate is
|
|
241
|
+
* why ("TWTR: Delisted Oct 27, 2022", "FB now lists ...; Meta Platforms
|
|
242
|
+
* trades as META") and stands in for the errors the empty load raised ("Not
|
|
243
|
+
* a ticker", "No options provider"); with data, `listingFateNotes` beside
|
|
244
|
+
* the errors. A symbol whose company moved, with nothing listed under it now,
|
|
245
|
+
* has nothing of its own to serve and throws `MovedSymbolError`.
|
|
246
|
+
*/
|
|
247
|
+
export function listingFateReport(
|
|
248
|
+
fate: ListingFate,
|
|
249
|
+
served: { served: boolean; firstDate?: string | null },
|
|
250
|
+
errors: readonly string[],
|
|
251
|
+
): { notes: string[]; errors: string[] } {
|
|
252
|
+
if (fate.moved) throw new MovedSymbolError(fate.moved);
|
|
253
|
+
const notes = listingFateNotes(fate, served);
|
|
254
|
+
return { notes, errors: !served.served && notes.length > 0 ? [] : [...errors] };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* A report that served nothing for its symbol (`listingFateReport`): what
|
|
259
|
+
* became of the symbol is the one reason it is empty, said once in place of
|
|
260
|
+
* every table's "No data." and of the load's errors; the errors alone when
|
|
261
|
+
* nothing became of it.
|
|
262
|
+
*/
|
|
263
|
+
export function listingFateEmpty(fate: ListingFate, errors: readonly string[]): { empty?: string; errors: string[] } {
|
|
264
|
+
const report = listingFateReport(fate, { served: false }, errors);
|
|
265
|
+
return report.notes.length ? { empty: report.notes.join(" "), errors: report.errors } : { errors: report.errors };
|
|
266
|
+
}
|
|
267
|
+
|
|
238
268
|
/**
|
|
239
269
|
* What a report or loader with nothing of its own to serve for a symbol
|
|
240
270
|
* throws when the symbol's company moved (`ListingFate.moved`): its message
|
|
@@ -16,6 +16,7 @@ import type { BrokerContractRef, InstrumentSearchResult } from "./instrument";
|
|
|
16
16
|
import type { CachePolicyMap } from "./persistence";
|
|
17
17
|
import type { CachedQueryHandle } from "../data/cached-query";
|
|
18
18
|
import type { TickerChanges } from "./ticker-changes";
|
|
19
|
+
import type { IssuerListings } from "./issuer-listings";
|
|
19
20
|
|
|
20
21
|
export type CachedAssetMethod = "getExchangeRate" | "getHolders" | "getAnalystResearch" | "getCorporateActions"
|
|
21
22
|
| "getOptionsChain" | "getSecFilings" | "getSecFilingDocuments" | "getSecFilingContent" | "getArticleSummary";
|
|
@@ -252,6 +253,11 @@ export interface AssetDataProvider {
|
|
|
252
253
|
* answer for is left out; null when the source cannot answer a batch at all.
|
|
253
254
|
*/
|
|
254
255
|
getTickerChangesBatch?(symbols: string[], options?: { signal?: AbortSignal }): Promise<Map<string, TickerChanges | null> | null>;
|
|
256
|
+
/**
|
|
257
|
+
* Every listing of the company a listing belongs to (RCI and RCI.B:TSX are
|
|
258
|
+
* Rogers). Null when the source does not hold the company.
|
|
259
|
+
*/
|
|
260
|
+
getIssuerListings?(symbol: string, exchange?: string, options?: { signal?: AbortSignal }): Promise<IssuerListings | null>;
|
|
255
261
|
/**
|
|
256
262
|
* Several searches in a few requests, each answered with the rows `search`
|
|
257
263
|
* gives it from this source (up to `limit`), by query as asked. A query the
|
package/src/types/headless.ts
CHANGED
|
@@ -542,6 +542,13 @@ export interface HeadlessPaneDefinition<Shape extends HeadlessPaneShape = Headle
|
|
|
542
542
|
discovery?: {
|
|
543
543
|
id?: string;
|
|
544
544
|
aliases?: string[];
|
|
545
|
+
/**
|
|
546
|
+
* Words that are no function's code but ask for this function's data
|
|
547
|
+
* (IVOL, IV for implied volatility). They do not open it, since one word
|
|
548
|
+
* can mean several functions: `fn IVOL` answers with the functions it
|
|
549
|
+
* means and the commands that run them, and `catalog ivol` lists them first.
|
|
550
|
+
*/
|
|
551
|
+
suggestFor?: string[];
|
|
545
552
|
intents?: string[];
|
|
546
553
|
dataRequirements?: string[];
|
|
547
554
|
limitations?: string[];
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every listing of one company, as Gloom Cloud's company universe groups
|
|
3
|
+
* them: share classes, a US receipt and its home line, dual listings. RCI on
|
|
4
|
+
* the NYSE and RCI.B on the TSX are both Rogers Communications.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** One listing, written as the service writes it: bare on a US venue (RCI), SYMBOL:EXCHANGE elsewhere (RCI.B:TSX). */
|
|
8
|
+
interface IssuerListing {
|
|
9
|
+
symbol: string;
|
|
10
|
+
/** The canonical venue code. */
|
|
11
|
+
exchange: string;
|
|
12
|
+
name: string;
|
|
13
|
+
/** The company's primary line. */
|
|
14
|
+
primary: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface IssuerListings {
|
|
18
|
+
/** The service's key for the company (cik:733099). */
|
|
19
|
+
issuer: string;
|
|
20
|
+
name: string;
|
|
21
|
+
/**
|
|
22
|
+
* Where the company is from, ISO 3166 alpha-2: its primary line's country,
|
|
23
|
+
* or the SEC address of a foreign company whose only lines are in the US.
|
|
24
|
+
*/
|
|
25
|
+
country: string;
|
|
26
|
+
/** The primary line first. */
|
|
27
|
+
listings: IssuerListing[];
|
|
28
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why an update step failed, as a fixed phase and code rather than the error
|
|
3
|
+
* text: the header says the reason in a few words, and error tracking gets
|
|
4
|
+
* the same two values, which carry no path, URL or account name.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** The step that failed. `relaunch` is getting back into the app after an install. */
|
|
8
|
+
export type UpdateFailurePhase = "check" | "download" | "verify" | "apply" | "relaunch";
|
|
9
|
+
|
|
10
|
+
const UPDATE_FAILURE_CODES = [
|
|
11
|
+
/** No route to the release server: offline, DNS, refused, TLS. */
|
|
12
|
+
"offline",
|
|
13
|
+
"timeout",
|
|
14
|
+
/** GitHub answered 403 or 429, its limit for requests without an account. */
|
|
15
|
+
"rate_limited",
|
|
16
|
+
"server_error",
|
|
17
|
+
/** The release has no such file (404). */
|
|
18
|
+
"not_found",
|
|
19
|
+
/** Any other refusal from the release server. */
|
|
20
|
+
"http_error",
|
|
21
|
+
/** The release list or manifest did not read as one. */
|
|
22
|
+
"bad_response",
|
|
23
|
+
/** The release has no build for this platform, or none with a checksum. */
|
|
24
|
+
"no_build",
|
|
25
|
+
/** The download stopped before the whole file arrived. */
|
|
26
|
+
"interrupted",
|
|
27
|
+
/** The file arrived whole but does not match its checksum, or does not unpack. */
|
|
28
|
+
"corrupt",
|
|
29
|
+
"permission_denied",
|
|
30
|
+
/** The macOS app runs from where it cannot be replaced: a disk image, App Translocation, another volume. */
|
|
31
|
+
"wrong_location",
|
|
32
|
+
/** The downloaded update and the installed app are on different disks, so the one cannot be moved onto the other. */
|
|
33
|
+
"cross_device",
|
|
34
|
+
"read_only",
|
|
35
|
+
"disk_full",
|
|
36
|
+
/** Another process holds the files being replaced. */
|
|
37
|
+
"file_in_use",
|
|
38
|
+
/** The update downloaded earlier is no longer where the install expects it. */
|
|
39
|
+
"staged_missing",
|
|
40
|
+
/** A restart into the update came back on the old version. */
|
|
41
|
+
"not_applied",
|
|
42
|
+
/** The app quit with a downloaded update waiting and was opened again on the old version. */
|
|
43
|
+
"not_restarted",
|
|
44
|
+
/** The install finished but the app did not come back by itself. */
|
|
45
|
+
"relaunch_missed",
|
|
46
|
+
"unknown",
|
|
47
|
+
] as const;
|
|
48
|
+
|
|
49
|
+
export type UpdateFailureCode = typeof UPDATE_FAILURE_CODES[number];
|
|
50
|
+
|
|
51
|
+
export interface UpdateFailure {
|
|
52
|
+
phase: UpdateFailurePhase;
|
|
53
|
+
code: UpdateFailureCode;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** What an HTTP status from the release server means for an update. */
|
|
57
|
+
export function httpStatusFailureCode(status: number): UpdateFailureCode {
|
|
58
|
+
if (status === 403 || status === 429) return "rate_limited";
|
|
59
|
+
if (status === 404 || status === 410) return "not_found";
|
|
60
|
+
if (status >= 500) return "server_error";
|
|
61
|
+
return "http_error";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const OFFLINE_ERRNO = new Set([
|
|
65
|
+
"ENOTFOUND",
|
|
66
|
+
"EAI_AGAIN",
|
|
67
|
+
"ECONNREFUSED",
|
|
68
|
+
"ENETUNREACH",
|
|
69
|
+
"EHOSTUNREACH",
|
|
70
|
+
"ENETDOWN",
|
|
71
|
+
// Bun's fetch
|
|
72
|
+
"ConnectionRefused",
|
|
73
|
+
"FailedToOpenSocket",
|
|
74
|
+
"UnableToConnect",
|
|
75
|
+
"CERT_HAS_EXPIRED",
|
|
76
|
+
"UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
|
|
77
|
+
"SELF_SIGNED_CERT_IN_CHAIN",
|
|
78
|
+
]);
|
|
79
|
+
const INTERRUPTED_ERRNO = new Set(["ECONNRESET", "EPIPE", "ECONNABORTED", "ConnectionClosed"]);
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The code for an error a step threw, from its system error code first and its
|
|
83
|
+
* message second. Only the code leaves the machine; the message stays local.
|
|
84
|
+
*/
|
|
85
|
+
export function classifyUpdateError(error: unknown): UpdateFailureCode {
|
|
86
|
+
const record = (error && typeof error === "object" ? error : {}) as { code?: unknown; name?: unknown; message?: unknown };
|
|
87
|
+
const message = typeof record.message === "string" ? record.message : typeof error === "string" ? error : "";
|
|
88
|
+
// A system error code, or one named in the message ("Failed to replace app: EACCES: ...").
|
|
89
|
+
const errno = typeof record.code === "string" ? record.code : /\b(E[A-Z]{3,})\b/.exec(message)?.[1] ?? "";
|
|
90
|
+
switch (errno) {
|
|
91
|
+
case "EACCES":
|
|
92
|
+
case "EPERM":
|
|
93
|
+
return "permission_denied";
|
|
94
|
+
case "EROFS":
|
|
95
|
+
return "read_only";
|
|
96
|
+
case "ENOSPC":
|
|
97
|
+
case "EDQUOT":
|
|
98
|
+
return "disk_full";
|
|
99
|
+
case "EBUSY":
|
|
100
|
+
case "ETXTBSY":
|
|
101
|
+
return "file_in_use";
|
|
102
|
+
case "EXDEV":
|
|
103
|
+
return "cross_device";
|
|
104
|
+
case "ETIMEDOUT":
|
|
105
|
+
return "timeout";
|
|
106
|
+
}
|
|
107
|
+
if (OFFLINE_ERRNO.has(errno)) return "offline";
|
|
108
|
+
if (INTERRUPTED_ERRNO.has(errno)) return "interrupted";
|
|
109
|
+
if (record.name === "TimeoutError" || record.name === "AbortError") return "timeout";
|
|
110
|
+
|
|
111
|
+
const status = /\b(?:HTTP|returned|status|failed:?)\s*\(?(\d{3})\b/i.exec(message)?.[1];
|
|
112
|
+
if (status) return httpStatusFailureCode(Number(status));
|
|
113
|
+
if (/checksum mismatch|hash mismatch/i.test(message)) return "corrupt";
|
|
114
|
+
if (/zstd|decompress|gunzip|incorrect header|unexpected end of (?:file|data)/i.test(message)) return "corrupt";
|
|
115
|
+
if (/timed? ?out/i.test(message)) return "timeout";
|
|
116
|
+
if (/rate limit/i.test(message)) return "rate_limited";
|
|
117
|
+
if (/read-only file system/i.test(message)) return "read_only";
|
|
118
|
+
if (/cross-device/i.test(message)) return "cross_device";
|
|
119
|
+
if (/permission denied|operation not permitted|access is denied/i.test(message)) return "permission_denied";
|
|
120
|
+
if (/no space left|disk full/i.test(message)) return "disk_full";
|
|
121
|
+
if (/resource busy|text file busy|being used by another process/i.test(message)) return "file_in_use";
|
|
122
|
+
if (/connection (?:reset|closed|aborted)|socket hang up|premature close|terminated/i.test(message)) return "interrupted";
|
|
123
|
+
if (/unable to connect|fetch failed|failed to fetch|network|getaddrinfo|could not resolve|certificate/i.test(message)) return "offline";
|
|
124
|
+
if (/invalid update\.json|unexpected .*payload|json/i.test(message)) return "bad_response";
|
|
125
|
+
return "unknown";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** When the failure has no more specific reason: what the step was. */
|
|
129
|
+
const PHASE_REASONS: Record<UpdateFailurePhase, string> = {
|
|
130
|
+
check: "check failed",
|
|
131
|
+
download: "download failed",
|
|
132
|
+
verify: "could not verify the download",
|
|
133
|
+
apply: "could not install",
|
|
134
|
+
relaunch: "could not restart",
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const CODE_REASONS: Partial<Record<UpdateFailureCode, string>> = {
|
|
138
|
+
offline: "no connection",
|
|
139
|
+
timeout: "connection timed out",
|
|
140
|
+
rate_limited: "GitHub rate limit",
|
|
141
|
+
server_error: "release server error",
|
|
142
|
+
not_found: "release server error",
|
|
143
|
+
http_error: "release server error",
|
|
144
|
+
bad_response: "unreadable release data",
|
|
145
|
+
no_build: "no build for this system",
|
|
146
|
+
interrupted: "download interrupted",
|
|
147
|
+
corrupt: "download corrupted",
|
|
148
|
+
permission_denied: "no permission to replace the app",
|
|
149
|
+
wrong_location: "move the app to Applications",
|
|
150
|
+
cross_device: "app and update on different disks",
|
|
151
|
+
read_only: "install folder is read-only",
|
|
152
|
+
disk_full: "disk full",
|
|
153
|
+
file_in_use: "app files in use",
|
|
154
|
+
staged_missing: "downloaded update missing",
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The reason in a few words, in English (the caller translates it), sized
|
|
159
|
+
* for the header: "download interrupted", "no permission to replace the app".
|
|
160
|
+
*/
|
|
161
|
+
export function updateFailureReason(failure: UpdateFailure): string {
|
|
162
|
+
return CODE_REASONS[failure.code] ?? PHASE_REASONS[failure.phase];
|
|
163
|
+
}
|