Merge native/testflight: native Calendar server API and Apple clients
Production runs main, which has no /api/native/*, so every installed native Calendar app fails every call. This lands the server half (and the Apple clients) from origin/native/testflight d133120. Conflict in internal/calendar/app.go: main's scheduledWork (which re-arms the wake after every request, native POSTs included, via the middleware wrapping the whole router) is kept as is; wireNative() is called before the scheduler starts so a native wiring failure cannot leak the timer goroutine.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
46 files changed,
+2661
−2
Makefile+1 −1go.mod+2 −0go.sum+2 −0internal/calendar/app.go+8 −0internal/calendar/credits_inventory_test.go+1 −1internal/calendar/native.go+196 −0internal/calendar/native_quick.go+140 −0internal/calendar/native_quick_test.go+133 −0internal/calendar/native_test.go+267 −0internal/calendar/native_write_test.go+78 −0internal/calendar/routes.go+9 −0native/.gitignore+3 −0native/LICENSE.lucide+43 −0native/PrivacyInfo.xcprivacy+17 −0native/README.md+77 −0native/REVIEW.md+38 −0native/UX-REVIEW.md+57 −0native/apple/Package.resolved+22 −0native/apple/Package.swift+15 −0native/apple/Sources/CalendarApp/CalendarApp.swift+50 −0native/apple/Sources/CalendarKit/CalendarMenuView.swift+59 −0native/apple/Sources/CalendarKit/CalendarModel.swift+120 −0native/apple/Sources/CalendarKit/CalendarModels.swift+49 −0native/apple/Sources/CalendarKit/CalendarMonthView.swift+73 −0native/apple/Sources/CalendarKit/CalendarSettingsView.swift+33 −0native/apple/Sources/CalendarKit/CalendarWorkspace.swift+144 −0native/apple/Sources/CalendarKit/EventEditor.swift+87 −0native/apple/Sources/CalendarKit/QuickAddView.swift+81 −0native/apple/Sources/CalendarReview/Review.swift+35 −0native/apple/Tests/CalendarKitTests/CivilDateTests.swift+28 −0native/apple/Tests/CalendarKitTests/SessionLifecycleTests.swift+73 −0native/ios/Assets.xcassets/AppIcon.appiconset/AppIcon.pngbinarynative/ios/Assets.xcassets/AppIcon.appiconset/Contents.json+14 −0native/ios/Info.plist+35 −0native/ios/Review/ReviewPhone.swift+22 −0native/ios/UITests/NativeUITests.swift+34 −0native/ios/project.yml+90 −0native/mac-updates.json+6 −0native/macos/AppIcon.icnsbinarynative/macos/AppStore.entitlements+12 −0native/macos/CalendarMacStore.xcodeproj/project.pbxproj+387 −0native/macos/CalendarMacStore.xcodeproj/project.xcworkspace/contents.xcworkspacedata+7 −0native/macos/Info.plist+43 −0native/macos/project.yml+54 −0native/scripts/build-iphone.sh+10 −0native/scripts/build-mac.sh+6 −0
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/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..aa38e0e |
| --- /dev/null |
| +++ b/native/UX-REVIEW.md |
| @@ -0,0 +1,57 @@ |
| +# 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. |
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..bbac6a4 |
| --- /dev/null |
| +++ b/native/apple/Sources/CalendarKit/CalendarMenuView.swift |
| @@ -0,0 +1,59 @@ |
| +#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 } |
| + 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() |
| + ScrollView { |
| + VStack(alignment: .leading, spacing: 12) { |
| + CalendarMonthView(model: model).controlSize(.small) |
| + Text(model.display(model.date, template: "EEEE d MMMM")).font(.headline) |
| + 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 |
| + 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") } |
| + }.accessibilityElement(children: .combine) |
| + Divider() |
| + } |
| + }.padding(.horizontal) |
| + } |
| + Divider().padding(.vertical, 8) |
| + QuickAddView(client: client, model: model).frame(height: model.quickText.isEmpty && model.quickSavedNotice.isEmpty ? 84 : 220).padding(.horizontal) |
| + HStack { |
| + Text(model.zone).font(.caption).foregroundStyle(.secondary) |
| + Spacer() |
| + Button("Quit Calendar") { NSApplication.shared.terminate(nil) } |
| + }.padding() |
| + }.frame(width: 380, height: 640) |
| + .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..84c3461 |
| --- /dev/null |
| +++ b/native/apple/Sources/CalendarKit/QuickAddView.swift |
| @@ -0,0 +1,81 @@ |
| +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) |
| + .textFieldStyle(.roundedBorder).lineLimit(2...4) |
| + .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..055c468 |
| --- /dev/null |
| +++ b/native/apple/Sources/CalendarReview/Review.swift |
| @@ -0,0 +1,35 @@ |
| +// 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)) |
| + let capture = Process() |
| + capture.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture") |
| + capture.arguments = ["-x", "-o", "-l", String(window.windowNumber), input.output] |
| + try capture.run() |
| + capture.waitUntilExit() |
| + guard capture.terminationStatus == 0 else { fatalError("Window capture failed") } |
| + } |
| +} |
diff --git a/native/apple/Tests/CalendarKitTests/CivilDateTests.swift b/native/apple/Tests/CalendarKitTests/CivilDateTests.swift| new file mode 100644 |
| index 0000000..dbef4c9 |
| --- /dev/null |
| +++ b/native/apple/Tests/CalendarKitTests/CivilDateTests.swift |
| @@ -0,0 +1,28 @@ |
| +import XCTest |
| +@testable import CalendarKit |
| +final class CivilDateTests: XCTestCase { |
| + @MainActor func testNewEventStartsAtNineAcrossDST() { |
| + for day in ["2026-03-29", "2026-10-25"] { |
| + let date=ISO8601DateFormatter().date(from: day+"T12:00:00Z")! |
| + let model=CalendarModel(zone: "Europe/Dublin", date: date) |
| + XCTAssertEqual(model.newEvent().start_local, day+"T09:00") |
| + } |
| + } |
| + @MainActor func testGapCannotSilentlyBecomeToday() { |
| + let model=CalendarModel(zone: "Europe/Dublin") |
| + XCTAssertNil(model.parse("2026-03-29T01:30", allDay: false, zone: "Europe/Dublin")) |
| + } |
| + func testAllDayUsesOccurrenceCivilDatesAcrossZones() { |
| + var event=CalendarEvent(); event.all_day=true |
| + event.day_start="2026-09-12"; event.day_end="2026-09-13" |
| + event.start_local="2026-09-01"; event.end_local="2026-09-02" // recurrence master must not win |
| + event.tz="UTC"; event.start=1789171200; event.end=1789257600 |
| + for zone in ["America/Los_Angeles", "Pacific/Kiritimati"] { |
| + var calendar=Calendar(identifier: .gregorian);calendar.timeZone=TimeZone(identifier: zone)! |
| + for day in [11,12,13] { |
| + let date=calendar.date(from: DateComponents(year:2026,month:9,day:day,hour:12))! |
| + XCTAssertEqual(event.occurs(on:date,calendar:calendar),day==12) |
| + } |
| + } |
| + } |
| +} |
diff --git a/native/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 "$@" |