@xulongzhe/clawbench-desktop-linux-x64-payload 0.105.0 → 0.106.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xulongzhe/clawbench-desktop-linux-x64-payload",
3
- "version": "0.105.0",
3
+ "version": "0.106.0",
4
4
  "description": "ClawBench desktop client — Linux x64 incremental-upgrade payload (app files only, no Electron runtime)",
5
5
  "files": [
6
6
  "payload.json",
Binary file
@@ -13,9 +13,10 @@
13
13
  password_label: 'Password',
14
14
  password_placeholder: 'Password',
15
15
  connect: 'Connect',
16
- protocol: 'Protocol',
17
16
  server_address: 'Server Address',
18
- add_connect: 'Add & Connect',
17
+ add: 'Add',
18
+ save: 'Save',
19
+ edit_server: 'Edit Server',
19
20
  cancel: 'Cancel',
20
21
  add_server: 'Add Server',
21
22
  confirm: 'Confirm',
@@ -24,8 +25,13 @@
24
25
  delete_server_msg: 'Delete this server?',
25
26
  saved_servers: 'Saved Servers',
26
27
  authenticating: 'Authenticating...',
28
+ saving: 'Saving...',
29
+ name_label: 'Name (optional)',
30
+ name_placeholder: 'e.g. Home NAS',
31
+ duplicate_name: 'That name is already used',
27
32
  enter_password: 'Please enter password',
28
- enter_address: 'Please enter server address',
33
+ enter_full_address: 'Enter a full address with http:// or https:// and a port, e.g. https://192.168.1.100:20000',
34
+ duplicate_server: 'That address is already saved',
29
35
  connection_failed: 'Connection failed: ',
30
36
  connect_error: 'Unable to connect. Check address and network.',
31
37
  wrong_password: 'Wrong password',
@@ -37,7 +43,16 @@
37
43
  splash_rendering: 'Rendering…',
38
44
  splash_initializing: 'Initializing app…',
39
45
  splash_cancel: 'Cancel connection',
40
- window_close: 'Close'
46
+ window_close: 'Close',
47
+ // Version-mismatch gate. The desktop and server versions differ in either
48
+ // direction, so the wording must not assume the app is the older side —
49
+ // the two `_older`/`_newer` variants cover that.
50
+ version_mismatch_title: 'Version mismatch',
51
+ version_mismatch_body: 'Desktop version: %1\nServer version: %2\n\nThe two versions may be incompatible: some features may behave unexpectedly or stop working.',
52
+ version_mismatch_older: 'The desktop app is older than the server. Updating is recommended.',
53
+ version_mismatch_newer: 'The desktop app is newer than the server. Downloading will align it to the server version (a downgrade).',
54
+ version_mismatch_download: 'Download',
55
+ version_mismatch_continue: 'Continue anyway'
41
56
  },
42
57
  zh: {
43
58
  slogan: '从终端到桌面',
@@ -45,9 +60,10 @@
45
60
  password_label: '登录密码',
46
61
  password_placeholder: '密码',
47
62
  connect: '连 接',
48
- protocol: '协议',
49
63
  server_address: '服务器地址',
50
- add_connect: '添 加 并 连 接',
64
+ add: '添 加',
65
+ save: '保 存',
66
+ edit_server: '编辑服务器',
51
67
  cancel: '取 消',
52
68
  add_server: '添加服务器',
53
69
  confirm: '确认',
@@ -56,8 +72,13 @@
56
72
  delete_server_msg: '删除此服务器?',
57
73
  saved_servers: '已保存服务器',
58
74
  authenticating: '认证中...',
75
+ saving: '保存中...',
76
+ name_label: '名称(选填)',
77
+ name_placeholder: '例如 家里的 NAS',
78
+ duplicate_name: '该名称已被使用',
59
79
  enter_password: '请输入密码',
60
- enter_address: '请输入服务器地址',
80
+ enter_full_address: '请输入完整地址,包含 http:// 或 https:// 与端口,例如 https://192.168.1.100:20000',
81
+ duplicate_server: '该地址已存在',
61
82
  connection_failed: '连接失败: ',
62
83
  connect_error: '无法连接到服务器,请检查地址和网络。',
63
84
  wrong_password: '密码错误',
@@ -69,7 +90,15 @@
69
90
  splash_rendering: '正在渲染…',
70
91
  splash_initializing: '正在初始化应用…',
71
92
  splash_cancel: '取消连接',
72
- window_close: '关闭'
93
+ window_close: '关闭',
94
+ // Version-mismatch gate. See the en block for why the wording is split by
95
+ // direction rather than assuming the app is older.
96
+ version_mismatch_title: '版本不一致',
97
+ version_mismatch_body: '桌面端版本: %1\n服务器版本: %2\n\n两者可能不兼容:部分功能可能异常或无法使用。',
98
+ version_mismatch_older: '桌面端版本低于服务器,建议更新。',
99
+ version_mismatch_newer: '桌面端版本高于服务器,下载将对齐到服务器版本(降级)。',
100
+ version_mismatch_download: '下载',
101
+ version_mismatch_continue: '仍然继续'
73
102
  }
