oficina / calendar Public

Merge the native Apple clients and native API into main

Brings origin/native/testflight (d133120) onto main, plus the menu-bar
agenda commit 3e6bd09 from native/apple-clients that was never pushed.
Production serves main, so /api/native/* has 404'd since a later deploy
replaced the native build and every installed Calendar app failed.
app.go keeps main's scheduledWork unchanged; wireNative runs before the
scheduler starts. New test: a native create arms the reminder wake.
Reviewed by Fable 5.1 on discussion #13.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Squashed from merge/native-testflight into main.
Co-Authored-By: Paul Campbell <paul@tito.io>
codex agent, operated by paul@keymail.dev pushed by claude agent, operated by paul@keymail.dev cd3478cfc9f446ebcbb2fdb65397822d7247d8d8 parent 5b9f0b6
47 files changed, +2792 −2
  • Makefile +1 −1
  • go.mod +2 −0
  • go.sum +2 −0
  • internal/calendar/app.go +8 −0
  • internal/calendar/credits_inventory_test.go +1 −1
  • internal/calendar/native.go +196 −0
  • internal/calendar/native_quick.go +140 −0
  • internal/calendar/native_quick_test.go +133 −0
  • internal/calendar/native_test.go +267 −0
  • internal/calendar/native_write_test.go +78 −0
  • internal/calendar/routes.go +9 −0
  • internal/calendartest/native_schedule_test.go +64 −0
  • native/.gitignore +3 −0
  • native/LICENSE.lucide +43 −0
  • native/PrivacyInfo.xcprivacy +17 −0
  • native/README.md +77 −0
  • native/REVIEW.md +38 −0
  • native/UX-REVIEW.md +77 −0
  • native/apple/Package.resolved +22 −0
  • native/apple/Package.swift +15 −0
  • native/apple/Sources/CalendarApp/CalendarApp.swift +50 −0
  • native/apple/Sources/CalendarKit/CalendarMenuView.swift +79 −0
  • native/apple/Sources/CalendarKit/CalendarModel.swift +120 −0
  • native/apple/Sources/CalendarKit/CalendarModels.swift +49 −0
  • native/apple/Sources/CalendarKit/CalendarMonthView.swift +73 −0
  • native/apple/Sources/CalendarKit/CalendarSettingsView.swift +33 −0
  • native/apple/Sources/CalendarKit/CalendarWorkspace.swift +144 −0
  • native/apple/Sources/CalendarKit/EventEditor.swift +87 −0
  • native/apple/Sources/CalendarKit/QuickAddView.swift +92 −0
  • native/apple/Sources/CalendarReview/Review.swift +51 −0
  • native/apple/Tests/CalendarKitTests/CivilDateTests.swift +28 −0
  • native/apple/Tests/CalendarKitTests/SessionLifecycleTests.swift +73 −0
  • native/ios/Assets.xcassets/AppIcon.appiconset/AppIcon.png binary
  • native/ios/Assets.xcassets/AppIcon.appiconset/Contents.json +14 −0
  • native/ios/Info.plist +35 −0
  • native/ios/Review/ReviewPhone.swift +22 −0
  • native/ios/UITests/NativeUITests.swift +34 −0
  • native/ios/project.yml +90 −0
  • native/mac-updates.json +6 −0
  • native/macos/AppIcon.icns binary
  • native/macos/AppStore.entitlements +12 −0
  • native/macos/CalendarMacStore.xcodeproj/project.pbxproj +387 −0
  • native/macos/CalendarMacStore.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 −0
  • native/macos/Info.plist +43 −0
  • native/macos/project.yml +54 −0
  • native/scripts/build-iphone.sh +10 −0
  • native/scripts/build-mac.sh +6 −0

CI passed — run details

CI log
=== vet ===
go vet ./...
=== fmt ===
=== test ===
go test ./...
?   	amadan.net/oficina/calendar/cmd/calendar	[no test files]
ok  	amadan.net/oficina/calendar/internal/calendar	4.457s
?   	amadan.net/oficina/calendar/internal/calendar/icons	[no test files]
?   	amadan.net/oficina/calendar/internal/calendar/seal	[no test files]
ok  	amadan.net/oficina/calendar/internal/calendartest	1.891s
ok  	amadan.net/oficina/calendar/internal/guardstest	0.038s
=== migration-check ===
go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check
rastrillo migration check: models and migrations agree
diff --git a/Makefile b/Makefile
index 510cc7f..0e9b3a3 100644
--- a/Makefile
+++ b/Makefile
@@ -129,6 +129,6 @@ credits:
go build -o "$$notices_tmp/notices" amadan.net/oficina/estilo.git/cmd/oficina-notices; \
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 "$$notices_tmp/notices" \
-package ./cmd/calendar -lucide \
- -exclude amadan.net/oficina/estilo.git,amadan.net/oficina/home/connect,amadan.net/oficina/home/handoff \
+ -exclude amadan.net/oficina/estilo.git,amadan.net/oficina/home/connect,amadan.net/oficina/home/handoff,amadan.net/rastrillo/native/server \
> "$$notices_tmp/notices.json"; \
mv "$$notices_tmp/notices.json" internal/calendar/credits/notices.json
diff --git a/go.mod b/go.mod
index 5aba938..aeec6fd 100644
--- a/go.mod
+++ b/go.mod
@@ -43,3 +43,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 e9f169a..59b71e4 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 62ab714..9a5d607 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"
@@ -28,6 +29,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
@@ -162,6 +164,12 @@ func New(cfg Config) (*App, error) {
}
h.homeEnabled = true
}
+ // Native wiring comes before the scheduler starts, so a native
+ // failure leaves no timer goroutine behind to leak.
+ if err := a.wireNative(); err != nil {
+ d.Close()
+ return nil, err
+ }
a.scheduled = newScheduledWork(store.nextDueAt, cfg.Env == "development", func(err error) {
logger.Error("calendar: scheduled work registration failed", "stage", "schedule.register", "err", scrubErr(err))
})
diff --git a/internal/calendar/credits_inventory_test.go b/internal/calendar/credits_inventory_test.go
index f88ea6b..f17c1a1 100644
--- a/internal/calendar/credits_inventory_test.go
+++ b/internal/calendar/credits_inventory_test.go
@@ -34,7 +34,7 @@ func TestCreditsMatchProductionDependencies(t *testing.T) {
if err != nil {
t.Fatal("production dependency graph unavailable", err)
}
- inventory, err := catalog.Collect(bytes.NewReader(raw), map[string]bool{"amadan.net/oficina/estilo.git": true, "amadan.net/oficina/home/handoff": true, "amadan.net/oficina/home/connect": true})
+ inventory, err := catalog.Collect(bytes.NewReader(raw), map[string]bool{"amadan.net/oficina/estilo.git": true, "amadan.net/oficina/home/handoff": true, "amadan.net/oficina/home/connect": true, "amadan.net/rastrillo/native/server": true})
if err != nil {
t.Fatal(err)
}
diff --git a/internal/calendar/native.go b/internal/calendar/native.go
new file mode 100644
index 0000000..2606d8d
--- /dev/null
+++ b/internal/calendar/native.go
@@ -0,0 +1,196 @@
+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"`
+ Status string `json:"status"`
+}
+
+func (h *handlers) nativeEvents(w http.ResponseWriter, r *http.Request) {
+ p, _ := CurrentPerson(r)
+ zone, ok := nativeViewZone(r.URL.Query().Get("zone"), personZone(p))
+ if !ok {
+ http.Error(w, "Choose a valid time zone", 422)
+ return
+ }
+ from, e := ParseDate(r.URL.Query().Get("from"), zone)
+ to, e2 := ParseDate(r.URL.Query().Get("to"), 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, ev.Status})
+ }
+ 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 {
+ nativeJSON(w, map[string]any{"id": id, "warning": "Event saved, but its reminder could not be scheduled."})
+ 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_quick.go b/internal/calendar/native_quick.go
new file mode 100644
index 0000000..a174ff6
--- /dev/null
+++ b/internal/calendar/native_quick.go
@@ -0,0 +1,140 @@
+package calendar
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "strings"
+)
+
+// Native quick-add uses the same pure parser as the web. The preview's concrete
+// civil dates are saved verbatim, rather than reinterpreting “tomorrow” later.
+type nativeQuickDraft struct {
+ CalendarID int64 `json:"calendar_id"`
+ Title string `json:"title"`
+ Location string `json:"location"`
+ StartLocal string `json:"start_local"`
+ EndLocal string `json:"end_local"`
+ TZ string `json:"tz"`
+ AllDay bool `json:"all_day"`
+ Repeat string `json:"repeat"`
+ Attendees []string `json:"attendees"`
+}
+type nativeQuickPreview struct {
+ Draft nativeQuickDraft `json:"draft"`
+ When string `json:"when"`
+ RepeatLabel string `json:"repeat_label"`
+ CalendarName string `json:"calendar_name"`
+ Assumptions []string `json:"assumptions"`
+}
+
+func decodeNativeQuick(w http.ResponseWriter, r *http.Request, value any) bool {
+ r.Body = http.MaxBytesReader(w, r.Body, 32<<10)
+ d := json.NewDecoder(r.Body)
+ d.DisallowUnknownFields()
+ if d.Decode(value) != nil || d.Decode(new(any)) != io.EOF {
+ http.Error(w, "Invalid quick-add", 400)
+ return false
+ }
+ return true
+}
+func (h *handlers) nativeQuickParse(w http.ResponseWriter, r *http.Request) {
+ var in struct {
+ Text string `json:"text"`
+ Zone string `json:"zone"`
+ }
+ if !decodeNativeQuick(w, r, &in) {
+ return
+ }
+ text := strings.TrimSpace(in.Text)
+ if text == "" || len(text) > 4096 {
+ http.Error(w, "Enter an event in no more than 4096 bytes", 422)
+ return
+ }
+ p, _ := CurrentPerson(r)
+ zone, ok := nativeViewZone(in.Zone, personZone(p))
+ if !ok {
+ http.Error(w, "Choose a valid time zone", 422)
+ return
+ }
+ q := ParseQuick(text, nowIn(zone), zone)
+ id, err := h.store.DefaultCalendarID(r.Context(), p.ID)
+ if err != nil || id == 0 {
+ http.Error(w, "Cannot load default calendar", 500)
+ return
+ }
+ calendars, err := h.store.Calendars(r.Context(), p.ID)
+ if err != nil {
+ http.Error(w, "Cannot load calendars", 500)
+ return
+ }
+ name := ""
+ for _, c := range calendars {
+ if c.ID == id {
+ name = c.Name
+ }
+ }
+ title := q.Title
+ if title == "" {
+ title = "Untitled"
+ }
+ attendees := q.Attendees
+ if attendees == nil {
+ attendees = []string{}
+ }
+ notes := q.Notes
+ if notes == nil {
+ notes = []string{}
+ }
+ pane := quickPaneFor(q, zone)
+ nativeJSON(w, nativeQuickPreview{nativeQuickDraft{id, title, q.Location, q.StartLocal, q.EndLocal, zone, q.AllDay, q.Repeat, attendees}, pane.When, pane.Repeat, name, notes})
+}
+func (h *handlers) nativeQuickSave(w http.ResponseWriter, r *http.Request) {
+ var in nativeQuickDraft
+ if !decodeNativeQuick(w, r, &in) {
+ return
+ }
+ switch in.Repeat {
+ case "", RepeatNone, RepeatDaily, RepeatWeekdays, RepeatWeekly, RepeatFortnight, RepeatMonthly, RepeatYearly:
+ default:
+ http.Error(w, "Unsupported repeat choice", 422)
+ return
+ }
+ if len(in.Attendees) > 100 {
+ http.Error(w, "Too many attendees", 422)
+ return
+ }
+ p, _ := CurrentPerson(r)
+ // CreateEvent remains the grant/validation authority. No calendar ownership,
+ // author identity, status or raw recurrence rule is accepted from the client.
+ ev, err := h.store.CreateEvent(r.Context(), p.ID, EventInput{CalendarID: in.CalendarID, Title: in.Title, Location: in.Location, StartLocal: in.StartLocal, EndLocal: in.EndLocal, TZ: in.TZ, AllDay: in.AllDay, Kind: KindMeeting, Status: StatusConfirmed, RRule: RuleFromRepeat(in.Repeat, "", in.StartLocal, in.TZ, in.AllDay)})
+ if err != nil {
+ nativeEventError(w, r, err)
+ return
+ }
+ warnings := []string{}
+ if len(in.Attendees) > 0 {
+ if err := h.store.SetAttendees(r.Context(), ev.ID, in.Attendees); err != nil {
+ warnings = append(warnings, "Attendees could not be recorded.")
+ }
+ }
+ if err := h.store.EnsureDefaultReminder(r.Context(), p, ev); err != nil {
+ warnings = append(warnings, "Its reminder could not be scheduled.")
+ }
+ result := map[string]any{"id": ev.ID}
+ if len(warnings) > 0 {
+ result["warning"] = "Event saved. " + strings.Join(warnings, " ")
+ }
+ nativeJSON(w, result)
+}
+
+func nativeViewZone(requested, fallback string) (string, bool) {
+ if requested == "" {
+ requested = fallback
+ }
+ if len(requested) > 128 {
+ return "", false
+ }
+ _, err := Zone(requested)
+ return requested, err == nil
+}
diff --git a/internal/calendar/native_quick_test.go b/internal/calendar/native_quick_test.go
new file mode 100644
index 0000000..49d330a
--- /dev/null
+++ b/internal/calendar/native_quick_test.go
@@ -0,0 +1,133 @@
+package calendar
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http/httptest"
+ "testing"
+ "time"
+)
+
+func TestNativeQuickPreviewAndSave(t *testing.T) {
+ f := newHomeRouterFixture(t)
+ call := func(path string, value any) *httptest.ResponseRecorder {
+ body, _ := json.Marshal(value)
+ r := httptest.NewRequest("POST", homeTestOrigin+path, bytes.NewReader(body))
+ r.Header.Set("Origin", homeTestOrigin)
+ r.Header.Set("Content-Type", "application/json")
+ for _, c := range f.cookies {
+ r.AddCookie(c)
+ }
+ w := httptest.NewRecorder()
+ f.app.mux.ServeHTTP(w, r)
+ return w
+ }
+ input := map[string]string{"text": "Lunch tomorrow at 1pm for an hour with bob@example.test", "zone": "Pacific/Kiritimati"}
+ if call("/api/native/quick/parse", input).Code == 200 {
+ t.Fatal("anonymous preview")
+ }
+ if call("/api/native/quick", nativeQuickDraft{}).Code == 200 {
+ t.Fatal("anonymous save")
+ }
+ f.local(t)
+ f.cookies = nativeClientCookies(t, f.app.mux, f.cookies)
+ var preview nativeQuickPreview
+ w := call("/api/native/quick/parse", input)
+ if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &preview) != nil {
+ t.Fatal("preview failed", w.Code)
+ }
+ loc, _ := Zone(input["zone"])
+ expected := ParseQuick(input["text"], time.Now().In(loc), input["zone"])
+ if preview.Draft.StartLocal != expected.StartLocal || preview.Draft.TZ != input["zone"] || preview.Draft.Title != expected.Title || len(preview.Draft.Attendees) != 1 {
+ t.Fatal("preview diverged from existing parser or requested zone")
+ }
+ p, err := f.app.store.EnsurePersonSetup(t.Context(), "alice@example.test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ personal, _ := f.app.store.DefaultCalendarID(t.Context(), p.ID)
+ if preview.Draft.CalendarID != personal || preview.CalendarName == "" || preview.When == "" {
+ t.Fatal("preview missing personal calendar or civil time")
+ }
+ // Save concrete dates from the preview, not a later interpretation of tomorrow.
+ preview.Draft.StartLocal = "2026-10-25T09:00"
+ preview.Draft.EndLocal = "2026-10-25T10:00"
+ preview.Draft.Repeat = RepeatWeekly
+ w = call("/api/native/quick", preview.Draft)
+ var saved struct{ ID int64 }
+ if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &saved) != nil || saved.ID == 0 {
+ t.Fatal("save failed", w.Code)
+ }
+ ev, err := f.app.store.Event(t.Context(), p.ID, saved.ID)
+ if err != nil || ev.StartLocal != preview.Draft.StartLocal || ev.TZ != preview.Draft.TZ || ev.RRule != "FREQ=WEEKLY" {
+ t.Fatal("saved meaning changed")
+ }
+ attendees, err := f.app.store.AttendeesFor(t.Context(), saved.ID)
+ if err != nil || len(attendees) != 1 || attendees[0] != "bob@example.test" {
+ t.Fatal("attendees not recorded")
+ }
+ preview.Draft.CalendarID = 999999
+ if call("/api/native/quick", preview.Draft).Code == 200 {
+ t.Fatal("unauthorized calendar accepted")
+ }
+ preview.Draft.CalendarID = personal
+ preview.Draft.Repeat = "unsupported"
+ if call("/api/native/quick", preview.Draft).Code != 422 {
+ t.Fatal("unsupported repeat silently lost")
+ }
+ input["zone"] = "invalid/not-a-zone"
+ if call("/api/native/quick/parse", input).Code != 422 {
+ t.Fatal("invalid zone accepted")
+ }
+}
+
+func TestNativeViewingZoneUsesRequestedCivilDay(t *testing.T) {
+ f := newHomeRouterFixture(t)
+ f.local(t)
+ p, _ := f.app.store.EnsurePersonSetup(t.Context(), "alice@example.test")
+ cal, _ := f.app.store.DefaultCalendarID(t.Context(), p.ID)
+ _, err := f.app.store.CreateEvent(t.Context(), p.ID, EventInput{CalendarID: cal, Title: "Across midnight", StartLocal: "2026-10-24T12:00", EndLocal: "2026-10-24T13:00", TZ: "UTC", Kind: KindMeeting, Status: StatusConfirmed})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, tc := range []struct {
+ zone string
+ count int
+ }{{"Pacific/Kiritimati", 1}, {"America/Los_Angeles", 0}} {
+ w := f.do("GET", "/api/native/events?from=2026-10-25&to=2026-10-26&zone="+tc.zone, "")
+ var response struct{ Events []nativeEvent }
+ if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &response) != nil || len(response.Events) != tc.count {
+ t.Fatalf("civil query wrong for %s", tc.zone)
+ }
+ }
+}
+
+func TestNativeQuickConfirmsSavedEventWithReminderWarning(t *testing.T) {
+ f := newHomeRouterFixture(t)
+ f.local(t)
+ p, _ := f.app.store.EnsurePersonSetup(t.Context(), "alice@example.test")
+ cal, _ := f.app.store.DefaultCalendarID(t.Context(), p.ID)
+ if _, err := f.app.db.Writer().Exec("CREATE TRIGGER native_quick_reminder_failure BEFORE INSERT ON reminders BEGIN SELECT RAISE(FAIL, 'fixture'); END"); err != nil {
+ t.Fatal(err)
+ }
+ draft := nativeQuickDraft{CalendarID: cal, Title: "Confirmed quick event", StartLocal: "2026-10-25T09:00", EndLocal: "2026-10-25T10:00", TZ: "Europe/Dublin"}
+ body, _ := json.Marshal(draft)
+ r := httptest.NewRequest("POST", homeTestOrigin+"/api/native/quick", bytes.NewReader(body))
+ r.Header.Set("Origin", homeTestOrigin)
+ for _, c := range f.cookies {
+ r.AddCookie(c)
+ }
+ w := httptest.NewRecorder()
+ f.app.mux.ServeHTTP(w, r)
+ var result struct {
+ ID int64
+ Warning string
+ }
+ if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &result) != nil || result.ID == 0 || result.Warning == "" {
+ t.Fatal("saved quick event reported as failure", w.Code)
+ }
+ event, err := f.app.store.Event(t.Context(), p.ID, result.ID)
+ if err != nil || event.Title != draft.Title {
+ t.Fatal("confirmed event not persisted")
+ }
+}
diff --git a/internal/calendar/native_test.go b/internal/calendar/native_test.go
new file mode 100644
index 0000000..73c8bc7
--- /dev/null
+++ b/internal/calendar/native_test.go
@@ -0,0 +1,267 @@
+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)
+
+ if os.Getenv("NATIVE_REVIEW_UI_TESTS") != "" {
+ command := exec.CommandContext(t.Context(), "xcodebuild", "-project", "native/ios/CalendarPhone.xcodeproj", "-scheme", "CalendarReviewPhone", "-destination", "platform=iOS Simulator,id="+simulator, "-parallel-testing-enabled", "NO", "-derivedDataPath", "native/build/ios", "CODE_SIGNING_ALLOWED=NO", "test")
+ command.Dir = "../.."
+ output, err := command.CombinedOutput()
+ _ = os.WriteFile(filepath.Join(os.TempDir(), "calendar-native-ui-tests.log"), output, 0600)
+ if err != nil {
+ t.Fatal("Native UI tests failed; see temporary xcodebuild log", err)
+ }
+ }
+ 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/native_write_test.go b/internal/calendar/native_write_test.go
new file mode 100644
index 0000000..36a1885
--- /dev/null
+++ b/internal/calendar/native_write_test.go
@@ -0,0 +1,78 @@
+package calendar
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestNativePreservesStatusAndGapWallTime(t *testing.T) {
+ f := newHomeRouterFixture(t)
+ f.local(t)
+ p, err := f.app.store.EnsurePersonSetup(t.Context(), "alice@example.test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ cal, err := f.app.store.DefaultCalendarID(t.Context(), p.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, status := range []string{StatusCancelled, StatusTentative} {
+ _, err = f.app.store.CreateEvent(t.Context(), p.ID, EventInput{CalendarID: cal, Title: "Gap meeting", TZ: "Europe/Dublin", StartLocal: "2026-03-29T01:30", EndLocal: "2026-03-29T03:30", Kind: KindMeeting, Status: status})
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ w := f.do("GET", "/api/native/events?from=2026-03-29&to=2026-03-30", "")
+ var got struct{ Events []nativeEvent }
+ if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &got) != nil || len(got.Events) != 2 {
+ t.Fatal("missing status events")
+ }
+ statuses := map[string]bool{}
+ for _, ev := range got.Events {
+ statuses[ev.Status] = true
+ if ev.StartLocal != "2026-03-29T01:30" || ev.EndLocal != "2026-03-29T03:30" {
+ t.Fatal("gap wall time changed")
+ }
+ }
+ if !statuses[StatusCancelled] || !statuses[StatusTentative] {
+ t.Fatal("native API dropped cancellation or tentative state")
+ }
+}
+
+func TestNativeSavedEventSurvivesReminderFailure(t *testing.T) {
+ f := newHomeRouterFixture(t)
+ f.local(t)
+ p, err := f.app.store.EnsurePersonSetup(t.Context(), "alice@example.test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ cal, err := f.app.store.DefaultCalendarID(t.Context(), p.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = f.app.db.Writer().Exec("CREATE TRIGGER native_reminder_failure BEFORE INSERT ON reminders BEGIN SELECT RAISE(FAIL, 'fixture failure'); END"); err != nil {
+ t.Fatal(err)
+ }
+ body, _ := json.Marshal(nativeEvent{CalendarID: cal, Title: "Saved event", TZ: "Europe/Dublin", StartLocal: "2026-10-24T09:00", EndLocal: "2026-10-24T10:00"})
+ r := httptest.NewRequest("POST", homeTestOrigin+"/api/native/events", bytes.NewReader(body))
+ r.Header.Set("Origin", homeTestOrigin)
+ r.Header.Set("Content-Type", "application/json")
+ for _, c := range f.cookies {
+ r.AddCookie(c)
+ }
+ w := httptest.NewRecorder()
+ f.app.mux.ServeHTTP(w, r)
+ var got struct {
+ ID int64
+ Warning string
+ }
+ if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &got) != nil || got.ID == 0 || got.Warning == "" {
+ t.Fatal("saved event was not confirmed separately from failed reminder", w.Code)
+ }
+ ev, err := f.app.store.Event(t.Context(), p.ID, got.ID)
+ if err != nil || ev.Title != "Saved event" {
+ t.Fatal("confirmed event was not stored")
+ }
+}
diff --git a/internal/calendar/routes.go b/internal/calendar/routes.go
index 019f81b..21187e8 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,7 @@ func (a *App) routes(h *handlers, authPlugin *rauth.Auth, list allowlist, origin
r.Use(csrf.Protect(origin))
}
+ r.Handle("/native/*", a.native)
r.Get("/help/open-source", h.credits)
// Signed-out.
@@ -107,6 +109,13 @@ func (a *App) routes(h *handlers, authPlugin *rauth.Auth, list allowlist, origin
r.Use(a.requirePerson)
r.Use(h.drainOpportunistically)
+ r.Post("/api/native/quick/parse", h.nativeQuickParse)
+ r.Post("/api/native/quick", h.nativeQuickSave)
+ 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/internal/calendartest/native_schedule_test.go b/internal/calendartest/native_schedule_test.go
new file mode 100644
index 0000000..0b74d7d
--- /dev/null
+++ b/internal/calendartest/native_schedule_test.go
@@ -0,0 +1,64 @@
+package calendartest
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ calendar "amadan.net/oficina/calendar/internal/calendar"
+)
+
+// A native create is a JSON POST, not a page load. Main's scheduledWork
+// middleware re-arms the wake after EVERY request, so the reminder the
+// native create schedules is armed before anyone opens a page. The
+// merge of native/testflight kept that behaviour; this stands in front
+// of it.
+func TestNativeCreateArmsTheWakeForItsReminder(t *testing.T) {
+ control := controlForTest(t)
+ app, session := signedIn(t, "someone@example.com")
+
+ rec := getAs(t, app.Handler(), session, "/api/native/session")
+ var sess struct {
+ DefaultCalendarID int64 `json:"default_calendar_id"`
+ }
+ if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &sess) != nil || sess.DefaultCalendarID == 0 {
+ t.Fatalf("native session = %d: %s", rec.Code, rec.Body.String())
+ }
+ if at, _ := control.snapshot(); !at.IsZero() {
+ t.Fatalf("timer armed before any work existed: %v", at)
+ }
+
+ start := time.Now().UTC().Add(2 * time.Minute)
+ body, _ := json.Marshal(map[string]any{
+ "calendar_id": sess.DefaultCalendarID, "title": "Native standup", "tz": "UTC",
+ "start_local": start.Format("2006-01-02T15:04"), "end_local": start.Add(15 * time.Minute).Format("2006-01-02T15:04"),
+ })
+ req := httptest.NewRequest(http.MethodPost, "/api/native/events", bytes.NewReader(body))
+ req.Header.Set("Origin", calendar.TestOrigin)
+ req.Header.Set("Content-Type", "application/json")
+ req.AddCookie(session)
+ rec = httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+ var saved struct {
+ ID int64 `json:"id"`
+ Warning string `json:"warning"`
+ }
+ if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &saved) != nil || saved.ID == 0 || saved.Warning != "" {
+ t.Fatalf("native create = %d: %s", rec.Code, rec.Body.String())
+ }
+
+ expected := app.StoreForTest().NextDueAt(context.Background())
+ if expected.IsZero() {
+ t.Fatal("native create scheduled no reminder, so this test proves nothing")
+ }
+ control.waitArmed(t)
+ // The default reminder is already overdue, so the wake is "now":
+ // no earlier than the due moment and no later than the check.
+ if at, _ := control.snapshot(); at.Before(expected) || at.After(time.Now()) {
+ t.Fatalf("native POST armed %v, want between %v and now", at, expected)
+ }
+}
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/PrivacyInfo.xcprivacy b/native/PrivacyInfo.xcprivacy
new file mode 100644
index 0000000..79bc9e2
--- /dev/null
+++ b/native/PrivacyInfo.xcprivacy
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>NSPrivacyAccessedAPITypes</key>
+ <array>
+ <dict>
+ <key>NSPrivacyAccessedAPIType</key>
+ <string>NSPrivacyAccessedAPICategoryUserDefaults</string>
+ <key>NSPrivacyAccessedAPITypeReasons</key>
+ <array>
+ <string>CA92.1</string>
+ </array>
+ </dict>
+ </array>
+</dict>
+</plist>
diff --git a/native/README.md b/native/README.md
new file mode 100644
index 0000000..b19efc7
--- /dev/null
+++ b/native/README.md
@@ -0,0 +1,77 @@
+# 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.
+
+For XCTest interaction checks against the isolated authenticated fixture, set
+`NATIVE_REVIEW_SIMULATOR=<UDID> NATIVE_REVIEW_UI_TESTS=1` with a writable
+`NATIVE_REVIEW_OUTPUT` PNG path when running the native Go fixture test. First
+build/install the separate ReviewPhone target. The UI tests create and inspect
+fixture events and never connect to a real account. Xcode result bundles
+are under `native/build/ios/Logs/Test`. Review credentials enter only the isolated
+review app's Documents directory and are removed when the fixture exits.
+
+## Quick-add, menu bar and time zones
+
+Use Quick add (Command-K on Mac) and type a sentence such as “Lunch tomorrow at
+1pm for an hour”. Review the understood date, time, calendar, recurrence and
+assumptions, then choose Add event (Command-Return on Mac). The server's existing
+Go parser is the only natural-language implementation. Saving uses the concrete
+preview dates, so midnight cannot reinterpret tomorrow. Attendees are recorded;
+no invitations are sent. Saved-event warnings stay visible instead of inviting
+a duplicate retry.
+
+The Mac menu-bar calendar stays available after the main window closes. It
+shares the selected date and filter, shows a compact month and day agenda, and
+contains quick-add. Open Calendar returns to the full window; Quit Calendar
+ends the app. It does not register itself to launch at login.
+
+Native Calendar follows this device's system time zone by default. Calendar
+settings offers Use system time zone or an explicit local override. On Mac use
+the app's Settings menu or the gear in the menu-bar panel; on iPhone use Settings
+in the calendar sidebar. The choice controls viewing ranges, new events and
+quick-add interpretation. Existing events retain their stored wall clock/IANA
+zone, and all-day entries remain civil dates. The server account's web preference
+is not rewritten by a device setting.
+
+The feature adds protected POST `/api/native/quick/parse` and
+POST `/api/native/quick`; GET `/api/native/events` accepts an optional viewing
+`zone`. Clients without that query retain the previous server-preference behavior.
+Preview bodies are bounded; create uses the existing grant checks and event store.
+
+Review: `/root/canary_release_review`, dispatched gpt-6-astra/high (runtime
+identity not independently verified), approved source after delayed-save
+sign-out, lost-confirmation and zone-encoding findings were fixed. Its isolated
+post-response-guard mutation failed four Swift assertions and was restored;
+all five Swift tests and the Go quick-add/viewing-zone tests passed. iPhone
+XCTest created an event through the actual parser/preview/save flow. The menu
+content was rendered against an authenticated fixture and inspected.
diff --git a/native/REVIEW.md b/native/REVIEW.md
new file mode 100644
index 0000000..75df1ec
--- /dev/null
+++ b/native/REVIEW.md
@@ -0,0 +1,38 @@
+# 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.
+
+Final review was separately dispatched as `gpt-6-astra` with high reasoning,
+without claiming independent evidence of runtime model identity. It found
+cancelled/tentative status missing from Calendar's DTO and UI. The follow-up
+carries status and labels it explicitly; regression tests pin both states.
+A confirmed event with a failed reminder now returns its saved id and a warning,
+so the native editor closes successfully instead of inviting a duplicate retry.
+A database-trigger failure test verifies that persisted event and warning.
+
+Final merge `d3fedf1` reviewed against both parents (58863aa and 9c44bc1) by
+`/root/canary_release_review`, dispatched as `gpt-6-astra` with high reasoning.
+Runtime model identity was not independently verified. Code and deployed canary
+review approved, with no remaining findings. Direct `/api/version` and the router
+reported the exact reviewed version. Native pending exchanges returned 202 without
+credentials; incorrect proofs returned 404, cross-origin requests 403, anonymous
+approval was refused, and failed approval left the request pending. No email or
+user-data mutation was performed. Existing credits access and branding remained
+intact. The final Go CI passed, and XCTest exercised native form and navigation
+against an isolated authenticated fixture.
diff --git a/native/UX-REVIEW.md b/native/UX-REVIEW.md
new file mode 100644
index 0000000..5068a18
--- /dev/null
+++ b/native/UX-REVIEW.md
@@ -0,0 +1,77 @@
+# Calendar UX review · 12 September 2026
+
+## CalendarWorkspace.swift
+
+1. **High: keep the agenda reachable at large text sizes.** The month occupied
+ a fixed sibling above the scrolling agenda. It now shares one scrolling List
+ with the agenda. Native phone navigation uses an inline title to give the
+ calendar more room. The selected calendar name appears in the title.
+
+ Before: `VStack { monthGrid; List { agenda } }`
+ After: `List { Section { monthHeader; monthGrid }; agenda }`
+
+ An intermediate LazyVGrid-in-List version crashed in the iOS collection-view
+ layout pass. The final month uses an eager Grid with six GridRows; the
+ subsequent create/inspect/edit XCTest passed. The 42 date identities persist.
+
+2. **Medium: distinguish today without relying on colour.** Today's date is
+ bold and outlined; VoiceOver reports Today and event count. Selection retains
+ the selected trait and uses the Calendar rose, fixing a mismatched blue.
+ Event strips use their calendar's hue and retain the calendar's written name.
+
+ Before: `.background(selected ? Color.accentColor : .clear)`
+ After: rose selection plus today outline/bold and accessible event counts.
+
+3. **Medium: let month controls adapt.** ViewThatFits moves controls below the
+ month title when horizontal space is insufficient; buttons retain 44-point
+ minimum targets. At accessibility sizes weekday initials preserve grid space,
+ while every date's accessible label contains the complete day/date.
+
+4. **Medium: disclose exclusive all-day ends.** Inspection previously labelled
+ the stored exclusive date Ends, suggesting one extra day of duration. It now
+ says Ends before and explains the boundary, matching the editor. All-day
+ inspection omits the irrelevant time-zone row. Stored dates are unchanged.
+
+## EventEditor.swift
+
+**Medium: label the editable notes field for assistive technology.**
+
+Before: `TextEditor(text: $event.notes)`
+After: `TextEditor(text: $event.notes).accessibilityLabel("Notes")`
+
+Priority addressed: agenda reachability and layout stability, then date meaning,
+control adaptation and editor accessibility.
+
+## Method and validation
+
+Applied `swiftui-pro` design, accessibility and navigation references, plus
+`swiftui-native-component-design` semantics/control/accessibility guidance.
+Reviewed actual SwiftUI renders on Mac, iPhone and iPad; checked light mode and
+accessibility-large Dynamic Type in dark mode on iPhone. Real forms/navigation
+are exercised through the isolated authenticated Go fixture and XCTest.
+No backend, auth, model retrieval or permission behavior changed.
+
+Independent reviewer: `/root/canary_release_review`, dispatched as `gpt-6-astra`
+with high reasoning; runtime identity not independently verified. Screenshots
+and source corrections were reviewed independently. Full VoiceOver walkthrough,
+localization and every accessibility size remain hands-on acceptance work.
+
+## Menu-bar agenda refinement · 13 September
+
+Pinned the compact month and selected-day heading above a dedicated scrolling
+agenda. Day selection updates the existing observed model immediately and resets
+the agenda scroll position. The header shows the selected day's event count
+only after a successful load. Previously the month and events shared one scroll
+region, hiding the agenda and making day selection appear ineffective.
+
+Mac quick-add now uses a plain native multiline field inside a padded surface
+with a thin inset focus outline, avoiding the rounded field's clipped outer
+focus ring. Expanded quick-add remains scrollable; panel height is bounded by
+the screen's available height while retaining space for the day agenda.
+
+Validation: Mac build and signature verification; authenticated fixture renders
+for a timed day, the following all-day event, and an empty day. The optional
+NATIVE_REVIEW_DAY_SEQUENCE=1 review harness changes the same published selection
+used by the month buttons, asserts each result and captures the live view without
+reloading. This verifies reactive rendering, not physical mouse input.
+Artifacts: /tmp/calendar-menu-dynamic.png, its .all-day.png and .empty.png suffixes.
diff --git a/native/apple/Package.resolved b/native/apple/Package.resolved
new file mode 100644
index 0000000..cf50d40
--- /dev/null
+++ b/native/apple/Package.resolved
@@ -0,0 +1,22 @@
+{
+ "pins" : [
+ {
+ "identity" : "native",
+ "kind" : "remoteSourceControl",
+ "location" : "https://amadan.net/rastrillo/native",
+ "state" : {
+ "revision" : "229525909e1d6ac3b6a353d1c4d022798b0e9a24"
+ }
+ },
+ {
+ "identity" : "sparkle",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/sparkle-project/Sparkle",
+ "state" : {
+ "revision" : "ac2def288cbff5cfc7df3ffef6abdf45b72bcb0a",
+ "version" : "2.9.6"
+ }
+ }
+ ],
+ "version" : 2
+}
diff --git a/native/apple/Package.swift b/native/apple/Package.swift
new file mode 100644
index 0000000..5c2847e
--- /dev/null
+++ b/native/apple/Package.swift
@@ -0,0 +1,15 @@
+// swift-tools-version: 5.9
+import PackageDescription
+import Foundation
+
+let sharedNative: Package.Dependency = ProcessInfo.processInfo.environment["OFICINA_NATIVE_PACKAGE_PATH"].map { .package(name: "native", path: $0) }
+ ?? .package(url: "https://amadan.net/rastrillo/native", revision: "229525909e1d6ac3b6a353d1c4d022798b0e9a24")
+
+let package = Package(name: "CalendarNative", platforms: [.macOS(.v14), .iOS(.v17)],
+ products: [.library(name: "CalendarKit", targets: ["CalendarKit"]), .executable(name: "CalendarMac", targets: ["CalendarMac"])],
+ dependencies: [sharedNative],
+ 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: [.product(name: "RastrilloMacUpdates", package: "native", condition: .when(platforms: [.macOS])), "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..78368d4
--- /dev/null
+++ b/native/apple/Sources/CalendarApp/CalendarApp.swift
@@ -0,0 +1,50 @@
+#if os(macOS) && canImport(RastrilloMacUpdates)
+import RastrilloMacUpdates
+#endif
+import CalendarKit
+import RastrilloApple
+import SwiftUI
+#if os(macOS)
+import AppKit
+#endif
+
+@main struct CalendarApp: App {
+ #if os(macOS) && canImport(RastrilloMacUpdates)
+ @State private var updater = NativeUpdater()
+ #endif
+ @StateObject private var connection = NativeConnection(defaultServer: "https://calendar.oficina.works")
+ @AppStorage("calendar.showMenuBar") private var showMenuBar = true
+ @State private var model = CalendarModel()
+ var body: some Scene {
+ WindowGroup("Calendar", id: "calendar") {
+ Group {
+ if let client = connection.client {
+ CalendarWorkspace(client: client, model: model, accounts: { model.clear(); connection.showAccounts(); model = CalendarModel() }, signOut: { model.clear(); connection.signOut(); model = CalendarModel() })
+ } else {
+ NativeAccountsView("Oficina Calendar", systemImage: "calendar", connection: connection)
+ }
+ }.id(connection.activeAccountID).onOpenURL(perform: connection.handleOpenURL).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)
+ #if canImport(RastrilloMacUpdates)
+ .commands { NativeUpdateCommands(updater: updater) }
+ #endif
+ #endif
+ #if os(macOS)
+ Settings { CalendarSettingsView() }
+ // MenuBarExtra can echo its current visibility; avoid a UserDefaults update loop.
+ MenuBarExtra("Oficina Calendar", systemImage: "calendar", isInserted: Binding(get: { showMenuBar }, set: { if showMenuBar != $0 { showMenuBar = $0 } })) {
+ if let client = connection.client {
+ CalendarMenuView(client: client, model: model).tint(Color(red: 0.78, green: 0.22, blue: 0.29))
+ } else {
+ NativeAccountsView("Oficina Calendar", systemImage: "calendar", connection: connection)
+ .frame(width: 380, height: 480)
+ }
+ }.menuBarExtraStyle(.window)
+ #endif
+ }
+}
diff --git a/native/apple/Sources/CalendarKit/CalendarMenuView.swift b/native/apple/Sources/CalendarKit/CalendarMenuView.swift
new file mode 100644
index 0000000..29048fa
--- /dev/null
+++ b/native/apple/Sources/CalendarKit/CalendarMenuView.swift
@@ -0,0 +1,79 @@
+#if os(macOS)
+import AppKit
+import RastrilloApple
+import SwiftUI
+
+public struct CalendarMenuView: View {
+ public let client: NativeHTTP
+ @ObservedObject var model: CalendarModel
+ @Environment(\.openWindow) private var openWindow
+ @Environment(\.openSettings) private var openSettings
+ public init(client: NativeHTTP, model: CalendarModel) { self.client = client; self.model = model }
+ private var quickExpanded: Bool { !model.quickText.isEmpty || !model.quickSavedNotice.isEmpty }
+ private var panelHeight: CGFloat { min(quickExpanded ? 760 : 640, (NSScreen.main?.visibleFrame.height ?? 800) - 16) }
+ private var quickHeight: CGFloat { quickExpanded ? min(220, max(84, panelHeight - 530)) : 84 }
+ public var body: some View {
+ VStack(spacing: 0) {
+ HStack {
+ Text(model.calendars.first(where: { $0.id == model.selectedCalendar })?.name ?? "All calendars").font(.headline)
+ Spacer()
+ Button("Open Calendar", systemImage: "arrow.up.forward.app") { openWindow(id: "calendar"); NSApplication.shared.activate(ignoringOtherApps: true) }.labelStyle(.iconOnly)
+ Button("Settings", systemImage: "gear") { openSettings(); NSApplication.shared.activate(ignoringOtherApps: true) }.labelStyle(.iconOnly)
+ }.padding()
+ CalendarMonthView(model: model).controlSize(.small).padding(.horizontal)
+ Divider()
+ HStack {
+ Text(model.display(model.date, template: "EEEE d MMMM")).font(.headline)
+ Spacer()
+ if !model.loading && model.error.isEmpty {
+ Text("\(model.dayEvents(model.date).count)").foregroundStyle(.secondary)
+ .accessibilityLabel("\(model.dayEvents(model.date).count) events")
+ }
+ }.padding(.horizontal).padding(.vertical, 10)
+ ScrollViewReader { scroll in
+ ScrollView {
+ VStack(alignment: .leading, spacing: 10) {
+ Color.clear.frame(height: 0).id("agendaTop")
+ if model.loading { ProgressView("Loading events…") }
+ if !model.error.isEmpty {
+ Text(model.error).foregroundStyle(.secondary)
+ Button("Try again") { Task { await model.load(client) } }
+ } else if model.dayEvents(model.date).isEmpty && !model.loading {
+ Text("No events on this day").foregroundStyle(.secondary)
+ }
+ ForEach(model.dayEvents(model.date), id: \.identity) { event in
+ HStack(alignment: .top, spacing: 10) {
+ RoundedRectangle(cornerRadius: 2).fill(Color.accentColor).frame(width: 3)
+ VStack(alignment: .leading, spacing: 4) {
+ Text(event.title).font(.headline).strikethrough(event.status == "cancelled")
+ Text(event.all_day ? "All day" : model.display(Date(timeIntervalSince1970: Double(event.start)), template: "jm"))
+ Text(event.calendar_name).font(.caption).foregroundStyle(.secondary)
+ if event.status == "cancelled" { Label("Cancelled", systemImage: "xmark.circle") }
+ if event.status == "tentative" { Label("Tentative", systemImage: "questionmark.circle") }
+ }
+ Spacer(minLength: 0)
+ }.fixedSize(horizontal: false, vertical: true)
+ .accessibilityElement(children: .combine)
+ Divider()
+ }
+ }.frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal)
+ }.accessibilityIdentifier("menuDayAgenda")
+ .onChange(of: model.date) { _, _ in scroll.scrollTo("agendaTop", anchor: .top) }
+ }.frame(minHeight: 100)
+ Divider().padding(.vertical, 8)
+ QuickAddView(client: client, model: model).frame(height: quickHeight).padding(.horizontal)
+ HStack {
+ Text(model.zone).font(.caption).foregroundStyle(.secondary)
+ Spacer()
+ Button("Quit Calendar") { NSApplication.shared.terminate(nil) }
+ }.padding()
+ }.frame(width: 380, height: panelHeight)
+ .onAppear { Task { await model.load(client) } }
+ .onChange(of: model.monthStart) { _, _ in Task { await model.load(client) } }
+ .onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in
+ if model.zone != CalendarModel.preferredZone { Task { await model.load(client) } }
+ }
+ .onReceive(NotificationCenter.default.publisher(for: .NSSystemTimeZoneDidChange)) { _ in Task { await model.load(client) } }
+ }
+}
+#endif
diff --git a/native/apple/Sources/CalendarKit/CalendarModel.swift b/native/apple/Sources/CalendarKit/CalendarModel.swift
new file mode 100644
index 0000000..4bfc9b5
--- /dev/null
+++ b/native/apple/Sources/CalendarKit/CalendarModel.swift
@@ -0,0 +1,120 @@
+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 = ""
+ @Published public private(set) var notice = ""
+ private var weekStart = 1
+ private var defaultCalendar: Int64 = 0
+ private var generation = UUID()
+ private var active = true
+ @Published var quickText = ""
+ @Published var quickSavedNotice = ""
+ @Published private(set) var quickSaving = false
+ private let fixedZone: String?
+ public static var preferredZone: String {
+ let override = UserDefaults.standard.string(forKey: "calendar.timeZoneOverride") ?? ""
+ return TimeZone(identifier: override)?.identifier ?? TimeZone.current.identifier
+ }
+ public init(zone: String? = nil, date: Date = Date()) {
+ fixedZone = zone; self.zone = zone ?? Self.preferredZone; 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 clear() {
+ active = false; quickText = ""; quickSavedNotice = ""; quickSaving = false
+ generation = UUID(); calendars = []; events = []; notice = ""; error = ""; loading = false
+ selectedCalendar = nil; date = Date(); zone = fixedZone ?? Self.preferredZone
+ }
+ 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 {
+ guard active else { return }
+ zone = fixedZone ?? Self.preferredZone
+ 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 }
+ 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)&zone=\(zone.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? zone)")
+ 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 {
+ guard active else { throw CancellationError() }
+ let saved: SavedEvent = try await client.request(event.id == 0 ? "/api/native/events" : "/api/native/events/\(event.id)", method: "POST", body: JSONEncoder().encode(event))
+ guard active else { throw CancellationError() }
+ await load(client)
+ guard active else { throw CancellationError() }
+ notice = saved.warning ?? ""
+ }
+ func saveQuick(_ preview: QuickPreview, client: NativeHTTP) async throws {
+ guard active, !quickSaving else { throw CancellationError() }
+ quickSaving = true
+ defer { if active { quickSaving = false } }
+ let result: SavedEvent = try await client.request("/api/native/quick", method: "POST", body: JSONEncoder().encode(preview.draft))
+ guard active else { throw CancellationError() }
+ quickText = ""
+ quickSavedNotice = result.warning ?? "Added \(preview.draft.title)."
+ notice = quickSavedNotice
+ if let date = parse(preview.draft.start_local, allDay: preview.draft.all_day, zone: preview.draft.tz) { self.date = date }
+ 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..b635e4c
--- /dev/null
+++ b/native/apple/Sources/CalendarKit/CalendarModels.swift
@@ -0,0 +1,49 @@
+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 status = "confirmed"
+ 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; let warning: String? }
diff --git a/native/apple/Sources/CalendarKit/CalendarMonthView.swift b/native/apple/Sources/CalendarKit/CalendarMonthView.swift
new file mode 100644
index 0000000..4173768
--- /dev/null
+++ b/native/apple/Sources/CalendarKit/CalendarMonthView.swift
@@ -0,0 +1,73 @@
+import SwiftUI
+
+struct CalendarMonthView: View {
+ @ObservedObject var model: CalendarModel
+ @Environment(\.controlSize) private var controlSize
+ private var compact: Bool {
+ #if os(macOS)
+ controlSize == .small
+ #else
+ false
+ #endif
+ }
+ @Environment(\.dynamicTypeSize) private var typeSize
+ private let accent = Color(red: 0.78, green: 0.22, blue: 0.29)
+ var body: some View { VStack(spacing: 0) { monthHeader; monthGrid } }
+ private var monthHeader: some View {
+ ViewThatFits(in: .horizontal) {
+ HStack {
+ Text(model.display(model.monthStart, template: "MMMM yyyy")).font(.title2.bold())
+ Spacer()
+ monthControls
+ }
+ VStack(alignment: .leading) {
+ Text(model.display(model.monthStart, template: "MMMM yyyy")).font(.title2.bold())
+ monthControls
+ }
+ }.padding(.vertical, compact ? 4 : 16)
+ }
+ private var monthControls: some View {
+ HStack {
+ Button("Previous month", systemImage: "chevron.left") { move(-1) }.labelStyle(.iconOnly).frame(minWidth: compact ? 28 : 44, minHeight: compact ? 28 : 44)
+ Button("Today") { model.date = Date() }.frame(minHeight: compact ? 28 : 44)
+ Button("Next month", systemImage: "chevron.right") { move(1) }.labelStyle(.iconOnly).frame(minWidth: compact ? 28 : 44, minHeight: compact ? 28 : 44)
+ }.buttonStyle(.borderless)
+ }
+ private func move(_ amount: Int) { model.date = model.calendar.date(byAdding: .month, value: amount, to: model.date) ?? model.date }
+ private var monthGrid: some View {
+ Grid(horizontalSpacing: 4, verticalSpacing: 4) {
+ GridRow {
+ ForEach(Array(model.weekdayLabels.enumerated()), id: \.offset) { _, label in
+ Text(typeSize.isAccessibilitySize ? String(label.prefix(1)) : label)
+ .font(.caption).foregroundStyle(.secondary).accessibilityHidden(true)
+ }
+ }
+ ForEach(0..<6) { week in
+ GridRow {
+ ForEach(Array(model.days.dropFirst(week * 7).prefix(7)), id: \.self) { day in
+ dayButton(day)
+ }
+ }
+ }
+ }.padding(.bottom, 12)
+ }
+ private func dayButton(_ day: Date) -> some View {
+ let selected = model.calendar.isDate(day, inSameDayAs: model.date)
+ let today = model.calendar.isDate(day, inSameDayAs: Date())
+ let count = model.dayEvents(day).count
+ let current = model.calendar.isDate(day, equalTo: model.monthStart, toGranularity: .month)
+ return Button { model.date = day } label: {
+ VStack(spacing: 3) {
+ Text("\(model.calendar.component(.day, from: day))")
+ .font(.body.monospacedDigit()).bold(today).lineLimit(1).minimumScaleFactor(0.7)
+ Circle().fill(count == 0 ? .clear : selected ? .white : accent).frame(width: 4, height: 4)
+ }.frame(maxWidth: .infinity, minHeight: compact ? 28 : 44)
+ .foregroundStyle(selected ? .white : current ? Color.primary : Color.secondary)
+ .background(selected ? accent : .clear, in: RoundedRectangle(cornerRadius: 9))
+ .overlay { RoundedRectangle(cornerRadius: 9).strokeBorder(today ? accent : .clear, lineWidth: 2) }
+ }.buttonStyle(.plain)
+ .accessibilityLabel(model.display(day, template: "EEEE d MMMM yyyy"))
+ .accessibilityValue("\(today ? "Today. " : "")\(count) events")
+ .accessibilityAddTraits(selected ? .isSelected : [])
+ }
+}
diff --git a/native/apple/Sources/CalendarKit/CalendarSettingsView.swift b/native/apple/Sources/CalendarKit/CalendarSettingsView.swift
new file mode 100644
index 0000000..a0fad8b
--- /dev/null
+++ b/native/apple/Sources/CalendarKit/CalendarSettingsView.swift
@@ -0,0 +1,33 @@
+import SwiftUI
+
+public struct CalendarSettingsView: View {
+ @AppStorage("calendar.timeZoneOverride") private var override = ""
+ @AppStorage("calendar.showMenuBar") private var showMenuBar = true
+ public init() {}
+ public var body: some View {
+ Form {
+ #if os(macOS)
+ Section { Toggle("Show calendar in menu bar", isOn: $showMenuBar) }
+ #endif
+ Section {
+ Toggle("Use system time zone", isOn: Binding(
+ get: { override.isEmpty },
+ set: { override = $0 ? "" : TimeZone.current.identifier }))
+ if override.isEmpty {
+ LabeledContent("Time zone", value: TimeZone.current.identifier.replacingOccurrences(of: "_", with: " "))
+ } else {
+ Picker("Time zone", selection: $override) {
+ ForEach(Array(Set(TimeZone.knownTimeZoneIdentifiers + ["UTC", TimeZone.current.identifier])).sorted(), id: \.self) { zone in
+ Text(zone.replacingOccurrences(of: "_", with: " ")).tag(zone)
+ }
+ }
+ }
+ } footer: {
+ Text("Used for your agenda, new events and quick-add on this device. Existing events keep their original time zones.")
+ }
+ }.formStyle(.grouped).navigationTitle("Calendar settings")
+ #if os(macOS)
+ .frame(width: 460, height: 300)
+ #endif
+ }
+}
diff --git a/native/apple/Sources/CalendarKit/CalendarWorkspace.swift b/native/apple/Sources/CalendarKit/CalendarWorkspace.swift
new file mode 100644
index 0000000..9c1a218
--- /dev/null
+++ b/native/apple/Sources/CalendarKit/CalendarWorkspace.swift
@@ -0,0 +1,144 @@
+import RastrilloApple
+import SwiftUI
+
+@MainActor public struct CalendarWorkspace: View {
+ public let client: NativeHTTP
+ public let signOut: () -> Void
+ private let accounts: (() -> Void)?
+ @StateObject private var model: CalendarModel
+ @State private var quickAdding = false
+ @State private var settings = false
+ @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(\.dynamicTypeSize) private var typeSize
+ private let accent = Color(red: 0.78, green: 0.22, blue: 0.29)
+ public init(client: NativeHTTP, model: CalendarModel? = nil, accounts: (() -> Void)? = nil, signOut: @escaping () -> Void) { self.client = client; self.signOut = signOut; self.accounts = accounts; _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("Settings", systemImage: "gear") { settings = true }
+ Button("Sign out", action: signOut)
+ }
+ }.toolbar { if let accounts { ToolbarItem(placement: .navigation) { Button("Accounts", systemImage: "chevron.backward", action: accounts) } } }.navigationTitle("Calendar").navigationSplitViewColumnWidth(min: 220, ideal: 260, max: 340)
+ } detail: {
+ List {
+ Section {
+ CalendarMonthView(model: model).listRowSeparator(.hidden)
+ }
+ if !model.notice.isEmpty { Label(model.notice, systemImage: "info.circle").font(.callout).padding() }
+ 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 {
+ 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(calendarColor(event.calendar_id)).frame(width: 4)
+ VStack(alignment: .leading, spacing: 5) {
+ Text(event.title).font(.headline).foregroundStyle(.primary).strikethrough(event.status == "cancelled")
+ if event.status == "cancelled" { Label("Cancelled", systemImage: "xmark.circle").font(.caption).foregroundStyle(.secondary) }
+ if event.status == "tentative" { Label("Tentative", systemImage: "questionmark.circle").font(.caption).foregroundStyle(.secondary) }
+ 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(model.calendars.first(where: { $0.id == model.selectedCalendar })?.name ?? "Calendar")
+ #if os(iOS)
+ .navigationBarTitleDisplayMode(.inline)
+ #endif
+ .toolbar {
+ ToolbarItem { Button("Quick add", systemImage: "text.badge.plus") { quickAdding = true }.keyboardShortcut("k", modifiers: .command) }
+ 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 }
+ .onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in
+ if model.zone != CalendarModel.preferredZone { Task { await model.load(client) } }
+ }
+ .onReceive(NotificationCenter.default.publisher(for: .NSSystemTimeZoneDidChange)) { _ in Task { await model.load(client) } }
+ .sheet(isPresented: $quickAdding) {
+ NavigationStack {
+ QuickAddView(client: client, model: model)
+ .padding().navigationTitle("Quick add")
+ .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Done") { quickAdding = false }.disabled(model.quickSaving) } }
+ }
+ .interactiveDismissDisabled(model.quickSaving)
+ #if os(macOS)
+ .frame(width: 440, height: 540)
+ #endif
+ }
+ .sheet(isPresented: $settings) { NavigationStack { CalendarSettingsView().toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { settings = false } } } } }
+ .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) }
+ if event.status == "cancelled" { Section { Label("Cancelled", systemImage: "xmark.circle") } }
+ if event.status == "tentative" { Section { Label("Tentative", systemImage: "questionmark.circle") } }
+ 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(event.all_day ? "Ends before" : "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))
+ if event.all_day { Text("The end date is the first day after the event.").font(.callout).foregroundStyle(.secondary) }
+ else { 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 func calendarColor(_ id: Int64) -> Color {
+ guard let calendar = model.calendars.first(where: { $0.id == id }) else { return accent }
+ return Color(hue: Double(calendar.hue % 360) / 360, saturation: 0.6, brightness: 0.75)
+ }
+}
diff --git a/native/apple/Sources/CalendarKit/EventEditor.swift b/native/apple/Sources/CalendarKit/EventEditor.swift
new file mode 100644
index 0000000..dd3ab09
--- /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).accessibilityLabel("Notes") }
+ 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/CalendarKit/QuickAddView.swift b/native/apple/Sources/CalendarKit/QuickAddView.swift
new file mode 100644
index 0000000..1aef824
--- /dev/null
+++ b/native/apple/Sources/CalendarKit/QuickAddView.swift
@@ -0,0 +1,92 @@
+import RastrilloApple
+import SwiftUI
+
+struct QuickDraft: Codable {
+ let calendar_id: Int64
+ let title, location, start_local, end_local, tz: String
+ let all_day: Bool
+ let `repeat`: String
+ let attendees: [String]
+}
+struct QuickPreview: Decodable {
+ let draft: QuickDraft
+ let when, repeat_label, calendar_name: String
+ let assumptions: [String]
+}
+struct QuickAddView: View {
+ let client: NativeHTTP
+ @ObservedObject var model: CalendarModel
+ @State private var preview: QuickPreview?
+ @State private var error = ""
+ @State private var parsing = false
+ @FocusState private var focused: Bool
+ private var requestKey: String { model.zone + "\n" + model.quickText }
+ var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 12) {
+ TextField("Lunch tomorrow at 1pm for an hour", text: $model.quickText, axis: .vertical)
+ .lineLimit(2...4)
+ #if os(macOS)
+ .textFieldStyle(.plain)
+ .padding(10)
+ .background(.background, in: RoundedRectangle(cornerRadius: 8))
+ .overlay {
+ RoundedRectangle(cornerRadius: 8)
+ .strokeBorder(focused ? Color.accentColor : Color.secondary.opacity(0.3), lineWidth: 1)
+ }
+ #else
+ .textFieldStyle(.roundedBorder)
+ #endif
+ .accessibilityLabel("Describe an event").accessibilityIdentifier("quickAddText")
+ .focused($focused).disabled(model.quickSaving)
+ Text(model.zone.replacingOccurrences(of: "_", with: " ")).font(.footnote).foregroundStyle(.secondary)
+ if parsing { ProgressView("Understanding your event…") }
+ if let preview {
+ VStack(alignment: .leading, spacing: 8) {
+ Text(preview.draft.title).font(.headline)
+ Label(preview.when, systemImage: "clock")
+ Label(preview.calendar_name, systemImage: "calendar")
+ if !preview.draft.location.isEmpty { Label(preview.draft.location, systemImage: "mappin") }
+ if !preview.repeat_label.isEmpty { Label(preview.repeat_label, systemImage: "repeat") }
+ if !preview.draft.attendees.isEmpty {
+ Label(preview.draft.attendees.joined(separator: ", "), systemImage: "person.2")
+ Text("Attendees are recorded, not invited. No invitations are sent.").font(.footnote)
+ }
+ ForEach(preview.assumptions, id: \.self) { Text($0).font(.footnote).foregroundStyle(.secondary) }
+ }.accessibilityIdentifier("quickAddPreview")
+ Button(model.quickSaving ? "Adding…" : "Add event", systemImage: "plus") { save(preview) }
+ .buttonStyle(.borderedProminent).disabled(model.quickSaving || parsing)
+ .accessibilityIdentifier("quickAddSave").keyboardShortcut(.return, modifiers: .command)
+ }
+ if !error.isEmpty { Label(error, systemImage: "exclamationmark.circle").foregroundStyle(.red) }
+ if !model.quickSavedNotice.isEmpty { Label(model.quickSavedNotice, systemImage: "checkmark.circle").accessibilityIdentifier("quickAddSaved") }
+ }.frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .task(id: requestKey) {
+ preview = nil; error = ""
+ guard !model.quickText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { parsing = false; return }
+ parsing = true
+ let key = requestKey
+ do {
+ try await Task.sleep(for: .milliseconds(350))
+ let result: QuickPreview = try await client.request("/api/native/quick/parse", method: "POST",
+ body: JSONEncoder().encode(["text": model.quickText, "zone": model.zone]))
+ guard !Task.isCancelled, requestKey == key else { return }
+ preview = result; parsing = false
+ } catch {
+ guard !Task.isCancelled, requestKey == key else { return }
+ self.error = error.localizedDescription; parsing = false
+ }
+ }
+ .onChange(of: model.quickText) { _, text in preview = nil; if !text.isEmpty { model.quickSavedNotice = "" } }
+ .onChange(of: model.zone) { _, _ in preview = nil }
+ }
+ private func save(_ preview: QuickPreview) {
+ error = ""
+ Task {
+ do { try await model.saveQuick(preview, client: client); self.preview = nil }
+ catch is CancellationError { }
+ catch { self.error = error.localizedDescription }
+ }
+ }
+}
diff --git a/native/apple/Sources/CalendarReview/Review.swift b/native/apple/Sources/CalendarReview/Review.swift
new file mode 100644
index 0000000..32f754d
--- /dev/null
+++ b/native/apple/Sources/CalendarReview/Review.swift
@@ -0,0 +1,51 @@
+// 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 menu = ProcessInfo.processInfo.environment["NATIVE_REVIEW_MENU"] == "1"
+ let view = NSHostingView(rootView: Group {
+ if menu { CalendarMenuView(client: client, model: model) }
+ else { 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: menu ? 380 : 1100, height: menu ? 640 : 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))
+ func capture(_ output: String) throws {
+ let capture = Process()
+ capture.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture")
+ capture.arguments = ["-x", "-o", "-l", String(window.windowNumber), output]
+ try capture.run()
+ capture.waitUntilExit()
+ guard capture.terminationStatus == 0 else { fatalError("Window capture failed") }
+ }
+ try capture(input.output)
+ if menu && ProcessInfo.processInfo.environment["NATIVE_REVIEW_DAY_SEQUENCE"] == "1" {
+ // Exercise the same published selection updated by the month buttons,
+ // without reloading: timed day -> all-day event -> empty day.
+ guard model.dayEvents(model.date).contains(where: { !$0.all_day }) else { fatalError("Missing timed fixture") }
+ model.date = model.calendar.date(byAdding: .day, value: 1, to: model.date)!
+ try await Task.sleep(for: .seconds(1))
+ guard model.dayEvents(model.date).contains(where: { $0.all_day }) else { fatalError("Missing all-day fixture") }
+ try capture(input.output + ".all-day.png")
+ model.date = model.calendar.date(byAdding: .day, value: 1, to: model.date)!
+ try await Task.sleep(for: .seconds(1))
+ guard model.dayEvents(model.date).isEmpty else { fatalError("Empty day retained events") }
+ try capture(input.output + ".empty.png")
+ }
+ }
+}
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/apple/Tests/CalendarKitTests/SessionLifecycleTests.swift b/native/apple/Tests/CalendarKitTests/SessionLifecycleTests.swift
new file mode 100644
index 0000000..5118ac7
--- /dev/null
+++ b/native/apple/Tests/CalendarKitTests/SessionLifecycleTests.swift
@@ -0,0 +1,73 @@
+import XCTest
+import RastrilloApple
+@testable import CalendarKit
+
+final class SessionLifecycleTests: XCTestCase {
+ // Independent reviewer mutation, 2026-09-13: remove saveQuick's guard after
+ // the POST response. Four assertions fail (cancellation/date/notice/result);
+ // restored source passes all five Swift tests.
+ @MainActor func testLateSavesCannotReloadClearedSession() async throws {
+ for quick in [false, true] {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/python3")
+ process.arguments = ["-u", "-c", """
+import http.server, threading, json
+started=threading.Event(); release=threading.Event(); reads=0
+class Handler(http.server.BaseHTTPRequestHandler):
+ def log_message(self,*args): pass
+ def reply(self,value):
+ data=json.dumps(value).encode(); self.send_response(200); self.send_header('Content-Length',str(len(data))); self.end_headers(); self.wfile.write(data)
+ def do_POST(self):
+ self.rfile.read(int(self.headers.get('Content-Length','0'))); started.set(); release.wait(10); self.reply({'id':1,'warning':'Event saved. Reminder unavailable.'})
+ def do_GET(self):
+ global reads
+ if self.path=='/wait': started.wait(10); self.reply({}); return
+ if self.path=='/release': release.set(); self.reply({}); return
+ if self.path=='/counts': self.reply({'reads':reads}); return
+ reads+=1; self.reply({})
+server=http.server.ThreadingHTTPServer(('127.0.0.1',0),Handler)
+print(server.server_port,flush=True); server.serve_forever()
+"""]
+ let output = Pipe(); process.standardOutput = output; process.standardError = FileHandle.nullDevice
+ try process.run()
+ defer { process.terminate() }
+ let data = output.fileHandleForReading.availableData
+ let port = try XCTUnwrap(String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines))
+ let client = NativeHTTP(origin: URL(string: "http://127.0.0.1:\(port)")!)
+ let model = CalendarModel(zone: "Europe/Dublin")
+ let draft = QuickDraft(calendar_id: 1, title: "Fixture", location: "", start_local: "2030-10-25T09:00", end_local: "2030-10-25T10:00", tz: "Europe/Dublin", all_day: false, repeat: "none", attendees: [])
+ let preview = QuickPreview(draft: draft, when: "Fixture time", repeat_label: "", calendar_name: "Personal", assumptions: [])
+ let task = Task {
+ if quick { try await model.saveQuick(preview, client: client) }
+ else { try await model.save(CalendarEvent(), client: client) }
+ }
+ _ = try await client.response("/wait")
+ model.clear()
+ let clearedDate = model.date
+ _ = try await client.response("/release")
+ do { try await task.value; XCTFail("Old save completed against cleared session") }
+ catch is CancellationError { }
+ await model.load(client) // Even queued refresh callbacks cannot revive this model.
+ struct Counts: Decodable { let reads: Int }
+ let counts: Counts = try await client.request("/counts")
+ XCTAssertEqual(counts.reads, 0)
+ XCTAssertEqual(model.date, clearedDate)
+ XCTAssertTrue(model.events.isEmpty)
+ XCTAssertTrue(model.calendars.isEmpty)
+ XCTAssertTrue(model.notice.isEmpty)
+ XCTAssertTrue(model.quickSavedNotice.isEmpty)
+ XCTAssertFalse(model.quickSaving)
+ }
+ }
+ @MainActor func testSystemTimeZoneDefaultAndOverride() {
+ let key = "calendar.timeZoneOverride"
+ let prior = UserDefaults.standard.object(forKey: key)
+ defer { if let prior { UserDefaults.standard.set(prior, forKey: key) } else { UserDefaults.standard.removeObject(forKey: key) } }
+ UserDefaults.standard.removeObject(forKey: key)
+ XCTAssertEqual(CalendarModel().zone, TimeZone.current.identifier)
+ UserDefaults.standard.set("Pacific/Kiritimati", forKey: key)
+ XCTAssertEqual(CalendarModel().newEvent().tz, "Pacific/Kiritimati")
+ UserDefaults.standard.set("", forKey: key)
+ XCTAssertEqual(CalendarModel().zone, TimeZone.current.identifier)
+ }
+}
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..bc4e656
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/Info.plist b/native/ios/Info.plist
new file mode 100644
index 0000000..dee8168
--- /dev/null
+++ b/native/ios/Info.plist
@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>CFBundleDevelopmentRegion</key>
+ <string>$(DEVELOPMENT_LANGUAGE)</string>
+ <key>CFBundleExecutable</key>
+ <string>$(EXECUTABLE_NAME)</string>
+ <key>CFBundleIdentifier</key>
+ <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
+ <key>CFBundleInfoDictionaryVersion</key>
+ <string>6.0</string>
+ <key>CFBundleName</key>
+ <string>$(PRODUCT_NAME)</string>
+ <key>CFBundlePackageType</key>
+ <string>APPL</string>
+ <key>CFBundleShortVersionString</key>
+ <string>$(MARKETING_VERSION)</string>
+ <key>CFBundleURLTypes</key>
+ <array>
+ <dict>
+ <key>CFBundleTypeRole</key>
+ <string>Editor</string>
+ <key>CFBundleURLName</key>
+ <string>works.oficina.calendar.signin</string>
+ <key>CFBundleURLSchemes</key>
+ <array>
+ <string>works.oficina.calendar</string>
+ </array>
+ </dict>
+ </array>
+ <key>CFBundleVersion</key>
+ <string>$(CURRENT_PROJECT_VERSION)</string>
+</dict>
+</plist>
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/UITests/NativeUITests.swift b/native/ios/UITests/NativeUITests.swift
new file mode 100644
index 0000000..45dcd10
--- /dev/null
+++ b/native/ios/UITests/NativeUITests.swift
@@ -0,0 +1,34 @@
+import XCTest
+@MainActor final class NativeUITests: XCTestCase {
+ func testNativeFormAndNavigation() {
+ let app=XCUIApplication();app.launch()
+ XCTAssertTrue(app.buttons["New event"].waitForExistence(timeout: 10))
+
+ app.buttons["New event"].tap()
+ let title=app.textFields["eventTitle"]
+ XCTAssertTrue(title.waitForExistence(timeout: 5)); title.tap(); title.typeText("Native UI event")
+ app.buttons["Save"].tap()
+ XCTAssertTrue(app.staticTexts["Native UI event"].firstMatch.waitForExistence(timeout: 10))
+ app.staticTexts["Native UI event"].firstMatch.tap()
+ XCTAssertTrue(app.buttons["Edit event"].waitForExistence(timeout: 5))
+ app.buttons["Edit event"].tap()
+ XCTAssertTrue(app.textFields["eventTitle"].waitForExistence(timeout: 5))
+ app.buttons["Cancel"].tap()
+
+ let capture=XCTAttachment(screenshot: app.screenshot());capture.lifetime = .keepAlways;add(capture)
+ }
+ func testQuickAddUsesPreview() {
+ let app = XCUIApplication(); app.launch()
+ XCTAssertTrue(app.buttons["Quick add"].waitForExistence(timeout: 10))
+ app.buttons["Quick add"].tap()
+ let input = app.descendants(matching: .any)["quickAddText"].firstMatch
+ XCTAssertTrue(input.waitForExistence(timeout: 5)); input.tap()
+ input.typeText("Native quick lunch tomorrow at 1pm for an hour")
+ let save = app.buttons["quickAddSave"]
+ XCTAssertTrue(save.waitForExistence(timeout: 10)); save.tap()
+ XCTAssertTrue(app.descendants(matching: .any)["quickAddSaved"].firstMatch.waitForExistence(timeout: 10))
+ app.buttons["Done"].tap()
+ XCTAssertTrue(app.staticTexts["Native quick lunch"].firstMatch.waitForExistence(timeout: 10))
+ }
+
+}
diff --git a/native/ios/project.yml b/native/ios/project.yml
new file mode 100644
index 0000000..247ba91
--- /dev/null
+++ b/native/ios/project.yml
@@ -0,0 +1,90 @@
+name: CalendarPhone
+options:
+ bundleIdPrefix: net.amadan.oficina
+ deploymentTarget:
+ iOS: '17.0'
+packages:
+ CalendarNative:
+ path: ../apple
+ RastrilloNative:
+ url: https://amadan.net/rastrillo/native
+ revision: 229525909e1d6ac3b6a353d1c4d022798b0e9a24
+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
+ - path: ../PrivacyInfo.xcprivacy
+ buildPhase: resources
+ dependencies:
+ - package: CalendarNative
+ product: CalendarKit
+ - package: RastrilloNative
+ product: RastrilloApple
+ settings:
+ base:
+ ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
+ PROVISIONING_PROFILE_SPECIFIER: $(OFICINA_PROVISIONING_PROFILE)
+ PRODUCT_BUNDLE_IDENTIFIER: works.oficina.calendar
+ TARGETED_DEVICE_FAMILY: 1,2
+ GENERATE_INFOPLIST_FILE: true
+ INFOPLIST_KEY_CFBundleDisplayName: Oficina Calendar
+ INFOPLIST_KEY_UILaunchScreen_Generation: true
+ INFOPLIST_KEY_UISupportedInterfaceOrientations: UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight
+ UIInterfaceOrientationPortraitUpsideDown
+ info:
+ path: Info.plist
+ properties:
+ CFBundleURLTypes:
+ - CFBundleURLName: works.oficina.calendar.signin
+ CFBundleURLSchemes:
+ - works.oficina.calendar
+ CFBundleTypeRole: Editor
+ CFBundleShortVersionString: $(MARKETING_VERSION)
+ CFBundleVersion: $(CURRENT_PROJECT_VERSION)
+ 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: true
+ INFOPLIST_KEY_UILaunchScreen_Generation: true
+ CalendarPhoneUITests:
+ type: bundle.ui-testing
+ platform: iOS
+ sources:
+ - UITests
+ dependencies:
+ - target: CalendarReviewPhone
+ settings:
+ base:
+ GENERATE_INFOPLIST_FILE: true
+ TEST_TARGET_NAME: CalendarReviewPhone
+schemes:
+ CalendarReviewPhone:
+ build:
+ targets:
+ CalendarReviewPhone: all
+ test:
+ targets:
+ - CalendarPhoneUITests
diff --git a/native/mac-updates.json b/native/mac-updates.json
new file mode 100644
index 0000000..24abb3c
--- /dev/null
+++ b/native/mac-updates.json
@@ -0,0 +1,6 @@
+{
+ "bundle_id": "net.amadan.oficina.calendar",
+ "feed_url": "https://raw.amadan.net/oficina/native-releases/main/calendar/appcast.xml",
+ "public_ed_key": "/FxO7cZnM7P5c9ZrUPUJvpCnR65sNmrzte+I5vHarE8=",
+ "keychain_account": "oficina"
+}
diff --git a/native/macos/AppIcon.icns b/native/macos/AppIcon.icns
new file mode 100644
index 0000000..b3444c8
Binary files /dev/null and b/native/macos/AppIcon.icns differ
diff --git a/native/macos/AppStore.entitlements b/native/macos/AppStore.entitlements
new file mode 100644
index 0000000..a046386
--- /dev/null
+++ b/native/macos/AppStore.entitlements
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>com.apple.security.app-sandbox</key>
+ <true/>
+ <key>com.apple.security.files.user-selected.read-write</key>
+ <true/>
+ <key>com.apple.security.network.client</key>
+ <true/>
+</dict>
+</plist>
diff --git a/native/macos/CalendarMacStore.xcodeproj/project.pbxproj b/native/macos/CalendarMacStore.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..7d85cf2
--- /dev/null
+++ b/native/macos/CalendarMacStore.xcodeproj/project.pbxproj
@@ -0,0 +1,387 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 77;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 2D95CDF84B7005707C55736F /* LICENSE.lucide in Resources */ = {isa = PBXBuildFile; fileRef = 702DF2FC8DE8845B9B541115 /* LICENSE.lucide */; };
+ 7BA2F172EA0C355E6F691264 /* CalendarKit in Frameworks */ = {isa = PBXBuildFile; productRef = 4EDF33588271A9ADC528E921 /* CalendarKit */; };
+ 8201D2952AB712E6AFFCE3D6 /* AppIcon.icns in Resources */ = {isa = PBXBuildFile; fileRef = 8556A11F588E3C65A9FB5EC2 /* AppIcon.icns */; };
+ 9FEB6A347CE93114EBE1B25F /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D427F214497D3A177541301D /* PrivacyInfo.xcprivacy */; };
+ C205939F17DD960B885DABCC /* CalendarApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B4080DD2BE7335C50069D17 /* CalendarApp.swift */; };
+ CEE5FCDA18FC5D92FC15F0CF /* RastrilloApple in Frameworks */ = {isa = PBXBuildFile; productRef = 5BE35D9773A0E6F90ECB6A70 /* RastrilloApple */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+ 285EE7D270909E9487B8EB95 /* CalendarMacStore.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CalendarMacStore.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 702DF2FC8DE8845B9B541115 /* LICENSE.lucide */ = {isa = PBXFileReference; path = LICENSE.lucide; sourceTree = "<group>"; };
+ 8556A11F588E3C65A9FB5EC2 /* AppIcon.icns */ = {isa = PBXFileReference; lastKnownFileType = image.icns; path = AppIcon.icns; sourceTree = "<group>"; };
+ 931951AE9216CB064CBA4D1A /* apple */ = {isa = PBXFileReference; lastKnownFileType = folder; name = apple; path = ../apple; sourceTree = SOURCE_ROOT; };
+ 9B4080DD2BE7335C50069D17 /* CalendarApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarApp.swift; sourceTree = "<group>"; };
+ D427F214497D3A177541301D /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 57D5E0DEC904EF3665B27279 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 7BA2F172EA0C355E6F691264 /* CalendarKit in Frameworks */,
+ CEE5FCDA18FC5D92FC15F0CF /* RastrilloApple in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 7FC41114F6CB8D866F7BE7E8 /* CalendarApp */ = {
+ isa = PBXGroup;
+ children = (
+ 9B4080DD2BE7335C50069D17 /* CalendarApp.swift */,
+ );
+ name = CalendarApp;
+ path = ../apple/Sources/CalendarApp;
+ sourceTree = "<group>";
+ };
+ 85704B022FB075C839F66EC3 /* Packages */ = {
+ isa = PBXGroup;
+ children = (
+ 931951AE9216CB064CBA4D1A /* apple */,
+ );
+ name = Packages;
+ sourceTree = "<group>";
+ };
+ 86632CBF9711A44C1EABC8B9 /* native */ = {
+ isa = PBXGroup;
+ children = (
+ 702DF2FC8DE8845B9B541115 /* LICENSE.lucide */,
+ D427F214497D3A177541301D /* PrivacyInfo.xcprivacy */,
+ );
+ name = native;
+ path = ..;
+ sourceTree = "<group>";
+ };
+ 96516FBDB8D54FDC6FDD744B /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 285EE7D270909E9487B8EB95 /* CalendarMacStore.app */,
+ );
+ name = Products;
+ sourceTree = "<group>";
+ };
+ C68A5CA810292318B1EF834B = {
+ isa = PBXGroup;
+ children = (
+ 8556A11F588E3C65A9FB5EC2 /* AppIcon.icns */,
+ 7FC41114F6CB8D866F7BE7E8 /* CalendarApp */,
+ 86632CBF9711A44C1EABC8B9 /* native */,
+ 85704B022FB075C839F66EC3 /* Packages */,
+ 96516FBDB8D54FDC6FDD744B /* Products */,
+ );
+ sourceTree = "<group>";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 9A1E714C2EDCB0D472675949 /* CalendarMacStore */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 8185C2FB17FC3A48168C8D3F /* Build configuration list for PBXNativeTarget "CalendarMacStore" */;
+ buildPhases = (
+ 9F86D9E2D26F7F946F378421 /* Sources */,
+ E9F5786ADB1113404E786011 /* Resources */,
+ 57D5E0DEC904EF3665B27279 /* Frameworks */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = CalendarMacStore;
+ packageProductDependencies = (
+ 4EDF33588271A9ADC528E921 /* CalendarKit */,
+ 5BE35D9773A0E6F90ECB6A70 /* RastrilloApple */,
+ );
+ productName = CalendarMacStore;
+ productReference = 285EE7D270909E9487B8EB95 /* CalendarMacStore.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 042A87CB145F34D136F57C77 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = YES;
+ LastUpgradeCheck = 1430;
+ TargetAttributes = {
+ 9A1E714C2EDCB0D472675949 = {
+ ProvisioningStyle = Automatic;
+ };
+ };
+ };
+ buildConfigurationList = BCE83814FD94CCCE4028B64D /* Build configuration list for PBXProject "CalendarMacStore" */;
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ Base,
+ en,
+ );
+ mainGroup = C68A5CA810292318B1EF834B;
+ minimizedProjectReferenceProxies = 1;
+ packageReferences = (
+ 922B089CE57D6BBF4A3D3703 /* XCRemoteSwiftPackageReference "native" */,
+ 9ADA6F51B2DBB32445C690E4 /* XCLocalSwiftPackageReference "../apple" */,
+ );
+ preferredProjectObjectVersion = 77;
+ productRefGroup = 96516FBDB8D54FDC6FDD744B /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 9A1E714C2EDCB0D472675949 /* CalendarMacStore */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ E9F5786ADB1113404E786011 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 8201D2952AB712E6AFFCE3D6 /* AppIcon.icns in Resources */,
+ 2D95CDF84B7005707C55736F /* LICENSE.lucide in Resources */,
+ 9FEB6A347CE93114EBE1B25F /* PrivacyInfo.xcprivacy in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 9F86D9E2D26F7F946F378421 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ C205939F17DD960B885DABCC /* CalendarApp.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ 193A754A4585D070CC4B6894 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_ENTITLEMENTS = AppStore.entitlements;
+ COMBINE_HIDPI_IMAGES = YES;
+ INFOPLIST_FILE = Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = works.oficina.calendar;
+ PROVISIONING_PROFILE_SPECIFIER = "$(OFICINA_PROVISIONING_PROFILE)";
+ SDKROOT = macosx;
+ };
+ name = Debug;
+ };
+ 48E6F4551835223021A8F40C /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_ENTITLEMENTS = AppStore.entitlements;
+ COMBINE_HIDPI_IMAGES = YES;
+ INFOPLIST_FILE = Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = works.oficina.calendar;
+ PROVISIONING_PROFILE_SPECIFIER = "$(OFICINA_PROVISIONING_PROFILE)";
+ SDKROOT = macosx;
+ };
+ name = Release;
+ };
+ 87245616253B4EF8ADE88648 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ CODE_SIGN_STYLE = Automatic;
+ COPY_PHASE_STRIP = NO;
+ CURRENT_PROJECT_VERSION = 3;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_HARDENED_RUNTIME = YES;
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
+ MARKETING_VERSION = 0.1.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = macosx;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ SWIFT_VERSION = 5.0;
+ };
+ name = Release;
+ };
+ D81624174305E0EF72F5E143 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ CODE_SIGN_STYLE = Automatic;
+ COPY_PHASE_STRIP = NO;
+ CURRENT_PROJECT_VERSION = 3;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_HARDENED_RUNTIME = YES;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "$(inherited)",
+ "DEBUG=1",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
+ MARKETING_VERSION = 0.1.0;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = macosx;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ };
+ name = Debug;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 8185C2FB17FC3A48168C8D3F /* Build configuration list for PBXNativeTarget "CalendarMacStore" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 193A754A4585D070CC4B6894 /* Debug */,
+ 48E6F4551835223021A8F40C /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Debug;
+ };
+ BCE83814FD94CCCE4028B64D /* Build configuration list for PBXProject "CalendarMacStore" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ D81624174305E0EF72F5E143 /* Debug */,
+ 87245616253B4EF8ADE88648 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Debug;
+ };
+/* End XCConfigurationList section */
+
+/* Begin XCLocalSwiftPackageReference section */
+ 9ADA6F51B2DBB32445C690E4 /* XCLocalSwiftPackageReference "../apple" */ = {
+ isa = XCLocalSwiftPackageReference;
+ relativePath = ../apple;
+ };
+/* End XCLocalSwiftPackageReference section */
+
+/* Begin XCRemoteSwiftPackageReference section */
+ 922B089CE57D6BBF4A3D3703 /* XCRemoteSwiftPackageReference "native" */ = {
+ isa = XCRemoteSwiftPackageReference;
+ repositoryURL = "https://amadan.net/rastrillo/native";
+ requirement = {
+ kind = revision;
+ revision = 229525909e1d6ac3b6a353d1c4d022798b0e9a24;
+ };
+ };
+/* End XCRemoteSwiftPackageReference section */
+
+/* Begin XCSwiftPackageProductDependency section */
+ 4EDF33588271A9ADC528E921 /* CalendarKit */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = CalendarKit;
+ };
+ 5BE35D9773A0E6F90ECB6A70 /* RastrilloApple */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = 922B089CE57D6BBF4A3D3703 /* XCRemoteSwiftPackageReference "native" */;
+ productName = RastrilloApple;
+ };
+/* End XCSwiftPackageProductDependency section */
+ };
+ rootObject = 042A87CB145F34D136F57C77 /* Project object */;
+}
diff --git a/native/macos/CalendarMacStore.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/native/macos/CalendarMacStore.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/native/macos/CalendarMacStore.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Workspace
+ version = "1.0">
+ <FileRef
+ location = "self:">
+ </FileRef>
+</Workspace>
diff --git a/native/macos/Info.plist b/native/macos/Info.plist
new file mode 100644
index 0000000..dd46943
--- /dev/null
+++ b/native/macos/Info.plist
@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>CFBundleDevelopmentRegion</key>
+ <string>$(DEVELOPMENT_LANGUAGE)</string>
+ <key>CFBundleDisplayName</key>
+ <string>Oficina Calendar</string>
+ <key>CFBundleExecutable</key>
+ <string>$(EXECUTABLE_NAME)</string>
+ <key>CFBundleIconFile</key>
+ <string>AppIcon</string>
+ <key>CFBundleIdentifier</key>
+ <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
+ <key>CFBundleInfoDictionaryVersion</key>
+ <string>6.0</string>
+ <key>CFBundleName</key>
+ <string>Oficina Calendar</string>
+ <key>CFBundlePackageType</key>
+ <string>APPL</string>
+ <key>CFBundleShortVersionString</key>
+ <string>$(MARKETING_VERSION)</string>
+ <key>CFBundleURLTypes</key>
+ <array>
+ <dict>
+ <key>CFBundleTypeRole</key>
+ <string>Editor</string>
+ <key>CFBundleURLName</key>
+ <string>works.oficina.calendar.signin</string>
+ <key>CFBundleURLSchemes</key>
+ <array>
+ <string>works.oficina.calendar</string>
+ </array>
+ </dict>
+ </array>
+ <key>CFBundleVersion</key>
+ <string>$(CURRENT_PROJECT_VERSION)</string>
+ <key>LSApplicationCategoryType</key>
+ <string>public.app-category.productivity</string>
+ <key>NSHighResolutionCapable</key>
+ <true/>
+</dict>
+</plist>
diff --git a/native/macos/project.yml b/native/macos/project.yml
new file mode 100644
index 0000000..f064a2e
--- /dev/null
+++ b/native/macos/project.yml
@@ -0,0 +1,54 @@
+name: CalendarMacStore
+options:
+ deploymentTarget:
+ macOS: '14.0'
+packages:
+ CalendarNative:
+ path: ../apple
+ RastrilloNative:
+ url: https://amadan.net/rastrillo/native
+ revision: 229525909e1d6ac3b6a353d1c4d022798b0e9a24
+settings:
+ base:
+ SWIFT_VERSION: '5.0'
+ CODE_SIGN_STYLE: Automatic
+ MARKETING_VERSION: 0.1.0
+ CURRENT_PROJECT_VERSION: '3'
+ ENABLE_HARDENED_RUNTIME: true
+targets:
+ CalendarMacStore:
+ type: application
+ platform: macOS
+ sources:
+ - ../apple/Sources/CalendarApp
+ - path: AppIcon.icns
+ buildPhase: resources
+ - path: ../LICENSE.lucide
+ buildPhase: resources
+ - path: ../PrivacyInfo.xcprivacy
+ buildPhase: resources
+ dependencies:
+ - package: CalendarNative
+ product: CalendarKit
+ - package: RastrilloNative
+ product: RastrilloApple
+ info:
+ path: Info.plist
+ properties:
+ CFBundleDisplayName: Oficina Calendar
+ CFBundleName: Oficina Calendar
+ CFBundleShortVersionString: $(MARKETING_VERSION)
+ CFBundleVersion: $(CURRENT_PROJECT_VERSION)
+ CFBundleIconFile: AppIcon
+ LSApplicationCategoryType: public.app-category.productivity
+ NSHighResolutionCapable: true
+ CFBundleURLTypes:
+ - CFBundleURLName: works.oficina.calendar.signin
+ CFBundleURLSchemes:
+ - works.oficina.calendar
+ CFBundleTypeRole: Editor
+ settings:
+ base:
+ PROVISIONING_PROFILE_SPECIFIER: $(OFICINA_PROVISIONING_PROFILE)
+ PRODUCT_BUNDLE_IDENTIFIER: works.oficina.calendar
+ CODE_SIGN_ENTITLEMENTS: AppStore.entitlements
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..2a82fc9
--- /dev/null
+++ b/native/scripts/build-mac.sh
@@ -0,0 +1,6 @@
+#!/bin/sh
+set -eu
+cd "$(dirname "$0")/.."
+swift package --package-path apple resolve
+shared_native="${OFICINA_NATIVE_PACKAGE_PATH:-apple/.build/checkouts/native}"
+python3 "$shared_native/tools/bundle-mac.py" . Calendar "$@"