oficina / sheets Public

Merge branch 'native/server-release' into main

Enable native Sheets clients with existing session and ownership checks
codex agent, operated by paul@keymail.dev pushed by paul@keymail.dev 52c424fd59de7069c7a67873f8a5f8036f5e5c56 parent 1fae2b2parent 4372dc6
57 files changed, +3031 −10
  • .gitignore +6 −0
  • cmd/sheets/main.go +5 −0
  • docs/superpowers/specs/2026-09-13-native-apple-clients.md +40 −0
  • go.mod +1 −0
  • go.sum +2 −0
  • internal/sheets/app.go +9 −0
  • internal/sheets/grid.go +6 −2
  • internal/sheets/handlers_comments.go +3 −0
  • internal/sheets/handlers_grid.go +7 −1
  • internal/sheets/handlers_history.go +3 −0
  • internal/sheets/handlers_import.go +4 −0
  • internal/sheets/handlers_rename.go +7 −0
  • internal/sheets/handlers_share.go +6 −0
  • internal/sheets/handlers_sheets.go +8 −0
  • internal/sheets/handlers_stream.go +6 −7
  • internal/sheets/handlers_view.go +7 −0
  • internal/sheets/native.go +106 −0
  • internal/sheets/native_permissions_test.go +53 −0
  • internal/sheets/native_review_test.go +289 −0
  • internal/sheets/native_test.go +102 −0
  • internal/sheets/render.go +3 −0
  • native/LICENSE.lucide +43 −0
  • native/PARITY.md +33 −0
  • native/README.md +57 −0
  • native/REVIEW.md +75 −0
  • native/apple/Package.resolved +14 −0
  • native/apple/Package.swift +9 −0
  • native/apple/Sources/SheetsApp/SheetsApp.swift +25 −0
  • native/apple/Sources/SheetsKit/Coordinate.swift +47 −0
  • native/apple/Sources/SheetsKit/SheetCell.swift +45 −0
  • native/apple/Sources/SheetsKit/SheetClipboard.swift +96 −0
  • native/apple/Sources/SheetsKit/SheetCloseGuard.swift +89 −0
  • native/apple/Sources/SheetsKit/SheetColor.swift +24 −0
  • native/apple/Sources/SheetsKit/SheetComments.swift +69 −0
  • native/apple/Sources/SheetsKit/SheetFiles.swift +162 −0
  • native/apple/Sources/SheetsKit/SheetFormatMenu.swift +60 −0
  • native/apple/Sources/SheetsKit/SheetGrid.swift +62 −0
  • native/apple/Sources/SheetsKit/SheetLive.swift +76 −0
  • native/apple/Sources/SheetsKit/SheetModel.swift +283 −0
  • native/apple/Sources/SheetsKit/SheetPanels.swift +97 −0
  • native/apple/Sources/SheetsKit/SheetSharing.swift +85 −0
  • native/apple/Sources/SheetsKit/SheetTable.swift +33 −0
  • native/apple/Sources/SheetsKit/SheetWire.swift +112 −0
  • native/apple/Sources/SheetsKit/SheetWrites.swift +58 −0
  • native/apple/Sources/SheetsKit/SheetsWorkspace.swift +207 −0
  • native/apple/Sources/SheetsKit/TSV.swift +52 −0
  • native/apple/Sources/SheetsReview/Review.swift +63 −0
  • native/apple/Sources/SheetsReview/ReviewInput.swift +8 −0
  • native/apple/Tests/SheetsKitTests/EditingTests.swift +118 −0
  • native/ios/Assets.xcassets/AppIcon.appiconset/AppIcon.png binary
  • native/ios/Assets.xcassets/AppIcon.appiconset/Contents.json +1 −0
  • native/ios/Review/ReviewInput.swift +7 −0
  • native/ios/Review/ReviewPhone.swift +22 −0
  • native/ios/UITests/NativeUITests.swift +139 −0
  • native/ios/project.yml +72 −0
  • native/scripts/build-iphone.sh +10 −0
  • native/scripts/build-mac.sh +5 −0

CI passed — run details