74
103
  };
75
104
  var LANG = 'en';
@@ -994,6 +1023,16 @@
994
1023
  font-weight: 500;
995
1024
  }
996
1025
 
1026
+ /* Row actions: edit + delete, kept in one group so the info block can
1027
+ stretch without pushing the icons apart. */
1028
+ .server-item-actions {
1029
+ flex-shrink: 0;
1030
+ display: flex;
1031
+ align-items: center;
1032
+ gap: 2px;
1033
+ }
1034
+
1035
+ .server-edit-btn,
997
1036
  .server-delete-btn {
998
1037
  flex-shrink: 0;
999
1038
  border: none;
@@ -1008,15 +1047,26 @@
1008
1047
  transition: background 0.1s, color 0.1s;
1009
1048
  }
1010
1049
 
1050
+ .server-edit-btn:hover {
1051
+ background: rgba(var(--accent-rgb), 0.12);
1052
+ color: var(--accent-color);
1053
+ }
1054
+
1011
1055
  .server-delete-btn:hover {
1012
1056
  background: rgba(220, 38, 38, 0.1);
1013
1057
  color: var(--color-red);
1014
1058
  }
1015
1059
 
1060
+ .server-item.active .server-edit-btn,
1016
1061
  .server-item.active .server-delete-btn {
1017
1062
  color: var(--text-muted);
1018
1063
  }
1019
1064
 
1065
+ .server-item.active .server-edit-btn:hover {
1066
+ color: var(--accent-color);
1067
+ background: rgba(var(--accent-rgb), 0.15);
1068
+ }
1069
+
1020
1070
  .server-item.active .server-delete-btn:hover {
1021
1071
  color: var(--color-red);
1022
1072
  background: rgba(220, 38, 38, 0.12);
@@ -1037,69 +1087,12 @@
1037
1087
  margin-bottom: 6px;
1038
1088
  }
1039
1089
 
1040
- /* Protocol radio */
1041
- .protocol-group {
1042
- display: flex;
1043
- gap: 20px;
1044
- margin-bottom: 16px;
1045
- }
1046
-
1047
- .protocol-group label {
1048
- display: flex;
1049
- align-items: center;
1050
- gap: 6px;
1051
- font-size: 14px;
1090
+ /* Title shown above the add form when it is reused for editing */
1091
+ .form-title {
1092
+ font-size: 15px;
1093
+ font-weight: 600;
1052
1094
  color: var(--text-primary);
1053
- cursor: pointer;
1054
- }
1055
-
1056
- .protocol-group input[type="radio"] {
1057
- appearance: none;
1058
- -webkit-appearance: none;
1059
- width: 18px;
1060
- height: 18px;
1061
- border: 2px solid var(--border-color);
1062
- border-radius: 50%;
1063
- position: relative;
1064
- cursor: pointer;
1065
- transition: border-color 0.2s;
1066
- }
1067
-
1068
- .protocol-group input[type="radio"]:checked {
1069
- border-color: var(--accent-color);
1070
- }
1071
-
1072
- .protocol-group input[type="radio"]:checked::after {
1073
- content: '';
1074
- position: absolute;
1075
- top: 3px;
1076
- left: 3px;
1077
- width: 8px;
1078
- height: 8px;
1079
- border-radius: 50%;
1080
- background: var(--accent-color);
1081
- }
1082
-
1083
- /* Host + Port row */
1084
- .host-row {
1085
- display: flex;
1086
- align-items: center;
1087
- gap: 4px;
1088
- }
1089
-
1090
- .host-row input:first-child {
1091
- flex: 1;
1092
- }
1093
-
1094
- .host-row .colon {
1095
- color: var(--text-muted);
1096
- font-size: 18px;
1097
- line-height: 44px;
1098
- }
1099
-
1100
- .host-row input:last-child {
1101
- width: 90px;
1102
- flex-shrink: 0;
1095
+ margin-bottom: 14px;
1103
1096
  }
1104
1097
 
1105
1098
  /* Input fields — matches LoginView.vue input style */
@@ -1458,6 +1451,94 @@
1458
1451
  background: var(--bg-tertiary);
1459
1452
  color: var(--text-primary);
1460
1453
  }
