@remodex/rmx 1.0.3 → 1.0.4
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/README.md +20 -5
- package/gui/dist/assets/index-1SDbgh2-.css +1 -0
- package/gui/dist/assets/{index-Cy432rMC.js → index-D3MaPif3.js} +13 -13
- package/gui/dist/index.html +2 -2
- package/package.json +5 -5
- package/src/android-remote/cloudflare-tunnel.ts +30 -8
- package/src/cli/help.ts +19 -6
- package/src/cli/index.ts +13 -75
- package/src/cli/init.ts +7 -1
- package/src/cli/internal-dispatch.ts +0 -6
- package/src/cli/onboard.ts +599 -0
- package/src/cli/system-command.ts +1 -85
- package/src/codex/inject.ts +8 -1
- package/src/codex/sync.ts +3 -0
- package/src/server/management/android-remote-routes.ts +1 -1
- package/src/server/management/config-routes.ts +0 -80
- package/src/server/management/context.ts +0 -4
- package/src/tray/windows-tray.ps1 +109 -66
- package/src/tray/windows.ts +7 -1
- package/src/update/job.ts +54 -6
- package/src/update/notify.ts +2 -2
- package/gui/dist/assets/index-CZqebSPQ.css +0 -1
- package/src/update/desktop-release.ts +0 -1620
package/src/codex/inject.ts
CHANGED
|
@@ -778,6 +778,8 @@ export function chooseCatalogPathForInjection(
|
|
|
778
778
|
|
|
779
779
|
export interface CodexInjectResult {
|
|
780
780
|
success: boolean;
|
|
781
|
+
/** Whether plain Codex routing in the main config.toml is owned by Remodex. */
|
|
782
|
+
routingApplied?: boolean;
|
|
781
783
|
message: string;
|
|
782
784
|
status?: "skipped";
|
|
783
785
|
skippedReason?: "desired_disabled" | "desired_enabled";
|
|
@@ -842,6 +844,7 @@ export async function injectCodexConfig(
|
|
|
842
844
|
if (!shouldSyncCodexOnStart(loadConfig())) {
|
|
843
845
|
return {
|
|
844
846
|
success: true,
|
|
847
|
+
routingApplied: false,
|
|
845
848
|
status: "skipped",
|
|
846
849
|
skippedReason: "desired_disabled",
|
|
847
850
|
message: "Codex integration is OFF; no Codex config, catalog, cache, or history was changed.",
|
|
@@ -868,10 +871,12 @@ export async function injectCodexConfig(
|
|
|
868
871
|
: undefined;
|
|
869
872
|
return {
|
|
870
873
|
success: true,
|
|
874
|
+
routingApplied: false,
|
|
871
875
|
...(nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning } : {}),
|
|
872
876
|
message:
|
|
873
877
|
`${adoptedProviderMessage}` +
|
|
874
878
|
`Codex config preserved byte-for-byte with external model_provider ${tomlString(activeProvider)}.\n` +
|
|
879
|
+
` This is why the main ${CODEX_CONFIG_PATH} did not change.\n` +
|
|
875
880
|
` Remodex Codex profile: ${CODEX_PROFILE_PATH} (codex --profile opencodex)\n` +
|
|
876
881
|
(profileCatalogPath
|
|
877
882
|
? ` Codex model catalog: ${profileCatalogPath}\n`
|
|
@@ -1267,6 +1272,7 @@ export async function injectCodexConfig(
|
|
|
1267
1272
|
if (keptUserBaseUrl) {
|
|
1268
1273
|
return {
|
|
1269
1274
|
success: true,
|
|
1275
|
+
routingApplied: false,
|
|
1270
1276
|
...(nativeSubagentDefaultsWarning
|
|
1271
1277
|
? { nativeSubagentDefaultsWarning }
|
|
1272
1278
|
: {}),
|
|
@@ -1275,7 +1281,7 @@ export async function injectCodexConfig(
|
|
|
1275
1281
|
catalogMessage +
|
|
1276
1282
|
historyMessage +
|
|
1277
1283
|
managedDefaultsMessage +
|
|
1278
|
-
` To route plain codex through the proxy, remove your openai_base_url line from
|
|
1284
|
+
` To route plain codex through the proxy, remove your openai_base_url line from ${CODEX_CONFIG_PATH} and rerun 'rmx sync'.\n` +
|
|
1279
1285
|
` Reference config: ${CODEX_PROFILE_PATH}`,
|
|
1280
1286
|
};
|
|
1281
1287
|
}
|
|
@@ -1284,6 +1290,7 @@ export async function injectCodexConfig(
|
|
|
1284
1290
|
: `Pointed Codex's built-in openai provider at the Remodex proxy (openai_base_url).\n`;
|
|
1285
1291
|
return {
|
|
1286
1292
|
success: true,
|
|
1293
|
+
routingApplied: true,
|
|
1287
1294
|
...(nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning } : {}),
|
|
1288
1295
|
message:
|
|
1289
1296
|
headline +
|
package/src/codex/sync.ts
CHANGED
|
@@ -25,6 +25,8 @@ export interface CodexSyncResult {
|
|
|
25
25
|
catalogExists: boolean;
|
|
26
26
|
catalogWritten: boolean;
|
|
27
27
|
cacheSynced: boolean;
|
|
28
|
+
/** Whether plain Codex routing in config.toml is owned by Remodex. */
|
|
29
|
+
routingApplied?: boolean;
|
|
28
30
|
message: string;
|
|
29
31
|
warning?: string;
|
|
30
32
|
comboOmissions?: ComboCatalogOmission[];
|
|
@@ -226,6 +228,7 @@ export async function syncModelsToCodex(
|
|
|
226
228
|
catalogExists,
|
|
227
229
|
catalogWritten,
|
|
228
230
|
cacheSynced,
|
|
231
|
+
...(result.routingApplied !== undefined ? { routingApplied: result.routingApplied } : {}),
|
|
229
232
|
message: result.message,
|
|
230
233
|
...(warning ? { warning } : {}),
|
|
231
234
|
...(comboOmissions.length > 0 ? { comboOmissions } : {}),
|
|
@@ -134,7 +134,7 @@ async function statusDTO(
|
|
|
134
134
|
? await controller.cloudflareConfiguration()
|
|
135
135
|
: {
|
|
136
136
|
mode: state.settings.tunnelMode,
|
|
137
|
-
...(state.settings.namedTunnelHostname
|
|
137
|
+
...(state.settings.tunnelMode === "named" && state.settings.namedTunnelHostname
|
|
138
138
|
? { namedHostname: state.settings.namedTunnelHostname }
|
|
139
139
|
: {}),
|
|
140
140
|
hasNamedTunnelToken: false,
|
|
@@ -377,86 +377,6 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
377
377
|
return jsonResponse({ ok: true, job });
|
|
378
378
|
}
|
|
379
379
|
|
|
380
|
-
/**
|
|
381
|
-
* Native desktop releases are deliberately separate from the npm runtime
|
|
382
|
-
* updater above. The manifest/check/download state is shared with the
|
|
383
|
-
* Tauri tray through `desktop-update.json`; a normal browser can inspect it
|
|
384
|
-
* and check GitHub. Installer launch never crosses this management route;
|
|
385
|
-
* only the exact-origin Tauri bridge can request it.
|
|
386
|
-
*/
|
|
387
|
-
if (url.pathname === "/api/desktop-update/check" && req.method === "GET") {
|
|
388
|
-
const checkDesktopReleaseUpdate = deps.checkDesktopReleaseUpdate
|
|
389
|
-
?? (await import("../../update/desktop-release")).checkDesktopReleaseUpdate;
|
|
390
|
-
const rawChannel = url.searchParams.get("channel");
|
|
391
|
-
if (rawChannel && rawChannel !== "latest" && rawChannel !== "preview") {
|
|
392
|
-
return jsonResponse({ error: "channel must be latest or preview" }, 400, req, config);
|
|
393
|
-
}
|
|
394
|
-
return jsonResponse(
|
|
395
|
-
await checkDesktopReleaseUpdate(
|
|
396
|
-
rawChannel === "preview" ? "preview" : "latest",
|
|
397
|
-
// A management request is never proof that a verified native shell
|
|
398
|
-
// initiated it. Browser callers may check and download only; the
|
|
399
|
-
// Tauri bridge owns installation.
|
|
400
|
-
{ desktopShell: () => false },
|
|
401
|
-
),
|
|
402
|
-
200,
|
|
403
|
-
req,
|
|
404
|
-
config,
|
|
405
|
-
);
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
if (url.pathname === "/api/desktop-update/run" && req.method === "POST") {
|
|
409
|
-
const desktopRelease = await import("../../update/desktop-release");
|
|
410
|
-
const startDesktopUpdateJob = deps.startDesktopUpdateJob
|
|
411
|
-
?? desktopRelease.startDesktopUpdateJob;
|
|
412
|
-
let body: unknown;
|
|
413
|
-
try {
|
|
414
|
-
body = await readManagementJsonBody(req);
|
|
415
|
-
} catch (error) {
|
|
416
|
-
rethrowManagementBodyTooLarge(error);
|
|
417
|
-
return jsonResponse({ error: "invalid JSON body" }, 400, req, config);
|
|
418
|
-
}
|
|
419
|
-
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
420
|
-
return jsonResponse({ error: "invalid JSON body" }, 400, req, config);
|
|
421
|
-
}
|
|
422
|
-
const update = body as { channel?: unknown; install?: unknown };
|
|
423
|
-
if (update.channel !== undefined && update.channel !== "latest" && update.channel !== "preview") {
|
|
424
|
-
return jsonResponse({ error: "channel must be latest or preview" }, 400, req, config);
|
|
425
|
-
}
|
|
426
|
-
if (update.install !== undefined && typeof update.install !== "boolean") {
|
|
427
|
-
return jsonResponse({ error: "install boolean is required" }, 400, req, config);
|
|
428
|
-
}
|
|
429
|
-
if (update.install === true) {
|
|
430
|
-
return jsonResponse(
|
|
431
|
-
{
|
|
432
|
-
error: "Open the Remodex desktop application to install this update.",
|
|
433
|
-
code: "desktop_required",
|
|
434
|
-
},
|
|
435
|
-
409,
|
|
436
|
-
req,
|
|
437
|
-
config,
|
|
438
|
-
);
|
|
439
|
-
}
|
|
440
|
-
try {
|
|
441
|
-
const job = startDesktopUpdateJob(
|
|
442
|
-
update.channel === "preview" ? "preview" : "latest",
|
|
443
|
-
{ install: false },
|
|
444
|
-
);
|
|
445
|
-
return jsonResponse({ ok: true, state: job }, 202, req, config);
|
|
446
|
-
} catch (error) {
|
|
447
|
-
if (error instanceof desktopRelease.DesktopUpdateError) {
|
|
448
|
-
return jsonResponse({ error: error.message, code: error.code }, error.status, req, config);
|
|
449
|
-
}
|
|
450
|
-
return jsonResponse({ error: "desktop update could not start", code: "download_failed" }, 500, req, config);
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
if (url.pathname === "/api/desktop-update/status" && req.method === "GET") {
|
|
455
|
-
const readDesktopUpdateState = deps.readDesktopUpdateState
|
|
456
|
-
?? (await import("../../update/desktop-release")).readDesktopUpdateState;
|
|
457
|
-
return jsonResponse({ ok: true, state: readDesktopUpdateState() }, 200, req, config);
|
|
458
|
-
}
|
|
459
|
-
|
|
460
380
|
if (url.pathname === "/api/sidecar-settings" && req.method === "GET") {
|
|
461
381
|
const ws = config.webSearchSidecar ?? {};
|
|
462
382
|
const vs = config.visionSidecar ?? {};
|
|
@@ -12,10 +12,6 @@ import type { AndroidRemoteGatewayController } from "../../android-remote/gatewa
|
|
|
12
12
|
import type { AndroidRemoteCloudflareProvisioner } from "../../android-remote/cloudflare-provisioning";
|
|
13
13
|
|
|
14
14
|
export interface ManagementApiDeps {
|
|
15
|
-
/** Native desktop-update seams keep route tests off the network and real owner state. */
|
|
16
|
-
checkDesktopReleaseUpdate?: typeof import("../../update/desktop-release").checkDesktopReleaseUpdate;
|
|
17
|
-
startDesktopUpdateJob?: typeof import("../../update/desktop-release").startDesktopUpdateJob;
|
|
18
|
-
readDesktopUpdateState?: typeof import("../../update/desktop-release").readDesktopUpdateState;
|
|
19
15
|
/** Android Remote persistence seam. Tests inject an in-memory store. */
|
|
20
16
|
androidRemoteStore?: AndroidRemoteStore;
|
|
21
17
|
/** Shared Android Remote gateway. Production and management routes use one controller. */
|
|
@@ -109,7 +109,7 @@ function Start-OcxCommand([string[]]$CommandArgs, [switch]$TrackExit) {
|
|
|
109
109
|
return $true
|
|
110
110
|
} catch {
|
|
111
111
|
Write-ActionLog "launch failed: $($_.Exception.GetType().Name)"
|
|
112
|
-
$notify.ShowBalloonTip(5000, "Remodex action failed", "The action could not start.
|
|
112
|
+
$notify.ShowBalloonTip(5000, "Remodex action failed", "The action could not start. Run rmx doctor for details.", [System.Windows.Forms.ToolTipIcon]::Error)
|
|
113
113
|
return $false
|
|
114
114
|
}
|
|
115
115
|
}
|
|
@@ -152,20 +152,27 @@ function Read-JsonUrl([string]$Url) {
|
|
|
152
152
|
|
|
153
153
|
$notify = New-Object System.Windows.Forms.NotifyIcon
|
|
154
154
|
$menu = New-Object System.Windows.Forms.ContextMenuStrip
|
|
155
|
-
$statusItem =
|
|
156
|
-
$
|
|
157
|
-
$safetyItem = New-Object System.Windows.Forms.ToolStripMenuItem
|
|
158
|
-
$safetyItem.Enabled = $false
|
|
159
|
-
$openItem = $menu.Items.Add("Open Dashboard")
|
|
160
|
-
$startItem = $menu.Items.Add("Start Proxy")
|
|
161
|
-
$stopItem = $menu.Items.Add("Stop Proxy and Restore Native Routing")
|
|
162
|
-
$restartItem = $menu.Items.Add("Restart Proxy")
|
|
155
|
+
$statusItem = $menu.Items.Add("🔴 Offline · Refresh")
|
|
156
|
+
$openItem = $menu.Items.Add("Open dashboard")
|
|
163
157
|
[void]$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator))
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
$
|
|
158
|
+
$proxyLifecycleItem = $menu.Items.Add("Start Proxy")
|
|
159
|
+
$applyChangesItem = $menu.Items.Add("Apply Changes")
|
|
160
|
+
$restartCodexItem = $menu.Items.Add("Restart Codex")
|
|
161
|
+
$restartRemodexItem = $menu.Items.Add("Restart Remodex")
|
|
167
162
|
[void]$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator))
|
|
168
|
-
$
|
|
163
|
+
$restartDesktopItem = $menu.Items.Add("Restart desktop application (advanced)…")
|
|
164
|
+
$checkUpdateItem = $menu.Items.Add("Check package updates")
|
|
165
|
+
[void]$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator))
|
|
166
|
+
$exitItem = $menu.Items.Add("Quit desktop shell")
|
|
167
|
+
|
|
168
|
+
$actionItems = @(
|
|
169
|
+
$proxyLifecycleItem,
|
|
170
|
+
$applyChangesItem,
|
|
171
|
+
$restartCodexItem,
|
|
172
|
+
$restartRemodexItem,
|
|
173
|
+
$restartDesktopItem,
|
|
174
|
+
$checkUpdateItem
|
|
175
|
+
)
|
|
169
176
|
|
|
170
177
|
$script:online = $false
|
|
171
178
|
$script:port = 10100
|
|
@@ -175,8 +182,29 @@ $script:pendingStarted = 0L
|
|
|
175
182
|
$script:pendingDeadline = 0L
|
|
176
183
|
$script:pendingOldProxyPid = $null
|
|
177
184
|
$script:pendingProcess = $null
|
|
185
|
+
$script:pendingExpectation = $null
|
|
186
|
+
$script:pendingItem = $null
|
|
187
|
+
|
|
188
|
+
function Reset-ActionLabels {
|
|
189
|
+
$proxyLifecycleItem.Text = if ($script:online) { "Stop Proxy" } else { "Start Proxy" }
|
|
190
|
+
$applyChangesItem.Text = "Apply Changes"
|
|
191
|
+
$restartCodexItem.Text = "Restart Codex"
|
|
192
|
+
$restartRemodexItem.Text = "Restart Remodex"
|
|
193
|
+
$restartDesktopItem.Text = "Restart desktop application (advanced)…"
|
|
194
|
+
$checkUpdateItem.Text = "Check package updates"
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function Update-ActionAvailability {
|
|
198
|
+
$busy = $null -ne $script:pendingAction
|
|
199
|
+
foreach ($item in $actionItems) { $item.Enabled = -not $busy }
|
|
200
|
+
}
|
|
178
201
|
|
|
179
|
-
function Set-PendingAction(
|
|
202
|
+
function Set-PendingAction(
|
|
203
|
+
[string]$Action,
|
|
204
|
+
[int]$TimeoutSeconds,
|
|
205
|
+
[ValidateSet("online", "offline", "restarted", "process")][string]$Expectation,
|
|
206
|
+
[System.Windows.Forms.ToolStripMenuItem]$Item
|
|
207
|
+
) {
|
|
180
208
|
if ($null -ne $script:pendingAction) {
|
|
181
209
|
Write-ActionLog "$Action ignored because $($script:pendingAction) is still pending"
|
|
182
210
|
return $false
|
|
@@ -193,6 +221,9 @@ function Set-PendingAction([string]$Action, [int]$TimeoutSeconds) {
|
|
|
193
221
|
$script:pendingStarted = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
|
|
194
222
|
$script:pendingDeadline = $script:pendingStarted + ($TimeoutSeconds * 1000)
|
|
195
223
|
$script:pendingOldProxyPid = $script:proxyPid
|
|
224
|
+
$script:pendingExpectation = $Expectation
|
|
225
|
+
$script:pendingItem = $Item
|
|
226
|
+
Update-ActionAvailability
|
|
196
227
|
return $true
|
|
197
228
|
}
|
|
198
229
|
|
|
@@ -200,6 +231,8 @@ function Complete-PendingAction([bool]$Success) {
|
|
|
200
231
|
if ($null -eq $script:pendingAction) { return }
|
|
201
232
|
$action = $script:pendingAction
|
|
202
233
|
$script:pendingAction = $null
|
|
234
|
+
$script:pendingExpectation = $null
|
|
235
|
+
$script:pendingItem = $null
|
|
203
236
|
if ($null -ne $script:pendingProcess) {
|
|
204
237
|
try {
|
|
205
238
|
$script:pendingProcess.Dispose()
|
|
@@ -213,7 +246,29 @@ function Complete-PendingAction([bool]$Success) {
|
|
|
213
246
|
$notify.ShowBalloonTip(2500, "Remodex", "$action completed.", [System.Windows.Forms.ToolTipIcon]::Info)
|
|
214
247
|
} else {
|
|
215
248
|
Write-ActionLog "$action failed to reach the expected state"
|
|
216
|
-
$notify.ShowBalloonTip(5000, "Remodex action failed", "$action did not reach the expected state.
|
|
249
|
+
$notify.ShowBalloonTip(5000, "Remodex action failed", "$action did not reach the expected state. Run rmx doctor for details.", [System.Windows.Forms.ToolTipIcon]::Error)
|
|
250
|
+
}
|
|
251
|
+
Reset-ActionLabels
|
|
252
|
+
Update-ActionAvailability
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function Start-PendingCommand(
|
|
256
|
+
[string]$Action,
|
|
257
|
+
[string]$ProgressLabel,
|
|
258
|
+
[string[]]$CommandArgs,
|
|
259
|
+
[int]$TimeoutSeconds,
|
|
260
|
+
[ValidateSet("online", "offline", "restarted", "process")][string]$Expectation,
|
|
261
|
+
[System.Windows.Forms.ToolStripMenuItem]$Item
|
|
262
|
+
) {
|
|
263
|
+
if (-not (Set-PendingAction -Action $Action -TimeoutSeconds $TimeoutSeconds -Expectation $Expectation -Item $Item)) {
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
$Item.Text = $ProgressLabel
|
|
267
|
+
$pending = Start-OcxCommand $CommandArgs -TrackExit
|
|
268
|
+
if ($pending -is [System.Diagnostics.Process]) {
|
|
269
|
+
$script:pendingProcess = $pending
|
|
270
|
+
} else {
|
|
271
|
+
Complete-PendingAction $false
|
|
217
272
|
}
|
|
218
273
|
}
|
|
219
274
|
|
|
@@ -226,34 +281,27 @@ function Update-TrayState {
|
|
|
226
281
|
$pidMatches = $null -eq $target.pid -or [int]$target.pid -eq [int]$health.pid
|
|
227
282
|
$script:online = $null -ne $health -and $health.status -eq "ok" -and $health.service -eq "opencodex" -and [int]$health.port -eq $script:port -and $pidMatches
|
|
228
283
|
$script:proxyPid = if ($script:online) { [int]$health.pid } else { $null }
|
|
284
|
+
$degraded = $false
|
|
229
285
|
if ($script:online) {
|
|
230
|
-
$statusItem.Text = "Proxy: Online (port $($script:port))"
|
|
231
286
|
$notify.Text = "Remodex: Online"
|
|
232
|
-
$startItem.Enabled = $false
|
|
233
|
-
$stopItem.Enabled = $true
|
|
234
|
-
$restartItem.Enabled = $true
|
|
235
287
|
try {
|
|
236
288
|
$startup = Read-JsonUrl "$origin/api/startup-health"
|
|
237
|
-
$
|
|
238
|
-
$
|
|
239
|
-
$notify.Icon = if ($startup.status -eq "at-risk") { $warningIcon } else { $onlineIcon }
|
|
289
|
+
$degraded = $startup.status -eq "at-risk"
|
|
290
|
+
$notify.Icon = if ($degraded) { $warningIcon } else { $onlineIcon }
|
|
240
291
|
} catch {
|
|
241
|
-
$
|
|
292
|
+
$degraded = $true
|
|
242
293
|
$notify.Icon = $warningIcon
|
|
243
294
|
}
|
|
295
|
+
$dot = if ($degraded) { "🟠" } else { "🟢" }
|
|
296
|
+
$label = if ($degraded) { "Degraded" } else { "Ready" }
|
|
297
|
+
$statusItem.Text = "$dot $label · port $($script:port) · PID $($script:proxyPid) · Refresh"
|
|
244
298
|
} else {
|
|
245
|
-
$statusItem.Text = "
|
|
246
|
-
$safetyItem.Text = "Restart safety: start the proxy to inspect"
|
|
299
|
+
$statusItem.Text = "🔴 Offline · Refresh"
|
|
247
300
|
$notify.Text = "Remodex: Offline"
|
|
248
301
|
$notify.Icon = $offlineIcon
|
|
249
|
-
$startItem.Enabled = $true
|
|
250
|
-
$stopItem.Enabled = $false
|
|
251
|
-
$restartItem.Enabled = $false
|
|
252
302
|
}
|
|
253
303
|
if ($null -ne $script:pendingAction) {
|
|
254
|
-
$
|
|
255
|
-
$stopItem.Enabled = $false
|
|
256
|
-
$restartItem.Enabled = $false
|
|
304
|
+
$statusItem.Text = "🟡 $($script:pendingAction)…"
|
|
257
305
|
}
|
|
258
306
|
$heartbeat = @{ pid = $PID; timestamp = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() }
|
|
259
307
|
if ($HostPid -gt 0) { $heartbeat.hostPid = $HostPid }
|
|
@@ -263,13 +311,15 @@ function Update-TrayState {
|
|
|
263
311
|
if ($null -ne $script:pendingAction) {
|
|
264
312
|
$now = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
|
|
265
313
|
$elapsed = $now - $script:pendingStarted
|
|
266
|
-
$reached = ($script:
|
|
267
|
-
($script:
|
|
268
|
-
($script:
|
|
314
|
+
$reached = ($script:pendingExpectation -eq "online" -and $script:online) -or
|
|
315
|
+
($script:pendingExpectation -eq "offline" -and -not $script:online) -or
|
|
316
|
+
($script:pendingExpectation -eq "restarted" -and $elapsed -gt 3000 -and $script:online -and $script:proxyPid -ne $script:pendingOldProxyPid)
|
|
269
317
|
$commandFailed = $false
|
|
318
|
+
$commandComplete = $false
|
|
270
319
|
if ($null -ne $script:pendingProcess) {
|
|
271
320
|
try {
|
|
272
|
-
$
|
|
321
|
+
$commandComplete = $script:pendingProcess.HasExited
|
|
322
|
+
$commandFailed = $commandComplete -and $script:pendingProcess.ExitCode -ne 0
|
|
273
323
|
} catch {
|
|
274
324
|
Write-ActionLog "pending process result inspection failed: $($_.Exception.GetType().Name)"
|
|
275
325
|
# If we cannot inspect the tracked command, we cannot prove it is still
|
|
@@ -279,52 +329,45 @@ function Update-TrayState {
|
|
|
279
329
|
}
|
|
280
330
|
}
|
|
281
331
|
if ($commandFailed) { Complete-PendingAction $false }
|
|
332
|
+
elseif ($script:pendingExpectation -eq "process" -and $commandComplete) { Complete-PendingAction $true }
|
|
282
333
|
elseif ($reached) { Complete-PendingAction $true }
|
|
283
334
|
elseif ($now -gt $script:pendingDeadline) { Complete-PendingAction $false }
|
|
284
335
|
}
|
|
336
|
+
if ($null -eq $script:pendingAction) { Reset-ActionLabels }
|
|
337
|
+
Update-ActionAvailability
|
|
285
338
|
}
|
|
286
339
|
|
|
287
340
|
$openItem.add_Click({ Start-OcxCommand @("gui") })
|
|
288
|
-
$
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
# service start can spend 20s and the CLI then observes health for another 40s.
|
|
292
|
-
$startProcess = Start-OcxCommand @("__tray-start") -TrackExit
|
|
293
|
-
if ($startProcess -is [System.Diagnostics.Process]) {
|
|
294
|
-
$script:pendingProcess = $startProcess
|
|
295
|
-
} else {
|
|
296
|
-
Complete-PendingAction $false
|
|
297
|
-
}
|
|
341
|
+
$statusItem.add_Click({
|
|
342
|
+
$statusItem.Text = "↻ Checking runtime…"
|
|
343
|
+
Update-TrayState
|
|
298
344
|
})
|
|
299
|
-
$
|
|
300
|
-
if (
|
|
301
|
-
|
|
302
|
-
$stopProcess = Start-OcxCommand @("stop") -TrackExit
|
|
303
|
-
if ($stopProcess -is [System.Diagnostics.Process]) {
|
|
304
|
-
$script:pendingProcess = $stopProcess
|
|
345
|
+
$proxyLifecycleItem.add_Click({
|
|
346
|
+
if ($script:online) {
|
|
347
|
+
Start-PendingCommand -Action "Stop Proxy" -ProgressLabel "Stopping Proxy…" -CommandArgs @("stop") -TimeoutSeconds 20 -Expectation "offline" -Item $proxyLifecycleItem
|
|
305
348
|
} else {
|
|
306
|
-
|
|
349
|
+
# service start can spend 20s and the CLI then observes health for another 40s.
|
|
350
|
+
Start-PendingCommand -Action "Start Proxy" -ProgressLabel "Starting Proxy…" -CommandArgs @("__tray-start") -TimeoutSeconds 75 -Expectation "online" -Item $proxyLifecycleItem
|
|
307
351
|
}
|
|
308
352
|
})
|
|
309
|
-
$
|
|
310
|
-
|
|
311
|
-
|
|
353
|
+
$applyChangesItem.add_Click({
|
|
354
|
+
Start-PendingCommand -Action "Apply Changes" -ProgressLabel "Applying changes…" -CommandArgs @("sync") -TimeoutSeconds 180 -Expectation "process" -Item $applyChangesItem
|
|
355
|
+
})
|
|
356
|
+
$restartCodexItem.add_Click({
|
|
357
|
+
Start-PendingCommand -Action "Restart Codex" -ProgressLabel "Restarting Codex…" -CommandArgs @("__desktop-restart-codex") -TimeoutSeconds 60 -Expectation "process" -Item $restartCodexItem
|
|
358
|
+
})
|
|
359
|
+
$restartRemodexItem.add_Click({
|
|
312
360
|
# /api/system/restart may drain active work for 60s and then spend up to 70s
|
|
313
361
|
# handing off to an identity-verified replacement. The tray observes health/PID
|
|
314
362
|
# rather than the detached CLI exit, so keep a watchdog margin around that shared
|
|
315
363
|
# lifecycle budget. The CLI remains the lifecycle owner; the tray never kills.
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
Complete-PendingAction $false
|
|
321
|
-
}
|
|
364
|
+
Start-PendingCommand -Action "Restart Remodex" -ProgressLabel "Restarting Remodex…" -CommandArgs @("__tray-restart") -TimeoutSeconds 160 -Expectation "restarted" -Item $restartRemodexItem
|
|
365
|
+
})
|
|
366
|
+
$restartDesktopItem.add_Click({
|
|
367
|
+
Start-PendingCommand -Action "Restart desktop application" -ProgressLabel "Restarting desktop application…" -CommandArgs @("__desktop-restart-client") -TimeoutSeconds 60 -Expectation "process" -Item $restartDesktopItem
|
|
322
368
|
})
|
|
323
|
-
$
|
|
324
|
-
|
|
325
|
-
$psi.FileName = $OpenCodexHome
|
|
326
|
-
$psi.UseShellExecute = $true
|
|
327
|
-
[void][System.Diagnostics.Process]::Start($psi)
|
|
369
|
+
$checkUpdateItem.add_Click({
|
|
370
|
+
Start-PendingCommand -Action "Open package updater" -ProgressLabel "Opening package updater…" -CommandArgs @("gui", "--update") -TimeoutSeconds 90 -Expectation "process" -Item $checkUpdateItem
|
|
328
371
|
})
|
|
329
372
|
$exitItem.add_Click({ [System.Windows.Forms.Application]::Exit() })
|
|
330
373
|
$notify.add_DoubleClick({ Start-OcxCommand @("gui") })
|
package/src/tray/windows.ts
CHANGED
|
@@ -647,7 +647,13 @@ export function installWindowsTray(startNow = true): WindowsTrayStatus {
|
|
|
647
647
|
try {
|
|
648
648
|
const hardenedDir = hardenSecretDir(getConfigDir(), { required: true });
|
|
649
649
|
if (!hardenedDir.ok) throw new Error("Windows tray directory ACL hardening did not complete; refusing to install persistence.");
|
|
650
|
-
|
|
650
|
+
// Windows PowerShell 5.1 treats a BOM-less script as the active ANSI code
|
|
651
|
+
// page. The parity menu deliberately uses the same Unicode status dots and
|
|
652
|
+
// ellipsis as the desktop shell, so install the trusted source as UTF-8 BOM.
|
|
653
|
+
replaceWindowsTrayOwnedFile(
|
|
654
|
+
entry.script,
|
|
655
|
+
Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), readFileSync(sourceScript)]),
|
|
656
|
+
);
|
|
651
657
|
for (const pair of iconPairs) replaceWindowsTrayOwnedFile(pair.installed, readFileSync(pair.source));
|
|
652
658
|
replaceWindowsTrayOwnedFile(launcherPath, Buffer.from("\uFEFF" + buildWindowsTrayLauncherScript(entry), "utf16le"));
|
|
653
659
|
runRegistry(["add", RUN_KEY, "/v", runValue, "/t", "REG_SZ", "/d", runCommand, "/f", "/reg:64"]);
|
package/src/update/job.ts
CHANGED
|
@@ -43,7 +43,7 @@ import {
|
|
|
43
43
|
type NpmCachePreflightReason,
|
|
44
44
|
} from "./npm-cache-preflight.mjs";
|
|
45
45
|
|
|
46
|
-
const
|
|
46
|
+
const RELEASES_URL = "https://github.com/ESCANOR-001/remodex-android/releases";
|
|
47
47
|
const UPDATE_JOB_FILENAME = "update-job.json";
|
|
48
48
|
const UPDATE_TIMEOUT_MS = 180_000;
|
|
49
49
|
const RESTART_TIMEOUT_MS = 60_000;
|
|
@@ -65,6 +65,8 @@ export interface UpdateCheckResult {
|
|
|
65
65
|
canUpdate: boolean;
|
|
66
66
|
command: string;
|
|
67
67
|
releaseNotesUrl: string;
|
|
68
|
+
/** Local time recorded after this exact runtime version was installed. */
|
|
69
|
+
lastInstalledAt?: string;
|
|
68
70
|
reason?: string;
|
|
69
71
|
}
|
|
70
72
|
|
|
@@ -81,6 +83,10 @@ export interface UpdateJobState {
|
|
|
81
83
|
command: string;
|
|
82
84
|
releaseNotesUrl: string;
|
|
83
85
|
log: string[];
|
|
86
|
+
/** Exact package version whose installer completed successfully. */
|
|
87
|
+
installedVersion?: string;
|
|
88
|
+
/** Local installation event, persisted as an ISO-8601 timestamp. */
|
|
89
|
+
installedAt?: string;
|
|
84
90
|
pid?: number;
|
|
85
91
|
error?: string;
|
|
86
92
|
exitCode?: number | null;
|
|
@@ -98,6 +104,7 @@ export interface UpdateCheckDeps {
|
|
|
98
104
|
currentVersion: () => string;
|
|
99
105
|
detectInstall: () => Installer;
|
|
100
106
|
latestVersion: (tag: Channel) => string | null;
|
|
107
|
+
readUpdateJob?: () => UpdateJobState | null;
|
|
101
108
|
}
|
|
102
109
|
|
|
103
110
|
interface UpdateWorkerProcess {
|
|
@@ -280,6 +287,27 @@ function isVersionLike(value: unknown): value is string {
|
|
|
280
287
|
&& /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(value);
|
|
281
288
|
}
|
|
282
289
|
|
|
290
|
+
/** Only timestamps written by this updater may cross the management API boundary. */
|
|
291
|
+
function normalizedUpdateTimestamp(value: unknown): string | null {
|
|
292
|
+
if (typeof value !== "string" || value.length > 40) return null;
|
|
293
|
+
const parsed = Date.parse(value);
|
|
294
|
+
if (!Number.isFinite(parsed)) return null;
|
|
295
|
+
const canonical = new Date(parsed).toISOString();
|
|
296
|
+
return canonical === value ? canonical : null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function releaseNotesUrlForVersion(version: unknown): string {
|
|
300
|
+
return isVersionLike(version)
|
|
301
|
+
? `${RELEASES_URL}/tag/v${version}`
|
|
302
|
+
: RELEASES_URL;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function isTrustedReleaseNotesUrl(value: unknown): value is string {
|
|
306
|
+
if (value === RELEASES_URL) return true;
|
|
307
|
+
if (typeof value !== "string" || !value.startsWith(`${RELEASES_URL}/tag/v`)) return false;
|
|
308
|
+
return releaseNotesUrlForVersion(value.slice(`${RELEASES_URL}/tag/v`.length)) === value;
|
|
309
|
+
}
|
|
310
|
+
|
|
283
311
|
function withheldSummary(error: unknown): string {
|
|
284
312
|
// `error.name` is writable, so it is external text like the message. A fixed classification
|
|
285
313
|
// is the only part of an unknown error we can state without repeating something we were
|
|
@@ -319,7 +347,7 @@ function withheldSummary(error: unknown): string {
|
|
|
319
347
|
*/
|
|
320
348
|
function brandOwnComposedText(key: string, value: unknown): unknown {
|
|
321
349
|
if (key === "releaseNotesUrl") {
|
|
322
|
-
return value
|
|
350
|
+
return isTrustedReleaseNotesUrl(value) ? value : "";
|
|
323
351
|
}
|
|
324
352
|
if (key === "command") {
|
|
325
353
|
// Render the command shape first, then apply the same path test as every other field. The
|
|
@@ -412,6 +440,11 @@ export function readUpdateJob(jobId?: string | null): UpdateJobState | null {
|
|
|
412
440
|
const parsed = JSON.parse(readFileSync(updateJobPath(), "utf8")) as UpdateJobState;
|
|
413
441
|
if (jobId && parsed.id !== jobId) return null;
|
|
414
442
|
if (!parsed || typeof parsed.id !== "string" || typeof parsed.status !== "string") return null;
|
|
443
|
+
if (parsed.installedAt !== undefined) {
|
|
444
|
+
const installedAt = normalizedUpdateTimestamp(parsed.installedAt);
|
|
445
|
+
if (installedAt) parsed.installedAt = installedAt;
|
|
446
|
+
else delete parsed.installedAt;
|
|
447
|
+
}
|
|
415
448
|
return parsed;
|
|
416
449
|
} catch {
|
|
417
450
|
return null;
|
|
@@ -511,6 +544,12 @@ export function checkForUpdate(
|
|
|
511
544
|
reason = "already_latest";
|
|
512
545
|
}
|
|
513
546
|
|
|
547
|
+
const previousJob = (deps.readUpdateJob ?? readUpdateJob)();
|
|
548
|
+
const installedVersion = previousJob?.installedVersion ?? previousJob?.latestVersion;
|
|
549
|
+
const lastInstalledAt = installedVersion === current
|
|
550
|
+
? normalizedUpdateTimestamp(previousJob?.installedAt)
|
|
551
|
+
: null;
|
|
552
|
+
|
|
514
553
|
return {
|
|
515
554
|
currentVersion: current,
|
|
516
555
|
latestVersion: latest,
|
|
@@ -519,7 +558,8 @@ export function checkForUpdate(
|
|
|
519
558
|
updateAvailable,
|
|
520
559
|
canUpdate: installer !== "source" && updateAvailable,
|
|
521
560
|
command,
|
|
522
|
-
releaseNotesUrl:
|
|
561
|
+
releaseNotesUrl: releaseNotesUrlForVersion(latest ?? current),
|
|
562
|
+
...(lastInstalledAt ? { lastInstalledAt } : {}),
|
|
523
563
|
...(reason ? { reason } : {}),
|
|
524
564
|
};
|
|
525
565
|
}
|
|
@@ -1745,6 +1785,8 @@ export interface GuiUpdateWorkerIo {
|
|
|
1745
1785
|
checkForUpdateFn?: (channel: Channel) => ReturnType<typeof checkForUpdate>;
|
|
1746
1786
|
/** Bypass the registry integrity probe, which runs before the cache gate and needs network. */
|
|
1747
1787
|
integrityFn?: (version: string | null) => ReturnType<typeof checkUpdatePackageIntegrity>;
|
|
1788
|
+
/** Clock seam for deterministic install-event records in tests. */
|
|
1789
|
+
now?: () => string;
|
|
1748
1790
|
/**
|
|
1749
1791
|
* Immutable target selected by a caller that already resolved a registry version.
|
|
1750
1792
|
* npm uses the hidden Node-launcher path so the package manager cannot re-resolve a
|
|
@@ -1767,7 +1809,7 @@ export async function runGuiUpdateWorker(
|
|
|
1767
1809
|
): Promise<void> {
|
|
1768
1810
|
let job = readUpdateJob(jobId);
|
|
1769
1811
|
const check = (io.checkForUpdateFn ?? checkForUpdate)(channel);
|
|
1770
|
-
const now = new Date().toISOString();
|
|
1812
|
+
const now = io.now ?? (() => new Date().toISOString());
|
|
1771
1813
|
// Capture the live listen target BEFORE the update command runs: the stop-first update
|
|
1772
1814
|
// flow clears pid/runtime state, so this is the last moment the real port is knowable.
|
|
1773
1815
|
// Only trust runtime-port.json when its pid matches the live pidfile process.
|
|
@@ -1789,8 +1831,8 @@ export async function runGuiUpdateWorker(
|
|
|
1789
1831
|
job = {
|
|
1790
1832
|
id: jobId,
|
|
1791
1833
|
status: "running",
|
|
1792
|
-
startedAt: now,
|
|
1793
|
-
updatedAt: now,
|
|
1834
|
+
startedAt: now(),
|
|
1835
|
+
updatedAt: now(),
|
|
1794
1836
|
currentVersion: check.currentVersion,
|
|
1795
1837
|
latestVersion: check.latestVersion,
|
|
1796
1838
|
channel: check.channel,
|
|
@@ -1885,6 +1927,12 @@ export async function runGuiUpdateWorker(
|
|
|
1885
1927
|
return;
|
|
1886
1928
|
}
|
|
1887
1929
|
|
|
1930
|
+
const installedVersion = io.exactVersion ?? check.latestVersion;
|
|
1931
|
+
job = updateJob(job, {
|
|
1932
|
+
...(installedVersion ? { installedVersion } : {}),
|
|
1933
|
+
installedAt: now(),
|
|
1934
|
+
}, `Package ${installedVersion ?? "update"} installed successfully.`);
|
|
1935
|
+
|
|
1888
1936
|
if (trayWasInstalled) {
|
|
1889
1937
|
const trayArgs = [process.argv[1], ...planWindowsTrayUpdate({ installed: trayWasInstalled, running: trayWasRunning }).installArgs];
|
|
1890
1938
|
const tray = runLoggedCommand(job, process.execPath, trayArgs, 20_000);
|
package/src/update/notify.ts
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
|
|
17
17
|
const VERSION_FILENAME = "version.json";
|
|
18
18
|
const REFRESH_INTERVAL_MS = 20 * 60 * 60 * 1000; // 20h, matching codex-rs
|
|
19
|
-
const
|
|
19
|
+
const RELEASES_URL = "https://github.com/ESCANOR-001/remodex-android/releases";
|
|
20
20
|
|
|
21
21
|
export interface VersionCache {
|
|
22
22
|
latest_version: string;
|
|
@@ -210,7 +210,7 @@ function renderPrompt(current: string, latest: string, channel: Channel): string
|
|
|
210
210
|
"",
|
|
211
211
|
` \x1b[38;5;141m✨ Update available!\x1b[0m \x1b[2m${current} -> ${latest}\x1b[0m`,
|
|
212
212
|
"",
|
|
213
|
-
` \x1b[2mRelease notes:\x1b[0m ${
|
|
213
|
+
` \x1b[2mRelease notes:\x1b[0m ${RELEASES_URL}/tag/v${latest}`,
|
|
214
214
|
"",
|
|
215
215
|
` 1) Update now (runs \`${command}\`)`,
|
|
216
216
|
" 2) Skip",
|