Add native Calendar clients for Mac and iOS
Preserve civil dates and wall times, use browser approval and existing calendar grants, and keep recurrence editing in the web calendar.
Co-Authored-By: Codex <codex~paul@keymail.dev>
25 files changed,
+1134
−0
go.mod+2 −0go.sum+2 −0internal/calendar/app.go+6 −0internal/calendar/native.go+190 −0internal/calendar/native_test.go+257 −0internal/calendar/routes.go+8 −0native/.gitignore+3 −0native/LICENSE.lucide+43 −0native/README.md+33 −0native/REVIEW.md+19 −0native/apple/Package.resolved+13 −0native/apple/Package.swift+10 −0native/apple/Sources/CalendarApp/CalendarApp.swift+24 −0native/apple/Sources/CalendarKit/CalendarModel.swift+85 −0native/apple/Sources/CalendarKit/CalendarModels.swift+48 −0native/apple/Sources/CalendarKit/CalendarWorkspace.swift+138 −0native/apple/Sources/CalendarKit/EventEditor.swift+87 −0native/apple/Sources/CalendarReview/Review.swift+31 −0native/apple/Tests/CalendarKitTests/CivilDateTests.swift+28 −0native/ios/Assets.xcassets/AppIcon.appiconset/AppIcon.pngbinarynative/ios/Assets.xcassets/AppIcon.appiconset/Contents.json+14 −0native/ios/Review/ReviewPhone.swift+22 −0native/ios/project.yml+56 −0native/scripts/build-iphone.sh+10 −0native/scripts/build-mac.sh+5 −0
diff --git a/go.mod b/go.mod| index a58712a..a010d0b 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -41,3 +41,5 @@ require ( |
| ) |
| tool github.com/carlosframework/rastrillo/cmd/rastrillo |
| + |
| +require amadan.net/rastrillo/native/server v0.0.0-20260912204600-33ffe9941734 |
diff --git a/go.sum b/go.sum| index a17fe9f..535eb8f 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -4,6 +4,8 @@ amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f h1:lkw3tjzHfZ |
| amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f/go.mod h1:YroavXe43Wietec+po8mtSk4a9Bir2h31Q3iAf4MWxI= |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 h1:RPKtyry/71i06o8L+gjAaUyexbmG56kO1YPs601URAM= |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489/go.mod h1:YISpzFJ+iMgrM0rUL032OSzsAQbP4/nozvXLCCOcn1k= |
| +amadan.net/rastrillo/native/server v0.0.0-20260912204600-33ffe9941734 h1:hI8/x1OEHCragb3bclo1X2bI6XCO1DDQ3plWcQPbI0g= |
| +amadan.net/rastrillo/native/server v0.0.0-20260912204600-33ffe9941734/go.mod h1:ZaaxeE278dJltdrLwmwpxUjQ5wuKzqDAVSNRet+WupY= |
| amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd h1:jlFSt95M1ontOg+VePU+kIJpChtWqDQAbnmlxqCnbgc= |
| amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE= |
| github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= |
diff --git a/internal/calendar/app.go b/internal/calendar/app.go| index 86e14bf..27d843a 100644 |
| --- a/internal/calendar/app.go |
| +++ b/internal/calendar/app.go |
| @@ -4,6 +4,7 @@ import ( |
| "amadan.net/oficina/home/connect" |
| "amadan.net/oficina/home/connect/linkstore" |
| "amadan.net/oficina/home/handoff" |
| + nativeauth "amadan.net/rastrillo/native/server" |
| "context" |
| "fmt" |
| "log/slog" |
| @@ -27,6 +28,7 @@ import ( |
| // development mail recorder both hang off this struct, and every later |
| // test depends on them. |
| type App struct { |
| + native *nativeauth.Auth |
| home *homeConnection |
| cfg Config |
| db *db.DB |
| @@ -158,6 +160,10 @@ func New(cfg Config) (*App, error) { |
| } |
| h.homeEnabled = true |
| } |
| + if err := a.wireNative(); err != nil { |
| + d.Close() |
| + return nil, err |
| + } |
| a.mux = a.routes(h, authPlugin, list, origin) |
| return a, nil |
| } |
diff --git a/internal/calendar/native.go b/internal/calendar/native.go| new file mode 100644 |
| index 0000000..2c2c807 |
| --- /dev/null |
| +++ b/internal/calendar/native.go |
| @@ -0,0 +1,190 @@ |
| +package calendar |
| + |
| +import ( |
| + "encoding/json" |
| + "io" |
| + "net/http" |
| + "strconv" |
| + "time" |
| + |
| + nativeauth "amadan.net/rastrillo/native/server" |
| + "github.com/carlosframework/rastrillo/sessions" |
| + "github.com/go-chi/chi/v5" |
| +) |
| + |
| +func (a *App) wireNative() error { |
| + s, err := sessions.New(sessions.Config{DB: a.db.Writer(), Origin: a.cfg.Origin, SigninPath: "/signin", Logger: a.logger}) |
| + if err != nil { |
| + return err |
| + } |
| + require := func(next http.Handler) http.Handler { |
| + if a.home != nil { |
| + return a.home.sessions.Require(a.requirePerson(next)) |
| + } |
| + return a.auth.RequireSession(a.requirePerson(next)) |
| + } |
| + a.native = nativeauth.New(s, a.cfg.Origin, "Calendar", require) |
| + return nil |
| +} |
| +func nativeJSON(w http.ResponseWriter, value any) { |
| + w.Header().Set("Content-Type", "application/json") |
| + w.Header().Set("Cache-Control", "no-store") |
| + _ = json.NewEncoder(w).Encode(value) |
| +} |
| +func (h *handlers) nativeSession(w http.ResponseWriter, r *http.Request) { |
| + p, _ := CurrentPerson(r) |
| + cals, err := h.store.Calendars(r.Context(), p.ID) |
| + if err != nil { |
| + http.Error(w, "Cannot load calendars", 500) |
| + return |
| + } |
| + type cal struct { |
| + ID int64 `json:"id"` |
| + Name string `json:"name"` |
| + Hue int `json:"hue"` |
| + Writable bool `json:"writable"` |
| + } |
| + out := make([]cal, 0, len(cals)) |
| + for _, c := range cals { |
| + out = append(out, cal{c.ID, c.Name, c.Hue, CanWrite(c.Role)}) |
| + } |
| + id, err := h.store.DefaultCalendarID(r.Context(), p.ID) |
| + if err != nil { |
| + http.Error(w, "Cannot load default calendar", 500) |
| + return |
| + } |
| + nativeJSON(w, map[string]any{"zone": p.Zone, "week_start": p.WeekStart, "calendars": out, "default_calendar_id": id}) |
| +} |
| + |
| +type nativeEvent struct { |
| + ID int64 `json:"id"` |
| + CalendarID int64 `json:"calendar_id"` |
| + Title string `json:"title"` |
| + Location string `json:"location"` |
| + Notes string `json:"notes"` |
| + AllDay bool `json:"all_day"` |
| + StartLocal string `json:"start_local"` |
| + EndLocal string `json:"end_local"` |
| + TZ string `json:"tz"` |
| + Start int64 `json:"start"` |
| + End int64 `json:"end"` |
| + Recurring bool `json:"recurring"` |
| + Writable bool `json:"writable"` |
| + CalendarName string `json:"calendar_name"` |
| + Occurrence string `json:"occurrence"` |
| + DayStart string `json:"day_start"` |
| + DayEnd string `json:"day_end"` |
| +} |
| + |
| +func (h *handlers) nativeEvents(w http.ResponseWriter, r *http.Request) { |
| + p, _ := CurrentPerson(r) |
| + from, e := ParseDate(r.URL.Query().Get("from"), p.Zone) |
| + to, e2 := ParseDate(r.URL.Query().Get("to"), p.Zone) |
| + if e != nil || e2 != nil || !to.After(from) || to.After(from.AddDate(0, 0, 62)) { |
| + http.Error(w, "Choose a range of at most 62 days", 400) |
| + return |
| + } |
| + events, err := h.store.EventsInRange(r.Context(), p.ID, from.AddDate(0, 0, -2).Unix(), to.AddDate(0, 0, 2).Unix()) |
| + if err != nil { |
| + http.Error(w, "Cannot load events", 500) |
| + return |
| + } |
| + out := make([]nativeEvent, 0, len(events)) |
| + for _, ev := range events { |
| + // The first editor handles single, non-booked events; series remain intact. |
| + _, booked := h.store.BookingSummaryFor(r.Context(), ev.ID) |
| + dayStart, dayEnd := "", "" |
| + if ev.AllDay { |
| + dayStart = ev.StartLocal |
| + if ev.RRule != "" && ev.SeriesID == nil && ev.OccurrenceLocal != "" { |
| + dayStart = ev.OccurrenceLocal |
| + } |
| + masterStart, e1 := time.Parse(LayoutDate, ev.StartLocal) |
| + masterEnd, e2 := time.Parse(LayoutDate, ev.EndLocal) |
| + occurrenceStart, e3 := time.Parse(LayoutDate, dayStart) |
| + if e1 != nil || e2 != nil || e3 != nil { |
| + http.Error(w, "Cannot load dates", 500) |
| + return |
| + } |
| + dayEnd = occurrenceStart.Add(masterEnd.Sub(masterStart)).Format(LayoutDate) |
| + if dayEnd <= r.URL.Query().Get("from") || dayStart >= r.URL.Query().Get("to") { |
| + continue |
| + } |
| + } else if ev.OccEndUTC <= from.Unix() || ev.OccStartUTC >= to.Unix() { |
| + continue |
| + } |
| + recurring := ev.RRule != "" || ev.SeriesID != nil |
| + out = append(out, nativeEvent{ev.ID, ev.CalendarID, ev.Title, ev.Location, ev.Notes, ev.AllDay, ev.StartLocal, ev.EndLocal, ev.TZ, ev.OccStartUTC, ev.OccEndUTC, recurring, ev.Writable() && !recurring && !booked, ev.CalendarName, ev.OccurrenceLocal, dayStart, dayEnd}) |
| + } |
| + nativeJSON(w, map[string]any{"events": out}) |
| +} |
| +func (h *handlers) nativeSave(w http.ResponseWriter, r *http.Request) { |
| + p, _ := CurrentPerson(r) |
| + r.Body = http.MaxBytesReader(w, r.Body, 128<<10) |
| + var in nativeEvent |
| + d := json.NewDecoder(r.Body) |
| + d.DisallowUnknownFields() |
| + if err := d.Decode(&in); err != nil || d.Decode(new(any)) != io.EOF { |
| + http.Error(w, "Invalid event", 400) |
| + return |
| + } |
| + id := int64(0) |
| + if raw := chi.URLParam(r, "id"); raw != "" { |
| + var err error |
| + id, err = strconv.ParseInt(raw, 10, 64) |
| + if err != nil || id <= 0 { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + } |
| + value := EventInput{CalendarID: in.CalendarID, Title: in.Title, Location: in.Location, Notes: in.Notes, AllDay: in.AllDay, StartLocal: in.StartLocal, EndLocal: in.EndLocal, TZ: in.TZ, Kind: KindMeeting, Status: StatusConfirmed} |
| + if value.TZ == "" { |
| + value.TZ = p.Zone |
| + } |
| + if id != 0 { |
| + ev, err := h.store.Event(r.Context(), p.ID, id) |
| + if err != nil { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + _, booked := h.store.BookingSummaryFor(r.Context(), id) |
| + if ev.RRule != "" || ev.SeriesID != nil || booked { |
| + http.Error(w, "Edit this series or booking in the web calendar", 409) |
| + return |
| + } |
| + value.CalendarID = ev.CalendarID |
| + value.Kind = ev.Kind |
| + value.Status = ev.Status |
| + if err = h.store.UpdateEvent(r.Context(), p.ID, id, value); err != nil { |
| + nativeEventError(w, r, err) |
| + return |
| + } |
| + } else { |
| + ev, err := h.store.CreateEvent(r.Context(), p.ID, value) |
| + if err != nil { |
| + nativeEventError(w, r, err) |
| + return |
| + } |
| + id = ev.ID |
| + if err := h.store.EnsureDefaultReminder(r.Context(), p, ev); err != nil { |
| + http.Error(w, "Event saved, but its reminder could not be scheduled. Refresh before retrying.", 500) |
| + return |
| + } |
| + } |
| + nativeJSON(w, map[string]any{"id": id, "saved_at": time.Now().Unix()}) |
| +} |
| +func nativeEventError(w http.ResponseWriter, r *http.Request, err error) { |
| + if NotFound(err) { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + if err == ErrReadOnly { |
| + http.Error(w, "This calendar is read-only", 403) |
| + return |
| + } |
| + if v, ok := Invalid(err); ok { |
| + http.Error(w, v.Message, 422) |
| + return |
| + } |
| + http.Error(w, "Cannot save event", 500) |
| +} |
diff --git a/internal/calendar/native_test.go b/internal/calendar/native_test.go| new file mode 100644 |
| index 0000000..d116f35 |
| --- /dev/null |
| +++ b/internal/calendar/native_test.go |
| @@ -0,0 +1,257 @@ |
| +package calendar |
| + |
| +import ( |
| + "bytes" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "strconv" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestNativeCalendarRoundTrip(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + call := func(method, path, body string) *httptest.ResponseRecorder { |
| + r := httptest.NewRequest(method, homeTestOrigin+path, strings.NewReader(body)) |
| + r.Header.Set("Content-Type", "application/json") |
| + r.Header.Set("Origin", homeTestOrigin) |
| + for _, c := range f.cookies { |
| + r.AddCookie(c) |
| + } |
| + w := httptest.NewRecorder() |
| + f.app.mux.ServeHTTP(w, r) |
| + return w |
| + } |
| + if call("GET", "/api/native/session", "").Code == 200 { |
| + t.Fatal("anonymous native session") |
| + } |
| + f.local(t) |
| + f.cookies = nativeClientCookies(t, f.app.mux, f.cookies) |
| + var session struct { |
| + Default int64 `json:"default_calendar_id"` |
| + } |
| + w := call("GET", "/api/native/session", "") |
| + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &session) != nil || session.Default == 0 { |
| + t.Fatal("missing personal calendar", w.Code) |
| + } |
| + event := nativeEvent{CalendarID: session.Default, Title: "Native roundtrip", TZ: "Europe/Dublin", StartLocal: "2026-10-24T09:00", EndLocal: "2026-10-24T10:00"} |
| + body, _ := json.Marshal(event) |
| + w = call("POST", "/api/native/events", string(body)) |
| + if w.Code != 200 { |
| + t.Fatal("create failed", w.Code, w.Body.String()) |
| + } |
| + w = call("GET", "/api/native/events?from=2026-10-01&to=2026-11-01", "") |
| + var result struct{ Events []nativeEvent } |
| + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &result) != nil || len(result.Events) != 1 { |
| + t.Fatal("list failed", w.Code, w.Body.String()) |
| + } |
| + got := result.Events[0] |
| + if got.StartLocal != event.StartLocal || got.TZ != event.TZ || got.CalendarID != session.Default { |
| + t.Fatal("wall clock or personal calendar changed") |
| + } |
| + event.AllDay = true |
| + event.StartLocal = "2026-10-25" |
| + event.EndLocal = "2026-10-26" |
| + body, _ = json.Marshal(event) |
| + if w = call("POST", "/api/native/events", string(body)); w.Code != 200 { |
| + t.Fatal("all-day create failed", w.Code, w.Body.String()) |
| + } |
| + if binary := os.Getenv("NATIVE_REVIEW_BINARY"); binary != "" || os.Getenv("NATIVE_REVIEW_SIMULATOR") != "" { |
| + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + // The fixture's canonical test origin differs from its ephemeral listener. |
| + // This adapter is exclusively in tests; real origin enforcement is covered above. |
| + r.Header.Set("Origin", homeTestOrigin) |
| + r.Host = strings.TrimPrefix(homeTestOrigin, "https://") |
| + r.URL.Scheme = "https" |
| + r.URL.Host = r.Host |
| + f.app.mux.ServeHTTP(w, r) |
| + })) |
| + defer server.Close() |
| + var credential map[string]string |
| + for _, c := range f.cookies { |
| + if strings.Contains(c.Name, "session") { |
| + credential = map[string]string{"name": c.Name, "value": c.Value} |
| + } |
| + } |
| + if credential == nil { |
| + t.Fatal("fixture session missing") |
| + } |
| + input, _ := json.Marshal(map[string]any{"origin": server.URL, "credential": credential, "output": os.Getenv("NATIVE_REVIEW_OUTPUT")}) |
| + if simulator := os.Getenv("NATIVE_REVIEW_SIMULATOR"); simulator != "" { |
| + bundle := "net.amadan.oficina.calendar.review" |
| + run := func(args ...string) []byte { |
| + t.Helper() |
| + out, err := exec.CommandContext(t.Context(), "xcrun", append([]string{"simctl"}, args...)...).CombinedOutput() |
| + if err != nil { |
| + t.Fatalf("simulator: %v: %s", err, out) |
| + } |
| + return out |
| + } |
| + container := strings.TrimSpace(string(run("get_app_container", simulator, bundle, "data"))) |
| + path := filepath.Join(container, "Documents", "review.json") |
| + if err := os.WriteFile(path, input, 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + defer os.Remove(path) |
| + run("launch", simulator, bundle) |
| + time.Sleep(3 * time.Second) |
| + run("io", simulator, "screenshot", os.Getenv("NATIVE_REVIEW_OUTPUT")) |
| + run("terminate", simulator, bundle) |
| + } else { |
| + cmd := exec.CommandContext(t.Context(), binary) |
| + cmd.Stdin = bytes.NewReader(input) |
| + if output, err := cmd.CombinedOutput(); err != nil { |
| + t.Fatalf("native review: %v: %s", err, output) |
| + } |
| + } |
| + |
| + } |
| + |
| + if call("GET", "/api/native/events?from=2026-01-01&to=2027-01-01", "").Code != 400 { |
| + t.Fatal("unbounded range") |
| + } |
| + event.CalendarID = 999999 |
| + body, _ = json.Marshal(event) |
| + if call("POST", "/api/native/events", string(body)).Code != 404 { |
| + t.Fatal("foreign calendar did not fail closed") |
| + } |
| +} |
| + |
| +// Independent review selected read-only and recurrent-write attempts. |
| +func TestNativeCalendarWriteGuards(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.local(t) |
| + alice, err := f.app.store.EnsurePersonSetup(t.Context(), "alice@example.test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + bob, err := f.app.store.EnsurePersonSetup(t.Context(), "replacement@example.test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cal, err := f.app.store.DefaultCalendarID(t.Context(), bob.ID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + ev, err := f.app.store.CreateEvent(t.Context(), bob.ID, EventInput{CalendarID: cal, Title: "Original", TZ: "Europe/Dublin", StartLocal: "2026-10-24T09:00", EndLocal: "2026-10-24T10:00", Kind: KindMeeting, Status: StatusConfirmed}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + attempt := func(id int64) *httptest.ResponseRecorder { |
| + body, _ := json.Marshal(nativeEvent{Title: "Changed", CalendarID: cal, TZ: ev.TZ, StartLocal: ev.StartLocal, EndLocal: ev.EndLocal}) |
| + r := httptest.NewRequest("POST", homeTestOrigin+"/api/native/events/"+strconv.FormatInt(id, 10), bytes.NewReader(body)) |
| + r.Header.Set("Content-Type", "application/json") |
| + r.Header.Set("Origin", homeTestOrigin) |
| + for _, c := range f.cookies { |
| + r.AddCookie(c) |
| + } |
| + w := httptest.NewRecorder() |
| + f.app.mux.ServeHTTP(w, r) |
| + return w |
| + } |
| + if attempt(ev.ID).Code != 404 { |
| + t.Fatal("ungranted write accepted") |
| + } |
| + if err = f.app.store.ensureGrant(t.Context(), cal, alice.ID, RoleViewer, bob.ID); err != nil { |
| + t.Fatal(err) |
| + } |
| + if w := attempt(ev.ID); w.Code != 403 && w.Code != 404 { |
| + t.Fatal("viewer write accepted", w.Code) |
| + } |
| + unchanged, err := f.app.store.Event(t.Context(), bob.ID, ev.ID) |
| + if err != nil || unchanged.Title != "Original" { |
| + t.Fatal("denied write changed event") |
| + } |
| + own, err := f.app.store.DefaultCalendarID(t.Context(), alice.ID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + recurring, err := f.app.store.CreateEvent(t.Context(), alice.ID, EventInput{CalendarID: own, Title: "Series", TZ: ev.TZ, StartLocal: ev.StartLocal, EndLocal: ev.EndLocal, Kind: KindMeeting, Status: StatusConfirmed, RRule: "FREQ=DAILY;COUNT=3"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if attempt(recurring.ID).Code != 409 { |
| + t.Fatal("series write accepted") |
| + } |
| + unchanged, err = f.app.store.Event(t.Context(), alice.ID, recurring.ID) |
| + if err != nil || unchanged.Title != "Series" || unchanged.RRule == "" { |
| + t.Fatal("series changed") |
| + } |
| +} |
| + |
| +func nativeClientCookies(t *testing.T, handler http.Handler, browser map[string]*http.Cookie) map[string]*http.Cookie { |
| + t.Helper() |
| + verifier := base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{42}, 32)) |
| + hash := sha256.Sum256([]byte(verifier)) |
| + call := func(path, body string, signed bool) *httptest.ResponseRecorder { |
| + r := httptest.NewRequest("POST", homeTestOrigin+path, strings.NewReader(body)) |
| + r.Header.Set("Origin", homeTestOrigin) |
| + r.Header.Set("Content-Type", "application/json") |
| + if signed { |
| + for _, c := range browser { |
| + r.AddCookie(c) |
| + } |
| + } |
| + w := httptest.NewRecorder() |
| + handler.ServeHTTP(w, r) |
| + return w |
| + } |
| + w := call("/native/request", `{"challenge":"`+base64.RawURLEncoding.EncodeToString(hash[:])+`"}`, false) |
| + var pending struct{ ID string } |
| + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &pending) != nil || pending.ID == "" { |
| + t.Fatal("native begin failed", w.Code) |
| + } |
| + if w = call("/native/authorize?id="+pending.ID, "", true); w.Code != 200 { |
| + t.Fatal("native approval failed", w.Code) |
| + } |
| + w = call("/native/exchange", `{"id":"`+pending.ID+`","verifier":"`+verifier+`"}`, false) |
| + var session struct{ Name, Value string } |
| + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &session) != nil || session.Value == "" { |
| + t.Fatal("native exchange failed", w.Code) |
| + } |
| + return map[string]*http.Cookie{session.Name: {Name: session.Name, Value: session.Value}} |
| +} |
| + |
| +func TestNativeAllDayCivilRangeBoundaries(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.local(t) |
| + person, err := f.app.store.EnsurePersonSetup(t.Context(), "alice@example.test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = f.app.store.g.Model(&Person{}).Where("id = ?", person.ID).Update("zone", "America/Los_Angeles").Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + cal, err := f.app.store.DefaultCalendarID(t.Context(), person.ID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, zone := range []string{"UTC", "Pacific/Kiritimati"} { |
| + if _, err = f.app.store.CreateEvent(t.Context(), person.ID, EventInput{CalendarID: cal, Title: "Civil day", AllDay: true, StartLocal: "2026-09-12", EndLocal: "2026-09-13", TZ: zone, Kind: KindMeeting, Status: StatusConfirmed, RRule: "FREQ=DAILY;COUNT=2"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + for _, day := range []string{"12", "13"} { |
| + next := "13" |
| + if day == "13" { |
| + next = "14" |
| + } |
| + w := f.do("GET", "/api/native/events?from=2026-09-"+day+"&to=2026-09-"+next, "") |
| + var got struct{ Events []nativeEvent } |
| + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &got) != nil || len(got.Events) != 2 { |
| + t.Fatal("civil boundary lost or duplicated events", w.Code, w.Body.String()) |
| + } |
| + for _, ev := range got.Events { |
| + if ev.DayStart != "2026-09-"+day || ev.DayEnd != "2026-09-"+next { |
| + t.Fatal("wrong civil occurrence dates") |
| + } |
| + } |
| + } |
| +} |
diff --git a/internal/calendar/routes.go b/internal/calendar/routes.go| index 3e2cb4f..3def059 100644 |
| --- a/internal/calendar/routes.go |
| +++ b/internal/calendar/routes.go |
| @@ -24,6 +24,7 @@ import ( |
| // - Inside the session: everything else. |
| func (a *App) routes(h *handlers, authPlugin *rauth.Auth, list allowlist, origin string) *http.ServeMux { |
| r := chi.NewRouter() |
| + r.Use(a.native.Resume) |
| // App-wide, so every state-changing route added later is born |
| // covered. Origin-checking, not tokens — nothing to mint or forget |
| // in a form. |
| @@ -34,6 +35,8 @@ func (a *App) routes(h *handlers, authPlugin *rauth.Auth, list allowlist, origin |
| r.Use(csrf.Protect(origin)) |
| } |
| + r.Handle("/native/*", a.native) |
| + |
| // Signed-out. |
| if a.home != nil { |
| r.Get("/signin", a.home.routes.Signin) |
| @@ -105,6 +108,11 @@ func (a *App) routes(h *handlers, authPlugin *rauth.Auth, list allowlist, origin |
| r.Use(a.requirePerson) |
| r.Use(h.drainOpportunistically) |
| + r.Get("/api/native/session", h.nativeSession) |
| + r.Get("/api/native/events", h.nativeEvents) |
| + r.Post("/api/native/events", h.nativeSave) |
| + r.Post("/api/native/events/{id}", h.nativeSave) |
| + |
| r.Get("/", h.today) |
| r.Get("/today", h.today) |
| r.Get("/day/{date}", h.day) |
diff --git a/native/.gitignore b/native/.gitignore| new file mode 100644 |
| index 0000000..7a084a8 |
| --- /dev/null |
| +++ b/native/.gitignore |
| @@ -0,0 +1,3 @@ |
| +build/ |
| +apple/.build/ |
| +ios/*.xcodeproj/ |
diff --git a/native/LICENSE.lucide b/native/LICENSE.lucide| new file mode 100644 |
| index 0000000..718bb3f |
| --- /dev/null |
| +++ b/native/LICENSE.lucide |
| @@ -0,0 +1,43 @@ |
| +ISC License |
| + |
| +Copyright (c) 2026 Lucide Icons and Contributors |
| + |
| +Permission to use, copy, modify, and/or distribute this software for any |
| +purpose with or without fee is hereby granted, provided that the above |
| +copyright notice and this permission notice appear in all copies. |
| + |
| +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES |
| +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF |
| +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR |
| +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES |
| +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN |
| +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF |
| +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. |
| + |
| +--- |
| + |
| +The following Lucide icons are derived from the Feather project: |
| + |
| +airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out |
| + |
| +The MIT License (MIT) (for the icons listed above) |
| + |
| +Copyright (c) 2013-present Cole Bemis |
| + |
| +Permission is hereby granted, free of charge, to any person obtaining a copy |
| +of this software and associated documentation files (the "Software"), to deal |
| +in the Software without restriction, including without limitation the rights |
| +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
| +copies of the Software, and to permit persons to whom the Software is |
| +furnished to do so, subject to the following conditions: |
| + |
| +The above copyright notice and this permission notice shall be included in all |
| +copies or substantial portions of the Software. |
| + |
| +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
| +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
| +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
| +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
| +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
| +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
| +SOFTWARE. |
diff --git a/native/README.md b/native/README.md| new file mode 100644 |
| index 0000000..6468401 |
| --- /dev/null |
| +++ b/native/README.md |
| @@ -0,0 +1,33 @@ |
| +# Oficina Calendar for Apple platforms |
| + |
| +Calendar provides calendar filtering, month navigation, a selected-day agenda, event detail and single-event creation/editing. It preserves event wall-clock time and IANA zone, and uses the existing personal-calendar default. Recurring events and bookings open the web editor. |
| + |
| +The SwiftUI client supports macOS 14+, iOS 17+ and iPadOS 17+. Mac uses a split |
| +window with a sidebar, toolbar and form sheets; iPhone collapses navigation into |
| +native columns and sheets. iPad uses the wider split layout. Both appearances |
| +use system typography and controls. Credentials stay in the per-app Keychain; |
| +responses and generated answers are held in memory. |
| + |
| +Build Mac with `native/scripts/build-mac.sh`; build the simulator app with |
| +`native/scripts/build-iphone.sh`. For a physical device use |
| +`OFICINA_DEVELOPMENT_TEAM=<team> native/scripts/build-iphone.sh device`. Generate |
| +the iOS project with `xcodegen generate --spec native/ios/project.yml`, then build |
| +`CalendarPhone` in Xcode. Physical devices require your development team. |
| +The generated projects and build products are ignored. |
| + |
| +The Swift and Go dependencies pin Rastrillo Native revision `33ffe994173438bbb45e88d16061eba2ac4f6d4c`. |
| + |
| +Browser sign-in requires the new backend `/native/` routes. A server that has |
| +not received this update reports that native support is unavailable. |
| + |
| +`CalendarReview` is a test-only executable: it accepts a loopback fixture |
| +credential over stdin, renders the real workspace and captures its window. |
| +Set NATIVE_REVIEW_BINARY and NATIVE_REVIEW_OUTPUT when running `go test |
| +./internal/calendar -run TestNative` to exercise it against the app's real test |
| +server. It is never included in the Mac or iOS app bundle. |
| + |
| +The native API adds GET `/api/native/session`, GET `/api/native/events` with a |
| +maximum 62-day half-open range, and POST `/api/native/events[/{id}]`. Existing |
| +person, calendar-grant and event-store checks remain authoritative. Recurrence |
| +expansion stays on the server. All-day ends are exclusive dates. The UI uses the |
| +person's saved week start and time zone, not the device's implicit formatting zone. |
diff --git a/native/REVIEW.md b/native/REVIEW.md| new file mode 100644 |
| index 0000000..d2408c1 |
| --- /dev/null |
| +++ b/native/REVIEW.md |
| @@ -0,0 +1,19 @@ |
| +# Independent review · 2026-09-12 |
| + |
| +Reviewer task `/root/native_review`, dispatched with this session's inherited |
| +model (the orchestration response provides no concrete model ID). The reviewer |
| +was not the implementation author. This is recorded as dispatched, without |
| +inventing a model attribution. |
| + |
| +First pass found inherited auth context could substitute the exchange caller's |
| +admission for the approved browser identity; fixed and independently chosen |
| +mutations proved the regression. Second pass found Calendar gap-time editing, |
| +all-day cross-zone membership, DST default times and Memoria same-title note |
| +replacement. All fixed with regression tests. Third pass found no remaining |
| +release blocker and passed both focused Go TestNative suites. The nonblocking |
| +gap-field toggle issue was also fixed. Canary checks are recorded separately. |
| + |
| +Mac and iPhone/iPad screens were rendered from the actual Swift clients against |
| +an authenticated Go fixture. Native approval/exchange creates the fixture client |
| +credential. Memoria's available Apple Foundation Model generated a response on |
| +this Mac. Calendar's three Swift civil-date tests and both Go CI suites pass. |
diff --git a/native/apple/Package.resolved b/native/apple/Package.resolved| new file mode 100644 |
| index 0000000..3e5101a |
| --- /dev/null |
| +++ b/native/apple/Package.resolved |
| @@ -0,0 +1,13 @@ |
| +{ |
| + "pins" : [ |
| + { |
| + "identity" : "native", |
| + "kind" : "remoteSourceControl", |
| + "location" : "https://amadan.net/rastrillo/native", |
| + "state" : { |
| + "revision" : "33ffe994173438bbb45e88d16061eba2ac4f6d4c" |
| + } |
| + } |
| + ], |
| + "version" : 2 |
| +} |
diff --git a/native/apple/Package.swift b/native/apple/Package.swift| new file mode 100644 |
| index 0000000..bed4afb |
| --- /dev/null |
| +++ b/native/apple/Package.swift |
| @@ -0,0 +1,10 @@ |
| +// swift-tools-version: 5.9 |
| +import PackageDescription |
| +let package = Package(name: "CalendarNative", platforms: [.macOS(.v14), .iOS(.v17)], |
| + products: [.library(name: "CalendarKit", targets: ["CalendarKit"]), .executable(name: "CalendarMac", targets: ["CalendarMac"])], |
| + dependencies: [.package(url: "https://amadan.net/rastrillo/native", revision: "33ffe994173438bbb45e88d16061eba2ac4f6d4c")], |
| + targets: [ |
| + .testTarget(name: "CalendarKitTests", dependencies: ["CalendarKit"]), |
| + .executableTarget(name: "CalendarReview", dependencies: ["CalendarKit", .product(name: "RastrilloApple", package: "native")]), |
| + .target(name: "CalendarKit", dependencies: [.product(name: "RastrilloApple", package: "native")]), |
| + .executableTarget(name: "CalendarMac", dependencies: ["CalendarKit", .product(name: "RastrilloApple", package: "native")], path: "Sources/CalendarApp")]) |
diff --git a/native/apple/Sources/CalendarApp/CalendarApp.swift b/native/apple/Sources/CalendarApp/CalendarApp.swift| new file mode 100644 |
| index 0000000..d8754a8 |
| --- /dev/null |
| +++ b/native/apple/Sources/CalendarApp/CalendarApp.swift |
| @@ -0,0 +1,24 @@ |
| +import CalendarKit |
| +import RastrilloApple |
| +import SwiftUI |
| + |
| +@main struct CalendarApp: App { |
| + @StateObject private var connection = NativeConnection(defaultServer: "https://calendar.oficina.works") |
| + var body: some Scene { |
| + WindowGroup { |
| + Group { |
| + if let client = connection.client { |
| + CalendarWorkspace(client: client, signOut: connection.signOut) |
| + } else { |
| + NativeSignInView("Oficina Calendar", systemImage: "calendar", connection: connection) |
| + } |
| + }.tint(Color(red: 0.78, green: 0.22, blue: 0.29)) |
| + #if os(macOS) |
| + .frame(minWidth: 780, minHeight: 650) |
| + #endif |
| + } |
| + #if os(macOS) |
| + .defaultSize(width: 1080, height: 800) |
| + #endif |
| + } |
| +} |
diff --git a/native/apple/Sources/CalendarKit/CalendarModel.swift b/native/apple/Sources/CalendarKit/CalendarModel.swift| new file mode 100644 |
| index 0000000..19321bb |
| --- /dev/null |
| +++ b/native/apple/Sources/CalendarKit/CalendarModel.swift |
| @@ -0,0 +1,85 @@ |
| +import Combine |
| +import Foundation |
| +import RastrilloApple |
| + |
| +@MainActor public final class CalendarModel: ObservableObject { |
| + @Published public var date = Date() |
| + @Published public var selectedCalendar: Int64? |
| + @Published public private(set) var calendars: [TeamCalendar] = [] |
| + @Published public private(set) var events: [CalendarEvent] = [] |
| + @Published public private(set) var zone = "UTC" |
| + @Published public private(set) var loading = false |
| + @Published public var error = "" |
| + private var weekStart = 1 |
| + private var defaultCalendar: Int64 = 0 |
| + private var generation = UUID() |
| + public init(zone: String = "UTC", date: Date = Date()) { self.zone = zone; self.date = date } |
| + public var calendar: Calendar { |
| + var c = Calendar(identifier: .gregorian) |
| + c.timeZone = TimeZone(identifier: zone) ?? .gmt |
| + c.firstWeekday = weekStart % 7 + 1 |
| + return c |
| + } |
| + public var weekdayLabels: [String] { |
| + let symbols = calendar.shortStandaloneWeekdaySymbols |
| + return (0..<7).map { symbols[(calendar.firstWeekday - 1 + $0) % 7] } |
| + } |
| + public var monthStart: Date { calendar.date(from: calendar.dateComponents([.year, .month], from: date)) ?? date } |
| + public var days: [Date] { |
| + let first = monthStart |
| + let offset = (calendar.component(.weekday, from: first) - calendar.firstWeekday + 7) % 7 |
| + let start = calendar.date(byAdding: .day, value: -offset, to: first) ?? first |
| + return (0..<42).compactMap { calendar.date(byAdding: .day, value: $0, to: start) } |
| + } |
| + public func dayEvents(_ day: Date) -> [CalendarEvent] { |
| + events.filter { (selectedCalendar == nil || $0.calendar_id == selectedCalendar) && $0.occurs(on: day, calendar: calendar) } |
| + } |
| + public func load(_ client: NativeHTTP) async { |
| + let token = UUID(); generation = token |
| + loading = true; error = "" |
| + defer { if generation == token { loading = false } } |
| + do { |
| + let session: CalendarSession = try await client.request("/api/native/session") |
| + guard generation == token else { return } |
| + zone = session.zone; weekStart = session.week_start; calendars = session.calendars; defaultCalendar = session.default_calendar_id |
| + let from = format(days.first ?? monthStart, allDay: true) |
| + let to = format(calendar.date(byAdding: .day, value: 1, to: days.last ?? monthStart)!, allDay: true) |
| + let response: CalendarEvents = try await client.request("/api/native/events?from=\(from)&to=\(to)") |
| + guard generation == token else { return } |
| + events = response.events |
| + } catch { if generation == token { events = []; self.error = error.localizedDescription } } |
| + } |
| + public func newEvent() -> CalendarEvent { |
| + var event = CalendarEvent() |
| + event.calendar_id = calendars.first(where: { $0.id == selectedCalendar && $0.writable })?.id ?? defaultCalendar |
| + event.tz = zone |
| + let day = calendar.startOfDay(for: date) |
| + let start = calendar.date(bySettingHour: 9, minute: 0, second: 0, of: day) ?? day |
| + event.start_local = format(start, allDay: false) |
| + event.end_local = format(start.addingTimeInterval(3600), allDay: false) |
| + return event |
| + } |
| + public func save(_ event: CalendarEvent, client: NativeHTTP) async throws { |
| + let _: SavedEvent = try await client.request(event.id == 0 ? "/api/native/events" : "/api/native/events/\(event.id)", method: "POST", body: JSONEncoder().encode(event)) |
| + await load(client) |
| + } |
| + public func format(_ date: Date, allDay: Bool, zone: String? = nil) -> String { |
| + let f = DateFormatter(); f.locale = Locale(identifier: "en_US_POSIX") |
| + f.timeZone = TimeZone(identifier: zone ?? self.zone) ?? .gmt |
| + f.dateFormat = allDay ? "yyyy-MM-dd" : "yyyy-MM-dd'T'HH:mm" |
| + return f.string(from: date) |
| + } |
| + public func display(_ date: Date, template: String, zone: String? = nil) -> String { |
| + let f = DateFormatter() |
| + f.locale = .current |
| + f.timeZone = TimeZone(identifier: zone ?? self.zone) ?? .gmt |
| + f.setLocalizedDateFormatFromTemplate(template) |
| + return f.string(from: date) |
| + } |
| + public func parse(_ value: String, allDay: Bool, zone: String) -> Date? { |
| + let f = DateFormatter(); f.locale = Locale(identifier: "en_US_POSIX") |
| + f.timeZone = TimeZone(identifier: zone) ?? .gmt |
| + f.dateFormat = allDay ? "yyyy-MM-dd" : "yyyy-MM-dd'T'HH:mm" |
| + return f.date(from: value) |
| + } |
| +} |
diff --git a/native/apple/Sources/CalendarKit/CalendarModels.swift b/native/apple/Sources/CalendarKit/CalendarModels.swift| new file mode 100644 |
| index 0000000..1723839 |
| --- /dev/null |
| +++ b/native/apple/Sources/CalendarKit/CalendarModels.swift |
| @@ -0,0 +1,48 @@ |
| +import Foundation |
| + |
| +public struct TeamCalendar: Decodable, Identifiable { |
| + public let id: Int64 |
| + public let name: String |
| + public let hue: Int |
| + public let writable: Bool |
| +} |
| +struct CalendarSession: Decodable { |
| + let week_start: Int |
| + let zone: String |
| + let calendars: [TeamCalendar] |
| + let default_calendar_id: Int64 |
| +} |
| +public struct CalendarEvent: Codable, Identifiable { |
| + public var id: Int64 = 0 |
| + public var calendar_id: Int64 = 0 |
| + public var title = "" |
| + public var location = "" |
| + public var notes = "" |
| + public var all_day = false |
| + public var start_local = "" |
| + public var end_local = "" |
| + public var tz = "" |
| + public var start: Int64 = 0 |
| + public var end: Int64 = 0 |
| + public var recurring = false |
| + public var writable = true |
| + public var calendar_name = "" |
| + public var occurrence = "" |
| + public var day_start = "" |
| + public var day_end = "" |
| + public func occurs(on day: Date, calendar: Calendar) -> Bool { |
| + if all_day { |
| + let f = DateFormatter(); f.locale = Locale(identifier: "en_US_POSIX") |
| + f.timeZone = calendar.timeZone; f.dateFormat = "yyyy-MM-dd" |
| + let date = f.string(from: day) |
| + return day_start <= date && day_end > date |
| + } |
| + let first = calendar.startOfDay(for: day) |
| + let last = calendar.date(byAdding: .day, value: 1, to: first)! |
| + return Double(start) < last.timeIntervalSince1970 && Double(end) > first.timeIntervalSince1970 |
| + } |
| + public var identity: String { "\(id)-\(occurrence)-\(start)" } |
| + public init() {} |
| +} |
| +struct CalendarEvents: Decodable { let events: [CalendarEvent] } |
| +struct SavedEvent: Decodable { let id: Int64 } |
diff --git a/native/apple/Sources/CalendarKit/CalendarWorkspace.swift b/native/apple/Sources/CalendarKit/CalendarWorkspace.swift| new file mode 100644 |
| index 0000000..0e2819b |
| --- /dev/null |
| +++ b/native/apple/Sources/CalendarKit/CalendarWorkspace.swift |
| @@ -0,0 +1,138 @@ |
| +import RastrilloApple |
| +import SwiftUI |
| + |
| +@MainActor public struct CalendarWorkspace: View { |
| + public let client: NativeHTTP |
| + public let signOut: () -> Void |
| + @StateObject private var model: CalendarModel |
| + @State private var editing: CalendarEvent? |
| + @State private var pendingEdit: CalendarEvent? |
| + @State private var inspecting: CalendarEvent? |
| + @State private var filter: Int64? = 0 |
| + @State private var column: NavigationSplitViewColumn = .detail |
| + @Environment(\.openURL) private var openURL |
| + public init(client: NativeHTTP, model: CalendarModel? = nil, signOut: @escaping () -> Void) { self.client = client; self.signOut = signOut; _model = StateObject(wrappedValue: model ?? CalendarModel()) } |
| + |
| + public var body: some View { |
| + NavigationSplitView(preferredCompactColumn: $column) { |
| + List(selection: $filter) { |
| + Section("Calendars") { |
| + Label("All calendars", systemImage: "calendar").tag(Int64(0)) |
| + ForEach(model.calendars) { calendar in |
| + Label(calendar.name, systemImage: "circle.fill") |
| + .listItemTint(Color(hue: Double(calendar.hue % 360) / 360, saturation: 0.6, brightness: 0.75)) |
| + .tag(calendar.id) |
| + } |
| + } |
| + Section { |
| + LabeledContent("Time zone", value: model.zone).font(.footnote) |
| + Button("Sign out", action: signOut) |
| + } |
| + }.navigationTitle("Calendar").navigationSplitViewColumnWidth(min: 220, ideal: 260, max: 340) |
| + } detail: { |
| + VStack(spacing: 0) { |
| + monthHeader.padding() |
| + monthGrid.padding(.horizontal) |
| + if model.loading { ProgressView().padding(8) } |
| + if !model.error.isEmpty { |
| + ContentUnavailableView("Couldn’t load your calendar", systemImage: "wifi.exclamationmark", |
| + description: Text(model.error)) |
| + Button("Try again") { Task { await model.load(client) } }.padding() |
| + } else { |
| + List { |
| + Section(model.display(model.date, template: "EEEE d MMMM")) { |
| + let events = model.dayEvents(model.date) |
| + if events.isEmpty { |
| + ContentUnavailableView("No events", systemImage: "calendar", |
| + description: Text("Enjoy the space, or add something to your day.")) |
| + } |
| + ForEach(events, id: \.identity) { event in |
| + Button { |
| + inspecting = event |
| + } label: { |
| + HStack(alignment: .top, spacing: 14) { |
| + RoundedRectangle(cornerRadius: 2).fill(.tint).frame(width: 4) |
| + VStack(alignment: .leading, spacing: 5) { |
| + Text(event.title).font(.headline).foregroundStyle(.primary) |
| + Text(event.calendar_name).font(.caption).foregroundStyle(.secondary) |
| + if !event.location.isEmpty { Label(event.location, systemImage: "mappin").font(.callout).foregroundStyle(.secondary) } |
| + } |
| + Spacer() |
| + Text(event.all_day ? "All day" : model.display(Date(timeIntervalSince1970: Double(event.start)), template: "jm")) |
| + .font(.callout.monospacedDigit()).foregroundStyle(.secondary) |
| + }.padding(.vertical, 6) |
| + }.buttonStyle(.plain) |
| + } |
| + } |
| + }.listStyle(.plain).refreshable { await model.load(client) } |
| + } |
| + } |
| + .navigationTitle("Calendar") |
| + .toolbar { |
| + ToolbarItem(placement: .primaryAction) { |
| + Button("New event", systemImage: "plus") { editing = model.newEvent() } |
| + .disabled(!model.calendars.contains(where: \.writable)).keyboardShortcut("n", modifiers: .command) |
| + } |
| + ToolbarItem { Button("Refresh", systemImage: "arrow.clockwise") { Task { await model.load(client) } } } |
| + } |
| + } |
| + .environment(\.timeZone, TimeZone(identifier: model.zone) ?? .gmt) |
| + .task { await model.load(client) } |
| + .onChange(of: model.monthStart) { _, _ in Task { await model.load(client) } } |
| + .onChange(of: filter) { _, value in model.selectedCalendar = value == 0 ? nil : value; column = .detail } |
| + .sheet(item: $editing) { EventEditor(model: model, client: client, event: $0) } |
| + .sheet(item: $inspecting, onDismiss: { if let event = pendingEdit { pendingEdit = nil; editing = event } }) { event in |
| + NavigationStack { |
| + Form { |
| + Section { Text(event.title).font(.title2.bold()); Text(event.calendar_name).foregroundStyle(.secondary) } |
| + Section("When") { |
| + LabeledContent("Starts", value: event.all_day ? event.day_start : model.display(Date(timeIntervalSince1970: Double(event.start)), template: event.all_day ? "d MMM yyyy" : "d MMM yyyy jm", zone: event.tz)) |
| + LabeledContent("Ends", value: event.all_day ? event.day_end : model.display(Date(timeIntervalSince1970: Double(event.end)), template: event.all_day ? "d MMM yyyy" : "d MMM yyyy jm", zone: event.tz)) |
| + LabeledContent("Time zone", value: event.tz) |
| + if event.recurring { Label("Repeating event", systemImage: "repeat") } |
| + } |
| + if !event.location.isEmpty { Section("Location") { Text(event.location).textSelection(.enabled) } } |
| + if !event.notes.isEmpty { Section("Notes") { Text(event.notes).textSelection(.enabled) } } |
| + if event.writable { |
| + Section { Button("Edit event") { pendingEdit = event; inspecting = nil } } |
| + } else { |
| + Section { Link("Open in web calendar", destination: client.origin.appendingPathComponent("events/\(event.id)")) } |
| + } |
| + }.formStyle(.grouped).navigationTitle("Event") |
| + .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { inspecting = nil } } } |
| + } |
| + #if os(macOS) |
| + .frame(width: 480, height: 500) |
| + #endif |
| + } |
| + } |
| + |
| + private var monthHeader: some View { |
| + HStack { |
| + Text(model.display(model.monthStart, template: "MMMM yyyy")).font(.title2.bold()) |
| + Spacer() |
| + Button("Previous month", systemImage: "chevron.left") { move(-1) }.labelStyle(.iconOnly).frame(minWidth: 44, minHeight: 44) |
| + Button("Today") { model.date = Date() } |
| + Button("Next month", systemImage: "chevron.right") { move(1) }.labelStyle(.iconOnly).frame(minWidth: 44, minHeight: 44) |
| + }.buttonStyle(.borderless).controlSize(.large) |
| + } |
| + private func move(_ amount: Int) { model.date = model.calendar.date(byAdding: .month, value: amount, to: model.date) ?? model.date } |
| + private var monthGrid: some View { |
| + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 4), count: 7), spacing: 4) { |
| + ForEach(model.weekdayLabels, id: \.self) { Text($0).font(.caption).foregroundStyle(.secondary) } |
| + ForEach(model.days, id: \.self) { day in |
| + let selected = model.calendar.isDate(day, inSameDayAs: model.date) |
| + let current = model.calendar.isDate(day, equalTo: model.monthStart, toGranularity: .month) |
| + Button { model.date = day } label: { |
| + VStack(spacing: 3) { |
| + Text("\(model.calendar.component(.day, from: day))").font(.body.monospacedDigit()) |
| + Circle().fill(model.dayEvents(day).isEmpty ? .clear : selected ? .white : Color.accentColor).frame(width: 4, height: 4) |
| + }.frame(maxWidth: .infinity, minHeight: 44) |
| + .foregroundStyle(selected ? .white : current ? Color.primary : Color.secondary) |
| + .background(selected ? Color.accentColor : .clear, in: RoundedRectangle(cornerRadius: 9)) |
| + }.buttonStyle(.plain).accessibilityLabel(model.display(day, template: "EEEE d MMMM yyyy")) |
| + .accessibilityAddTraits(selected ? .isSelected : []) |
| + } |
| + }.padding(.bottom, 12) |
| + } |
| +} |
diff --git a/native/apple/Sources/CalendarKit/EventEditor.swift b/native/apple/Sources/CalendarKit/EventEditor.swift| new file mode 100644 |
| index 0000000..f23e590 |
| --- /dev/null |
| +++ b/native/apple/Sources/CalendarKit/EventEditor.swift |
| @@ -0,0 +1,87 @@ |
| +import RastrilloApple |
| +import SwiftUI |
| + |
| +struct EventEditor: View { |
| + @ObservedObject var model: CalendarModel |
| + let client: NativeHTTP |
| + @State var event: CalendarEvent |
| + @State private var start: Date |
| + @State private var end: Date |
| + @State private var scheduleChanged = false |
| + @State private var wallClockFields: Bool |
| + @State private var saving = false |
| + @State private var error = "" |
| + @Environment(\.dismiss) private var dismiss |
| + |
| + init(model: CalendarModel, client: NativeHTTP, event: CalendarEvent) { |
| + self.model = model; self.client = client |
| + _event = State(initialValue: event) |
| + let parsedStart = model.parse(event.start_local, allDay: event.all_day, zone: event.tz) |
| + let parsedEnd = model.parse(event.end_local, allDay: event.all_day, zone: event.tz) |
| + _wallClockFields = State(initialValue: parsedStart == nil || parsedEnd == nil) |
| + _start = State(initialValue: parsedStart ?? model.date) |
| + _end = State(initialValue: parsedEnd ?? model.date) |
| + } |
| + var body: some View { |
| + NavigationStack { |
| + Form { |
| + Section { |
| + TextField("Title", text: $event.title).accessibilityIdentifier("eventTitle") |
| + TextField("Location", text: $event.location) |
| + if event.id == 0 { |
| + Picker("Calendar", selection: $event.calendar_id) { |
| + ForEach(model.calendars.filter(\.writable)) { Text($0.name).tag($0.id) } |
| + } |
| + } |
| + } |
| + Section("When") { |
| + Toggle("All day", isOn: $event.all_day).disabled(wallClockFields) |
| + if wallClockFields { |
| + TextField("Starts", text: $event.start_local) |
| + TextField("Ends", text: $event.end_local) |
| + Text("This event uses a local time during a clock change. Its original local dates are preserved; edit them as YYYY-MM-DDTHH:mm.").font(.footnote).foregroundStyle(.secondary) |
| + } else { |
| + DatePicker("Starts", selection: $start, displayedComponents: event.all_day ? [.date] : [.date, .hourAndMinute]) |
| + DatePicker(event.all_day ? "Ends before" : "Ends", selection: $end, displayedComponents: event.all_day ? [.date] : [.date, .hourAndMinute]) |
| + } |
| + LabeledContent("Time zone", value: event.tz) |
| + if event.all_day { Text("The end date is the first day after this event.").font(.footnote).foregroundStyle(.secondary) } |
| + } |
| + Section("Notes") { TextEditor(text: $event.notes).frame(minHeight: 100) } |
| + if !error.isEmpty { Section { Text(error).foregroundStyle(.red) } } |
| + }.formStyle(.grouped).environment(\.timeZone, TimeZone(identifier: event.tz) ?? .gmt) |
| + .navigationTitle(event.id == 0 ? "New event" : "Edit event") |
| + .toolbar { |
| + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() }.disabled(saving) } |
| + ToolbarItem(placement: .confirmationAction) { |
| + Button(saving ? "Saving…" : "Save") { |
| + if scheduleChanged && !wallClockFields { |
| + event.start_local = model.format(start, allDay: event.all_day, zone: event.tz) |
| + event.end_local = model.format(end, allDay: event.all_day, zone: event.tz) |
| + } |
| + saving = true |
| + Task { |
| + do { try await model.save(event, client: client); dismiss() } |
| + catch { self.error = error.localizedDescription; saving = false } |
| + } |
| + }.disabled(saving || event.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || (!wallClockFields && end <= start)) |
| + } |
| + } |
| + } |
| + #if os(macOS) |
| + .frame(width: 500, height: 560) |
| + #endif |
| + .onChange(of: start) { _, _ in scheduleChanged = true } |
| + .onChange(of: end) { _, _ in scheduleChanged = true } |
| + .onChange(of: event.all_day) { _, allDay in |
| + scheduleChanged = true |
| + if allDay && !wallClockFields { |
| + var calendar = Calendar(identifier: .gregorian) |
| + calendar.timeZone = TimeZone(identifier: event.tz) ?? .gmt |
| + start = calendar.startOfDay(for: start) |
| + end = max(calendar.startOfDay(for: end), calendar.date(byAdding: .day, value: 1, to: start)!) |
| + } |
| + } |
| + .interactiveDismissDisabled(saving) |
| + } |
| +} |
diff --git a/native/apple/Sources/CalendarReview/Review.swift b/native/apple/Sources/CalendarReview/Review.swift| new file mode 100644 |
| index 0000000..63fbdcd |
| --- /dev/null |
| +++ b/native/apple/Sources/CalendarReview/Review.swift |
| @@ -0,0 +1,31 @@ |
| +// Test-only executable. Receives an isolated fixture credential over stdin. |
| +import AppKit |
| +import SwiftUI |
| +import RastrilloApple |
| +import CalendarKit |
| +struct Input: Decodable { let origin: URL; let credential: NativeSession; let output: String } |
| +@main struct Review { |
| + @MainActor static func main() async throws { |
| + let input = try JSONDecoder().decode(Input.self, from: FileHandle.standardInput.readDataToEndOfFile()) |
| + guard input.origin.host == "127.0.0.1" else { fatalError("Review accepts only an isolated loopback fixture") } |
| + let app = NSApplication.shared |
| + app.setActivationPolicy(.accessory) |
| + let client = NativeHTTP(origin: input.origin, credential: input.credential) |
| + let model = CalendarModel() |
| + model.date = ISO8601DateFormatter().date(from: "2026-10-24T12:00:00Z")!; await model.load(client) |
| + guard model.error.isEmpty else { fatalError("Fixture failed: " + model.error) } |
| + let view = NSHostingView(rootView: CalendarWorkspace(client: client, model: model, signOut: {}).preferredColorScheme(.light).tint(Color(red: 0.76, green: 0.2, blue: 0.3))) |
| + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1100, height: 800), styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false) |
| + window.appearance = NSAppearance(named: .aqua) |
| + window.contentView = view |
| + window.center() |
| + window.orderFrontRegardless() |
| + try await Task.sleep(for: .seconds(2)) |
| + let capture = Process() |
| + capture.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture") |
| + capture.arguments = ["-x", "-o", "-l", String(window.windowNumber), input.output] |
| + try capture.run() |
| + capture.waitUntilExit() |
| + guard capture.terminationStatus == 0 else { fatalError("Window capture failed") } |
| + } |
| +} |
diff --git a/native/apple/Tests/CalendarKitTests/CivilDateTests.swift b/native/apple/Tests/CalendarKitTests/CivilDateTests.swift| new file mode 100644 |
| index 0000000..dbef4c9 |
| --- /dev/null |
| +++ b/native/apple/Tests/CalendarKitTests/CivilDateTests.swift |
| @@ -0,0 +1,28 @@ |
| +import XCTest |
| +@testable import CalendarKit |
| +final class CivilDateTests: XCTestCase { |
| + @MainActor func testNewEventStartsAtNineAcrossDST() { |
| + for day in ["2026-03-29", "2026-10-25"] { |
| + let date=ISO8601DateFormatter().date(from: day+"T12:00:00Z")! |
| + let model=CalendarModel(zone: "Europe/Dublin", date: date) |
| + XCTAssertEqual(model.newEvent().start_local, day+"T09:00") |
| + } |
| + } |
| + @MainActor func testGapCannotSilentlyBecomeToday() { |
| + let model=CalendarModel(zone: "Europe/Dublin") |
| + XCTAssertNil(model.parse("2026-03-29T01:30", allDay: false, zone: "Europe/Dublin")) |
| + } |
| + func testAllDayUsesOccurrenceCivilDatesAcrossZones() { |
| + var event=CalendarEvent(); event.all_day=true |
| + event.day_start="2026-09-12"; event.day_end="2026-09-13" |
| + event.start_local="2026-09-01"; event.end_local="2026-09-02" // recurrence master must not win |
| + event.tz="UTC"; event.start=1789171200; event.end=1789257600 |
| + for zone in ["America/Los_Angeles", "Pacific/Kiritimati"] { |
| + var calendar=Calendar(identifier: .gregorian);calendar.timeZone=TimeZone(identifier: zone)! |
| + for day in [11,12,13] { |
| + let date=calendar.date(from: DateComponents(year:2026,month:9,day:day,hour:12))! |
| + XCTAssertEqual(event.occurs(on:date,calendar:calendar),day==12) |
| + } |
| + } |
| + } |
| +} |
diff --git a/native/ios/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/native/ios/Assets.xcassets/AppIcon.appiconset/AppIcon.png| new file mode 100644 |
| index 0000000..548da0a |
| Binary files /dev/null and b/native/ios/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ |
diff --git a/native/ios/Assets.xcassets/AppIcon.appiconset/Contents.json b/native/ios/Assets.xcassets/AppIcon.appiconset/Contents.json| new file mode 100644 |
| index 0000000..0fe6aaf |
| --- /dev/null |
| +++ b/native/ios/Assets.xcassets/AppIcon.appiconset/Contents.json |
| @@ -0,0 +1,14 @@ |
| +{ |
| + "images": [ |
| + { |
| + "filename": "AppIcon.png", |
| + "idiom": "universal", |
| + "platform": "ios", |
| + "size": "1024x1024" |
| + } |
| + ], |
| + "info": { |
| + "author": "xcode", |
| + "version": 1 |
| + } |
| +} |
| \ No newline at end of file |
diff --git a/native/ios/Review/ReviewPhone.swift b/native/ios/Review/ReviewPhone.swift| new file mode 100644 |
| index 0000000..1d99752 |
| --- /dev/null |
| +++ b/native/ios/Review/ReviewPhone.swift |
| @@ -0,0 +1,22 @@ |
| +// Isolated visual review app, never part of the production target. |
| +import SwiftUI |
| +import RastrilloApple |
| +import CalendarKit |
| +struct ReviewInput: Decodable { let origin: URL; let credential: NativeSession } |
| +@main struct ReviewPhone: App { |
| + @StateObject private var model = CalendarModel() |
| + private let client: NativeHTTP |
| + init() { |
| + let path = URL.documentsDirectory.appendingPathComponent("review.json") |
| + let input = try! JSONDecoder().decode(ReviewInput.self, from: Data(contentsOf: path)) |
| + precondition(input.origin.host == "127.0.0.1") |
| + client = NativeHTTP(origin: input.origin, credential: input.credential) |
| + } |
| + var body: some Scene { |
| + WindowGroup { |
| + CalendarWorkspace(client: client, model: model, signOut: {}) |
| + .tint(Color(red: 0.76, green: 0.2, blue: 0.3)) |
| + .task { model.date = ISO8601DateFormatter().date(from: "2026-10-24T12:00:00Z")!; await model.load(client) } |
| + } |
| + } |
| +} |
diff --git a/native/ios/project.yml b/native/ios/project.yml| new file mode 100644 |
| index 0000000..35b76cd |
| --- /dev/null |
| +++ b/native/ios/project.yml |
| @@ -0,0 +1,56 @@ |
| +name: CalendarPhone |
| +options: |
| + bundleIdPrefix: net.amadan.oficina |
| + deploymentTarget: |
| + iOS: '17.0' |
| +packages: |
| + CalendarNative: |
| + path: ../apple |
| + RastrilloNative: |
| + url: https://amadan.net/rastrillo/native |
| + revision: 33ffe994173438bbb45e88d16061eba2ac4f6d4c |
| +settings: |
| + base: |
| + SWIFT_VERSION: '5.0' |
| + CODE_SIGN_STYLE: Automatic |
| + MARKETING_VERSION: '0.1.0' |
| + CURRENT_PROJECT_VERSION: '1' |
| +targets: |
| + CalendarPhone: |
| + type: application |
| + platform: iOS |
| + sources: |
| + - ../apple/Sources/CalendarApp |
| + - Assets.xcassets |
| + - path: ../LICENSE.lucide |
| + buildPhase: resources |
| + dependencies: |
| + - package: CalendarNative |
| + product: CalendarKit |
| + - package: RastrilloNative |
| + product: RastrilloApple |
| + settings: |
| + base: |
| + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon |
| + PRODUCT_BUNDLE_IDENTIFIER: net.amadan.oficina.calendar.ios |
| + TARGETED_DEVICE_FAMILY: '1,2' |
| + GENERATE_INFOPLIST_FILE: YES |
| + INFOPLIST_KEY_CFBundleDisplayName: Oficina Calendar |
| + INFOPLIST_KEY_UILaunchScreen_Generation: YES |
| + INFOPLIST_KEY_UISupportedInterfaceOrientations: UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortraitUpsideDown |
| + |
| + CalendarReviewPhone: |
| + type: application |
| + platform: iOS |
| + sources: [Review] |
| + dependencies: |
| + - package: CalendarNative |
| + product: CalendarKit |
| + - package: RastrilloNative |
| + product: RastrilloApple |
| + settings: |
| + base: |
| + PRODUCT_BUNDLE_IDENTIFIER: net.amadan.oficina.calendar.review |
| + TARGETED_DEVICE_FAMILY: '1,2' |
| + GENERATE_INFOPLIST_FILE: YES |
| + INFOPLIST_KEY_UILaunchScreen_Generation: YES |
diff --git a/native/scripts/build-iphone.sh b/native/scripts/build-iphone.sh| new file mode 100755 |
| index 0000000..ec3b5ac |
| --- /dev/null |
| +++ b/native/scripts/build-iphone.sh |
| @@ -0,0 +1,10 @@ |
| +#!/bin/sh |
| +set -eu |
| +cd "$(dirname "$0")/.." |
| +xcodegen generate --spec ios/project.yml |
| +case "${1:-simulator}" in |
| + simulator) xcodebuild -project ios/CalendarPhone.xcodeproj -scheme CalendarPhone -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' -derivedDataPath build/ios CODE_SIGNING_ALLOWED=NO build ;; |
| + device) : "${OFICINA_DEVELOPMENT_TEAM:?Set your Apple development team ID}" |
| + xcodebuild -project ios/CalendarPhone.xcodeproj -scheme CalendarPhone -destination 'generic/platform=iOS' -derivedDataPath build/device "DEVELOPMENT_TEAM=$OFICINA_DEVELOPMENT_TEAM" -allowProvisioningUpdates build ;; |
| + *) echo 'Use simulator or device' >&2; exit 2 ;; |
| +esac |
diff --git a/native/scripts/build-mac.sh b/native/scripts/build-mac.sh| new file mode 100755 |
| index 0000000..39675d6 |
| --- /dev/null |
| +++ b/native/scripts/build-mac.sh |
| @@ -0,0 +1,5 @@ |
| +#!/bin/sh |
| +set -eu |
| +cd "$(dirname "$0")/.." |
| +swift package --package-path apple resolve |
| +python3 apple/.build/checkouts/native/tools/bundle-mac.py . Calendar |