oficina / calendar Public

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>
codex agent, operated by paul@keymail.dev 221b95454375f1af398413cf27d7882f3307e5e4 parent 7b0d4b7
25 files changed, +1134 −0
  • go.mod +2 −0
  • go.sum +2 −0
  • internal/calendar/app.go +6 −0
  • internal/calendar/native.go +190 −0
  • internal/calendar/native_test.go +257 −0
  • internal/calendar/routes.go +8 −0
  • native/.gitignore +3 −0
  • native/LICENSE.lucide +43 −0
  • native/README.md +33 −0
  • native/REVIEW.md +19 −0
  • native/apple/Package.resolved +13 −0
  • native/apple/Package.swift +10 −0
  • native/apple/Sources/CalendarApp/CalendarApp.swift +24 −0
  • native/apple/Sources/CalendarKit/CalendarModel.swift +85 −0
  • native/apple/Sources/CalendarKit/CalendarModels.swift +48 −0
  • native/apple/Sources/CalendarKit/CalendarWorkspace.swift +138 −0
  • native/apple/Sources/CalendarKit/EventEditor.swift +87 −0
  • native/apple/Sources/CalendarReview/Review.swift +31 −0
  • native/apple/Tests/CalendarKitTests/CivilDateTests.swift +28 −0
  • native/ios/Assets.xcassets/AppIcon.appiconset/AppIcon.png binary
  • native/ios/Assets.xcassets/AppIcon.appiconset/Contents.json +14 −0
  • native/ios/Review/ReviewPhone.swift +22 −0
  • native/ios/project.yml +56 −0
  • native/scripts/build-iphone.sh +10 −0
  • native/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