forked from 1b-agent/opencadstudio-upstream
feat: web automation wrapper (#1349), DATALINK table on web, System theme; honest decode accounting
Ported from2c38de53(#1717). Main already carries the hatch PDF draw order fix (#1345), the numeric set_properties compare (#1348) and the web open from bytes, so those parts stay out; what remains: #1349 — window.ocsControl.ready/request promise wrapper with an ocs-ready event (web/ocs-control.js, loaded by the web page), and the startup donation prompt is suppressed once a control request is seen (opt-in pre-boot via localStorage["opencadstudio.automation"] = "1"). docs/automation/web.md documents both. The control protocol stays at 1. DATALINK CSV-to-table works on web (picker + bytes through the session registry — was std::fs, silently dead); COLORSCHEME accepts System (prefers-color-scheme on web, AppsUseLightTheme on Windows). Decode accounting, made honest per the fix-of-fix review: into_rgba8 allocates the RGBA destination while the native buffer is alive for every non-Rgba8 layout (L8 5, La8 6, Rgb8 7, L16 6, La16 8 B/px peaks — the formula now says so), GIF bills the screen+frame coexistence at 8 B/px, and TIFF drops the 3x multipliers both ways — tiff 0.11 splits the given budget between frame and scratch (a total ceiling), so the old double-billing refused honest 16-bit GeoTIFFs ~2.25x early while admitting hostile chunk transients at 3x. (cherry picked from commit2c38de53)
This commit is contained in:
1 parent
f015c3b5fa
commit
db8db14006
37 files changed
+436
-52
No files matched your search
@@ -169,6 +169,9 @@ web-sys = { version = "=0.3.85", features = [
|
||||
"EventInit",
|
||||
"EventTarget",
|
||||
"BeforeUnloadEvent",
|
||||
# COLORSCHEME SYSTEM resolves prefers-color-scheme through a media
|
||||
# query listener read at boot and on every re-run.
|
||||
"MediaQueryList",
|
||||
"console",
|
||||
"Node",
|
||||
"HtmlElement",
|
||||
|
||||
+11
-2
@@ -16,7 +16,14 @@ control channel is ready once `window.wasmBindings?.ocs_control_submit` is defin
|
||||
| `ocs_control_submit(request: string) → string` | Queues one request (a JSON string) and returns a ticket immediately. |
|
||||
| `ocs_control_take(ticket: string) → string \| undefined` | Returns the reply (a JSON string) once it is ready, `undefined` before that. A reply is removed when taken. |
|
||||
|
||||
Poll `ocs_control_take` until it returns a reply, as the smoke test does:
|
||||
The page also loads `ocs-control.js`, which wraps the two functions in promises:
|
||||
|
||||
| API | Behaviour |
|
||||
|---|---|
|
||||
| `await window.ocsControl.ready` | Resolves once the control channel exists; the `ocs-ready` window event fires at the same moment. |
|
||||
| `await window.ocsControl.request(request, {timeoutMs, pollMs})` | Submits one request and resolves with the final reply, following `accepted`/`running` replies through `op: "operation"`. Rejects only on timeout or a missing channel. |
|
||||
|
||||
Without the wrapper, poll `ocs_control_take` until it returns a reply, as the smoke test does:
|
||||
|
||||
```js
|
||||
async function control(request) {
|
||||
@@ -55,7 +62,9 @@ rather than a disk, so use `data_base64` for a file the page has not seen.
|
||||
|
||||
A dialog can be open when the page is ready. The donation prompt appears once per
|
||||
application version in each browser profile, so a fresh or disposable profile always
|
||||
sees it. While a modal is open `state.modal` is set, and `{"op":"new"}` returns `ok`
|
||||
sees it — unless the session is automated: the first control request drops a prompt
|
||||
that is still queued, and a host that sets `localStorage["opencadstudio.automation"]`
|
||||
to `"1"` before the page loads never gets one. A prompt already on screen stays. While a modal is open `state.modal` is set, and `{"op":"new"}` returns `ok`
|
||||
without creating a tab until the modal is closed. Close it first:
|
||||
|
||||
```js
|
||||
|
||||
@@ -4423,6 +4423,7 @@ layout =
|
||||
.vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F <layer> = تجميد | T <layer> = إلغاء التجميد | Enter = خروج
|
||||
|
||||
view =
|
||||
.color-scheme-set-to-system-currently-name = تم تعيين نظام الألوان على "النظام" (حاليًا __ocs_fmt_0__).
|
||||
.ss-create-sheet-set = Create Sheet Set
|
||||
.ss-requires-integer-between = يتطلب عددًا صحيحًا بين __ocs_fmt_0__ و __ocs_fmt_1__.
|
||||
.ss-new-sheet = New Sheet
|
||||
|
||||
@@ -4410,6 +4410,7 @@ layout =
|
||||
.vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F <слой> = Замрази | T <слой> = Размрази | Enter = Изход
|
||||
|
||||
view =
|
||||
.color-scheme-set-to-system-currently-name = Цветовата схема е зададена на „Системна“ (в момента __ocs_fmt_0__).
|
||||
.ss-create-sheet-set = Create Sheet Set
|
||||
.ss-requires-integer-between = Изисква се цяло число между __ocs_fmt_0__ и __ocs_fmt_1__.
|
||||
.ss-new-sheet = New Sheet
|
||||
|
||||
@@ -4408,6 +4408,7 @@ layout =
|
||||
.vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F <layer> = Zmrazit | T <layer> = Rozmrazit | Enter = Konec
|
||||
|
||||
view =
|
||||
.color-scheme-set-to-system-currently-name = Barevné schéma nastaveno na „Systém“ (aktuálně __ocs_fmt_0__).
|
||||
.ss-create-sheet-set = Create Sheet Set
|
||||
.ss-requires-integer-between = Vyžaduje celé číslo mezi __ocs_fmt_0__ a __ocs_fmt_1__.
|
||||
.ss-new-sheet = New Sheet
|
||||
|
||||
@@ -4409,6 +4409,7 @@ layout =
|
||||
.vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F <Ebene> = Einfrieren | T <Ebene> = Auftauen | Eingabe = Beenden
|
||||
|
||||
view =
|
||||
.color-scheme-set-to-system-currently-name = Farbschema auf „System“ eingestellt (derzeit __ocs_fmt_0__).
|
||||
.ss-create-sheet-set = Create Sheet Set
|
||||
.ss-requires-integer-between = Erfordert eine ganze Zahl zwischen __ocs_fmt_0__ und __ocs_fmt_1__.
|
||||
.ss-new-sheet = New Sheet
|
||||
|
||||
@@ -4411,6 +4411,7 @@ layout =
|
||||
.vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F <επίπεδο> = Πάγωμα | T <επίπεδο> = Ξεπάγωμα | Enter = Έξοδος
|
||||
|
||||
view =
|
||||
.color-scheme-set-to-system-currently-name = Ο χρωματικός συνδυασμός ρυθμίστηκε σε „Σύστημα“ (αυτή τη στιγμή __ocs_fmt_0__).
|
||||
.ss-create-sheet-set = Create Sheet Set
|
||||
.ss-requires-integer-between = Απαιτείται ακέραιος αριθμός μεταξύ __ocs_fmt_0__ και __ocs_fmt_1__.
|
||||
.ss-new-sheet = New Sheet
|
||||
|
||||
@@ -1162,6 +1162,8 @@ common =
|
||||
.save-block-as = Save Block As
|
||||
.save-data-extraction = Save Data Extraction
|
||||
.csv = CSV
|
||||
.csv-files = CSV Files
|
||||
.link-a-csv-data-source = Link a CSV data source
|
||||
.export-stl = Export STL
|
||||
.stl-files = STL Files
|
||||
.export-step-ap203 = Export STEP AP203
|
||||
@@ -4415,6 +4417,7 @@ layout =
|
||||
.vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F <layer> = Freeze | T <layer> = Thaw | Enter = Exit
|
||||
|
||||
view =
|
||||
.color-scheme-set-to-system-currently-name = Color scheme set to 'System' (currently __ocs_fmt_0__).
|
||||
.ss-create-sheet-set = Create Sheet Set
|
||||
.ss-requires-integer-between = Requires an integer between __ocs_fmt_0__ and __ocs_fmt_1__.
|
||||
.ss-new-sheet = New Sheet
|
||||
|
||||
@@ -4411,6 +4411,7 @@ layout =
|
||||
.vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F <capa> = Inutilizar | T <capa> = Reutilizar | Intro = Salir
|
||||
|
||||
view =
|
||||
.color-scheme-set-to-system-currently-name = Esquema de color establecido en «Sistema» (actualmente __ocs_fmt_0__).
|
||||
.ss-create-sheet-set = Create Sheet Set
|
||||
.ss-requires-integer-between = Requiere un número entero entre __ocs_fmt_0__ y __ocs_fmt_1__.
|
||||
.ss-new-sheet = New Sheet
|
||||
|
||||
@@ -4410,6 +4410,7 @@ layout =
|
||||
.vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F <layer> = Jäädytys | T <layer> = Sulatus | Enter = Poistu
|
||||
|
||||
view =
|
||||
.color-scheme-set-to-system-currently-name = Väriteema asetettu arvoon "Järjestelmä" (tällä hetkellä __ocs_fmt_0__).
|
||||
.ss-create-sheet-set = Create Sheet Set
|
||||
.ss-requires-integer-between = Vaatii kokonaisluvun väliltä __ocs_fmt_0__–__ocs_fmt_1__.
|
||||
.ss-new-sheet = New Sheet
|
||||
|
||||
@@ -4411,6 +4411,7 @@ layout =
|
||||
.vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F <layer> = Freeze , T <layer> = Thaw , Entrée = Sortie
|
||||
|
||||
view =
|
||||
.color-scheme-set-to-system-currently-name = Jeu de couleurs défini sur « Système » (actuellement __ocs_fmt_0__).
|
||||
.ss-create-sheet-set = Create Sheet Set
|
||||
.ss-requires-integer-between = Nécessite un nombre entier compris entre __ocs_fmt_0__ et __ocs_fmt_1__.
|
||||
.ss-new-sheet = New Sheet
|
||||
|
||||
@@ -4399,6 +4399,7 @@ layout =
|
||||
.vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F <लेयर> = फ़्रीज़ | T <लेयर> = थॉ | Enter = बाहर निकलें
|
||||
|
||||
view =
|
||||
.color-scheme-set-to-system-currently-name = रंग योजना 'सिस्टम' पर सेट की गई (वर्तमान में __ocs_fmt_0__)।
|
||||
.ss-create-sheet-set = Create Sheet Set
|
||||
.ss-requires-integer-between = __ocs_fmt_0__ और __ocs_fmt_1__ के बीच एक पूर्णांक आवश्यक है।
|
||||
.ss-new-sheet = New Sheet
|
||||
|
||||
@@ -4421,6 +4421,7 @@ layout =
|
||||
.vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F <layer> = Fagyasztás | T <layer> = Felengedés | Enter = Kilépés
|
||||
|
||||
view =
|
||||
.color-scheme-set-to-system-currently-name = Színséma beállítva: „Rendszer” (jelenleg __ocs_fmt_0__).
|
||||
.ss-create-sheet-set = Tervlapkészlet létrehozása
|
||||
.ss-requires-integer-between = __ocs_fmt_0__ és __ocs_fmt_1__ közötti egész szám szükséges.
|
||||
.ss-new-sheet = Új tervlap
|
||||
|
||||
@@ -4410,6 +4410,7 @@ layout =
|
||||
.vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F <layer> = Congela | T <layer> = Scongela | Enter = Esci
|
||||
|
||||
view =
|
||||
.color-scheme-set-to-system-currently-name = Combinazione colori impostata su "Sistema" (attualmente __ocs_fmt_0__).
|
||||
.ss-create-sheet-set = Create Sheet Set
|
||||
.ss-requires-integer-between = Richiede un numero intero compreso tra __ocs_fmt_0__ e __ocs_fmt_1__.
|
||||
.ss-new-sheet = New Sheet
|
||||
|
||||
@@ -4399,6 +4399,7 @@ layout =
|
||||
.vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F <layer> = フリーズ | T <layer> = フリーズ解除 | Enter = 終了
|
||||
|
||||
view =
|
||||
.color-scheme-set-to-system-currently-name = カラースキームを「システム」に設定しました(現在: __ocs_fmt_0__)。
|
||||
.ss-create-sheet-set = Create Sheet Set
|
||||
.ss-requires-integer-between = __ocs_fmt_0__ から __ocs_fmt_1__ までの整数が必要です。
|
||||
.ss-new-sheet = New Sheet
|
||||
|
||||
@@ -4410,6 +4410,7 @@ layout =
|
||||
.vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F <layer> = 동결 | T <layer> = 해동 | Enter = 종료
|
||||
|
||||
view =
|
||||
.color-scheme-set-to-system-currently-name = 색 구성표가 '시스템'으로 설정되었습니다(현재 __ocs_fmt_0__).
|
||||
.ss-create-sheet-set = Create Sheet Set
|
||||
.ss-requires-integer-between = __ocs_fmt_0__에서 __ocs_fmt_1__ 사이의 정수가 필요합니다.
|
||||
.ss-new-sheet = New Sheet
|
||||
|
||||
@@ -4409,6 +4409,7 @@ layout =
|
||||
.vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F <laag> = Bevriezen | T <laag> = Ontdooien | Enter = Afsluiten
|
||||
|
||||
view =
|
||||
.color-scheme-set-to-system-currently-name = Kleurenschema ingesteld op 'Systeem' (momenteel __ocs_fmt_0__).
|
||||
.ss-create-sheet-set = Create Sheet Set
|
||||
.ss-requires-integer-between = Vereist een geheel getal tussen __ocs_fmt_0__ en __ocs_fmt_1__.
|
||||
.ss-new-sheet = New Sheet
|
||||
|
||||
@@ -4410,6 +4410,7 @@ layout =
|
||||
.vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F <layer> = Zamrożenie | T <layer> = Odwilż | Enter = Wyjście
|
||||
|
||||
view =
|
||||
.color-scheme-set-to-system-currently-name = Schemat kolorów ustawiono na „System” (obecnie __ocs_fmt_0__).
|
||||
.ss-create-sheet-set = Create Sheet Set
|
||||
.ss-requires-integer-between = Wymagana liczba całkowita z zakresu od __ocs_fmt_0__ do __ocs_fmt_1__.
|
||||
.ss-new-sheet = New Sheet
|
||||
|
||||
@@ -4411,6 +4411,7 @@ layout =
|
||||
.vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F <camada> = Congelar | T <camada> = Descongelar | Enter = Sair
|
||||
|
||||
view =
|
||||
.color-scheme-set-to-system-currently-name = Esquema de cores definido como "Sistema" (atualmente __ocs_fmt_0__).
|
||||
.ss-create-sheet-set = Create Sheet Set
|
||||
.ss-requires-integer-between = Requer um número inteiro entre __ocs_fmt_0__ e __ocs_fmt_1__.
|
||||
.ss-new-sheet = New Sheet
|
||||
|
||||
@@ -4412,6 +4412,7 @@ layout =
|
||||
.vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F <слой> = заморозить | T <слой> = разморозить | Enter = выход
|
||||
|
||||
view =
|
||||
.color-scheme-set-to-system-currently-name = Цветовая схема установлена на «Системную» (сейчас __ocs_fmt_0__).
|
||||
.ss-create-sheet-set = Create Sheet Set
|
||||
.ss-requires-integer-between = Требуется целое число от __ocs_fmt_0__ до __ocs_fmt_1__.
|
||||
.ss-new-sheet = New Sheet
|
||||
|
||||
@@ -4371,6 +4371,7 @@ layout =
|
||||
.vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F <layer> = Dondur | T <layer> = Çöz | Enter = Çık
|
||||
|
||||
view =
|
||||
.color-scheme-set-to-system-currently-name = Renk düzeni 'Sistem' olarak ayarlandı (şu anda __ocs_fmt_0__).
|
||||
.ss-create-sheet-set = Sayfa Kümesi Oluştur
|
||||
.ss-requires-integer-between = __ocs_fmt_0__ ile __ocs_fmt_1__ arasında bir tam sayı gerekir.
|
||||
.ss-new-sheet = Yeni Sayfa
|
||||
|
||||
@@ -4394,6 +4394,7 @@ layout =
|
||||
.vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F <图层> = 冻结 | T <图层> = 解冻 | Enter = 退出
|
||||
|
||||
view =
|
||||
.color-scheme-set-to-system-currently-name = 颜色方案已设为“系统”(当前:__ocs_fmt_0__)。
|
||||
.ss-create-sheet-set = Create Sheet Set
|
||||
.ss-requires-integer-between = 需要介于 __ocs_fmt_0__ 和 __ocs_fmt_1__ 之间的整数。
|
||||
.ss-new-sheet = New Sheet
|
||||
|
||||
@@ -4406,6 +4406,7 @@ layout =
|
||||
.vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F <圖層> = 凍結 | T <圖層> = 解凍 | Enter = 退出
|
||||
|
||||
view =
|
||||
.color-scheme-set-to-system-currently-name = 配色方案已設為「系統」(目前:__ocs_fmt_0__)。
|
||||
.ss-create-sheet-set = Create Sheet Set
|
||||
.ss-requires-integer-between = 需要介於 __ocs_fmt_0__ 和 __ocs_fmt_1__ 之間的整數。
|
||||
.ss-new-sheet = New Sheet
|
||||
|
||||
@@ -1298,6 +1298,34 @@ impl OpenCADStudio {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// Web DATALINK: the picked .csv's bytes arrived. They are registered in
|
||||
/// the picked-source registry — so `DATALINKUPDATE` re-reads them
|
||||
/// in-session instead of failing on the absent filesystem — and fed to
|
||||
/// the same table-insert path the native typed-path command uses.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) fn on_datalink_csv_picked(
|
||||
&mut self,
|
||||
picked: Option<(String, Vec<u8>)>,
|
||||
) -> Task<Message> {
|
||||
let Some((name, bytes)) = picked else {
|
||||
return Task::none();
|
||||
};
|
||||
let text = String::from_utf8_lossy(&bytes).into_owned();
|
||||
if !register_picked_source(&name, Arc::new(bytes)) {
|
||||
if let Some(name) = take_last_collision().take() {
|
||||
self.command_line.push_warning(
|
||||
crate::tf!(
|
||||
"\"{name}\" is already loaded with different contents; the existing file was kept."
|
||||
)
|
||||
.as_ref(),
|
||||
);
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
self.place_data_link_table(&text, &name);
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(super) fn on_data_link_browse(&mut self) -> Task<Message> {
|
||||
Task::perform(
|
||||
async {
|
||||
|
||||
+59
-40
@@ -1799,51 +1799,25 @@ impl OpenCADStudio {
|
||||
.push_info(crate::t!("Usage: DATALINK <path-to-.csv>").as_ref());
|
||||
return Some(Task::none());
|
||||
}
|
||||
// Web: nothing is behind a typed path — pick the .csv through
|
||||
// the browser, read its bytes at pick time, and register them
|
||||
// in the picked-source registry; the same table-insert path
|
||||
// then runs from `DataLinkCsvPicked`.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = path;
|
||||
return Some(Task::perform(
|
||||
crate::io::pick_datalink_csv_web(),
|
||||
Message::DataLinkCsvPicked,
|
||||
));
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(text) => {
|
||||
let rows_data = parse_csv_table(&text);
|
||||
let nrows = rows_data.len();
|
||||
let ncols = rows_data.iter().map(|r| r.len()).max().unwrap_or(0);
|
||||
if nrows == 0 || ncols == 0 {
|
||||
self.command_line
|
||||
.push_error(crate::t!("DATALINK: the CSV file is empty.").as_ref());
|
||||
return Some(Task::none());
|
||||
}
|
||||
use codec::entities::TableBuilder;
|
||||
use codec::types::Vector3;
|
||||
let mut table = TableBuilder::new(nrows, ncols)
|
||||
.at(Vector3::new(0.0, 0.0, 0.0))
|
||||
.row_height(0.5)
|
||||
.column_width(2.0)
|
||||
.build();
|
||||
for (r, row) in rows_data.iter().enumerate() {
|
||||
for (c, cell) in row.iter().enumerate() {
|
||||
table.set_cell_text(r, c, cell);
|
||||
}
|
||||
}
|
||||
let doc = &self.tabs[i].scene.document;
|
||||
let current_style = doc.header.current_table_style_name.clone();
|
||||
table.table_style_handle =
|
||||
doc.objects
|
||||
.iter()
|
||||
.find_map(|(handle, object)| match object {
|
||||
codec::objects::ObjectType::TableStyle(style)
|
||||
if style.name.eq_ignore_ascii_case(¤t_style) =>
|
||||
{
|
||||
Some(*handle)
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
let link_path = std::fs::canonicalize(path)
|
||||
.map(|value| value.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|_| path.to_string());
|
||||
let command =
|
||||
crate::modules::annotate::data_link::DataLinkPlaceCommand::new(
|
||||
table, &link_path,
|
||||
);
|
||||
self.command_line
|
||||
.push_info(&crate::command::CadCommand::prompt(&command));
|
||||
self.tabs[i].active_cmd = Some(Box::new(command));
|
||||
self.place_data_link_table(&text, &link_path);
|
||||
}
|
||||
Err(e) => {
|
||||
self.command_line.push_error(
|
||||
@@ -2076,6 +2050,51 @@ impl OpenCADStudio {
|
||||
}
|
||||
Some(self.finish_dispatch(cmd))
|
||||
}
|
||||
|
||||
/// Build the linked table from CSV text and start the placement command —
|
||||
/// the shared tail of the native typed-path `DATALINK` read and the web
|
||||
/// picker's `DataLinkCsvPicked` (which registers the picked bytes in the
|
||||
/// picked-source registry first so the link re-reads in-session).
|
||||
pub(in crate::app) fn place_data_link_table(&mut self, csv_text: &str, link_path: &str) {
|
||||
let i = self.active_tab;
|
||||
let rows_data = parse_csv_table(csv_text);
|
||||
let nrows = rows_data.len();
|
||||
let ncols = rows_data.iter().map(|r| r.len()).max().unwrap_or(0);
|
||||
if nrows == 0 || ncols == 0 {
|
||||
self.command_line
|
||||
.push_error(crate::t!("DATALINK: the CSV file is empty.").as_ref());
|
||||
return;
|
||||
}
|
||||
use codec::entities::TableBuilder;
|
||||
use codec::types::Vector3;
|
||||
let mut table = TableBuilder::new(nrows, ncols)
|
||||
.at(Vector3::new(0.0, 0.0, 0.0))
|
||||
.row_height(0.5)
|
||||
.column_width(2.0)
|
||||
.build();
|
||||
for (r, row) in rows_data.iter().enumerate() {
|
||||
for (c, cell) in row.iter().enumerate() {
|
||||
table.set_cell_text(r, c, cell);
|
||||
}
|
||||
}
|
||||
let doc = &self.tabs[i].scene.document;
|
||||
let current_style = doc.header.current_table_style_name.clone();
|
||||
table.table_style_handle = doc.objects.iter().find_map(|(handle, object)| {
|
||||
match object {
|
||||
codec::objects::ObjectType::TableStyle(style)
|
||||
if style.name.eq_ignore_ascii_case(¤t_style) =>
|
||||
{
|
||||
Some(*handle)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
});
|
||||
let command =
|
||||
crate::modules::annotate::data_link::DataLinkPlaceCommand::new(table, link_path);
|
||||
self.command_line
|
||||
.push_info(&crate::command::CadCommand::prompt(&command));
|
||||
self.tabs[i].active_cmd = Some(Box::new(command));
|
||||
}
|
||||
}
|
||||
|
||||
// Scan LandXML text for <CgPoint> survey points. Each element's text content is
|
||||
|
||||
@@ -535,7 +535,8 @@ impl OpenCADStudio {
|
||||
"Available themes: {}",
|
||||
"DARK LIGHT DRACULA NORD SOLARIZED_LIGHT SOLARIZED_DARK \
|
||||
GRUVBOX_LIGHT GRUVBOX_DARK TOKYONIGHT TOKYONIGHTSTORM TOKYONIGHTLIGHT \
|
||||
KANAGAWAWAVE KANAGAWADRAGON KANAGAWALOTUS MOONFLY NIGHTFLY OXOCARBON FERRA",
|
||||
KANAGAWAWAVE KANAGAWADRAGON KANAGAWALOTUS MOONFLY NIGHTFLY OXOCARBON FERRA \
|
||||
SYSTEM (follows the OS or browser color scheme)",
|
||||
));
|
||||
self.command_line.push_info(&crate::tf!(
|
||||
"Current color scheme: <{}>",
|
||||
@@ -559,7 +560,8 @@ impl OpenCADStudio {
|
||||
self.command_line.push_output(&crate::tf!("Available themes: {}",
|
||||
"DARK LIGHT DRACULA NORD SOLARIZED_LIGHT SOLARIZED_DARK \
|
||||
GRUVBOX_LIGHT GRUVBOX_DARK TOKYONIGHT TOKYONIGHTSTORM TOKYONIGHTLIGHT \
|
||||
KANAGAWAWAVE KANAGAWADRAGON KANAGAWALOTUS MOONFLY NIGHTFLY OXOCARBON FERRA",
|
||||
KANAGAWAWAVE KANAGAWADRAGON KANAGAWALOTUS MOONFLY NIGHTFLY OXOCARBON FERRA \
|
||||
SYSTEM (follows the OS or browser color scheme)",
|
||||
));
|
||||
return Some(Task::none());
|
||||
}
|
||||
@@ -569,6 +571,29 @@ impl OpenCADStudio {
|
||||
);
|
||||
return Some(Task::none());
|
||||
}
|
||||
// SYSTEM keeps its own name in the saved config and resolves
|
||||
// to a concrete Light/Dark theme at run time — the browser's
|
||||
// prefers-color-scheme on the web, the Windows personalization
|
||||
// setting natively. Re-running the command re-resolves; there
|
||||
// is no live preference listener.
|
||||
if upper_sub == "SYSTEM" {
|
||||
if self.ui_theme.name == "Custom" {
|
||||
self.saved_custom_palette = Some(self.ui_theme.palette);
|
||||
}
|
||||
let resolved = crate::app::config::resolve_system_theme();
|
||||
let name = format!("{resolved:?}");
|
||||
self.ui_theme.name = "System".to_string();
|
||||
self.ui_theme.palette =
|
||||
crate::app::config::UiThemePalette::from_iced(resolved.seed());
|
||||
self.theme_color_inputs = self.ui_theme.palette.hex_values();
|
||||
self.active_theme = resolved;
|
||||
self.sync_model_space_theme(true);
|
||||
self.persist_settings_if_changed();
|
||||
self.command_line.push_output(crate::tf!(
|
||||
"Color scheme set to 'System' (currently {name})."
|
||||
).as_ref());
|
||||
return Some(Task::none());
|
||||
}
|
||||
let theme = crate::app::config::parse_theme_name(sub);
|
||||
if let Some(t) = theme {
|
||||
if self.ui_theme.name == "Custom" {
|
||||
|
||||
@@ -109,12 +109,77 @@ impl UiThemeConfig {
|
||||
pub fn to_iced(&self) -> iced::Theme {
|
||||
if self.name == "Custom" {
|
||||
iced::Theme::custom("Custom", self.palette.to_iced())
|
||||
} else if self.name.eq_ignore_ascii_case("System") {
|
||||
resolve_system_theme()
|
||||
} else {
|
||||
builtin_theme(&self.name).unwrap_or(iced::Theme::Oxocarbon)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the `System` color scheme (COLORSCHEME SYSTEM) to a concrete
|
||||
/// theme: the browser `prefers-color-scheme` media query on the web, the
|
||||
/// Windows `AppsUseLightTheme` personalization value natively, and the dark
|
||||
/// default on the remaining native platforms (no cheap OS query there).
|
||||
/// Read at boot (`apply_config`) and re-read whenever COLORSCHEME SYSTEM
|
||||
/// runs again — there is no live preference listener by design.
|
||||
pub fn resolve_system_theme() -> iced::Theme {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let light = web_sys::window().is_some_and(|window| {
|
||||
window
|
||||
.match_media("(prefers-color-scheme: light)")
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some_and(|query| query.matches())
|
||||
});
|
||||
if light {
|
||||
iced::Theme::Light
|
||||
} else {
|
||||
iced::Theme::Dark
|
||||
}
|
||||
}
|
||||
#[cfg(all(not(target_arch = "wasm32"), target_os = "windows"))]
|
||||
{
|
||||
use windows_sys::Win32::System::Registry::{
|
||||
RegGetValueW, HKEY_CURRENT_USER, RRF_RT_REG_DWORD,
|
||||
};
|
||||
let sub_key: Vec<u16> = "Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"
|
||||
.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let value: Vec<u16> = "AppsUseLightTheme"
|
||||
.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let mut data: u32 = 0;
|
||||
let mut size = std::mem::size_of::<u32>() as u32;
|
||||
// ERROR_SUCCESS (0) with data 1 means the apps theme is Light; a
|
||||
// missing value (older Windows, wiped personalization) is Dark.
|
||||
let light = unsafe {
|
||||
RegGetValueW(
|
||||
HKEY_CURRENT_USER,
|
||||
sub_key.as_ptr(),
|
||||
value.as_ptr(),
|
||||
RRF_RT_REG_DWORD,
|
||||
std::ptr::null_mut(),
|
||||
&mut data as *mut u32 as *mut core::ffi::c_void,
|
||||
&mut size,
|
||||
) == 0
|
||||
&& data == 1
|
||||
};
|
||||
if light {
|
||||
iced::Theme::Light
|
||||
} else {
|
||||
iced::Theme::Dark
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(target_arch = "wasm32", target_os = "windows")))]
|
||||
{
|
||||
iced::Theme::Dark
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct UiThemePalette {
|
||||
@@ -538,6 +603,25 @@ fn config_path() -> Option<std::path::PathBuf> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn system_scheme_resolves_to_a_concrete_theme_and_keeps_its_name() {
|
||||
// Fresh configs keep the existing default; SYSTEM is opt-in.
|
||||
assert_ne!(UiThemeConfig::default().name, "System");
|
||||
let mut cfg = UiThemeConfig::default();
|
||||
cfg.name = "System".to_string();
|
||||
let resolved = cfg.to_iced();
|
||||
// Case-insensitive like the other stored names.
|
||||
cfg.name = "system".to_string();
|
||||
assert_eq!(cfg.to_iced(), resolved);
|
||||
assert!(
|
||||
matches!(resolved, iced::Theme::Light | iced::Theme::Dark),
|
||||
"SYSTEM must resolve through the platform preference"
|
||||
);
|
||||
// The stored name survives resolution; re-runs re-resolve.
|
||||
assert_eq!(cfg.name, "system");
|
||||
assert_eq!(resolve_system_theme(), resolved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_space_defaults() {
|
||||
let cfg = ModelSpaceThemeConfig::default();
|
||||
|
||||
@@ -106,6 +106,10 @@ pub(super) struct State {
|
||||
completed: VecDeque<(String, Value, Value)>,
|
||||
owner: Option<(u64, String)>,
|
||||
pub enabled: bool,
|
||||
/// Set by the first control request of the session (or by a web host's
|
||||
/// boot-time opt-in): while control is enabled, startup prompts that
|
||||
/// would block automation — the donation prompt — are suppressed (#1349).
|
||||
pub automation_seen: bool,
|
||||
serial: u64,
|
||||
events: VecDeque<Value>,
|
||||
/// Monotonic counter bumped on every real selection change, so clients
|
||||
@@ -489,6 +493,18 @@ impl OpenCADStudio {
|
||||
}
|
||||
|
||||
pub(super) fn control_request(&mut self, req: Value) -> (Value, Task<Message>) {
|
||||
// First control contact marks the session automated (#1349): a
|
||||
// DonationPrompt that is still queued (not yet shown) is dropped so
|
||||
// automation never waits behind it, and `queue_startup_prompts`
|
||||
// stops queueing one. A prompt already on screen stays — a human
|
||||
// may be reading it; clients close it with `close_modal`.
|
||||
if !self.control.automation_seen && self.control.enabled {
|
||||
self.control.automation_seen = true;
|
||||
if self.active_modal != Some(super::ModalKind::DonationPrompt) {
|
||||
self.pending_startup_modals
|
||||
.retain(|kind| kind != &super::ModalKind::DonationPrompt);
|
||||
}
|
||||
}
|
||||
let op = req["op"].as_str().unwrap_or("");
|
||||
let query = matches!(
|
||||
op,
|
||||
|
||||
@@ -2754,6 +2754,11 @@ pub enum Message {
|
||||
/// `None` = cancelled.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
CenterlineLinetypeFilePicked(Option<(String, String)>),
|
||||
/// Web DATALINK: the picked .csv file's name and raw bytes — registered
|
||||
/// in the picked-source registry, then fed to the same table-insert path
|
||||
/// the native typed-path command uses. `None` = cancelled.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
DataLinkCsvPicked(Option<(String, Vec<u8>)>),
|
||||
/// Append one typed character to the command-line input from the
|
||||
/// global key-press subscription. Used when the text-input widget
|
||||
/// itself isn't focused (focus parked on viewport / button / etc.)
|
||||
@@ -5079,6 +5084,16 @@ impl OpenCADStudio {
|
||||
fn boot_web() -> (Self, Task<Message>) {
|
||||
#[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
|
||||
let mut s = Self::new();
|
||||
// A web host that intends to automate can opt in before the first
|
||||
// control request by setting `opencadstudio.automation` to "1" in
|
||||
// localStorage (the same pre-load mechanism the settings use): the
|
||||
// startup donation prompt then never queues (#1349).
|
||||
if web_sys::window().and_then(|window| window.local_storage().ok().flatten()).and_then(
|
||||
|storage| storage.get_item("opencadstudio.automation").ok().flatten(),
|
||||
) == Some("1".to_string())
|
||||
{
|
||||
s.control.automation_seen = true;
|
||||
}
|
||||
s.queue_startup_prompts();
|
||||
let focus = s.focus_cmd_input();
|
||||
let primary_font =
|
||||
|
||||
+40
-1
@@ -8,7 +8,13 @@ impl OpenCADStudio {
|
||||
self.pending_startup_modals
|
||||
.push_back(ModalKind::AssocPrompt);
|
||||
}
|
||||
if self.donation_prompt_version != env!("OCS_APP_VERSION") {
|
||||
// Automation sessions (#1349): once control traffic has arrived (or a
|
||||
// web host opted in at boot) while control is enabled, the donation
|
||||
// prompt is suppressed — a modal would block every automated
|
||||
// operation until a client closed it by hand.
|
||||
if self.donation_prompt_version != env!("OCS_APP_VERSION")
|
||||
&& !(self.control.enabled && self.control.automation_seen)
|
||||
{
|
||||
self.pending_startup_modals
|
||||
.push_back(ModalKind::DonationPrompt);
|
||||
}
|
||||
@@ -226,6 +232,39 @@ mod tests {
|
||||
assert!(restarted.active_modal.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automation_sessions_skip_the_donation_prompt() {
|
||||
// First control contact drops a still-queued donation prompt and
|
||||
// stops later queueing while control stays enabled (#1349).
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.default_assoc_prompted = true;
|
||||
app.queue_startup_prompts();
|
||||
assert_eq!(app.active_modal, Some(ModalKind::DonationPrompt));
|
||||
let _ = app.update(Message::CloseModal);
|
||||
app.donation_prompt_version = "previous-release".to_string();
|
||||
app.pending_startup_modals.push_back(ModalKind::DonationPrompt);
|
||||
let _ = app.control_request(serde_json::json!({"op":"state"}));
|
||||
assert!(app.pending_startup_modals.is_empty());
|
||||
app.queue_startup_prompts();
|
||||
assert_ne!(app.active_modal, Some(ModalKind::DonationPrompt));
|
||||
|
||||
// A still-queued (not yet shown) prompt is dropped on first contact.
|
||||
let mut queued = OpenCADStudio::new_for_test();
|
||||
queued.default_assoc_prompted = true;
|
||||
queued.donation_prompt_version = "previous-release".to_string();
|
||||
queued.pending_startup_modals.push_back(ModalKind::DonationPrompt);
|
||||
let _ = queued.control_request(serde_json::json!({"op":"state"}));
|
||||
queued.queue_startup_prompts();
|
||||
assert_ne!(queued.active_modal, Some(ModalKind::DonationPrompt));
|
||||
|
||||
// A prompt already on screen is left for its reader; clients close
|
||||
// it themselves, and a session with no control traffic still prompts.
|
||||
let mut human = OpenCADStudio::new_for_test();
|
||||
human.default_assoc_prompted = true;
|
||||
human.queue_startup_prompts();
|
||||
assert_eq!(human.active_modal, Some(ModalKind::DonationPrompt));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_dialogs_wait_their_turn_and_preserve_unseen_prompts() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
|
||||
@@ -1348,6 +1348,14 @@ impl OpenCADStudio {
|
||||
pub(in crate::app) fn apply_config(&mut self, cfg: crate::app::config::AppConfig) {
|
||||
self.apply_settings(&cfg.settings);
|
||||
self.ui_theme = cfg.theme.clone();
|
||||
// A saved `System` scheme re-resolves at boot: the stored palette is
|
||||
// whatever the last resolution produced, so refresh it alongside the
|
||||
// live theme (COLORSCHEME SYSTEM documents this boot-time read).
|
||||
if self.ui_theme.name.eq_ignore_ascii_case("System") {
|
||||
self.ui_theme.palette = crate::app::config::UiThemePalette::from_iced(
|
||||
crate::app::config::resolve_system_theme().seed(),
|
||||
);
|
||||
}
|
||||
self.active_theme = self.ui_theme.to_iced();
|
||||
self.theme_color_inputs = self.ui_theme.palette.hex_values();
|
||||
self.model_space = cfg.model_space;
|
||||
|
||||
@@ -2964,6 +2964,12 @@ impl OpenCADStudio {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Message::ScriptFilePicked(None) => Task::none(),
|
||||
|
||||
// Web DATALINK: the picked .csv's bytes arrived — register them
|
||||
// as the link's in-session source, then run the same table-insert
|
||||
// path the native typed-path command feeds.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Message::DataLinkCsvPicked(picked) => self.on_datalink_csv_picked(picked),
|
||||
|
||||
// Web CENTERLTYPEFILE: the picked .lin/.dnl file's text arrived —
|
||||
// load its linetypes, then record the setting under the file's
|
||||
// name (the native command reads the typed path inside
|
||||
|
||||
@@ -273,6 +273,27 @@ pub async fn pick_linetype_file_web() -> Option<(String, String)> {
|
||||
Some((name, String::from_utf8_lossy(&bytes).into_owned()))
|
||||
}
|
||||
|
||||
/// Web DATALINK: pick the .csv through the browser and read its raw bytes —
|
||||
/// the stand-in for the typed path the native command reads with
|
||||
/// `std::fs::read_to_string`. The caller registers the bytes in the
|
||||
/// picked-source registry so the link re-reads in-session. `None` = the user
|
||||
/// cancelled.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub async fn pick_datalink_csv_web() -> Option<(String, Vec<u8>)> {
|
||||
let Some(handle) = crate::sys::file_dialog()
|
||||
.set_title(crate::t!("Link a CSV data source").as_ref())
|
||||
.add_filter(crate::t!("CSV Files").as_ref(), &["csv", "txt", "CSV", "TXT"])
|
||||
.add_filter(crate::t!("All Files").as_ref(), &["*"])
|
||||
.pick_file()
|
||||
.await
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let name = handle.file_name();
|
||||
let bytes = handle.read().await;
|
||||
Some((name, bytes))
|
||||
}
|
||||
|
||||
/// Load a CAD file from a known path. Parsing and cache building run on a
|
||||
/// dedicated OS thread so the async executor stays free for rendering during
|
||||
/// the load. Writes phase markers into `phase` so the UI can show
|
||||
|
||||
@@ -1013,6 +1013,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
|
||||
"Color Temperature" => Some(("properties", "color-temperature")),
|
||||
"Color override (#RRGGBB):" => Some(("styles", "color-override-rrggbb")),
|
||||
"Color scheme set to '{name}'." => Some(("view", "color-scheme-set-to-name")),
|
||||
"Color scheme set to 'System' (currently {name})." => Some(("view", "color-scheme-set-to-system-currently-name")),
|
||||
"Color {}" => Some(("ui", "color")),
|
||||
"Column count" => Some(("properties", "column-count")),
|
||||
"Column gutter" => Some(("properties", "column-gutter")),
|
||||
@@ -4187,6 +4188,8 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
|
||||
"Data Extraction Settings" => Some(("common", "data-extraction-settings")),
|
||||
"Add Drawings" => Some(("common", "add-drawings")),
|
||||
"CSV" => Some(("common", "csv")),
|
||||
"CSV Files" => Some(("common", "csv-files")),
|
||||
"Link a CSV data source" => Some(("common", "link-a-csv-data-source")),
|
||||
"Pick the file again to load its contents in the browser." => Some(("file", "pick-the-file-again-to-load-its-contents")),
|
||||
"Adding folders is not available in the browser. Add drawing files instead." => Some(("file", "adding-folders-is-not-available-in-the-browser")),
|
||||
"Drawing not loaded in the browser: {name}." => Some(("file", "drawing-not-loaded-in-the-browser")),
|
||||
|
||||
@@ -772,19 +772,27 @@ fn native_footprint(format: Option<image::ImageFormat>, color: Option<image::Col
|
||||
image::ColorType::Rgba32F => 16,
|
||||
_ => 4,
|
||||
});
|
||||
// A 16-bit frame and its RGBA conversion can coexist; an 8-bit one is
|
||||
// converted with at most a small row buffer in flight.
|
||||
let peak_bpp = if native_bpp > 4 { native_bpp + 4 } else { 4 };
|
||||
let codec_factor = match format {
|
||||
Some(image::ImageFormat::Tiff) => 3,
|
||||
_ => 1,
|
||||
// `DynamicImage::into_rgba8` only MOVES for Rgba8; every other layout
|
||||
// allocates the RGBA destination while the native buffer is still
|
||||
// alive, so both coexist (image 0.25 buffer.rs cast_in_color_space).
|
||||
let peak_bpp = if native_bpp == 4 { 4 } else { native_bpp + 4 };
|
||||
// GIF decodes the frame into its own buffer while the screen-sized
|
||||
// output buffer is live — two RGBA-sized allocations coexist.
|
||||
let peak_bpp = match format {
|
||||
Some(image::ImageFormat::Gif) => peak_bpp.max(8),
|
||||
_ => peak_bpp,
|
||||
};
|
||||
// tiff 0.11 splits the given budget between the frame buffer and the
|
||||
// chunk scratch (a TOTAL cap), so no multiplier is needed: the honest
|
||||
// peak is the coexisting native+RGBA pair, already in peak_bpp.
|
||||
let codec_factor = 1;
|
||||
NativeFootprint { peak_bpp, codec_factor }
|
||||
}
|
||||
|
||||
/// Budget decision for probed pixel dimensions. Pure and cfg-independent
|
||||
/// when the budgets are passed explicitly, so tests can probe arbitrary
|
||||
/// budgets; the decode path always passes this target's constants.
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
fn tier_for(
|
||||
width: u32,
|
||||
height: u32,
|
||||
@@ -915,6 +923,10 @@ fn decode_with_budgets<R: std::io::BufRead + std::io::Seek>(
|
||||
// 0.25); the tier already folded that factor in, and the allowance
|
||||
// matches it so an in-tier TIFF never codec-refuses.
|
||||
let mut limits = image::Limits::default();
|
||||
// tiff 0.11 SPLITS this cap between the frame buffer and chunk scratch
|
||||
// (a total ceiling), so the plain budget is the right allowance; the
|
||||
// ×3 multiplier that image's older tiff codec needed would instead
|
||||
// admit hostile chunk transients up to 3× the budget.
|
||||
limits.max_alloc = Some(match reader.format() {
|
||||
Some(image::ImageFormat::Gif) => probed
|
||||
.map(|(width, height, _, _)| {
|
||||
@@ -922,7 +934,6 @@ fn decode_with_budgets<R: std::io::BufRead + std::io::Seek>(
|
||||
})
|
||||
.unwrap_or(max_decode_bytes)
|
||||
.min(max_decode_bytes),
|
||||
Some(image::ImageFormat::Tiff) => max_decode_bytes.saturating_mul(3),
|
||||
_ => max_decode_bytes,
|
||||
});
|
||||
reader.limits(limits);
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
<link data-trunk rel="copy-file" href="web/discussions.json" />
|
||||
<link data-trunk rel="copy-file" href="web/videos.json" />
|
||||
<link data-trunk rel="copy-file" href="web/locale-labels.json" />
|
||||
<link data-trunk rel="copy-file" href="web/ocs-control.js" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="loading">
|
||||
@@ -51,6 +52,7 @@
|
||||
<div class="title">Open <span>CAD</span> Studio</div>
|
||||
<div class="sub">Loading…</div>
|
||||
</div>
|
||||
<script src="ocs-control.js" defer></script>
|
||||
<script>
|
||||
(() => {
|
||||
const splash = document.getElementById("loading");
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// Web automation convenience API (#1349).
|
||||
//
|
||||
// The wasm module exposes the control bridge through `window.wasmBindings`
|
||||
// (ocs_control_submit / ocs_control_take). This module wraps it in promises:
|
||||
//
|
||||
// await window.ocsControl.ready resolves once the bridge exists
|
||||
// window.addEventListener("ocs-ready", …) fired once, at the same moment
|
||||
// await window.ocsControl.request({...}) one control request -> final reply
|
||||
//
|
||||
// Hosts that intend to automate from the start should set
|
||||
// localStorage["opencadstudio.automation"] = "1" before the page loads so the
|
||||
// startup donation prompt never appears (see docs/automation/web.md).
|
||||
(() => {
|
||||
if (window.ocsControl) return;
|
||||
const bridge = () => {
|
||||
const api = window.wasmBindings;
|
||||
return api && api.ocs_control_submit && api.ocs_control_take ? api : null;
|
||||
};
|
||||
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
let readyResolve;
|
||||
const ready = new Promise(resolve => { readyResolve = resolve; });
|
||||
const settle = () => {
|
||||
readyResolve(window.ocsControl);
|
||||
window.dispatchEvent(new Event("ocs-ready"));
|
||||
};
|
||||
const poll = setInterval(() => {
|
||||
if (bridge()) {
|
||||
clearInterval(poll);
|
||||
settle();
|
||||
}
|
||||
}, 50);
|
||||
if (bridge()) {
|
||||
clearInterval(poll);
|
||||
settle();
|
||||
}
|
||||
|
||||
// Submit one request and poll take() until the app stores its reply.
|
||||
async function exchange(api, payload, pollMs, deadline) {
|
||||
const ticket = api.ocs_control_submit(JSON.stringify(payload));
|
||||
for (;;) {
|
||||
const raw = api.ocs_control_take(ticket);
|
||||
if (raw) return JSON.parse(raw);
|
||||
if (performance.now() >= deadline) {
|
||||
throw new Error("ocs-control: timed out: " + JSON.stringify(payload));
|
||||
}
|
||||
await sleep(pollMs);
|
||||
}
|
||||
}
|
||||
|
||||
// One control request. Replies that are still `accepted`/`running` (an
|
||||
// async open, capture or save) are followed through `op:"operation"` until
|
||||
// a final status arrives. Resolves with the final reply object; rejects
|
||||
// only on timeout or a missing bridge.
|
||||
async function request(payload, options = {}) {
|
||||
const pollMs = options.pollMs ?? 50;
|
||||
const deadline = performance.now() + (options.timeoutMs ?? 120000);
|
||||
const api = bridge();
|
||||
if (!api) {
|
||||
throw new Error("ocs-control: bridge not ready (await ocsControl.ready)");
|
||||
}
|
||||
let reply = await exchange(api, payload, pollMs, deadline);
|
||||
while (["accepted", "running"].includes(reply.status) && reply.request_id) {
|
||||
await sleep(pollMs);
|
||||
reply = await exchange(
|
||||
api, {op: "operation", request_id: reply.request_id}, pollMs, deadline);
|
||||
}
|
||||
return reply;
|
||||
}
|
||||
|
||||
window.ocsControl = {ready, request};
|
||||
})();
|
||||
Reference in new issue
Block a user