1454
+
1455
+ /* ── Version-mismatch gate ──────────────────────────────────────────────
1456
+ Replaces the loading splash when the desktop/server versions differ. Like
1457
+ the splash it is hidden unless a body class is set (added by
1458
+ __versionMismatchShow), so the same document can serve the login form, the
1459
+ splash and the gate. Styled as a card, mirroring the app's DialogOverlay so
1460
+ the prompt reads as the same UI as the in-app dialogs. */
1461
+ .gate { display: none; }
1462
+
1463
+ body.gate-mode .gate {
1464
+ position: fixed;
1465
+ inset: 0;
1466
+ /* Above the splash: the gate replaces it, but both may be display:flex at
1467
+ once (the page is reused and the main process does not always clear
1468
+ splash-mode), so the gate must win the paint order. */
1469
+ z-index: 200;
1470
+ display: flex;
1471
+ align-items: center;
1472
+ justify-content: center;
1473
+ padding: 0 20px;
1474
+ background: var(--bg-primary);
1475
+ }
1476
+
1477
+ body.gate-mode .content { display: none; }
1478
+
1479
+ .gate-card {
1480
+ width: 100%;
1481
+ max-width: 360px;
1482
+ padding: 18px 20px 14px;
1483
+ background: var(--bg-secondary);
1484
+ border-radius: 14px;
1485
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.16), 0 14px 44px rgba(0, 0, 0, 0.3);
1486
+ }
1487
+
1488
+ .gate-title {
1489
+ font-size: 15px;
1490
+ font-weight: 600;
1491
+ color: var(--text-primary);
1492
+ margin-bottom: 8px;
1493
+ }
1494
+
1495
+ .gate-body {
1496
+ font-size: 13px;
1497
+ line-height: 1.6;
1498
+ color: var(--text-secondary);
1499
+ white-space: pre-line;
1500
+ word-break: break-word;
1501
+ }
1502
+
1503
+ .gate-direction {
1504
+ margin-top: 10px;
1505
+ font-size: 12px;
1506
+ line-height: 1.5;
1507
+ color: var(--text-muted);
1508
+ }
1509
+
1510
+ .gate-actions {
1511
+ display: flex;
1512
+ justify-content: flex-end;
1513
+ gap: 8px;
1514
+ margin-top: 16px;
1515
+ }
1516
+
1517
+ .gate-btn {
1518
+ padding: 7px 14px;
1519
+ font-size: 13px;
1520
+ font-family: inherit;
1521
+ border-radius: 6px;
1522
+ cursor: pointer;
1523
+ transition: opacity 150ms ease, background 150ms ease, color 150ms ease;
1524
+ }
1525
+
1526
+ .gate-continue {
1527
+ color: var(--text-secondary);
1528
+ background: var(--bg-tertiary);
1529
+ border: 1px solid var(--border-color);
1530
+ }
1531
+ .gate-continue:hover {
1532
+ color: var(--text-primary);
1533
+ }
1534
+
1535
+ .gate-download {
1536
+ color: #fff;
1537
+ background: var(--accent-color);
1538
+ border: none;
1539
+ font-weight: 600;
1540
+ }
1541
+ .gate-download:hover { opacity: 0.9; }
1461
1542
  </style>
1462
1543
  </head>
1463
1544
  <body>
@@ -1474,6 +1555,22 @@
1474
1555
  <button type="button" class="splash-cancel" id="splashCancel" data-i18n="splash_cancel">取消连接</button>
1475
1556
  </div>
1476
1557
 
