2026-11-04 18:02:27 +01:00
<!DOCTYPE html>
< html lang = "en" >
< head >
< meta charset = "utf-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1" >
< title > Codeman</ title >
< style >
: root {
color-scheme : dark ;
--bg : #0a0a0a ;
--panel : #121316 ;
--line : #23252b ;
--line-strong : #30333b ;
--text : #e8eaed ;
--muted : #8b9099 ;
--accent : #60a5fa ;
--accent-2 : #3b82f6 ;
--bad : #f87171 ;
--warn : #fbbf24 ;
--ok : #4ade80 ;
}
* { box-sizing : border-box ; }
2026-11-06 03:26:57 +01:00
html { height : 100 % ; }
2026-11-04 18:02:27 +01:00
body {
2026-11-06 03:26:57 +01:00
min-height : 100 % ; margin : 0 ;
2026-11-04 18:02:27 +01:00
background : radial-gradient ( 1200 px 600 px at 50 % -10 % , #111a2b 0 % , var ( -- bg ) 60 % ) fixed , var ( -- bg );
color : var ( -- text );
font : 14 px / 1.45 - apple-system , BlinkMacSystemFont , "SF Pro Text" , "Segoe UI" , system-ui , sans-serif ;
2026-11-06 03:26:57 +01:00
display : flex ; flex-direction : column ; align-items : center ;
2026-11-04 18:02:27 +01:00
-webkit- user-select : none ; user-select : none ;
}
2026-11-06 03:26:57 +01:00
/* The top margin centres the tallest everyday page (a saved server with its
login, 708px), so the header stays where it is while the card changes: a
taller card grows downward, and a window too small for the page scrolls it
from the top (centring with align-items would cut the top off, out of
reach). */
main { width : min ( 440 px , calc ( 100 vw - 48 px )); margin : max ( 20 px , calc (( 100 vh - 708 px ) / 2 )) 0 24 px ; }
2026-11-04 18:02:27 +01:00
header { text-align : center ; margin-bottom : 26 px ; }
header img { width : 76 px ; height : 76 px ; display : block ; margin : 0 auto 12 px ; }
h1 { font-size : 24 px ; font-weight : 650 ; letter-spacing : -0.01 em ; margin : 0 ; }
header p { margin : 4 px 0 0 ; color : var ( -- muted ); }
2026-11-06 03:26:57 +01:00
@ media ( max-height : 640px ) {
header { margin-bottom : 16 px ; }
header img { width : 48 px ; height : 48 px ; margin-bottom : 8 px ; }
header p { display : none ; }
h1 { font-size : 20 px ; }
}
/* As tall as its usual content at least, with the two settings at its foot,
so switching tabs or a status arriving does not resize it. */
2026-11-04 18:02:27 +01:00
. card {
2026-11-06 03:26:57 +01:00
display : flex ; flex-direction : column ; min-height : 304 px ;
2026-11-04 18:02:27 +01:00
background : var ( -- panel ); border : 1 px solid var ( -- line ); border-radius : 14 px ;
padding : 20 px ; box-shadow : 0 20 px 60 px rgba ( 0 , 0 , 0 , 0.45 );
}
label . field { display : block ; font-size : 12 px ; color : var ( -- muted ); margin : 0 0 6 px ; }
input [ type = text ], input [ type = password ], input [ type = url ] {
width : 100 % ; height : 38 px ; padding : 0 12 px ; border-radius : 9 px ;
border : 1 px solid var ( -- line - strong ); background : #0c0d10 ; color : var ( -- text );
font : inherit ; outline : none ; -webkit- user-select : text ; user-select : text ;
}
input : focus { border-color : var ( -- accent -2 ); box-shadow : 0 0 0 3 px rgba ( 59 , 130 , 246 , 0.25 ); }
. row { display : flex ; gap : 10 px ; }
. row > * { flex : 1 ; }
. gap { height : 14 px ; }
# recent { display : flex ; flex-wrap : wrap ; gap : 6 px ; margin-top : 10 px ; }
# recent : empty { display : none ; }
. chip {
display : inline-flex ; align-items : center ; gap : 2 px ; max-width : 100 % ;
border : 1 px solid var ( -- line - strong ); border-radius : 999 px ; background : #0f1013 ;
color : var ( -- text ); font-size : 12 px ;
}
. chip button { all : unset ; cursor : default ; padding : 4 px 4 px 4 px 10 px ; overflow : hidden ; text-overflow : ellipsis ; white-space : nowrap ; max-width : 300 px ; }
. chip button . x { padding : 4 px 9 px 4 px 4 px ; color : var ( -- muted ); }
. chip button . x : hover { color : var ( -- bad ); }
. chip : hover { border-color : var ( -- accent -2 ); }
. chip button : disabled { opacity : 0.5 ; }
# login [ hidden ] { display : none ; }
# login { margin-top : 14 px ; padding-top : 14 px ; border-top : 1 px solid var ( -- line ); }
. check { display : flex ; align-items : center ; gap : 8 px ; color : var ( -- muted ); font-size : 13 px ; margin-top : 10 px ; }
. check input { accent-color : var ( -- accent -2 ); margin : 0 ; }
. actions { display : flex ; gap : 10 px ; margin-top : 18 px ; }
button . primary , button . secondary {
height : 38 px ; border-radius : 9 px ; font : inherit ; font-weight : 600 ; padding : 0 16 px ; cursor : default ;
}
2026-11-06 03:26:57 +01:00
button . primary { flex : 1 ; border : 0 ; color : white ; background : linear-gradient ( 180 deg , #2f6fed 0 % , #2563eb 100 % ); }
2026-11-04 18:02:27 +01:00
button . secondary { border : 1 px solid var ( -- line - strong ); background : #16181d ; color : var ( -- text ); }
2026-11-06 03:26:57 +01:00
/* A press answers at once, before the app does. Disabled comes last, so a
button that cannot be pressed does not look as if it could. */
button . primary : hover { filter : brightness ( 1.07 ); }
button . primary : active { filter : brightness ( 0.9 ); }
2026-11-04 18:02:27 +01:00
button . secondary : hover { border-color : var ( -- accent -2 ); }
2026-11-06 03:26:57 +01:00
button . secondary : active { background : #1f232b ; }
button . primary : disabled , button . secondary : disabled { opacity : 0.55 ; filter : none ; }
button . secondary : disabled { border-color : var ( -- line - strong ); background : #16181d ; }
button : focus-visible , . check input : focus-visible , summary : focus-visible , footer a : focus-visible { outline : 2 px solid var ( -- accent -2 ); outline-offset : 2 px ; }
2026-11-04 18:02:27 +01:00
button [ hidden ] { display : none ; }
2026-11-06 03:26:57 +01:00
# status { margin-top : 14 px ; font-size : 13 px ; color : var ( -- muted ); display : flex ; gap : 8 px ; align-items : flex-start ; }
2026-11-04 18:02:27 +01:00
# status . bad { color : var ( -- bad ); }
# status . warn { color : var ( -- warn ); }
# status . ok { color : var ( -- ok ); }
2026-11-06 03:26:57 +01:00
# status-text { min-width : 0 ; overflow-wrap : anywhere ; -webkit- user-select : text ; user-select : text ; }
2026-11-04 18:02:27 +01:00
# status . detail { display : block ; color : var ( -- muted ); font-size : 12 px ; margin-top : 4 px ; white-space : pre-wrap ; -webkit- user-select : text ; user-select : text ; }
# suggest { display : block ; max-width : 100 % ; margin-top : 10 px ; overflow : hidden ; text-overflow : ellipsis ; white-space : nowrap ; }
# suggest [ hidden ] { display : none ; }
# webkit { margin : 0 0 14 px ; padding : 10 px 12 px ; border : 1 px solid rgba ( 251 , 191 , 36 , 0.4 ); border-radius : 9 px ; background : rgba ( 251 , 191 , 36 , 0.08 ); color : var ( -- warn ); font-size : 13 px ; -webkit- user-select : text ; user-select : text ; }
# webkit [ hidden ] { display : none ; }
. spinner { width : 14 px ; height : 14 px ; flex : none ; margin-top : 2 px ; border-radius : 50 % ; border : 2 px solid rgba ( 96 , 165 , 250 , 0.25 ); border-top-color : var ( -- accent ); animation : spin 0.8 s linear infinite ; }
. spinner [ hidden ] { display : none ; }
@ keyframes spin { to { transform : rotate ( 360 deg ); } }
@ media ( prefers-reduced-motion : reduce ) { . spinner { animation-duration : 2.4 s ; } }
2026-11-06 03:26:57 +01:00
. prefs { margin-top : auto ; }
. prefs :: before { content : '' ; display : block ; margin-top : 16 px ; padding-top : 4 px ; border-top : 1 px solid var ( -- line ); }
2026-11-04 18:02:27 +01:00
. tabs { display : flex ; gap : 4 px ; padding : 4 px ; margin : 0 0 18 px ; background : #0c0d10 ; border : 1 px solid var ( -- line ); border-radius : 11 px ; }
. tabs button { all : unset ; flex : 1 ; text-align : center ; height : 30 px ; line-height : 30 px ; border-radius : 8 px ; font-weight : 600 ; font-size : 13 px ; color : var ( -- muted ); cursor : default ; }
. tabs button : hover { color : var ( -- text ); }
. tabs button [ aria-selected = "true" ] { background : #1c1f26 ; color : var ( -- text ); box-shadow : inset 0 1 px 0 rgba ( 255 , 255 , 255 , 0.04 ), 0 1 px 3 px rgba ( 0 , 0 , 0 , 0.45 ); }
. tabs button : focus-visible { outline : 2 px solid var ( -- accent -2 ); outline-offset : 1 px ; }
[ role = tabpanel ][ hidden ] { display : none ; }
. local { display : flex ; gap : 12 px ; align-items : flex-start ; min-height : 42 px ; }
. dot { width : 10 px ; height : 10 px ; border-radius : 50 % ; margin-top : 5 px ; flex : none ; background : #4b5059 ; }
. dot . ok { background : var ( -- ok ); box-shadow : 0 0 0 4 px rgba ( 74 , 222 , 128 , 0.14 ); }
. dot . warn { background : var ( -- warn ); }
. dot . busy { background : var ( -- accent ); animation : pulse 1.2 s ease-in-out infinite ; }
@ keyframes pulse { 50 % { opacity : 0.35 ; } }
@ media ( prefers-reduced-motion : reduce ) { . dot . busy { animation : none ; } }
# local-title { font-weight : 600 ; }
. sub { color : var ( -- muted ); font-size : 12 px ; margin-top : 3 px ; -webkit- user-select : text ; user-select : text ; overflow-wrap : anywhere ; }
2026-11-06 17:39:12 +01:00
/* The Codeman servers found on this Mac: a row each, in the look of a choice
in the install, with the row's own Connect button. */
# local-found-note { margin : 8 px 0 0 ; }
# local-also { margin-top : 16 px ; padding-top : 14 px ; border-top : 1 px solid var ( -- line ); }
# local-also-head { color : var ( -- muted ); font-size : 12 px ; margin : 0 0 6 px ; }
. found {
display : flex ; gap : 10 px ; align-items : center ; padding : 9 px 11 px ; border-radius : 9 px ;
border : 1 px solid var ( -- line - strong ); background : #0f1013 ;
}
. found + . found { margin-top : 6 px ; }
. found > span { flex : 1 ; min-width : 0 ; overflow-wrap : anywhere ; -webkit- user-select : text ; user-select : text ; }
. found b { display : block ; font-weight : 600 ; }
. found small { display : block ; margin-top : 1 px ; color : var ( -- muted ); font-size : 12 px ; }
. found button { flex : none ; }
2026-11-06 03:26:57 +01:00
# elsewhere { margin-top : 16 px ; padding-top : 14 px ; border-top : 1 px solid var ( -- line ); }
# elsewhere [ hidden ] { display : none ; }
2026-11-04 18:02:27 +01:00
# elsewhere summary { color : var ( -- muted ); font-size : 13 px ; cursor : default ; list-style : none ; }
# elsewhere summary :: -webkit-details-marker { display : none ; }
# elsewhere summary :: before { content : '\25B8' ; display : inline-block ; width : 14 px ; transition : transform 0.15 s ; }
# elsewhere [ open ] summary :: before { transform : rotate ( 90 deg ); }
# elsewhere summary : hover { color : var ( -- text ); }
# elsewhere . field { margin-top : 12 px ; }
. inline { display : flex ; gap : 8 px ; align-items : center ; }
. inline > input { flex : 1 ; min-width : 0 ; }
. inline > button { flex : none ; }
. oneliner { display : flex ; gap : 8 px ; align-items : center ; margin-top : 8 px ; }
. oneliner code { flex : 1 ; min-width : 0 ; font : 12 px / 1.4 ui-monospace , SFMono-Regular , Menlo , monospace ; background : #0c0d10 ; border : 1 px solid var ( -- line - strong ); border-radius : 8 px ; padding : 9 px 10 px ; color : #c9d1d9 ; -webkit- user-select : all ; user-select : all ; overflow-x : auto ; white-space : nowrap ; }
. statusrow { display : flex ; gap : 10 px ; align-items : flex-start ; }
2026-11-06 03:26:57 +01:00
. statusrow [ hidden ] { display : none ; }
2026-11-04 18:02:27 +01:00
. statusrow # status { flex : 1 ; }
. statusrow # cancel { margin-top : 10 px ; }
2026-11-06 04:02:20 +01:00
/* The graphical install takes over the card. Its steps differ in height, and
the page is anchored from the top (see `main`), so the logo stays put. */
2026-11-06 02:14:58 +01:00
main : has ( . card . wizard-on ) header p { display : none ; }
. card . wizard-on > . tabs , . card . wizard-on > # pane-local , . card . wizard-on > # form ,
2026-11-06 04:02:20 +01:00
. card . wizard-on > . statusrow , . card . wizard-on > # suggest , . card . wizard-on > # elsewhere ,
. card . wizard-on > . prefs { display : none ; }
2026-11-06 02:14:58 +01:00
# wizard [ hidden ], # wizard [ hidden ] { display : none ; }
. wiz-head { display : flex ; align-items : center ; gap : 9 px ; }
. wiz-head . spinner { margin-top : 0 ; }
# wiz-title { font-size : 17 px ; font-weight : 650 ; letter-spacing : -0.01 em ; margin : 0 ; }
# wiz-title : focus { outline : none ; }
# wiz-lead { margin : 6 px 0 0 ; color : var ( -- muted ); font-size : 13 px ; }
# wiz-message {
margin : 12 px 0 0 ; padding : 10 px 12 px ; border : 1 px solid rgba ( 248 , 113 , 113 , 0.4 ); border-radius : 9 px ;
background : rgba ( 248 , 113 , 113 , 0.08 ); color : var ( -- bad ); font-size : 13 px ; white-space : pre-wrap ;
overflow-wrap : anywhere ; -webkit- user-select : text ; user-select : text ;
}
# wiz-message . warn { border-color : rgba ( 251 , 191 , 36 , 0.4 ); background : rgba ( 251 , 191 , 36 , 0.08 ); color : var ( -- warn ); }
. wiz-group { margin-top : 14 px ; }
# wiz-agents { display : grid ; grid-template-columns : 1 fr 1 fr ; gap : 6 px ; }
. choice {
display : flex ; gap : 9 px ; align-items : flex-start ; padding : 9 px 11 px ; border-radius : 9 px ;
border : 1 px solid var ( -- line - strong ); background : #0f1013 ; cursor : default ;
}
. choice : hover { border-color : #454a55 ; }
. choice : has ( input : checked ) { border-color : var ( -- accent -2 ); background : rgba ( 59 , 130 , 246 , 0.1 ); }
. choice : has ( input : focus-visible ) { outline : 2 px solid var ( -- accent -2 ); outline-offset : 1 px ; }
. choice input { accent-color : var ( -- accent -2 ); margin : 2 px 0 0 ; flex : none ; }
. choice b { display : block ; font-weight : 600 ; }
. choice small { display : block ; margin-top : 1 px ; color : var ( -- muted ); font-size : 12 px ; }
2026-11-06 13:23:56 +01:00
. choice : has ( input : disabled ) { border-color : var ( -- line ); background : none ; color : var ( -- muted ); }
2026-11-06 02:14:58 +01:00
# wiz-reach . choice + . choice { margin-top : 6 px ; }
# wiz-password { margin-top : 10 px ; }
# wiz-agent-note { margin : 10 px 0 0 ; color : var ( -- muted ); font-size : 12 px ; }
# wiz-agent-note a , . wiz-link { all : unset ; color : #7c8491 ; cursor : default ; }
# wiz-agent-note a { color : var ( -- accent ); white-space : nowrap ; }
# wiz-agent-note a : hover , . wiz-link : hover { color : var ( -- text ); }
. wiz-link : focus-visible , # wiz-agent-note a : focus-visible { outline : 2 px solid var ( -- accent -2 ); outline-offset : 2 px ; border-radius : 3 px ; }
. wiz-link { display : block ; margin : 12 px auto 0 ; font-size : 12 px ; text-align : center ; }
# wiz-summary { margin : 14 px 0 0 ; padding : 12 px 0 0 ; border-top : 1 px solid var ( -- line ); list-style : none ; color : var ( -- muted ); font-size : 12 px ; }
# wiz-summary li { padding : 2 px 0 2 px 14 px ; position : relative ; }
# wiz-summary li :: before { content : '' ; position : absolute ; left : 2 px ; top : 9 px ; width : 4 px ; height : 4 px ; border-radius : 50 % ; background : #5d626b ; }
# wiz-summary li . warn { color : var ( -- warn ); }
/* The install's rail: one node per phase, joined by a line that fills in as
the phases finish. The running node carries the phase's own clock, because
the longest phases print nothing while they work. */
# wiz-rail { margin : 16 px 0 0 ; padding : 0 ; list-style : none ; }
# wiz-rail li { position : relative ; display : flex ; align-items : baseline ; gap : 10 px ; padding : 0 0 13 px 24 px ; color : #6b717c ; }
# wiz-rail li : last-child { padding-bottom : 0 ; }
# wiz-rail li :: before {
content : '' ; position : absolute ; left : 4 px ; top : 6 px ; width : 8 px ; height : 8 px ; box-sizing : content-box ;
border-radius : 50 % ; background : var ( -- panel ); border : 1 px solid #454a55 ; z-index : 1 ;
}
# wiz-rail li :: after { content : '' ; position : absolute ; left : 8.5 px ; top : 12 px ; bottom : -6 px ; width : 1 px ; background : var ( -- line - strong ); }
# wiz-rail li : last-child :: after { display : none ; }
# wiz-rail li . done { color : var ( -- text ); }
# wiz-rail li . done :: before { background : var ( -- ok ); border-color : var ( -- ok ); }
# wiz-rail li . done :: after { background : rgba ( 74 , 222 , 128 , 0.45 ); }
# wiz-rail li . now { color : var ( -- text ); font-weight : 600 ; }
# wiz-rail li . now :: before { background : var ( -- accent ); border-color : var ( -- accent ); box-shadow : 0 0 0 4 px rgba ( 96 , 165 , 250 , 0.18 ); animation : pulse 1.2 s ease-in-out infinite ; }
# wiz-rail li . failed { color : var ( -- bad ); font-weight : 600 ; }
# wiz-rail li . failed :: before { background : var ( -- bad ); border-color : var ( -- bad ); }
@ media ( prefers-reduced-motion : reduce ) { # wiz-rail li . now :: before { animation : none ; } }
# wiz-rail . clock { margin-left : auto ; color : var ( -- muted ); font-size : 12 px ; font-weight : 400 ; font-variant-numeric : tabular-nums ; }
# wiz-details { margin-top : 14 px ; }
# wiz-details summary { color : var ( -- muted ); font-size : 12 px ; cursor : default ; list-style : none ; }
# wiz-details summary :: -webkit-details-marker { display : none ; }
# wiz-details summary :: before { content : '\25B8' ; display : inline-block ; width : 13 px ; transition : transform 0.15 s ; }
# wiz-details [ open ] summary :: before { transform : rotate ( 90 deg ); }
# wiz-details summary : hover { color : var ( -- text ); }
# wiz-log {
margin : 8 px 0 0 ; height : 124 px ; overflow : auto ; padding : 8 px 10 px ; border-radius : 8 px ; background : #0c0d10 ;
border : 1 px solid var ( -- line ); color : #aab1bb ; font : 11 px / 1.45 ui-monospace , SFMono-Regular , Menlo , monospace ;
white-space : pre-wrap ; overflow-wrap : anywhere ; -webkit- user-select : text ; user-select : text ;
}
# wizard . actions . wiz-busy { display : inline-flex ; align-items : center ; gap : 8 px ; color : var ( -- muted ); font-size : 13 px ; }
2026-11-06 03:26:57 +01:00
footer { margin-top : 18 px ; text-align : center ; color : var ( -- muted ); font-size : 12 px ; }
footer a { color : #aab1bb ; text-decoration : none ; cursor : default ; }
2026-11-04 18:02:27 +01:00
footer a : hover { color : var ( -- accent ); }
kbd { font : 11 px / 1 ui-monospace , SFMono-Regular , Menlo , monospace ; border : 1 px solid var ( -- line - strong ); border-bottom-width : 2 px ; border-radius : 4 px ; padding : 2 px 4 px ; color : #9aa1ab ; }
</ style >
</ head >
< body >
< main >
< header >
< img src = "icon.png" alt = "" >
< h1 > Codeman</ h1 >
< p > Run Codeman on this Mac, or connect to another machine</ p >
</ header >
< div class = "card" >
< div class = "tabs" role = "tablist" aria-label = "Where Codeman runs" >
< button type = "button" role = "tab" id = "tab-local" aria-controls = "pane-local" aria-selected = "true" > This Mac</ button >
< button type = "button" role = "tab" id = "tab-remote" aria-controls = "form" aria-selected = "false" tabindex = "-1" > Another machine</ button >
</ div >
2026-11-06 02:14:58 +01:00
< section id = "wizard" aria-labelledby = "wiz-title" hidden >
< div class = "wiz-head" >< span class = "spinner" id = "wiz-spinner" hidden ></ span >< h2 id = "wiz-title" tabindex = "-1" ></ h2 ></ div >
< p id = "wiz-lead" ></ p >
< div id = "wiz-message" role = "alert" hidden ></ div >
< div class = "wiz-group" id = "wiz-agent-step" hidden >
< div id = "wiz-agents" role = "radiogroup" aria-labelledby = "wiz-title" ></ div >
< p id = "wiz-agent-note" ></ p >
</ div >
< div class = "wiz-group" id = "wiz-reach-step" hidden >
< div id = "wiz-reach" role = "radiogroup" aria-labelledby = "wiz-title" >
< label class = "choice" >< input type = "radio" name = "reach" value = "local" checked >
< span >< b > Only this Mac</ b >< small > The safest choice. You can open it to other devices later.</ small ></ span ></ label >
< label class = "choice" >< input type = "radio" name = "reach" value = "network" >
< span >< b > This Mac and my local network</ b >< small > Phones and laptops on the same Wi-Fi sign in with a password.</ small ></ span ></ label >
</ div >
< div id = "wiz-password" class = "row" hidden >
< div >< label class = "field" for = "wiz-pw" > Password</ label >< input id = "wiz-pw" type = "password" autocomplete = "new-password" ></ div >
< div >< label class = "field" for = "wiz-pw2" > Repeat it</ label >< input id = "wiz-pw2" type = "password" autocomplete = "new-password" ></ div >
</ div >
< ul id = "wiz-summary" ></ ul >
</ div >
< ol id = "wiz-rail" hidden ></ ol >
< details id = "wiz-details" hidden >
< summary > Show the installer’ s output</ summary >
< pre id = "wiz-log" ></ pre >
</ details >
< div class = "actions" >
< button class = "secondary" id = "wiz-back" type = "button" hidden > Back</ button >
< button class = "secondary" id = "wiz-skip" type = "button" hidden > Skip for now</ button >
< button class = "primary" id = "wiz-next" type = "button" hidden ></ button >
< button class = "secondary" id = "wiz-cancel" type = "button" hidden > Cancel install</ button >
</ div >
< button class = "wiz-link" id = "wiz-terminal" type = "button" hidden > Install in Terminal instead (asks for your Mac password)</ button >
</ section >
2026-11-04 18:02:27 +01:00
< p id = "webkit" role = "alert" hidden > Codeman needs a newer WebKit than this Mac has: macOS 13.3 or later, or Safari 16.4 or later on macOS 11 and 12.</ p >
< section id = "pane-local" role = "tabpanel" aria-labelledby = "tab-local" >
< div class = "local" >
< span class = "dot busy" id = "local-dot" ></ span >
2026-11-06 03:26:57 +01:00
< div role = "status" >
2026-11-04 18:02:27 +01:00
< div id = "local-title" > Looking for Codeman on this Mac…</ div >
< div class = "sub" id = "local-detail" ></ div >
</ div >
</ div >
2026-11-06 17:39:12 +01:00
< div id = "local-found" role = "list" aria-labelledby = "local-title" hidden ></ div >
< p class = "sub" id = "local-found-note" hidden > Starting another server here would take over its sessions.</ p >
2026-11-04 18:02:27 +01:00
< div class = "actions" >
< button class = "primary" id = "local-open" type = "button" hidden > Open Codeman</ button >
< button class = "primary" id = "local-start" type = "button" hidden > Start Codeman</ button >
< button class = "primary" id = "local-install" type = "button" hidden > Install Codeman on this Mac</ button >
< button class = "secondary" id = "local-stop" type = "button" hidden > Stop waiting</ button >
< button class = "secondary" id = "local-check" type = "button" hidden > Check again</ button >
</ div >
2026-11-06 13:23:56 +01:00
< button class = "wiz-link" id = "local-agents" type = "button" hidden > Add a coding agent</ button >
2026-11-06 17:39:12 +01:00
< div id = "local-also" hidden >
< div id = "local-also-head" > Also running on this Mac</ div >
< div id = "local-also-list" role = "list" aria-labelledby = "local-also-head" ></ div >
</ div >
2026-11-04 18:02:27 +01:00
</ section >
< form id = "form" role = "tabpanel" aria-labelledby = "tab-remote" autocomplete = "off" novalidate hidden >
< label class = "field" for = "url" > Server address</ label >
< input id = "url" type = "text" spellcheck = "false" autocapitalize = "off"
placeholder = "http://127.0.0.1:3000 or https://your-box.ts.net" list = "recent-list" >
< datalist id = "recent-list" ></ datalist >
2026-11-06 03:26:57 +01:00
< div id = "recent" role = "group" aria-label = "Recent servers" ></ div >
2026-11-04 18:02:27 +01:00
< div id = "login" hidden >
< div class = "row" >
< div >
< label class = "field" for = "username" > Username</ label >
< input id = "username" type = "text" spellcheck = "false" autocapitalize = "off" placeholder = "admin" >
</ div >
< div >
< label class = "field" for = "password" > Password</ label >
< input id = "password" type = "password" placeholder = "" >
</ div >
</ div >
< label class = "check" >< input id = "remember" type = "checkbox" checked > Remember the password in the Keychain</ label >
</ div >
< div class = "actions" >
< button class = "primary" id = "connect" type = "submit" > Connect</ button >
< button class = "secondary" id = "start" type = "button" hidden > Start Codeman on this Mac</ button >
</ div >
</ form >
2026-11-06 03:26:57 +01:00
< div class = "statusrow" id = "status-row" hidden >
2026-11-04 18:02:27 +01:00
< div id = "status" role = "status" aria-live = "polite" >< span class = "spinner" id = "spinner" hidden ></ span >< span id = "status-text" ></ span ></ div >
< button class = "secondary" id = "cancel" type = "button" hidden > Cancel</ button >
</ div >
< button class = "secondary" id = "suggest" type = "button" hidden ></ button >
2026-11-06 03:26:57 +01:00
< details id = "elsewhere" hidden >
< summary > Codeman is not installed there yet?</ summary >
< label class = "field" for = "ssh" > Install it over SSH</ label >
< div class = "inline" >
<!-- 300 is what setup.rs takes for a login, and at most 900 bytes: under the cap of a message the app accepts. -->
< input id = "ssh" type = "text" maxlength = "300" spellcheck = "false" autocapitalize = "off" placeholder = "you@machine, or a name from ~/.ssh/config" >
< button class = "secondary" id = "ssh-install" type = "button" > Install</ button >
</ div >
< p class = "sub" > Terminal opens, connects with ssh and runs Codeman’ s installer there. Or run this in a terminal on that machine:</ p >
< div class = "oneliner" >< code id = "oneliner" > curl -fsSL https://getcodeman.com/install | bash</ code >< button class = "secondary" id = "copy" type = "button" > Copy</ button ></ div >
</ details >
2026-11-04 18:02:27 +01:00
< div class = "prefs" >
2026-11-06 03:26:57 +01:00
< label class = "check" >< input id = "auto" type = "checkbox" checked > Connect automatically when the app opens</ label >
2026-11-04 18:02:27 +01:00
< label class = "check" >< input id = "fullscreen" type = "checkbox" > Always open in full screen < kbd > ⌃⌘ F</ kbd ></ label >
</ div >
</ div >
< footer >
< span id = "version" > Codeman Desktop</ span > ·
< a href = "#" id = "docs" > Help</ a >
</ footer >
</ main >
< script >
// Codeman's terminal (xterm 6) uses class static blocks, which WebKit parses
// from Safari 16.4 on. An older WebKit rejects this whole script, so the flag
// stays unset. This is its own script because the page's CSP has no
// 'unsafe-eval', so new Function cannot run the test.
void class { static { window . __codemanStaticBlocks = true ; } };
</ script >
< script >
( function () {
'use strict' ;
var $ = function ( id ) { return document . getElementById ( id ); };
// Codeman would not load in this web view: say so, never connect, and keep
// Connect disabled. The page still answers the app (ready, preferences).
var tooOld = window . __codemanStaticBlocks !== true ;
var els = {
form : $ ( 'form' ), url : $ ( 'url' ), list : $ ( 'recent-list' ), recent : $ ( 'recent' ),
login : $ ( 'login' ), username : $ ( 'username' ), password : $ ( 'password' ), remember : $ ( 'remember' ),
connect : $ ( 'connect' ), cancel : $ ( 'cancel' ), start : $ ( 'start' ), suggest : $ ( 'suggest' ),
2026-11-06 03:26:57 +01:00
statusRow : $ ( 'status-row' ), status : $ ( 'status' ), spinner : $ ( 'spinner' ), statusText : $ ( 'status-text' ),
2026-11-04 18:02:27 +01:00
auto : $ ( 'auto' ), fullscreen : $ ( 'fullscreen' ), version : $ ( 'version' ), webkit : $ ( 'webkit' ),
tabLocal : $ ( 'tab-local' ), tabRemote : $ ( 'tab-remote' ), paneLocal : $ ( 'pane-local' ),
localDot : $ ( 'local-dot' ), localTitle : $ ( 'local-title' ), localDetail : $ ( 'local-detail' ),
2026-11-06 02:14:58 +01:00
card : document . querySelector ( '.card' ),
wizard : $ ( 'wizard' ), wizSpinner : $ ( 'wiz-spinner' ), wizTitle : $ ( 'wiz-title' ), wizLead : $ ( 'wiz-lead' ), wizMessage : $ ( 'wiz-message' ),
wizAgentStep : $ ( 'wiz-agent-step' ), wizAgents : $ ( 'wiz-agents' ), wizAgentNote : $ ( 'wiz-agent-note' ),
wizReachStep : $ ( 'wiz-reach-step' ), wizPassword : $ ( 'wiz-password' ), wizPw : $ ( 'wiz-pw' ), wizPw2 : $ ( 'wiz-pw2' ),
wizSummary : $ ( 'wiz-summary' ), wizRail : $ ( 'wiz-rail' ), wizDetails : $ ( 'wiz-details' ), wizLog : $ ( 'wiz-log' ),
wizBack : $ ( 'wiz-back' ), wizSkip : $ ( 'wiz-skip' ), wizNext : $ ( 'wiz-next' ), wizCancel : $ ( 'wiz-cancel' ),
wizTerminal : $ ( 'wiz-terminal' ),
2026-11-04 18:02:27 +01:00
localOpen : $ ( 'local-open' ), localStart : $ ( 'local-start' ), localInstall : $ ( 'local-install' ),
2026-11-06 13:23:56 +01:00
localStop : $ ( 'local-stop' ), localCheck : $ ( 'local-check' ), localAgents : $ ( 'local-agents' ),
2026-11-06 17:39:12 +01:00
localFound : $ ( 'local-found' ), localFoundNote : $ ( 'local-found-note' ),
localAlso : $ ( 'local-also' ), localAlsoList : $ ( 'local-also-list' ),
2026-11-04 18:02:27 +01:00
elsewhere : $ ( 'elsewhere' ), ssh : $ ( 'ssh' ), sshInstall : $ ( 'ssh-install' ), copy : $ ( 'copy' )
};
var manual = /[?&]manual=1/ . test ( location . search );
// startUrl: the address the user asked to start Codeman for ('' when this
// page did not ask, e.g. it was reloaded while a start was running).
// suggestion: the address the suggest button connects to.
2026-11-06 03:26:57 +01:00
// tab: 'local' (This Mac) or 'remote' (Another machine). askedFrom: the tab
// the connect under way was asked from, where its answer belongs. local: the
// last This Mac status from the app. wait: set while an install runs in
2026-11-06 02:14:58 +01:00
// Terminal and this page polls for the server it brings up. gui: the
// graphical install while its screens are up (newWizard), null otherwise.
2026-11-06 04:02:20 +01:00
// reopen: a connect to this Mac was refused, so open it if it turns out to
// be starting. startingLooks: how often the page has looked again at a
2026-11-06 17:39:12 +01:00
// starting server. askedFound: the connect under way was asked from the row
// of a server found on this Mac.
2026-11-04 18:02:27 +01:00
var state = {
servers : [], busy : false , lastUrl : '' , startUrl : '' , suggestion : '' ,
2026-11-06 17:39:12 +01:00
tab : 'local' , askedFrom : 'local' , askedFound : false , local : null , localChecking : false , opening : false ,
2026-11-06 13:25:27 +01:00
wait : null , waitTimer : 0 , stoppedWaiting : false , gui : null , wantAgents : false ,
2026-11-06 03:40:53 +01:00
reopen : false , startingLooks : 0 , startingTimer : 0
2026-11-04 18:02:27 +01:00
};
// While an install runs: how often to look, how long a finished install may
// take to answer before Start is offered, and when to stop looking.
var WAIT_POLL_MS = 3000 ;
var WAIT_START_GRACE_MS = 90 * 1000 ;
var WAIT_MAX_MS = 30 * 60 * 1000 ;
2026-11-06 02:14:58 +01:00
// The phases `install.sh --progress` reports, in its order.
var PHASES = [ 'runtime' , 'agent' , 'source' , 'packages' , 'build' , 'service' ];
var MAX_LOG_LINES = 600 ;
2026-11-06 03:40:53 +01:00
// A server that is still starting: how often to look again, and how many times.
var STARTING_POLL_MS = 1000 ;
var STARTING_MAX_LOOKS = 20 ;
2026-11-06 03:26:57 +01:00
// What the app takes in one message, in UTF-8 bytes (MAX_URL_LEN and
// MAX_FIELD_LEN in policy.rs). It drops a longer one without an answer, so
// the page has to be the one that says so.
var MAX_URL = 4096 ;
var MAX_FIELD = 1024 ;
2026-11-06 03:40:53 +01:00
function tooLong ( text , max ) {
var bytes = 0 ;
for ( var i = 0 ; i < text . length ; i ++ ) {
var c = text . charCodeAt ( i );
if ( c < 0x80 ) bytes += 1 ;
else if ( c < 0x800 ) bytes += 2 ;
else if ( c >= 0xd800 && c < 0xdc00 ) { bytes += 4 ; i ++ ; } // a surrogate pair: one character
else bytes += 3 ;
}
return bytes > max ;
}
2026-11-04 18:02:27 +01:00
function post ( msg ) { try { window . ipc . postMessage ( JSON . stringify ( msg )); } catch ( e ) { /* not in the app */ } }
function setStatus ( kind , text , detail ) {
2026-11-06 03:26:57 +01:00
els . statusRow . hidden = ! text ;
// In a small window the line may be below the fold: bring it into view.
2026-11-06 03:40:53 +01:00
if ( text && els . statusRow . scrollIntoView ) els . statusRow . scrollIntoView ({ block : 'nearest' });
2026-11-04 18:02:27 +01:00
els . status . className = kind || '' ;
els . spinner . hidden = kind !== 'busy' ;
els . statusText . textContent = text || '' ;
if ( detail ) {
var d = document . createElement ( 'span' );
d . className = 'detail' ;
d . textContent = detail ;
els . statusText . appendChild ( d );
}
}
// One request at a time: every way to start one (Connect, a recent server,
// the suggest button) is disabled while busy, never hidden, so the second
// click of a double click cannot land on whatever moved into its place.
// Cancel only where the native side can really cancel: a probe or a page
// load, never a server start.
function setBusy ( busy , cancellable ) {
state . busy = busy ;
els . connect . disabled = busy || tooOld ;
els . suggest . disabled = busy || tooOld ;
els . cancel . hidden = ! ( busy && cancellable !== false );
if ( busy ) els . start . hidden = true ;
disableChips ();
syncLocalButtons ();
}
// Forgetting a server stays possible when the WebKit is too old; connecting
// to one does not.
function disableChips () {
Array . prototype . forEach . call ( els . recent . querySelectorAll ( 'button' ), function ( b ) {
b . disabled = state . busy || ( tooOld && ! b . classList . contains ( 'x' ));
});
}
// Nothing to suggest when this WebKit cannot open Codeman at any address.
function suggest ( url ) {
if ( tooOld ) return ;
state . suggestion = url ;
els . suggest . textContent = 'Use ' + url ;
els . suggest . title = 'Connect to ' + url ;
els . suggest . hidden = false ;
}
function hideSuggestion () {
state . suggestion = '' ;
els . suggest . hidden = true ;
}
function host ( url ) {
try { var u = new URL ( url ); return u . host + ( u . pathname === '/' ? '' : u . pathname . replace ( /\/$/ , '' )); } catch ( e ) { return url ; }
}
// The address as the app normalizes it (policy::normalize_server_url):
2026-11-06 03:40:53 +01:00
// http:// by default, https:// for a .ts.net name without a port, no query
// or fragment, a trailing slash. Only used to tell whether two addresses
// are one server.
2026-11-04 18:02:27 +01:00
function normalize ( raw ) {
var s = String ( raw || '' ). trim ();
if ( ! s ) return '' ;
if ( s . indexOf ( '://' ) < 0 ) {
var name = s . split ( /[\/:]/ )[ 0 ]. toLowerCase ();
2026-11-06 03:40:53 +01:00
var tailscaleServe = /\.ts\.net$/ . test ( name ) && s . charAt ( name . length ) !== ':' ;
s = ( tailscaleServe ? 'https' : 'http' ) + '://' + s ;
2026-11-04 18:02:27 +01:00
}
try {
var u = new URL ( s );
u . search = '' ;
u . hash = '' ;
if ( ! /\/$/ . test ( u . pathname )) u . pathname += '/' ;
return u . href ;
} catch ( e ) { return '' ; }
}
function sameServer ( a , b ) {
var na = normalize ( a );
return na !== '' && na === normalize ( b );
}
function renderRecent () {
els . recent . textContent = '' ;
els . list . textContent = '' ;
state . servers . forEach ( function ( s ) {
var opt = document . createElement ( 'option' );
opt . value = s . url ;
els . list . appendChild ( opt );
var chip = document . createElement ( 'span' );
chip . className = 'chip' ;
var pick = document . createElement ( 'button' );
pick . type = 'button' ;
pick . textContent = host ( s . url ) + ( s . username ? ' (' + s . username + ')' : '' );
pick . title = 'Connect to ' + s . url ;
pick . addEventListener ( 'click' , function () {
if ( state . busy ) return ;
choose ( s );
connect ();
});
var x = document . createElement ( 'button' );
x . type = 'button' ;
x . className = 'x' ;
x . textContent = '× ' ;
x . title = 'Forget this server' + ( s . hasSavedPassword ? ' and its saved password' : '' );
2026-11-06 03:26:57 +01:00
x . setAttribute ( 'aria-label' , 'Forget ' + host ( s . url ));
2026-11-04 18:02:27 +01:00
x . addEventListener ( 'click' , function () { if ( ! state . busy ) post ({ cmd : 'forget' , url : s . url }); });
chip . appendChild ( pick );
chip . appendChild ( x );
els . recent . appendChild ( chip );
});
disableChips ();
}
function choose ( s ) {
els . url . value = s . url ;
els . username . value = s . username || '' ;
els . password . value = '' ;
els . password . placeholder = s . hasSavedPassword ? 'saved in Keychain' : '' ;
els . login . hidden = ! s . username ;
}
// The saved server the address field names, if any, with its login.
function matchSaved () {
var match = state . servers . filter ( function ( s ) { return s . url === els . url . value . trim (); })[ 0 ];
if ( match ) choose ( match );
2026-11-06 03:26:57 +01:00
else els . password . placeholder = '' ;
2026-11-04 18:02:27 +01:00
}
2026-11-06 17:39:12 +01:00
// found: asked from the row of a server found on this Mac (connectFound).
function connect ( found ) {
2026-11-04 18:02:27 +01:00
if ( state . busy || tooOld ) return ;
var url = els . url . value . trim ();
if ( ! url ) { setStatus ( 'bad' , 'Enter the address of your Codeman server.' ); els . url . focus (); return ; }
2026-11-06 03:26:57 +01:00
var username = els . login . hidden ? '' : els . username . value . trim ();
var password = els . login . hidden ? '' : els . password . value ;
if ( tooLong ( url , MAX_URL )) { setStatus ( 'bad' , 'That address is too long.' ); els . url . focus (); return ; }
if ( tooLong ( username , MAX_FIELD ) || tooLong ( password , MAX_FIELD )) {
setStatus ( 'bad' , 'That username or password is too long.' );
return ;
}
2026-11-04 18:02:27 +01:00
state . lastUrl = url ;
2026-11-06 03:26:57 +01:00
state . askedFrom = state . tab ;
2026-11-06 17:39:12 +01:00
state . askedFound = found === true ;
2026-11-04 18:02:27 +01:00
setBusy ( true );
setStatus ( 'busy' , 'Connecting to ' + url + '…' );
var msg = { cmd : 'connect' , url : url , remember : els . remember . checked };
2026-11-06 03:26:57 +01:00
if ( username ) msg . username = username ;
if ( password ) msg . password = password ;
2026-11-04 18:02:27 +01:00
post ( msg );
}
function showLogin () {
els . login . hidden = false ;
if ( ! els . username . value ) els . username . value = 'admin' ;
els . password . focus ();
}
function hostName ( url ) {
try { return new URL ( url ). hostname . toLowerCase (); } catch ( e ) { return '' ; }
}
var LOCAL_NETWORK = 'On macOS 15 and later the app may also need Local Network access: System Settings > Privacy & Security > Local Network, then turn on Codeman.' ;
var explain = {
needsLogin : [ 'warn' , 'This server asks for a login.' , 'Codeman’ s default username is admin; the password is CODEMAN_PASSWORD on the server.' ],
badCredentials : [ 'bad' , 'Wrong username or password.' , 'After 10 failed attempts Codeman locks logins from this address for 15 minutes.' ],
rateLimited : [ 'bad' , 'Too many failed logins from this address.' , 'If you know the password, enter it and connect: a single-user Codeman accepts the right password even now. Otherwise, or on a multi-user server, wait up to 15 minutes and try again.' ],
hostNotAllowed : [ 'bad' , 'Codeman refused this host name.' , 'Codeman only answers names it knows. Add this one to CODEMAN_ALLOWED_HOSTS on the server, or use its tailnet name or 127.0.0.1.' ],
refused : [ 'bad' , 'Nothing is answering at this address.' , '' ],
timeout : [ 'bad' , 'The server did not answer within 5 seconds.' , 'Check that the machine is awake and reachable (Tailscale connected, same network).' ],
unreachable : [ 'bad' , 'This Mac cannot reach that address.' , 'Check that the machine is on and on the same network (or that Tailscale is connected). ' + LOCAL_NETWORK ],
unresolvable : [ 'bad' , 'That host name could not be found.' , 'Check the spelling, or that Tailscale is connected for a .ts.net name.' ],
maybeHttps : [ 'bad' , 'The server closed the connection without answering.' , 'The port may be serving HTTPS: a Codeman started with --https only answers https://. Try the https:// address, or your tailnet name (https://….ts.net). Codeman’ s own self-signed certificate names only localhost and 127.0.0.1, so the app cannot accept it at this address: serve Codeman through tailscale serve (a real ts.net certificate) or a reverse proxy with a real certificate.' ],
2026-11-06 03:26:57 +01:00
tlsUntrusted : [ 'bad' , 'The server’ s HTTPS certificate is not trusted.' , 'This is usually Codeman’ s self-signed certificate (codeman web --https). It names only localhost and 127.0.0.1, so the app cannot accept it at this address. Serve Codeman through tailscale serve (a real https://….ts.net certificate) or a reverse proxy with a real certificate. For a server on this Mac, run Codeman without --https and use http://127.0.0.1:PORT.' ]
2026-11-04 18:02:27 +01:00
};
// The same two at localhost or 127.0.0.1, the only names Codeman's
// self-signed certificate carries, so the only ones where trusting it in
// Keychain Access can help.
var explainLoopback = {
maybeHttps : 'The port may be serving HTTPS: a Codeman started with --https only answers https://. Try the https:// address. The app’ s web view refuses Codeman’ s self-signed certificate, though: marking it as trusted in Keychain Access may work, or run Codeman without --https.' ,
tlsUntrusted : 'This is usually Codeman’ s self-signed certificate (codeman web --https), which the app’ s web view refuses. Run Codeman without --https and use http://127.0.0.1:PORT instead. Marking the certificate as trusted in Keychain Access may also work.'
};
function loopbackName ( url ) {
var h = hostName ( url );
return h === 'localhost' || h === '127.0.0.1' ;
}
function onProbeResult ( msg ) {
2026-11-06 03:26:57 +01:00
// The answer belongs to the tab its connect was asked from, whichever one
// shows by now; with no connect under way, to the one in view.
var asked = state . busy ? state . askedFrom : state . tab ;
2026-11-04 18:02:27 +01:00
setBusy ( false );
hideSuggestion ();
var o = msg . outcome || {};
var e = explain [ o . kind ];
if ( o . kind === 'needsLogin' || o . kind === 'badCredentials' || o . kind === 'rateLimited' ) {
// The login fields live on the Another machine tab.
selectTab ( 'remote' );
showLogin ();
2026-11-06 03:26:57 +01:00
} else if ( asked === 'local' ) {
2026-11-06 17:39:12 +01:00
// Opening the Codeman on this Mac failed: look at it again. Opening it
// once it is up is for the one the tab's first line shows (openLocal),
// never for a found server's row, which may be another server.
state . reopen = o . kind === 'refused' && ! state . askedFound ;
2026-11-06 03:26:57 +01:00
selectTab ( 'local' );
// selectTab starts the look itself when there has been none yet.
if ( ! state . localChecking ) checkLocal ( 'login' );
} else {
selectTab ( 'remote' );
2026-11-04 18:02:27 +01:00
}
if ( o . kind === 'notCodeman' ) {
setStatus ( 'bad' , 'Something answered, but it is not a Codeman server (HTTP ' + o . status + ').' , 'Check the port and, behind a proxy, the path Codeman is mounted under.' );
} else if ( o . kind === 'redirect' ) {
var where = o . location ? 'It points to ' + o . location + '.' : '' ;
if ( o . suggested ) {
setStatus ( 'warn' , 'This address redirects to another one.' , where + '\nCodeman seems to answer at ' + o . suggested + '.' );
suggest ( o . suggested );
} else {
setStatus ( 'bad' , 'This address redirects somewhere else.' , where + '\nThe app connects only to the address Codeman itself answers on. Check the address and, behind a proxy, the path Codeman is mounted under.' );
}
} else if ( o . kind === 'failed' ) {
setStatus ( 'bad' , 'Could not connect.' , o . detail || '' );
} else if ( e ) {
var detail = e [ 2 ];
if ( o . kind === 'refused' && msg . canStart ) detail = 'Codeman is not running on this Mac. Start it here, or check the port.' ;
if ( o . kind === 'rateLimited' && o . sentCredentials ) detail = 'The saved or typed password may be wrong. ' + detail ;
if ( o . kind === 'unresolvable' && /\.local\.?$/ . test ( hostName ( msg . url ))) {
detail = 'Check the spelling, and that the machine is on and on the same network. ' + LOCAL_NETWORK ;
}
if ( explainLoopback [ o . kind ] && loopbackName ( msg . url )) detail = explainLoopback [ o . kind ];
if ( o . kind === 'tlsUntrusted' && o . detail ) detail += '\n(' + o . detail + ')' ;
2026-11-06 03:26:57 +01:00
if (( o . kind === 'needsLogin' || o . kind === 'badCredentials' ) && /^http:/i . test ( normalize ( msg . url )) && ! isLocalUrl ( msg . url )) {
detail += ' This address is http://, so the password crosses the network unencrypted: fine on a tailnet or a network you trust.' ;
}
2026-11-04 18:02:27 +01:00
setStatus ( e [ 0 ], e [ 1 ], detail );
if ( o . kind === 'maybeHttps' && /^http:/i . test ( msg . url || '' )) suggest ( String ( msg . url ). replace ( /^http:/i , 'https:' ));
} else {
setStatus ( 'bad' , 'Could not connect.' );
}
els . start . hidden = ! ( o . kind === 'refused' && msg . canStart );
}
function onStartResult ( msg ) {
setBusy ( false );
// Connect only to the server this page asked to start; after a reload
// mid-start, only to the one the address field shows.
var asked = state . startUrl || els . url . value ;
state . startUrl = '' ;
if ( ! msg . ok ) { setStatus ( 'bad' , 'Codeman did not start.' , msg . output || '' ); return ; }
setStatus ( 'ok' , 'Codeman started.' , msg . output || '' );
if ( ! msg . url || ! sameServer ( msg . url , asked )) return ;
if ( ! sameServer ( msg . url , els . url . value )) { els . url . value = msg . url ; matchSaved (); }
connect ();
}
function isLocalUrl ( url ) {
var h = hostName ( normalize ( url ));
return h === 'localhost' || /^127\./ . test ( h ) || h === '[::1]' ;
}
2026-11-06 03:26:57 +01:00
// Look at this Mac, once, when the This Mac tab shows and nothing has yet.
function ensureLocalChecked () {
if ( state . tab === 'local' && ! state . local && ! state . localChecking ) checkLocal ( 'login' );
}
// later: leave the look at this Mac to whoever asked (ensureLocalChecked).
function selectTab ( tab , later ) {
2026-11-04 18:02:27 +01:00
if ( tab !== 'local' && tab !== 'remote' ) return ;
var changed = state . tab !== tab ;
state . tab = tab ;
var local = tab === 'local' ;
els . tabLocal . setAttribute ( 'aria-selected' , String ( local ));
els . tabRemote . setAttribute ( 'aria-selected' , String ( ! local ));
els . tabLocal . tabIndex = local ? 0 : - 1 ;
els . tabRemote . tabIndex = local ? - 1 : 0 ;
els . paneLocal . hidden = ! local ;
els . form . hidden = local ;
2026-11-06 03:26:57 +01:00
els . elsewhere . hidden = local ;
2026-11-04 18:02:27 +01:00
if ( changed && ! state . busy ) {
setStatus ( '' , '' );
hideSuggestion ();
}
2026-11-06 03:26:57 +01:00
if ( ! later ) ensureLocalChecked ();
2026-11-04 18:02:27 +01:00
}
// How hard to look: 'quick' (files and processes, while an install runs),
// 'login' (also the login shell; every automatic look) or 'deep' (also the
// interactive profile, only on Check again).
function checkLocal ( how ) {
if ( how !== 'quick' ) {
state . localChecking = true ;
renderLocal ();
}
var msg = { cmd : 'localStatus' , quick : how === 'quick' };
if ( how === 'deep' ) msg . deep = true ;
post ( msg );
}
function syncLocalButtons () {
els . localOpen . disabled = state . busy || tooOld ;
els . localStart . disabled = state . busy || tooOld ;
els . localInstall . disabled = state . busy || tooOld || state . opening ;
els . localCheck . disabled = state . busy || state . localChecking ;
2026-11-06 13:23:56 +01:00
els . localAgents . disabled = state . busy || tooOld || state . opening ;
2026-11-04 18:02:27 +01:00
els . sshInstall . disabled = state . busy || state . opening ;
2026-11-06 17:39:12 +01:00
Array . prototype . forEach . call ( els . paneLocal . querySelectorAll ( '.found button' ), function ( b ) {
b . disabled = state . busy || tooOld ;
});
}
// A Codeman on this Mac that serves HTTPS, stopped or running.
var HTTPS_REFUSED = 'It is set up with HTTPS (codeman web --https), whose self-signed certificate this app refuses. Run it without --https to open it here.' ;
// The Codeman servers the app found on this Mac (setup.rs, inspect), other
// than the one the tab's first line is about.
function otherServers ( s ) {
return ( s . found || []). filter ( function ( f ) { return f && f . url !== s . url ; });
}
function canConnect ( f ) {
return f . state === 'running' || f . state === 'needsLogin' ;
}
// What a found server's row says under its address: its version, or why the
// app cannot open it.
function foundNote ( f ) {
if ( f . state === 'running' ) return f . version ? 'version ' + f . version : '' ;
if ( f . state === 'needsLogin' ) return 'Asks for a login.' ;
if ( f . state === 'certificate' ) return HTTPS_REFUSED ;
return 'It does not answer at this address.' ;
}
// One row per found server: its address, a note, and Connect when the app
// can open it. primary: the first Connect is the tab's main action.
function renderFound ( list , servers , primary ) {
list . textContent = '' ;
servers . forEach ( function ( f ) {
var url = String ( f . url || '' );
var row = document . createElement ( 'div' );
row . className = 'found' ;
row . setAttribute ( 'role' , 'listitem' );
var text = document . createElement ( 'span' );
var where = document . createElement ( 'b' );
where . textContent = url ;
text . appendChild ( where );
var note = foundNote ( f );
if ( note ) {
var small = document . createElement ( 'small' );
small . textContent = note ;
text . appendChild ( small );
}
row . appendChild ( text );
if ( canConnect ( f )) {
var button = document . createElement ( 'button' );
button . type = 'button' ;
button . className = primary ? 'primary' : 'secondary' ;
primary = false ;
button . textContent = 'Connect' ;
button . title = 'Connect to ' + url ;
button . setAttribute ( 'aria-label' , 'Connect to ' + url );
button . addEventListener ( 'click' , function () { connectFound ( url ); });
row . appendChild ( button );
}
list . appendChild ( row );
});
2026-11-04 18:02:27 +01:00
}
function renderLocal () {
var s = state . local ;
2026-11-06 02:14:58 +01:00
var show = { open : false , start : false , install : false , stop : false , check : false };
2026-11-04 18:02:27 +01:00
var dot = '' ;
var title = '' ;
var detail = '' ;
2026-11-06 17:39:12 +01:00
// Found servers: `main` when they are what the tab is about, `also` when
// they run beside the one its first line shows.
var main = [];
var also = [];
2026-11-04 18:02:27 +01:00
if ( state . wait ) {
dot = 'busy' ;
title = 'Installing Codeman in Terminal…' ;
detail = 'Answer the installer’ s questions there. This window opens Codeman when it is done.' ;
show . stop = true ;
2026-11-06 03:26:57 +01:00
} else if ( ! s ) {
2026-11-04 18:02:27 +01:00
dot = 'busy' ;
title = 'Looking for Codeman on this Mac…' ;
} else if ( s . installRunning && s . state !== 'running' && s . state !== 'needsLogin' ) {
dot = 'busy' ;
title = 'Codeman is being installed in Terminal.' ;
detail = 'Finish the installer there, then Check again.' ;
show . check = true ;
2026-11-06 03:40:53 +01:00
} else if ( s . state === 'starting' ) {
dot = 'busy' ;
title = 'Codeman is starting on this Mac…' ;
detail = 'It will answer at ' + s . url + ' in a moment.' ;
show . check = true ;
2026-11-04 18:02:27 +01:00
} else if ( s . state === 'runningElsewhere' ) {
2026-11-06 17:39:12 +01:00
// Not at the address the app opens by default: each one gets a row.
main = ( s . found || []). filter ( Boolean );
dot = main . some ( canConnect ) ? 'ok' : 'warn' ;
title = 'Codeman is already running on this Mac.' ;
// No row: the app found the server's process, but no address to try.
if ( ! main . length ) {
detail = 'The app could not find the address it listens at: connect to it under Another machine. ' +
'Starting another server here would take over its sessions.' ;
}
2026-11-04 18:02:27 +01:00
show . check = true ;
} else if ( s . state === 'running' ) {
dot = 'ok' ;
title = 'Codeman is running on this Mac.' ;
detail = s . url + ( s . version ? ' · version ' + s . version : '' );
show . open = true ;
2026-11-06 17:39:12 +01:00
also = otherServers ( s );
2026-11-04 18:02:27 +01:00
} else if ( s . state === 'needsLogin' ) {
dot = 'ok' ;
title = 'Codeman is running on this Mac.' ;
detail = s . url + ' asks for a login: Open asks for the password.' ;
show . open = true ;
2026-11-06 17:39:12 +01:00
also = otherServers ( s );
2026-11-04 18:02:27 +01:00
} else if ( s . state === 'stopped' ) {
title = 'Codeman is installed on this Mac, but not running.' ;
if ( s . daemon ) {
detail = 'A system LaunchDaemon runs it, which an administrator starts: sudo launchctl kickstart -k system/com.codeman.web' ;
} else if ( /^https:/i . test ( s . url )) {
2026-11-06 17:39:12 +01:00
detail = HTTPS_REFUSED ;
2026-11-04 18:02:27 +01:00
} else {
detail = s . service
? 'It is set up to start when you log in, but nothing answers at ' + s . url + '. Start asks launchd to run it now.'
: 'Start runs it in the background at ' + s . url + '.' ;
show . start = true ;
}
show . check = true ;
} else if ( s . state === 'notInstalled' ) {
title = 'Codeman is not installed on this Mac.' ;
2026-11-06 02:14:58 +01:00
detail = 'Install sets it up here in a few minutes, with no password and no Terminal. ' +
2026-11-04 18:02:27 +01:00
'Codeman then runs in the background and starts when you log in, so your sessions keep running when this app is closed.' ;
show . install = true ;
show . check = true ;
} else if ( ! s . installed ) {
// Something else holds Codeman's port; a fresh install takes the next free one.
var other = s . outcome || {};
title = 'Codeman is not installed on this Mac.' ;
detail = 'Something else answers at ' + s . url + ( other . kind === 'notCodeman' ? ' (HTTP ' + other . status + ')' : '' ) +
2026-11-06 02:14:58 +01:00
', so Install sets Codeman up on the next free port: a few minutes, with no password and no Terminal.' ;
2026-11-04 18:02:27 +01:00
show . install = true ;
show . check = true ;
} else {
var o = s . outcome || {};
dot = 'warn' ;
2026-11-06 17:39:12 +01:00
also = otherServers ( s );
2026-11-04 18:02:27 +01:00
if ( o . kind === 'notCodeman' ) {
title = 'Something else answers at ' + s . url ;
2026-11-06 17:39:12 +01:00
detail = 'It is not Codeman (HTTP ' + o . status + '). Stop what runs on that port, or connect to ' +
( also . some ( canConnect ) ? 'the Codeman listed below.' : 'Codeman’ s real address under Another machine.' );
2026-11-04 18:02:27 +01:00
} else {
var e = explain [ o . kind ];
title = e ? e [ 1 ] : 'Could not reach Codeman on this Mac.' ;
detail = ( explainLoopback [ o . kind ]) || ( e ? e [ 2 ] : ( o . detail || '' ));
}
show . check = true ;
}
2026-11-06 03:26:57 +01:00
// Looking again: what was found last stays on screen, so nothing jumps.
if ( state . localChecking && s && ! state . wait ) dot = 'busy' ;
2026-11-04 18:02:27 +01:00
els . localDot . className = 'dot' + ( dot ? ' ' + dot : '' );
els . localTitle . textContent = title ;
els . localDetail . textContent = detail ;
els . localOpen . hidden = ! show . open ;
els . localStart . hidden = ! show . start ;
els . localInstall . hidden = ! show . install ;
els . localStop . hidden = ! show . stop ;
els . localCheck . hidden = ! show . check ;
2026-11-06 17:39:12 +01:00
renderFound ( els . localFound , main , true );
els . localFound . hidden = ! main . length ;
els . localFoundNote . hidden = ! main . length ;
renderFound ( els . localAlsoList , also , false );
els . localAlso . hidden = ! also . length ;
2026-11-06 13:23:56 +01:00
// Codeman is here, in whatever state: adding an agent asks nothing of it.
els . localAgents . hidden = ! s || ! s . installed || !! state . wait || state . localChecking || ( s . installRunning && ! show . open );
2026-11-04 18:02:27 +01:00
syncLocalButtons ();
}
function onLocalStatus ( s ) {
2026-11-06 13:23:56 +01:00
var wanted = state . wantAgents ;
state . wantAgents = false ;
2026-11-04 18:02:27 +01:00
state . localChecking = false ;
state . local = s || null ;
2026-11-06 13:23:56 +01:00
// A graphical run is going without this page (it was reloaded, or the app
// was quit and opened again): show it from its first line.
if ( s && ( s . installInApp || s . agentInstallInApp ) && ! state . gui ) {
state . gui = newWizard ( 'installing' , s . installInApp ? 'install' : 'agent' );
2026-11-06 02:14:58 +01:00
state . gui . port = s . installPort || 3000 ;
2026-11-06 13:23:56 +01:00
post ({ cmd : s . installInApp ? 'installAttach' : 'agentAttach' });
2026-11-06 02:14:58 +01:00
renderWizard ();
return ;
}
2026-11-04 18:02:27 +01:00
// An install from this app is still running (this page was shown again
// while it ran): wait for it, unless the user stopped waiting.
2026-11-06 02:14:58 +01:00
if ( ! state . wait && s && s . installRunning && ! s . installInApp && ! state . stoppedWaiting && s . state !== 'running' && s . state !== 'needsLogin' ) {
2026-11-04 18:02:27 +01:00
state . wait = { since : Date . now (), finishedAt : 0 , port : s . installPort || 3000 };
renderLocal ();
scheduleWait ();
}
if ( state . wait ) {
onWaitStatus ( state . local || {});
return ;
}
2026-11-06 03:40:53 +01:00
if ( followStarting ( s )) return ;
2026-11-04 18:02:27 +01:00
renderLocal ();
2026-11-06 13:23:56 +01:00
if ( wanted && s && s . installed && ! state . gui ) openAgentChooser ();
else if ( state . gui && state . gui . step === 'added' ) renderWizard ();
2026-11-04 18:02:27 +01:00
}
2026-11-06 03:40:53 +01:00
// A Codeman that is still starting (its process runs, but listens nowhere
// yet: the first seconds after login, a restart): look again every second
// for a while. Once it is up, open it if a connect to this Mac was refused
// on the way here, which is what the user had asked for. True when it opened.
function followStarting ( s ) {
clearTimeout ( state . startingTimer );
if ( s && s . state === 'starting' ) {
if ( state . startingLooks < STARTING_MAX_LOOKS ) {
state . startingLooks ++ ;
state . startingTimer = setTimeout ( function () {
if ( state . local && state . local . state === 'starting' && ! state . wait ) checkLocal ( 'quick' );
}, STARTING_POLL_MS );
}
return false ;
}
var up = !! s && ( s . state === 'running' || s . state === 'needsLogin' );
var open = up && state . reopen && state . startingLooks > 0 && state . tab === 'local' && ! state . busy ;
state . startingLooks = 0 ;
state . reopen = false ;
if ( open ) {
renderLocal ();
openLocal ();
}
return open ;
}
2026-11-04 18:02:27 +01:00
// Point the Another machine form at `url` with only the login saved for it:
// never the username or a typed password of the server the form showed
// before, which connect() would otherwise send along.
function useAddress ( url ) {
els . url . value = url || '' ;
els . login . hidden = true ;
els . username . value = '' ;
els . password . value = '' ;
els . password . placeholder = '' ;
matchSaved ();
}
function openLocal () {
if ( state . busy || tooOld || ! state . local ) return ;
useAddress ( state . local . url );
connect ();
}
2026-11-06 17:39:12 +01:00
// Connect on a found server's row. It is the connect of an address typed
// under Another machine, and of Open: the app checks the address, uses the
// login saved for it and remembers the server the same way.
function connectFound ( url ) {
if ( state . busy || tooOld ) return ;
useAddress ( url );
connect ( true );
}
2026-11-04 18:02:27 +01:00
function startLocal () {
if ( state . busy || tooOld || ! state . local ) return ;
useAddress ( state . local . url );
state . startUrl = state . local . url ;
post ({ cmd : 'startServer' , url : state . startUrl });
}
2026-11-06 02:14:58 +01:00
// The Terminal route: what the graphical install cannot do, and what anyone
// may prefer. The installer asks its own questions there.
function installInTerminal () {
2026-11-04 18:02:27 +01:00
if ( state . busy || tooOld || state . opening ) return ;
2026-11-06 02:14:58 +01:00
var network = !! state . gui && state . gui . reach === 'network' ;
closeWizard ();
selectTab ( 'local' );
2026-11-04 18:02:27 +01:00
state . opening = true ;
syncLocalButtons ();
setStatus ( 'busy' , 'Opening Terminal…' );
2026-11-06 02:14:58 +01:00
post ({ cmd : 'installLocal' , network : network });
}
2026-11-06 13:23:56 +01:00
// mode 'install': Codeman itself. mode 'agent': one coding agent for a Mac
// that has Codeman, on the same screens, with no question about Codeman.
function newWizard ( step , mode ) {
2026-11-06 02:14:58 +01:00
return {
2026-11-06 13:23:56 +01:00
step : step , mode : mode || 'install' , plan : null , agent : '' , reach : 'local' , port : 3000 , message : '' , phase : '' ,
phaseSince : 0 , seen : {}, log : [], ok : false , noAgent : false
2026-11-06 02:14:58 +01:00
};
}
function openWizard () {
if ( state . busy || tooOld || state . opening ) return ;
state . gui = newWizard ( 'preparing' );
post ({ cmd : 'installPrepare' });
renderWizard ();
}
2026-11-06 13:23:56 +01:00
function openAgentChooser () {
if ( state . busy || tooOld || state . opening ) return ;
state . gui = newWizard ( 'preparing' , 'agent' );
post ({ cmd : 'agentPrepare' });
renderWizard ();
}
// The menu's way in. It can arrive before the first look at this Mac, and a
// Mac with no Codeman gets This Mac's Install instead.
function addAgent () {
selectTab ( 'local' );
if ( state . gui ) return ;
if ( ! state . local || state . localChecking ) state . wantAgents = true ;
else if ( state . local . installed ) openAgentChooser ();
}
2026-11-06 02:14:58 +01:00
function closeWizard () {
clearInterval ( state . clock );
state . gui = null ;
renderWizard ();
renderLocal ();
}
function retryWizard () {
var reach = state . gui ? state . gui . reach : 'local' ;
var agent = state . gui ? state . gui . agent : '' ;
2026-11-06 13:23:56 +01:00
var mode = state . gui ? state . gui . mode : 'install' ;
state . gui = newWizard ( 'preparing' , mode );
2026-11-06 02:14:58 +01:00
state . gui . reach = reach ;
state . gui . agent = agent ;
2026-11-06 13:23:56 +01:00
post ({ cmd : mode === 'agent' ? 'agentPrepare' : 'installPrepare' });
2026-11-06 02:14:58 +01:00
renderWizard ();
}
function installedAgents () {
var g = state . gui ;
return g && g . plan ? g . plan . clis . filter ( function ( c ) { return c . installed ; }) : [];
}
function offeredAgents () {
var g = state . gui ;
return g && g . plan ? g . plan . clis . filter ( function ( c ) { return c . installable ; }) : [];
}
function agentById ( id ) {
return offeredAgents (). filter ( function ( c ) { return c . id === id ; })[ 0 ] || null ;
}
2026-11-06 13:23:56 +01:00
// A run this page did not start has no plan, so no name for the agent.
function agentName () {
var c = agentById ( state . gui . agent );
return c ? c . label : 'a coding agent' ;
}
// The first step the installer's answer calls for. In an install the agent
// step exists only when this Mac has no coding agent at all.
2026-11-06 02:14:58 +01:00
function onInstallPlan ( plan ) {
var g = state . gui ;
if ( ! g || g . step !== 'preparing' ) return ;
g . plan = plan ;
2026-11-06 13:23:56 +01:00
if ( g . mode === 'agent' ) {
// `blocker` says why Codeman cannot be installed, which is not the question.
if ( ! agentById ( g . agent )) g . agent = offeredAgents (). length ? offeredAgents ()[ 0 ]. id : '' ;
g . step = 'agent' ;
} else if ( plan . blocker ) {
2026-11-06 02:14:58 +01:00
g . step = 'blocked' ;
} else if ( ! installedAgents (). length && offeredAgents (). length ) {
// Kept across Try again; otherwise the first of the catalogue's order.
if ( ! agentById ( g . agent )) g . agent = offeredAgents ()[ 0 ]. id ;
g . step = 'agent' ;
} else {
g . step = 'reach' ;
}
renderWizard ();
}
function renderAgents () {
var g = state . gui ;
els . wizAgents . textContent = '' ;
2026-11-06 13:23:56 +01:00
var rows = g . mode === 'agent' ? offeredAgents (). concat ( installedAgents ()) : offeredAgents ();
rows . forEach ( function ( c ) {
2026-11-06 02:14:58 +01:00
var label = document . createElement ( 'label' );
label . className = 'choice' ;
var input = document . createElement ( 'input' );
input . type = 'radio' ;
input . name = 'agent' ;
input . value = c . id ;
input . checked = c . id === g . agent ;
2026-11-06 13:23:56 +01:00
input . disabled = c . installed ;
2026-11-06 02:14:58 +01:00
input . addEventListener ( 'change' , function () {
g . agent = c . id ;
renderAgentNote ();
2026-11-06 13:23:56 +01:00
renderWizardButtons ();
2026-11-06 02:14:58 +01:00
});
var name = document . createElement ( 'b' );
name . textContent = c . label ;
var text = document . createElement ( 'span' );
text . appendChild ( name );
2026-11-06 13:23:56 +01:00
if ( c . installed ) {
var here = document . createElement ( 'small' );
here . textContent = 'Installed' ;
text . appendChild ( here );
}
2026-11-06 02:14:58 +01:00
label . appendChild ( input );
label . appendChild ( text );
els . wizAgents . appendChild ( label );
});
renderAgentNote ();
}
function renderAgentNote () {
var c = agentById ( state . gui . agent );
els . wizAgentNote . textContent = '' ;
if ( ! c ) return ;
els . wizAgentNote . appendChild ( document . createTextNode ( 'You sign in to ' + c . label + ' the first time you start it. ' ));
if ( /^https:\/\// . test ( c . docs )) {
var a = document . createElement ( 'a' );
a . href = '#' ;
a . textContent = 'About ' + c . label ;
a . addEventListener ( 'click' , function ( ev ) {
ev . preventDefault ();
post ({ cmd : 'openExternal' , url : c . docs });
});
els . wizAgentNote . appendChild ( a );
}
}
function renderSummary () {
var g = state . gui ;
var plan = g . plan ;
var lines = [{ text : 'Installs into your home folder (about 1 GB), with no administrator password' }];
var needs = [];
if ( plan . needs . node ) needs . push ( 'Node.js' );
if ( plan . needs . tmux ) needs . push ( 'tmux' );
if ( needs . length ) lines . push ({ text : 'Downloads ' + needs . join ( ' and ' ) });
var found = installedAgents ();
var chosen = agentById ( g . agent );
if ( found . length ) {
lines . push ({ text : 'Uses ' + found . map ( function ( c ) { return c . label ; }). join ( ', ' ) + ', already on this Mac' });
} else if ( chosen ) {
lines . push ({ text : 'Installs ' + chosen . label });
} else {
lines . push ({ text : 'No coding agent yet: Codeman opens, but sessions need one to run' , warn : true });
}
lines . push ({ text : 'Runs in the background and starts when you log in' });
if ( plan . needs . git ) lines . push ({ text : 'This Mac has no git, so you update Codeman by running this install again' });
els . wizSummary . textContent = '' ;
lines . forEach ( function ( line ) {
var li = document . createElement ( 'li' );
li . textContent = line . text ;
if ( line . warn ) li . className = 'warn' ;
els . wizSummary . appendChild ( li );
});
}
// Which rows the rail has. A run this page did not start has no plan, so it
// shows what the installer reported instead.
function railRows () {
var g = state . gui ;
var plan = g . plan ;
var chosen = agentById ( g . agent );
2026-11-06 13:23:56 +01:00
if ( g . mode === 'agent' ) return [{ id : 'agent' , label : 'Installing ' + agentName () }];
2026-11-06 02:14:58 +01:00
var rows = [];
PHASES . forEach ( function ( id ) {
var label = {
runtime : 'Getting Node.js and tmux' ,
agent : 'Installing ' + ( chosen ? chosen . label : 'a coding agent' ),
source : 'Downloading Codeman' ,
packages : 'Installing packages' ,
build : 'Building Codeman' ,
service : 'Starting Codeman'
}[ id ];
if ( id === 'runtime' && plan ) {
if ( ! plan . needs . node && ! plan . needs . tmux ) return ;
if ( ! plan . needs . tmux ) label = 'Getting Node.js' ;
if ( ! plan . needs . node ) label = 'Getting tmux' ;
}
if ( id === 'agent' && ( plan ? ! chosen : ! g . seen . agent )) return ;
rows . push ({ id : id , label : label });
});
return rows ;
}
function clockText ( since ) {
var s = Math . max ( 0 , Math . floor (( Date . now () - since ) / 1000 ));
return Math . floor ( s / 60 ) + ':' + ( '0' + ( s % 60 )). slice ( - 2 );
}
function renderRail () {
var g = state . gui ;
var current = PHASES . indexOf ( g . phase );
els . wizRail . textContent = '' ;
railRows (). forEach ( function ( row ) {
var at = PHASES . indexOf ( row . id );
var li = document . createElement ( 'li' );
var name = document . createElement ( 'span' );
name . textContent = row . label ;
li . appendChild ( name );
if ( g . ok || at < current ) {
li . className = 'done' ;
} else if ( at === current && g . step === 'failed' ) {
li . className = 'failed' ;
} else if ( at === current ) {
li . className = 'now' ;
li . setAttribute ( 'aria-current' , 'step' );
var clock = document . createElement ( 'span' );
clock . className = 'clock' ;
clock . id = 'wiz-clock' ;
clock . textContent = clockText ( g . phaseSince );
li . appendChild ( clock );
}
els . wizRail . appendChild ( li );
});
}
function renderLog () {
var log = els . wizLog ;
var atEnd = log . scrollTop + log . clientHeight >= log . scrollHeight - 8 ;
log . textContent = state . gui . log . join ( '\n' );
if ( atEnd ) log . scrollTop = log . scrollHeight ;
}
function appendLog ( lines ) {
var g = state . gui ;
if ( ! lines || ! lines . length ) return ;
g . log = g . log . concat ( lines ). slice ( - MAX_LOG_LINES );
renderLog ();
}
function setPhase ( phase ) {
var g = state . gui ;
if ( ! phase || phase === g . phase || PHASES . indexOf ( phase ) < 0 ) return ;
g . phase = phase ;
g . phaseSince = Date . now ();
g . seen [ phase ] = true ;
renderRail ();
}
function onInstallUpdate ( msg ) {
var g = state . gui ;
if ( ! g || g . step !== 'installing' ) return ;
setPhase ( msg . phase );
appendLog ( msg . log );
}
function onInstallFinished ( msg ) {
var g = state . gui ;
if ( ! g ) return ;
appendLog ( msg . log );
clearInterval ( state . clock );
2026-11-06 13:23:56 +01:00
if ( msg . ok && g . mode === 'agent' ) {
g . ok = true ;
g . step = 'added' ;
renderWizard ();
// Whether there is a Codeman to open from here.
checkLocal ( 'quick' );
return ;
}
2026-11-06 02:14:58 +01:00
if ( msg . ok ) {
g . ok = true ;
g . step = 'finishing' ;
renderWizard ();
// The same watch as a Terminal install: it opens Codeman at the first
// answer, and offers Start when none comes.
state . wait = { since : Date . now (), finishedAt : 0 , port : g . port };
checkLocal ( 'quick' );
return ;
}
if ( msg . cancelled ) {
2026-11-06 13:23:56 +01:00
var stopped = g . mode === 'agent' ? 'Stopped installing ' + agentName () + '.' : 'Install cancelled.' ;
var again = g . mode === 'agent' ? '' : 'Install again to pick up where it stopped.' ;
2026-11-06 02:14:58 +01:00
closeWizard ();
checkLocal ( 'login' );
2026-11-06 13:23:56 +01:00
setStatus ( '' , stopped , again );
2026-11-06 02:14:58 +01:00
return ;
}
g . step = 'failed' ;
g . message = msg . error || 'The installer stopped without saying why.' ;
renderWizard ();
els . wizDetails . open = true ;
renderLog ();
}
// Each screen of the graphical install, drawn from state.gui alone.
function renderWizard () {
var g = state . gui ;
els . card . classList . toggle ( 'wizard-on' , !! g );
els . wizard . hidden = ! g ;
if ( ! g ) return ;
var step = g . step ;
2026-11-06 13:23:56 +01:00
var name = agentName ();
var installingLead = 'You can close this window. The install keeps going, and the app picks it up when you open it again.' ;
var copy = g . mode === 'agent' ? {
preparing : [ 'Looking at this Mac' , 'Getting the list of coding agents and checking which ones are here.' ],
prepareFailed : [ 'Could not get the list of coding agents' , '' ],
agent : [ 'Add a coding agent' , offeredAgents (). length
? 'Pick one to install on this Mac. It needs no password, and Codeman keeps running.'
: installedAgents (). length
? 'Every coding agent this app can install is already on this Mac.'
: 'This app cannot install a coding agent on this Mac.' ],
installing : [ 'Installing ' + name , installingLead ],
added : [ name === 'a coding agent' ? 'The coding agent is installed' : name + ' is installed' ,
2026-11-06 14:25:34 +01:00
'If Codeman does not list it yet, reload it, or give it a few minutes. You sign in the first time you start it.' ],
2026-11-06 13:23:56 +01:00
failed : [ 'The install stopped' , 'Its output is below. Try again, or follow the instructions on the agent’ s own site.' ]
}[ step ] : {
2026-11-06 02:14:58 +01:00
preparing : [ 'Looking at this Mac' , 'Getting the installer and checking what is already here.' ],
blocked : [ 'This Mac needs something first' , '' ],
prepareFailed : [ 'Could not get the installer' , '' ],
agent : [ 'Choose a coding agent' , 'Codeman runs coding agents, and this Mac has none yet. Pick one to install with Codeman. You can add more later.' ],
reach : [ 'Who can reach Codeman?' , '' ],
2026-11-06 13:23:56 +01:00
installing : [ 'Installing Codeman' , installingLead ],
2026-11-06 02:14:58 +01:00
finishing : [ 'Starting Codeman' , 'Codeman opens here as soon as it answers.' ],
2026-11-06 13:23:56 +01:00
ready : [ 'Codeman is ready' , 'It has no coding agent yet, and sessions need one to run. You can add one now, or later from the Codeman menu.' ],
2026-11-06 02:14:58 +01:00
failed : [ 'The install stopped' , 'Its full log is in ~/.codeman/install.log. Try again picks up where it stopped.' ]
}[ step ];
els . wizSpinner . hidden = ! ( step === 'preparing' || step === 'finishing' );
els . wizTitle . textContent = copy [ 0 ];
els . wizLead . textContent = copy [ 1 ];
els . wizLead . hidden = ! copy [ 1 ];
var message = step === 'blocked' ? g . plan . blocker : ( step === 'prepareFailed' || step === 'failed' ) ? g . message : '' ;
els . wizMessage . textContent = message ;
els . wizMessage . hidden = ! message ;
els . wizMessage . className = step === 'blocked' ? 'warn' : '' ;
els . wizAgentStep . hidden = step !== 'agent' ;
els . wizReachStep . hidden = step !== 'reach' ;
2026-11-06 13:23:56 +01:00
var running = step === 'installing' || step === 'finishing' || step === 'failed' || step === 'added' ;
2026-11-06 02:14:58 +01:00
els . wizRail . hidden = ! running ;
els . wizDetails . hidden = ! running ;
if ( step === 'agent' ) renderAgents ();
if ( step === 'reach' ) {
renderSummary ();
syncReach ();
}
if ( running ) renderRail ();
clearInterval ( state . clock );
if ( step === 'installing' ) {
state . clock = setInterval ( function () {
var clock = $ ( 'wiz-clock' );
if ( clock ) clock . textContent = clockText ( state . gui . phaseSince );
}, 1000 );
}
2026-11-06 13:23:56 +01:00
renderWizardButtons ();
els . wizCancel . disabled = false ;
var checked = step === 'agent' && els . wizAgents . querySelector ( 'input:checked' );
if ( checked ) checked . focus ();
else if ( ! els . wizNext . hidden ) els . wizNext . focus ();
else if ( ! els . wizBack . hidden ) els . wizBack . focus ();
else els . wizTitle . focus ();
}
function localAnswers () {
return !! state . local && ( state . local . state === 'running' || state . local . state === 'needsLogin' );
}
function renderWizardButtons () {
var g = state . gui ;
var step = g . step ;
var agentMode = g . mode === 'agent' ;
var chosen = agentById ( g . agent );
2026-11-06 02:14:58 +01:00
var next = {
2026-11-06 13:23:56 +01:00
agent : agentMode ? ( chosen ? 'Install ' + chosen . label : '' ) : 'Continue' ,
reach : 'Install Codeman' , failed : 'Try again' , prepareFailed : 'Try again' , blocked : 'Install in Terminal' ,
ready : 'Open Codeman' , added : localAnswers () ? 'Open Codeman' : ''
2026-11-06 02:14:58 +01:00
}[ step ];
els . wizNext . hidden = ! next ;
els . wizNext . textContent = next || '' ;
// Not while preparing: a second prepare would clear the folder of the first.
2026-11-06 13:23:56 +01:00
els . wizBack . hidden = ! ( step === 'blocked' || step === 'prepareFailed' || step === 'agent' || step === 'reach' ||
( agentMode && ( step === 'failed' || step === 'added' )));
els . wizBack . textContent = step === 'added' ? 'Done' : 'Back' ;
var aside = step === 'ready' ? 'Add a coding agent'
: step === 'added' && offeredAgents (). length > 1 ? 'Add another'
: step === 'agent' && ! agentMode ? 'Skip for now' : '' ;
els . wizSkip . hidden = ! aside ;
els . wizSkip . textContent = aside ;
2026-11-06 02:14:58 +01:00
els . wizCancel . hidden = step !== 'installing' ;
2026-11-06 13:23:56 +01:00
els . wizTerminal . hidden = agentMode || ! ( step === 'reach' || step === 'failed' || step === 'prepareFailed' );
2026-11-06 02:14:58 +01:00
}
function syncReach () {
var g = state . gui ;
var network = g . reach === 'network' ;
els . wizPassword . hidden = ! network ;
Array . prototype . forEach . call ( document . getElementsByName ( 'reach' ), function ( r ) { r . checked = r . value === g . reach ; });
}
function startInstall () {
var g = state . gui ;
var password = '' ;
if ( g . reach === 'network' ) {
password = els . wizPw . value ;
if ( ! password ) {
els . wizMessage . textContent = 'Set a password for the other devices to sign in with.' ;
els . wizMessage . hidden = false ;
els . wizPw . focus ();
return ;
}
if ( password !== els . wizPw2 . value ) {
els . wizMessage . textContent = 'The two passwords are different.' ;
els . wizMessage . hidden = false ;
els . wizPw2 . focus ();
return ;
}
}
var msg = { cmd : 'installStart' };
if ( password ) msg . networkPassword = password ;
if ( ! installedAgents (). length && agentById ( g . agent )) msg . aiCli = g . agent ;
els . wizPw . value = '' ;
els . wizPw2 . value = '' ;
2026-11-06 13:23:56 +01:00
// Skipped: the first thing to say once Codeman answers.
g . noAgent = ! installedAgents (). length && ! msg . aiCli && offeredAgents (). length > 0 ;
beginRun ();
post ( msg );
}
function startAgent () {
var g = state . gui ;
if ( ! agentById ( g . agent )) return ;
beginRun ();
post ({ cmd : 'agentStart' , aiCli : g . agent });
}
function beginRun () {
var g = state . gui ;
2026-11-06 02:14:58 +01:00
g . step = 'installing' ;
g . phase = '' ;
g . seen = {};
g . log = [];
renderLog ();
renderWizard ();
}
function wizardNext () {
var g = state . gui ;
if ( ! g ) return ;
2026-11-06 13:23:56 +01:00
if ( g . step === 'agent' && g . mode === 'agent' ) {
startAgent ();
} else if ( g . step === 'agent' ) {
2026-11-06 02:14:58 +01:00
g . step = 'reach' ;
renderWizard ();
} else if ( g . step === 'reach' ) {
startInstall ();
2026-11-06 13:23:56 +01:00
} else if ( g . step === 'ready' || g . step === 'added' ) {
closeWizard ();
selectTab ( 'local' );
openLocal ();
2026-11-06 02:14:58 +01:00
} else if ( g . step === 'failed' || g . step === 'prepareFailed' ) {
retryWizard ();
} else if ( g . step === 'blocked' ) {
installInTerminal ();
}
}
function wizardBack () {
var g = state . gui ;
if ( ! g ) return ;
if ( g . step === 'reach' && ! installedAgents (). length && offeredAgents (). length && ! g . plan . blocker ) {
g . step = 'agent' ;
renderWizard ();
} else {
closeWizard ();
}
2026-11-04 18:02:27 +01:00
}
function scheduleWait () {
clearTimeout ( state . waitTimer );
state . waitTimer = setTimeout ( function () { if ( state . wait ) checkLocal ( 'quick' ); }, WAIT_POLL_MS );
}
function endWait () {
clearTimeout ( state . waitTimer );
state . wait = null ;
2026-11-06 02:14:58 +01:00
if ( state . gui && state . gui . step === 'finishing' ) closeWizard ();
2026-11-04 18:02:27 +01:00
}
function portOf ( url ) {
try {
var u = new URL ( url );
return Number ( u . port || ( u . protocol === 'https:' ? 443 : 80 ));
} catch ( e ) { return 0 ; }
}
2026-11-06 03:26:57 +01:00
// One answer while an install runs. It stops at the first answer from a
// server: a password-protected one counts every unauthenticated look as a
// failed login.
2026-11-04 18:02:27 +01:00
function onWaitStatus ( s ) {
var w = state . wait ;
var now = Date . now ();
if ( s . installPort ) w . port = s . installPort ;
// Until the installer writes its LaunchAgent, the app looks at port 3000,
// where something else may answer when the install took another port.
var ours = portOf ( s . url ) === w . port ;
if ( ours && ( s . state === 'running' || s . state === 'needsLogin' )) {
2026-11-06 13:23:56 +01:00
var g = state . gui ;
if ( g && g . step === 'finishing' && g . noAgent ) {
clearTimeout ( state . waitTimer );
state . wait = null ;
g . step = 'ready' ;
renderWizard ();
return ;
}
2026-11-04 18:02:27 +01:00
endWait ();
selectTab ( 'local' );
renderLocal ();
openLocal ();
return ;
}
if ( ours && s . state === 'other' ) {
// Something answers that is not a working Codeman: say what.
endWait ();
renderLocal ();
return ;
}
if ( s . installComplete && ( s . state === 'stopped' || ! ours )) {
if ( ! w . finishedAt ) {
w . finishedAt = now ;
} else if ( now - w . finishedAt > WAIT_START_GRACE_MS ) {
endWait ();
renderLocal ();
setStatus ( 'warn' , 'The install finished, but Codeman is not answering.' , 'Start runs it now. If the installer reported a problem, its log is ~/.codeman/install.log.' );
return ;
}
}
if ( now - w . since > WAIT_MAX_MS ) {
endWait ();
renderLocal ();
setStatus ( 'warn' , 'Stopped waiting for the install.' , 'Check again once the installer in Terminal is done.' );
return ;
}
scheduleWait ();
}
function installRemote () {
if ( state . busy || state . opening ) return ;
var target = els . ssh . value . trim ();
if ( ! target ) {
setStatus ( 'bad' , 'Enter the machine to install Codeman on, as user@host.' );
els . ssh . focus ();
return ;
}
state . opening = true ;
syncLocalButtons ();
setStatus ( 'busy' , 'Opening Terminal…' );
post ({ cmd : 'installRemote' , target : target });
}
window . __codemanDesktop = {
onNative : function ( msg ) {
switch ( msg . type ) {
case 'init' :
state . servers = msg . servers || [];
state . startUrl = '' ;
setBusy ( false );
hideSuggestion ();
setStatus ( '' , '' );
els . start . hidden = true ;
els . auto . checked = !! msg . autoConnect ;
els . fullscreen . checked = !! msg . startFullscreen ;
els . version . textContent = 'Codeman Desktop ' + ( msg . version || '' );
renderRecent ();
if ( state . servers [ 0 ]) choose ( state . servers [ 0 ]);
2026-11-06 03:26:57 +01:00
var auto = !! ( msg . autoConnect && ! manual && ! tooOld && ! msg . starting && ! msg . notice && state . servers [ 0 ]);
// The tab of the server used last; This Mac on a first run. An
// automatic connect opens Codeman without looking at this Mac first:
// that look runs a few processes and asks the server once more, and
// it is only needed when the connect fails, which then asks for it.
selectTab ( state . servers [ 0 ] && ! isLocalUrl ( state . servers [ 0 ]. url ) ? 'remote' : 'local' , auto );
2026-11-04 18:02:27 +01:00
if ( msg . starting ) {
setBusy ( true , false );
setStatus ( 'busy' , 'Starting Codeman on this Mac…' , msg . notice || '' );
} else if ( msg . notice ) {
setStatus ( 'warn' , msg . notice );
}
2026-11-06 03:26:57 +01:00
if ( auto ) connect ();
2026-11-04 18:02:27 +01:00
else if ( state . tab === 'remote' ) els . url . focus ();
2026-11-06 13:23:56 +01:00
if ( msg . addAgent ) addAgent ();
2026-11-04 18:02:27 +01:00
break ;
case 'servers' :
state . servers = msg . servers || [];
renderRecent ();
2026-11-06 03:26:57 +01:00
if ( ! state . busy ) matchSaved ();
2026-11-04 18:02:27 +01:00
break ;
case 'prefs' :
if ( typeof msg . startFullscreen === 'boolean' ) els . fullscreen . checked = msg . startFullscreen ;
if ( typeof msg . autoConnect === 'boolean' ) els . auto . checked = msg . autoConnect ;
break ;
case 'probeResult' :
onProbeResult ( msg );
break ;
case 'opening' :
// Still busy: Cancel abandons the load and brings this page back.
if ( msg . passwordChange ) {
setStatus ( 'ok' , 'Opening Codeman…' , 'Codeman will ask you for a new password for this account. Connect with the new password next time.' );
} else {
setStatus ( 'ok' , msg . version ? 'Opening Codeman ' + msg . version + '…' : 'Opening Codeman…' );
}
2026-11-06 03:26:57 +01:00
// The server page is still loading: this is not a finished state.
els . spinner . hidden = false ;
2026-11-04 18:02:27 +01:00
break ;
case 'error' :
setBusy ( false );
2026-11-06 03:20:01 +01:00
// The tab first: switching it clears the status line.
2026-11-04 18:02:27 +01:00
if ( msg . needsPassword ) {
selectTab ( 'remote' );
showLogin ();
}
2026-11-06 03:20:01 +01:00
setStatus ( 'bad' , msg . message || 'Something went wrong.' );
2026-11-06 03:26:57 +01:00
ensureLocalChecked ();
2026-11-04 18:02:27 +01:00
break ;
case 'serverStarting' :
setBusy ( true , false );
setStatus ( 'busy' , 'Starting Codeman on this Mac…' );
break ;
case 'serverStartResult' :
onStartResult ( msg );
break ;
case 'localStatus' :
onLocalStatus ( msg . status );
break ;
case 'installStarted' :
state . opening = false ;
setStatus ( '' , '' );
state . wait = { since : Date . now (), finishedAt : 0 , port : Number ( msg . port ) || 3000 };
selectTab ( 'local' );
renderLocal ();
scheduleWait ();
break ;
2026-11-06 13:23:56 +01:00
case 'addAgent' :
addAgent ();
break ;
2026-11-06 02:14:58 +01:00
case 'installPlan' :
onInstallPlan ( msg . plan );
break ;
case 'installPrepareFailed' :
if ( state . gui && state . gui . step === 'preparing' ) {
state . gui . step = 'prepareFailed' ;
state . gui . message = msg . message || 'Could not get the installer.' ;
renderWizard ();
}
break ;
case 'installRunning' :
if ( state . gui ) state . gui . port = Number ( msg . port ) || 3000 ;
break ;
case 'installStartFailed' :
if ( state . gui ) {
state . gui . step = 'failed' ;
state . gui . message = msg . message || 'Could not start the installer.' ;
renderWizard ();
}
break ;
case 'installUpdate' :
onInstallUpdate ( msg );
break ;
case 'installFinished' :
onInstallFinished ( msg );
break ;
2026-11-04 18:02:27 +01:00
case 'remoteInstallStarted' :
state . opening = false ;
syncLocalButtons ();
selectTab ( 'remote' );
useAddress ( msg . url );
setStatus ( 'ok' , 'Terminal is installing Codeman on ' + msg . host + '.' ,
'Answer the installer’ s questions there. At the end it prints the address of Codeman on that machine: enter it above and connect (' + msg . url + ' is the usual one).' );
break ;
case 'installFailed' :
state . opening = false ;
syncLocalButtons ();
2026-11-06 03:26:57 +01:00
// The tab first: switching it clears the status line.
if ( msg . remote ) selectTab ( 'remote' );
2026-11-04 18:02:27 +01:00
setStatus ( 'bad' , msg . message || 'Could not open Terminal.' );
break ;
case 'copied' :
setStatus ( 'ok' , 'Copied.' , 'Paste it into a terminal on the other machine.' );
break ;
}
}
};
2026-11-06 03:26:57 +01:00
// The second click of a double click acts only on the control that took the
// first: the first may have moved another one under the pointer.
var firstControl = null ;
document . addEventListener ( 'click' , function ( ev ) {
var t = ev . target && ev . target . closest ? ev . target : null ;
var c = t && ( t . closest ( 'label' ) || t . closest ( 'button, summary, a, input' ));
if ( ev . detail < 2 ) { firstControl = c ; return ; }
if ( c !== firstControl ) { ev . preventDefault (); ev . stopPropagation (); }
}, true );
2026-11-04 18:02:27 +01:00
els . form . addEventListener ( 'submit' , function ( ev ) { ev . preventDefault (); connect (); });
2026-11-06 03:26:57 +01:00
els . cancel . addEventListener ( 'click' , function () {
post ({ cmd : 'cancel' });
setBusy ( false );
setStatus ( '' , '' );
ensureLocalChecked ();
});
2026-11-04 18:02:27 +01:00
els . start . addEventListener ( 'click' , function () {
if ( state . busy ) return ;
state . startUrl = state . lastUrl || els . url . value . trim ();
post ({ cmd : 'startServer' , url : state . startUrl });
});
els . suggest . addEventListener ( 'click' , function () {
if ( state . busy || ! state . suggestion ) return ;
els . url . value = state . suggestion ;
matchSaved ();
connect ();
});
els . auto . addEventListener ( 'change' , function () { post ({ cmd : 'setAutoConnect' , value : els . auto . checked }); });
els . fullscreen . addEventListener ( 'change' , function () { post ({ cmd : 'setStartFullscreen' , value : els . fullscreen . checked }); });
els . url . addEventListener ( 'input' , function () {
matchSaved ();
els . start . hidden = true ;
hideSuggestion ();
});
els . tabLocal . addEventListener ( 'click' , function () { selectTab ( 'local' ); });
els . tabRemote . addEventListener ( 'click' , function () { selectTab ( 'remote' ); });
[ els . tabLocal , els . tabRemote ]. forEach ( function ( tab ) {
tab . addEventListener ( 'keydown' , function ( ev ) {
if ( ev . key !== 'ArrowLeft' && ev . key !== 'ArrowRight' ) return ;
ev . preventDefault ();
var next = state . tab === 'local' ? 'remote' : 'local' ;
selectTab ( next );
( next === 'local' ? els . tabLocal : els . tabRemote ). focus ();
});
});
2026-11-06 17:39:12 +01:00
// Return opens or starts the Codeman on this Mac, or connects to the one
// found here when that is the tab's main action, and Esc cancels a connect,
2026-11-06 03:26:57 +01:00
// as in a Mac dialog. Install is left to a click: a stray Return must not
// start an installer.
document . addEventListener ( 'keydown' , function ( ev ) {
if ( ev . defaultPrevented ) return ;
if ( ev . key === 'Escape' && ! els . cancel . hidden ) { ev . preventDefault (); els . cancel . click (); return ; }
if ( ev . key !== 'Enter' || state . tab !== 'local' ) return ;
if ( ev . target && ev . target . closest && ev . target . closest ( 'button, a, input, summary' )) return ;
2026-11-06 03:40:53 +01:00
// offsetParent: really on screen, whatever hides it (its own `hidden`, or
// a container that is not shown).
2026-11-06 17:39:12 +01:00
var main = [ els . localOpen , els . localStart , els . localFound . querySelector ( 'button.primary' )]
. filter ( function ( b ) { return b && b . offsetParent && ! b . disabled ; })[ 0 ];
2026-11-06 03:26:57 +01:00
if ( main ) { ev . preventDefault (); main . click (); }
});
2026-11-04 18:02:27 +01:00
els . localOpen . addEventListener ( 'click' , openLocal );
els . localStart . addEventListener ( 'click' , startLocal );
2026-11-06 02:14:58 +01:00
els . localInstall . addEventListener ( 'click' , openWizard );
2026-11-06 13:23:56 +01:00
els . localAgents . addEventListener ( 'click' , openAgentChooser );
2026-11-06 02:14:58 +01:00
els . wizNext . addEventListener ( 'click' , wizardNext );
els . wizBack . addEventListener ( 'click' , wizardBack );
els . wizSkip . addEventListener ( 'click' , function () {
2026-11-06 13:23:56 +01:00
var g = state . gui ;
if ( ! g ) return ;
if ( g . step === 'ready' || g . step === 'added' ) {
openAgentChooser ();
return ;
}
g . agent = '' ;
g . step = 'reach' ;
2026-11-06 02:14:58 +01:00
renderWizard ();
});
els . wizCancel . addEventListener ( 'click' , function () {
els . wizCancel . disabled = true ;
post ({ cmd : 'installCancel' });
});
els . wizTerminal . addEventListener ( 'click' , installInTerminal );
Array . prototype . forEach . call ( document . getElementsByName ( 'reach' ), function ( radio ) {
radio . addEventListener ( 'change' , function () {
if ( ! state . gui ) return ;
state . gui . reach = radio . value ;
els . wizMessage . hidden = true ;
syncReach ();
});
});
2026-11-04 18:02:27 +01:00
els . localStop . addEventListener ( 'click' , function () { state . stoppedWaiting = true ; endWait (); checkLocal ( 'login' ); });
els . localCheck . addEventListener ( 'click' , function () { if ( ! state . busy && ! state . localChecking ) checkLocal ( 'deep' ); });
els . sshInstall . addEventListener ( 'click' , installRemote );
// Enter in the SSH field installs; it must not submit the form (Connect).
els . ssh . addEventListener ( 'keydown' , function ( ev ) {
if ( ev . key === 'Enter' ) { ev . preventDefault (); installRemote (); }
});
els . copy . addEventListener ( 'click' , function () { post ({ cmd : 'copyInstallCommand' }); });
$ ( 'docs' ). addEventListener ( 'click' , function ( ev ) { ev . preventDefault (); post ({ cmd : 'openExternal' , url : 'https://github.com/Ark0N/Codeman/wiki' }); });
if ( tooOld ) {
els . webkit . hidden = false ;
setBusy ( false );
}
post ({ cmd : 'ready' });
})();
</ script >
</ body >
</ html >