Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
caaea1b
Integrate CollabCraft cloud Open/Save/Share/presence into PdfCraft
wdunn001 Oct 15, 2026
211d2cf
Fix atomic session-file write and service-origin token isolation
wdunn001 Oct 15, 2026
683e091
Fix egui 0.36 API breaks in cloud icon and style
wdunn001 Oct 15, 2026
5455c23
Clean up warnings found by Linux build: unused import and dead code
wdunn001 Oct 15, 2026
5121140
Bootstrap cloud account on startup so root login carries into PDFCraft
wdunn001 Oct 15, 2026
9e45be1
Fix callback and logout routing for browser cloud session
wdunn001 Oct 15, 2026
f9471c5
Fix: access cloud.status via field, not trait method
wdunn001 Oct 15, 2026
f06af60
Fix: Window::closed() returns Result<bool, JsValue>
wdunn001 Oct 15, 2026
0e203cd
Render cloud open/save pages in CentralPanel; add File menu and tab-s…
wdunn001 Oct 15, 2026
be9419f
Fix cloud_tests: use Harness::new_eframe, rewrite conflict test with …
wdunn001 Oct 15, 2026
6d758ec
Fix shapes_from_page: eliminate unsafe by moving Option<&mut App> int…
wdunn001 Oct 15, 2026
69adcbd
Fix cloud_page_shell Back button: use text button so label appears as…
wdunn001 Oct 15, 2026
3a3dcd0
Fix unlinked account regression expectation
wdunn001 Oct 15, 2026
fb35c2c
Fix account test: NotLinked maps to link=Offered not account_fault; a…
wdunn001 Oct 15, 2026
f764bdb
Remove unused Link import from cloud_tests
wdunn001 Oct 15, 2026
68ee7c8
i18n: add CollabCraft cloud open/save strings to all locales
wdunn001 Oct 15, 2026
1ee9fe9
test(commands): skip cloud ids without CollabCraft service
wdunn001 Oct 15, 2026
717e227
fix(pickers): consume pick_override once per dialog chain
wdunn001 Oct 15, 2026
9fc81cc
test(commands): use run_inline for picker-driven open flows
wdunn001 Oct 15, 2026
f090c2d
Name CollabCraft shares with the pdfcraft app
wdunn001 Oct 15, 2026
ea0fc63
Merge for_app invite naming into cloud-pages
wdunn001 Oct 16, 2026
427d195
Merge storytold/pdfcraft main into cloud-pages; keep main i18n
wdunn001 Oct 16, 2026
b8a6574
Fix merge conflict fallout in manifests and docs
wdunn001 Oct 16, 2026
25d0366
Regenerate Cargo.lock after merge fix
wdunn001 Oct 16, 2026
2b7bac1
Wire Request signatures through CollabCraft e-sign.
wdunn001 Oct 16, 2026
d0f2363
Box EnvelopeDraft in Request::Create for craft-ui clippy.
wdunn001 Oct 16, 2026
0e7f0c4
Pin craft-libs to clippy-clean esign-controls tip.
wdunn001 Oct 16, 2026
a4edbaf
rustfmt esign UI host wiring for xtask ci.
wdunn001 Oct 16, 2026
c7a9252
Fix e-sign sender Identity from cloud Account fields.
wdunn001 Oct 16, 2026
9a72ff7
clippy: matches! and drop redundant cloud_page_shell closures.
wdunn001 Oct 16, 2026
9cfb6be
Unify collab-craft pin with craft-libs esign tip.
wdunn001 Oct 16, 2026
fda0d41
clippy: move open_browser before tests; static draw FnOnce.
wdunn001 Oct 16, 2026
dfa9709
Remove duplicate open_browser after reorder.
wdunn001 Oct 16, 2026
52e225e
Open a keyed CollabCraft invite in the web app instead of a blank doc…
wdunn001 Oct 16, 2026
8566d2a
Merge Request signatures e-sign UI into cloud-pages.
wdunn001 Oct 17, 2026
41f439e
Merge storytold/pdfcraft main into cloud-pages.
wdunn001 Oct 19, 2026
00ceb22
Address CodeRabbit review on cloud Open/Save integration.
wdunn001 Oct 19, 2026
6cd7c19
Address CodeRabbit login popup and e-sign form commit findings.
wdunn001 Oct 19, 2026
aaa6fe9
Open wasm login popup from account-menu sign-in gesture.
wdunn001 Oct 19, 2026
acff857
Pin craft-ui lockfile to sign-in gesture rev.
wdunn001 Oct 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
655 changes: 333 additions & 322 deletions Cargo.lock