1558
+ <!-- Version-mismatch gate. Shown by the main process (see splash.ts /
1559
+ versionGate.ts) INSTEAD of the loading splash when the desktop and server
1560
+ versions differ in either direction. Blocking: it has no cancel button, so
1561
+ the only ways out are "download" and "continue". -->
1562
+ <div class="gate" id="versionGate">
1563
+ <div class="gate-card">
1564
+ <div class="gate-title" id="gateTitle" data-i18n="version_mismatch_title">版本不一致</div>
1565
+ <div class="gate-body" id="gateBody"></div>
1566
+ <div class="gate-direction" id="gateDirection"></div>
1567
+ <div class="gate-actions">
1568
+ <button type="button" class="gate-btn gate-continue" id="gateContinue" data-i18n="version_mismatch_continue">仍然继续</button>
1569
+ <button type="button" class="gate-btn gate-download" id="gateDownload" data-i18n="version_mismatch_download">下载</button>
1570
+ </div>
1571
+ </div>
1572
+ </div>
1573
+
1477
1574
  <div class="bg-gradient"></div>
1478
1575
  <div class="bg-grid"></div>
1479
1576
 
@@ -1538,31 +1635,20 @@
1538
1635
  </div>
1539
1636
  </form>
1540
1637
 
1541
- <!-- Add server form (hidden by default) -->
1638
+ <!-- Add / edit server form (hidden by default) -->
1542
1639
  <form id="addServerForm" onsubmit="return false;" style="display:none;">
1543
- <!-- Protocol -->
1640
+ <div class="form-title" id="addFormTitle" style="display:none;"></div>
1641
+
1642
+ <!-- Optional display name -->
1544
1643
  <div class="field-group">
1545
- <span class="field-label" data-i18n="protocol">协议</span>
1546
- <div class="protocol-group">
1547
- <label>
1548
- <input type="radio" name="addProtocol" value="https" checked>
1549
- HTTPS
1550
- </label>
1551
- <label>
1552
- <input type="radio" name="addProtocol" value="http">
1553
- HTTP
1554
- </label>
1555
- </div>
1644
+ <span class="field-label" data-i18n="name_label">名称(选填)</span>
1645
+ <input type="text" id="addName" data-i18n-placeholder="name_placeholder" placeholder="例如 家里的 NAS" maxlength="40" autocorrect="off" autocapitalize="sentences" spellcheck="false">
1556
1646
  </div>
1557
1647
 
1558
- <!-- Host + Port -->
1648
+ <!-- Server address (protocol + host + port in one field) -->
1559
1649
  <div class="field-group">
1560
1650
  <span class="field-label" data-i18n="server_address">服务器地址</span>
1561
- <div class="host-row">
1562
- <input type="text" id="addHost" placeholder="192.168.1.100" autocorrect="off" autocapitalize="none" spellcheck="false">
1563
- <span class="colon">:</span>
1564
- <input type="number" id="addPort" value="20000" inputmode="numeric">
1565
- </div>
1651
+ <input type="text" id="addAddress" placeholder="https://192.168.1.100:20000" autocorrect="off" autocapitalize="none" spellcheck="false" inputmode="url">
1566
1652
  </div>
1567
1653
 
1568
1654
  <!-- Password -->
@@ -1578,7 +1664,7 @@
1578
1664
  </div>
1579
1665
 
1580
1666
  <button type="submit" class="connect-btn" id="addConnectBtn">
1581
- <span id="addBtnText" data-i18n="add_connect">添 加 并 连 接</span>
1667
+ <span id="addBtnText" data-i18n="add">添 加</span>
1582
1668
  </button>
1583
1669
  <button type="button" class="cancel-btn" id="cancelAddBtn" data-i18n="cancel">取 消</button>
1584
1670
 
@@ -1622,6 +1708,7 @@
1622
1708
  // SVG icons
1623
1709
  var SVG_SERVER = '<svg class="server-item-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><circle cx="6" cy="6" r="1"/><circle cx="6" cy="18" r="1"/></svg>';
1624
1710
  var SVG_X = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
1711
+ var SVG_PENCIL = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>';
1625
1712
 
