Skip to content

Commit 00d2397

Browse files
committed
feat: add macos service installer
1 parent 18288eb commit 00d2397

28 files changed

Lines changed: 1882 additions & 27 deletions

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22

33
## Unreleased
44

5+
## v0.4.0 - 2026-05-07
6+
7+
- Added macOS user-level service installation through `ctr-go service install`, including friendly first-run prompts, non-interactive flags, LaunchAgent generation, start/stop/restart/status, login toggle, and uninstall.
8+
- Added a macOS menu bar tray app for service control, status, logs/config access, doctor, setup, and start-with-system toggling.
9+
- Added macOS `.pkg` packaging alongside the existing release archives.
10+
- Kept daemon secrets in local `config.env`; LaunchAgent receives only `CTR_GO_CONFIG` and never stores Telegram tokens in plist environment variables.
11+
- Preserved proxy runtime env in private config for LaunchAgent deployments and redacted Telegram bot URLs from fatal stderr output.
12+
- Added ADR-018, service installer docs, tray command tests, LaunchAgent unit coverage, and macOS package dry-run validation.
13+
514
## v0.3.0 - 2026-05-07
615

716
- Added official GitHub Release binaries for macOS, Linux, and Windows, with SHA-256 checksums.

README.md

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Codex Telegram bot and remote UI for local OpenAI Codex App Server, built in Go.
1212
- Thread-first routing keeps replies, tools, Plan Mode, Details, and Final cards attached to the right run.
1313
- Built for long-running local coding-agent work from a phone.
1414

15-
Current release: `v0.3.0`.
15+
Current release: `v0.4.0`.
1616

1717
![codex-tg Telegram Plan Mode demo](docs/assets/telegram-plan-mode-demo.png)
1818

