diff --git a/api/api.go b/api/api.go index 6a1bb3e..1f7b7a5 100644 --- a/api/api.go +++ b/api/api.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "strconv" + "strings" "woke/db" ) @@ -12,13 +13,14 @@ type Notifier func() // Server provides a REST API for alarm CRUD. type Server struct { - store *db.Store - mux *http.ServeMux - notify Notifier + store *db.Store + mux *http.ServeMux + notify Notifier + password string } -func New(store *db.Store, notify Notifier) *Server { - s := &Server{store: store, notify: notify} +func New(store *db.Store, password string, notify Notifier) *Server { + s := &Server{store: store, notify: notify, password: password} s.mux = http.NewServeMux() s.mux.HandleFunc("GET /api/alarms", s.listAlarms) s.mux.HandleFunc("GET /api/alarms/{id}", s.getAlarm) @@ -30,7 +32,18 @@ func New(store *db.Store, notify Notifier) *Server { } func (s *Server) Handler() http.Handler { - return s.mux + if s.password == "" { + return s.mux + } + // Wrap with auth middleware + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if !strings.HasPrefix(auth, "Bearer ") || auth[7:] != s.password { + writeError(w, http.StatusUnauthorized, "invalid or missing authorization") + return + } + s.mux.ServeHTTP(w, r) + }) } type alarmRequest struct { diff --git a/api/client.go b/api/client.go new file mode 100644 index 0000000..86e105b --- /dev/null +++ b/api/client.go @@ -0,0 +1,192 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "time" + "woke/db" +) + +// Client implements db.AlarmStore via HTTP calls to a remote woke server. +type Client struct { + baseURL string + password string + http *http.Client +} + +func NewClient(baseURL, password string) *Client { + return &Client{ + baseURL: baseURL, + password: password, + http: &http.Client{Timeout: 10 * time.Second}, + } +} + +func (c *Client) do(method, path string, body any) (*http.Response, error) { + var reqBody *bytes.Buffer + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + reqBody = bytes.NewBuffer(data) + } else { + reqBody = &bytes.Buffer{} + } + + req, err := http.NewRequest(method, c.baseURL+path, reqBody) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + if c.password != "" { + req.Header.Set("Authorization", "Bearer "+c.password) + } + return c.http.Do(req) +} + +func (c *Client) ListAlarms() ([]db.Alarm, error) { + resp, err := c.do("GET", "/api/alarms", nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("server returned %d", resp.StatusCode) + } + + var alarms []alarmResponse + if err := json.NewDecoder(resp.Body).Decode(&alarms); err != nil { + return nil, err + } + + result := make([]db.Alarm, len(alarms)) + for i, a := range alarms { + result[i] = a.toAlarm() + } + return result, nil +} + +func (c *Client) GetAlarm(id int) (db.Alarm, error) { + resp, err := c.do("GET", fmt.Sprintf("/api/alarms/%d", id), nil) + if err != nil { + return db.Alarm{}, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return db.Alarm{}, fmt.Errorf("server returned %d", resp.StatusCode) + } + + var a alarmResponse + if err := json.NewDecoder(resp.Body).Decode(&a); err != nil { + return db.Alarm{}, err + } + return a.toAlarm(), nil +} + +func (c *Client) CreateAlarm(a db.Alarm) (int, error) { + req := alarmRequest{ + Name: a.Name, + Description: a.Description, + Time: a.Time, + Trigger: a.Trigger, + SoundPath: a.SoundPath, + Enabled: &a.Enabled, + } + resp, err := c.do("POST", "/api/alarms", req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + return 0, fmt.Errorf("server returned %d", resp.StatusCode) + } + + var created alarmResponse + if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { + return 0, err + } + return created.ID, nil +} + +func (c *Client) UpdateAlarm(a db.Alarm) error { + req := alarmRequest{ + Name: a.Name, + Description: a.Description, + Time: a.Time, + Trigger: a.Trigger, + SoundPath: a.SoundPath, + Enabled: &a.Enabled, + } + resp, err := c.do("PUT", fmt.Sprintf("/api/alarms/%d", a.ID), req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("server returned %d", resp.StatusCode) + } + return nil +} + +func (c *Client) DeleteAlarm(id int) error { + resp, err := c.do("DELETE", fmt.Sprintf("/api/alarms/%d", id), nil) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("server returned %d", resp.StatusCode) + } + return nil +} + +func (c *Client) ToggleAlarm(id int) error { + resp, err := c.do("PATCH", fmt.Sprintf("/api/alarms/%d/toggle", id), nil) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("server returned %d", resp.StatusCode) + } + return nil +} + +func (c *Client) MarkTriggered(id int) error { + // Client mode doesn't mark triggered — server handles this + return nil +} + +func (a alarmResponse) toAlarm() db.Alarm { + alarm := db.Alarm{ + ID: a.ID, + Name: a.Name, + Description: a.Description, + Time: a.Time, + Trigger: a.Trigger, + SoundPath: a.SoundPath, + Enabled: a.Enabled, + SnoozeCount: a.SnoozeCount, + } + if t, err := time.Parse("2006-01-02T15:04:05Z", a.CreatedAt); err == nil { + alarm.CreatedAt = t + } + if t, err := time.Parse("2006-01-02T15:04:05Z", a.UpdatedAt); err == nil { + alarm.UpdatedAt = t + } + if a.LastTriggered != nil { + if t, err := time.Parse("2006-01-02T15:04:05Z", *a.LastTriggered); err == nil { + alarm.LastTriggered = &t + } + } + return alarm +} diff --git a/db/db.go b/db/db.go index 70d91e8..7be50c6 100644 --- a/db/db.go +++ b/db/db.go @@ -201,6 +201,12 @@ type Settings struct { ColorClock string ColorAlarm string ShowSeconds bool + + // Client/server mode + ServerURL string // If set, run as client connecting to this URL + ServerPassword string // Password for authenticating to remote server + APIPassword string // Password required for incoming API requests (host mode) + PollSeconds int // How often to poll server in client mode } func DefaultSettings() Settings { @@ -213,6 +219,10 @@ func DefaultSettings() Settings { ColorClock: "#00FF88", ColorAlarm: "#FF4444", ShowSeconds: true, + ServerURL: "", + ServerPassword: "", + APIPassword: "", + PollSeconds: 5, } } @@ -226,6 +236,10 @@ func (s *Store) LoadSettings() Settings { cfg.ColorClock = s.GetSetting("color_clock", cfg.ColorClock) cfg.ColorAlarm = s.GetSetting("color_alarm", cfg.ColorAlarm) cfg.ShowSeconds = s.GetSetting("show_seconds", "true") == "true" + cfg.ServerURL = s.GetSetting("server_url", cfg.ServerURL) + cfg.ServerPassword = s.GetSetting("server_password", cfg.ServerPassword) + cfg.APIPassword = s.GetSetting("api_password", cfg.APIPassword) + cfg.PollSeconds = s.getSettingInt("poll_seconds", cfg.PollSeconds) return cfg } @@ -239,6 +253,10 @@ func (s *Store) SaveSettings(cfg Settings) error { "color_clock": cfg.ColorClock, "color_alarm": cfg.ColorAlarm, "show_seconds": fmt.Sprintf("%t", cfg.ShowSeconds), + "server_url": cfg.ServerURL, + "server_password": cfg.ServerPassword, + "api_password": cfg.APIPassword, + "poll_seconds": fmt.Sprintf("%d", cfg.PollSeconds), } for k, v := range pairs { if err := s.SetSetting(k, v); err != nil { diff --git a/db/interface.go b/db/interface.go new file mode 100644 index 0000000..e657ab0 --- /dev/null +++ b/db/interface.go @@ -0,0 +1,13 @@ +package db + +// AlarmStore defines the interface for alarm CRUD operations. +// Implemented by *Store (local SQLite) and api.Client (remote HTTP). +type AlarmStore interface { + ListAlarms() ([]Alarm, error) + GetAlarm(id int) (Alarm, error) + CreateAlarm(a Alarm) (int, error) + UpdateAlarm(a Alarm) error + DeleteAlarm(id int) error + ToggleAlarm(id int) error + MarkTriggered(id int) error +} diff --git a/main.go b/main.go index 922a683..a38f83c 100644 --- a/main.go +++ b/main.go @@ -16,9 +16,19 @@ import ( const helpText = `woke - TUI alarm clock with REST API Usage: - woke Start the TUI (API server runs on :9119) + woke Start the TUI (API server runs on :9119 in host mode) woke --help Show this help +Modes: + Host mode (default): Uses local SQLite, exposes REST API on :9119 + Client mode: Connects to remote woke server, syncs alarms, fires locally + + Configure mode in Settings (press 'c'): + - Server URL: empty = host mode, or http://host:9119 for client mode + - Server pass: password to authenticate with remote server + - API password: password required for incoming API requests (host mode) + - Poll seconds: how often client polls server for alarm updates + API endpoints (http://localhost:9119): List alarms: @@ -48,6 +58,9 @@ API endpoints (http://localhost:9119): Delete alarm: curl -X DELETE http://localhost:9119/api/alarms/1 + With authentication (if API password is set): + curl -H 'Authorization: Bearer yourpassword' http://localhost:9119/api/alarms + TUI keybindings: j/k Navigate alarms a Add alarm @@ -64,6 +77,7 @@ func main() { os.Exit(0) } + // Always open local store for settings store, err := db.Open() if err != nil { fmt.Fprintf(os.Stderr, "Failed to open database: %v\n", err) @@ -71,23 +85,37 @@ func main() { } defer store.Close() + settings := store.LoadSettings() + clientMode := settings.ServerURL != "" + + // Determine alarm store: local DB or remote API + var alarmStore db.AlarmStore + if clientMode { + alarmStore = api.NewClient(settings.ServerURL, settings.ServerPassword) + fmt.Fprintf(os.Stderr, "Client mode: connecting to %s\n", settings.ServerURL) + } else { + alarmStore = store + } + pl := player.New() - sched := scheduler.New(store) + sched := scheduler.New(alarmStore, clientMode) sched.Start() defer sched.Stop() - model := ui.NewModel(store, sched, pl) + model := ui.NewModel(store, alarmStore, sched, pl, clientMode) p := tea.NewProgram(model, tea.WithAltScreen()) - // Start HTTP API server — notify TUI on mutations via p.Send - srv := api.New(store, func() { p.Send(ui.AlarmsChangedMsg{}) }) - go func() { - addr := ":9119" - fmt.Fprintf(os.Stderr, "API server listening on %s\n", addr) - if err := http.ListenAndServe(addr, srv.Handler()); err != nil { - fmt.Fprintf(os.Stderr, "API server error: %v\n", err) - } - }() + // Start HTTP API server only in host mode + if !clientMode { + srv := api.New(store, settings.APIPassword, func() { p.Send(ui.AlarmsChangedMsg{}) }) + go func() { + addr := ":9119" + fmt.Fprintf(os.Stderr, "API server listening on %s\n", addr) + if err := http.ListenAndServe(addr, srv.Handler()); err != nil { + fmt.Fprintf(os.Stderr, "API server error: %v\n", err) + } + }() + } if _, err := p.Run(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) diff --git a/scheduler/scheduler.go b/scheduler/scheduler.go index d8abcb3..25bd8d9 100644 --- a/scheduler/scheduler.go +++ b/scheduler/scheduler.go @@ -15,19 +15,21 @@ type AlarmEvent struct { // Scheduler checks alarms every second and fires events when they trigger. type Scheduler struct { - store *db.Store - events chan AlarmEvent - stop chan struct{} - mu sync.Mutex - parser cron.Parser + store db.AlarmStore + events chan AlarmEvent + stop chan struct{} + mu sync.Mutex + parser cron.Parser + clientMode bool // If true, skip MarkTriggered/ToggleAlarm (server handles it) } -func New(store *db.Store) *Scheduler { +func New(store db.AlarmStore, clientMode bool) *Scheduler { return &Scheduler{ - store: store, - events: make(chan AlarmEvent, 10), - stop: make(chan struct{}), - parser: cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow), + store: store, + clientMode: clientMode, + events: make(chan AlarmEvent, 10), + stop: make(chan struct{}), + parser: cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow), } } @@ -90,11 +92,14 @@ func (s *Scheduler) check(now time.Time, firedThisMinute map[int]string) { if s.shouldTrigger(a, now) { firedThisMinute[a.ID] = currentMinute - _ = s.store.MarkTriggered(a.ID) - // Disable one-shot alarms after triggering - if a.Trigger == "once" { - _ = s.store.ToggleAlarm(a.ID) + // In host mode, update the DB; in client mode, server handles this + if !s.clientMode { + _ = s.store.MarkTriggered(a.ID) + // Disable one-shot alarms after triggering + if a.Trigger == "once" { + _ = s.store.ToggleAlarm(a.ID) + } } select { diff --git a/ui/config.go b/ui/config.go index a5aab7f..3a11691 100644 --- a/ui/config.go +++ b/ui/config.go @@ -20,6 +20,10 @@ const ( cfgColorClock cfgColorAlarm cfgShowSeconds + cfgServerURL + cfgServerPassword + cfgAPIPassword + cfgPollSeconds cfgFieldCount ) @@ -43,6 +47,10 @@ func newConfigModel(cfg db.Settings) *configModel { } else { c.fields[cfgShowSeconds] = "false" } + c.fields[cfgServerURL] = cfg.ServerURL + c.fields[cfgServerPassword] = cfg.ServerPassword + c.fields[cfgAPIPassword] = cfg.APIPassword + c.fields[cfgPollSeconds] = strconv.Itoa(cfg.PollSeconds) return c } @@ -145,6 +153,12 @@ func (c *configModel) save(m *Model) (tea.Model, tea.Cmd) { return *m, nil } + pollSeconds, err := strconv.Atoi(strings.TrimSpace(c.fields[cfgPollSeconds])) + if err != nil || pollSeconds < 1 || pollSeconds > 60 { + c.err = "Poll seconds must be 1-60" + return *m, nil + } + cfg := db.Settings{ SnoozeMinutes: snooze, TimeoutMinutes: timeout, @@ -158,6 +172,11 @@ func (c *configModel) save(m *Model) (tea.Model, tea.Cmd) { showSec := strings.TrimSpace(c.fields[cfgShowSeconds]) cfg.ShowSeconds = showSec == "true" + cfg.ServerURL = strings.TrimSpace(c.fields[cfgServerURL]) + cfg.ServerPassword = strings.TrimSpace(c.fields[cfgServerPassword]) + cfg.APIPassword = strings.TrimSpace(c.fields[cfgAPIPassword]) + cfg.PollSeconds = pollSeconds + if cfg.DefaultSound == "" { cfg.DefaultSound = "default" } @@ -185,6 +204,10 @@ func (c *configModel) View() string { "Clock color:", "Alarm color:", "Show seconds:", + "Server URL:", + "Server pass:", + "API password:", + "Poll (sec):", } hints := [cfgFieldCount]string{ @@ -196,6 +219,10 @@ func (c *configModel) View() string { "hex e.g. #00FF88", "hex e.g. #FF4444", "space to toggle", + "empty=local, or http://host:9119", + "password to auth with server", + "password for incoming API requests", + "1-60, client poll frequency", } var lines []string diff --git a/ui/form.go b/ui/form.go index b4540d8..34e57eb 100644 --- a/ui/form.go +++ b/ui/form.go @@ -135,13 +135,13 @@ func (f *formModel) save(m *Model) (tea.Model, tea.Cmd) { if f.editing != nil { alarm.ID = f.editing.ID alarm.Enabled = f.editing.Enabled - if err := m.store.UpdateAlarm(alarm); err != nil { + if err := m.alarmStore.UpdateAlarm(alarm); err != nil { f.err = fmt.Sprintf("Save failed: %v", err) return *m, nil } m.statusMsg = "Alarm updated" } else { - if _, err := m.store.CreateAlarm(alarm); err != nil { + if _, err := m.alarmStore.CreateAlarm(alarm); err != nil { f.err = fmt.Sprintf("Save failed: %v", err) return *m, nil } diff --git a/ui/model.go b/ui/model.go index 364a408..4ae38a8 100644 --- a/ui/model.go +++ b/ui/model.go @@ -29,12 +29,15 @@ type alarmsLoadedMsg []db.Alarm type snoozeFireMsg db.Alarm type autoTimeoutMsg struct{} type AlarmsChangedMsg struct{} +type pollTickMsg struct{} // Model is the main bubbletea model. type Model struct { - store *db.Store - scheduler *scheduler.Scheduler - player *player.Player + store *db.Store // For settings (always local) + alarmStore db.AlarmStore // For alarm CRUD (local or remote) + scheduler *scheduler.Scheduler + player *player.Player + clientMode bool // State alarms []db.Alarm @@ -63,13 +66,15 @@ type Model struct { statusMsg string } -func NewModel(store *db.Store, sched *scheduler.Scheduler, pl *player.Player) Model { +func NewModel(store *db.Store, alarmStore db.AlarmStore, sched *scheduler.Scheduler, pl *player.Player, clientMode bool) Model { m := Model{ - store: store, - scheduler: sched, - player: pl, - now: time.Now(), - settings: store.LoadSettings(), + store: store, + alarmStore: alarmStore, + scheduler: sched, + player: pl, + clientMode: clientMode, + now: time.Now(), + settings: store.LoadSettings(), } m.applySettings() return m @@ -107,11 +112,15 @@ func (m *Model) applySettings() { } func (m Model) Init() tea.Cmd { - return tea.Batch( + cmds := []tea.Cmd{ tick(), listenForAlarms(m.scheduler), - loadAlarmsCmd(m.store), - ) + loadAlarmsCmd(m.alarmStore), + } + if m.clientMode { + cmds = append(cmds, pollTick(m.settings.PollSeconds)) + } + return tea.Batch(cmds...) } func tick() tea.Cmd { @@ -127,7 +136,7 @@ func listenForAlarms(sched *scheduler.Scheduler) tea.Cmd { } } -func loadAlarmsCmd(store *db.Store) tea.Cmd { +func loadAlarmsCmd(store db.AlarmStore) tea.Cmd { return func() tea.Msg { alarms, err := store.ListAlarms() if err != nil { @@ -137,6 +146,12 @@ func loadAlarmsCmd(store *db.Store) tea.Cmd { } } +func pollTick(seconds int) tea.Cmd { + return tea.Tick(time.Duration(seconds)*time.Second, func(t time.Time) tea.Msg { + return pollTickMsg{} + }) +} + func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: @@ -154,6 +169,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.refreshAlarms() return m, nil + case pollTickMsg: + m.refreshAlarms() + return m, pollTick(m.settings.PollSeconds) + case tickMsg: m.now = time.Time(msg) if m.firingAlarm != nil { @@ -244,7 +263,7 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch key { case "y", "Y": if m.cursor < len(m.alarms) { - _ = m.store.DeleteAlarm(m.alarms[m.cursor].ID) + _ = m.alarmStore.DeleteAlarm(m.alarms[m.cursor].ID) m.refreshAlarms() if m.cursor >= len(m.alarms) && m.cursor > 0 { m.cursor-- @@ -280,7 +299,7 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case " ": // Toggle enabled if m.cursor < len(m.alarms) { - _ = m.store.ToggleAlarm(m.alarms[m.cursor].ID) + _ = m.alarmStore.ToggleAlarm(m.alarms[m.cursor].ID) m.refreshAlarms() } case "a": @@ -341,7 +360,7 @@ func autoTimeoutCmd(dur time.Duration) tea.Cmd { } func (m *Model) refreshAlarms() { - alarms, err := m.store.ListAlarms() + alarms, err := m.alarmStore.ListAlarms() if err == nil { m.alarms = alarms } @@ -372,6 +391,17 @@ func (m Model) View() string { dateLine = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, dateLine) sections = append(sections, dateLine) + // Connection status + var connStatus string + if m.clientMode { + connStatus = fmt.Sprintf("[Client: %s]", m.settings.ServerURL) + } else { + connStatus = "[Local]" + } + connLine := HelpStyle.Render(connStatus) + connLine = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, connLine) + sections = append(sections, connLine) + // Snooze indicator if !m.snoozeUntil.IsZero() && m.now.Before(m.snoozeUntil) { snoozeText := fmt.Sprintf("[Snoozing %s until %s]", m.snoozeName, m.snoozeUntil.Format("15:04:05")) diff --git a/woke b/woke index 2a7e649..d553ea4 100755 Binary files a/woke and b/woke differ