1626
1713
  function applyI18n() {
1627
1714
  document.querySelectorAll('[data-i18n]').forEach(function(el) {
@@ -1679,6 +1766,37 @@
1679
1766
  if (el) el.classList.remove('is-fading');
1680
1767
  };
1681
1768
 
1769
+ // ── Version-mismatch gate ───────────────────────────────────────────────
1770
+ // Shown INSTEAD of the loading splash when the desktop and server versions
1771
+ // differ in either direction (see desktop/src/main/versionGate.ts). The main
1772
+ // process calls __versionMismatchShow with the two versions and which side is
1773
+ // newer; the buttons report the user's choice back through the native bridge.
1774
+ //
1775
+ // The direction decides the wording: "newer" means the desktop app is AHEAD,
1776
+ // where downloading aligns DOWN to the server — so "update" would be wrong.
1777
+ /** Fill the gate with the version details. Called from the main process. */
1778
+ window.__versionMismatchShow = function(info) {
1779
+ if (!info) return;
1780
+ var body = document.getElementById('gateBody');
1781
+ if (body) {
1782
+ body.textContent = t('version_mismatch_body')
1783
+ .replace('%1', info.clientVersion || '')
1784
+ .replace('%2', info.serverVersion || '');
1785
+ }
1786
+ var dir = document.getElementById('gateDirection');
1787
+ if (dir) {
1788
+ dir.textContent = t(info.direction === 'newer'
1789
+ ? 'version_mismatch_newer'
1790
+ : 'version_mismatch_older');
1791
+ }
1792
+ document.body.classList.add('gate-mode');
1793
+ };
1794
+
1795
+ /** Clear the gate state. The page is REUSED, so this runs before every show. */
1796
+ window.__versionMismatchReset = function() {
1797
+ document.body.classList.remove('gate-mode');
1798
+ };
1799
+
1682
1800
  if (SPLASH_MODE) {
1683
1801
  document.body.classList.add('splash-mode');
1684
1802
  }
@@ -1715,6 +1833,20 @@
1715
1833
  try { ClawBenchNative.cancelSplash(); } catch(e) {}
1716
1834
  });
1717
1835
  }
1836
+ // Version-gate buttons. Wired here (not in the show hook) so they work
1837
+ // even if the gate is shown after the page has already loaded.
1838
+ var continueBtn = document.getElementById('gateContinue');
1839
+ if (continueBtn) {
1840
+ continueBtn.addEventListener('click', function() {
1841
+ try { ClawBenchNative.versionContinue(); } catch(e) {}
1842
+ });
1843
+ }
1844
+ var downloadBtn = document.getElementById('gateDownload');
1845
+ if (downloadBtn) {
1846
+ downloadBtn.addEventListener('click', function() {
1847
+ try { ClawBenchNative.versionDownload(); } catch(e) {}
1848
+ });
1849
+ }
1718
1850
  return;
1719
1851
  }
1720
1852
 
@@ -1768,64 +1900,81 @@
1768
1900
  }
1769
1901
  });
1770
1902
 
