mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 18:59:33 +01:00
Merge pull request #736: fix(settings): stop a failed settings write from saying it was saved
This commit is contained in:
commit
58f7dbc7ad
7 files changed
+639
-21
No files matched your search
@@ -917,6 +917,8 @@
|
||||
'Saved on this device. Server-wide settings can only be changed by an admin.':
|
||||
'已保存到本设备。服务器全局设置只能由管理员更改。',
|
||||
'Server-wide settings can only be changed by an admin': '服务器全局设置只能由管理员更改',
|
||||
// The reason a settings write gives when no answer came back (settings-ui.js _settingsWriteError).
|
||||
'the server could not be reached': '无法连接到服务器',
|
||||
'Tunnel active': '隧道已启用',
|
||||
'Tunnel starting — QR code will appear when ready...': '隧道正在启动,准备好后将显示二维码…',
|
||||
'Push notifications enabled': '推送通知已启用',
|
||||
@@ -1379,6 +1381,7 @@
|
||||
[/^Lifetime: (\d+) sessions created$/, (_m, count) => `累计已创建 ${count} 个会话`],
|
||||
[/^Tunnel active: (.+)$/, (_m, url) => `隧道已启用:${url}`],
|
||||
[/^Tunnel error: (.+)$/, (_m, error) => `隧道错误:${error}`],
|
||||
[/^Tunnel not stopped: ([\s\S]+)$/, (_m, why) => `隧道未停止:${why}`],
|
||||
[/^Update to v(.+)$/, (_m, version) => `更新到 v${version}`],
|
||||
[/^You're up to date \(v(.+)\)\.$/, (_m, version) => `已是最新版本(v${version})。`],
|
||||
[/^Update available: v(.+)$/, (_m, version) => `有可用更新:v${version}`],
|
||||
@@ -1447,6 +1450,17 @@
|
||||
(_m, n) => `窗口太小,容纳不下 ${n} 个窗格:只显示聚焦的窗格`,
|
||||
],
|
||||
[/^The agent exited \((-?\d+)\)$/, (_m, code) => `智能体已退出(${code})`],
|
||||
// A settings write the server did not store (settings-ui.js). The server's reason passes
|
||||
// through untranslated; the one the page supplies itself has its own entry.
|
||||
[
|
||||
/^Saved on this device, not on the server: ([\s\S]+)$/,
|
||||
(_m, why) => `已保存到本设备,但未保存到服务器:${ZH_CN[why] || why}`,
|
||||
],
|
||||
[
|
||||
/^(Run mode|Respawn presets) saved on this device, not on the server: ([\s\S]+)$/,
|
||||
(_m, what, why) =>
|
||||
`${what === 'Run mode' ? '运行模式' : '重生预设'}已保存到本设备,但未保存到服务器:${ZH_CN[why] || why}`,
|
||||
],
|
||||
// A dialog read off a hook-less CLI's screen (src/web/screen-blocked.ts), as the
|
||||
// notification drawer shows it. The CLI's label passes through.
|
||||
[/^(.+) is waiting for your answer in the terminal$/, (_m, cli) => `${cli} 正在终端中等待你的回答`],
|
||||
|
||||
@@ -601,13 +601,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
// presets are a key of the server-wide settings.json there, and its PUT is admin-only
|
||||
// (403), so they are kept as their own on this device and win over the server's on the
|
||||
// next load (_rememberOwnSetting, settings-ui.js). A PUT refused that way (sent before the
|
||||
// role was known) marks the page read-only for the writes after it.
|
||||
// role was known) marks the page read-only for the writes after it; any other failure is toasted.
|
||||
if (this._settingsReadOnly?.()) {
|
||||
this._rememberOwnSetting?.('respawnPresets', custom);
|
||||
return;
|
||||
}
|
||||
this._apiPut('/api/settings', { respawnPresets: custom })
|
||||
.then((res) => this._noteSettingsWriteRefusal?.(res))
|
||||
.then((res) => this._noteSettingsWriteResult?.(res, 'Respawn presets'))
|
||||
.catch(() => {});
|
||||
},
|
||||
|
||||
|
||||
@@ -735,12 +735,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
// where settings.json is server-wide and its PUT admin-only: the mode is kept as their own
|
||||
// on this device and wins over the server's on the next load (_rememberOwnSetting,
|
||||
// settings-ui.js). A PUT refused that way (sent before the role was known) marks the page
|
||||
// read-only for the writes after it (_noteSettingsWriteRefusal).
|
||||
// read-only for the writes after it (_noteSettingsWriteRefusal); any other failure is toasted.
|
||||
if (this._settingsReadOnly?.()) {
|
||||
this._rememberOwnSetting?.('runMode', mode);
|
||||
} else {
|
||||
this._apiPut('/api/settings', { runMode: mode })
|
||||
.then((res) => this._noteSettingsWriteRefusal?.(res))
|
||||
.then((res) => this._noteSettingsWriteResult?.(res, 'Run mode'))
|
||||
.catch(() => {});
|
||||
}
|
||||
// Close menu
|
||||
|
||||
@@ -2300,6 +2300,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
// A stop the server did not store (a 409 for a settings.json that does not parse) left the
|
||||
// tunnel running: say so, and leave the button showing it on.
|
||||
const stopError = newEnabled ? '' : await this._settingsWriteError(res);
|
||||
if (stopError) {
|
||||
this.showToast(`Tunnel not stopped: ${stopError}`, 'error', { duration: 0 });
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
if (newEnabled) {
|
||||
this._showTunnelConnecting();
|
||||
// Poll tunnel status as fallback in case SSE event is missed
|
||||
@@ -2661,9 +2669,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
* Shows the server's (actionable) message as an error toast. Since #706 this is the
|
||||
* fallback for when `_confirmTunnelEnable` could not read the tunnel status.
|
||||
* @param {Response|null} res - the fetch Response from the settings PUT
|
||||
* @param {object} [sent] - what that PUT sent, resent with the acknowledgement: the refusal
|
||||
* rejected all of it, so an App Settings save retried with the tunnel keys alone lost the rest
|
||||
* @returns {Promise<boolean>} true if the tunnel-enable was refused (caller should abort)
|
||||
*/
|
||||
async _handleTunnelEnableRefusal(res) {
|
||||
async _handleTunnelEnableRefusal(res, sent = { tunnelEnabled: true }) {
|
||||
if (!res || res.ok) return false;
|
||||
let message = 'Tunnel refused: set CODEMAN_PASSWORD before exposing Codeman publicly.';
|
||||
let errorCode = '';
|
||||
@@ -2695,7 +2705,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const retry = await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tunnelEnabled: true, acknowledgeUnauthTunnel: true }),
|
||||
body: JSON.stringify({ ...sent, acknowledgeUnauthTunnel: true }),
|
||||
});
|
||||
if (retry.ok) {
|
||||
this.showToast('Public tunnel enabling — no password set ⚠️', 'warning');
|
||||
@@ -2745,6 +2755,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.closeTunnelPanel();
|
||||
return;
|
||||
}
|
||||
// A stop the server did not store left the tunnel running (see toggleTunnelFromWelcome).
|
||||
const stopError = enable ? '' : await this._settingsWriteError(res);
|
||||
if (stopError) {
|
||||
this.showToast(`Tunnel not stopped: ${stopError}`, 'error', { duration: 0 });
|
||||
this.closeTunnelPanel();
|
||||
return;
|
||||
}
|
||||
if (enable) {
|
||||
this._updateTunnelIndicator(false);
|
||||
const indicator = document.getElementById('tunnelIndicator');
|
||||
@@ -2963,7 +2980,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Gesture overlay is injected at page render (server-side), so a change to it
|
||||
// only takes effect on reload — remember the prior value to decide below.
|
||||
const _prev = this.loadAppSettingsFromStorage();
|
||||
const _prevGestureEnabled = (_prev.gestureControlEnabled ?? false) === true;
|
||||
// After a save the server did not store, this device's blob is ahead of the server, so the
|
||||
// two server-side changes judged against it (the gesture reload, the plan-usage collection
|
||||
// flip) are judged against the blob that save started from until a save lands.
|
||||
const _prevServer = this._unsavedSettingsBaseline ?? _prev;
|
||||
const _prevGestureEnabled = (_prevServer.gestureControlEnabled ?? false) === true;
|
||||
// WebGL toggle: default ON (desktop), so only an explicit stored false counts
|
||||
// as "previously off" — used below to detect a real OFF→ON flip.
|
||||
const _prevWebglEnabled = (_prev.webglRendererEnabled ?? true) === true;
|
||||
@@ -3282,7 +3303,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// switch collection off for every desktop, whose chip then went stale with
|
||||
// no error anywhere. An explicit toggle on any device still writes it, in
|
||||
// either direction.
|
||||
const _chipFlip = this.planUsageCollectionFlip(_prev, settings.showPlanUsageLimits);
|
||||
const _chipFlip = this.planUsageCollectionFlip(_prevServer, settings.showPlanUsageLimits);
|
||||
const {
|
||||
localEchoEnabled: _leo,
|
||||
// Per-device (it is about this screen's keyboard) and absent from
|
||||
@@ -3355,6 +3376,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
...serverSettings
|
||||
} = settings;
|
||||
let webhookError = '';
|
||||
let saveError = '';
|
||||
let serverSaved = false;
|
||||
try {
|
||||
// #706: a save that turns the tunnel on asks first when the server has no login
|
||||
@@ -3368,13 +3390,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
// A non-admin's save is not sent at all (see settingsReadOnly above): the server
|
||||
// would refuse it with a 403, the plan-usage collection flip included.
|
||||
const res = settingsReadOnly ? null : await this._apiPut('/api/settings', {
|
||||
const body = {
|
||||
...serverSettings,
|
||||
...(_chipFlip !== undefined ? { showPlanUsageLimits: _chipFlip } : {}),
|
||||
...(tunnelGate.acknowledge ? { acknowledgeUnauthTunnel: true } : {}),
|
||||
notificationPreferences: notifPrefsToSave,
|
||||
voiceSettings,
|
||||
});
|
||||
};
|
||||
const res = settingsReadOnly ? null : await this._apiPut('/api/settings', body);
|
||||
|
||||
// Multi-user mode, a non-admin whose role was not known when this save started
|
||||
// (/api/me had not answered, or failed and admin-ui.js fell back to a single-user
|
||||
@@ -3389,15 +3412,24 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// COD-55: the server refuses an unauthenticated public tunnel with a 403 — which
|
||||
// rejects the WHOLE settings PUT. Surface the message and revert the tunnel toggle
|
||||
// so it doesn't look enabled.
|
||||
if (!refusedAsNonAdmin && settings.tunnelEnabled && (await this._handleTunnelEnableRefusal(res))) {
|
||||
// so it doesn't look enabled. Only a 403: any other failure with the switch on is
|
||||
// not about the tunnel, and turning the switch off for it would be wrong too.
|
||||
const tunnelRefused = !refusedAsNonAdmin && settings.tunnelEnabled && res?.status === 403;
|
||||
if (tunnelRefused && (await this._handleTunnelEnableRefusal(res, body))) {
|
||||
this._abandonTunnelEnable(settings, keepOpen);
|
||||
return;
|
||||
}
|
||||
|
||||
// `_apiPut` answers null or a non-ok response instead of throwing, so this is the only
|
||||
// evidence the server kept the flags the Apply refresh below reads.
|
||||
serverSaved = !!res?.ok;
|
||||
// evidence the server kept the flags the Apply refresh below reads (a tunnel 403 still
|
||||
// here was retried with the whole save and accepted).
|
||||
serverSaved = !!res?.ok || tunnelRefused;
|
||||
// Any other failure (a 409 for a settings.json that does not parse, a 400, a 500, a dropped
|
||||
// connection) stored nothing on the server, so the save must not say it did. A known
|
||||
// non-admin sent nothing (res is null).
|
||||
saveError =
|
||||
settingsReadOnly || refusedAsNonAdmin || tunnelRefused ? '' : await this._settingsWriteError(res);
|
||||
this._unsavedSettingsBaseline = saveError ? _prevServer : null;
|
||||
|
||||
// Save model configuration separately. For a non-admin it is not sent either, and the
|
||||
// answer says whether this save would have changed it.
|
||||
@@ -3408,7 +3440,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
// it. Only when the group was edited: an untouched group must not re-PUT. A refusal (bad URL,
|
||||
// enabled with no URL) keeps the modal open below, with the pasted URL still in the box.
|
||||
webhookError = this._webhookPending() ? await this.saveWebhook() : '';
|
||||
if (webhookError) {
|
||||
if (saveError) {
|
||||
this.showToast(`Saved on this device, not on the server: ${saveError}`, 'warning', { duration: 0 });
|
||||
} else if (webhookError) {
|
||||
this.showToast(`Settings saved, but not the webhook: ${webhookError}`, 'warning');
|
||||
} else if (syncedChangeRefused || modelConfigRefused || refusedAsNonAdmin) {
|
||||
// ONE toast for the whole save, and only when the user really changed a server-wide
|
||||
@@ -3421,7 +3455,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Show tunnel-specific feedback if toggled on (never for a non-admin, whose save
|
||||
// starts nothing: the box only shows what the server already has)
|
||||
if (settings.tunnelEnabled && !settingsReadOnly && !refusedAsNonAdmin) {
|
||||
if (settings.tunnelEnabled && !settingsReadOnly && !refusedAsNonAdmin && !saveError) {
|
||||
this.showToast('Tunnel starting — QR code will appear when ready...', 'info');
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -3433,9 +3467,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// old flags, and a webhook-only failure still saved the rest, so this runs ahead of that branch.
|
||||
if (keepOpen && serverSaved) this._refreshSettingsAfterApply(settings);
|
||||
|
||||
// A failed settings PUT keeps the modal open too, with the form as the user left it.
|
||||
if (webhookError) {
|
||||
document.getElementById('webhookGroup')?.scrollIntoView({ block: 'center' });
|
||||
} else if (!keepOpen) {
|
||||
} else if (!keepOpen && !saveError) {
|
||||
this.closeAppSettings();
|
||||
}
|
||||
|
||||
@@ -3446,10 +3481,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// The gesture overlay is injected at page render (server reads
|
||||
// gestureControlEnabled from settings.json), so a change only takes effect on
|
||||
// reload. Reload when it actually changed — the server PUT above already
|
||||
// persisted the new value.
|
||||
// persisted the new value (not after a failed PUT: the reload would render the old one).
|
||||
// `?? false`: a non-admin's save puts a synced key the server never stored back to
|
||||
// absent (_keepServerSyncedSettings), and absent means off, as in _prevGestureEnabled.
|
||||
if ((settings.gestureControlEnabled ?? false) !== _prevGestureEnabled) {
|
||||
if (!saveError && (settings.gestureControlEnabled ?? false) !== _prevGestureEnabled) {
|
||||
this.showToast(
|
||||
settings.gestureControlEnabled ? 'Enabling gesture control — reloading…' : 'Disabling gesture control — reloading…',
|
||||
'info'
|
||||
@@ -3498,6 +3533,35 @@ Object.assign(CodemanApp.prototype, {
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Why a settings write did not land, or '' when it did: the server's `error` (a 409 when
|
||||
* settings.json does not parse, a 400, ...), else the HTTP status, else, for the null that
|
||||
* `_apiPut` answers a dropped connection with, that the server could not be reached. Reads a
|
||||
* clone, like _noteSettingsWriteRefusal.
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async _settingsWriteError(res) {
|
||||
if (!res) return 'the server could not be reached';
|
||||
if (res.ok) return '';
|
||||
let body = null;
|
||||
try {
|
||||
body = await res.clone().json();
|
||||
} catch {
|
||||
/* not JSON: the status is all there is */
|
||||
}
|
||||
return body?.error || `HTTP ${res.status}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* The end of a background settings write (run mode, respawn presets): the admin-only refusal is
|
||||
* _noteSettingsWriteRefusal's, and any other failure says that `what` stayed on this device only.
|
||||
*/
|
||||
async _noteSettingsWriteResult(res, what) {
|
||||
if (await this._noteSettingsWriteRefusal(res)) return;
|
||||
const error = await this._settingsWriteError(res);
|
||||
if (error) this.showToast(`${what} saved on this device, not on the server: ${error}`, 'warning', { duration: 0 });
|
||||
},
|
||||
|
||||
/**
|
||||
* Where a non-admin's OWN run mode and respawn presets live (multi-user mode). Both are keys of
|
||||
* the server-wide settings.json, whose PUT is admin-only, so this user's picks are kept on this
|
||||
|
||||
Reference in new issue
Block a user