Large diffs are not rendered by default.

11 changes: 10 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,16 @@ repository = "https://github.com/storytold/pdfcraft"

[workspace.dependencies]
# Shared frontend components; engines remain independent of egui.
craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
craft-ui = { git = "https://github.com/wdunn001/craft-libs", version = "0.1.0", rev = "ffdf0556cefc0e7c14ceb32bf4138609702c6f13", features = ["serde", "cloud", "esign"] }
# Provider-neutral cloud types (Entry, Capability, Permission). Types and contracts only: the
# crate opens no socket and names no provider, so this builds for the web too.
craft-cloud = { git = "https://github.com/wdunn001/craft-libs", version = "0.1.0", rev = "3aa7841deee2f9919a4dcca55487db5063293654" }
craft-esign = { git = "https://github.com/wdunn001/craft-libs", version = "0.1.0", rev = "3aa7841deee2f9919a4dcca55487db5063293654" }
# How the desktop and browser apps reach CollabCraft. No provider credential or URL compiled in.
craft-cloud-client = { git = "https://github.com/wdunn001/collab-craft", rev = "23a3b6612eb73a51d54139e25658c94689adbb06" }
# The share dialog's backend: the half that opens a socket, kept here so only one session lives
# in the process and a sign-in through Open/Save reaches the share dialog too.
collabcraft-share = { git = "https://github.com/wdunn001/collab-craft", rev = "23a3b6612eb73a51d54139e25658c94689adbb06" }
# Quit the macOS way (#1136): ⌘Q asks about unsaved changes, and closing quits through `terminate:`.
craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "f58bc1a3df490992d633393d0898af0588ce882c" }
# Shared system spooler; document composition remains in pdfcraft-print.
Expand Down
9 changes: 8 additions & 1 deletion apps/pdfcraft-web/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,17 @@ workspace = true

[dependencies]
pdfcraft-ui-egui = { workspace = true }
# Browser transport for Open and Save against CollabCraft. Session rides the cookie; no token file.
craft-cloud-client = { workspace = true }
# Share dialog backend for the browser build.
collabcraft-share = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "glow", "persistence", "accesskit"] }
egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }

[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "Location", "Url", "UrlSearchParams", "Response", "Event", "MessageEvent", "EventTarget"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "Location", "Url", "UrlSearchParams", "Response", "Event", "MessageEvent", "EventTarget", "Request", "RequestInit", "RequestCredentials"] }
js-sys = "0.3"
151 changes: 151 additions & 0 deletions apps/pdfcraft-web/src/cloud.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
//! CollabCraft transport for the browser build.
//!
//! The same jobs and events as the desktop worker ([`pdfcraft::cloud`]), but
//! the client is async and the session rides the same-origin cookie the service
//! set at sign-in. There is no device flow and no token file.
//!
//! ## Login and logout routing
//!
//! When the account check returns Unauthenticated the transport reports the
//! fault and the title bar shows Sign in. The login popup opens only when the
//! user clicks Sign in (see [`pdfcraft_ui_egui::web_auth`] and [`pdfcraft_ui_egui::cloud::pump`]).
//!
//! A polling closure watches the popup and, when it closes, stores the key
//! `"pdfcraft:auth_done"` in egui's ephemeral memory and requests a repaint.
//! The update loop sees the flag, clears it, and calls `sign_in()` to force a
//! fresh `Job::Account`.
//!
//! Sign-out navigates to the service root `/` only after `/auth/logout`
//! succeeds. Reloading the PDFCraft page on a failed logout would leave the
//! session cookie active while the user believes they signed out.

use craft_cloud_client::{Client, Error};
use pdfcraft_ui_egui::cloud::{Account, CloudBackend, Event, Fault, Job, Post};
use wasm_bindgen::JsCast as _;
use wasm_bindgen_futures::spawn_local;

/// Hands cloud jobs to async tasks. Cheap to clone.
#[derive(Clone)]
pub struct Service {
client: Client,
ctx: egui::Context,
}

impl Service {
/// Always available when the app is served by CollabCraft on the same
/// origin. The page names the service; nothing is compiled in.
pub fn new(ctx: egui::Context) -> Service {
log::info!("cloud storage through {}", Client::from_page().base());
Service { client: Client::from_page(), ctx }
}
}