1771
- // Event: add and connect to new server
1903
+ // Event: save the add/edit form. Both "Add Server" and the row pencil land
1904
+ // here; the form's editUrl data attribute is what distinguishes them.
1905
+ // Saving never connects — the user picks the server and presses Connect.
1772
1906
  document.getElementById('addServerForm').addEventListener('submit', function(e) {
1773
1907
  e.preventDefault();
1774
1908
  if (connecting) return;
1775
1909
  hideError('addErrorMsg');
1776
1910
 
1777
- var host = document.getElementById('addHost').value.trim();
1778
- var port = document.getElementById('addPort').value.trim();
1911
+ var url = buildServerUrl(document.getElementById('addAddress').value);
1912
+ if (!url) {
1913
+ showError('addErrorMsg', t('enter_full_address'));
1914
+ document.getElementById('addAddress').focus();
1915
+ return;
1916
+ }
1779
1917
  var password = document.getElementById('addPassword').value;
1780
- var protocol = document.querySelector('input[name="addProtocol"]:checked').value;
1781
-
1782
- if (!host) {
1783
- showError('addErrorMsg', t('enter_address'));
1784
- document.getElementById('addHost').focus();
1918
+ var name = normalizeServerName(document.getElementById('addName').value);
1919
+ // The URL this form is editing ('' when adding). Captured before save so
1920
+ // the rename branch can retire the old entry afterwards.
1921
+ var editingUrl = document.getElementById('addServerForm').getAttribute('data-edit-url') || '';
1922
+
1923
+ // Renaming onto another saved server would silently overwrite that
1924
+ // server's password (saveServer keys on URL). Refuse instead.
1925
+ var clash = savedServers.some(function(s) {
1926
+ return s.url === url && s.url !== editingUrl;
1927
+ });
1928
+ if (clash) {
1929
+ showError('addErrorMsg', t('duplicate_server'));
1785
1930
  return;
1786
1931
  }
1787
- if (!port) {
1788
- port = (protocol === 'https') ? '443' : '80';
1932
+ // Names are unique labels. The edited entry is exempt from matching
1933
+ // itself; unnamed servers never collide with each other.
1934
+ if (findServerNameConflict(savedServers, name, editingUrl)) {
1935
+ showError('addErrorMsg', t('duplicate_name'));
1936
+ return;
1789
1937
  }
1790
1938
 
1791
- var url = protocol + '://' + host + ':' + port;
1792
- if (url.endsWith('/')) url = url.substring(0, url.length - 1);
1793
-
1794
- setConnecting(true, true);
1939
+ setSaving(true);
1940
+ // Both calls go through ipcRenderer.invoke, so failures surface as
1941
+ // rejections. Chain them: the rename must delete the old entry only after
1942
+ // the new one is written, and the list must reload after both settle.
1795
1943
  try {
1796
- // Save to native list first. See the note in the connect handler above:
1797
- // these are async, so the rejection must be handled explicitly.
1798
- ClawBenchNative.saveServer(url, password);
1799
- Promise.resolve(ClawBenchNative.connectToServer(url, password)).catch(function(err) {
1800
- showError('addErrorMsg', t('connection_failed') + (err && err.message ? err.message : ''));
1801
- setConnecting(false, true);
1802
- });
1944
+ Promise.resolve(ClawBenchNative.saveServerNamed(url, password, name))
1945
+ .then(function() {
1946
+ // Only when the address actually changed: deleting the same URL
1947
+ // would drop the entry we just saved.
1948
+ if (editingUrl && editingUrl !== url) {
1949
+ return Promise.resolve(ClawBenchNative.removeServer(editingUrl));
1950
+ }
1951
+ })
1952
+ .then(function() {
1953
+ setSaving(false);
1954
+ hideAddForm();
1955
+ })
1956
+ .catch(function(err) {
1957
+ showError('addErrorMsg', t('connection_failed') + (err && err.message ? err.message : ''));
1958
+ setSaving(false);
1959
+ });
1803
1960
  } catch(e) {
1804
1961
  showError('addErrorMsg', t('connection_failed') + e.message);
1805
- setConnecting(false, true);
1962
+ setSaving(false);
1806
1963
  }
1807
1964
  });
1808
1965
 
1809
- // Event: blur on the host field -> normalize a full URL into protocol + port.
1810
- // Blur, not paste/input: the field is only rewritten after the user leaves it,
1811
- // so typing is never disturbed mid-keystroke. Reading .value (rather than
1812
- // clipboard data) also sidesteps WebView clipboard restrictions.
1813
- document.getElementById('addHost').addEventListener('blur', function() {
1814
- var parsed = parseServerInput(document.getElementById('addHost').value);
1815
- // Not a URL we understand -> leave the field exactly as the user typed it.
1816
- if (!parsed) return;
1966
+ // Event: blur on the address field -> normalize a full address in place.
1967
+ // Blur, not paste/input: the field is only rewritten after the user leaves
1968
+ // it, so typing is never disturbed mid-keystroke. Reading .value (rather
1969
+ // than clipboard data) also sidesteps WebView clipboard restrictions.
1970
+ document.getElementById('addAddress').addEventListener('blur', function() {
1971
+ var url = buildServerUrl(document.getElementById('addAddress').value);
1972
+ // Incomplete address -> leave the field exactly as the user typed it; the
1973
+ // submit handler reports what is missing.
1974
+ if (!url) return;
1817
1975
 
1818
1976
  hideError('addErrorMsg');
1819
- document.getElementById('addHost').value = parsed.host;
1820
- // Only override the port when the URL carried one explicitly; the field
1821
- // keeps its default (20000) otherwise.
1822
- if (parsed.port !== null) {
1823
- document.getElementById('addPort').value = parsed.port;
1824
- }
1825
- // No scheme in the input -> leave the radio untouched.
1826
- if (parsed.protocol !== null) {
1827
- document.querySelector('input[name="addProtocol"][value="' + parsed.protocol + '"]').checked = true;
1828
- }
1977
+ document.getElementById('addAddress').value = url;
1829
1978
  });
1830
1979
 
1831
1980
  // Event: show add form
@@ -1881,11 +2030,19 @@
1881
2030
  html += ' <div class="server-item-info">';
1882
2031
  html += ' <span class="server-indicator"></span>';
1883
2032
  html += ' ' + SVG_SERVER;
1884
- html += ' <span class="server-item-url">' + escHtml(formatHost(srv.url)) + '</span>';
2033
+ // A named server shows its name; an unnamed one falls back to the
2034
+ // address. The full address always stays in the title tooltip, so a
2035
+ // named row is still identifiable (and copying it is possible).
2036
+ html += ' <span class="server-item-url" title="' + escHtml(srv.url) + '">' + escHtml(normalizeServerName(srv.name) || formatHost(srv.url)) + '</span>';
2037
+ html += ' </div>';
2038
+ html += ' <div class="server-item-actions">';
2039
+ html += ' <button class="server-edit-btn" onclick="event.stopPropagation(); editServer(\'' + escJs(srv.url) + '\')" title="' + t('edit_server') + '">';
2040
+ html += ' ' + SVG_PENCIL;
2041
+ html += ' </button>';
2042
+ html += ' <button class="server-delete-btn" onclick="event.stopPropagation(); deleteServer(\'' + escJs(srv.url) + '\')" title="' + t('delete') + '">';
2043
+ html += ' ' + SVG_X;
2044
+ html += ' </button>';
1885
2045
  html += ' </div>';
1886
- html += ' <button class="server-delete-btn" onclick="event.stopPropagation(); deleteServer(\'' + escJs(srv.url) + '\')" title="' + t('delete') + '">';
1887
- html += ' ' + SVG_X;
1888
- html += ' </button>';
1889
2046
  html += '</div>';
1890
2047
  });
1891
2048
 
@@ -1999,17 +2156,59 @@
1999
2156
  });
