Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/web/public/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -918,6 +918,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': '推送通知已启用',
Expand Down Expand Up @@ -1380,6 +1382,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}`],
Expand Down Expand Up @@ -1448,6 +1451,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} 正在终端中等待你的回答`],
Expand Down
4 changes: 2 additions & 2 deletions src/web/public/respawn-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {});
},

Expand Down
4 changes: 2 additions & 2 deletions src/web/public/session-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
94 changes: 79 additions & 15 deletions src/web/public/settings-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = '';
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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) {
Expand All @@ -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();
}

Expand All @@ -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'
Expand Down Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions test/plan-usage-collection-flip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,11 @@ describe('saveAppSettings wiring', () => {
);
// Stripped from serverSettings like the other per-device display keys.
expect(save).toMatch(/showPlanUsageLimits: _pul,/);
// Decided once against the device's prior settings, before they are overwritten.
expect(save).toMatch(/const _chipFlip = this\.planUsageCollectionFlip\(_prev, settings\.showPlanUsageLimits\);/);
// Decided once against the device's prior settings, before they are overwritten (or, after a
// save the server did not store, against the settings that save started from).
expect(save).toMatch(
/const _chipFlip = this\.planUsageCollectionFlip\(_prevServer, settings\.showPlanUsageLimits\);/
);
// And only a real flip reaches the PUT body.
expect(save).toMatch(/\.\.\.\(_chipFlip !== undefined \? \{ showPlanUsageLimits: _chipFlip \} : \{\}\),/);
});
Expand Down
1 change: 1 addition & 0 deletions test/settings-admin-only-ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ const REAL = new Set([
'_keepServerSyncedSettings',
'_displaySettingKeySet',
'_noteSettingsWriteRefusal',
'_settingsWriteError',
'_applyOwnSettings',
'_readOwnSettings',
'_ownSettingsKey',
Expand Down
Loading
Loading