impl CloudBackend for Service {
fn submit(&self, job: Job, post: Post) {
let worker = self.clone();
spawn_local(async move {
worker.run(job, post).await;
});
}
}

impl Service {
async fn run(self, job: Job, post: Post) {
let post = |event: Event| {
post.send(event);
self.ctx.request_repaint();
};
match job {
Job::Account => {
let answer =
self.me().await.map(|me| Account { display: me.display, capabilities: me.capabilities, provider_linked: me.provider_linked });
post(Event::Account(answer));
}
Job::LinkStart => match self.client.link_start().await.map_err(fault) {
Ok(start) => post(Event::Browser { url: start.login_url, handle: Some(start.handle) }),
Err(f) => post(Event::Linked(Err(f))),
},
Job::LinkPoll(handle) => {
let answer = self.client.link_poll(&handle).await.map_err(fault);
post(Event::Linked(answer));
}
Job::SignOut => {
if let Err(e) = fetch_logout().await {
log::warn!("sign-out failed: {e}");
post(Event::Account(Err(Fault::Refused(format!("Sign-out failed: {e}")))));
return;
}
if let Some(window) = web_sys::window() {
let _ = window.location().assign("/");
}
}
Comment thread
wdunn001 marked this conversation as resolved.
Job::List(path) => {
let entries = self.client.list(&path).await.map_err(fault);
post(Event::Listed { path, entries });
}
Job::SharedWithMe => {
let answer = self.client.shared_with_me().await.map_err(fault);
post(Event::Shared(answer));
}
Job::Read { entry, into } => {
let id = entry.id.clone();
let bytes = self.client.read(&id).await.map_err(fault);
post(Event::Read { entry, into, bytes });
}
Job::Write { uid, entry, bytes } => {
let id = entry.id.clone();
let etag = entry.etag.clone();
let answer = self.client.write(&id, &bytes, etag.as_deref()).await.map_err(fault);
post(Event::Saved { uid, entry: answer });
}
Job::Create { uid, folder, name, bytes } => {
let answer = self.client.create(&folder, &name, &bytes).await.map_err(fault);
post(Event::Saved { uid, entry: answer });
}
Job::Conversation(id) => {
let answer = self.client.conversation(&id).await.map_err(fault);
post(Event::Conversation(answer));
}
}
}

async fn me(&self) -> Result<craft_cloud_client::Me, Fault> {
self.client.me().await.map_err(fault)
}
}

fn fault(e: Error) -> Fault {
match e {
Error::Unauthenticated => Fault::Unauthenticated,
Error::NotLinked => Fault::NotLinked,
Error::Unreachable(detail) => Fault::Unreachable(detail),
Error::Refused { code, .. } if code == "conflict" || code == "409" || code == "412" => Fault::Conflict,
Error::Refused { code, detail } => Fault::Refused(if detail.trim().is_empty() { code } else { detail }),
Error::Malformed(detail) => Fault::Refused(format!("the cloud service gave an answer PdfCraft could not read: {detail}")),
Error::Unsendable(detail) => {
log::error!("cloud request could not be encoded: {detail}");
Fault::Refused("PdfCraft could not build that request".to_owned())
}
}
}

async fn fetch_logout() -> Result<(), String> {
let global = js_sys::global();
let fetch = js_sys::Reflect::get(&global, &wasm_bindgen::JsValue::from_str("fetch"))
.ok()
.and_then(|v| v.dyn_into::<js_sys::Function>().ok())
.ok_or_else(|| "no fetch".to_owned())?;
let init = web_sys::RequestInit::new();
init.set_method("POST");
init.set_credentials(web_sys::RequestCredentials::Include);
let req = web_sys::Request::new_with_str_and_init("/auth/logout", &init).map_err(|e| format!("{e:?}"))?;
let promised = fetch.call1(global.as_ref(), req.as_ref()).map_err(|e| format!("{e:?}"))?;
let promised: js_sys::Promise = promised.dyn_into().map_err(|_| "not a promise".to_owned())?;
let resp = wasm_bindgen_futures::JsFuture::from(promised).await.map_err(|e| format!("{e:?}"))?;
let resp: web_sys::Response = resp.dyn_into().map_err(|_| "not a response".to_owned())?;
if resp.ok() {
Ok(())
} else {
Err(format!("HTTP {}", resp.status()))
}
}
55 changes: 52 additions & 3 deletions apps/pdfcraft-web/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@

#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]

#[cfg(target_arch = "wasm32")]
mod cloud;