2000
2157
  }
2001
2158
 
2159
+ /**
2160
+ * Show the form for adding a NEW server: empty fields, "Add" button.
2161
+ */
2002
2162
  function showAddForm() {
2163
+ var form = document.getElementById('addServerForm');
2164
+ // Empty data-edit-url is what marks this as an add, not an edit.
2165
+ form.setAttribute('data-edit-url', '');
2166
+ document.getElementById('addFormTitle').style.display = 'none';
2167
+ document.getElementById('addName').value = '';
2168
+ document.getElementById('addAddress').value = '';
2169
+ document.getElementById('addPassword').value = '';
2170
+ document.getElementById('addBtnText').textContent = t('add');
2171
+
2172
+ document.getElementById('serverListSection').style.display = 'none';
2173
+ document.getElementById('connectForm').style.display = 'none';
2174
+ document.getElementById('addServerBtn').style.display = 'none';
2175
+ form.style.display = 'block';
2176
+ }
2177
+
2178
+ /**
2179
+ * Reuse the add form to edit an existing server. data-edit-url holds the
2180
+ * entry being edited so submit can tell the two modes apart and retire the
2181
+ * old URL when the address changes.
2182
+ */
2183
+ function editServer(url) {
2184
+ var srv = savedServers.find(function(s) { return s.url === url; });
2185
+ if (!srv) return;
2186
+
2187
+ var form = document.getElementById('addServerForm');
2188
+ form.setAttribute('data-edit-url', url);
2189
+ var title = document.getElementById('addFormTitle');
2190
+ title.textContent = t('edit_server');
2191
+ title.style.display = 'block';
2192
+ document.getElementById('addName').value = normalizeServerName(srv.name);
2193
+ document.getElementById('addAddress').value = srv.url;
2194
+ // Prefill the saved password so saving without retyping keeps it.
2195
+ document.getElementById('addPassword').value = srv.password || '';
2196
+ document.getElementById('addBtnText').textContent = t('save');
2197
+
2003
2198
  document.getElementById('serverListSection').style.display = 'none';
2004
2199
  document.getElementById('connectForm').style.display = 'none';
2005
2200
  document.getElementById('addServerBtn').style.display = 'none';
2006
- document.getElementById('addServerForm').style.display = 'block';
2201
+ form.style.display = 'block';
2202
+ hideError('addErrorMsg');
2203
+ document.getElementById('addAddress').focus();
2007
2204
  }
2008
2205
 
2009
2206
  function hideAddForm() {
2010
2207
  document.getElementById('addServerForm').style.display = 'none';
2011
2208
  document.getElementById('addErrorMsg').classList.remove('visible');
2012
- renderServerList();
2209
+ // Reload from native rather than re-rendering the stale in-memory array:
2210
+ // saving changed the list (new entry, promoted head, renamed URL).
2211
+ loadServerList();
2013
2212
  }
2014
2213
 
