feat(desktop): native macOS app that runs, installs or connects to Codeman

Adds packages/desktop, a small native app (Rust: tao, wry/WKWebView, muda,
objc2) that opens Codeman in its own window. The universal app is about 5 MB
(3.2 MB as a DMG) and runs on macOS 11.3 or later.

- Connect screen with two tabs. This Mac finds the local Codeman and offers
  Open, Start or Install. Start uses launchd for a LaunchAgent install and
  codeman web -d otherwise. It never starts a second server while one runs,
  because two servers on one data dir share live sessions. Another machine
  connects by address (recent servers, Keychain logins) and can install
  Codeman there over SSH.
- Installs run Codeman's install.sh in Terminal from a self-deleting
  .command script. The installer needs a terminal for sudo, Homebrew and
  ssh. A local install uses --local --service and the first free port from
  3000, and the app opens Codeman once it answers.
- The local Codeman's login is read from its LaunchAgent or
  ~/.codeman/.env. It is sent only to 127.0.0.1 on that port, only while
  the user's own Codeman process holds it, and is never saved.
- Native JS dialogs (alert, confirm, prompt) and HTTP Basic challenge
  answers, both scoped to Codeman's own pages, so the page keeps working
  after a server restart. App cookies are cleared before every connect.
- Pop-outs through window.CodemanHost, with an allowlist (webview/ never
  opens a window), external links in the default browser, downloads to
  ~/Downloads, real full screen and window state that is remembered.
- The connect page refuses a WebKit older than Safari 16.4, which Codeman's
  terminal needs.

Codeman side: viewSubagentTranscript shows a toast when the pop-up is
refused instead of failing, with zh-CN strings and a test that pins them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-05 00:36:49 +01:00
1 parent 2aa6016d33
commit 8ba5134174
22 files changed
+13547 -1

No files matched your search

+12
View File
@@ -983,6 +983,14 @@
'Could not open a new window for this session': '无法在新窗口中打开此会话',
'Could not open a new window for this preview': '无法在新窗口中打开此预览',
'Could not open a new window for this dashboard': '无法在新窗口中打开此网页标签',
// The Codeman desktop app's shim when the app would not open the window a
// page asked for (packages/desktop/src/assets.rs).
'Could not open a new window': '无法打开新窗口',
// A subagent transcript or a detached preview whose window.open came back
// null (panels-ui.js viewSubagentTranscript / detachFilePreview).
'Could not open the transcript window: pop-up windows are blocked here': '无法打开对话记录窗口:此处已阻止弹出窗口',
'Pop-up blocked: allow pop-ups for this site to detach previews':
'弹出窗口已被阻止:请允许此网站的弹出窗口以分离预览',
// Dictation whose session closed before the text was sent (voice-input.js).
'That session has closed; dictation not sent': '该会话已关闭,语音输入未发送',
// Terminal touch-selection bar (long-press to select). The bar is a sibling of
@@ -1457,6 +1465,10 @@
[/^Worktree (\S+) was kept: ([\s\S]+)$/, (_m, wt, why) => `工作树 ${wt} 已保留:${why}`],
// A pick, drop or paste that mixed admitted files with refused ones (image-input.js).
[/^(\d+) files? skipped: unsupported type$/, (_m, n) => `已跳过 ${n} 个不支持类型的文件`],
// The Codeman desktop app's download toasts (packages/desktop/src/assets.rs).
// The file name is user text: it passes through untranslated.
[/^Downloaded (.+) to Downloads$/, (_m, name) => `已将 ${name} 下载到“下载”文件夹`],
[/^Download of (.+) failed$/, (_m, name) => `下载 ${name} 失败`],
// Group names are user text: they pass through untranslated.
[/^Move to "(.+)"$/, (_m, group) => `移到“${group}”`],
// Session names are user text too.
+10 -1
View File
@@ -1963,7 +1963,16 @@ Object.assign(CodemanApp.prototype, {
// Show in a modal or new window
const content = data.data.formatted.join('\n');
const win = window.open('', '_blank', 'width=800,height=600');
// This open runs after the fetch above, so a pop-up blocker that wants a
// click can refuse it, and the Codeman desktop app always refuses an
// empty window. Both return null; say so instead of throwing into the
// catch below, whose 'Failed to load transcript' would be wrong.
let win = null;
try { win = window.open('', '_blank', 'width=800,height=600'); } catch {}
if (!win) {
this.showToast('Could not open the transcript window: pop-up windows are blocked here', 'error');
return;
}
win.document.write(`
<html>
<head>