#[cfg(target_arch = "wasm32")]
fn main() {
use eframe::wasm_bindgen::JsCast;
Expand All @@ -17,18 +20,31 @@ fn main() {
else {
return;
};
let mut invite = match craft_cloud_client::invite::arrive().await {
craft_cloud_client::invite::Arrival::Ordinary => None,
craft_cloud_client::invite::Arrival::Refused(reason) => {
craft_cloud_client::invite::refuse(&reason);
return;
}
craft_cloud_client::invite::Arrival::Invited(invitation) => Some(invitation),
};
let started = eframe::WebRunner::new()
.start(
canvas,
options,
Box::new(|cc| {
Box::new(move |cc| {
let mut app = PdfCraftApp::new();
let invited = invite.is_some();
// "printcraft": settings saved under the app's former name, PrintCraft.
if let Some(json) = cc.storage.and_then(|s| s.get_string("pdfcraft").or_else(|| s.get_string("printcraft"))) {
if !invited
&& let Some(json) = cc.storage.and_then(|s| s.get_string("pdfcraft").or_else(|| s.get_string("printcraft")))
{
app.restore(&json);
}
// `?file=<url>` opens a PDF from a URL (same-origin or CORS-enabled).
if let Some(url) = query_param("file") {
if !invited
&& let Some(url) = query_param("file")
{
let inbox = app.startup_inbox.clone();
let failed = app.failed_inbox.clone();
let ctx = cc.egui_ctx.clone();
Expand Down Expand Up @@ -75,6 +91,17 @@ fn main() {
&"PdfCraft: ?host=parent needs a framing page on the same origin; saves download instead".into(),
);
}
// Open and Save against cloud storage. In the browser the session rides
// the same-origin cookie; there is no device flow and no token file.
// `Client::from_page` reads the service URL from the page; on a plain
// deployment it is absent and the interface shows the link-account prompt.
app.cloud.connect(cloud::Service::new(cc.egui_ctx.clone()));
if let Some(share) = collabcraft_share::CollabCraft::from_env() {
app.share.connect(share.for_app("pdfcraft"));
}
if let Some(invitation) = invite.take() {
open_invite(&mut app, invitation);
}
Ok(Box::new(app))
}),
)
Expand All @@ -90,6 +117,28 @@ fn main() {
});
}

#[cfg(target_arch = "wasm32")]
fn open_invite(app: &mut pdfcraft_ui_egui::PdfCraftApp, invitation: craft_cloud_client::invite::Invitation) {
let name = invitation.name.clone();
match pdfcraft_ui_egui::cloud::CloudApp::open_bytes(app, &name, invitation.document.clone()) {
Ok(Some(_)) => {
let mode = if invitation.read_only() { " (view only)" } else { "" };
let who = if invitation.recipient.is_empty() { String::new() } else { format!(" (shared with {})", invitation.recipient) };
app.cloud.status = Some(format!("Opened \"{name}\"{mode}{who}"));
if let Err(e) = invitation.join() {
eframe::web_sys::console::warn_1(&format!("invite relay join failed: {e}").into());
}
}
Ok(None) => {
app.cloud.status = Some(format!("Shared \"{name}\" needs a choice before it opens"));
}
Err(e) => {
eframe::web_sys::console::error_1(&format!("invite open failed: {e}").into());
craft_cloud_client::invite::refuse(&format!("The shared document could not be opened. {e}"));
}
}
}

#[cfg(target_arch = "wasm32")]
fn warn_before_unload(unsaved: std::sync::Arc<std::sync::atomic::AtomicBool>) {
use eframe::wasm_bindgen::{JsCast, closure::Closure};
Expand Down
6 changes: 6 additions & 0 deletions apps/pdfcraft/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ workspace = true
ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "_ring"] }
rustls-native-certs = "0.8"
pdfcraft-ui-egui = { workspace = true }
# How this application reaches CollabCraft. No provider credential, provider URL or provider
# protocol live here. The service is read from the environment; nothing is compiled in.
craft-cloud-client = { workspace = true }
# The share dialog's backend: the half that opens a socket. Kept here so only one session exists
# per process and a sign-in through Open/Save also reaches the share dialog.
collabcraft-share = { workspace = true }
# `build_info`: the version and compile-time build label for `--version` and the log.
pdfcraft-engine = { workspace = true }
# wgpu draws; OpenGL (glow) is the fallback when wgpu can't start on an old or unusual GPU or
Expand Down
Loading