2015
2214
  function formatHost(url) {
@@ -2050,13 +2249,31 @@
2050
2249
  } else {
2051
2250
  btn.disabled = false;
2052
2251
  if (isAddForm) {
2053
- btn.innerHTML = '<span id="addBtnText">' + t('add_connect') + '</span>';
2252
+ // The form doubles as add and edit, so restore whichever label its
2253
+ // current mode calls for rather than always "Add".
2254
+ var editing = document.getElementById('addServerForm').getAttribute('data-edit-url');
2255
+ btn.innerHTML = '<span id="addBtnText">' + t(editing ? 'save' : 'add') + '</span>';
2054
2256
  } else {
2055
2257
  btn.innerHTML = '<span id="btnText">' + t('connect') + '</span>';
2056
2258
  }
2057
2259
  }
2058
2260
  }
2059
2261
 
2262
+ /**
2263
+ * Busy state for the add/edit form's own Save/Add button. Separate from
2264
+ * setConnecting because saving does not authenticate: showing "Authenticating"
2265
+ * would misdescribe what is happening.
2266
+ */
2267
+ function setSaving(saving) {
2268
+ var btn = document.getElementById('addConnectBtn');
2269
+ if (saving) {
2270
+ btn.disabled = true;
2271
+ btn.innerHTML = '<div class="spinner"></div><span>' + t('saving') + '</span>';
2272
+ } else {
2273
+ setConnecting(false, true);
2274
+ }
2275
+ }
2276
+
2060
2277
  // Called by native code on connection failure.
2061
2278
  // msg is the Chromium load-error description (e.g. "ERR_CONNECTION_REFUSED")
2062
2279
  // delivered from the main process's did-fail-load handler; empty when the
@@ -46,3 +46,61 @@ function parseServerInput(text) {
46
46
 
47
47
  return { protocol: protocol, host: host, port: port };
48
48
  }
49
+
50
+ /**
51
+ * Build the canonical server URL from a single free-text address.
52
+ *
53
+ * Strict on purpose: the login form no longer has a protocol radio or a port
54
+ * field, so the address must carry BOTH explicitly. A bare host or a
55
+ * scheme-less `host:port` returns null and the caller shows "enter a full
56
+ * address" rather than guessing a scheme/port the user never chose.
57
+ *
58
+ * Normalisation the parser already gives us: scheme lowercased, path/query/
59
+ * fragment dropped, surrounding whitespace trimmed.
60
+ *
61
+ * Returns 'scheme://host:port', or null when the input is not a complete
62
+ * server address. Callers must treat null as "reject, do not save".
63
+ */
64
+ function buildServerUrl(text) {
65
+ var parsed = parseServerInput(text);
66
+ if (!parsed || !parsed.protocol || !parsed.port) return null;
67
+ return parsed.protocol + '://' + parsed.host + ':' + parsed.port;
68
+ }
69
+
70
+ /**
71
+ * Normalise a server's display name: trimmed. Empty means "no name", which the
72
+ * list renders as the address instead.
73
+ *
74
+ * Returns '' for anything that is not a string, so callers can pass the raw
75
+ * field value without guarding.
76
+ */
77
+ function normalizeServerName(text) {
78
+ if (typeof text !== 'string') return '';
79
+ return text.trim();
80
+ }
81
+
82
+ /**
83
+ * Find the server whose NAME equals `name`, ignoring `exceptUrl`.
84
+ *
85
+ * Names are unique labels, so the form refuses to save a duplicate. The
86
+ * comparison is case-insensitive (users do not think of "Home" and "home" as
87
+ * different servers) and an empty `name` never matches — it means "no name",
88
+ * and unnamed servers must not collide with each other.
89
+ *
90
+ * `exceptUrl` is the entry currently being edited: a server must not collide
91
+ * with itself when its name is left unchanged.
92
+ *
93
+ * Returns the conflicting entry (an object with at least `.url`), or null.
94
+ */
95
+ function findServerNameConflict(servers, name, exceptUrl) {
96
+ var wanted = normalizeServerName(name).toLowerCase();
97
+ if (!wanted) return null;
98
+ if (!Array.isArray(servers)) return null;
99
+ for (var i = 0; i < servers.length; i++) {
100
+ var srv = servers[i];
101
+ if (!srv) continue;
102
+ if (exceptUrl && srv.url === exceptUrl) continue;
103
+ if (normalizeServerName(srv.name).toLowerCase() === wanted) return srv;
104
+ }
105
+ return null;
106
+ }