Skip to content

Commit f4da3e3

Browse files
committed
unify Studio document sessions
1 parent 68ba096 commit f4da3e3

11 files changed

Lines changed: 276 additions & 187 deletions

File tree

Editor/editorfe/src/main.js

Lines changed: 3 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { scriptWidgetDiagnostics, scriptWidgetHover } from "./scriptWidgetLangua
3636
import { studioTheme } from "./studioTheme.js";
3737
import { loadDocumentState, saveDocumentState } from "./documentState.js";
3838
import { DocumentSaveCoordinator, initialStudioState, recoverableDraft, reduceStudioState, removeDraft, shouldWarnBeforeLeaving, writeDraft } from "./webStudioState.js";
39+
import { WebStudioAPI } from "./webStudioAPI.js";
3940
import "./style.css";
4041

4142
const isWebStudio = /^https?:$/.test(window.location.protocol);
@@ -299,14 +300,6 @@ async function startWebStudio() {
299300
let loadedPath = "";
300301
let saveCoordinator = null;
301302

302-
class StudioAPIError extends Error {
303-
constructor(message, status, body = {}) {
304-
super(message);
305-
this.status = status;
306-
this.body = body;
307-
}
308-
}
309-
310303
function requirePairing(message = "Session ended. Enter the new code shown on your device.") {
311304
token = "";
312305
window.sessionStorage.removeItem("scriptwidget.web-studio.token");
@@ -329,19 +322,8 @@ async function startWebStudio() {
329322
document.querySelector("#preview-detail").textContent = studioState.preview === "requested" ? "Requested on device" : "Not requested";
330323
}
331324

332-
async function api(path, options = {}) {
333-
const response = await fetch(path, {
334-
...options,
335-
headers: { "Content-Type": "application/json", "X-Studio-Token": token, ...(options.headers || {}) },
336-
});
337-
const body = await response.json().catch(() => ({}));
338-
if (!response.ok) {
339-
const error = new StudioAPIError(body.message || `Request failed (${response.status})`, response.status, body);
340-
if (response.status === 401 && path !== "/api/v1/pair") requirePairing();
341-
throw error;
342-
}
343-
return body;
344-
}
325+
const apiClient = new WebStudioAPI({ token: () => token, onUnauthorized: () => requirePairing() });
326+
const api = (path, options) => apiClient.request(path, options);
345327

346328
saveCoordinator = new DocumentSaveCoordinator(({ packageID, path, content, baseRevision }) => (
347329
api("/api/v1/document", {
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
export class StudioAPIError extends Error {
2+
constructor(message, status, body = {}) {
3+
super(message);
4+
this.name = "StudioAPIError";
5+
this.status = status;
6+
this.body = body;
7+
}
8+
}
9+
10+
export class WebStudioAPI {
11+
constructor({ fetchImpl = globalThis.fetch, token = () => "", onUnauthorized = () => {} } = {}) {
12+
if (typeof fetchImpl !== "function") throw new TypeError("A fetch implementation is required");
13+
this.fetchImpl = fetchImpl;
14+
this.token = token;
15+
this.onUnauthorized = onUnauthorized;
16+
}
17+
18+
async request(path, options = {}) {
19+
const response = await this.fetchImpl(path, {
20+
...options,
21+
headers: {
22+
"Content-Type": "application/json",
23+
"X-Studio-Token": this.token(),
24+
...(options.headers || {}),
25+
},
26+
});
27+
const body = await response.json().catch(() => ({}));
28+
if (!response.ok) {
29+
const error = new StudioAPIError(body.message || `Request failed (${response.status})`, response.status, body);
30+
if (response.status === 401 && path !== "/api/v1/pair") this.onUnauthorized(error);
31+
throw error;
32+
}
33+
return body;
34+
}
35+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
import { StudioAPIError, WebStudioAPI } from "../src/webStudioAPI.js";
4+
5+
test("WebStudioAPI sends the current token and preserves caller headers", async () => {
6+
let captured;
7+
const client = new WebStudioAPI({
8+
token: () => "fresh-token",
9+
fetchImpl: async (path, options) => {
10+
captured = { path, options };
11+
return { ok: true, json: async () => ({ result: "ok" }) };
12+
},
13+
});
14+
assert.deepEqual(await client.request("/api/v1/session", { headers: { "X-Request-ID": "abc" } }), { result: "ok" });
15+
assert.equal(captured.options.headers["X-Studio-Token"], "fresh-token");
16+
assert.equal(captured.options.headers["X-Request-ID"], "abc");
17+
});
18+
19+
test("WebStudioAPI exposes structured server conflicts", async () => {
20+
const client = new WebStudioAPI({
21+
fetchImpl: async () => ({ ok: false, status: 409, json: async () => ({ message: "Conflict", currentRevision: "r2" }) }),
22+
});
23+
await assert.rejects(client.request("/api/v1/document"), (error) => {
24+
assert.ok(error instanceof StudioAPIError);
25+
assert.equal(error.status, 409);
26+
assert.equal(error.body.currentRevision, "r2");
27+
return true;
28+
});
29+
});
30+
31+
test("WebStudioAPI invalidates unauthorized sessions except failed pairing", async () => {
32+
let unauthorized = 0;
33+
const client = new WebStudioAPI({
34+
onUnauthorized: () => { unauthorized += 1; },
35+
fetchImpl: async () => ({ ok: false, status: 401, json: async () => ({}) }),
36+
});
37+
await assert.rejects(client.request("/api/v1/session"));
38+
await assert.rejects(client.request("/api/v1/pair"));
39+
assert.equal(unauthorized, 1);
40+
});
41+
42+
test("WebStudioAPI tolerates an empty non-JSON success response", async () => {
43+
const client = new WebStudioAPI({
44+
fetchImpl: async () => ({ ok: true, json: async () => { throw new SyntaxError("empty"); } }),
45+
});
46+
assert.deepEqual(await client.request("/api/v1/session", { method: "DELETE" }), {});
47+
});

Shared/ScriptWidgetRuntime/Common/ScriptWidgetPackage.swift

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,86 @@ struct FileModel: Identifiable, Hashable {
2828
var id: String { relativePath }
2929
}
3030

31+
/// Stable protocol shared by every Studio host. Keeping message names here
32+
/// prevents the iOS and macOS bridges from drifting independently.
33+
enum StudioProtocol {
34+
static let version = 1
35+
static let ready = "studio.ready"
36+
static let documentOpen = "document.open"
37+
static let documentChanged = "document.changed"
38+
static let documentSave = "document.save"
39+
static let documentReplace = "document.replace"
40+
static let documentSetReadOnly = "document.setReadOnly"
41+
static let editorInsert = "editor.insert"
42+
static let editorFormat = "editor.format"
43+
static let editorGetState = "editor.getState"
44+
45+
static func envelope(type: String, documentID: String?, payload: [String: Any]) -> [String: Any] {
46+
[
47+
"protocolVersion": version,
48+
"type": type,
49+
"documentID": documentID as Any? ?? NSNull(),
50+
"payload": payload,
51+
]
52+
}
53+
}
54+
55+
struct StudioDocumentSnapshot: Equatable {
56+
let content: String
57+
let version: Int
58+
let selection: Range<Int>
59+
60+
init?(state: Any?) {
61+
guard
62+
let state = state as? [String: Any],
63+
let content = state["content"] as? String
64+
else { return nil }
65+
let version = state["version"] as? Int ?? 0
66+
let selection = state["selection"] as? [String: Any]
67+
let from = selection?["from"] as? Int ?? 0
68+
let to = selection?["to"] as? Int ?? from
69+
self.init(content: content, version: version, selection: min(from, to)..<max(from, to))
70+
}
71+
72+
init(content: String, version: Int, selection: Range<Int>) {
73+
self.content = content
74+
self.version = version
75+
self.selection = selection
76+
}
77+
}
78+
79+
/// Owns document identity and the persisted-draft baseline for native Studio
80+
/// hosts. Web views remain responsible only for transport and presentation.
81+
final class StudioDocumentSession {
82+
private(set) var documentID: String?
83+
private(set) var savedContent = ""
84+
private let drafts: StudioDraftStore
85+
86+
init(drafts: StudioDraftStore = .shared) {
87+
self.drafts = drafts
88+
}
89+
90+
func open(documentID: String, content: String) -> String {
91+
self.documentID = documentID
92+
savedContent = content
93+
return drafts.recover(documentID: documentID, currentContent: content)?.content ?? content
94+
}
95+
96+
func recordDraft(_ content: String) {
97+
guard let documentID else { return }
98+
drafts.save(documentID: documentID, baseContent: savedContent, content: content)
99+
}
100+
101+
func markSaved(_ content: String) {
102+
savedContent = content
103+
if let documentID { drafts.remove(documentID: documentID) }
104+
}
105+
106+
func needsSave(_ content: String) -> Bool {
107+
content != savedContent
108+
}
109+
}
110+
31111
/// Crash-safe editor drafts. A draft is restored only while its base file hash
32112
/// still matches, so an iCloud or external edit always wins over stale local work.
33113
struct StudioDraftRecord: Codable, Equatable {

Tests/ScriptWidgetRuntimeTests/RuntimeUnitTests.swift

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,42 @@ final class StudioDraftStoreTests: XCTestCase {
549549
store.save(documentID: "/widget/main.jsx", baseContent: "saved", content: "saved")
550550
XCTAssertNil(store.recover(documentID: "/widget/main.jsx", currentContent: "saved"))
551551
}
552+
553+
func testDocumentSessionRestoresDraftTracksBaselineAndClearsAfterSave() {
554+
store.save(documentID: "/widget/main.jsx", baseContent: "saved", content: "draft")
555+
let session = StudioDocumentSession(drafts: store)
556+
557+
XCTAssertEqual(session.open(documentID: "/widget/main.jsx", content: "saved"), "draft")
558+
XCTAssertFalse(session.needsSave("saved"))
559+
XCTAssertTrue(session.needsSave("draft"))
560+
561+
session.markSaved("draft")
562+
XCTAssertFalse(session.needsSave("draft"))
563+
XCTAssertNil(store.recover(documentID: "/widget/main.jsx", currentContent: "draft"))
564+
}
565+
566+
func testDocumentSessionDraftNeverCrossesDocumentIdentity() {
567+
let session = StudioDocumentSession(drafts: store)
568+
_ = session.open(documentID: "/widget/a.jsx", content: "a")
569+
session.recordDraft("draft-a")
570+
_ = session.open(documentID: "/widget/b.jsx", content: "b")
571+
session.recordDraft("draft-b")
572+
573+
XCTAssertEqual(store.recover(documentID: "/widget/a.jsx", currentContent: "a")?.content, "draft-a")
574+
XCTAssertEqual(store.recover(documentID: "/widget/b.jsx", currentContent: "b")?.content, "draft-b")
575+
}
576+
577+
func testStudioSnapshotNormalizesReverseSelection() throws {
578+
let snapshot = try XCTUnwrap(StudioDocumentSnapshot(state: [
579+
"content": "hello",
580+
"version": 4,
581+
"selection": ["from": 5, "to": 2],
582+
]))
583+
XCTAssertEqual(snapshot.content, "hello")
584+
XCTAssertEqual(snapshot.version, 4)
585+
XCTAssertEqual(snapshot.selection, 2..<5)
586+
XCTAssertNil(StudioDocumentSnapshot(state: ["version": 1]))
587+
}
552588
}
553589

554590
final class StudioEditorBundleIntegrationTests: XCTestCase {

0 commit comments

Comments
 (0)