@@ -51,12 +51,13 @@ The demo flow is documented in [docs/demo/telegram-plan-mode-demo.md](docs/demo/
5151
- Final Card with Details pagination and on-demand Tools file export.
5252
- On-demand full log archive from Codex session JSONL.
5353
- SQLite-backed durable state for bindings, routes, callbacks, observer target, panels, and delivery metadata.
54+
- macOS service installer with friendly first-run setup, user LaunchAgent management, and menu bar tray control.
5455
- Cross-platform Go daemon foundation for Windows, macOS, and Linux.
5556

5657
## Platform Status
5758

5859
- Windows: actively tested with the local Codex App Server, Telegram Bot API, observer flows, and live E2E demo.
59-
- macOS: `v0.3.0` is verified stable on macOS 26.3.1 arm64 with Go 1.26.2, LaunchAgent daemon startup, local build, Details binding validation, Telegram command-menu readback, real Chat folder creation, low-noise notification validation, Plan Mode reset validation, and live Telegram readback E2E. `v0.3.0` adds release binaries and first-run config without changing the Telegram runtime contract.
60+
- macOS: `v0.4.0` is verified stable on macOS 26.3.1 arm64 with Go 1.26.2, user LaunchAgent service setup, local build, package dry-run, Details binding validation, Telegram command-menu readback, real Chat folder creation, low-noise notification validation, Plan Mode reset validation, and live Telegram readback E2E.
6061
- Linux: CI runs tests/builds on Ubuntu; full local daemon/runtime validation is still pending.
6162

6263
## Quickstart
@@ -67,8 +68,24 @@ Prerequisites:
6768
- A Telegram bot token from BotFather.
6869
- Your Telegram numeric user id.
6970

70-
Download the latest `ctr-go` archive for your OS from
71+
On macOS, download the latest `.pkg` from
7172
[GitHub Releases](https://github.com/mideco-tech/codex-tg/releases/latest),
73+
install it, then run:
74+
75+
```powershell
76+
ctr-go service install --start --start-at-login
77+
ctr-go doctor
78+
```
79+
80+
`ctr-go service install` starts a friendly first-run setup wizard when required.
81+
The same values can be passed with flags for scripted installs. It writes a
82+
private local config file at `~/.codex-tg/config.env` by default, creates a
83+
user LaunchAgent, and starts the daemon when `--start` is present.
84+
If your shell uses proxy variables such as `HTTPS_PROXY` or `NO_PROXY`, the
85+
installer preserves them in the private config so the LaunchAgent can reach the
86+
same network without putting secrets or user ids into the plist.
87+
88+
For Linux, Windows, or manual macOS setup, download the latest `ctr-go` archive,
7289
unpack it, then run:
7390

7491
```powershell
@@ -77,9 +94,8 @@ ctr-go doctor
7794
ctr-go daemon run
7895
```
7996

80-
`ctr-go init` writes a private local config file at
81-
`~/.codex-tg/config.env` by default. Use `CTR_GO_CONFIG` to point at another
82-
file. Explicit environment variables still override config file values.
97+
Use `CTR_GO_CONFIG` to point at another config file. Explicit environment
98+
variables still override config file values.
8399

84100
Build from source:
85101

@@ -116,6 +132,11 @@ Set `CTR_GO_NOTIFY_NEW_RUN=off` to keep `New run` visible but silent. `[Plan]` p
116132

117133
```powershell
118134
ctr-go init
135+
ctr-go service install
136+
ctr-go service start
137+
ctr-go service stop
138+
ctr-go service restart
139+
ctr-go service status
119140
ctr-go doctor
120141
ctr-go status
121142
ctr-go repair
@@ -126,6 +147,7 @@ Source-build equivalents:
126147

127148
```powershell
128149
go run ./cmd/ctr-go init
150+
go run ./cmd/ctr-go service install
129151
go run ./cmd/ctr-go doctor
130152
go run ./cmd/ctr-go status
131153
go run ./cmd/ctr-go repair

cmd/ctr-go-tray/main_darwin.go

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
//go:build darwin
2+
3+
package main
4+
5+
import (
6+
"bytes"
7+
"context"
8+
"fmt"
9+
"os"
10+
"os/exec"
11+
"path/filepath"
12+
"strings"
13+
"time"
14+
15+
"fyne.io/systray"
16+
17+
"github.com/mideco-tech/codex-tg/internal/config"
18+
"github.com/mideco-tech/codex-tg/internal/trayapp"
19+
)
20+
21+
var templateIcon = []byte{
22+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
23+
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
24+
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00,
25+
0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x60, 0x00, 0x00, 0x00,
26+
0x02, 0x00, 0x01, 0xe2, 0x21, 0xbc, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49,
27+
0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
28+
}
29+
30+
func main() {
31+
systray.Run(onReady, func() {})
32+
}
33+
34+
func onReady() {
35+
ctrGo := resolveCTRGo()
36+
systray.SetTitle("ctg")
37+
systray.SetTooltip("codex-tg")
38+
if len(templateIcon) > 0 {
39+
systray.SetTemplateIcon(templateIcon, templateIcon)
40+
}
41+
42+
statusItem := systray.AddMenuItem("Status: checking...", "Show service status")
43+
startItem := systray.AddMenuItem("Start", "Start codex-tg service")
44+
stopItem := systray.AddMenuItem("Stop", "Stop codex-tg service")
45+
restartItem := systray.AddMenuItem("Restart", "Restart codex-tg service")
46+
loginItem := systray.AddMenuItemCheckbox("Start with system", "Toggle login LaunchAgent", false)
47+
systray.AddSeparator()
48+
doctorItem := systray.AddMenuItem("Run doctor", "Run ctr-go doctor in Terminal")
49+
configItem := systray.AddMenuItem("Open config", "Open config.env")
50+
logsItem := systray.AddMenuItem("Open logs", "Open codex-tg logs")
51+
setupItem := systray.AddMenuItem("Open Terminal Setup", "Run friendly setup wizard")
52+
systray.AddSeparator()
53+
quitItem := systray.AddMenuItem("Quit", "Quit tray app")
54+
55+
refresh := func() {
56+
status := readServiceStatus(ctrGo)
57+
statusItem.SetTitle("Status: " + status.summary)
58+
if status.startAtLogin {
59+
loginItem.Check()
60+
} else {
61+
loginItem.Uncheck()
62+
}
63+
if !status.configExists {
64+
statusItem.SetTitle("Status: Needs setup")
65+
}
66+
}
67+
refresh()
68+
go func() {
69+
ticker := time.NewTicker(10 * time.Second)
70+
defer ticker.Stop()
71+
for range ticker.C {
72+
refresh()
73+
}
74+
}()
75+
76+
go func() {
77+
for {
78+
select {
79+
case <-statusItem.ClickedCh:
80+
runTerminalCommand(ctrGo, "service", "status")
81+
case <-startItem.ClickedCh:
82+
runAndRefresh(refresh, ctrGo, trayapp.ActionStart)
83+
case <-stopItem.ClickedCh:
84+
runAndRefresh(refresh, ctrGo, trayapp.ActionStop)
85+
case <-restartItem.ClickedCh:
86+
runAndRefresh(refresh, ctrGo, trayapp.ActionRestart)
87+
case <-loginItem.ClickedCh:
88+
if readServiceStatus(ctrGo).startAtLogin {
89+
runAndRefresh(refresh, ctrGo, trayapp.ActionDisableLogin)
90+
} else {
91+
runAndRefresh(refresh, ctrGo, trayapp.ActionEnableLogin)
92+
}
93+
case <-doctorItem.ClickedCh:
94+
runTerminalCommand(ctrGo, "doctor")
95+
case <-configItem.ClickedCh:
96+
_ = exec.Command("open", config.ConfigFilePath()).Start()
97+
case <-logsItem.ClickedCh:
98+
_ = exec.Command("open", config.DefaultPaths().LogDir).Start()
99+
case <-setupItem.ClickedCh:
100+
runTerminalCommand(ctrGo, trayapp.ServiceSetupArgs()...)
101+
case <-quitItem.ClickedCh:
102+
systray.Quit()
103+
return
104+
}
105+
}
106+
}()
107+
}
108+
109+
type serviceStatus struct {
110+
summary string
111+
startAtLogin bool
112+
configExists bool
113+
}
114+
115+
func readServiceStatus(ctrGo string) serviceStatus {
116+
out, err := runCTRGo(ctrGo, trayapp.ActionStatus)
117+
status := serviceStatus{summary: "unknown", configExists: fileExists(config.ConfigFilePath())}
118+
if err != nil {
119+
if !status.configExists {
120+
status.summary = "needs setup"
121+
}
122+
return status
123+
}
124+
text := string(out)
125+
status.startAtLogin = strings.Contains(text, "Start with system: true")
126+
if strings.Contains(text, "Loaded: true") {
127+
status.summary = "running"
128+
} else if status.configExists {
129+
status.summary = "stopped"
130+
}
131+
return status
132+
}
133+
134+
func runAndRefresh(refresh func(), ctrGo string, action trayapp.Action) {
135+
_, _ = runCTRGo(ctrGo, action)
136+
refresh()
137+
}
138+
139+
func runCTRGo(ctrGo string, action trayapp.Action) ([]byte, error) {
140+
args, err := trayapp.CTRGoArgs(action)
141+
if err != nil {
142+
return nil, err
143+
}
144+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
145+
defer cancel()
146+
cmd := exec.CommandContext(ctx, ctrGo, args...)
147+
return cmd.CombinedOutput()
148+
}
149+
150+
func runTerminalCommand(ctrGo string, args ...string) {
151+
var script bytes.Buffer
152+
script.WriteString(shellQuote(ctrGo))
153+
for _, arg := range args {
154+
script.WriteByte(' ')
155+
script.WriteString(shellQuote(arg))
156+
}
157+
escaped := strings.ReplaceAll(script.String(), `"`, `\"`)
158+
_ = exec.Command("osascript", "-e", fmt.Sprintf(`tell application "Terminal" to do script "%s"`, escaped)).Start()
159+
}
160+
161+
func resolveCTRGo() string {
162+
if value := strings.TrimSpace(os.Getenv("CTR_GO_BIN")); value != "" {
163+
return value
164+
}
165+
if found, err := exec.LookPath("ctr-go"); err == nil {
166+
return found
167+
}
168+
if exe, err := os.Executable(); err == nil {
169+
candidate := filepath.Join(filepath.Dir(exe), "ctr-go")
170+
if fileExists(candidate) {
171+
return candidate
172+
}
173+
}
174+
return "/usr/local/bin/ctr-go"
175+
}
176+
177+
func fileExists(path string) bool {
178+
_, err := os.Stat(path)
179+
return err == nil
180+
}
181+
182+
func shellQuote(value string) string {
183+
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
184+
}

cmd/ctr-go-tray/main_other.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
//go:build !darwin
2+
3+
package main
4+
5+
import (
6+
"fmt"
7+
"runtime"
8+
)
9+
10+
func main() {
11+
fmt.Printf("ctr-go-tray is macOS-only in this release; current OS is %s\n", runtime.GOOS)
12+
}

cmd/ctr-go/main.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import (
2727
func main() {
2828
if err := run(os.Args[1:]); err != nil {
2929
log.SetOutput(os.Stderr)
30-
log.Printf("ctr-go: %v", err)
30+
log.Printf("ctr-go: %s", telegram.SanitizeLogError(err))
3131
os.Exit(1)
3232
}
3333
}
@@ -44,6 +44,8 @@ func runWithIO(args []string, in io.Reader, out io.Writer) error {
4444
switch args[0] {
4545
case "init":
4646
return runInit(args[1:], in, out)
47+
case "service":
48+
return runService(args[1:], in, out)
4749
case "daemon":
4850
if len(args) < 2 || args[1] != "run" {
4951
return errors.New("usage: ctr-go daemon run")
@@ -354,6 +356,8 @@ func writeConfigEnv(path string, values map[string]string, force bool) error {
354356
func printUsage(out io.Writer) {
355357
_, _ = fmt.Fprintln(out, "Usage:")
356358
_, _ = fmt.Fprintln(out, " ctr-go init [--force]")
359+
_, _ = fmt.Fprintln(out, " ctr-go service install [flags]")
360+
_, _ = fmt.Fprintln(out, " ctr-go service start|stop|restart|status|enable-login|disable-login|uninstall")
357361
_, _ = fmt.Fprintln(out, " ctr-go daemon run")
358362
_, _ = fmt.Fprintln(out, " ctr-go status")
359363
_, _ = fmt.Fprintln(out, " ctr-go doctor")

cmd/ctr-go/main_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"bytes"
5+
"errors"
56
"io"
67
"log"
78
"os"
@@ -11,6 +12,7 @@ import (
1112
"testing"
1213

1314
"github.com/mideco-tech/codex-tg/internal/config"
15+
"github.com/mideco-tech/codex-tg/internal/telegram"
1416
)
1517

1618
func TestDaemonLogOutputCanBeDisabled(t *testing.T) {
@@ -144,3 +146,14 @@ func TestStatusAndDoctorDoNotLeakConfigFileToken(t *testing.T) {
144146
}
145147
}
146148
}
149+
150+
func TestFatalErrorSanitizerRedactsTelegramBotURL(t *testing.T) {
151+
errText := `Post "https://api.telegram.org/bot123456:abcdefghijklmnopqrstuvwxyz/getMe": context deadline exceeded`
152+
got := telegram.SanitizeLogError(errors.New(errText))
153+
if strings.Contains(got, "123456:abcdefghijklmnopqrstuvwxyz") {
154+
t.Fatalf("fatal error sanitizer leaked token: %q", got)
155+
}
156+
if !strings.Contains(got, "bot<redacted>") {
157+
t.Fatalf("fatal error sanitizer = %q, want redacted Telegram bot URL", got)
158+
}
159+
}

0 commit comments

Comments
 (0)