diff --git a/.gitignore b/.gitignore
index 189890a..2a88a1c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,4 +23,10 @@
.claude/worktrees
.claude/HANDOFF.md
/sheets-arm64
+
+# Native build products
+native/apple/.build/
+native/apple/.swiftpm/
+native/build/
+native/ios/*.xcodeproj/
/releases/
diff --git a/cmd/sheets/main.go b/cmd/sheets/main.go
index 5401076..a559c3f 100644
--- a/cmd/sheets/main.go
+++ b/cmd/sheets/main.go
@@ -107,6 +107,11 @@ func main() {
os.Exit(1)
}
+ if err := app.ConfigureNative(); err != nil {
+ logger.Error("native configuration unavailable")
+ os.Exit(1)
+ }
+
// rastrillo.Options.Mux wants a *http.ServeMux; the app's own
// router is a chi.Router (an http.Handler), so it mounts under one.
mux := http.NewServeMux()
diff --git a/docs/superpowers/specs/2026-09-13-native-apple-clients.md b/docs/superpowers/specs/2026-09-13-native-apple-clients.md
new file mode 100644
index 0000000..c37adcb
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-13-native-apple-clients.md
@@ -0,0 +1,40 @@
+# Native Sheets for Mac and iOS
+
+Status: reviewed for implementation by independently dispatched gpt-6-astra (high), 2026-09-13. Spec-only approval; branch/parity validation remains required. User decisions, 2026-09-13: simultaneous Mac and iOS builds; full editing parity with the existing web app; entirely native editors. This extends the original design's deferred mobile-native row without changing the sealed-at-rest trust model or server calculation authority.
+
+## Actual parity inventory
+
+Source baseline: `internal/sheets/app.go`, `templates/sheet.html`, `templates/table.html`, `static/commands.js`, `static/clipboard.js`, `handlers_grid.go`, `handlers_history.go`, `handlers_comments.go`, `handlers_view.go`.
+
+| Existing working surface | Native implementation |
+| --- | --- |
+| Home sign-in, team/sheet grants | Shared RastrilloApple authentication, instance selection; existing server identity/actor/grants middleware |
+| Sheet list, create, rename | Native navigation list and forms over JSON responses |
+| One worksheet per document | Same first-tab identity; no invented tab creation |
+| Formula bar and editable cells | Native SwiftUI grid, selection and native text fields; server evaluates and formats every input |
+| Range selection, select all, clear, copy/cut/paste, fill down/right | Native range address field and clipboard; existing atomic batch route and server copy translation |
+| Insert Function: SUM/AVERAGE/COUNT/MIN/MAX | Native insert menu infers arguments with the same selection semantics as commands.js, then submits through the server calc path |
+| Keyboard Enter/Tab commit-and-navigate, Escape abandon, undo/format/copy/paste/select-all shortcuts | Native focus and keyboard actions, preserving unsaved drafts until confirmed |
+| Bold/italic/alignment/number format/fill/text colour/clear formatting | Native menus call existing range format endpoint; server style records remain canonical |
+| Row height/column width | Native numeric controls call existing size endpoint |
+| Input undo; cell history and restore | Native history panel; existing actor-specific undo and restore paths |
+| Non-destructive sort/filter in table view | Native view controls; server `applyView` returns source row coordinates, preserving reference and edit identity |
+| Cell comment threads and resolve | Native comment panel using existing sealed comment storage and commenter permission |
+| Owner grants/revocation/viewer-or-commenter share links | Native sharing panel with existing grant validation |
+| XLSX import and export | Native .xlsx/.xlsm file import and .xlsx export; same converter and jobs; first worksheet limitation clearly stated |
+| SSE edits, resync, reconnect | Existing stream with monotonically versioned server responses; cancel on navigation, refetch snapshot after reconnect/resync |
+| Light/dark choice and imported fill/ink warning | Native per-person preference; authored colours retained and warning surfaced |
+
+Charts, conditional-format rule editing, multi-worksheet UI, row/column insertion/deletion, destructive sort, formatting/resize undo, and offline editing are not implemented in the current web product. They are not falsely represented as parity requirements met by new native controls. The initial native work must nevertheless cover every working surface above.
+
+## Architecture and safety
+
+`native/` contains a Swift package executable shared by macOS 26 and iOS 26, platform app metadata and repeatable build scripts based on Calendar/Memoria. RastrilloApple is pinned to `33ffe994173438bbb45e88d16061eba2ac4f6d4c`. No WKWebView or JavaScript editor is used. Auth uses the shared system-browser flow. AppKit/UIKit use is limited to platform clipboard/file integration where SwiftUI does not expose the needed function.
+
+Add JSON read adapters for list, sheet metadata, history, comments, sharing and ordered row windows. Existing command endpoints remain canonical; where their response is HTML, add explicit JSON response negotiation while preserving the HTML routes. A native request cannot bypass `actor`, `sheetFromURL`, `openTab`, `mayWrite`, `mayComment` or sharing ownership. Every SSE delivery must re-resolve current native token/session validity, current admission and sheet grants; the existing stream captures an actor at subscription and its mayRead check alone is insufficient for token revocation. Token invalidation closes the stream before delivering more content. All writes use existing store/calc and sealed persistence paths. API responses are `Cache-Control: no-store`; editing content lives only in memory, never an unsealed disk cache. An explicit user export streams to a private temporary XLSX for the system save picker; this export staging file is removed on completion, cancellation or failure and is not a document cache. Failed saves keep the input draft and show a retryable error. Navigation and closing must surface unresolved drafts with save/discard/cancel, and export waits for pending writes before reading server content. Import job ownership remains `sessions.Current.Subject`, not actor.ID; native JSON progress/result polling resolves that same session subject and never exposes another owner’s job.
+
+The grid uses stable original coordinates as identity, virtualized rows, accessible cell labels, native selection and range-address controls, and explicit fill/resize menu alternatives. Snapshot refresh must not overwrite a focused unsaved draft. Writes are serialized; server versions older than the accepted version are ignored, gaps trigger refetch rather than invented local values. Full snapshot replacement clears deleted cells and old formats. Batch paste observes server byte/count caps and never silently truncates a paste. Server formula translation is used for internal copied cells including mixed/absolute references. Native sort/filter never moves storage coordinates.
+
+## Verification and completion
+
+Server tests cover JSON access with independent members and roles, mutation permission refusal, native response negotiation, sorting coordinates, history/comment privacy, and unchanged sealed-at-rest gates. Swift tests cover coordinate parsing/ranges, TSV quoting, copy origins, insert-function selection semantics, select-all, keyboard commit/Tab/Enter/Escape and serial/version reconciliation. SSE tests revoke a native token and admission during an established stream and require no further content delivery. Build both actual macOS app and generic iOS Simulator app with Xcode. Exercise native rendering and editing against fixtures when simulator tooling permits; report any unavailable verification explicitly. Do not claim complete parity until the inventory is wired and exercised. Import tests cover .xlsx/.xlsm and cross-session job polling refusal. No publishing or deployment is authorized by this implementation task.
diff --git a/go.mod b/go.mod
index d7a564a..6d19d77 100644
--- a/go.mod
+++ b/go.mod
@@ -15,6 +15,7 @@ require (
)
require (
+ amadan.net/rastrillo/native/server v0.0.0-20260912204600-33ffe9941734
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
github.com/google/uuid v1.6.0 // indirect
diff --git a/go.sum b/go.sum
index 6365c46..1eb2714 100644
--- a/go.sum
+++ b/go.sum
@@ -121,3 +121,5 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+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=
diff --git a/internal/sheets/app.go b/internal/sheets/app.go
index 9a8052a..2f70b3f 100644
--- a/internal/sheets/app.go
+++ b/internal/sheets/app.go
@@ -3,6 +3,7 @@
package sheets
import (
+ nativeauth "amadan.net/rastrillo/native/server"
"net/http"
"amadan.net/oficina/home/connect"
@@ -52,6 +53,7 @@ type Config struct {
// App holds the wiring. One per process.
type App struct {
+ native *nativeauth.Auth
home *connect.Bridge
homeRoutes *connect.Routes
homeSessions *sessions.Sessions
@@ -137,6 +139,9 @@ func (a *App) UseAuth(x *auth.Auth) { a.auth = x }
// Router returns the mounted handler.
func (a *App) Router() http.Handler {
r := chi.NewRouter()
+ if a.native != nil {
+ r.Use(a.nativeResume)
+ }
// Origin-checking CSRF, app-wide, on every state-changing method.
// App-wide rather than per-route because the failure mode of
@@ -168,6 +173,9 @@ func (a *App) Router() http.Handler {
// should see (TestIndexIsNotACatchAll pins this).
r.Get("/", a.handleIndex)
a.mountAuth(r)
+ if a.native != nil {
+ r.Handle("/native/*", a.native)
+ }
// Fingerprinted and immutably cached, mounted at the FS's own path
// with no prefix stripped — rastrillo's Assets uses
// http.FileServerFS semantics, where the URL path is "/" plus the
@@ -185,6 +193,7 @@ func (a *App) Router() http.Handler {
r.Group(func(r chi.Router) {
r.Use(a.requireSession)
+ r.Get("/api/native/jobs/{id}", a.handleNativeJob)
r.Get("/sheets", a.handleSheetList)
r.Post("/sheets", a.handleSheetCreate)
// Static before the {sheetID} pattern below. chi prefers an
diff --git a/internal/sheets/grid.go b/internal/sheets/grid.go
index 24f6afe..b32d0b1 100644
--- a/internal/sheets/grid.go
+++ b/internal/sheets/grid.go
@@ -163,6 +163,10 @@ func extent(h *store.Handle, idx posIndex) (rows, cols int) {
// the response rather than present and empty, which is the difference
// between a 100×26 window costing two kilobytes and costing sixty.
func snapshot(h *store.Handle, idx posIndex, rows, cols int) ([]gridCell, styleTable, error) {
+ return snapshotWindow(h, idx, 0, 0, rows, cols)
+}
+
+func snapshotWindow(h *store.Handle, idx posIndex, firstRow, firstCol, rows, cols int) ([]gridCell, styleTable, error) {
coords, err := h.Cells()
if err != nil {
return nil, nil, err
@@ -178,11 +182,11 @@ func snapshot(h *store.Handle, idx posIndex, rows, cols int) ([]gridCell, styleT
out := make([]gridCell, 0, len(coords))
for _, c := range coords {
r, ok := idx.rowPos[c.RowID]
- if !ok || r >= rows {
+ if !ok || r < firstRow || r >= firstRow+rows {
continue
}
colPos, ok := idx.colPos[c.ColID]
- if !ok || colPos >= cols {
+ if !ok || colPos < firstCol || colPos >= firstCol+cols {
continue
}
raw, _ := h.RawInput(c.RowID, c.ColID)
diff --git a/internal/sheets/handlers_comments.go b/internal/sheets/handlers_comments.go
index aa52f1c..bffe2ba 100644
--- a/internal/sheets/handlers_comments.go
+++ b/internal/sheets/handlers_comments.go
@@ -156,6 +156,9 @@ func (a *App) cellIDsFromForm(r *http.Request, h *store.Handle) (uint64, uint64,
}
func (a *App) commentRedirect(w http.ResponseWriter, r *http.Request, s SheetSummary, notice string) {
+ if nativeNotice(w, r, notice) {
+ return
+ }
http.Redirect(w, r, "/sheets/"+strconv.FormatUint(s.ID, 10)+"/comments?notice="+notice,
http.StatusSeeOther)
}
diff --git a/internal/sheets/handlers_grid.go b/internal/sheets/handlers_grid.go
index bb8d5c0..410c930 100644
--- a/internal/sheets/handlers_grid.go
+++ b/internal/sheets/handlers_grid.go
@@ -77,7 +77,12 @@ func (a *App) handleCellsGet(w http.ResponseWriter, r *http.Request) {
rows := clamp(intParam(r, "rows"), defaultWindowRows, maxWindowRows)
cols := clamp(intParam(r, "cols"), defaultWindowCols, maxWindowCols)
- cells, styles, err := snapshot(h, newPosIndex(h), rows, cols)
+ firstRow, firstCol := intParam(r, "firstRow"), intParam(r, "firstCol")
+ if firstRow < 0 || firstCol < 0 || firstRow >= maxWindowRows || firstCol >= maxWindowCols {
+ http.Error(w, "Window out of range.", 400)
+ return
+ }
+ cells, styles, err := snapshotWindow(h, newPosIndex(h), firstRow, firstCol, rows, cols)
if err != nil {
a.fail(w, r, err)
return
@@ -260,6 +265,7 @@ func intParam(r *http.Request, name string) int {
// writeJSON sends a response. An encode failure means the connection is
// already gone, so there is nothing left to say about it.
func writeJSON(w http.ResponseWriter, status int, resp gridResponse) {
+ w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(resp)
diff --git a/internal/sheets/handlers_history.go b/internal/sheets/handlers_history.go
index e595191..9788f98 100644
--- a/internal/sheets/handlers_history.go
+++ b/internal/sheets/handlers_history.go
@@ -235,6 +235,9 @@ func (a *App) applyRaw(s SheetSummary, rowID, colID uint64, raw string, by Membe
}
func (a *App) historyRedirect(w http.ResponseWriter, r *http.Request, s SheetSummary, notice string) {
+ if nativeNotice(w, r, notice) {
+ return
+ }
http.Redirect(w, r, "/sheets/"+strconv.FormatUint(s.ID, 10)+"/history?notice="+notice,
http.StatusSeeOther)
}
diff --git a/internal/sheets/handlers_import.go b/internal/sheets/handlers_import.go
index 5b7be53..0a5a87b 100644
--- a/internal/sheets/handlers_import.go
+++ b/internal/sheets/handlers_import.go
@@ -137,6 +137,10 @@ func (a *App) handleSheetImport(w http.ResponseWriter, r *http.Request) {
a.fail(w, r, err)
return
}
+ if wantsNative(r) {
+ nativeJSON(w, 202, map[string]string{"job": job.ID})
+ return
+ }
http.Redirect(w, r, "/jobs/"+job.ID, http.StatusSeeOther)
}
diff --git a/internal/sheets/handlers_rename.go b/internal/sheets/handlers_rename.go
index f0ddfb1..9c8df4f 100644
--- a/internal/sheets/handlers_rename.go
+++ b/internal/sheets/handlers_rename.go
@@ -100,6 +100,10 @@ func (a *App) handleSheetRename(w http.ResponseWriter, r *http.Request) {
if err := a.renameSheet(s.ID, title); err != nil {
if errors.Is(err, errBlankTitle) {
+ if wantsNative(r) {
+ http.Error(w, "A sheet needs a title.", 400)
+ return
+ }
// Straight back to the sheet, which still shows the name it
// had. Nothing was changed and there is nothing to explain
// that the unchanged title does not already say.
@@ -116,6 +120,9 @@ func (a *App) handleSheetRename(w http.ResponseWriter, r *http.Request) {
// sheetRedirect sends the browser back to a sheet.
func (a *App) sheetRedirect(w http.ResponseWriter, r *http.Request, id uint64) {
+ if nativeNotice(w, r, "renamed") {
+ return
+ }
http.Redirect(w, r, "/sheets/"+strconv.FormatUint(id, 10), http.StatusSeeOther)
}
diff --git a/internal/sheets/handlers_share.go b/internal/sheets/handlers_share.go
index 77e51a6..b564b87 100644
--- a/internal/sheets/handlers_share.go
+++ b/internal/sheets/handlers_share.go
@@ -246,11 +246,17 @@ func (a *App) sheetForSharing(w http.ResponseWriter, r *http.Request) (SheetSumm
// in a request line is an address in the platform's access logs.
func (a *App) shareRedirectAbout(w http.ResponseWriter, r *http.Request, s SheetSummary,
notice string, memberID uint64) {
+ if nativeNotice(w, r, notice) {
+ return
+ }
http.Redirect(w, r, "/sheets/"+strconv.FormatUint(s.ID, 10)+"/share?notice="+notice+
"&who="+strconv.FormatUint(memberID, 10), http.StatusSeeOther)
}
func (a *App) shareRedirect(w http.ResponseWriter, r *http.Request, s SheetSummary, notice string) {
+ if nativeNotice(w, r, notice) {
+ return
+ }
http.Redirect(w, r, "/sheets/"+strconv.FormatUint(s.ID, 10)+"/share?notice="+notice,
http.StatusSeeOther)
}
diff --git a/internal/sheets/handlers_sheets.go b/internal/sheets/handlers_sheets.go
index 2c79762..ad5ad1b 100644
--- a/internal/sheets/handlers_sheets.go
+++ b/internal/sheets/handlers_sheets.go
@@ -100,6 +100,10 @@ func (a *App) renderSheetList(w http.ResponseWriter, r *http.Request, status int
a.fail(w, r, err)
return
}
+ if wantsNative(r) {
+ nativeJSON(w, status, map[string]any{"Sheets": list})
+ return
+ }
rows := make([]sheetRow, 0, len(list))
for _, s := range list {
rows = append(rows, sheetRow{SheetSummary: s, Preview: a.previewFor(s)})
@@ -133,6 +137,10 @@ func (a *App) handleSheetCreate(w http.ResponseWriter, r *http.Request) {
a.fail(w, r, err)
return
}
+ if wantsNative(r) {
+ nativeJSON(w, 201, s)
+ return
+ }
http.Redirect(w, r, "/sheets/"+strconv.FormatUint(s.ID, 10), http.StatusSeeOther)
}
diff --git a/internal/sheets/handlers_stream.go b/internal/sheets/handlers_stream.go
index f9f0eb9..e982db9 100644
--- a/internal/sheets/handlers_stream.go
+++ b/internal/sheets/handlers_stream.go
@@ -48,7 +48,7 @@ const (
// written against 101 would never pass here. The conflation is worth
// naming because the source document this design came from made it.
func (a *App) handleStream(w http.ResponseWriter, r *http.Request) {
- viewer, ok := a.actor(r)
+ _, ok := a.actor(r)
if !ok {
a.notFound(w, r)
return
@@ -62,11 +62,13 @@ func (a *App) handleStream(w http.ResponseWriter, r *http.Request) {
// needs the same grant a read of the grid does. Without this check
// the fan-out would be the one route that ignored sharing entirely
// — and the quietest possible leak, since nothing renders it.
- if !a.mayRead(sheetID, viewer) {
+ if !a.streamAuthorized(r, sheetID) {
a.notFound(w, r)
return
}
+ sub := a.hub.Subscribe(tabID)
+ defer sub.Close()
rc := http.NewResponseController(w)
w.Header().Set("Content-Type", "text/event-stream")
@@ -79,7 +81,7 @@ func (a *App) handleStream(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
- // Flushed before anything is subscribed, so the client's onopen
+ // Flushed immediately after subscribing, so the client's onopen
// fires immediately rather than on the first edit. A grid that says
// "connecting" until somebody types looks broken for as long as
// nobody does.
@@ -87,9 +89,6 @@ func (a *App) handleStream(w http.ResponseWriter, r *http.Request) {
return
}
- sub := a.hub.Subscribe(tabID)
- defer sub.Close()
-
ping := time.NewTicker(pingEvery)
defer ping.Stop()
idle := time.NewTimer(idleTimeout)
@@ -143,7 +142,7 @@ func (a *App) handleStream(w http.ResponseWriter, r *http.Request) {
// One indexed read per delivered message, not per edit on
// the instance: a subscriber only reaches this line when a
// message is already addressed to its tab.
- if !a.mayRead(sheetID, viewer) {
+ if !a.streamAuthorized(r, sheetID) {
writeEvent(w, rc, "bye", `{"reason":"access-changed"}`)
return
}
diff --git a/internal/sheets/handlers_view.go b/internal/sheets/handlers_view.go
index f387682..8cd45bd 100644
--- a/internal/sheets/handlers_view.go
+++ b/internal/sheets/handlers_view.go
@@ -49,6 +49,10 @@ func (a *App) handleViewSave(w http.ResponseWriter, r *http.Request) {
// it: losing somebody's filter because they mistyped a column is a
// worse answer than ignoring the submission.
if err := spec.Validate(); err != nil {
+ if wantsNative(r) {
+ http.Error(w, "Invalid sort or filter.", 400)
+ return
+ }
a.tableRedirect(w, r, s)
return
}
@@ -60,5 +64,8 @@ func (a *App) handleViewSave(w http.ResponseWriter, r *http.Request) {
}
func (a *App) tableRedirect(w http.ResponseWriter, r *http.Request, s SheetSummary) {
+ if nativeNotice(w, r, "saved") {
+ return
+ }
http.Redirect(w, r, "/sheets/"+strconv.FormatUint(s.ID, 10)+"/table", http.StatusSeeOther)
}
diff --git a/internal/sheets/native.go b/internal/sheets/native.go
new file mode 100644
index 0000000..b903d81
--- /dev/null
+++ b/internal/sheets/native.go
@@ -0,0 +1,106 @@
+package sheets
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+
+ nativeauth "amadan.net/rastrillo/native/server"
+ "github.com/carlosframework/rastrillo/sessions"
+ "github.com/go-chi/chi/v5"
+)
+
+// ConfigureNative reuses the cookie-backed auth and admission path.
+func (a *App) ConfigureNative() error {
+ d, err := a.db.DB()
+ if err != nil {
+ return err
+ }
+ s, err := sessions.New(sessions.Config{DB: d, Origin: a.origin, SigninPath: "/signin"})
+ if err != nil {
+ return err
+ }
+ a.native = nativeauth.New(s, a.origin, "Sheets", a.requireSession)
+ return nil
+}
+func wantsNative(r *http.Request) bool {
+ return strings.Contains(r.Header.Get("Accept"), "application/json")
+}
+func nativeJSON(w http.ResponseWriter, status int, value any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Cache-Control", "no-store")
+ w.Header().Add("Vary", "Accept")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(value)
+}
+
+// Only explicit page payloads are serializable. No auth or shell data is exposed.
+func nativePage(w http.ResponseWriter, r *http.Request, page string, status int, data any) bool {
+ if !wantsNative(r) {
+ return false
+ }
+ switch page {
+ case "sheet", "history", "comments", "share", "table", "import":
+ nativeJSON(w, status, data)
+ return true
+ }
+ return false
+}
+func nativeNotice(w http.ResponseWriter, r *http.Request, notice string) bool {
+ if !wantsNative(r) {
+ return false
+ }
+ message := noticeText(notice)
+ if message == "" {
+ message = historyNotice(notice)
+ }
+ if message == "" {
+ message = commentNotice(notice)
+ }
+ if message == "" {
+ message = "Saved."
+ }
+ status := http.StatusOK
+ switch notice {
+ case "bad-address", "bad-role", "last-owner", "bad-link-role", "empty", "no-cell", "gone":
+ status = http.StatusUnprocessableEntity
+ }
+ nativeJSON(w, status, map[string]string{"notice": notice, "message": message})
+ return true
+}
+func (a *App) handleNativeJob(w http.ResponseWriter, r *http.Request) {
+ session, ok := sessions.Current(r)
+ if !ok {
+ a.notFound(w, r)
+ return
+ }
+ job, ok := a.jobs.Get(chi.URLParam(r, "id"), session.Subject)
+ if !ok {
+ a.notFound(w, r)
+ return
+ }
+ nativeJSON(w, 200, map[string]any{"status": job.Status, "progress": job.Progress, "error": job.Err, "location": job.Location})
+}
+func (a *App) streamAuthorized(r *http.Request, sheetID uint64) bool {
+ address, ok := a.identity(r)
+ if !ok || !a.Admits(address) {
+ return false
+ }
+ viewer, ok := a.actor(r)
+ return ok && a.mayRead(sheetID, viewer)
+}
+
+// Local and Home sign-in finish on /sheets; the shared approval continuation
+// recognises /. Translate only this middleware probe, never the app route.
+func (a *App) nativeResume(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ probe := r
+ if r.Method == http.MethodGet && r.URL.Path == "/sheets" {
+ probe = r.Clone(r.Context())
+ u := *r.URL
+ u.Path = "/"
+ probe.URL = &u
+ }
+ a.native.Resume(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { next.ServeHTTP(w, r) })).ServeHTTP(w, probe)
+ })
+}
diff --git a/internal/sheets/native_permissions_test.go b/internal/sheets/native_permissions_test.go
new file mode 100644
index 0000000..33a4b59
--- /dev/null
+++ b/internal/sheets/native_permissions_test.go
@@ -0,0 +1,53 @@
+package sheets
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "strconv"
+ "testing"
+)
+
+func TestNativeRolesAndJobOwnership(t *testing.T) {
+ a, cookies := signIn(t)
+ own, _ := newSheet(t, a, cookies, "Private native history")
+ shared, _ := newSheet(t, a, cookies, "Shared native sheet")
+ guestOn(t, a, shared.ID, "native-guest@example.com", SheetViewer)
+ guestCookies := signInAs(t, a, "native-guest@example.com")
+ privatePath := "/sheets/" + strconv.FormatUint(own.ID, 10)
+ sharedPath := "/sheets/" + strconv.FormatUint(shared.ID, 10)
+ for _, suffix := range []string{"", "/history", "/comments", "/share", "/table"} {
+ rec := do(t, a, as(nativeGet(privatePath+suffix), guestCookies))
+ if rec.Code != 404 {
+ t.Fatalf("guest private %s=%d", suffix, rec.Code)
+ }
+ }
+ for _, suffix := range []string{"/rename", "/undo", "/restore", "/comments", "/view", "/share"} {
+ req := postForm(sharedPath+suffix, map[string][]string{"title": {"forbidden"}, "r": {"0"}, "c": {"0"}, "body": {"forbidden"}})
+ req.Header.Set("Accept", "application/json")
+ rec := do(t, a, as(req, guestCookies))
+ if rec.Code != 404 {
+ t.Fatalf("viewer mutation %s=%d", suffix, rec.Code)
+ }
+ }
+ rec := do(t, a, as(nativeGet(sharedPath), guestCookies))
+ var page sheetPageView
+ if err := json.Unmarshal(rec.Body.Bytes(), &page); err != nil {
+ t.Fatal(err)
+ }
+ if page.MayWrite || page.MayComment || page.MayShare {
+ t.Fatal("viewer offered editing permissions")
+ }
+ job, err := a.jobs.Start(signedInMember(t, a).Address, "Native test", "/sheets/1", func(context.Context, func(string)) error { return nil })
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec = do(t, a, as(nativeGet("/api/native/jobs/"+job.ID), guestCookies))
+ if rec.Code != http.StatusNotFound {
+ t.Fatal("another subject read native import job")
+ }
+ rec = do(t, a, as(nativeGet("/api/native/jobs/"+job.ID), cookies))
+ if rec.Code != 200 || !json.Valid(rec.Body.Bytes()) {
+ t.Fatalf("owner job response = %d", rec.Code)
+ }
+}
diff --git a/internal/sheets/native_review_test.go b/internal/sheets/native_review_test.go
new file mode 100644
index 0000000..4bf21af
--- /dev/null
+++ b/internal/sheets/native_review_test.go
@@ -0,0 +1,289 @@
+package sheets
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+// TestNativeReview uses an isolated migrated database, real browser sign-in and
+// the shared native approval/exchange. It never contacts a production account.
+func TestNativeReview(t *testing.T) {
+ binary, simulator := os.Getenv("NATIVE_REVIEW_BINARY"), os.Getenv("NATIVE_REVIEW_SIMULATOR")
+ if binary == "" && simulator == "" {
+ t.Skip("set an isolated native review binary or simulator")
+ }
+ output := os.Getenv("NATIVE_REVIEW_OUTPUT")
+ if output == "" {
+ t.Fatal("NATIVE_REVIEW_OUTPUT is required")
+ }
+ a, browser := signIn(t)
+ seed, cells := newSheet(t, a, browser, "Native review workbook")
+ for _, raw := range []string{`{"r":0,"c":0,"raw":"Item"}`, `{"r":0,"c":1,"raw":"Hours"}`, `{"r":1,"c":0,"raw":"Design"}`, `{"r":1,"c":1,"raw":"12"}`, `{"r":2,"c":0,"raw":"Engineering"}`, `{"r":2,"c":1,"raw":"24"}`, `{"r":3,"c":0,"raw":"Total"}`, `{"r":3,"c":1,"raw":"=SUM(B2:B3)"}`} {
+ if w := postCell(t, a, browser, cells, raw); w.Code != 200 {
+ t.Fatal("seed write refused", w.Code)
+ }
+ }
+ // Seed helpers use relative requests; the live fixture then enables the same
+ // canonical-origin protection used by the production app.
+ a.origin = testOrigin
+ if err := a.ConfigureNative(); err != nil {
+ t.Fatal(err)
+ }
+ router := a.Router()
+ credential := reviewNativeCredential(t, router, browser)
+ var refusedSyntheticEdit atomic.Bool
+ var requestedExport atomic.Bool
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Test-only listener adapter. Credentials remain real; only the ephemeral
+ // loopback origin is mapped to the fixture's fixed canonical origin.
+ r.Header.Set("Origin", testOrigin)
+ r.Host = strings.TrimPrefix(testOrigin, "https://")
+ r.URL.Scheme = "https"
+ r.URL.Host = r.Host
+ if os.Getenv("NATIVE_REVIEW_UI_TESTS") != "" && r.Method == "POST" && strings.HasSuffix(r.URL.Path, "/cells/batch") {
+ body, err := io.ReadAll(io.LimitReader(r.Body, (1<<20)+1))
+ if err != nil {
+ http.Error(w, "Isolated fixture could not read edit.", 400)
+ return
+ }
+ r.Body = io.NopCloser(bytes.NewReader(body))
+ if bytes.Contains(body, []byte(`"raw":"=6*7"`)) && refusedSyntheticEdit.CompareAndSwap(false, true) {
+ http.Error(w, "One isolated test edit was deliberately refused.", 503)
+ return
+ }
+ }
+ if r.Method == "GET" && strings.HasSuffix(r.URL.Path, "/export.xlsx") {
+ requestedExport.Store(true)
+ }
+ router.ServeHTTP(w, r)
+ }))
+ defer server.Close()
+ defer server.CloseClientConnections()
+ input, err := json.Marshal(map[string]any{"origin": server.URL, "credential": credential, "output": output})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if simulator == "" {
+ cmd := exec.CommandContext(t.Context(), binary)
+ cmd.Stdin = bytes.NewReader(input)
+ if log, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("native review failed: %v: %s", err, log)
+ }
+ return
+ }
+ bundle := "net.amadan.oficina.sheets.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 fixture failed: %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.MkdirAll(filepath.Dir(path), 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, input, 0600); err != nil {
+ t.Fatal(err)
+ }
+ defer func() {
+ _ = os.Remove(path)
+ // Xcode can migrate a data container while reinstalling the review app.
+ // Resolve again so cleanup follows the file to its current container.
+ current, err := exec.Command("xcrun", "simctl", "get_app_container", simulator, bundle, "data").Output()
+ if err != nil {
+ t.Errorf("resolve review credential cleanup: %v", err)
+ return
+ }
+ credentialPath := filepath.Join(strings.TrimSpace(string(current)), "Documents", "review.json")
+ if err := os.Remove(credentialPath); err != nil && !os.IsNotExist(err) {
+ t.Errorf("remove review credential: %v", err)
+ }
+ staged, _ := filepath.Glob(filepath.Join(strings.TrimSpace(string(current)), "tmp", "oficina-sheets-export-*"))
+ for _, file := range staged {
+ if err := os.RemoveAll(file); err != nil {
+ t.Errorf("remove isolated export artifact: %v", err)
+ }
+ }
+ }()
+ defer exec.Command("xcrun", "simctl", "terminate", simulator, bundle).Run()
+ if os.Getenv("NATIVE_REVIEW_UI_TESTS") != "" {
+ resultBundle := filepath.Join(t.TempDir(), "Review.xcresult")
+ commandContext, cancel := context.WithTimeout(t.Context(), 6*time.Minute)
+ defer cancel()
+ command := exec.CommandContext(commandContext, "xcodebuild", "-project", "native/ios/SheetsPhone.xcodeproj", "-scheme", "SheetsReviewPhone", "-destination", "platform=iOS Simulator,id="+simulator, "-parallel-testing-enabled", "NO", "-derivedDataPath", "native/build/review-ios", "CODE_SIGNING_ALLOWED=NO", "-resultBundlePath", resultBundle, "test-without-building")
+ command.Dir = "../.."
+ only := os.Getenv("NATIVE_REVIEW_UI_ONLY")
+ if only != "" {
+ command.Args = append(command.Args, "-only-testing:SheetsPhoneUITests/"+only)
+ }
+ logPath := filepath.Join(os.TempDir(), "sheets-native-ui-"+simulator+".log")
+ logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer logFile.Close()
+ command.Stdout, command.Stderr = logFile, logFile
+ command.WaitDelay = 5 * time.Second
+ if err := command.Run(); err != nil {
+ t.Fatal("Native UI tests failed; see", logPath, err)
+ }
+ if only == "" {
+ verifyReviewFormula(t, a, credential, seed.ID, true, true)
+ if !refusedSyntheticEdit.Load() || !requestedExport.Load() {
+ t.Fatal("Native retry and export controls did not reach their isolated server paths")
+ }
+ } else if strings.Contains(only, "CreateAndEdit") {
+ verifyReviewFormula(t, a, credential, seed.ID, true, false)
+ } else if strings.Contains(only, "FailedCellEdit") {
+ verifyReviewFormula(t, a, credential, seed.ID, false, true)
+ if !refusedSyntheticEdit.Load() {
+ t.Fatal("Retry UI did not exercise the isolated write refusal")
+ }
+ } else if strings.Contains(only, "Export") && !requestedExport.Load() {
+ t.Fatal("Native export control did not reach the isolated server")
+ }
+ current := strings.TrimSpace(string(run("get_app_container", simulator, bundle, "data")))
+ staged, err := filepath.Glob(filepath.Join(current, "tmp", "oficina-sheets-export-*"))
+ if err != nil || len(staged) != 0 {
+ t.Fatal("Cancelled native export left an unsealed staged workbook")
+ }
+ attachments := filepath.Join(t.TempDir(), "attachments")
+ export := exec.CommandContext(t.Context(), "xcrun", "xcresulttool", "export", "attachments", "--path", resultBundle, "--output-path", attachments)
+ if log, err := export.CombinedOutput(); err != nil {
+ t.Fatalf("export native screenshots: %v: %s", err, log)
+ }
+ data, err := os.ReadFile(filepath.Join(attachments, "manifest.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var manifest []struct {
+ Attachments []struct {
+ ExportedFileName string
+ SuggestedHumanReadableName string
+ }
+ }
+ if err := json.Unmarshal(data, &manifest); err != nil {
+ t.Fatal(err)
+ }
+ for _, test := range manifest {
+ for _, attachment := range test.Attachments {
+ if strings.Contains(attachment.SuggestedHumanReadableName, "Sheets native") {
+ image, err := os.ReadFile(filepath.Join(attachments, filepath.Base(attachment.ExportedFileName)))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(output, image, 0600); err != nil {
+ t.Fatal(err)
+ }
+ return
+ }
+ }
+ }
+ t.Fatal("native formula screenshot attachment missing")
+ }
+ run("launch", simulator, bundle)
+ time.Sleep(3 * time.Second)
+ run("io", simulator, "screenshot", output)
+}
+
+func reviewNativeCredential(t *testing.T, handler http.Handler, browser []*http.Cookie) map[string]string {
+ t.Helper()
+ proof := make([]byte, 32)
+ if _, err := rand.Read(proof); err != nil {
+ t.Fatal(err)
+ }
+ verifier := base64.RawURLEncoding.EncodeToString(proof)
+ hash := sha256.Sum256([]byte(verifier))
+ call := func(path, body string, signed bool) *httptest.ResponseRecorder {
+ r := httptest.NewRequest("POST", testOrigin+path, strings.NewReader(body))
+ r.Header.Set("Origin", testOrigin)
+ 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]string{"name": session.Name, "value": session.Value}
+}
+
+// Verify the UI's result through a fresh authenticated read, so rendering a
+// optimistic value without persistence cannot satisfy this fixture.
+func verifyReviewFormula(t *testing.T, a *App, credential map[string]string, seedID uint64, requireMain, requireRetry bool) {
+ t.Helper()
+ get := func(path string) *httptest.ResponseRecorder {
+ request := nativeGet(path)
+ request.AddCookie(&http.Cookie{Name: credential["name"], Value: credential["value"]})
+ return do(t, a, request)
+ }
+ var list struct{ Sheets []SheetSummary }
+ response := get("/sheets")
+ if response.Code != 200 || json.Unmarshal(response.Body.Bytes(), &list) != nil {
+ t.Fatal("native verification list failed")
+ }
+ verifiedMain, verifiedRetry := false, false
+ for _, sheet := range list.Sheets {
+ if sheet.ID == seedID {
+ continue
+ }
+ response = get(fmt.Sprintf("/api/sheets/%d/tabs/%d/cells?rows=2&cols=2", sheet.ID, sheet.TabID))
+ if response.Code != 200 {
+ t.Fatal("native verification grid failed", response.Code)
+ }
+ grid := decodeGrid(t, response)
+ verified := 0
+ for _, cell := range grid.Cells {
+ if cell.R == 0 && cell.C == 0 && cell.Raw == "=6*7" && cell.Display == "42" {
+ verifiedRetry = true
+ }
+ want := "42"
+ if cell.R == 0 && cell.C == 1 {
+ want = "42.00"
+ }
+ if cell.R < 2 && cell.C < 2 && cell.Raw == "=7*6" && cell.Display == want {
+ verified++
+ }
+ }
+ if verified == 4 {
+ verifiedMain = true
+ }
+ }
+ if (!requireMain || verifiedMain) && (!requireRetry || verifiedRetry) {
+ return
+ }
+ t.Fatal("Native formula, fill, range paste, formatting and history restore did not persist")
+}
diff --git a/internal/sheets/native_test.go b/internal/sheets/native_test.go
new file mode 100644
index 0000000..5629478
--- /dev/null
+++ b/internal/sheets/native_test.go
@@ -0,0 +1,102 @@
+package sheets
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+)
+
+func nativeGet(path string) *http.Request {
+ r := httptest.NewRequest("GET", path, nil)
+ r.Header.Set("Accept", "application/json")
+ return r
+}
+func TestNativeSheetPayloadUsesExistingPermissions(t *testing.T) {
+ a, cookies := signIn(t)
+ s, cells := newSheet(t, a, cookies, "Native secret title")
+ postCell(t, a, cookies, cells, `{"r":0,"c":0,"raw":"=2+3"}`)
+ for _, path := range []string{"/sheets", "/sheets/" + strconv.FormatUint(s.ID, 10), "/sheets/" + strconv.FormatUint(s.ID, 10) + "/history", "/sheets/" + strconv.FormatUint(s.ID, 10) + "/comments", "/sheets/" + strconv.FormatUint(s.ID, 10) + "/table"} {
+ rec := do(t, a, as(nativeGet(path), cookies))
+ if rec.Code != 200 || !json.Valid(rec.Body.Bytes()) || rec.Header().Get("Cache-Control") != "no-store" {
+ t.Fatalf("native payload %s: %d", path, rec.Code)
+ }
+ if strings.Contains(rec.Body.String(), "<svg") {
+ t.Fatal("native payload includes HTML thumbnail")
+ }
+ }
+ rec := do(t, a, nativeGet("/sheets/"+strconv.FormatUint(s.ID, 10)))
+ if rec.Code == 200 {
+ t.Fatal("anonymous native sheet read succeeded")
+ }
+}
+func TestNativeCreateAndHistoryCommandsAnswerJSON(t *testing.T) {
+ a, cookies := signIn(t)
+ r := postForm("/sheets", map[string][]string{"title": {"Created natively"}})
+ r.Header.Set("Accept", "application/json")
+ rec := do(t, a, as(r, cookies))
+ if rec.Code != 201 {
+ t.Fatalf("create=%d", rec.Code)
+ }
+ var s SheetSummary
+ if err := json.Unmarshal(rec.Body.Bytes(), &s); err != nil {
+ t.Fatal(err)
+ }
+ path := "/sheets/" + strconv.FormatUint(s.ID, 10)
+ r = postForm(path+"/rename", map[string][]string{"title": {"Renamed natively"}})
+ r.Header.Set("Accept", "application/json")
+ rec = do(t, a, as(r, cookies))
+ if rec.Code != 200 || !json.Valid(rec.Body.Bytes()) {
+ t.Fatalf("rename=%d", rec.Code)
+ }
+ r = postForm(path+"/undo", nil)
+ r.Header.Set("Accept", "application/json")
+ rec = do(t, a, as(r, cookies))
+ if !strings.Contains(rec.Body.String(), `"nothing"`) {
+ t.Fatalf("undo did not preserve notice: %s", rec.Body.String())
+ }
+}
+func TestNativeNegotiationDoesNotExposeAuthPayloads(t *testing.T) {
+ a, _ := signIn(t)
+ r := nativeGet("/signin")
+ rec := do(t, a, r)
+ if strings.Contains(rec.Header().Get("Content-Type"), "application/json") {
+ t.Fatal("auth page was serialized")
+ }
+}
+
+func TestNativeStreamRechecksRevokedSession(t *testing.T) {
+ a, cookies := signIn(t)
+ s, _ := newSheet(t, a, cookies, "Private stream")
+ srv := liveServer(t, a)
+ response := openStream(t, srv, s, cookies)
+ if response.StatusCode != 200 {
+ t.Fatal(response.StatusCode)
+ }
+ if err := a.db.Exec("DELETE FROM sessions").Error; err != nil {
+ t.Fatal(err)
+ }
+ a.publish(s.TabID, gridResponse{Version: 1, Cells: []gridCell{{R: 0, C: 0, Raw: "must-not-leak", Display: "must-not-leak"}}})
+ event := readEventWithin(t, response.Body, 2*time.Second)
+ if !strings.Contains(event, "access-changed") || strings.Contains(event, "must-not-leak") {
+ t.Fatalf("unexpected event: %s", event)
+ }
+}
+
+func TestNativeGridWindowKeepsOriginalCoordinates(t *testing.T) {
+ a, cookies := signIn(t)
+ _, cells := newSheet(t, a, cookies, "Window")
+ postCell(t, a, cookies, cells, `{"r":30,"c":27,"raw":"=7*8"}`)
+ rec := do(t, a, as(nativeGet(cells+"?firstRow=25&firstCol=26&rows=25&cols=25"), cookies))
+ value := decodeGrid(t, rec)
+ if len(value.Cells) != 1 || value.Cells[0].R != 30 || value.Cells[0].C != 27 || value.Cells[0].Display != "56" {
+ t.Fatalf("window lost source coordinate: %+v", value.Cells)
+ }
+ rec = do(t, a, as(nativeGet(cells+"?firstRow=-1"), cookies))
+ if rec.Code != 400 {
+ t.Fatal("negative offset accepted")
+ }
+}
diff --git a/internal/sheets/render.go b/internal/sheets/render.go
index 1afa1bf..d7fbe30 100644
--- a/internal/sheets/render.go
+++ b/internal/sheets/render.go
@@ -161,6 +161,9 @@ func (a *App) pageData(r *http.Request, title string, data any) PageData {
// page, and the browser shows a truncated document with no indication
// anything went wrong.
func (a *App) render(w http.ResponseWriter, r *http.Request, page, title string, status int, data any) {
+ if nativePage(w, r, page, status, data) {
+ return
+ }
t, ok := pages[page]
if !ok {
http.Error(w, "Something went wrong.", http.StatusInternalServerError)
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/PARITY.md b/native/PARITY.md
new file mode 100644
index 0000000..2a5f6bb
--- /dev/null
+++ b/native/PARITY.md
@@ -0,0 +1,33 @@
+# Native editing parity and verification
+
+The source inventory is in `docs/superpowers/specs/2026-09-13-native-apple-clients.md`.
+This table separates implemented wiring from native UI exercise. Server route
+coverage does not establish that every native control has been exercised.
+
+| Working web action | Native wiring | Verification obtained |
+| --- | --- | --- |
+| Sign in, list and create sheets | Shared native sign-in; workspace and model | Real isolated native approval and iPhone/iPad create flow; JSON list/create tests |
+| Rename | Native panel, canonical rename command | Server JSON mutation test; native control compiles |
+| Edit values/formulas; select cell and display raw formula | Native cell, formula field, serialized writes | iPhone/iPad create, edit, reselect, replace; independent fresh persistence check; Swift draft tests |
+| Range address selection and select all | Coordinate parser, range field and Edit menu | Actual iPad range-address selection; Swift coordinate/reversed range tests; select-all control compiles |
+| Clear, copy, cut, paste and fill down/right | Native clipboard, canonical translated batch writes | Actual iPad single-cell fill down, range copy/paste to B1:B2 and fresh server persistence; Swift TSV/formula-origin/bounds tests; clear/cut/fill-right controls compile |
+| SUM, AVERAGE, COUNT, MIN, MAX insertion | Model insertion method and native menu | Web argument-selection implementation compared; native controls compile |
+| Enter/Tab, Escape, arrows/type-to-edit and shortcuts | Formula field, grid and menu shortcuts | Save/tap exercised on simulators; hardware keyboard sequences not independently exercised |
+| Bold, italic, alignment, number formats, fill/ink and clear formatting | Native format menu, canonical format endpoint | Actual iPad range Number formatting and fresh server format/display verification; server format suite; other native format controls compile |
+| Row height and column width | Native sizes panel, canonical size endpoint | Server size suite; native controls compile |
+| Input undo, history and restore | Edit menu and history panel | Actual iPad undo and first-open History Restore, with fresh authenticated raw/display/history checks; native JSON and server history suites |
+| Non-destructive table sorting/filtering | Native table and view panel, canonical view endpoint | Server suites and source-coordinate snapshot test; native controls compile |
+| Comment thread add/resolve | Native comment panel, canonical routes | JSON access/permission tests and server comment suite; native controls compile |
+| Grants, revoke, viewer/commenter links | Native sharing panel, existing owner-only routes | Native mutation denial tests and server sharing suite; native controls compile |
+| XLSX/XLSM import and job progress | Native picker, original import route and owner-only JSON job polling | Workbook import suite; native owner/other-session polling tests; native picker compiles |
+| XLSX export | Streaming download to explicit private temporary export, system file exporter | Actual iPad destination picker and cancellation; fixture verifies authenticated export request and temporary XLSX removal; final destination write not independently exercised |
+| Live edits, reconnect, resync and revoked access | Stream, tiled snapshots, version reconciliation, server delivery revalidation | SSE framing/version/draft tests; live revoked-session test; native background task compiles |
+| Appearance and imported colour warnings | Appearance selector, native colours and warning text | Actual light iPhone/iPad screenshots inspected; dark mode not independently exercised |
+| Unsaved edit protection | Draft state, retry/discard, navigation confirmation and Mac close/quit guard | Actual iPad forced HTTP503 retains draft, disables new formula edits/selection, and Retry succeeds; Swift pending-batch/draft tests; Mac close/quit prompts compile |
+
+Both simulator UI runs passed the explicitly documented flow in `REVIEW.md`.
+Mac executable builds passed, but the environment did not grant screen capture
+access, so no Mac screenshot is accepted as verification. A signed iOS device
+build was produced; physical-device execution and exhaustive native interaction
+coverage remain separate validation.
+The implementation contains no embedded web editor.
diff --git a/native/README.md b/native/README.md
new file mode 100644
index 0000000..e783e81
--- /dev/null
+++ b/native/README.md
@@ -0,0 +1,57 @@
+# Oficina Sheets for Mac, iPhone and iPad
+
+Entirely native SwiftUI editors use the same authenticated Go calculation,
+formatting, sealed storage and permission paths as the web application.
+No embedded browser editor or second formula engine is used.
+
+Build with Xcode 26.6, Swift 6.2+, and XcodeGen:
+
+```sh
+native/scripts/build-mac.sh
+native/scripts/build-iphone.sh simulator
+```
+
+The macOS bundle is `native/build/Oficina Sheets.app`. Simulator products
+are under `native/build/ios`. Device builds use `build-iphone.sh device`
+and require `OFICINA_DEVELOPMENT_TEAM` and the team's normal signing setup.
+Simulator and device builds use separate DerivedData directories.
+`OFICINA_SKIP_PROJECT_GENERATION=1` allows the suite runner to generate the
+project once before starting parallel builds.
+
+The shared native transport and Mac packaging tools are pinned to reviewed
+revision `a49307c29cf36dff2cf423c3f98b38269ce9a553` of
+`https://amadan.net/rastrillo/native`. Both SwiftPM and Xcode resolve that
+revision; no sibling development checkout is required.
+
+Select a cell, then edit its formula bar, double-click, or type while the
+Mac grid has keyboard focus. Enter saves and moves down; Tab saves and
+moves right; Escape discards the focused cell edit. The range field accepts
+addresses such as `A1:C10`. Menus provide copy/paste, clear, fill, formatting,
+size controls, functions, history, comments, sharing and workbook transfer.
+Sort/filter opens a native table preserving each cell's original reference.
+
+Network content uses ephemeral transport. Requested exports stream to a private
+temporary workbook and are handed to the system file-save picker; the temporary
+file is removed on completion, cancellation or failure. Failed value batches
+remain available for retry/discard; window close and Quit protect pending
+edits. The app does not claim offline editing or sealed local document caches.
+
+Current web limits remain visible: a single worksheet, a 500-row/200-column
+grid window, 500-cell paste limit, first-worksheet XLSX/XLSM import up to
+20 MiB, and no undo for formatting or sizing. Formula evaluation and number
+display come from the Go server. Charts, conditional-format rule editing,
+structural row/column changes and additional worksheet editing do not exist
+in the web baseline and are not represented as finished features here.
+
+Verification:
+
+```sh
+swift test --package-path native/apple
+go test ./...
+```
+
+`SheetsReview` and `SheetsReviewPhone` are separate, unshipped review targets.
+Their fixture server uses temporary databases and ordinary authenticated
+sessions. Production entry points never contain fixture credentials.
+See the native design spec and review records for exercised coverage;
+compiling a target alone does not establish editing parity.
diff --git a/native/REVIEW.md b/native/REVIEW.md
new file mode 100644
index 0000000..2673991
--- /dev/null
+++ b/native/REVIEW.md
@@ -0,0 +1,75 @@
+# Isolated native review
+
+The review targets are separate from the production applications. They accept
+only a loopback fixture, use real magic-link sign-in followed by the shared
+native approval/exchange, and never access a production account. The Go test
+creates and removes its own database. Mac credentials travel over stdin;
+simulator credentials use a mode-0600 `Documents/review.json` that the fixture
+removes after success or failure.
+
+From the repository root, build the simulator review app and UI test runner:
+
+```sh
+xcodegen generate --spec native/ios/project.yml
+xcodebuild -project native/ios/SheetsPhone.xcodeproj \
+ -scheme SheetsReviewPhone -sdk iphonesimulator \
+ -destination 'generic/platform=iOS Simulator' \
+ -derivedDataPath native/build/review-ios CODE_SIGNING_ALLOWED=NO build-for-testing
+```
+
+Set `NATIVE_REVIEW_SIMULATOR` to a booted iPhone or iPad simulator UUID, then run:
+
+```sh
+xcrun simctl install "$NATIVE_REVIEW_SIMULATOR" \
+ native/build/review-ios/Build/Products/Debug-iphonesimulator/SheetsReviewPhone.app
+NATIVE_REVIEW_UI_TESTS=1 NATIVE_REVIEW_OUTPUT=/tmp/sheets-native-review.png \
+ go test ./internal/sheets -run '^TestNativeReview$' -count=1 -timeout 8m
+```
+
+The UI cases create a sheet, enter `=2+3`, check `5`, select the cell to read its
+formula, change it to `=7*6`, and check `42`. They then exercise fill down,
+range copy/paste, number formatting, own input undo and version-history restore.
+A separate case injects one HTTP 503 for the exact synthetic formula `=6*7`,
+checks that its draft remains visible and protected, and retries it. Export
+opens the real system destination picker and cancels; the fixture verifies
+the export request reached the server and no staged XLSX remains afterward.
+It does not test saving a file to an external destination.
+A fresh authenticated server read verifies the resulting raw formulas and
+computed values. To isolate a case, set `NATIVE_REVIEW_UI_ONLY` to
+`NativeUITests/testCreateAndEditFormula`,
+`NativeUITests/testFailedCellEditCanBeRetriedWithoutLosingDraft`, or
+`NativeUITests/testExportWorkbookOpensNativeDestinationAndCancels`.
+The fixture exports the test's screenshot attachment to `NATIVE_REVIEW_OUTPUT`.
+Its xcodebuild log is stored under the operating system temporary directory
+as `sheets-native-ui-<simulator UUID>.log`.
+
+For a Mac window capture:
+
+```sh
+swift build --package-path native/apple --product SheetsReview
+NATIVE_REVIEW_BINARY="$PWD/native/apple/.build/debug/SheetsReview" \
+ NATIVE_REVIEW_OUTPUT=/tmp/sheets-native-mac.png \
+ go test ./internal/sheets -run '^TestNativeReview$' -count=1 -timeout 90s
+```
+
+Mac window capture requires existing macOS screen-recording access. The helper
+checks access and does not request it automatically. On 2026-09-13, the review
+executable compiled and authenticated, but this environment lacked capture
+access; no Mac screenshot was accepted as verification.
+
+On 2026-09-13 the initial formula UI flow and server persistence check passed on iPhone 17 Pro
+and iPad Pro 13-inch (M5), iOS 26.5. The same test first exposed compact-screen
+navigation and cell-selection failures before their fixes. This exercises the
+named flow, not every feature in the parity inventory.
+The expanded three-case iPad UI suite passed after exposing a blank history
+panel and an export filename crash. Its combined Xcode result finalization
+then timed out; separate fixture runs distinguish UI assertions from complete
+server-persistence and cleanup verification. The isolated export case passed
+including its server and temporary-file checks.
+The isolated expanded editing case subsequently passed in 192.565 seconds,
+including its fresh 2×2 formula/format/history checks; the isolated refused-write
+retry passed in 45.066 seconds, including verification that `=6*7` persisted.
+The final export stores the workbook under its title inside a private unique
+directory. Its isolated test passed in 58.071 seconds after a simulator restart,
+including removal of that directory after cancellation. The screenshot was
+visually checked: the system picker displays “Native review workbook”.
diff --git a/native/apple/Package.resolved b/native/apple/Package.resolved
new file mode 100644
index 0000000..64d5af2
--- /dev/null
+++ b/native/apple/Package.resolved
@@ -0,0 +1,14 @@
+{
+ "originHash" : "95848ac3db32ddb942bdc47a84b774889b32bdbc0e52aa27bbbc232f34802013",
+ "pins" : [
+ {
+ "identity" : "native",
+ "kind" : "remoteSourceControl",
+ "location" : "https://amadan.net/rastrillo/native",
+ "state" : {
+ "revision" : "a49307c29cf36dff2cf423c3f98b38269ce9a553"
+ }
+ }
+ ],
+ "version" : 3
+}
diff --git a/native/apple/Package.swift b/native/apple/Package.swift
new file mode 100644
index 0000000..4dc734e
--- /dev/null
+++ b/native/apple/Package.swift
@@ -0,0 +1,9 @@
+// swift-tools-version: 6.2
+import PackageDescription
+let package = Package(name: "SheetsNative", platforms: [.macOS(.v26), .iOS(.v26)],
+ products: [.library(name:"SheetsKit",targets:["SheetsKit"]), .executable(name:"SheetsMac",targets:["SheetsMac"]), .executable(name:"SheetsReview",targets:["SheetsReview"])],
+ dependencies:[.package(url:"https://amadan.net/rastrillo/native",revision:"a49307c29cf36dff2cf423c3f98b38269ce9a553")],
+ targets:[.target(name:"SheetsKit",dependencies:[.product(name:"RastrilloApple",package:"native")]),
+ .executableTarget(name:"SheetsMac",dependencies:["SheetsKit",.product(name:"RastrilloApple",package:"native")],path:"Sources/SheetsApp"),
+ .executableTarget(name:"SheetsReview",dependencies:["SheetsKit",.product(name:"RastrilloApple",package:"native")]),
+ .testTarget(name:"SheetsKitTests",dependencies:["SheetsKit"])])
diff --git a/native/apple/Sources/SheetsApp/SheetsApp.swift b/native/apple/Sources/SheetsApp/SheetsApp.swift
new file mode 100644
index 0000000..508f3a0
--- /dev/null
+++ b/native/apple/Sources/SheetsApp/SheetsApp.swift
@@ -0,0 +1,25 @@
+import RastrilloApple
+import SheetsKit
+import SwiftUI
+
+@main struct SheetsApp: App {
+ #if os(macOS)
+ @NSApplicationDelegateAdaptor(SheetsApplicationDelegate.self) private var appDelegate
+ #endif
+ @StateObject private var connection = NativeConnection(
+ defaultServer: "https://sheets.oficina.works")
+ var body: some Scene {
+ WindowGroup {
+ Group {
+ if let client = connection.client {
+ SheetsWorkspace(client: client, signOut: connection.signOut)
+ } else {
+ NativeSignInView("Oficina Sheets", systemImage: "tablecells", connection: connection)
+ }
+ }.tint(.green)
+ #if os(macOS)
+ .frame(minWidth: 760, minHeight: 600)
+ #endif
+ }
+ }
+}
diff --git a/native/apple/Sources/SheetsKit/Coordinate.swift b/native/apple/Sources/SheetsKit/Coordinate.swift
new file mode 100644
index 0000000..1293619
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/Coordinate.swift
@@ -0,0 +1,47 @@
+import Foundation
+
+struct Coordinate: Hashable, Sendable {
+ let r: Int
+ let c: Int
+ var ref: String {
+ var c = c
+ var label = ""
+ repeat {
+ label = String(UnicodeScalar(65 + c % 26)!) + label
+ c = c / 26 - 1
+ } while c >= 0
+ return label + String(r + 1)
+ }
+ static func parse(_ text: String) -> Coordinate? {
+ let text = text.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
+ let letters = text.prefix { $0.isASCII && $0.isLetter }
+ guard !letters.isEmpty, letters.count <= 3, let row = Int(text.dropFirst(letters.count)),
+ row > 0, row <= 5000
+ else { return nil }
+ let col = letters.utf8.reduce(0) { $0 * 26 + Int($1) - 64 }
+ guard col <= 2000 else { return nil }
+ return Coordinate(r: row - 1, c: col - 1)
+ }
+}
+struct CellRange: Equatable, Sendable {
+ let start: Coordinate
+ let end: Coordinate
+ init(_ a: Coordinate, _ b: Coordinate) {
+ start = Coordinate(r: min(a.r, b.r), c: min(a.c, b.c))
+ end = Coordinate(r: max(a.r, b.r), c: max(a.c, b.c))
+ }
+ static func parse(_ text: String) -> CellRange? {
+ let parts = text.split(separator: ":", omittingEmptySubsequences: false)
+ guard (1...2).contains(parts.count), let a = Coordinate.parse(String(parts[0])),
+ let b = Coordinate.parse(String(parts.last!))
+ else { return nil }
+ return CellRange(a, b)
+ }
+ var coordinates: [Coordinate] {
+ (start.r...end.r).flatMap { r in (start.c...end.c).map { Coordinate(r: r, c: $0) } }
+ }
+ var count: Int { (end.r - start.r + 1) * (end.c - start.c + 1) }
+ func contains(_ p: Coordinate) -> Bool {
+ (start.r...end.r).contains(p.r) && (start.c...end.c).contains(p.c)
+ }
+}
diff --git a/native/apple/Sources/SheetsKit/SheetCell.swift b/native/apple/Sources/SheetsKit/SheetCell.swift
new file mode 100644
index 0000000..d3b499e
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/SheetCell.swift
@@ -0,0 +1,45 @@
+import SwiftUI
+
+struct SheetCell: View {
+ @Bindable var model: SheetModel
+ let point: Coordinate
+ var beginEditing: () -> Void = {}
+ @Environment(\.colorScheme) private var scheme
+ private func width(_ col: Int) -> Double { model.sizes.cols?[String(col)] ?? 110 }
+ private func height(_ row: Int) -> Double { model.sizes.rows?[String(row)] ?? 32 }
+ var body: some View {
+ let cell = model.cells[point]
+ let style = model.styles[cell?.f ?? ""]
+ let alignment: Alignment =
+ style?.a == "center"
+ ? .center : style?.a == "right" || (style?.a == nil && cell?.n == true) ? .trailing : .leading
+ return Button {
+ model.select(point)
+ } label: {
+ Text(cell?.display ?? "").font(.body.weight(style?.b == true ? .bold : .regular))
+ .italic(style?.i == true).lineLimit(1).padding(.horizontal, 5)
+ .frame(width: width(point.c), height: height(point.r), alignment: alignment)
+ .foregroundStyle(ink(style)).background(fill(style))
+ .contentShape(.rect)
+ .overlay {
+ Rectangle().strokeBorder(
+ model.selection.contains(point) ? Color.accentColor : Color.secondary.opacity(0.2),
+ lineWidth: model.selection.contains(point) ? 2 : 0.5)
+ }
+ }.buttonStyle(.plain)
+ .simultaneousGesture(TapGesture(count: 2).onEnded { if model.mayWrite { beginEditing() } })
+ .accessibilityLabel("\(point.ref), \(cell?.display ?? "Empty")")
+ .accessibilityValue(style?.lc == true ? "Imported colours have low contrast" : "")
+ .accessibilityAddTraits(model.selection.contains(point) ? .isSelected : [])
+ }
+ private func fill(_ style: CellStyle?) -> Color {
+ if let hex = style?.fx { return Color(hex: hex) }
+ if let h = style?.fh, let c = style?.fc { return .oklch(scheme == .dark ? 0.30 : 0.92, c, h) }
+ return Color.clear
+ }
+ private func ink(_ style: CellStyle?) -> Color {
+ if let hex = style?.k { return Color(hex: hex) }
+ if let h = style?.kh, let c = style?.kc { return .oklch(scheme == .dark ? 0.80 : 0.42, c, h) }
+ return .primary
+ }
+}
diff --git a/native/apple/Sources/SheetsKit/SheetClipboard.swift b/native/apple/Sources/SheetsKit/SheetClipboard.swift
new file mode 100644
index 0000000..5413ddb
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/SheetClipboard.swift
@@ -0,0 +1,96 @@
+import Foundation
+
+#if os(macOS)
+ import AppKit
+#else
+ import UIKit
+#endif
+@MainActor extension SheetModel {
+ func clearCopyProvenance() {
+ copied = nil
+ copiedText = ""
+ }
+ func copy() {
+ let range = selection
+ let rows = (range.start.r...range.end.r).map { r in
+ (range.start.c...range.end.c).map { c in
+ CellWrite(r: r, c: c, raw: cells[Coordinate(r: r, c: c)]?.raw ?? "")
+ }
+ }
+ copied = rows
+ copiedText = TSV.encode(rows.map { $0.map(\.raw) })
+ #if os(macOS)
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(copiedText, forType: .string)
+ #else
+ UIPasteboard.general.string = copiedText
+ #endif
+ }
+ func paste() async {
+ guard !dirty && !busy else {
+ error = "Save or discard your cell edit first."
+ return
+ }
+ #if os(macOS)
+ let text = NSPasteboard.general.string(forType: .string)
+ #else
+ let text = UIPasteboard.general.string
+ #endif
+ guard let text else {
+ error = "The clipboard has no text cells."
+ return
+ }
+ let values = TSV.decode(text)
+ guard values.reduce(0, { $0 + $1.count }) <= 500 else {
+ error = "Paste supports up to 500 cells at once."
+ return
+ }
+ var writes: [CellWrite] = []
+ for (r, row) in values.enumerated() {
+ for (c, raw) in row.enumerated() {
+ let dest = Coordinate(r: selection.start.r + r, c: selection.start.c + c)
+ guard dest.r < rows && dest.c < cols else {
+ error = "This paste extends beyond the visible sheet. Nothing was changed."
+ return
+ }
+ let source =
+ text == copiedText && copied?.indices.contains(r) == true
+ && copied![r].indices.contains(c) ? copied![r][c] : nil
+ writes.append(CellWrite(r: dest.r, c: dest.c, raw: raw, fromR: source?.r, fromC: source?.c))
+ }
+ }
+ _ = await write(writes)
+ }
+ func clear() async {
+ guard !dirty && !busy else {
+ error = "Save or discard your cell edit first."
+ return
+ }
+ _ = await write(
+ selection.coordinates.filter { !(cells[$0]?.raw ?? "").isEmpty }.map {
+ CellWrite(r: $0.r, c: $0.c, raw: "")
+ })
+ }
+ func fill(down: Bool) async {
+ guard !dirty && !busy else {
+ error = "Save or discard your cell edit first."
+ return
+ }
+ _ = await write(fillWrites(down: down))
+ }
+ func fillWrites(down: Bool) -> [CellWrite] {
+ let source = selection.start
+ let destinations =
+ selection.count == 1
+ ? [
+ Coordinate(
+ r: min(source.r + (down ? 1 : 0), rows - 1), c: min(source.c + (down ? 0 : 1), cols - 1))
+ ]
+ : selection.coordinates
+ return destinations.compactMap { point -> CellWrite? in
+ if source == point { return nil }
+ return CellWrite(
+ r: point.r, c: point.c, raw: cells[source]?.raw ?? "", fromR: source.r, fromC: source.c)
+ }
+ }
+}
diff --git a/native/apple/Sources/SheetsKit/SheetCloseGuard.swift b/native/apple/Sources/SheetsKit/SheetCloseGuard.swift
new file mode 100644
index 0000000..f4fca79
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/SheetCloseGuard.swift
@@ -0,0 +1,89 @@
+#if os(macOS)
+ import AppKit
+ import SwiftUI
+ // Native windows must never silently discard a focused or failed edit.
+ struct SheetCloseGuard: NSViewRepresentable {
+ let model: SheetModel
+ func makeNSView(context: Context) -> CloseGuardView {
+ let view = CloseGuardView()
+ view.model = model
+ return view
+ }
+ func updateNSView(_ view: CloseGuardView, context: Context) { view.model = model }
+ }
+ @MainActor final class CloseGuardView: NSView, NSWindowDelegate {
+ weak var model: SheetModel?
+ // AppKit delegate messaging and mutation occur on the window’s main thread.
+ nonisolated(unsafe) weak var previousDelegate: (any NSWindowDelegate)?
+ override func viewDidMoveToWindow() {
+ super.viewDidMoveToWindow()
+ guard let window, window.delegate !== self else { return }
+ SheetCloseRegistry.views.removeAll { $0.value == nil }
+ if !SheetCloseRegistry.views.contains(where: { $0.value === self }) {
+ SheetCloseRegistry.views.append(WeakCloseGuard(value: self))
+ }
+ previousDelegate = window.delegate
+ window.delegate = self
+ }
+ override func responds(to selector: Selector!) -> Bool {
+ super.responds(to: selector) || previousDelegate?.responds(to: selector) == true
+ }
+ override func forwardingTarget(for selector: Selector!) -> Any? { previousDelegate }
+ func windowShouldClose(_ sender: NSWindow) -> Bool {
+ guard let model else { return previousDelegate?.windowShouldClose?(sender) ?? true }
+ if model.busy {
+ model.error = "Wait for the current save to finish before closing this window."
+ return false
+ }
+ guard model.hasUnconfirmedChanges else {
+ return previousDelegate?.windowShouldClose?(sender) ?? true
+ }
+ let alert = NSAlert()
+ alert.messageText = "Keep your cell edit?"
+ alert.informativeText = "This cell has changes that have not been saved."
+ alert.addButton(withTitle: "Save")
+ alert.addButton(withTitle: "Discard")
+ alert.addButton(withTitle: "Cancel")
+ alert.beginSheetModal(for: sender) { response in
+ Task { @MainActor in
+ if response == .alertFirstButtonReturn {
+ await model.saveAll()
+ if !model.hasUnconfirmedChanges { sender.performClose(nil) }
+ } else if response == .alertSecondButtonReturn {
+ model.discardAll()
+ sender.performClose(nil)
+ }
+ }
+ }
+ return false
+ }
+ }
+ @MainActor struct WeakCloseGuard { weak var value: CloseGuardView? }
+ @MainActor enum SheetCloseRegistry { static var views: [WeakCloseGuard] = [] }
+ @MainActor public final class SheetsApplicationDelegate: NSObject, NSApplicationDelegate {
+ public func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply
+ {
+ let models = SheetCloseRegistry.views.compactMap { $0.value?.model }
+ if models.contains(where: { $0.busy }) { return .terminateCancel }
+ let pending = models.filter { $0.hasUnconfirmedChanges }
+ if pending.isEmpty { return .terminateNow }
+ let alert = NSAlert()
+ alert.messageText = "Save your spreadsheet edits before quitting?"
+ alert.addButton(withTitle: "Save")
+ alert.addButton(withTitle: "Discard")
+ alert.addButton(withTitle: "Cancel")
+ switch alert.runModal() {
+ case .alertFirstButtonReturn:
+ Task { @MainActor in
+ for model in pending { await model.saveAll() }
+ sender.reply(toApplicationShouldTerminate: !pending.contains { $0.hasUnconfirmedChanges })
+ }
+ return .terminateLater
+ case .alertSecondButtonReturn:
+ pending.forEach { $0.discardAll() }
+ return .terminateNow
+ default: return .terminateCancel
+ }
+ }
+ }
+#endif
diff --git a/native/apple/Sources/SheetsKit/SheetColor.swift b/native/apple/Sources/SheetsKit/SheetColor.swift
new file mode 100644
index 0000000..6d8b8c1
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/SheetColor.swift
@@ -0,0 +1,24 @@
+import SwiftUI
+
+extension Color {
+ init(hex: String) {
+ let value = UInt64(hex.trimmingCharacters(in: CharacterSet(charactersIn: "#")), radix: 16) ?? 0
+ self.init(
+ .sRGB, red: Double((value >> 16) & 255) / 255, green: Double((value >> 8) & 255) / 255,
+ blue: Double(value & 255) / 255, opacity: 1)
+ }
+ static func oklch(_ l: Double, _ c: Double, _ h: Double) -> Color {
+ let a = c * cos(h * .pi / 180)
+ let b = c * sin(h * .pi / 180)
+ let x = pow(l + 0.3963377774 * a + 0.2158037573 * b, 3)
+ let y = pow(l - 0.1055613458 * a - 0.0638541728 * b, 3)
+ let z = pow(l - 0.0894841775 * a - 1.291485548 * b, 3)
+ func gamma(_ v: Double) -> Double {
+ min(1, max(0, v <= 0.0031308 ? 12.92 * v : 1.055 * pow(v, 1 / 2.4) - 0.055))
+ }
+ return Color(
+ .sRGB, red: gamma(4.0767416621 * x - 3.3077115913 * y + 0.2309699292 * z),
+ green: gamma(-1.2684380046 * x + 2.6097574011 * y - 0.3413193965 * z),
+ blue: gamma(-0.0041960863 * x - 0.7034186147 * y + 1.707614701 * z), opacity: 1)
+ }
+}
diff --git a/native/apple/Sources/SheetsKit/SheetComments.swift b/native/apple/Sources/SheetsKit/SheetComments.swift
new file mode 100644
index 0000000..1e581b6
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/SheetComments.swift
@@ -0,0 +1,69 @@
+import SwiftUI
+
+struct CommentEntry: Decodable, Identifiable {
+ let ID: UInt64
+ let Author: String
+ let Body: String
+ var id: UInt64 { self.ID }
+}
+struct CommentThread: Decodable, Identifiable {
+ let RowPos: Int
+ let ColPos: Int
+ let Ref: String
+ let Entries: [CommentEntry]
+ let Resolved: Bool
+ var id: String { Ref }
+}
+struct CommentsPage: Decodable { let Threads: [CommentThread]? }
+struct SheetComments: View {
+ @Bindable var model: SheetModel
+ @State private var threads: [CommentThread] = []
+ @State private var bodyText = ""
+ var body: some View {
+ Group {
+ if model.page?.MayComment == true {
+ Text("Comment on " + model.selection.start.ref)
+ TextField("Comment", text: $bodyText, axis: .vertical)
+ Button("Add comment") {
+ Task {
+ await model.command(
+ "/comments",
+ [
+ "r": String(model.selection.start.r), "c": String(model.selection.start.c),
+ "body": bodyText,
+ ])
+ if model.lastNotice == "added" { bodyText = "" }
+ await load()
+ }
+ }.disabled(
+ bodyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || model.dirty
+ || model.busy)
+ }
+ ForEach(threads) { thread in
+ Section(thread.Ref + (thread.Resolved ? " · Resolved" : "")) {
+ ForEach(thread.Entries) { comment in
+ VStack(alignment: .leading) {
+ Text(comment.Author).font(.caption)
+ Text(comment.Body).textSelection(.enabled)
+ }
+ }
+ if !thread.Resolved && model.page?.MayComment == true {
+ Button("Resolve thread") {
+ Task {
+ await model.command(
+ "/comments/resolve", ["r": String(thread.RowPos), "c": String(thread.ColPos)])
+ await load()
+ }
+ }
+ }
+ }
+ }
+ }.task { await load() }
+ }
+ private func load() async {
+ do {
+ let response: CommentsPage = try await model.client.request(model.base + "/comments")
+ threads = response.Threads ?? []
+ } catch { model.error = error.localizedDescription }
+ }
+}
diff --git a/native/apple/Sources/SheetsKit/SheetFiles.swift b/native/apple/Sources/SheetsKit/SheetFiles.swift
new file mode 100644
index 0000000..da8ef7b
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/SheetFiles.swift
@@ -0,0 +1,162 @@
+import CoreTransferable
+import SwiftUI
+import UniformTypeIdentifiers
+
+struct WorkbookExport: Transferable {
+ let url: URL
+ let filename: String
+ init(url: URL, title: String) {
+ self.url = url
+ filename = Self.filename(for: title)
+ }
+ static func filename(for title: String) -> String {
+ var name = title.components(separatedBy: .controlCharacters).joined()
+ .replacingOccurrences(of: "/", with: "-").replacingOccurrences(of: ":", with: "-")
+ .trimmingCharacters(in: .whitespacesAndNewlines.union(CharacterSet(charactersIn: ".")))
+ while name.utf8.count > 200 { name.removeLast() }
+ if name.isEmpty { name = "Workbook" }
+ return name.lowercased().hasSuffix(".xlsx") ? name : name + ".xlsx"
+ }
+ static var transferRepresentation: some TransferRepresentation {
+ FileRepresentation(exportedContentType: UTType(filenameExtension: "xlsx") ?? .data) {
+ workbook in
+ SentTransferredFile(workbook.url, allowAccessingOriginalFile: false)
+ }.suggestedFileName { $0.filename }
+ }
+}
+struct ImportStarted: Decodable { let job: String }
+struct ImportProgress: Decodable {
+ let status: String
+ let progress: String
+ let error: String
+ let location: String
+}
+struct SheetFiles: View {
+ @Bindable var model: SheetModel
+ let importing: Bool
+ @State private var picker = false
+ @State private var exporter = false
+ @State private var workbook: WorkbookExport?
+ @State private var active = true
+ @State private var progress = ""
+ @State private var working = false
+ var body: some View {
+ Group {
+ if importing {
+ Text("Import an .xlsx or .xlsm workbook up to 20 MB. Only its first worksheet is imported.")
+ Button("Choose workbook") { picker = true }.disabled(working)
+ } else {
+ Button("Save workbook…") { Task { await export() } }.disabled(
+ working || model.hasUnconfirmedChanges || model.busy)
+ }
+ if working { ProgressView() }
+ if !progress.isEmpty { Text(progress) }
+ }.fileImporter(
+ isPresented: $picker,
+ allowedContentTypes: [
+ UTType(filenameExtension: "xlsx") ?? .data, UTType(filenameExtension: "xlsm") ?? .data,
+ ]
+ ) { result in
+ switch result {
+ case .success(let url): Task { await upload(url) }
+ case .failure: progress = "Could not open that workbook."
+ }
+ }
+ .fileExporter(
+ isPresented: $exporter, item: workbook,
+ contentTypes: [UTType(filenameExtension: "xlsx") ?? .data],
+ defaultFilename: model.page?.Sheet.Title ?? "Workbook",
+ onCompletion: { result in
+ if case .failure = result { progress = "The workbook could not be saved." }
+ cleanupExport()
+ }, onCancellation: { cleanupExport() }
+ )
+ .onAppear { active = true }
+ .onDisappear {
+ active = false
+ if !exporter { cleanupExport() }
+ }
+ }
+ private func cleanupExport() {
+ if let workbook { try? FileManager.default.removeItem(at: workbook.url.deletingLastPathComponent()) }
+ workbook = nil
+ }
+ private func export() async {
+ guard !model.hasUnconfirmedChanges && !model.busy else {
+ progress = "Save your cell edit first."
+ return
+ }
+ working = true
+ defer { working = false }
+ let directory = FileManager.default.temporaryDirectory
+ .appendingPathComponent("oficina-sheets-export-" + UUID().uuidString, isDirectory: true)
+ let title = model.page?.Sheet.Title ?? "Workbook"
+ let destination = directory.appendingPathComponent(WorkbookExport.filename(for: title))
+ do {
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false,
+ attributes: [.posixPermissions: 0o700])
+ try await model.client.download(model.base + "/export.xlsx", to: destination)
+ guard active else {
+ try? FileManager.default.removeItem(at: directory)
+ return
+ }
+ workbook = WorkbookExport(url: destination, title: title)
+ exporter = true
+ } catch {
+ try? FileManager.default.removeItem(at: directory)
+ progress = error.localizedDescription
+ }
+ }
+ private func upload(_ url: URL) async {
+ working = true
+ defer { working = false }
+ let access = url.startAccessingSecurityScopedResource()
+ defer { if access { url.stopAccessingSecurityScopedResource() } }
+ do {
+ let file = try Data(contentsOf: url, options: .mappedIfSafe)
+ guard file.count <= 20 << 20 else {
+ progress = "The workbook exceeds 20 MB."
+ return
+ }
+ let ext = url.pathExtension.lowercased()
+ guard ext == "xlsx" || ext == "xlsm" else {
+ progress = "Choose an .xlsx or .xlsm workbook."
+ return
+ }
+ let boundary = UUID().uuidString
+ let filename = url.lastPathComponent
+ .replacingOccurrences(of: "\\", with: "\\\\")
+ .replacingOccurrences(of: "\"", with: "\\\"")
+ .replacingOccurrences(of: "\r", with: "")
+ .replacingOccurrences(of: "\n", with: "")
+ var body = Data(
+ "--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\nContent-Type: application/octet-stream\r\n\r\n"
+ .utf8)
+ body.append(file)
+ body.append(Data("\r\n--\(boundary)--\r\n".utf8))
+ let started: ImportStarted = try await model.client.request(
+ "/sheets/import", method: "POST", body: body,
+ contentType: "multipart/form-data; boundary=\(boundary)")
+ while !Task.isCancelled {
+ let state: ImportProgress = try await model.client.request(
+ "/api/native/jobs/\(started.job)")
+ progress = state.progress
+ if state.status == "done" {
+ progress = "Imported"
+ await model.loadList()
+ if let id = UInt64(state.location.split(separator: "/").last ?? ""),
+ let sheet = model.sheets.first(where: { $0.ID == id })
+ {
+ await model.open(sheet)
+ }
+ return
+ }
+ if state.status == "failed" {
+ progress = state.error
+ return
+ }
+ try await Task.sleep(for: .seconds(1))
+ }
+ } catch { progress = error.localizedDescription }
+ }
+}
diff --git a/native/apple/Sources/SheetsKit/SheetFormatMenu.swift b/native/apple/Sources/SheetsKit/SheetFormatMenu.swift
new file mode 100644
index 0000000..e9555ca
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/SheetFormatMenu.swift
@@ -0,0 +1,60 @@
+import SwiftUI
+
+struct SheetFormatMenu: View {
+ @Bindable var model: SheetModel
+ @State private var columnWidth = 110
+ @State private var rowHeight = 32
+ var body: some View {
+ Menu("Format", systemImage: "textformat") {
+ Button("Bold") {
+ Task {
+ await model.format([
+ "bold": !model.selection.coordinates.allSatisfy {
+ model.styles[model.cells[$0]?.f ?? ""]?.b == true
+ }
+ ])
+ }
+ }.keyboardShortcut("b")
+ Button("Italic") {
+ Task {
+ await model.format([
+ "italic": !model.selection.coordinates.allSatisfy {
+ model.styles[model.cells[$0]?.f ?? ""]?.i == true
+ }
+ ])
+ }
+ }.keyboardShortcut("i")
+ Menu("Alignment") {
+ Button("Automatic") { Task { await model.format(["align": ""]) } }
+ ForEach(["left", "center", "right"], id: \.self) { value in
+ Button(value.capitalized) { Task { await model.format(["align": value]) } }
+ }
+ }
+ Menu("Number format") {
+ ForEach(model.page?.NumberFormats ?? []) { value in
+ Button(value.Label) { Task { await model.format(["numfmt": value.Code]) } }
+ }
+ }
+ Menu("Fill colour") {
+ Button("No fill") { Task { await model.format(["fill": "none"]) } }
+ ForEach(model.page?.FillSwatches ?? []) { value in
+ Button(value.Label) { Task { await model.format(["fill": value.Name]) } }
+ }
+ }
+ Menu("Text colour") {
+ Button("Default") { Task { await model.format(["ink": "none"]) } }
+ ForEach(model.page?.InkSwatches ?? []) { value in
+ Button(value.Label) { Task { await model.format(["ink": value.Name]) } }
+ }
+ }
+ Button("Clear formatting") {
+ Task {
+ await model.format([
+ "bold": false, "italic": false, "align": "", "numfmt": "", "fill": "none",
+ "ink": "none",
+ ])
+ }
+ }
+ }.disabled(!model.mayWrite || model.hasUnconfirmedChanges || model.busy)
+ }
+}
diff --git a/native/apple/Sources/SheetsKit/SheetGrid.swift b/native/apple/Sources/SheetsKit/SheetGrid.swift
new file mode 100644
index 0000000..74f3f04
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/SheetGrid.swift
@@ -0,0 +1,62 @@
+import SwiftUI
+
+struct SheetGrid: View {
+ @Bindable var model: SheetModel
+ var beginEditing: () -> Void = {}
+ var body: some View {
+ ScrollView([.horizontal, .vertical]) {
+ LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
+ Section {
+ ForEach(0..<model.rows, id: \.self) { row in
+ HStack(spacing: 0) {
+ Text(String(row + 1)).frame(width: 48, height: height(row)).background(.quaternary)
+ ForEach(0..<model.cols, id: \.self) { col in
+ SheetCell(
+ model: model, point: Coordinate(r: row, c: col), beginEditing: beginEditing)
+ }
+ }
+ }
+ } header: {
+ HStack(spacing: 0) {
+ Text("").frame(width: 48, height: 32)
+ ForEach(0..<model.cols, id: \.self) { col in
+ Text(Coordinate(r: 0, c: col).ref.dropLast()).frame(width: width(col), height: 32)
+ }
+ }.background(.regularMaterial)
+ }
+ }
+ }.accessibilityLabel("Spreadsheet grid")
+ .focusable()
+ .onKeyPress(.leftArrow) { move(0, -1) }
+ .onKeyPress(.rightArrow) { move(0, 1) }
+ .onKeyPress(.upArrow) { move(-1, 0) }
+ .onKeyPress(.downArrow) { move(1, 0) }
+ .onKeyPress(.delete) {
+ guard model.mayWrite else { return .ignored }
+ Task { await model.clear() }
+ return .handled
+ }
+ .onKeyPress(.return) {
+ guard model.mayWrite else { return .ignored }
+ beginEditing()
+ return .handled
+ }
+ .onKeyPress(characters: .alphanumerics.union(.punctuationCharacters).union(.symbols)) {
+ press in
+ guard model.mayWrite, !model.busy, !model.hasUnconfirmedChanges,
+ press.modifiers.intersection([.command, .control, .option]).isEmpty
+ else { return .ignored }
+ model.draft = press.characters
+ beginEditing()
+ return .handled
+ }
+ }
+ private func move(_ r: Int, _ c: Int) -> KeyPress.Result {
+ let p = model.selection.start
+ model.select(
+ Coordinate(r: max(0, min(model.rows - 1, p.r + r)), c: max(0, min(model.cols - 1, p.c + c))))
+ return .handled
+ }
+ private func width(_ col: Int) -> Double { model.sizes.cols?[String(col)] ?? 110 }
+ private func height(_ row: Int) -> Double { model.sizes.rows?[String(row)] ?? 32 }
+}
diff --git a/native/apple/Sources/SheetsKit/SheetLive.swift b/native/apple/Sources/SheetsKit/SheetLive.swift
new file mode 100644
index 0000000..dced629
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/SheetLive.swift
@@ -0,0 +1,76 @@
+import Foundation
+
+@MainActor extension SheetModel {
+ func watch() async {
+ guard page != nil else { return }
+ let identity = page?.Sheet.ID
+ while !Task.isCancelled && page?.Sheet.ID == identity {
+ live = "Connecting"
+ do {
+ // Subscribe before fetching the snapshot in onOpen; buffered deltas
+ // then reconcile by version once the initial fetch finishes.
+ let parser = SheetEventParser()
+ try await client.stream(
+ grid + "/stream",
+ onOpen: { [weak self] in
+ await self?.refresh()
+ await self?.setLive()
+ }
+ ) { [weak self] line in
+ let event = await parser.receive(line)
+ guard let event else { return }
+ await self?.received(event)
+ }
+ } catch is CancellationError { return } catch { live = "Reconnecting" }
+ if live == "Access changed" || live == "Paused" { return }
+ do { try await Task.sleep(for: .seconds(2)) } catch { return }
+ }
+ }
+ private func setLive() { live = "Live" }
+ private func received(_ event: SheetEvent) async {
+ switch event.name {
+ case "cells":
+ live = "Live"
+ if let response = try? JSONDecoder().decode(GridResponse.self, from: Data(event.data.utf8)) {
+ await accept(response)
+ } else {
+ await refresh()
+ }
+ case "resync": await refresh()
+ case "bye":
+ if event.data.contains("access-changed") {
+ live = "Access changed"
+ cells = [:]
+ styles = [:]
+ error = "Your access changed. Reopen the sheet to continue."
+ } else if event.data.contains("idle") {
+ live = "Paused"
+ }
+ default: live = "Live"
+ }
+ }
+}
+struct SheetEvent: Sendable {
+ let name: String
+ let data: String
+}
+actor SheetEventParser {
+ private var name = ""
+ private var data = ""
+ func receive(_ line: String) -> SheetEvent? {
+ if line.isEmpty {
+ let event = SheetEvent(name: name, data: data)
+ name = ""
+ data = ""
+ return event
+ }
+ if line.hasPrefix("event:") {
+ name = String(line.dropFirst(6)).trimmingCharacters(in: .whitespaces)
+ }
+ if line.hasPrefix("data:") {
+ if !data.isEmpty { data += "\n" }
+ data += line.dropFirst(5).trimmingCharacters(in: .whitespaces)
+ }
+ return nil
+ }
+}
diff --git a/native/apple/Sources/SheetsKit/SheetModel.swift b/native/apple/Sources/SheetsKit/SheetModel.swift
new file mode 100644
index 0000000..1cf6c09
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/SheetModel.swift
@@ -0,0 +1,283 @@
+import Foundation
+import Observation
+import RastrilloApple
+
+@MainActor @Observable final class SheetModel {
+ var sheets: [SheetSummary] = []
+ var page: SheetPage?
+ var cells: [Coordinate: Cell] = [:]
+ var styles: [String: CellStyle] = [:]
+ var sizes = Sizes()
+ var version: UInt64 = 0
+ var selection = CellRange(Coordinate(r: 0, c: 0), Coordinate(r: 0, c: 0))
+ var rangeText = "A1"
+ var draft = ""
+ var originalDraft = ""
+ var dirty: Bool { draft != originalDraft }
+ var failedWrites: [CellWrite] = []
+ var hasUnconfirmedChanges: Bool { dirty || !failedWrites.isEmpty }
+ var busy = false
+ var error = ""
+ var notice = ""
+ var live = "Connecting"
+ var lastNotice = ""
+ var history: [HistoryEntry] = []
+ var table: TablePage?
+ var view = ViewSpec()
+ var tablePage = 1
+ var showTable = false
+ var copied: [[CellWrite]]?
+ var copiedText = ""
+ let client: NativeHTTP
+ init(client: NativeHTTP) { self.client = client }
+ var base: String { "/sheets/\(page?.Sheet.ID ?? 0)" }
+ var grid: String { "/api\(base)/tabs/\(page?.Sheet.TabID ?? 0)" }
+ var rows: Int { page?.Rows ?? 100 }
+ var cols: Int { page?.Cols ?? 26 }
+ var mayWrite: Bool { page?.MayWrite == true }
+ func loadList() async {
+ do {
+ let response: SheetList = try await client.request("/sheets")
+ sheets = response.Sheets ?? []
+ } catch { self.error = error.localizedDescription }
+ }
+ func open(_ sheet: SheetSummary) async {
+ guard !hasUnconfirmedChanges && !busy else {
+ error = "Save or discard your cell edit before changing sheets."
+ return
+ }
+ busy = true
+ defer { busy = false }
+ do {
+ let value: SheetPage = try await client.request("/sheets/\(sheet.ID)")
+ clearCopyProvenance()
+ page = value
+ version = 0
+ cells = [:]
+ styles = [:]
+ table = nil
+ history = []
+ showTable = false
+ await refresh()
+ busy = false
+ select(Coordinate(r: 0, c: 0))
+ } catch { self.error = error.localizedDescription }
+ }
+ func refresh() async {
+ guard let sheetID = page?.Sheet.ID else { return }
+ do {
+ var updated: [Coordinate: Cell] = [:]
+ var formats: [String: CellStyle] = [:]
+ var fetchedVersion: UInt64 = .max
+ var fetchedSizes = Sizes()
+ for firstRow in stride(from: 0, to: rows, by: 25) {
+ for firstCol in stride(from: 0, to: cols, by: 50) {
+ try Task.checkCancellation()
+ let path =
+ grid
+ + "/cells?firstRow=\(firstRow)&firstCol=\(firstCol)&rows=\(min(25,rows-firstRow))&cols=\(min(50,cols-firstCol))"
+ let (data, status) = try await client.response(path, maxBytes: 64 << 20)
+ guard status == 200 else {
+ throw NativeClientError.message("The sheet could not be refreshed.")
+ }
+ let response = try JSONDecoder().decode(GridResponse.self, from: data)
+ guard page?.Sheet.ID == sheetID else { return }
+ fetchedVersion = min(fetchedVersion, response.version)
+ for cell in response.cells ?? [] { updated[cell.coordinate] = cell }
+ formats.merge(response.formats ?? [:]) { _, new in new }
+ fetchedSizes = response.sizes ?? Sizes()
+ }
+ }
+ // SSE delivery remains buffered while onOpen awaits this snapshot.
+ if fetchedVersion >= version {
+ version = fetchedVersion
+ cells = updated
+ styles = formats
+ sizes = fetchedSizes
+ refreshDraftIfClean()
+ }
+ } catch is CancellationError { return } catch { self.error = error.localizedDescription }
+ }
+
+ func accept(_ response: GridResponse) async {
+ guard response.version >= version else { return }
+ if response.resync == true || response.version > version + 1 {
+ await refresh()
+ return
+ }
+ version = response.version
+ for cell in response.cells ?? [] { cells[cell.coordinate] = cell }
+ styles.merge(response.formats ?? [:]) { _, new in new }
+ if let sizes = response.sizes { self.sizes = sizes }
+ refreshDraftIfClean()
+ }
+ private func refreshDraftIfClean() {
+ guard !dirty else { return }
+ originalDraft = cells[selection.start]?.raw ?? ""
+ draft = originalDraft
+ }
+ func select(_ point: Coordinate) {
+ guard !hasUnconfirmedChanges && !busy else {
+ error = "Save or discard this edit before selecting another cell."
+ return
+ }
+ selection = CellRange(point, point)
+ rangeText = point.ref
+ originalDraft = cells[point]?.raw ?? ""
+ draft = originalDraft
+ }
+ func selectRange() {
+ guard !hasUnconfirmedChanges && !busy, let range = CellRange.parse(rangeText),
+ range.end.r < rows, range.end.c < cols
+ else {
+ error = "Choose a range inside the visible sheet, after saving your edit."
+ return
+ }
+ selection = range
+ originalDraft = cells[range.start]?.raw ?? ""
+ draft = originalDraft
+ }
+ func discardAll() {
+ failedWrites = []
+ discard()
+ }
+ func saveAll() async {
+ if !failedWrites.isEmpty { await retryWrites() }
+ if failedWrites.isEmpty && dirty { await commit() }
+ }
+ func discard() {
+ originalDraft = cells[selection.start]?.raw ?? ""
+ draft = originalDraft
+ error = ""
+ }
+ func commit(moveRight: Bool = false) async {
+ let point = selection.start
+ let saved = dirty ? await write([CellWrite(r: point.r, c: point.c, raw: draft)]) : true
+ if saved {
+ originalDraft = draft
+ select(
+ Coordinate(
+ r: moveRight ? point.r : min(rows - 1, point.r + 1),
+ c: moveRight ? min(cols - 1, point.c + 1) : point.c))
+ }
+ }
+
+ func format(_ values: [String: Any]) async {
+ guard mayWrite && !busy && !hasUnconfirmedChanges else { return }
+ var values = values
+ values["r1"] = selection.start.r
+ values["c1"] = selection.start.c
+ values["r2"] = selection.end.r
+ values["c2"] = selection.end.c
+ busy = true
+ defer { busy = false }
+ do {
+ let value: GridResponse = try await client.request(
+ grid + "/format", method: "POST", body: JSONSerialization.data(withJSONObject: values))
+ await accept(value)
+ } catch { self.error = error.localizedDescription }
+ }
+ func resize(axis: String, size: Int) async {
+ guard mayWrite && !busy && !hasUnconfirmedChanges else { return }
+ busy = true
+ defer { busy = false }
+ do {
+ let response: GridResponse = try await client.request(
+ grid + "/size", method: "POST",
+ body: JSONSerialization.data(withJSONObject: [
+ axis: axis == "col" ? selection.start.c : selection.start.r, "size": size,
+ ]))
+ await accept(response)
+ } catch { self.error = error.localizedDescription }
+ }
+ func form<T: Decodable>(_ path: String, _ fields: [String: String]) async throws -> T {
+ var parts = URLComponents()
+ parts.queryItems = fields.sorted(by: { $0.key < $1.key }).map {
+ URLQueryItem(name: $0.key, value: $0.value)
+ }
+ let body = (parts.percentEncodedQuery ?? "").replacingOccurrences(of: "+", with: "%2B")
+ let (bytes, status) = try await client.response(
+ path, method: "POST", body: Data(body.utf8), contentType: "application/x-www-form-urlencoded",
+ maxBytes: 64 << 20)
+ guard (200..<300).contains(status) else {
+ if let reply = try? JSONDecoder().decode(Notice.self, from: bytes) {
+ throw NativeClientError.message(reply.message)
+ }
+ throw NativeClientError.message(
+ "The request was not confirmed. Check your access and try again.")
+ }
+ return try JSONDecoder().decode(T.self, from: bytes)
+ }
+ @discardableResult func command(_ suffix: String, _ fields: [String: String] = [:]) async -> Bool
+ {
+ guard !busy && !hasUnconfirmedChanges else {
+ error = "Save or discard the current edit first."
+ return false
+ }
+ lastNotice = ""
+ busy = true
+ defer { busy = false }
+ do {
+ let value: Notice = try await form(base + suffix, fields)
+ lastNotice = value.notice
+ notice = value.message
+ await refresh()
+ return true
+ } catch {
+ self.error = error.localizedDescription
+ return false
+ }
+ }
+ func create() async {
+ guard !hasUnconfirmedChanges && !busy else {
+ error = "Save or discard the current edit first."
+ return
+ }
+ busy = true
+ defer { busy = false }
+ do {
+ let sheet: SheetSummary = try await form("/sheets", [:])
+ await loadList()
+ busy = false
+ await open(sheet)
+ } catch { self.error = error.localizedDescription }
+ }
+ func loadHistory() async {
+ let sheetID = page?.Sheet.ID
+ do {
+ let value: HistoryPage = try await client.request(base + "/history")
+ guard page?.Sheet.ID == sheetID else { return }
+ history = value.Entries ?? []
+ } catch { self.error = error.localizedDescription }
+ }
+ func loadTable() async {
+ let sheetID = page?.Sheet.ID
+ do {
+ let value: TablePage = try await client.request(base + "/table?page=\(tablePage)")
+ guard page?.Sheet.ID == sheetID else { return }
+ table = value
+ view = value.View
+ } catch { self.error = error.localizedDescription }
+ }
+ func saveView() async -> Bool {
+ guard
+ await command(
+ "/view",
+ [
+ "sortCol": String(view.sortCol), "sortDesc": view.sortDesc ? "1" : "",
+ "filterCol": String(view.filterCol), "filterOp": view.filterOp,
+ "filterVal": view.filterVal,
+ ])
+ else { return false }
+ await loadTable()
+ return true
+ }
+ func insertFunction(_ name: String) async {
+ let p = selection.start
+ var top = p.r
+ while top > 0 && !(cells[Coordinate(r: top - 1, c: p.c)]?.raw ?? "").isEmpty { top -= 1 }
+ let arg =
+ top < p.r ? Coordinate(r: top, c: p.c).ref + ":" + Coordinate(r: p.r - 1, c: p.c).ref : ""
+ _ = await write([CellWrite(r: p.r, c: p.c, raw: "=\(name)(\(arg))")])
+ }
+}
diff --git a/native/apple/Sources/SheetsKit/SheetPanels.swift b/native/apple/Sources/SheetsKit/SheetPanels.swift
new file mode 100644
index 0000000..5d7feb7
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/SheetPanels.swift
@@ -0,0 +1,97 @@
+import SwiftUI
+
+struct SheetPanels: View {
+ @Bindable var model: SheetModel
+ let panel: String
+ @Environment(\.dismiss) private var dismiss
+ @State private var title = ""
+ @State private var width = 110
+ @State private var height = 32
+ @State private var mode = "auto"
+ var body: some View {
+ NavigationStack {
+ Form {
+ if !model.error.isEmpty { Text(model.error).foregroundStyle(.red) }
+ switch panel {
+ case "Rename":
+ TextField("Sheet title", text: $title)
+ Button("Rename") {
+ Task {
+ guard await model.command("/rename", ["title": title]) else { return }
+ await model.loadList()
+ if let sheet = model.sheets.first(where: { $0.ID == model.page?.Sheet.ID }) {
+ await model.open(sheet)
+ }
+ dismiss()
+ }
+ }
+ case "Sizes":
+ TextField("Column width", value: $width, format: .number)
+ Button("Set column width") { Task { await model.resize(axis: "col", size: width) } }
+ .disabled(!(16...2000).contains(width))
+ TextField("Row height", value: $height, format: .number)
+ Button("Set row height") { Task { await model.resize(axis: "row", size: height) } }
+ .disabled(!(16...2000).contains(height))
+ Text("Formatting and size changes do not have undo.").font(.caption)
+ case "History":
+ ForEach(model.history) { entry in
+ VStack(alignment: .leading) {
+ Text(entry.Ref).font(.headline)
+ Text(entry.Who).font(.caption)
+ Text("Before: " + entry.Old).textSelection(.enabled)
+ Text("After: " + entry.New).textSelection(.enabled)
+ if model.mayWrite {
+ Button("Restore before this edit") {
+ Task {
+ await model.command("/restore", ["event": String(entry.ID)])
+ await model.loadHistory()
+ }
+ }
+ }
+ }
+ }
+ case "Data":
+ Picker("Sort column", selection: $model.view.sortCol) {
+ Text("None").tag(-1)
+ ForEach(0..<26, id: \.self) { Text(Coordinate(r: 0, c: $0).ref.dropLast()).tag($0) }
+ }
+ Toggle("Descending", isOn: $model.view.sortDesc)
+ Picker("Filter column", selection: $model.view.filterCol) {
+ Text("None").tag(-1)
+ ForEach(0..<26, id: \.self) { Text(Coordinate(r: 0, c: $0).ref.dropLast()).tag($0) }
+ }
+ Picker("Comparison", selection: $model.view.filterOp) {
+ ForEach(["contains", "equals", "gt", "lt", "nonempty"], id: \.self) { Text($0).tag($0) }
+ }
+ TextField("Filter value", text: $model.view.filterVal)
+ Button("Apply and open table") {
+ Task {
+ guard await model.saveView() else { return }
+ model.showTable = true
+ dismiss()
+ }
+ }.disabled(!model.mayWrite)
+ Button("Open table") {
+ model.showTable = true
+ dismiss()
+ }
+ Text("Sorting changes display order. Cell references stay in their original positions.")
+ .font(.caption)
+ case "Comments": SheetComments(model: model)
+ case "Share": SheetSharing(model: model)
+ case "Import", "Export": SheetFiles(model: model, importing: panel == "Import")
+ default: EmptyView()
+ }
+ }.formStyle(.grouped).navigationTitle(panel)
+ .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
+ }
+ #if os(macOS)
+ .frame(minWidth: 480, minHeight: 500)
+ #endif
+ .task {
+ title = model.page?.Sheet.Title ?? ""
+ if panel == "History" { await model.loadHistory() }
+ if panel == "Data" { await model.loadTable() }
+ }
+ }
+}
diff --git a/native/apple/Sources/SheetsKit/SheetSharing.swift b/native/apple/Sources/SheetsKit/SheetSharing.swift
new file mode 100644
index 0000000..5251571
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/SheetSharing.swift
@@ -0,0 +1,85 @@
+import SwiftUI
+
+struct SharedMember: Decodable {
+ let ID: UInt64
+ let Address: String
+}
+struct SharedPerson: Decodable, Identifiable {
+ let Member: SharedMember
+ let Role: String
+ var id: UInt64 { Member.ID }
+}
+struct SharedLink: Decodable, Identifiable {
+ let ID: UInt64
+ let Role: String
+ var id: UInt64 { self.ID }
+}
+struct SharingPage: Decodable {
+ let People: [SharedPerson]?
+ let Links: [SharedLink]?
+ let NewLink: String
+ let Notice: String
+}
+struct SheetSharing: View {
+ @Bindable var model: SheetModel
+ @State private var sharing: SharingPage?
+ @State private var address = ""
+ @State private var role = "viewer"
+ @State private var linkRole = "viewer"
+ var body: some View {
+ Group {
+ Text("People you share with can see your team’s name.").font(.callout)
+ TextField("Email address", text: $address)
+ Picker("Access", selection: $role) {
+ ForEach(["viewer", "commenter", "editor", "owner"], id: \.self) {
+ Text($0.capitalized).tag($0)
+ }
+ }
+ Button("Grant access") {
+ Task {
+ await model.command("/share", ["address": address, "role": role])
+ await load()
+ }
+ }.disabled(address.isEmpty)
+ ForEach(sharing?.People ?? []) { person in
+ LabeledContent(person.Member.Address, value: person.Role)
+ Button("Remove access", role: .destructive) {
+ Task {
+ await model.command("/share/revoke", ["member": String(person.id)])
+ await load()
+ }
+ }
+ }
+ Picker("Link access", selection: $linkRole) {
+ Text("Viewer").tag("viewer")
+ Text("Commenter").tag("commenter")
+ }
+ Button("Create link") {
+ Task {
+ do {
+ sharing = try await model.form(model.base + "/share/link", ["role": linkRole])
+ } catch { model.error = error.localizedDescription }
+ }
+ }
+ if let sharing, !sharing.NewLink.isEmpty {
+ Text(sharing.NewLink).textSelection(.enabled)
+ ShareLink(item: sharing.NewLink)
+ Text("Copy this link now. It is shown only once.").font(.caption)
+ }
+ ForEach(sharing?.Links ?? []) { link in
+ Button("Revoke \(link.Role) link \(link.ID)", role: .destructive) {
+ Task {
+ await model.command("/share/link/revoke", ["link": String(link.ID)])
+ await load()
+ }
+ }
+ }
+ Text(model.notice).font(.callout)
+ }.task { await load() }
+ }
+ private func load() async {
+ do { sharing = try await model.client.request(model.base + "/share") } catch {
+ model.error = error.localizedDescription
+ }
+ }
+}
diff --git a/native/apple/Sources/SheetsKit/SheetTable.swift b/native/apple/Sources/SheetsKit/SheetTable.swift
new file mode 100644
index 0000000..3d6d4b1
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/SheetTable.swift
@@ -0,0 +1,33 @@
+import SwiftUI
+
+struct SheetTable: View {
+ @Bindable var model: SheetModel
+ var body: some View {
+ VStack {
+ HStack {
+ Button("Back to grid") { model.showTable = false }
+ Button("Previous page") {
+ model.tablePage -= 1
+ Task { await model.loadTable() }
+ }.disabled(model.table?.HasPrev != true || model.hasUnconfirmedChanges || model.busy)
+ Text("Page \(model.tablePage)")
+ Button("Next page") {
+ model.tablePage += 1
+ Task { await model.loadTable() }
+ }.disabled(model.table?.HasNext != true || model.hasUnconfirmedChanges || model.busy)
+ }
+ ScrollView([.horizontal, .vertical]) {
+ LazyVStack(spacing: 0) {
+ ForEach(model.table?.Rows ?? [], id: \.Number) { row in
+ HStack(spacing: 0) {
+ Text(String(row.Number)).frame(width: 48)
+ ForEach(row.Cells, id: \.C) { cell in
+ SheetCell(model: model, point: Coordinate(r: cell.R, c: cell.C))
+ }
+ }
+ }
+ }
+ }
+ }.task { await model.loadTable() }
+ }
+}
diff --git a/native/apple/Sources/SheetsKit/SheetWire.swift b/native/apple/Sources/SheetsKit/SheetWire.swift
new file mode 100644
index 0000000..fc56d43
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/SheetWire.swift
@@ -0,0 +1,112 @@
+import Foundation
+
+struct SheetSummary: Decodable, Identifiable, Hashable, Sendable {
+ let ID: UInt64
+ let Title: String
+ let TabID: UInt64
+ let TabName: String
+ var id: UInt64 { self.ID }
+}
+struct SheetList: Decodable { let Sheets: [SheetSummary]? }
+struct PaletteItem: Decodable, Identifiable {
+ let Name: String
+ let Label: String
+ var id: String { Name }
+}
+struct NumberStyle: Decodable, Identifiable {
+ let Code: String
+ let Label: String
+ var id: String { Code }
+}
+struct SheetPage: Decodable {
+ let Sheet: SheetSummary
+ let Rows: Int
+ let Cols: Int
+ let Truncated: Bool
+ let MayWrite: Bool
+ let MayComment: Bool
+ let MayShare: Bool
+ let FillSwatches: [PaletteItem]
+ let InkSwatches: [PaletteItem]
+ let NumberFormats: [NumberStyle]
+ let Warned: [String]?
+}
+struct Cell: Codable, Sendable {
+ let r: Int
+ let c: Int
+ var raw: String
+ var display: String
+ var n: Bool?
+ var f: String?
+ var coordinate: Coordinate { Coordinate(r: r, c: c) }
+}
+struct CellStyle: Decodable, Sendable {
+ var b: Bool?
+ var i: Bool?
+ var a: String?
+ var n: String?
+ var fx: String?
+ var k: String?
+ var fh: Double?
+ var fc: Double?
+ var kh: Double?
+ var kc: Double?
+ var lc: Bool?
+}
+struct Sizes: Decodable, Sendable {
+ var cols: [String: Double]?
+ var rows: [String: Double]?
+}
+struct GridResponse: Decodable, Sendable {
+ let version: UInt64
+ var rows: Int?
+ var cols: Int?
+ var cells: [Cell]?
+ var formats: [String: CellStyle]?
+ var sizes: Sizes?
+ var resync: Bool?
+}
+struct CellWrite: Encodable, Sendable {
+ let r: Int
+ let c: Int
+ let raw: String
+ var fromR: Int?
+ var fromC: Int?
+}
+struct Notice: Decodable {
+ let notice: String
+ let message: String
+}
+struct HistoryEntry: Decodable, Identifiable {
+ let ID: UInt64
+ let Ref: String
+ let Who: String
+ let Old: String
+ let New: String
+ let When: String
+ var id: UInt64 { self.ID }
+}
+struct HistoryPage: Decodable { let Entries: [HistoryEntry]? }
+struct ViewSpec: Codable {
+ var sortCol = -1
+ var sortDesc = false
+ var filterCol = -1
+ var filterOp = "contains"
+ var filterVal = ""
+}
+struct TableCell: Decodable {
+ let R: Int
+ let C: Int
+ let Raw: String
+ let Display: String
+}
+struct TableRow: Decodable {
+ let Number: Int
+ let Cells: [TableCell]
+}
+struct TablePage: Decodable {
+ let View: ViewSpec
+ let Rows: [TableRow]?
+ let HasNext: Bool
+ let HasPrev: Bool
+}
diff --git a/native/apple/Sources/SheetsKit/SheetWrites.swift b/native/apple/Sources/SheetsKit/SheetWrites.swift
new file mode 100644
index 0000000..1f6384f
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/SheetWrites.swift
@@ -0,0 +1,58 @@
+import Foundation
+import RastrilloApple
+
+@MainActor extension SheetModel {
+ @discardableResult func write(_ writes: [CellWrite], retrying: Bool = false) async -> Bool {
+ guard failedWrites.isEmpty || retrying else {
+ error = "Retry or discard the pending edits before making another change."
+ return false
+ }
+ guard mayWrite && !busy else { return false }
+ guard writes.allSatisfy({ $0.raw.utf8.count <= 8192 }) else {
+ error = "A cell exceeds the input size limit. Nothing was changed."
+ return false
+ }
+ if writes.isEmpty { return true }
+ busy = true
+ defer { busy = false }
+ var offset = 0
+ do {
+ struct Batch: Encodable { let cells: [CellWrite] }
+ while offset < writes.count {
+ var count = min(500, writes.count - offset)
+ var data = try JSONEncoder().encode(Batch(cells: Array(writes[offset..<offset + count])))
+ while data.count > 1 << 20 && count > 1 {
+ count /= 2
+ data = try JSONEncoder().encode(Batch(cells: Array(writes[offset..<offset + count])))
+ }
+ let (bytes, status) = try await client.response(
+ grid + "/cells/batch", method: "POST", body: data, maxBytes: 64 << 20)
+ guard (200..<300).contains(status) else {
+ throw NativeClientError.message("The server did not confirm the edit.")
+ }
+ let response = try JSONDecoder().decode(GridResponse.self, from: bytes)
+ await accept(response)
+ offset += count
+ }
+ failedWrites = []
+ error = ""
+ notice = "Saved"
+ if showTable { await loadTable() }
+ return true
+ } catch {
+ failedWrites = Array(writes.dropFirst(offset))
+ self.error =
+ "\(offset) cells confirmed; \(failedWrites.count) still need saving. "
+ + error.localizedDescription
+ return false
+ }
+ }
+ func retryWrites() async {
+ let pending = failedWrites
+ if await write(pending, retrying: true), pending.count == 1, let value = pending.first,
+ value.r == selection.start.r, value.c == selection.start.c, value.raw == draft
+ {
+ originalDraft = draft
+ }
+ }
+}
diff --git a/native/apple/Sources/SheetsKit/SheetsWorkspace.swift b/native/apple/Sources/SheetsKit/SheetsWorkspace.swift
new file mode 100644
index 0000000..dabc154
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/SheetsWorkspace.swift
@@ -0,0 +1,207 @@
+import RastrilloApple
+import SwiftUI
+
+@MainActor public struct SheetsWorkspace: View {
+ @State private var model: SheetModel
+ @State private var column = NavigationSplitViewColumn.sidebar
+ private struct Panel: Identifiable {
+ let name: String
+ var id: String { name }
+ }
+ @State private var panel: Panel?
+ @State private var title = ""
+ @State private var pendingSheet: SheetSummary?
+ @State private var askDiscard = false
+ @FocusState private var formulaFocused: Bool
+ @Environment(\.scenePhase) private var scenePhase
+ @AppStorage("sheets.appearance") private var appearance = "auto"
+ private let signOut: () -> Void
+ public init(client: NativeHTTP, signOut: @escaping () -> Void) {
+ _model = State(initialValue: SheetModel(client: client))
+ self.signOut = signOut
+ }
+ public var body: some View {
+ NavigationSplitView(preferredCompactColumn: $column) {
+ List {
+ ForEach(model.sheets) { sheet in
+ Button(sheet.Title) {
+ if model.hasUnconfirmedChanges {
+ pendingSheet = sheet
+ askDiscard = true
+ } else {
+ Task { await model.open(sheet) }
+ }
+ }.disabled(model.busy)
+ }
+ Button("New sheet", systemImage: "plus") { Task { await model.create() } }.disabled(
+ model.hasUnconfirmedChanges || model.busy)
+ Button("Import workbook", systemImage: "square.and.arrow.down") { show("Import") }.disabled(
+ model.hasUnconfirmedChanges || model.busy)
+ Picker("Appearance", selection: $appearance) {
+ Text("System").tag("auto")
+ Text("Light").tag("light")
+ Text("Dark").tag("dark")
+ }
+ Button("Sign out") {
+ if !model.hasUnconfirmedChanges && !model.busy {
+ signOut()
+ } else {
+ model.error = "Save or discard your cell edit before signing out."
+ }
+ }
+ }.navigationTitle("Sheets")
+ } detail: {
+ if model.page != nil {
+ editor
+ } else {
+ ContentUnavailableView(
+ "Choose a sheet", systemImage: "tablecells",
+ description: Text("Create a spreadsheet or open one from the list."))
+ }
+ }
+ .task { await model.loadList() }
+ .onChange(of: model.page?.Sheet.ID) { _, value in if value != nil { column = .detail } }
+ .onChange(of: scenePhase) { _, value in
+ if value != .active { model.clearCopyProvenance() }
+ }
+ .task(id: "\(model.page?.Sheet.ID ?? 0)-\(scenePhase == .active)") {
+ if scenePhase == .active { await model.watch() }
+ }
+ .preferredColorScheme(appearance == "dark" ? .dark : appearance == "light" ? .light : nil)
+ #if os(macOS)
+ .background(SheetCloseGuard(model: model).frame(width: 0, height: 0))
+ #endif
+ .sheet(item: $panel) { item in SheetPanels(model: model, panel: item.name) }
+ .confirmationDialog("Keep your cell edit?", isPresented: $askDiscard, titleVisibility: .visible)
+ {
+ Button("Save and continue") {
+ Task {
+ await model.saveAll()
+ if !model.hasUnconfirmedChanges, let next = pendingSheet { await model.open(next) }
+ }
+ }
+ Button("Discard and continue", role: .destructive) {
+ model.discardAll()
+ if let next = pendingSheet { Task { await model.open(next) } }
+ }
+ Button("Cancel", role: .cancel) { pendingSheet = nil }
+ }
+ }
+ private func show(_ name: String) {
+ panel = Panel(name: name)
+ }
+ @ViewBuilder private var editorCommands: some View {
+ Menu("File") {
+ Button("Rename") { show("Rename") }.disabled(!model.mayWrite)
+ Button("Export workbook") { show("Export") }.disabled(model.busy || model.dirty)
+ Button("Version history") { show("History") }
+ if model.page?.MayShare == true { Button("Share") { show("Share") } }
+ }
+ Menu("Edit") {
+ Button("Undo my last value edit") { Task { await model.command("/undo") } }.disabled(
+ !model.mayWrite
+ )
+ .keyboardShortcut("z")
+ Button("Copy") { model.copy() }.keyboardShortcut("c")
+ Button("Cut") {
+ model.copy()
+ Task { await model.clear() }
+ }.keyboardShortcut("x").disabled(!model.mayWrite)
+ Button("Paste") { Task { await model.paste() } }.keyboardShortcut("v").disabled(
+ !model.mayWrite)
+ Button("Clear contents") { Task { await model.clear() } }.disabled(!model.mayWrite)
+ Button("Fill down") { Task { await model.fill(down: true) } }.keyboardShortcut("d").disabled(
+ !model.mayWrite)
+ Button("Fill right") { Task { await model.fill(down: false) } }.disabled(!model.mayWrite)
+ Button("Select all") {
+ model.rangeText = "A1:" + Coordinate(r: model.rows - 1, c: model.cols - 1).ref
+ model.selectRange()
+ }.keyboardShortcut("a")
+ }.disabled(model.hasUnconfirmedChanges || model.busy)
+ Menu("Insert function") {
+ ForEach(["SUM", "AVERAGE", "COUNT", "MIN", "MAX"], id: \.self) { name in
+ Button(name) { Task { await model.insertFunction(name) } }
+ }
+ }.disabled(!model.mayWrite || model.hasUnconfirmedChanges || model.busy)
+ SheetFormatMenu(model: model)
+ Button("Sizes") { show("Sizes") }.disabled(
+ !model.mayWrite || model.hasUnconfirmedChanges || model.busy)
+ Button("Sort and filter") { show("Data") }.disabled(
+ model.hasUnconfirmedChanges || model.busy)
+ Button("Comments") { show("Comments") }
+ Button("Refresh", systemImage: "arrow.clockwise") { Task { await model.refresh() } }
+ }
+ private var editor: some View {
+ VStack(spacing: 0) {
+ ViewThatFits(in: .horizontal) {
+ HStack { editorCommands }.fixedSize(horizontal: true, vertical: false)
+ Menu("Sheet actions", systemImage: "ellipsis.circle") { editorCommands }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }.padding(8)
+ HStack {
+ TextField("Cell or range", text: $model.rangeText).frame(width: 110).onSubmit {
+ model.selectRange()
+ }.accessibilityLabel("Cell or range, for example A1:B5")
+ Text("fx").foregroundStyle(.secondary)
+ TextField("Cell contents or formula", text: $model.draft).focused($formulaFocused).disabled(
+ !model.mayWrite || model.busy || !model.failedWrites.isEmpty
+ )
+ .autocorrectionDisabled()
+ #if os(iOS)
+ .textInputAutocapitalization(.never)
+ #endif
+ .onSubmit { Task { await model.commit() } }
+ .onKeyPress(.escape) {
+ model.discard()
+ return .handled
+ }
+ .onKeyPress(.tab) {
+ Task { await model.commit(moveRight: true) }
+ return .handled
+ }
+ if model.dirty {
+ Button("Save") { Task { await model.commit() } }.disabled(model.busy)
+ Button("Discard") { model.discard() }.disabled(model.busy)
+ }
+ }.textFieldStyle(.roundedBorder).padding(8)
+ if !model.failedWrites.isEmpty {
+ HStack {
+ Button("Retry pending edits") { Task { await model.retryWrites() } }.disabled(model.busy)
+ Button("Discard pending edits", role: .destructive) {
+ model.failedWrites = []
+ model.discard()
+ }.disabled(model.busy)
+ }.padding(8)
+ }
+ if !model.error.isEmpty {
+ Text(model.error).foregroundStyle(.red).padding(8).accessibilityAddTraits(
+ .updatesFrequently)
+ }
+ if model.showTable {
+ SheetTable(model: model)
+ } else {
+ SheetGrid(model: model, beginEditing: { formulaFocused = true })
+ }
+ HStack {
+ Text(model.page?.Sheet.TabName ?? "Sheet")
+ Spacer()
+ if model.busy { ProgressView().controlSize(.small) }
+ Text(model.dirty ? "Unsaved cell edit" : model.notice)
+ Text(model.live).foregroundStyle(.secondary)
+ }.font(.caption).padding(8)
+ if model.page?.Truncated == true {
+ Text("This grid shows up to 500 rows and 200 columns. Export includes the complete sheet.")
+ .font(.caption).padding(4)
+ }
+ if !(model.page?.Warned ?? []).isEmpty {
+ Text(
+ "Imported colours have low contrast in: "
+ + (model.page?.Warned ?? []).joined(separator: ", ")
+ ).font(.caption).padding(4)
+ }
+ }.navigationTitle(model.page?.Sheet.Title ?? "Sheets")
+ #if os(iOS)
+ .navigationBarTitleDisplayMode(.inline)
+ #endif
+ }
+}
diff --git a/native/apple/Sources/SheetsKit/TSV.swift b/native/apple/Sources/SheetsKit/TSV.swift
new file mode 100644
index 0000000..f1698f5
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/TSV.swift
@@ -0,0 +1,52 @@
+import Foundation
+
+// Quoting matches spreadsheets: tabs/newlines/quotes round-trip as cell content.
+enum TSV {
+ static func encode(_ rows: [[String]]) -> String {
+ rows.map {
+ $0.map { text in
+ text.contains(where: {
+ $0 == "\t" || $0 == "\r" || $0 == "\n" || $0 == "\r\n" || $0 == "\""
+ }) ? "\"" + text.replacingOccurrences(of: "\"", with: "\"\"") + "\"" : text
+ }.joined(separator: "\t")
+ }.joined(separator: "\n")
+ }
+ static func decode(_ text: String) -> [[String]] {
+ let chars = Array(text)
+ var rows: [[String]] = []
+ var row: [String] = []
+ var cell = ""
+ var quoted = false
+ var i = 0
+ while i < chars.count {
+ let ch = chars[i]
+ if ch == "\"" {
+ if quoted && i + 1 < chars.count && chars[i + 1] == "\"" {
+ cell.append(ch)
+ i += 1
+ } else if quoted || cell.isEmpty {
+ quoted.toggle()
+ } else {
+ cell.append(ch)
+ }
+ } else if !quoted && ch == "\t" {
+ row.append(cell)
+ cell = ""
+ } else if !quoted && (ch == "\n" || ch == "\r\n" || ch == "\r") {
+ row.append(cell)
+ rows.append(row)
+ row = []
+ cell = ""
+ } else {
+ cell.append(ch)
+ }
+ i += 1
+ }
+ row.append(cell)
+ rows.append(row)
+ if rows.last == [""] && (text.last == "\n" || text.last == "\r\n" || text.last == "\r") {
+ rows.removeLast()
+ }
+ return rows
+ }
+}
diff --git a/native/apple/Sources/SheetsReview/Review.swift b/native/apple/Sources/SheetsReview/Review.swift
new file mode 100644
index 0000000..d7b0044
--- /dev/null
+++ b/native/apple/Sources/SheetsReview/Review.swift
@@ -0,0 +1,63 @@
+// Test-only executable; isolated credentials arrive through stdin, never argv.
+import AppKit
+import SwiftUI
+import RastrilloApple
+import SheetsKit
+
+@main struct Review: App {
+ private let input: ReviewInput
+ private let client: NativeHTTP
+
+ init() {
+ input = try! JSONDecoder().decode(ReviewInput.self, from: FileHandle.standardInput.readDataToEndOfFile())
+ precondition(input.origin.scheme == "http" && input.origin.host == "127.0.0.1")
+ client = NativeHTTP(origin: input.origin, credential: input.credential)
+ }
+
+ var body: some Scene {
+ WindowGroup("Sheets native review") {
+ SheetsWorkspace(client: client, signOut: {})
+ .preferredColorScheme(.light)
+ .task { await capture() }
+ }.defaultSize(width: 1200, height: 820)
+ }
+
+ @MainActor private func capture() async {
+ do {
+ guard CGPreflightScreenCaptureAccess() else {
+ FileHandle.standardError.write(Data("macOS screen-capture access is unavailable for the review executable.\n".utf8))
+ exit(2)
+ }
+ try await Task.sleep(for: .milliseconds(500))
+ guard let window = NSApplication.shared.windows.first(where: { $0.title == "Sheets native review" }) else {
+ throw NativeClientError.message("Review window did not open.")
+ }
+ window.makeKeyAndOrderFront(nil)
+ NSApplication.shared.activate()
+ for _ in 0..<40 {
+ if let view = window.contentView, pressFixture(in: view) { break }
+ try await Task.sleep(for: .milliseconds(100))
+ }
+ try await Task.sleep(for: .seconds(2))
+ let screenshot = Process()
+ screenshot.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture")
+ screenshot.arguments = ["-x", "-o", "-l", String(window.windowNumber), input.output]
+ try screenshot.run()
+ while screenshot.isRunning { try await Task.sleep(for: .milliseconds(50)) }
+ guard screenshot.terminationStatus == 0 else { throw NativeClientError.message("Review window capture failed.") }
+ NSApplication.shared.terminate(nil)
+ } catch {
+ FileHandle.standardError.write(Data("Native review capture failed.\n".utf8))
+ exit(1)
+ }
+ }
+
+ @MainActor private func pressFixture(in element: Any) -> Bool {
+ guard let accessible = element as? any NSAccessibilityProtocol else { return false }
+ if accessible.accessibilityLabel() == "Native review workbook", accessible.accessibilityPerformPress() { return true }
+ for child in accessible.accessibilityChildren() ?? [] {
+ if pressFixture(in: child) { return true }
+ }
+ return false
+ }
+}
diff --git a/native/apple/Sources/SheetsReview/ReviewInput.swift b/native/apple/Sources/SheetsReview/ReviewInput.swift
new file mode 100644
index 0000000..c89dde8
--- /dev/null
+++ b/native/apple/Sources/SheetsReview/ReviewInput.swift
@@ -0,0 +1,8 @@
+import Foundation
+import RastrilloApple
+
+struct ReviewInput: Decodable {
+ let origin: URL
+ let credential: NativeSession
+ let output: String
+}
diff --git a/native/apple/Tests/SheetsKitTests/EditingTests.swift b/native/apple/Tests/SheetsKitTests/EditingTests.swift
new file mode 100644
index 0000000..60116b6
--- /dev/null
+++ b/native/apple/Tests/SheetsKitTests/EditingTests.swift
@@ -0,0 +1,118 @@
+import Foundation
+import RastrilloApple
+import Testing
+
+@testable import SheetsKit
+
+@Test func coordinatesAndReverseRanges() {
+ #expect(Coordinate.parse("aa151") == Coordinate(r: 150, c: 26))
+ #expect(Coordinate.parse("A0") == nil)
+ #expect(Coordinate.parse("A1junk") == nil)
+ #expect(CellRange.parse("B3:A1")?.count == 6)
+ #expect(CellRange.parse("A1:") == nil)
+}
+@Test func spreadsheetClipboardRoundTrip() {
+ let values = [["a\tb", "line\nnext", "say \"hello\""], ["", "=A1+$B$2", ""]]
+ #expect(TSV.decode(TSV.encode(values)) == values)
+ #expect(TSV.decode("a\tb\r\nc\td\r\n") == [["a", "b"], ["c", "d"]])
+}
+
+@MainActor @Test func fillCopiesAnchorWithFormulaOriginAndBounds() {
+ let model = SheetModel(client: NativeHTTP(origin: URL(string: "http://localhost")!))
+ model.cells[Coordinate(r: 0, c: 0)] = Cell(r: 0, c: 0, raw: "=B1", display: "0")
+ let down = model.fillWrites(down: true)
+ #expect(down.count == 1)
+ #expect(down.first?.r == 1 && down.first?.c == 0)
+ #expect(down.first?.raw == "=B1")
+ #expect(down.first?.fromR == 0 && down.first?.fromC == 0)
+ let right = model.fillWrites(down: false)
+ #expect(right.first?.r == 0 && right.first?.c == 1)
+ model.selection = CellRange(Coordinate(r: 0, c: 0), Coordinate(r: 1, c: 1))
+ #expect(model.fillWrites(down: true).count == 3)
+ #expect(
+ model.fillWrites(down: true).allSatisfy { $0.raw == "=B1" && $0.fromR == 0 && $0.fromC == 0 })
+ let end = Coordinate(r: model.rows - 1, c: model.cols - 1)
+ model.selection = CellRange(end, end)
+ #expect(model.fillWrites(down: true).isEmpty)
+ #expect(model.fillWrites(down: false).isEmpty)
+ model.copied = [down]
+ model.copiedText = "=B1"
+ model.clearCopyProvenance()
+ #expect(model.copied == nil && model.copiedText.isEmpty)
+}
+@Test func streamedEventsRespectBoundaries() async {
+ let parser = SheetEventParser()
+ #expect(await parser.receive("event: cells") == nil)
+ #expect(await parser.receive("data: {\"version\":1}") == nil)
+ let event = await parser.receive("")
+ #expect(event?.name == "cells")
+ #expect(event?.data == "{\"version\":1}")
+}
+
+@MainActor @Test func formulaDraftTracksSelectionSynchronously() {
+ let model = SheetModel(client: NativeHTTP(origin: URL(string: "http://localhost")!))
+ let first = Coordinate(r: 0, c: 0)
+ model.cells[first] = Cell(r: 0, c: 0, raw: "=2+3", display: "5")
+ model.select(first)
+ #expect(model.draft == "=2+3")
+ #expect(!model.dirty)
+ model.draft = "=7*6"
+ model.select(Coordinate(r: 1, c: 0))
+ #expect(model.selection.start == first)
+ #expect(model.draft == "=7*6")
+ model.discard()
+ #expect(model.draft == "=2+3")
+ #expect(!model.dirty)
+ model.select(Coordinate(r: 1, c: 0))
+ #expect(model.draft.isEmpty)
+ #expect(!model.dirty)
+}
+
+@MainActor @Test func olderLiveResponseCannotReplaceCurrentCell() async {
+ let model = SheetModel(client: NativeHTTP(origin: URL(string: "http://localhost")!))
+ model.version = 2
+ model.cells[Coordinate(r: 0, c: 0)] = Cell(r: 0, c: 0, raw: "new", display: "new")
+ await model.accept(
+ GridResponse(version: 1, cells: [Cell(r: 0, c: 0, raw: "old", display: "old")]))
+ #expect(model.cells[Coordinate(r: 0, c: 0)]?.raw == "new")
+}
+
+@MainActor @Test func peerEditsUpdateCleanFormulaButPreserveDraft() async {
+ let model = SheetModel(client: NativeHTTP(origin: URL(string: "http://localhost")!))
+ let first = Coordinate(r: 0, c: 0)
+ model.cells[first] = Cell(r: 0, c: 0, raw: "=2+3", display: "5")
+ model.select(first)
+ await model.accept(GridResponse(version: 1, cells: [Cell(r: 0, c: 0, raw: "=3+4", display: "7")]))
+ #expect(model.draft == "=3+4")
+ #expect(!model.dirty)
+ model.draft = "my pending edit"
+ await model.accept(GridResponse(version: 2, cells: [Cell(r: 0, c: 0, raw: "=4+5", display: "9")]))
+ #expect(model.cells[first]?.display == "9")
+ #expect(model.draft == "my pending edit")
+ #expect(model.dirty)
+}
+
+@MainActor @Test func pendingBatchCannotBeReplacedByFormulaCommit() async {
+ let model = SheetModel(client: NativeHTTP(origin: URL(string: "http://localhost")!))
+ model.failedWrites = [
+ CellWrite(r: 1, c: 0, raw: "first pending"), CellWrite(r: 2, c: 0, raw: "second pending"),
+ ]
+ model.draft = "a new edit"
+ await model.commit()
+ #expect(model.failedWrites.map(\.raw) == ["first pending", "second pending"])
+ #expect(model.dirty)
+ #expect(model.error.contains("Retry or discard"))
+ model.discard()
+ model.rangeText = "B2:C3"
+ model.selectRange()
+ #expect(model.selection.start == Coordinate(r: 0, c: 0))
+ #expect(model.failedWrites.count == 2)
+}
+
+@Test func workbookExportNamesUseSafeDocumentTitles() {
+ let url = URL(fileURLWithPath: "/tmp/private-staging.xlsx")
+ #expect(WorkbookExport(url: url, title: "Budget 2026").filename == "Budget 2026.xlsx")
+ #expect(WorkbookExport(url: url, title: "../Budget:2026\n").filename == "-Budget-2026.xlsx")
+ #expect(WorkbookExport(url: url, title: "...").filename == "Workbook.xlsx")
+ #expect(WorkbookExport(url: url, title: String(repeating: "🧪", count: 100)).filename.utf8.count <= 205)
+}
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..ac7e7c9
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..0a42994
--- /dev/null
+++ b/native/ios/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1 @@
+{"images":[{"filename":"AppIcon.png","idiom":"universal","platform":"ios","size":"1024x1024"}],"info":{"author":"xcode","version":1}}
diff --git a/native/ios/Review/ReviewInput.swift b/native/ios/Review/ReviewInput.swift
new file mode 100644
index 0000000..dfea57d
--- /dev/null
+++ b/native/ios/Review/ReviewInput.swift
@@ -0,0 +1,7 @@
+import Foundation
+import RastrilloApple
+
+struct ReviewInput: Decodable {
+ let origin: URL
+ let credential: NativeSession
+}
diff --git a/native/ios/Review/ReviewPhone.swift b/native/ios/Review/ReviewPhone.swift
new file mode 100644
index 0000000..749636e
--- /dev/null
+++ b/native/ios/Review/ReviewPhone.swift
@@ -0,0 +1,22 @@
+// Test-only target. The production application never reads fixture credentials.
+import SwiftUI
+import RastrilloApple
+import SheetsKit
+
+@main struct ReviewPhone: App {
+ 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.scheme == "http" && input.origin.host == "127.0.0.1")
+ client = NativeHTTP(origin: input.origin, credential: input.credential)
+ }
+
+ var body: some Scene {
+ WindowGroup {
+ SheetsWorkspace(client: client, signOut: {})
+ .preferredColorScheme(.light)
+ }
+ }
+}
diff --git a/native/ios/UITests/NativeUITests.swift b/native/ios/UITests/NativeUITests.swift
new file mode 100644
index 0000000..6235cd3
--- /dev/null
+++ b/native/ios/UITests/NativeUITests.swift
@@ -0,0 +1,139 @@
+import XCTest
+
+@MainActor final class NativeUITests: XCTestCase {
+ func testCreateAndEditFormula() {
+ continueAfterFailure = false
+ let app = XCUIApplication()
+ app.launch()
+ defer { app.terminate() }
+ let create = app.buttons["New sheet"]
+ XCTAssertTrue(create.waitForExistence(timeout: 15))
+ create.tap()
+ let formula = app.textFields["Cell contents or formula"]
+ XCTAssertTrue(formula.waitForExistence(timeout: 10), "Creating a sheet must navigate to its native editor")
+ formula.tap()
+ formula.typeText("=2+3")
+ app.buttons["Save"].tap()
+ XCTAssertTrue(app.buttons["A1, 5"].waitForExistence(timeout: 10), "The server-calculated value must appear")
+ let saved = NSPredicate(format: "exists == false")
+ XCTAssertEqual(XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: saved, object: app.buttons["Save"])], timeout: 5), .completed)
+ app.buttons["A1, 5"].tap()
+ let rawFormula = NSPredicate(format: "value == %@", "=2+3")
+ XCTAssertEqual(XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: rawFormula, object: formula)], timeout: 5), .completed, "Selecting a cell must expose its formula rather than its display value")
+ formula.coordinate(withNormalizedOffset: CGVector(dx: 0.95, dy: 0.5)).tap()
+ formula.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: 4))
+ formula.typeText("=7*6")
+ app.buttons["Save"].tap()
+ XCTAssertTrue(app.buttons["A1, 42"].waitForExistence(timeout: 10))
+ XCTAssertEqual(XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: saved, object: app.buttons["Save"])], timeout: 5), .completed)
+ app.buttons["A1, 42"].tap()
+ let finalFormula = NSPredicate(format: "value == %@", "=7*6")
+ XCTAssertEqual(XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: finalFormula, object: formula)], timeout: 5), .completed)
+
+ // Exercise commands through the actual native menus, preserving A1 as the
+ // fixture's independent server-persistence sentinel.
+ menu(app, "Edit", command: "Fill down")
+ XCTAssertTrue(app.buttons["A2, 42"].waitForExistence(timeout: 10))
+ select(app, "A1:A2")
+ menu(app, "Edit", command: "Copy")
+ select(app, "B1")
+ menu(app, "Edit", command: "Paste")
+ XCTAssertTrue(app.buttons["B1, 42"].waitForExistence(timeout: 10))
+ XCTAssertTrue(app.buttons["B2, 42"].waitForExistence(timeout: 10))
+ select(app, "B1:B2")
+ openMenu(app, "Format")
+ app.buttons["Number format"].tap()
+ app.buttons["Number"].tap()
+ XCTAssertTrue(app.buttons["B1, 42.00"].waitForExistence(timeout: 10))
+ XCTAssertTrue(app.buttons["B2, 42.00"].waitForExistence(timeout: 10))
+ menu(app, "Edit", command: "Undo my last value edit")
+ XCTAssertTrue(app.buttons["B2, Empty"].waitForExistence(timeout: 10))
+ select(app, "B2")
+ XCTAssertEqual(XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: NSPredicate(format: "value == %@ OR value == %@", "", "Cell contents or formula"), object: formula)], timeout: 10), .completed)
+ menu(app, "File", command: "Version history")
+ let restore = app.buttons["Restore before this edit"].firstMatch
+ XCTAssertTrue(restore.waitForExistence(timeout: 10))
+ restore.tap()
+ app.buttons["Done"].tap()
+ // Canonical value undo clears the sparse cell row, including its format;
+ // restoring the input therefore returns the default number display.
+ XCTAssertTrue(app.buttons["B2, 42"].waitForExistence(timeout: 10), "History Restore must restore the pasted formula")
+ select(app, "A1")
+ let screenshot = XCTAttachment(screenshot: app.screenshot())
+ screenshot.name = "Sheets native formula editing"
+ screenshot.lifetime = .keepAlways
+ add(screenshot)
+ }
+
+ private func select(_ app: XCUIApplication, _ reference: String) {
+ let field = app.textFields["Cell or range, for example A1:B5"]
+ XCTAssertTrue(field.waitForExistence(timeout: 5))
+ field.coordinate(withNormalizedOffset: CGVector(dx: 0.95, dy: 0.5)).tap()
+ field.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: (field.value as? String ?? "").count))
+ field.typeText(reference + "\n")
+ }
+
+ func testFailedCellEditCanBeRetriedWithoutLosingDraft() {
+ continueAfterFailure = false
+ let app = XCUIApplication()
+ app.launch()
+ defer { app.terminate() }
+ XCTAssertTrue(app.buttons["New sheet"].waitForExistence(timeout: 15))
+ app.buttons["New sheet"].tap()
+ let formula = app.textFields["Cell contents or formula"]
+ XCTAssertTrue(formula.waitForExistence(timeout: 10))
+ formula.tap()
+ // Only this exact synthetic input triggers one refusal in the fixture.
+ formula.typeText("=6*7")
+ app.buttons["Save"].tap()
+ let retry = app.buttons["Retry pending edits"]
+ XCTAssertTrue(retry.waitForExistence(timeout: 10))
+ XCTAssertEqual(formula.value as? String, "=6*7")
+ XCTAssertFalse(formula.isEnabled)
+ app.buttons["A2, Empty"].tap()
+ XCTAssertEqual(app.textFields["Cell or range, for example A1:B5"].value as? String, "A1")
+ retry.tap()
+ XCTAssertTrue(app.buttons["A1, 42"].waitForExistence(timeout: 10))
+ XCTAssertEqual(XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: retry)], timeout: 10), .completed)
+ let screenshot = XCTAttachment(screenshot: app.screenshot())
+ screenshot.name = "Sheets native retried edit"; screenshot.lifetime = .keepAlways; add(screenshot)
+ }
+
+ func testExportWorkbookOpensNativeDestinationAndCancels() {
+ continueAfterFailure = false
+ let app = XCUIApplication()
+ app.launch()
+ defer { app.terminate() }
+ let workbook = app.buttons["Native review workbook"]
+ XCTAssertTrue(workbook.waitForExistence(timeout: 15))
+ workbook.tap()
+ XCTAssertTrue(app.textFields["Cell contents or formula"].waitForExistence(timeout: 10))
+ menu(app, "File", command: "Export workbook")
+ let export = app.buttons["Save workbook…"]
+ XCTAssertTrue(export.waitForExistence(timeout: 10))
+ export.tap()
+ let cancel = app.buttons["Cancel"]
+ XCTAssertTrue(cancel.waitForExistence(timeout: 15), "A native file destination picker must open after download")
+ let screenshot = XCTAttachment(screenshot: app.screenshot())
+ screenshot.name = "Sheets native export destination"
+ screenshot.lifetime = .keepAlways
+ add(screenshot)
+ cancel.tap()
+ XCTAssertTrue(export.waitForExistence(timeout: 10))
+ app.buttons["Done"].tap()
+ }
+
+ private func openMenu(_ app: XCUIApplication, _ name: String) {
+ if app.buttons["Sheet actions"].exists { app.buttons["Sheet actions"].tap() }
+ let menu = app.buttons[name]
+ XCTAssertTrue(menu.waitForExistence(timeout: 5), "Missing native menu: \(name)")
+ menu.tap()
+ }
+
+ private func menu(_ app: XCUIApplication, _ name: String, command: String) {
+ openMenu(app, name)
+ let action = app.buttons[command]
+ XCTAssertTrue(action.waitForExistence(timeout: 5), "Missing native command: \(command)")
+ action.tap()
+ }
+}
diff --git a/native/ios/project.yml b/native/ios/project.yml
new file mode 100644
index 0000000..953f5c6
--- /dev/null
+++ b/native/ios/project.yml
@@ -0,0 +1,72 @@
+name: SheetsPhone
+options:
+ bundleIdPrefix: net.amadan.oficina
+ deploymentTarget:
+ iOS: '26.0'
+packages:
+ SheetsNative:
+ path: ../apple
+ RastrilloNative:
+ url: https://amadan.net/rastrillo/native
+ revision: a49307c29cf36dff2cf423c3f98b38269ce9a553
+settings:
+ base:
+ SWIFT_VERSION: '6.0'
+ CODE_SIGN_STYLE: Automatic
+ MARKETING_VERSION: '0.1.0'
+ CURRENT_PROJECT_VERSION: '1'
+targets:
+ SheetsPhone:
+ type: application
+ platform: iOS
+ sources:
+ - ../apple/Sources/SheetsApp
+ - Assets.xcassets
+ dependencies:
+ - package: SheetsNative
+ product: SheetsKit
+ - package: RastrilloNative
+ product: RastrilloApple
+ settings:
+ base:
+ ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
+ PRODUCT_BUNDLE_IDENTIFIER: net.amadan.oficina.sheets.ios
+ TARGETED_DEVICE_FAMILY: '1,2'
+ GENERATE_INFOPLIST_FILE: YES
+ INFOPLIST_KEY_CFBundleDisplayName: Oficina Sheets
+ INFOPLIST_KEY_UILaunchScreen_Generation: YES
+ INFOPLIST_KEY_UISupportedInterfaceOrientations: UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortraitUpsideDown
+
+ SheetsReviewPhone:
+ type: application
+ platform: iOS
+ sources: [Review]
+ dependencies:
+ - package: SheetsNative
+ product: SheetsKit
+ - package: RastrilloNative
+ product: RastrilloApple
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: net.amadan.oficina.sheets.review
+ TARGETED_DEVICE_FAMILY: '1,2'
+ GENERATE_INFOPLIST_FILE: YES
+ INFOPLIST_KEY_UILaunchScreen_Generation: YES
+
+ SheetsPhoneUITests:
+ type: bundle.ui-testing
+ platform: iOS
+ sources: [UITests]
+ dependencies:
+ - target: SheetsReviewPhone
+ settings:
+ base:
+ GENERATE_INFOPLIST_FILE: YES
+ TEST_TARGET_NAME: SheetsReviewPhone
+schemes:
+ SheetsReviewPhone:
+ build:
+ targets:
+ SheetsReviewPhone: all
+ test:
+ targets: [SheetsPhoneUITests]
diff --git a/native/scripts/build-iphone.sh b/native/scripts/build-iphone.sh
new file mode 100755
index 0000000..a0a2c52
--- /dev/null
+++ b/native/scripts/build-iphone.sh
@@ -0,0 +1,10 @@
+#!/bin/sh
+set -eu
+cd "$(dirname "$0")/.."
+if [ "${OFICINA_SKIP_PROJECT_GENERATION:-0}" != 1 ]; then xcodegen generate --spec ios/project.yml; fi
+case "${1:-simulator}" in
+simulator) xcodebuild -project ios/SheetsPhone.xcodeproj -scheme SheetsPhone -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/SheetsPhone.xcodeproj -scheme SheetsPhone -destination 'generic/platform=iOS' -derivedDataPath build/device "DEVELOPMENT_TEAM=$OFICINA_DEVELOPMENT_TEAM" -allowProvisioningUpdates build ;;
+*) exit 2 ;;
+esac
diff --git a/native/scripts/build-mac.sh b/native/scripts/build-mac.sh
new file mode 100755
index 0000000..6c300f7
--- /dev/null
+++ b/native/scripts/build-mac.sh
@@ -0,0 +1,5 @@
+#!/bin/sh
+set -eu
+cd "$(dirname "$0")/.."
+swift package --package-path apple resolve
+python3 apple/.build/checkouts/native/tools/bundle-mac.py . Sheets